简介:这是一份面向编译原理课程设计的完整实验资源,以 PL/0 语言编译器为基础,围绕 if-then-else 条件分支、do-until 循环,以及 for 循环 to/downto 两种步进形式进行语法扩充。资源覆盖条件判断、多种循环结构与变量迭代等典型编译实现场景,并配有按功能划分的测试用例,适合正在完成编译原理课设或自学编译器前端实现的学生参考。压缩包共 18 个文件,包含 PL/0 编译器源码(pl0.c、pl0.h)、可直接运行的 pl0.exe,以及验证各种扩充语法的测试文本(如 test-else、testfor、dowhile 等)和若干临时文件,整体仅 62KB,结构简洁、模块完整,便于直接运行对照。目前已有 775 人学习下载。通过阅读源码与配套测试用例,读者可以理解词法/语法分析、符号表维护和语句翻译等关键环节,并据此继续扩展特性;对于正在准备课设答辩或期末项目的学习者,这份资源提供了直接可用的代码框架和验证思路。 编译原理课程设计选PL/0语言做扩充,几乎是每个计算机专业学生都要过的一道坎。PL/0语言是Niklaus Wirth在《算法与数据结构》中设计的一个教学用Pascal子集,规模不大但五脏俱全——词法分析、递归下降语法分析、符号表、目标代码生成、虚拟机解释执行,一条完整的编译链路全都包含了。用这样一个小编译器练手,能在一学期内把前端到后端的流程走通,比直接去啃LLVM或者手写一个JavaScript解释器要现实得多。
这篇文章就围绕“对PL/0语言进行扩充”这件事展开。我会从方案选型讲起,逐步拆解词法、语法、语义和代码生成的改动思路,再分享我实际调试时踩过的坑和排查技巧。如果你正在做类似的课程设计,或者想拿一个高分,这篇文章应该能帮你少走不少弯路。
1. 项目背景:PL/0语言的经典与局限
1.1 为什么课程设计总爱选PL/0
很多同学第一次听到PL/0,心里都在犯嘀咕:这语言也太简陋了,连数组都没有,循环只有WHILE,简直不像一门“正经”编程语言。但恰恰是这种简单,让它成了编译原理教学里最合适的实验载体。
原版的PL/0语法结构非常紧凑,EBNF描述下来没几页纸。整个编译器用C或Pascal写,代码量通常在1000到2000行左右。很多学校的课设要求就是在这棵“小树”上做加法,加几个控制流、加一批运算符、加一种数据类型,既能锻炼对编译原理的理解,又不至于让工作量失控。同时,因为PL/0的代码结构足够清晰,你做的每一项改动都能直观地对应到词法、语法、语义、目标代码生成这四个阶段中的一个或几个,方便展示你“真的懂了”。
拿我们实验室来说,往年课设的常用扩充方向大概有十几种:REPEAT-UNTIL循环、FOR循环、逻辑运算、一维数组、字符串类型、CASE分支语句、函数参数传递、注释支持、+=这类复合赋值符等等。把这些题目放在一起对比,你就会发现,看起来都是“改一个编译器”,但工作量和风险差别特别大。选错了方向,轻则熬夜debug,重则直接把符号表和语义分析搞崩。
1.2 原版PL/0编译器长什么样
动手之前,我建议先把原版代码完整读一遍,别上来就改。原版PL/0编译器一般结构如下:
- 词法分析器:负责把源代码流拆成token,识别基本符号、数字、标识符、保留字。
- 语法分析器:递归下降实现,核心入口是program、block、statement、condition、expression、term、factor这几层函数。
- 符号表:一张线性表,每个符号记录name、kind(常量/变量/过程)、level(层差)、adr(地址)等信息。
- 代码生成器:在语法分析过程中同步生成P-code指令,常见的指令集包含LIT、LOD、STO、CAL、INT、JMP、JPC、OPR。
- 虚拟机/解释器:读取P-code并执行,维护一套栈式运行环境。
值得注意的信号是,原版的语句集合其实很小,只有赋值语句、IF语句、WHILE语句、过程调用语句、复合语句(BEGIN...END)这些。这是一个EBNF式的简化语法,保证了每一层递归下降解析函数都很好写。但对应的代价就是,语言表达能力确实有限,所以课设的扩充空间非常大。
1.3 扩充前先想清楚的三件事
按照我带过的项目经验,改编前最怕的不是代码难写,而是目标不清晰。这里有三件事,越早想清楚越好。
第一,明确扩充目标的边界。你要加的是“REPEAT-UNTIL”,还是“REPEAT-UNTIL加FOR加AND/OR加数组”?边界一旦扩大,功能之间会产生联动,比如FOR循环和数组都得依赖符号表记录更多的类型信息,注释识别要动词法层,工程量会指数级上升。我见过太多同学一开始雄心勃勃列了六七个功能,最后连一个REPEAT都没写完。
第二,搞清楚改动会波及哪些模块。很多特性的实现都不是孤立的。比如加FOR循环,看起来只是statement层加个解析分支,但你需要额外引入“循环变量在循环体内不可被赋值”这类语义检查,这会牵动赋值语句的判断逻辑。再比如加逻辑运算符AND/OR,如果只是把AND当成一个普通二元运算符处理,不实现短路求值,那很可能会被验收老师追问“为什么是jpc指令而不是opr指令”。
第三,准备好回归测试。原版PL/0编译器通常自带一个demo程序,记录一下改动前能正常编译输出的正确结果。每改一个功能点就回归测试一次,别等所有功能都写完了再统一调试,否则你根本定位不了错误。
2. 扩充方案选型:加什么特性最稳
2.1 常见扩充方向横向对比
我整理了一下课程设计里常见的扩充方向,按“实现难度”和“性价比”两个维度做了个对比,便于大家选型。
| 扩充方向 | 涉及模块 | 难度 | 性价比 | 常见坑点 |
|---|---|---|---|---|
| REPEAT-UNTIL | 语法+代码生成 | 低 | 高 | 条件与WHILE相反,容易写反跳转逻辑 |
| FOR循环 | 语法+语义+代码生成 | 中 | 较高 | 循环变量处理容易出错 |
| 逻辑运算AND/OR/NOT | 词法+语法+代码生成 | 中高 | 高 | 短路求值表达式易拆错 |
| 一维数组 | 词法+符号表+语义+代码生成 | 高 | 中 | 符号表需要存数组长度和元素类型 |
| 字符串类型 | 词法+符号表+虚拟机 | 高 | 低 | 需要引入新的存储模型 |
| 复合赋值符(+=等) | 词法+语法+代码生成 | 低 | 中 | 只是语法糖,容易忽略左值语义 |
| 多行注释支持 | 词法 | 低 | 较高 | 注释状态切换容易出bug |
观察这个表可以发现,REPEAT-UNTIL和复合赋值符是典型的小改动,适合时间紧张的同学;逻辑运算虽然要动不少地方,但做完后程序的表达能力提升非常明显,性价比其实很高。一维数组是很经典的“进阶题”,但需要在符号表上大改,如果代码功底不够扎实,容易陷入“数组元素赋值后取不出来”的调试泥潭。
2.2 我的选择:REPEAT、FOR、逻辑运算、注释
我做这个项目时,最终选了四个特性的组合:
- 增加REPEAT-UNTIL循环语句。
- 增加FOR循环语句,含TO和DOWNTO两种方向。
- 增加逻辑运算符AND、OR、NOT,且实现短路求值。
- 支持源码中的单行注释(用双斜杠//标识)。
选这四个,理由很简单。前两个是控制流扩充,对整个语法分析器的改造有代表性;逻辑运算能体现你真正理解布尔表达式在栈式虚拟机上的求值过程;注释支持虽然不起眼,却能让词法分析器的状态处理思路得到体现,而且对后续测试用例的编写帮助很大。
不选数组和字符串,也是听了之前学长学姐的教训。数组牵扯到符号表结构改动,字符串牵扯到存储模型改动,这两个方向在验收时一旦被追问,需要解释的东西非常多。而我选的这四个特性,彼此相对独立,每个都在单独模块里,适合逐个实现、逐个验证,且“每个特性都能讲清原理”,这正好符合课设的得分逻辑。
2.3 实施顺序与依赖关系
哪怕选好了特性,实施顺序也很重要。我的实际顺序是:
- 先做词法层:加入AND、OR、NOT、FOR、REPEAT、UNTIL、DO、DOWNTO这些保留字,以及双斜杠注释。
- 再做语法层:分别加入REPEAT-UNTIL语句、FOR语句、逻辑表达式产生式。
- 再做代码生成:优先实现REPEAT-UNTIL的跳转逻辑,然后实现FOR的初始化、判断、递增/递减跳转,最后实现AND/OR/NOT的短路求值指令序列。
- 最后扩展解释器:实际上我的方案里没有新增P-code指令,REPEAT和FOR都用原有的JMP、JPC、LIT、LOD、STO、OPR组合出来,逻辑运算也用JMP/JPC做短路跳转。这样虚拟机不用动,省了很多事。
这一步很关键:如果新增了指令集,就得同步改虚拟机的指令解释逻辑,工作量和排查难度都会变大。能用基础指令组合实现的,尽量不要发明新指令。
3. 核心实现:词法、语法、语义与代码生成
3.1 词法分析:保留字表与符号识别的扩充
PL/0原版词法分析通常是一个get_symbol函数,读入下一个token并设置全局的sym、id、num等变量。基础符号像+、-、*、/、=、<>、<、<=、>、>=、:=、(、)、,、;、.,都通过字符判断逐一分流。扩充时,最省事的做法是在保留字识别上做文章。
原版识别保留字的方法是查表:先把字母串读出来,然后在一张写死的保留字表里查找。所以新增保留字只需两步:
- 在保留字表里加上AND、OR、NOT、FOR、REPEAT、UNTIL、DO、DOWNTO。
- 在枚举类型里加上对应的sym值,例如symfor、symrepeat、symuntil、symdo、symdownto、symand、symor、symnot。
需要注意一个坑:原始PL/0对保留字大小写是敏感的,一般要求代码里必须小写。你新增的保留字也保持一致。如果想让编译器对大小写不敏感,需要在词法分析阶段把所有标识符统一转成小写再查表,但这会影响变量名区分,属于额外需求,课设阶段不建议贸然引入。
再来说注释。双斜杠注释在词法上会带来一个跨行读取的状态切换。原来get_symbol只负责读一个token,读完了就返回。加了//注释后,如果读到斜杠,还得再读下一个字符判断是不是另一个斜杠。如果是注释,就一直读字符直到换行符或文件结束,然后继续读下一个token。这里最直接的坑是:读文件指针已经越过了换行符,导致某些按行统计报错信息的同学,报错行号会比实际少一行。解决办法是,在跳过注释时遇到换行符要把全局行号计数器加一,而不是直接丢掉不管。
3.2 语法分析:递归下降法的新增产生式
语法层面的改动,核心是给statement、condition、expression等函数增加新的分支。
REPEAT-UNTIL语句的EBNF可以写成:
repeat_stmt = "repeat" statement { ";" statement } "until" condition .在递归下降的statement函数里增加一个branch:
if (sym == symrepeat) { getsym(); int label = cx; // 记录循环体起始位置 do { statement(); } while (sym == semicolon && getsym()); // 期望当前符号是 symuntil if (sym != symuntil) error(...); getsym(); condition(); // 生成条件表达式代码 emit(JPC, 0, label); // 条件为假则跳回循环体 }注意这里有一个常见理解偏差:PL/0的WHILE语句是“条件成立则继续循环”,所以生成的是JPC跳向循环结束。而REPEAT-UNTIL是“条件成立则退出循环”,条件为假时跳回循环体开始。所以REPEAT语句末尾的跳转目标和WHILE正好相反。我见过不少同学把REPEAT的JPC写错方向,导致程序要么死循环,要么一次都不执行。
FOR循环的EBNF可以写成:
for_stmt = "for" ident ":=" expression ("to" | "downto") expression "do" statement .实现时我的做法是:先把初始表达式求值存入循环变量;生成循环判断label,在那里读取循环变量和终值表达式做比较;如果满足循环条件则进入循环体,循环体结束后对循环变量做加1或减1运算,再跳回判断label。伪指令序列大致如下:
; for i := start to final do body <生成start表达式代码> STO i label_cond: LOD i <生成final表达式代码> OPR GT ; i > final ? JPC label_do ; 不满足则进入循环体 JMP label_end label_do: <生成循环体代码> LOD i LIT 1 OPR ADD STO i JMP label_cond label_end:这里面最容易被忽略的是语义检查:循环变量必须是一个已声明的整型变量,不能是常量或过程名。同时,循环体内部不允许对这个循环变量赋值。如果不做这个检查,会出现匪夷所思的结果。我在代码里专门加了一个循环变量标志位来做这个检查。
逻辑运算的实现,我放在了condition层。我的condition函数原本只支持:
condition = expression ( "=" | "<>" | "<" | "<=" | ">" | ">=" ) expression .为了支持AND、OR、NOT,我把条件表达式扩展成布尔表达式结构。这样每个bool_factor最终都可以递归回到关系比较,同时支持括号嵌套。代码生成采用短路求值,原理其实很直接:对于表达式a AND b,先求a,如果a为假则整体为假,不需要再求b;对于a OR b,先求a,如果a为真则整体为真,不需要求b。具体生成时,用JMP和JPC指令配合标签做跳转。
生成逻辑简述如下:
- AND:求a,JPC到false_label;再求b,JPC到false_label;设置结果为真,JMP到end_label;false_label这里设置结果为假;end_label合并。
- OR:求a,JPC跳过结果为真的设置;再求b,JPC到false_label;真值路径上设置结果为真,JMP到end_label;false_label设置结果为假;end_label合并。
这样每遇到一个逻辑运算符,都会留下多个待回填的跳转目标。递归下降写起来会稍微麻烦一点,但一旦理清“真假跳转”的思路,代码就会非常清晰。
3.3 语义分析与代码生成:生成什么样的P-code
PL/0的语义动作基本都是在语法分析的过程中同步完成的,没有单独的语义分析阶段。符号表在这里承担了主要的语义信息。
拿FOR循环来说,符号表里需要记录循环变量的层差和偏移量。当进入FOR语句时,我先在符号表里查找这个标识符,确认它是变量,再检查当前作用域里这个变量是否已经被标记为“循环变量”。如果是,直接报错“for control variable cannot be assigned”。
符号表本身在原版里比较简单,每一条记录是(name, kind, level, value, adr)这样的组合。扩充逻辑运算时,符号表不需要大动,但扩充数组时就需要在记录里增加数组长度、元素类型等字段,而且访问数组元素时要生成下标寻址指令。这也是我放弃数组方案的重要原因——工程量主要就在符号表结构的重构上。
代码生成方面,我的目标代码全部基于原有P-code指令集。这里有一个技巧:无论你加多少语法糖,只要它们能用一个顺序执行的栈式虚拟机表达,就一定能用LIT、LOD、STO、JMP、JPC、OPR等指令组合出来。关键是别把指令序列当成“伪代码”,一定要在纸上画出栈的变化过程。比如REPEAT的JPC跳转,跳回label循环体开始处时,栈顶状态必须和进入循环时保持一致,否则下一次条件判断时栈里的数据就乱了。
我在这个阶段踩过一个大坑:写FOR循环时,终值表达式被放在循环判断label之前生成,导致每次循环都会重新计算终值。事实上如果终值是个常量表达式,这问题不太明显;但如果终值是个变量,且循环体里修改了这个变量,语义就不对了。正确的做法是:循环开始前先把终值计算好存到一个临时变量或临时栈单元,然后在每次判断时直接取这个临时值。我这里为了简便,采用了“每次判断都重新计算终值”的方案,但在文档里明确说明了这种实现的语义,最终验收也能解释清楚。
3.4 解释器:虚拟机侧的适配
有人说,我扩充了语言,却没动解释器,是不是太容易了?其实不是。不动解释器,不代表不需要理解解释器。你生成的P-code最终要在这台虚拟机上跑,所以你得清楚LOD和STO在运行时到底怎么操作栈帧,JPC判断的是栈顶的哪个值。
原版PL/0解释器通常维护三个寄存器:程序计数器p、栈顶指针t、基地址寄存器b。每次执行LOD指令时,需要沿着display链或静态链查找对应层级的地址。看懂这套运行时环境后,再回头看自己生成的跳转指令,才明白为什么循环体的前后不能有多余的栈残留。
我的建议是,在解释器的主循环里临时加一个打印指令码和栈顶值的调试输出开关。这样每次执行到JPC跳转时,你能亲眼看到栈顶的值是什么、跳到了哪里,排查死循环或跳转错位会快得多。
4. 实操过程:测试程序设计与联调
4.1 一个覆盖全部新特性的测试程序
扩充完成后,我写了一个综合测试程序,确保每个新特性都被执行到。核心代码如下:
program demo; var i, sum, neg, cnt; begin sum := 0; for i := 1 to 10 do sum := sum + i; repeat sum := sum - 1 until sum <= 20; if (sum > 0) and (sum <= 10) then write(sum) else write(0); cnt := 0; for i := 10 downto 1 do if (i = 5) or (not (cnt > 0)) then cnt := cnt + 1 end.这个测试程序有几个设计考量:FOR的TO和DOWNTO都测到了;REPEAT-UNTIL测到了条件为真的退出路径;AND/OR/NOT在关系表达式的基础上组合出现。如果这整个程序能编译并运行出正确结果,说明核心功能基本靠谱。
但要注意,这只能证明“主流程通”。真正要测边界,还需要单独写更小的用例。比如REPEAT只在条件满足时才退出、FOR循环终值小于初值、NOT作用于复杂括号表达式等。这些零散用例排查起来更容易定位。
4.2 分阶段调试:先语法后再语义
我调试的顺序是:先确保词法层识别正确,再确保语法树构建正确,最后才验证运行结果。
具体做法是给编译器加一个“token流打印”的调试模式。每读到一个token就输出它的类型和原始文本。这样我一眼就能看出来AND、OR、NOT、FOR这些新保留字是否被正确识别,注释是否被正确跳过。词法没问题后,再做语法分析。我在语法分析函数的入口都打印当前函数名和当前token,这样遇到“expected xx but found xx”之类的报错,就能快速定位是哪一层递归出错。
这个阶段最痛苦的是语法错误会引发连锁报错。比如REPEAT语句写错了,可能连续报出五六个错误。后来我在error函数里只保留前三个错误,并且打印出错的行号和预期符号,定位速度快了很多。
4.3 边界条件与错误处理
下列边界情况,是我在自测时专门构造的:
- FOR循环初值大于终值(TO方向):循环体应执行0次。
- FOR循环终值等于初值:循环体应执行1次。
- REPEAT循环体内条件在第一次判断就为真:循环体至少执行1次。
- AND表达式左操作数为假时,右操作数不应该被求值。我特意在右操作数里放了一个除零表达式来验证短路——如果没短路,运行时就会出错。
- NOT括号表达式:检查优先级是否正确。
这些用例都很小,但每一个都能命中一个容易出错的处理逻辑。比如FOR循环0次的实现,很多人没注意,导致循环体必然执行一次。短路求值的验证,是最高性价比的一个测试,很多实现只是把AND当成普通二元运算用乘法模拟,必然过不了这个测试。
5. 常见问题与排查技巧实录
5.1 语法分析中的经典翻车点
递归下降解析器一旦写错,最常见的现象是“shift/reduce式”的连锁报错。比如我在实现FOR语句的DOWNTO时,最初没有在语句解析分支里单独处理“识别DOWNTO和TO的差异”,只是简单地在do前解析了一个终结符号,结果导致“for i := 10 downto 1 do”在downto那里就报错了。原因很简单:递归下降是顺序解析的,你必须显式判断当前符号是TO还是DOWNTO,然后走不同的表达式解析分支。
另一个翻车点是REPEAT语句的;处理。REPEAT循环体内部是可以包含多个语句的,这些语句用分号隔开,但不是复合语句。所以在解析循环体的每个statement后,如果遇到分号就继续解析下一个statement,否则就要求遇到UNTIL。这里要注意,最后一个循环体语句后不允许有多余的分号,否则会解析出空语句,虽然不报错,但语义会很怪。
5.2 符号表与作用域的坑
符号表在PL/0里是按层管理的。嵌套过程里可以访问外层变量,但外层不能访问内层。新增FOR循环时,我只在全局层写了循环变量的声明,一切正常。但后来在一个过程里写FOR循环时,出现了“for variable not found”的错误。
原因说出来很尴尬:FOR循环变量查找时,我没有按层差正确搜索符号表。原版符号表是一个按“层号+序号”组织的线性表,查找时从当前层开始向前找。我把层号搞错了,导致在过程内部查不到全局变量。解决方法是把查找函数单独抽出来,传入当前层号,先查本层,再逐层向外。
还有一点容易忽略:循环变量和普通变量的符号表记录要区分开。我是在符号表记录的kind字段上加了一个特殊标记,表示“当前是循环变量”,并在循环结束时恢复。否则,两个嵌套FOR循环如果复用同一个循环变量,第二次循环的语义检查就会误报“循环变量被赋值”。
5.3 生成代码的执行期错误
有时编译器不报错,但运行结果不对。这种问题最头疼。我遇到的典型案例是:REPEAT-UNTIL条件为假时应该跳回循环体,结果它直接跳到了循环体中间的某一行,导致变量只更新了一半,进入死循环。
排查方法我上面提过:在解释器里加指令打印。打印后发现,我生成的JPC目标地址是循环体的中间label,而不是循环体开始。追根溯源,是我在解析REPEAT时把循环体起始地址记录错了——记录在了condition解析完成之后,而非循环体开始之前。这个bug只用肉眼检查生成代码很难发现,但一旦打印指令流转,一秒钟就能定位。
逻辑运算短路求值也有一个隐藏很深的坑:真值设置路径和假值设置路径没有正确合并。最终结果是表达式总能得到正确结果,但如果表达式是另一个IF语句的条件,程序会走出奇怪的跳转路径。后来我在每个bool_factor的生成函数返回前,统一用explicit的“LIT 0,1”或“LIT 0,0”来表示真/假,再用JMP合并路径,代码可读性和正确性都提升了不少。
5.4 避坑清单汇总
| 症状 | 根因 | 处理建议 |
|---|---|---|
| 注释识别后报错行号混乱 | 跳过注释时未处理行号 | 遇到换行符时递增行号计数器 |
| REPEAT循环体不执行 | 条件跳转方向写反 | 对照“入栈顺序”画跳转流程图 |
| FOR循环结束值为0次时仍执行1次 | 判断条件使用了错误的比较指令 | 明确“>”跳出循环,使用GT或LE正确配对 |
| AND/OR表达式短路失效 | 未使用JPC/JMP做条件跳转,而是当作普通算术 | 重写bool_expr生成逻辑,引入真假标签 |
| 嵌套过程内FOR变量找不到 | 符号表查找未处理层差 | 迭代向上层搜索,并检查层号 |
| 运行死循环 | JPC目标地址指向了循环体中间 | 在解释器里打印指令和跳转目标 |
这份清单基本覆盖了我实现过程中遇到的所有高频bug。归根结底,问题大多出在“对栈式运行模型的理解不够直观”,而解决方案也很统一:把每一条指令执行后的栈状态画出来,对照着检查你生成的指令序列。
最后再说一句课程设计以外的体会。编译器的调试和普通应用开发完全不同,它的“程序”和“运行时环境”是两套逻辑。你在调试代码生成逻辑时,要时刻保持“CPU视角”,而不是“源码视角”。我自己最后能顺利完成这个扩充,很大程度上是因为我在解释器里加了足够多的调试输出,把黑盒变成了白盒。如果你也在做PL/0扩充,不妨先从这一步开始。
本文还有配套的精品资源,点击获取