news 2026/11/6 4:03:43

PL/0子集编译器课程设计:六模块流水线与验收用例

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
PL/0子集编译器课程设计:六模块流水线与验收用例

简介:这份北邮编译原理课程设计资料包面向计算机专业学生及希望动手理解编译器构造的学习者,围绕将Pascal高级语言转换为可执行指令的完整流程展开,覆盖词法分析、语法分析、语义分析、代码生成以及符号表管理、错误处理和中间代码等实践环节。压缩包共83个文件,约123KB,以21个h头文件和19个cpp源文件搭建编译器与编辑器的工程骨架,并配有8个pas源程序、8个asm汇编样例及bin、rez等测试与资源文件,便于对照不同阶段的输入输出。已有1196人学习下载,说明其在课程设计场景中具有一定参考价值。读者可从中获得一套较完整的课程设计工程结构,结合Pascal与汇编测试用例观察词法单元识别、抽象语法树构造、类型检查及目标代码生成的落地方式,也能参考虚拟机和文本编辑相关模块的组织思路,为复现实验、完善课设或深入理解编译系统提供可操作的样本。

1. 从一个跑得通却算不对的表达式求值说起

某高校编译原理课程设计的答辩现场,经常出现这样的场面:词法、语法都过了,一输入2+3*4,屏幕上却打出 20。问题不在这门课难,而在词法、语法、语义、中间代码这几段之间的接口没定死,各写各的。这套方案把 PL/0 子集编译器拆成词法分析、语法分析、符号表、语义检查、四元式生成、解释执行六段,每段给出输入输出约定、核心数据结构和可复现的用例,最后落到一张能自评也能被追问的验收表。适合要在两三周内交出一份能演示、能讲清细节的编译原理课程设计的人。

2. 需求与源语言定义:把课程设计切成六个能独立验收的模块

动手写代码之前最该做的一件事,是把"我的编译器要接受什么样的程序"写死。这一步偷懒,后面每加一个语法结构,词法、语义、代码生成三处都要跟着改,越改越乱。我的做法是先定语言子集,再定词法规则表,最后按依赖关系排出模块交付顺序,让每个模块都能单独跑、单独验收。

2.1 源语言子集的取舍:为什么砍到 PL/0 而不是自己造一门语言

课程设计周期通常只有两到四周,文法越大,语义检查和代码生成的组合越多。自己造一门带数组、结构体、函数重载的语言,写到语义分析就会发现每种类型组合都要单独处理,时间根本不够。选 PL/0 的扩展子集是性价比最高的:它包含常量定义、变量声明、过程声明、赋值、复合语句、条件、循环、输入输出,足以覆盖课程要求里的全部知识点,又不至于把精力耗在边角语法上。

下面这份 EBNF 是可以直接照抄进设计文档的文法定义,扩展了read、write和逻辑条件:

program = block "." ; block = [ "const" ident "=" number { "," ident "=" number } ";" ] [ "var" ident { "," ident } ";" ] { "procedure" ident ";" block ";" } statement ; statement = [ assignment | "call" ident | "begin" statement { ";" statement } "end" | "if" condition "then" statement | "while" condition "do" statement | "read" "(" ident ")" | "write" "(" ident ")" ] ; condition = "odd" expression | expression ( "=" | "#" | "<" | "<=" | ">" | ">=" ) expression ; expression = [ "+" | "-" ] term { ( "+" | "-" ) term } ; term = factor { ( "*" | "/" ) factor } ; factor = ident | number | "(" expression ")" ; assignment = ident ":=" expression ;

这份文法有三处刻意的设计。expression / term / factor三层分开,是为了把优先级固化在文法结构里,而不是在代码里靠一堆if判断谁先算;这正是第 1 章那个2+3*4翻车的根因。不等号用#而不是<>,是为了避免词法阶段把<>和<的两字符匹配搅在一起。statement整个用方括号包起来允许为空,是为了让begin end空块和过程体为空这两种情况都能合法通过,省掉后面补特例的麻烦。

提示:文法一旦定稿就冻结,写进设计文档并标注版本。后面所有模块的自测用例都以这份文法为准,改文法必须同步改用例,否则验收时对不上账。

