简介:面向南京航空航天大学计算机专业学生、也适合其他编译原理初学者的课程设计资源,以精简的PL0教学语言为载体,使用Python实现从词法分析、语法分析到语义分析与后端代码生成的完整编译器。压缩包共8个文件,主体是5个Python脚本,分别对应词法分析器、语法分析、语义分析、后端及语义分析测试版本;另外附带课程设计报告(doc)和说明文档(md/txt),整体仅792KB,已有181人学习下载。通过这份资源,读者可以运行并调试一个可用的PL0编译器,也可以跟着报告梳理“词法分析—语法分析—语义分析—代码生成”的完整链路,还能对比测试版与正式版语义分析脚本,加深对递归下降分析、抽象语法树和中间代码优化的理解。整体而言,这份课设对于完成NUAA作业或自学编译原理都具有不错的参考价值。
1. 编译原理课设选PL0:Python版编译器,四段代码各管一摊
编译原理课设做PL0,是南京航空航天大学计算机专业的经典实践。PL0是教学用的简化语言,语法精简,去掉浮点和复杂类型,只剩整数、赋值、条件、循环、过程调用这几样,却把编译器前端的词法分析、语法分析、语义分析,以及后端的目标代码生成串成了一个完整闭环。这份资源用Python写了一个能跑的PL0编译器,词法分析器.py、语法分析.py、语义分析.py、后端.py四个模块分开成了独立文件,每一段都可以单独改、单独跑。正在做课设、或者想用Python把编译器构造流程快速过一遍的人,照着它改最省力。写这份项目的血泪经验是:跑通一遍完整链路,比对着课本抄三遍第二章都管用。
2. 文件构成与设计取舍:五个py文件在编译链路里的位置
拿到压缩包,第一件事是把文件先对一遍。这里不急着跑,先看清楚哪个文件对应编译的哪个阶段,跑起来出错才知道去哪看。这份资源的结构不算复杂,但有两个语义分析文件混在一起,不看清单直接动手容易跑错脚本。
2.1 文件清单:从PL0源码到P-code,每一段代码的角色
包里的文件对应关系大致如下:
| 文件 | 在编译链路里的角色 |
|---|---|
| 词法分析器.py | 读入PL0源程序,输出token流 |
| 语法分析.py | 读token流,递归下降,识别语句结构 |
| 语义分析.py | 建符号表,检查变量有无定义、类型是否匹配 |
| 语义分析_测试版本.py | 语义分析的调试版,带更多打印输出 |
| 后端.py | 生成目标代码(P-code),并输出指令列表 |
| article.txt | 测试用的PL0源程序,词法分析从这读入 |
| 编译原理课设报告.doc | 课设报告模板 |
| README.md | 简要运行说明 |
先看article.txt,它放的是测试用的PL0源程序。常见课设里放的是一段求最大公约数或阶乘的代码,结构大致是这个味道:
VAR a, b, c; BEGIN a := 12; b := 8; WHILE a # b DO IF a > b THEN a := a - b ELSE b := b - a; WRITE a END.这段程序里有两个容易在第一次跑时吓住人的点。一个是#号,它代表“不等于”,PL0里刻意不用!=这种现代写法;另一个是语句结束的分号规则,WHILE后的IF语句整行直接就是一条语句,不需要额外包BEGIN END。看清这两个符号,词法分析那一关就好过了。
语法分析.py读的不是article.txt原文,而是读词法分析器产出的token流。词法分析器.py输出一个[(符号, 值), ...]列表,语法分析器再基于这个列表做递归下降。两个文件之间不直接共享源码字符串,这是PL0课设最常见的代码组织方式:词法分析完整跑完,才轮到语法分析。这种分段式的结构对调试非常友好,词法阶段出了问题,不用碰语法分析的任何逻辑。
2.2 三个设计取舍:Python单遍、P-code输出、双语义分析文件
这个项目有几个设计决策,带过很多份课设,我认为值得单独说。理解了取舍,后续改代码才知道往哪个方向改。
第一个决策是用Python做单遍编译。词法分析产出token流之后,语法分析边下降边调后端生成指令,符号表也是边扫边建的。这是教学编译器的最常见方案,适合控制代码量,因为课程设计核心是跑通链路,不是做优化。如果换成两遍编译,中间还要多维护一棵抽象语法树,代码量直接翻一倍,对于课程设计来说性价比不高。Python在这里的优势不是性能,而是列表、字典、元组这些内置数据结构和语法分析器的思路天然对齐,写起来几乎没有心智负担。
第二个决策是后端直接生成P-code。P-code是栈式虚拟机指令,指令集很小,每行形如LIT 0 12、STO 0 1这种三列结构,人工可读性极强。对比一下,如果生成x86汇编,光是寄存器分配和栈帧结构就得写上千行,对于一个周数有限的课设来说,这个复杂度是灾难级的。P-code的好处是能打印成文本文件,报告里直接贴出来就是一张很好的成果图,答辩时指着说“这是中间代码”就够用了。
第三个决策,也是最容易让人疑惑的:为什么有两个语义分析文件。语义分析_测试版本.py和语义分析.py,前者通常是带了符号表dump、token流打印的调试版,后者是干净版。我的建议是跑的时候以语义分析.py为准,测试版本只用来对照看中间状态。很多同学拿到包先打开测试版本,被五六十行打印输出搅晕,其实那份是给人看过程用的,不是最终跑结果用的。区分两个文件的方法很简单:看有没有print调用,有打印的是调试版。
2.3 PL0文法里最关键的几条产生式
PL0的文法一周内能背下来,核心就这么几条:
分程序 ::= 常量说明 变量说明 过程说明 语句 语句 ::= 标识符 := 表达式 | BEGIN 语句 {; 语句} END | IF 条件 THEN 语句 | WHILE 条件 DO 语句 | CALL 标识符 | READ 标识符 | WRITE 表达式 表达式 ::= [+|-] 项 {(+|-) 项} 项 ::= 因子 {(*|/) 因子} 因子 ::= 标识符 | 数字 | ( 表达式 )特别注意“语句 ::= IF 条件 THEN 语句”这一条,它解释了为什么IF后面可以再跟IF而不需要END:只要THEN后面是一个合法语句就行。嵌套是靠“语句”递归展开的,不是靠括号。理解这一点,递归下降写起来会顺手很多,因为每个非终结符对应一个函数,嵌套天然就是函数调函数的形态。
还有一种常见误解是把BEGIN END和IF THEN配对当成一回事。BEGIN END是复合语句,包住一串以分号分隔的语句;IF THEN的语句体如果只有一条,可以不加BEGIN END。两者混用是初写PL0时最容易产生语法错误的地方。我见过很多同学在IF的THEN后面习惯性写BEGIN,结果BEGIN和END的数量对不上,语法分析一路报错,这就是文法级别没理解透的表现。
3. 词法与语法分析:token流和递归下降的落地写法
编译课的课本把词法分析讲得很理论,状态转换图、有限自动机那一套,但落到课设上,要处理的就三类素材:保留字、标识符和数字、运算符和界符。把这三类识别清楚,词法分析就过关了。语法分析这边,递归下降的思路是所有非终结符都用函数实现,这个模式对PL0这种小文法足够且直观。
3.1 词法分析:保留字识别放在标识符之后
词法分析器.py的核心逻辑,大致是这个样子:
# 词法分析器.py 核心片段 # 保留字表:PL0全部保留字,先组词后查表 KEYWORDS = { 'const': 'constsym', 'var': 'varsym', 'procedure': 'procsym', 'begin': 'beginsym', 'end': 'endsym', 'if': 'ifsym', 'then': 'thensym', 'while': 'whilesym', 'do': 'dosym', 'call': 'callsym', 'read': 'readsym', 'write': 'writesym', 'odd': 'oddsym' } # 双字符运算符:PL0只有 <= 和 >= TWO_CHAR_OPS = {'<=', '>='} def next_symbol(src, i): """从 src[i] 开始读一个 token,返回 (符号类型, 语义值, 新下标)""" n = len(src) while i < n and src[i] in ' \t\n\r': i += 1 if i >= n: return ('period', None, i) ch = src[i] if ch.isalpha(): start = i while i < n and (src[i].isalpha() or src[i].isdigit()): i += 1 word = src[start:i] if word in KEYWORDS: return (KEYWORDS[word], None, i) return ('ident', word, i) if ch.isdigit(): start = i while i < n and src[i].isdigit(): i += 1 return ('number', int(src[start:i]), i) if i + 1 < n and src[i:i+2] in TWO_CHAR_OPS: op = src[i:i+2] return ({'<=': 'leq', '>=': 'geq'}[op], None, i + 2) # 单字符符号 single = {'+': 'plus', '-': 'minus', '*': 'times', '/': 'slash', '=': 'eql', '#': 'neq', '<': 'lss', '>': 'gtr', '(': 'lparen', ')': 'rparen', ',': 'comma', ';': 'semicolon', '.': 'period'} if ch in single: return (single[ch], None, i + 1) raise ValueError(f'unexpected char: {ch!r} at position {i}')这个函数的关键在于处理保留字和标识符的顺序:先组词,再查表。如果写反了,遇到“ifx”这个用户自定义标识符,就会因为前两个字符是if而被误判成保留字。PL0语言虽然小,但课设报告里对这种边界情况一向查得严。保留字表用了大写的symbol名,是为了和语法分析里的判断保持一致,语法分析直接用字符串比较,不用维护第二套映射关系。
参数方面,src是读入的整个源码字符串,i是当前扫描下标。返回值故意设计成三元组,而不是把下标放到对象里,这样词法分析器是无状态的,下一段token从哪开始完全由返回值决定。数字识别必须一次吃干净连续的数字字符,再把整段转成int,否则“12”会被拆成两个token。运算符部分先尝试双字符,匹配不到再走单字符分支,这个顺序不能反,否则“<=”会被拆成“<”和“=”。
实际跑的时候,词法分析器.py读article.txt只需要三行:open读文件、while循环调next_symbol、把token收集到列表里。输出到控制台时,每一行形如(number, 12)、(ident, 'a')、becomes。看到becomes出现在正确位置,词法分析就过关了。调试时如果发现token流里混进了汉字或者奇怪的符号,问题基本不在词法逻辑,而在文件编码,这个在第5章会专门讲。
3.2 递归下降:表达式为什么用循环而不是递归
PL0的表达式产生式是“表达式 ::= 项 {(+|-) 项}”,花括号表示0到多个。最直接、也最坑的实现是上来先调expression()自身,这就成了左递归,在Python里会直接触发RecursionError。正确的写法是先处理一个项,再用while循环吃掉剩余的同优先级运算符。
# 语法分析.py 核心片段 class Parser: def __init__(self, tokens): self.tokens = tokens # 词法分析产出的 (sym, val) 列表 self.pos = 0 self.sym, self.val = tokens[0] def advance(self): """吃掉当前 token,推进到下一个""" self.pos += 1 if self.pos < len(self.tokens): self.sym, self.val = self.tokens[self.pos] else: self.sym, self.val = 'period', None def factor(self): """因子:标识符 | 数字 | ( 表达式 )""" if self.sym == 'ident': name = self.val self.advance() self.semantic.emit_ident(name) elif self.sym == 'number': num = self.val self.advance() self.backend.gen('LIT', 0, num) elif self.sym == 'lparen': self.advance() self.expression() if self.sym != 'rparen': raise SyntaxError('missing )') self.advance() else: raise SyntaxError(f'unexpected token: {self.sym}') def term(self): """项:因子 {(*|/) 因子}""" self.factor() while self.sym in ('times', 'slash'): op = self.sym self.advance() self.factor() self.backend.gen('OPR', 0, 3 if op == 'times' else 4) def expression(self): """表达式:[+|-] 项 {(+|-) 项}""" if self.sym in ('plus', 'minus'): self.backend.gen('OPR', 0, 5) # 取负 self.advance() self.term() while self.sym in ('plus', 'minus'): op = self.sym self.advance() self.term() self.backend.gen('OPR', 0, 1 if op == 'plus' else 2)每递归一层就至少吃掉一个token,这是递归下降不会栈溢出的底线。factor里遇到数字就立即生成LIT指令,遇到标识符就交给语义分析登记,这一行的顺序决定了符号表能拿到正确的名字。term里的while循环处理乘除法,每轮循环都会调用factor,而factor内部又会调用expression处理括号,所以括号嵌套的层级无论多深,最终都会因为token被消耗完而自然结束。
这套代码有几个参数值得注意。self.sym是当前token的类型,self.val是对应的值,self.pos是token流里的下标。递归下降里非常忌讳不用advance()而直接改pos,那会让parsing逻辑散落到各处,查错时根本不知道当前状态在哪一层。后端指令用('OPR', 0, 1)这种三元组传参,第0位是指令名,第1位是层差,第2位是子功能号。不同的运算符映射到不同的子功能号:1是加、2是减、3是乘、4是除,这个表在后面第4章会完整列出来。
3.3 把token流接进语法分析:一个简单的驱动循环
语法分析的主体是递归下降,但顶层还需要一个驱动循环。PL0的简单课设通常只编译一个分程序,读到period(句点)就结束:
# 语法分析.py 顶层驱动 with open('article.txt', encoding='utf-8') as f: src = f.read() tokens = [] i = 0 while True: sym, val, i = next_symbol(src, i) tokens.append((sym, val)) if sym == 'period': break parser = Parser(tokens) parser.block() # 分程序 if parser.sym != 'period': raise SyntaxError(f'expected period, got {parser.sym}')这个驱动循环把词法分析和语法分析拼接起来的方式,是很多同学第一次接触时最困惑的。词法分析的两层循环要分清:外层循环控制整个文件读完,内层是next_symbol内部跳过空白字符的逻辑。如果内层循环的停止条件写错,会出现跳过不了换行的诡异现象——第一行能解析,第二行开始全部变成乱码。
语法分析里有一个很容易遗漏的点:读文件时open的编码参数。在Windows上,只写open('article.txt')容易沿系统默认编码打开,而文件实际是UTF-8,跑出来的现象不是报错,而是第一个汉字乱码后整个语法树错位。我一般会强制写encoding='utf-8',养成习惯以后能少踩很多坑。顶层驱动和递归下降函数是两层逻辑,驱动管文件级循环,递归下降管语句级嵌套,两者职责分开,后面加trace调试时改动只在驱动层,不会波及文法函数。
4. 语义分析与后端:符号表、P-code与双语义文件之谜
语法分析只认结构,不认含义。a := b,结构上没问题,但b到底有没有声明、是常量还是变量,这要语义分析来管。后端则负责把语法分析过程中顺路生成的动作翻译成P-code指令。这一步做完,编译器从源码到中间代码的完整链路就通了。
4.1 符号表:三个字段加一张按层叠放的表
PL0的符号表不复杂,每个符号记录三个字段就够了:
# 语义分析.py 核心片段 class Symbol: def __init__(self, name, kind, level, addr): self.name = name # 标识符原名 self.kind = kind # 'const' | 'var' | 'proc' self.level = level # 所在层,主程序为0 self.addr = addr # 常量值 / 变量偏移 / 过程入口 class SymbolTable: def __init__(self): self.tables = [] self.current_level = 0 def enter_block(self): """进入新的分程序,新建一层符号表""" self.tables.append([]) self.current_level += 1 def leave_block(self): """退出分程序,整层弹掉""" self.tables.pop() self.current_level -= 1 def enter(self, sym): self.tables[self.current_level].append(sym) def lookup(self, name): """由内向外找,内层同名符号优先""" for level in range(self.current_level, -1, -1): for s in reversed(self.tables[level]): if s.name == name: return s return Nonelookup的查找方向是这段代码的核心:从当前层一路查回全局。PL0允许内层过程和外层变量同名,查表必须保证先命中内层。如果一个课设版本的符号表是单一字典,不按层压栈,嵌套过程一多就会读出错误的值。reversed是为了让同层内后声明的先被找到,这符合多数PL0程序的视觉习惯,但实际影响不大,重点是层间的顺序。
enter_block和leave_block在语法分析的block()入口和出口各调用一次,成对出现。这一步很容易漏,漏了之后的现象很迷惑:内层过程结束后,它声明的变量仍然留在符号表里,外层后续代码可能错误地引用到。符号表在语义分析里的角色是“登记处”,语法分析遇到标识符就调emit_ident登记,遇到赋值就查表验证左侧是变量,遇到过程调用就查表确认是过程。这些查证做完,语义错误才能被挡在编译期而不是运行期。
4.2 后端P-code:指令集小到能背下来
后端.py的功能是提供gen()方法,把中间代码逐条写进列表。PL0教程的P-code指令集,总共就十几条:
| 指令 | 参数 | 含义 |
|---|---|---|
| LIT | 常量值 | 把常量压栈 |
| LOD | 层差、变量地址 | 读变量值压栈 |
| STO | 层差、变量地址 | 弹栈写入变量 |
| CAL | 层差、过程入口 | 调用过程 |
| INT | 栈帧长度 | 为局部变量分配空间 |
| JMP | 目标指令号 | 无条件跳转 |
| JPC | 目标指令号 | 条件跳转(条件为假时跳) |
| OPR | 子功能号 | 算术、比较、读写、返回 |
OPR的子功能号需要单独记:0返回、1加、2减、3乘、4除、5取负、6等于、7不等、8小于、9大于等于、10大于、11小于等于、12输出、13读入。后端.py的gen方法实现很简单:
# 后端.py 核心片段 class Backend: def __init__(self): self.code = [] def gen(self, op, level, arg): """生成一条指令。op 是指令名,level 是层差,arg 是参量""" self.code.append((op, level, arg)) def dump(self): """打印指令列表,报告里直接复制这段输出""" for idx, (op, l, a) in enumerate(self.code): print(f'{idx:4d} {op:3s} {l} {a}')gen方法在语法分析阶段被反复调用,但此时不需要知道指令序号——跳转指令的目标地址靠回填。常见做法是JMP/JPC先填一个占位的-1,等目标位置确定后遍历代码列表,把-1换回真实行号。后端.py里通常还会留一个pass_two()做这个回填,这一步不能省,否则跑出来的代码一到WHILE就乱跳。
层差是一个很容易搞混的参数。它记录的是“当前层”到“变量所在层”的差值,而不是层号的绝对值。主程序变量级别是0,某个过程里的变量级别是1,那语法分析在过程内部引用主程序变量时,层差就是1。这个参数在报告里经常被追问,一定要分清。P-code虽然不直接生成机器码,但它的栈式结构和真实的JVM字节码、Python字节码是同一个思路,理解了P-code,后面学任何虚拟机都有参照物。
4.3 测试版和正式版语义分析:用打印量区分定位
压缩包里的语义分析_测试版本.py,我在前面的文件清单里说它是带调试输出的版本。两版代码逻辑基本一致,差异集中在三点:测试版会打印每一条enter/lookup调用、会dump符号表全貌、会在leave_block时打印被弹出的符号。正式版则只保留符号表的静默维护。
调试时先用测试版跑一次,确认符号表进出正常;交代码时换成正式版,把打印注释掉。如果你拿到的是别人的包,不确定哪个文件是哪个版本,打开文件看有没有print调用,一眼就能区分。测试版的价值在于:它把符号表这个黑匣子里的状态显式吐出来,遇到“变量未定义”类的报错,对照打印结果就能看出是没登记成功还是查表顺序错了。正式版的价值在于代码干净、逻辑集中,适合作为最终提交的版本。
这里有一个实操建议:把article.txt换成自己写的PL0测试程序时,先跑测试版语义分析,确认新程序里的变量都被符号表正确登记,再跑完整编译。这样可以把语义问题和词法语法问题分开排查。如果新程序里用到了过程嵌套,尤其要看leave_block时的打印,确认每次弹出都只弹当前层。
5. 复现与避坑:跑通PL0时的五个常见问题与修法
复现顺序不复杂:先跑词法分析器读article.txt,再跑语法分析,中途会调语义分析和后端,最后看后端输出的P-code。问题是,正因为它流程短,每个环节的错会被后面环节放大,词法漏一个字符,语法就开始连环报错。以下五条是我在这份资源上复现时遇到的高频问题,全部按现象到原因再到解决来写。这五个坑里,前三个是代码逻辑问题,后两个是环境和预处理问题,环境问题往往比逻辑问题更隐蔽。
5.1 RecursionError:左递归把Python调用栈打穿
现象:语法分析一跑就报RecursionError: maximum recursion depth exceeded,或者控制台完全没输出直接崩溃,好像整个分析器停在了原地。
原因:expression()的实现走了左递归——函数在最开头又调用了自己。PL0的表达式产生式是“表达式 ::= 项 {(+|-) 项}”,花括号表示重复,但很多手写实现会图省事,把expression()写成先调用expression()再处理项。这种写法在理论上等价于文法左递归,Python解释器的栈一压就爆。文法里没有任何一个token被消耗,递归就不会结束。
解决:把表达式改成先term(),再用while循环吃掉后续的加减号,配合advance()保证每轮至少前进一步。凡是产生式里出现花括号(即{...}),一律用循环实现,不递归。factor里处理括号时倒是可以递归调expression,那是因为括号本身消耗了一个token,递归深度受源码嵌套层数限制。
验证方法:改写后跑一遍,观察第6章加的trace输出,如果expression进入后sym的值从ident变成了plus,说明结构正确。如果还爆栈,检查是不是两个非终结符互相调用时都没有先advance。
5.2 数字被拆成两个token
现象:源码里写a := 12,语法分析收到的却是两个number:1和2,赋值语句直接被拆烂。
原因:数字循环的停止条件写在了下标更新之前,或者用了isdigit()和isalpha()混合判断,导致第一个数字字符处理完后,循环条件已经失效。另一个常见错误是把isalpha判断放在isdigit之前,遇到“12a”这种非法程序时,会把“12”解析成数字后再把“a”当标识符,报错位置离真实问题隔着好几行。
解决:数字分支统一用while i < n and src[i].isdigit(),先判断下标越界再取字符,循环结束后用int(src[start:i])一次性转成整数。注意把条件顺序固定成“先i < n再取字符”,这个顺序写反,最后一个字符会直接越界。识别完数字后要立即返回,不要在数字分支后面继续走运算符判断。
验证方法:直接打印词法分析输出,确认(number, 12)是单个token而不是两个。顺带检查“12a”这种非法程序是否能报出期望的错,如果没报,说明词法分析把标识符和数字的边界放得太宽了。
5.3 嵌套过程结束后,符号表里还留着内层变量
现象:外层过程里引用了内层过程的变量名,编译竟然通过了,运行结果却乱七八糟,且报错位置总在跳转指令附近。
原因:符号表是单一列表,没有按分程序压栈弹栈。内层过程退出时,leave_block没有把整层符号弹掉,内层声明就污染了外层作用域。这种问题的隐蔽性在于:它不会报“变量未定义”,反而会报出一堆和变量无关的跳转错误,容易把人引到后端调试上浪费时间。
解决:检查block()入口有没有enter_block、出口有没有leave_block,两个调用必须成对出现。想快速定位,就用测试版本语义分析,退出内层后dump一次符号表,看残留符号一眼可见。leave_block实现时不要清空整个tables列表,只pop当前层,否则内层声明会全部丢失。
验证方法:在leave_block后打印符号表里当前层的符号数量,如果内层退出后符号数没减少,说明弹栈逻辑缺失。用两个同名变量分别在外层和内层声明,跑一遍测试版本,看lookup最终命中哪一层。
5.4 Windows下运行报错或输出乱码
现象:运行词法分析器.py时报UnicodeDecodeError,或者终端里出现“锘�”之类花字,看起来代码本身没问题但就是跑不通。这个坑最迷惑人的地方在于:不是每台机器都会出,和编辑器的保存编码有关。
原因:article.txt是UTF-8编码,而Windows默认用GBK打开文本;更常见的场景是py文件里写了中文注释,控制台按GBK解释源码时把代码里的中文字符串读成乱码。Python 3虽然在源码层面默认UTF-8,但open()函数如果不指定编码,在Windows上会用locale.getpreferredencoding(),也就是GBK。
解决:所有open操作写死encoding='utf-8';源码文件头部加# -- coding: utf-8 --;如果终端实在显示乱码,把代码文件的编码统一另存为UTF-8。检查一下你的编辑器右下角,如果显示的是GBK或ANSI,立刻另存为UTF-8再跑。
验证方法:换个目录重跑一次,同一份文件在不同环境下结果一致,才算处理干净。如果在命令行下还是有乱码,执行chcp 65001切到UTF-8代码页再试。
5.5 article.txt里混进中文注释或“?”占位符
现象:词法分析在某个中文字符处直接raise ValueError,或者把“?”识别成非法字符,整个分析中断在源码的中间位置。
原因:PL0标准语法里没有“?”这个符号,而有些课设报告里用“?”占位;中文注释混在源码中时,Python的isalpha()会把中文字符判定为字母,词法分析会把一整段中文误认成标识符。这是词法分析里最典型的“文本预处理没做”问题。由于词法分析器逐字符扫描,它不会自己跳过注释,注释处理必须由外层先做完。
解决:正式编译前先剥注释。最稳妥的方式是写一个三行的预处理函数,把非ASCII字符和注释块直接滤掉,再喂给next_symbol。不要指望词法分析器能天然跳过注释——这份课设也没有在词法层处理注释。预处理函数要注意不要误删字符串里的字符,不过PL0没有字符串类型,所以可以直接过滤非ASCII。
验证方法:预处理后跑词法分析,输出里不出现ident值为中文的token。如果还有残留问题,比如“?”没被滤干净,把预处理函数的过滤条件从“非ASCII”扩展成“不在PL0合法字符集”,一劳永逸。
6. 进阶技巧:给语法分析加trace跟踪,半小时定位死循环
课设跑到后期,很多人会卡在语法分析上:明明感觉语句结构没错,分析器却停不住或跳转错误。这时候不要一行行读代码,直接给语法分析套一层跟踪。
我习惯做法是给Parser加一个可选的trace开关,在advance()里打印每一步的token状态:
# 语法分析.py 中加一段可选的 trace class Parser: def __init__(self, tokens, trace=False): self.tokens = tokens self.pos = 0 self.sym, self.val = tokens[0] self.trace = trace def advance(self): """带 trace 的 token 推进,调试时能看到每次消费的 token""" if self.trace: print(f'[{self.pos:4d}] consume {self.sym} val={self.val}') self.pos += 1 if self.pos < len(self.tokens): self.sym, self.val = self.tokens[self.pos] else: self.sym, self.val = 'period', None再配一个进栈式跟踪,在每个非终结符函数上打点:
def trace_rule(name): """装饰器:打印进入和退出某个文法规则的时刻""" def deco(fn): def wrapper(self, *args): print(f'>> enter {name}, sym={self.sym}') r = fn(self, *args) print(f'<< exit {name}, sym={self.sym}') return r return wrapper return deco用法是在expression、term、factor这些函数定义前加@trace_rule('expression')。跑一次就能看到每次进入表达式时,当前token是什么。如果发现某个符号被反复进入却不消耗,就是死递归;如果进入了一个函数后sym永远不变,就是advance没有生效。
这套调试方法解决了我当年最头疼的一个问题:WHILE语句嵌套里漏写了一个advance(),整个分析器卡在同一个token上转圈。开了trace之后,打印里清楚显示每次进入while子句时sym都停在同一个标识符上,问题位置一眼就能圈定。从那以后,我每次调试语法分析都先开trace再跑,打出来的堆栈比猜代码快得多,也稳定得多。如果你也卡在这个课设的某个奇怪死循环里,不妨先加这一行打印,再往下查。希望帮到你。
本文还有配套的精品资源,点击获取