news 2026/10/3 2:48:10

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

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
用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没有else,if语句的结构是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调用过程,入口地址为offset
INTamount为局部变量在数据栈上预留空间
JMPaddr无条件跳转到addr
JPCaddr弹出栈顶,为假则跳转到addr
OPR0..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 end,Python直接抛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 = -4,C语言是-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根本不追求性能,但老师会喜欢你理解“解释执行”的含义。按我自己的教训来说,所有扩展都必须建立在基础版能跑通的基础上,否则你会陷入“到底是我改动坏了还是本来就坏了”的排查旋涡——先把基础版所有程序跑一遍并备份一份可运行版本,再动手加新特性,这张后悔药对所有人适用。希望这些经验能帮你把课设从“能过”做成“能讲清楚”,少熬几个半夜。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/3 2:47:37

RSM代理模型:小样本CAE仿真下的高精度可解释建模方法

简介&#xff1a;本资源是一套面向工程优化与实验建模初学者的RSM代理模型MATLAB实践代码&#xff0c;适用于高校科研、工业设计及数据分析方向的学习者&#xff0c;用于理解并实现不同阶数响应面模型的构建与预测。压缩包共8个.m文件&#xff0c;总大小仅3KB&#xff0c;包含r…

作者头像 李华
网站建设 2026/10/3 2:47:18

分布式软总线组件:设备发现、组网与传输全链路落地实践

简介&#xff1a;这份资源面向 OpenHarmony 底层组件开发者与分布式通信方向的学习者&#xff0c;聚焦分布式软总线在设备发现、组网与传输三大核心能力上的工程实现。它针对现实中 WiFi、蓝牙等多种通信方式差异大、链路融合共享与冲突难以统一处理的痛点&#xff0c;提供不区…

作者头像 李华
网站建设 2026/10/3 2:46:40

Spark 2.x实时新闻话题统计:Kafka到MySQL完整实现与避坑指南

简介&#xff1a;这份资源是面向计算机相关专业学生与大数据入门者的Spark 2.X新闻话题实时统计分析项目实战包&#xff0c;可用于毕业设计、课程设计、作业或项目立项演示。项目已通过导师评审&#xff0c;答辩成绩95分&#xff0c;代码经测试可正常运行&#xff0c;适合在现有…

作者头像 李华
网站建设 2026/10/3 2:46:28

基于Spark的电商智能分析:流式计算、推荐与关联规则实战

简介&#xff1a;基于Spark的电商商品智能分析系统毕业设计源码包&#xff0c;面向大数据、软件工程相关专业学生&#xff0c;亦适合对实时计算与推荐系统感兴趣的开发者。系统以Spark Streaming接收并处理用户浏览、点击等实时行为数据&#xff0c;结合注意力模型实时计算商品…

作者头像 李华
网站建设 2026/10/3 2:46:25

高光谱数据预处理:Python全流程代码与工程实践

简介&#xff1a;针对高光谱数据预处理环节&#xff0c;这套基于Python开发的完整项目源码与配套文档&#xff0c;适合进行毕业设计、课程设计或相关算法研究的学生和开发者使用。压缩包共17个文件&#xff0c;包含2个Python脚本、1个CSV样例光谱数据、12张说明图片以及License…

作者头像 李华
网站建设 2026/10/3 2:46:06

非二进制LDPC的EXIT分析:MATLAB代码包与J函数拟合全解析

简介&#xff1a;一份围绕非二进制低密度奇偶校验码&#xff08;LDPC&#xff09;的MATLAB分析资源&#xff0c;核心聚焦外信息传递&#xff08;EXIT&#xff09;图的计算与迭代解码性能评估&#xff0c;适合通信工程、编码理论方向的研究生、科研人员&#xff0c;以及具备一定…

作者头像 李华