编译原理学习总结:新人也能看懂的编译器流水线与避坑指南
- 约 14 分钟读完 - 全文 5443 字编译原理学习总结:新人也能看懂的编译器流水线与避坑指南
参考教材:王生原、董渊、张素琴、吕映芝、蒋维杜《编译原理(第 3 版)》,清华大学出版社。 目标读者:刚开编译原理课、被「NFA/DFA/LL(1)/LR」一堆缩写吓到的同学;或者工作中想手写个 DSL、规则引擎、配置解析器,发现需要补编译基础的工程师。
编译原理这门课的劝退点不在「难」,而在概念链条太长:正则表达式→NFA→DFA→最小化,文法→FIRST→FOLLOW→分析表,每一环都建立在前一环上,掉队一步后面全懵。这篇总结帮你做三件事:
- 建立主干认知:编译器就是一条「翻译流水线」,六个阶段各干一件事;
- 给出最小可运行的实现:词法分析器、递归下降 Parser、一个 60 行的计算器;
- 把新人必踩的坑(左递归、二义性、优先级结合性、FIRST/FOLLOW 算错)提前标出来。
一、先建立心智模型:编译器 = 翻译流水线
编译器做的事,和人工翻译一篇文章一模一样:先认单词,再分析句子结构,再理解含义,最后用另一种语言重新表达。对应六个阶段:
flowchart LR
SRC["源程序<br/>a = b + 2 * c"] --> LEX["词法分析<br/>切成单词"]
LEX --> SYN["语法分析<br/>搭出句子结构树"]
SYN --> SEM["语义分析<br/>检查含义是否合理"]
SEM --> ICG["中间代码生成<br/>三地址码"]
ICG --> OPT["代码优化"]
OPT --> CG["目标代码生成<br/>汇编/机器码"]
SYM(["符号表<br/>记录每个名字是什么"]) -.贯穿.- LEX
SYM -.贯穿.- SYN
SYM -.贯穿.- SEM
ERR(["错误处理<br/>报错并恢复继续"]) -.贯穿.- LEX
ERR -.贯穿.- SYN
ERR -.贯穿.- SEM
记三个要点,后面全部内容都挂在这上面:
- 符号表和错误处理不是独立阶段,而是贯穿全程的两条辅助线。符号表回答「这个名字是什么(类型、作用域、地址)」;错误处理保证发现一个错不崩溃,继续分析把后面的错也找出来。
- 前三个阶段是「理解原文」(分析/前端),后三个阶段是「重新表达」(综合/后端)。中间代码是分界线——这也是为什么 GCC/LLVM 能「N 种语言 × M 种CPU」:每种语言写个前端,每种 CPU 写个后端,中间用统一的 IR 对接。
- 每个阶段的输出是下一个阶段的输入,所以学的时候务必搞清每个阶段的「输入是什么、输出是什么」,概念就不容易混。
日常最常见的对照例子:
| 阶段 | 干的事 | 日常类比 | 典型报错 |
|---|---|---|---|
| 词法分析 | 字符流 → Token 流 | 认出单词 | invalid character |
| 语法分析 | Token 流 → 语法树 | 分析句子结构 | unexpected token |
| 语义分析 | 检查类型、声明 | 理解句子含义 | undefined variable / 类型不匹配 |
| 中间代码 | 语法树 → 三地址码 | 列翻译提纲 | — |
| 优化 | 等价变换让代码更快 | 润色 | — |
| 目标代码 | IR → 汇编/机器码 | 正式成文 | — |
二、词法分析:把字符流切成单词
2.1 任务与产出
输入 a = b + 2 * c,输出 Token 序列:ID(a) ASSIGN PLUS...。每个 Token 带「种别 + 属性值」。
2.2 理论主线:正则表达式 → NFA → DFA → 最小化
这是教材第 3 章的核心链条,也是新人最容易断掉的地方。一张图记住四个概念的关系:
flowchart LR
RE["正则表达式<br/>letter(letter|digit)*"] -->|Thompson 构造| NFA["NFA<br/>一个字符可走多条路"]
NFA -->|子集构造法| DFA["DFA<br/>每个状态+字符<br/>只有唯一去处"]
DFA -->|状态划分| MINDFA["最小化 DFA<br/>合并等价状态"]
三个构造算法各自一句话记住:
- Thompson 构造:把正则的每种运算(连接、
|、*)各对应一个固定的 NFA 拼图,像搭积木一样拼出整图。因为允许 ε 边(不读字符就能跳的边),所以拼起来毫无难度——这就是引入 NFA 的原因。 - 子集构造法:DFA 的每个状态 = NFA 的「一组状态的集合」(从起点出发、经过任意条 ε 边能到达的所有状态,即 ε-闭包)。NFA 的不确定性被「打包进集合」消掉了。
- 最小化:不断把「行为一样」(对每个字符都跳到同一组)的状态合并。
避坑:ε-闭包是子集构造里最容易漏算的一步——每读一个字符到达新状态后,必须再沿 ε 边补一轮闭包,漏了就会丢边,识别结果出错。
2.3 例:识别标识符与整数的 DFA
stateDiagram-v2
[*] --> S0
S0 --> S1 : 字母
S1 --> S1 : 字母或数字
S0 --> S2 : 数字
S2 --> S2 : 数字
S1 --> [*] : 其他字符,接受 ID
S2 --> [*] : 其他字符,接受 NUM
2.4 手写实现:实践中没人先画 NFA
工程上写词法分析器,直接用「最长匹配 + 逐个分派」的循环,十几行搞定,理论留在脑子里:
import re
TOKEN_SPEC = [
("NUM", r"\d+"), # 数字
("ID", r"[A-Za-z_]\w*"), # 标识符
("OP", r"[+\-*/=]"), # 运算符
("SKIP", r"[ \t]+"), # 空白,跳过
("BAD", r"."), # 兜底:非法字符
]
def tokenize(code):
pattern = "|".join(f"(?P<{k}>{v})" for k, v in TOKEN_SPEC)
for m in re.finditer(pattern, code):
kind, val = m.lastgroup, m.group()
if kind == "SKIP":
continue
if kind == "BAD":
raise SyntaxError(f"非法字符: {val!r}") # 词法错误在这里报
yield (kind, val)
print(list(tokenize("a = b + 2 * c")))
# [('ID','a'), ('OP','='), ('ID','b'), ('OP','+'), ('NUM','2'), ('OP','*'), ('ID','c')]避坑:正则的排列顺序就是优先级(= 要排在 == 后面之类),以及永远留一个兜底规则报「非法字符」,否则遇到乱字符直接静默跳过,后面语法分析报错位置会莫名其妙。
三、语法分析:把单词流搭成结构树
3.1 上下文无关文法(CFG)与推导
语法规则用 CFG 描述,例如表达式文法:
E → E + T | T
T → T * F | F
F → ( E ) | id | num2 + 3 * 4 的语法树(注意 * 比 + 离根更远——离根越远越先算,优先级就藏在这里):
flowchart TD
E1["E"] --> E2["E"] & PLUS["+"] & T1["T"]
E2 --> T2["T"] --> F1["F"] --> N2["num 2"]
T1 --> T3["T"] & MUL["*"] & F2["F"]
T3 --> F3["F"] --> N3["num 3"]
F2 --> N4["num 4"]
3.2 两大流派:自顶向下 vs 自底向上
flowchart TB
subgraph TD["自顶向下(LL 家族)"]
direction LR
A1["从根开始<br/>预测该展开哪条规则"] --> A2["代表:递归下降<br/>LL(1) 表驱动"]
A2 --> A3["优点:好写好懂<br/>缺点:怕左递归/回溯"]
end
subgraph BU["自底向上(LR 家族)"]
direction LR
B1["从叶子开始<br/>移进-归约"] --> B2["代表:LR(0)/SLR/<br/>LALR(1)/LR(1)"]
B2 --> B3["优点:能力强<br/>缺点:表难手写<br/>用 Yacc/Bison 生成"]
end
- 手写 Parser 用递归下降(每个非终结符一个函数),工具生成用 Yacc/Bison(LALR)。工业界主流语言(GCC 的 C++、Go、Rust 前端)大量采用手写递归下降——错误信息友好、调试容易。
- LR 家族记一个不等式:LR(1) ⊃ LALR(1) ⊃ SLR ⊃ LR(0),能力依次减弱、表依次变小。Yacc 生成的是 LALR(1)——能力和表大小的折中。
3.3 LL(1) 的三道手续:左递归、左公因子、FIRST/FOLLOW
把文法改造成 LL(1) 可用,是教材第 4 章的主线,也是作业/考试重灾区:
坑 1:左递归会让递归下降死循环。 E → E + T 翻译成函数就是「解析 E 先调自己」,无限递归。必须改写成右递归/迭代形式:
E → T E'
E' → + T E' | ε (原来是左递归 E → E + T | T)
T → F T'
T' → * F T' | ε
F → ( E ) | id | num坑 2:左公因子导致「选哪条规则」看不准。 S → if E then S | if E then S else S 两条规则开头一样,提取公因子:
S → if E then S S'
S' → else S | ε坑 3:FIRST/FOLLOW 算错,分析表全错。 记住用途就不容易错:
- FIRST(A):A 推出的串开头可能是什么终结符——决定「看到某个 token 该选哪条产生式」;
- FOLLOW(A):A 后面可能跟什么终结符——只在「考虑是否选 ε 产生式」时用。
算 FOLLOW 的三条规则(教材原文浓缩):$ 进 FOLLOW(S);A → αBβ 则 FIRST(β)−ε 进 FOLLOW(B);A → αB 或 β 可空时 FOLLOW(A) 进 FOLLOW(B)。最常犯的错就是漏掉第三条,考试拿分点也在这里。
3.4 手写实现:递归下降表达式解析器
改造后的文法直接翻译成人话:每个非终结符一个函数,看到什么 token 走哪条分支:
class Parser:
def __init__(self, tokens):
self.tokens = list(tokens)
self.pos = 0
def peek(self):
return self.tokens[self.pos] if self.pos < len(self.tokens) else (None, None)
def eat(self, kind):
k, v = self.peek()
if k != kind:
raise SyntaxError(f"期望 {kind},实际 {k}:{v}") # 语法错误在这里报
self.pos += 1
return v
# E → T E'
def parse_E(self):
val = self.parse_T()
while self.peek() == ("OP", "+"):
self.eat("OP"); val += self.parse_T() # E' → + T E'
return val
# T → F T'
def parse_T(self):
val = self.parse_F()
while self.peek() == ("OP", "*"):
self.eat("OP"); val *= self.parse_F() # T' → * F T'
return val
# F → num | ( E )
def parse_F(self):
if self.peek()[0] == "NUM":
return int(self.eat("NUM"))
self.eat("LP"); val = self.parse_E(); self.eat("RP")
return val三个层次各管一件事:E 管加减、T 管乘除、F 管括号与原子——优先级靠「调用层级」天然保证,结合性靠 while 循环方向保证(左结合)。
四、语义分析与中间代码
4.1 语义分析:语法对了,意思对吗
int x = "abc"; 语法完全合法,但语义不通。语义分析干三件事:
- 查符号表:变量声明过吗?作用域对吗?重复定义了吗?
- 类型检查:运算两边类型兼容吗?函数调用参数个数/类型对吗?
- 理论上用**属性文法/语法制导翻译(SDD/SDT)**描述:给语法树节点挂「综合属性」(从下往上传,如类型)和「继承属性」(从上往下传,如声明的类型传给变量表项)。
避坑:语义错误往往是「前面埋雷后面炸」——声明处没检查,使用处才报。写编译器时把检查尽量前移到「信息刚产生的地方」。
4.2 中间代码:三地址码
a = b + 2 * c 翻成三地址码(每条指令至多一个运算符):
t1 = 2 * c
t2 = b + t1
a = t2作用:把前端和后端解耦。LLVM IR、Java 字节码都是这个思想——前端不管什么 CPU,后端不管什么语言。
五、优化与目标代码
5.1 优化:等价变换,不改结果改速度
新人只需记住四类常见优化,看到汇编里的「怪异写法」能认出是哪种:
| 优化 | 例子 | 效果 |
|---|---|---|
| 常量折叠/传播 | 2*3 → 6 |
编译期算掉 |
| 公共子表达式消除 | a+b 算两次 → 算一次存临时量 |
少算 |
| 死代码消除 | 删掉结果没人用的语句 | 代码变小 |
| 循环优化 | i*4 → 强度削弱为加法;循环不变量外提 |
热点提速 |
避坑:优化的铁律是「语义等价」。浮点运算不满足结合律,(a+b)+c 和 a+(b+c) 结果可能不同——所以激进优化可能改变浮点结果(GCC 的 -ffast-math 就会),数值程序测试对不上时先查这个。
5.2 目标代码:核心是寄存器分配
指令选择、指令调度了解一下即可,真正值得记住的是寄存器分配:寄存器快但数量有限,谁住寄存器谁住内存?经典解法是图着色——变量当顶点、同时活跃的变量连边,寄存器当颜色,给图染色。这个「把分配问题建模成图着色」的思想,比算法细节本身更有迁移价值。
六、动手实战:60 行写一个四则运算计算器
把第二、三章的东西拼起来:词法用正则,语法用递归下降,语义直接求值(跳过中间代码),支持 + - * / ( ):
import re
def tokenize(code):
spec = [("NUM", r"\d+"), ("OP", r"[+\-*/]"),
("LP", r"\("), ("RP", r"\)"), ("SKIP", r"\s+"), ("BAD", r".")]
pattern = "|".join(f"(?P<{k}>{v})" for k, v in spec)
for m in re.finditer(pattern, code):
if m.lastgroup == "SKIP": continue
if m.lastgroup == "BAD": raise SyntaxError(f"非法字符 {m.group()!r}")
yield (m.lastgroup, m.group())
def calc(code):
ts, pos = list(tokenize(code)), [0] # pos 用列表包一层方便改
def peek(): return ts[pos[0]] if pos[0] < len(ts) else (None, None)
def eat(k):
if peek()[0] != k: raise SyntaxError(f"期望 {k}, 得到 {peek()}")
pos[0] += 1; return ts[pos[0]-1][1]
def expr(): # expr → term ((+|-) term)*
v = term()
while peek() in (("OP","+"), ("OP","-")):
op = eat("OP"); r = term()
v = v + r if op == "+" else v - r
return v
def term(): # term → factor ((*|/) factor)*
v = factor()
while peek() in (("OP","*"), ("OP","/")):
op = eat("OP"); r = factor()
v = v * r if op == "*" else v / r
return v
def factor(): # factor → NUM | ( expr )
if peek()[0] == "NUM": return int(eat("NUM"))
eat("LP"); v = expr(); eat("RP"); return v
result = expr()
if pos[0] != len(ts): raise SyntaxError("表达式未结束,多了内容")
return result
print(calc("2 + 3 * (4 - 1)")) # 11写完这个,你就走完了一个迷你编译器的完整前端:字符 → Token → 语法树(隐含在调用栈里)→ 结果。想再进一步,把 expr/term/factor 里的「直接求值」换成「返回 AST 节点」,就有了语法树;遍历 AST 输出三地址码,就有了中间代码层。
七、典型问题速查(新人必踩的坑)
| 症状 | 根因 | 解法 |
|---|---|---|
| 递归下降死循环/栈溢出 | 文法有左递归 E → E + T |
改写为右递归形式(E → T E') |
| 同一个输入两种语法树 | 文法二义性(如 E → E+E) |
分层文法(E/T/F)或显式声明优先级结合性 |
| if-else 挂错位置(dangling else) | 二义性经典案例 | 规定 else 匹配最近的未配对 then(Yacc 默认移进优先即可) |
1+2*3 算成 9 |
优先级没分层 | 运算符按优先级各分一层非终结符 |
8/4/2 算成 4 |
结合性错了(写成右结合) | 左结合用 while 循环收集,右结合(如 ^、赋值)用递归 |
| LL(1) 分析表一格填了两条 | FIRST 集相交,或 ε 规则的 FIRST∩FOLLOW 非空 | 提左公因子;重算 FIRST/FOLLOW |
| 遇到一个错就整个编译中断 | 没做错误恢复 | 恐慌模式:跳过 token 直到同步符号(;、})再继续 |
| 子集构造结果丢边 | 读完字符后忘了补 ε-闭包 | 每步「先 move 再 closure」写成固定套路 |
| 优化后浮点结果对不上 | 浮点不满足结合律,优化重排了运算 | 关掉激进浮点优化,或接受精度差异 |
八、学习路线建议
- 第一周:看懂六阶段流水线图,用正则手写一个词法分析器。
- 第二周:攻形式语言——正则→NFA→DFA→最小化,动手做一遍子集构造的纸面推导(这步走一遍比看十遍有用)。
- 第三周:递归下降写表达式 Parser,亲手把左递归文法改写成 LL(1)。
- 第四周:跑通本文的计算器,然后加变量赋值(需要符号表)——这一步会让你真正理解符号表为什么贯穿始终。
- 进阶:读 LR 分析表构造(SLR→LALR),用 Yacc/Bison 或 ANTLR 生成一个 Parser 对比手写的差别;再往后可以啃「龙书」的优化章节。
最后说一句教材反复强调的心法:编译器的每个阶段都是「输入一种表示,输出另一种表示」。学不懂某个概念时,先问两个问题——它输入什么、输出什么?答案往往就是理解本身。
参考资料:
- 王生原、董渊、张素琴、吕映芝、蒋维杜《编译原理(第 3 版)》,清华大学出版社
- Aho 等《编译原理》(龙书, Compilers: Principles, Techniques, and Tools)
- ANTLR 官方文档(工具生成 Parser 的现代选择)
- LLVM 官方文档(中间代码与后端思想的工业界范本)