简介:针对编译原理课程设计中 PL/0 语言的功能扩充需求,提供了一套可直接运行的完整实现。项目在经典 PL/0 编译器基础上新增 if-then-else 条件分支、do-while-until 循环以及 for-to/downto-do 两类步进循环语句,其中 for 循环步长分别为 +1 和 -1,同步扩展了词法、语法分析与目标代码生成逻辑,适合编译原理课程设计及编译器扩展学习者。压缩包共 18 个文件,约 62KB,以 C 源码和头文件为核心,配合多组 txt 测试用例,另有编译生成的 exe 可执行程序及少量临时文件,可直接编译运行并验证新增语法。目前已有 775 人学习下载,说明该方案对同类课题具有较强参照价值。资源内部按功能组织测试输入,覆盖 if 分支、do-while 循环、for 递增/递减循环等场景,便于对照源码理解新增语法在分析过程中的处理细节,可帮助读者快速定位仿写要点,独立完成课程设计。 每个学期总有一批人被“编译原理课程设计”这几个字按在地上摩擦,而其中最经典的一道题,就是给经典的PL/0语言做扩充。我当年拿到这个题目时,第一反应是“这不就加几个语法嘛”,结果打开源码之后彻底傻眼——词法、语法、语义和代码生成四个模块搅在一起,牵一发而动全身,改哪个都不是。后来花了整整一周把整个编译器从头到尾读了一遍,才总算摸到了门道。
这篇文章就围绕PL/0语言扩充这件事,把我实际做过的完整思路、踩过的坑、以及真正好用的实现顺序全部梳理一遍。无论你是刚拿到题目还没动手,还是已经改出几个Bug卡住了,这篇文章都能给你一个可以直接落地的参考方案。
1. 拿到题目先别写代码:PL/0的底盘究竟长什么样
1.1 一个极简却五脏俱全的编译器
PL/0语言是Pascal作者Wirth当年在《Algorithms + Data Structures = Programs》里提出的一个教学用迷你语言,专门用来演示编译器的完整工作流程。它短小到什么程度呢?整个语言的语法规则不到20条,数据类型只有整型,保留字也就十来个,没有数组、没有浮点数、没有字符串。但你别看它小,一个真实编译器该有的核心模块全都有:词法分析、递归下降语法分析、符号表管理、目标代码生成,以及一个栈式虚拟机运行时。
它的整体编译方式是“一遍扫描”的。词法分析器识别出一个Token,语法分析器立刻决定下一步怎么推导,代码生成器接着就把对应的P-代码(一种面向栈式虚拟机的伪汇编指令)发出去。三个过程交替进行,而不是像很多教材讲的那样“先全部分词,再全部分析语法”。对于课程设计来说,一遍扫描的设计让你改起来非常爽,但也有个代价:你必须在脑子里始终记得“当前这个语法分析的递归函数到底调到了哪一层”,不然很容易改晕。
1.2 从语句到P-代码的联动链路
PL/0的目标代码是运行在一个非常简单的栈式虚拟机上,指令集非常有限,常用的无非就是这些:
LIT:把常量压入栈LOD:把局部变量或全局变量的值压入栈STO:把栈顶的值存到某个变量JMP:无条件跳转JPC:条件跳转(栈顶条件为假时跳转)OPR:执行算术运算、比较运算和栈操作CAL、INT、RET:过程调用、分配栈空间、返回
你写的PL/0源码最终就是翻译成这一串P-代码,然后交给虚拟机执行。这里最关键的一点是:语句的结构如何,决定了目标代码的跳转结构。比如if语句生成的代码里一定有条件跳转,while循环生成的代码里一定有无条件跳转配合条件跳转。一旦你理解了每个语句结构的“代码模板”,扩充的时候其实就是往这些模板里插入新的模板。
符号表和这个过程的联动也很重要。词法分析器遇到一个标识符时,会立刻去符号表里查这个标识符是常量、变量还是过程;语法分析器生成LOD/STO指令时,用的地址就是符号表里记录的变量地址和层级偏移。所以符号表并不是一个孤立的“数据存储”,它直接决定了最终目标代码的可寻址性。想扩充语言,符号表是躲不开的一环。
2. 扩充一个语法特性,为什么必须动四层代码
很多人改PL/0最常犯的错误是:只在词法分析里加了一个新的保留字,然后编译一跑,直接报语法错误。原因很简单,你把一个单词识别出来了,但语法分析器的“语句解析函数”根本不认识它,就像你给一个团队介绍了一位新同事,却没告诉任何人他负责什么岗位。真正的扩充,至少要同时改到词法、语法、语义/符号表、代码生成这四个层面。
2.1 词法层:保留字不是随便加的
PL/0词法分析器的实现方式通常是:读取一个标识符后,在保留字表里逐一比对。如果匹配上就返回对应的保留字Token,否则就当作普通标识符处理。
如果你要扩充for循环,首先要干的事就是把for、to、downto这几个保留字塞进保留字表。注意,这里有个小坑:原版PL/0中,标识符和保留字的“判定方式”是共用一个识别流程的,如果你只往保留字表里加了for,但没在Token枚举类型里新增对应的类型,那么词法分析器即使识别出了for,也不知道该返回什么类型给你。
一个常见的做法是在Token枚举里新增:
typedef enum { IDENT, NUMBER, CONST, VAR, PROCEDURE, IF, THEN, ELSE, WHILE, DO, FOR, TO, DOWNTO, // 新增 ASSIGN, CALL, BEGIN, END, READ, WRITE, PLUS, MINUS, TIMES, SLASH, EQ, NEQ, LT, LEQ, GT, GEQ, LPAREN, RPAREN, SEMICOLON, PERIOD } TokenType;词法分析器还要注意一点:for这样原本合法的标识符,一旦被设为保留字后,用户就不能再用它做变量名了,否则词法阶段就会当成保留字返回。大多数教学实现都不处理这个冲突会直接报错,这没问题,但你要知道这是“语义错误”,不是词法错误。
2.2 语法层:递归下降分支的分流逻辑
PL/0采用的是递归下降分析法,解析语句的入口函数大概是这样的逻辑:
void statement() { if (当前Token是IDENT) { // 赋值语句:id := expr } else if (当前Token是CALL) { // 调用语句 } else if (当前Token是BEGIN) { // 复合语句 } else if (当前Token是IF) { // if语句 } else if (当前Token是WHILE) { // while语句 } else if (当前Token是READ) { // read语句 } else if (当前Token是WRITE) { // write语句 } }如果你要给语言加for循环,就要在这个statement()函数里新增一个else if (当前Token是FOR)分支,然后写一个专门解析FOR语句的递归函数。这和很多同学想象的“改一个地方就可以了”完全不同——你在词法层加一个保留字,相当于给这个if-else分流逻辑增加了一个“入口标记”,然后在语法层真正把这个标记对应的解析规则写出来。
2.3 语义与代码生成层:语义落地的关键
词法和语法层面搞定后,for循环只是“能被识别、能被解析”,但它要能真正执行,必须生成正确的目标代码。这就是代码生成层的工作。
语义层面的问题,比如“控制变量必须是变量,不能是常量”“初值和终值表达式必须合法”等,也要在语法分析和代码生成的过程中一起处理。例如在解析for id := expr1 to expr2 do statement时,你需要先确认id在符号表里是一个变量类型的标识符,而不是常量或过程名,否则应该报错。
很多人改到这里就会问:为什么不能偷个懒,直接把for循环翻译成一个while循环再生成代码?从原理上说,for和while在语义上可以互相转换,但直接编译成while会丢失for自身的语义特点,比如循环结束后控制变量的值、比如终值的求值时机。课程设计往往需要你展示新的代码生成模板,所以不建议这么偷懒。
3. 完整走一遍FOR循环扩充:从保留字到目标代码
接下来进入正题。我以经典的for循环为例,把一条扩充路完整走一遍。为什么选for?因为它比repeat这种简单循环稍微复杂一点,又不像case语句那样需要引入大型跳转表,刚好卡在“有难度但能讲清楚”的黄金档位。
3.1 词法与语法改造
假设我们要扩充的语法是:
for id := expr1 to expr2 do statement for id := expr1 downto expr2 do statement词法部分,在保留字表里加入for、to、downto,并在Token枚举中新增对应的Token类型,这一步我在上一节已经演示过了。
语法部分,在statement()里加一个分支,然后写一个forStatement()函数。伪代码如下:
void forStatement() { match(FOR); ident = parseIdent(); // 解析控制变量名 match(ASSIGN); expression(); // 解析初值表达式 expr1 emitStore(ident); // 把初值存入控制变量 if (lookahead == TO) { direction = UP; match(TO); } else if (lookahead == DOWNTO) { direction = DOWN; match(DOWNTO); } else { error("expected TO or DOWNTO"); } expression(); // 解析终值表达式 expr2 emitStore(tmpVar); // 存入临时变量 int L1 = newLabel(); int L2 = newLabel(); emitLabel(L1); emitLoad(ident); emitLoad(tmpVar); if (direction == UP) emitOp(LE); // 若 i <= tmp 则继续 else emitOp(GE); // 若 i >= tmp 则继续 emitJpc(L2); // 条件为假跳转到循环结束 statement(); // 循环体 emitLoad(ident); emitConst(1); if (direction == UP) emitOp(ADD); else emitOp(SUB); emitStore(ident); emitJmp(L1); emitLabel(L2); }这里有一个很巧妙的地方:for循环的初值表达式赋值发生在进入循环之前,这没有问题;但终值表达式也必须提前求值并保存到临时变量中。为什么要提前保存?因为如果终值是一个复杂的表达式,里面有函数调用或变量变化,标准的高级语言语义规定for循环的终值只求值一次,之后在每次迭代中直接使用这个已经保存的值。如果你把这个临时变量的保存省略了,直接在每次循环比较时重新求值终值表达式,那遇到for i := 1 to i + 10这种语句,终值每次都会变,循环就变成死循环或异常跳出了。这个问题不只在课程设计中,在很多真实编译器里也是一个经典的语义陷阱。
3.2 设计目标代码模板:先画跳转,再写代码
在你开始写代码生成之前,我强烈建议先把目标代码的模板在纸上画出来。不要嫌这个步骤多余,我见过太多人一上来就写emit,结果跳转标签搞成一团乱麻,最后也不知道哪段代码是循环体。
以for i := 1 to 10 do write(i);为例,设控制变量i的地址为0,临时变量地址为1,生成的目标代码大致长这样:
LIT 0 1 ; 常量1入栈 STO 0 0 ; i := 1 LIT 0 10 ; 常量10入栈 STO 0 1 ; tmp := 10 L1: LOD 0 0 ; 取i LOD 0 1 ; 取tmp OPR 0 11 ; 比较 i <= tmp JPC 0 L2 ; 若不满足则跳转到L2 ; ---------- 循环体开始 ---------- LOD 0 0 ... (write的代码) ; ---------- 循环体结束 ---------- LOD 0 0 LIT 0 1 OPR 0 2 ; 加法 STO 0 0 ; i := i + 1 JMP 0 L1 ; 跳回循环开始 L2:注意OPR指令后面跟的操作编号在不同版本里可能有细微差异,比如有的教材里OPR 0 9是小于,有的版本里11是小于等于,你只要对照自己的虚拟机实现即可。
画完模板你就会发现,for循环和while循环的本质区别在于:for循环额外多出了“初始化”“自增/自减”“终值判断”三段逻辑,而且while只在循环入口检查条件,for在入口检查条件的同时还要在循环体结束后做变量更新。这个区别会直接影响你生成代码的跳转布局,也直接体现了“为什么简单的‘把for翻译成while’不是最优雅的扩充方案”。
3.3 downto方向和嵌套循环的处理
downto方向的处理其实非常简单,只需要把比较指令换成GE(大于等于),把自增改成自减。很多同学在这个地方踩坑,是因为分不清“比较符号的方向”和“自增自减的方向”到底该由谁决定。这里有个判断技巧:循环的方向决定步进方向,比较符的方向决定循环继续的条件——向上循环的条件是控制变量在终值之下,所以判断“小于等于”;向下循环的条件是控制变量在终值之上,所以判断“大于等于”。
嵌套循环的难点则在于标签管理。如果你的代码生成器是用一个全局整型变量来编号标签,那嵌套循环其实不需要额外处理,因为每个标签都是唯一的。真正容易出问题的是伪代码里的emitStore(ident),如果控制变量和内部临时变量的地址解析在嵌套作用域里出错了,那生成出来的LOD/STO指令就可能存错层级。
我的经验是:标签一律用一个自增计数器管理,不要用循环深度去拼标签名,否则嵌套一深,同名标签互相覆盖,跳转就全乱了。PL/0本身没有continue和break,所以不需要额外的嵌套层级信息去匹配跳转,但如果你后续要加这两个语法,就要在循环解析函数里维护一个“标签栈”了。
4. 符号表、错误恢复与作用域:不止是“加个关键字”
如果说上一节是“把新语法跑通”,那这一节就是回答“怎么跑得稳”的问题。这也是课程设计答辩时,老师最喜欢深挖的地方。
4.1 符号表表项怎么设计才够扩展
经典PL/0的符号表项一般长这样:
typedef struct { char name[20]; int kind; // 0: 常量, 1: 变量, 2: 过程 int level; // 嵌套层号 int value; // 常量值 int address; // 变量在栈帧中的偏移地址 int size; // 过程活动记录大小 } Symbol;如果你只是加for循环,这个表项其实够用,因为控制变量和临时变量都还是普通的整型变量。但如果你同时要加数组,那问题就来了:数组需要知道下界、上界、维数,甚至每一维的长度,这些信息放哪里?很多实现的做法是给符号表项新增字段,或者在符号表后面挂一个“数组维度表”。我的建议是,除非你的题目明确要求加数组,否则不要一上来就把表项改得太重,否则原有的代码生成逻辑会全面崩盘。
一个折中的方案是给符号表项增加一个type字段,用来区分整型、Bool、数组等类型。这样就算这次课程设计用不上,你后续扩充实数和布尔类型时也能少改很多地方。
4.2 编译错误的三级分类与报错体验
PL/0原版的错误处理非常原始,基本是“遇错就停”。这对一个课程设计来说不是不行,但答辩时如果老师让你编译一个含多个错误的程序,而你只报第一个错误,体验会差很多。我当时给自己定了一个改进方案:把错误分成三级来报。
- 词法错误:比如非法字符、数字后面紧跟字母。
- 语法错误:比如
for后面没跟id、to后面没有表达式、do缺失。 - 语义错误:比如控制变量未声明、控制变量是常量、终值表达式里用了未声明的标识符。
每一类错误都要带行号和错误编号。行号这东西看起来简单,实际实现时就是个计数器,在词法分析器每次读到换行符时加一,然后把当前行号随Token一起传到语法分析器。这样,代码生成器报错的时候也能指明行号。
这里有个容易踩坑的点:当你在一处语法错误发生后,是否继续解析后续代码?如果你继续解析,递归下降的同步恢复机制就会变得复杂,比如statement()里解析for出错了,你得飞快地跳过一堆Token到下一个分号或end,否则后面全是连锁报错。很多课程设计都不会做得很完善,但你要在文档里说明这个取舍,这也是老师爱问的“你的错误恢复策略是什么”。
4.3 过程嵌套与作用域:为什么层号和偏移要一起算
PL/0是支持过程嵌套的,这决定了符号表绝不只是一个“名字-类型”的映射表,而是一个带有“层号”的层次字典。当一个变量被LOD引用时,需要根据当前的静态层(过程层)和目标变量所在层的层差,来生成LOD 层差 偏移这样的目标地址。
新的for循环语句如果出现在一个嵌套过程里,控制变量的LOD/STO指令同样要遵守这个层差规则。我见过一个同学直接把控制变量当成全局变量来处理,用LOD 0 地址,结果嵌套过程里操作全局变量和局部变量的层差全错了,程序运行时莫名其妙访问了错误的内存单元。
这个问题的解决思路是:在符号表里记录每个变量的level和address,代码生成时用当前过程层号减去目标变量层号得到层差。如果你对层差的计算逻辑还不太熟悉,建议先用几个小的测试用例手动推演一遍P-代码,把每个LOD指令的层差都算出来,再跑程序核对,比单纯看代码理解快得多。
5. 怎么证明你的扩充是对的:测试用例与验收答辩
课程设计的最后一步,往往不是“代码能跑”而是“你能证明代码是对的”。很多同学写完扩充功能就松了一口气,结果一演示就翻车。测试阶段有一个清晰的方法论,能省掉你大量现场解Bug的时间。
5.1 从功能、边界、错误三条线设计测试用例
测试for循环扩充,建议至少覆盖下面这几类用例:
- 功能正确性:
for i := 1 to 10 do ...,验证循环次数是10次,i的值从1递增到10。 - 边界条件:
for i := 5 to 5 do ...,循环体应执行1次;for i := 3 to 1 do ...,循环体应执行0次。 - 方向翻转:
for i := 10 downto 1 do ...,验证步进方向正确。 - 嵌套组合:
for循环里嵌套if、while、另一个for,验证标签跳转没有错乱。 - 语义错误:
for 3 := 1 to 5 do ...(控制变量是常量)应报语义错误;for x := 1 to 5 do ...(x未声明)应报语义错误。 - 缺关键字错误:
for i := 1 5 do ...(缺少to)应指出具体行号和错误编号。
每一类用例跑完后,建议把目标代码保存下来,对照你当初画的模板用手工推演一遍,确认跳转目标都落在正确的标签位置。这样即使运行结果错了,你也能快速定位是“逻辑模板错”还是“执行期错”。
5.2 目标代码级别的检查方法
我调试PL/0时最常用的技巧是:打开编译器的中间代码输出开关,看生成的P-代码序列。直接用眼睛盯着那几十行指令,一步一步模拟虚拟机的栈变化,比在代码里打一堆printf有效得多。
比如你的for循环如果运行结果是死循环,快速排查方法就是看JMP和JPC的跳转目标是否落在循环开始标签之前。如果你发现JMP跳到了循环体内部,那八成是生成循环体代码时标签编号没有正确弹出。又比如循环只执行了一遍就退出,优先检查LOD控制变量和LOD临时变量的地址是否相同——如果地址相同,很可能你忘了给临时变量在符号表里分配独立的地址,导致终值被覆盖。
5.3 答辩高频问题与我的回答思路
最后简单说一下答辩时老师大概率会问的几个问题,以及我当时的回答思路,供你参考:
- 为什么终值要保存到临时变量,不能直接比较控制变量和表达式?答:因为高级语言语义要求
for循环的终值只求值一次。如果每次循环都重新计算终值,终值表达式里的变量一旦在循环体中被修改,循环次数就会变得不可预测,与语言规范不符。 - 你的
for循环和课前的while循环在目标代码上有什么区别?答:while循环没有初始化代码和循环变量更新代码,只有“条件判断-循环体-跳回”三个部分;for循环多了初始化、步进和终值比较,并且循环体的更新位置在循环体执行完之后、跳回标签之前。 - 如果循环体内改变了控制变量的值,会发生什么?答:如果语言规范不允许修改,应该在编译期禁止对控制变量赋值;如果规范允许,运行时控制变量会被用户修改,循环次数会受影响。我在实现时选择直接禁止在循环体内对控制变量赋值,更为严谨。
- 你的符号表结构支持新增的临时变量吗?答:支持,临时变量在符号表里作为普通变量项登记,但名字用系统生成的唯一标识符(如
tmp1、tmp2),不会与用户标识符冲突。
整个过程走下来,你会发现最耗时间的往往不是写新代码,而是理解原版PL/0编译器里那套已经跑通的“编译链路”。把它想成是一条流水线,你要做的是在某个工位旁边新增一个加工步骤,而不是把整条流水线拆掉重装。等你真正跑通一个for循环,再回头去看repeat、去看case,都会觉得格外顺。这个项目对我来说最大的收获,是从这一条极简的流水线上,真正看清了“语言设计”和“编译器实现”是如何互相牵制的。
本文还有配套的精品资源,点击获取