编译原理 完整知识体系交互讲解

上手:亲手看穿一次编译

别急着学算法。先用四条命令把「源码 → 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+ba + 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 一个栈格、算一步存一次;-O2alloca 全消失mem2reg 一个 pass 就把栈格提升成了寄存器;
  • 汇编那一站更极端:-O2 下整个函数塌成 lea eax, [rdi + 2*rsi] 加一条 ret——乘 2 被强度削减成地址计算的比例因子,参数本就在寄存器里,一次内存都不碰。
别拿这个五行样例推广到真实项目:它小到整个函数都能被内联加折叠,add(1, 2)-O2 后只剩一个常数。
不想装工具链就用 godbolt.org把优化等级从 -O0 拖到 -O3、看着汇编一行行消失,是这一章最值得花的十分钟。

这一页按流水线顺序组织,而流水线顺序恰好也是依赖顺序——前一站的产物是后一站的原料,顺着读就行。但有几处值得提前知道。

四段结构

  • 02–06 前端理论最密集04 章是地基,推导、二义性、文法变换不过关,05、06 就只能背算法;
  • 07–09 语义与中间表示:从这里开始产物能直接跑命令看到;
  • 10–13 优化与后端:数据流分析、SSA、寄存器分配;
  • 14–16 现代议题与动手:第一遍可以跳,回头再补。

迷路时回 02 章那张主线卡

  • 它把整条流水线串成一句话。卡在某个算法里出不来时就回去问:我这一步的输入是上一站的什么,又交给下一站什么?
最常见的弯路是把编译原理当成「为了自己造一门语言」来学,于是一头扎进语法分析、在 LR 分析表里耗掉全部热情。真实收益更多来自后半段:读懂优化、看穿性能、理解你天天用的那个编译器。
一个贯穿全页的自测法:每学完一章,回到本章第一张卡的 demo.c,问「这一章对应它的哪一步产物?我能不能预测那份输出?」——能预测才算学会。这比做完习题更能检验理解,因为工具会毫不留情地告诉你猜错了。

编译器导论

编译器的本质、结构与流程——理解前后端如何通过统一中间表示解耦,是后续一切的地图。

编译还是解释,分歧其实只在一件事上:翻译发生在执行之前,还是执行的同时。就这一个时机的选择,决定了一门语言是「启动慢但跑得快」还是「即开即用但跑得慢」。

同一根时机轴上的几个点

把翻译整个提到执行前,就是编译:先花一整趟把源程序译成目标程序,之后每次运行都直接跑机器码,快,而且有充裕时间做深度优化(C / C++ / Rust)。把翻译推到执行时、逐条边译边跑,就是解释:省掉了先译一遍的等待,启动快、易移植、好调试,代价是每跑一次都要重新理解一遍源码。真正的主流语言大多落在中间:先把源码编成紧凑的字节码交虚拟机解释,再把反复执行的热点在运行时临时编成机器码(JIT,如 JVM、V8)——冷代码省启动、热代码得速度。所谓 AOT(提前编译)与 JIT,无非是把编译放在「部署前」还是「运行时」,仍是这同一根时机轴的两端。

三种时机放一起看

翻译时机:  编译前 ←───────────────→ 运行时

AOT 编译    源码 ──▶ 机器码,之后才运行            gcc / rustc / go
字节码+JIT  源码 ─▶ 字节码 ─(运行时热点)▶ 机器码    JVM / V8 / .NET
解释        源码 ──▶ 边读边执行,不留独立产物      python / bash

三种实现路线,同一根时机轴上的三个点

AOT 编译字节码 + JIT纯解释
翻译发生在部署前先编字节码,热点在运行时再编执行的同时
启动最快(直接跑机器码)中(要先加载并解释一段)快(不用先译一遍)
峰值性能高(有充裕时间做深度优化)可以更高(能用运行时真实类型/分支信息)
能不能跨平台分发一份产物不能(每个目标机一份)能(字节码与机器无关)能(分发源码)
典型gcc / rustc / goJVM / V8 / .NET / PyPybash / 早期 CPython 的部分路径
  • 值得单独记的一格是「峰值性能」:JIT 可以比 AOT 更快,因为它掌握 AOT 拿不到的信息——这个虚方法实际只有一个实现、这个分支运行时从不走、这个变量总是整数。代价是这些假设可能失效,于是需要去优化(deoptimization)退回解释执行。「编译早 → 优化信息少,编译晚 → 优化信息多但没时间」是这条轴的核心张力。

深入理解:「编译型 / 解释型语言」其实是伪命题

  • 编译还是解释,是实现的属性,不是语言的属性。同一门语言两者都能有:C++ 有解释器(Cling),Python 有编译器(Nuitka 直出机器码、PyPy 在运行时把热点 JIT 编成机器码),Java 更是先 javac 编成字节码、再由 JVM 解释 + JIT。所以「某语言是编译型还是解释型」从提法上就错了——真正该问的是「这个实现把翻译放在了哪一步」。还有个常被忽略的点:解释器内部照样跑完整的词法、语法、语义分析,它和编译器的分界只在最后一步——是吐出目标码,还是直接执行。
