简介:编译原理课程设计资料,面向计算机专业本科生,围绕PL/0编译器的修改扩充展开,重点集中在运算符扩展和循环语句实现。内容包含基本要求与选做内容:基本部分完成+=、-=赋值运算以及Pascal FOR语句的TO/DOWNTO两种形式;选做部分实现++、--运算和一维数组类型,对于字符类型、实数类型及带参数函数等未实现项,文档中给出了后续扩展思路,可作为进阶研究参考。压缩包共42个文件,大小1.25MB,涵盖PL/0源程序、C++工程文件、Pascal单元、中间代码(COD)、目标文件(OBJ)、可执行程序以及课设文档,各类型文件分工明确,便于按需查看源码或验证运行结果。已有1053人学习下载。资源自带多个测试PL/0程序,可直接运行观察输出,也可对照中间代码理解符号表、语句翻译与代码生成过程,适合需要完整实现参照的读者缩短调试周期。
1. 给PL/0加一个运算符:为什么改一处会全线翻车
给PL/0做课程设计扩充,最简单也最常见的选题就是“加一个运算符”。很多同学以为这活最轻松:词法分析里认一个新符号、加个token、完事。可真动手就翻车——词法分析器确实认出了%,递归下降的语法分析却直接不认,报一个莫名其妙的“语法错误”;咬咬牙跳过语法层,硬在表达式里塞进新指令,虚拟机又把它当成非法操作码,运行阶段直接崩掉。原因在于PL/0虽然是个教学编译器,但它是词法、语法、代码生成、P-code解释执行四层咬合成的完整链路。“运算符”这个词在课程设计里的真实含义,不是加一个符号,而是让一个运算语义穿透这四层。这篇就把这条链路拆开:token怎么切、优先级怎么嵌进文法、P-code指令怎么执行,以及哪些改动会当场翻车。
2. PL/0的骨架:词法+递归下降+P-code虚拟机为什么这么搭
2.1 两级结构:编译器和虚拟机为什么要分开
PL/0最反直觉的设计,是它不生成真实机器码,而是生成一套中间代码P-code,再交给一个用同一种语言写成的解释程序执行。也就是说,PL/0实际上是个“编译器前端 + 栈式虚拟机”的两段式结构。
这样设计的动机很实在。课程设计的考核目标是你对“编译过程”的理解,而不是让你去处理寄存器分配、指令对齐这些真实机器码的麻烦事。P-code做了一层中间抽象:只要语义正确,编译器前端可以完全不关心最终在哪里运行;虚拟机的实现又和语法分析无关,只负责读入P-code指令、操作一个栈。两边各自可测,出了问题容易定位。更关键的是,P-code是可打印的文本指令,写a + 2 * b表达式,编译结果是一串能直接读出来的指令序列,这比调试真实机器码友好太多。经典PL/0的中间码指令集很小,常用的就这几类:
| 指令 | 格式 | 含义 |
|---|---|---|
| LIT | LIT 0, a | 把常量 a 压入运行栈 |
| LOD | LOD 0, a | 把变量 a 的值压入运行栈 |
| STO | STO 0, a | 把栈顶值弹出,写入变量 a |
| INT | INT 0, a | 为当前过程分配 a 个栈空间 |
| JMP | JMP 0, a | 无条件跳转到指令序号 a |
| JPC | JPC 0, a | 栈顶为假(0)时跳转到 a,否则继续 |
| OPR | OPR 0, n | 执行编号为 n 的算术或逻辑运算 |
这张表就是运算符落地的地方。你加的每一个新运算,最终都要变成某种形式的OPR操作,或者变成一组JMP/JPC的组合。理解了这个结构,后续新增运算符时才会有整体的改动图景。
2.2 递归下降:每个非终结符就是一个函数
PL/0没有用语法分析器生成器,而是手写递归下降分析器,原因是它的文法足够简单,而递归下降本身和EBNF文法几乎一一对应。文法的每条规则对应一个函数:expression、term、factor、statement、condition,函数体就是在模仿文法产生式。
这种做法的实际好处是错误定位极其直观。语法分析报错时,你能立刻知道是卡在哪个非终结符的处理函数里,而不是面对一坨状态迁移表。对课程设计来说,这就意味着“新增运算符”这个需求,映射到代码上就是“在某个函数里增加一个分支”。
表达式在经典PL/0里的文法是这样分层的:
expression = [ "+" | "-" ] term { ( "+" | "-" ) term } term = factor { ( "*" | "/" ) factor } factor = ident | number | "(" expression ")"term函数里会有这样的循环:
void term() { factor(); while (sym == STAR || sym == SLASH) { SYMBOL op = sym; getsym(); /* 吃掉运算符 */ factor(); /* 分析右操作数 */ if (op == STAR) { emit(OPR, 0, 4); /* 乘法 */ } else { emit(OPR, 0, 5); /* 除法 */ } } }这里的关键点:factor()先递归下去,把左右两个操作数的取值指令都生成到指令列表里,然后紧跟着发一条OPR乘法指令。运行时操作数栈里已经有了两个值,OPR指令从栈里取出来算完再压回去。编译期并不计算任何结果,它只负责“把运算过程表达出来”。这也是整个PL/0代码生成最简单也最核心的模式——递归下降的过程就是生成指令的过程。
2.3 符号表和作用域:变量怎么找到自己的值
运算符之外的部分同样影响扩充。PL/0的符号表是围绕分程序结构组织的,标识符分为常量、变量、过程三类,每类都记录名字、所在层级和地址。局部变量在栈上的位置不是绝对地址,而是“层级 + 相对偏移”,访问变量时生成的指令是LOD lev, addr,运行时根据静态作用域链一层层找到属于当前过程的栈基址。
运算符扩充到这里会出现一个隐蔽问题:如果只是加算术运算符,符号表完全不用动;可一旦加了AND、OR这类逻辑运算符,或者引入boolean类型,符号表的每个表项就需要增加一个“类型”字段。经典的PL/0里没有类型区分,b := a > 0这样的关系运算结果是 0/1,可以直接塞进整数变量里,这既是简化,也是很多课程设计在扩充到一半时被绊倒的地方。所以你加运算符之前,要先回答一个前置问题:这个运算符的操作数是整数还是独立的布尔类型?答案决定符号表和虚拟机要不要一起改。
2.4 栈式虚拟机:一条指令的运行机制
P-code虚拟机用一个运行时栈同时扮演三个角色:操作数栈、局部变量存储、过程调用栈。LIT、LOD把值压栈,OPR弹出两个操作数、把结果压回去,STO把栈顶弹出写入变量。以加法指令为例,解释执行的核心逻辑是:
case OPR: switch (code[i + 1]) { case 2: /* 加法 */ stack[top - 1] = stack[top - 1] + stack[top]; top--; break; case 3: /* 减法 */ stack[top - 1] = stack[top - 1] - stack[top]; top--; break; }减法那行要特别留神:先压入的是左操作数,后压入的是右操作数,所以必须写stack[top - 1] - stack[top],写反了a - b会变成b - a。这类“栈顶顺序”问题是运算符扩充中最容易翻车的细节,尤其是除法和减法这种不可交换运算。新增一个运算符,不只是加一个枚举值和一层文法分支,最终的落点一定是这里的OPR分支或与之配合的跳转指令。
3. 运算符的完整旅程:token、优先级、P-code,三层联动怎么改
3.1 词法层:token是怎么从字符流里切出来的
PL/0的词法分析器通常是一个手写的状态机,逐个读入字符,匹配出关键字、标识符、数字和运算符符号。它返回的不是字符串,而是一个枚举类型的token,比如PLUS、MINUS、STAR、SLASH、EQU、NEQ、LSS、LEQ、GTR、GEQ。语法分析器完全不关心%在字符流里长什么样,它只关心sym这个全局变量当前是不是PERCENT。
新增运算符在词法层要做两件事。第一,在符号枚举里加一个新值;第二,在词法分析的状态迁移里加一个分支。看起来是机械操作,但有两个隐含坑位。一个是多字符运算符的切分顺序:PL/0里有:=、<=、>=、<>这些组合,判断新符号时如果先匹配了单字符分支,可能把组合符号拦腰截断。另一个是符号冲突:有些课程设计顺手加了C风格的/* */注释,如果新运算符恰好是*附近的高位字符,注释识别会抢在前面吞掉字符流。所以加运算符之前,先翻一遍词法分析器处理读符号的代码,确认新符号和既有注释规则、组合符号不会打架。
3.2 语法层:优先级为什么用“文法分层”而不是查表
初学者常问:运算符优先级不是用一张表存的吗,为什么PL/0非要用多层文法嵌套?因为文法的嵌套深度本身就编码了优先级。expression处理加减,term处理乘除,factor处理括号和原子。越在里层的非终结符,对应运算的优先级越高。2 + 3 * 4走到term里先把乘法解析完,expression里的加法永远拿到的已经是乘完的结果。这个嵌套结构解决了两个问题:一是优先级,二是结合性。循环写法天然是左结合,a - b - c会被解析成(a - b) - c。
所以给新运算符定优先级,本质是决定把它插入哪一层。和*/同级的运算符就进term的循环,和+-同级的就进expression的循环。这一步绝对不能凭感觉,最稳妥的做法是在纸上先把EBNF文法改写出来,再把文法翻译成代码。很多人省略了第一步,直接在代码里加分支,结果%的优先级和*不一致,写8 % 3 * 2和预期结果偏离,就是这种省略的代价。
3.3 语义动作:边递归下降边发指令
语法分析和代码生成在PL/0里是同一个过程,语法分析函数在匹配到产生式的某个部分时,立刻调用emit函数往指令列表里追加一条P-code。这种“在语法规则里夹带生成动作”的手法叫语法制导翻译。以a + 2 * b为例,递归下降的展开过程:
a -> LOD a + (什么都不发,继续分析 term) 2 -> LIT 2 * (等待下一个 factor) b -> LOD b 接着 -> OPR 4(乘法) 接着 -> OPR 2(加法)得到指令序列LOD a, LIT 2, LOD b, OPR 4, OPR 2。运行时的执行顺序和指令顺序一致,先取到a,再取到2,再取到b,乘完栈里是[a, 2*b],加完得到[a + 2*b]。
这里有一个值得品味的细节:编译器在生成OPR 4时,乘法指令在编译期还不知道操作数是什么——它只是规定了“运行时从栈顶取两个数相乘”。操作数何时入栈,由后面递归分析factor时发出的LOD、LIT指令决定。这种基于栈的生成方式,让表达式求值和指令生成的顺序完全同构,也是PL/0能用手写递归下降轻松实现的核心原因。给表达式加任何新运算符,都是在某个循环里重复这个模式:先分析左右操作数,再发运算指令。
3.4 虚拟机层:一条OPR指令从发出到执行
编译出的指令列表最终交给虚拟机逐条执行。虚拟机维护一个指令计数器i和一个栈顶指针top,读一条指令就根据操作码分派。OPR是算术逻辑运算的总入口,靠指令的第二个操作数区分具体运算。
新增运算指令的常见做法是给OPR分配新的操作编号。比如经典实现里OPR 0返回、OPR 1取负、OPR 2加、OPR 3减、OPR 4乘、OPR 5除,关系运算集中在OPR 8到OPR 13。要加取模,就在词法枚举和语法分支之外,再给OPR分配一个编号14,并在虚拟机的case OPR里加对应的分支。漏改虚拟机分支的典型表现是:编译阶段没有任何报错,生成指令列表也正确,一运行就崩——因为虚拟机遇到一个它不认识的OPR编号,行为未定义。这种“编译过、运行崩”的现象,几乎每次都是四层改动不同步导致的。
4. 动手扩充:取模%、幂 ^ 和短路AND/OR的代码生成细节
4.1 取模 %:最简单扩充,四层改动一个都不能少
取模是入门级的扩充,但完整流程值得走一遍。词法层加上枚举值和识别逻辑,语法层让%和*/同级,虚拟机层给OPR分配新编号。核心是term函数的改动:
typedef enum { /* 原有符号 */ PLUS, MINUS, STAR, SLASH, /* 新增 */ PERCENT } SYMBOL; void term() { factor(); while (sym == STAR || sym == SLASH || sym == PERCENT) { SYMBOL op = sym; getsym(); factor(); if (op == STAR) { emit(OPR, 0, 4); /* 乘法 */ } else if (op == SLASH) { emit(OPR, 0, 5); /* 除法 */ } else { emit(OPR, 0, 14); /* 取模,新分配的操作编号 */ } } }虚拟机分支对应改成:
case 14: /* 取模 */ stack[top - 1] = stack[top - 1] % stack[top]; top--; break;取模最能暴露一个优先级问题:-3 % 2这种负数取模,不同语言结果符号不同——C语言里结果是-1,而数学里更常用非负余数。PL/0的课程设计通常不做复杂语义规定,但你在写文档时要说明采用的约定。另一个容易被忽略的点是除零:a % 0和a / 0一样,虚拟机会直接除零崩溃,PL/0原本没有运行时异常机制,遇到这种情况输出一个错误信息比潇洒崩溃更体面。
4.2 幂运算 ^:右结合性不是加一行分支这么简单
幂运算才是第一个真正的坎。2 * 3 * 4左结合没问题,但2 ^ 3 ^ 2数学上等于2^(3^2),是右结合。如果照抄term的循环写法,得到的会是(2^3)^2,结果从512变成64,完全错误。
右结合的实现必须改成递归:
void power() { factor(); /* 先解析左操作数 */ if (sym == POWER) { getsym(); /* 吃掉 ^ */ power(); /* 递归解析右操作数,保证右结合 */ emit(OPR, 0, 15); /* 幂运算 */ } }注意递归的顺序。左操作数先入栈,然后递归进入右侧的power(),更右边的指数先计算完成,所以最终栈里排列是[左操作数, 右操作数],这时候发幂指令,虚拟机里要写成:
case 15: /* 幂运算,注意操作数顺序 */ stack[top - 1] = pow(stack[top - 1], stack[top]); top--; break;pow的第一个参数是栈底的左操作数、第二个参数是栈顶的右操作数,写反就颠倒幂的底和指数。幂运算还会引入新问题:指数为0、负指数、结果溢出,PL/0的整数模型处理不了实数结果,所以通常限制指数为非负整数,或者直接用浮点栈模型。这里建议把课程设计的边界写清楚:支持非负整数指数,负指数和溢出统一报告运行时错误。
4.3 短路逻辑 AND/OR:控制流参与运算
逻辑运算符的扩充和前两个完全不同,因为你不能简单地把AND当作普通二元运算生成一条OPR。a AND b的短路语义是:如果a为假,整个表达式直接为假,根本不用计算b。用OPR实现普通“逻辑与”容易,但短路语义必须落到控制流上。
以AND为例的代码生成思路:先分析左操作数,生成条件跳转——左操作数为假就跳到整个表达式的“假出口”;为真则继续分析右操作数,右操作数为假同样跳到假出口。这里要用到跳转地址“回填”技术,先用占位地址发一条JPC,等右操作数分析完之后再回头把真实的跳转目标填进指令列表:
int and() { int falseAddr, endAddr; condition(); /* 生成左操作数判定 */ falseAddr = emit(JPC, 0, 0); /* 左为假,跳走;地址先占位 */ condition(); /* 生成右操作数判定 */ endAddr = emit(JMP, 0, 0); /* 右为真,跳到最终真出口 */ /* 回填:falseAddr 指向当前指令位置 */ code[falseAddr + 1] = codeLen; emit(JPC, 0, 0); /* 右为假,跳假出口 */ /* 这里再回填 endAddr */ code[endAddr + 1] = codeLen; emit(LIT, 0, 1); /* 真出口,压入 1 */ /* 假出口在下一段,压入 0 */ }这段代码昭示了运算符扩充的一个全新维度:一旦涉及短路求值,代码生成就不只是“发一条运算指令”,而是在指令列表里编排跳转路径。这也是课程设计从“加法级”迈向“控制流级”的标志。很多同学在这里翻车,不是因为不会写递归下降,而是跳转地址不够清晰——回填的本质是“先留一个洞,等后面的分析完成后再把洞填上”,代码里全是“洞”,一旦多个短路表达式嵌套,每个洞对应哪个出口就必须在纸上把指令序号标清楚。调试方法也很直接:把一个短表达式手工编译一遍,画出指令序号和跳转箭头,再和程序输出对比。
5. 避坑清单:课程设计里那些“现象—原因—解决”的血泪记录
5.1 运算符优先级错乱:8 % 3 * 2结果不对
现象:编译、运行都不报错,但表达式计算结果和数学规则不符,%的优先级高于预期或者低于预期。
原因:新增%时没有先改写EBNF文法,而是凭感觉在代码里加分支。比如把%加进了factor层,它会变成类似一元运算的高优先级符号,8 % 3 * 2被解析成8 % (3 * 2),和预期的(8 % 3) * 2完全两回事。递归下降的优先级完全由文法嵌套位置决定,和代码书写的顺序无关。
解决:动手改代码前,先把完整EBNF文法写在纸上,标注每个新运算符插在哪一层,然后对照文法逐层修改。改完用一组优先级表达式做回归测试,比如a % b * c、a + b % c、(a % b) * c,每个式子手工推一遍期望结果再运行。
5.2 负号处理:-3变成直接压入字面量
现象:对负数做运算结果偶尔正确,但出现-3^2、2^-3这类组合时优先级混乱,甚至语法报错。
原因:词法分析或factor层把-3当成一个整体,直接生成LIT -3。这个做法掩盖了“负号是一个运算符”的事实,-应该是一元取负运算,由编译器生成LIT 3; OPR 1两条指令。字面量负数方案在简单场景能糊弄过去,一旦和幂运算、函数调用混合,它的优先级就暴露问题。经典PL/0里一元取负的处理位置在factor层:遇到减号,递归分析后面的factor,再发OPR取负指令。
解决:把负号当作一元运算符处理,而不是词法上的负数字面量。检查factor函数是否处理了前导-,并且生成OPR 1。同时注意a - -b这种连着两个减号的情况,递归下降能自然处理,但报错信息要设计好。
5.3 短路求值的跳转地址回填错位
现象:AND、OR嵌套时,程序行为像玄学,有时跳过不该跳的代码,有时进入死循环,或者条件判断结果恒真恒假。
原因:多个短路表达式嵌套时,每个JPC/JMP都对应一个“假出口”或“真出口”,回填地址在代码生成结束后才补写。一旦写错了回填位置,比如把两个表达式的假出口填成了同一个地址,控制流就会飞到完全无关的指令。这类问题从编译结果文本上极难发现,因为指令序列看起来完整,但跳转目标指向错误。
解决:给指令列表加一个“打印当前已生成指令”的调试函数,每个短表达式生成完后立即打印并手工对照。用一层嵌套的a AND (b OR c)用例,把指令序号和跳转目标画成箭头图,逐条检查。另外,建议在每个短路表达式结束后用一个临时变量接收结果,把生成的指令序列分成清晰的段落,比在一大串指令里数地址可靠得多。
5.4 编译通过但运行崩:虚拟机栈越界
现象:新增运算符后编译阶段一切正常,运行时却访问到非法内存地址,或者栈顶指针变成负数,输出完全不可读的数字。
原因:最经典的是四层改动不同步——词法、语法、代码生成改了,虚拟机漏改;或者反过来,虚拟机加了新OPR分支,但语法层根本没往指令列表里发这个指令。PL/0的虚拟机对边界检查极弱,一旦top越过栈底或栈顶,它不会体面报错,只会读到栈外的垃圾数据。
解决:把整个扩充列成一张“波及清单”:词法枚举、词法分支、EBNF文法、递归下降函数、OPR编号、虚拟机OPR分支,六个格子全打上勾才允许跑测试。虚拟机里加栈边界检查,top越界时输出当前指令序号和栈顶值,这能救回大量排查时间。
5.5 符号表类型缺失:关系运算的结果被人当整数乱用
现象:扩充了逻辑运算符之后,IF (a > b) THEN的判断结果失控,或者x := (a > b) + 3这种代码编译通过、运行结果莫名其妙。
原因:经典PL/0没有类型系统,关系运算的结果是0/1,被当作普通整数压栈。逻辑运算符如果建立在这样的0/1模型上,AND、OR的行为看起来正常,但一旦允许用户把布尔结果拿去和整数做算术,语义就彻底乱了。这不是运算符本身错了,而是“没有类型”这个简化设计在运算符变多之后撑不住了。
解决:课程设计里把类型边界写清楚。最省事的做法是声明“关系运算和逻辑运算的结果只在条件表达式里有效”,然后语法层限制使用位置;进阶做法是给符号表加一个类型字段,并在赋值语句里做类型检查。后者工作量大一个量级,但这是让你的课程设计从“功能实现”升级到“语义完整”的关键一步。
6. 设计取舍:优先级表驱动还是文法分层,扩充的边界画在哪
做到这里,值得停下来想一个本质问题:PL/0为什么坚持用文法分层表达优先级,而不是像很多表达式解析器那样用一张“优先级表 + 中缀转后缀”?两种路线没有绝对优劣。表驱动的代表性算法是调度场算法,把所有运算符的优先级和结合性集中放在一张表里,表达式统一走同一个转换流程。它的优势是数据驱动,新增运算符往往只改一行表项,代码结构完全不用动。劣势是错误诊断困难,语法报错时你面对的是一条抽象的转换规则,而不像递归下降那样能清楚知道“我当时在分析那一层”。
文法分层的优势是每层非终结符就是一行清晰的语义单元:factor表示原子,term表示乘除级,expression表示加减级。调试时你能说出“我在power函数里处理右结合”,这种可读性对教学场景极其宝贵。代价是运算符一多,文法层数会膨胀。想想如果还有位运算、移位、比较运算,文法会变成七八层嵌套,每层的递归下降函数都是相似结构,维护成本指数上升。两种路线的对比:
| 维度 | 文法分层(PL/0路线) | 优先级表 + 调度场 |
|---|---|---|
| 可读性 | 每层对应一个语义单元 | 优先级数字不直观 |
| 新增运算符 | 改文法、改函数 | 加一行表项 |
| 错误诊断 | 定位到具体非终结符 | 定位到算法流程 |
| 右结合支持 | 递归实现,清晰 | 表项里加结合性字段 |
我给这个取舍的建议是:课程设计阶段,文法分层更合适;但如果你打算扩充到十种以上运算符,不妨把表达式解析器重写成“优先级爬升”的折中方案——它既保留递归下降的直觉,又让优先级数据集中在表里。扩充的边界应该画在“保持PL/0教学本质”这条线上:多做运算符、短路求值、类型检查这些语义层面的东西,少碰寄存器分配、指令选择这些工业级优化。每次动手前,先画一张“波及清单”,把词法、语法、代码生成、虚拟机四个格子标好再动代码。每个格子都要有对应的测试用例。希望帮到你。
本文还有配套的精品资源,点击获取