☰
PL0编译器扩充实战:浮点、数组与for循环的完整改造
2026/10/14 9:04:11 网站建设 项目流程

简介:面向编译原理课程设计,资源以PL0语言为简洁载体,系统展示对语言及编译器的扩充和修改过程,覆盖词法分析、语法分析、语义分析、中间代码生成、代码优化与目标代码生成等核心环节,适合需要动手实践编译器构造的本科生、研究生和自学开发者。压缩包共6个文件,包含pl0.cpp与pl0.h源程序、编译原理课程设计说明书docx,以及test1.txt、test2.txt、test3.txt三个测试文件,整体体积仅146KB,结构紧凑,便于快速阅读、调试和验证。已有1431人学习下载,资源兼顾理论说明与实际代码,可帮助读者理解PL0语言语法扩充的思路、编译流程的完整实现,以及如何通过测试用例验证编译器行为。通过研读源程序和设计文档,读者能掌握符号表管理、错误处理、代码优化等实践技巧,并可将相关扩充方案迁移到其他教学编译器设计与改进中。

1. PL0 编译器扩充:先动哪个层,决定你能撑到第几版

PL0 编译器在不少编译原理课程里是“既能跑又跑不远”的典型:词法、语法、代码生成和虚拟机全被压缩在几千行内,能处理加减乘除、if/while 和过程调用,但一涉及数组、浮点或像样的 for 循环,就露馅了。题目里的“扩充和修改”,翻译过来通常是两件事:一是给语言增加新特性,让更多程序能跑;二是把原装那个只能演示的编译器改造成你能向别人讲清每一层在干什么的编译器。它适合正在做课程设计的人,也适合想补编译器开发基础的人。先记住一句话:PL0 是编译器本体,不是编辑器,你要动的是符号表、指令集和虚拟机这三块硬骨头。

2. 动手前拆解:PL0 的五段结构与扩充需求落在哪一层

2.1 先看一条语句的旅程,再决定改哪里

扩充 PL0 之前,要把“一条源语句从进来到跑起来”的链路画出来,否则你只会陷入改一个 bug 冒出三个 bug 的循环。以if x > 0 then y := x + 1;为例,经历五个阶段:源程序字符流被词法分析切成 token,语法分析按递归下降识别出 if 条件与赋值结构,同时翻译成一段类 P-code 指令,最后由虚拟机逐条执行并维护运行时栈。

阶段输入输出PL0 里的典型载体
词法分析ifx>0then...token 序列getsym()、保留字表
语法分析token 序列语义动作 + 目标代码block()、statement()、expression()
代码生成语法结构指令数组 code[]emit()、address 回填
虚拟机执行code[]栈上的计算结果base()、OPR 分发、JMP/JPC
运行时栈返回值/参数/局部变量最终输出stack[]、display 表

改动任何一个需求,都要先定位它主要落在哪个阶段,再判断会连带影响哪些相邻阶段。最常见的问题是直接去改语法分析函数,却忘了符号表里没有类型字段、虚拟机的 OPR 没有新操作码,最后代码生成了,虚拟机一跑就崩。

2.2 扩充需求映射:不同需求改的层完全不一样

“对 PL0 进行扩充”在不同人手里指的是不同的事。常见做法是先列一张映射表,把需求钉在具体层上,避免在中途把编译器改成一团乱麻。

扩充需求主要改动层必然联动层建议顺序
增加浮点类型词法(浮点字面量)、符号表(类型字段)代码生成的 LIT/OPR、虚拟机运算第一步
增加数组符号表(size、维度)、语法(factor 对下标的处理)新增 LDA/IXA/LOAD/STORE 指令第二步
增加 for 循环语法(statement 新分支)代码生成(跳转回填)第三步
增加 and/or 短路逻辑语法(condition 重写)代码生成(JPC 多目标回填)第四步
增加过程参数传递方式符号表(参数类别)、调用约定虚拟机 CAL/INT 的栈帧处理最后做