「编译」不等于「生成机器码」——把程序翻译成另一种语言就叫编译:javac 出字节码、TypeScript 编译器出 JavaScript,都是正经编译器。另一个高频口误:JIT 也是编译(只是挪到了运行时做),别把「带 JIT 的实现」说成「纯解释执行」。
判别习惯:别问「这门语言是编译型还是解释型」,改问「这个实现把翻译放在哪一步」。连 CPython 也是先把源码编成字节码再解释——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 的噩梦——这正是不少老编译器难以移植的病根。
别把「优化」当成中端专属:前端就会做字面量折叠——GCC 15 -O0int x=1+2; 的 GIMPLE 里已经是 x = 3;后端还有指令调度、窥孔等机器相关优化。中端垄断的只是「机器无关优化」这一层,不是「一切优化」。
对号入座的判据:问「换一门源语言要不要改?换一块目标 CPU 要不要改?」——只随语言变的在前端,只随 CPU 变的在后端,两者都不动的在中端。LLVM 的命令行恰好三段各一个: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# 改用多趟(先扫一遍收集全部声明,再回头解析),才摆脱这约束——同一段里方法可以调用后面才定义的方法。你以为是语法规定,其实是编译趟数的历史选择。
别把流程图读成时间顺序:真实编译器里通常是语法分析器当主驱动、按需调用词法分析器取下一个 token,两个阶段交错进行,token 流很少整个物化出来。「阶段」是逻辑分工、「趟」才是物理扫描——把两者画等号,就解释不了单趟编译器怎么六个阶段全做了。
每一站的产物如今都能亲眼看(LLVM 21):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 分析表、图着色时,很容易陷进单个算法里出不来。每当那样,回到这条流水线问一句:「我现在做的这一步,输入是上一站的什么、又交给下一站什么?」——本页每个算法都只是在完成某一站的转换,没有一个是孤立的。
「语义分析」这一站查的是静态语义——类型、作用域、重复定义这些编译期能判定的事;除零、数组越界、空指针属于运行期行为,这一站并不负责抓(顶多给个警告)。初学者常把「过了编译」误当「逻辑没错」——整条流水线自始至终只是在翻译你的程序,从没执行过它。
这趟旅程可以亲眼验证:godbolt.org 上贴一段 C,右侧实时看到生成的汇编(§12 的产物);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 里嵌内联汇编——每一处都是这条流水线在某一环特意留的接口。把大问题切成「窄而可换」的小工具,是这条链最值得偷师的工程智慧。
课本流水线是逻辑分工,别当成一环一个进程:GCC 15 -c 只跑 cc1as 两个进程——预处理早已并进 cc1,并没有独立的 cpp 进程被调起;Clang 21 更进一步,-### 显示 -c 全程只有一个 job(集成汇编器直出 .o)。工具边界会合并,分工概念不变。
想点名接力队员:gcc -v hello.c 会打出真实调用的 cc1ascollect2(链接器的包装);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+ba + b 编出来一模一样、也是你无法从 token 反推原始源码的原因(除非像 clang-format、代码高亮那样专门另存「琐碎信息 trivia」)。从此往后,语法分析器再也看不见字符,只看得见 token——所以编译器报错才总说 "unexpected token",它早已活在 token 的世界里。
数「一段代码有多少 token」,数的是词素出现次数:sum、count、i 是三个 token(同属种别 id),不是一个;反过来空白和注释一个都不算——它们在词法阶段就被丢弃,从不进 token 流。
课三个词的关系用一句话锁住:pattern 是规则,lexeme 是这次匹配到的那串字符,token 是交给语法分析器的那张卡片。卡片上只留「种别 + 必要的属性」,词素本身多数会被丢掉——所以 sumtotal 在语法分析器眼里是同一个东西(都是 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 末的乔姆斯基谱系给出全局地图。
「正则数不清括号」限定于任意深度的嵌套:只要深度有上界(比如最多 100 层),语言就仍是正规的——有限自动机数得清有限的层数,只是正则式会长得没法看。别把「正则不能碰嵌套」绝对化成咒语。
判定「是不是正规语言」的判据:识别它需要记住的信息量是否有限。「a 的个数为偶数」只需 1 比特,正规;「aⁿbⁿ」要记任意大的 n,不正规。状态有限 = 记忆有限,这是全部判据。

正则是「规则」,可它怎么真正跑起来去逐字符识别?答案是有限自动机——一台只有有限个状态的小机器。它有确定与非确定两副面孔,核心问题就一个字:确不确定。

一台会跳状态的小机器,两副面孔

自动机的运作极简单:读入一个符号,就按转移规则从当前状态跳到下一个状态,读完看是否落在「接受状态」上——所需零件不过是状态、输入符号、转移规则、一个初态和一组接受态。它的两副面孔差在「确不确定」:DFA(确定有限自动机) 每个状态对每个输入至多跳一处、也不许凭空(ε)空转,照着实现就是一张查表,运行时毫不含糊;NFA(非确定有限自动机) 则允许同一输入岔出好几条路、还能走 ε 空转移,描述起来更灵活。关键事实是二者识别的语言完全相同——NFA 便于描述、DFA 便于执行,于是词法工具的固定套路就是「先按正则搭出 NFA,再转成 DFA 去跑」。

同一步:NFA 岔路,DFA 唯一

              读 a        读 b       ε 空跳
NFA  状态 q0  {q0, q1}     {q0}       → q2     ← 一个输入可多个去向
DFA  状态 A   B(唯一)    A(唯一)    无       ← 照表走,绝不含糊

两者识别的语言相同;NFA 好描述、DFA 好执行 ⇒ 套路:正则 → NFA → DFA

NFA / DFA 对照(记住「能力相同、代价不同」)

NFADFA
一个状态 + 一个输入的出边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 走带回溯的引擎,功能更强(支持反向引用)却可能被构造输入拖成指数级。你选用哪个正则库,本质上就是在选「自动机派」还是「回溯派」。
「NFA 比 DFA 强大」是最常见的错话——二者识别能力完全相同,差别只在状态数与确定性。「回溯引擎慢」也别赖到 NFA 头上:逐字符维护状态集合的 NFA 模拟是线性的,指数爆炸是回溯实现的锅——Python 的 re 匹配 (a+)+$,26 个 a 加一个 b 已要 2 秒且每加 2 个 a 翻 4 倍,而走自动机路线的 grep 瞬时返回。
一眼判别:盯着「一个状态 + 一个输入符号」的出边数——恰好一条,是 DFA;可以零条、多条、或有 ε 边,是 NFA。因此每个 DFA 天生就是 NFA,包含方向别记反。

正则怎么由小到大拼出来,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))。
「最坏 2ⁿ」不是吓唬人:语言「倒数第 n 个字符是 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 是否同构即可,这是可判定、能写成程序的。词法生成器也靠最小化把转移表压到最小,省内存、加速查表。
「最小 DFA 唯一」这条别推广到 NFA:同一语言的最小 NFA 可以不唯一,求最小 NFA 还是已证明的困难问题(PSPACE 完全)。所以判两个正则等价走的是「各自最小化 DFA 再比同构」,没有对称的 NFA 版本。
两个状态能否合并,本质只看有没有「区分串」:从两者出发喂同一个串,一个接受一个拒绝,就绝不能并。怀疑两状态等价时先随手喂几个短串找反例,比跑完整算法快——划分细化无非是把「找区分串」系统化。

前面是「正则 → 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,无 ε)+ 子集构造」合并成一步的教科书精炼版。
手算 firstpos / lastpos 最易漏的是 nullable 分支:连接结点 c₁c₂ 若 c₁ 可空,firstpos 必须并上 c₂ 的(c₂ 可空时 lastpos 对称地并上 c₁ 的);漏掉一处,followpos 表整张连锁出错。另记住:只有连接两种结点给 followpos 添规则,| 结点不贡献。
接受态的判定只有一条:状态集合里含结束标记 # 的那个位置就是接受态。把卡里那张 followpos 表走到底只得两个状态——{1,2,3} 与 {1,2,3,4}(Python 脚本),后者含 #₄ 即接受态,不用再找别的判定规则。

