
简介这是山东大学编译原理课程PL/0编译器实验的完整实现面向计算机专业学生及编译原理学习者覆盖词法分析、语法分析、语义分析与符号表建立等编译器前端核心环节。压缩包共94个文件、约513KB以C/C源码、CMake构建脚本、可执行程序、实验报告及多组测试输入与运行截图为主要内容便于对照工程结构重新编译和验证结果。目前已有104人学习下载。资源除完整可运行的PL/0编译器外还包含模块划分清晰的工程组织、多组PL/0测试程序如素数、乘方、公约数计算及对应截图可直接用于课程设计参考、实验报告撰写和编译原理备考复习。1. PL0-Compiler是什么把编译原理课程实验压成一台能跑的小编译器第一次打开“PL0-Compiler 山东大学SDU编译原理课程实验”这个项目标题时我差点把它当成又一个普通的课程设计。真正动手之后才发现它要求你为一种叫 PL/0 的教学语言写一个完整的小型编译器从词法分析到语法分析再到符号表、语义检查、中间代码生成最后还要有一个解释器把程序跑起来。PL/0 本质上是 Pascal 的极小核心子集只有常量、变量、过程、赋值、if、while、call 这些最基础的语法单位但恰好把编译原理课程里最该掌握的主干线全部覆盖了。所以这篇笔记的服务对象很明确你要么是刚拿到 PL0-Compiler 实验、不知道从哪下手要么已经写完一版但总在某几个边界用例上翻车。很多学校的编译原理实验都在 PL/0 上做或大或小的剪裁SDU 的版本也跳不出“语言说明 编译器框架 解释器”这个套路。下文我会按顺序拆开词法、语法、语义和代码生成每一步都给出能直接抄的代码和参数最后再把我踩过的几个坑完整复述一遍。用 Java 来写编译原理实验是最常见的选型对象化改造也顺手这篇就默认用 Java 落地。2. 词法分析先从源文件榨出干净的 Token 流PL0-Compiler 的地基词法分析是整个 PL0-Compiler 里最先动手的部分也是很多初学者“看着简单、写起来全是暗坑”的地方。PL/0 的词汇很少十几个保留字、一个标识符、一个整数、几个运算符和界符所以常见做法是只写一个 getsym() 函数每次调用从源代码里取出下一个 Token。这里有两个决策会直接影响后面所有代码Token 用什么结构表达关键字怎么识别。2.1 Token 结构设计枚举、保留字表和行号定位我一般会用枚举把单词类型固定下来而不是用 int 常量手写一堆 0/1/2。原因很简单后面的递归下降解析器里到处要比较sym Kind.IF或者switch(sym)枚举能让错误消息和调试输出自然可读写的时候还不会把 3 和 4 记混。public enum Kind { IDENT(标识符), NUMBER(整数), CONST(const), VAR(var), PROCEDURE(procedure), BEGIN(begin), END(end), IF(if), THEN(then), ELSE(else), WHILE(while), DO(do), CALL(call), ODD(odd), ASSIGN(:), PLUS(), MINUS(-), TIMES(*), DIV(/), EQ(), NEQ(#), LT(), LE(), GT(), GE(), LPAREN((), RPAREN()), COMMA(,), SEMICOLON(;), PERIOD(.); final String label; Kind(String label) { this.label label; } }这段枚举基本就是 PL/0 词法范围的全集。注意末尾的PERIOD对应程序结束的“.”很多人会漏掉它结果语法分析在收尾时总是报“缺 .”。除了枚举之外还需要一个 EOF表示文件读完放在 Token 类型里比较合适。Token 本身我建议做成一个小类kind 表示词性text 保留原始字符intVal 只对 NUMBER 有意义line 用来给后续报错定位。public class Token { public Kind kind; public String text; public int intVal; public int line; }这样一个 Token 对象从词法阶段传到语法分析再传进符号表字符串只在最开始保留一份后面全程都用对象引用查错了也知道该把错误定位到第几行。2.2 getsym 主循环先收标识符再查表收编关键字PL/0 的保留字不多但如果你写成if word.equals(const) else if word.equals(var)这种长串一旦要加关键字或者调整大小写策略函数会臭不可闻。编译原理实验里最稳妥的做法是源程序里以字母开头的连续串一律先当作“可能是标识符”来扫描然后查一个 Map查不到就是 IDENT查得到就改成对应保留字。先定义保留字表private static final MapString, Kind KEYWORDS new HashMap(); static { KEYWORDS.put(const, Kind.CONST); KEYWORDS.put(var, Kind.VAR); KEYWORDS.put(procedure, Kind.PROCEDURE); KEYWORDS.put(begin, Kind.BEGIN); KEYWORDS.put(end, Kind.END); KEYWORDS.put(if, Kind.IF); KEYWORDS.put(then, Kind.THEN); KEYWORDS.put(else, Kind.ELSE); KEYWORDS.put(while, Kind.WHILE); KEYWORDS.put(do, Kind.DO); KEYWORDS.put(call, Kind.CALL); KEYWORDS.put(odd, Kind.ODD); }扫描主流程private void getsym() { skipSpaceAndComment(); if (pos source.length()) { token new Token(Kind.EOF, EOF, line); return; } char c source.charAt(pos); if (Character.isLetter(c)) { int start pos; while (pos source.length() (Character.isLetterOrDigit(source.charAt(pos)) || source.charAt(pos) _)) { pos; } String word source.substring(start, pos); Kind kind KEYWORDS.get(word.toLowerCase()); token new Token(kind null ? Kind.IDENT : kind, word, line); } else if (Character.isDigit(c)) { scanNumber(); } else { scanOperatorOrPunctuation(c); } }这段逻辑里我允许了标识符中包含数字和下划线这是很多课程实验会给的小放宽。如果你拿到的文档严格规定“标识符只能以字母开头后续只能是字母或数字”把_的分支去掉就行。大小写策略也值得提前定好我做了toLowerCase()所以Begin和BEGIN都会被识别成 begin如果你们的实验区分大小写删掉 toLowerCase 并且保证保留字表全小写也可以。注意关键字识别采用“先当标识符扫再查表改判”的顺序比边扫描边逐字符匹配要稳得多。这样标识符和关键字共用同一套字符推进逻辑少一个分支就少一类 bug。2.3 三个容易埋雷的边界多字符符号、整数溢出、非法字符多字符符号是词法阶段最容易留坑的地方。PL/0 的不等号是#而不是!小于等于和大于等于分别是、赋值号是:。每次 getsym 只推进一个字符所以遇到时必须看一眼下一个字符是不是是就合成一个 LE同时 pos 要推进两位漏掉这次 peek 的错误非常难查症状是x 2被解析成和两个符号语法分析直接懵掉。数字扫描我建议用 long 做累加再判溢出不能等 int 已经溢出之后再补救private void scanNumber() { int start pos; long v 0; while (pos source.length() Character.isDigit(source.charAt(pos))) { v v * 10 (source.charAt(pos) - 0); if (v Integer.MAX_VALUE) { error(ERR_NUM_TOO_LARGE, 整数溢出: source.substring(start, pos)); while (pos source.length() Character.isDigit(source.charAt(pos))) { pos; } token new Token(Kind.NUMBER, source.substring(start, pos), line); token.intVal 0; return; } pos; } token new Token(Kind.NUMBER, source.substring(start, pos), line); token.intVal (int) v; }判溢出时必须把剩余数字全部消费掉否则一个大整数会被拆成两段数字后面可能误判成合法程序。非法字符的处理同理、^、$这类谁都不是的字符如果分支里只报错不推进 pos下一轮 getsym 还会看到同一个字符轻则错误刷屏严重时直接死循环。正确做法是报错之后pos继续。注释要不要支持一定要看实验文档。有的 PL/0 版本沿用 Wirth 原书的{ }注释有的学校改成/* */有的是压根不要求。如果要求支持我一般只做非嵌套的/* */private void skipSpaceAndComment() { while (pos source.length()) { if (Character.isWhitespace(source.charAt(pos))) { pos; } else if (source.charAt(pos) / pos 1 source.length() source.charAt(pos 1) *) { pos 2; while (pos 1 source.length() !(source.charAt(pos) * source.charAt(pos 1) /)) { pos; } pos 2; } else { break; } } }这段代码的最大好处是空白和注释可以交替出现中间不需要反复调用 getsym。缺点是注释不闭合时 pos 会一路走到文件尾所以主流程里最好检查一下pos是否已经越界并报“注释未闭合”。写完词法器我建议先跑一个临时 main把源文件里的 Token 流打出来看一眼确认无误再往下写语法分析public static void main(String[] args) { Lexer lexer new Lexer(begin x : 1 2; end.); Token t; while ((t lexer.next()).kind ! Kind.EOF) { System.out.printf(%d\t%s\t%s\t%d%n, t.line, t.kind, t.text, t.intVal); } }这一步能帮你把问题隔离在词法阶段。别急着往后写Token 流不对后面所有解析结果都是空中楼阁。3. 语法分析用递归下降扣住 PL/0 文法表达式优先级不再翻车词法分析把源程序切成 Token 流之后语法分析负责按文法把这些 Token 组织成语法结构。PL/0 的文法是经典的自上而下文法常见做法是手写递归下降一个非终结符对应一个函数。它和 LL(1) 文法长得几乎一样手工实现也不需要额外的表驱动框架调试时还能直接看到调用链最适合作为编译原理实验的骨干。3.1 PL/0 的文法全貌先把 block 和 statement 递归关系画清楚一份标准的 PL/0 文法可以缩写成下面这套 BNFprogram block . block [constDecl] [varDecl] {procDecl} statement constDecl const ident number {, ident number} ; varDecl var ident {, ident} ; procDecl procedure ident ; block ; statement [ident : expression | call ident | begin statement {; statement} end | if condition then statement [else statement] | while condition do statement] condition odd expression | expression relop expression expression [|-] term {(|-) term} term factor {(*|/) factor} factor ident | number | ( expression ) relop | # | | | | 这套文法里有几个地方值得先说破。block是绝对核心它同时处理常量声明、变量声明、过程声明和一条语句而且过程声明里又会递归调用block也就是说过程可以嵌套定义。statement的两种写法里if后面可以跟可选的elsebegin...end内部是用分号分隔的语句序列最后一个分号可写可不写这里最容易产生多余的错误报错。函数划分不需要什么高深设计每个非终结符写一个私有方法方法名直接叫block、statement、condition、expression、term、factor。程序结构和文法一一对应答辩的时候别人问哪段对应哪条产生式你指一下方法名就行。private void block() { table.enterBlock(); int savedVarOffset currentVarOffset; if (sym Kind.CONST) { constDecl(); } if (sym Kind.VAR) { varDecl(); } while (sym Kind.PROCEDURE) { procDecl(); } statement(); currentVarOffset savedVarOffset; table.exitBlock(); }这里的currentVarOffset要格外小心。每个块进入时都从某个固定偏移开始给局部变量分配槽位但这个偏移不能一直累加否则不同块的变量地址会互相踩踏。所以进入 block 时保存旧值块结束再恢复正是这段代码里savedVarOffset的职责。3.2 expression/term/factor 的调用层级乘除为何排在加减下面表达式是语法分析里最好检验优先级的地方。PL/0 的表达式定义成[加号|减号] term { (加号|减号) term }term 定义成factor { (乘号|除号) factor }factor 才是真正出现标识符、整数和括号的那一层。这种分层结构保证了乘除先算、加减后算括号通过递归回到 expression 实现。private void expression() { if (sym Kind.PLUS || sym Kind.MINUS) { boolean minus sym Kind.MINUS; getsym(); term(); if (minus) { gen(OPR, 0, OPR_NEGATIVE); } } else { term(); } while (sym Kind.PLUS || sym Kind.MINUS) { Kind op sym; getsym(); term(); if (op Kind.PLUS) { gen(OPR, 0, OPR_ADD); } else { gen(OPR, 0, OPR_SUB); } } }这段代码先处理一元正负号再通过 while 循环把同层级的加减法运算符全部消耗掉。如果没有最前面的if (sym Kind.PLUS || sym Kind.MINUS)-x1就会被理解成“负号后面缺一个操作数”从而误报语法错误。term 的结构和 expression 几乎一样区别只是把因子换成低一层private void term() { factor(); while (sym Kind.TIMES || sym Kind.DIV) { Kind op sym; getsym(); factor(); if (op Kind.TIMES) { gen(OPR, 0, OPR_MUL); } else { gen(OPR, 0, OPR_DIV); } } }factor 是真正查符号表、生成 LIT 或 LOD 指令的地方private void factor() { if (sym Kind.IDENT) { Symbol s table.find(token.text); if (s null) { error(ERR_UNDEFINED, 未定义标识符 token.text); } else if (s.kind() Kind.CONST) { gen(LIT, 0, s.val()); } else if (s.kind() Kind.VAR) { gen(LOD, levelDiff(myLevel, s.level()), s.addr()); } else { error(ERR_INVALID_EXPR, 过程名不能出现在表达式中 token.text); } getsym(); } else if (sym Kind.NUMBER) { gen(LIT, 0, token.intVal); getsym(); } else if (sym Kind.LPAREN) { getsym(); expression(); if (sym ! Kind.RPAREN) { error(ERR_RPAREN_EXPECTED, 缺少右括号); } else { getsym(); } } else { error(ERR_FACTOR_EXPECTED, 因子开始符号非法); getsym(); } }factor 里的getsym()必须放在每个分支的公共出口否则解析到非法因子时会原地重复处理同一个 Token递归下降立刻被打成死循环。写完这三层拿12*3和(12)*3两个用例分别验证一个结果应该对另一个应该不对如果输出反了多半是 expression 和 term 的层级倒挂了。3.3 test() 错误同步语法分析如何从第一个错里活下来递归下降解析器遇到一个不期望的符号时如果只是简单报错并 return后续语句大概率会被连环误报。经典 PL/0 教材里的做法是写一个test()函数它接收一个期望符号集合如果当前符号不在集合里就报错并向前同步直到找到集合中的某个符号为止。private void test(int[] expected, int errCode, String msg) { if (!inSet(expected)) { error(errCode, msg); do { getsym(); } while (!inSet(expected) sym ! Kind.EOF); } } private boolean inSet(int[] expected) { for (int s : expected) { if (s sym.ordinal()) { return true; } } return false; }这里的ordinal()是因为我用了枚举类型如果你用 int 常量表示符号直接比较数字即可。调用方式就像这样// 解析 statement 前先确认当前符号是语句合法的开头 test(new int[]{Kind.IDENT.ordinal(), Kind.CALL.ordinal(), Kind.BEGIN.ordinal(), Kind.IF.ordinal(), Kind.WHILE.ordinal()}, ERR_STMT_EXPECTED, 语句开始符号非法);“同步”这个词听起来玄实际含义就是跳过那些既不属于当前集合、也不像错误终结符的 Token直到找到一段可以继续分析的结构边界。比如begin x : ; end里面赋值号后面缺表达式expression 会报错但同步到end后statement 还能继续往下走不会把“end 后面缺分号”这种假错误也报出来。错误同步是答辩论时老师大概率会问的点它不追求报错数量追求报错质量。4. 符号表与语义动作PL0-Compiler 的判断力藏在这里语法分析只回答“这串 Token 合不合文法”但x : 1里的 x 到底有没有定义、能不能给常量赋值、过程名能不能当变量用这些必须靠符号表和语义检查来回答。符号表就是编译器的“业务现场”所有声明信息、作用域关系、变量地址都要在这里落地。4.1 符号表设计按块管理进入和退出 block 时自动清理PL/0 支持过程嵌套所以符号表必须按块划分。常见做法是给每个符号记录一个 blockId进入块时压栈并递增编号退出块时把属于当前块的符号全部弹出。public record Symbol(String name, Kind kind, int val, int level, int addr, int blockId) { } public class SymbolTable { private final ListSymbol symbols new ArrayList(); private int currentBlock; private final DequeInteger blockMarks new ArrayDeque(); public void enterBlock() { blockMarks.push(currentBlock); currentBlock; } public void exitBlock() { int mark blockMarks.pop(); while (!symbols.isEmpty() symbols.get(symbols.size() - 1).blockId() mark) { symbols.remove(symbols.size() - 1); } currentBlock blockMarks.peek(); } public Symbol find(String name) { for (int i symbols.size() - 1; i 0; i--) { Symbol s symbols.get(i); if (s.name().equals(name)) { return s; } } return null; } public boolean existsInCurrentBlock(String name) { for (Symbol s : symbols) { if (s.blockId() currentBlock s.name().equals(name)) { return true; } } return false; } }因为符号列表是从表尾开始查找的所以内层声明的同名符号总会被先找到这正好实现了内层遮蔽外层的语义。退出内层块时符号被清理内层名称对外不可见外层名称重新变回可见。Symbol用 Java record 写很紧凑val 字段对常量才有意义addr 字段对变量和过程才有意义别在语义检查里混淆它们。4.2 声明处理与语义检查查重、未定义变量、常量赋值拦截处理 const 声明和 var 声明时重复定义是最需要拦截的错误。判断规则不是“全局唯一”而是“当前块内不得重名”。private void constDecl() { getsym(); // 跳过 const do { if (sym ! Kind.IDENT) { error(ERR_IDENT_EXPECTED, 常量声明缺少标识符); return; } String name token.text; if (table.existsInCurrentBlock(name)) { error(ERR_DUP_DECL, 重复定义: name); } getsym(); if (sym ! Kind.EQ) { error(ERR_EQ_EXPECTED, 常量声明缺少 ); getsym(); } else { getsym(); } if (sym ! Kind.NUMBER) { error(ERR_NUM_EXPECTED, 常量值必须是整数); return; } int val token.intVal; table.add(new Symbol(name, Kind.CONST, val, currentLevel, 0, currentBlock)); getsym(); } while (sym Kind.COMMA); if (sym ! Kind.SEMICOLON) { error(ERR_SEMI_EXPECTED, 常量声明缺少分号); } else { getsym(); } }变量声明类似只是不给值而是分配偏移量。给变量分配地址时记住一个原则过程符号不占用数据槽位常量符号也不占用只有 VAR 才会让currentVarOffset递增。PL/0 的栈式虚拟机里每个栈帧通常要留出三个固定槽给动态链、静态链和返回地址所以局部变量从偏移 3 开始分配是常见约定。如果你没有这三槽设计可以从 0 开始但要保证调用过程时栈帧布局和解释器一致。赋值语句的语义检查尤其要拦常量赋值private void assignment() { String name token.text; Symbol s table.find(name); if (s null) { error(ERR_UNDEFINED, 未定义变量: name); } else if (s.kind() Kind.CONST) { error(ERR_CONST_ASSIGN, 不能给常量赋值: name); } getsym(); if (sym ! Kind.ASSIGN) { error(ERR_ASSIGN_EXPECTED, 缺少赋值号 :); return; } getsym(); expression(); if (s ! null s.kind() Kind.VAR) { gen(STO, levelDiff(currentLevel, s.level()), s.addr()); } }如果变量未定义语法分析可以继续但不应生成 STO 指令如果变量其实是常量就更不能生成存储指令。语义错误和代码生成要分开这类“报错但不再生成错误代码”的处理是实验评分里很容易被检查的细节。4.3 中间代码生成一个指令八条的表驱动虚拟机PL/0 不生成 x86 汇编而是生成一套精简的中间指令再由解释器执行。我习惯用八条指令指令参数含义LITval整常数压栈OPRop对栈顶做运算LODlevel offset从指定层取变量压栈STOlevel offset栈顶存入指定层变量CALlevel offset调用过程INTsize给当前块分配栈空间JMPaddr无条件跳转JPCaddr栈顶为假时跳转OPR 的子操作码属于内部约定你完全可以用自己的一套编号只需要保证生成器和解释器一致。我常用0 返回、1 取负、2 加、3 减、4 乘、5 除、6 判断奇数、7 到 12 是六个比较运算、13 读入、14 输出。生成代码时if 和 while 都要用“先留空地址生成完毕再回填”的 backpatch 技巧// if cond then stmt else stmt condition(); int jpc gen(JPC, 0, 0); // 条件为假时跳出 then 分支 statement(); if (sym Kind.ELSE) { int jmp gen(JMP, 0, 0); // then 分支执行完要跳过 else patch(jpc, pc()); // 回填假跳转到 else 的语句 getsym(); statement(); patch(jmp, pc()); // 回填跳过 else } else { patch(jpc, pc()); // 没有 else 时跳到整个语句之后 }回填的位置差一行就会让程序逻辑彻底变样比如 if 的两个分支都执行或者 while 循环永远多跑一次。写完之后把指令流打印出来逐条核对你会发现这套虚拟机对排错非常友好。5. 避坑PL0-Compiler 调试中我反复踩的四个编译原理实验坑这门实验最大的特点是代码量不大但每类错误都藏在最不起眼的位置。下面四个问题是我在类似 PL0-Compiler 实现里见过最多、自己也踩过的坑全部按“现象 → 原因 → 解决”的顺序写。5.1 词法死循环非法字符被吞掉后 getsym 原地打转现象给词法器一个包含或者^的测试程序编译进程不退出CPU 却跑满有的实现则表现为错误消息洪流刷屏到几乎停不下来。原因getsym 在 popup fallback 里识别不了这个字符只是报错但没有推进 pos。下一轮 getsym 还是读同一个字符于是无限循环。有些代码在错误分支里调用了自己却没在递归前先推进 pos效果同样。解决非法字符分支里必须先pos再报错保证每一次 getsym 调用最多只处理一个字符。词法主函数里凡是能返回 Token 的分支都必须有明确的字符推进动作。我在 2.3 节里把它写在代码注释里就是因为它太容易漏。5.2 优先级翻车12*3 算出 9term 层没把 factor 包起来现象语法分析没报错解释执行的结果却不对12*3输出 9而不是 7。原因expression 里没有调用 term而是直接调用了 factor或者 term 里没有 while 循环只解析了一个因子就 return。递归下降的每层函数都对应文法的每一层优先级少一层就退化成完全左结合或右结合。解决严格按照expression → term → factor三层来写。第一层处理加减第二层处理乘除第三层处理标识符、数字和括号。调试时用12*3、(12)*3、-x1三个用例同时验证任何一个不对都说明层级关系有误。5.3 作用域污染内层过程退出后同名变量仍然可见现象内层过程声明了一个和外层同名的变量返回外层后外层变量居然被内层的那个同名变量遮蔽了更严重的是外层之后访问这个变量时程序行为完全错乱。原因符号表只做增加不做清理退出内层块时没有把本块符号弹出去。find 函数又总是从表尾往前查于是内层符号一直“穿透”作用域屏障。解决用 4.1 节的 blockId 方案进入块时记 mark退出块时按 mark 弹出本块符号。写完作用域测试用例后最好打印一份符号表观察名字和 blockId 的对应关系不要只靠运行结果判断。5.4 跳转目标差一行if/while 的 backpatch 算错位置现象if 的 then 和 else 两个分支同时执行或者 while 循环多执行一次、少执行一次而词法、语法、符号表都查不出问题。原因JPC 或 JMP 的后缀位置填错了。常见做法是把回填时机放在 getsym 之后结果实际地址已经比预期多跑了一段也有的是把“语句结束后的地址”当成了“else 分支的地址”来填。解决把生成指令的 pc 值打印出来对照中间代码人工走一遍。没有 else 时JPC 应跳到整个 if 语句之后有 else 时JPC 跳到 else 语句首条指令JMP 跳到整个语句之后。while 的回填目标则是条件判断指令之前形成一个向后循环的 JMP。注意不要迷信“程序跑通了就没问题”。PL0-Compiler 这类实验的评分点通常很细建议把中间代码和符号表都保留成可打印状态答辩时这些比嘴上讲原理更能说明问题。6. 验证 PL0-Compiler从最小用例集到自动化 diff 的收尾等到词法、语法、语义和代码生成都跑通之后最需要做的不是着急写完整测试而是建立一套能反复执行的回归用例集。PL/0 语言小每个语法成分都不难测真正的风险在于“改动一处带来别处回退”这种问题只有靠自动化 diff 才抓得住。6.1 最小用例集每个语法成分都要有一个正例和一个反例我建议准备这样一个 cases 目录每个.pl0文件对应一个测试主题用例文件覆盖点期望结果const.pl0常量声明、常量引用输出 3var_assign.pl0变量声明、赋值输出 2priority.pl012*3与(12)*37 和 9if_else.pl0if/else 分支分支选择正确while.pl0循环 0 次和多次输出累计结果nested_proc.pl0过程嵌套、作用域遮蔽内外变量互不干扰err_dup.pl0重复定义编译期报错err_undef.pl0未定义标识符编译期报错err_const_assign.pl0给常量赋值编译期报错每个用例文件旁边放一个.out文件保存期望输出。数量不用多但每个语法成分都必须覆盖到“能跑”和“不能跑”两个方向。反向用例的价值在于验证编译期的错误报告和退出码而不是看解释器执行。6.2 自动化 diff把“好像能跑”变成“回归全绿”在项目根目录放一个run_tests.sh把每一个.pl0文件编译后执行再和期望输出对拍#!/usr/bin/env bash if ! java -cp . com.sdu.pl0.Main $src real.out 21; then ... fi for src in cases/*.pl0; do base${src%.pl0} java -cp . PL0Compiler $src /dev/null real_${base}.out 21 if [ -f ${base}.out ]; then if ! diff -u ${base}.out real_${base}.out diff.log; then echo FAIL: $src cat diff.log exit 1 fi fi rm -f real_${base}.out done echo test all passed这段脚本的逻辑很简单编译产物和期望输出做diff -u任何不一致都立刻失败并打印现场。跑一次全绿才算这个页面的最小可用版本真正稳住了。后续不管改词法、改语法还是加新指令集都要重跑一遍这个脚本而不是只手动试一两个例子。6.3 一个看栈的底牌给解释器加 trace 开关调试 PL/0 生成指令时最抓狂的情况是“我知道结果不对但不知道是哪一条指令开始不对”。给解释器加一个全局布尔变量trace在每条指令执行后打印当前 pc、操作码、操作数和栈顶内容if (trace) { System.out.printf(pc%04d op%s level%d addr%d stackTop%d%n, pc - 1, opName(ins.op()), ins.level(), ins.addr(), stack.empty() ? Integer.MIN_VALUE : stack.peek()); }开 trace 之后把12*3的指令序列和手算结果逐条对照很快就能发现是1*2先算还是2*3先算。这个习惯我当时一直保持到实验结束先小用例开 trace 验证指令序列再关掉 trace 跑完整测试套件两者配合几乎没有排不掉的逻辑错误。希望帮到你。本文还有配套的精品资源点击获取