☰
给PL/0增加%和^:运算符优先级与结合性的完整实现指南
2026/10/10 16:07:43 网站建设 项目流程

简介:这是一份编译原理课程设计资源,围绕经典PL/0教学编译器进行系统性修改与扩充,适合高校计算机专业学生完成课程设计、准备实验报告或答辩演示。基础部分已完成两类扩充:一是新增+=和-=复合赋值运算符;二是加入Pascal风格的FOR递增与递减循环语句,步长分别固定为+1和-1。选做部分则实现了自增++、自减--运算符,以及一维数组类型;字符类型、实数类型、带返回值的函数和带参数函数作为未完成扩展方向,便于后续深入。整套资源以RAR格式压缩,共42个文件,容量约1.25MB,涵盖PL/0源程序、COD中间代码、C++工程文件、可执行程序、配置和文档说明等,目录结构清晰,方便按类型查阅。目前已有1053人在CSDN学习下载。通过对照源码与多组测试程序,可以直观理解语法分析、语义处理、中间代码生成等关键模块的实现思路,也为继续添加新语法特性提供了可扩展的工程框架,实用性和学习价值都比较高。

1. 为什么大家都在改PL/0的运算符:这个课程设计的真正价值

每学期编译原理课设计都会有一批人被 PL/0 绊住。网上流传的 PL/0 编译器工程不少,但大多数人拿回来后只会改改保留字、加个 while 循环就交差。答辩时老师随口问一句“你的运算符优先级是怎么在文法里落地的”,现场就安静了。我见过太多人栽在这一问上。真正值得做的修改是动运算符——比如给 PL/0 增加 % 取模运算和 ^ 幂运算,它逼着你把词法分析、语法分析、语义生成、虚拟机解释、边界测试整条链路走一遍,而且工作量控制在一个课程设计周期内能完成的范围。这篇笔记把改法从头到尾写清楚,包括优先级怎么设计、结合性怎么控制、虚拟机指令怎么扩展、哪些坑必须绕开。适合正在做编译原理课程设计、或者想真正搞懂“运算符在编译器里是怎么一路走到底”的人。

2. 先看明白PL/0是怎么把运算符接进编译器的:从词法到虚拟机的一条链路

2.1 运算符在PL/0里走的那条路:从字符到虚拟机指令

PL/0 是一个单遍编译的教学语言,它的结构非常典型:词法分析器把源码切成一串 token(PL/0 里叫 symbol),语法分析器用递归下降方法做自顶向下的推导,在推导过程中直接生成中间代码。中间代码不是三地址码,而是一套面向栈式虚拟机的 P-code 伪指令。生成完中间代码后,PL/0 并不像工业编译器那样链接成目标文件,而是直接在虚拟机上解释执行。这套结构虽然简单,但五脏俱全,运算符在里面走的路径尤其清晰。

先看词法层。PL/0 的运算符符号在词法分析时被映射成枚举值,经典的 PL/0 会定义这些运算符 token:

// 运算符相关的 symbol 枚举(典型 PL/0 实现) enum Symbol { PLUS, // + MINUS, // - TIMES, // * SLASH, // / EQL, // = NEQ, // # LSS, // < LEQ, // <= GTR, // > GEQ, // >= // 新增的运算符在这里扩展 MODSYM, // % POWSYM // ^ }

这段枚举是后续所有逻辑的根基。词法分析器在扫描源码字符时,遇到对应字符就返回这些 symbol;语法分析器拿到 symbol 后,根据当前推导到哪个文法位置决定是否接收;接收后立即调用中间代码生成函数,产出一条 P-code;解释器再执行这条 P-code 完成运算。这就是“运算符进编译器”的完整路径。

中间代码这边,PL/0 的运算类指令统一成 OPR 指令,后面跟一个编号来表示具体运算。比如LIT 0, 5是把常数 5 压栈,OPR 0, 2是弹出栈顶两个数做加法。以a := 3 + 4 * 5为例,语法分析器生成的是逆波兰形态的指令序列,我整理成表格方便看:

