1. 编译流程总览
1.1 前端与后端
编译器把一个高级语言程序翻译成等价的目标代码。整个流程可分为前端(与语言相关:词法/语法/语义)与后端(与机器相关:优化/指令选择/寄存器分配),中间的桥梁是中间表示 IR。
源代码
│ 1. 词法分析(Lexing) 字符流 → Token 流
│ 2. 语法分析(Parsing) Token 流 → AST
│ 3. 语义分析(Semantic) AST + 类型检查 → 带标注 AST
│ 4. 中间代码生成 → 三地址码 / SSA IR
│ 5. 优化(Optimization) 优化后的 IR
│ 6. 目标代码生成 → 汇编 / 机器码
↓
目标程序
| 阶段 | 输入 | 输出 | 典型工具/概念 |
|---|---|---|---|
| 词法分析 | 字符流 | Token 流 | Lexer、正则、DFA |
| 语法分析 | Token 流 | AST | Parser、LL/LR、递归下降 |
| 语义分析 | AST | 带类型标注 AST | 符号表、类型检查 |
| 中间代码生成 | 标注 AST | 三地址码/SSA | IR、基本块 |
| 优化 | IR | 优化后 IR | 常量传播、死代码消除 |
| 目标代码生成 | IR | 汇编/机器码 | 指令选择、寄存器分配 |
理解编译流程的价值不止于写编译器:正则/DFA 是文本处理核心,递归下降是手写解析器的标准做法,SSA 是 LLVM/GCC 的公共优化基础——这些知识直接迁移到解释器、DSL、SQL 解析器、模板引擎等场景。
2. 词法分析:正则与 DFA
2.1 Token 与正则
词法分析把字符流切分为 Token(类型 + 值 + 位置)。每种 Token 用正则表达式描述,全部正则合并为一个 NFA,再确定化为 DFA,即可线性扫描识别所有 Token。
# 简单词法分析器:识别标识符、数字、运算符、关键字
import re
TOKEN_RE = [
("NUMBER", r"\d+"),
("ID", r"[A-Za-z_]\w*"),
("OP", r"[+\-*/=<>!]"),
("WS", r"\s+"),
]
KEYWORDS = {"if", "else", "while", "return"}
def lex(code):
tokens = []
pos = 0
while pos < len(code):
for kind, pattern in TOKEN_RE:
m = re.match(pattern, code[pos:])
if m:
text = m.group()
if kind == "ID" and text in KEYWORDS:
kind = "KEYWORD"
if kind != "WS": # 跳过空白
tokens.append((kind, text))
pos += len(text)
break
else:
raise SyntaxError(f"无法识别: {code[pos:]}")
tokens.append(("EOF", ""))
return tokens
print(lex("x = 42 + y")) # [('ID','x'),('OP','='),('NUMBER','42'),('OP','+'),('ID','y')]
2.2 正则 → NFA → DFA
| 正则运算 | NFA 构造 | 说明 |
|---|---|---|
连接 ab | 两状态串行 | ε 连接 |
| 并 `a | b` | 新起点分支 |
闭包 a* | 回边 | 可重复 0 次以上 |
字符类 [a-z] | 多输入边 | 等价并 |
DFA 状态最小化:合并等价状态后得到最小 DFA。像 lex/flex、Go 的
regexp、Python 的re内部都基于 NFA/DFA(或 Thompson 模拟)。正则能力边界是正则语言;需要括号配对、递归结构时必须上升到语法分析。
| 自动机 | 状态转换 | 复杂度 |
|---|---|---|
| NFA | 一个输入可走多条 ε/字符边 | 模拟 O(n·m) |
| DFA | 每个输入唯一确定下一状态 | 匹配 O(n),状态可能指数膨胀 |
3. 语法分析:递归下降
3.1 文法与产生式
语法分析根据文法把 Token 流组合成 AST。经典算术表达式文法(含优先级与结合性):
expr := term (('+' | '-') term)*
term := factor (('*' | '/') factor)*
factor := NUMBER | '(' expr ')'
3.2 递归下降实现
递归下降是最容易手写实现的解析方法:每个非终结符对应一个递归函数,下降过程即推导过程。上面的文法消除了左递归与歧义,可直接翻译为代码。
class Parser:
def __init__(self, tokens):
self.tokens = tokens
self.pos = 0
def peek(self):
return self.tokens[self.pos]
def consume(self, expect=None):
tok = self.tokens[self.pos]
if expect and tok[1] != expect:
raise SyntaxError(f"期望 {expect}, 实际 {tok}")
self.pos += 1
return tok
def parse_expr(self):
node = self.parse_term()
while self.peek()[1] in ("+", "-"):
op = self.consume()[1]
rhs = self.parse_term()
node = (op, node, rhs) # 左结合:((a+b)-c)
return node
def parse_term(self):
node = self.parse_factor()
while self.peek()[1] in ("*", "/"):
op = self.consume()[1]
rhs = self.parse_factor()
node = (op, node, rhs)
return node
def parse_factor(self):
kind, val = self.consume()
if kind == "NUMBER":
return ("num", int(val))
if val == "(":
inner = self.parse_expr()
self.consume(")")
return inner
raise SyntaxError("非法因子")
# tokens = lex("3 + 4 * (2 - 1)")
# ast = Parser(tokens).parse_expr() → ('+', ('num',3), ('*',('num',4), ('-',('num',2),('num',1))))
4. 语法分析:LL 与 LR
4.1 自顶向下 vs 自底向上
| 维度 | LL(自顶向下) | LR(自底向上) |
|---|---|---|
| 方向 | 从开始符号向下推导 | 从终结符向上归约 |
| 表驱动 | LL(1) 预测分析表 | LR(0)/SLR/LR(1)/LALR |
| 文法要求 | 无左递归、无二义 | 适用文法更广(几乎所有编程语言) |
| 代表工具 | 手写递归下降、ANTLR(LL*) | Yacc、Bison、LALR |
| 优点 | 直观、错误信息好 | 文法表达力强、无回溯 |
LL(1) 预测分析需要 FIRST/FOLLOW 集:看到当前终结符即可唯一决定采用哪条产生式,故为"1 个前瞻符号"的 LL。手写解析器通常就是 LL 思想(递归下降)。
# LL(1) 预测分析表驱动的核心循环(伪代码)
def predictive_parse(table, start_symbol, tokens):
stack = [start_symbol]
a = next_token(tokens)
while stack:
X = stack.pop()
if X.is_terminal():
if X == a:
a = next_token(tokens)
else:
raise SyntaxError("终结符不匹配")
else:
production = table[X][a] # 由前瞻符号查表决定产生式
stack.push(reversed(production)) # 右端逆序入栈
| 文法类别 | 能否被 LL 处理 | 能否被 LR 处理 | 例子 |
|---|---|---|---|
| 左递归文法 | 否(需改写) | 是 | E → E + T |
| 二义文法 | 否 | 通过优先级声明 | E → E + E |
| 完全上下文无关 | 否 | 否 | aⁿbⁿcⁿ(需要上下文) |
5. 抽象语法树 AST
5.1 AST 与语法树区别
语法树(Parse Tree / CST)保留文法全部细节(含无意义的括号、分隔符);AST 只保留对后续阶段有意义的结构,剔除括号与冗余节点。AST 是编译前端与各工具(Lint、格式化、代码生成)共享的核心数据结构。
// 表达式 3 + 4 * (2 - 1) 的 AST
{
"type": "BinaryExpr",
"op": "+",
"left": { "type": "Number", "value": 3 },
"right": {
"type": "BinaryExpr",
"op": "*",
"left": { "type": "Number", "value": 4 },
"right": {
"type": "BinaryExpr",
"op": "-",
"left": { "type": "Number", "value": 2 },
"right": { "type": "Number", "value": 1 }
}
}
}
# AST 求值:递归遍历节点
def evaluate(node):
kind = node[0]
if kind == "num":
return node[1]
op, left, right = node
l, r = evaluate(left), evaluate(right)
if op == "+": return l + r
if op == "-": return l - r
if op == "*": return l * r
if op == "/": return l // r
5.2 语义分析
AST 构建后进入语义分析:建立符号表、做类型检查、解析作用域与名称绑定。
| 检查项 | 说明 | 出错示例 |
|---|---|---|
| 未定义变量 | 符号表查找失败 | use x; 未声明 |
| 类型不匹配 | 操作数类型与运算符不符 | "a" + 1(强类型语言) |
| 作用域冲突 | 重复定义 | int x; int x; |
| 返回类型检查 | 函数返回值与声明不符 | int f(){ return "x"; } |
6. 中间表示:三地址码与 SSA
6.1 三地址码
每条指令至多三个地址(变量/常量),把复杂表达式拆成基本运算,便于后续优化与代码生成。真实中间表示如 LLVM IR、Java Bytecode、WebAssembly 都类似三地址码。
源代码: result = a + b * c
三地址码:
t1 = b * c
t2 = a + t1
result = t2
| 指令类型 | 三地址形式 | 例子 |
|---|---|---|
| 赋值 | x = y op z | t1 = b * c |
| 拷贝 | x = y | t2 = a |
| 跳转 | goto L / if x relop y goto L | if t1 > 0 goto L1 |
| 调用 | call f | call print |
| 返回 | return x | return t2 |
6.2 SSA(静态单赋值)
SSA 要求每个变量只赋值一次,合并控制流时用 φ(phi)函数选择不同分支的值。SSA 让数据流信息显式化,是 LLVM/GCC 优化器的基础。
转换前: 转换后(SSA):
a = 1 a1 = 1
if (cond) { if (cond) {
a = a + 2; → a2 = a1 + 2;
} else { } else {
a = a * 3; a3 = a1 * 3;
} }
a4 = φ(a2, a3) ← phi 合并两个分支
| 优点 | 说明 |
|---|---|
| 定义即使用链 | 变量只定义一次,定义-使用关系清晰 |
| 简化优化 | 常量传播、公共子表达式消除在 SSA 上实现更简单 |
| 精确的活变量分析 | φ 节点显式表达数据流汇合点 |
7. 代码优化
7.1 优化分类
优化按阶段分:局部优化(基本块内)、全局优化(跨基本块)、机器相关优化(寄存器分配、指令调度)。目标是减少指令数、消除冗余、利用硬件特性。
| 优化技术 | 原理 | 示例 |
|---|---|---|
| 常量折叠 | 编译期计算常量表达式 | 2 * 3 → 6 |
| 常量传播 | 把常量代入使用处 | x = 42; y = x + 1 → y = 43 |
| 死代码消除 | 删除结果未被使用的指令 | 删掉从未读的赋值 |
| 公共子表达式消除 | 复用已算过的相同子表达式 | a*b + a*b 只算一次 |
| 循环不变式外提 | 把循环内不变计算移到循环外 | for: x = i * const 提外 |
| 强度削减 | 昂贵运算换廉价运算 | i * 2 → i + i 或 i << 1 |
7.2 窥孔优化与寄存器分配
# 窥孔优化示例:合并相邻指令(模式匹配滑动窗口)
def peephole(ir):
optimized = []
for insn in ir:
# 模式 1: x = y; z = x → z = y
# 模式 2: if x == 0 goto L; goto L2 → if x == 0 goto L; L2: ...
optimized.append(insn)
return optimized
寄存器分配(如图着色算法)决定哪些变量驻留寄存器、哪些溢出到内存,对最终性能影响巨大。现代编译器(LLVM)还执行指令调度、内联、向量化(SIMD)与自动并行化。
8. 解释器 vs 编译器
8.1 两种执行模型
| 维度 | 编译器 | 解释器 |
|---|---|---|
| 工作方式 | 一次性翻译为机器码 | 逐语句/逐字节码执行 |
| 启动速度 | 慢(编译时间) | 快 |
| 执行速度 | 快(原生机器码) | 慢(解释开销) |
| 错误发现 | 运行前发现语法/类型错误 | 运行到才暴露 |
| 代表 | C、Go、Rust | Python、Ruby、旧 JS 引擎 |
8.2 混合模型与 JIT
现代语言普遍采用混合策略:先编译到字节码(跨平台 IR),再在运行时用 JIT(即时编译) 热点编译为机器码。V8 的 Ignition + TurboFan、JVM 的 C1/C2、CPython 的 JIT(3.13+ 实验性)都是如此。
源代码 → 字节码(IR)→ [解释器执行] → 热点检测 → [JIT 编译为机器码] → 缓存执行
└→ 反优化(去优化)退回解释器
| 策略 | 延迟 | 吞吐 | 可移植性 |
|---|---|---|---|
| 纯解释 | 最低 | 最低 | 最高 |
| 字节码 + JIT | 中 | 高(热点后) | 高 |
| 直接编译为机器码 | 最高(编译开销) | 最高 | 低(需平台适配) |
9. 综合案例与工具链
9.1 一条语句的完整旅程
以
result = a + b * 2;为例,走完 9 个阶段:
1. 词法: ID(result) OP(=) ID(a) OP(+) ID(b) OP(*) NUM(2)
2. 语法: Assignment(result, Binary(+, a, Binary(*, b, 2)))
3. 语义: 符号表绑定 a:float, b:float → 类型检查通过
4. IR: t1 = b * 2; t2 = a + t1; result = t2
5. 优化: 常量折叠 t1 = b * 2(2 为立即数); 死代码消除(若 result 未用)
6. 代码生成: mov r1, [b]; imul r1, 2; add r1, [a]; mov [result], r1
9.2 工具链与学习资源
| 工具 | 类型 | 用途 |
|---|---|---|
| flex / lex | 词法生成器 | 从正则生成 C 词法器 |
| yacc / bison | LALR 语法生成器 | 生成 C 语法分析器 |
| ANTLR | LL(*) 解析器生成器 | Java/Python/Go 多语言目标,DSL 开发 |
| LLVM | 编译器基础设施 | Clang 后端、优化、JIT |
| Tree-sitter | 增量增量解析器 | 编辑器语法高亮、代码分析 |
入门路径建议:用递归下降手写一个小语言(如计算器 → 简单脚本)理解 AST 与求值;再借助 LLVM IR 生成器把 AST 编译为真实机器码,即可贯通编译原理全流程。
参考文章
- 龙书《Compilers: Principles, Techniques, and Tools》
- Wikipedia — SSA (Static single-assignment form)
- Wikipedia — Recursive descent parser
- LLVM 官方文档 — LLVM Language Reference Manual
- Crafting Interpreters(免费在线书)
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。