自动机是理论,真做一个扫描器还得回答几个现实问题——切到哪算一个词、关键字怎么认、怎么读得快。

从自动机到扫描器,要补上的几件事

切到哪算一个词?最长匹配原则(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 出一个 lessequala+++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 的表达力边界,恰好画出了「语法」与「语义」的分工线。
「上下文无关」限定的是产生式左部(单个非终结符、展开不看邻居),不是说「解析器不用看上下文」——预测分析照样要向前看符号。也别把它当成语言合法性的全部:C 里 a * b; 是乘法还是指针声明要靠符号表才能定,这类判定超出 CFG 能力,正是要推给语义分析的东西。
判断「要不要动用 CFG」的一眼法:看语言里有没有必须配对计数的嵌套结构(括号、begin/end、标签开闭)——有就超出正则,没有就别浪费。想看真实 CFG 长什么样:C 标准附录 A、ECMAScript 规范每节开头的产生式,就是工业级的产生式清单。

同一套产生式可以正着用、也可以倒着用——这一正一反,正好对应后两章的两类分析法。

正着推导,倒着归约

正着用是推导:从开始符号出发,每一步挑一个非终结符、用某条产生式的右部替换它(记作 ⇒),直到全变成终结符,就生成了一个句子。倒着用是归约:从一串终结符出发,反复把「某条产生式的右部」替换回它的左部,最终收回开始符号——这是自底向上分析的基本动作。每步该展开哪个非终结符是有讲究的:总挑最左边的叫最左推导,总挑最右边的叫最右推导(又称规范推导),这个选择看似随意,却和两类分析法严丝合缝对应。(推导途中那些可能还含非终结符的中间串叫句型;全是终结符的句型才是句子。)

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 为什么「沿最右推导逆序工作」就成了理所当然。
「同一句子存在两个不同推导 ⇒ 二义」是经典误判:同一棵分析树本就对应多个推导(最左、最右、乱序,只是替换顺序不同)。二义的正确判据是两棵不同的分析树,等价说法是两个不同的最左推导(或两个不同的最右推导)。
判最左/最右,只看每步被替换的非终结符位置,一眼即知。反过来记一条:LR 分析器输出的归约序列倒着念,正是一次最右推导——读 Bison 的调试输出用得上,也正是「LR 为何叫 R」的答案。

分析树记录的不是「怎么推」,而是句子的结构;而一个句子若能画出不止一棵树,麻烦就来了——因为结构就是语义。

一句两解,就是二义

分析树把一次推导的层次画出来:根是开始符号,叶子是终结符,中间结点是非终结符。它关心的是句子搭成了什么形状。二义性指同一个句子能画出不止一棵分析树。为什么这一点关键?因为结构决定语义——两棵树意味着两种解释、两种算法、两种结果。最经典的是悬空 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 声明压掉冲突,也不把文法改写得又臃肿又难读。
二义是文法的属性、不是语言的:同一批句子换成分层的 E/T/F 文法就只剩一棵树。另外别把分析树和 AST 混为一谈——分析树忠实记录每步产生式(连 E′ 这类脚手架都在),AST 是删繁就简后的语义骨架,编译器后续阶段用的是后者。
证明二义只需一个反例:找条短句画出两棵树即可——本卡的 a+b*c 在 E→E+E|E*E|id 下恰好两棵,就是完整证明。反方向没有对称的招:「CFG 是否二义」不可判定,实践中靠「LR 构造零冲突」这类充分条件间接担保无二义。

这些变换都是为了迁就下一章的自顶向下分析而对文法做的预处理——语言一字不变,变的只是文法的写法。要治的是两个专门绊倒自顶向下的病。

治两个病:左递归与左公因子

左递归 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 → 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 正则卡。
谱系按「够用的最弱机器」归档:正则语言当然也能写成 CFG(3 ⊂ 2 说的就是这个),答「a*b 是 2 型」不算错但没抓住要点。较真一层:2 ⊂ 1 在文法层面要单独处理 ε 产生式(经典定义的 1 型不允许缩短句型),课本包含图把这条脚注省了。
记忆钩子:型号数字越大能力越弱(3 型最弱);识别机沿「有限状态 → 加个栈 → 带长受限 → 无限带」逐档加装备。一句话对应:词法 = 3 型/DFA,语法 = 2 型/PDA。

自顶向下分析

有了文法,怎么用它去分析 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 本就不远。这是「理论最强」输给「工程最顺手」的经典一幕。
直接左递归看得见,间接左递归照样死循环:A→Bα、B→Aβ 单看每个函数都没毛病,跑起来 parseA→parseB→parseA 无限互调直到栈溢出。改写前要先代入展开、让间接递归现形成直接左递归再消除。
手写口诀:见终结符写 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) ⇒ {+, $}
三个高频错步:① 算 FIRST(αβ) 时 α 可空却忘并 FIRST(β)——S→ABc 且 A、B 均可空时 FIRST 应是 {a,b,c} 而非 {a};② A→αBβ 中 β 可空时漏把 FOLLOW(A) 灌给 FOLLOW(B);③ 把 ε 写进 FOLLOW——FOLLOW 永远只收终结符和 $。
手算顺序:先把全部 FIRST 算到不动点,再算 FOLLOW(后者要用前者)。收工前的自查:拿每条产生式再过一遍,任何集合若还能变大就是没收敛——「迭代到不动点」这步最容易提前收工。

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)
「消了左递归、提了左公因子 = LL(1)」是最常见误判:S→Aa、A→a|ε 两样毛病都没有,却因 FIRST(a)∩FOLLOW(A)={a} 让 M[A,a] 挤进两条产生式,不是 LL(1)。左递归/左公因子只是「必死项」,排除它们不构成「已活」的证明——最终判据只有「每格至多一条产生式」。
查冲突优先盯有 ε 产生式的非终结符:两个判据里 FIRST 互斥好查,栽跟头的多半是 FIRST(α)∩FOLLOW(A) 那条。填表口诀:「非空产生式按 FIRST 填,ε 产生式按 FOLLOW 填」。

编译器碰到第一个语法错误就罢工,体验极差。错误恢复的目标不是「改对」,而是出错后还能站稳、接着往下查,一次多报几个错。

三种止损办法