这张表同时也是开发顺序的建议。先加浮点是因为它能让“给符号表加字段”这件事练手;数组和 for 依赖跳转与地址计算;短路逻辑要动 condition 的代码生成结构,放后面更稳。另一个容易混的点是入口:PL0 的 program 块本身是编译器约定的入口,没有 C 那种 main 类型的概念,做扩充时不需要额外生成入口符号,只需要让整个块编译后从 code[0] 开始执行。

2.3 用最小改动跑通一遍“体检”,建立回归基线

改任何一行代码之前,先让原版编译器能跑通一批用例,并把“通过/不通过”变成可执行脚本。这一步节省的时间比后面所有工作加起来都多。

#!/bin/bash # 批量回归:先判断退出码,再比对期望输出 # 用法:把 pl0 可执行文件放在当前目录,tests 下放 *.pl0 和可选的 *.out pass=0 fail=0 for src in tests/*.pl0; do base=$(basename "$src" .pl0) timeout 5 ./pl0 "$src" > "/tmp/$base.actual" 2>&1 if [ $? -ne 0 ]; then echo "[FAIL] $src 非零退出码" fail=$((fail + 1)) elif [ -f "tests/$base.out" ] && ! diff -q "tests/$base.out" "/tmp/$base.actual" > /dev/null; then echo "[FAIL] $src 输出与期望不符" fail=$((fail + 1)) else pass=$((pass + 1)) fi done echo "pass=$pass fail=$fail"

这段脚本的核心价值是三个:timeout 5防止死循环把测试卡死;退出码先筛掉“编译/执行崩溃”的用例;diff再筛掉“结果不对”的用例。我一般会在第一次运行后就把原始的 PL0 用例全部收进 tests 目录,之后每做一次扩充就重跑一遍,把“旧功能被新改动弄坏”这件事变成脚本可检测的回归,而不是靠手感。

3. 给 PL0 增加浮点类型:词法、符号表与虚拟机的三层联动

3.1 词法层:新增保留字与浮点字面量识别

原版 PL0 的保留字表只有 const、var、procedure、begin、end、if、then、while、do、odd、call 这些,词法分析在读到标识符后先查保留字再决定返回哪个符号。加浮点类型的第一步,是在保留字表里加一个float,同时让词法分析识别带小数点的数字。

typedef struct { char *name; int sym; } ReservedWord; ReservedWord reserved[] = { {"begin", SYM_BEGIN}, {"call", SYM_CALL}, {"const", SYM_CONST}, {"do", SYM_DO}, {"end", SYM_END}, {"if", SYM_IF}, {"odd", SYM_ODD}, {"procedure", SYM_PROCEDURE}, {"then", SYM_THEN}, {"var", SYM_VAR}, {"while", SYM_WHILE}, {"int", SYM_INT}, {"float", SYM_FLOAT} /* 新增 */ };

保留字查找的顺序很关键。常见实现是在 getsym 识别出字母开头后,先把整个词读到临时缓冲区,然后顺序查这张表;查到就返回对应符号,查不到才当作标识符处理。新增关键字后要注意:float必须被表拦住,否则它会被当成普通变量。

浮点字面量的识别同理:原版只认数字,遇到3.14会在小数点处把 token 断开。需要改数字扫描逻辑,让它在数字后遇到.且后面还有数字时继续读,并把值存成 double。词法层还需要用类型区分,我一般会新增一个全局变量floatNumber来保存刚读到的浮点值,供语法层取用。

3.2 符号表层:类型字段决定变量怎么分配

原版 PL0 的符号表记录里通常只有 name、kind、level、address、size 五个信息,kind 区分常量、变量、过程。要支持浮点变量,最直观的做法是加一个 type 字段,并让地址分配逻辑跟着类型走。

#define MAX_ID_LEN 32 #define KIND_CONST 1 #define KIND_VAR 2 #define KIND_PROC 3 #define KIND_ARRAY 4 typedef struct { char name[MAX_ID_LEN]; int kind; int type; /* 新增: 0=int, 1=float */ int level; int address; int size; } Symbol;