生成顺序P-code含义
1LIT 0, 3把 3 压入栈顶
2LIT 0, 4把 4 压入栈顶
3LIT 0, 5把 5 压入栈顶
4OPR 0, 4弹出栈顶两数做乘法,结果压栈
5OPR 0, 2弹出栈顶两数做加法,结果压栈
6STO 0, 0弹出栈顶存入变量 a

注意第 4 步先做乘法,第 5 步再做加法,顺序是语法分析器根据运算符优先级决定的,不是解释器决定的。解释器只负责按指令一条条执行,它不管优先级。这一点是理解 PL/0 运算符机制的关键:优先级在文法层已经消解掉了,运行期的栈式虚拟机只看到一堆已经排好序的指令。搞清楚这条链路,后面加运算符就不会瞎改。

2.2 相比改保留字和加语句,改运算符的性价比最高

做 PL/0 课程设计,改动方向有无数种,但不同方向的对训练覆盖面和答辩亮点很不一样。我按实际工程改动量做个对比:

改动方向涉及层典型工作量答辩亮点
改保留字词法分析、符号表半天低,基本是查表加一项
加一种语句(如 for)词法、语法、语义2 天中,语法树和跳转逻辑要讲清楚
改运算符(如加 % 和 ^)词法、文法、优先级、中间代码、虚拟机、测试3 到 5 天高,能讲结合性、右递归、栈深、指令扩展
改类型系统(如加 float)全链路一周以上高,但容易失控

改保留字是最常见的偷懒做法。改动确实小,因为保留字在词法层就是查一张表,查到就把标识符 token 替换成对应保留字 token,语法层加一个分支就行。但你不用动文法、不用动优先级、不用动中间代码,老师一问就露馅。

加语句(比如 for 循环)比改保留字强,因为要处理跳转指令和循环嵌套,但它不涉及运算符优先级和结合性的核心概念。而改运算符恰恰是“牵一发动全身”的典型:词法层要识别新符号,文法层要重排优先级,语法层要决定左结合还是右结合,代码生成层要输出新的 OPR 子码,虚拟机解释器要新增执行分支,测试要覆盖优先级、结合性、边界值。这一套走下来,编译原理里“语法导向翻译”和“运行期语义”两座山都爬过了。

所以我的结论很直接:如果你的课程设计目标是“在有限时间里把一个改动做完整、做闭环”,改运算符是性价比最高的选择。接下来几章就按实际动手的顺序,把 % 和 ^ 的扩展完整做一遍。

3. 动手改:给PL/0加%和^,优先级、结合性与文法设计

3.1 文法先行:EBNF改造与词法新增符号

改运算符的第一步不是写代码,而是改文法。PL/0 原版表达式文法用 EBNF 表达大概是这个样子:

<表达式> ::= <项> { <加减运算符> <项> } <项> ::= <因子> { <乘除运算符> <因子> } <因子> ::= <标识符> | <整数> | ( <表达式> )

这套文法里,优先级靠“谁先被推导”来体现:表达式层只能接收加减,项层只能接收乘除,因子层接收标识符、整数和括号。所以3 + 4 * 5在推导时,4 * 5必须先归约成一个项,然后才能参与加减运算——乘除自然高于加减。

现在要加入%取模和^幂运算。这里首要问题是优先级怎么排。做运算优先级设计时我遵循两个直觉:取模与乘除同级,因为%本质是除法家族的运算;幂运算高于乘除,且幂是右结合的,因为数学里2^3^2约定为2^(3^2)而不是(2^3)^2。按这个设计,修改后的文法为:

<表达式> ::= <项> { <加减运算符> <项> } <项> ::= <幂> { <乘除模运算符> <幂> } <因子> ::= <因数> [ ^ <因子> ] <因数> ::= <标识符> | <整数> | ( <表达式> )

这里把一个细节说透:原来没有“因数”这个词法层,因子就是原子操作数。现在把^单独拆出来放在因子层,意思是“因子可以是一个幂表达式,也可以是操作数后面跟 ^ 再跟因子”,这样^的优先级就被抬到乘除之上。而取模%和乘除*/被放在项层,用{ ... }循环迭代实现,天然是左结合。

文法改完,词法层要同步新增两个符号。PL/0 原版字符集里没有%和^,词法分析器遇到这两个字符时会直接报“非法字符”。修改词法扫描函数,在get_sym里增加分支:

