最近在看 C++、Rust、Go 的编译过程。
刚开始最容易被一堆名词卡住:
- GCC
- Clang
- LLVM
- AST
- IR
- SSA
- MIR
- GIMPLE
- RTL
- Linker
- Loader
单独看每个词,好像都能理解一点。但把它们放到一张图里,就很容易乱。
后来我发现,问题不在于名词太多,而在于没有从一段真实程序出发。
编译器不是简单地“把源码翻译成汇编”。更准确地说,它是在不断降低程序的抽象层级:
每一层中间表示,都是为了让某一类问题变得更容易处理。
下面用一段很小的 C++ 程序,把主流编译链路串起来。
这段代码故意保留了几个点:
一个小程序,基本够覆盖主流编译流程。
1. 源码不是编译器真正想要的东西
程序员看到的是:
人很容易理解它大概等价于:
但编译器不能一开始就这么理解。
源码只是字符流:
编译器必须先把字符处理成更稳定的结构。
这也是为什么现代编译流程不会从源码直接跳到汇编。源码适合人读,不适合机器分析。
2. 预处理:C/C++ 最早的一层文本系统
C/C++ 的第一步通常是预处理。
预处理器主要处理:
在这段程序里,最明显的是:
预处理后,核心代码会变成类似这样:
这一步有个很重要的认知:
预处理不是语义分析。
它不理解类型。
它不知道 add 是不是函数。
它也不知道 INC(add(1, 2)) 是否合理。
它只是按照规则展开文本。
这也是 C/C++ 宏容易制造问题的原因。宏不是函数,它不会遵守函数那套类型检查和作用域规则。
例如:
如果写:
会被展开成:
而不是:
很多 C/C++ 的奇怪问题,其实还没进入真正编译阶段,只是在预处理阶段就已经埋雷了。
3. 词法分析:把字符切成 Token
预处理后,编译器开始做词法分析。
源码片段:
会被切成类似:
这一步只负责“切词”。
它知道 int 是关键字。
知道 x 是标识符。
知道 1 是数字字面量。
但它还不知道:
这些都不是词法分析负责的。
可以用 Clang 看 Token:
如果编译错误里出现非法字符、字符串没有闭合、数字字面量格式错误,通常就停在这一层附近。
4. 语法分析:从 Token 变成 AST
Token 还是线性的。
编译器需要知道程序结构。
例如:
语法分析后会变成一棵树,简化后大概是:
这就是 AST,抽象语法树。
AST 的作用不是为了显得高级,而是为了把源码结构稳定下来。
源码是文本。
AST 是结构。
这一步之后,编译器终于知道:
但 AST 仍然不能证明程序正确。
例如:
即使 foo 根本不存在,也可以先构建 AST。
语法上没问题。
语义上才有问题。
可以用 Clang 看 AST:
真实 C++ AST 会非常长。模板、重载、隐式转换、构造函数、析构函数都会出现在里面。
这也是 C++ 前端复杂度很高的原因。不是因为 Token 难切,也不是因为语法树难画,而是因为语义太复杂。
5. 语义分析:编译器真正开始理解程序
语义分析会处理这些问题:
对这段代码来说:
预处理后是:
语义分析会确认:
如果写成:
语法仍然成立。
AST 也能构建。
但语义分析会报错,因为参数类型不匹配。
很多开发者平时说“编译错误”,其实可以再细分:
这三个错误发生在完全不同的阶段。
Rust 的 Borrow Checker 也属于这个大范围。
例如:
这不是语法错误。
它的结构完全合法。
真正的问题是语义层面的:x 已经离开作用域,r 不能再引用它。
所以 Rust 的所有权、借用、生命周期,不应该理解成语法特性,而应该理解成更强的语义分析。
这也是 Rust 编译器比 Go 编译器复杂很多的原因之一。
6. AST 不适合优化,所以需要 IR
很多人第一次学编译流程,会以为:
这个模型太粗糙。
现代编译器不会长期停留在 AST 上做优化。
原因很简单:
AST 太接近源码。
例如:
AST 会保留很多源码结构。
但优化器真正关心的是:
AST 对这些问题并不友好。
所以编译器会把 AST 降低到 IR。
IR 是 Intermediate Representation,中间表示。
比如这段:
可以理解为降低成类似三地址码:
这就比 AST 更适合优化。
表达式树被拆成了简单指令。
数据依赖也更清楚。
LLVM IR 会更像这样,简化后:
这里有几个点值得注意。
add 在 C++ 里可能会被编译成:
这是 C++ name mangling。
因为 C++ 支持函数重载,链接器不能只看到一个名字 add。
例如:
这两个函数源码里都叫 add,但链接时必须区分。
而 printf 因为声明成:
所以不会被 C++ 改名,仍然叫:
这就是 extern "C" 的实际意义之一:控制符号名,方便和 C ABI 对接。
7. 优化:编译器开始改写程序
如果不开优化,编译器会比较忠实地保留程序结构。
如果打开优化:
这段代码很可能被优化成类似:
add(1, 2) 没了。
INC 展开后的 + 1 也没了。
x 也没了。
最后只剩:
这不是编译器乱改代码。
这是它证明了这些改写不会改变可观察行为。
这里发生了几类典型优化:
如果看过 Java JIT、Go SSA、Rust LLVM 优化,会发现这些优化名字经常重复出现。
原因很简单:程序优化的基本问题是相通的。
8. SSA:现代优化器为什么喜欢“变量只赋值一次”
SSA 是理解现代编译器优化的关键。
SSA,全称 Static Single Assignment。
意思是每个变量在静态程序里只赋值一次。
看这个例子:
普通代码里,x 被赋值两次。
控制流一复杂,优化器就很难判断某个位置的 x 到底来自哪里。
SSA 会把它变成类似:
这里的:
表示:
SSA 的价值在于,它把“变量会变化”这件事变成了显式的数据流关系。
这对优化非常重要。
例如:
都会受益于 SSA。
LLVM IR 是 SSA 形式。
Go 编译器内部使用 SSA。
HotSpot C2 也使用接近 SSA 的 Sea of Nodes IR。
很多数据库优化器虽然不叫 SSA,但也会维护类似的数据依赖关系。
这不是某个编译器的小技巧,而是现代优化系统的基础方法。
9. 后端:从平台无关 IR 到机器相关 IR
LLVM IR 仍然是平台无关的。
比如:
这句话没有指定:
这些都是后端的问题。
后端要处理:
同一个加法,在 x86-64 下可能是:
在 ARM64 下可能是:
IR 相同,目标机器不同,最终指令就不同。
这也是 LLVM 的价值所在。
语言实现者只要生成 LLVM IR,就可以复用 LLVM 的后端能力。
否则每写一门语言,都要自己支持:
这几乎不现实
10. 目标文件:机器码还不是最终程序
后端生成汇编后,汇编器会生成目标文件:
main.o 里面有机器码,但它还不是完整程序。
可以用:
看到符号。
可能会看到类似:
含义大概是:
U printf 不是错误。
它只是说:当前目标文件里没有 printf 的实现,链接阶段需要去别的地方找。
这就是很多人第一次遇到 undefined reference 时容易误解的地方。
它不是语法错误。
也不是类型错误。
它是链接阶段的符号解析失败。
11. 链接:把分散的二进制拼成一个程序
链接器做的事情可以粗略理解为:
它要解决:
如果写 C++,还会涉及:
比如:
说明链接器找不到 foo 的实现。
说明多个目标文件都提供了同一个强符号。
说明 C++ 符号名对不上,可能是声明和定义不一致,也可能是 C/C++ ABI 混用出了问题。
链接器是独立于编译器前端的另一个大系统。
很多大型 C++ 工程的构建问题,其实不是编译器问题,而是链接器问题。
12. 加载:程序运行前,操作系统还要接手
执行:
也不是 CPU 直接从 main 开始跑。
Linux 下大致会经历:
程序的内存布局大致包括:
所以严格说,完整链路不是:
而是:
编译器解决“怎么生成程序”。
链接器解决“怎么合成程序”。
加载器解决“怎么把程序变成进程”。
13. Clang、LLVM、GCC 应该怎么放在一张图里
现在再看这些名字,就清楚很多。
Clang + LLVM 是这样:
GCC 是另一条链路:
这两条链路解决的是同一类问题,但内部表示不同。
Clang 不是 LLVM 的别名。
LLVM 也不是 Clang 的别名。
更准确地说:
对应关系可以这样记:
14. Rust 为什么有 HIR、MIR,又为什么用 LLVM
Rust 的流程大致是:
Rust 这几层不是为了显得复杂。
每层都有明确责任。
AST 接近源码。
HIR 会把一些语法糖和表层结构降下来,让程序形态更稳定。
THIR 更适合类型检查后的表达式分析。
MIR 是 Rust 很关键的一层,适合表达控制流、move、borrow、drop、生命周期等语义。
LLVM IR 则负责进入通用优化和机器码生成阶段。
这里要注意一个区分:
Borrow Checker 不能直接依赖 LLVM IR。
因为 LLVM IR 已经太低级,Rust 的 ownership、borrow、drop 语义在那一层已经不适合作为主要分析对象。
也不能直接依赖 AST。
因为 AST 太接近语法表面,语法糖太多,不适合做严格的数据流和控制流分析。
所以 Rust 需要 MIR。
这就是中间表示真正的价值:不是多一层抽象,而是为特定分析提供合适的程序形态。
15. Go 为什么看起来简单很多
Go 的编译流程大致是:
Go 默认不走 LLVM。
它有自己的 SSA 和后端。
这和 Go 的设计目标有关。
Go 语言本身刻意保持简单:
所以 Go 编译器可以更直接。
这也是 Go 编译速度快的一个重要原因。
当然,“简单”不是说 Go 编译器没有技术含量。
Go 的逃逸分析、内联、SSA 优化、栈增长、GC 相关元数据生成,都有不少工程细节。
但和 C++、Rust 相比,Go 前端语义复杂度确实低很多。
16. 为什么现代编译器总在发明新的 IR
到这里,基本可以回答最初的问题。
为什么 GCC 有 GIMPLE 和 RTL?
为什么 LLVM 有 LLVM IR、SelectionDAG、Machine IR?
为什么 Rust 有 HIR、THIR、MIR?
为什么 Go 有 SSA?
因为没有一种表示适合所有阶段。
AST 适合表示源码结构。
HIR 适合消除表层语法。
MIR 适合表达语言语义和控制流。
LLVM IR 适合做平台无关优化。
Machine IR 适合做寄存器分配和指令调度。
RTL 适合 GCC 后端描述接近机器的操作。
同一段程序,在不同阶段要被看成不同的东西。
对人来说,它是业务逻辑。
对前端来说,它是语法树。
对类型系统来说,它是约束集合。
对优化器来说,它是数据流图。
对后端来说,它是指令选择和寄存器分配问题。
对链接器来说,它是符号和重定位记录。
对加载器来说,它是 ELF 段和动态库依赖。
编译器的复杂性,正是来自这些视角之间的切换。
17. 真正需要记住的主流流程
如果只保留主流路径,不陷入全部细节,可以记成这条线:
对应到几个主流编译器:
这张图比单独背 AST、IR、SSA 更有用。
因为它告诉你每个系统在同一条工业流水线里的位置。
18. 学这套东西对工程有什么用
这不是纯理论。
遇到宏问题,你知道去看预处理结果:
遇到语法和语义问题,你知道它发生在前端:
想看优化前后的差异,你知道去看 LLVM IR:
遇到符号找不到,你知道看目标文件:
想看 ELF 结构:
想看汇编:
这些工具不是为了炫技。
它们对应的是编译流程中的不同阶段。
能定位阶段,问题就已经解决了一半。
19. 编译器真正有价值的地方
编译器最有价值的地方,不是几个术语,而是一种看复杂系统的方法。
一个复杂输入,不会被直接执行。
它会先变成某种中间表示。
然后被分析、约束、优化、降低,最后才进入执行层。
这个模式不只存在于编译器里。
数据库会把 SQL 变成逻辑计划和物理计划。
JVM 会把字节码变成内部 IR,再交给 JIT 优化。
React 会把 UI 更新组织成 Fiber 结构。
Kubernetes Scheduler 会把调度请求变成资源约束和评分模型。
这些系统看起来不一样,但底层思路很接近:
编译器只是这个思想最经典、最完整的版本。
如果能把 C++、Rust、Go、GCC、LLVM 这条线看懂,再回头看 JVM、数据库、前端框架、调度系统,很多设计就不再是孤立的名词了。