这里要特别强调地址分配的边界问题。原版var x, y;的地址分配是逐个变量 address 递增 1,因为每个变量只占一个栈槽。浮点变量如果准备用一个 slot 装 double,那么地址递增仍然可以保持 1;如果解释器栈设计成“浮点占两个槽”,那后面的变量地址就得跳过 2。更规范的方案是把运行时栈槽定义成一个联合体结构,统一占 8 字节,这样地址递增规律不会被破坏,代价是栈空间变大。

声明语句也要配套改,常见的语法是var float x;。语法分析在 var 段的每个变量名前先看一个可选类型修饰符,默认为 int,读到 float 则把 type 置 1,然后照常登记符号。

3.3 虚拟机层:按类型分发运算,避免“整数被当浮点算”的隐患

原版虚拟机的数据栈是 int 数组,所有 LIT、LOD、STO 和 OPR 都按整数操作。加浮点后,栈元素必须有能力表达“这个槽是 int 还是 double”,否则比较大小、打印输出全都会乱。常见做法是把栈槽改成带 tag 的联合体。

typedef struct { int type; /* 0=int, 1=float */ union { int i; double f; } value; } Slot; Slot stack[MAX_STACK]; int sp = 0; /* 栈顶指针 */ void pushInt(int v) { stack[sp].type = 0; stack[sp].value.i = v; sp++; }

加法指令的分发逻辑要按两个操作数的类型决定结果类型:只要其中一个为浮点,结果就该是浮点;两个都是 int,则保持整数运算。这样能避免3 / 2意外得到1.5这种改变原语义的行为。

case OPR_ADD: { Slot b = stack[--sp]; Slot a = stack[--sp]; if (a.type == 1 || b.type == 1) { double av = (a.type == 1) ? a.value.f : a.value.i; double bv = (b.type == 1) ? b.value.f : b.value.i; pushFloat(av + bv); } else { pushInt(a.value.i + b.value.i); } break; }

浮点字面量的压栈指令也要单独设计,不能复用原来的LIT 0, 字面量,因为 LIT 的参数是一个整数槽位。一种稳妥方案是给编译器加一个浮点常量池,生成LITF指令时把下标写进指令参数,执行时从常量池取值:

double constPool[MAX_CONST]; int constCount = 0; /* factor 里遇到浮点字面量时的生成代码 */ int idx = constCount++; constPool[idx] = floatValue; emit(INSTR_LITF, 0, idx);

为什么不用把 double 直接塞进 code 数组?因为原版 code 是 int 数组,硬塞会导致指令长度与 pc 推进逻辑全部重写,还涉及字节对齐。常量池方式是真实编译器里的常规选择,改动面小,后面加字符串类型也能继续沿用。

4. 扩充语句与表达式:for 循环、短路逻辑与数组下标的可复现实现

4.1 给 statement 增加 for 循环:一个分支看清地址回填

PL0 原版没有 for,只有 while。for 的难点不在语法识别,而在代码生成时跳转地址的回填。文法大致定义为for 变量 := 初值 to 终值 do 语句。递归下降的做法是:在 statement() 的 switch 里新增一个分支,识别到 SYM_FOR 就进入专门函数。