2.2 词法规则表:种别码先编好,后面全都省事

词法分析器的输出是 Token 序列,Token 的种别码是整个前端共用的一套"词汇表"。种别码定得粗,语法分析里到处是字符串比较;定得太细,又会写出一堆用不上的枚举。我一般按"关键字、标识符、整数、运算符、界符"五类来编,运算符用语义化的名字而不是符号本身,这样语法分析表打印出来人能看懂。

类别匹配规则种别码示例备注
关键字const var procedure begin end if then while do call read write oddKW_IF、KW_WHILE与标识符同规则匹配后查表判定
标识符字母开头,后接字母、数字、下划线ID长度上限设 32,超长报错
无符号整数连续数字NUM不做溢出检查,交给运行期
运算符:= <= >= = # < > + - * /OP_ASSIGN、OP_LE、OP_ADD两字符算符优先匹配
界符( ) , ; .LPAREN、SEMI、DOT.是程序结束标记

表里"两字符算符优先匹配"这条不是可选项。如果按字符从左到右贪心,输入a<=b会被切成a、<、=、b,语法分析直接崩在赋值语句上。这个坑每年都有人踩,解决办法是把<=、>=、:=三个两字符算符放在单词扫描的最前面判断。

2.3 六个模块的职责边界与交付顺序

模块划分的关键不是数量好看,而是每个模块都有明确的输入输出,能脱离上下游单独测试。下面这张表是这套方案的模块清单,建议按从上到下的顺序交付,每一步都能独立演示。

模块输入输出独立自测方式依赖
词法分析源程序字符串Token 序列打印 Token 流,人工比对无
语法分析Token 序列抽象语法树打印带缩进的树形结构词法
符号表管理声明节点作用域栈与名字条目dump 符号表内容语法树
语义检查语法树 + 符号表类型标注树 + 错误列表故意写错程序看报错符号表
四元式生成类型标注树四元式序列打印四元式,手工推演语义
解释执行四元式序列输出结果 / 运行期错误跑示例程序对答案四元式

顺序不能颠倒。词法的 Token 种别码是语法的输入约定,语法的树节点结构是语义检查的输入约定,语义检查打上的类型标注又是代码生成的依据。任何一步跳着做,最后都要回头返工,这是血泪经验。交付节奏上,前三个模块建议在一周内跑通,因为它们是纯结构转换,没有太多设计决策;语义和代码生成各留三到四天,留一天专门做用例。

3. 总体架构设计:三段式流水线与一张活的作用域栈

整体架构的核心问题是:各阶段之间传什么、谁持有全局状态。传得太细,模块耦合;传得太粗,下游拿不到需要的信息。这套方案用三段式流水线,中间产物分别是 Token 流、抽象语法树和四元式序列,全局状态只有一个符号表和一个错误收集器,其余全部通过参数传递。

3.1 数据流:Token 流、语法树、四元式的三段式衔接

第一段是词法到语法。词法分析器一次性把源程序切成 Token 列表,末尾追加一个EOF作为哨兵。语法分析只认种别码不认字面量,这样后面换一门源语言只需要改词法,语法分析器一行不动。Token 里保留行号、列号和原始文本,这两个字段在报错时是刚需,不保留的话诊断信息只能告诉用户"有语法错误",验收时会被追问到答不上来。

第二段是语法到语义。抽象语法树的每个节点带一个kind字段和若干子节点,节点结构要能表达优先级:a+b*c的根是+,右孩子是*。树的节点不加类型信息,类型检查在语义阶段遍历时填进去。这种"先建结构后填属性"的做法,好处是语法分析和语义检查彻底解耦,语法分析器不需要知道int是什么。

第三段是语义到执行。四元式是最朴素的三地址码,格式为(op, arg1, arg2, result),够用且好调试。生成后直接交给一个解释器执行,解释器维护一个运行栈,变量按层次和偏移寻址。不生成真正的目标机器码,是因为课程设计的验收重点是前端,机器码那部分写出来也没人看,反而挤占调试时间。

