尧图精选

PL/0修改扩充实战:从词法分析到解释器完整添加for循环

🕒 发布时间:2026/10/1 17:11:44 📁 来源:尧图网络
简介面向编译原理课程设计中的PL/0修改扩充任务压缩包内提供一套可直接运行的完整方案覆盖新增、-复合赋值运算符实现Pascal FOR语句的TO与DOWNTO两种循环并完成、--运算符和一维数组类型扩展字符型、实数型、有返回值函数及有参数函数则暂未实现。包体共42个文件以PL/0源程序、C与Delphi源码、编译生成的cod目标码、exe可执行程序和doc设计文档为主测试程序与设计说明可逐项对照验证各扩展点整体体积约1.25MB。已有1052人学习该资源适合需要掌握词法分析、语法分析和中间代码生成的编译原理学生参考。借助完整的源码与测试用例既能快速定位运算符和FOR语句在PL/0编译器中的处理细节又可为其他语言设施改造与课程答辩提供迁移与解释依据。1. PL/0 修改扩充为什么总在“最后一步”翻车如果你手里已经有一个能跑的 PL/0 编译器别急着塞新功能。很多人在编译原理课程设计里对 PL/0 作出修改扩充第一反应是找到语句解析的地方塞一段代码结果十分钟后面对的是要么不识别新关键字、要么死循环、要么原来能跑的 while 也跟着挂掉的局面。PL/0 不是一段可以随便改的示例代码它是一条从词法分析、语法分析、中间代码生成到解释执行的完整流水线改任何一个环节另外三个环节都会跟着变。这篇文章按一次真实的“加 for 循环”过程把每层代码改在哪、改完怎么验、容易翻车在哪讲清楚适合正在做编译原理实验或课程设计、手里已经有一版能编过的 PL/0 的读者。2. 原版 PL/0 的结构与改动边界四段代码、两张表、一套 P-code2.1 从一段标识符开始看清 PL/0 的四段流水线PL/0 之所以被拿来当课程设计基线是因为它把编译器最核心的四个阶段压缩进了一个能编译能运行的最小集合。教材里通常把 PL/0 描述成“Pascal 子集编译器”实际代码里你能找到四个关键函数词法分析的 getsym、语法分析和代码生成的 block、解释执行的 interpret以及负责输出指令列表的 listcode。这四个函数不是平级关系而是像流水线一样串起来前一个函数的输出恰好是后一个函数的输入。以最常见的 Pascal 版实现为例getsym 每次从源程序里读一个字符跳过空格和注释返回一个符号编号给上层block 拿到符号编号后按递归下降的语法规则决定接下来该调用表达式分析还是语句分析同时把语义动作翻译成 P-code 指令写进 code 数组最后 interpret 逐条取出 code 数组里的指令在一个基于栈的虚拟机里完成运算。调用链看起来是 getsym 驱动 blockblock 驱动 interpret实际上 interpret 并不直接调 getsym它只认中间代码。这就引出一个重要结论你对 PL/0 做修改扩充本质上是在改这三层之间的“契约”。procedure getsym; { 词法分析读字符产符号 } procedure block(lev, tx); { 语法分析代码生成 } procedure interpret; { 解释执行 P-code }如果你拿到的是 C 或 Java 重写版函数名可能叫 nextToken、parseStatement、runVirtualMachine职责划分差不多。动手改之前先把这四个函数的入口和出口列出来每层改了之后谁会被影响心里要有数。很多人的翻车都是跳过了这一步直接去改 statement 的 switch 分支结果词法层根本没产生新关键字。2.2 符号表和 P-code 指令是修改扩充的“两张表”PL/0 里有两张表是你躲不开的。第一张是符号表保存常量、变量、过程的名字、类型、层级和地址。第二张是 P-code 指令表或者说一条指令的格式约定。符号表决定你的新语法能不能找到对应的存储位置P-code 指令决定你的新功能能不能在虚拟机上跑起来。常见的 P-code 指令只有十来条但每条都带参数理解它们比理解指令本身更重要。LIT 把常数压栈LOD 从某个层级的变量地址取值压栈STO 把栈顶存回变量CAL 调用过程INT 为局部变量分配栈空间JMP 无条件跳转JPC 在栈顶为假时跳转OPR 做算术和比较运算。你加的 for 循环不需要新增任何指令只用 LOD、LIT、STO、OPR、JPC、JMP 这六条就能拼出来这一点先记住。// 常见 P-code 指令格式指令码层级/类型地址/值 typedef struct { int op; // 例如 1LIT, 2LOD, 3STO, 4CAL, 5INT, // 6JMP, 7JPC, 8OPR int l; // 层级层差 int a; // 地址或运算编号 } instruction;OPR 是所有算术比较的核心原版里通常用 a 字段区分加、减、乘、除和等于、小于等运算。不同的 PL/0 实现给这些运算编的号不一样有的从 1 开始有的从 0 开始。调试的时候最笨也最有效的办法是把 listcode 的输出打开看生成的指令序列是否符合预期。2.3 先给改动点分级加关键字、加语句、加类型分别动哪里很多人拿到题目说“对 PL/0 作出修改扩充”不知道从哪里下手其实可以先按改动范围分个级。只加一个保留字是最轻的比如把 read 扩成 readln只需要动 getsym 里的保留字表。加一条语句重一点比如 repeat-until 或 for需要动 getsym 加保留字、动 statement 加语法分支、必要时动 OPR 加比较运算。加一种数据类型更重浮点或一维数组要动符号表结构、词法数字识别、OPR 的运行时运算规则。改动类型需要动的代码位置改动量典型风险加一个保留字getsym 保留字表最小查表逻辑不匹配加一条语句getsym statement 可能加 OPR 运算中等跳转地址回填错位加一种类型getsym 符号表 interpret 运算逻辑较大类型标记不一致加过程特性block CAL 调用约定 符号表较大栈帧管理出错这个分级表是我自己习惯用的每次拿到扩充需求先往表格里归类再决定改动顺序。编译原理实验里最常见的失败不是能力不够而是把“加一条语句”误判成“加一个保留字”只改了词法就以为完事跑起来发现语法分析根本认不出新语句又回头补代码来回折腾半天。3. 给 PL/0 加 for 循环词法、语法、解释器三处落地的完整改造3.1 词法层注册 FOR、TO 两个保留字选 for 循环做例子是因为它刚好覆盖词法、语法、代码生成三层又不至于引入数组或类型系统那种大改动。先说词法层。PL/0 的 getsym 读到字母开头的字符串后会去保留字表里查一下查得到就返回对应的符号编号查不到返回 IDENTSYM 表示标识符。所以第一步是往保留字表里加 FOR 和 TO。DO 这个词要看原版 while 语句有没有很多版本 while 语句是“while 条件 do 语句”那 DO 已经存在不用重复加。// getsym 保留字表按原版顺序追加不要破坏已有条目 char* keyword[] { begin, end, if, then, while, do, call, const, var, procedure, read, write, for, to, // 新增两行 };这里有一个容易被忽略的参数细节如果原版的保留字表是用 strcmp 顺序查找的加在末尾没问题如果用的是二分查找表必须是字典序FOR 应该插在 END 和 IF 之间TO 插在 THEN 后面。判断方法是看 getsym 里查表的代码是 for 循环逐个比还是 low/high 折半比。很多翻车就翻在这里词加了查表算法却找不到。加完保留字还要给符号编号表补两个枚举值比如 FORSYM 和 TOSYM并在 getsym 返回时做映射。验证这一步是否成功最简单的方法是写一个只包含 for 的测试文件让编译器输出词法分析结果确认返回的是 FORSYM 而不是 IDENTSYM。3.2 语法层在 statement() 中插入 for 分支词法层能识别 FOR 之后语法分析才有机会见到它。PL/0 的 statement 函数是一串 if-else 判断每个分支处理一种语句。for 语句的语法是“for 变量 : 初值 to 终值 do 语句”注意终值和语句之间不写分号分号是用于分隔语句列表的不是 for 语法的一部分。// statement() 的 if-else 链中追加一个分支 else if (sym FORSYM) { getsym(); // 吃掉 FOR if (sym ! IDENTSYM) error(EXPECT_IDENT); int idx lookup(tab, name); // 查符号表 if (idx -1) error(UNDECLARED); getsym(); // 吃掉变量名 if (sym ! BECOMESYM) error(EXPECT_ASSIGN); getsym(); expression(); // 解析初值结果在栈顶 emit(STO, 0, tab[idx].addr); // 初始化: i 初值 getsym(); // 吃掉 TO if (sym ! TOSYM) error(EXPECT_TO); getsym(); expression(); // 解析终值结果在栈顶 int loopStart cx; // 记录循环条件指令位置 emit(LOD, 0, tab[idx].addr); // 把 i 取到栈顶 emit(OPR, 0, LE); // 比较 i 终值 int jumpEnd cx; // 记录待回填跳转 emit(JPC, 0, 0); // 假则跳出循环 getsym(); // 吃掉 DO statement(); // 解析循环体 emit(LOD, 0, tab[idx].addr); // 重新取 i emit(LIT, 0, 1); emit(OPR, 0, ADD); // i 1 emit(STO, 0, tab[idx].addr); // 写回 i emit(JMP, 0, loopStart); // 无条件回到条件判断 code[jumpEnd].a cx; // 回填跳出地址 }这段代码的核心顺序是“先算终值再判断后步进”。初值表达式算完就 STO 保存之后每一次循环都重新 LOD、判断、执行体、自增、跳回。特别注意寄存在 loopStart 的是判断指令的位置不是初值指令的位置这样 JMP 才能回到 LOD 那条指令。JUMP 的目标是循环头不是 STO 初始化那条。OPR 的 LE 运算编号需要对着原版 OPR 的实现查有的版本 8 号是等于9 号是小于10 号是小于等于。写错编号不会报编译错只会得到运行时结果不对。还有一个细节栈顶比较的方向原版 OPR 的比较通常约定栈顶第二个元素是左操作数栈顶是右操作数所以先 LOD i 再压终值比较语义就是 i 终值如果顺序反了会变成终值 i循环体一次都不执行。3.3 解释器层确认 OPR 已有 LE 运算补齐缺的 case如果原版 PL/0 支持 while 和 if那么 OPR 里很可能已经有小于等于的比较运算因为 while 条件用得到。但有些教学版为了简化只实现了加、减、乘、除和等于、小于这种情况下你需要去 interpret 函数的 OPR 分支里补一个 LE 的 case。补法不是简单复制要看清原版的比较结果怎么表示通常是 1 为真、0 为假。// interpret 中 OPR 分支的示意按原版编号调整 case LESS_EQ: stack[sp-1] (stack[sp-1] stack[sp]) ? 1 : 0; sp--; // 弹出右操作数 break;如果你把 for 改成“for i : 10 downto 1”或者改成“for i : 1 to 10 by 2”处理方式一样只是把 LE 改成 GE把自增的 LIT 0,1 改成 LIT 0,2语义上完全对称。这里要注意的是PL/0 的虚拟机操作数是栈顶和次顶运算结果写回次顶然后把栈指针减一这是所有 OPR 运算的统一约定新增运算也遵循它。解释器层的代码写完不需要重新编译词法和语法部分因为中间代码格式没变。这一点是 for 循环相比浮点类型最舒服的地方你全程都没有改动 P-code 指令集只在已有的指令上做组合。3.4 从头跑通的最小验证一个带 for 的完整程序三层代码都改完后用最小用例验证。下面这个程序计算 1 到 10 的累加预期输出 55。var i, sum; begin sum : 0; for i : 1 to 10 do sum : sum i; write(sum) end.先跑这个输出 55 说明主流程通了。再跑一个边界用例终值小于初值预期循环体一次都不执行sum 保持 0。最后跑一个循环体里套 if 的用例确认 for 和已有语句能嵌套。三个用例都过了再交代码或写实验报告这时你已经能确定改动是自洽的不会出现“单测过了但一执行别的程序就崩”的尴尬。4. 按课程设计要求选扩充点repeat、浮点、数组三选一的改造成本4.1 repeat-until改动量最小的加分项如果你只需要保证“能交差且不出错”repeat-until 是性价比最高的扩充。它的语法是“repeat 语句列表 until 条件”语义是执行循环体至少一次直到条件为真。因为条件被放在循环体后面翻译风格正好和 while 相反先无条件跳到循环体执行完体之后再判断条件条件为假就跳回循环体开头。// statement() 中 repeat 分支的生成逻辑示意 else if (sym REPEATSYM) { getsym(); int loopStart cx; // 循环体开始位置 statement(); while (sym SEMICOLON) { // 循环内允许多条语句 getsym(); statement(); } if (sym ! UNTILSYM) error(EXPECT_UNTIL); getsym(); expression(); // 条件表达式 emit(JPC, 0, loopStart); // 为假跳回循环体 }这个方案不需要新增 P-code 指令不需要改符号表唯一的隐藏点是循环体最后一条语句后面不能强行加分号否则会把分号当成语句分隔符的一部分导致 until 前面解析出错。repeat-until 放在第 4 章第一个说是因为它能让课程设计的报告里写满两页“设计思路”但实际代码改动只有十几行适合时间紧张的情况。4.2 浮点类型改的是词法、符号表、运算三个层次把整数扩充成浮点数听起来像把 int 换成 float实际操作远没那么轻松。第一层是词法分析getsym 读数字的循环原来只读十进制数字现在遇到小数点要继续读小数部分还要决定要不要支持科学计数法。第二层是符号表常量表里存的数原来是整数现在要标记类型否则解释执行时不知道该把栈里的值当作整数还是浮点。第三层是 interpret 的运算逻辑加减乘除在整数和浮点上的处理不一样混合运算还要决定隐式转换规则。// 词法层识别浮点常量整数部分 小数点 小数部分 if (isdigit(ch)) { int val 0; while (isdigit(ch)) { val val * 10 ch - 0; ch next(); } if (ch .) { ch next(); float fval val; float factor 0.1; while (isdigit(ch)) { fval (ch - 0) * factor; factor * 0.1; ch next(); } // 标记为 FLOAT_LITERAL后续符号表条目类型记录为 float } }这不是一个可以“顺手做完”的改动它需要你在符号表里给每个变量和常量加类型字段还要在 OPR 的每个运算分支里处理两套数据类型。课程设计如果选了浮点方向建议把“类型标记”和“运算分派”作为报告章节标题这是最能体现工作量的地方。4.3 一维数组符号表要加“数组”行访问要变址一维数组的课程设计往往是高分方向因为它动到了 P-code 指令的寻址方式。符号表里原来每个变量条目对应一个栈地址数组变量要记录起始地址、元素个数和元素大小。访问数组元素时先算下标再按下标乘元素大小加上起始地址最后才是真正的 LOD 或 STO。// 数组访问的 LOD 变址方案示意 // i : arr[j] 翻译成 // LOD j ; 下标 j 压栈 // LIT 0, 1 ; 元素大小 // OPR 0, MUL ; 下标 * 元素大小达到地址偏移 // LOD arrBase ; 数组基址压栈 // OPR 0, ADD ; 基址 偏移 // LOD 0, 0 ; 不透明指令实际做“取栈顶为地址的栈值”这个方案有一个技术难点原版 PL/0 的 LOD 操作数是编译期已知的层级和地址而数组下标是运行期才知道的值。常见的做法是规定数组名本身在符号表里占一个条目其地址就是基址解释器遇到带特殊标记的 LOD 指令时把栈顶当作地址偏移量再做一次间接寻址。需要改的是符号表结构和解释器的寻址逻辑词法层反而几乎不动。4.4 选型建议按现有代码风格而不是题目字数来定很多人在 repeat、浮点、数组三个方向间犹豫我的建议是先看你手上的 PL/0 是怎么组织符号表的。如果符号表还是最原始的并行数组结构变量名、类型、地址各占一个数组那么做数组扩充会比较痛苦因为你要往每个并行数组里加字段很容易漏一个。如果符号表已经改成 struct 数组条目内聚度高数组方向可行。浮点方向则要看 interpret 的 OPR 实现是否集中在一个函数里集中则改动可控分散在多个地方则会很累。扩充方向主要改动位置新增 P-code 指令推荐指数repeat-untilstatement 分支无高适合保底for 循环词法 statement OPR无高适合中等目标浮点类型词法 符号表 OPR无改运算规则中工程量集中在解释器一维数组符号表 LOD/STO 寻址可选新增 LDA中高适合冲高分5. PL/0 扩充避坑五个最常见的翻车点与排错路径5.1 新关键字不识别getsym 返回了标识符现象测试程序里明明写了 for编译器却报“标识符不存在”或者把它当成变量。原因通常有两类一类是保留字表里确实加了词但查表算法是二分查找没按字典序插入查不到另一类是你加了 FORSYM 枚举值但 getsym 里返回符号编号时写错分支。解决先给 getsym 加一个临时调试输出打印每个读到的标识符字符串和最终返回的 sym 值看它落在哪个环节。打印完一眼就能看出是查表问题还是映射问题不用猜。5.2 for 循环变量必须先声明否则符号表找不到现象写 for i : 1 to 10 do ...编译直接报“i 未声明”。这是 PL/0 的符号表机制决定的所有变量必须先出现在 var 声明区。有的课程设计允许循环变量隐式声明即 for 语句里见到未声明标识符就自动往符号表插一个变量条目但这是对原版语义的修改。解决二选一要么要求用户在 var 区声明循环变量并在实验报告里写明这个约束要么在 for 分支里加隐式声明逻辑同时注意隐式声明的变量作用域到当前 block 结束为止不能污染外层。我一般推荐前者少改一处就少一个坑。5.3 循环体一次不执行或无限循环跳转地址回填错位现象for 1 to 10 跑出来 sum 是 0或者程序卡死不退出。原因是两处loopStart 记录的是条件判断指令的位置不是初始化指令的位置JPC 的回填目标跳到了错误的地方。解决把 listcode 生成的指令序列打出来对照 for 的翻译模板逐条看。最关键的是确认 JPC 后面紧跟的地址是“循环体结束后的下一条指令”也就是步进代码之后的位置而不是循环体里的某条指令。回填时用的变量必须是在生成 JPC 那一刻记下的 cx不能在 statement() 递归返回后再取 cx那时已经变了。5.4 加了新语句后原版 while 和 if 跑不准了现象for 能跑但配套的 while 示例程序结果变了。原因往往不是 OPR 的 LE 加错了而是你在 statement() 的分支链里提前 return 或者漏了断 if-else导致某些语句执行顺序错乱。PL/0 的 statement 是一整条 if-else if 链每个分支做完自己的事后应该自然走到链的末尾返回。如果某个分支里写了 return而又没有把整个链条重构好后续语句会被跳过。解决改完后不要只测新功能把原版自带的所有示例程序重新跑一遍输出和改动前逐字对比。5.5 输出结果正确但一提交就扣分缺少错误处理现象正常程序全过但故意写错语法的测试程序没有被识别编译器直接崩溃。PL/0 课程设计有一个隐藏评分点错误恢复。你要保证 for 语句里 TO 缺失、: 缺失、终值表达式不合法时编译器能报告具体错误信息而不是数组越界。解决在 for 的每一个 getsym 和期望符号判断之间插入错误检查参考原版 if 语句的处理方式在错误后跳过当前语句恢复分析而不是立即退出。6. 改完怎么验收用一组边界用例证明你的编译器没改坏验收这件事很多人做成“跑一个示例程序输出对了就交”。但编译原理实验的验收通常看两点新功能的完整性和旧功能的回归稳定性。我自己习惯准备三组用例正常用例、边界用例、错误用例每组至少两个程序。正常用例覆盖 for 的累加、嵌套、循环体内含 if边界用例覆盖终值小于初值、终值等于初值、循环变量在循环体中参与运算错误用例覆盖 FOR 后缺变量、TO 前缺少表达式、循环体缺少 do。下面这个脚本可以批量跑检查退出码和输出。#!/bin/bash # 批量回归脚本假设编译器可执行文件为 pl0 # 用例文件放在 tests/ 目录期望输出放在 expected/ 目录 for src in tests/*.pl0; do name$(basename $src .pl0) ./pl0 $src /tmp/out.txt 21 if [ $? -ne 0 ]; then echo FAIL(exit): $name elif ! diff -q /tmp/out.txt expected/$name.txt /dev/null; then echo FAIL(diff): $name else echo PASS: $name fi done边界用例的价值在于它能暴露地址回填错误。比如终值小于初值的用例你的 for 循环如果条件判断方向写反程序会在第一次判断时就进入循环体然后自增直到溢出这一瞬间就能看出问题。错误用例则能暴露符号表清理和错误恢复逻辑原版 PL/0 在报错后通常会跳到下一条语句继续分析你的新分支必须遵守同样的约定。写验收报告时把每一组用例的源程序和期望输出贴进去再附一张“新改动影响范围”的表格罗列改了哪些函数、加了哪些枚举值。这一步既是给自己留痕迹也是给老师看工作量。我自己的习惯是每次只动一个语法点改完马上重编译并跑三条最小用例空循环、单次循环、十次循环。空循环验证条件判断单次循环验证步进和终止十次循环验证地址回填的稳定性。循环逻辑这种地基性质的代码宁可慢也不贪功能。希望这些 PL/0 修改扩充的经验能帮到你动手前先看原版的 getsym 和 OPR 编号比什么都管用。本文还有配套的精品资源点击获取
上一篇/下一篇内容由系统自动关联 返回资讯列表 →