void parseFor(void) { /* 进入时当前 token 是 SYM_FOR */ int vLevel, vAddr; int condAddr, endAddr; int step = 1; nextToken(); /* 读到循环变量标识符 */ querySymbol(); /* 查符号表,取 level 与 address */ nextToken(); /* 读到 := */ nextToken(); /* 初值表达式的第一个 token */ expression(); /* 生成初值代码,结束时停在 TO/DOWNTO */ emit(STO, vLevel, vAddr); /* 循环变量 = 初值 */ if (sym != SYM_TO && sym != SYM_DOWNTO) error("for needs to/downto"); step = (sym == SYM_TO) ? 1 : -1; condAddr = codeIndex; /* 记录条件检查点 */ nextToken(); /* 跳过 TO/DOWNTO */ emit(LOD, vLevel, vAddr); /* 先压循环变量当前值 */ expression(); /* 再压终值,结束时停在 DO */ if (step == 1) emit(OPR, 0, OPR_LE); else emit(OPR, 0, OPR_GE); endAddr = codeIndex; /* JPC 的目标还没确定 */ emit(JPC, 0, 0); /* 条件不成立则跳出循环 */ nextToken(); /* 读 DO 后的第一个 token */ statement(); /* 解析循环体 */ /* 循环变量自增/自减 */ emit(LOD, vLevel, vAddr); emit(LIT, 0, 1); emit((step == 1) ? OPR_ADD : OPR_SUB); emit(STO, vLevel, vAddr); emit(JMP, 0, condAddr); /* 跳回条件检查点 */ code[endAddr].arg = codeIndex; /* 回填 JPC 的目标地址 */ }

这段代码的关键在于操作数的压栈顺序:先压循环变量,再压终值,执行OPR_LE时才能得到“i <= limit”而不是反过来的判断。回填地址是另一个关键,JPC 0, 0里的 0 是占位,循环体结束并处理完自增后,codeIndex 指向退出点,这时把 endAddr 所在指令的参数改掉。

生成出的指令序列对调错很有用,它大致长这样:

说明指令
初值赋值LIT 0,1→STO 0, i
条件检查点LOD 0, i→LOD 0, n→OPR 0,12
假则跳出JPC 0, end
循环体...
自增LOD 0, i→LIT 0,1→OPR 0,2→STO 0, i
回跳JMP 0, cond
退出点end:

警告一点:循环体里如果再次给循环变量赋值,整个自增逻辑会被破坏,语义上应该禁止。这里可以在解析循环体时检查赋值目标,遇到循环变量直接报编译错。

4.2 短路逻辑 and/or:不要再直接映射成算术指令

原版 condition 只支持odd 表达式或表达式 关系符 表达式,生成的代码把这关系的结果以 0/1 压栈,再用 JPC 决定跳转。加 and/or 后如果还按“先算左、再算右、最后按位与”的思路做,会丢掉短路语义,而且一旦右边表达式有除零风险,运行结果会和真实语言不一致。

短路实现需要一个带真目标与假目标的布尔表达式生成器。以if a<b and c<d then ...为例,代码生成应遵循:左侧为假则整个条件为假,直接跳到 JPC;左侧为真才继续计算右侧;右侧结果直接决定最终跳转。

void parseShortCircuitAnd(int falseTarget) { int leftFalse = codeIndex; emit(JPC, 0, 0); /* 先占位:左假就跳 */ /* 到这里说明左真,继续解析右侧条件 */ code[leftFalse].arg = codeIndex; /* 左侧为假时,应跳过右侧 */ } void parseShortCircuitOr(int trueTarget) { int leftTrue = codeIndex; emit(JPC, 0, 0); /* 原版只有“为假跳”,这里要另做处理 */ /* 左侧为假才走到这,继续解析右侧 */ code[leftTrue].arg = codeIndex; }

实际落地时,会给 condition 解析加一层“支持链式逻辑运算”的状态机:先解析一个单条件,再循环判断当前 token 是 and 还是 or,每遇到一个逻辑符就生成对应的守卫跳转。不要试图把 and/or 塞进 expression 的优先级链,因为 PL0 原版条件判断和算术表达式的边界是清晰的,混在一起后连旧的if a<b then都会被误判成语法错误,这类翻车后面避坑章节会详细讲。

4.3 数组下标的地址计算:新增四条指令比改 STO 更稳

原版 PL0 没有数组。var a[10];要在符号表里登记一个 KIND_ARRAY 的符号,address 指向数组首元素在栈帧中的相对偏移,size 记录元素个数。声明后的地址分配规则要改:不能只递增 1,而要address += size,否则后面声明的变量会覆盖数组元素。