最常用的是恐慌模式:一旦卡住,就往后跳过输入符号,直到撞上一个「同步符号」——常取 FOLLOW 集里的成员,因为那恰是当前结构合法收尾的位置,从那儿重新开始最不容易再乱套。更精细的有短语级恢复(对输入做个局部小修补,比如替你补上漏掉的分号)和出错产生式(把常见的错误写法直接编进文法、专门捕捉并给出针对性提示)。它们的共同点是都不图把程序改正确,只图别让一个错误引发一连串假报警或漏报。

恐慌模式:跳到分号再站起来

输入: int a = ; b = 5;      // '=' 后漏了表达式
              ↑ 出错
恐慌模式:向后跳过 token,直到撞上同步符号 ';'
          → 丢弃出错的残片
          → 从 ';' 之后重新开始,继续检查 b = 5

目的是止损:一次多报几个真错误,而非改对

深入理解:为什么老手只信第一条报错

  • 你一定见过:补上一个漏掉的分号,报错就从二十条塌成零条。那十九条全是幻觉——第一个错让分析器状态错乱,恐慌模式又没能跳回真正的同步点,后面便是一连串被带偏的假报警(级联错误 cascading errors)。所以老手的铁律是:永远从最顶上第一条错误改起,改完立刻重编,绝不硬啃下面那些。这也点破了同步集合的两难——跳得太少,残渣继续引爆假报警;跳得太多,又把后面的真错一起吞掉漏报。错误恢复从不是「改对」,而是在「少漏报」与「少假报」之间走钢丝。C++ 的报错为何格外像雪崩?模板让「合法收尾点」几乎无从判定,同步一跳就过头。
手写递归下降最粗暴的「恢复」是 match 失败就 throw 回顶层——一次只能报一个错,恢复形同虚设。正确做法是每个非终结符函数在自己的同步集合处停住、吞掉残片返回错误结点接着分析;只跳输入不调整调用层,后面照样雪崩假报警。
同步集合别只取 FOLLOW:把语句/声明的开头关键字(if、while、int)也加进去,跳过时更不容易吞掉下一条语句。现实对照:给 clang 喂一处缺表达式加一处漏分号,它恰好各报一条、还在漏分号处给出「expected ';'」的补全提示——短语级恢复的活样本(LLVM 21)。

自底向上分析

第二条路——自底向上:与上一章反过来,从输入串出发反复归约、最终收回到开始符号。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 宽」最直观的体现:同一个特性,一个范式的绝症,是另一个范式的日常。
「栈顶匹配某条右部就归约」是贪心陷阱:本卡表格倒数第二行栈为 E+T,栈顶 T 也匹配 E→T 的右部,可此刻句柄是整个 E+T——若抢先把 T 归约成 E,栈变成 E+E 就再也走不下去了。匹配右部只是必要条件,「是句柄」才充分。
手动模拟的自查不变式:任何时刻「栈内容 + 剩余输入」拼起来必须是一个最右句型,哪步拼不出来哪步就抄错了。另记住句柄永远在栈顶一段、绝不埋在栈中间——这正是能用栈实现的根本原因。

分析器凭什么知道「栈顶凑到句柄没有」?靠一台预先造好的自动机,而它的每个状态都由一组「项目」拼成。

圆点标进度,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· }                           // 增广项目移到末尾 ⇒ 接受
两个经典错步:① GOTO 移点后忘做闭包——本卡 GOTO(I0,'(') 漏了闭包就丢掉 S→·(S) 和 S→·a 两条;② 闭包拉入产生式时点位摆错——拉进来的永远是点在最左的初始项目 B→·γ,不是照抄原项目的点位。
手算两步口诀:「GOTO 先移点、后闭包」「闭包扩到不动点」——新拉进的项目点后若还是非终结符要继续拉。画完 DFA 拿状态数自查:本卡文法 S→(S)|a 连增广共 6 个状态,龙书表达式文法是 12 个。

光有 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 精确别名分析」都是它的同胞。精度、开销、假冲突/假保守,永远在同一个三角里拉扯。
两个混淆:① 自底向上裁「归约与否」用的是 FOLLOW,别拿 FIRST 去查——FIRST 是自顶向下选产生式的工具;② SLR 报冲突 ≠ 文法二义:S→L=R|R、L→*R|id、R→L 没有二义,却在 = 上撞出移进-归约冲突(LR(1) 就能收编它)。
查冲突先扫「混合状态」:状态里只有移进项、或只有一条归约项,什么都不用查;既有归约项又有别的动作,才需要拿 FOLLOW 裁决——整张表只有这类状态可能藏冲突。全表都不混合,文法干脆就是 LR(0)。

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)」的文法确实存在,但少见且刻意。
「LALR 合并可能引入任何冲突」——错,只可能新增 reduce/reduce,绝不会 shift/reduce(移进由 core 决定,被合并的状态 core 相同)。经典判定题文法 S→aEc|aFd|bFc|bEd(E→e、F→e):LR(1) 零冲突,合并后 e 的归约向前看撞车——遇到「是 LR(1) 非 LALR(1)」的题,冲突类型必为 r/r。
LR(1) 闭包的向前看一句话:由 [A→α·Bβ, a] 拉入 B→·γ 时,新向前看取 FIRST(βa)——β 非空先看 β,β 可空或为空串才轮到 a 上场。手算 LALR 别傻造全部 LR(1) 状态再合并:先造 LR(0) 骨架再传播向前看即可,Bison 用的正是这一族算法。