// 词法分析器新增符号识别,按字符逐个扫描 switch (ch) { case '%': sym = Symbol.MODSYM; getChar(); break; case '^': sym = Symbol.POWSYM; getChar(); break; // 原有 + - * / < > = # 分支保持不变 default: error(ILLEGAL_CHAR); // 非法字符 }

这段代码的逻辑是:扫描器看到%或^时,不再报错,而是直接返回新增的运算符 symbol,并吞掉当前字符。参数上不需要额外状态,因为它和+、*一样是单字符运算符,没有<=、>=这类两字符组合。有一点要注意,如果你的源码里%同时可能被用于注释符(有些 PL/0 扩展用%写注释),必须先处理注释再处理运算符,否则注释内容会被当成取模表达式,那个报错信息会非常难排查。

我建议用符号%和^而不是保留字MOD和POW,原因是挂保留字要动保留字表、要动标识符查找逻辑、要考虑用户用MOD做变量名时的兼容处理,改动面大了一圈。符号方案只要词法层加两个字符分支就行,对原有代码打扰最小。代价是错误提示不够友好,比如用户漏写右操作数时,报错信息里看不到MOD字样,只有“缺少因子”。这是可以接受的折中。

3.2 递归下降:怎么写Factor和Power让优先级落到代码上

文法定稿后,语法分析器这边要动手改函数。PL/0 的递归下降版本里,表达式、项、因子各自对应一个函数:expression()、term()、factor()。原版factor()直接处理标识符、整数和括号,现在要把^和取模塞进去,需要拆出一个新的幂层级。我重写后的核心代码如下:

// 表达式层:只处理加减 void expression() { term(); while (sym == Symbol.PLUS || sym == Symbol.MINUS) { // 记录运算符,然后解析右操作数 Symbol op = sym; getSym(); term(); emitOp(op); // 生成对应的 OPR 指令 } } // 项层:处理乘、除、取模 void term() { factor(); while (sym == Symbol.TIMES || sym == Symbol.SLASH || sym == Symbol.MODSYM) { Symbol op = sym; getSym(); factor(); emitOp(op); } } // 幂层:右结合,优先于乘除 void factor() { power(); if (sym == Symbol.POWSYM) { getSym(); factor(); emitOp(Symbol.POWSYM); } } // 因数层:真正的原子操作数 void power() { if (sym == Symbol.IDENT) { // 变量:查符号表、生成 LOD 指令 } else if (sym == Symbol.NUMBER) { // 整数:生成 LIT 指令 } else if (sym == Symbol.LPAREN) { getSym(); expression(); // 这里要检查右括号 if (sym != Symbol.RPAREN) { error(MISSING_RPAREN); } } else { error(EXPECT_FACTOR); } }

关键在factor()和power()的分工。factor()现在的职责变得很窄:它只负责判断“当前这个因子是不是幂运算的底数”。先调用power()读底数,然后看下一个符号是不是^;如果是,就递归调用factor()读指数。因为factor()调用自身是在^右侧,递归的方向是从右往左,所以2^3^2被解析成2^(3^2),这就是右结合的本质。

term()里的取模和乘除走同一个 while 循环,意味着它们的优先级一样,而且循环体是左到右迭代,结合性也是左结合。这里有个常见的认识误区:优先级和结合性在递归下降里是两回事,优先级决定函数嵌套的深度,结合性决定同一层里多个同级运算符的计算顺序。想验证这个结论,可以自己写一个2 % 3 * 4的推导过程:取模和乘除同级,按左结合先算取模再算乘法。

3.3 中间代码形状:^和%在P-code里怎么编

语法分析器拿到运算符后,调用emitOp()生成中间代码。传统 PL/0 把运算统一编码成OPR 0, n,其中 n 是子码。我扩展子码时把取模和幂排到原有编码之后:

子码运算生成位置
1取负factor() 里负号处理
2加法expression()
3减法expression()
4乘法term()
5除法term()
6取模term(),新增
7幂factor(),新增
8奇数判断factor() 里 odd

生成指令的代码在一段 switch 里,以取模为例:

