
简介编译原理课程设计资源面向正在学习编译原理、需要完成PL/0语言编译器扩展任务的本科生及自学者。资源以C语言实现的PL/0编译器为基础重点实现了三类语法扩充if-then-else条件分支、do-until循环以及for循环支持步长为1和-1的递增/递减形式覆盖了常见流程控制结构。压缩包共18个文件包含C源码、头文件、可执行程序和11个txt测试用例测试样例针对不同边界情况设计便于验证扩充语法和调试程序。包体仅62KB轻量易用下载后可直接查看源码、运行exe并配合测试文件开展实验。目前已有775人学习下载适合作为课程设计参考或编译原理实验的扩展练习。通过梳理源码中词法分析、语法分析与语义处理的改动读者能深入理解编译器前端如何支持新语法结构也可借助现有测试用例快速定位实现中的问题。 做编译原理课程设计选题是个很现实的问题。自己从头写个能编译完整语言的编译器一个学期都紧张直接拿现成项目改又觉得没参与感。我最后选了“对PL/0语言进行扩充”这个路线在Wirth设计的PL/0教学编译器基础上加入一维数组、for循环、if-else分支以及and/or/not逻辑运算。这套改造覆盖了编译前端到后端的几乎所有环节工作量适中又能把词法、语法、语义、中间代码生成和虚拟机执行一整条链路实实在在地跑通。这篇文章把我当时从设计到实现再到调试的过程完整复盘一遍给同样在做PL/0扩充或者想拿教学编译器练手的同学一个可以直接参考的完整方案。1. 项目整体设计与扩充点选型1.1 扩充方向怎么选选择扩充点不能只挑语法上“好写”的还要看它能不能覆盖完整的编译流程。一个for循环表面只是多一条语法规则实际要动词法新增保留字、语法新增产生式、语义循环变量必须是已声明的整型变量、代码生成循环跳转回填、虚拟机新增指令或OPR五个层面。数组更典型它逼着你去改符号表结构把单一变量变成带size的数据区块还要处理下标检查。逻辑运算看似简单但实现短路求值后条件表达式的代码生成逻辑会彻底重构。我当时选这三个方向原因很直接它们是高级语言区别于玩具语言的标志性特性而且每一块都能对应到编译原理课本的某一章答辩时能讲得清楚。数组对应符号表与运行时存储组织for循环对应控制流与回填技术逻辑运算对应表达式求值与代码生成。三个点加起来正好把课程的核心知识点串起来不会让人觉得你在堆砌特性。1.2 原版PL/0的架构回顾PL/0是Pascal的简化版编译器是递归下降加中间代码解释执行的经典结构。词法分析用一个get_symbol函数扫描源程序把关键字、标识符、数字、运算符换成枚举类型的token语法分析由block、statement、condition、expression、term、factor这一组递归函数组成符号表用哈希表记录名字、类型常量/变量/过程、层级、地址代码生成直接输出P-Code指令最后在虚拟机上逐条执行。整条链路很像一个流水线原料是源码经过一道道工序最后变成可执行的指令序列。这个架构最大的优点是各阶段耦合低想扩什么往往从词法开始一路改到虚拟机链条非常清晰。缺点是原版面向教学缺少一些工程化设计比如生成代码时目标地址经常要预留位置再回填地址管理全靠一个cx指针扩充时要格外小心。这也是我在后面调试阶段反复踩坑的地方。2. 词法与语法分析改造2.1 新增保留字与Token设计PL/0原有的关键字大概十几个begin、end、if、then、while、do、call、const、var、procedure、odd、read、write等。我新增了FOR、TO、DOWNTO、DO、REPEAT、UNTIL、ELSE、AND、OR、NOT、ARRAY、OF这些。改动集中在get_symbol里把保留字表扩充同时在枚举token类型里加对应项。// 枚举新增 typedef enum { NUL, IDENT, NUMBER, FOR, TO, DOWNTO, DO, REPEAT, UNTIL, ELSE, AND, OR, NOT, ARRAY, OF, // ... 原有token } symbol_type; // 保留字表新增 struct { char* name; symbol_type sym; } keywords[] { {for, FOR}, {to, TO}, {downto, DOWNTO}, {repeat, REPEAT}, {until, UNTIL}, {else, ELSE}, {and, AND}, {or, OR}, {not, NOT}, {array, ARRAY}, {of, OF}, // ... 原有保留字 };这里有个很容易踩的坑原版用字符匹配标识符后查保留字表表是静态数组如果你只加token不加保留字字符串表新关键字会被当成普通标识符后面语法分析直接报“缺标识符”。所以每次加关键字必须同步改三处枚举定义、保留字符串表、词法识别分支。另一个细节是关键字的识别要在读到完整单词之后进行不能边读边匹配否则像“forward”这种单词会误判成for虽然PL/0里没有forward但这个思维一定要有。2.2 for循环、repeat和if-else的递归下降实现statement()是语法分析的大分派函数。原版只有赋值、call、begin-end、if、while五类语句我加了for、repeat以及if-else里的else部分。for语句的文法我采用类似Pascal的写法for_stmt :: for ident : expression to expression do statement | for ident : expression downto expression do statement实现时要注意两个expression可能很复杂比如to后面的上界表达式本身含有函数调用或数组访问递归下降天然支持这种嵌套但要求你在生成代码前先把循环变量的地址、临时变量位置等因素确定好。我在实现里先解析循环变量然后分别调用expression()求初值和终值再解析do后的statement最后统一回填跳转目标。repeat-until如果也要加其实比while更容易因为until条件放在循环底部循环体会先执行一次通过JPC条件跳转决定是否回到循环头中间不需要复杂的JMP回填。但repeat-until的最大价值是它能让你对比理解“顶部测试”和“底部测试”两种循环的代码生成差异课程设计里很加印象分。if-else的经典难点是else悬挂问题。递归下降里我用一个简单办法if语句解析完then后的statement后再判断下一个token是不是else是就继续解析else分支不是就结束。因为递归下降本身自带就近匹配只要保证else分支在then分支解析之后紧跟着做判断就不会出现else被错误匹配到外层if的情况。2.3 逻辑运算与优先级处理PL/0原本condition只支持“odd expr”或者“expr 关系运算符 expr”。我想支持a and b、a or b、not a这种常见逻辑表达式。参照C/Pascal的优先级not and or且逻辑运算的优先级低于比较运算。我的做法是把expression层重新拆分让优先级由文法层级自然保证logical_or - logical_and - comparison - add_term - mul_term - factor。这样一层套一层就像数学里的乘除优先于加减一样优先级完全由语法树的层级决定不用额外写优先级判断逻辑。但注意逻辑层不只是做语法归约它还要在代码生成阶段负责短路跳转这部分我放到第4章详细讲。这里提醒一个容易踩的优先级坑not的优先级比关系运算高意味着“not a b”会被解析成“(not a) b”而不是“not (a b)”。如果你的本意是后者源程序必须写“not (a b)”。这个和C语言里!与的优先级关系一致测试时非常容易踩坑。3. 语义分析与符号表升级3.1 符号表结构怎么改原版PL/0的符号表结构大致是这样typedef struct { char name[16]; enum { CONSTANT, VARIABLE, PROCEDURE } kind; int val; // 常量的值 int level; // 声明所在层级 int addr; // 变量/过程相对地址 } Symbol;加了数组之后这一项显然不够用了一个数组要占用一片连续数据区符号表里必须记录元素个数、下标上下界。我把kind拆成CONSTANT、VARIABLE、ARRAY、PROCEDURE四种数组额外带size、low、high字段。变量本身的addr存的是数组首元素相对当前层数据区基址的偏移元素访问通过“基址 动态计算的下标”得到。这个过程很自然地逼你把“声明期静态分配”和“运行期动态计算地址”分开想清楚这是数组扩充的核心收获。工程上还有个细节原版符号表是按固定数组开的比如每个符号项固定一个结构体声明一个数组只占一个符号项但它的数据区占用是size个单元。P-machine的每个活动记录包含数据区INT指令会为过程或主程序的局部变量一次性分配空间。数组扩展后声明阶段计算出数组占用的data单元数分配空间时要把size算进去否则栈上数据会被后面的变量覆盖。3.2 数组声明处理与下标检查给PL/0加数组声明我采用的文法形式是var_decl :: var var_list ; var_list :: var_item {, var_item} var_item :: ident | ident [ number ]允许一次声明多个变量和数组混排比如var i, a[10], b[5];解析器中遇到[号就按声明数组处理否则按普通变量。数组元素个数通过number得到之后把low设成0、high设成size-1也就是下标从0开始。这个选择要和后续所有测试用例保持一致很多人在测试时下意识写1到10结果和0到9对不上误以为编译器算错了。下标检查的语义很简单计算下标表达式运行期判断是否落在[low, high]区间越界就报错停机。为什么课程设计也要做这个因为编译前端生成代码时如果完全不检查数组越界会静默覆盖内存测试时的报错根本没有意义会让排错变成灾难。加检查的代价很低在数组访问代码生成前插入几行比较和条件跳转。实际工作量不大收益很高。4. 中间代码生成与虚拟机扩展4.1 P-Code指令集扩展原版P-Code指令大概有LIT、OPR、LOD、STO、CAL、INT、JMP、JPC这几类OPR后面跟一个操作码区分具体运算0是返回1是取负2是加3是减4是乘5是除6是odd7是等于8是不等于9是小于10是大于等于11是大于12是小于等于。我需要扩展两类能力数组访问和逻辑运算。数组访问我新增了OPR 16、OPR 17两个子功能。OPR 16执行数组取值的完整操作弹出下标idx和基址base检查下标越界后压入data[baseidx]的值。OPR 17执行数组赋值弹出值val和下标idx再弹出基址base越界检查后写data[baseidx]val。这样数组访问在代码生成时统一变成“压基址、压下标、调OPR”现有LOD/STO指令完全不用动新老代码能共存。逻辑运算就比较巧了and和or我新增OPR 13和OPR 14not用OPR 15但真正实现短路求值不是在OPR里硬算而是通过JPC跳转完成的OPR 13/14更像兜底。为什么不直接用OPR实现逻辑运算因为如果a and b中a为假标准语义是b根本不需要求值如果b里有数组访问或函数调用不去执行它才是正确的。这个点课本上叫短路求值写起来很有讲究。4.2 for循环的代码生成模板for循环的代码生成是这次课程设计里最值得写的部分因为涉及目标地址回填。基本思路如下我给的是生成上的伪代码流程// for i : e1 to e2 do body 生成 e1 的代码 STO i // 循环变量初始值 生成 e2 的代码 STO t1 // 终值保存到临时变量 t1 JMP cond_label // 先跳到条件判断 loop_label: // 循环体入口 生成 body 的代码 LOD i LIT 1 OPR ADD STO i // i : i 1 cond_label: LOD i LOD t1 OPR LE // i t1 ? JPC end_label JMP loop_label end_label:几条关键心得终值e2的代码必须在循环体外生成一次不能放在cond_label里每轮重新计算否则语义就不对了。两个跳转指令JMP和JPC的目标都是绝对地址编译期不确定时要先把目标位置记录下来等loop_label和cond_label确定后再回填。另外downto只需要把最后的OPR LE换成OPR GE、把循环变量加1改成减1就行。4.3 逻辑短路求值实现and的短路生成逻辑可以这样理解// 生成 a and b 生成 a 的代码 JPC false_label 生成 b 的代码 JPC false_label LIT 1 JMP end_label false_label: LIT 0 end_label:or的版本类似但跳转方向相反我用文字描述先把a算出来如果a为真直接跳到true_labela为假才继续算bb最终为真走true_label为假走false_label两块都压入对应的布尔值0或1。这样无论a还是b有副作用都只会在必要的时候执行完全符合短路语义。这里要特别提醒逻辑表达式的代码生成和普通算术表达式完全不同因为它依赖跳转而非连续求值。写这段代码时我最大的感受是理解了编译课本里“条件表达式生成代码的方式是控制流”这句话的真正含义。要保证每个JPC都指向正确的标签标签位置在递归下降过程中必须小心维护建议用局部变量保存标签编号不要用全局的同一个变量。4.4 虚拟机执行器适配在解释执行循环里加OPR 16、17以及13、14、15的case分支逻辑本身不复杂主要是栈操作。OPR 17这种操作数多的情况要仔细核对弹出顺序先弹值、再弹下标、最后弹基址顺序一错后续指令全部乱套。另一个点是执行栈的边界检查数组越界报错信息要打印出数组名和越界下标这需要编译器在符号表里保留名字信息不能只打印地址。我加了一行示意输出调试时能直接看到“array a index 10 out of range [0, 9]”这种信息省了很多事。5. 测试用例与调试实录5.1 一个贯穿性的测试程序我写了一段能同时覆盖for、数组和逻辑运算的测试程序用来验收var a[10], i, sum; begin sum : 0; for i : 0 to 9 do begin a[i] : i * i; if (i 5) and (i 2) then sum : sum a[i]; end; write(sum); end.手工算一下i取0、1、3、4时满足条件各加入0、1、9、16sum等于26。输出26说明for增量、数组存储、if合并、and短路和write全部正常。这个程序虽然短小但几乎覆盖了所有新增特性我把它作为最终验收用例一直留着。调试时的技巧编译器刚改完第一版别急着跑复杂程序先用最小程序逐项测试。单独测for、单独测数组、单独测and、or、not再把它们组合起来。每一步都要打印中间代码P-Code列表和最终结果。P-Code列表是排查代码生成问题的核心工具我习惯把cx指针指向的每一条指令都按“地址 指令 操作数”的格式打出来对着设计模板逐行核对。5.2 典型问题排查速查表现象原因处理方式for循环死循环JMP回填目标为0或地址错误循环体结束没有跳回条件判断打印P-Code重点看loop和cond两侧的JMP/JPC目标地址数组元素结果不对测试时下标从1写起但实现从0开始统一low0测试程序从0开始计数and条件恒为假短路跳转的false_label用成了一个固定地址多个条件互相干扰检查逻辑代码生成模板每个表达式使用局部标签变量语法分析报缺分号新关键字没有加进保留字表被当成标识符同步修改枚举、保留字字符串表、词法识别分支三处变量值被数组越界覆盖下标检查没做写到了栈上其他位置在OPR 16/17执行时加上越界检查for循环嵌套时终值不对临时变量只分配了一个内层for覆盖了外层for的终值按最大嵌套深度预留临时变量或使用栈式临时量5.3 调试中踩过的一些隐蔽问题还有几个比较隐蔽的问题我在测试时遇到过值得单独拿出来说说。第一符号表作用域弹出后嵌套过程里声明的数组空间是否回收。原版PL/0是标准的过程嵌套block退出时要清理符号表项。数组只是占的data区空间更大清理逻辑不用改但符号表项数变多后要确认哈希删除顺序没问题否则残留项会污染二次声明同名数组。第二for循环的循环变量和外部变量重名。同一层级的变量声明时编译期会做重复检查所以不会出问题但如果for循环体内又声明了一个同名局部变量按照原版语义是允许内层遮蔽外层的。这一块语义约束我没有做深测试时也不建议做太复杂否则容易把课程设计变成翻译器。第三临时变量的分配方案。for的终值要保存到临时变量这个临时变量必须不是用户声明的变量。我一开始图省事只留了一个临时项结果for嵌套时内层循环的终值把外层循环的终值覆盖了运行结果惨不忍睹。最后按最大嵌套深度预留了临时变量池每个循环从池里取一个不冲突的编号这个问题才算彻底解决。这个课程设计做完我的直接感受是编译原理里的很多概念比如地址回填、短路求值、作用域管理课本上翻十遍都不如自己在编译器里改一遍来得深刻。P-Code虽然简陋但基于它做扩充的成本低新手很容易看到“代码生成”在整体管线中的位置。如果后续还想加难度可以试试递归函数调用、多维数组、参数引用传递每一处扩充都是一次完整的编译前端到后端联动。我个人调试时最受益的习惯是每次改完词法或语法立刻打印一遍P-Code和设计模板逐行对照这个习惯帮我省掉了大量找bug的时间。本文还有配套的精品资源点击获取