自动机造好后,落成两张表来驱动分析;而所谓「这个文法能不能 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——它意味着「同一段输入该归约成谁」根本没定,多半是文法设计撞了车。学会分辨「哪种冲突能忍、哪种必须改」,比一味追求「零冲突」更接近老手。
别迷信默认解法:Bison 对 s/r 默认移进(悬空 else 恰好蒙对),对 r/r 默认选文法中靠前的那条产生式——后者几乎从不是你要的语义,r/r 必须改文法。冲突被默认「解决」后表照样生成、程序照样能跑,警告一闪而过,这正是它危险的地方。
调 Bison 的两件利器:3.7+ 的 -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 combinatorYacc / Bison、LALRPOP、tree-sitter
  • 工业界的选择正在偏向 LL/手写递归下降——不是因为它理论上更强(恰恰更弱),而是因为报错质量和可调试性在真实编译器里比表达力值钱:GCC、Clang、Roslyn(C#)、rustc、V8 的解析器全是手写递归下降。表达力不够就用「加一点前瞻 + 少量特判」补,换来的是能给出人类看得懂的诊断信息。
  • 反过来 LR 的主场是你不控制文法的时候:为既有语言写工具、做语法高亮、做代码分析——tree-sitter 用 GLR 就是为了鲁棒地解析各种语言而不必为每种语言手写。
两个方向性错误:① 「LR 更强」是文法类的真包含(LL(1) 文法 ⊊ LR(1) 文法),别倒着记成 LL 罩住 LR;② 二义文法两边都救不了——它既非 LL(k) 也非 LR(k),对任意 k 成立;Yacc 能吃二义文法靠的是 %left 这类外挂声明强行消歧,不是 LR 本身变强了。
包含关系一句钉死:每个 LL(k) 文法都是 LR(k) 文法,反例只存在于另一个方向——左递归文法全体都是「LR 收、LL 不收」的活证据。选型的现实映射:手写/ANTLR ≈ LL 系,Yacc/Bison ≈ LALR,tree-sitter ≈ GLR。

语法制导翻译

分析确认了结构,但还没算出任何东西。本章把语义计算「挂」到上两章的文法上:用属性与语义规则,在分析的同时生成翻译结果、并构造出抽象语法树(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 的响应式重算,也都是同一个内核。
「继承属性」与面向对象的「继承」毫无关系——这里 inherited 只指「从父结点或左兄弟得来」。两个容易被忽略的边界:开始符号在树根、没有父结点,拿不到继承属性;终结符的属性由词法器给定、视作综合属性,不能有继承属性。
判别只看信息流向:往上汇总的是综合,从父结点或左兄弟传下来的是继承。写递归下降时它们就是代码的形状——继承属性是函数参数、综合属性是返回值,用这个对应判别几乎不会错。

把语义规则挂到产生式上,就是语法制导定义(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 的两种形态,就懂了为什么能「边分析边算、不留完整树」;而任何依赖成环的定义都算不了,因为找不到「先算谁」的起点。
别把「S-属性配 LR、L-属性配 LL」背成硬性专属——S-属性在递归下降里用返回值照样算,L-属性也能靠标记非终结符搬进 LR。真正的硬限制只有一条:依赖图有环,谁都算不了。
判 L-属性有机械口诀:逐条检查继承属性的来源,只许是「父的继承属性、左边兄弟的属性、自己」;一旦引用了右边兄弟的属性,立即判非 L-属性。综合属性不受这条限制,不用查。

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 这种名字),意外冲突往往就是它引来的。更本质的一点:动作是有副作用的(生成代码、报错、改符号表),所以分析顺序不能乱——这也是分析器坚持一遍确定顺序、不做投机重排的深层原因。
动作的位置不是装饰——同一个打印动作从产生式末尾左移一格,执行时机就从「归约完」变成「匹配到一半」,输出顺序全变。Bison 里冲突凭空多出来时,先怀疑 mid-rule action:它被改写成空产生式后会引入新的归约点。
记「SDD 管算什么,SDT 管何时算」即可分清两者。Bison 的 .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 后整个重打印)——编译器急着丢掉的「琐碎信息」,正是它们必须留住的东西。
「AST 一定删光括号」是课本理想型:LLVM 21,clang 的 AST 里 (1+2)*3 保留着 ParenExpr 结点——生产编译器为了精确诊断和重构工具,AST 往往比课本的更「具体」。另外 AST 合并公共子表达式后就成了 DAG,同一结点有多个父亲,天真的递归遍历会重复访问、重复计算。
想AST 和分析树的差别就是「抽象」二字:分析树忠实记录每一步推导,所以括号、分号、单产生式的中间结点全在;AST 只留语义上必要的结构——(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) 编码成不同符号——也是符号表要区分重载的直接产物。一张编译期的账本,一路延伸到了链接、调试乃至运行时反射。
「退出作用域就销毁那层表」只在可见性意义上成立——代码生成和调试信息后面还要查,真实实现是把表从当前可见链上摘下但保留(常挂在 AST 或作用域树上)。「先用后声明」也不是多查一次表能解决的:要么语言强制先声明(C),要么编译器先扫一遍收全声明再回头解析(Java/C#)。
记「哈希管快查、栈管嵌套」,两者叠加就是经典实现。想看符号表的「遗产」: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 没有循环体那层词法作用域可捕获。遮蔽则只是「最近嵌套」的自然结果:查名字从里往外,撞上第一个就停。
别把作用域(名字在哪可见)和生存期(对象活多久)混为一谈:C 的 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 把可空性纳入类型,才从根上堵住它。至于「强/弱类型」没有统一定义(混指了会不会隐式转换、能不能绕过类型系统好几件事),讨论时最好直接说清你指哪一种,别被这对含糊标签带偏。
「静态/动态」和「强/弱」是两根独立的轴,别搅在一起:Python 是动态但强'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 只是让编译器闭嘴,缩窄照样截断,连警告都替你关了。
两条最容易记错的规则:C 的 struct 按名字(标签)等价——成员完全相同的两个 struct 也不兼容;而 typedef 只造别名、不造新类型。想让隐式转换现形,开 -Wconversionlong → 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 能同时利于所有优化」逼出来的必然。
两个常见误会:一,「一个编译器一种 IR」——Clang 一条流水线就叠着 AST、LLVM IR、SelectionDAG、MachineIR 好几层;二,「IR 是通用标准」——LLVM IR 和 GIMPLE 互不相认,编译器 IR 本质是各家内部方言,「一份到处跑」的字节码(JVM/wasm)是为分发执行设计的另一物种。
这选 IR 高低的判据只有一条:你打算在这一层做什么优化。要做「函数内联、公共子表达式」这类与机器无关的事,就得留在高层(类型、控制结构还在);要做「寄存器分配、指令调度」,就得降到低层(地址、寄存器已显式)。这也是工业编译器为什么不止一层 IR——GCC 有 GENERIC/GIMPLE/RTL 三层,每层各服务一批优化。想逐层摸一遍: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 的 GIMPLE:gcc -fdump-tree-gimple -c x.ca = 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 的指针本身就是间接。认出「加一层间接」这个万能手筋,比记住间接三元式的细节重要得多——代价永远是那多出来的一次查表。
三元式里手滑写出 t1 是最常见的笔误——它的全部要点就是没有显式临时名,结果只能用位置编号引用。也别把「间接三元式」理解成「指针版三元式」:间接的是那张指令次序表,重排时指令本体一条都不挪。
记忆钩子:四元式给结果起名(t1),三元式给结果记行号((0)),间接三元式给行号再加一张目录。两种表示互转,先写四元式,再把每个临时量机械替换成定义它的行号,就得到三元式——比分别翻译快得多也稳得多。

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 问题」):多个 φ 本该同时并行赋值,天真地依次拷贝会互相覆盖,得靠引入临时量或排好顺序化解。记住 φ 是「选择器」而非「运算」,就不会被它绕晕。
「LLVM IR 天生是 SSA」得打补丁:-O0 下局部变量全是 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)得先把各下标减去下界再套公式。
LLVM 把这条地址算式做成了专门指令: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 的翻译就水到渠成——所以说跳转目标的安排才是整个翻译的核心。
短路保护的是可观察行为,不是「右边必定不执行」的机器承诺:clang -O1 把 a<b && c<d 折成无分支的 and i1——两边都算,因为比较无副作用、as-if 规则放行;右边一旦有解引用或函数调用才必须保住短路。手算翻译时最常见的错是把左假出口接到右操作数上——记「左假=整式假,左真才看右」。
两种译法在 LLVM IR 里都看得见:数值法是 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 下编出来只剩 addllealret 三条寄存器指令,栈一次没碰(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)、才有稳定的动态库与系统调用接口——跨语言、跨编译器互操作的地基就是这纸合约。栈帧里还有两条别混的链:控制链(动态链)指向「谁调用了我」、用于返回,反映运行时调用路径;访问链(静态链)指向「我定义在谁体内」、用于访问外层变量,反映源码嵌套结构。一个关乎「从哪来」、一个关乎「写在哪」。
  • 联动组成原理页:这里的栈帧与调用约定,组成原理页「过程调用:栈帧与调用约定」从硬件与安全(缓冲区溢出、栈金丝雀)视角又走了一遍,操作系统页则从进程/上下文切换的角度看同一块栈——三页对照最透。
