ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

用Python重写PL0编译器:从词法分析到虚拟机实现

用Python重写PL0编译器:从词法分析到虚拟机实现 简介南京航空航天大学编译原理课程设计源码包面向高校计算机专业学生及编译原理初学者聚焦用Python实现PL0教学语言的完整编译器。资源共8个文件压缩包约792KB核心为5个Python脚本分别对应词法分析器、语法分析、语义分析及测试版本、后端处理等模块另含课设报告、说明文档和参考文本便于对照理解各阶段实现思路。目前已有181人学习适合正在完成编译原理课设或希望从零搭建小型编译器、深入理解编译过程的人群参考。借助完整源码与设计报告可以系统掌握词法分析、语法分析、语义分析、中间代码生成与目标代码形成等关键流程理解上下文无关文法、递归下降分析、抽象语法树等核心概念节省从零设计的时间同时为后续扩展代码优化等高级功能提供可运行的基础。1. 从NUAA课设到自造轮子PL0编译器为什么值得用Python重写一遍每到编译原理课设验收季学生之间流传最广的源码标题之一就是NUAA的PL0课设工程——一个用Pascal语言定义、却在各高校实验室里被反复实现的迷你编译器。PL0的语法体量足够覆盖词法、语法、语义和代码生成的全部核心环节又小到一个人能在两周内从零写完所以它成了国内编译原理课程的经典半固定题目。把PL0用Python重写出来不只是“交作业换学分”而是你第一次有机会把词法分析、递归下降、目标代码生成这三块黑匣子全部打开亲眼看到源代码变成中间代码再被机器执行的全过程。适合的人是在被C语言版课设折磨、想用更短时间看到完整效果的同学以及想补编译原理基础但不想啃龙书的开发者。2. 词法分析手写状态机还是正则库PL0课设选型怎么最稳2.1 为什么这层必须手写而不是用正则PL0的词法规则非常有限保留字十来个、单字符运算符十来种、外加标识符和数字。很多人第一反应是直接用Python的re库一个match搞定实际做起来会发现两个麻烦。第一PL0要求“标识符不能以数字开头数字不能以字母开头”这个规则用正则写起来不复杂但一旦出现像12abc这种非法输入正则的贪婪匹配会把12和abc拆分成两个合法token而手写状态机能在数字后遇到字母时立刻报错。第二课程设计的验收点往往包含一个“画出DFA状态图”的要求手写状态机在代码结构上与DFA一一对应答辩时老师问你状态转移怎么实现你直接指着代码说state 2遇到digit回到state 2遇到letter转错误态比说“正则引擎内部处理”有说服力得多。常见做法是保留字表用dict存识别完标识符后查表确认类型数字用连续digit字符累积成十进制值特殊符号按单字符逐一匹配。这里有个细节PL0标准语法里没有大于号和小于号只有和#不等于但很多课设版本会加上、。你拿到哪个版本就先确认符号表别按龙书默认的符号集写死。2.2 一个能直接跑的词法器核心代码class Lexer: def __init__(self, source: str): self.src source self.pos 0 # 当前读取位置 self.line 1 # 当前行号错误提示用 self.tokens [] # 最终token列表 self.reserved { const: CONST, var: VAR, procedure: PROCEDURE, begin: BEGIN, end: END, if: IF, then: THEN, while: WHILE, do: DO, call: CALL, read: READ, write: WRITE, odd: ODD, } def scan(self): while self.pos len(self.src): ch self.src[self.pos] # 跳过空白和换行 if ch in \t\r: self.pos 1 continue if ch \n: self.line 1 self.pos 1 continue # 标识符字母开头后接字母或数字 if ch.isalpha(): self._scan_ident() continue # 数字digit开头后接digit if ch.isdigit(): self._scan_number() continue # 单字符符号 self._scan_symbol() return self.tokens def _scan_ident(self): start self.pos while self.pos len(self.src) and self.src[self.pos].isalnum(): self.pos 1 word self.src[start:self.pos] # 第0个字符是字母但如果中间出现非字母数字已被循环拦停 if not self.src[start].isalpha(): self._error(标识符不能以数字开头) self.tokens.append((self.reserved.get(word, IDENT), word, self.line)) def _scan_number(self): start self.pos while self.pos len(self.src) and self.src[self.pos].isdigit(): self.pos 1 # 数字后紧跟字母属于非法token if self.pos len(self.src) and self.src[self.pos].isalpha(): self._error(f数字后不能紧跟字母: {self.src[self.pos]}) self.tokens.append((NUMBER, int(self.src[start:self.pos]), self.line)) def _scan_symbol(self): ch self.src[self.pos] table { : PLUS, -: MINUS, *: MUL, /: DIV, (: LPAREN, ): RPAREN, : EQL, #: NEQ, : LSS, : GTR, ,: COMMA, ;: SEMI, .: PERIOD } if ch in table: self.tokens.append((table[ch], ch, self.line)) self.pos 1 else: self._error(f无法识别的字符: {ch}) def _error(self, msg): raise SyntaxError(f第{self.line}行: {msg})这段代码里最关键的设计是token统一成三元组(类型, 值, 行号)。类型供语法分析器判断值在标识符场景存原始字符串、在数字场景存换算好的int值行号用来做错误定位这是验收时老师最常追问的点——你的编译器能不能报出“第几行第几个错误”。符号表用dict承载保留字映射这里还有个冷知识odd在PL0里是唯一一个以单词形式出现的运算符它表示奇数判断语法位置出现在条件表达式里不能当普通运算符号处理。2.3 词法阶段的三个参数与边界行为写词法器最容易忽略的是self.src尾部的处理。如果程序没有以.结束PL0标准要求报错“程序缺少结束点”。上面代码里没包含这个检查实际完整版在scan()末尾应追加一行判断self.tokens[-1][1] ! .则会触发错误。另一个容易忽略的是空源文件——直接返回空token列表语法分析器会崩在取第一个token上所以词法器对外要提供has_more()这类保护接口。第三个边界是行号统计Python的\n在Windows下会变成\r\n\r被空格分支吞掉不影响逻辑但编辑器的行尾符会带来意外报错行号偏移我在做课设时统一在读取文件后用source.replace(\r\n, \n)归一化。这里能看到明显的Python优势C语言版要用getchar()配合ungetc()回退字符Python字符串自带索引和切片整个词法器不到一百行大部分时间是拼错误提示文案。如果你是从Python入门教程直接切过来做这个课设的最该注意的不是语法而是“不要把token设计成只有字符串”因为你后面语法分析要频繁比对token类型类型和值分离能少写很多判断。3. 语法分析把BNF写进递归下降函数代码长什么样3.1 PL0的EBNF文法与递归下降的一一对应PL0的正式文法一般写成EBNF形式程序由分程序加.组成分程序依次是常量定义、变量定义、过程定义、语句语句是赋值、过程调用、begin语句块、if语句、while语句、read/write的排列组合。递归下降的思路是每个非终结符对应一个函数函数内部按照产生式右侧的顺序调用其他函数或匹配终结符。例如statement函数的开头逻辑就是查看当前token是IDENT就走赋值语句分支是BEGIN就走复合语句分支是IF就走条件语句分支。这里有个初学者最容易踩的设计坑EBNF里的方括号[]表示可选花括号{}表示重复如果直接把可选和重复翻译成循环和if代码会嵌套得很丑。更清晰的做法是先把EBNF改写成等价的LL(1)文法消除左递归和公共前缀。PL0的文法很干净本身没有左递归公共前缀冲突在statement层面通过往前看一个token就能区分所以递归下降写起来非常顺。3.2 条件语句和控制流转移回填补丁怎么打PL0没有elseif语句的结构是if 条件 then 语句while语句是while 条件 do 语句。翻译成P-code时if需要在条件为假时跳转到if语句的出口while需要在条件为假时跳出循环体、在循环体末尾无条件跳回循环入口。经典的实现是“先留空地址解析完目标语句后再回填”。语法分析器和代码生成器共享一个code列表和一个next_code_index计数器生成跳转指令时先把位置记下来等确定跳转目标后再写回。class Parser: def __init__(self, lexer: Lexer): self.lexer lexer self.tokens lexer.scan() self.pos 0 self.code [] # 生成的P-code指令列表 self.symtab {} # 符号表 def match(self, expected_type): if self.tokens[self.pos][0] ! expected_type: raise SyntaxError(f期望{expected_type}实际{self.tokens[self.pos]}) self.pos 1 def parse_statement(self): ttype self.tokens[self.pos][0] if ttype IDENT: self._parse_assign() elif ttype BEGIN: self.match(BEGIN) self.parse_statement() while self.tokens[self.pos][0] SEMI: self.match(SEMI) self.parse_statement() self.match(END) elif ttype IF: self.parse_condition() # 条件为假时跳转到ENDIF地址先占位 jpc_index len(self.code) self.code.append((JPC, 0)) self.match(THEN) self.parse_statement() self.code[jpc_index] (JPC, len(self.code)) elif ttype WHILE: loop_start len(self.code) self.parse_condition() jpc_index len(self.code) self.code.append((JPC, 0)) self.match(DO) self.parse_statement() self.code.append((JMP, loop_start)) self.code[jpc_index] (JPC, len(self.code)) else: raise SyntaxError(f{self.tokens[self.pos][1]} 不能作为语句开头) def parse_condition(self): # odd表达式或比较表达式 if self.tokens[self.pos][0] ODD: self.match(ODD) self.parse_expression() self.code.append((OPR, 6)) # OPR 6 奇数判断 else: self.parse_expression() op_type self.tokens[self.pos][0] if op_type in (EQL, NEQ, LSS, GTR): self.match(op_type) self.parse_expression() self.code.append((OPR, self._cmp_op_code(op_type))) else: raise SyntaxError(条件表达式缺少比较运算符)回填逻辑核心就在jpc_index的用法if分支里先生成一个假的JPC 0指令等parse_statement()执行完知道了当前指令总数再回填那个占位符为JPC 当前长度。这个技巧在语法分析里是必考的熟练掌握后你会发现四则表达式求值、数组下标范围检查都是同一套路子。3.3 表达式与运算符优先级谁在穿针引线PL0的表达式处理是课设中容易写乱的部分本质是解决算术优先级。标准做法是把表达式拆成三层expression负责加减term负责乘除factor负责常量、变量、括号表达式和一元负号。每个函数末尾不生成额外指令而是让递归调用更深层函数时把操作数通过P-code指令推进栈。例如term函数里遇到*就递归解析右边又一个因子然后追加一条OPR 4乘法指令。这层的设计决定了代码生成器是否同步工作。我见过有人把语法分析和代码生成分写成两个阶段先建AST再遍历AST生成指令代码会多一倍。在PL0这种小型语言上直接在递归下降函数里内联代码生成更省事。代价是代码可读性差一点但课设验收只看结果和关键机制老师问“你的乘法怎么翻译成指令的”你能指着那一行append((OPR, 4))讲清楚就足够了。4. 目标代码与虚拟机P-code指令集设计与解释器主循环4.1 PL0的经典指令表为什么LIT和LOD要分开PL0的标准目标代码是一套栈式虚拟机指令常见指令如下表。指令操作数顶多是两个整数指令本身记作(操作, 层级差, 偏移量)或简化为(操作, 参数)。指令参数含义LITvalue把常量value压入数据栈LODlevel, offset从静态链跳level层后取变量值压栈STOlevel, offset栈顶存回指定变量CALlevel, offset调用过程入口地址为offsetINTamount为局部变量在数据栈上预留空间JMPaddr无条件跳转到addrJPCaddr弹出栈顶为假则跳转到addrOPR0..9算术运算、比较运算、返回LIT和LOD的区别从表面看都是“取一个数压栈”但LIT的数是编译期常量LOD的数是在栈上某位置读变量这个位置是运行期经过静态链寻址得到的。很多Python移植版在这里翻车把变量读取简化成“数组下标直接访问”原因在于C版PL0用base()函数沿静态链逐级跳转而静态链的作用是支持过程嵌套访问外层变量。你在Python里可以省掉指针的显式操作但静态链跳转逻辑必须保留否则过程嵌套的程序在运行期会读到错误的值。4.2 解释器核心数据结构与主循环class VM: def __init__(self, code: list): self.code code self.stack [] # 数据栈 self.pc 0 # 程序计数器 self.base_addr [] # 静态链记录也可随栈帧存储 def _find_base(self, level): # 沿静态链向上找level层PL0静态链存在栈帧的第三个位置 bp len(self.stack) - 1 while level 0: bp self.stack[bp - 2] # 静态链指向外层栈帧的基址 level - 1 return bp def run(self): while self.pc len(self.code): op, *args self.code[self.pc] self.pc 1 if op LIT: self.stack.append(args[0]) elif op LOD: base self._find_base(args[0]) self.stack.append(self.stack[base args[1]]) elif op STO: base self._find_base(args[0]) self.stack[base args[1]] self.stack.pop() elif op INT: for _ in range(args[0]): self.stack.append(0) elif op JMP: self.pc args[0] elif op JPC: val self.stack.pop() if val 0: self.pc args[0] elif op CAL: # 压入返回地址、动态链、静态链 self.stack.append(self.pc 1) self.stack.append(self._find_base(0)) self.stack.append(self._find_base(args[0])) self.pc args[1] elif op OPR: self._execute_opr(args[0]) def _execute_opr(self, sub_op): if sub_op 0: # RET返回 ret_addr self.stack[-3] bp self.stack[-2] # 恢复调用者栈基址 ret_val self.stack[-4] # 函数返回值 # 弹掉整个栈帧 del self.stack[-4:] self.stack.append(ret_val) self.pc ret_addr elif sub_op 1: # 加法 b self.stack.pop(); a self.stack.pop() self.stack.append(a b) elif sub_op 2: b self.stack.pop(); a self.stack.pop() self.stack.append(a - b) elif sub_op 3: b self.stack.pop(); a self.stack.pop() self.stack.append(a * b) elif sub_op 4: b self.stack.pop(); a self.stack.pop() if b 0: raise RuntimeError(除零错误) self.stack.append(a / b) elif sub_op 6: # ODD判断 self.stack.append(self.stack.pop() % 2)这段代码是整套课设最“玄学”的地方尤其RET那一截栈帧里依次保存的是返回地址、动态链、静态链、操作数栈结果。初写解释器最常见的翻车现场是函数返回后pc不知道跳回哪、或者局部变量被上一层覆盖。调试办法是在栈的关键操作后打印整条栈用文本肉眼追踪每一条指令执行前后栈的数量变化。4.3 解释器参数调优与运行期错误处理数据栈初始容量在C版里是固定数组有上溢下溢检查。Python用list天然没有上溢问题但下溢栈空时弹元素会抛IndexError这个异常信息对用户不友好。建议在_execute_opr和JPC分支里自行判断栈长度抛出自定义的“运行时栈下溢”错误。另一个实用细节是除零错误C版直接崩溃Python版可以在虚拟机层拦截后带出行号信息——把行号从词法器一路传到P-code指令里比如指令存成(op, args, line)三元组运行出错时打印所在PL0源文件行号。这个增强是答辩加分项成本只有二十分钟。5. 避坑PL0课设里最常见的5类翻车现场与排查路径5.1 现象递归层次过深直接RecursionError一个正常的PL0程序嵌套了五六层 begin endPython直接抛RecursionError: maximum recursion depth exceeded。原因是Python默认递归深度是1000而你的parse_statement、parse_expression、parse_term互相调用理论上每次嵌套会产生3层递归调用。深度1000大约对应300多层源程序嵌套正常程序到不了但课设测试数据里可能塞一个故意嵌套的极简程序。解决方式是import sys; sys.setrecursionlimit(5000)但这不是治本治本是彻底消除语法分析里的左递归——这在PL0里不存在所以问题基本都出在表达式链条太长而非真正的语法递归优先检查是否有死循环调用再考虑调recursionlimit。5.2 现象a和A被当成同一个变量PL0标准里标识符区分大小写教材中没明确写但很多C语言移植版顺手做成了大小写不敏感。Python天然是大小写敏感的于是你会遇到词法器把Begin识别为标识符而不是保留字begin语法分析在期待THEN的位置收到了Begin报错信息指向完全错误的地方。这属于规格不一致问题。我的建议是课设一开始就写清楚规格文档——要么完全大小写敏感、要么词法器统一转小写不要中途改。转小写会带来另一个坑WRITE写成write后你如果同时支持大写转向输出指令符号表里的变量名也别保留原始大小写否则查表对不上。5.3 现象负数常量被翻译成0 减 正整数表达式x : -1容易被翻译成LIT 0; LIT 1; OPR 减法这在结果上没错但有多余指令。真正的问题是如果PL0扩展支持一元负号-1和0-1在整数除法和取模下是有语义区别的。课设验收按标准PL0来一元负号不是标准的一部分-1应该被词法器拆成MINUS符号和NUMBER 1语法分析器在factor层处理。实现时务必在factor的MINUS分支里追加LIT 0和减法指令否则遇到-x会变成“取负变量”而表达式栈上根本没有任何可运算的值。5.4 现象除法结果不对整数除法有五六个其他版本Python的/是浮点除法PL0标准里数字只有整数除法结果应为整数除法向下取整还是截断除法C语言里/对整型是截断除法是朝着零的方向截断。Python的//是向下取整。对正数两者一样负数就有差异-7 // 2 -4C语言是-3。如果课设验收测了x : -7 / 2这个结果直接决定成绩。最稳的做法是在_execute_opr的除法分支里显式写int(a / b)模拟C语义不要用//。5.5 现象符号表变量重复定义没有被拒绝PL0标准要求同一作用域内不能重复定义变量但这个过程是语义分析范畴很多学生只做了语法分析导致var x, x;这样的程序被翻译成两份互相覆盖的P-code指令运行结果全对验收老师手写一个重复定义测试就露馅。在parse_var和parse_const阶段查符号表前先判断name in self.symtab重复就抛错。这行代码五分钟写完是语义分析里最基础的检查务必加上。6. 验证方法用经典测试程序把P-code打开来看课设做完后最慌的是不知道“对不对”。我习惯准备两个验证手段一个Fibonacci或最大公约数测试程序一个P-code单步跟踪器。测试程序源码不超过二十行但能覆盖过程调用、if分支、while循环、read/write输出。写过PL0的人都知道表达式的嵌套最容易测出优先级问题所以测试程序里特意写一句x : (a b) * (c - d) / 2。第二件事是做一个--trace开关让虚拟机每执行一条指令就打印当前指令、数据栈内容、PC值跑两遍再逐行肉眼核对一遍操作数栈的变化是否符合预期。手写解释器的一个好处就是你可以随时往栈的append位置打印数据不需要gdb那套复杂断点流程。如果还想往深做给PL0增加一个else分支是性价比最高的扩展。改动集中在parse_statement的IF分支把JPC先回填到else代码块起始处else代码块结束后补一条JMP到整个if语句出口。连带要改的是测试程序覆盖两个分支和嵌套if这一条能让答辩时长增加五分钟。另一个扩展是给虚拟机加一个“时钟周期”计数器把每条指令的执行计数打出来虽然PL0根本不追求性能但老师会喜欢你理解“解释执行”的含义。按我自己的教训来说所有扩展都必须建立在基础版能跑通的基础上否则你会陷入“到底是我改动坏了还是本来就坏了”的排查旋涡——先把基础版所有程序跑一遍并备份一份可运行版本再动手加新特性这张后悔药对所有人适用。希望这些经验能帮你把课设从“能过”做成“能讲清楚”少熬几个半夜。本文还有配套的精品资源点击获取
返回列表