☰
PL/0编译器扩充实战:从else到数组的增量修改指南
2026/10/13 4:33:36 网站建设 项目流程

简介:这份资源围绕编译原理课程设计展开,面向正在学习编译原理、需要动手实践编译器构造的高校学生与Pascal语言爱好者,帮助读者在PL0这一Pascal简化语言的基础上完成词法分析、语法分析、语义分析、中间代码生成、代码优化与目标代码生成等核心环节的扩充与修改。压缩包共6个文件,包含3个txt测试用例、1个cpp源程序、1个h头文件以及1份docx课程设计说明书,整体约146KB,体积轻便,便于快速搭建实验环境并对照文档理解各模块实现思路。目前已有1433人学习下载,说明其在同类课程设计中具有一定参考价值。读者可借助源程序与说明文档,掌握解析算法改进、错误处理机制完善以及新增语法特性等实践方法,同时通过测试文件验证编译器行为,逐步培养严谨的逻辑思维与软件工程能力,为后续系统级编程打下基础。

1. 从 PL/0 到能跑的小语言:为什么值得动手改一遍编译器

很多人第一次接触编译原理,绕不开 PL/0。它足够小,小到词法、语法、语义分析、代码生成、虚拟机执行能塞进几百行里;但它又足够完整,完整到你能亲手把一门语言从源码一路推到栈式虚拟机上跑出结果。问题也恰恰出在“太小”上:没有else、没有for、没有数组、没有过程参数、没有返回值,写个稍微像样的程序就处处掣肘。于是“对 PL/0 语言及其编译器进行扩充和修改”就成了一个非常典型的进阶练手方向——它不是让你重写一门新语言,而是让你在已有骨架上做增量手术,每加一个特性,都要同时改词法、语法、符号表、代码生成和虚拟机五处,任何一处漏掉,程序就会在运行时给你一个莫名其妙的栈错误。

这篇文章面向的是已经读过 PL/0 源码、能编译运行原始版本,但想把它改造成“能写点真东西”的从业者或高年级学生。我会按“先立住原理、再动手复现”的顺序,把扩充过程中最常被加的几个特性——else分支、for循环、过程带参数、数组、以及一个简单的return——拆成可抄作业的步骤。每一处都给出关键代码块、参数含义和失败时该看哪里。中间会专门用一章讲避坑,因为我自己在这上面翻过车,有些坑不提前说,你大概率要花一整个下午去查栈帧。

2. 扩充前先把 PL/0 的骨架摸清楚:五个必须同时改的地方

2.1 原始 PL/0 的编译执行链路

PL/0 的经典实现是一条直线:词法分析器把字符流切成 token,语法分析器用递归下降识别程序结构,同时在分析过程中生成类 P-code 的中间指令,最后交给一个栈式虚拟机解释执行。符号表在语法分析阶段填充,用来记录变量、常量和过程的层级、地址与大小。整个过程没有独立的语义分析阶段,语义检查是嵌在语法分析里的,比如“变量未声明就使用”这种错误,是在expression或statement处理时查符号表发现的。

这条链路决定了你每加一个语言特性,改动会同时落在至少五个位置:词法要认识新关键字或新符号;语法要新增产生式;符号表要能记录新实体的属性;代码生成要发出正确的指令序列;虚拟机要能执行这些指令。只改其中一两处,编译可能通过,但运行一定出错。常见做法是先把要加的特性列成一张表,标出这五处分别要动什么,再动手写代码。

2.2 符号表结构决定了你能加什么

原始 PL/0 的符号表通常是一个固定大小的数组,每个条目记录name、kind(变量/常量/过程)、level、addr、size。这个结构对变量和常量够用,但一旦要加“过程带参数”,就不够了——你需要记录形参个数、形参在栈帧中的偏移、以及调用时如何传参。如果还要加数组,size字段的含义就从“占用几个栈单元”变成“元素个数 × 元素宽度”,并且需要额外的类型标记。

我的建议是先把符号表条目扩成结构体,至少包含:名字、种类、层级、地址、大小、类型标记、形参个数。不要试图用原来的平行数组硬撑,改到后面你会分不清哪个数组对应哪个属性。下面是一个常见的扩展结构,用 C 描述:

