从 C++ 到 Rust、Go:把主流编译流程真正串起来
最近在看 C++、Rust、Go 的编译过程。
刚开始最容易被一堆名词卡住:
- GCC
- Clang
- LLVM
- AST
- IR
- SSA
- MIR
- GIMPLE
- RTL
- Linker
- Loader
单独看每个词,好像都能理解一点。但把它们放到一张图里,就很容易乱。
后来我发现,问题不在于名词太多,而在于没有从一段真实程序出发。
编译器不是简单地“把源码翻译成汇编”。更准确地说,它是在不断降低程序的抽象层级:
人类写的源码
↓
结构化表示
↓
语义明确的表示
↓
适合优化的表示
↓
接近机器的表示
↓
机器可以执行的二进制
每一层中间表示,都是为了让某一类问题变得更容易处理。
下面用一段很小的 C++ 程序,把主流编译链路串起来。
extern "C" int printf(const char*, ...);
#define INC(x) ((x) + 1)
int add(int a, int b) {
return a + b;
}
int main() {
int x = INC(add(1, 2));
printf("%d\n", x);
return 0;
}
这段代码故意保留了几个点:
宏:INC
函数调用:add
外部符号:printf
C++ 符号:add 会发生 name mangling
链接阶段:printf 需要从 C 标准库解析
优化空间:add(1, 2) 和 INC 可以被优化成常量
一个小程序,基本够覆盖主流编译流程。
1. 源码不是编译器真正想要的东西
程序员看到的是:
int x = INC(add(1, 2));
人很容易理解它大概等价于:
int x = ((add(1, 2)) + 1);
但编译器不能一开始就这么理解。
源码只是字符流:
i n t 空格 x 空格 = 空格 I N C ...
编译器必须先把字符处理成更稳定的结构。
这也是为什么现代编译流程不会从源码直接跳到汇编。源码适合人读,不适合机器分析。
2. 预处理:C/C++ 最早的一层文本系统
C/C++ 的第一步通常是预处理。
clang++ -E main.cpp -o main.ii
预处理器主要处理:
#include
#define
#ifdef
#pragma
在这段程序里,最明显的是:
#define INC(x) ((x) + 1)
预处理后,核心代码会变成类似这样:
int main() {
int x = ((add(1, 2)) + 1);
printf("%d\n", x);
return 0;
}
这一步有个很重要的认知:
预处理不是语义分析。
它不理解类型。
它不知道 add 是不是函数。
它也不知道 INC(add(1, 2)) 是否合理。
它只是按照规则展开文本。
这也是 C/C++ 宏容易制造问题的原因。宏不是函数,它不会遵守函数那套类型检查和作用域规则。
例如:
#define SQUARE(x) x * x
如果写:
SQUARE(1 + 2)
会被展开成:
1 + 2 * 1 + 2
而不是:
(1 + 2) * (1 + 2)
很多 C/C++ 的奇怪问题,其实还没进入真正编译阶段,只是在预处理阶段就已经埋雷了。
3. 词法分析:把字符切成 Token
预处理后,编译器开始做词法分析。
源码片段:
int x = ((add(1, 2)) + 1);
会被切成类似:
keyword int
identifier x
operator =
punctuator (
punctuator (
identifier add
punctuator (
literal 1
punctuator ,
literal 2
punctuator )
punctuator )
operator +
literal 1
punctuator )
punctuator ;
这一步只负责“切词”。
它知道 int 是关键字。
知道 x 是标识符。
知道 1 是数字字面量。
但它还不知道:
x 是局部变量
add 是函数
printf 是外部函数
1 能不能传给 add
这些都不是词法分析负责的。
可以用 Clang 看 Token:
clang++ -Xclang -dump-tokens -fsyntax-only main.cpp
如果编译错误里出现非法字符、字符串没有闭合、数字字面量格式错误,通常就停在这一层附近。
4. 语法分析:从 Token 变成 AST
Token 还是线性的。
编译器需要知道程序结构。
例如:
int x = ((add(1, 2)) + 1);
语法分析后会变成一棵树,简化后大概是:
VarDecl x : int
└── BinaryOperator +
├── CallExpr add
│ ├── IntegerLiteral 1
│ └── IntegerLiteral 2
└── IntegerLiteral 1
这就是 AST,抽象语法树。
AST 的作用不是为了显得高级,而是为了把源码结构稳定下来。
源码是文本。
AST 是结构。
这一步之后,编译器终于知道:
这是一个变量声明
变量名是 x
初始化表达式是一个加法
加法左边是函数调用
加法右边是整数 1
但 AST 仍然不能证明程序正确。
例如:
int x = foo(1, 2);
即使 foo 根本不存在,也可以先构建 AST。
语法上没问题。
语义上才有问题。
可以用 Clang 看 AST:
clang++ -Xclang -ast-dump -fsyntax-only main.cpp
真实 C++ AST 会非常长。模板、重载、隐式转换、构造函数、析构函数都会出现在里面。
这也是 C++ 前端复杂度很高的原因。不是因为 Token 难切,也不是因为语法树难画,而是因为语义太复杂。
5. 语义分析:编译器真正开始理解程序
语义分析会处理这些问题:
add 是否存在
add 参数数量是否正确
add 参数类型是否匹配
printf 是否声明过
int x 的初始化是否合法
函数返回值是否符合声明
对这段代码来说:
int x = INC(add(1, 2));
预处理后是:
int x = ((add(1, 2)) + 1);
语义分析会确认:
add 的类型是 int(int, int)
add(1, 2) 返回 int
返回值可以和 1 做加法
结果可以赋给 int x
如果写成:
int x = add("hello", 2);
语法仍然成立。
AST 也能构建。
但语义分析会报错,因为参数类型不匹配。
很多开发者平时说“编译错误”,其实可以再细分:
语法错误:代码结构不合法
语义错误:结构合法,但意思不合法
链接错误:单个文件能编译,但最终符号找不到
这三个错误发生在完全不同的阶段。
Rust 的 Borrow Checker 也属于这个大范围。
例如:
let r;
{
let x = 1;
r = &x;
}
println!("{}", r);
这不是语法错误。
它的结构完全合法。
真正的问题是语义层面的:x 已经离开作用域,r 不能再引用它。
所以 Rust 的所有权、借用、生命周期,不应该理解成语法特性,而应该理解成更强的语义分析。
这也是 Rust 编译器比 Go 编译器复杂很多的原因之一。
6. AST 不适合优化,所以需要 IR
很多人第一次学编译流程,会以为:
源码
↓
AST
↓
汇编
这个模型太粗糙。
现代编译器不会长期停留在 AST 上做优化。
原因很简单:
AST 太接近源码。
例如:
int x = INC(add(1, 2));
AST 会保留很多源码结构。
但优化器真正关心的是:
这个值从哪里来
这个值被谁使用
这个计算有没有副作用
这个分支是否可达
这个变量能不能放进寄存器
这个函数能不能内联
AST 对这些问题并不友好。
所以编译器会把 AST 降低到 IR。
IR 是 Intermediate Representation,中间表示。
比如这段:
int x = INC(add(1, 2));
可以理解为降低成类似三地址码:
t1 = call add, 1, 2
t2 = t1 + 1
x = t2
call printf, "%d\n", x
return 0
这就比 AST 更适合优化。
表达式树被拆成了简单指令。
数据依赖也更清楚。
LLVM IR 会更像这样,简化后:
define i32 @_Z3addii(i32 %a, i32 %b) {
entry:
%sum = add nsw i32 %a, %b
ret i32 %sum
}
define i32 @main() {
entry:
%call = call i32 @_Z3addii(i32 1, i32 2)
%add = add nsw i32 %call, 1
call i32 (ptr, ...) @printf(ptr @.str, i32 %add)
ret i32 0
}
这里有几个点值得注意。
add 在 C++ 里可能会被编译成:
_Z3addii
这是 C++ name mangling。
因为 C++ 支持函数重载,链接器不能只看到一个名字 add。
例如:
int add(int, int);
double add(double, double);
这两个函数源码里都叫 add,但链接时必须区分。
而 printf 因为声明成:
extern "C" int printf(const char*, ...);
所以不会被 C++ 改名,仍然叫:
printf
这就是 extern "C" 的实际意义之一:控制符号名,方便和 C ABI 对接。
7. 优化:编译器开始改写程序
如果不开优化,编译器会比较忠实地保留程序结构。
如果打开优化:
clang++ -O2 -S -emit-llvm main.cpp -o main.ll
这段代码很可能被优化成类似:
define i32 @main() {
entry:
call i32 (ptr, ...) @printf(ptr @.str, i32 4)
ret i32 0
}
add(1, 2) 没了。
INC 展开后的 + 1 也没了。
x 也没了。
最后只剩:
printf("%d\n", 4)
这不是编译器乱改代码。
这是它证明了这些改写不会改变可观察行为。
这里发生了几类典型优化:
函数内联:add(1, 2) 被展开
常量折叠:1 + 2 + 1 变成 4
死代码删除:中间变量 x 不再需要
如果看过 Java JIT、Go SSA、Rust LLVM 优化,会发现这些优化名字经常重复出现。
原因很简单:程序优化的基本问题是相通的。
8. SSA:现代优化器为什么喜欢“变量只赋值一次”
SSA 是理解现代编译器优化的关键。
SSA,全称 Static Single Assignment。
意思是每个变量在静态程序里只赋值一次。
看这个例子:
int f(bool cond) {
int x = 1;
if (cond) {
x = 2;
}
return x;
}
普通代码里,x 被赋值两次。
控制流一复杂,优化器就很难判断某个位置的 x 到底来自哪里。
SSA 会把它变成类似:
x1 = 1
if cond goto then else merge
then:
x2 = 2
goto merge
merge:
x3 = phi(x1, x2)
return x3
这里的:
phi(x1, x2)
表示:
如果从未进入 if 的路径来,x3 = x1
如果从 then 分支来,x3 = x2
SSA 的价值在于,它把“变量会变化”这件事变成了显式的数据流关系。
这对优化非常重要。
例如:
常量传播
死代码删除
公共子表达式消除
循环不变代码外提
值编号
逃逸分析
都会受益于 SSA。
LLVM IR 是 SSA 形式。
Go 编译器内部使用 SSA。
HotSpot C2 也使用接近 SSA 的 Sea of Nodes IR。
很多数据库优化器虽然不叫 SSA,但也会维护类似的数据依赖关系。
这不是某个编译器的小技巧,而是现代优化系统的基础方法。
9. 后端:从平台无关 IR 到机器相关 IR
LLVM IR 仍然是平台无关的。
比如:
%sum = add i32 %a, %b
这句话没有指定:
x86 用哪条指令
ARM 用哪条指令
结果放哪个寄存器
参数从哪里来
调用约定是什么
这些都是后端的问题。
后端要处理:
指令选择
寄存器分配
指令调度
调用约定
栈帧布局
目标平台 ABI
同一个加法,在 x86-64 下可能是:
mov eax, edi
add eax, esi
ret
在 ARM64 下可能是:
add w0, w0, w1
ret
IR 相同,目标机器不同,最终指令就不同。
这也是 LLVM 的价值所在。
语言实现者只要生成 LLVM IR,就可以复用 LLVM 的后端能力。
否则每写一门语言,都要自己支持:
x86-64
ARM64
RISC-V
Windows ABI
Linux ABI
macOS ABI
寄存器分配
指令选择
调试信息
异常处理
这几乎不现实
10. 目标文件:机器码还不是最终程序
后端生成汇编后,汇编器会生成目标文件:
clang++ -c main.cpp -o main.o
main.o 里面有机器码,但它还不是完整程序。
可以用:
nm main.o
看到符号。
可能会看到类似:
0000000000000000 T _Z3addii
0000000000000010 T main
U printf
含义大概是:
_Z3addii 已定义
main 已定义
printf 未定义
U printf 不是错误。
它只是说:当前目标文件里没有 printf 的实现,链接阶段需要去别的地方找。
这就是很多人第一次遇到 undefined reference 时容易误解的地方。
它不是语法错误。
也不是类型错误。
它是链接阶段的符号解析失败。
11. 链接:把分散的二进制拼成一个程序
链接器做的事情可以粗略理解为:
main.o
+
libc
+
启动代码
+
运行时库
↓
可执行文件
它要解决:
符号解析
地址分配
重定位
静态库选择
动态库记录
入口点设置
如果写 C++,还会涉及:
name mangling
ODR
模板实例化
静态初始化
异常表
RTTI
虚表
比如:
undefined reference to `foo'
说明链接器找不到 foo 的实现。
multiple definition of `foo'
说明多个目标文件都提供了同一个强符号。
undefined reference to `_Z3addii'
说明 C++ 符号名对不上,可能是声明和定义不一致,也可能是 C/C++ ABI 混用出了问题。
链接器是独立于编译器前端的另一个大系统。
很多大型 C++ 工程的构建问题,其实不是编译器问题,而是链接器问题。
12. 加载:程序运行前,操作系统还要接手
执行:
./a.out
也不是 CPU 直接从 main 开始跑。
Linux 下大致会经历:
内核读取 ELF
映射程序段到虚拟内存
加载动态链接器
加载共享库
处理重定位
初始化运行时
调用 main
程序的内存布局大致包括:
.text 代码段
.rodata 只读数据
.data 已初始化全局变量
.bss 未初始化全局变量
heap 堆
stack 栈
所以严格说,完整链路不是:
源码 → 可执行文件
而是:
源码 → 目标文件 → 可执行文件 → 进程
编译器解决“怎么生成程序”。
链接器解决“怎么合成程序”。
加载器解决“怎么把程序变成进程”。
13. Clang、LLVM、GCC 应该怎么放在一张图里
现在再看这些名字,就清楚很多。
Clang + LLVM 是这样:
C++ Source
↓
Clang Frontend
↓
Clang AST
↓
LLVM IR
↓
LLVM Optimizer
↓
LLVM Backend
↓
Object File
↓
Linker
↓
Executable
GCC 是另一条链路:
C++ Source
↓
GCC C++ Frontend
↓
GENERIC
↓
GIMPLE
↓
GIMPLE SSA
↓
RTL
↓
GCC Backend
↓
Object File
↓
Linker
↓
Executable
这两条链路解决的是同一类问题,但内部表示不同。
Clang 不是 LLVM 的别名。
LLVM 也不是 Clang 的别名。
更准确地说:
Clang 是 C/C++/Objective-C 前端。
LLVM 是中端和后端基础设施。
GCC 是另一套完整编译器系统。
对应关系可以这样记:
Clang 负责读懂 C++。
LLVM 负责优化和生成机器码。
GCC 自己既有前端,也有中端和后端。
14. Rust 为什么有 HIR、MIR,又为什么用 LLVM
Rust 的流程大致是:
Rust Source
↓
AST
↓
HIR
↓
THIR
↓
MIR
↓
Borrow Check
↓
LLVM IR
↓
LLVM Backend
↓
Machine Code
Rust 这几层不是为了显得复杂。
每层都有明确责任。
AST 接近源码。
HIR 会把一些语法糖和表层结构降下来,让程序形态更稳定。
THIR 更适合类型检查后的表达式分析。
MIR 是 Rust 很关键的一层,适合表达控制流、move、borrow、drop、生命周期等语义。
LLVM IR 则负责进入通用优化和机器码生成阶段。
这里要注意一个区分:
MIR 是 Rust 语义层的 IR。
LLVM IR 是机器代码生成层的 IR。
Borrow Checker 不能直接依赖 LLVM IR。
因为 LLVM IR 已经太低级,Rust 的 ownership、borrow、drop 语义在那一层已经不适合作为主要分析对象。
也不能直接依赖 AST。
因为 AST 太接近语法表面,语法糖太多,不适合做严格的数据流和控制流分析。
所以 Rust 需要 MIR。
这就是中间表示真正的价值:不是多一层抽象,而是为特定分析提供合适的程序形态。
15. Go 为什么看起来简单很多
Go 的编译流程大致是:
Go Source
↓
Parser
↓
AST
↓
Type Check
↓
SSA
↓
Go Backend
↓
Machine Code
Go 默认不走 LLVM。
它有自己的 SSA 和后端。
这和 Go 的设计目标有关。
Go 语言本身刻意保持简单:
没有模板元编程
没有复杂宏系统
没有 Rust 那种 ownership 检查
没有 C++ 那种重载和隐式规则
所以 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. 真正需要记住的主流流程
如果只保留主流路径,不陷入全部细节,可以记成这条线:
源码
↓
预处理
↓
Token
↓
AST
↓
语义分析
↓
IR
↓
SSA
↓
优化
↓
目标相关 IR
↓
汇编 / 目标文件
↓
链接
↓
可执行文件
↓
加载执行
对应到几个主流编译器:
Clang/LLVM:
C++ → Clang AST → LLVM IR → LLVM Backend → Object
GCC:
C++ → GENERIC → GIMPLE SSA → RTL → Object
Rust:
Rust → AST/HIR/THIR → MIR → LLVM IR → Object
Go:
Go → AST → Type Check → SSA → Go Backend → Object
这张图比单独背 AST、IR、SSA 更有用。
因为它告诉你每个系统在同一条工业流水线里的位置。
18. 学这套东西对工程有什么用
这不是纯理论。
遇到宏问题,你知道去看预处理结果:
clang++ -E main.cpp
遇到语法和语义问题,你知道它发生在前端:
clang++ -Xclang -ast-dump -fsyntax-only main.cpp
想看优化前后的差异,你知道去看 LLVM IR:
clang++ -O0 -S -emit-llvm main.cpp -o main_O0.ll
clang++ -O2 -S -emit-llvm main.cpp -o main_O2.ll
遇到符号找不到,你知道看目标文件:
nm main.o
想看 ELF 结构:
readelf -a a.out
想看汇编:
objdump -d a.out
这些工具不是为了炫技。
它们对应的是编译流程中的不同阶段。
能定位阶段,问题就已经解决了一半。
19. 编译器真正有价值的地方
编译器最有价值的地方,不是几个术语,而是一种看复杂系统的方法。
一个复杂输入,不会被直接执行。
它会先变成某种中间表示。
然后被分析、约束、优化、降低,最后才进入执行层。
这个模式不只存在于编译器里。
数据库会把 SQL 变成逻辑计划和物理计划。
JVM 会把字节码变成内部 IR,再交给 JIT 优化。
React 会把 UI 更新组织成 Fiber 结构。
Kubernetes Scheduler 会把调度请求变成资源约束和评分模型。
这些系统看起来不一样,但底层思路很接近:
输入不是直接执行的。
先建立表示。
再基于表示做分析和优化。
最后执行。
编译器只是这个思想最经典、最完整的版本。
如果能把 C++、Rust、Go、GCC、LLVM 这条线看懂,再回头看 JVM、数据库、前端框架、调度系统,很多设计就不再是孤立的名词了。