fp 并非必需:gcc 和 clang 从 -O1 起默认 -fomit-frame-pointerpushq %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 的「传一个函数而不是传值」都是同一个机制的后代。
「参数靠压栈传递」是 32 位时代的课本简化:x86-64 SysV 下前 6 个整型参数走寄存器 rdi rsi rdx rcx r8 r9,第 7 个起才压栈——编个 7 参函数 gcc -S 一眼可见(GCC 15)。看栈帧图时记住:现代 ABI 里多数实参根本不在帧里。
两步实验判别任何语言的传参:在函数里①把形参整个重新赋值、②只改形参对象的字段,分别看外面变不变。①不变②变,就是 call-by-sharing——Java、Python 都是这个答案:「传的是值,但那个值本身是个引用」。

允许函数嵌套定义的语言有个特殊难题:内层函数要访问外层的局部变量,可外层的栈帧每次运行都落在不同位置,地址没法写死。

顺着定义的嵌套关系往外指

访问链(静态链)的解法是:每个栈帧存一根指针,顺着定义时的嵌套关系指向外层的活动记录;要访问外 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 / 环境),让它的寿命跟着闭包走、而非跟着那个早已返回的栈帧。所以「局部变量在栈上」这个直觉一遇闭包就得打破——这也是带闭包的语言几乎都离不开垃圾回收的深层原因。
静态链回溯步数=嵌套深度差,与调用深度无关:g 递归 100 层后其内层函数访问 g 的 x,控制链上百节,静态链仍只走「定义嵌套差」那一两步。把两条链混着数、拿调用栈深度当回溯步数,是最常见的错。
想亲眼看到静态链:GNU C 扩展允许函数嵌套,写个「内层函数用外层局部变量」的例子,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)怎么做,见操作系统页内存管理章。
「有 GC 就不会内存泄漏」是错觉:追踪式 GC 回收的是「不可达」,不是「不再用」——忘了注销的监听器、只进不出的缓存 map 都还可达,GC 一个不收。Java、JS 照样泄漏,只是从「忘了 free」换成了「忘了断引用」。
Python 一门语言就能摸到两大流派:平时靠引用计数即时回收(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 再在上面推理。学会「把线性代码切成块、连成图」这一步,等于拿到了几乎所有程序分析工具的入场券。
手算切块两大经典错:一是忘了「条件跳转的下一条」(fall-through 那条)也是 leader;二是把 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 编译开销」的取舍。
术语陷阱:编译器嘴里的「全局优化」,「全局」指单个函数内跨基本块,不是全程序!跨函数叫过程间优化(IPO),跨编译单元得上 LTO——把 global 望文生义成 whole-program 是初学者高频错。
LLLVM 恰好把这个层级差做成了两个 pass:对「菱形两臂各算一次 a+b、汇合点再算一次」的 IR,early-cse(只沿支配路径看)动不了汇合点那一次,换 gvn(全局值编号)则插入 φ 把它消掉——「范围越大、能证明的事越多」在这里是可以并排看的两个产物。(喂给 opt 之前要先绕开 optnone,办法在 09 章 SSA 卡。)

这是编译器的优化武器库,每一件都是「在不改语义的前提下让代码更省」的一次改写。而它们真正的门道,不在单件威力,而在彼此配合。

一箱互相喂球的改写

公共子表达式消除(CSE) 把算过一遍的表达式复用;复制传播常量传播 / 折叠 把变量替换成它已知的值、能当场算的就当场算;死代码消除(DCE) 删掉永远用不到的计算;代数化简 抹掉 x*1x+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。
「-O0 完全不优化」是伪命题:gcc -O0 下 int x=1+2; 在前端 GIMPLE 里已是 x = 3,汇编直接 movl $3(GCC 15)——字面量的常量折叠发生在前端。优化器(如 sccp)负责的是跨语句、跨变量的常量「传播」,别把折叠全记在 -O2 头上。
「一步喂下一步」可以在 opt-21 里逐帧回放:对「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、少用全局多用局部能让优化器放开手脚。优化的天条是「只能更快、不能改变语义」,编译器对外提的谨慎,正是在死守这条底线。
不是所有带环的 CFG 都是自然循环:回边要求「头支配尾」,双入口的不可归约环里没有一条边满足它,圈不出自然循环,多数循环优化直接放弃。手画 CFG 把任何环路都当自然循环、再套外提公式,是最常见的错。
两条路亲眼看 LICM:手写循环里带 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 的局面。支配关系本身也溢出到编译器之外——控制依赖、程序切片、某些安全分析都建在它上面。一个把「φ 放哪最省」讲清楚的算法,撬动了整个现代优化的基础设施。
「别把支配关系和 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 结构都翻译得分毫不差,再谈榨性能。
别把代码生成想成「一条 IR 翻一条指令」的机械直译——指令选择、寄存器分配、调度都在后端做优化决策:同一份 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 又如何用几百个物理寄存器重命名绕开这个限制,见组成原理页指令集与微架构章。
「结点度数 ≥ k 就染不了色」是经典错判:邻居之间可能撞色,留给它的颜色往往还有——Briggs 的乐观着色正是靠「先压栈赌一把」修正了 Chaitin 的保守。另外别把线性扫描当成图着色的省事等价物:它赢在编译得快(JIT 在乎这个),生成代码的质量通常要让一截。
想亲眼看分配器的差距:同一份 IR 用 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)——穷举搜索出实现同一功能的最短指令序列,真有工具在用。一个靠重排、一个靠替换,都是最后一公里的榨取。
调度「只要保持数据依赖」说的可不止 RAW 一种:除写后读外还有反依赖(WAR)、输出依赖(WAW),以及最容易漏的内存依赖——两条 load/store 只要可能指向同一地址(别名分析说不清),就不能换序。手推调度时漏了这几类,重排出的序列会「看着更快、结果算错」。
窥孔最经典的产物你天天在汇编里见:返回 0 的函数被 GCC 和 Clang 都编成 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 章「前中后端解耦」思想的工业级兑现,也是这一整页理论真正开花结果的地方。
「LLVM IR 平台无关,一次生成到处运行」——错。clang -emit-llvm 吐出的 IR 开头几行就钉着 target datalayouttarget 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 换掉这段预热、拿冷启动速度。运行时编译的红利与开销,是同一枚硬币的两面。
别把「Java/JS 是解释型语言」当铁律——编译还是解释是实现的选择,不是语言的属性:HotSpot、V8 里热代码跑的是货真价实的机器码,反过来 C 也有解释器。同理「JIT 一定快/慢于 AOT」都不成立:长跑服务里 JIT 可借投机反超,冷启动的短命进程则被预热拖死。
记分层就记两条真实流水线(官方文档口径):HotSpot 是解释器 → C1(客户端编译器,快而浅)→ C2(服务端编译器,慢而深);V8 是 Ignition 解释器 → Sparkplug 基线 → Maglev 中层 → TurboFan 顶层。判别标尺:层越高,编译越贵、代码越快、越依赖运行画像。

