首页 / 资讯中心 / 文章详情

Python版PL0编译原理课设:从P-code虚拟机到工程实践

Python版PL0编译原理课设:从P-code虚拟机到工程实践 ★ FEATURED ARTICLE
简介面向南京航空航天大学编译原理课程设计的PL0编译器Python实现完整工程适合计算机专业学生及自学编译原理的初学者参考。资源内含词法分析器、语法分析、语义分析及后端代码实现并附实验报告、说明文档与测试用例文本覆盖从词法识别到目标代码生成的完整流程可帮助理解递归下降分析、抽象语法树等核心概念。压缩包共8个文件以Python源码为主辅以md说明和doc课设报告整体大小约792KB结构清晰便于按模块学习。已有181人学习使用适合正在完成PL0课设或希望以Python快速上手编译器前端与后端实现的读者下载对照。文件组织兼顾代码与文档既能支撑实验复现也可作为撰写课程设计报告的参考资料。1. 一份 Python 版 PL0 课设代码到底该从哪一行看起别急着打开词法分析的 .py 文件。南京航空航天大学NUAA编译原理课设这份 PL0Python 版_Compile_Principle.zip真正值得你先看的是最后的解释器——也就是通常叫 vm.py 或 pcode.py 的那个文件。原因很简单PL0 的设计里编译器前端只是把源代码翻译成一套名为 P-code 的中间表示真正“跑出结果”的是那台用 Python 模拟出来的栈式虚拟机。先把这台机器的行为摸清楚你再回头看词法、语法、语义分析整个编译器的数据流就像一张摊开的地图。这篇笔记面向两类人正在做编译原理课设、手里已经有一份 Python 版 PL0 源码的学生以及想用 Python 快速搭一个可运行语言原型、但不想从零发明轮子的开发者。目标就一个——让你在半天内把这个黑匣子变成看得懂、改得动、能过验收的代码。2. 先立理论PL0 是什么P-code 虚拟机要解决什么问题2.1 PL0 的定位教学用语言但五脏俱全PL0 是 Niklaus Wirth 为《Compiler Construction》这本书设计的教学语言名字里的 PL 是 Programming Language0 表示“最小的那个”。它的语法相当克制只有常量声明、变量声明、过程声明、赋值、条件、循环、过程调用和读写语句。正是因为语法集小一份实现通常也就一千行上下才能在课设周期内完成。别因为它小就轻视这恰恰是它的价值词法分析、语法分析、语义分析、中间代码生成、运行时环境这几大块一个都不缺每一块还都小到能在一晚上读完。你手里这份 Python 版实现常见做法是把它拆成五个模块tokenizer词法、parser语法语义、symbol table符号表、code generator可能并入 parser、interpreter虚拟机。跑通一个 PL0 程序数据流是源码 → token 流 → 语法树/指令 → P-code 指令流 → 虚拟机执行结果。这和真实编译器的分层几乎是同构的只是把汇编和机器执行换成了 P-code 和软件虚拟机。2.2 编译器前端和虚拟机的分工很多第一次接触 PL0 的人有个误解以为编译器应该直接把 PL0 源码“翻译”成能在操作系统上跑的原生机器码。但课设里通常不是这样。PL0 编译器的核心产物是 P-code一段面向栈式虚拟机的指令序列。然后由同一个工程里的 Python 解释器逐条执行这些指令。这就是所谓“编译 解释”的混合模式编译一次解释执行。这种设计的好处有几个。第一P-code 指令集极简每条指令做一件非常明确的事情比如“取一个常量入栈”“从指定层差和偏移量读取变量”“调用某个过程”因此生成代码的逻辑很容易和递归下降语法分析过程绑定在一起。第二虚拟机本身是一个数组当栈用实现起来非常直观调试时还能随时打印栈内容看到一条指令前后栈的变化。第三它回避了真实的 x86 汇编和内存寻址课设验收时不需要跑在特定操作系统上Python 环境就够。2.3 P-code 指令集八条指令撑起一个运行时P-code 的经典指令集非常精简你拿到手的 Python 版基本不会跳出下面这张表只是部分实现的指令名可能有差异有的用字符串指令名有的用编号。先把指令语义背下来后面看 parser 生成代码时会有种“原来这里在发这条指令”的通透感。指令操作数语义LIT常量值把常量值压入栈顶OPR运算编号对栈顶元素做运算并弹出入栈0过程返回1取负2加3减4乘5除6奇数判断7相等8不等9小于10大于等于11大于12小于等于不同教材编号略有出入LOD层差、偏移量按层差和偏移量把变量值从数据区压入栈顶STO层差、偏移量把栈顶值存到对应位置的变量并弹出该值CAL过程入口地址调用过程保存当前栈基址和返回地址跳转到入口INT栈空间大小为过程分配局部数据区把栈指针上移JMP目标指令地址无条件跳转用于循环回跳JPC目标指令地址栈顶为假时跳转用于 if/while 分支退出那 OPR 运算编号为什么不同教材不一样这是 PL0 实现里最常见的差异点之一。有些实现把“过程返回”单独拆成一条 RET 指令有些则放在 OPR 0 里。我在实践中见过一种很务实的做法——在 Python 里直接用字符串指令名并用字典映射 op例如(OPR, add)这样读代码的人一眼就懂调试输出也更友好。你也可以保留编号实现但一定把编号表写在文件最上方注释里这是课设答辩时老师最关注的细节之一。2.4 栈式虚拟机的运行时布局理解 P-code 必须理解它的运行时栈布局。这台虚拟机只有一块内存叫数据栈所有常量、变量、返回地址、过程基址全都压在这一个栈上。栈从底往上增长栈底留一个寄存器位置存放初始基址。每次进入一个过程先执行 INT 指令按局部变量数量把栈顶抬高形成这一层调用的数据区过程返回时把栈顶拉回到调用前的状态再把返回地址弹出来接着执行。这三块信息——返回地址、动态链旧栈基址、静态链——是课设实现里最容易出错的地方。静态链是用来按“层差”寻址的LOD 指令的层差表示“向外跳几层定义的作用域”偏移量表示在这层数据区里的第几个位置。如果你在 parser 里把层差算错虚拟机执行时定位到的就不是源程序里的那个变量而是栈上某个随机位置的数据。这种错误最难受——程序通常能跑只是结果错了而且错得没有规律。3. 把 zip 变成能出结果的 Python 程序工程结构与跑通最小流程3.1 一份典型的 PL0 Python 工程按什么逻辑拆文件NUAA 这份课设 zip 解压出来后具体文件名每届可能不同但组织逻辑基本是固定的。我比较习惯的布局是lexer.py管词法parser.py管语法和代码生成symboltable.py管符号表vm.py管虚拟机main.py做总控和文件读写入口。有的实现会把符号表作为一个类嵌在 parser 里也有的把虚拟机写成pcode.py但核心模块不会少。另外 zip 里通常还会带一到两个样例程序后缀是 .pl0 或 .pl内容可能是计算阶乘、求最大公约数这类经典示例。我强烈建议第一步不要直接读代码而是找到样例程序打开读一遍确认 PL0 语法长什么样。跑通样例看到预期输出。再按 lexer → parser → vm 的顺序读代码。这个过程能帮你建立“源代码是什么样的 → 中间代码是什么样的 → 执行结果是什么样的”的三层数据流认知。而且跑通样例之后你改动代码时立刻就有了一个回归基准不管怎么改阶乘样例的输出必须保持不变。3.2 运行最小闭环从命令行到 P-code dump典型运行命令如下前提是 Python 3 环境。我一般建议用 3.8 以上的版本太老的版本有些新语法特性不支持新版本基本都能跑。跑之前先确认 python 命令可用Windows 上如果提示找不到命令多半是环境变量没配好——这个问题和你写的编译器代码没有半点关系但每年卡住不少同学。# 查看程序用法通常会有 --help 或 -h 参数 python main.py --help # 编译并执行一个 PL0 源码文件 python main.py examples/fib.pl0 # 常见变体指定中间代码输出文件配合调试使用 python main.py examples/fib.pl0 --dump-pcode fib.pcode先说逻辑说明第一行--help是看入口文件支持哪些参数这是拿到陌生代码后最快的摸底方式。第二行如果有输出说明整个编译-执行链路已经通了。第三行的--dump-pcode不是 PL0 标准参数但在很多课设实现里存在作用是把生成的 P-code 指令序列打印到标准输出重定向到文件后可以逐条核对生成逻辑。如果你的这份代码没有这个参数可以在 vm.py 的 execute 循环里临时加一句打印当前指令的代码效果一样。参数说明examples/fib.pl0是源码路径替换成你自己的 pl0 文件即可。如果--help输出里没有--dump-pcode直接去掉这个参数跑第二条命令。文件路径建议用相对路径避免 Windows 上盘符和分隔符带来的兼容性问题。3.3 从零搭一个最小 PL0 源文件测试链路有时候 zip 里自带的样例不够用你需要按自己的语法理解写出最简单的程序来验证某条指令。一个能跑通完整链路的极小 PL0 程序长这样VAR x; BEGIN x : 42; WRITELN(x) END.这六行覆盖了变量声明、赋值、常量入栈、变量存储、读输出、过程返回这几段关键逻辑。我用这种“最小可运行”程序来验证虚拟机链路是否通而不是一上来就跑大程序——如果这个都跑不对问题一定出在编译器的主干流程而不是某个语法角落。值得注意的名字是 WRITELN 和标准库。PL0 通常通过预定义过程来读写那套 OPR 或者 LIT 指令生成方式落到哪个编号上因实现而异。你只需要记住读写语句在 parser 里会被翻译成对特定 P-code 指令的组合使用这也解释了为什么 OPR 在不同教材里定义不同——本质上是“外部函数调用”机制的设计差异而 PL0 用栈上约定好编号来解决。4. 词法、语法与符号表的 Python 实现最容易丢分的三块细节4.1 词法分析保留字、标识符与数字的状态机词法分析器读入整个源码文件输出 token 流为 syntax 层铺路。Python 实现里最常见的做法是正则表达式切割但实际课设里为了贴合“编译原理”课程要求多半用状态机。我认可后者因为它能说明词法分析的边界条件一个标识符里不能有数字开头、保留字不能是 user_defined 变量的名字、不支持嵌套注释有的支持但 PL0 原始语义并不要求。class Token: def __init__(self, kind, value, line, col): self.kind kind # ident / number / keyword / symbol / eof self.value value # 原始字符串或数值 self.line line self.col col KEYWORDS {CONST, VAR, PROCEDURE, BEGIN, END, IF, THEN, WHILE, DO, CALL, ODD, READ, READLN, WRITE, WRITELN} def tokenize(src): tokens [] i, n 0, len(src) while i n: ch src[i] if ch.isspace(): i 1 continue if ch.isalpha(): j i while j n and src[j].isalnum(): j 1 word src[i:j] kind keyword if word.upper() in KEYWORDS else ident tokens.append(Token(kind, word, ...)) i j continue if ch.isdigit(): j i while j n and src[j].isdigit(): j 1 tokens.append(Token(number, int(src[i:j]), ...)) i j continue if ch in :,.;-*/(): ... i 1 continue raise SyntaxError(funexpected char {ch} at line {line}) return tokens逻辑说明这段代码是简化版核心逻辑是三个分支。空白跳过字母开头连续吸收字母和数字构成 ident 或 keyword数字开头连续吸收数字构成 number。剩余符号按单字符处理遇到未知字符直接抛异常这是课设验收时很看重的错误定位能力。参数说明KEYWORDS是大写集合所以比较时把读取的 word 统一转大写。isalnum让标识符可以带数字但不能以数字开头符合 PL0 的定义。到这里词法分析的另一半题目是行号和列号记录——真正课设里 output 的 error message 必须能定位到行所以 Token 里务必带 line/col别偷懒。4.2 递归下降语法分析EBNF 和 Python 的天然对应语法分析阶段是这份代码的骨架。PL0 的 EBNF 文法里program block .block [const 声明][var 声明][procedure 声明] statementstatement 赋值 | if | while | 过程调用 | begin...end 复合语句 | 空语句。递归下降解析器把每个非终结符写成一个同名方法方法内部按产生式右侧做匹配。这种写法有一个天然优势——语法分析的同时可以做代码生成边识别边输出 P-code无需单独构建语法树。def statement(self): if self.current().kind ident: name self.current().value self.expect(ident) self.expect(:) # 生成 STO 指令前先找到符号位置层差 偏移量 sym self.symbol_table.lookup_deep(name) if sym is None: raise CompileError(fundefined variable {name} at line {self.line}) self.expression() # 注意顺序表达式先算结果在栈顶再 STO 到变量 self.emit(STO, sym.level_diff, sym.offset) elif self.current().value IF: ... elif self.current().value WHILE: ...逻辑说明上面这段是语句解析里最典型的赋值分支。先看当前 token 是不是 ident如果是变量名接着确认下一个 token 是赋值符:然后递归解析表达式最后把表达式的计算结果存回变量。关键在于sym的 level_diff 和 offset 必须由符号表提前分配好这两个数字直接烙进 STO 指令成为运行时寻址的依据。参数说明emit可被看成向当前指令列表里追加一条 P-code 的操作指令由操作符和操作数构成后面调试时会用到此前 dump-pcode 的结果正是因为指令是边生成边收集的。level_diff的计算是经典难点当前所在的嵌套层数减去变量在符号表里定义的层数才是真正的层差。这个值如果算错就不只是结果错就连调试时看了 P-code 都无法一眼发现。4.3 符号表作用域嵌套和层差的正确算法符号表是课设里被低估的一笔。很多同学把符号表理解成一个 dict变量名对类型够用就行。但 PL0 有嵌套过程定义也就是说过程 A 里还可以定义过程 BB 里能用 A 和全局的变量。于是符号表必须支持作用域栈进入一个过程时入栈一个新作用域退出时弹栈。这是最传统的符号表实现方式也是出错率最高的一环。class SymbolTable: def __init__(self): # 每个元素是一个 dictname - Symbol 对象 self.scopes [] def open_scope(self): self.scopes.append({}) def close_scope(self): self.scopes.pop() def declare(self, name, kind, offset): # 只在当前层声明重复声明由 parse 层报错 if name in self.scopes[-1]: raise CompileError(fduplicate declaration {name}) self.scopes[-1][name] Symbol(kind, offset) def lookup(self, name): # 从内向外逐层查找返回 Symbol for i in range(len(self.scopes) - 1, -1, -1): if name in self.scopes[i]: return self.scopes[i][name] return None逻辑说明open_scope和close_scope的调用点很固定——在语法分析器解析到 PROCEDURE 的 block 开头时 open在 block 结束时 close。declare只往当前层写lookup从最内层往外找找到即返回。这样设计内层过程可以访问外层变量外层过程看不见内层新声明的变量符合块结构语言的经典做法。参数说明Symbol对象里保存 kindconst/var/procedure、offset在当前层数据区的位置和 level定义所在作用域深度。level_diff不要存在 Symbol 里因为同一个变量在不同深度的语句里引用时层差不同这个值其实在 parser 调用 lookup 时用当前作用域深度减 Symbol.level 得到。很多学生踩坑就是提前在 declare 时把 level 固定成了一个小值导致多层嵌套时内层引用外层变量丢失错误报文还提示符号表取值异常。5. 避坑拿到这份代码后你八成会踩的五个坑5.1 现象跑任何样例都死循环或栈溢出最容易见到的现象是程序编译通过但执行时要么不结束要么 Python 报 RecursionError 或无限循环。原因多半不是源码死循环而是 JMP 和 JPC 指令的目标地址算错了。PL0 里 while 循环的生成模式是在循环体前记录当前指令序号生成循环体代码循环体结束后生成 JMP 跳回那个序号而 while 的条件入口会生成 JPC条件为假时跳过整个循环体。任何一个跳转地址计算错一位循环结构就变成回环黑洞。解决第一步用 dump-pcode 把指令序列打出来找到 while 对应的 JPC / JMP 指令对手动走一遍三条指令入口 JPC、循环体、JMP 回跳确认 JMP 的目标正好落在 JPC 之前。大部分死循环问题在这一步就能定位。我强烈建议你在跑代码前先形成“看指令而不是看输出”的调试习惯这会让你在一个下午内定位到这题。5.2 现象重复声明变量不报错验收时被老师当场质疑PL0 语义规定同层变量不能重复声明。有的 Python 实现在 declare 时不做检查只是默默覆盖 dict 里的旧条目。于是你写VAR x, x;程序照样编译过验收时一旦老师试这个用例课设分就被扣掉一截。原因符号表层作用域声明时只做“写入”动作没有先查“是否已存在”。解决在 declare 函数第一行加重复检查存在就抛编译错误并附带行号。这也是你在自己改造代码时可以顺手补强的地方——给符号表加断言逻辑几乎不花时间但答辩时是用来说明“你理解语义分析”的最有力证据。5.3 现象过程调用返回后栈数据错乱输出负值或零这是最折磨人的问题。过程 CALL 时PL0 的常规做法是先把“静态链”压栈然后把“动态链”就是当前栈基址压栈再保存返回地址最后 JMP 到过程入口。过程入口先执行 INT 分配局部变量区返回时先恢复栈指针再依次弹出动态链和返回地址跳回下一条指令。常见问题出在INT后面的栈大小参数。如果这个词法定义里过程局部变量数算少了比如漏掉为静态链、动态链、返回地址预留的三个槽位过程执行时局部变量区会覆盖返回地址或动态链等过程返回时取出已经被覆写的地址程序立刻疯掉。解决仔细算好过程入口处栈布局推荐画一张“进入过程前栈里压了什么、进入后 INT 参数应为多少”的图。这是编译原理课设里最值得花时间的图没有之一。5.4 现象注释里的文字被当代码处理报错信息飞到莫名其妙的地方PL0 的注释语法是/* ... */Python 版实现里如果 lexer 没处理注释或者注释跨行时行号记错就会出现“明明注释里有个 :却被当赋值符号报错”的诡异现象。原因词法分析器里漏了注释分支。解决在 tokenize 的主循环开头加一个判断如果当前字符是/且下一个字符是*则不断向后读取直到找到*/或者文件结束。注意两点一是要处理“文件结束还没遇到 */”的情况并报出“未闭合注释”的编译错误二是注释内部可能出现任意字符包括关键字所以注释吸收逻辑必须在识别保留字之前执行。很多课设验收的隐蔽测试用例就专门挑这一条。5.5 现象Windows 命令行下输出乱码或报 UnicodeDecodeErrorPL0 源码文件可能是 UTF-8 编码也可能在 Windows 记事本里被存成 GBK。Python 的 open() 如果不指定 encoding在不同平台默认编码不同于是一份在别人机器上正常读取的文件到你机器上读出来全是乱码。原因就在文件编码与默认编码不一致。解决学生很需要一个一致的文件处理策略——推荐在 main.py 里统一用with open(path, r, encodingutf-8) as f:读取源码。如果源码本身是 GBK 编码则先把文件另存为 UTF-8。这是一次性的调整却能避免整个人在半个月里被编码问题反复摩擦。校验方法是在跑第一个样例前先打印前 100 个字符确认没有乱码再走编译链路。6. 从“能跑”到“敢答辩”给 PL0 编译器做一回“体检”到这一步你的 PL0 Python 版已经能跑样例也踩过上面五个坑。但课设验收的标准不是“能跑 hello world”而是“编译器在语义上正确且处理错误时行为可靠”。我常用一套很简单的自测清单在这里分享给你。第一组是基础正确性数字运算、变量赋值、嵌套 if / while、单层和两层过程调用、读写交互。第二组是语义边界常量不可被赋值、变量未声明就使用、同层重复声明、过程内引用外层变量。第三组是鲁棒性缺失右括号、缺失分号、未闭合注释。这套清单跑下来你的编译器在老师手里大概率不会再翻出意外。进阶的技巧是用 P-code dump 做静态对照。同一段 PL0 源码自己先手推一遍指令序列再和程序生成的 dump 逐条比对。这种“人肉编译器”练习很古早但确实是最能暴露符号表层差错误和跳转地址错误的方法。我在做自己的课设时就是靠这个方式找到了 STO 指令偏移量少算一格的 bug——当时被那个问题折磨到凌晨两点事后回想手推指令只花了十五分钟。最后一个习惯每次改动词法或语法规则后先把五六个最小测试用例重跑一遍确认输出没变再继续往下写。编译器的改动是连锁反应的一条规则变更可能让整个指令序列偏移而这种偏移只会在某个角落以诡异结果出现直接追查等于大海捞针。给自己留一份“回归测试用例集”是课设周期里最值得的投入。希望这个方案能帮你在同样的路上少熬几个夜。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?
咨询建站