PL/0扩充实战:从REPEAT循环到数组下标访问的完整指南 简介面向编译原理课程设计的PL/0扩充实现方案适合需要完成编译原理大作业或课程设计的本科生、研究生及编译器构造爱好者参考。资源完整实现了赋值运算符、-和自增自减、--扩展了Pascal风格FOR语句的TO与DOWNTO两种步长并新增一维数组支持字符类型、实数类型以及带参数/返回值的函数虽未实现但工程中保留了接口与讨论线索。包体共42个文件约1.25MB主要包含PL/0源程序、C工程源码、可直接运行的exe测试程序以及说明文档目录按源程序、编译生成物、窗体工程和辅助配置等拆分。目前已有1053人学习下载。通过阅读源码与测试样例可理解运算符扩展的词法、语法、语义处理流程FOR循环的翻译方法以及数组指令生成技巧同时也能借鉴其工程组织方式是编译器设计实践的一份实用参考也可以作为后续扩展字符、实数等类型的起点。1. PL/0修改扩充先找扩展点再动代码这门课程设计才不踩坑“编译原理课程设计——对PL/0作出修改扩充”这类题目最劝退的不是代码量而是“不知道改哪里”。PL/0是一个为教学设计的Pascal子集编译器一般包含词法分析、递归下降语法分析、P-code生成和栈式解释执行四段。很多人拿到源程序后第一反应是把全部代码看一遍结果看了两天还在 getsym 里打转。其实课程设计真正的得分点在于“有依据地修改”而不是“把代码写得多花哨”。这篇文章按我实际做过的方法来写先带你认清PL/0各层结构和修改入口再给一个最小闭环扩充REPEAT-UNTIL循环和一个纵深扩充数组下标访问的完整改法最后把最容易让解释器翻车的五个坑一次性讲透。适合拿到题目还不知道该往哪个方向扩展的人也适合已经加了一半功能、却被P-code黑匣子卡住的人。2. 吃透PL/0的四个层面读懂原版之前先别动任何一行代码2.1 原版PL/0的常见结构从getsym到interpret各地教材流传的PL/0源程序命名略有差异但结构基本一致通常由几个主要模块组成词法分析负责把一个字符流变成记号流sym语法分析用递归下降的方式从记号流构造出语法树的同时直接生成指令指令序列存放在code数组里最后由解释器解释执行。只要改任何一个语法结构这四个层面往往要一起动这也是PL/0扩充题目的核心难点。原版的几个关键函数和它们在扩充时的作用可以对照这张表来看层次常见函数名负责内容修改扩充时的工作词法层getch / getsym读字符、拼标识符、匹配保留字和运算符增加保留字、运算符、符号类型符号表table / enter登记常量、变量、过程记录层级和地址为数组增加长度字段为函数增加返回值标记语法层block / statement / expression / term / factor递归下降分析并调用gen生成P-code增加新语句分支、新表达式产生式代码生成gen把指令追加到code数组增加新指令的mnemonic和语义处理解释执行interpret模拟栈式虚拟机增加新指令的栈操作最常见的误区是只改语法层而不改解释层。语法层只负责“生成指令”真正把指令跑起来的是解释器。比如新增一个FOR循环语法层生成JMP、JPC指令就够了解释器不需要新指令但新增数组访问时光靠LOD和STO无法表达“按运行时计算出的下标去取数”这时必须给指令集加新的指令。原版PL/0的P-code指令集一般是LIT压入常量、LOD取变量、STO存变量、CAL调用过程、INT调整栈空间、JMP无条件跳转、JPC条件跳转、OPR执行运算。OPR后面跟一个操作数来区分0返回、1取负、2加、3减、4乘、5除等。扩展时最常动的是JMP、JPC、LOD、STO四条指令的地址字段以及OPR的运算项。2.2 选择修改方向四个常见扩充切口和对应工作量拿到“对PL/0作出修改扩充”的题目老师一般不会规定具体扩充什么而是让你自己选。选得好不好直接决定后面工作量。我在实验室带过不少做这个题目的学生大家的主攻方向基本归为四类增加循环语句、增加数据类型、增加函数返回值、增加运算符。四类方向侵入的层次和风险差很多。扩充方向涉及层次工作量答辩亮点增加REPEAT-UNTIL循环词法语法小半天能跑通展示跳转地址回填增加FOR循环词法语法符号表中展示循环变量的处理增加数组词法语法符号表新指令大展示完整的编译解释联动增加函数返回值语法符号表调用栈大展示嵌套调用与栈帧我的建议是如果时间只有一周做REPEAT-UNTIL加一两个表达式运算符如果有两周以上做数组。数组扩充虽然麻烦但它能把“符号表长度字段、声明时连续分配地址、运行时计算下标、解释器间接寻址”这一整套流程串起来是四个方向里最能体现编译原理体系感的选择。单纯加运算符只要在OPR里补case看不出你理解了解释器。2.3 用“报错归属法”判断你的改动要动哪些层拿到原码后先别急着改我一般会先写一个小测试程序故意用上还没实现的新语法例如写一个x[2] : 3 4;。然后看报错发生在哪个阶段如果词法分析报“非法符号”说明缺词法定义如果语法分析报“此处不应出现该符号”说明缺语法产生式如果前后端都通过了但运行结果不对问题就出在指令生成或解释器。这个方法对定位扩充点很有用。它等于反向告诉你每一个新特性在编译流程里“卡”在哪一层哪一层就需要补代码。例如你在测试程序里写repeat a : a - 1 until a 0;如果程序识别不了repeat第一优先改词法保留字表如果repeat识别了但until后面不认识第二优先改statement语法分支。按照这个顺序逐层推进很少会出现“改了语法但忘了解释器”的漏网之鱼。3. 最小闭环扩充给PL/0增加REPEAT-UNTIL循环的完整改法3.1 词法层保留字表增加两个词不同版本的PL/0保留字数量不同常见原版有13个左右const、var、procedure、begin、end、if、then、while、do、odd、call、read、write有些版本还有mod。要给循环加REPEAT和UNTIL首先要在保留字表里加入这两个词并同步修改保存字的数量。以Pascal写法的原版为例保留字表通常是这样一个数组const norw 15; { 原版可能是13增加两个保留字后改成15 } resword: array[1..norw] of string (const, var, procedure, begin, end, if, then, while, do, odd, call, read, write, repeat, until);getsym在读到一个以字母开头的标识符后会在resword数组里顺序查找找到就把它转换成对应的sym值。因此必须同时保证两点一是norw这个常量确实增加否则数组越界或漏查二是在符号枚举类型里加入repeatsym和untilsym两项否则语法层没有对应的“记号”可用。很多翻车案例就是只改了数组没改计数结果repeat被当成普通标识符处理报“未声明变量”之类的错。如果你的PL/0是C或Java实现查找逻辑大同小异。C版常见的写法是if (strcmp(id, resword[i]) 0)循环匹配Java版常见是用Arrays.asList(reswords).contains(id)本质都是先在标识符表里比对。改的时候注意保留字表的容量即可。3.2 语法层statement里新增分支并生成跳转指令REPEAT-UNTIL的语义是“先执行循环体再检查条件条件为假继续循环”。这句话翻译成P-code时核心是“循环体起始位置的一处地址记录和末尾条件判断后的一条条件跳转指令”。由于条件为假时要跳回循环体所以跳转指令的目标地址指向循环体的开头而开头地址在语法分析时是已知的。在PL/0的statement过程里对每个语句类型都有一段处理逻辑。增加REPEAT分支的常见做法是if sym repeatsym then begin startAddr : cx; { 记录循环体第一条指令的位置 } getsym; statement; { 循环体第一句话 } while sym semicolon do begin getsym; statement; { 循环体后续语句 } end; if sym untilsym then begin getsym; condition; { 求条件值结果留在栈顶 } gen(JPC, 0, startAddr); { 条件为假(0)则跳回循环体 } end else error(21); end这里的关键变量是startAddr : cx必须在循环体第一条语句还没有生成指令之前记录。cx是code数组当前可写入位置的下标gen函数每写入一条指令cx就会自增。如果不记录这个位置而是留到后面再填地址就需要额外的回填逻辑复杂度会上升。REPEAT分支之所以比FOR简单正是因为它天然只需要“记录当前地址”不需要“回填”。condition过程会把比较结果放到栈顶解释器执行JPC时如果栈顶值是0就把程序计数器设为startAddr否则顺序执行下一条。这正好对应UNTIL“条件为真时退出循环”的语义。3.3 解释层JPC不用新指令但要确认实现方向增加REPEAT-UNTIL几乎不用改解释器前提是你的PL/0原版已经实现了JPC指令。但这里有个特别容易看反的点PL/0的JPC到底在什么条件下跳转我见过一个学生把JPC实现成“栈顶非0跳转”结果REPEAT循环变成了“条件为真继续循环”跟WHILE的行为一样了。原版解释器里JPC的标准语义是“栈顶为0则跳转”JPC: begin top : top - 1; if s[top] 0 then pc : code[pc].a else pc : pc 1; end;判断一个实现是不是按这个语义写的最直接的办法是看它原本对WHILE循环的处理。WHILE-DO的生成逻辑通常是“先算条件再生成JPC跳转到循环体之后”如果原版WHILE用JPC实现那么REPEAT的JPC用法就保持和它一致。如果原版把WHILE用JMP加判断指令实现你就需要先搞懂它那套约定的跳转方向再决定REPEAT的JPC参数。3.4 用一个小样例验证REPEAT是否真的跑通改完词法和语法重新编译PL/0然后跑一个最简单的测试程序。我通常用这个var a, s; begin a : 5; s : 0; repeat s : s a; a : a - 1 until a 0; write(s) end.这段程序计算54321正确输出应该是15。如果输出结果不对优先打印生成的P-code看看REPEAT开头的指令地址和JPC的目标地址是否一致。也可以用WHILE等效改写同一个程序来对照结果。如果WHILE版本正确而REPEAT版本错误问题一定集中在JPC的跳转目标或栈顶条件判断上。4. 纵深扩充给PL/0增加数组与下标访问新指令IND和STOIND4.1 符号表给数组变量加长度字段并连续分配地址数组扩充是PL/0课程设计里工程量最大的常见方向之一。原版变量声明非常简单每个变量在数据区占1个槽位符号表记录它的类型、层级和相对地址。数组的引入意味着一个变量标识符要占据多个连续的地址槽位因此符号表里至少需要增加一个“长度”字段。在原版block过程处理var声明的地方常见的原版逻辑是这样每读到一个标识符就把当前数据偏移dx作为它的地址然后dx加1。扩充后应该是var size: integer; { 数组长度普通变量记为1 } while sym varsym do begin getsym; while sym identsym do begin table[tx].name : id; table[tx].kind : variable; table[tx].level : lev; size : 1; if sym lbracketsym then begin getsym; if sym numbersym then begin size : num; { 读取数组长度 } getsym; if sym rbracketsym then getsym else error(9); end else error(9); end; table[tx].size : size; { 普通变量size为1 } table[tx].addr : dx; dx : dx size; { 连续分配多个槽位 } getsym; end; ... end;这段代码里最关键的一行是dx : dx size。它决定了这个数组在数据区占了多少空间。后面该层过程进入时需要生成INT指令来分配栈空间dx的这个累计值也会被该指令用到。如果这里漏写size只加1数组的后续变量会跟数组后面的元素重叠运行时数据互相覆盖排查起来非常痛苦。4.2 读取数组元素factor里计算下标并生成间接取数指令数组元素访问最难处理的是“下标在运行时才计算”。例如a[i 1]i的值要等程序执行到这一句才知道。所以不能像普通变量LOD那样在编译期算死一个绝对地址必须在运行时先用表达式求值把下标算出再取得数组基址两者相加得到最终元素的绝对地址最后按这个地址取数。原版没有直接“按栈顶地址取值”的指令所以需要新增两条指令IND表示“从栈顶存储的地址处取出数据”STOIND表示“把栈顶的值存入次栈顶所表示的地址处”。factor处理数组元素时翻译逻辑是这样的factor: begin if sym identsym then begin read(tmp); { 暂存符号表下标 } getsym; if sym lbracketsym then begin getsym; expression; { 先求下标结果留在栈顶 } gen(LDA, lev, table[tmp].addr); { 再压入数组基址 } gen(OPR, 0, 2); { 下标和基址相加得到元素地址 } gen(IND, 0, 0); { 从该地址取数 } if sym rbracketsym then getsym else error; end else begin if table[tmp].kind variable then gen(LOD, lev, table[tmp].addr) else if table[tmp].kind constant then gen(LIT, 0, table[tmp].val); end; end; ... end;这里新指令LDA的作用是把某个变量在一层栈帧里的相对地址换算成绝对栈地址后压入栈中。以a[i]为例最终栈中的状态是下标i的值在下面数组基址在上面两者相加恰好是数组基址 下标即目标元素的绝对地址。IND指令再从栈顶地址取出该地址上的数据。三条指令的配合顺序不能乱尤其要注意求下标表达式不能产生多余栈顶垃圾比如逗号分隔的多维下标在这个阶段不要盲目支持。4.3 给数组元素赋值赋值语句左部地址处理与STOIND解释数组元素出现在赋值语句左侧时处理顺序要反过来。a[i] : 5需要先计算出a[i]的地址并暂存在栈中再去求右边的值。右边值到栈顶后把值写入“栈顶下一格的地址”所指向的位置。因此语句处理逻辑里遇到赋值号的左部是数组元素时要提前把地址压栈。if sym identsym then begin read(tmp); getsym; isArray : false; if sym lbracketsym then begin isArray : true; getsym; expression; { 计算左部下标 } gen(LDA, lev, table[tmp].addr); { 压入数组基址 } gen(OPR, 0, 2); { 得到元素地址 } if sym rbracketsym then getsym else error; getsym; { 读完左部sym应指向赋值号 } end; if sym becomessym then begin getsym; expression; { 把右值压栈 } if isArray then gen(STOIND, 0, 0) { 值在栈顶地址在次顶 } else gen(STO, lev, table[tmp].addr); end; end;解释器里新增的两个case栈操作顺序必须严格配合上面的生成逻辑。以栈顶为top、地址在次栈顶为例IND: begin s[top] : s[s[top]]; { 把栈顶视为地址取出该地址内容覆盖栈顶 } end; STOIND: begin s[s[top - 1]] : s[top]; { 值存入地址所指单元 } top : top - 2; { 弹出值和地址 } end;这里最容易踩的坑是栈的方向和top的定位。不同版本的PL/0解释器有的top指向当前已使用栈顶有的top指向下一空闲位置还有的栈从高地址向下增长。如果你的版本和这里的方向相反要把s[s[top]]这类下标的加减方向整体翻转。写完这两条指令后建议先只跑一个a[2] : 5; write(a[2]);的最小样例确认无误再上复杂程序。4.4 新增指令的表示与分发四个地方必须同步修改新增IND、STOIND、LDA三条指令不只是语法层调用gen时多传几个参数的问题opcode枚举、指令助记符数组、gen函数里的合法性检查、解释器的case分发都要同步。省略任何一处通常都表现为“编译时不报错运行结果乱掉”的现象。我见过的最典型翻车是只加了指令枚举忘了在对应解释器的case里写分支执行到新指令时落进了default分支被当成非法指令直接停机。另一种翻车是助记符数组没加dump指令时显示空白很难看出生成的代码对不对。建议按这个顺序检查新增指令一查opcode枚举二查助记符数组三查interpret的case最后才查语法层的gen调用。四者全部到位新指令才算接进系统。如果你拿到的是Java版本或C版本核心改动完全一致差异只在语法细节Java的switch可以写成枚举带方法C里则是code[i].f IND这类条件分支。无论哪种语言只要记住“生成指令靠gen、执行指令靠interpret、显示指令靠助记符数组”这三个落点就不会漏改。5. 避坑PL/0修改过程中最容易翻车的5个点5.1 跳转地址回填错位REPEAT变成死循环或一次都不执行现象REPEAT循环体里的语句执行次数不对要么无限循环要么循环体完全没跑直接跳到UNTIL之后。原因startAddr记录的位置不对。很多版本里cx在gen内部维护但gen在写入指令前会对code数组做合法性判断如果写入前cx已经增加startAddr取到的就是“下下条指令”的位置。另一种情况是原版WHILE实现用了“先JMP到条件判断处再JPC跳回”的两段式结构REPEAT沿用了错误的前半段结构导致地址错位。解决先在语法层加入REPEAT前用writeln输出当前cx值跑一个只有一条语句的测试程序对比P-code里循环体第一条指令的数组下标。确保JPC的地址字段和循环体第一条指令下标值完全相等。必要时把cx的定义从“下一条空闲指令”改成“当前指令计数”来对齐。5.2 数组声明分配长度错误后续变量和数组元素相互覆盖现象声明了一个var a[10], b;之后b的值总是被修改或者给a[0]赋值后a[1]的内容也跟着变。原因声明a时符号表size字段没有设置或者dx累计时只加了1。这样a[1]的地址和b的地址在逻辑上是同一个栈槽位运行时相互踩踏。解决在block处理var声明的分支里确保每次处理到带方括号的标识符时都读取长度并执行dx : dx size。普通变量要显式把size置为1再分配。改完后可以声明一个数组和一个普通变量给数组最后一个元素赋值然后print普通变量确认两者互不影响。5.3 IND指令执行后栈顶值还是原来的地址数组读取不到内容现象write(a[2])输出的不是a[2]的值而是一个很大的数像是把地址本身打印出来了。原因IND指令的实现写成了top : top - 1; s[top] : s[s[top]];但你的解释器top本来就指向栈顶元素而不是下一空闲槽位。减一次top后读到的根本不是目标地址内容而是把地址低位值当数据用了。解决先在你的interpret函数里找到LIT和LOD的实现确认这个版本的top到底指向哪。LIT的写法如果是top : top 1; s[top] : num;说明top指向栈顶元素如果写法是s[top] : num; top : top 1;说明top指向下一空闲位。按同一个约定重写IND和STOIND的栈操作。5.4 保留字查找不匹配REPEAT和UNTIL永远进不了词法关现象词法层似乎没改成功REPEAT始终被当成普通标识符报“IDENTIFIER UNDECLARED”。原因修改保留字数组时只把新词加到了数组末尾却没有把保存字数量常量增大或者getsym查找用二分/固定长度循环长度变量没有同步。还有一种是原版把所有保留字用小写存储而你的测试程序里写了REPEAT大写导致字符串比较不等。解决检查关键字数量常量、数组上限两处并确认测试程序中关键字大小写与源码约定一致。原版PL/0多数是大小写敏感的课程设计阶段不要为了迁就用户去改大小写直接把测试程序写对。如果在Java版里维护了一个HashSet还要看它是否有初始化容量限制。5.5 dump指令时新指令显示为空或程序跑到一半停止现象生成P-code数组后打印指令列表新增的IND列空白或者解释器报“unknown opcode”。原因操作码助记符数组长度没跟上新增指令数量打印时访问越界返回空串或者解释器里没有增加对应case导致默认错误分支触发停机。解决把指令助记符数组长度增加三条保持下标与opcode枚举一一对应。然后给解释器的case分支补上IND和STOIND两个分支。如果用的是Java的switch表达式注意每个case都要处理栈指针top的增减不要只处理s数组内容。6. 回归验证和一个小技巧怎么证明你的PL/0没被改坏功能做完了最需要的一件事是“回归验证”。PL/0改动往往会牵连到原有语法比如数组改动很容易影响普通变量声明。我自己的习惯是保留一份原版PL/0可执行文件先把原版能跑的几个样例程序全部跑一遍得到一份标准输出然后在你改完的版本上跑同一个输入输出必须完全一致。这一步能筛掉大部分隐藏的回归性错误。如果你手头没有原版样例可以用下面这个程序自测它同时用到变量声明、表达式优先级、WHILE循环、数组读写四类功能任何一个层次断掉都会在结果里显形。var a[10], i, sum; begin i : 0; while i 10 do begin a[i] : i * i; i : i 1 end; sum : 0; i : 0; while i 10 do begin sum : sum a[i]; i : i 1 end; write(sum) end.这个程序会输出0到9的平方和285。如果输出不对再用dump指令的辅助工具定位procedure dumpCode(startAddr, endAddr: integer); var i: integer; begin for i : startAddr to endAddr - 1 do writeln(i:4, , mnem[code[i].f]:4, , code[i].l:1, , code[i].a:4); end;在解释执行前调用这个dump子程序把整个code数组打印出来。人工检查数组元素访问的序列应当能看到先压下标、压基址、加、IND的连续四条指令顺序不对就说明factor里的生成逻辑有问题。REPEAT改造则检查JPC目标的地址是否等于循环体第一条指令的下标。另一个我常用的小技巧是给解释器做一个“单步模式”。在interpret主循环的每条指令执行前增加一个debug开关打印当前pc、栈指针top以及栈顶附近几个元素。编译出错或者数值异常时不要从头到尾跑一遍而是在可疑指令之前打开单步一行一行看栈内容怎么变。大多数栈错乱问题在单步模式下三分钟内就能定位比反复加print语句快得多。这个调试思路对任何语言实现的版本都有效。做PL/0扩充这门课设我个人最大的教训是永远不要相信“这条指令肯定没问题”。跳转地址差一个数、栈方向反一个方向表现都是结果错乱而不像高级语言那样给你一个异常。每一步小改动都要配一个最小测试样例宁可多写十个小的也别憋一个大程序最后对了就交差了。希望这次整理的改法和踩坑记录能帮你少走点弯路把这门课设变成真正读懂编译器的入口。本文还有配套的精品资源点击获取