有些优化光靠静态分析下不了手,得引入额外信息、或搬出更重的数学。

用真实运行画像,或更重的数学

PGO(画像引导优化)先拿真实负载跑一遍,用采样或插桩收集一份运行画像——哪条分支热、哪个函数值得内联、代码该怎么摆放——再据此编译。多面体模型(polyhedral) 则用一套精确的数学刻画循环嵌套的依赖关系,支撑激进的循环变换。再往上还有自动向量化(SIMD)自动并行化。PGO 的成败全系于一个前提,下面点破,稍不留神它会帮倒忙。

拿真实负载指导编译

① 插桩编译  →  ② 拿真实负载跑一遍,采集画像
                (哪条分支热、哪个函数值得内联、代码怎么摆)
③ 按画像重新编译  →  热路径内联、代码紧凑排布

成败全看:采画像用的输入,像不像日后真实负载?
拿玩具输入采的画像,可能把冷路径当热路径 —— 帮倒忙

深入理解:PGO 把「先测量再优化」自动化了——但画像会过期

  • PGO 把 JIT 那套「用运行信息指导优化」搬到了 AOT:先拿真实负载跑一遍、采集哪条分支热、哪个函数值得内联,再据此编译。它其实是把每个程序员都听过的老话"先测量、再优化(别过早优化)"自动化、规模化了——只不过测量的是分支与调用频率,优化的是内联、代码布局、冷热分区。成败全系于一个前提:采画像用的输入,像不像日后真实负载?拿玩具输入跑出的画像去指导生产,可能把冷路径当热路径优化、帮倒忙;代表性不足的画像有时比没有还危险。工程上还有个绕不开的痛点——画像会过期:代码一改旧画像就对不上了。于是 Google 搞出 AutoFDO:直接用 perf 采样生产环境(无需插桩、开销极低),让画像始终贴着真实流量,在整个机群规模上持续喂给编译器。
「写了循环编译器就会自动向量化」想得太美:GCC 15 在 -O2 下对再普通不过的 a[i]=b[i]*2.0f 循环报 missed、并不向量化,-O3 才向量化——且因「指针可能别名」还得额外生成带运行时检查的版本(输出「loop versioned for vectorization because of possible aliasing」)。成没成别猜,用 -fopt-info-vec / -Rpass=loop-vectorize 看报告。
上手 PGO 只要三步(GCC 15):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」,而是「批处理编译,还是伺候一个每秒都在改的活文档」。
别被「compiler-compiler」这名字唬住:Yacc/Bison 只生成语法分析器那一小段(Lex/Flex 只管词法),语义分析、IR、优化、代码生成全得你自己写——「用了生成器」离「有了编译器」还差整条后半程。
选型三句话:写新语言前端、要好报错 → ANTLR 或手写递归下降;语法要罩得广、批处理解析(配置/DSL)→ Bison 一类 LALR 生成器;伺候编辑器里「每秒都在改的活文档」→ tree-sitter + LSP。报出工具名,等于把 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; }
「模板编译」不等于「模板安全」。编译器只处理你写在源码里的模板;运行时拼出来的 HTML 字符串(v-html / dangerouslySetInnerHTML)完全绕过它,既没有语法检查也没有转义。这类 API 的输入如果来自用户,就是标准的 XSS 入口。编译期的保护只覆盖编译期能看到的东西——这条对任何语言的任何编译器都成立。
判断一门「模板语言」值不值得编译,看它的静态占比:一个页面里通常 70% 以上的节点永远不变(布局容器、标题、图标)。编译器能把这部分彻底移出每次渲染的路径,收益随页面复杂度线性增长——这正是下一卡里 _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" }).code
「JSX 是 React 的语法」是过时的说法。它只是一种语法,编译目标可以是任何函数:Preact 编到 h()、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,而那又带来另一组问题(样式无法从外部定制)。
这三种做法都可以用同一个问题验证:「这条规则是在什么时候被决定的?」构建期决定 → 产物里能直接搜到;运行时决定 → 产物里只有生成它的代码。用产物搜一遍,比读文档更快得到答案——这也是判断一个样式方案能不能在 SSR / 服务端组件里用的最直接办法。

微项目:亲手写一个编译器

前面十四章拆开讲的每个阶段,这一章连成一条真能跑的链:给一门只有 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后序遍历重写 AST32 个结点折成 13 个
⑤ 字节码 + VM中间代码09、13栈式指令集 + 回填跳转同一程序快约 3 倍
⑥ 降到 LLVM IR目标代码09、12、13alloca/load/store + 基本块mem2reg 当场生出 φ 函数

