简介:南京航空航天大学编译原理课程设计全套材料面向计算机专业学生及需要完成词法、语法分析实验的学习者,覆盖课程设计从源码实现到报告撰写的完整流程。压缩包共32个文件、约961KB,包含C/C++源码与头文件、可执行程序、编译中间文件,以及课程设计报告和答辩PPT等文档,词法部分与语法部分均有对应实现,便于对照理解分析器构造与调试过程。当前已有752人学习,说明该套课设在同类资料中具有一定参考价值。读者可从中获得经过检验、运行无明显BUG的词法与语法分析程序,结合报告梳理设计思路、模块划分和测试方法,也可参考答辩PPT组织课程设计汇报;对于需要复现实验、排查编译错误或完善课程设计文档的学生,具有较直接的借鉴意义。
1. 编译原理课程设计的第一道坎,不是算法而是工程边界
期末周做编译原理课程设计,最常见的场景是:课本上的 NFA 转 DFA、FIRST 集、SLR 分析表都算得出来,真丢一个int a = 1 + 2 * 3;进自己写的程序,要么卡死,要么算成 9。问题几乎从来不在算法本身,而在于没人告诉你这条流水线该怎么切模块、接口怎么定、错误怎么兜住。这篇笔记就按一次完整实现走一遍:源语言子集怎么划、词法与语法分析怎么落地、符号表和四元式怎么接、最后怎么验证编译器没瞎算。它适合正在赶课设、或者想把一个玩具编译器改到能演示的人,也适合带课设、需要给学生一个可复现参照的助教。
2. 把编译器拆成可交付的模块:从源语言子集到工具选型
课程设计最容易失控的地方,是一上手就写代码。写到一半发现 while 条件里要支持函数调用,词法表没留位置,AST 节点结构改了一遍,前面调好的递归下降全部推倒。比较稳妥的顺序是反过来:先钉死源语言子集,再定模块之间的数据结构,最后才动手写每个模块的内部实现。这三步做完,剩下的工作是填空题而不是改架构。
2.1 源语言子集划到哪里:先把文法写死再动手
一个学期能写完的编译原理课程设计,源语言控制在 200 行代码以内能覆盖全部特性最合适。我的经验是砍到只剩「声明、赋值、算术、比较、if、while、print」这七样,函数调用和数组一律不进第一版。判断标准很朴素:每多支持一个特性,词法、语法、语义、代码生成四处都要改,而评分点通常只看流水线是否完整,不看语言多丰富。
先把文法用 EBNF 固定下来,贴在文件头当注释。后面所有模块都以这份文法为准,谁改谁负责同步。
program -> block block -> '{' decl_list stmt_list '}' decl_list -> (decl ';')* decl -> INT ID ('=' expr)? | FLOAT ID ('=' expr)? stmt_list -> stmt* stmt -> assign | if_stmt | while_stmt | block | print_stmt assign -> ID '=' expr ';' if_stmt -> IF '(' expr ')' stmt (ELSE stmt)? while_stmt -> WHILE '(' expr ')' stmt print_stmt -> PRINT '(' expr ')' ';' expr -> logic_or logic_or -> logic_and ('||' logic_and)* logic_and -> equality ('&&' equality)* equality -> rel (('=='|'!=') rel)* rel -> add (('<'|'>'|'<='|'>=') add)* add -> mul (('+'|'-') mul)* mul -> unary (('*'|'/') unary)* unary -> ('!'|'-') unary | primary primary -> INT_LIT | FLOAT_LIT | ID | '(' expr ')'这段文法有三个刻意设计。第一,表达式分成了七层优先级,每层一个非终结符,递归下降时一个函数对应一层,谁也不会把*和+的优先级搞混。第二,所有二元运算都写成X ('op' X)*而不是左递归的X -> X 'op' X,这样递归下降不需要消除左递归就能直接写。第三,if 的 else 分支是可选的,但语法上规定 else 就近匹配最近的 if,后面写语法分析时靠「能吞就吞」自然满足,不用额外做悬垂 else 处理。
特性取舍建议用一张表锁死,评审时也能直接当说明文档。
| 特性 | 是否纳入第一版 | 理由 |
|---|---|---|
| int / float 两类变量 | 纳入 | 类型检查有内容可讲,又不至于失控 |
| 算术与比较运算 | 纳入 | 四元式生成的主要素材 |
| if / while 与块作用域 | 纳入 | 回填和符号表栈必须练到 |
| 一维数组 | 推迟 | 引入地址计算,工作量翻倍 |
| 函数定义与调用 | 推迟 | 需要活动记录和参数传递约定 |
| 字符串与结构体 | 不纳入 | 与类型系统耦合太深 |
2.2 手写还是生成器:三条路线的实际取舍
编译原理课程设计在实现路线上通常有三条:纯手写递归下降、用词法/语法生成器自动产出分析器、以及手写词法配表驱动 LL(1)。三条路都能拿到分,但踩坑的分布完全不同。
纯手写的优点是调试路径短,报错信息可以精确到「第 12 行第 8 列,期望)实际是;」,出错时打断点就能看到调用栈。缺点是文法一改就要同步改代码,而且很容易在表达式优先级上写错一层。生成器那条路,文法文件写起来舒服,但一旦出现移进/归约冲突,排查就得回到状态机层面看分析表,对课设周期来说时间成本偏高。表驱动 LL(1) 介于两者之间,好处是 FIRST/FOLLOW 集正好是课本重点,写出来和讲义对得上。
我一般推荐手写递归下降,理由是它把「文法怎么变成代码」这件事暴露得最彻底,答辩时也最好讲。
| 维度 | 手写递归下降 | 生成器自动产出 | 表驱动 LL(1) |
|---|---|---|---|
| 上手成本 | 低 | 中 | 中高 |
| 报错定位精度 | 高,可带行列号 | 一般 | 中 |
| 文法改动代价 | 中,需改代码 | 低,改文法文件 | 中,需重算分析表 |
| 冲突排查难度 | 无冲突概念 | 高,要看分析表 | 中,看 FIRST/FOLLOW |
| 与课本对应度 | 中 | 低 | 高 |
选定路线之后,还有一件事要提前想清楚:错误恢复策略。课设里最常见的做法是「遇到第一个语法错误直接抛异常退出」,这在演示时很难看,因为一个分号写错,后面全不检查了。稍微好一点的做法是在语句边界做 panic mode 恢复——发现错误就跳到下一个分号或右花括号,记录一条错误继续往下走,最后统一汇报。这一点在 4.3 节展开。
2.3 模块之间的接口约定:Token、AST、四元式长什么样
三个核心数据结构定下来,后面四个模块才能并行开发。Token 要带行列号,否则报错只能给个「语法错误」;AST 节点要留一个line字段,语义分析报错时要用;四元式用固定四元组,跳转目标留空等着回填。
from dataclasses import dataclass, field from typing import Optional, List @dataclass class Token: kind: str # ID / INT_LIT / FLOAT_LIT / KW / OP / EOF text: str # 原始文本,报错时原样回显给用户 line: int col: int @dataclass class Node: kind: str # Program / Block / Assign / If / While / BinOp / Num / Var ... line: int = 0 value: str = "" # 变量名、字面量文本、运算符 children: List["Node"] = field(default_factory=list) @dataclass class Quad: op: str # + - * / < > == = JMP JZ PARAM PRINT arg1: Optional[str] = None arg2: Optional[str] = None result: Optional[str] = None # 跳转类指令里暂时留空,等回填Token 的kind用字符串而不是枚举,是为了调试时直接打印就看得懂,代价是拼写错误编译器不报,写的时候要统一常量。AST 的children是一个有序列表而不是具名字段,好处是遍历代码短,坏处是取值靠下标 —— 所以每个节点的子节点顺序必须在注释里写死,比如 BinOp 固定是[left, right],If 固定是[cond, then, else?]。
Quad 里最关键的是result字段的双重身份:算术指令里它是目标临时变量,跳转指令里它是目标标号,未回填时是None。后面回填函数就靠这个字段是否为空来判断。
这三份结构定完,再定一条流水线约定:词法只产 Token 列表,语法只产 AST,语义分析在 AST 上挂类型信息并产出四元式列表,代码生成只读四元式。任何模块都不许越过上一步直接读源文件文本。这条约束听起来啰嗦,但它能保证你改词法时不会连带改坏代码生成。
3. 词法与语法分析的落地:一个能跑的最小骨架
这一章的两个模块是最容易「看起来会写、写出来跑不通」的部分。词法的坑集中在最长匹配和关键字识别,语法的坑集中在优先级和错误位置。下面这套骨架在几百行规模内够用,也方便在上面加特性。
3.1 手写词法分析器:最长匹配与关键字表
词法分析的核心只有一条规则:每次必须吃掉尽可能长的前缀。<=不能被拆成<和=,123.45不能被拆成123、.、45。写法上,先尝试双字符运算符,再尝试单字符运算符,最后才匹配标识符和数字,这个顺序不能反。
import re KEYWORDS = {"int", "float", "if", "else", "while", "print"} TWO_CHAR_OPS = ("==", "!=", "<=", ">=", "&&", "||") ONE_CHAR_OPS = set("+-*/(){};=<>!") def tokenize(src: str) -> list[Token]: tokens, i, line, col, n = [], 0, 1, 1, len(src) while i < n: c = src[i] if c == "\n": i, line, col = i + 1, line + 1, 1 continue if c in " \t\r": i, col = i + 1, col + 1 continue # 1) 先看双字符运算符,保证 <= 不会被拆成 < 和 = two = src[i:i+2] if two in TWO_CHAR_OPS: tokens.append(Token("OP", two, line, col)) i, col = i + 2, col + 2 continue # 2) 标识符或关键字:贪心吃满 [A-Za-z_][A-Za-z0-9_]* m = re.match(r"[A-Za-z_][A-Za-z0-9_]*", src[i:]) if m: text = m.group(0) kind = "KW" if text in KEYWORDS else "ID" tokens.append(Token(kind, text, line, col)) i, col = i + len(text), col + len(text) continue # 3) 数字:浮点模式必须排在整数模式前面,否则 3.14 会被截成 3 m = re.match(r"\d+\.\d+|\d+", src[i:]) if m: text = m.group(0) kind = "FLOAT_LIT" if "." in text else "INT_LIT" tokens.append(Token(kind, text, line, col)) i, col = i + len(text), col + len(text) continue # 4) 单字符运算符与分隔符 if c in ONE_CHAR_OPS: tokens.append(Token("OP", c, line, col)) i, col = i + 1, col + 1 continue raise SyntaxError(f"第 {line} 行第 {col} 列出现无法识别的字符 {c!r}") tokens.append(Token("EOF", "", line, col)) return tokens这段代码的四个分支顺序就是最长匹配的执行顺序,改顺序一定会翻车:把数字匹配挪到双字符运算符前面,3.14里的小数点会被当成分隔符;把关键字判断写成「先匹配 ID 再查表」和「直接从关键字表起手」效果一样,但后者要小心intx这种以关键字开头的标识符,所以这里统一走标识符再查表最稳。
line和col两个计数器看着琐碎,但它们是后面所有报错的基础。列号在跳过空白时也要递增,否则一行里第二个错误的位置会指到行首。EOF 这个哨兵 Token 必须有,递归下降里所有while not at("EOF")的循环都靠它收尾,少了它会数组越界。
最后把print也算进关键字,是因为它在文法里是一个语句起始符,如果被识别成普通 ID,语法分析看到print(会以为是函数调用,然后报一个完全找不着北的错误。
3.2 递归下降的骨架:一个非终结符对应一个函数
递归下降写起来几乎是文法的一一翻译。每个非终结符写成一个方法,方法体里按产生式右部的顺序依次匹配。难点不在翻译,而在于两层之间怎么传值 —— 这里用返回值传 AST 子树,用成员变量存当前 Token 下标。
class Parser: def __init__(self, tokens: list[Token]): self.toks = tokens self.pos = 0 def peek(self) -> Token: return self.toks[self.pos] def at(self, text: str) -> bool: return self.peek().text == text def eat(self, text: str) -> Token: t = self.peek() if t.text != text: raise SyntaxError(f"第 {t.line} 行第 {t.col} 列:期望 {text!r},实际是 {t.text!r}") self.pos += 1 return t # block -> '{' decl_list stmt_list '}' def parse_block(self) -> Node: node = Node("Block", line=self.peek().line) self.eat("{") while self.at("int") or self.at("float"): node.children.append(self.parse_decl()) while not self.at("}") and not self.at("EOF"): node.children.append(self.parse_stmt()) self.eat("}") return node # stmt -> assign | if_stmt | while_stmt | block | print_stmt def parse_stmt(self) -> Node: t = self.peek() if t.text == "{": return self.parse_block() if t.text == "if": return self.parse_if() if t.text == "while": return self.parse_while() if t.text == "print": return self.parse_print() if t.kind == "ID": return self.parse_assign() raise SyntaxError(f"第 {t.line} 行第 {t.col} 列:无法识别的语句开头 {t.text!r}")eat是唯一抛语法错误的地方,这样所有报错都带上了行列号,格式统一。parse_block里那个while not at("}") and not at("EOF")的双重判断是必须的:只判}的话,源文件少一个右花括号就会死循环,因为下标永远不前进。
parse_stmt靠首 Token 分派。这里有个隐式约定:语句不能以运算符开头,所以看到ID就是赋值。这个约定在加入函数调用后要改成「看到 ID 再看下一个是不是(」,改动只涉及这一个函数。
声明和语句在parse_block里是分开的两段 while,意味着「声明必须写在语句之前」,C89 风格。这样做是为了语义分析简单——进入块时先一次性把所有声明登记进符号表,后面处理语句时符号表不用再变。如果允许声明和语句交错,符号表就得跟着语句流走,复杂度上一个台阶。
3.3 表达式优先级与左递归消除的具体改法
表达式是整个语法分析里最容易写错优先级的部分。文法里七层优先级对应七个函数,调用关系必须是单向的:expr调logic_or,logic_or调logic_and,一路往下到primary,绝不能回环。
以加减和乘除这两层为例,改法是把左递归add -> add '+' mul展平成循环:
# add -> mul (('+'|'-') mul)* def parse_add(self) -> Node: left = self.parse_mul() while self.at("+") or self.at("-"): op = self.peek() self.pos += 1 right = self.parse_mul() left = Node("BinOp", line=op.line, value=op.text, children=[left, right]) return left # mul -> unary (('*'|'/') unary)* def parse_mul(self) -> Node: left = self.parse_unary() while self.at("*") or self.at("/"): op = self.peek() self.pos += 1 right = self.parse_unary() left = Node("BinOp", line=op.line, value=op.text, children=[left, right]) return left这两个函数的形状完全一样,只差运算符集合和下一层的调用目标。while循环等价于左递归,但因为每轮把已解析的左子树包成新节点再赋值回left,天然形成了左结合的树。如果写成递归调用自己,1-2-3会被解析成1-(2-3),结果是 2 而不是 -4,这是课设里非常典型的错误。
优先级正确性的判断标准是看树形状。1 + 2 * 3解析出来的根节点必须是+,右子节点才是*。调试时直接打印 AST 的括号形式最直观:(+ 1 (* 2 3))就是对的,(* (+ 1 2) 3)就说明parse_add里错误地调用了自己。
还有一处细节:unary层要处理-和!的前缀形式,-x和!x都是一元运算,但如果写成- -x也要能解析,所以unary的实现是在看到运算符后递归调用自己,而不是直接调primary。
4. 语义分析与中间代码生成:符号表、类型检查与回填
前面两步只保证「形状对」,这一步才保证「意思对」。语义分析要干三件事:把变量登记进符号表并检查重复声明、检查每个表达式和语句的类型是否合法、在检查的同时顺带产出四元式。很多实现把最后一步拆成独立的一遍遍历,其实合并在一起更省事,因为类型检查本来就要走一遍 AST。
4.1 栈式符号表:作用域的进入与退出
符号表用列表套字典实现最省心:列表代表作用域链,下标 0 是全局,append 就是进入新块,pop 就是退出。查找时从后往前扫,先命中的就是最近声明。
class SymbolTable: def __init__(self): self.scopes = [{}] # 栈底是全局作用域 def enter_scope(self): self.scopes.append({}) def leave_scope(self): self.scopes.pop() def declare(self, name: str, typ: str, line: int): cur = self.scopes[-1] if name in cur: # 只查当前作用域,允许内层遮蔽外层 raise SyntaxError(f"第 {line} 行:变量 {name} 在同一作用域内重复声明") cur[name] = typ def lookup(self, name: str) -> str: for scope in reversed(self.scopes): # 从最内层往外找 if name in scope: return scope[name] return Nonedeclare只在最内层查重,不查外层,这是为了支持遮蔽:内层块里写int a = 2;是合法的,它遮蔽了外层的a。这一点在答辩时经常被问到,如果实现成全局查重,那么两个并列的块里各声明一个同名变量就会报错,和常见语言的预期不符。
lookup从后往前扫,返回第一个命中的类型,这就是词法作用域的实现方式。注意返回None表示未声明,调用方必须判空并给出「变量未声明」的错误,而不是让None继续往下传,否则后面做类型运算时会抛一个莫名其妙的TypeError。
作用域的进入和退出必须严格配对,parse_block对应的语义处理函数里,进入时enter_scope(),退出时leave_scope(),中间的语句处理如果抛异常,finally里也要保证退出,否则符号表栈会越积越深,后面的块查变量会查到已经被销毁的作用域里。
4.2 类型检查规则与隐式转换
只有 int 和 float 两种类型的时候,类型规则可以写死成一张小表。核心原则是:算术运算允许 int 提升到 float,但赋值时把 float 塞进 int 变量要报错或者显式截断,比较运算两侧类型必须可比较,条件表达式必须是数值类型。
| 运算 | 左类型 | 右类型 | 结果类型 | 说明 |
|---|---|---|---|---|
+ - * / | int | int | int | 整数除法,需要额外判断除零 |
+ - * / | int | float | float | int 侧插入 I2F 转换四元式 |
+ - * / | float | float | float | 直接运算 |
< > <= >= | 数值 | 数值 | bool | 结果用于条件跳转,不单独建类型 |
== != | 数值 | 数值 | bool | int 与 float 比较时统一提升 |
= | int | float | 报错 | 建议要求显式转换,避免精度问题被掩盖 |
= | float | int | float | int 侧插入 I2F |
把规则写成表之后,实现就是一个查表加分支的函数,输入两个操作数类型和运算符,输出结果类型或抛错。bool不作为独立类型存在,而是「比较运算的产物只能用作条件」,这样省掉一套布尔运算的类型规则,也让后面的跳转代码生成简单很多。
隐式转换的实现方式是插入转换四元式,而不是在生成时直接改类型。比如float f = i + 1;里i是 int,1是 int,+的结果是 int,赋值给 float 时生成一条I2F t1,再生成= t1 f。这样做的好处是四元式序列可以逐条解释执行来验证结果,转换过程是显式的、可追踪的。
4.3 四元式生成与布尔表达式的回填
四元式生成的关键是临时变量管理。用一个全局计数器new_temp()每次加一,保证不同表达式之间不撞名。控制流语句的难点在于跳转目标此刻还不知道,必须留空等后面填,这就是回填。
以 while 为例,生成顺序是:先记下循环开始的位置,生成条件表达式的四元式并拿到「条件为假时跳转」的待填列表,生成循环体,循环体末尾无条件跳回开始位置,最后把假出口回填到循环体之后。
class CodeGen: def __init__(self): self.quads = [] self.temp_id = 0 def new_temp(self) -> str: self.temp_id += 1 return f"t{self.temp_id}" def emit(self, op, arg1=None, arg2=None, result=None) -> int: self.quads.append(Quad(op, arg1, arg2, result)) return len(self.quads) - 1 # 返回该四元式的下标,供回填定位 def backpatch(self, holes: list[int], target: int): for idx in holes: self.quads[idx].result = str(target) # 跳转指令的目标统一写在 result 字段 def gen_while(self, node: Node): start = len(self.quads) # 循环条件的第一条指令位置 cond_false = self.gen_cond(node.children[0]) # 返回「条件为假时」的待填列表 self.gen_stmt(node.children[1]) # 循环体 self.emit("JMP", result=str(start)) # 无条件跳回条件 self.backpatch(cond_false, len(self.quads)) # 假出口指向循环结束后的第一条emit返回下标这一点是整个回填机制的基础。没有它在返回时就撤回指令的写法,所以每生成一条跳转指令,都要同时把它的下标收进待填列表。backpatch就是遍历列表,把result字段从None改成目标位置字符串。
gen_cond返回的是truelist和falselist两个列表,if 语句只用falselist跳到 else 分支,while 用falselist跳出循环。短路求值的&&和||也靠这套机制:a && b里如果a为假,直接跳到整体为假的位置,不再计算b,做法就是把a的falselist直接并入整体的falselist。
赋值语句的四元式最简单,= arg1 result,其中result是变量名而不是临时变量。print 语句生成PRINT arg1,解释执行时直接输出。所有分支语句生成完之后,quads列表里应该没有result为None的跳转指令,这是最后一道自检:遍历一遍,发现JMP或JZ的result是None就说明有列表忘了回填。
4.4 基本块划分与活跃变量分析
流水线跑通之后,想拿更高分通常要加优化。最轻量的是局部优化:按跳转指令和标号把四元式切成基本块,每块内做一次活跃变量分析,再做常量折叠和公共子表达式消除。
基本块划分规则只有两条:第一条指令是新块开头,任何跳转目标所在指令也是新块开头;跳转指令和下一条是标号的指令是本块结尾。划分完之后,每块内独立分析,块的入口记录活跃变量集合。
活跃变量分析用 use/def 集合加逆序迭代。对每条四元式,先算出它用到的变量集合和定义的变量集合,然后从块尾往块头扫,每扫一条更新live_out = live_in - def + use。跨块的要靠前驱后继关系迭代到不动点,课设规模下循环个三五轮就收敛了。
def live_analysis(quads: list[Quad]) -> dict[int, set[str]]: live_in, live_out = {}, {} for i in reversed(range(len(quads))): q = quads[i] uses = {x for x in (q.arg1, q.arg2) if x and not x.isdigit()} defs = {q.result} if q.op not in ("JMP", "JZ", "PRINT") and q.result else set() succ_live = live_in.get(i + 1, set()) # 单后继的简化写法,遇到跳转要换成按目标查 live_out[i] = succ_live live_in[i] = (succ_live - defs) | uses return live_in这段是单后继简化版,遇到JMP、JZ时要改成按跳转目标合并后继的live_in,否则跨块的活跃变量会算漏。得到的活跃信息最直接的用途是寄存器分配:如果当前只有两个寄存器,块内同时活跃的变量超过两个就必须溢出到临时变量。课设里通常不真做分配,只输出「每个基本块入口的活跃变量集合」作为优化结果展示,这已经足够说明优化器在工作。
5. 避坑与排查:五个让课程设计当场翻车的瞬间
下面五条都是实际写的时候大概率会撞上的,按「现象 → 原因 → 解决」列出来,遇到时可以直接对照。
第一条:程序跑起来直接卡死,CPU 占满但没有任何输出。现象是丢一个正常源文件进去,命令行不动了,强制中断后调用栈停在parse_block里。原因是块解析的循环只判断了当前 Token 不是},源文件少写一个右花括号时循环永远不结束,pos停在 EOF 上不再前进。解决是循环条件加上not self.at("EOF"),并且在parse_stmt的兜底分支里对 EOF 抛错,让错误尽快冒出来。
第二条:1 + 2 * 3算出来是 9。现象是表达式结果和手算对不上,而且换一个2 * 3 + 1又对了。原因是parse_add里没走循环,而是递归调用了自己或者错误地调用了parse_primary,导致优先级被压平,实际按从左到右结合。解决是把加减层和乘除层拆成两个独立函数,加减层只调乘除层,乘除层只调一元层,调用关系单向不可回环,然后用 AST 的括号形式打印验证树形状。
第三条:块内声明的变量出了块还能用。现象是{ int a = 1; } print(a);这样明显越界的代码通过了语义检查,甚至能生成四元式。原因是处理块时只调用了enter_scope却没在末尾调用leave_scope,或者中途因为抛错跳过了退出逻辑。解决是用try/finally包住块处理,保证退出作用域一定执行,同时在lookup里对找不到的名字显式报「未声明」,不要返回None让错误延后爆发。
第四条:生成的跳转指令目标是空的,解释执行时直接崩溃。现象是四元式打印出来,某条JZ的result是None,跑解释器时拿它当数组下标直接越界。原因是回填列表在某条分支上漏了合并,比如 if 没有 else 分支时,truelist和falselist只回填了其中一个。解决是在整个函数生成完之后加一道全局自检:遍历所有四元式,凡是JMP、JZ且result is None的一律报错并打印下标,把问题挡在解释执行之前。
第五条:<=被识别成<和=,报错位置还指在行尾。现象是简单的比较语句报「期望表达式,实际是=」,但肉眼看上去写得完全正确。原因是词法分析里单字符运算符的判断放在了双字符前面,或者双字符匹配的切片长度写成了 1。解决是固定分支顺序:双字符运算符、标识符与关键字、浮点、整数、单字符,任何一步命中就continue,绝不落到下一步;同时在col的累加里用实际吃掉的长度,别写死加一。
6. 怎么证明你的编译器没算错:三道验证关卡
写完不验证,等于交了一份自己都不知道对不对的作业。我一般会布三道关卡,由浅到深,每道都能独立发现问题。
第一道是 AST 可视化。写一个十行的dump(node, depth)递归函数,把树打成缩进括号形式,拿几个手写的小程序跑一遍,重点看1+2*3、a = b = 1、if (a<b) x=1; else x=2;这三类。表达式优先级和悬垂 else 的绑定关系,在这张图上是一眼能看出来的,比断点调试快得多。
第二道是四元式解释执行。写一个两百行左右的解释器,用字典存变量,用pc扫四元式列表,遇到JMP直接改pc,遇到JZ看条件值决定是否跳转。这一步的价值在于它不依赖目标机器的任何假设,纯靠四元式语义跑出结果。然后用同一批测试程序,对比「C 或 Python 里手写的等价逻辑」和「解释器跑出来的结果」,不一致就说明中间代码生成有问题。
第三道是随机差分测试,这一道最能兜住边界。写一个随机表达式生成器,只生成+ - * /、括号和 1 到 9 的字面量,深度控制在 4 层以内,躲开除零。生成 500 条,每条同时交给「自己写的解析加求值」和「语言自带的 eval」,比对结果。真正跑起来的时候会发现一类很隐蔽的 bug:负数字面量和一元负号混淆,-3被当成字面量还是-(3),两种解析路径在2--3这种输入下结果不同。
import random def gen_expr(depth: int) -> str: if depth == 0: return str(random.randint(1, 9)) kind = random.choice(["bin", "bin", "paren"]) if kind == "paren": return f"({gen_expr(depth - 1)})" op = random.choice(["+", "-", "*"]) # 不含 /,避免除零干扰比对 return f"{gen_expr(depth - 1)} {op} {gen_expr(depth - 1)}" def check(n: int = 500) -> int: bad = 0 for _ in range(n): src = gen_expr(3) mine = eval_with_my_compiler(src) # 走自己的词法-语法-四元式-解释器 ref = eval(src) # 用宿主语言求值做参照 if mine != ref: bad += 1 print("不一致:", src, "mine=", mine, "ref=", ref) return bad生成器刻意排除了除法和变量,就是为了让不一致一定来自编译器本身,而不是浮点误差或未初始化变量。真出现不一致时,先把那条表达式单独拿出来打 AST,八成能看到某个运算符的左右子树接反了。
这三道关卡跑完,心就比较定了。我自己的习惯是每次改完代码生成,先把这 500 条随机用例跑一遍再提交,比肉眼检查快得多,也能挡住那些「改了一处、坏了两处」的回归。编译原理课程设计真正难的地方不在于某个算法多深,而在于一条链路上每个模块都可能悄悄错一点点,最后表现成一个莫名其妙的结果,所以能自动比对的验证手段,越早写越省事。希望帮到你。
本文还有配套的精品资源,点击获取