void emitOp(Symbol op) { switch (op) { case MODSYM: gen(OPR, 0, 6); // 取模子码 break; case POWSYM: gen(OPR, 0, 7); // 幂子码 break; // 其余原有运算分支不变 } }

生成出来的 P-code 顺序遵循逆波兰形态。以8 % 3为例,生成的指令是LIT 0, 8、LIT 0, 3、OPR 0, 6,执行时弹出栈顶两数,8在栈底、3在栈顶,先弹3作为右操作数,再弹8作为左操作数。这个弹出的顺序对取模和除法尤其重要——取模不满足交换律,弹反了会把8 % 3算成3 % 8,结果完全不同。后面虚拟机实现时我会再强调这个顺序约定。

到这里,词法、文法、代码生成三层都已改完,但编译器还不能跑,因为解释器遇到子码 6 和 7 时会直接报“未知运算”。下一章把虚拟机补齐。

4. 落了地:扩展解释器与虚拟机的指令,让新运算符真正跑起来

4.1 指令扩展与解释器实现

解释器是最后一道关卡。PL/0 的解释器主体是一个大 switch,根据OPR指令的子码分发到不同运算分支。新增取模和幂,就是在 switch 里补两个 case。这里给出 Java 版本的核心实现,C 语言版本逻辑完全相同:

// PL/0 虚拟机解释器:OPR 指令执行分支(Java 版) case 6: // 取模运算 int divisor = stack[top--]; // 栈顶先弹出的是除数 int dividend = stack[top--]; // 再弹出的是被除数 if (divisor == 0) { error("Division by zero in MOD operator"); } // 余数符号跟随被除数,和 Java 的 % 行为一致 stack[++top] = dividend % divisor; break; case 7: // 幂运算,指数必须为非负整数 int exponent = stack[top--]; // 栈顶先弹出的是指数 int base = stack[top--]; // 再弹出的是底数 if (exponent < 0) { error("Negative exponent not supported in PL/0"); } int result = 1; for (int i = 0; i < exponent; i++) { result *= base; // 乘法溢出检查:结果超过 int 范围时给出明确报错 if (result > Integer.MAX_VALUE / (base == 0 ? 1 : base)) { error("Integer overflow in POW operator"); } } stack[++top] = result; break;

先看取模分支。弹出顺序沿用了除法的惯例:先弹出的divisor是右操作数,后弹出的dividend是左操作数,对应8 % 3中的8和3。如果divisor == 0必须报错,因为在 Java 里整数取模遇到 0 会抛ArithmeticException,如果不拦截,解释器会在运行期崩溃,而不是给出编译器的友好错误信息。

幂运算有几个边界要处理。指数为负数时,PL/0 只有整数类型,没法表示小数结果,直接报错是最安全的。指数为 0 时,任何底数的 0 次幂都是 1(除 0 的 0 次幂在数学上无定义,但 PL/0 层面可以接受结果为 1 的约定)。溢出检查放在循环里,每次乘完判断下一次乘法是否可能超界。一个容易漏掉的问题:底数为 0 时,Integer.MAX_VALUE / (base == 0 ? 1 : base)会除零,这个三元判断就是专门规避这个边界。建议实际提交的版本里把溢出的上限放宽到Integer.MAX_VALUE,因为课程设计的评分不会真的测大数幂,但会测 0 的 0 次方之类的边界输入。

4.2 新指令入栈和出栈的约定

栈式虚拟机的运算指令要统一遵守“后进先出,先弹右操作数”的约定。这句话听起来简单,但实操中很多翻车都出在这里。我用一个带中间状态的栈跟踪表来说明9 % 5 + 3的完整执行过程:

执行步骤指令栈内容(栈顶在最右)说明
1LIT 0, 9[9]压入被除数
2LIT 0, 5[9, 5]压入除数
3OPR 0, 6[4]弹出 5、9,计算 9%5,压入 4
4LIT 0, 3[4, 3]压入加数
5OPR 0, 2[7]弹出 3、4,计算 4+3,压入 7

第 3 步里,解释器先从栈顶弹出5,这个5是除数;再弹出9,这个9是被除数。如果你在改代码时不小心把弹出顺序写反,9 % 5会算成5 % 9,结果是 5 而不是 4,而且这种错误不会报任何异常,只在最终结果上差一个数。这是最阴的错——它不崩,只是算错。

4.3 最小测试程序:从词法到执行一次验证

改完词法、语法、解释器之后,第一件事不是写大用例,而是用最小程序把每条新路径跑通。我一般先写这样一份测试源码:

// test_mini.pl0:覆盖新增运算符的最小测试 var a, b; begin a := 17 % 5; // 期望 2 b := 2 ^ 3; // 期望 8 a := a * b % 4; // 期望 0 b := 2 ^ 3 ^ 2; // 期望 256(右结合),如果是 (2^3)^2 则得到 64 end.

第一行验证取模的基本路径,期望结果 2;第二行验证整数幂,期望 8;第三行混合乘法和取模,验证同级左结合,16 % 4得到 0;第四行验证幂的右结合,2^(3^2)等于 2 的 9 次方是 512,等等——我这里写错了。2^3^2按右结合是2^(3^2)等于 2 的 9 次方等于 512,按左结合是(2^3)^2等于 8 的 2 次方等于 64。我刚才把自己绕进去了,实际验证时以你编译器的输出为准,数学上2^(3^2) = 512才是右结合的正确结果。

如果解释器带 trace 模式,把生成的 P-code 打出来,2 ^ 3 ^ 2应该生成这样的指令序列:先把 2 压栈,然后把 3 压栈,递归读入第二个^,把 2 压栈,执行OPR 0, 7得到 9,再执行外层的OPR 0, 7得到 512。指令顺序里,内层幂的 OPR 出现在外层幂之前,这就是右结合在指令序列上的特征。看到这个顺序,基本可以断定结合性实现对了。

5. 避坑:运算符扩充最常见的五个翻车现场

5.1 词法层翻车:%和^被当成非法字符

现象:编译a := 17 % 5,词法分析器直接报错“非法字符”并停止编译。原因:词法分析的字符扫描 switch 里没有%的分支,源码里遇到%时落入 default 分支。解决办法是在词法层新增两个字符分支,返回MODSYM和POWSYM。有一个容易被忽略的位置:如果你的 PL/0 版本里有“空白符过滤”和“注释跳过”逻辑,要确认新增符号的分支写在注释处理之后,否则%如果同时也是注释符(某些扩展版本用%做行注释标记),注释后面的代码会被误吞。解决后建议用一个只含1 % 1的最小文件验证词法层单独工作。

5.2 优先级翻车:加了幂之后乘法顺序全乱

现象:2 + 3 * 4 ^ 2期望结果是 50(先算 4^2=16,再算 316=48,加 2),实际算成 98(先把 34 算成 12,再算 12^2=144,这明显超出预期形态——总之结果不对)。原因:语法分析阶段没有把^放到比乘除更高的层级,而是和图元操作数放在了同一层,导致乘法先参与运算。解决办法是回到文法,把“幂层”从因子层里独立出来,让factor()先进入幂层再往下读底数。这里我自己的教训是:不要只在代码里调 switch 分支顺序来“修”优先级,优先级必须体现在文法嵌套上,代码里的分支顺序救不了文法层级的缺陷。

5.3 结合性翻车:2^3^2 从左往右算了

现象:2 ^ 3 ^ 2输出 64,而数学上约定右结合应该是 512。原因:factor()里处理^时,用了循环迭代的方式连续读取右操作数,生成的是(2^3)^2的指令顺序,也就是左结合。解决办法:把幂的解析改成右递归,也就是读完底数后,如果下一个符号是^,递归调用factor()处理指数部分。写递归下降时“左递归做左结合、右递归做右结合”这句话是铁律,幂运算作为右结合运算符,代码形态必须是递归而非迭代。

5.4 虚拟机层翻车:取模遇到除零直接崩溃

现象:执行a := 17 % 0,解释器抛异常退出,或者栈上出现脏数据。原因:解释器执行OPR 0, 6时没有检查除数是否为 0。Java 里的整数取模对除零会抛ArithmeticException,C 语言里则是未定义行为,可能得到 0 或直接段错误。解决办法:在 MOD 和 DIV 分支里都加上除数检查,报错后终止解释器,并且保证错误信息里能定位到是哪个运算符、哪个源码位置。一般 PL/0 错误处理会用一个error()函数,可以传入当前行号,这样调试定位快很多。

5.5 回归漏网:改完运算符后老程序行为变化

现象:原有的 PL/0 测试程序(比如阶乘、最大公约数)能编译,但输出结果和改造前不同。原因:改动文法层级时,无意中影响了原有运算符的优先级或结合性。最常见的是在term()里加入MODSYM时,把TIMES和SLASH的处理逻辑顺带改动,导致乘法从左结合变成右结合或优先级被抬高。解决办法:保留一份改造前的解释器,把原有测试程序分别跑一遍,对比输出结果。这个对比最好用脚本自动化,下一章详细说回归测试的做法。

6. 进阶:如何验证运算符改造没有破坏原文法——回归测试与错误注入

6.1 改造前后输出对比:同一批 PL/0 程序的 diff 必须为空

运算符改造最大的风险是破坏原有运算符的优先级。我之前吃过这个亏:给term()加MODSYM时手滑改了循环的退出条件,结果乘法从左结合变成了右结合,所有 2 个以上乘法的表达式全部算错,而且不报错。靠眼睛盯输出根本盯不出来。后来我养成了一个习惯,做一个回归测试脚本,把改造前的解释器(比如pl0_old.jar)和改造后的解释器(pl0_new.jar)对同一批程序跑一遍,用 diff 对比输出:

#!/bin/bash # regression_test.sh:PL/0 运算符改造回归测试 TEST_DIR="test_cases" for src in "$TEST_DIR"/*.pl0; do name=$(basename "$src" .pl0) java -jar pl0_old.jar "$src" > "out_old_$name.txt" 2>&1 java -jar pl0_new.jar "$src" > "out_new_$name.txt" 2>&1 if diff -q "out_old_$name.txt" "out_new_$name.txt" > /dev/null; then echo "[PASS] $name" else echo "[FAIL] $name" diff "out_old_$name.txt" "out_new_$name.txt" fi done

这个脚本会遍历test_cases目录下的所有.pl0程序,分别用旧版和新版解释器执行,把标准输出和标准错误都重定向到文件,再用 diff 对比。2>&1很关键,它把错误信息也捕获进来,否则程序编译报错时你会误以为输出为空正常通过。测试用例集至少要包含:加减法混合、乘除法混合、括号嵌套、负号处理、变量赋值、循环里的表达式,这些是原有运算符迁移的底线;再加上新增运算符的优先级和结合性用例。

6.2 错误注入:故意写错运算符拼写和缺操作数,验证报错不崩溃

回归测试通过只说明“原来的功能没坏”,还不能证明“新功能在异常输入下表现正确”。还要做一批错误用例,故意写错,看编译器报错是否清晰、是否崩溃。我常犯的错误是漏写操作数:a := 5 %后面直接跟分号,这种输入最容易让递归下降死循环或者让虚拟机栈泄漏。错误用例集我一般这么设计:

测试输入期望行为
a := 5 % ;报“缺少因子”并停止编译,不崩溃
a := 2 ^;报“缺少因子”并停止编译,不崩溃
a := 2 ^ -3;报“不支持负指数”或语法错误
a := 5 % 0;报“除零错误”
a := (5 % 3报“缺少右括号”并停止编译
a := % 3;报“缺少因数”而不是非法字符

一个容易被忽略的点:2 ^ -3这类输入在语法层是合法的(幂的右侧是一个负因子),真正该报错的是解释器执行阶段。所以设计错误用例要区分“编译期应该拒绝的”和“运行期应该拒绝的”,混在一起会掩盖文法缺陷。我把错误用例单独放在error_cases目录,跑的时候不参与 diff 回归,只验证进程退出码非零且不崩溃。

从那以后我每次改完运算符,都强制把回归脚本和错误用例完整跑一遍再交——有过一次在答辩前夜靠回归脚本抓出自己把幂结合性写反的翻车,那次之后我彻底信了“改动越小越要自测”。这套流程不复杂,但能挡住 90% 的运算符改造事故,希望帮到你。

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

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

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

立即咨询