怎么用这一章

  • 先自己写,卡住了再看代码。每张卡的结构都是「目标 → 关键几十行 → 本机跑出来的输出 → 现在你来」,中间那段代码是骨架不是答案,故意留了扩展空间。
  • 随时可以停。只做①②就已经拥有一个能画出 AST 的解析器;做到③就有一门能跑的语言;④⑤⑥ 是三个独立的加分项,顺序可换。
  • 改坏它比写对它更有价值:把②里 binwhile 改成递归调用自己(结合性立刻反过来)、把③的短路分支删掉(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 → 可执行文件
别把这六级当成「玩具版 gcc」。它们复刻的是阶段的骨架,刻意省掉了真编译器里占代码量九成的东西——完整的类型系统、函数与调用、错误恢复、寄存器分配、边界检查。正因为省掉了,你才看得见骨架;也正因为省掉了,别把这里的代码抄进任何真实项目。真正带走的是「这个阶段凭什么存在」,不是这几十行本身。
工具只要 node(前五级)加一个 clang(第六级),两样都是几分钟装好的东西。建议从第一天起就写测试:准备十来个「源码 → 期望输出」的样例,每加一个特性全跑一遍——编译器是回归测试收益最高的程序之一,因为它的每次改动都可能悄悄改变某一类输入的行为,而你不可能每次都手工验证。

第一级最简单,也最容易低估:它是全链条唯一见过原始字符的一站,行号、注释、空白的去留全在这里定死,后面所有阶段都只认 token。写完它,03 章那句「语法分析器再也看不见字符」就有了实感。

三件必须做对的事

  • 最长匹配:扫到 < 别急着交货,先看下一个字符是不是 =——<= 必须整体成为一个 token。代码里就是那句「先试双字符表,再退回单字符」,顺序反了 a <= b 会被切成 <=
  • 保留字表letwhile 和标识符长得一模一样,所以先按标识符规则扫出整个词,再查表决定它的种别——这正是 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+ba + 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 会被切成 letter,而且汇编不报错、语法分析才报一个看不懂的错。规则只有一条——先按标识符的最长匹配扫完,再查表判种别。同理,双字符运算符表必须在单字符之前试,顺序写反了 != 会变成 !=
末尾那个 eof 哨兵 token 是最划算的一行代码:有了它,解析器里所有 peek() 都不必再判越界——想读下一个总能读到,读到 eof 就是「没了」。这是 03 章双缓冲哨兵的同一种手筋,在只有几十行的词法器上一样成立。现在你来:加上字符串字面量(要处理转义与「未闭合引号」的报错)和浮点数——两者都会逼你在扫描循环里加一个小状态机。

05 章说「一个非终结符一个函数」,写出来才知道它有多直白:优先级不是一张要背的表,而是函数的调用层数;结合性不是一条规则,而是你用循环还是用递归。这两句话是这一级全部的收获。

优先级爬升:层号就是优先级

  • 把二元运算按优先级从低到高排成一串函数,每层只做一件事:调用下一层,然后循环吃掉本层的运算符or → and → equality → compare → term → factor → unary → primary 八层,*+ 先算,仅仅因为 factorterm 的下面一层。
  • 这就是 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);把上面那句改成递归自调用,同一段源码输出 1820-(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 / 0Infinity 而不是报错,取整后成了 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
  }
};
别让求值器悄悄承担本该归语义分析的活。这一版里「未定义变量」是跑到那一行才报的——循环里第 100 万次才走到的分支,错误就第 100 万次才暴露。真编译器把这类检查提前到编译期,代价是必须多走一趟、多维护一份符号表。体会到这个代价,才算真懂 08 章为什么要单独存在;在自己的语言里加一趟「跑之前先扫一遍所有变量是否声明过」,是本级最值得做的扩展。
注意 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*1x+0x*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;
    }
  }
}
别把这里的三倍当成「字节码相对树遍历的普适倍数」。它高度依赖宿主语言:本页在 Node 上量到约 3 倍,同样的两版实现放进 C 里差距通常更大(树遍历的指针追逐与函数调用在 C 里更贵),而在本身就有强 JIT 的宿主上,树遍历有时会被优化得意外地好。倍数会变,方向不会变——要引用具体数字,就在你自己的机器上跑一遍,别抄这一页。
先写反汇编器,再写 VM。三十行的 disasm 让字节码从一串数字变成可读的清单,回填算错一格立刻看得见——没有它,调跳转 bug 全靠盯着数组下标猜。这和 01 章「先学会看每一站的产物」是同一条方法论:能打印的中间产物,一定要先做出打印功能现在你来:把两条常见的相邻指令合成一条「超级指令」(比如 LOAD 紧跟 CONST),再测一次,看能不能再挤出一成。

最后一级不写机器码——写 LLVM IR,然后把它交给 clang。这正是 02 章「m+n 而非 m×n」的兑现:你只写了个前端,就白拿了整条工业级优化管线和全部目标平台。产物是一个真正的可执行文件。

生成策略:先别管 SSA

  • 每个变量老老实实 alloca 一个栈格,读写全走 load/store——不要试图自己构造 φ 函数。这不是偷懒,而是 LLVM 官方推荐的前端写法:把内存形式交给 mem2reg,它会用 11 章那套支配边界算法替你放 φ,比手写靠谱得多。
  • 控制流按 09 章的方式编成基本块 + brwhile 拆成 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
285
别在前端里手工构造 φ。看着「只是把 load/store 省掉」,实际要正确放置 φ 得先算支配树与支配边界(11 章),前端一旦写错,症状是某个分支下变量取到旧值——IR 校验器还未必拦得住。官方给前端的建议就是一句话:变量一律 alloca,让 mem2reg 去做。另外 optclang -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 给编辑器写一个语言的语法高亮——都是编译技术的「日常岗位」。
最常见的烂尾路线是跳级:一上来就「自制语言 + LLVM 后端」,热情全耗在构建配置和 API 上。15 章第③级的树遍历求值器零依赖、一个晚上就能跑通,先拿到「我的语言能跑」的正反馈再上台阶;同理别把龙书从头啃到尾当路线——它是参考书,动手项目才是主线。
一个统一的自测标准:拿一段 10 行的 C 代码,能否口头走完它的一生——词法切出哪些 token、语法树长什么样、生成什么中间代码、哪些优化会命中、最后的汇编大致什么形状。走得下来,说明十三章已连成一条流水线;卡住的那一段,就是下一个该动手实现的阶段——15 章正好按这个顺序把六级台阶排好了,直接从卡住的那一级开工。