访问a[i]时,编译器在 factor 里识别到数组符号后,会解析方括号内的下标表达式。因为下标是运行时的值,原来的 LOD/STO 只能用静态偏移,必须引入一组新指令,把“变量地址计算”从静态偏移变成动态地址。我一般会加四条:LDA取变量基址,IXA计算基址+下标,LOAD读目标地址,STORE写目标地址。

case INSTR_LDA: { /* 取当前层级的栈基址加静态偏移,压入变量地址 */ int addr = base(argLevel) + argAddr; pushInt(addr); break; } case INSTR_IXA: { /* 栈顶是下标,次栈顶是基址,相加得到最终地址 */ int index = pop().value.i; int baseAddr = pop().value.i; pushInt(baseAddr + index); break; } case INSTR_STORE: { /* 先弹地址,再弹值,把值写入该地址 */ int addr = pop().value.i; Slot v = pop(); stack[addr] = v; break; } case INSTR_LOAD: { int addr = pop().value.i; push(stack[addr]); break; }

数组赋值a[i] := x对应指令序列是:LDA a、压入 i 的值、IXA、压入 x 的值、STORE。数组读取y := a[i]则把末尾换成LOAD。这套指令最大的好处是原版 LOD/STO 完全不用动,过程嵌套的 display 计算也不受影响。缺点是数组访问变成两步,解释器慢一点,但教学编译器完全可接受。

5. 避坑指南:PL0 扩充路上最常见的 5 个翻车现场

5.1 新增关键字后,原有程序全部报 undefined identifier

现象:只是往保留字表加了一个 float,结果原有程序里的变量名全变成“未定义标识符”。

原因:getsym 在读到字母后,先把词当标识符查符号表,查不到再反过来查保留字;或者保留字表的容量宏没调大,新增词把表尾撑破,查找结果出现乱序。更隐蔽的原因是新增关键字和某个保留字前缀相同,查找时用了strncmp而没先比长度。

解决:把保留字查找提到符号表查找之前,所有关键字由保留字表一票否决;检查保留字表数组长度定义,别用固定数组时忘记加长;查找函数先比长度,再比内容。

5.2 for 循环第一次跑就死循环

现象:for i := 1 to 10 do ...进入后出不来,打印寄存器能看到 i 一直不变,或者一直跳到某个错误的指令。

原因:最常见是回填地址抄错,把 JPC 应当跳向的退出点填成了条件检查点;也有可能是条件判断压栈顺序反了,导致i <= 10被解释成10 <= i,第一轮条件就不成立或者永远成立。

解决:把生成的 code 数组逐条打印出来,对照 4.1 节那张指令序列表;确认 JPC 的 arg 是出口地址,JMP 的 arg 是 condAddr;确认 OPR_LE 之前栈里先有循环变量、后有终值。打印指令是编译器开发里最朴素也最有效的调试手段。

5.3 加了浮点后,print 出的整数全变成科学计数法或精度丢失

现象:var int x; x := 3;输出变成 3.000000,或者明明写的是整数运算,结果出现肉眼不可见的浮点误差。

原因:把数据栈整体改成 double 数组后,整数和浮点没有区分,打印统一按浮点处理;或者算术分发里只要有一个操作数是改了 type 的常量,另一个整数被自动提升成浮点,打破了原语言“整数运算必须保持整数”的约束。

解决:栈槽带 tag,随时能判定当前值的类型;打印指令按 tag 分发,int 用%d,float 用%g;算术运算只有当两个操作数都是 int 时才走整数分支。这个坑属于“设计时偷懒,调错时加倍偿还”的典型。

5.4 数组声明把后续变量的地址全顶歪了

现象:var a[10], b;之后给 b 赋值,运行时居然改到了 a 的元素,程序输出莫名错乱。