三段的衔接处各有一个"闸门":Token 流末尾的EOF、语法树的根节点、四元式序列末尾的HALT。闸门的实际作用是让每一段都能独立判定"输入结束了没有",避免下游在空输入上死循环。这个细节听起来微不足道,但没有它,输入一个空文件就能让程序挂住。

3.2 符号表:作用域栈与名字解析

符号表是整个前端唯一的全局状态,设计不好会牵连语义检查和代码生成两个模块。最常见的错误设计是用一个哈希表装所有名字,遇到过程嵌套就分不清同名变量属于哪一层,语义检查会把内层变量当成外层变量用,报出一堆莫名其妙的"重复声明"。

正确的做法是作用域栈:每进入一个block压一层,离开时弹一层。查找名字时从栈顶往下找,找到第一个就返回,天然实现了"内层遮蔽外层"的语义。

class SymbolTable: def __init__(self): self.scopes = [{}] # 栈底是全局作用域 self.counts = [0] # 每层已分配的变量个数,用来算偏移 def enter_scope(self): """进入 block / procedure 时调用""" self.scopes.append({}) self.counts.append(0) def leave_scope(self): """离开时调用,必须保证异常路径上也会走到""" self.scopes.pop() self.counts.pop() def declare(self, name, kind, type_=None): """在当前层声明名字;同层重复声明返回 None 由调用方报错""" cur = self.scopes[-1] if name in cur: return None self.counts[-1] += 1 cur[name] = { "kind": kind, # const / var / procedure "type": type_, # int,本方案只有一种类型 "level": len(self.scopes) - 1, # 层次,代码生成寻址要用 "addr": self.counts[-1] - 1, # 层内偏移 } return cur[name] def lookup(self, name): """由内向外查找,实现遮蔽语义""" for lvl in range(len(self.scopes) - 1, -1, -1): if name in self.scopes[lvl]: return self.scopes[lvl][name] return None

level和addr这两个字段是为解释执行准备的。变量寻址时用(level, addr)二元组,运行时栈上按层切帧,从当前层往上数level层再取第addr个槽位,就能拿到值。如果不存这两个字段,运行期就得靠名字查表,性能差不说,递归调用时还会串值。

leave_scope必须放在finally块里调用。这一点在第 5 章还会展开,语义检查遇到错误通常要抛异常中断当前 block,如果只在正常路径弹栈,作用域栈就会和实际嵌套深度对不上,后面的名字解析全错。

3.3 错误处理:把诊断信息当第一类公民

课程设计里最容易被扣分的不是功能缺失,而是报错信息。一个只打印"语法错误"的编译器,答辩时老师随便输一个错程序,你就得现场翻代码找位置。合格的诊断信息至少包含四项:错误类别、行号列号、出错附近的原文片段、期望什么。

统一成一个错误收集器,各阶段往里丢,最后一次性输出。这样做的好处是语法分析遇到错误可以跳过继续分析,一次报出多个错误,而不是报一个就退出。

