ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

手写PL0编译器实践:从词法分析到P-Code解释器全攻略

手写PL0编译器实践:从词法分析到P-Code解释器全攻略 简介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 -stdc17 -g -Wall -O0 main.cpp lexer.cpp symtab.cpp parser.cpp interp.cpp -o pl0 ./pl0 examples/swap.pas参数说明-stdc17启用现代 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 生成代码量约 150250 行规则少但生成代码不可读调试可单步追踪每个分支出错时黑匣子需要看生成代码扩展新符号加一个 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_mapstd::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 时先处理一元正负号然后进入 termterm 内部循环处理乘除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 的中间表示每条指令由一个操作码和两个参数组成。经典指令集如下指令参数执行语义LIT0 a常量 a 入栈OPR0 n按子码 n 执行算术或比较LODl a取第 l 层偏移 a 的变量值入栈STOl a栈顶值写入第 l 层偏移 a 的变量CALl a调用入口地址 a 的过程INT0 a栈指针加 a给局部变量腾空间JMP0 a无条件跳到指令 aJPC0 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语法打印指令最后解释器打印栈。没有这几道“仪表盘”你迟早会在某个看不见的状态里翻车。希望帮到你。本文还有配套的精品资源点击获取
返回列表