typedef struct { char name[MAX_NAME_LEN]; // 标识符名称 int kind; // 0=常量 1=变量 2=过程 3=数组 int level; // 声明所在层级 int addr; // 变量为栈偏移,过程为入口地址 int size; // 变量占1,数组为元素个数,过程为形参个数 int type; // 0=整型 1=数组,用于区分 } Symbol;

逻辑说明:kind区分实体类别,level用于跨层级查找,addr在变量和过程上含义不同但都必要,size在数组场景下表示元素个数,type用来在代码生成时决定是取单值还是取基址。参数说明:MAX_NAME_LEN按你的词法缓冲区大小设,一般 16 或 32 足够;kind和type不要合并,合并后数组和变量在过程参数传递时容易混淆。

2.3 虚拟机指令集要预留扩展位

原始 PL/0 虚拟机的指令通常有LIT、LOD、STO、CAL、INT、JMP、JPC、OPR这几类。加else只需要多用JMP和JPC,不用改指令集;加for也类似,用现有跳转就能实现;但加数组和过程参数时,往往需要新增指令,比如取数组元素地址的LDA、按地址加载的LDI、按地址存储的STI。如果你不想动虚拟机,也可以用LIT加偏移配合LOD模拟,但代码会变得很难读。

我一般会预留几个操作码,比如在OPR里增加OPR_ARR_LOAD和OPR_ARR_STORE,或者在指令枚举末尾直接加LDA、LDI、STI。这样虚拟机主循环里加几个case就能支持数组。下面是一个指令枚举扩展示例:

typedef enum { LIT, LOD, STO, CAL, INT, JMP, JPC, OPR, LDA, // 取数组元素地址:栈顶为基址,次栈顶为下标 LDI, // 按地址间接加载 STI, // 按地址间接存储 RET // 过程返回,配合参数清理 } OpCode;

逻辑说明:LDA把“基址 + 下标”算成绝对地址压栈,LDI和STI再根据这个地址读写。参数说明:LDA执行时栈顶是下标,次栈顶是数组基址,结果地址压回栈顶;LDI弹出地址,把该地址内容压栈;STI弹出地址和值,把值写入地址。注意栈的生长方向,如果原始虚拟机是向上生长,地址计算要用加法,向下生长则用减法,这一点在写LDA时必须和INT指令的栈调整保持一致。

3. 加 else 和 for:用现有跳转指令就能落地的两个特性

3.1 else 分支的语法与回填

原始 PL/0 的if语句只支持if 条件 then 语句,没有else。加else的语法产生式很直接:if 条件 then 语句 else 语句。难点不在识别,而在代码生成时的跳转回填。当解析到then后面的语句时,你并不知道后面有没有else,所以需要先发一条JPC跳过 then 分支,等看到else再回填跳转地址。

常见做法是在语法分析函数里维护一个“跳转链”,或者用简单的回填变量。下面是一个递归下降里的处理片段:

void if_statement() { int jpc_idx, jmp_idx; expression(); // 解析条件 jpc_idx = gen_jpc(); // 条件为假时跳转,地址待回填 statement(); // then 分支 if (token == ELSE) { jmp_idx = gen_jmp(); // then 分支结束跳转到 else 之后 backpatch(jpc_idx, next_addr()); // 回填 JPC 到 else 开始 get_token(); statement(); // else 分支 backpatch(jmp_idx, next_addr()); // 回填 JMP 到整个 if 之后 } else { backpatch(jpc_idx, next_addr()); // 没有 else,JPC 直接跳到 if 之后 } }

逻辑说明:gen_jpc生成一条条件跳转指令并返回其地址,gen_jmp生成无条件跳转,backpatch把之前预留的跳转目标改成当前指令地址。参数说明:jpc_idx和jmp_idx是代码数组下标,next_addr()返回下一条待生成指令的位置。注意backpatch的时机:有else时,JPC要跳到else分支的第一条指令,而不是JMP之后;没有else时,JPC跳到整个if之后。这两个目标地址差一条JMP,写错会导致 then 分支执行完又掉进 else。

3.2 for 循环的展开与边界检查

加for循环有两种做法:一种是把它当语法糖,展开成while形式;另一种是生成专门的循环指令。我推荐第一种,因为不用改虚拟机。for i := a to b do 语句可以展开为:先给i赋a,然后每次循环前判断i <= b,执行循环体,最后i := i + 1跳回判断。展开后的代码生成逻辑如下:

void for_statement() { int loop_start, jpc_idx; get_token(); // 跳过 for int var_addr = find_symbol(token); // 循环变量地址 get_token(); // 跳过变量名 get_token(); // 跳过 := expression(); // 初值 gen_sto(var_addr); // 存入循环变量 loop_start = next_addr(); // 循环判断起点 gen_lod(var_addr); // 取循环变量 expression(); // 终值 gen_opr(OPR_LE); // 比较 i <= 终值 jpc_idx = gen_jpc(); // 假则跳出循环 get_token(); // 跳过 do statement(); // 循环体 gen_lod(var_addr); // 取循环变量 gen_lit(1); // 常量 1 gen_opr(OPR_ADD); // i + 1 gen_sto(var_addr); // 存回 gen_jmp(loop_start); // 跳回判断 backpatch(jpc_idx, next_addr()); // 回填跳出地址 }

逻辑说明:gen_lod生成取变量指令,gen_sto生成存变量指令,gen_opr生成算术或比较操作,gen_jmp生成无条件跳转。参数说明:var_addr是循环变量在栈帧中的偏移,loop_start是判断条件的指令下标,jpc_idx是条件为假时的跳转地址。注意循环变量必须在当前作用域已声明,且终值表达式在每次循环时重新求值——如果你希望终值只求一次,需要在循环前把终值存到一个临时变量里,否则循环体修改了终值表达式里的变量,循环次数会变。

3.3 两个特性一起加时的符号表冲突

else和for本身不冲突,但如果你在for循环体里声明局部变量,而原始 PL/0 的过程和块结构又比较简单,就可能出现符号表层级混乱。常见现象是:循环体里声明的变量在循环外还能访问,或者循环变量被循环体里的同名变量覆盖。原因在于原始 PL/0 的块结构只在过程入口和出口调整符号表,for和if不形成新作用域。

解决方式有两种:一是明确规定for和if不引入新作用域,循环体里的变量声明直接报错;二是给for和if也加上作用域层级,在进入时记录符号表栈顶,退出时回退。我一般选第一种,因为改动小,而且和原始 PL/0 的块结构一致。如果你确实需要块内局部变量,那就得把符号表改成栈式结构,并在statement入口和出口做压栈和弹栈。这一步改动量不小,建议先把else和for跑通,再考虑作用域扩展。

4. 过程带参数和数组:真正需要动符号表和虚拟机的两个硬骨头

4.1 过程参数传递的栈帧布局

原始 PL/0 的过程调用CAL只记录返回地址和静态链,没有参数。加参数需要约定参数传递方式:是值传递还是引用传递,参数放在调用者的栈里还是被调用者的栈里。常见做法是值传递,参数由调用者在调用前压栈,被调用者通过静态链和参数偏移访问。栈帧布局大致是:调用者压入参数,CAL压入返回地址和静态链,被调用者用INT调整局部变量空间。

符号表里过程条目需要记录形参个数,形参本身作为变量条目记录在过程的符号表层级里,地址从 3 开始(因为返回地址、静态链、旧栈顶各占一个位置)。下面是一个调用处理的代码片段:

void call_statement(int proc_idx) { int i; for (i = 0; i < sym_table[proc_idx].size; i++) { expression(); // 依次解析实参 } gen_cal(sym_table[proc_idx].level, sym_table[proc_idx].addr); }

逻辑说明:sym_table[proc_idx].size是形参个数,循环解析实参并压栈,最后生成CAL指令。参数说明:level和addr用于虚拟机建立静态链和跳转。注意实参个数必须和形参个数一致,否则栈帧会错位;如果支持默认参数,需要在符号表里记录默认值,并在实参不足时补压。原始 PL/0 没有类型检查,加参数后最好在语法分析时检查实参个数,不匹配直接报错,不要留到运行时。

4.2 数组的地址计算与越界检查

数组是另一个需要动虚拟机的特性。假设数组声明为var a[10],符号表里size记 10,type记数组。访问a[i]时,代码生成需要先取数组基址,再取下标,用LDA算出元素地址,再用LDI加载。赋值a[i] := expr则用LDA算地址,求值后STI存储。下面是一个数组元素加载的代码片段:

void array_load(int arr_idx) { gen_lod(sym_table[arr_idx].addr); // 取数组基址 expression(); // 解析下标 gen_lda(); // 基址 + 下标 -> 元素地址 gen_ldi(); // 按地址加载 }

逻辑说明:gen_lod把数组基址压栈,expression把下标压栈,gen_lda弹出基址和下标,计算绝对地址并压栈,gen_ldi弹出地址并加载内容。参数说明:arr_idx是数组在符号表中的下标,addr是数组基址在栈帧中的偏移。注意下标越界检查:可以在LDA之前插入比较指令,判断下标是否在0到size-1之间,越界则跳转到错误处理。如果不做检查,越界写会破坏栈上其他变量,这种错误在运行时很难定位,属于典型的“血泪经验”。

4.3 过程返回值的处理

原始 PL/0 的过程没有返回值,加return需要约定返回值放在哪里。常见做法是放在栈顶,调用者在CAL之后从栈顶取。被调用者执行return expr时,求值expr并保留在栈顶,然后执行RET指令,RET负责恢复栈帧并保留返回值。虚拟机里RET的实现要小心:先保存返回值,再恢复栈指针和静态链,最后把返回值压回栈顶。

case RET: ret_val = stack[sp]; // 保存返回值 sp = bp - 1; // 恢复栈指针到旧栈顶 bp = stack[sp + 2]; // 恢复静态链 pc = stack[sp + 1]; // 恢复返回地址 stack[++sp] = ret_val; // 返回值压回栈顶 break;

逻辑说明:bp是基址指针,sp是栈顶指针,pc是程序计数器。参数说明:stack[sp+1]是返回地址,stack[sp+2]是静态链,具体偏移取决于你的栈帧布局。注意RET之后栈顶是返回值,调用者如果不需要返回值,需要发一条POP或调整栈指针,否则栈会越来越高。这个细节在加返回值时最容易漏,漏了之后程序跑几次循环就栈溢出。

5. 扩充 PL/0 时最容易翻车的五个地方

5.1 跳转回填地址差一条指令

现象:if有else时,then 分支执行完又执行了 else 分支;或者for循环多跑一次。原因:回填JPC时目标地址算错,把JMP指令本身当成了跳转目标,或者把next_addr()用在了生成指令之前。解决:在回填前打印指令数组,确认目标下标指向的是分支第一条指令,而不是跳转指令本身。我一般会在backpatch里加一句断言,检查目标地址的指令不是JMP。

5.2 符号表层级没恢复导致变量串层

现象:过程 A 里声明的变量在过程 B 里能访问,或者递归调用时局部变量互相覆盖。原因:进入过程时符号表栈顶没有保存,退出时没有回退,导致内层声明覆盖外层。解决:在block入口保存sym_top,出口恢复。如果加for和if作用域,同样处理。注意恢复符号表的同时要恢复数据栈的层级信息,否则代码生成用的level会错。

5.3 数组下标越界写坏栈帧

现象:程序运行到某处突然跳飞,或者变量值莫名其妙改变。原因:数组访问没有越界检查,LDA算出的地址超出了数组范围,STI写到了返回地址或静态链上。解决:在LDA前插入边界检查,越界跳转到错误处理。如果不想每条访问都检查,至少在调试版本里打开检查,发布版本再关。这个坑我踩过,查了一下午才发现是下标写成了size而不是size-1。

5.4 过程参数个数不匹配导致栈错位

现象:调用带参数的过程后,过程内部变量地址全错,或者返回后调用者栈不平衡。原因:实参个数和形参个数不一致,调用者多压或少压了栈,被调用者按形参个数调整栈帧,导致基址偏移错位。解决:在语法分析调用语句时,比较实参个数和符号表里记录的形参个数,不匹配直接报编译错误。不要留到运行时,运行时栈错位的表现是随机的,很难定位。

5.5 虚拟机指令新增后忘记改反汇编或调试输出

现象:程序行为不对,但看指令序列又觉得没问题,因为调试输出里新指令显示为空或错误。原因:新增LDA、LDI、STI后,虚拟机的反汇编函数或跟踪输出没有加对应case,导致你看到的指令流和实际执行的不一致。解决:每加一个指令,同步更新反汇编和跟踪输出。这个坑不致命,但会让你在调试时怀疑人生,属于典型的“黑匣子”问题。

6. 用一组回归用例验证你的扩充是否真的稳

扩充完成后,不要只跑一个hello world就收工。我一般会准备一组回归用例,覆盖每个新特性的正常路径和边界。下面是一个测试程序示例,包含else、for、过程参数、数组和返回值:

var a[5], i, sum; procedure fill(n); var j; begin j := 0; while j < n do begin a[j] := j * 2; j := j + 1 end end; procedure get(i); begin return a[i] end; begin fill(5); sum := 0; for i := 0 to 4 do begin if i = 2 then sum := sum + 100 else sum := sum + get(i) end; write(sum) end.

这个程序跑通,说明for、if-else、过程调用、数组读写、返回值都基本可用。预期输出是0 + 2 + 100 + 6 + 8 = 116。如果输出不对,按特性逐个注释掉排查:先去掉else看 then 分支,再去掉for用while替代,最后单独测数组和过程。这种二分排查比盯着代码看快得多。

验证时还要注意栈的平衡。可以在虚拟机里加一个栈深度检查,每次CAL和RET后打印sp和bp,确认调用前后栈指针回到同一位置。如果发现sp每次调用后都增加,说明参数或返回值没有清理干净。这个习惯帮我省了很多后悔药,尤其是加返回值那次,RET后忘了弹返回值,循环跑三次就栈溢出。

最后说一个我自己的教训:扩充 PL/0 最忌讳一次加太多特性。我曾经想一口气把else、for、数组、参数、返回值全加上,结果编译通过但运行随机崩溃,查了两天才发现是符号表层级和栈帧偏移互相影响。后来改成每加一个特性就跑一遍回归用例,确认稳定再动下一个,效率反而高得多。如果你也在做这个方向,建议先把else和for跑稳,再啃参数和数组,最后加返回值。希望帮到你。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询