原因:声明数组后,address 分配还沿用了逐个递增 1 的逻辑,b 的地址落在 a 的元素中间;运行时 STO 把数据写进错误的栈槽。

解决:在enterSymbol里把数组的 size 计入地址步长,声明完数组后address += size;过程内部也要一致,因为局部变量的偏移和数组 size 同时参与栈帧计算。符号表打印是排查这类问题的好帮手,声明结束后把变量名、address、size 全部列出来,一眼就能看出分配是否越界。

5.5 加了逻辑运算符后,原来合法的条件表达式突然报语法错

现象:没加 and/or 之前if a<b then ...一切正常,加了之后同一个程序编译失败,报 condition 的 first 集冲突或者 token 错位。

原因:condition() 被改成了“先解析逻辑层再解析关系层”,但逻辑层把<、>这些关系符当成了不该出现的 token;或者为了处理短路,把 expression() 的入口逻辑改了,导致原来的单条件也被新状态机拒绝。

解决:保持 condition() 的入口不变,先尝试解析单个关系条件,再判断后面有没有 and/or;逻辑运算只在条件层追加循环,不要改动 expression 的优先级分层。新增功能优先做成“附加层”而不是“重写原函数”,这样旧语法才不会被新逻辑误伤。

6. 验证方法与一个值得投入的进阶方向:让扩充后的编译器真正可信

6.1 回归测试是唯一的后悔药

扩充做完不算完,能证明“旧程序没坏、新程序能跑”才算完。把 2.3 节的 bash 脚本固定下来,每加一个小特性就重跑一次。我自己的习惯是:改词法后只跑“所有用例输出不变”,改语法后额外跑“每个用例退出码正常”,改虚拟机后跑带浮点与数组的专项用例。没有这套回归,改完第三层时常常会回头改第一层,然后陷入“修好 A 弄坏 B”的血泪循环。

6.2 在虚拟机里插断言,比肉眼盯输出快十倍

解释器执行循环里加两个检查:每次执行 JMP/JPC 之前检查目标地址是否落在 code 数组范围内;每次 push/pop 后检查 sp 是否越过栈边界。再狠一点,在 statement 函数返回时打印当前指令数和 sp 值,一旦某个用例的 sp 与预期不符,崩溃现场能直接指向某条指令,省去大量盲猜。

/* 在每次执行 JMP/JPC 前检查跳转目标 */ if (jumpTarget < 0 || jumpTarget >= codeLen) { fprintf(stderr, "invalid jump target %d at pc=%d\n", jumpTarget, pc); exit(1); }

这个检查能拦住 5.2 节那种回填错地址的翻车,也能在你后续加循环展开等编译器优化时提供安全边界。

6.3 一个值得投入的进阶方向:按字节寻址

浮点常量池和数组的 LDA/IXA 思路再往前走一步,就是把虚拟机的栈从“一个 Slot 一个值”改成“按字节寻址的内存 + 对象头”。这会带来两个实质收益:一是字符串、记录体、动态数组都能用同一套“地址+长度”的表示;二是后续做编译器优化时,寄存器分配和常量折叠能直接在更接近真实机器的结构上进行。

这个方向做起来比想象中费时间,但它能把“扩充 PL0 语言”升级成“真正理解现代编译器为目标代码做的事情”。建议先做一个小目标:把数组元素的边界检查放进 IXA 指令,越界就报运行时错误。完成这一步后,你手上就不再是一个教学玩具,而是一个具备防御能力的小型虚拟机。

我自己的教训是:第一次做 PL0 扩充时,连续三天沉浸在加语法分支的快感里,完全没建回归脚本,结果提交前一晚发现旧用例挂了一半,连是哪次改动弄坏的都查不出来。后来先把测试脚本跑绿,再把新功能一个个加进去,才真正体会到“让旧程序一直能跑”对编译器开发的分量。希望你这次动手前,先把那条最小回归命令跑起来,它会成为你整个扩充过程里最值钱的一行代码。

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

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

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

立即咨询