简介:这是山东大学编译原理课程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 Map<String, 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 文法可以缩写成下面这套 BNF:
program = 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后面可以跟可选的else,begin...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),-x+1就会被理解成“负号后面缺一个操作数”,从而误报语法错误。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,递归下降立刻被打成死循环。写完这三层,拿1+2*3和(1+2)*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 List<Symbol> symbols = new ArrayList<>(); private int currentBlock; private final Deque<Integer> 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 汇编,而是生成一套精简的中间指令,再由解释器执行。我习惯用八条指令:
| 指令 | 参数 | 含义 |
|---|---|---|
| LIT | val | 整常数压栈 |
| OPR | op | 对栈顶做运算 |
| LOD | level offset | 从指定层取变量压栈 |
| STO | level offset | 栈顶存入指定层变量 |
| CAL | level offset | 调用过程 |
| INT | size | 给当前块分配栈空间 |
| JMP | addr | 无条件跳转 |
| JPC | addr | 栈顶为假时跳转 |
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 优先级翻车:1+2*3 算出 9,term 层没把 factor 包起来
现象:语法分析没报错,解释执行的结果却不对;1+2*3输出 9,而不是 7。
原因:expression 里没有调用 term,而是直接调用了 factor;或者 term 里没有 while 循环,只解析了一个因子就 return。递归下降的每层函数都对应文法的每一层优先级,少一层就退化成完全左结合或右结合。
解决:严格按照expression → term → factor三层来写。第一层处理加减,第二层处理乘除,第三层处理标识符、数字和括号。调试时用1+2*3、(1+2)*3、-x+1三个用例同时验证,任何一个不对都说明层级关系有误。
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 | 常量声明、常量引用 | 输出 3 |
| var_assign.pl0 | 变量声明、赋值 | 输出 2 |
| priority.pl0 | 1+2*3与(1+2)*3 | 7 和 9 |
| if_else.pl0 | if/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 2>&1; then ... fi for src in cases/*.pl0; do base="${src%.pl0}" java -cp . PL0Compiler "$src" < /dev/null > "real_${base}.out" 2>&1 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 之后,把1+2*3的指令序列和手算结果逐条对照,很快就能发现是1*2先算还是2*3先算。这个习惯我当时一直保持到实验结束:先小用例开 trace 验证指令序列,再关掉 trace 跑完整测试套件,两者配合几乎没有排不掉的逻辑错误。希望帮到你。
本文还有配套的精品资源,点击获取