上手:亲手看穿一次编译
别急着学算法。先用四条命令把「源码 → token → 语法树 → IR → 汇编」这条流水线在自己机器上跑一遍——后面每一章讲的都是这条链上的某一环,先看见它,再去拆它。
编译原理最容易学成纸上作业。而现代工具链早就把每一站的中间产物都开放成了命令行开关——四条命令就能把课本那张流程图变成你屏幕上的真实输出。装好 clang(或 gcc),跟着敲一遍。
准备:一个五行的样例
$ cat > demo.c <<'EOF'
int add(int a, int b) {
int t = a + b * 2;
return t;
}
EOF- 用它是因为它一次性包含了后面每一章的引子:标识符与常量(词法,03 章)、运算符优先级(文法与二义性,04 章)、类型与作用域(语义,08 章)、能被强度削减的乘法(优化,11 章)、参数与寄存器(目标代码,12 章)。
四站产物,四条命令
$ clang -fsyntax-only -Xclang -dump-tokens demo.c # ① token 流 → 03 章 $ clang -fsyntax-only -Xclang -ast-dump \ -Xclang -ast-dump-filter=add demo.c # ② 语法树+类型 → 04、08 章 $ clang -S -emit-llvm -o - demo.c # ③ 中间代码 IR → 09 章 $ clang -S -o - demo.c # ④ 目标汇编 → 12 章 # 用 gcc 的话,对应同一站的开关是: $ gcc -fdump-tree-gimple-raw -c demo.c # GIMPLE(gcc 自己的中间表示),相当于 ③ $ gcc -S -o - demo.c # 汇编,相当于 ④
- 四份输出依次变短、变陌生:token 流最长最琐碎,AST(抽象语法树)有层次,IR 已看不出源码结构,汇编只剩寄存器和跳转。这个「逐步丢掉源码形态」的过程,就是整本编译原理在做的事——每一步都在为下一步的机械处理铺路,代价是离人越来越远。
-O0 的产物推断「编译器就这水平」。-O0 是刻意不优化,为的是让调试时变量都在、语句能一一对应,所以汇编里满是冗余存取。想看真实能力必须开 -O2;但那时源码与汇编的对应关系会被打散(变量常驻寄存器、中间存取消失、整个表达式可能折叠成一个立即数),「我的变量被优化掉了」不是 bug。两个档位各看一次,落差本身就是最好的教材。a + b * 2 的树(乘法该在下层),再看 clang 画的对不对。四站的产物里,前两站最容易读、后两站离源码最远却最能说明「编译器替你做了什么」。诀窍是永远对比着看 -O0 与 -O2。
① token 流:每行一个「种别 + 词素」
int 'int' [StartOfLine] // 关键字,种别不是 identifier identifier 'add' [LeadingSpace] // 相当于课本的 ⟨id, "add"⟩ l_paren '(' int 'int' identifier 'a' comma ',' ... numeric_constant '2' // ⟨num, 2⟩ semi ';'
- 三件事当场可验证:关键字与标识符已分开、空白和注释一个都不在(所以
a+b与a + b编出来一样)、a <= b出来的是一个lessequal而不是两个符号(最长匹配)。
② AST:优先级以树的形状呈现
Dumping add: FunctionDecl add 'int (int, int)' // 名字 + 完整类型 |-ParmVarDecl used a 'int' |-ParmVarDecl used b 'int' `-CompoundStmt |-DeclStmt | `-VarDecl used t 'int' cinit | `-BinaryOperator 'int' '+' // 加法在上层 | |-ImplicitCastExpr 'int' <LValueToRValue> | | `-DeclRefExpr 'int' lvalue ParmVar 'a' | `-BinaryOperator 'int' '*' // 乘法在下层 ← 优先级更高 | |-ImplicitCastExpr 'int' <LValueToRValue> | | `-DeclRefExpr 'int' lvalue ParmVar 'b' | `-IntegerLiteral 'int' 2 `-ReturnStmt `-ImplicitCastExpr 'int' <LValueToRValue> ... // 每行还带 0x… 地址与 <行:列>,这里省掉了
- 「乘法优先于加法」在这里不是一条规则,而是一个形状:
a + b * 2的乘法节点长在加法节点下面,优先级从此变成了树的深度问题(04 章)。 - 顺带看见了语义阶段的产物(08 章):每个节点都标着类型,变量引用直接连回声明——这些不是语法分析的成果,是语义分析补上的。
③ LLVM IR 与 ④ 汇编:对比着看两个优化档位
-O0的 IR 就是课本三地址码的样子:每个变量alloca一个栈格、算一步存一次;-O2下alloca全消失,mem2reg一个 pass 就把栈格提升成了寄存器;- 汇编那一站更极端:
-O2下整个函数塌成lea eax, [rdi + 2*rsi]加一条ret——乘 2 被强度削减成地址计算的比例因子,参数本就在寄存器里,一次内存都不碰。
add(1, 2) 开 -O2 后只剩一个常数。这一页按流水线顺序组织,而流水线顺序恰好也是依赖顺序——前一站的产物是后一站的原料,顺着读就行。但有几处值得提前知道。
四段结构
- 02–06 前端理论最密集:04 章是地基,推导、二义性、文法变换不过关,05、06 就只能背算法;
- 07–09 语义与中间表示:从这里开始产物能直接跑命令看到;
- 10–13 优化与后端:数据流分析、SSA、寄存器分配;
- 14–16 现代议题与动手:第一遍可以跳,回头再补。
迷路时回 02 章那张主线卡
- 它把整条流水线串成一句话。卡在某个算法里出不来时就回去问:我这一步的输入是上一站的什么,又交给下一站什么?
demo.c,问「这一章对应它的哪一步产物?我能不能预测那份输出?」——能预测才算学会。这比做完习题更能检验理解,因为工具会毫不留情地告诉你猜错了。编译器导论
编译器的本质、结构与流程——理解前后端如何通过统一中间表示解耦,是后续一切的地图。
编译还是解释,分歧其实只在一件事上:翻译发生在执行之前,还是执行的同时。就这一个时机的选择,决定了一门语言是「启动慢但跑得快」还是「即开即用但跑得慢」。
同一根时机轴上的几个点
把翻译整个提到执行前,就是编译:先花一整趟把源程序译成目标程序,之后每次运行都直接跑机器码,快,而且有充裕时间做深度优化(C / C++ / Rust)。把翻译推到执行时、逐条边译边跑,就是解释:省掉了先译一遍的等待,启动快、易移植、好调试,代价是每跑一次都要重新理解一遍源码。真正的主流语言大多落在中间:先把源码编成紧凑的字节码交虚拟机解释,再把反复执行的热点在运行时临时编成机器码(JIT,如 JVM、V8)——冷代码省启动、热代码得速度。所谓 AOT(提前编译)与 JIT,无非是把编译放在「部署前」还是「运行时」,仍是这同一根时机轴的两端。
三种时机放一起看
翻译时机: 编译前 ←───────────────→ 运行时 AOT 编译 源码 ──▶ 机器码,之后才运行 gcc / rustc / go 字节码+JIT 源码 ─▶ 字节码 ─(运行时热点)▶ 机器码 JVM / V8 / .NET 解释 源码 ──▶ 边读边执行,不留独立产物 python / bash
三种实现路线,同一根时机轴上的三个点
| AOT 编译 | 字节码 + JIT | 纯解释 | |
|---|---|---|---|
| 翻译发生在 | 部署前 | 先编字节码,热点在运行时再编 | 执行的同时 |
| 启动 | 最快(直接跑机器码) | 中(要先加载并解释一段) | 快(不用先译一遍) |
| 峰值性能 | 高(有充裕时间做深度优化) | 可以更高(能用运行时真实类型/分支信息) | 低 |
| 能不能跨平台分发一份产物 | 不能(每个目标机一份) | 能(字节码与机器无关) | 能(分发源码) |
| 典型 | gcc / rustc / go | JVM / V8 / .NET / PyPy | bash / 早期 CPython 的部分路径 |
- 值得单独记的一格是「峰值性能」:JIT 可以比 AOT 更快,因为它掌握 AOT 拿不到的信息——这个虚方法实际只有一个实现、这个分支运行时从不走、这个变量总是整数。代价是这些假设可能失效,于是需要去优化(deoptimization)退回解释执行。「编译早 → 优化信息少,编译晚 → 优化信息多但没时间」是这条轴的核心张力。
深入理解:「编译型 / 解释型语言」其实是伪命题
- 编译还是解释,是实现的属性,不是语言的属性。同一门语言两者都能有:C++ 有解释器(Cling),Python 有编译器(Nuitka 直出机器码、PyPy 在运行时把热点 JIT 编成机器码),Java 更是先
javac编成字节码、再由 JVM 解释 + JIT。所以「某语言是编译型还是解释型」从提法上就错了——真正该问的是「这个实现把翻译放在了哪一步」。还有个常被忽略的点:解释器内部照样跑完整的词法、语法、语义分析,它和编译器的分界只在最后一步——是吐出目标码,还是直接执行。
python3 -c "import dis; dis.dis(compile('x+1','','eval'))" 能亲眼看到字节码,纯粹逐行读源码的主流实现如今几乎不存在。编译器为什么要拦腰切成三段?为了让「改动只波及一段」。前端只懂源语言,后端只懂目标机,中间夹一层谁都不依赖的优化——三段靠一种统一的中间表示(IR)对接。
三段各管什么、为什么这么切
前端把源代码一路做到中间代码:词法、语法、语义分析都在这里,它认得的是源语言的规则。后端把中间代码落成某台机器的指令:代码生成、寄存器分配、机器相关优化,它认得的是目标机的脾气。中端夹在当中,只在机器无关的 IR 上做优化,谁都不偏袒。这么切的回报是算术级的:要支持 m 种语言、n 种目标机,若前后端搅在一起就得写 m×n 套;有了统一 IR,只需 m 个前端加 n 个后端,m+n 就够——这正是 LLVM 敢让几十种语言共享同一套优化与后端的底气。
m×n 还是 m+n
// 不解耦:每种语言各自下到每种机器 —— m × n 套 C, Go, Rust × x86, ARM, RISC-V → 3 × 3 = 9 套 // 解耦:都先降到统一 IR,前后端各写一次 —— m + n 个 C ─┐ ┌─▶ x86 Go ─┼─▶ 统一 IR ─▶ 机器无关优化 ─┼─▶ ARM Rust┘ └─▶ RISC-V 前端 3 个 + 后端 3 个 = 6 个(中间优化还共享)
深入理解:这条界线,就是 Rust、Swift 能快速诞生的原因
- 「前端依赖源语言、后端依赖目标机」不是学院派的洁癖,而是决定改动传播范围的生命线:新增一门语言只需写个新前端、移植到新芯片只需写个新后端,中间的 IR 与几百个优化 Pass 完全复用。这正是 Rust、Swift、Julia 能「站在 LLVM 肩上」迅速成型的底气——它们几乎只写了前端,就白拿了整条工业级优化与多平台后端。反过来,一旦界线被打破(前端里塞进目标机假设、后端里泄露源语言语义),m 种语言 × n 种机器就退回 m×n 的噩梦——这正是不少老编译器难以移植的病根。
-O0 下 int x=1+2; 的 GIMPLE 里已经是 x = 3;后端还有指令调度、窥孔等机器相关优化。中端垄断的只是「机器无关优化」这一层,不是「一切优化」。clang(前端出 IR)→ opt(中端跑优化 Pass)→ llc(后端出汇编),每段都能单独跑、单独看产物。典型流程:词法分析 → 语法分析 → 语义分析 → 中间代码生成 → 代码优化 → 目标代码生成;符号表管理与错误处理贯穿全程。
每一步都是一次形态转换
每个阶段都对同一段程序做一次形态转换,上一阶段的产物恰好是下一阶段的原料:词法分析把字符流切成词法单元(token)流;语法分析把扁平的 token 流搭成有层次的语法树,顺带检查结构合法;语义分析在树上查类型、查作用域,为它加注解;中间代码生成把带注解的树降成机器无关的 IR(如三地址码);优化在不改变语义的前提下改写 IR,让它更快更小;目标代码生成再把 IR 落成汇编 / 机器码。符号表与错误处理不隶属某一步,而是横贯全程的两条服务线。
示例
// 源语句 position = initial + rate * 60 // 词法分析后的 token 流 <id,position> <=> <id,initial> <+> <id,rate> <*> <60> // 中间代码(三地址码) t1 = inttofloat(60) t2 = id_rate * t1 t3 = id_initial + t2 id_position = t3
深入理解:C 为什么逼你「先声明后使用」
- 「阶段」是按做什么分的逻辑分工,「趟(pass)」是按扫描几遍分的物理实现,两者不必一一对应:多个阶段揉进一趟省内存,拆成多趟则每遍专注一事、便于优化。而这个区分有个你天天撞见的后果——C 逼你「先声明后使用」,正是早期单趟编译的遗产:编译器从上往下只扫一遍,读到一个名字时它若还没出现过,就无从知道类型,只能报错;于是有了头文件、函数原型这些「提前打招呼」的机制。Java、C# 改用多趟(先扫一遍收集全部声明,再回头解析),才摆脱这约束——同一段里方法可以调用后面才定义的方法。你以为是语法规定,其实是编译趟数的历史选择。
clang -fsyntax-only -Xclang -dump-tokens 看词法产物、-Xclang -ast-dump 看语法树与类型标注、-S -emit-llvm 看中间代码、-S 看目标代码——把示例那样的赋值语句包进函数喂进去,课本流程图立刻变成实物。整本编译原理其实是一条流水线:一个表达式从源文本进去、机器码从另一端出来,中间每一站正好对应本页的一章。钻进任何算法细节前,先把这趟旅程走一遍——之后每一章,都是把某一站放大。
十三站旅程(跟着 position = initial + rate * 60 走)
源代码字符流 position = initial + rate * 60 ← 一串字符
↓ 词法分析(§03):正则 + 有限自动机切词
Token 流 ⟨id,position⟩⟨=⟩⟨id,initial⟩⟨+⟩⟨id,rate⟩⟨*⟩⟨60⟩
↓ 语法分析(§04–06):CFG + LL / LR 判断结构
语法树 / AST =( position, +( initial, *( rate, 60 ) ) )
↓ 语义分析(§07–08):查类型、查作用域、加注解
带类型的 AST 60 是 int → 插入 inttofloat;各 id 登记进符号表
↓ 中间代码生成(§09):降为机器无关的三地址码
IR t1=inttofloat(60); t2=rate*t1; t3=initial+t2; position=t3
↓ 代码优化(§11;运行时环境 §10 为其打底)
优化后 IR 常量折叠、删冗余、循环不变量外提……语义不变、更快
↓ 目标代码生成(§12):指令选择 + 寄存器分配
汇编 / 机器码 mulss / addss …,变量落到寄存器
↓ 现代工具链(§13):LLVM / 链接 / JIT / AOT两条贯穿全程的分界
- 前端 vs 后端:§03–09 只依赖源语言(换成 C++ 只改这半边),§12 只依赖目标机(换成 ARM 只改那半边),中间靠 IR 解耦——这正是上一张卡说的「m+n 而非 m×n」。
- 正则 vs 文法:词法用正则(数不清括号),语法用 CFG(能数嵌套)——为什么恰好在这里分家,§04 末的「乔姆斯基谱系」会给出全局地图。
为什么这条主线值得反复回看
- 学子集构造、LR 分析表、图着色时,很容易陷进单个算法里出不来。每当那样,回到这条流水线问一句:「我现在做的这一步,输入是上一站的什么、又交给下一站什么?」——本页每个算法都只是在完成某一站的转换,没有一个是孤立的。
clang -emit-llvm -S 看 LLVM IR(§09 的产物);gcc -E / -S / -c 则能把工具链(§13)的每一环单独停下来看。从源代码到能跑的程序,编译器只是流水线上的一环,前后各有分工明确的工具接力。看清这条链,才知道「编译」到底负责哪一段、把哪些活儿外包了出去。
一条接力链,各做一件窄事
预处理器先做宏展开、头文件包含、条件编译,吐出来的仍是源程序;编译器把它译成汇编;汇编器再把汇编逐条译成可重定位的机器码;链接器把多个目标文件拼到一起、把彼此的外部引用对上号;最后加载器把成品装入内存、定好最终运行地址。每个工具只做一件窄而确定的事,于是每个都足够简单、能被单独替换——这也引出了下面的问题:编译器为什么不干脆一步到位、直接吐机器码?
一条链,gcc 每步都能单独停下
hello.c │ 预处理 cpp 展开宏、#include gcc -E → hello.i(仍是源码) │ 编译 cc1 源码 → 汇编 gcc -S → hello.s │ 汇编 as 汇编 → 可重定位机器码 gcc -c → hello.o │ 链接 ld 合并 .o、解析外部引用 gcc → a.out ▼ 加载 loader 装入内存、定地址后运行
深入理解:正因各司其职,你才能把链接器换成快十倍的 mold
- 编译器的直接产物常是汇编而非机器码——把汇编逐条编码成二进制是汇编器的固定活儿,编译器外包出去,自己只操心「该生成哪些指令」,不必管每条指令的二进制格式。这种「每环窄而独立」最大的红利是可替换:正因链接器只认「目标文件格式」这一份契约,社区才能一代代把它换掉——ld → gold → lld → mold,每代快上数倍,而编译器一行都不用改。同理你能用
gcc -S单独看汇编、用-c停在目标文件、往 C 里嵌内联汇编——每一处都是这条流水线在某一环特意留的接口。把大问题切成「窄而可换」的小工具,是这条链最值得偷师的工程智慧。
-c 只跑 cc1 和 as 两个进程——预处理早已并进 cc1,并没有独立的 cpp 进程被调起;Clang 21 更进一步,-### 显示 -c 全程只有一个 job(集成汇编器直出 .o)。工具边界会合并,分工概念不变。gcc -v hello.c 会打出真实调用的 cc1、as、collect2(链接器的包装);gcc --save-temps 则把 .i / .s / .o 中间产物全留在当前目录,一环一件对着看。词法分析
流水线的第一站:拿到源程序的字符流,把它切分为一个个词法单元(token)。理论核心是正则表达式与有限自动机,以及它们之间的相互转换——交出的 token 流,就是下一章语法分析的输入。
词法分析把字符流切成一个个有意义的单元。这里有三个词老被当成同义词混用,其实它们是三个不同层次——分清了,才看得懂词法分析到底在产出什么。
模子、实物、贴好标签的成品
模式(pattern)是一条规则,规定「什么样的字符串算这一类」,通常用正则写;词素(lexeme)是源程序里真正出现、匹配上某条模式的那段具体字符;词法单元(token)则是词法分析交给语法分析的成品,形如 ⟨种别, 属性值⟩ 的二元组,例如 ⟨id, 指向符号表的指针⟩。一句话串起来:模式是模子,词素是照模子浇出来的实物,token 是给实物贴好标签、供下一站直接取用的封装。
一行源码切成 token
源码: sum = count + i * 2 lexeme(实物) token(成品) pattern(模子·正则) sum ⟨id, ptr⟩ [a-zA-Z]+ ← sum/count/i 三个不同 lexeme… = ⟨assign⟩ = count ⟨id, ptr⟩ [a-zA-Z]+ ← …匹配同一 pattern… + ⟨op, +⟩ [-+*/] i ⟨id, ptr⟩ [a-zA-Z]+ ← …同归一个 token: id * ⟨op, *⟩ [-+*/] 2 ⟨num, 2⟩ [0-9]+
深入理解:词法分析是一道「信息销毁」的关口
- 分清"一类(token) vs 一个实例(lexeme) vs 描述规则(pattern)"是起点,但更深的一层是:词法分析在归类的同时主动丢弃信息——空白、缩进、注释、以及
0x1F还是31这种具体写法,绝大多数都不进 token 流。这正是a+b与a + b编出来一模一样、也是你无法从 token 反推原始源码的原因(除非像 clang-format、代码高亮那样专门另存「琐碎信息 trivia」)。从此往后,语法分析器再也看不见字符,只看得见 token——所以编译器报错才总说 "unexpected token",它早已活在 token 的世界里。
sum 和 total 在语法分析器眼里是同一个东西(都是 identifier),差别只在符号表里那个指针。想不明白「为什么语法分析器不关心变量叫什么」,就是卡在这一层。正则表达式用最朴素的几种拼法,就能精确框定一大类词素的形状。但它的能力恰好卡在一条清晰的线上——理解这条线在哪,比记住语法更重要。
三种拼法,搭出所有词素模式
正则靠三种最基本的组合描述模式:选择(a|b,二选一)、连接(ab,先 a 后 b)、闭包(a*,重复任意次、含零次);+ ? [...] 不过是常用简写。再给较长的子表达式起个名字复用(正则定义),就能像搭积木一样拼出「标识符」「浮点数」这类模式。它的表达力刚好够刻画 token 内部的形状——却也止步于此:一旦需要「记住前面出现过什么」,正则就无能为力,这正是下面要点破的边界。
能描述什么、不能描述什么
// 正则定义:给子式命名再复用 digit → [0-9] digits → digit digit* // 一个以上数字 id → [a-zA-Z][a-zA-Z0-9]* // 字母打头 number → digits (. digits)? // 整数或小数 能:认出 id、number、<=、字符串字面量… 不能:判断 ((())) 括号是否配对 —— 要「记住还欠几个 )」,正则没有记忆
深入理解:这就是「别用正则解析 HTML」的理论根据
- 正则的能力恰好卡在「不带记忆」这条线上:它认得单个标识符、数字,却数不清括号有没有配对——配对要求「记住还欠几个右括号」,而有限状态机没有计数能力。所以任何任意深度的嵌套(HTML 标签、JSON、括号表达式)从原理上就超出了正则——这正是那条著名忠告「别用正则解析 HTML」的硬核依据,而非经验之谈。要描述嵌套,必须升级到上下文无关文法(下一章)。⚠️ 注意:工程里的「正则引擎」往往加了反向引用、递归扩展,早已不是理论意义上的正则,代价是可能被一个刁钻输入拖进指数级回溯(ReDoS,真实的线上事故来源)。词法与语法为何按 3 型 / 2 型分家,§04 末的乔姆斯基谱系给出全局地图。
正则是「规则」,可它怎么真正跑起来去逐字符识别?答案是有限自动机——一台只有有限个状态的小机器。它有确定与非确定两副面孔,核心问题就一个字:确不确定。
一台会跳状态的小机器,两副面孔
自动机的运作极简单:读入一个符号,就按转移规则从当前状态跳到下一个状态,读完看是否落在「接受状态」上——所需零件不过是状态、输入符号、转移规则、一个初态和一组接受态。它的两副面孔差在「确不确定」:DFA(确定有限自动机) 每个状态对每个输入至多跳一处、也不许凭空(ε)空转,照着实现就是一张查表,运行时毫不含糊;NFA(非确定有限自动机) 则允许同一输入岔出好几条路、还能走 ε 空转移,描述起来更灵活。关键事实是二者识别的语言完全相同——NFA 便于描述、DFA 便于执行,于是词法工具的固定套路就是「先按正则搭出 NFA,再转成 DFA 去跑」。
同一步:NFA 岔路,DFA 唯一
读 a 读 b ε 空跳
NFA 状态 q0 {q0, q1} {q0} → q2 ← 一个输入可多个去向
DFA 状态 A B(唯一) A(唯一) 无 ← 照表走,绝不含糊
两者识别的语言相同;NFA 好描述、DFA 好执行 ⇒ 套路:正则 → NFA → DFANFA / DFA 对照(记住「能力相同、代价不同」)
| NFA | DFA | |
|---|---|---|
| 一个状态 + 一个输入的出边 | 0 条、1 条或多条 | 恰好 1 条 |
| ε 空转移 | 允许 | 不允许 |
| 状态数(由长度 n 的正则来) | O(n) | 最坏 2ⁿ |
| 匹配一个串的时间 | O(串长 × 状态数) | O(串长),每字符一次查表 |
| 识别能力 | 正规语言 | 正规语言(与 NFA 完全相同) |
| 好在哪 | 好描述(Thompson 构造直接拼) | 好执行(零分支、零回溯) |
- 所以工具的固定套路是「正则 → NFA → DFA」:用 NFA 的易构造性,换 DFA 的执行确定性。RE2 那类引擎更进一步——建好线性大小的 Thompson NFA,然后边匹配边惰性生成 DFA 状态,既拿到 DFA 的速度又躲开建表的指数爆炸。
深入理解:RE2 不会被 ReDoS 打垮,Python 的 re 会——差别就在这
- NFA 允许「同时处于好几个状态」,DFA 的每个状态则必须明确代表 NFA 的一整个状态子集;n 个状态的子集有 2ⁿ 种,故 DFA 最坏会指数膨胀——这是拿空间换确定性的经典权衡。但它绝非课本习题:正则库正是在这里分野。RE2、Go、Rust 走自动机路线(NFA 模拟 / 转 DFA),保证对任意输入线性时间、天生免疫 ReDoS;PCRE、Perl、Python、Java 走带回溯的引擎,功能更强(支持反向引用)却可能被构造输入拖成指数级。你选用哪个正则库,本质上就是在选「自动机派」还是「回溯派」。
re 匹配 (a+)+$,26 个 a 加一个 b 已要 2 秒且每加 2 个 a 翻 4 倍,而走自动机路线的 grep 瞬时返回。正则怎么由小到大拼出来,NFA 就怎么由小到大拼出来——Thompson 构造把「按结构递归」这个想法用到了极致。
每个算子一种固定拼法,只用 ε 缝合
最小的积木是「单个符号 a」和「ε」各自对应的两态小 NFA;往上三种组合各有一套固定拼法:选择 r|s 新起一个初态、用 ε 岔向两个分支,两分支的终态再用 ε 汇到新终态;连接 rs 把 r 的终态用 ε 接上 s 的初态;闭包 r* 补一条 ε 回边(允许重复)和一条 ε 跳过边(允许零次)。正因为每个算子只添几个状态、几条 ε 边,拼出来的 NFA 规模始终与正则长度成正比,绝不会爆炸。
把 a·b 拼起来
RE = a → ①─a→②
RE = b → ③─b→④
RE = a·b → ①─a→② ⋯ε⋯▶ ③─b→④ // a 的终态 ε 接上 b 的初态
每个算子只加常数个状态 / ε 边 ⇒ NFA 大小 ∝ 正则长度深入理解:自包含拼装,和函数式的「组合子」是同一种思想
- Thompson 构造的诀窍:每步都新建自己的初态终态,只用 ε 边把子 NFA 「缝」进来,绝不改动其内部。因为每个片段严格自包含、只从指定入口进 / 出口出,拼起来才不「串味」,最终仍只识别原正则——这跟解析器组合子(parser combinators)、乐高积木、SICP 说的「闭包性质」是同一种构造美学:给小部件一个统一接口,就能无限拼出复杂物。它还有个实用后果:Thompson NFA 的规模始终线性于正则长度、绝不爆炸;于是 RE2 那类引擎能先建这个小 NFA,再边匹配边惰性生成 DFA 状态,既拿到 DFA 的速度、又躲开 DFA 的指数级建表。
r* 最容易丢边:四条 ε 一条不能少——丢了「新初态 → 新终态」的跳过边,画出来是 r+(不许零次);丢了「r 终态 → r 初态」的回边,画出来是 r?(不许重复)。非确定的麻烦,在于「同一时刻你可能同时处在好几个状态」。子集构造的破解办法直截了当:把 NFA 的一整个状态集合,当成 DFA 的一个状态。
把「多个可能」打包成「一个确定」
一旦把「此刻可能所处的那一堆 NFA 状态」整体看作 DFA 的单个状态,非确定性就消失了。落实只需两个操作:ε-closure(T) 求出从集合 T 出发、只走 ε 能到达的全部状态;move(T, a) 求出 T 中状态读入 a 后到达的状态集。DFA 的初态就是初始状态的 ε-closure,此后每个新状态都由 ε-closure(move(T, a)) 算出;只要某个集合里含有 NFA 的任一接受态,它就是 DFA 的接受态。
深入理解:DFA 本质是 NFA 的「预编译缓存」
- 每走一步都要再取一次 ε-closure,不是形式要求——DFA 的一个状态必须囊括「此刻经 ε 能到的所有 NFA 状态」,漏一个就可能把该接受的串判成拒绝。看穿这层,子集构造的真面目就清楚了:它拿一次性的建表开销,把 NFA「每步现算能到哪些状态」的工作预先缓存成一张确定的转移表;此后每个字符只需查表跳转、零分支、零回溯,匹配稳定 O(串长)。这正是「编译期多花力气、运行期飞快」那套哲学的又一化身(与 JIT、预编译 SQL 同源)。代价就是那个 2ⁿ:多数真实正则的 DFA 很小,但对抗性正则能撑爆,所以有些引擎给 DFA 设大小上限、超了就退回 NFA 模拟。
// NFA(识别 a*b):0 -ε→ 1,3 1 -a→ 2 2 -ε→ 1,3 3 -b→ 4(终态 4)
A = ε-closure({0}) = {0,1,3} // DFA 初态
move(A,a) = {2} → ε-closure({2}) = {1,2,3} = B move(A,b) = {4} = C
move(B,a) = {2} → 闭包仍是 B move(B,b) = {4} = C
C 含 NFA 终态 4 ⇒ C 为 DFA 终态;A、B、C 三个状态,构造完毕ε-closure({s₀}) 而不是 {s₀}——第一步就要先取闭包;判接受态只要求集合里含任一 NFA 接受态,不是「全是接受态」。每步转移也别丢外层那次闭包:ε-closure(move(T,a))。(a|b)*a(a|b)ⁿ⁻¹)用 n+1 个状态的 NFA 就能认,最小 DFA 却恰好要 2ⁿ 个状态——Python 脚本n=5 时子集构造吐出 32 个状态、最小化一个都并不掉。不过工程里的常见正则大多离这个上界很远。子集构造吐出的 DFA 常带多余状态:有些状态对「今后任何输入的接受与否」表现完全一致,留着只是白占空间。最小化就是把这些行为不可区分的状态并成一个。
不断寻找「可区分」的证据,直到分不动
做法叫「划分细化」:先把状态粗分成 {接受态} 和 {非接受态} 两组,再反复检查——若同一组里两个状态遇到某个输入被送进了不同的组,就说明它们行为有别,把这一组拆开;如此细分下去,直到再也拆不动,剩下的每一组便是一批真正等价、可以合并成一个的状态。顺带把从初态永远到不了的「不可达状态」、以及到不了任何接受态的「死状态」一并清除,DFA 就压到了最小。
划分细化跑一遍
初分两组: { 接受态 } | { 非接受态 }
细分规则: 同组两状态,若某输入把它们送进不同组 ⇒ 拆开
s ─a→ 组X,t ─a→ 组Y(X≠Y)⇒ s、t 不等价,分家
… 反复细分,直到没有组还能拆
结果:读任何输入去向都落在同一组的状态(如 B、C)⇒ 合并成一个深入理解:最小 DFA 唯一,于是能证明两个正则「等价」
- 起手先按 {接受}/{非接受} 分两组,是因为「接不接受」是两状态最本质、绝不能合并的差别;之后每轮看「同一输入是否把它们送进不同组」继续细分,本质是不断找「行为可区分」的证据,直到分不动。这背后藏着一个漂亮定理(Myhill–Nerode):对一个正规语言,最小 DFA 是唯一的(至多相差改名)。于是有了一件实用武器——想判断两个写法迥异的正则是不是等价(匹配完全相同的串集),把它们各自最小化、看最小 DFA 是否同构即可,这是可判定、能写成程序的。词法生成器也靠最小化把转移表压到最小,省内存、加速查表。
前面是「正则 → NFA → DFA」两步走,这张卡抄近道:不建 NFA,直接从正则的语法树造出 DFA。省掉的正是 ε 转移那一层麻烦。
为每个位置算出「接下来能轮到谁」
先给正则末尾补一个结束标记、写成 (r)#,再把每个叶子——也就是某个字符出现的位置——编上号。然后自底向上给每个结点算三样东西:nullable(这一部分能否匹配空串)、firstpos(它开头可能是哪些位置)、lastpos(它结尾可能是哪些位置)。由它们推出真正的核心 followpos:某个位置的字符匹配完,紧接着可能轮到哪些位置。有了这张「位置 → 后继位置」的关系表,DFA 的状态就直接是一组位置的集合、转移照 followpos 走——NFA 那一整步被跳了过去。
在语法树上算 followpos
增广:(a|b)*a# 叶子编号: a₁ b₂ a₃ #₄
自底向上算性质:
a₁: first={1} last={1} b₂: first={2} last={2}
(a|b)*: nullable=真, first={1,2}, last={1,2}
followpos(谁匹配完能接谁):
1、2 之后 → {1,2,3} 3 之后 → {4}
DFA 状态 = 位置集合,按 followpos 转移,NFA 被跳过深入理解:直接法省掉的是 ε 那一层——但 flex / re2c 其实没走这条道
- followpos 的两条规则回答同一个问题——「某位置匹配完,紧接着可能轮到哪些位置」:连接结点 c₁c₂ 贡献「顺序」(c₁ 末尾之后接 c₂ 开头),星结点 n* 贡献「重复」(n 末尾之后可回到 n 开头)。看清一条是「往下走」、一条是「绕回来」就不会记混。而「跳过 NFA 直接建 DFA」不是炫技——它的实际好处是这条路天生没有 ε 状态要消,得到的初始 DFA 往往更紧凑。但别把它安到具体工具头上:查源码可知 flex 和 re2c 都是先建 NFA 再确定化(flex 的 nfa.c 加 dfa.c 里的 ntod(),re2c 的 re_to_nfa)——这条近道的本质,是把「位置自动机(Glushkov NFA,无 ε)+ 子集构造」合并成一步的教科书精炼版。
自动机是理论,真做一个扫描器还得回答几个现实问题——切到哪算一个词、关键字怎么认、怎么读得快。
从自动机到扫描器,要补上的几件事
切到哪算一个词?最长匹配原则(maximal munch):能匹配更长就绝不停在短的,所以 <= 会整体识别成一个 token,而非拆成 < 和 =。关键字怎么办?它们长得和标识符一模一样,通常先把保留字预装进符号表,识别出一个标识符后一查便知,或干脆为它们单列模式。怎么读得快?用双缓冲加哨兵(sentinel):一次读入一大块、用一个特殊结束符省掉「每读一个字符都判一次是否越界」。至于生成扫描器的体力活,则常交给 Lex / Flex——你写正则规则,它自动吐出上面这套自动机代码。
最长匹配:<= 是一个还是两个
输入: a <= b
↑ 扫到 '<' 别急着交,最长匹配:再看下一个是 '='?
是 → 整体吃成一个 ⟨le, "<="⟩ (不是 ⟨lt⟩⟨assign⟩)
关键字: if 先查「保留字表」→ 命中就是 ⟨if⟩,否则才当 ⟨id⟩
读取: 双缓冲 + 末尾哨兵,省掉每读一字符都判越界深入理解:最长匹配的经典出错——C++ 的 vector<vector<int>>
- 最长匹配(能匹配更长就绝不停短)让
<=整体成一个 token 而非<加=——听着无害,却制造过一个折磨 C++ 十几年的坑:vector<vector<int>>里的>>被贪婪地当成「右移运算符」,害得早期 C++ 必须写成> >(中间加空格),直到 C++11 专门给编译器打补丁才免了这道手续。更深一层:词法有时离不开语法的反馈——C 里a * b到底是「乘法」还是「a 是类型、声明指针 b」,光靠词法分不清,得知道a是不是typedef名,这就是著名的 lexer hack:让词法器偷偷查符号表。所谓「词法、语法各管一段」,边界远没有教科书画得那么干净。
a+++++b 被切成 a ++ ++ + b,语法必死(GCC 15 / Clang 21 都报错),尽管改切成 a ++ + ++ b 本可通过编译——词法器绝不会为救活语法而回退重切,C/C++ 标准规定的就是这种贪心切法。-dump-tokens 命令,a<=b 出一个 lessequal,a+++b 切成 plusplus plus(即 (a++)+b)——和课本规则一字不差。文法与语法基础
词法分析交出的是一串扁平的 token,本章要问的是:它们组合出的结构合法吗?上下文无关文法正是描述这种嵌套结构的工具。掌握推导、分析树、二义性与文法变换,是紧接着两章(自顶向下、自底向上)两类分析法的共同地基。
CFG 要接手词法解决不了的事:数清嵌套。它凭什么能,而正则不能?答案藏在一条不起眼的约束里。
一堆替换规则,靠自我引用描述任意深嵌套
CFG 的骨架简单到只有一样东西:一批产生式,每条形如「某个非终结符 → 一串符号」,例如 E → E + E。非终结符是「待展开的语法成分」(表达式、语句),终结符是最终的词法单元;从开始符号出发反复替换,就生成出合法的程序结构。它强过正则的关键在自我引用:产生式右边可以再度出现左边那个非终结符,于是括号套括号、表达式套表达式的任意深嵌套都描述得了——这正是正则数不清、CFG 数得清的那类结构。而每条产生式的左部只允许是单个非终结符,这个看似不起眼的限制,正是下面『上下文无关』得名的由来。
一条产生式,套出任意深
文法: E → E + E | E * E | ( E ) | id
数清任意深嵌套(正则做不到):
( id + ( id * id ) )
└── E 里又出现 E,靠产生式右部再现自己 ——「自我引用」
左部永远是单个非终结符(E → …)⇒ 展开与左右无关 = 上下文无关深入理解:CFG 管不了的事,全被推给了后面的语义分析
- 左部只能是单个非终结符,意味着「一个成分怎么展开与左右无关」——这是「上下文无关」名字的由来。上下文相关文法(左部带上下文 αAβ → αγβ)表达力更强,但分析代价急剧上升,所以编程语言的语法特意停在 CFG 这一档:用一点表达力换「能被高效、确定地分析」。代价是——真实语言里一大堆规则本就不是上下文无关的:「变量必须先声明后使用」「赋值两边类型要匹配」「调用实参个数要对」,CFG 一条都表达不了。它们没消失,而是被整体推给了后面的语义分析阶段去查。编译器为什么非要在语法分析之后再单开一趟语义分析?根子就在这——CFG 的表达力边界,恰好画出了「语法」与「语义」的分工线。
a * b; 是乘法还是指针声明要靠符号表才能定,这类判定超出 CFG 能力,正是要推给语义分析的东西。同一套产生式可以正着用、也可以倒着用——这一正一反,正好对应后两章的两类分析法。
正着推导,倒着归约
正着用是推导:从开始符号出发,每一步挑一个非终结符、用某条产生式的右部替换它(记作 ⇒),直到全变成终结符,就生成了一个句子。倒着用是归约:从一串终结符出发,反复把「某条产生式的右部」替换回它的左部,最终收回开始符号——这是自底向上分析的基本动作。每步该展开哪个非终结符是有讲究的:总挑最左边的叫最左推导,总挑最右边的叫最右推导(又称规范推导),这个选择看似随意,却和两类分析法严丝合缝对应。(推导途中那些可能还含非终结符的中间串叫句型;全是终结符的句型才是句子。)
id + id 的一次最左推导
文法: E → E + T | T T → id
最左推导(每步替换最左非终结符):
E ⇒ E + T ⇒ T + T ⇒ id + T ⇒ id + id
归约就是倒着走: id + id ⇒ … ⇒ E
中间的 E+T、T+T 叫「句型」;末尾 id+id 全是终结符,叫「句子」深入理解:LL、LR 名字里那个方向,就藏在这里
- 自底向上分析每步把一个句柄归约回非终结符,相当于把某次推导「倒着走」。为什么对应的偏偏是最右推导?因为归约总从左边最先凑齐的子串下手,把这一串串归约倒过来看,正好是每步替换最右非终结符的推导。这层对应不是巧合,它直接命名了两大范式:LL 的第二个 L = Leftmost(构造最左推导),LR 的 R = Rightmost(对应最右推导的逆)。「从上往下猜」天生配最左推导、「从下往上归约」天生配最右推导——名字里的字母不是编号,而是这套对应关系的浓缩。想通它,后面 LR 为什么「沿最右推导逆序工作」就成了理所当然。
分析树记录的不是「怎么推」,而是句子的结构;而一个句子若能画出不止一棵树,麻烦就来了——因为结构就是语义。
一句两解,就是二义
分析树把一次推导的层次画出来:根是开始符号,叶子是终结符,中间结点是非终结符。它关心的是句子搭成了什么形状。二义性指同一个句子能画出不止一棵分析树。为什么这一点关键?因为结构决定语义——两棵树意味着两种解释、两种算法、两种结果。最经典的是悬空 else,和没规定优先级的表达式文法(a+b*c 到底先乘还是先加?)。消解办法有两条:要么改写文法把歧义从根上堵死,要么外加优先级、结合性规则替分析器拍板。
a + b * c 的两棵树
文法无优先级: E → E + E | E * E | id
同一句 a + b * c,能画出两棵分析树:
先乘(对) 先加(错)
+ *
/ \ / \
a * + c
/ \ / \
b c a b
两棵树 = 两种结构 = 两种结果 ⇒ 这就是二义性(要靠优先级消解)深入理解:运算符优先级表,就是在给二义文法打补丁
- 二义性 = 「同一个句子能画出两棵冲突的分析树」,最经典的是悬空 else(
if a then if b then s1 else s2里 else 配哪个 if)。注意它是文法的毛病、不是语言的——同种语言常能写出二义与无二义两套文法。这解释了几件你见惯的事:为什么每份语言手册都附一张运算符优先级表(*先于+、赋值右结合……)——那正是在给一个故意保持简洁的二义表达式文法「外挂」消歧规则;为什么 C 明文规定「else 配最近的 if」;以及为什么 Yacc/Bison 会报 shift/reduce conflict——那正是二义性在分析表上现了原形。实践中人们宁可留着二义文法、再用%left %right声明压掉冲突,也不把文法改写得又臃肿又难读。
这些变换都是为了迁就下一章的自顶向下分析而对文法做的预处理——语言一字不变,变的只是文法的写法。要治的是两个专门绊倒自顶向下的病。
治两个病:左递归与左公因子
左递归 A → Aα | β:产生式左边第一个符号又是自己,递归下降还没读入任何符号就先调用自己,无限自套。消除的办法是把「先递归后收尾」改写成「先展开后收尾」(见下例),生成的句子集合一模一样。左公因子 A → αβ₁ | αβ₂:两条产生式开头一截相同,分析器光看开头分不清走哪条、只能回溯;提取公因子成 A → αA′, A′ → β₁ | β₂,把「分岔点」推后到能看清为止。至于间接左递归,先把非终结符按序代入、让藏着的直接左递归暴露出来,再照上法消除即可。
示例
// 左递归(不适合递归下降) E → E + T | T // 消除后 E → T E′ E′ → + T E′ | ε
深入理解:这就是为什么手写解析器写不了「E → E + T」
- 消除左递归只是把「先递归再展开」改写成「先展开再收尾」,生成的句子集合一字不差,变的只是分析树的结合方向。关键在于:问题出在分析方法,不在语言本身——自顶向下分析一进门就调用自己、还没读入任何符号就无限递归。这不是纸上谈兵:你亲手写递归下降、或用 PEG / parser-combinator 库时,只要直接照抄
E → E + T就会当场栈溢出死循环,必须改写成循环形式E → T (+ T)*。正因这道手续繁琐又易错,现代工具如 ANTLR4 干脆内置了「自动消除左递归」,让你能自然地写左递归、由它在背后改写。你以为在学一个古板的课本步骤,其实是在补一个每个手写解析器作者都栽过的坑。
a-b-c 算成 a-(b-c)。手写递归下降要在循环里用累加器自左向右建 AST——这是照抄课本变换后最高频的真实 bug。E → E + T | T 直接改写成循环形式 E → T (+ T)*,一个 while 就消掉左递归,可读性远胜引入尾递归非终结符——两种写法生成的语言相同。别把这四型当成四个要背的表项,它其实是一把尺子:对产生式的限制越松,能描述的结构越复杂,识别它所需的机器也越强。
一把尺子:表达力越强,代价越高
四档从强到弱——0 型不加任何限制,要图灵机才认(等于「可计算」的极限);1 型上下文相关,展开一个符号还得看它左右的邻居;2 型上下文无关,展开与上下文无关、靠下推自动机(本质是一个栈)识别;3 型正规,最弱,有限自动机足矣。四者层层包含:3 ⊂ 2 ⊂ 1 ⊂ 0。编译器只用得着最下面两档——词法用 3 型、语法用 2 型,再往上的表达力用不着、代价还高。至于为什么恰好停在这两档,看下面。
四档能力,编译器只用最下两档
型 限制越松 → 能力越强 识别机器 编译器用途 3 正规(最弱) 有限自动机 ← 词法 2 上下文无关 下推自动机(栈) ← 语法 1 上下文相关 线性有界自动机 0 无限制(最强) 图灵机 包含关系:3 ⊂ 2 ⊂ 1 ⊂ 0
四型一览(表格版,便于窄屏对照)
| 型 | 产生式限制 | 识别机器 | 关键能力 | 编译器用途 |
|---|---|---|---|---|
| 3 正规 | 最严(右部至多一个非终结符且位置固定) | 有限自动机 | 记忆有限——数不清嵌套 | 词法分析 |
| 2 上下文无关 | 左部只能是单个非终结符 | 下推自动机(一个栈) | 能计数配对——括号、表达式嵌套 | 语法分析 |
| 1 上下文相关 | 左部可带上下文 αAβ → αγβ | 线性有界自动机 | 能表达「先声明后使用」这类约束 | 不用(代价太高,推给语义分析) |
| 0 无限制 | 无 | 图灵机 | 等于「可计算」的极限 | 不用(等价性、停机都不可判定) |
- 包含关系是 3 ⊂ 2 ⊂ 1 ⊂ 0:越往下限制越严、能力越弱,但性质越好——这正是「最小力量原则」要的东西。
深入理解:「能用弱的就别用强的」——最小力量原则
- 词法归 3 型、语法归 2 型,分界就在结构有没有嵌套:词法单元内部无嵌套,正规文法足矣;括号、表达式可任意深嵌套,识别它们要「计数配对」,只有下推自动机(2 型,本质是一个栈)才胜任。但更值得带走的是背后的工程哲学——最小力量原则(rule of least power):能用弱的工具就绝不用强的。为什么词法不直接上 CFG、语法不直接上图灵完备的解析器?因为工具越弱、性质越好:正规语言可判等价、可最小化、线性时间;上下文无关能高效分析;一旦爬到图灵完备(0 型),连「这段会不会停机 / 两个是否等价」都变得不可判定。这也是为什么「把配置文件写成图灵完备语言」常被视作反模式。同一条原则,Tim Berners-Lee 把它写进了 Web 的设计准则。有限自动机为何数不清括号,见 §03 正则卡。
自顶向下分析
有了文法,怎么用它去分析 token 流?第一条路——自顶向下:从开始符号出发预测产生式,边猜边匹配。核心是 FIRST / FOLLOW 集与 LL(1) 文法,递归下降是其最直观的实现。下一章的自底向上是它的镜像思路。
自顶向下最直观的落地,直白到几乎是把文法「翻译」成代码——理解它,等于理解「文法即程序结构」。
一个非终结符,一个函数
做法是给每个非终结符写一个函数,函数体照着它的产生式走:要匹配终结符就吃掉一个 token,要匹配非终结符就调用对应的函数。你根本不用自己维护分析栈——程序的调用栈就是分析栈;也不用显式建树——函数的调用嵌套就是语法树的形状。正因如此它易手写、报错友好,不少工业编译器的前端就是手写的。代价是它要求文法「够乖」:不能回溯(一步走错,之前已执行的动作收不回来),于是只瞄一个符号就得选定产生式——这正是下面 LL(1) 的要求,也是上一章非先消左递归、提左公因子不可的原因。
文法照着写成函数
文法 对应的递归函数
E → T E' parseE() { parseT(); parseE_(); }
E' → + T E' | ε parseE_() { if (peek()=='+') { match('+'); parseT(); parseE_(); } }
T → id parseT() { match(id); }
调用栈 = 分析栈; 函数的嵌套调用 = 语法树的形状深入理解:为什么 GCC、Clang、V8 都手写递归下降,而不用「更强」的 LR
- 递归下降遇到左递归
A → Aα就在没读任何符号时先调用自己、直接死循环——实现方式的硬伤,只能改文法回避;带回溯的版本更有隐患:走错要回头时,之前已执行的语义动作(已生成的代码、已报的错)收不回来。所以实用分析器坚持无回溯、要求 LL(1)。但真正反直觉的是结局:尽管 LR 理论上更强,GCC、Clang、V8、Roslyn 这些顶级编译器却全都手写递归下降——其中 GCC 是真把当年 Bison 生成的分析器换掉了(C++ 于 3.4、C 于 4.1),Clang、V8、Roslyn 则生来就是手写。原因很实在——递归下降报错友好得多、遇到新语法容易临时加特判、错误恢复也更优雅,而真实语言的文法离 LL 本就不远。这是「理论最强」输给「工程最顺手」的经典一幕。
match,见非终结符写函数调用,选 | 分支用 FIRST 判,EBNF 的 * 就是 while。顺带一个事实:Clang 的 C/C++ 前端就是手写递归下降的——工业界不用 LR 生成器写 C++ 前端,正是因为报错质量和错误恢复得自己控制(产物长什么样见 07 章)。预测分析要在「只准瞄一眼下一个输入符号」的苛刻条件下决定用哪条产生式——FIRST 与 FOLLOW 就是为这一眼备下的两张速查表。
一张管「开头」,一张管「后面」
FIRST(α) 收集 α 推导出的串可能的第一个终结符;若 α 能整个推成空,就把 ε 也收进去。FOLLOW(A) 收集各种句型里紧跟在 A 后面的终结符;若 A 能落在句子末尾,就把结束符 $ 收进去。算 FIRST 时遇到能为空的符号,便顺势再看它后面一个;算 FOLLOW 时则顺着产生式 A → αBβ,把 β 的 FIRST 灌给 B,β 若可空再把 A 的 FOLLOW 也灌过去。为什么两条边界偏偏一个含 ε、一个含 $,下面细说。
深入理解:它的算法,和 §11 数据流分析是同一个套路
- 这两个集合服务于同一窘境:站在一个非终结符前、只准瞄一眼下个符号,就得选定产生式。FIRST 告诉你「每条路开头可能是什么」用来对号入座;某条路能整个为空(ε)时,就靠 FOLLOW 看「它后面会跟什么」来决定要不要选那条空产生式。用途一清楚,两条边界就自然了:ε 不进 FOLLOW(收的是真能跟在后面的终结符,ε 是「空」不是符号),$ 只进 FOLLOW(代表「输入到头」这件只发生在末尾的事)。而更该带走的是怎么算它们——反复扫描、把新符号并进集合,直到再没有变化(不动点)为止。这个「单调累积、迭代到不动点」的套路,你会在 §11 数据流分析里一字不差地再遇见一次;认出这种跨主题反复出现的骨架,比记住某个具体集合更值钱。
// 文法:E → T E′ E′ → + T E′ | ε T → id
FIRST(T) = {id} FIRST(E) = FIRST(T) = {id} FIRST(E′) = {+, ε}
FOLLOW(E) = {$} // 开始符号先放入 $
FOLLOW(E′) = FOLLOW(E) = {$} // E′ 位于 E → T E′ 的末尾
FOLLOW(T):并入 FIRST(E′)\{ε} = {+};又 E′ ⇒* ε,再并入 FOLLOW(E) ⇒ {+, $}LL(1) 这名字拆开,就是它的全部承诺:只凭一个向前看符号,就能唯一定下用哪条产生式。
名字即定义,表格即预先算好的决策
从左(L)到右扫描输入、构造最左(L)推导、只用 1 个向前看符号——一个文法属于 LL(1),等价于说「站在任何非终结符前,瞄一眼下个符号就能唯一确定该用哪条产生式」。判据正由上一张的两个集合给出:对每对候选 A → α | β,要求 FIRST(α) 与 FIRST(β) 不相交(开头就能区分);若 β 能推成空,还要求 FIRST(α) 与 FOLLOW(A) 不相交。把这些决策预先算好、填成一张预测分析表 M[A, a],就能用「一个栈 + 输入串 + 查表」的循环来驱动分析,连递归函数都省了。
深入理解:表格里的「冲突」,是文法在喊「一眼看不清」
- 分析表每格只能填一条产生式,这不是规定,而是 LL(1) 全部承诺的体现:「瞄一眼下个符号就唯一定产生式」。一旦某格挤进两条,就说明有个符号无法区分两条产生式——背后往往是 FIRST 集相交、文法二义、或没消左递归。所以「表格冲突」不是你填错了,而是文法在喊「我一眼看不清」。有两层可迁移的东西:其一,把文法预先编译成一张二维表,分析器就退化成「一个栈 + 查表」的 while 循环、连递归都不用——这正是把「规则」编译成「快速查找」的典型手法(和词法把正则编译成 DFA 同源);其二,「一眼不够」未必是死路:多看几个符号(LL(2)、乃至 ANTLR 的 LL(*) 自适应向前看)常能化解,代价是表更大——向前看长度 k,就是你为分析能力付的价。
// 沿用上卡文法与集合:FIRST(E′)={+, ε} FOLLOW(E′)={$}
E → T E′ :FIRST(T E′) = {id} ⇒ 填 M[E, id]
E′ → + T E′ :FIRST(+ T E′) = {+} ⇒ 填 M[E′, +]
E′ → ε :按 FOLLOW(E′) = {$} ⇒ 填 M[E′, $]
T → id :FIRST(id) = {id} ⇒ 填 M[T, id];每格至多一条产生式 ⇒ 文法是 LL(1)编译器碰到第一个语法错误就罢工,体验极差。错误恢复的目标不是「改对」,而是出错后还能站稳、接着往下查,一次多报几个错。
三种止损办法
最常用的是恐慌模式:一旦卡住,就往后跳过输入符号,直到撞上一个「同步符号」——常取 FOLLOW 集里的成员,因为那恰是当前结构合法收尾的位置,从那儿重新开始最不容易再乱套。更精细的有短语级恢复(对输入做个局部小修补,比如替你补上漏掉的分号)和出错产生式(把常见的错误写法直接编进文法、专门捕捉并给出针对性提示)。它们的共同点是都不图把程序改正确,只图别让一个错误引发一连串假报警或漏报。
恐慌模式:跳到分号再站起来
输入: int a = ; b = 5; // '=' 后漏了表达式 ↑ 出错 恐慌模式:向后跳过 token,直到撞上同步符号 ';' → 丢弃出错的残片 → 从 ';' 之后重新开始,继续检查 b = 5 目的是止损:一次多报几个真错误,而非改对
深入理解:为什么老手只信第一条报错
- 你一定见过:补上一个漏掉的分号,报错就从二十条塌成零条。那十九条全是幻觉——第一个错让分析器状态错乱,恐慌模式又没能跳回真正的同步点,后面便是一连串被带偏的假报警(级联错误 cascading errors)。所以老手的铁律是:永远从最顶上第一条错误改起,改完立刻重编,绝不硬啃下面那些。这也点破了同步集合的两难——跳得太少,残渣继续引爆假报警;跳得太多,又把后面的真错一起吞掉漏报。错误恢复从不是「改对」,而是在「少漏报」与「少假报」之间走钢丝。C++ 的报错为何格外像雪崩?模板让「合法收尾点」几乎无从判定,同步一跳就过头。
自底向上分析
第二条路——自底向上:与上一章反过来,从输入串出发反复归约、最终收回到开始符号。LR 系分析能力强、覆盖几乎所有实用语言,是 Yacc/Bison 的理论基础。至此结构分析完成,语法树的骨架已经立好。
自底向上和自顶向下正相反:不从开始符号往下猜,而是从输入串往回收,全部难点凝在一个概念上——句柄。
一个栈、两个动作、一个句柄
它靠一个栈攒住已读进来、尚未归约的符号,每步只在两个基本动作间抉择:移进(shift) 再读一个 token 压栈;归约(reduce) 当栈顶正好凑成某条产生式的右部,就把它替换回左部(另有接受、报错两种收尾)。何时移、何时归,全看栈顶那段「此刻正该归约」的子串——这就是句柄。认准句柄,动作就定了;认错,分析就崩。所谓活前缀,不过是分析途中合法地留在栈里、还没越过句柄右端的那截前缀。整章的核心问题,就是「如何又快又准地认出句柄」。
id + id 的移进-归约
文法: E → E + T | T T → id 栈 | 剩余输入 | 动作 $ | id + id $ | 移进 $ id | + id $ | 归约 T→id ← 句柄 id $ T | + id $ | 归约 E→T $ E | + id $ | 移进 $ E + | id $ | 移进 $ E + id | $ | 归约 T→id $ E + T | $ | 归约 E→E+T ← 句柄 E+T $ E | $ | 接受
深入理解:左递归杀死自顶向下,却正合自底向上的胃口
- 句柄每步位置唯一,是因为最右推导每步替换的都是唯一确定的最右非终结符,倒过来看,每步该归约的那段(句柄)位置也唯一。这个「唯一性」让 LR 成为可能——分析器不必猜该归约哪段,理论上每步都有确定答案,难点只剩「如何高效认出它」。而它带来一个与前两章遥相呼应的红利:左递归——那个让递归下降当场死循环、逼我们改写文法的东西——对自底向上毫无障碍。因为自底向上从叶子往上归约,
E → E + T不过是「不断移进、再从左到右一段段归约」,天然顺滑。这正是「LR 能接受的文法比 LL 宽」最直观的体现:同一个特性,一个范式的绝症,是另一个范式的日常。
分析器凭什么知道「栈顶凑到句柄没有」?靠一台预先造好的自动机,而它的每个状态都由一组「项目」拼成。
圆点标进度,CLOSURE 与 GOTO 拼状态
一个 LR(0) 项目就是一条产生式加一个圆点,如 A → α·β,圆点标记「已经匹配到哪儿了」。两个操作把项目组织成自动机:CLOSURE——若圆点后面是个非终结符,意味着接下来随时可能开始匹配它,于是把它那些产生式的初始项目(圆点在最左)递归拉进来,凑成一个项目集;GOTO(I, X)——把项目集 I 中「圆点后是 X」的项目的圆点全部右移一格,就得到读入 X 之后到达的新状态。所有项目集连起来,正是一台识别活前缀的 DFA。
深入理解:反直觉的核心——「合法栈内容」竟是正则的
- 圆点是根「进度条」:
A → α·β表示「已匹配 α,正等着 β」。圆点后是非终结符,就意味着随时可能开始匹配它的任意一条产生式,所以闭包要把那些产生式的初始项目递归拉进来——一个项目集便代表「此刻我可能正处在哪几条产生式的中间」;也因此点后是终结符就移进(填 ACTION)、是非终结符就是归约后跳转(填 GOTO),两张表的分工顺理成章。但真正精妙、也最反直觉的是:这些项目集连成的东西是一台 DFA——尽管识别 CFG 一般需要栈,可「哪些栈内容是合法的(活前缀)」这件事本身竟是正则语言,能用有限自动机认出来!LR 分析器 = 这台识别活前缀的 DFA + 一个栈。理解到这层,才算真懂 LR 凭什么能在线性时间里工作。
// 增广文法:S′ → S S → (S) | a
I0 = CLOSURE({S′ → ·S}) = { S′→·S, S→·(S), S→·a } // 点后是 S ⇒ 递归拉入 S 的产生式
GOTO(I0, a) = { S→a· } // 圆点到末尾 ⇒ 归约项目
GOTO(I0, '(') = CLOSURE({S→(·S)}) = { S→(·S), S→·(S), S→·a }
GOTO(I0, S) = { S′→S· } // 增广项目移到末尾 ⇒ 接受光有 LR(0) 自动机还差临门一步:走到一个「圆点在末尾」的归约项目 A → α·,到底该不该现在就归约?SLR 给了个最省事的补丁。
只在「后面跟得上」时才归约
LR(0) 不看输入、一律归约,很容易和「其实还该继续移进」撞车。SLR(Simple LR,简单 LR)的补丁是:只有当前输入符号落在 FOLLOW(A) 里时才归约——毕竟归约成 A 之后,A 后面本就只可能跟这些符号,不在其中就说明此刻归约必错。就这一条,便化解掉一部分移进-归约冲突,而构造几乎没变复杂。代价是这个判据偏粗,能力有限,下面正好点破它粗在哪。
看 FOLLOW 决定要不要归约
状态里有归约项 A → α· ,当前输入是 a:
a ∈ FOLLOW(A) ? ── 是 → 归约(A 后面本就只跟这些符号)
└─ 否 → 不归约(此刻归约必错)
例:FOLLOW(T) = {+, $},遇到归约项 T → id·
下一个是 + ⇒ 归约 下一个是 ( ⇒ 不归约深入理解:SLR「太粗」,和 §11 别名分析「太保守」是同一种病
- SLR 用 FOLLOW(A) 笼统判断「要不要归约成 A」,但 FOLLOW 把 A 在所有上下文里的后继全混在一起;某个具体状态其实只允许其中一部分后继,SLR 分不清这层差别,就把本不冲突的情形误报成冲突——这就是它「过于宽松」的含义。LR(1) 给每个项目单独绑一个精确的向前看符号,只认当前状态真正合法的后继,才化解了这种一刀切的假冲突,代价是状态数大增。往大了看,这是编译器里反复上演的同一出戏:用「忽略上下文的粗略近似」换便宜,代价是过度保守、误报——数据流的「流不敏感 vs 流敏感」、指针的「粗糙 vs 精确别名分析」都是它的同胞。精度、开销、假冲突/假保守,永远在同一个三角里拉扯。
SLR 用 FOLLOW 判断归约,毛病是太笼统。往精确里做、再往实用里收,就得到了这两种主流方案。
把判据做精,再把表压小
规范 LR(1) 把判据做到极致精确:给每个项目单独绑一个向前看符号,写成 [A → α·β, a],只在真正合法的后继 a 出现时才归约,比笼统的 FOLLOW 精准得多,能力也最强。可它的项目集分得极细,分析表大到不实用。LALR(1) 是那个恰到好处的折中:把「圆点位置全同、只是向前看符号不同」的状态合并,一举把表压到和 SLR 一个量级,能力却贴近 LR(1)。四者由弱到强依次是 LR(0)、SLR(1)、LALR(1)、LR(1),而 Yacc / Bison 默认选的正是性价比最高的 LALR(1)。
带向前看的项目 + LALR 合并
LR(1) 项目多带一个向前看符号,比 FOLLOW 精确:
[ A → α·β , a ] ← 只在后继真是 a 时才归约
LALR:合并「圆点位置全同、只是向前看不同」的状态
[A→α· , a] + [A→α· , b] ─合并─▶ [A→α· , a/b]
表压到 SLR 大小,能力却近 LR(1) ⇒ Yacc / Bison 默认深入理解:Bison 报 reduce/reduce 冲突,常是 LALR 合并的「锅」
- LALR 把「心(core,即项目本身)相同、只向前看符号不同」的状态合并,以此把表压到 SLR 那么小。合并时向前看集取并集,于是可能让两条归约的向前看撞到一起、凭空多出 reduce/reduce 冲突;但「要不要移进」看的是圆点后的符号、跟向前看无关,所以合并绝不会新增 shift/reduce 冲突。这个「省一大截表、只偶尔损失一点能力」的划算买卖,正是 Yacc/Bison 默认选它的原因。落到实处:当 Bison 报出一个费解的 reduce/reduce 冲突、而你确信文法本身没歧义时,元凶往往就是这次合并——改用 GLR 模式(
%glr-parser)或手动拆分状态即可化解。真正「是 LR(1) 却不是 LALR(1)」的文法确实存在,但少见且刻意。
自动机造好后,落成两张表来驱动分析;而所谓「这个文法能不能 LR」,最终就体现为表里有没有格子被塞进两个动作。
两张表驱动,一格两义即冲突
ACTION[s, a] 告诉你在状态 s、看到输入 a 时该移进、归约、接受还是报错;GOTO[s, A] 告诉你归约出非终结符 A 后跳往哪个状态。理想情况每格只有一个动作;一旦某格塞进两个,就是冲突:既能移进又能归约叫移进-归约冲突(悬空 else 是典型),两条产生式都能归约叫归约-归约冲突。冲突未必是文法写错——对着二义文法补一句优先级、结合性声明,往往既消了冲突又缩小了表。真出了错,Yacc / Bison 靠一个保留记号 error 做恐慌式恢复:不断弹栈直到某状态肯接纳 error,再跳过输入到同步符号(如 ;)继续。
两张表 + 一格两义的冲突
ACTION GOTO 状态 id + $ E T 0 s5 1 2 sN=移进到状态N 1 s6 acc rN=用第N条产生式归约 2 r2 r2 acc=接受 冲突 = 一格挤进两个动作: 悬空 else:同格既能 s(移进 else)又能 r(收尾内层 if)⇒ 移进-归约冲突
深入理解:shift/reduce 冲突常可忽略,reduce/reduce 多半是真 bug
- 悬空 else 的冲突里,「移进」= 让 else 跟最近的 if 配对,「归约」= 提前给内层 if 收尾、把 else 让给外层。几乎所有语言都要 else 配最近的 if,所以 Yacc 干脆默认移进优先,一条规则消掉这个经典冲突——可见冲突并不总是文法错误,有时只是需要一个明确的取舍策略。这条经验能直接用在调 Bison 上:shift/reduce 冲突常常良性(默认移进往往正合语义,评估后可放过),而 reduce/reduce 冲突几乎总是真 bug——它意味着「同一段输入该归约成谁」根本没定,多半是文法设计撞了车。学会分辨「哪种冲突能忍、哪种必须改」,比一味追求「零冲突」更接近老手。
-Wcounterexamples 会直接构造触发冲突的反例句子,比自己啃 .output 文件里的项目集快得多;%left/%right/%prec 只作用于 shift/reduce 冲突,对 reduce/reduce 无能为力。两条路线的取舍,一句话概括就是:好写,还是更强。
好写 vs 更强
LL 自顶向下、构造最左推导、边猜边验,最大的好处是直观——能手写、报错信息友好;坏处是能罩住的文法窄。LR 自底向上、对应最右推导的逆,能力强得多、几乎覆盖所有实用语言,但分析表庞大,通常靠 Yacc / Bison 这类工具生成。实践中能被 LR 接受的文法范围明显比 LL 宽——至于「LR 为什么更强」,下面从「决策推迟得更晚」这个角度说透。
两范式对照
LL(自顶向下) LR(自底向上) 推导 最左 最右的逆 决策时机 早:看开头就押注 晚:整条移进栈上再定 文法能力 较窄 更广(真包含 LL) 实现 可手写、报错友好 表大,多用工具生成 代表 递归下降、ANTLR Yacc / Bison
深入理解:「尽量晚做决定」是一条通用智慧
- LR 为什么普遍更强?关键在决策推迟得更晚:LL 还没看到产生式内容、只凭开头几个符号就得押注选哪条,信息少;LR 允许先把整条产生式移进栈、看全了再决定归约,决策点更靠后、信息更足,自然对付得了更复杂的文法。这条"把决定拖到掌握足够信息的最后一刻"的智慧远不止用于分析器——CPU 的投机执行、惰性求值、数据库查询计划、乃至工程里「可逆决定尽量晚做」,都是它的分身。代价也一致:晚决策要背更多「暂存的可能性」(LR 就是那张庞大的分析表),而早决策(LL)简单、直观、可手写、报错友好。同一道取舍,你会在计算机系统的各个角落反复重逢。
LL 与 LR 的正面对照
| LL(1)(自顶向下) | LR(1) / LALR(1)(自底向上) | |
|---|---|---|
| 工作方式 | 从开始符号往下猜,构造最左推导 | 从输入往上归约,走最右推导的逆 |
| 决策时机 | 看到产生式开头就要选定用哪条 | 可以先移进、攒够了再决定 |
| 表达力 | 较弱(LL(1) ⊂ LALR(1) ⊂ LR(1)) | 强——多数实际语言文法是 LR 不是 LL |
| 左递归 | 不能有,必须先改写 | 可以直接用(还更自然) |
| 手写难度 | 容易——一个非终结符一个函数,可读可调试 | 几乎必须用生成器(分析表是机器算的) |
| 报错质量 | 好——知道自己在解析什么成分,能说「这里缺一个分号」 | 差——只知道「在状态 137 遇到了意外的 token」 |
| 代表工具 | 手写递归下降、ANTLR、PEG / parser combinator | Yacc / Bison、LALRPOP、tree-sitter |
- 工业界的选择正在偏向 LL/手写递归下降——不是因为它理论上更强(恰恰更弱),而是因为报错质量和可调试性在真实编译器里比表达力值钱:GCC、Clang、Roslyn(C#)、rustc、V8 的解析器全是手写递归下降。表达力不够就用「加一点前瞻 + 少量特判」补,换来的是能给出人类看得懂的诊断信息。
- 反过来 LR 的主场是你不控制文法的时候:为既有语言写工具、做语法高亮、做代码分析——tree-sitter 用 GLR 就是为了鲁棒地解析各种语言而不必为每种语言手写。
%left 这类外挂声明强行消歧,不是 LR 本身变强了。语法制导翻译
分析确认了结构,但还没算出任何东西。本章把语义计算「挂」到上两章的文法上:用属性与语义规则,在分析的同时生成翻译结果、并构造出抽象语法树(AST)——从此往后各章加工的都是这棵树。
分析只搭出了语法树的骨架,可类型、地址、代码这些真正要算的东西还悬着。属性文法就是让这些信息顺着树流动起来的办法。
给符号挂属性,让信息沿树流动
做法是给每个文法符号挂上「属性」,再用语义规则让信息顺着分析树流动。流向分两种:综合属性由子结点往上汇总——子表达式的类型决定父表达式的类型,方向自底向上;继承属性由父结点或左兄弟往下、横向传递——一处声明的类型要发给它管辖的每个变量。终结符的属性直接由词法分析给定,所以只有综合属性、没有继承属性。至于什么时候一定算得出、什么时候会卡死,看下面。
一个往上汇总,一个往下分发
// 综合属性 val:自底向上,子决定父 E.val = 8 / | \ T.val=3 + T.val=5 ← 父的 val 由子的 val 算出 // 继承属性 type:自顶向下,一处声明发给多个变量 int a , b , c └── type=int ─┴──┴──▶ ← 从声明处横向 / 向下分发给每个 id
深入理解:Excel 表格,本质就是一个属性文法求值器
- 给文法符号「挂属性」,是因为语法分析只搭出骨架,真正的翻译(求值、定类型、生成代码、算地址)需要让语义信息沿树流动:综合属性从子往上汇总,继承属性从父/兄往下传;开始符号在树根、没父结点,自然拿不到继承属性。而属性文法真正的思想是声明式——你只写「谁等于谁的什么函数」这些方程、不写求值顺序,编译器从属性间的依赖关系排出一个无环顺序(拓扑排序)自动求值,成环就无解(「算 A 要先有 B、算 B 又要先有 A」)。这套「声明关系、让系统自己调度计算顺序」的模式你其实天天在用:Excel 单元格公式就是一个活的属性文法求值器,
make/Bazel 的依赖图、React/Vue 的响应式重算,也都是同一个内核。
把语义规则挂到产生式上,就是语法制导定义(SDD)。为它分类的那条线很实际:能不能在分析的同一趟里顺手把属性算完,而不必先建完整棵树再回头算?
两档属性,对应两种「边分析边算」
S-属性定义只用综合属性(纯往上汇总),归约到哪就能算到哪,天然贴合自底向上的 LR。L-属性定义放宽一点,允许继承属性,但限定它只能依赖左边已处理过的符号和父结点——于是从左到右做一遍深度优先遍历也能算完,贴合 LL;S-属性其实就是「没有继承属性」的 L-属性特例。一组规则到底算不算得出,就看它的依赖图有没有环:有环意味着「算 A 得先有 B、算 B 又得先有 A」,找不到起点,无解。
能不能一趟算完
S-属性:只有综合属性(纯往上)→ 归约到哪算到哪,配 LR
L-属性:综合 + 受限继承(继承只依赖左边已处理的符号 / 父)
A → B C {C.in = B.val} ✓ 用左边 B,从左到右一趟能算
A → B C {B.in = C.val} ✗ 要用右边 C,一趟算不了
依赖图无环 ⇒ 可算;有环 ⇒ 无解深入理解:S-属性就是函数式里的 fold(归约)
- 区分这两类属性,其实在回答一个很实际的问题:"能不能在同一趟里顺手把属性算完,而不必先建完整棵树再回头算?" S-属性只往上汇总,归约到哪算到哪,天然贴合自底向上的 LR——它就是函数式里的
fold/reduce:把每个结点的子结果合成父结果,一路归约到根(学名 catamorphism)。L-属性放宽一点,允许继承属性、但只依赖左边已处理过的符号,于是从左到右一遍深度优先也能算完,贴合 LL——相当于额外「从左往右串一个累加器」。S-属性不过是「没有继承属性」的 L-属性特例。看穿它们是 fold 的两种形态,就懂了为什么能「边分析边算、不留完整树」;而任何依赖成环的定义都算不了,因为找不到「先算谁」的起点。
SDD 说的是「该算什么」,SDT(翻译方案)更进一步说「在哪一步动手」——把语义动作 {…} 嵌进产生式的具体位置,分析走到那里就执行。
动作摆在哪,就在哪一刻执行
位置大有讲究:动作全摆在最右端(后缀 SDT),意味着整条产生式归约完才动手,正好配 S-属性 + LR;动作可插在任意位置,配 L-属性 + LL,递归下降走到哪就调到哪。在 LR 里想执行「中间动作」会有麻烦——它凭空多出一个「必须此刻做点事」的点,扰乱了原本靠向前看做出的移进/归约判断;解决办法是引入一个什么都不匹配的标记(marker)非终结符,把动作合法地「托管」进文法,既保住这个执行点、又不破坏分析。
动作放哪,就在哪一步执行
语义动作 {…} 嵌在产生式的哪个位置,分析走到那就执行:
后缀(动作在最右) E → E + T { print('+') } 归约完才做 ── 配 LR
中间(动作在中段) E → E + { m } T { … } 匹配到一半就做 ── 配 LL
LR 里的中间动作会扰乱移进 / 归约 ⇒ 用标记非终结符 M → ε 托管它深入理解:Yacc 的「中规则动作」为何会莫名制造冲突
- 语义动作写在产生式的哪个位置,分析走到那里就执行:放最右端 = 「整条归约完再动手」,放中间 = 「匹配到一半就插一脚」。在 LR 里,中间动作会引入冲突,因为它相当于凭空塞进一个「必须此刻做点什么」的点,扰乱了原本靠向前看做出的移进/归约判断——这不是抽象说法:Yacc/Bison 遇到 mid-rule action 会在幕后把它改写成一个空产生式的标记非终结符(你能在
-v详细输出里看到自动生成的$@1这种名字),意外冲突往往就是它引来的。更本质的一点:动作是有副作用的(生成代码、报错、改符号表),所以分析顺序不能乱——这也是分析器坚持一遍确定顺序、不做投机重排的深层原因。
.y 文件就是活的 SDT:$$ 是左部属性、$1/$3 是右部符号的属性,大括号动作嵌在哪,分析走到哪就执行。翻译的第一个实在产物,就是一棵抽象语法树(AST)——它是此后各章共同加工的那份数据。
只留骨架的那棵树
AST 用综合属性在归约 / 遍历时自底向上搭出来:每凑齐一个子结构,就用 mknode / mkleaf 造一个结点,让属性记住指向它的指针,一层层拼成整棵树。它和忠实记录每步推导的具体语法树(CST)不同——AST 把括号、分号、单产生式这类只为分析服务的结点全部删掉,只留「运算符 + 操作数」的骨架,干净紧凑。若再把重复出现的公共子表达式合并、共享同一个结点,AST 就成了 DAG(有向无环图)。之所以另造这棵树而不直接沿用 CST,下面细说。
CST 塞满语法,AST 只留骨架
a + b * c
具体语法树 CST 抽象语法树 AST
E +
/|\ / \
E + T a *
| /|\ / \
T T * T b c
(塞满 E/T/单产生式/标点) (只留运算符 + 操作数)
后续各章加工的都是右边这棵干净的 AST深入理解:Babel、TypeScript、你的 IDE,全都在这棵树上干活
- 为什么另造 AST 而不直接用分析树(CST)?CST 忠实记录推导每一步,塞满括号、分号、单产生式这类只为分析服务、对语义毫无信息的结点;AST 把它们删光,只留「运算符 + 操作数」的骨架,后续类型检查、代码生成才能在一棵干净紧凑的树上跑。而它的意义远超编译器内部——AST 是整个代码工具生态的通用货币:Babel/TypeScript 转译、ESLint 查风格、Prettier 格式化、IDE 的跳转/重构/补全、tree-sitter 高亮,无一不是「源码 → 解析成 AST → 遍历/改写这棵树 → 再打印回代码」,连遍历手法(访问者模式 visitor)都如出一辙。一个有趣的反例:tree-sitter 这类要保留每个字符的工具反而离不开更啰嗦的 CST(Prettier 则是解析成附着注释的 AST 后整个重打印)——编译器急着丢掉的「琐碎信息」,正是它们必须留住的东西。
(1+2)*3 保留着 ParenExpr 结点——生产编译器为了精确诊断和重构工具,AST 往往比课本的更「具体」。另外 AST 合并公共子表达式后就成了 DAG,同一结点有多个父亲,天真的递归遍历会重复访问、重复计算。(a+b) 的括号在 AST 里不见了,因为它的作用已经体现在树形本身。自查方法:把画出的 AST 做中序遍历(必要处补括号),应该能还原原式;还原不出来,就是丢了不该丢的结构。语义分析与类型系统
AST 有了骨架,还要检查它「说得通」——这是前端的最后一关:作用域与名字解析、符号表管理,以及类型系统的等价、转换与推导。查完并加注类型的 AST,才够格交给后端降成中间代码。
符号表是编译器给每个标识符记的一本账,从语义分析一直查到代码生成,贯穿全程。
一本按作用域分层的账
一个名字叫什么、是什么类型、属于哪个作用域、将来落在内存或寄存器的什么位置、(若是函数)带哪些参数——都记在这里。实现上常用哈希表(查得快)或有序表;碰上嵌套作用域,就用一叠符号表(栈)或一条链把内外层串起来:进入一个作用域就压一层新表,退出就弹掉。这样内外层同名的变量各记各的、互不踩踏。至于为什么非按作用域分层不可、以及「先用后声明」的前向引用为何棘手,看下面。
嵌套作用域 = 一叠符号表
int x; ┌─ 全局表 ── x:int, f:func
void f(int a){ │
int x; ├─ f 的表 ── a:int, x:int ← 遮蔽全局 x
{ int y; … } ├─ 块的表 ── y:int
} └─ 退出块 / 函数就弹掉对应表
进入作用域压一层表、退出弹掉;查名字从栈顶往下找深入理解:调试器认得变量名,靠的是符号表被写进了可执行文件
- 符号表按作用域分层,是因为同一名字在不同作用域其实是不同实体,各有各的类型和地址,必须分开记账,否则内外层互相踩。前向引用(先用后声明)棘手,是因为用到名字那一刻它还没进表——逼着语言要么规定「必须先声明」(C 的头文件由此而来),要么改成两趟(先扫一遍收全声明再回头解析,Java/C# 走这条)。而符号表的寿命常常超出编译本身:编译器把它的一部分序列化进可执行文件的调试信息(DWARF/PDB),gdb 才能把内存地址映射回你写的变量名。C++ 的名字改写(name mangling)——把
foo(int)与foo(double)编码成不同符号——也是符号表要区分重载的直接产物。一张编译期的账本,一路延伸到了链接、调试乃至运行时反射。
void foo(int) 与 void foo(double) 编译后用 nm 看,是 _Z3fooi 与 _Z3food 两个符号(LLVM 21)——重载区分在符号层的落地。同一个名字在程序里可能声明了好几处,名字解析就是判定某次使用到底绑定到哪一个声明。
按文本嵌套定,还是按运行调用链定
有两套规矩:静态(词法)作用域按程序文本的嵌套结构来定,编译期一看便知;动态作用域按运行时的调用链来定,得跑起来才知道(如今少见)。块结构语言遵循「最近嵌套」原则——查一个名字时从当前块往外一层层找,撞上的第一个声明就是它,内层的同名声明会遮蔽外层。为什么现代语言几乎清一色选静态作用域,看下面。
最近嵌套,从里往外
int x = 1;
void f() {
int x = 2; // 内层 x 遮蔽外层
{ int x = 3;
print(x); } // → 3(最近嵌套:从里往外,撞上第一个)
print(x); // → 2
}
静态作用域:绑定由文本嵌套决定,编译期就能钉死深入理解:闭包之所以成立,全靠静态作用域
- 现代语言几乎清一色选静态作用域,因为它由文本嵌套决定、编译期就能把每个名字钉到它的声明上——读代码的人和编译器看到同一套绑定,既好读又好优化;动态作用域要到运行时按调用链才知道绑谁,行为随调用路径变化、难以预测。而静态作用域最深远的产物是闭包:一个函数能「记住」它定义处的变量,正因绑定在定义时就按文本嵌套定死了,哪怕函数被传到别处再调用也不变——闭包和词法作用域本是一枚硬币的两面。它也解释了那个折磨过无数人的 JS 坑:循环里用
var建的回调全打印同一个末值,换成块级作用域的let才对——因为var没有循环体那层词法作用域可捕获。遮蔽则只是「最近嵌套」的自然结果:查名字从里往外,撞上第一个就停。
static 局部变量作用域限于函数内,生存期却贯穿整个程序。「动态作用域已绝迹」也是错觉——Bash 函数里的 local、Emacs Lisp、Perl 的 local 都是活着的动态作用域。-Wshadow,gcc 报「declaration of 'x' shadows a previous local」、clang 报「declaration shadows a local variable」(GCC 15/LLVM 21)。类型系统的活儿,是在程序「把一堆比特解释错」之前就把它拦下来——别拿浮点当指针,别给字符串做减法。
先描述类型,再检查是否对得上
它先要能描述类型:整数、浮点这些基本类型,加上数组、记录、指针、函数这些由基本类型构造出来的复合类型。再检查每处运算的操作数类型对不对得上,检查规则通常写成「前提 → 结论」的推理规则。检查的时机分两种:静态在编译期一次查清、运行时零开销但偏保守;动态拖到运行时才查、更灵活但错误要到执行那一刻才暴露。两者的取舍、以及「强 / 弱类型」这对标签为何含糊,看下面。
类型规则长什么样
规则写成「前提 → 结论」:
e1 : int e2 : int
───────────────────── // 两个 int 相加,结果才是 int
e1 + e2 : int
静态检查:编译期查完,运行时零开销,偏保守
动态检查:运行到那一刻才查,灵活但错误暴露晚深入理解:类型,是一份机器帮你验的「证明」
- 类型检查的根本目的,是在「把一堆比特解释错」(拿浮点当指针、给字符串做减法)之前就拦住它。但现代视角把它拔得更高:一个类型就是一条命题,一段通过检查的程序就是它的证明(Curry–Howard 对应)。由此有了一句设计箴言"让非法状态无法表示"——把约束编进类型(非空、枚举穷尽、单位不混用),那类错误就在编译期灰飞烟灭、而非留到线上。最著名的反例是
null:Tony Hoare 亲口称之为「我的十亿美元错误」,正因早期类型系统没把「可能为空」编进类型,才让空指针横行几十年;Kotlin/Rust/Swift 把可空性纳入类型,才从根上堵住它。至于「强/弱类型」没有统一定义(混指了会不会隐式转换、能不能绕过类型系统好几件事),讨论时最好直接说清你指哪一种,别被这对含糊标签带偏。
'1'+1 直接 TypeError),C 是静态但偏弱(强转可把指针重新解释成几乎任何类型)。也别把「过了编译」当「类型无错」——静态检查偏保守,数组越界、整数溢出大多不归它管。char* 减 double,clang 报「invalid operands to binary expression ('char *' and 'double')」,gcc 报「invalid operands to binary - …」(GCC 15/LLVM 21)。围绕类型,系统要回答三个环环相扣的问题:两个类型算不算一样?一个能不能当另一个用?不写标注能不能自动推出来?
等价、转换、推导
等价:「结构等价」看两个类型长得一不一样,「名字等价」看名字是不是同一个(更严格)。转换:编译器悄悄替你转叫隐式(coercion)、你自己写明叫显式(cast);小类型塞进大类型叫拓宽(widening)、反过来叫缩窄(narrowing)。推导:不写类型标注、由用法反推,ML / Haskell 的 Hindley-Milner 就靠「合一(unification)」自动补齐全部类型。此外还有重载消解(同名函数该选哪个)与多态(一段代码适配多种类型)。这里最容易埋 bug 的是隐式转换,下面点破。
结构等价 vs 名字等价
type Point = { x:int, y:int }
type Vec = { x:int, y:int } // 结构相同、名字不同
结构等价:Point 与 Vec 视为同一类型(长得一样即可)
名字等价:Point 与 Vec 视为不同类型(更严,尊重「特意起不同名」)
隐式转换的坑:long → float 悄悄丢精度,最难查深入理解:一次单位混用,让 3.27 亿美元的火星探测器坠毁
- 结构等价看「长得一样就算同类型」,名字等价看「名字一样才算」——后者更严,因为它把程序员「特意起了不同名字」的意图当真:两个字段完全相同的类型,一个叫
Meter、一个叫Foot,显然不该混用。这不是学术洁癖:1999 年 NASA 的火星气候探测者号解体坠毁,直接原因就是一段软件用磅力、另一段按牛顿解读同一个数——两个「都是 double」的量被结构等价放行,酿成约 3.27 亿美元的灰烬;只要类型系统坚持名字等价、把单位编进类型,这个错就编不过。今天 Rust/Haskell 的 newtype 模式(把裸值包一层、强制按名字区分)正是这套思路的日常化。另一隐患是隐式转换:把窄类型塞进宽类型可能悄悄丢精度(long → float),这类「看不见的转换」是难查 bug 的温床——也是现代语言纷纷收紧隐式转换的原因。
int → float 也是拓宽,可 float 尾数只有 24 位,装不下 32 位整数的全部精度,16777217 转过去就成了 16777216。反过来「显式 cast 更安全」也不成立——cast 只是让编译器闭嘴,缩窄照样截断,连警告都替你关了。typedef 只造别名、不造新类型。想让隐式转换现形,开 -Wconversion:long → float 会报「conversion from 'long int' to 'float' may change value」(GCC 15)。中间代码生成
前端的终点、后端的起点:把上一章带类型的 AST 降为机器无关的中间表示(IR)。它是连接前后端、承载后续优化的枢纽——三地址码、SSA、回填是核心。主线上「换语言只改前端、换芯片只改后端」的解耦,就靠这一层实现。
IR 夹在源语言和目标机之间,是整个后端的枢纽——前后端靠它解耦、机器无关优化在它上面做、支持多目标机也全凭它。
不是一种东西,而是一道光谱
IR 并非单一形式,而是一道光谱:高级 IR 接近 AST、还留着循环和数组下标;中级是三地址码这类摊平的线性代码;低级已经接近汇编。常见的具体形式有语法树 / DAG、三地址码、SSA、字节码、LLVM IR 等。落在光谱的哪一档并非无关紧要——它直接决定了哪些优化做得动、哪些已经没法做,下面详说。
同一句,三个层次的 IR
a[i] = a[i] + 1 高级 IR a[i] = a[i] + 1 ← 还留着数组下标 中级 IR t1 = i * 4 ← 三地址码,摊平 t2 = a + t1 *t2 = *t2 + 1 低级 IR load r1, [a + i*4] ← 接近汇编,带寄存器 add r1, r1, 1 store [a + i*4], r1 越往下:高层结构丢得越多,越贴近机器
深入理解:为什么一个编译器里塞着四五种 IR——直到 MLIR
- IR 离源码越近,越保留高层结构(循环、数组下标、类型),适合做循环变换、别名相关的优化,但离机器远;越接近汇编,越能做寄存器分配、指令级优化,可循环、类型这些信息早丢了。于是「选哪一层 IR」提前决定了「哪些优化做得动」——正因鱼与熊掌不可兼得,现代编译器索性叠好几层 IR、每层各做各的优化:Clang 一路就经过 AST → LLVM IR → SelectionDAG → MachineIR。这条思路最新的集大成者是 Google 的 MLIR(Multi-Level IR)——它干脆让你自定义任意多层「方言(dialect)」,从张量、循环一路降到硬件;也正因 LLVM IR 那层太低、做不了张量融合,TVM、XLA 这些 AI 编译器才各自发明了更高层的 IR。你以为多层 IR 是过度设计,其实是「没有一种 IR 能同时利于所有优化」逼出来的必然。
clang -S -emit-llvm(中级)、gcc -fdump-tree-gimple -c、再 llc 降到汇编。三地址码把程序摊平成一串极规整的小步骤:每条指令至多一个运算符、至多牵涉三个地址。
一步一运算,控制流全显式
基本形态是 x = y op z;此外还有拷贝、无条件 / 条件跳转、函数调用、数组与指针访问等几类。嵌套的复杂表达式怎么办?——引入临时变量 t1、t2… 拆成一步步算(见下例)。控制流也不再靠缩进和嵌套体现,而是显式地用标号(label)加 goto 表达。写起来这么啰嗦是有回报的,下面讲它换来了什么。
示例
// a = b * c + b * c
t1 = b * c
t2 = b * c
t3 = t1 + t2
a = t3深入理解:摊平成规整小步,和 RISC「指令简单定长」是同一种哲学
- 为什么限制「每条指令至多一个运算符」?因为这样每步都摊平成「取两个操作数、做一次运算、存进一个临时量」,结构极规整。优化器分析数据依赖、消除公共子表达式、分配寄存器时,面对的全是统一小步骤,不必再拆嵌套表达式。写起来啰嗦,换来机器处理时的简单——这跟硬件里 RISC「指令简单、定长、规整」胜过 CISC 是同一种哲学:牺牲书写的紧凑,换取处理的规整与可优化。还有个常被忽略的视角:三地址码里随手造的临时量
t1、t2…其实是无限多的虚拟寄存器——此刻假装机器有用不完的寄存器,把「物理上只有十几个寄存器」这个难题推迟到 §12 的寄存器分配再解决。「先假设资源无限、再回头分配」,正是编译器分层攻坚的经典策略。
x = y、跳转 goto L 都只占一两个地址,照样是合法指令。手写翻译最常见的扣分点是拆得不彻底——每条至多一个运算符,写出 t1 = a + b * c 本身就已经错了。gcc -fdump-tree-gimple -c x.c,a = b*c + b*c 出来是 _1 = b * c; a = _1 * 2;——临时量 _1 正是课本的 t1,还能看到前端已顺手把两个 b*c 合并成乘 2(GCC 15)。三地址码是概念,真存进数据结构有三种做法,取舍都落在「省空间」和「好挪动」之间。
省空间还是好重排
四元式 (op, arg1, arg2, result) 把结果的临时变量名显式写出,优化时要移动、重排指令都方便。三元式 (op, arg1, arg2) 省掉 result,改用「这条指令的位置编号」来指代它的结果,省空间,却也因此一挪动就全乱。间接三元式 是折中:另加一张「指令指针表」,重排时只动这张表、不碰指令本体。为什么多这一层间接就解决了问题,下面细说。
同一段 TAC,三种存法
a = b * c + d
四元式(带 result) 三元式(用行号指代结果)
(0) * b c t1 (0) * b c
(1) + t1 d t2 (1) + (0) d ← "(0)" = 第 0 条的结果
(2) = t2 a (2) = a (1)
移动一条指令:四元式改 t1 即可;三元式行号全乱
⇒ 间接三元式加一层「指令指针表」,重排只动表深入理解:David Wheeler 那句名言的活标本——「再加一层间接」
- 三元式用「指令的位置编号」引用上一步的结果,省掉了临时变量名,却埋了雷:优化一旦移动或删除指令,所有位置编号全乱套。间接三元式因此加一层「指令指针表」,重排时只动这张表、不碰指令本体。这正是计算机科学最著名那条箴言的活标本——David Wheeler:「计算机科学里的任何问题,都能靠再加一层间接来解决。」(后半句同样精辟:「……除了'间接层太多'这个问题。」)你会在无数地方撞见它的同胞:操作系统用页表把虚拟地址间接到物理地址、数据库用索引间接到行、网络用 DNS 把域名间接到 IP、C 的指针本身就是间接。认出「加一层间接」这个万能手筋,比记住间接三元式的细节重要得多——代价永远是那多出来的一次查表。
SSA 定下一条极强的纪律:每个变量只被赋值一次。就这一条,让「这个值打哪来」变得一眼可知。
一个变量只赋一次值,汇合处插 φ
做法是给变量按版本重命名,x 每被赋值一次就换个新名 x₁, x₂…。好处立竿见影——数据来源清清楚楚,常量传播、值编号、死代码消除这些数据流优化瞬间变简单。唯一的麻烦在控制流汇合处:if 的两个分支都给 x 赋了值,汇合后该用哪个版本?答案是插一个 φ 函数,放在汇合点、按来路挑对应版本。φ 该放进哪些块最省,由「支配边界」定(见 §11)。φ 究竟是不是一条真指令,下面点破。
看一眼:φ 从哪来
// 原始:两个分支都给 x 赋值 if (a > 0) x = 1; else x = 2; y = x + 1; // 汇合点:该用哪个 x? // SSA:每次赋值换个新版本号,汇合处插 φ if (a > 0) x₁ = 1; else x₂ = 2; x₃ = φ(x₁, x₂); // φ 按来路挑:左路→x₁、右路→x₂ y₁ = x₃ + 1; // 往后只认 x₃,来源唯一
深入理解:SSA 是现代编译器的地基,而 φ 是活不到最后的「幽灵」
- SSA 让每个变量只赋值一次,好处是「这个值打哪来」一眼可知、数据流分析瞬间简单——正因如此,几乎所有正经编译器都建在 SSA 上:LLVM IR、GCC 的 GIMPLE、V8 的 Turbofan、HotSpot 的 C2 无一例外。它把「某个值的定义与所有使用」(def-use 链)变得几乎免费,常量传播、死代码消除、值编号全都水到渠成。控制流一汇合就有麻烦:if 两分支都给 x 赋了值,汇合后用哪个版本?φ 函数就是放在汇合点、「按来路挑版本」的记号——但它是个幽灵:真实机器没有这条指令,代码生成前必须按每条前驱边把 φ 拆成普通拷贝(out-of-SSA)。这一步还藏着经典陷阱(「lost-copy / swap 问题」):多个 φ 本该同时并行赋值,天真地依次拷贝会互相覆盖,得靠引入临时量或排好顺序化解。记住 φ 是「选择器」而非「运算」,就不会被它绕晕。
alloca+load/store 的内存风格,寄存器版纯 SSA 要等 mem2reg 之后才有 φ。还有个暗坑:-O0 会给函数打 optnone 属性,直接 opt -passes=mem2reg 会被静默跳过、一个 φ 都不出——须加 -Xclang -disable-O0-optnone(LLVM 21)。clang -S -emit-llvm -O0 -Xclang -disable-O0-optnone x.c,再 opt -passes=mem2reg -S,if/else 汇合点立刻出现 phi i32 [ 1, %3 ], [ 2, %4 ];GCC 侧 gcc -fdump-tree-ssa 里是 # x_1 = PHI <x_4(3), x_3(4)>(GCC 15/LLVM 21)。把声明和赋值翻成三地址码,核心其实是一件事:算地址。
一切归结为地址算术
碰到声明,编译器算出每个变量的类型宽度(width)和它在所属存储区里的偏移(offset),填进符号表备用。碰到数组引用 a[i],就把它换成一条地址算式 base + index × width;多维数组还要按「行优先」或「列优先」的约定,把下标摊平成一维偏移。碰到记录 / 结构的字段,则按各字段预先算好的偏移定位。为什么这套地址算术值得逐字讲透,下面说。
a[i][j] 怎么变成地址算式
int a[3][4]; 访问 a[i][j](行优先,每元素 4 字节):
地址 = base(a) + (i * 4 + j) * 4
└ i 行、每行 4 个元素 ┘
翻成三地址码:
t1 = i * 4
t2 = t1 + j
t3 = t2 * 4
t4 = a + t3 // 即 &a[i][j]
地址偏一位,整个访问就落到别的数据上 —— 所以要讲透深入理解:这条偏移公式,就是「双重循环写反了慢十倍」的根源
- 数组在内存里是一段连续字节,编译器把
a[i][j]翻成「基址 + 偏移」的地址算式;按行优先还是列优先摊平、下标从 0 还是 1 起,都会改变这条公式。它值得讲透,不只因「容易算错」——偏一位整个访问就落到别的数据上——更因为它直接决定性能:行优先下同一行的元素地址相邻,你按a[i][j](i 外层、j 内层)遍历就是顺着内存走、缓存命中率极高;一旦把两层循环写反成先 j 后 i,每步都跳一大截、疯狂 cache miss,同样的计算能慢上数倍到十倍。这也是 C(行优先)和 Fortran(列优先)互调数组是个雷区的原因。看懂摊平原理,你才既知道编译器在翻译什么、又知道该顺着哪个方向写循环。
int a[3][4] 摊平是 (i*4+j),写成 i*3 就全偏。另两处易漏:偏移最后要乘元素宽度 w;下标从 1 起的语言(如 Fortran)得先把各下标减去下界再套公式。int a[3][4] 的 a[i][j] 翻成两级 getelementptr——先按行选 [4 x i32]、再选列,课本的 base+(i*4+j)*w 被结构化进了类型里(LLVM 21)。手算口诀:从最外层下标起「乘本维长度、加下一个下标」层层嵌套,最后一次性乘元素宽度。布尔表达式有两种译法,分野在于:到底要不要真把「真 / 假」这个值算出来。
算出真值,还是直接编成跳转
数值法老实把真假算成 1 和 0,像算术一样求出一个值。控制流法(跳转代码)则根本不算真假,而是直接把「成立跳这、不成立跳那」编成条件跳转——&& 和 || 天然配这种译法,因为它们短路:左边一旦定局,右边就不必再看。if / while / for 则各有一套标号 + goto 的固定翻译模板。控制流法的精髓全在「跳转目标怎么安排」,下面讲透。
a && b 的短路跳转
if (a < b && c < d) S; 控制流法(不算真假,直接跳):
if a < b goto L1 // 左边真,才有必要看右边
goto Lfalse // 左边假 → 短路,整个假
L1: if c < d goto Ltrue
goto Lfalse
Ltrue: (S 的代码)
Lfalse: …
&& 的左假出口直接指向整个表达式的假出口 —— 这就是短路深入理解:if (p && p->x) 能防空指针,全靠短路是「语义」而非「优化」
- 用「控制流法」翻译布尔时,并不真算出 true/false,而是直接把「成立跳哪、不成立跳哪」编成跳转:
&&一旦左边为假就没必要看右边(短路),于是左假的跳转目标直接指向「整式为假」的出口。这里有个关键认知——短路是语言的语义承诺,不是编译器的优化:正因保证左假就不碰右边,你才能安全地写if (p != NULL && p->x),用左侧挡住右侧的空指针解引用;也正因如此,&&(短路)和&(按位、两边都算)在几乎每门语言里都是两个不同的运算符,绝不能混用。把真出口、假出口的去向安排妥当,if/while的翻译就水到渠成——所以说跳转目标的安排才是整个翻译的核心。
a<b && c<d 折成无分支的 and i1——两边都算,因为比较无副作用、as-if 规则放行;右边一旦有解引用或函数调用才必须保住短路。手算翻译时最常见的错是把左假出口接到右操作数上——记「左假=整式假,左真才看右」。icmp 出 i1 再 zext 成 0/1(int y = a<b; 如此);控制流法是 a&&b 在 -O0 下的分支(发行版 clang 需加 -fno-discard-value-names 才能看到这些名字)——左边比较后 br i1 %cmp, label %land.lhs.true, label %if.end,块名前缀 land 即 logical-and(LLVM 21)。一趟就把代码生成完,会撞上一个死结:要生成一条跳转,可它的目标地址还没生成出来。
先记账,后补填
典型场景是 if 的假出口要跳到尚不存在的 else 之后。回填的办法就是「先记账、后补填」:先把跳转目标留空,把这些待填的指令攒进一张列表(truelist / falselist / nextlist),等地址确定了再统一填回去。配套只需三个操作——makelist(i) 新建列表、merge 合并两张列表、backpatch(p, i) 把列表 p 里的空目标一次全填成 i。真正烧脑的是嵌套结构里列表怎么合并、怎么往外传,下面说。
走一遍:留空 → 回填
// 生成 if (a < b) 的跳转时,真 / 假出口地址还不知道 100: if a < b goto ___ // truelist = {100},目标留空 101: goto ___ // falselist = {101},目标留空 ... // 接着往下生成代码 // 等真出口 = 102、假出口 = 200 确定后,一次性填回去 backpatch(truelist, 102) → 100: if a < b goto 102 backpatch(falselist, 200) → 101: goto 200
深入理解:汇编器的「向前跳转」、链接器的重定位,都是这一招
- 一趟生成代码时常撞上「要跳转,却还不知道目标地址在哪」——比如 if 的假出口得跳到尚未生成的 else 之后。回填的办法是:先把目标留空、把这些待填指令攒进一张 list,等地址确定后统一填回去(先记账、月底再对账)。真正烧脑的是嵌套结构里 list 的合并与传递:内层的「下一条去哪」要接到外层出口上,一处接错跳转就飞了。而这套「先留空、后补填」绝非编译独有——它正是汇编器处理「向前跳转到后面才定义的标号」的做法(攒一张 fixup 表,或干脆扫两趟),也正是 §13 链接器做重定位的内核:先在目标文件里给未知地址留占位,等符号解析定了地址再逐一填实。抓住「先记账、后补填」,你就同时看懂了编译、汇编、链接三处的同一道工序。
merge 只合并「欠账名单」、不填任何地址,真正写地址的只有 backpatch——手算最常见的错是合并时顺手把目标填死。也别把回填当唯一解:允许扫两趟的话,第一趟记全标号地址、第二趟直接生成即可;回填是坚持「一趟出码」换来的技巧。M → ε 在动作里记下「下一条指令地址」,N → ε 生成一条待填的 goto——嵌套 if/while 的接线几乎全靠它俩。老实给每条指令编号、把 truelist/falselist 写在产生式旁边,比心算稳得多。运行时环境
生成目标代码前先插一段必修课:程序真正跑起来时,存储怎么组织?活动记录与调用约定、参数传递、非局部访问,以及堆与垃圾回收。这些约定是后面两章(优化、目标代码)落地时必须遵守的地基。
程序一跑起来,内存就按「数据能活多久」分成几块——布局与分配策略,本质都是在回答生命期问题。
按生命期分区,按生命期分配
代码区放指令;静态 / 全局区放整个程序期间都在的数据;栈放随函数调用来去的局部数据;堆放手动申请、生命期不定的动态对象。典型布局里栈从高地址往下长、堆从低地址往上长,中间留白供两者伸缩。对应地有三种分配策略:静态分配(编译期就把地址钉死)、栈式分配(随调用压入弹出)、堆分配(运行时按需申请释放)。为什么这三者恰好对应三种生命期、递归又为什么非用栈不可,看下面。
内存分几块,各放什么
高地址 ┌────────────┐
│ 栈 │ ↓ 向下增长(函数调用来去)
│ ↓ ↑ │
│ 堆 │ ↑ 向上增长(malloc 动态对象)
├────────────┤
│ 静态 / 全局 │ 整个程序期间都在
├────────────┤
│ 代码 │ 指令
低地址 └────────────┘
静态分配 = 地址钉死 / 栈 = 随调用压弹 / 堆 = 按需申请深入理解:「有递归就得有栈」,也意味着「递归太深必然爆栈」
- 三种分配对应三种「生命期」:编译期就固定的(全局变量)静态分配、地址一次钉死;局部变量随调用来去,栈的后进先出恰好契合;递归尤其非用栈不可——同一函数会同时有好几个尚未返回的实例,每个实例的局部变量各占一份,只有栈能为每次调用现开一层、返回再撤掉。而这枚硬币的反面就是那个你一定崩过的错误——栈溢出(stack overflow):栈空间有硬上限(Linux 默认约 8MB、Windows 约 1MB),递归太深或开个巨大的局部数组就撞穿它。这也解释了尾调用优化(TCO)为何重要:当递归调用是函数最后一步,编译器可复用当前栈帧、把递归变成循环、栈不再增长——Scheme 甚至用语言标准强制保证 TCO,于是那里「递归」和「循环」一样廉价。要不要有运行时栈、栈能多深,全由这套生命期机制决定。
int t=a+b; return t*2; 在 gcc -O2 下编出来只剩 addl、leal、ret 三条寄存器指令,栈一次没碰(GCC 15)。「栈式分配」说的是生命期纪律,不保证每个变量真落内存。static int 局部变量作用域在函数内,却住静态区——gcc -S 看得到它进了 .data 段、以 (%rip) 寻址,而普通局部变量是 -4(%rbp) 这样的栈偏移(GCC 15)。一个 static 关键字就把变量搬了区,因为生命期变了。每次函数调用都在栈上现开一块存储——活动记录(栈帧),它就是「一次调用」在运行时的实体。
一次调用需要的一切,都装在这一块里
帧里装着这次调用需要的一切:返回值、实参、保存的机器状态(寄存器)、局部变量与临时变量,还有两条链——控制链(动态链)和访问链(静态链)。两个指针框住它:帧指针 fp 锚定当前帧的固定基准,栈指针 sp 指向栈顶。调用发生时,调用者与被调者按约定分工:一段调用序列压入参数、保存现场,一段返回序列再把这些拆掉、恢复现场。两条链具体各管什么、为什么绝不能混,看下面。
一帧长什么样
高地址 ↑ 实参、返回值 ← 调用者压入 返回地址 ← call 指令自动压入 调用者的 fp ← 控制链:返回时据此还原上一帧 ────────────── ← fp 帧指针,锚定本帧、偏移固定 保存的寄存器 局部变量 临时变量 ────────────── ← sp 栈指针,指向栈顶 低地址 ↓(栈向下增长)
深入理解:调用约定是一纸「ABI 合约」——跨语言调用全靠它
- 函数调用要跨越两段互不相识的代码,双方必须事先约好「谁在调用前后保住哪些寄存器、参数怎么传、返回值放哪」(调用约定),否则被调函数一动手就毁了调用者的数据。把这套约定固定成平台标准,就是 ABI(如 x86-64 的 System V、Windows x64)。它意义深远:正因大家都守同一份 ABI,GCC 编的目标文件才能和 Clang 编的链接到一起,你才能从 C 调 Rust、从 Python 调 C(FFI)、才有稳定的动态库与系统调用接口——跨语言、跨编译器互操作的地基就是这纸合约。栈帧里还有两条别混的链:控制链(动态链)指向「谁调用了我」、用于返回,反映运行时调用路径;访问链(静态链)指向「我定义在谁体内」、用于访问外层变量,反映源码嵌套结构。一个关乎「从哪来」、一个关乎「写在哪」。
- 联动组成原理页:这里的栈帧与调用约定,组成原理页「过程调用:栈帧与调用约定」从硬件与安全(缓冲区溢出、栈金丝雀)视角又走了一遍,操作系统页则从进程/上下文切换的角度看同一块栈——三页对照最透。
-O1 起默认 -fomit-frame-pointer,pushq %rbp 直接消失、rbp 变回普通寄存器,寻址改用 sp 偏移(GCC 15 / LLVM 21)。火焰图截不到完整调用栈时,第一反应就是补 -fno-omit-frame-pointer——课本那张「fp 锚定的帧」是 -O0 的样子。gcc -S -O0 的函数开头 pushq %rbp; movq %rsp,%rbp 就是「保存调用者的 fp(控制链)、立起本帧基准」那两步,之后局部变量全是 -4(%rbp) 这样的固定偏移(GCC 15)——读懂这三行,活动记录图就落了地。实参怎么绑到形参有几种约定,差别其实只在一条轴上:传进去的是「值的一份拷贝」,还是「通往原变量的一条路」。
拷贝,还是共享
值传递(call-by-value) 给拷贝,函数改不动外面的实参。引用传递(call-by-reference) 给路,形参和实参成了同一块存储的两个名字,函数一改、外面跟着变。值-结果(copy-restore) 是「进来先拷一份、返回时再写回去」的折中。名字传递(call-by-name) 最特别:实参每被用到一次就重新求值一次——注意它和「只求值一次并缓存结果」的 call-by-need(惰性求值)不是一回事。抓住「拷贝 vs 共享」这条轴,下面这些差异就都不必硬记了。
改得动外面,还是改不动
void f(int p) { p = 99; } // 值传递
int x = 1; f(x); → x 仍是 1 // 改的是拷贝
void g(int &p) { p = 99; } // 引用传递
int x = 1; g(x); → x 变成 99 // p 与 x 是同一块存储的两个名字
分歧只一条:传「值的拷贝」,还是「通往原变量的路」深入理解:「Java 到底是传值还是传引用」——千古争论的答案
- 各种传参方式的根本分歧只有一句话:传进去的是「值的拷贝」,还是「通往原变量的路」。值传递给拷贝、函数改不动外面;引用传递给路,形参实参成了同一块存储的两个名字(别名),内外都能改、执行顺序还会影响结果。这把标尺正好终结那个千古争论——Java、Python 永远是「传值」,只不过传的那个「值」本身是个引用(指针)(学名 call-by-sharing):所以你能在函数里改对象的字段(顺着那根指针),却改不了调用者手里指向谁(重新赋值只动了拷贝来的指针)。别名还牵出一串工程后果:C 的
restrict关键字就是程序员向编译器承诺「这两个指针不别名」、好让它放心激进优化;Rust 的借用检查器(不许两个可变引用同时存在)本质是把「无别名」变成编译期铁律,于是能做 C 做不到的优化。抓住「拷贝 vs 共享」,这些差异都不必硬记。
四种参数传递的对照
| 方式 | 传过去的是 | 被调用方改形参,调用方看得见吗 | 典型语言 |
|---|---|---|---|
| 传值 | 实参的一份拷贝 | 看不见 | C 的标量、Java/Python 的「变量本身」 |
| 传引用 | 实参的地址(形参就是原变量的别名) | 看得见——赋值直接改到原变量 | C++ 的 T&、C# 的 ref、Pascal 的 var |
| 传共享 | 对象引用的拷贝 | 改对象内部看得见,整体重新赋值看不见 | Java / Python / JS 的对象 |
| 传名 | 整个表达式(用到才求值,且每次都重求) | 看得见,且会有反直觉的副作用 | Algol 60;今天以宏、惰性求值、C# 的 Func<> 存活 |
- 「传共享」这一行解释了那个最经典的争论:Java/Python 到底是传值还是传引用?答案是传的是「引用的值」——所以
list.append(x)调用方看得见,而list = []调用方看不见。争论双方各说对了一半,因为他们用的是只有两个词的词汇表。 - 传名是理解宏和惰性求值的钥匙:参数不是先算好再传,而是把表达式本身传过去、用到才算。经典的 Jensen 设备(用传名实现求和)今天看像魔法,但 C 的宏、Haskell 的惰性、React 的「传一个函数而不是传值」都是同一个机制的后代。
rdi rsi rdx rcx r8 r9,第 7 个起才压栈——编个 7 参函数 gcc -S 一眼可见(GCC 15)。看栈帧图时记住:现代 ABI 里多数实参根本不在帧里。允许函数嵌套定义的语言有个特殊难题:内层函数要访问外层的局部变量,可外层的栈帧每次运行都落在不同位置,地址没法写死。
顺着定义的嵌套关系往外指
访问链(静态链)的解法是:每个栈帧存一根指针,顺着定义时的嵌套关系指向外层的活动记录;要访问外 k 层,就沿链回溯 k 步——回溯步数正好等于嵌套深度差。嵌套越深回溯越慢,于是又有了 display 表:用一个数组直接按层号索引各层活动记录,一步 O(1) 到位。这套机制在什么语言里用得上、又为什么 C 根本用不着,看下面。
静态链:顺定义往外指
g() {
int x; ← 内层要访问它
h() {
k() { 用 x } ← k 定义在 h 里,h 定义在 g 里
}
}
运行时每帧存一根静态链,顺「定义嵌套」往外指:
k 帧 ─静态链→ h 帧 ─静态链→ g 帧(在这找到 x)
访问外 2 层 = 沿链走 2 步。C 不许函数嵌套 ⇒ 用不上这套深入理解:闭包为什么得把捕获的变量搬到堆上
- 嵌套函数要访问外层的局部变量,但外层栈帧每次运行都在不同位置,于是用一条「静态链」顺着定义的嵌套关系往外指:访问外 k 层就顺链回溯 k 步,回溯长度正好等于嵌套深度差。C 里函数不能嵌套,非局部的只剩全局变量(地址固定、直接访问),所以 C 根本用不上这套。但一旦语言允许闭包(把内层函数当值返回或传走),静态链就不够了——外层函数一返回,它的栈帧就被销毁,可闭包还想访问那些变量!这就是经典的 funarg 问题。解法是把被捕获的变量从栈「提升」到堆上单独存(上值 upvalue / 环境),让它的寿命跟着闭包走、而非跟着那个早已返回的栈帧。所以「局部变量在栈上」这个直觉一遇闭包就得打破——这也是带闭包的语言几乎都离不开垃圾回收的深层原因。
gcc -S -O0 能看到调用前把外层帧地址装进 %r10——x86-64 SysV 约定静态链指针就走这个寄存器(GCC 15)。堆管的是那些生命期不守栈规矩的对象——想什么时候建就建、想活多久就活多久。代价是「谁来释放」成了难题。
引用计数 vs 追踪,各有盲区
手动管理容易漏(内存泄漏)或错(悬空指针),于是有了垃圾回收(GC)自动清掉没人再用的对象。主流思路两大类:引用计数给每个对象数「还有几处引用」,归零即回收,简单而即时;追踪式从根出发扫描「还够得着谁」,够不着的即垃圾,包含标记-清扫、标记-整理、复制收集、分代收集等一系列变体。分配本身还要对付内 / 外碎片。各种算法为什么没有一个全能、分别在权衡什么,看下面。
引用计数栽在环上
引用计数:被引用数归 0 就回收
外部 → A,且 A ⇄ B 互相指
初始: A.count=2(外部 + B), B.count=1(来自 A)
删掉外部引用后: A.count=1(B), B.count=1(A) ← 都不为 0!
环里互相撑着,谁都归不了零 —— 循环引用的盲区
追踪式 GC 从根出发扫「还够得着谁」,能识破环,
代价是要在「扫描时暂停(STW)」与「总吞吐」之间权衡深入理解:Python 用引用计数、Java 用追踪、Rust 干脆两者都不用
- 引用计数靠「没人引用就回收」,可两对象互相引用成环时计数永远降不到 0、谁也回收不掉——这是它绕不过的盲区,得靠额外的环检测或可达性追踪来补。追踪式 GC 又要在「扫描时暂停程序(STW)」与「总吞吐」之间权衡:停顿越短用户越无感,但为此引入的并发/增量机制会拖低吞吐。这道权衡撑起了整部 GC 演化史:Python、Swift、C++ 的 shared_ptr 走引用计数(即时、可预测,但有环泄漏和每次增减的开销);Java、Go 走追踪(能收环,但要对付停顿——Java 的 GC 从 CMS 到 G1 再到 ZGC/Shenandoah,一路把最大停顿从「秒级」压到「亚毫秒」);而 Rust 索性两者都不要:用所有权 + RAII 让对象在编译期就定好析构时机、离开作用域立刻释放,把内存管理搬回编译期、运行时零 GC。天下没有免费的自动内存管理,这三条路只是在同一道权衡上选了不同的点。
- 联动操作系统页:这里管的是「对象层」的回收策略,而堆内存本身怎么向下问内核要(brk/mmap)、内外碎片与分配器(伙伴系统/slab)怎么做,见操作系统页内存管理章。
sys.getrefcount 可看计数),环形垃圾靠内置 gc 模块的追踪收集兜底,gc.collect() 就是手动触发一次环检测。记忆钩子:「计数死得即时但怕环,追踪能破环但要扫堆」。代码优化
拿到 §09 的 IR、降到机器码之前,先在不改变语义的前提下把它改得更快更小。基础是基本块、流图与数据流分析,热点循环是重中之重。优化后的 IR 仍是 IR,交给下一章生成目标代码。
优化前得先把线性的指令流整理成有结构的形状——先切出「一路走到底」的段落,再把段落连成图。
先找单入单出的段,再连成图
基本块是一段「一进必一出、中间绝不跳进跳出」的最大指令序列——控制流一旦进了它的头,就必定顺着执行到它的尾。怎么切?找首指令(leader):程序第一条、任何跳转的目标、以及任何跳转的下一条,都是新块的开头。把基本块当结点、块间的控制转移当边,连成的图就是控制流图(CFG),之后所有全局优化都在这张图上做。为什么 leader 规则恰好这么定,看下面。
找 leader,切块,连成 CFG
三地址码(标出 leader): 1: t = 0 ← leader(第一条) 2: if a<b goto 5 3: t = 1 ← leader(跳转的下一条) 4: goto 6 5: t = 2 ← leader(跳转目标 / 也是跳转的下一条) 6: x = t ← leader(跳转目标) 切块 B1={1,2} B2={3,4} B3={5} B4={6} 连边成 CFG: B1→B2、B1→B3、B2→B4、B3→B4
深入理解:CFG 是从代码覆盖率到静态分析的共同底座
- 划分基本块的关键是找准首指令(leader):跳转的目标、以及跳转指令的下一条,都必须另起一块。道理在于基本块要求「单入单出」——若控制流能从中间跳入或跳出,块内指令就不再总是一起顺序执行,后续所有基于块的分析都会失效。而基本块与 CFG 的用处远不止编译优化:它是整个程序分析领域的通用底座。你看到的代码覆盖率报告,量的正是「哪些基本块被执行到了」;静态分析工具(Coverity、clang-analyzer)、反编译器、乃至安全模糊测试(fuzzing)的路径探索,全都先把程序还原成 CFG 再在上面推理。学会「把线性代码切成块、连成图」这一步,等于拿到了几乎所有程序分析工具的入场券。
call 当跳转来切——函数调用会回到原处接着执行,通常不终结基本块。gcc -fdump-tree-cfg=/dev/stdout -c x.c,dump 里的 <bb 2> 就是基本块、goto <bb 5>; 就是 CFG 的边(GCC 15);clang 侧 -S -emit-llvm 输出里每个标号开头的段落即一个基本块。全局优化要靠一些「全局事实」撑腰——这里用的值可能是哪来的、这个变量之后还会不会用。数据流分析就是把这类事实算出来的统一框架。
四要素定死一个分析,迭代到不动点
任何一个数据流问题都由四样东西定死:方向(沿控制流前向、还是逆流后向)、传递函数(一个基本块 gen 出什么、kill 掉什么)、汇合算子(几条路径交汇时取并集还是交集)、边界条件(入口 / 出口的初值)。经典的三个问题——到达定值、活跃变量、可用表达式——各自的四要素配置都不同。求解办法是沿图反复迭代,直到信息不再变化,即到达不动点(fixpoint)。为什么方向和汇合算子不能随意搭配、初值又必须与之匹配,看下面。
四要素定死一个分析
问题 方向 汇合 传递函数 求的是 到达定值 前向 ∪ gen/kill 之前可能定值了什么 活跃变量 后向 ∪ use/def 之后是否还会用到 可用表达式 前向 ∩ gen/kill 每条路径都算过的表达式 沿 CFG 反复迭代,直到信息不再变化 = 不动点
深入理解:它的理论底座是格论,现代静态分析都建在上面
- 方向与汇合算子不是随意搭配的,而由问题语义决定:到达定值关心「之前可能定值了什么」,故前向、用并集(∪)收集所有可能;活跃变量关心「之后是否还会用到」,故后向。汇合用交(∩)= 「所有路径都成立才成立」,用并(∪)= 「有一条路径成立即可」——初值(⊤/⊥)必须与之匹配,否则迭代到的不动点是错的。这套东西背后是一门叫格论(lattice)的数学:正因信息集合构成有限高度的格、传递函数单调,迭代才保证会停且收敛到唯一的最小不动点(Kildall 的单调数据流框架给了这个定理)。把它再抽象一层,就是 抽象解释(abstract interpretation)——用一个「近似的格」模拟程序执行,从而不真跑程序就证明它的性质。今天几乎所有严肃的静态分析器、乃至一些形式化验证工具,内核都是这套「在格上迭代到不动点」。你以为在学一个编译器小技巧,其实摸到了程序分析的地基。
优化按「看多大范围」分层,而范围越大越贵——贵在哪,正是这张卡要说清的。
看得越宽,越离不开数据流分析
局部优化只在单个基本块内做:块内指令必定顺序执行,谁在谁前一目了然,常借一张 DAG 就能消掉冗余。全局优化跨基本块,一条指令的「上一步」可能来自好几个分支、循环还会绕回来,非靠上一张的数据流分析在整张 CFG 上迭代不可。过程间优化(interprocedural) 再跨函数边界,要顾及的路径最多、代价也最高。为什么一跨出基本块就离不开数据流分析,看下面。
块内一眼看穿,跨块要分析
局部(块内) 全局(跨块)
b = a + 1 L1: t = a + 1
c = a + 1 ← 同表达式 L2: …(可能来自多个前驱)
一眼可知 c = b t 从哪来?要沿 CFG 追
块内顺序执行、谁在谁前一目了然;
跨块有分支 / 回边 ⇒ 只能靠数据流分析在整图上迭代深入理解:这条「越全局越贵」的曲线,尽头就是 -flto
- 为什么全局优化非要数据流分析、局部却不用?局部优化困在一个基本块里,指令必定顺序执行、谁在谁前一目了然,直接看(常用 DAG)就够;一旦跨基本块,一条指令的「上一步」可能来自好几个分支、循环还会绕回来,肉眼理不清「值从哪来、之后还用不用」,只能靠数据流分析在整张流图上迭代。跨的边界越大、要顾及的路径越多、越贵——而这条「越全局越贵」的曲线是有实际刻度的:块内 → 函数内(全局)→ 跨函数(过程间)→ 跨整个程序。曲线的尽头就是你在编译选项里见过的 LTO(链接时优化,
-flto):它把各编译单元的中间表示留到链接时汇总,做跨文件的内联与优化——代价是链接慢得多、更吃内存,回报是更快的二进制。你每次纠结「要不要开-flto」,权衡的正是这条曲线上「分析范围 vs 编译开销」的取舍。
a+b、汇合点再算一次」的 IR,early-cse(只沿支配路径看)动不了汇合点那一次,换 gvn(全局值编号)则插入 φ 把它消掉——「范围越大、能证明的事越多」在这里是可以并排看的两个产物。(喂给 opt 之前要先绕开 optnone,办法在 09 章 SSA 卡。)这是编译器的优化武器库,每一件都是「在不改语义的前提下让代码更省」的一次改写。而它们真正的门道,不在单件威力,而在彼此配合。
一箱互相喂球的改写
公共子表达式消除(CSE) 把算过一遍的表达式复用;复制传播、常量传播 / 折叠 把变量替换成它已知的值、能当场算的就当场算;死代码消除(DCE) 删掉永远用不到的计算;代数化简 抹掉 x*1、x+0 这类恒等式;强度削弱 拿便宜运算换贵的(乘法换成加法);还有函数内联、循环展开。它们看似各管一摊,实则互相制造机会——下面这一点,正是优化真正的学问所在。
一步喂下一步
x = 3 常量传播 → y = 3 * 2 y = x * 2 常量折叠 → y = 6 if (y > 100) … 该条件恒假 → 死代码消除,整个分支删掉 一步的产物成了下一步的机会 ⇒ 优化的顺序与轮数本身就是学问
深入理解:优化顺序是个没有最优解的难题
- 这些优化彼此喂球:常量传播把变量换成常量,常量折叠当场算出结果,让某条件恒假、触发死代码消除,又暴露出新的公共子表达式……于是编译器把它们排成一条 pass 流水、反复迭代到再无新机会。但这里藏着一个至今没有通解的难题——相位排序(phase-ordering):A 优化可能既给 B 制造机会、又毁掉 C 的机会,不存在对所有程序都最优的固定顺序,编译器用的全是经验调出来的启发式流水线。还有个反直觉的后果:
-O2常会暴露你在-O0下「一直正常」的 bug——优化器坚信程序里没有未定义行为(UB)、并据此激进改写,把你踩着 UB 侥幸跑通的代码打回原形。所以「一开优化就出错」往往不是编译器的锅,而是它照出了你早已写下的 UB。
int x=1+2; 在前端 GIMPLE 里已是 x = 3,汇编直接 movl $3(GCC 15)——字面量的常量折叠发生在前端。优化器(如 sccp)负责的是跨语句、跨变量的常量「传播」,别把折叠全记在 -O2 头上。x=3; y=x*2; if(y>100)…」形状的 IR 单跑 -passes=sccp,常量一路传进条件、条件跳转被改写成无条件跳转,只剩够不着的死块;再补一趟 simplifycfg,死块消失(LLVM 21;optnone 那道坎见 09 章 SSA 卡)。程序的时间绝大多数花在循环里——循环体跑成千上万遍,省下一条指令就乘以迭代次数,所以循环是优化的头号目标。
把省下的每一条乘以迭代次数
核心几招:循环不变代码外提(LICM) 把每轮都算出同样结果的计算挪到循环外、只算一次;归纳变量识别加强度削弱,把随循环规律变化的量(如 i*4)改成每轮加一个常数;还有循环展开、循环合并 / 分裂、向量化。这些都要先靠支配关系认出「自然循环」和它的回边。其中 LICM 看着简单,却有个绝不能违背的前提,下面讲。
循环不变量外提(LICM)
优化前: LICM 后:
for (i=0; i<n; i++) t = a * b; ← 每轮不变,提到循环外
x[i] = a * b + i; for (i=0; i<n; i++)
x[i] = t + i;
前提:a*b 每轮真的不变、无副作用、不抛异常
否则外提就改变了语义 —— 优化只能更快、不能改意思深入理解:为什么手动把 strlen(s) 提出循环,有时真比编译器强
- 循环优化收益大,因为循环体跑成千上万遍,省一条指令就乘以迭代次数。但把「循环不变」的计算提到循环外(LICM)有个前提:它必须每轮真算出同样的值、无副作用、不抛异常——否则一旦它本不该执行(循环一次没进)或求值顺序被改,结果就变了。这条铁律解释了一个常见现象:编译器常常不敢把
for (i=0; i<strlen(s); i++)里的strlen(s)提出去,因为它无法证明循环体没偷偷改动s(别名问题又来了)——于是这个 O(n) 调用每轮都跑,整个循环退化成 O(n²);你手动把它存进局部变量,反而「帮」编译器守住了那个它不敢假设的前提。这也是为什么内存访问远比寄存器值难优化、为什么const/restrict、少用全局多用局部能让优化器放开手脚。优化的天条是「只能更快、不能改变语义」,编译器对外提的谨慎,正是在死守这条底线。
mul %a, %b 的 .ll,opt-21 -passes=licm -S 后乘法被搬到循环前的 preheader;C 层用 clang -O2 -Rpass=licm,编译器自己汇报「remark: hoisting mul」(LLVM 21)。很多现代优化都建立在同一个结构事实上:支配关系——从入口到 n 的每条路都必经 d,就说 d 支配 n。
一个结构事实,撑起 φ 放置、循环识别与 SSA 优化
把支配关系整理成支配树,再算出支配边界(d 的支配力刚好失效的第一批块),就知道 φ 函数该放哪;自然循环也靠它识别——一条回边的头支配其尾,就圈出一个循环。有了 SSA,这些还能撑起更狠的优化:稀疏条件常量传播(SCCP)、全局值编号(GVN)。不过它们常要先做别名 / 指针分析判断两个指针是否指向同一块内存——分析越准,能安全施展的优化越多。为什么 φ 恰好落在支配边界,下面讲透。
支配边界 = φ 该放的地方
CFG: 1
/ \
2 3 2、3 各给 x 赋一次值
\ /
4 ← 汇合点
1 支配所有点;但 4 不被 2 或 3 单独支配
⇒ 4 落在 2、3 的「支配边界」上(支配力刚失效、版本头回相遇)
⇒ φ 就放在 4: x₃ = φ(x 来自2, x 来自3)深入理解:一个算法让 SSA 从「理论漂亮」变成「人人都用」
- 为什么 φ 恰好放在支配边界?若给 x 定值的块 d 支配块 n,则到 n 的所有路径都必经 d,n 处只看得到 d 那个版本、无须 φ;而支配边界正是 d 的支配力「刚好失效」的第一批块——控制流在那里首次与绕开 d 的路径汇合,不同版本头回相遇,必须放 φ 来挑版本。放更靠前是多余、更靠后会漏,支配边界给出的恰是「最少且够用」的 φ 位置。这不只是漂亮,更是决定性的:正是 Cytron 等人 1991 年基于支配边界的高效 SSA 构造算法(配上 Lengauer–Tarjan 快速求支配树),把 SSA 从「理论优雅但太贵」变成「每个编译器都用得起」,才有了今天 LLVM、GCC 全线拥抱 SSA 的局面。支配关系本身也溢出到编译器之外——控制依赖、程序切片、某些安全分析都建在它上面。一个把「φ 放哪最省」讲清楚的算法,撬动了整个现代优化的基础设施。
-O0 打的 optnone 属性,完整命令在 09 章那张 SSA 卡。opt-21 -passes='print<domtree>' 打印支配树;对菱形 CFG 的 IR 跑 -passes=mem2reg,φ 恰好出现在两臂汇合的那个块(LLVM 21;IR 用 clang 生成时要加 -Xclang -disable-O0-optnone,否则 mem2reg 被 optnone 静默跳过)——正是支配边界给出的位置。目标代码生成
流水线的最后一站:把(优化后的)IR 降为具体目标机的代码。指令选择、寄存器分配(图着色)与指令调度,是决定生成质量的三件事。走完这一站,主线开头那个 position 赋值语句就变成了能在硅上执行的机器码。
流水线的最后一站,把(优化后的)IR 落成某台具体机器的指令。这里有条铁律:正确永远压在性能前面。
IR 加一份机器模型,正确第一
它吃两样输入:IR,以及一份目标机模型——有哪些寄存器、什么指令集、支持哪些寻址方式、每条指令代价多少。目标是既正确又高效、还得会利用机器的特长。这一步高度机器相关:换一种指令集、一套调用约定、一个内存模型,代码生成就得重写——这正是「后端依赖目标机」那条界线的落点。为什么这一步里正确性要死死压在性能之前,看下面。
IR + 机器模型 → 汇编
输入 IR: t = a + b
目标机模型: 寄存器 r0–r15、加法 add、各指令代价…
↓ 指令选择 + 寄存器分配
输出汇编: add r1, r2, r3 (a→r2, b→r3, t→r1)
高度机器相关:换 ISA / 调用约定 / 内存模型就得重写后端
先保证每种 IR 都译得分毫不差,再谈榨性能深入理解:为什么会有「被数学证明正确」的编译器(CompCert)
- 代码生成为什么把正确性摆在优化前面?因为它是最贴近机器的一环,一旦选错指令、丢了寄存器,程序就直接跑错,而且这类 bug 藏在生成的机器码里、极难调试——你的源码明明对的、产物却错,几乎无从下手。相比之下优化只是锦上添花,再快的错误代码也一文不值。正因编译器 bug 后果如此严重,「怎么保证编译器自己没错」成了一个专门的研究领域:Csmith 用随机生成的 C 程序「模糊测试」编译器,在 GCC、LLVM 里挖出过数百个此前没人发现的 wrong-code 编译器 bug(多在中端优化器);CompCert 更极端——它是一个用 Coq 形式化证明过「生成的汇编与源程序语义等价」的 C 编译器,用在航空这类容不得半点差错的领域。这一步的信条因此是:先保证每种 IR 结构都翻译得分毫不差,再谈榨性能。
llc -O0 生成 80 次栈访存、-O2 只剩 30 次(LLVM 21)。抱着「后端只是照抄」的直觉,你会读不懂 -O0 与 -O2 汇编之间的巨大差距。clang -S -emit-llvm 拿到 IR,再 llc x.ll -o - 看它降成汇编;同一份 IR 换 llc -mtriple=aarch64 就换了个后端——「后端依赖目标机」一条命令可见;RISC-V 侧要用 clang --target=riscv64 -S -emit-llvm 重出 IR 再 llc(x86 IR 里钉着的 target-cpu 属性会让 RV64 后端直接报错)。结果:a+b*4 在 x86 是一条 lea,RISC-V 变 slli+addw 两条(LLVM 21)。同一个 IR 操作,目标机往往有好几种指令都能实现、代价还各不相同。指令选择就是从这些覆盖方式里挑总代价最小的一种。
用最小代价盖住整棵 IR 树
最朴素的做法是「一个 IR 结点配一条指令」的模板映射;讲究点的把 IR 看成树、用树覆盖——一条复杂指令能一口吃掉一整棵子树。挑法上,最大吞噬(maximal munch) 每步尽量吃最大的一块(和词法分析的「最长匹配」同名同源,只是这里吃的是 IR 子树),而用动态规划能求出全局最小代价、而非只顾眼前最贪。工具化的方案有 BURS(自底向上重写系统)/ 树文法。为什么值得为「挑哪条指令」大费周章,看下面。
同一操作,挑最省的覆盖
IR: t = a + b*4 同一操作,几种目标指令:
方案 A(3 条) 方案 B(1 条,x86 支持)
mul t1, b, 4 lea t, [a + b*4]
add t, a, t1 ← 一条复杂寻址指令顶三条
用动态规划挑「总代价最小」的覆盖,而非只贪眼前
(与词法「最长匹配」同源,只是这里吃的是 IR 子树)深入理解:LLVM 的指令选择器,是从一份「机器说明书」自动生成的
- 同一个 IR 操作往往有好几种目标指令能实现,各自的周期数、访存次数不同,还常有「一条复杂指令顶几条简单指令」的情况;指令选择就是在这些覆盖里挑总代价最小的一种。它和词法里的最长匹配同源——都是「每步尽量吃掉最大的一块」,只不过这里吃的是 IR 树的子树(学名树覆盖 / 树铺砌 tree tiling),用动态规划能求全局最小代价而非只顾眼前。工业界更进一步:与其为每种芯片手写这个匹配器,不如从一份声明式的「机器描述」自动生成它——LLVM 的 TableGen(
.td文件)正是干这个的:你用一种 DSL 描述目标机有哪些指令、各自的模式和代价,工具自动吐出指令选择器。这就是「给 LLVM 加一个新后端,大半工作是写描述、而非写代码」的原因,也是第 02 章「后端只依赖目标机」落到实处的样子。
int f(int a,int b){return a+b*4;} 用 gcc/clang -O2 -S 编,真能看到 leal (%rdi,%rsi,4), %eax 一条顶三条(GCC 15 / LLVM 21);同一个函数交给 RISC-V 后端(clang --target=riscv64)则出 slli+addw——没有缩放寻址的 ISA,最优覆盖就完全不同。IR 里可以随便用无数个「虚拟寄存器」,真机器却只有十几个物理寄存器——寄存器分配就是把前者塞进后者。
塞得下就着色,塞不下就溢出
经典办法是图着色:把「同时活跃、不能共用一个寄存器」的变量两两连边,得到干扰图;给它做 k 着色(相邻不同色)恰好对应用 k 个物理寄存器互不冲突地装下所有变量。装不下时就得溢出(spilling)——把某个变量赶回内存、用时再读。常用算法有 Chaitin / Briggs(图着色系)和 JIT 偏爱的线性扫描(linear scan),它们都以活跃变量 / 活跃区间分析为前提。为什么这问题能化成图着色、以及溢出该挑谁最不亏,看下面。
干扰图 k 着色
变量活跃区间相交 → 干扰图连边:
a ─── b a、b、c 两两同时活跃
╲ ╱ 2 色(=2 个寄存器)染不了 → 需要 3 色
c
k 着色成功 = k 个寄存器够用;失败 → 溢出一个到内存
溢出谁?别挑循环里反复用的 —— 热路径上反复读写内存最亏深入理解:为什么优化后 gdb 里变量成了 <optimized out>
- 把「同时活跃、不能共用寄存器」的变量两两连边成干扰图,给它 k 着色恰好对应 k 个物理寄存器——寄存器分配就这样变成了图着色。图着色是 NP 难的,实际全靠启发式;着色失败就得溢出(spill)一个变量回内存。选谁溢出是性能生死线:溢出循环里的热变量,等于在热路径上反复读写内存,能让一段代码慢上数倍。而这一步也正是调试之痛的根源——release 版里一个变量可能自始至终只活在某个寄存器、甚至被整个优化掉,内存里根本没有它的落脚处,于是 gdb 只能显示
<optimized out>。「debug 版好调、release 版难调」,一大半就出在这一步。 - 联动组成原理页:寄存器为什么快到「不占额外周期」、为什么物理上只有十几个、乱序 CPU 又如何用几百个物理寄存器重命名绕开这个限制,见组成原理页指令集与微架构章。
llc --regalloc=fast 与 --regalloc=greedy 各编一遍、数栈访存条数——16 参数的搅拌函数上前者 72 次、后者 30 次(LLVM 21);fast 正是 -O0 用的快速分配器,greedy 是 -O2 默认。手推分配的顺序口诀:活跃分析 → 建干扰图 → k 着色 → 染不动再溢出。临到最后还有两招压榨性能,路子截然不同:一个靠重排,一个靠替换。
一个重排,一个替换
指令调度靠重排:现代 CPU 流水线执行,若后一条指令要等前一条的结果就会停顿(stall);调度器在不破坏依赖的前提下调换指令顺序,把互不相干的指令填进这些空档,常用列表调度。窥孔优化靠替换:拿一个小滑动窗口在代码上滑过,一认出「冗余加载、可化简的跳转、能强度削弱的运算」这类固定坏味道,就地换成更省的写法——看不到全局,却便宜又有效。为什么重排的自由度是被数据依赖卡死的,看下面。
重排绕停顿,窥孔换烂招
指令调度(重排绕开停顿): load r1,[x] load r1,[x] ← load 后立刻用 r1 会停顿 add r2,r1,1 → mul r3,r4,r5 ← 插一条无关指令填空档 mul r3,r4,r5 add r2,r1,1 有数据依赖的两条不能换序,否则算错 窥孔(小窗口就地替换): mov r1,r2; mov r2,r1 → mov r1,r2 // 删冗余
深入理解:乱序 CPU 会自己重排指令,那编译器调度还有用吗
- 指令调度想靠重排让流水线不停顿,但不能乱排:有数据依赖的指令(后一条要用前一条的结果)必须保持先后,否则结果就错——调度的自由度正是被这些依赖约束住的。这里有个现代人常问的问题:既然 x86、大核 ARM 这些乱序超标量(out-of-order)CPU 会在硬件里动态重排指令,编译器还费劲调度干嘛?答案是:一来大量顺序执行(in-order)的芯片(很多嵌入式、DSP、GPU 通道)没有硬件乱序,全靠编译器;二来即便在乱序 CPU 上,编译器调度仍能减小寄存器压力、帮硬件的指令窗口更好地挖并行——两级调度互补而非替代。窥孔优化则走另一条路:只在一个小滑动窗口里认出「冗余加载、可化简的跳转」这类坏味道、就地替换;把它推到极致就是超优化(superoptimization)——穷举搜索出实现同一功能的最短指令序列,真有工具在用。一个靠重排、一个靠替换,都是最后一公里的榨取。
xorl %eax,%eax 而非 movl $0,%eax——llvm-mc --show-encoding 可见前者 2 字节、后者 5 字节(GCC 15 / LLVM 21)。汇编里看到「怪写法」,先想想是不是窥孔替换留下的省字节/断依赖习语。现代编译技术
前面十章走完了从字符流到机器码的经典线性流程;本章超越经典教材(俗称「龙书」的《编译原理》):LLVM 把前面的 IR / 优化 / 后端做成可复用的模块化基础设施,再看 JIT/AOT、画像引导优化,以及链接加载与构造工具。
LLVM 把第 02 章「前中后端解耦」那套思想,做成了可复用的工业级基础设施。理解它,就是看这个思想如何落地。
统一 IR + 可插拔 Pass
它是标准的三段式:各种前端(Clang 等)都把源码降到同一种 LLVM IR,中端在 IR 上优化,后端再降到各目标机。这套 IR 强类型、SSA 形式,有可读文本 / 位码 / 内存三种等价形态。优化被拆成一个个可插拔的 Pass(分析 Pass 与变换 Pass),随意组合成流水线。于是多种前端、多种后端共享同一套中端优化——几百个 Pass 谁都能复用,任意前端配任意后端。它真正的厉害之处不在某个算法,下面点破。
统一 IR,任意前后端拼装
Clang(C) ─┐ ┌─▶ x86 后端 Rust ─┼─▶ LLVM IR ─▶ 一堆 Pass ─┼─▶ ARM 后端 Swift ─┘ (强类型 / SSA) 可插拔组合 └─▶ RISC-V 后端 几百个分析 / 变换 Pass,任意前端配任意后端都能复用 厉害的不是某个算法,而是「统一 IR + 可拼装 Pass」这套架构
深入理解:近十五年的新语言大爆发,一半要记在 LLVM 账上
- LLVM 的强大不在某个算法,而在「一套统一 IR + 可自由拼装的 Pass」这套工程架构:所有前端都降到同一种 SSA 形式的 LLVM IR,于是几百个优化/分析 Pass 谁都能复用、任意前端配任意后端。这套架构的历史影响巨大——Rust、Swift、Julia、Clang、Zig、Crystal 之所以能在短短十几年里接连成型,正因它们只需写个前端、就白拿了 LLVM 整条优化与多平台后端;连 AddressSanitizer、UBSan 这些查内存错/未定义行为的利器,本质都是往这条 Pass 流水线里插的插桩变换 Pass。所以看懂 LLVM 的重点不是背某个 Pass,而是体会「统一 IR 让优化与源语言、与目标机彻底解耦」——它是第 02 章「前中后端解耦」思想的工业级兑现,也是这一整页理论真正开花结果的地方。
clang -emit-llvm 吐出的 IR 开头几行就钉着 target datalayout 与 target triple(LLVM 21),类型宽度、对齐、ABI 都已按目标机定死;它是「统一的中间语言」,不是 Java 字节码那种可移植分发格式。clang -S -emit-llvm -Xclang -disable-O0-optnone(前端出 IR;那个旗标的来由见 09 章 SSA 卡)→ opt -passes=mem2reg,instcombine -S(中端逐个 pass 优化)→ llc(后端出汇编)。opt --print-passes 能列出全部可用 pass,且每个层级都把 passes(变换)与 analyses(分析)分开列出(LLVM 21,共 500+ 条目)。还记得第 02 章那根「翻译时机」轴?JIT 和 AOT 就是它的两端,差别只在把编译放在运行时还是部署前。
运行时编译 vs 提前编译
AOT(提前编译)在部署前把一切编好(GraalVM native-image、Go),运行时零编译开销。JIT(即时编译)把编译推到运行时:先解释字节码,再把反复执行的热点临时编成机器码(JVM HotSpot、V8、.NET)。实践中常用分层编译——解释器加多级 JIT 协作,冷代码省心、热代码逐层提速;按「编译单位」又分 tracing JIT(编热路径)与 method JIT(编整个方法)。JIT 有个 AOT 天生拿不到的本钱,下面讲。
AOT 提前编,JIT 边跑边赌
AOT:部署前编好 ──────▶ 运行(零编译开销) gcc / go JIT:先解释 ─(此处总是 int?)▶ 赌是 int,编快路径 V8 / HotSpot 赌错 ─deopt→ 退回慢路径重来 只有运行时才看得到「程序真跑起来的样子」 所以 JIT 敢做 AOT 做不了的投机优化 —— 这就是它的本钱
深入理解:「JIT 预热」和 Serverless 冷启动,都是这套做法的账单
- JIT 相比 AOT 的独门优势,是它能看到「程序真跑起来的样子」:哪个分支常走、某个多态调用实际总是同一个类型。于是它敢做投机优化——赌「这里八成是 int」直接编快路径,万一赌错再「去优化(deopt)」退回慢路径重来。这种「先赌、错了回滚」只有运行时才玩得转,正是 V8、HotSpot 快的关键,有时长跑的 JVM 甚至能超过等价的 AOT 二进制(因为它能按真实类型把虚调用内联掉)。但这套做法要还账:程序刚启动时还没采到画像、跑在解释或低级编译档,得「预热(warmup)」一阵才提速——这就是为什么 JVM 基准测试必须先跑几千轮再计时,也是 Serverless 冷启动慢的元凶之一(函数被回收后每次都要重新预热)。GraalVM 的 native-image 之所以存在,正是想用 AOT 换掉这段预热、拿冷启动速度。运行时编译的红利与开销,是同一枚硬币的两面。
有些优化光靠静态分析下不了手,得引入额外信息、或搬出更重的数学。
用真实运行画像,或更重的数学
PGO(画像引导优化)先拿真实负载跑一遍,用采样或插桩收集一份运行画像——哪条分支热、哪个函数值得内联、代码该怎么摆放——再据此编译。多面体模型(polyhedral) 则用一套精确的数学刻画循环嵌套的依赖关系,支撑激进的循环变换。再往上还有自动向量化(SIMD) 和自动并行化。PGO 的成败全系于一个前提,下面点破,稍不留神它会帮倒忙。
拿真实负载指导编译
① 插桩编译 → ② 拿真实负载跑一遍,采集画像
(哪条分支热、哪个函数值得内联、代码怎么摆)
③ 按画像重新编译 → 热路径内联、代码紧凑排布
成败全看:采画像用的输入,像不像日后真实负载?
拿玩具输入采的画像,可能把冷路径当热路径 —— 帮倒忙深入理解:PGO 把「先测量再优化」自动化了——但画像会过期
- PGO 把 JIT 那套「用运行信息指导优化」搬到了 AOT:先拿真实负载跑一遍、采集哪条分支热、哪个函数值得内联,再据此编译。它其实是把每个程序员都听过的老话"先测量、再优化(别过早优化)"自动化、规模化了——只不过测量的是分支与调用频率,优化的是内联、代码布局、冷热分区。成败全系于一个前提:采画像用的输入,像不像日后真实负载?拿玩具输入跑出的画像去指导生产,可能把冷路径当热路径优化、帮倒忙;代表性不足的画像有时比没有还危险。工程上还有个绕不开的痛点——画像会过期:代码一改旧画像就对不上了。于是 Google 搞出 AutoFDO:直接用
perf采样生产环境(无需插桩、开销极低),让画像始终贴着真实流量,在整个机群规模上持续喂给编译器。
a[i]=b[i]*2.0f 循环报 missed、并不向量化,-O3 才向量化——且因「指针可能别名」还得额外生成带运行时检查的版本(输出「loop versioned for vectorization because of possible aliasing」)。成没成别猜,用 -fopt-info-vec / -Rpass=loop-vectorize 看报告。gcc -O2 -fprofile-generate 编译 → 跑真实负载(生成 *.gcda)→ 换 -fprofile-use 重编。Clang 对应 -fprofile-instr-generate + llvm-profdata merge + -fprofile-instr-use(LLVM 21 跑通全程)。各个源文件是分开编译的,最后总得有人把这一堆目标文件缝成一个能跑的整体——这是链接器和加载器的活儿。
对上号,填实地址,装入内存
目标文件按平台有 ELF(可执行与可链接格式,Linux)/ PE(Windows)/ Mach-O(macOS)等格式。链接器做两件事:符号解析(谁定义了 foo、谁引用了 foo,一一对上号)和重定位(把地址填实)。链接分两种:静态链接把库直接并进可执行文件;动态链接把库留到运行时再加载、让多个程序共享一份。最后加载器把成品装入内存、做最终的地址重定位(还含 ASLR 地址空间布局随机化)。两类经典报错从何而来、动态库到底在权衡什么,看下面。
对上号,填地址
a.o:定义 foo、引用 bar 链接器两件事: b.o:定义 bar、引用 foo ① 符号解析:foo / bar 对上号 ───链接───▶ a.out ② 重定位:把地址填实 两类经典报错都源于「对不上号」: 没人定义 → undefined reference(未定义符号) 多人定义 → duplicate symbol(重复符号) 动态库把对号推迟到运行时:省内存,却引入「版本对不上」
深入理解:「undefined reference」和「DLL hell」,都出在这一步
- 链接的活儿是把各自编译的目标文件拼成整体:谁定义了 foo、谁引用了 foo 得对上号(符号解析),再把地址填实(重定位)。C/C++ 程序员咒骂过无数次的两条错误正源于「对不上号」:
undefined reference to …(没人定义)、multiple definition of …(多人定义)。动态库把这道对号推迟到运行时,好处是多个程序共享一份库、还能单独打安全补丁,坏处是引入了臭名昭著的 "DLL hell" /.so版本地狱:程序跑起来才发现找不到库、或库的 ABI 变了导致行为诡异——只在运行时才炸。这也是为什么静态链接近年在「部署」场景强势回归:Go 默认静态链接、musl + Alpine、Rust 的 musl target,都为把一切打进一个自包含单文件,用磁盘和内存换「拷过去就能跑、不看运维脸色」的确定性。静态 vs 动态,本质是「省内存 / 易升级」与「部署确定性」之间的权衡。 - 联动操作系统页:目标文件被加载器映射进虚拟地址空间、动态库的运行时符号绑定(PLT/GOT)、以及 ASLR 到底随机化了什么,都在操作系统页与组成原理页的虚拟内存章展开——链接器交棒给加载器的那一刻,正是本页与那两页的接缝。
静态库 vs 动态库
| 静态库 .a / .lib | 动态库 .so / .dll / .dylib | |
|---|---|---|
| 什么时候合进来 | 链接期——代码被拷进可执行文件 | 加载期(或运行期 dlopen) |
| 产物体积 | 大(每个程序各带一份) | 小(多个程序共享同一份) |
| 内存占用 | 各自一份 | 代码页可被多进程共享 |
| 修 bug 要不要重新编译使用者 | 要(重新链接) | 不要(换掉 .so 即可) |
| 部署 | 简单——一个文件拷走就能跑 | 要保证目标机上有对应版本的库 |
| 失败长什么样 | 链接期 undefined reference | 运行期 error while loading shared libraries |
- 最值得记的是最后一行:静态链接把问题暴露在构建时,动态链接把问题推迟到用户机器上。这就是「依赖地狱」的技术根源,也是 Go 默认静态链接、Docker 镜像流行、以及近年 Rust/Zig 强调静态产物的共同动机——用体积换「构建成功就一定跑得起来」。
- 动态库还有一个静态库没有的能力:运行期按需加载(
dlopen)。插件系统、数据库的扩展、浏览器的编解码器全靠它——程序可以加载编译时根本不知道存在的代码。
gcc -c 一声不吭就过(编译只看声明),拼装时才炸出 undefined reference to `foo'(GCC 15/ld)。同族错误:以为 #include 就把库「链进来」了——头文件只给声明,库本体还得用 -l 告诉链接器。nm x.o 看符号表(U=引用了但未定义、T=定义在代码段,GCC 15),ldd 看依赖哪些动态库,readelf -h 看 ELF 头。报 undefined reference 时用 nm 在各 .o/.a 里搜「谁该定义它」,比瞪着链接命令行快得多。前面十一章的算法,实践中大多有现成工具替你生成,不必手搓——而选哪个工具,往往就是在重走书里的某道理论取舍。
把枯燥的部分交给生成器
词法交给 Lex / Flex:写好正则规则,它吐出扫描器。语法有两大生成器,正好对应两种分析范式——Yacc / Bison 走自底向上的 LALR(1),ANTLR 走自顶向下的 ALL(*)。更大的框架如 LLVM、GCC、MLIR 则把后端整套基础设施都备好了。当然也可以手写前端,换来更好的报错和掌控——手写 vs 生成器,各有取舍。而选 Bison 还是 ANTLR,本质上又是在重走那道 LL vs LR 的老题,下面说。
Bison 还是 ANTLR:又是 LL vs LR
词法 Lex / Flex → 语法生成器 → 分析器代码 Bison 自底向上 LALR(1) 文法罩得广,冲突报告较难读 ANTLR 自顶向下 ALL(*) 生成的像手写递归下降,报错友好 选哪个 = 重走「LL 好读 vs LR 更强」这道取舍 理论最终决定了你用起来的手感
深入理解:如今的真相——手写递归下降赢了,tree-sitter 又改了游戏
- Bison(自底向上 LALR(1),罩得广但移进-归约冲突难读)与 ANTLR(自顶向下 ALL(*),生成的像手写递归下降一样好读、报错友好,只有间接左递归才需另作处理(直接左递归会自动改写))之争,本质是重走「LL 好读 vs LR 更强」这道理论取舍。但要讲透「当下真相」还得补两句:如 §05 所说,工业级编译器前端大多回归手写递归下降,生成器不再是唯一答案;而真正改变游戏的是 tree-sitter——它做增量解析(你敲一个字符只重解析受影响的一小片),又能容错地解析「写到一半、语法还不完整」的代码,正好戳中编辑器的刚需,如今 GitHub 高亮、Neovim、众多 IDE 都建在它上面。配合 LSP(语言服务器协议),「解析 → AST → 分析」这条老流水线被搬进了每一个编辑器窗口。选什么工具,早已不只是「LL 还是 LR」,而是「批处理编译,还是伺候一个每秒都在改的活文档」。
界面 DSL 的编译:模板、JSX 与样式
前面十三章的流水线不只用来编译 C。今天前端框架里跑得最多的编译器,编的是界面:Vue 模板、JSX、Svelte 组件、Tailwind 类名。这一章拿真实的编译产物做样本,看它们如何复用同一套词法—语法—IR—代码生成,以及编译期分析在这里换来了什么。
「模板」听起来像字符串替换,实际上是一门小语言:它有语法、有作用域、有类型(哪些是表达式、哪些是静态文本),也就必然要走完整的前端流水线。把它当编译器看,前面十三章的每个阶段都能一一对上。
阶段对应表
| 本页的阶段 | 在界面 DSL 里的样子 |
|---|---|
| 词法分析 | 把 <div :class="a">{{ n }}</div> 切成标签、属性、插值等记号 |
| 语法分析 | 建出元素树(AST),插值里的表达式再交给一个 JS 表达式解析器 |
| 语义分析 | 解析变量来自哪个作用域(组件状态、v-for 的局部变量、插槽参数) |
| 中间表示与优化 | 标记哪些节点是静态的、哪些属性会变,做静态提升与常量折叠 |
| 代码生成 | 产出一个 render 函数(Vue)、一串 jsx() 调用(React)、或直接产出 DOM 操作指令(Svelte) |
为什么值得编译,而不是运行时解释
- 静态可分析:模板里绝大多数结构在编译期就确定了,把「每次渲染都要重新判断的事」提前算完,是最典型的编译期收益;
- 产物更小:不需要把模板解析器打包进浏览器(Vue 的「运行时 + 编译器」版本比「仅运行时」大一截);
- 错误提前:拼错标签名、用了不存在的指令,在构建时就能报,而不是等用户点进那个页面。
三种目标形态
- 编译成「描述」(Vue / React):产出一棵虚拟节点树,运行时再 diff 后落到 DOM;
- 编译成「指令」(Svelte / Solid):直接产出「创建这个节点、把这个文本节点更新成 x」的命令序列,运行时没有 diff;
- 编译成「样式表」(Tailwind / 零运行时 CSS-in-JS):产物根本不是 JS,是 CSS 文件;
- 三者的取舍在「运行时携带多少通用能力」:描述式通用但要带 diff 引擎,指令式产物直接但每个组件的代码更长。
// 同一段界面,三种编译目标的产物形态(示意)
// ① 编译成描述:产出虚拟节点,运行时 diff
render() { return h("div", { class: "card" }, [h("h1", title)]); }
// ② 编译成指令:产出创建与更新两个函数,没有 diff
function create() { div = element("div"); h1 = element("h1"); t = text(title); }
function update(changed) { if (changed.title) set_data(t, title); }
// ③ 编译成样式表:产物是 CSS,不是 JS
.card { border-radius: .5rem; padding: 1rem; }v-html / dangerouslySetInnerHTML)完全绕过它,既没有语法检查也没有转义。这类 API 的输入如果来自用户,就是标准的 XSS 入口。编译期的保护只覆盖编译期能看到的东西——这条对任何语言的任何编译器都成立。_cache 与 _hoisted 在干的事。下面是本机用 @vue/compiler-dom 3.5.40 真跑出来的产物(输入是一段四个节点的模板)。三处编译期分析的痕迹清晰可见,而它们恰好对应本页第 08~10 章讲的那类优化思路。
产物里的三个记号
_hoisted_1 = { class: "card" }:静态的属性对象被提升到 render 函数外面,每次渲染复用同一个对象,不再重新分配——这就是常量提升在界面上的样子;_cache[0] || (_cache[0] = ...):纯静态的<p>静态文本</p>整个节点被缓存,第一次渲染后就不再创建,注释里标着-1 /* CACHED */;- patch flag:动态节点带上一个位标记——只有文本会变标
1 /* TEXT */,文本和 class 都会变标3 /* TEXT, CLASS */。运行时 diff 时看这个数字就知道该比什么,不必逐个属性比对。
这是一种「把分析结果编码进产物」的手法
- patch flag 本质上是编译期算出的数据流信息,用位掩码编码后传给运行时——和本页 09 章讲的活跃变量分析、10 章讲的把分析结果附在 IR 上,是同一套思路;
- 收益也一样:把「每次都要重新判断」变成「一次判断、多次使用」;
- 代价是运行时与编译器强耦合:版本不匹配时产物里的标记含义可能变,这就是「模板编译器版本必须和运行时一致」这条要求的由来。
顺带解释一个常见困惑
- Vue 有「完整版」与「仅运行时版」两种构建:前者把模板编译器打包进浏览器,能在运行时编译字符串模板;后者只有运行时,模板必须在构建期编译好;
- 用
.vue单文件组件时走的是后者——这也是「为什么我传给template选项的字符串报错说编译器不可用」的原因。
// 输入模板
<div class="card"><h1>{{ title }}</h1><p>静态文本</p><span :class="cls">{{ n }}</span></div>
// @vue/compiler-dom 3.5.40 的实际产物(本机跑出来的,未删减)
const _hoisted_1 = { class: "card" } // ① 静态属性提升到外面
export function render(_ctx, _cache) {
return (_openBlock(), _createElementBlock("div", _hoisted_1, [
_createElementVNode("h1", null, _toDisplayString(_ctx.title), 1 /* TEXT */),
_cache[0] || (_cache[0] = _createElementVNode("p", null, "静态文本", -1 /* CACHED */)),
_createElementVNode("span", {
class: _normalizeClass(_ctx.cls)
}, _toDisplayString(_ctx.n), 3 /* TEXT, CLASS */) // ③ patch flag 是位掩码
]))
}key、或者把整块内容塞进一个动态组件/插槽——编译器一旦无法确定结构,就只能退回「全部当动态处理」。判据不是猜,而是把产物打出来看 _hoisted / _cache 还在不在。这与本页优化章的老结论一致:优化失效通常不是因为优化器弱,而是因为你写的代码让分析无法收敛。npm i @vue/compiler-dom 之后三行就能打印产物(compile(tpl, { mode: "module" }).code)。把自己写的复杂模板扔进去看一眼,是理解「为什么这段渲染慢」的最快办法——如果产物里几乎没有 _cache 和 _hoisted,说明你的模板里几乎没有静态部分可提。JSX 的编译比模板简单得多——它只是把一种表达式语法翻译成函数调用,没有作用域分析、没有静态提升。下面两段是本机用 esbuild 0.28.1 对同一段源码做的两种转换,差别一目了然。
两种运行时约定
- classic(老写法):编译成
React.createElement(...),因此每个用了 JSX 的文件都必须自己import React——这是很多老代码顶部那行 import 的唯一用途; - automatic(今天的默认):编译器自己在文件顶部插入
import { jsx, jsxs } from "react/jsx-runtime",你不用再手动 import React;同时它把「单个子节点」与「多个子节点」分成jsx/jsxs两个函数,让运行时少做一次判断; - 产物里的
/* @__PURE__ */注释是给打包器看的:告诉它这个调用没有副作用,如果结果没人用就可以整段删掉——这是编译器与打包器之间的一个约定协议,也是 tree-shaking 能删掉未使用组件的原因。
JSX 不做的事(常见误解)
- 不做类型检查:那是 TypeScript 的活,esbuild 这类工具只剥语法不查类型;
- 不做静态提升:
<p>hi</p>每次渲染都会重新创建一个对象——这正是 React 侧需要memo与 React Compiler 的原因,而 Vue 在模板编译期就顺手做掉了; - 不认识组件语义:大写开头当变量、小写开头当字符串标签,仅此而已。
一个能自己验证的判据
- 任何「这段 JSX 到底编译成什么」的疑问,三行代码就能验(见下方 code);
- 同样的办法适用于 TS 的装饰器、可选链、模板字符串——凡是「语法糖」,都可以用一次转换看清它的真身。这是本页开头讲的「脱糖」在日常工作里最实用的形态。
// 源码
const App = () => <div className="card" onClick={fn}><h1>{title}</h1><p>hi</p></div>;
// esbuild 0.28.1,jsx: "automatic"(今天的默认)—— 产物
import { jsx, jsxs } from "react/jsx-runtime";
const App = () => /* @__PURE__ */ jsxs("div", { className: "card", onClick: fn, children: [
/* @__PURE__ */ jsx("h1", { children: title }),
/* @__PURE__ */ jsx("p", { children: "hi" })
] });
// jsx: "transform"(classic)—— 需要作用域里有 React
const App = () => /* @__PURE__ */ React.createElement("div", { className: "card", onClick: fn },
/* @__PURE__ */ React.createElement("h1", null, title),
/* @__PURE__ */ React.createElement("p", null, "hi"));
// 自己验:三行
// const esbuild = require("esbuild");
// esbuild.transformSync(src, { loader: "jsx", jsx: "automatic" }).codeh()、Solid 编到细粒度的 DOM 操作(因此 Solid 的 JSX 没有虚拟 DOM)、Vue 也有 JSX 插件。看一段 JSX 的实际行为,必须先知道它编译到了什么——同一段 <div>{count()}</div> 在 React 里是「整个组件重跑」,在 Solid 里是「只更新那个文本节点」。/* @__PURE__ */ 这个注释值得单独记住:它是编译器写给打包器的一句「这里可以放心删」。自己写库时,把工厂函数调用标上它,能让使用者的产物小一圈。反过来,如果你的代码在顶层做了有副作用的调用却被标成 PURE,打包器会把它删掉——于是出现「本地好好的,打包后某个注册逻辑没执行」这类幽灵问题。最后一类界面编译器的产物不是 JS 而是 CSS。它们解决的是同一个老问题:全局命名空间与「用不到的规则」——而解法全都发生在构建期。
三种做法
- ① 扫描生成(Tailwind):构建时把源码当文本扫一遍,找出所有出现过的类名,只生成这些类的规则。它不是一个 CSS 框架,而是一个以源码为输入的代码生成器;
- ② 作用域哈希(CSS Modules / Vue scoped):把
.title改写成.title_a3f9x(或加上属性选择器),同时把 JS 里的引用一起改。这是一次重命名 + 引用更新,等价于编译器里的符号重整; - ③ 编译期提取(vanilla-extract / Panda / StyleX):你用 TypeScript 写样式,构建时求值并抽成静态 CSS 文件,运行时一行样式代码都不剩。
扫描式生成的固有限制
- 它靠文本匹配而不是求值,所以拼出来的类名它看不见:
"text-" + color在产物里根本不会生成对应规则; - 这不是 bug 而是设计——要在构建期知道所有类名,就不能依赖运行时才确定的值;
- 正确写法是把完整类名写成常量映射,让扫描器能原样看到它们。这条限制与本页讲的「静态分析的边界」是同一件事:分析器只能看见语法层面确定的东西。
为什么这些方案这几年集体赢了
- 服务端渲染与流式输出让「运行时插入样式」变得别扭:样式要跟着 HTML 一起先到,运行时方案得额外做提取;
- 构建期产出的 CSS 是纯静态文件:能被 CDN 缓存、能并行下载、不占用主线程;
- 代价是动态性受限:真正随用户输入变化的样式(比如用户自选主题色)只能走 CSS 变量,而不能在构建期生成。这也是「令牌用 CSS 变量、其余走构建期」成为今天默认组合的原因。
/* ① 扫描生成:源码里出现过的类名才会有规则 */
// 能被扫到:
<div className="text-red-500 p-4" />
// 扫不到(产物里没有这两条规则,页面上就是没样式):
<div className={"text-" + color + "-500"} />
// 修法:完整类名写成映射
const COLOR = { red: "text-red-500", blue: "text-blue-500" };
/* ② 作用域哈希:编译前后 */
/* 源码 */ .title { font-size: 20px; }
/* 产物 */ .Card_title__a3f9x { font-size: 20px; }
// JS 侧的引用被同步改写:styles.title === "Card_title__a3f9x"
/* ③ 编译期提取:用 TS 写,产物是纯 CSS */
export const button = style({ padding: "8px 16px", borderRadius: 8 });
// 构建后:.button_1a2b3c { padding: 8px 16px; border-radius: 8px }
// 运行时只剩一个字符串常量,没有样式计算box-sizing 这类会继承的属性照样穿透组件边界;直接给 body、* 或标签选择器写的规则也不会被作用域限制住。所以「用了 CSS Modules 就不会互相影响」是错觉——真正的隔离要靠 Shadow DOM,而那又带来另一组问题(样式无法从外部定制)。微项目:亲手写一个编译器
前面十四章拆开讲的每个阶段,这一章连成一条真能跑的链:给一门只有 let / print / if / while 的迷你语言,从词法器一路写到 LLVM IR。六级台阶各自独立、随时可停,前三级零依赖的 JavaScript 就能跑完(node 一条命令),最后一级借 clang 把产物编成真正的可执行文件。每一级都附本机上的输出与数字(Node 24.18 / LLVM 22.1)——你的绝对值会不同,但倍数关系与结论应当一致。
编译原理是「看懂了 ≠ 会写」的重灾区:LR 分析表能背下来,真让你把一段源码变成能跑的东西,往往连第一步都迈不出去。这一章的做法是把前十四章的每个阶段各写一个几十行的最小版本,串成一条完整的链——语言小到两百行文法能说完,但从字符流到机器码,一步不缺。
和 15 章推荐的那几本书什么关系
- Crafting Interpreters、chibicc 这些是好书,但它们是「跟着走完一整本」的承诺——几十个章节、上千行代码,很多人停在第三章。本章反过来:每一级台阶都在一个晚上之内能收工,且都有能跑的产物,做完一级就已经赚到。
- 两者不冲突:本章是那些书的最小骨架版。六级走完再去啃它们,你会发现自己认得里面的每一个数据结构,剩下要学的只是「怎么把它做完整」。
- 选 JavaScript 不是偷懒——零依赖、零构建、报错友好,让你把全部注意力留给编译本身而不是内存管理。语言换成 Python、Go、Rust 都一样,骨架一行不用改。
六级台阶
| 台阶 | 复刻的阶段 | 对应章 | 关键手法 | 能亲眼看到什么 |
|---|---|---|---|---|
| ① 词法器 | 词法分析 | 03 | 单次扫描 + 最长匹配 + 保留字表 | 课本的 ⟨种别, 属性⟩ 变成一个数组 |
| ② 递归下降 | 语法分析 | 04、05 | 优先级爬升,一层一个函数 | 优先级与结合性是树形,不是规则 |
| ③ 树遍历求值 | 语义 + 运行时 | 07、08、10 | 作用域链 + 短路求值 | 语言第一次「活」了 |
| ④ 常量折叠 | 代码优化 | 11 | 后序遍历重写 AST | 32 个结点折成 13 个 |
| ⑤ 字节码 + VM | 中间代码 | 09、13 | 栈式指令集 + 回填跳转 | 同一程序快约 3 倍 |
| ⑥ 降到 LLVM IR | 目标代码 | 09、12、13 | alloca/load/store + 基本块 | mem2reg 当场生出 φ 函数 |
怎么用这一章
- 先自己写,卡住了再看代码。每张卡的结构都是「目标 → 关键几十行 → 本机跑出来的输出 → 现在你来」,中间那段代码是骨架不是答案,故意留了扩展空间。
- 随时可以停。只做①②就已经拥有一个能画出 AST 的解析器;做到③就有一门能跑的语言;④⑤⑥ 是三个独立的加分项,顺序可换。
- 改坏它比写对它更有价值:把②里
bin的while改成递归调用自己(结合性立刻反过来)、把③的短路分支删掉(d != 0 && 10 / d当场炸)、把⑤的回填地址算错一格(跳转飞掉)——每一处「坏法」都对应正文里的一条设计理由。
// 六级台阶共用的样例程序:语法只有这么多
let n = 0;
let s = 0;
while (n < 10) {
s = s + n * n; // 赋值、算术、优先级
n = n + 1;
}
if (s > 100) { print s; } else { print 0; }
// 六级跑完,这段源码会依次变成:
// ① token 数组 → ② AST → ③ 求值结果 285
// ④ 折叠后的 AST → ⑤ 字节码 → ⑥ LLVM IR → 可执行文件node(前五级)加一个 clang(第六级),两样都是几分钟装好的东西。建议从第一天起就写测试:准备十来个「源码 → 期望输出」的样例,每加一个特性全跑一遍——编译器是回归测试收益最高的程序之一,因为它的每次改动都可能悄悄改变某一类输入的行为,而你不可能每次都手工验证。第一级最简单,也最容易低估:它是全链条唯一见过原始字符的一站,行号、注释、空白的去留全在这里定死,后面所有阶段都只认 token。写完它,03 章那句「语法分析器再也看不见字符」就有了实感。
三件必须做对的事
- 最长匹配:扫到
<别急着交货,先看下一个字符是不是=——<=必须整体成为一个 token。代码里就是那句「先试双字符表,再退回单字符」,顺序反了a <= b会被切成<和=。 - 保留字表:
let、while和标识符长得一模一样,所以先按标识符规则扫出整个词,再查表决定它的种别——这正是 03 章说的「关键字不是词法层面的特殊形状,而是查表查出来的」。反过来先匹配关键字,letter会被切成let+ter。 - 行号:每遇到一个换行就
line++,并把它塞进每个 token。这是全链条唯一还知道行号的地方,漏了它,后面任何一级的报错都只能说「出错了」而说不出在哪。
词法阶段的输出
$ node -e "…" // 对样例程序跑词法 token 数: 38 前 8 个: let id(n) = num(0) ; let id(s) = // 吞吐(20000 条 print 语句) 60001 个 token,词法 15 ms、语法 8 ms
- 词法比语法慢不是错觉:它逐字符走完了整个源文件,而语法只在 token 数组上走一趟。真实编译器里词法通常也是「便宜但占比不低」的一站,所以才有 03 章那些双缓冲、哨兵的讲究。
- 注释与空白在这一步就没了——把源码里的
a+b与a + b各跑一遍,token 数组一模一样,信息销毁当场可见。
// 约 40 行:单次扫描 + 最长匹配 + 保留字表
const KEYWORDS = new Set(["let", "print", "if", "else", "while"]);
const TWO = ["==", "!=", "<=", ">=", "&&", "||"];
function lex(src) {
const toks = []; let i = 0, line = 1;
while (i < src.length) {
const c = src[i];
if (c === "\n") { line++; i++; continue; } // 行号只在这里记
if (/\s/.test(c)) { i++; continue; } // 空白直接丢弃
if (c === "/" && src[i + 1] === "/") { // 注释也丢弃
while (i < src.length && src[i] !== "\n") i++; continue;
}
if (/[0-9]/.test(c)) { // 数字
let j = i; while (j < src.length && /[0-9.]/.test(src[j])) j++;
toks.push({ k: "num", v: +src.slice(i, j), line }); i = j; continue;
}
if (/[A-Za-z_]/.test(c)) { // 先当标识符扫完整个词
let j = i; while (j < src.length && /[A-Za-z0-9_]/.test(src[j])) j++;
const w = src.slice(i, j);
toks.push({ k: KEYWORDS.has(w) ? w : "id", v: w, line }); // 再查保留字表
i = j; continue;
}
const pair = src.slice(i, i + 2);
if (TWO.includes(pair)) { toks.push({ k: pair, v: pair, line }); i += 2; continue; }
if ("+-*/%()={};<>!,".includes(c)) { toks.push({ k: c, v: c, line }); i++; continue; }
throw new Error(`第 ${line} 行: 无法识别的字符 ${JSON.stringify(c)}`);
}
toks.push({ k: "eof", v: null, line }); // 哨兵:省掉所有越界判断
return toks;
}letter 会被切成 let 加 ter,而且汇编不报错、语法分析才报一个看不懂的错。规则只有一条——先按标识符的最长匹配扫完,再查表判种别。同理,双字符运算符表必须在单字符之前试,顺序写反了 != 会变成 ! 和 =。eof 哨兵 token 是最划算的一行代码:有了它,解析器里所有 peek() 都不必再判越界——想读下一个总能读到,读到 eof 就是「没了」。这是 03 章双缓冲哨兵的同一种手筋,在只有几十行的词法器上一样成立。现在你来:加上字符串字面量(要处理转义与「未闭合引号」的报错)和浮点数——两者都会逼你在扫描循环里加一个小状态机。05 章说「一个非终结符一个函数」,写出来才知道它有多直白:优先级不是一张要背的表,而是函数的调用层数;结合性不是一条规则,而是你用循环还是用递归。这两句话是这一级全部的收获。
优先级爬升:层号就是优先级
- 把二元运算按优先级从低到高排成一串函数,每层只做一件事:调用下一层,然后循环吃掉本层的运算符。
or → and → equality → compare → term → factor → unary → primary八层,*比+先算,仅仅因为factor在term的下面一层。 - 这就是 04 章那张
a + b * c的树:乘法结点更深 = 先求值。你没写任何一条「乘法优先」的规则,优先级是从函数嵌套里长出来的。
循环还是递归,决定结合性
while (ops.includes(peek().k)) { // 循环 ⇒ 左结合
l = { t: "Bin", op, l, r: next() }; // 每轮把已有的树塞进左边
}
// 换成 return { t:"Bin", op, l, r: bin(next, ops) } 就成了右结合print 20 - 5 - 3;输出 12(左结合,(20-5)-3);把上面那句改成递归自调用,同一段源码输出 18(20-(5-3))——一个字的改动,减法的语义就反了,而两种写法都编译通过、都不报错。- 这正是 04 章「消除左递归保语言不保树形」那条 pitfall 的现场:文法变换之后照树求值会把
a-b-c算错,修法就是在循环里用累加器自左向右建树——也就是上面这四行。
报错:一半的工程量在这里
// 本机上的三条报错 let y = ; → 第 2 行: 表达式里出现意外的 ; let x = (1 + 2; → 第 1 行: 期望 ),实际 ; let x = 1 @ 2; → 第 1 行: 无法识别的字符 "@" (这条来自①,词法期就拦下了)
- 三条错分属两个阶段,「报错来自哪一站」本身就是信息——这正是 01 章那张分诊卡的道理,只不过现在是你自己实现的。
- 能说出行号,全靠①在每个 token 上带了
line。这是「早期阶段多存一点元信息、后期阶段就能给出好诊断」的最小样本,也是 05 章说「手写递归下降报错友好」的实际来源。
// 优先级爬升:八层函数,层号就是优先级
const expr = () => or();
const or = () => bin(and, ["||"]);
const and = () => bin(equality, ["&&"]);
const equality = () => bin(compare, ["==", "!="]);
const compare = () => bin(term, ["<", ">", "<=", ">="]);
const term = () => bin(factor, ["+", "-"]);
const factor = () => bin(unary, ["*", "/", "%"]);
function bin(next, ops) {
let l = next(); // 先要下一层的结果
while (ops.includes(peek().k)) { // ← 循环 = 左结合(换成递归就是右结合)
const op = toks[p++].k;
l = { t: "Bin", op, l, r: next() }; // 把已有的树塞进左子树
}
return l;
}
// 语句层:文法怎么写,函数就怎么写
function stmt() {
if (at("let")) { eat("let"); const n = eat("id").v; eat("=");
const e = expr(); eat(";"); return { t: "Let", name: n, init: e }; }
if (at("while")) { eat("while"); eat("("); const c = expr(); eat(")");
return { t: "While", cond: c, body: block() }; }
// … if / print / 赋值 / 表达式语句同理
}while 改成「看着更对称」的递归自调用。两种写法都能通过全部语法测试、都能画出漂亮的树,差别只在算术结果——20 - 5 - 3 一个给 12 一个给 18。这类 bug 不会被解析器的测试抓到(树的形状都合法),只会被求值的测试抓到,所以第②级和第③级的测试必须一起写。eat(k) 这个「要么吃掉指定 token、要么带行号抛错」的小工具函数是整个解析器的骨架,写好它,剩下的语句函数几乎是把文法抄一遍。现在你来:加函数定义与调用——这一步会同时逼出参数列表的解析、调用表达式该挂在优先级的哪一层(比 unary 更紧),以及第③级里的调用栈设计,是整条链上收益最大的一次扩展。第三级是整条链上性价比最高的一站:不到一百行,你的语言就能跑了。而且它顺手兑现了 08 章的符号表、09 章的短路求值、10 章的运行时环境——三章内容浓缩成一个 switch。
符号表:先用一个 Map,再谈作用域
- 求值器的全部状态就是一个
Map<名字, 值>。08 章说的「一本按作用域分层的账」,在最简版本里就是一层;等你加了函数或块级作用域,把它换成「一叠 Map + 从栈顶往下查」,那一章的图就完全落地了。 - 查不到就报
未定义变量 zz——注意这是运行期报的。想让它变成编译期错误,就得在求值之前单独走一趟语义分析:那正是 08 章存在的理由,你会亲手感到「多一趟能换来什么」。
短路必须在求值器里特判
case "Bin": if (n.op === "&&") return ev(n.l) ? (ev(n.r) ? 1 : 0) : 0; // 左假 → 右边根本不求值 if (n.op === "||") return ev(n.l) ? 1 : (ev(n.r) ? 1 : 0); const a = ev(n.l), b = ev(n.r); // ← 其余运算才两边都算
- 把这两行删掉,
&&就会走下面那句「两边都先算」。同一段源码let ok = 0; if (ok != 0 && missing > 1) { print 1; } else { print 0; }:有短路时正常输出0,删掉后抛出「未定义变量 missing」——左边明明已经把它挡住了。 - 顺带一个值得记住的坑:用「除以零」来演示短路,在这个宿主上是演示不出来的——JS 里
10 / 0得Infinity而不是报错,取整后成了 0,两版输出一模一样。要验证短路,右操作数必须带一个在本语言里真的会炸的副作用;宿主语言的容忍度会悄悄吃掉你的测试用例,这类「测试其实什么都没测到」在写编译器时格外常见。 - 这就是 09 章那句「短路是语言的语义承诺,不是编译器的优化」——在你自己的求值器里,它是两行必须手写的特判,漏了就是语义 bug,没有任何优化器会替你补上。
三种执行方式的耗时
样例程序(n 从 0 到 9 累加 n*n) → 输出 285
3,000,000 次循环的版本 → 树遍历耗时 约 560~640 ms
(这个数字是第⑤级的对照基准,记住它)// 表达式求值:一个 switch,递归下去
const env = new Map(); // 这就是符号表(最简版:一层)
const ev = (n) => {
switch (n.t) {
case "Num": return n.v;
case "Var":
if (!env.has(n.name)) throw new Error("未定义变量 " + n.name);
return env.get(n.name);
case "Un": return n.op === "-" ? -ev(n.e) : (ev(n.e) ? 0 : 1);
case "Bin": {
if (n.op === "&&") return ev(n.l) ? (ev(n.r) ? 1 : 0) : 0; // 短路
if (n.op === "||") return ev(n.l) ? 1 : (ev(n.r) ? 1 : 0);
const a = ev(n.l), b = ev(n.r);
switch (n.op) { case "+": return a + b; /* … */ }
}
}
};
// 语句执行:控制流直接借宿主语言的控制流
const run = (n) => {
switch (n.t) {
case "Block": for (const s of n.body) run(s); return;
case "Let":
case "Assign": env.set(n.name, ev(n.init ?? n.expr)); return;
case "Print": console.log(ev(n.expr)); return;
case "If": if (ev(n.cond)) run(n.then); else if (n.else) run(n.else); return;
case "While": while (ev(n.cond)) run(n.body); return; // ← 借宿主的 while
}
};While 那行:解释器直接借用了宿主语言的循环,所以你一行控制流代码都没写。这正是「树遍历解释器好写」的根源,也是它的天花板——到第⑤⑥级要生成字节码或机器码时,这个便宜就没得占了,跳转必须自己回填。现在你来:把 env 换成一叠 Map,给 {} 加上块级作用域,再验证内层同名变量确实遮蔽外层(08 章「最近嵌套」)。有了 AST 就能做优化了,而最容易上手的一种只要三十行:后序遍历,凡是两个子结点都已是常量的运算,当场算出来换成一个常量结点。11 章说的「一步喂下一步」,在这三十行里会自己发生。
为什么必须后序
- 先递归处理子树、再看自己——只有这样,
2 * 3 + 4 * 5里的两个乘法才会先变成 6 和 20,轮到加法时它才看得见「两个常量」。写成前序,加法先看到的是两棵子树,一次也折不动。 - 折叠完成后,
If的条件可能已经是常量——于是顺手做死分支消除:条件恒真就整个换成 then 分支、恒假换成 else,while (0)直接删掉。这就是「常量传播 → 常量折叠 → 死代码消除」那条链,只不过你是在一趟遍历里同时做完的。
一段四行的源码
let x = 2 * 3 + 4 * 5;
if (1 > 2) { print 111; } else { print x; }
while (0) { print 999; }
print x * (10 - 10) + 7;
折叠前:AST 32 结点 → 编出 50 字节码
折叠后:AST 13 结点 → 编出 17 字节码
输出两版完全一致(26 和 7),语义不变- 结点少了六成,字节码少了三分之二。注意
x * (10 - 10)只折到x * 0就停了——再往前需要「乘 0 得 0」这条代数化简规则,而它不属于常量折叠。想让print那行也折成 7,就得再加一条代数恒等式规则,这正好演示了 11 章「优化技术是一箱互相喂球的改写」。 - 三十行代码换来六成结点,这个投入产出比就是编译器优化的日常:没有哪个 pass 是聪明的,聪明的是它们叠在一起反复跑。
// 后序遍历重写 AST:先折子树,再看自己
function fold(n) {
if (!n || typeof n !== "object") return n;
// ① 先递归处理全部子结点 —— 顺序是关键
for (const k of ["l", "r", "e", "expr", "init", "cond", "then", "else"])
if (n[k]) n[k] = fold(n[k]);
if (Array.isArray(n.body)) n.body = n.body.map(fold);
else if (n.body) n.body = fold(n.body);
// ② 两个操作数都成了常量 ⇒ 当场算掉
if (n.t === "Bin" && n.l.t === "Num" && n.r.t === "Num") {
const a = n.l.v, b = n.r.v;
const tbl = { "+": a + b, "-": a - b, "*": a * b, "/": (a / b) | 0,
"<": +(a < b), ">": +(a > b), "==": +(a === b) /* … */ };
if (n.op in tbl) return { t: "Num", v: tbl[n.op] };
}
// ③ 条件成了常量 ⇒ 死分支消除(上一步喂给这一步)
if (n.t === "If" && n.cond.t === "Num")
return n.cond.v ? n.then : (n.else ?? { t: "Block", body: [] });
if (n.t === "While" && n.cond.t === "Num" && n.cond.v === 0)
return { t: "Block", body: [] };
return n;
}1 / 0 折叠期直接抛异常,本该是运行期行为;带副作用的表达式(本章的语言还没有,加了函数调用就有了)折掉之后副作用就没了。真编译器为此有一条铁律——只折叠「无副作用、不会陷入、结果确定」的运算,这正是 11 章讲 LICM 时那三个前提的同一套。写到这一级你会第一次亲身遇到它。x*1、x+0、x*0)与常量传播(记住 let x = 5 之后 x 的值),然后观察上面那段源码能不能一路折到只剩两个 print。树遍历慢在哪?每求一个值都要走一次 switch、跟一次指针、递归一层函数调用。字节码把这些一次性算完:编译期把树摊平成一维指令数组,运行期只剩「取指令、跳表、动栈顶」。02 章那句「把翻译提前,运行期就快」,这一级能自己量出来。
栈式指令集:不用分配寄存器
- 操作数全部走一个栈:
CONST压常量、LOAD/STORE读写变量槽、ADD弹两个压一个。选栈式而不是三地址,就是为了跳过寄存器分配——那是 12 章一整章的内容,这一级先绕开(JVM、CPython、Lua 的字节码也都是栈式,理由相同)。 - 变量名在编译期就换成了槽位下标:运行期不再有任何字符串查找,这一步单独就能带来可观的提速——正是 08 章符号表「记录地址/偏移」的最小形态。
跳转靠回填:09 章那张卡的现场
// 编 if 时,还不知道 else 在哪 —— 先填 0 占位,记下位置 ex(n.cond); emit(OP.JZ, 0); const j = code.length - 1; st(n.then); code[j] = code.length; // ← 现在知道了,回头把洞填上
- 先留空、后补填——和 09 章回填卡、01 章重定位说的是同一道工序。写错一格跳转就飞掉,而且不报错,只是结果不对:这是本级最容易调半天的 bug,也是最值得亲手踩一次的。
反汇编出来的字节码
// 反汇编 let a = 1; while (a < 3) { a = a + 1; } print a; 0 CONST 1 11 LOAD 0 2 STORE 0 13 CONST 1 4 LOAD 0 15 ADD 6 CONST 3 16 STORE 0 8 LT 18 JMP 4 ← 回边,跳回条件 9 JZ 20 20 LOAD 0 22 PRINT 23 HALT // 300 万次循环的样例程序(同一份 AST,两种执行方式) 树遍历 约 560~640 ms 字节码VM 约 195 ms ⇒ 加速 约 2.9~3.3 倍,输出完全一致 字节码长度 41 字,常量池 4 项,变量槽 2 个
- 三倍不是上限,也不是什么惊人数字——它买到的是「把每次执行都要做的判断,挪到只做一次的编译期」,这条原则在词法的 DFA、LR 的分析表、正则的预编译里已经出现过三次了。
- 想再快,路子就分两条:继续在 VM 里做(计算跳转 goto、超级指令、内联缓存),或者干脆别解释了——那就是下一级。
// 编译:AST → 一维字节码数组
function compile(ast) {
const code = [], consts = [], slots = new Map();
const slot = (n) => { if (!slots.has(n)) slots.set(n, slots.size); return slots.get(n); };
function st(n) {
switch (n.t) {
case "While": {
const top = code.length; // 回边目标:现在就知道
ex(n.cond);
emit(OP.JZ, 0); const j = code.length - 1; // 出口:还不知道,留洞
st(n.body);
emit(OP.JMP, top);
code[j] = code.length; // 回填
return;
}
// … Let / Print / If 同理
}
}
st(ast); emit(OP.HALT);
return { code, consts, nslots: slots.size };
}
// 执行:一个循环 + 一个 switch,没有递归、没有对象
function runVM({ code, consts, nslots }) {
const st = new Float64Array(256); let sp = 0; // 操作数栈
const slots = new Float64Array(nslots); // 变量槽,按下标访问
let ip = 0;
for (;;) {
switch (code[ip++]) {
case OP.CONST: st[sp++] = consts[code[ip++]]; break;
case OP.LOAD: st[sp++] = slots[code[ip++]]; break;
case OP.STORE: slots[code[ip++]] = st[--sp]; break;
case OP.ADD: st[sp - 2] += st[sp - 1]; sp--; break;
case OP.JZ: { const t = code[ip++]; if (!st[--sp]) ip = t; break; }
case OP.JMP: ip = code[ip]; break;
case OP.HALT: return;
}
}
}disasm 让字节码从一串数字变成可读的清单,回填算错一格立刻看得见——没有它,调跳转 bug 全靠盯着数组下标猜。这和 01 章「先学会看每一站的产物」是同一条方法论:能打印的中间产物,一定要先做出打印功能。现在你来:把两条常见的相邻指令合成一条「超级指令」(比如 LOAD 紧跟 CONST),再测一次,看能不能再挤出一成。最后一级不写机器码——写 LLVM IR,然后把它交给 clang。这正是 02 章「m+n 而非 m×n」的兑现:你只写了个前端,就白拿了整条工业级优化管线和全部目标平台。产物是一个真正的可执行文件。
生成策略:先别管 SSA
- 每个变量老老实实
alloca一个栈格,读写全走load/store——不要试图自己构造 φ 函数。这不是偷懒,而是 LLVM 官方推荐的前端写法:把内存形式交给mem2reg,它会用 11 章那套支配边界算法替你放 φ,比手写靠谱得多。 - 控制流按 09 章的方式编成基本块 +
br:while拆成loop.cond/loop.body/loop.end三块。短路的&&这里必须真的编成分支并汇合出一个phi——因为 IR 里没有「短路运算符」这种东西,它只有跳转。
mem2reg 当场生出 φ
$ node llvm.mjs '…样例程序…' > out.ll $ opt -passes=mem2reg -S out.ll loop.cond0: ; preds = %loop.body1, %entry %s.addr.0 = phi i64 [ 0, %entry ], [ %t11, %loop.body1 ] %n.addr.0 = phi i64 [ 0, %entry ], [ %t13, %loop.body1 ] %t5 = icmp slt i64 %n.addr.0, 10 …
- 你生成的 IR 里一个 φ 都没有,全是 alloca/load/store;一趟
mem2reg之后,两个循环变量各得到一个 φ,位置恰好在「两条前驱边汇合处」——09 章的 SSA 卡和 11 章的支配边界卡,在这两行里同时兑现了。
-O2 把整个循环算没了
$ opt -passes='default<O2>' -S out.ll
define noundef i32 @main() {
entry:
%0 = tail call i32 (ptr, ...) @printf(ptr @.fmt, i64 285)
ret i32 0
}
$ clang -O2 -S -masm=intel out.ll -o -
main:
…
mov edx, 285 ← 循环整个不见了
call printf- 十次迭代的累加被完全在编译期算完:循环消失、变量消失,只剩一个立即数。你写的前端一行优化都没做,这些全部来自那条你白拿的 pass 管线。
- 这也是 01 章那条 pitfall 的实证:想观察真实的优化行为,输入必须是运行期才知道的值。把上界 10 换成从命令行读进来的数,循环就会老老实实留在汇编里。
// 变量:一律 alloca + load/store,φ 交给 mem2reg 去建
case "Var": { const t = tmp(); e(`${t} = load i64, ptr ${slot(x.name)}`); return t; }
case "Assign": { const v = ex(x.expr); e(`store i64 ${v}, ptr ${slot(x.name)}`); return; }
// while:三个基本块 + 两条 br,和 09 章的 CFG 一一对应
case "While": {
const C = lbl("loop.cond"), B = lbl("loop.body"), X = lbl("loop.end");
e("br label %" + C); out.push(C + ":");
const c = ex(x.cond), b = tmp();
e(b + " = icmp ne i64 " + c + ", 0");
e("br i1 " + b + ", label %" + B + ", label %" + X);
out.push(B + ":"); st(x.body); e("br label %" + C);
out.push(X + ":"); return;
}
// 生成的 IR(节选)—— 一个 φ 都没有,全是内存操作
define i32 @main() {
entry:
%n.addr = alloca i64
%s.addr = alloca i64
store i64 0, ptr %n.addr
br label %loop.cond0
loop.cond0:
%t3 = load i64, ptr %n.addr
%t5 = icmp slt i64 %t3, 10
…
// 编译并运行:一条命令,产物是真的可执行文件
$ clang out.ll -o out && ./out
285opt 对 clang -O0 生成的 IR 会因 optnone 属性静默跳过(13 章那条 pitfall),而手写的 IR 没有这个属性,可以直接跑——这也是自己生成 IR 比抄 clang 输出更适合做实验的原因。clang 能直接吃 .ll 文本,所以这一级不需要任何 LLVM 的 C++ API、不需要链接 libLLVM——你的编译器只是个吐字符串的程序。这是给新语言写后端最省事的起手式(早期的 Rust、Crystal 都这么干过),等真需要精细控制时再换成 C++/Rust 的 LLVM 绑定不迟。现在你来:把 opt -passes=mem2reg,instcombine,sccp,simplifycfg 一个个 pass 单独跑,看每一趟各改了什么——这是 11 章「一步喂下一步」最直观的回放。从这里到精通:路线图
前面十五章讲完了从字符流到机器码的整条流水线,也把它套到了界面 DSL 上、并在微项目里亲手跑通了一遍。但编译原理是出了名的「看懂了 ≠ 会写」——最后这一章接着 15 章往上走:更大的动手项目、进阶读物与一条自测标准。
编译器是最适合「边造边学」的领域:每个阶段都有清晰的输入输出,写到哪一章就能跑到哪一章——按顺序给出三级台阶,每级都有明确的产出物。
第一级:15 章的六级台阶(起点在页内)
- 先把 15 章走完:它已经把「词法 → 递归下降 → 求值 → 常量折叠 → 字节码 VM → LLVM IR」六级各写了一个能跑的最小版本,每级一晚上收工。这一级不需要另找教材,也不必等读完全页——读到哪一章就能开工对应的那一级。
- 走完之后再给你的语言加函数与调用:这是 15 章刻意留白、也是收益最大的一次扩展,它会同时逼出参数传递、调用栈、作用域链与返回值约定——10 章运行时环境的内容从此有了体感。
第二级:把语言做完整
- 跟着免费在线书 Crafting Interpreters(《手写解释器》)把 Lox 做完:它是 15 章那条链的「完整版」——闭包、类、垃圾回收、更认真的错误恢复,正是最小骨架里省掉的部分。有了 15 章的底子,这本书会读得快得多。
- 再读 chibicc(Rui Ueyama 的迷你 C 编译器,每个 commit 就是一课)或 LLVM 官方的 Kaleidoscope 教程:前者体会「不靠框架直出汇编」(12 章那些指令选择、寄存器分配得自己来),后者是 15 章第⑥级往下的延伸——换成真正的 LLVM API 而不只是吐 IR 文本。
第三级:进阶读物与方向
- 系统补理论:《Engineering a Compiler》比龙书更工程视角、更好读;龙书当参考书按需查。想深入优化就读 SSA 相关论文与 LLVM 源码,想玩新方向看 MLIR(机器学习编译)。
- 参与真实项目:给 LLVM/GCC 提一个小 patch,或用 tree-sitter 给编辑器写一个语言的语法高亮——都是编译技术的「日常岗位」。