class Diag: """全局错误收集器,各阶段共用""" def __init__(self): self.items = [] def add(self, stage, line, col, code, msg, hint=""): self.items.append({ "stage": stage, # LEX / PARSE / SEMANTIC / RUNTIME "line": line, "col": col, "code": code, # 如 E1001,便于统计用例命中 "msg": msg, "hint": hint, # 期望值、候选修正 }) def report(self): for e in sorted(self.items, key=lambda x: (x["line"], x["col"])): print(f"[{e['stage']}] {e['line']}:{e['col']} {e['code']} {e['msg']}") if e["hint"]: print(f" 提示:{e['hint']}") return len(self.items)

错误码用E + 四位数字的格式,词法E10xx、语法E20xx、语义E30xx、运行期E40xx。这套编码在验收时特别好用:测试用例表里直接写"期望命中 E3001",跑一遍对比错误码,通过与否一目了然,不用人工读报错文本。

注意:错误收集器不要在若干具体情况下立即终止程序,除 EOF 提前出现之外都按"记录并恢复"处理。恢复策略见 4.2 节。

4. 核心模块实现:词法状态机、LL(1) 分析表与四元式回填

有了接口约定,剩下的是每个模块内部怎么写。这一章给四个核心模块的关键代码和设计取舍,代码都是能直接跑的最小实现,参数含义和改写方式一并说明。

4.1 词法分析器:最长匹配与状态推进

词法分析的本质是一个带最长匹配的状态机。手写扫描器比正则表达式引擎更好调试,因为出错时你能在断点里看到当前字符和位置。核心循环按"空白跳过 → 注释跳过 → 标识符/关键字 → 数字 → 两字符算符 → 单字符算符 → 界符 → 报错"的顺序判断,顺序本身就是优先级。

KEYWORDS = {"const", "var", "procedure", "begin", "end", "if", "then", "while", "do", "call", "read", "write", "odd"} OP_KIND = {"<=": "OP_LE", ">=": "OP_GE", ":=": "OP_ASSIGN", "+": "OP_ADD", "-": "OP_SUB", "*": "OP_MUL", "/": "OP_DIV", "=": "OP_EQ", "#": "OP_NE", "<": "OP_LT", ">": "OP_GT"} DELIM_KIND = {"(": "LPAREN", ")": "RPAREN", ",": "COMMA", ";": "SEMI", ".": "DOT"} def tokenize(src, diag): tokens, i, line, col = [], 0, 1, 1 n = len(src) while i < n: ch = src[i] if ch in " \t\r": i += 1; col += 1 continue if ch == "\n": i += 1; line += 1; col = 1 continue if src.startswith("/*", i): # 块注释不嵌套,遇到未闭合直接报错到文件尾 start_line, start_col = line, col i += 2; col += 2 while i < n and not src.startswith("*/", i): if src[i] == "\n": line += 1; col = 1 else: col += 1 i += 1 if i >= n: diag.add("LEX", start_line, start_col, "E1001", "注释未闭合") break i += 2; col += 2 continue # 标识符与关键字:同一条规则,扫完再查表 if ch.isalpha(): j = i while j < n and (src[j].isalnum() or src[j] == "_"): j += 1 word = src[i:j] if len(word) > 32: diag.add("LEX", line, col, "E1002", f"标识符超长 {word}") kind = "KW_" + word.upper() if word in KEYWORDS else "ID" tokens.append(Token(kind, word, line, col)) col += j - i; i = j continue if ch.isdigit(): j = i while j < n and src[j].isdigit(): j += 1 tokens.append(Token("NUM", src[i:j], line, col)) col += j - i; i = j continue # 两字符算符必须先试,否则 <= 会被切成 < 和 = two = src[i:i + 2] if two in OP_KIND: tokens.append(Token(OP_KIND[two], two, line, col)) i += 2; col += 2 continue if ch in OP_KIND: tokens.append(Token(OP_KIND[ch], ch, line, col)) i += 1; col += 1 continue if ch in DELIM_KIND: tokens.append(Token(DELIM_KIND[ch], ch, line, col)) i += 1; col += 1 continue diag.add("LEX", line, col, "E1003", f"非法字符 {ch!r}") i += 1; col += 1 # 跳过坏字符继续扫描,保证一次报全 tokens.append(Token("EOF", "", line, col)) return tokens

几点说明。line和col在每条分支里都要同步推进,这是最容易漏的地方,漏了之后所有报错的行列号全偏。非法字符不抛异常而是记录后跳过,是为了让词法阶段一次报出所有坏字符,而不是报一个停一次。EOF哨兵带上最后的位置,语法分析报"文件意外结束"时能指到正确位置。

写完之后立刻做一件事:拿源程序文件逐个打印 Token 流,人工核对三五个复杂表达式。这一步花十分钟,能省掉后面调试语法分析的两小时。

4.2 语法分析:递归下降写起来快,LL(1) 分析表好答辩

两种做法的差别不在于能力,而在于答辩时的可解释性。递归下降每个非终结符一个函数,代码直观,但被问到"为什么这里回退"或者"这个文法是不是 LL(1)"就答不上来。LL(1) 分析表由 FIRST/FOLLOW 集机械生成,表本身就是证据,被追问时可以直接把表打出来说明冲突在哪。

我推荐主实现用 LL(1) 预测分析表,理由是它能自动检测冲突,文法改动能立刻反映在表的结构上。

EPS = "ε" def first_of_seq(seq, first_sets, nonterms): """求符号串的 FIRST 集;遇到可推出 ε 的非终结符就继续往后看""" result = set() for sym in seq: if sym in nonterms: result |= (first_sets[sym] - {EPS}) if EPS not in first_sets[sym]: return result else: result.add(sym) return result result.add(EPS) # 整串都能推空 return result def build_table(rules, first_sets, follow_sets, nonterms): """rules: {A: [产生式右部列表]},返回 {非终结符: {终结符: 产生式}}""" table = {A: {} for A in nonterms} for A, prods in rules.items(): for prod in prods: f = first_of_seq(prod, first_sets, nonterms) for t in f - {EPS}: if t in table[A]: raise ValueError(f"LL(1) 冲突:{A} 在 {t} 上有多条产生式") table[A][t] = prod if EPS in f: # A 可推空,则跟随 FOLLOW(A) for t in follow_sets[A]: if t in table[A]: raise ValueError(f"LL(1) 冲突:{A} 在 {t} 上有多条产生式") table[A][t] = prod return table def ll1_parse(tokens, table, start="program", diag=None): """带 panic 模式恢复的预测分析主循环""" stack = ["$", start] # 栈底放结束符 pos = 0 while stack: top = stack[-1] look = tokens[pos].kind if top == "$" and look == "EOF": return True if top in table: # 非终结符:查表展开 prod = table[top].get(look) if prod is None: diag.add("PARSE", tokens[pos].line, tokens[pos].col, "E2001", f"在 {top} 处遇到意外的 {look}", hint=f"期望 {sorted(table[top].keys())}") # 恢复:跳过当前 token,重新观察下一个 pos += 1 if pos >= len(tokens): return False continue stack.pop() for sym in reversed(prod): # 逆序压栈,保证从左到右匹配 if sym != EPS: stack.append(sym) else: # 终结符:直接比对 if top == look: stack.pop(); pos += 1 else: diag.add("PARSE", tokens[pos].line, tokens[pos].col, "E2002", f"期望 {top},实际 {look}") stack.pop() # 弹出期望项,保持在原 token 上重试 return False

first_of_seq里"遇到可推空的非终结符继续往后看"这个循环,是 FIRST 集计算最容易写错的地方。A → B C,如果B能推空,FIRST(A)必须包含FIRST(C),只算FIRST(B)会让预测表缺项。

ll1_parse的恢复策略有两处区别:非终结符查表失败时向前跳一个 token,因为当前 token 无论如何都接不下去;终结符不匹配时只弹期望项不动 token,因为可能是栈顶多压了一个符号。这两种恢复方式组合起来,一个错语句不会引发后面一连串的连锁报错。

提示:文法必须消除左递归,并且提取公共左因子;这两项检查写进设计文档。expression = expression "+" term这种直接左递归会让递归下降无限递归,也会让 FIRST 集计算陷入死循环。

4.3 语义分析:声明检查、类型检查、左值校验三件事

语义分析遍历语法树,顺手把类型标注填回节点,同时做三件检查:名字是否已声明、赋值目标是否是左值、运算两边类型是否匹配。本方案只有int一种类型,类型检查看似多余,但把检查框架搭起来,答辩时被问"如果要加布尔类型怎么办"就能给出明确的扩展点。

def visit_assign(node, symtab, diag): # 1) 左边必须是可以赋值的对象 if node.left.kind != "ID": diag.add("SEMANTIC", node.line, node.col, "E3001", "赋值号左边不是变量") return None entry = symtab.lookup(node.left.name) # 2) 名字必须先声明 if entry is None: diag.add("SEMANTIC", node.line, node.col, "E3002", f"变量 {node.left.name} 未声明") return None if entry["kind"] == "const": diag.add("SEMANTIC", node.line, node.col, "E3003", f"常量 {node.left.name} 不允许赋值") return None # 3) 右边求值并做类型标注 rhs_type = visit_expr(node.right, symtab, diag) if rhs_type is None: return None if rhs_type != entry["type"]: diag.add("SEMANTIC", node.line, node.col, "E3004", f"类型不匹配:{entry['type']} := {rhs_type}") return None node.type = entry["type"] node.sym = entry # 把符号表条目挂到节点上,代码生成直接用 return node.type

node.sym = entry这一行是关键设计:语义阶段把符号表条目直接挂到语法树节点上,代码生成阶段不需要再查表,直接用level和addr生成四元式。这样做省掉了一次名字解析,也避免了"生成了内层变量却被解析成外层变量"这类问题。

变量使用也要检查"是否声明",这条检查在visit_expr里对ID节点做,逻辑和上面第 2 步一样,只是报错码换成"未声明的标识符"。过程调用要额外核对参数个数和类型,虽然 PL/0 的过程没有参数,但扩展版通常会加,检查点提前留好。

4.4 中间代码生成:四元式与控制流的回填

四元式生成里最麻烦的不是算术表达式,而是if和while的跳转目标。条件成立时往哪跳、不成立时往哪跳,这些目标在生成条件代码的时候还不知道,因为后面的代码还没生成。标准解法是回填:先占个空位,等目标确定后再回来填。

class Emitter: def __init__(self): self.quads = [] self.tmp_no = 0 def new_tmp(self): self.tmp_no += 1 return f"t{self.tmp_no}" # 每次递增,避免临时变量重名 def emit(self, op, a1, a2, res): self.quads.append([op, a1, a2, res]) return len(self.quads) - 1 # 返回下标,供回填定位 def backpatch(self, holes, target): for idx in holes: self.quads[idx][3] = target def gen_condition(node, em): """条件表达式生成 (真出口链, 假出口链),两条链都待回填""" t1 = gen_expr(node.left, em) t2 = gen_expr(node.right, em) truelist, falselist = [], [] if node.op in ("OP_EQ", "OP_NE"): em.emit("j" + node.op[3:].lower(), t1, t2, None) # 条件跳转,目标待定 truelist.append(len(em.quads) - 1) elif node.op in ("OP_LT", "OP_LE", "OP_GT", "OP_GE"): em.emit("j" + node.op[3:].lower(), t1, t2, None) truelist.append(len(em.quads) - 1) else: # odd x:单目判断,生成一条条件跳转 em.emit("jodd", t1, None, None) truelist.append(len(em.quads) - 1) falselist.append(em.emit("j", None, None, None)) # 无条件跳过真分支 return truelist, falselist def gen_while(node, em): start = len(em.quads) # 条件判断的入口 truelist, falselist = gen_condition(node.cond, em) em.backpatch(truelist, len(em.quads)) # 真出口 = 循环体第一条 gen_statement(node.body, em) em.emit("j", None, None, start) # 循环体结束跳回条件 em.backpatch(falselist, len(em.quads))# 假出口 = 循环之后

new_tmp用一个全局递增计数器,而不是在表达式内部局部计数。如果每进入一个子表达式就把临时变量从t1重新数起,嵌套表达式的结果会互相覆盖,这是排查起来最费劲的一类错误——程序不报错,算出来的数字就是不对。

backpatch接收的是一组四元式下标,把它们的第四段统一改成目标地址。if的处理和while类似,只是真出口回填到then分支开头,假出口回填到else分支开头,两分支执行完都要跳到整个语句之后。回填顺序和链的合并方法是这一类实现的难点,建议先在纸上画一遍带下标的位置图,再写代码。

5. 避坑与排查:五个最容易在验收现场翻车的点

这些坑几乎每个做编译器课设的人都会撞上一两个,区别只是撞的时候是凌晨三点还是答辩前十分钟。下面按"现象 → 原因 → 解决"写清楚,遇到对应现象可以直接对号入座。

5.1 错误恢复把分析器送进死循环

现象:输入1+或者少个分号的程序,程序既不报错也不退出,CPU 跑满。原因:预测分析遇到不匹配时弹出栈顶但没推进pos,下一轮循环栈顶还是同一个符号、当前 token 还是同一个,反复循环。解决:给恢复函数加一条硬约束——每轮恢复必须推进pos或减小栈深度,两者至少发生一件;在循环里加一个计数器,连续恢复超过 50 次就判定为失控,输出诊断后强制退出。

5.2 表达式优先级写错,结果差了十万八千里

现象:2+3*4输出 20,a-b-c算成a-(b-c)。原因:把+ - * /放在同一个层级用循环处理,左结合变成了按顺序从左到右混合计算。解决:文法严格分层成expression → term → factor三层,每一层的循环只处理本层运算符;加法减法一层、乘法除法一层、括号和原子一层,优先级由结构保证,不靠代码里的判断。

5.3 作用域栈泄漏,内层变量污染外层

现象:过程内部定义的变量,在过程外部能访问到;或者对两个过程中的同名变量报"重复声明"。原因:语义检查块时抛了异常,leave_scope没被调用,作用域栈只进不出,后面所有名字查找都在错误的层上。解决:把leave_scope放进try / finally,异常路径和正常路径都保证弹栈;另外在每次 dump 符号表时打印栈深,和语法树嵌套深度比对,一旦不一致立刻能定位。

5.4 临时变量重名导致嵌套表达式结果被覆盖

现象:a := (b+c)*(d+e)算出来等于b+c或者某个中间值,具体哪个不一定。原因:临时变量编号在递归下降的每个函数里各自局部计数,不同分支生成了同名t1,后生成的覆盖了先生成的。解决:临时变量编号挂在Emitter实例上全局递增,任何位置生成新临时变量都走new_tmp();打印四元式时检查编号唯一性,作为一条固定自检项。

5.5 测试用例只写正例,答辩现场被一个错例问倒

现象:自己准备的三个程序都跑得通,老师现场输一个begin x := 1 end(x没声明),程序直接崩溃或者打印出x = 0。原因:开发过程只用正确程序验证,错误路径的报错和恢复逻辑从没执行过。解决:用例表里正例与错例按 1:1 配比,每个错误码至少有一条对应用例;跑用例时自动比对错误码而不是人眼看报错文本,这样才能批量回归。

注意:这五条建议在答辩前一周集中过一遍,特别是 5.3 和 5.4,它们的共同特征是"不崩溃但结果错",最容易被当成玄学问题耗掉一整晚。

6. 验收与答辩:用一套分级用例证明编译器是对的

到这一步代码基本成型,接下来要解决的其实是信任问题:老师凭什么相信你的编译器是对的。答案不是"我跑过了",而是一张分级用例表加一份能现场复现的演示脚本。

6.1 分级测试用例矩阵

用例按模块分五级,每级的通过标准不同。这套矩阵建议直接写进课程设计报告的测试章节,答辩时打印出来放在手边。

级别覆盖内容典型用例通过标准
L1 词法关键字、标识符、注释、非法字符var a_b1;、/*x*/、@Token 流正确;@命中 E1003 且行列号准确
L2 语法缺分号、括号不匹配、缺少 thena := 1 b := 2、(1+2命中 E2001/E2002,且只报一条不连锁
L3 语义未声明、重复声明、常量赋值x := 1、const c=1; c:=2分别命中 E3002、E3003
L4 代码生成优先级、if/else、while、过程调用2+3*4、嵌套 if、累加循环四元式序列正确,执行结果与手算一致
L5 综合完整的可运行程序求最大公约数、冒泡排序、斐波那契前 N 项输出正确,无错误诊断

L4 这一级是重灾区。优先级用例必须包含2+3*4、(2+3)*4、a-b-c、a/(b*c)四个,覆盖优先级和结合性;if用例要包含无else、有else、if嵌if三种;while用例要包含零次循环、一次循环和多次循环三种边界。L5 的三个程序是答辩现场的主打,选它们是因为输出可以独立验算,老师不用看代码就知道对不对。

跑用例的方式建议写成脚本,一条命令跑完全部用例并输出通过率。报告里写"124 条用例,通过 121 条,3 条已知限制"比写"测试充分"有说服力得多。

6.2 答辩现场的三分钟演示脚本

演示顺序决定第一印象。我一般按"先错后对、先小后大、先结果后原理"来排:

第一步,打开一个故意写错的程序,让它一次性报出三个错误——一个词法、一个语法、一个语义,指着屏幕说明每个错误码的含义和行列号定位。这一步展示的是诊断能力,比展示功能更能体现工程完成度。

第二步,换一个正确的程序,先打印 Token 流,再打印四元式序列,最后跑解释执行看输出。三步对应三个模块,讲的顺序和流水线顺序一致,老师能顺着往下问。

第三步,打开符号表 dump,指着level和addr说明作用域嵌套和变量寻址。如果老师问"过程调用时变量怎么找",这里就是现成的答案。

第四步,翻到用例矩阵,报通过率和已知限制。主动说限制比被问出来强,比如"本方案不支持数组,扩展点在语义分析的visit_expr里加一个分支即可"。

整个演示控制在三分钟,剩下的时间留给追问。追问最多的三个问题是"文法是不是 LL(1)""错误恢复怎么做的""临时变量怎么编号",这三个答案在前面几章里都有,提前准备一遍就能答得干脆。

从那以后我每次带课程设计,都强制先写用例表再写分析器——不是因为流程规范,而是因为被自己写的死循环卡到凌晨之后,才发现能自动跑一遍的用例集才是真正的后悔药。这套方案的模块划分、代码骨架和用例矩阵整理在一起,需要的话可以拿去改成自己的版本。希望帮到你。

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

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/11/6 4:03:10

2026年AI编程工具选型与实战指南

1. AI编程工具概述与选型逻辑2026年的AI编程工具市场已经进入成熟期&#xff0c;各类工具在功能定位上呈现出明显的差异化特征。作为从业十年的全栈开发者&#xff0c;我亲测了市面上主流的8款工具&#xff0c;发现它们大致可分为三类&#xff1a;第一类是集成开发环境&#xf…

作者头像 李华
网站建设 2026/11/6 4:03:06

网络工程师转型指南:云计算、安全与DevOps路径解析

1. 网络工程师的职业转型背景分析从事网络工程多年的同行们&#xff0c;常常会在职业生涯的某个阶段面临转型的困惑。我见过太多CCIE、HCIE持证工程师在35岁左右陷入职业焦虑——不是技术能力不足&#xff0c;而是行业变革带来的岗位需求变化。传统网络运维岗位正在被云计算和自…

作者头像 李华
网站建设 2026/11/6 4:01:52

数据仓库自动化运维工具的设计与实践

1. 项目背景与核心价值数据仓库作为企业数据资产的核心载体&#xff0c;其运维复杂度随着数据量增长呈指数级上升。传统运维模式下&#xff0c;DBA团队需要手动处理数百个ETL作业的调度监控、存储空间预警、查询性能调优等重复性工作。我曾亲历某金融项目凌晨3点被电话叫醒处理…

作者头像 李华
网站建设 2026/11/6 4:01:27

Kubernetes离线部署MySQL 5.7全量包实战指南

简介&#xff1a;本资源是一套面向Kubernetes初学者与运维工程师的MySQL 5.7全量离线部署方案&#xff0c;专为无外网环境或需快速复现生产级数据库服务的场景设计&#xff0c;解决K8s集群中MySQL稳定部署、数据持久化与启动流程标准化等核心问题。压缩包共7个文件&#xff0c;…

作者头像 李华
网站建设 2026/11/6 4:01:18

进程、线程与协程的核心区别及高并发实践

1. 理解进程、线程与协程的本质区别第一次被问到这个问题是在快手二面的技术深挖环节。面试官放下简历突然发问&#xff1a;"用你最熟悉的方式解释下进程、线程和协程的区别"。这个问题看似基础&#xff0c;但要想答得漂亮&#xff0c;需要从操作系统原理到实际应用场…

作者头像 李华
网站建设 2026/11/6 3:58:46

轨迹预测完整代码实战:滑动窗口、GRU基线到插件化推理

简介&#xff1a;面向具备Python与深度学习基础、希望实践轨迹预测并具备一定代码阅读能力的学生和算法开发者&#xff0c;这套压缩包提供可直接研读的完整工程代码及配套数据。资源共11个文件&#xff0c;以7个Python脚本为核心&#xff0c;辅以模型检查点、一份CSV轨迹数据以…

作者头像 李华