简介:这是一份面向编译原理课程设计的完整PL/0编译器修改扩充资料包,适合计算机专业本科生或正在完成课程设计的学生使用。资源以Pascal语言实现的PL/0编译器为基础,完成了赋值运算的+=、-=扩充,并新增了Pascal风格的FOR-TO和FOR-DOWNTO循环语句,分别支持步长为1与-1的循环控制;选做部分还实现了++、--运算与一维数组类型,整体改动覆盖词法分析、语法分析与中间代码生成等关键环节。压缩包共42个文件,约1.25MB,包含PL/0源代码(.pl0)、编译中间代码(.cod)、可执行程序(.exe)、Delphi工程与源文件(.cpp/.pas/.dfm)及课程设计文档(.doc),源码、测试程序与结果文件一一对应,目录结构清晰,便于对照学习。资源已有1053人学习下载。读者可从中获得可运行的编译器程序、扩充语法后的完整源码实现、测试样例及设计说明文档,非常适合用于理解符号表管理、语句翻译与代码生成原理,也可直接作为课程设计或实验报告的参考。
1. 拿到这份 PL/0 扩展工程:先搞清楚它解决了什么痛点
A同学把原版 PL/0 编译器编译下面这段程序时的表情,我到现在还记得:if (a % 10 == 0) && (b > 0) then write 1;语法错误直接甩脸。原版 PL/0 只有+ - * /四个算术运算符和几个比较符,遇到取模、逻辑与、复合赋值全部歇菜。最反直觉的一点是:PL/0 里所谓的“运算符优先级”根本不存在于一张显式的优先级表,而是藏在expression → term → factor这几个递归下降函数的嵌套层次里。想扩运算符,就是往这组函数里塞新层。这份资源做的是把词法、语法、代码生成三层各开出新的运算符通道,让% && || ! +=全部可用。适合三类人:正在做编译原理课程设计的本科生、想搞懂运算符优先级本质的开发者、以及想快速拿到一份能跑能改的教科书编译器源码的人。
2. 原版结构梳理:三层编译管线与运算符体系的重排方案
2.1 原版编译器的三层结构与运算符短板
原版 PL/0 是一个完整的教学编译器,结构上分成三层:词法分析器负责把源代码字符流切分成symbol枚举;语法分析器用递归下降做语法树构建,边分析边调用gen()生成中间代码;最后是 P-code 虚拟机,逐条执行LIT、LOD、STO、ADD、SUB、MUL、DIV、JMP、JPC这些指令。整套管线是单遍编译——语法分析的同时直接产生指令,不做独立语法树,指令生成后马上可以被虚拟机解释执行。
原版运算符体系极简:算术层只有+ - * /,比较层有= # < > <= >=,赋值层只有:=。没有逻辑与、逻辑或、逻辑非,没有取模,没有复合赋值。这意味着任何带布尔表达式的程序,比如if a > 1 then还凑合,但if x > 1 && y < 2 then直接没法写。想表达“两个条件同时成立”,只能嵌套if,代码膨胀得厉害。位运算、取模这类日常需求,基本等于判死刑。
更深层的问题是:运算符的语义被硬编码在语法分析里。term()函数里看到*就gen(MUL),看到/就gen(DIV),没有任何表驱动机制。所以扩充运算符不是改一个表就能解决的,得同时动词法识别、语法层次、指令集三处,这也是为什么很多人拿到原版却不敢下手改的原因。
2.2 运算符扩充清单:从取模到复合赋值
这份资源里我看到的扩充方案,选了五个方向,每个方向都在三层里分别落地:
| 新增运算符 | 词法符号 | 语法层次位置 | 新增/复用指令 | 说明 |
|---|---|---|---|---|
%取模 | MOD | term层(与* /同级) | 新增MOD指令 | 处理整除取余 |
&&逻辑与 | AND | 新增bool_term层 | 新增AND指令 | 两个布尔值的非短路求值 |
||逻辑或 | ORR | 新增expression层 | 新增ORR指令 | 原expression改名为bool_term的下层 |
!逻辑非 | NOT | 新增bool_factor层 | 新增NOT指令 | 单目前缀运算符 |
+=复合加赋值 | PLUSEQ | statement赋值语句分支 | LOD + ADD + STO组合 | 语法糖,展开为a := a + expr |
新增的指令集中在虚拟机解释器里加case分支。AND、ORR、NOT都是栈式机的直接运算:从操作数栈弹出值,计算,压回。MOD额外需要做一个除零检查。这些指令完全兼容原版 P-code 的执行模型,不改变栈帧结构。+=不新增指令,而是在语法分析阶段直接展开成三条已有指令的组合,这是成本最低的一类扩充。
我在实际改的时候发现一个原则:能靠改写指令序列实现的,就不要动虚拟机核心。+=这种语法糖展开,比新增一个PLUSEQ指令要稳得多,因为你不需要改解释器,指令流程本身也被限定在已有的栈操作里,不容易引入内存越界之类的问题。
2.3 扩展后的优先级表与语法层次怎么排
语法层次就是优先级的实体。原版三层结构是:
expression -> term { ( + | - ) term } term -> factor { ( * | / ) factor } factor -> ident | number | ( expression )扩充后我把它拆成六层,优先级从低到高排列:
expression -> bool_term { || bool_term } bool_term -> bool_factor { && bool_factor } bool_factor -> ! bool_factor | additive { ( = | # | < | > | <= | >= ) additive } additive -> term { ( + | - ) term } term -> factor { ( * | / | % ) factor } factor -> ident | number | ( expression )比较运算符从原来的condition挪进了bool_factor层,这样a + b < c * d这种“算术表达式之间做比较”的用例才成立。!直接递归作用于bool_factor,保证!(a > b)和!flag都能编译。每一层对应一个递归下降函数,函数名就是层名,代码结构一横一竖都对齐。
这里有个坑必须提前说:原来的condition产生式会被整体替换,if/while语句里嵌的条件语法要同步改掉。我见过有人只加了bool_term层,忘了改condition()函数,结果if a && b then依然报错,花了半小时排查才发现是if语句里还在走老逻辑。语法分析和语义分析是牵一发动全身的,层加好了,调用点也得跟着换。
3. 核心模块拆解:一个a % b && c从词法到 P-code 的完整链路
3.1 词法分析:多字符符号识别与最长匹配
原版getsym()处理:=时用的是“读一个字符 + 判断下一个字符”的方式。扩展后的运算符大多是多字符符号,&&、||、!=、+=都需要向前看一位。常见的做法是在读入当前字符后,再 peek 一个字符,两个字符一起决定符号类型,这就是最长匹配原则。
symbol getsym(void) { // 跳过空白符后读取当前字符到 ch switch (ch) { case '!': getch(); if (ch == '=') { getch(); sym = NEQ; } /* != 保持原样 */ else { sym = NOT; } /* 单目逻辑非 */ break; case '&': getch(); if (ch == '&') { getch(); sym = AND; } /* 最长匹配: && */ else { error("unexpected '&'"); } break; case '|': getch(); if (ch == '|') { getch(); sym = ORR; } else { error("unexpected '|'"); } break; case '+': getch(); if (ch == '=') { getch(); sym = PLUSEQ; } /* 复合赋值 */ else { sym = PLUS; } break; case '%': getch(); sym = MOD; break; /* 其它原有符号: + - * / ( ) = # < > , ; . */ } }逻辑说明:!分支里先读入下一个字符,如果是=就合并成!=,否则当前!单独成NOT符号。&和|都强制要求后面跟一个相同字符,单独出现直接报错。+=和+的区分同样依赖向前看。这里最关键的是顺序:必须先读下一个字符再决定符号,绝不能先返回PLUS再回头看——那样+=会被切成+和=两个符号,语法必然崩。
参数说明:sym是全局符号变量,ch是当前字符,getch()每次读取新字符并更新ch。注意error()只报告错误不退出,否则测试时会卡在第一个符号错误上,后面全被吞掉。
3.2 语法分析:递归下降中新增的两个层次
语法层的改动集中在expression()改名和两个新函数的插入。原来的expression变成additive(),新expression管||,中间插入bool_term管&&,bool_factor管比较和!。
/* expression: 最低优先级, 处理 || */ void expression(void) { bool_term(); /* 先解析左侧 */ while (sym == ORR) { getsym(); /* 吃掉 || */ bool_term(); /* 解析右侧 */ gen(ORR, 0, 0); /* 生成逻辑或指令 */ } } /* bool_term: 处理 && */ void bool_term(void) { bool_factor(); while (sym == AND) { getsym(); bool_factor(); gen(AND, 0, 0); } } /* bool_factor: 处理 ! 前缀和比较运算 */ void bool_factor(void) { if (sym == NOT) { getsym(); bool_factor(); /* 递归处理右操作数 */ gen(NOT, 0, 0); /* 生成逻辑非 */ } else { additive(); /* 先算算术表达式 */ if (sym == EQ || sym == NEQ || sym == LSS || sym == GTR || sym == LEQ || sym == GEQ) { symbol rel = sym; /* 记住比较符 */ getsym(); additive(); gen_relop(rel); /* 生成对应比较指令 */ } } }逻辑说明:expression的 while 循环保证了a || b || c这种左结合表达式正确生成两条ORR。bool_factor里!分支递归调用自己,所以!!flag也能编译,NOT指令连发两次。没有比较符时,bool_factor就是把additive的结果当布尔值用,这符合 C 语言里“非零即真”的约定。
参数说明:gen()三个参数依次是指令码、层级 level、地址 addr,算术逻辑指令后两个为 0。gen_relop()是一个辅助函数,把EQ/NEQ/LSS/GTR/LEQ/GEQ映射到对应指令码,避免在每个语法函数里重复if判断。
3.3 中间代码生成:复合赋值的三类指令组合
+=展开是这次扩充里最体现“语法糖”的地方。原版赋值语句直接expression()然后STO,但a += expr需要先取旧值,再算新值,然后相加、存回,整个序列四条指令。
/* 赋值语句分支: 处理 := 和 += */ void assignment(int ident_index) { int level = table[ident_index].level; int addr = table[ident_index].addr; getsym(); /* 读走 := 或 += */ if (sym == PLUSEQ) { getsym(); /* 读走 +=, 进入表达式 */ gen(LOD, level, addr); /* 1. 取变量旧值 */ expression(); /* 2. 计算右侧新值 */ gen(ADD, 0, 0); /* 3. 旧值 + 新值 */ gen(STO, level, addr); /* 4. 写回变量 */ } else { expression(); gen(STO, level, addr); /* 原版 := 逻辑 */ } }逻辑说明:四条指令的顺序不能乱。LOD先把旧值压栈,expression()计算把新值压栈,栈顶此时是“新值、旧值”从顶到底,ADD把栈顶两个数弹出相加再压回,最后STO弹栈写入变量。如果你把LOD放到expression()之后,虽然加法可交换结果不变,但换成将来做*=或-=时顺序就会出错。我一般习惯统一保持“先旧后新”的书写顺序。
参数说明:ident_index是变量在符号表里的下标,level是所在过程嵌套层数,addr是相对层基址的偏移。取这两个值必须在getsym()读走运算符之前完成,因为一旦读完运算符,符号表指针就可能被后续标识符覆盖——这是很多人写代码时踩过的悬垂坑。
3.4 虚拟机扩展:P-code 解释器加四个分支
指令集扩充在虚拟机里是纯增量代码。每个新指令就是一个case分支,操作数栈的压栈弹栈规则和原有算术指令完全一致。
switch (op) { /* ... 原有 LIT LOD STO ADD SUB MUL DIV JMP JPC 等 ... */ case MOD: if (stack[sp] == 0) halt("division by zero"); /* 除零保护 */ stack[sp - 1] = stack[sp - 1] % stack[sp]; sp--; break; case AND: stack[sp - 1] = stack[sp - 1] && stack[sp]; /* 非短路逻辑与 */ sp--; break; case ORR: stack[sp - 1] = stack[sp - 1] || stack[sp]; sp--; break; case NOT: stack[sp] = !stack[sp]; /* 栈顶取反 */ break; }逻辑说明:MOD先用栈顶元素做除零判断,这里stack[sp]是右操作数,因为栈式机里后压入的永远是右操作数。AND和ORR直接把两个操作数按整型真假做逻辑运算,结果为 0 或 1。注意这是非短路版本:a && b即使 a 为假也会先算出 b 的值。要短路得在语法分析层翻译成JPC跳转,而不是用这条指令,后面二次开发部分会展开。
参数说明:stack是操作数栈数组,sp是栈顶指针。所有指令都在同一 switch 里,新增 case 不会影响原有指令的执行顺序。我强烈建议在halt()里打印出错指令和栈深,排查无限递归或栈溢出时会省很多时间。原版虚拟机基本不打印上下文,调试全靠硬猜,这是这份资源比原版让人舒服的地方。
4. 运行与验证:构建工程、测试用例、优先级对拍的完整流程
4.1 目录结构与工程构建
工程用 C 语言重写了一遍原版 Pascal 代码,整体目录结构很标准,源码、测试、构建脚本三种东西分得很清楚:
pl0-ext/ ├── Makefile # 构建入口, test 目标连带跑测试 ├── README.md # 项目说明和运算符扩充清单 ├── src/ │ ├── pl0.c # 编译器主入口, 初始化符号表与虚拟机 │ ├── lexer.c # 词法分析, getsym() 及符号映射 │ ├── parser.c # 递归下降语法分析与代码生成 │ ├── codegen.c # gen() 指令发射与指令码定义 │ ├── vm.c # P-code 解释器, 含新增的 4 个 case │ └── pl0.h # 公共头文件, 符号枚举与指令码 └── test/ ├── arithmetic.pl0 # 取模与混合运算用例 ├── logic.pl0 # 逻辑与、或、非用例 ├── assign.pl0 # += 复合赋值用例 └── priority.pl0 # 优先级综合对拍用例构建命令很简单,make生成pl0可执行文件,make test会依次跑完test/下所有扩展名.pl0的用例。我在调试时习惯先用make clean && make强制全量重编,因为词法和语法分析的头文件改动频率高,增量编译偶尔会漏掉依赖。
CC = gcc CFLAGS = -Wall -g -O0 SRCS = src/pl0.c src/lexer.c src/parser.c src/codegen.c src/vm.c pl0: $(SRCS) $(CC) $(CFLAGS) -o $@ $(SRCS) test: pl0 ./pl0 test/arithmetic.pl0 ./pl0 test/logic.pl0 ./pl0 test/assign.pl0 ./pl0 test/priority.pl0-O0是刻意关掉优化的,编译器不开优化时生成的代码行为与源码一一对应,栈操作和指令跳转会更容易在单步调试里跟踪。-g保留调试符号,配合 gdb 可以在gen()或vm.c的case MOD处打断点,直观看到入栈出栈过程。想看到中间代码的同学,可以在pl0.h里打开一个TRACE宏,让gen()在发射指令时同步打印指令码和操作数,比黑盒跑完只看结果要直观得多。
4.2 运算符测试用例设计
测试用例的设计思路是“每个运算符一个最小用例 + 一个优先级综合用例”。最小用例用来定位是哪一层出了问题:词法坏了症状是符号解析失败,语法坏了症状是产生式报错,中间代码坏了症状是编译通过但结果不对。优先级综合用例则是把多层运算符揉在一起,验证整条链路。
/* test/priority.pl0: 优先级综合对拍用例 */ var a, b, r; begin a := 17; b := 5; r := a + b * 3 - a / b % 2; write r; end.这条表达式的计算顺序是:b * 3 = 15,a / b = 3,3 % 2 = 1,最后17 + 15 - 1 = 31。如果%的优先级和结合性排错,比如让%比*低,就会算出完全不同的结果。它的价值在于一个表达式同时覆盖加、乘、除、模四个运算符的层级关系,一次对拍能验证大半条语法链。
另一个用例专门打逻辑运算符和复合赋值:
/* test/logic.pl0: 逻辑运算符 + 复合赋值 */ var a, b, c; begin a := 12; b := 0; c := 3; a += 8; /* a = 20 */ if !(b > 0) && (a > 10) || c == 3 then write a; end.逻辑说明:!(b > 0)为真,a > 10为真,c == 3为真,最终条件是(真 && 真) || 真,整体为真,输出 20。如果!的优先级排错,被解析成!(b > 0 && (a > 10 || c == 3)),结果就变了。两个用例控制变量:第一个盯算术优先级,第二个盯逻辑组合和+=展开,独立跑能直接缩小问题范围。
4.3 中间代码与真实计算的对拍验证
想验证代码生成是否正确,最硬核的方式是打开指令打印,把生成的中间代码逐行和预期结果对拍。priority.pl0的中间代码大概长这样:
LIT 17 ; 常量 17 压栈 STO a ; 写回变量 a LIT 5 STO b ; b = 5 LOD a ; 取 a = 17 LOD b ; 取 b = 5 LIT 3 MUL ; b * 3 = 15 ADD ; 17 + 15 = 32 LOD a ; 取 a LOD b ; 取 b DIV ; a / b = 3 LIT 2 MOD ; 3 % 2 = 1 SUB ; 32 - 1 = 31 STO r WRT ; 输出 31你手工算出来的 31 和机器跑出来的结果一致,才能证明%的语法层位置没错、MOD指令的栈操作没写反。这个对拍过程听着原始,却是最有效的验证手段——所有课程设计翻车,十个里有八个是“手工算得出来但机器算不对”,而机器不对的根因往往就在某一层指令的入栈顺序上。
我会把每个测试文件的预期输出写进文件名,比如priority.pl0.expect内容就是31,让测试脚本自动比对实际输出和预期文件。这样每次改完代码,跑一遍make test就能知道有没有回归破坏,不用每次手动去看终端输出。
5. 避坑记录:改 PL/0 运算符时最容易翻车的四个地方
5.1&&被切成两个&,报 unexpected symbol
现象:编译a && b时在第一个&处直接报语法错误,提示出现未定义的符号。代码里单独用&当作逻辑与,但原版的符号枚举里根本没有&这项。
原因:词法分析只读了一个字符就返回符号,没有向前看。&&被切断成&和&,第一个&落入 default 分支触发报错。这是典型的词法层偷懒,没有吃透“符号长度不定时必须 peek 一位”的原则。
解决:在getsym()的case '&'里先getch()读第二个字符,判断如果是&就返回AND,否则报错。所有多字符运算符(!=、+=、||)都统一走这个模式。从那以后我做词法扩展都强制要求自己写完再看一遍有没有漏掉向前看的符号。
5.2!=和!前缀互相干扰
现象:if !(a != b) then这个程序编译时,词法把!正确识别为NOT,但到了bool_factor()里处理!后面的(a != b)时,!=又被切成了!和=,导致比较运算碎掉。
原因:NOT和NEQ共用同一个前缀字符!,词法里只处理了“!后面跟=时算NEQ”这一种情况。放在单目!前缀的上下文里,它后面的(让词法按NOT处理了,但括号结束后的!=又走了一遍单独判定,两套路径对不上。
解决:保证整份词法代码里!只有一条判定路径,在一个 switch 分支里通过 peek 完整分辨!=和NOT,不要在语法层再二次判断。我后来写了个规则:词法符号的定义是唯一真源,语法层只管用,绝不在语法函数里根据下一个字符猜符号类型。
5.3%与* /同级,但右操作数算错
现象:r := a + b * 3 - a / b % 2跑出来结果不是手工算的 31,而是别的值。单独测a / b % 2时结果也不对。
原因:term()函数里 while 循环处理*、/、%时,用的是循环内每次重新读取的当前符号来决定生成哪条指令。当表达式是a / b % 2时,读完b后当前符号是%,但循环变量判断的还是上一个符号,导致%分支被跳过或者生成了错误的指令。
解决:在进入 while 循环时用局部变量保存操作符,再读右操作数,最后根据保存的值生成指令。也就是我在语法分析代码里写的那种symbol op = sym; getsym(); ... switch(op)结构。记住操作符是“先存后用”,不能让当前符号在递归调用过程中被覆盖。这个是递归下降里最经典的坑,血泪经验。
5.4+=展开时忘取旧值,变量被直接覆盖
现象:a += 8编译执行后 a 的值不是原值加 8,而是直接被赋值成 8。单步看中间代码,发现只有expression()生成的常量压栈加STO,缺少LOD a那一步。
原因:赋值语句分支里处理:=的逻辑是“先算表达式再 STO”,我在扩展+=时直接套用了这个结构,忘了+=语义里需要先取出变量旧值参与运算。这属于新功能复用了错误的老代码路径。
解决:按“先LOD旧值,再算表达式,再ADD,最后STO”的顺序生成四条指令。每次判断赋值类运算符时,看老逻辑能不能直接套用;不能就直接单写一个分支,别怕重复代码。课程设计里这类问题往往不是不会写,而是顺手复制后没有回头审视语义差异。
5.5 条件语句里比较符号走入了错误层级
现象:if a = 1 && b = 2 then能编译,if a = 1 then也能编译,但if a = 1 && b = 2 then单独跑得对,加进 while 循环里就报错。
原因:原版condition()只允许单个比较表达式,没有递归组合能力。扩展后bool_factor里虽然有比较逻辑,但if语句的condition()还是老代码,导致布尔表达式进去就被拆错。
解决:把condition()改成直接调用新的expression(),让if/while走和普通表达式完全相同的语法层次。检查点是在改完语法后,把所有会用到条件的位置if、while、if...else全部列出来,逐一确认调用链都指向新入口,而不是只改了一处。漏改一处,语法看似正确但跑起来就是玄学。
6. 二次开发:从运算符扩充走向一门小语言
6.1 位运算符和短路求值的改造思路
下一步最常见的方向是加位运算,& | ^ << >>。词法层面把单独&从报错改成BAND符号,同时保留&&为逻辑与;语法层面在additive和term之间插一个位运算层。注意区分位与和逻辑与:一个在算术层,一个在布尔层,优先级不一样,千万别放到同一层。
短路求值就是另一套玩法了。现在AND、ORR指令是非短路的,a && b即使 a 为假也会先求 b 的值。改成短路要在语法分析阶段把布尔表达式翻译成跳转序列,而不是线性指令流。常见做法是这样:
a && b 对应的跳转式中间代码: LOD a JPC L_false ; a 为假则直接走 false 分支 LOD b JPC L_false ; b 为假同样走 false LIT 1 JMP L_end L_false: LIT 0 L_end: ...这样右侧表达式只在左侧为真时才会被真实求值,和 C 语言语义对齐。改造时可以把bool_factor里的AND/ORR指令全部换成JPC加标签序列,中间代码表达力比指令求值强得多。代价是符号表里要新增一个标签计数器,栈式求值的简单性会被破坏掉一部分,代码生成逻辑明显变长,需要有心理准备。
6.2 一套可复用的自测顺序和发育路线
我建议的验证顺序是:先单独测试词法符号识别,准备一个“全符号扫描”用例,把每个运算符的符号枚举值打印出来;再做单个表达式的求值验证,最后才上优先级综合对拍。这三级测法能让问题暴露在最浅的层级,省掉在语法和虚拟机之间来回猜的时间。我一般会把测试代码按此顺序组织,每新增一个运算符就走一遍这三关,通过后再进综合用例。走完这条路线,编译器就具备对自己语言的基本自举能力了,让它编译自己写的编译测试程序,是课程设计里最有成就感的里程碑。我从那以后每次改词法,都强制先跑一遍全符号对照用例,再动语法树——这条血泪经验希望能帮到你。
本文还有配套的精品资源,点击获取