news 2026/10/16 19:00:01

PL/0扩充实战:从REPEAT循环到数组下标访问的完整指南

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
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.

这段程序计算5+4+3+2+1,正确输出应该是15。如果输出结果不对,优先打印生成的P-code,看看REPEAT开头的指令地址和JPC的目标地址是否一致。也可以用WHILE等效改写同一个程序来对照结果。如果WHILE版本正确而REPEAT版本错误,问题一定集中在JPC的跳转目标或栈顶条件判断上。

4. 纵深扩充:给PL/0增加数组与下标访问(新指令IND和STOIND)

4.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扩充这门课设,我个人最大的教训是:永远不要相信“这条指令肯定没问题”。跳转地址差一个数、栈方向反一个方向,表现都是结果错乱,而不像高级语言那样给你一个异常。每一步小改动都要配一个最小测试样例,宁可多写十个小的,也别憋一个大程序最后对了就交差了。希望这次整理的改法和踩坑记录能帮你少走点弯路,把这门课设变成真正读懂编译器的入口。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/16 0:28:24

用Python实现本地模拟横向联邦学习:从FedAvg到单机可复现实验

简介&#xff1a;面向机器学习初学者与联邦学习研究者的横向联邦学习本地模拟资源&#xff0c;使用Python构建&#xff0c;以公开数据集搭建可复现的单机模拟环境&#xff0c;让没有分布式集群的读者也能体验多客户端协作建模的完整过程。资源从数据加载、模型定义、客户端本地…

作者头像 李华
网站建设 2026/10/15 14:21:11

O2O平台CRM系统架构设计:从线索公私海到平台化落地

简介&#xff1a;美团O2O的CRM系统架构设计.doc 以美团 CRM 为样本&#xff0c;系统拆解 O2O 平台如何借助客户关系管理增强线下资源控制与服务品质。资源面向产品经理、B端运营及电商架构师&#xff0c;适合需要理解销售线索管理、运营中台、数据决策支持和移动办公场景的读者…

作者头像 李华
网站建设 2026/10/12 16:55:27

7针SPI OLED改I2C实战:硬件跳线+驱动适配全指南

1. 项目概述&#xff1a;为什么一块7针SPI接口的OLED屏&#xff0c;非要“改”成I2C用&#xff1f;你手头有一块常见的0.96英寸SSD1306驱动的单色OLED模块&#xff0c;背面丝印清清楚楚写着“7PIN SPI”&#xff0c;引脚排列是VCC、GND、SCL、SDA、RES、DC、CS——注意&#xf…

作者头像 李华