简介:PL0-Compiler 是山东大学编译原理课程实验的完整工程,面向计算机专业学生及自学者,解决 PL/0 教学语言编译器前端的实现问题。工程覆盖词法分析、语法分析、语义分析与符号表管理,配有 BNF 文法定义和抽象语法树构建思路,实现过程中涉及词法规则设计、语法检查及作用域维护,适合对照课程要求逐段阅读与调试。压缩包共 94 个文件,约 513KB,主体为 C/C++ 源码、CMakeLists 构建脚本、in/out 测试用例、png 运行截图及 docx 实验报告,目录层次清晰,便于按模块学习。已有 104 人学习浏览。内容包含可运行的 PL/0 编译器前端代码、实验结果截图与项目组织方式,可帮助复现实验、理解中间表示构造,并为后续后端代码生成提供扩展基础,也可用于课程设计或小型语言前端开发起步。
1. 为什么 PL0-Compiler 这个实验值得从零手写一遍
编译原理长期是“课堂听得懂、作业写不动”的代表,PL0-Compiler 山东大学SDU编译原理课程实验正好把这层窗户纸捅破:它不是让你读源码,而是让你在一个学期内,把词法分析、语法分析、语义分析与代码生成完整走一遍。PL/0 是 Pascal 作者 Wirth 设计的教学语言,去掉类型系统、数组和指针,只保留分程序嵌套、变量、常量、过程和几条控制语句。你需要为它手写词法扫描器、递归下降解析器和 P-Code 解释器,最终把一个 .pas 文件翻译成能在栈式虚拟机上执行的指令流。做完这轮实验,你对“编译器到底在干什么”会有从字符到指令的完整图景。这篇文章只讲一件事:用最少的概念、最稳的路线把它写通,并告诉你哪些地方最容易卡住。
2. 把 PL/0 实验当成一条编译流水线来拆:语言定义、四阶段与工程组织
在第一轮动手之前,先别急着敲 main 函数。编译原理的难点不在单个函数,而在整条流水线的接口设计。PL/0 实验的工程边界很小,但每个阶段的输入输出必须清楚,否则后面改一处坏三处。这一章把语言定义、四个阶段的职责和最小工程结构一次讲完。
2.1 PL/0 到底定义了什么语言:一个分程序能做的事
PL/0 的结构模型是“分程序嵌套”。一个完整的程序由一个分程序(block)加上结尾的点号组成,分程序内部依次是:常量定义、变量定义、过程定义和语句体。常量定义放在最前面,用逗号分隔多个名字;变量定义紧随其后;过程定义可以写多个,每个过程内部又是一个完整的 block,所以天然支持嵌套。最后是语句体,也是程序真正执行的部分。
一个能跑通全流程的示例程序如下:
const max = 10; var a, b; procedure swap; var temp; begin temp := a; a := b; b := temp end; begin a := 5; b := 7; call swap; write(a + b) end.这段程序覆盖了大部分核心语法:常量、变量、过程定义、过程调用、赋值、begin-end 复合语句和 write。你注意看,过程 swap 的语句体里 a、b 都是外层变量,而 temp 是过程内部变量;这要求符号表能区分作用域层级。如果后面能打印出 swap 执行前后的 a、b,就说明作用域和赋值都写对了。
PL/0 刻意只保留一种整数类型,所以没有类型检查环节,词法和语法分析被极大简化。但分支、循环、过程调用这些控制流机制一个不少。换句话说,它把编译原理里“与类型和优化无关”的骨架全部保留了下来,这也是为什么国内高校的实验普遍用它打底;山东大学SDU编译原理课程实验就是这条路线上的典型任务。如果你手边的教材是编译原理清华大学出版社第三版,PL/0 的语言定义和书里第二章以后的内容能一一对应,做实验时遇到理论问题可以直接回书里查。
2.2 词法、语法、语义、代码生成:四阶段怎么塞进一个实验
很多学生以为四个阶段是四个独立程序,实际上在 PL/0 里它们是四个模块,按顺序在一个进程里完成。每个阶段的输入输出很明确,见下表:
| 阶段 | 输入 | 输出 | PL/0 实验里的形态 |
|---|---|---|---|
| 词法分析 | 源程序字符流 | Token 序列 | Lexer::next(),逐个返回 Token |
| 语法分析 | Token 序列 | 语法结构 | 递归下降函数调用链体现嵌套关系 |
| 语义分析 | 符号表 + 文法规则 | 带作用域层级的变量地址 | 符号表 enter/查找、重复声明检查 |
| 代码生成 | 语法分析中的语义动作 | P-Code 指令序列 | emit() 往指令数组里追加指令 |
| 解释执行 | P-Code 指令序列 | 程序运行结果 | Interpreter 按 PC/SP/BP 执行 |
PL/0 采用典型单遍编译:语法分析一边用递归下降读 Token,一边直接产出 P-Code,不先构造完整的抽象语法树再单独遍历。这对刚学编译原理的人是个好消息,代码量能减少三分之一;坏处是语义动作和语法规则缠在一起,写的时候必须心里清楚“当前这一句生成哪几条指令”,否则代码生成很容易写散。我一般会在每个语法函数里用注释标明它对应的产生式,例如 parseExpression 入口写// expression -> [ '+' | '-' ] term { ... },这样定位问题快得多。
四个阶段里最容易低估的是“符号表贯穿始终”这件事。常量定义要登记值,变量定义要分配层级和栈偏移,过程定义要记入口地址,语句体所有标识符引用都要能在符号表里查到。后面的避坑章节里,一大半问题都出在符号表的状态没管好,而不是递归下降本身写不出来。
2.3 工程怎么拆:最小文件组织与一条构建命令
我见过不少同学把所有代码塞进一个 main.cpp,写到语法分析阶段函数已经上千行,变量名互相打架。PL/0 虽然小,也建议一开始就拆成几个文件,单独调试。最小可用的拆分如下:
pl0/ ├── lexer.h / lexer.cpp # 词法分析,产出一个 Token 流 ├── symtab.h / symtab.cpp # 符号表,维护作用域层级 ├── parser.h / parser.cpp # 递归下降语法分析 + P-Code 生成 ├── interp.h / interp.cpp # P-Code 解释执行 ├── main.cpp # 读文件、串联各阶段 └── examples/ # 放 PL/0 测试程序词法分析器暴露一个 next() 方法,每次返回一个 Token;语法分析器构造时接收 Lexer 引用和符号表引用,执行 parseProgram(),完成后内部指令数组就绪;解释器拿这个指令数组运行。main.cpp 只做三件事:读整个源文件、创建 Lexer、调用 Parser,最后进入 Interpret。这样每一层都能单独写测试程序,Lexer 可以只打印 Token 序列,Parser 可以只输出指令序列。
构建命令用 g++ 一条写完:
g++ -std=c++17 -g -Wall -O0 main.cpp lexer.cpp symtab.cpp parser.cpp interp.cpp -o pl0 ./pl0 examples/swap.pas参数说明:-std=c++17启用现代 C++ 特性,例如 string_view 和 enum class,避免和 C 风格代码混在一起;-g保留调试信息,出问题时可以直接 gdb 断点跟踪 Parser 和 Interpreter;-O0关闭优化,保证指针和栈帧行为可预期;-Wall会提示未使用变量、符号比较这类低级问题。如果你更习惯 Java,用 Java 实现同样顺理成章,核心是不变的,只是把 Lexer、Parser、Interpreter 的类接口按同样的方式拆开。文件拆完,你后面改作用域或者加新指令时,就不用在两三千行的一个文件里滚动找函数了。
3. 词法分析器:手写 Token 扫描,是整个编译流水线的第一道关口
词法分析往往被当成“最简单的部分”,很多人半天写完就不管了。实际上它是错误信息的第一道关卡:这里识别错一个符号,后面语法分析会报出莫名其妙的位置。PL/0 词法规则少,但手写一遍仍然值得,因为它能让你看清字符流到 Token 流的转换本质。
3.1 手写还是 flex 生成:课程实验里我会坚持手写
flex 一类的生成器可以在几行规则里产出完整的扫描程序,对商业编译器前端是高效做法。但在课程实验里,我不建议直接用 flex。原因很简单:实验要求你理解 DFA 与手工扫描之间的关系,生成器会把“识别到底发生在哪一步”变成一个黑匣子,出了问题你连现象都描述不清。手写的话,整个识别循环不超过两百行,所有状态转换都是显式代码,跑一个十六进制 dump 就能定位错误。
从工程角度看,PL/0 的符号只有几十个,用 flex 反而要维护生成代码和构建依赖。手写的代价很低,收益是每一步都能断点。对比一下更清楚:
| 对比点 | 手写扫描 | flex 生成 |
|---|---|---|
| 代码量 | 约 150~250 行 | 规则少,但生成代码不可读 |
| 调试 | 可单步追踪每个分支 | 出错时黑匣子,需要看生成代码 |
| 扩展新符号 | 加一个 case 即可 | 改规则并重新生成 |
| 课程实验契合度 | 与 DFA 理论一对一 | 容易绕过理论 |
如果你是冲着完成实验去的,手写也是提交时最稳妥的版本,老师追问实现细节你答得上来的概率高得多。
3.2 Token 定义与扫描循环:一个最小可用的 Lexer
先定义 Token 枚举。PL/0 的符号分成几类:数字、标识符、算术符、括号、赋值符、分隔符、比较符和文件结束。用 enum class 而不是普通常量的好处是类型安全,switch 分支少写错。
enum class TokenKind { // 字面量与标识符 TK_NUMBER, TK_IDENT, // 算术与括号 TK_PLUS, TK_MINUS, TK_MUL, TK_DIV, TK_LPAREN, TK_RPAREN, // 声明与语句分隔 TK_CONST, TK_VAR, TK_PROCEDURE, TK_BEGIN, TK_END, TK_IF, TK_THEN, TK_WHILE, TK_DO, TK_CALL, TK_READ, TK_WRITE, TK_ODD, // 赋值与标点 TK_ASSIGN, TK_COMMA, TK_SEMI, TK_DOT, // 比较:= # < <= > >= TK_EQ, TK_NEQ, TK_LT, TK_LE, TK_GT, TK_GE, TK_EOF };每个 Token 还需要附带值和源位置,这里用整数保存数字值,用字符串保存标识符原文,行号用于报错。
struct Token { TokenKind kind; int value = 0; // TK_NUMBER 时有效 std::string text; // TK_IDENT 或保留字 int line = 1; };扫描循环的核心是“跳过空白 → 按首字符分流 → 识别完整词法单元”。下面的代码省略了类定义和错误处理细节,把识别逻辑完整展示出来:
Token Lexer::next() { while (pos_ < src_.size()) { char c = src_[pos_]; if (c == ' ' || c == '\t') { ++pos_; continue; } if (c == '\n') { ++line_; ++pos_; continue; } break; } if (pos_ >= src_.size()) return {TokenKind::TK_EOF, 0, "", line_}; char c = src_[pos_]; if (isIdentStart(c)) { size_t begin = pos_; while (pos_ < src_.size() && isIdentChar(src_[pos_])) ++pos_; std::string text(src_.substr(begin, pos_ - begin)); return makeIdentToken(text, line_); } if (isdigit(c)) { size_t begin = pos_; int val = 0; while (pos_ < src_.size() && isdigit(src_[pos_])) { val = val * 10 + (src_[pos_] - '0'); ++pos_; } return {TokenKind::TK_NUMBER, val, "", line_}; } ++pos_; switch (c) { case '+': return {TokenKind::TK_PLUS, 0, "", line_}; case '-': return {TokenKind::TK_MINUS, 0, "", line_}; case '*': return {TokenKind::TK_MUL, 0, "", line_}; case '/': return {TokenKind::TK_DIV, 0, "", line_}; case '(': return {TokenKind::TK_LPAREN, 0, "", line_}; case ')': return {TokenKind::TK_RPAREN, 0, "", line_}; case ',': return {TokenKind::TK_COMMA, 0, "", line_}; case ';': return {TokenKind::TK_SEMI, 0, "", line_}; case '.': return {TokenKind::TK_DOT, 0, "", line_}; case '=': return {TokenKind::TK_EQ, 0, "", line_}; case '#': return {TokenKind::TK_NEQ, 0, "", line_}; case '<': if (pos_ < src_.size() && src_[pos_] == '=') { ++pos_; return {TokenKind::TK_LE, 0, "", line_}; } return {TokenKind::TK_LT, 0, "", line_}; case '>': if (pos_ < src_.size() && src_[pos_] == '=') { ++pos_; return {TokenKind::TK_GE, 0, "", line_}; } return {TokenKind::TK_GT, 0, "", line_}; case ':': if (pos_ < src_.size() && src_[pos_] == '=') { ++pos_; return {TokenKind::TK_ASSIGN, 0, "", line_}; } error("expect '=' after ':'"); return {TokenKind::TK_EOF, 0, "", line_}; default: error("unexpected character"); return {TokenKind::TK_EOF, 0, "", line_}; } }逻辑说明:每次从当前位置开始,先跳过空白并维护行号,然后看首字符决定走哪条识别路径。标识符和数字都要求读满整个连续段,不能见一个字符就返回。双字符运算符<=、>=、:=必须做两个字符的预读,否则会把<=拆成<和=,语义完全错乱。这里的 Token 编号不用和书上的什么全局常量对上,保持内部一致即可。
参数说明:isIdentStart 我定义为字母或下划线,PL/0 原始定义里并没有下划线,加上下划线只是为了变量命名更自然,如果你严格按教材走可以把下划线去掉。数字识别用val * 10累加,示例代码没做溢出保护,但在实验阶段建议在累加前检查val > (INT_MAX - digit) / 10,否则一长串数字会变成负数,这种 bug 特别难发现。行号 line_ 在每个'\n'处加一,报错信息里就能精确到第几行。
3.3 保留字识别与错误恢复:两个容易被低估的细节
保留字在 PL/0 里不是特殊识别的。标准做法是先把字母串整体读成标识符,再查一张保留字表,命中就转成对应的 TokenKind,否则当普通标识符。这样写的好处是扫描循环不用区分“这是不是保留字开头”,语句结构整齐。
static const std::unordered_map<std::string, TokenKind> kKeywords = { {"const", TokenKind::TK_CONST}, {"var", TokenKind::TK_VAR}, {"procedure", TokenKind::TK_PROCEDURE}, {"begin", TokenKind::TK_BEGIN}, {"end", TokenKind::TK_END}, {"if", TokenKind::TK_IF}, {"then", TokenKind::TK_THEN}, {"while", TokenKind::TK_WHILE}, {"do", TokenKind::TK_DO}, {"call", TokenKind::TK_CALL}, {"read", TokenKind::TK_READ}, {"write", TokenKind::TK_WRITE}, {"odd", TokenKind::TK_ODD} }; Token makeIdentToken(const std::string& text, int line) { auto it = kKeywords.find(text); if (it != kKeywords.end()) return {it->second, 0, text, line}; return {TokenKind::TK_IDENT, 0, text, line}; }参数说明:kKeywords 是 static const,进程内只初始化一次;unordered_map 查找是常数期望时间,对 PL/0 这种小规模语言性能完全够用。如果你不想引入 map,写成 if-else 字符串比较链也可以,只是分支多看起来乱。
第二个容易被低估的是错误恢复。词法分析器遇到非法字符或者冒号后面没跟等号时,很多实现直接 exit(1),导致用户只能看到第一个错误,改完再跑又报下一个。更好的做法是记下错误位置和描述,跳过当前非法字符继续扫描,最后让 main.cpp 统一汇报所有错误。这样调试多错时效率高很多。控制好恢复粒度,别因为出错就循环无限;只要每次至少消费一个字符,就不会卡死。
如果你想把理论和代码对应起来,对照编译原理清华大学出版社第三版第二章的有限自动机内容,会发现这个手写扫描器本质上就是一个 DFA:标识符识别、数字识别、双字符运算符预读,对应的就是状态转移图上的各个状态。理解了那套理论,你以后写 JSON、SQL 的扫描器也能直接复用这一套逻辑。
4. 递归下降语法分析与语义动作:PL/0 实验的核心工作量
词法分析只是把字符变成 Token,真正的语法树结构要靠递归下降搭起来。这一章是 PL/0 实验里代码量最大、也最锻炼人的部分。顺着 EBNF 文法往下写,每个非终结符就是一个函数,写完文法也就写完了。
4.1 EBNF 与递归下降的对应关系:每个非终结符一个函数
用 EBNF 描述 PL/0 文法,这就是后续所有代码的蓝图:
program = block "." . block = [ "const" ident "=" number { "," ident "=" number } ";" ] [ "var" ident { "," ident } ";" ] { "procedure" ident ";" block ";" } statement . statement = [ ident ":=" expression | "call" ident | "begin" statement { ";" statement } "end" | "if" condition "then" statement | "while" condition "do" statement | "read" ident | "write" expression ] . condition = "odd" expression | expression ( "=" | "#" | "<" | "<=" | ">" | ">=" ) expression . expression = [ "+" | "-" ] term { ( "+" | "-" ) term } . term = factor { ( "*" | "/" ) factor } . factor = ident | number | "(" expression ")" .这份文法是经典的 Wirth 版 PL/0,注意 statement 用方括号包起来,表示空语句也合法。递归下降的核心动作是:看到非终结符就调用同名函数,看到终结符就检查当前 Token 是否匹配并消费它。例如 program 对应的 parseProgram 就是先 parseBlock,然后期待一个点号。
为什么不需要回溯?因为文法的每个产生式都可以用当前 Token 的 FIRST 集合决定走哪个分支。statement 的多个分支,开头的关键词分别是标识符、call、begin、if、while、read、write,互不冲突,只看一个 Token 就足够。这就是 LL(1) 分析的直观含义。你不需要把 FIRST 集合都手工算出来,但要知道:如果某天你写了一条两个分支都以同一类 Token 开头的文法,就必须做左因子提取,否则递归下降会选错分支。
4.2 表达式与因子的递归下降:优先级靠调用层级体现
表达式相关的三个函数是理解“优先级靠层级实现”的最佳样例。分析 expression 时,先处理一元正负号,然后进入 term;term 内部循环处理乘除;factor 负责原子项。乘除比加减先被解析到,是因为 parseTerm 在 parseExpression 内部被先调用执行。看代码:
// expression -> [ '+' | '-' ] term { ('+' | '-') term } void Parser::parseExpression() { if (lookahead().kind == TokenKind::TK_PLUS || lookahead().kind == TokenKind::TK_MINUS) { TokenKind op = lookahead().kind; consume(); emitLit(0); // 先压一个 0 parseTerm(); emitOp(1); // OPR 1:一元负,0 - term return; } parseTerm(); while (lookahead().kind == TokenKind::TK_PLUS || lookahead().kind == TokenKind::TK_MINUS) { TokenKind op = lookahead().kind; consume(); parseTerm(); emitOp(op == TokenKind::TK_PLUS ? 2 : 3); // OPR 2 加法,OPR 3 减法 } } // term -> factor { ('*' | '/') factor } void Parser::parseTerm() { parseFactor(); while (lookahead().kind == TokenKind::TK_MUL || lookahead().kind == TokenKind::TK_DIV) { TokenKind op = lookahead().kind; consume(); parseFactor(); emitOp(op == TokenKind::TK_MUL ? 4 : 5); // OPR 4 乘法,OPR 5 除法 } } // factor -> ident | number | '(' expression ')' void Parser::parseFactor() { if (lookahead().kind == TokenKind::TK_IDENT) { Symbol sym = symTab_.find(lookahead().text); emitLod(sym); // 变量值入栈 consume(); } else if (lookahead().kind == TokenKind::TK_NUMBER) { emitLit(lookahead().value); consume(); } else if (lookahead().kind == TokenKind::TK_LPAREN) { consume(); parseExpression(); expect(TokenKind::TK_RPAREN); } else { error("invalid factor"); } }代码后的逻辑说明:parseExpression 里处理一元正负号时先压 0 再取负,是为了统一用二元指令;如果你没有处理一元号,-1会被当成0 - 1或者直接语法错误。parseTerm 的 while 循环让 a*b/c 这种连续乘除自然左结合。parseFactor 遇到左括号时递归进入 parseExpression,这样就实现了括号优先级最高。
参数说明:emitLit 和 emitOp 是 Parser 内部向指令数组追加 P-Code 的便捷函数,OPR 子码 1 到 5 对应取负、加、减、乘、除,要和后面解释器的实现保持一致。一个要注意的问题:factor 里的标识符必须查符号表,查不到要报“未声明标识符”,这是语义分析最简单也最必要的动作。
4.3 符号表与作用域:过程嵌套和四行处理逻辑
符号表是语义分析的中枢。PL/0 只需要三类符号:常量、变量、过程。常量记录值,变量记录运行时的栈偏移,过程记录入口地址。每个符号还要记录它所在的嵌套层数:
enum class SymbolKind { CONST, VAR, PROCEDURE }; struct Symbol { std::string name; SymbolKind kind; int level; // 声明所在层 int address; // const 值 / var 栈偏移 / procedure 入口 };符号表的存取策略是:进入一个 block 时,新建一个“当前层”容器;离开 block 时整个丢掉。查找时从最内层往外找,先命中就先返回。这是作用域遮蔽规则的正确实现。核心操作就三件事:
void SymTab::enterBlock() { levels_.emplace_back(); } void SymTab::exitBlock() { levels_.pop_back(); } Symbol SymTab::find(const std::string& name) const { for (auto it = levels_.rbegin(); it != levels_.rend(); ++it) { auto pos = std::find_if(it->begin(), it->end(), [&](const Symbol& s) { return s.name == name; }); if (pos != it->end()) return *pos; } error("undeclared identifier: " + name); return {}; }逻辑说明:enterBlock 在每次 block 开头调用,exitBlock 在 block 结尾调用,嵌套过程自然形成栈式层级。find 从最内层开始反向搜索,所以内层变量可以遮蔽外层同名变量。声明重复变量的检查放在声明解析函数里:先在当前层里查一遍,查到就报错,查不到再插入。
一个容易忽略的细节是过程中变量的地址分配。PL/0 的解释器用 BP 寄存器指向当前过程栈帧底部,变量地址是相对于当前 BP 的偏移;当引用外层变量时,LOD/STO 指令带一个“层级差”参数。编译期符号表只管静态作用域,运行时的栈帧切换是解释器的活,这两个别混在一起。如果你把声明的层数直接存进符号表,解释器执行时用它算差值是常见的做法。
4.4 P-Code 生成与解释器:跳转回填是重头戏
P-Code 是 PL/0 的中间表示,每条指令由一个操作码和两个参数组成。经典指令集如下:
| 指令 | 参数 | 执行语义 |
|---|---|---|
| LIT | 0 a | 常量 a 入栈 |
| OPR | 0 n | 按子码 n 执行算术或比较 |
| LOD | l a | 取第 l 层偏移 a 的变量值入栈 |
| STO | l a | 栈顶值写入第 l 层偏移 a 的变量 |
| CAL | l a | 调用入口地址 a 的过程 |
| INT | 0 a | 栈指针加 a,给局部变量腾空间 |
| JMP | 0 a | 无条件跳到指令 a |
| JPC | 0 a | 栈顶为 0 时跳到指令 a,否则顺序执行 |
代码生成的关键是跳转回填。因为写 if 语句时,条件判断产生的 JPC 目标地址要等 then 后面的语句体写完才知道。典型写法是先在指令数组里占一个位置,拿到指令序号后再把真正的目标地址写回去:
// statement -> if condition then statement void Parser::parseIf() { consume(); // if parseCondition(); // 比较结果留在栈顶 int jpcIndex = emitJump(); // 占位 JPC,参数稍后回填 expect(TokenKind::TK_THEN); parseStatement(); // then 分支 patch(jpcIndex, codeSize()); // 回填:假则跳到 then 之后 }逻辑说明:emitJump 等于往指令数组 push 一条 JMP,但返回的是它的数组下标;patch 再把后续代码的当前位置写入该指令的参数位。条件为假时,解释器看到 JPC 的参数是“then 之后”的指令号,自然跳过整个 then 分支。如果不做回填,跳转目标会写成 0,程序一执行就直接跳到开头,死循环或者行为完全不可控。
while 语句是同一个思路,但要两个跳转点:一个在条件判断之前记录循环体开始的指令号,一个在语句体结束后无条件跳回循环头。这节先点到这里,具体的细节我在避坑章节里再展开,因为这是整个 PL/0 实验里翻车率最高的位置。
5. 避坑:PL/0 实验里最容易翻车的 5 个点
不管词法写得对不对,递归下降抄没抄对,实验最后卡住的地方通常都很集中。下面这 5 个坑是我写这个实验时踩过、也帮别人排过最多的,每条按现象、原因、解决三个层次说清楚。
5.1 嵌套过程的同名变量遮蔽失败:符号表作用域何时退出
现象:过程 swap 里声明 temp,外层恰好也有 temp,执行完 swap 后外层 temp 的值也跟着变了;或者反过来,内层访问 temp 时拿到的是外层值。
原因:符号表的作用域弹出时机不对。常见写法是解析到 block 末尾才调用 exitBlock,但过程定义的声明期和过程语句体的执行期在文法上是两个阶段;如果声明外层变量时把内层过程的符号也留在当前层,查找顺序就会错。另一个常见原因是变量地址分配逻辑没有区分“层级差”,运行时 LOD 取错了栈帧。
解决:严格按 block 结构调用 enterBlock/exitBlock——进入过程定义体时 enterBlock,过程定义解析完 exitBlock;外层 block 自己的变量声明在此之前完成。查找符号时从当前层向第 0 层反向搜索,匹配到就返回,不要返回后继续往后查。写完后用一个内外层同名变量的样例程序验证,这是最便宜的测试。
5.2 超前读 Token 处理不一致:语法分析“随机抽风”
现象:解析器跑第一个样例没问题,跑到第二个就报 unexpected token,错误位置还老是在分号附近;或者 while 循环里少读了半个 Token,条件判断错位。
原因:递归下降里所有函数共用一个 lookahead Token,但有些分支里多调了一次 consume,或者子函数提前读了后面的 Token,导致父函数看到的状态错位。这是递归下降最常见的实现细节问题,和文法正确性无关。
解决:统一协议——每个语法函数只负责“判断当前 lookahead 是否匹配,匹配则消耗,不匹配则报错”。除了赋值语句里需要预读赋值号,其余情况不要在一个函数里连续 consume 多个却不留痕迹。我给自己的规则是:所有进入函数时先看 lookahead,所有分支选择都基于它,所有 consume 都走同一个 advance() 方法,不做任何旁路读取。加一个断言在 advance 里,如果遇到 EOF 还没消费完就强制报错,能直接暴露这个 bug。
5.3 while 循环的跳转回填错误:死循环或直接跳过
现象:while 程序要么无限循环,要么一次都不进循环体,比 if 语句难调得多。
原因:while 需要两个回填点。很多实现只处理了条件为假的 JPC 回填,忘记在循环体结束后生成一条 JMP 跳回循环头;或者 JPC 回填目标写的是循环体结束位置,导致条件为真时直接跳过。
解决:按这个结构写 parseWhile——先记录条件起始指令号 loopStart;解析 condition 生成比较指令;再 emit 一条 JPC 占位;解析 statement;然后 emit 一条 JMP,目标就是 loopStart;最后把 JPC 的占位参数回填到当前指令号。写完后用计数循环验证:i 从 1 加到 10,打印每次 i,肉眼确认执行了 10 轮而不是 1 轮或无限轮。
5.4 比较运算被当成算术运算:if 条件永远为真
现象:if a > b then ... 的条件永远成立,或者 a > b 在表达式里被当成“大于号”直接参与加减。
原因:PL/0 文法的比较运算只在 condition 层出现,不在 expression 层。有人图省事把六个比较符都加进 parseExpression 的循环里,结果优先级彻底乱掉,1 + 2 < 3 的解析树变成 1 + (2 < 3),类型也没法解释。还有人在解释器里把 OPR 比较子码和算术子码混在一起,导致运行结果是错的但编译期不报错。
解决:严格用 parseCondition 处理比较:odd 单独一个分支;否则先 parseExpression,再读比较符,再 parseExpression,最后 emit 对应的 OPR 比较子码。比较子码我建议和 Wirth 原书保持一致:等于 7、不等于 8、小于 9、小于等于 10、大于 11、大于等于 12。解释器里按子码 switch,不要和加减乘除的子码共用 case。
5.5 赋值号和表达式中同一个变量:取址还是取值没分清
现象:b := a 执行完,a 和 b 都变成了同一个值;或者 read 语句把一个常量写坏了。
原因:变量出现在赋值号左边时,需要的是变量地址(STO 目标);出现在表达式里时,需要的是变量值(LOD 入栈)。如果解析语句时看到标识符就直接走 factor 的查找逻辑,把两边都当成取值,赋值自然就错了。read 语句也一样,读入的值要 STO 到变量地址里,而不是 LOD 取出来再存。
解决:在 parseStatement 的赋值分支里,单独解析标识符并检查下一个 Token 是不是赋值号,是就进入赋值逻辑;不是则说明这是一个表达式开头,交给 parseExpression 处理。符号表查找结果进入指令前,先判断符号的 kind 是 CONST 还是 VAR:常量不能被赋值,过程名不能被当变量用。把这些检查放在语义动作里,错误信息就会具体很多,比如 “cannot assign to constant”。
这些坑有一个共性:编译器和解释器各有一层状态,调试时永远要知道自己当前在看的是编译期符号表,还是运行期栈帧。搞混这两个,排错时间能翻三倍。
6. 调试顺序与指令级验证:让解释器不再是个黑匣子
6.1 用指令跟踪把问题定位到具体一条 P-Code
解释器是最终消费者,但它最容易被当成黑匣子。我见过很多人程序跑出错误结果,只能对着源代码干瞪眼。其实给解释器加一个打印开关,问题会透明很多。执行每一条指令前,打印指令名、参数、当前栈指针和栈顶两三个值;关键控制流地方再打一条 jump to 信息。这样 10 条指令以内,就能看出来是符号表算错层级,还是跳转目标写错。
验证顺序也有讲究:先用最简单程序验证常量与赋值,再验证表达式优先级,然后验证 if,再验证 while,最后才上过程嵌套。每加一部分就回归前面所有用例。这个习惯是我写编译器时保下来的:某一层出了问题,至少知道是最近改动的代码引入的,而不是在几百行里盲猜。
进阶方向上,给 PL/0 加数组和 for 循环通常是课程加分项。数组涉及 factor 的下标解析和符号表里的基址/长度字段;for 循环需要引入一个循环控制变量,绕不开 while 的跳转回填。先把 P-Code 解释器调稳,再做这些扩展会顺很多。做完整个实验后最大的教训是:编译器的每一层入口都要能独立打印中间产物,词法打印 Token,语法打印指令,最后解释器打印栈。没有这几道“仪表盘”,你迟早会在某个看不见的状态里翻车。希望帮到你。
本文还有配套的精品资源,点击获取