尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

PL/0编译器扩充实战:词法分析、递归下降与代码生成

PL/0编译器扩充实战:词法分析、递归下降与代码生成 简介PL0语言作为Pascal教学子集是编译原理课程设计的经典载体。面向正在学习编译原理、需要完成课程设计或希望深入理解编译器构造的本科生与开发者提供了一套完整的PL0编译器扩充与修改方案。压缩包内共6个文件包括pl0.h与pl0.cpp扩展源程序、3个txt测试文件以及docx格式的课程设计说明书包体仅146KB内容紧凑、目录清晰。目前已有1427人学习下载。资料围绕词法分析、语法分析、语义检查、中间代码生成与优化等核心环节展开设计文档对各个模块的实现思路和错误处理进行了说明便于结合源码逐步推演编译流程。借助测试文件可快速验证编译器行为也为尝试扩展语言特性、改进解析算法或优化策略提供了直接操作基础能有效支撑课程设计实践与答辩准备。 记得第一次拿到“对PL0语言及其编译器进行扩充和修改”这个题目时我以为是普通的课程设计结果越做越深从单纯“加个循环语句”到最后把整个编译器的指令生成逻辑捋了个遍。后来带学弟做项目、自己在工作里搞脚本语言解析器才彻底明白PL/0这套骨架看似简单但你要是真的把它扩充明白了词法分析、递归下降、三地址码生成这些底层功夫就算真正入门了后面看任何编译原理教材都顺眼得多。这篇文章我不想按教材顺序讲就按我带项目时的真实思路来——先搞清楚PL/0到底“缺什么”再定扩充方案然后一步步动刀最后把调试和踩坑经验一并给你。适合三类人看正在做编译原理课程设计的在校生、想通过小项目理解编译器工作流程的自学者、以及工作后需要维护或改造中小型解释器/编译器的工程师。1. 项目整体拆解为什么选PL/0从哪几路扩充1.1 PL/0的定位与核心价值PL/0是Pascal之父Niklaus Wirth在《Algorithms Data Structures Programs》里设计的一个教学语言配套编译器大概1000多行Pascal代码。它麻雀虽小但五脏俱全整形变量声明、常量定义、赋值、条件分支、WHILE循环、嵌套过程定义与调用、算术比较运算对应到编译器上就是完整的词法分析、语法分析递归下降、语义处理、类P-code目标代码生成。实际动手扩充之前先把它的原有骨架摸清否则容易“改一处、崩三处”。我建议你按下面顺序读源码全局数据结构symbol枚举nul、ident、number、plus、minus等、token结构、code数组存放生成的目标指令词法分析入口getsym函数重点看它是怎么区分关键字、标识符和数字的语法分析入口block、statement、condition、expression这几个过程它们覆盖了声明和语句的核心语法代码生成辅助函数gen、listcode以及指令集定义LIT、LOD、STO、CAL、INT、JMP、JPC、OPR为什么课程设计普遍选它扩充因为它的每个模块之间耦合度低新增一个语句类型时改动范围通常能控制在词法识别语法分支代码生成三处非常适合验证对编译流程的掌握程度。1.2 常见扩充方向与选型建议我做的时候选了三个扩充点加FOR循环、加布尔类型的逻辑运算AND、OR、NOT、加数组变量支持。你也可以根据课程要求选其中一两个做但建议至少包含一个“语句类扩充”和一个“表达式/类型类扩充”这样词法和语法层面都有实际改动。语句类扩充REPEAT-UNTIL、FOR、CASE影响statement过程的分支设计表达式类扩充布尔类型、逻辑运算符、位运算影响expression、term、factor和条件处理的联动类型/结构扩充数组、字符串、指针需要引入新的符号表结构和地址分配逻辑系统能力扩充读语句READ、写语句WRITE已经有可扩展为格式化输出、文件读写1.3 为什么要在“增量修改”上下功夫很多同学拿到这个题第一反应是“重写一个编译器”这是最大的坑。PL/0的代码逻辑是围绕递归下降展开的你大改保留字判断规则或者符号表结构很容易把原有代码的层级匹配关系弄坏。正确做法是“增量修改”原函数能不动就不动新加的内容用独立函数或分支承载每加完一个功能就立即编译测试保证随时可以回退。2. 核心细节解析与硬件基础2.1 指令集与运行机制PL/0编译器生成的目标代码不直接是机器码而是一组面向栈式虚拟机的指令这个设计在现代LLVM架构里都有影子理解了它后面学任何中间表示都不吃力。核心指令包括LIT 0,a把常量a压入栈顶LOD t,a把层级为t、偏移为a的变量值压栈STO t,a将栈顶值写到层级为t、偏移为a的变量CAL t,a调用层级为t、入口地址为a的过程INT 0,a为局部变量开辟a个单元的空间JMP 0,a无条件跳转到地址aJPC 0,a栈顶值为0假时跳转到地址aOPR 0,op执行算术或比较运算op的编号决定了具体操作加法、减法、乘法、除法、取负、比较相等、比较不等、小于、小于等于、大于、大于等于、奇数判断、读、写、换行、结束运行时的数据区就是一个大数组或动态栈过程调用时压入返回地址、动态链、静态链布置局部变量空间表达式求值靠栈顶操作完成。这也是为什么PL/0适合做编译器入门——所有现代语言中“栈帧”“调用约定”“间接寻址”这些概念在这里都是直观的。2.2 符号表与寻址设计PL/0的符号表是经典的表驱动结构每遇到一个标识符就把它登记到符号表里写上名字、类型变量/常量/过程、所属层级层差、在所在过程内的偏移地址。扩充数组时我会在符号表里多加一个结构体成员记录元素个数length、单个元素占用单元数unitsize。数组变量在符号表中的地址记录的是首元素的位置对数组元素的引用需要用“基址下标偏移”计算这个会在后面第3节讲到。2.3 错误处理与容错原版的PL/0遇到词法或语法错误通常直接报错中止但这在教学扩充中不够用。我实际做的时候给它加了一个“错误计数尽量恢复”机制词法错误如非法字符、数字溢出跳过当前字符继续扫描语法错误如表达式缺右括号、语句缺分号在错误信息里记录行号通过“同步符号”机制跳过一段输入避免错误雪崩语义错误如未声明变量、类型不匹配报错后在符号表里做一个占位登记防止后续引用崩溃3. 实操过程与核心环节实现3.1 准备工作与环境搭建PL/0原版用Pascal写成但现代环境里更多人用C/C做重写与扩充。我用C实现了我的版本编译环境是Visual Studio Code MinGW-w64也可以用Linux下的gcc。开始前你需要准备一份PL/0原版源码建议带行号的C/Pascal版本先读懂再动手一个测试用例集包含合法程序和故意写错的程序比如变量未声明、除零、括号不匹配、循环条件错误等用来验证编译器的报错能力一个栈式虚拟机模拟器PL/0的目标代码无法直接在CPU上跑需要一个解释器来执行生成的指令序列你可以自己写一个200行左右的模拟器也可以直接复用原项目的解释器部分我强烈建议先写好测试集再动代码。很多人最后翻车都是因为代码写完了才发现不知道“正确结果应该长什么样”。3.2 扩充FOR循环从语法定义到代码生成FOR循环是我做的第一个扩充点也是初学者最容易困惑的地方。它的语法定义是for 变量 : 初值 to 终值 do 语句等价语义是变量从初值开始每次循环后自增1直到超过终值之前不断执行循环体。这里有个细节PL/0原本没有“自增”指令所以我们可以把循环条件翻译为“若 变量终值 则跳出循环”。翻译流程如下我会结合伪代码讲关键逻辑case FORSYM: getSym(); // 读循环变量 if (sym ! IDENTSYM) error(); name id; // 保存变量名 getSym(); if (sym ! ASSIGNSYM) error(); // 缺少 : getSym(); expression(); // 解析初值表达式结果在栈顶 // 给循环变量赋值 gen(LIT, 0, 1); // 先生成常量1备用循环步长 gen(STO, level, table[name].addr); // 栈顶存到循环变量 // 用位置标记语句地址 int startAddr cx; getSym(); if (sym TOSYM) { // 遇到 to getSym(); expression(); // 解析终值表达式 gen(LIT, 0, 1); gen(STO, addr_temp); // 这里暂存循环变量的当前值 gen(LIT, 0, 0); } // 生成条件判断循环变量 终值时跳出 int temp cx; gen(JMP, 0, 0); // 占位跳转指令稍后回填 // 解析循环体 statement(); // 循环变量自增 gen(LOD, level, table[name].addr); gen(LIT, 0, 1); gen(OPR, 0, ADD); gen(STO, level, table[name].addr); // 跳回条件判断处 gen(JMP, 0, startAddr); // 回填循环出口 code[temp].a cx;这里最关键也最容易错的是指令地址的回填JMP/JPC指令的目标地址在生成时可能还不知道需要先占位等后续代码生成完再去改它的操作数字段。PL/0教科书里通常用“code[temp].a cx”这种方式回填你也可以用单独的list记录待回填位置。实际操作中我发现很多刚接触的同学会在“终值表达式只计算一次”这一点上踩坑。如果你把终值表达式放在每次循环判断前重新求值那变量终值如果也在变化会导致循环次数不确定正确做法是循环开始时把终值算一次存到临时位置后面判断时只读取这个临时值。3.3 扩充逻辑运算与短路求值第二个扩充点是布尔类型的逻辑运算。PL/0原版只有整数和比较运算我在其基础上增加了AND、OR、NOT三个逻辑运算符并顺便把布尔常量true、false映射为整数1和0。这里涉及一个重要的设计问题逻辑表达式要不要做短路求值短路求值是现代语言的标配比如C语言里“a ! 0 b / a 1”中如果a等于0就不会执行b/a避免除零错误。PL/0扩充时如果要做短路求值就得在条件表达式翻译时改用JMP/JPC跳转实现“先算左边左边能决定结果就不算右边”。我先做了简单版本直接按普通表达式求值两个操作数都算完再执行逻辑运算指令。这样实现简单代码短适合课程设计。但如果将来想加数组越界检查或更复杂的语义建议直接上短路版本。非短路版本的实现思路在词法里增加ANDSYM、ORSYM、NOTSYM三个保留字再在expression、term、factor的递归层级里增加对应处理分支。我会把逻辑运算符的优先级设为低于比较运算符、高于赋值运算符expression :: term { (plus|minus) term } term :: factor { (times|divide) factor } factor :: ident | number | (expression) | not factor同时condition部分需要支持“比较运算 逻辑运算组合”的布尔表达式。3.4 扩充数组类型符号表和地址分配的联动数组扩充是三者里最容易漏细节的。我在符号表结构里增加了字段代表数组信息语法定义采用如下简化形式var a, b, c[10];实现时需要在声明解析里读方括号中的数组长度把“当前过程的数据区大小”加上数组长度同时符号表里记录该数组的起始地址、元素长度、元素类型。由于PL/0的栈式数据区中每个变量默认占1个单元数组就占用连续length个单元。引用数组元素时比如“a[i]”需要生成三段代码先计算下标表达式的值在栈顶再将这个值乘以元素大小然后加上数组首地址最后作为真实地址去读取或写入// 读取 a[i] 的值 gen(LOD, level, table[name].addr); // 压入数组首地址 expression(); // 计算下标i压栈 gen(OPR, 0, ADD); // 首地址 下标 元素地址 gen(OPR, 0, IND); // 从该地址取值间接访问这里IND是我在虚拟机指令集里新增的指令功能是把栈顶元素当成内存地址取出该地址处的值或写入具体视上下文。值得强调的是新增指令需要同步修改目标代码解释器和code listing函数否则编出来的代码会被虚拟机当未知指令处理。这是我的一个真实教训当时改完编译器忘了虚拟机结果跑一个数组程序直接崩掉排查了40分钟才发现是解释器没更新。3.5 一个完整编译链路演示为了让你直观看到整体流程我用一个简单的FOR循环程序跑完整条链路var i, sum; begin sum : 0; for i : 1 to 10 do sum : sum i; write(sum) end.词法分析阶段getsym会依次识别var、i、分号、sum等符号生成token流。语法分析阶段statement进入begin复合语句块初始化sum遇到FOR关键字后调用for循环的解析分支生成如下类P-code我为方便阅读加了注释INT 0, 3 ; 为变量 i、sum 及临时变量分配空间 LIT 0, 0 STO 0, sum ; sum 0 LIT 0, 1 STO 0, i ; i 1 JMP 0, L1 ; 进入条件判断 L2: LOD 0, sum LOD 0, i OPR 0, ADD STO 0, sum ; sum sum i LOD 0, i LIT 0, 1 OPR 0, ADD STO 0, i ; i i 1 L1: LOD 0, i LIT 0, 11 OPR 0, LT ; i 11 ? JPC 0, L3 ; 如果不满足则跳出 JMP 0, L2 L3: LOD 0, sum OPR 0, WRT ; 输出 sum OPR 0, STP ; 停机这个汇编例子跑起来后输出55也就是1到10的和。如果你做出来的输出不对优先检查FOR循环里初值赋值的STO目标和条件判断的跳转方向。实际做的时候我建议用这种带注释的方式打印目标代码调试效率会高很多。4. 常见问题与排查技巧实录4.1 运行时“栈溢出”或数据区异常这个现象往往不是因为循环体太大而是因为INT指令分配的局部空间数和符号表里登记的数量不一致。比如你给过程里新增了数组变量但忘了在“局部变量区大小”里加上数组长度那么后续写入数组元素时就会写到调用栈上的返回地址轻则数据错乱重则直接栈溢出。我的排查方式写一个最小复现程序把INT指令的第一个操作数改成足够大的值看程序是否正常。如果正常说明问题出在声明解析时没有累加数据区大小去对应位置补上即可。4.2 跳转地址回填错误程序跳到错误的位置这个是最经典的PL/0改错难点。症状是生成的代码看起来条条有理但跑起来要么死循环、要么提前退出。我建议你把回填前后所有相关临时变量打印出来手动算一遍期望跳转地址找出到底是哪一步算错了。一个常见原因是“在statement解析时调用表达式表达式内部又生成跳转占位指令”导致你记录的cx位置是statement开头的地址但实际跳转目标应该是表达式内部的中间位置。解决方案是统一用“当前code数组长度”来标记关键位置别用自己记的临时变量除非你能保证它跟code数组同步更新。4.3 符号表里的层级level写错导致LOD/STO寻址错误在嵌套过程里LOD和STO指令需要两个操作数层级差level和偏移地址。很多人在扩充FOR循环时循环变量写在主过程却把level当成了当前嵌套深度导致生成的LOD/STO的level比实际多1或少1。判断方法是运行测试程序时一旦出现“取出的值是垃圾值”且程序没有崩溃优先怀疑level计算。你可以在符号表查询函数里打印名字、level、addr三个字段对比自己口头推导的期望值。4.4 常见问题速查表我把做这个项目期间遇到的问题整理成一个表方便你排查现象可能原因排查步骤编译阶段报“未知符号”保留字未加入词法映射检查getsym的保留字表是否包含新增关键字语法分析死循环statement分支中漏写getSym或同步符号在异常分支打印sym值观察是否一直停在同一符号变量值不对LOD/STO层级或偏移计算有误打印符号表内容对照目标代码的LOD/STO参数数组访问越界但不报错没有生成边界检查指令检查新增IND指令前后是否需要LIT 0,length做比较目标代码运行到一半停住跳转指令回填错误打印代码数组人工模拟跳转流程OPR操作码无效目标代码解释器未同步新增指令更新虚拟机里OPR和新增指令的分支4.5 调试技巧与工具我调试这个项目时最喜欢干的事就是“单步追踪目标代码”。你可以给虚拟机写一个debug模式每执行一条指令就把栈内容、指令地址、指令助记符打出来。这样对照着目标代码数组一目了然。另外强烈建议你把“编译器”和“虚拟机”分开编译、分开调试。编译器负责生成目标代码虚拟机负责执行。如果编译器生成的指令有问题你在虚拟机上怎么调都没用先把目标代码打印出来用手动模拟的方式验证它是对的再去调虚拟机。5. 后续扩展与我的心得做完FOR循环、逻辑运算和数组扩充后这个项目已经从“照抄教材”变成了“带自己想法的编译器”。如果时间允许我建议你再做两个扩展一是添加注释语法// 和 /* */这个对词法分析是个很好的考验需要处理“跳过但不报错”的逻辑二是增加字符串类型这个会让符号表和内存分配再复杂一档。我在实际做这个项目时最深的一个体会是编译器开发最忌讳“一口气写500行再编译”。哪怕你心里觉得“这不就几行代码吗”也最好拆成小步先加保留字识别、再加语法分支、再生成目标代码、再改虚拟机。每一步都尽量跑通一个小用例再继续否则错误出现后你根本不知道是自己哪一层的改动引起的。如果你也在做这个课题建议先花半天时间把原版PL/0的代码从头到尾读一遍、亲手加注释再开始改功能。这个“读代码”的时间不是浪费它会让后面每一处修改都更精准。等这个项目做完你再看LLVM的教程或者企业级编译器的文档时你会发现那些你曾经觉得玄乎的概念——IR生成、指令选择、寄存器分配——在你手里都有了一个最基本的原型剩下的事情就只是在这个骨架上填充更高级的细节而已。本文还有配套的精品资源点击获取
返回列表