mobile wallpaper 1mobile wallpaper 2mobile wallpaper 3mobile wallpaper 4
3194 字
9 分钟
编译器全景:从源码到机器码的旅程
2025-12-05

你每天都在使用编译器——gccclangjavaccargo buildgo build……但你有没有想过,从你敲下最后一行代码到程序真正跑起来,中间发生了什么?为什么同样的代码,-O0-O2 的性能可能差 10 倍?为什么 Python 比 C 慢那么多?JIT 编译到底是什么魔法?

本章是整个系列的”地图”。不会深入任何一个阶段的细节(那是后续章节的任务),而是从宏观视角俯瞰编译器的整体架构:编译器、解释器、JIT 的本质区别是什么?编译器为什么要分成前端、中端、后端三段?整个编译流水线长什么样?

理解了这幅全景图,后续每一章的学习就有了锚点。

一、编译器、解释器与 JIT:三种执行模型#

1.1 编译器(AOT Compiler)#

编译器的核心任务是翻译——将高级语言源码一次性翻译成目标机器能直接执行的机器码。这个过程发生在程序运行之前,因此称为 Ahead-Of-Time(AOT)编译

flowchart LR SRC["源码<br/>main.c"] -->|编译器| OBJ["目标文件<br/>main.o"] OBJ -->|链接器| EXE["可执行文件<br/>a.out"] EXE -->|加载器| RUN["运行"] style SRC fill:#e3f2fd,stroke:#1565c0 style OBJ fill:#fff3e0,stroke:#e65100 style EXE fill:#e8f5e9,stroke:#2e7d32 style RUN fill:#fce4ec,stroke:#c62828

AOT 编译的典型代表:C/C++(GCC/Clang)、Rust(rustc)、Go(gc)、Swift。

优势

  • 运行时零编译开销——程序直接以机器码执行
  • 编译期可以做全局优化——有完整的程序信息
  • 类型安全在编译期验证——运行时无需类型检查

劣势

  • 编译时间长——大型项目可能需要数分钟甚至数十分钟
  • 平台绑定——x86 编译的程序无法在 ARM 上运行
  • 无法利用运行时信息——无法根据实际数据分布做优化

1.2 解释器(Interpreter)#

解释器的核心任务是逐行执行——读取源码,逐条解释执行,不生成独立的机器码文件。

flowchart LR SRC["源码<br/>script.py"] -->|解释器| INTERP["逐行解释执行"] INTERP --> RESULT["结果"] style SRC fill:#e3f2fd,stroke:#1565c0 style INTERP fill:#fff3e0,stroke:#e65100 style RESULT fill:#e8f5e9,stroke:#2e7d32

解释执行的典型代表:CPython、Ruby(MRI)、PHP、Bash。

优势

  • 启动快——无需编译,直接执行
  • 跨平台——只需为每个平台实现解释器
  • 灵活——支持 eval()、REPL 等动态特性

劣势

  • 运行速度慢——每条语句都需要重新解析和执行
  • 无法做全局优化——解释器看不到”未来”的代码
  • 运行时类型检查开销——每次操作都要检查类型

1.3 JIT 编译器(Just-In-Time Compiler)#

JIT 编译器结合了编译器和解释器的优点——程序以解释方式启动,运行时将热点代码编译成机器码并缓存,后续执行直接运行机器码。

flowchart TB SRC["源码/字节码"] --> INTERP["解释执行"] INTERP -->|热点检测| PROF["性能剖析"] PROF -->|触发编译| JIT["JIT 编译"] JIT --> CACHE["机器码缓存"] CACHE -->|下次调用| FAST["直接执行机器码"] style SRC fill:#e3f2fd,stroke:#1565c0 style INTERP fill:#fff3e0,stroke:#e65100 style JIT fill:#e8f5e9,stroke:#2e7d32 style FAST fill:#fce4ec,stroke:#c62828

JIT 的典型代表:JVM(HotSpot)、V8(JavaScript)、PyPy、LuaJIT。

Note

JIT 编译的核心洞察是二八定律:程序 80% 的时间花在 20% 的代码上。JIT 只编译那 20% 的热点代码,既获得了接近 AOT 的性能,又保持了解释器的灵活性。在 JIT 编译 中深入分析这一机制。

1.4 三种执行模型的对比#

特性AOT 编译解释执行JIT 编译
启动速度慢(需编译)快(解释启动)
峰值性能高(热点编译后)
跨平台需重新编译天然跨平台天然跨平台
类型安全编译期检查运行时检查混合
动态特性不支持完全支持部分支持
内存占用较高(代码缓存)
调试体验较复杂
典型代表C/C++/Rust/GoPython/Ruby/PHPJava/JavaScript/LuaJIT

二、编译器的三段式架构#

现代编译器几乎都采用三段式架构:前端(Frontend)、中端(Middle-end/Optimizer)、后端(Backend)。这个设计不是偶然的——它解决了一个核心问题:M×N 组合爆炸

2.1 为什么需要三段式?#

假设你有 M 种源语言和 N 种目标平台。如果没有三段式架构,你需要为每种”源语言-目标平台”组合写一个完整的编译器——总共 M×N 个编译器。

三段式架构引入了一个**统一的中间表示(IR)**作为桥梁:

flowchart TB subgraph 前端["前端(M 个)"] C["C 前端"] CPP["C++ 前端"] RUST["Rust 前端"] SWIFT["Swift 前端"] end subgraph 中端["中端(1 个)"] IR["统一 IR + 优化器"] end subgraph 后端["后端(N 个)"] X86["x86-64 后端"] ARM["ARM 后端"] RISCV["RISC-V 后端"] WASM["WASM 后端"] end C --> IR CPP --> IR RUST --> IR SWIFT --> IR IR --> X86 IR --> ARM IR --> RISCV IR --> WASM style 前端 fill:#e3f2fd,stroke:#1565c0 style 中端 fill:#e0f2f1,stroke:#00695c style 后端 fill:#fff3e0,stroke:#e65100

现在你只需要 M 个前端 + N 个后端 + 1 个中端——总共 M+N+1 个模块。这就是 LLVM 能够同时支持 C、C++、Rust、Swift 等多种语言,同时 targeting x86、ARM、RISC-V、WASM 等多种平台的根本原因。

2.2 前端:源码理解#

前端负责理解源码——将人类可读的源代码转换为机器可处理的中间表示。前端包含三个阶段:

  1. 词法分析第 2 章):将字符流分解为 Token 流
  2. 语法分析第 3 章):将 Token 流组织为抽象语法树(AST)
  3. 语义分析第 4 章):对 AST 进行类型检查和语义验证
# 前端处理的简化示例
source = "int x = 42;"
# 词法分析:字符流 → Token 流
tokens = [
Token(type="INT", value="int"),
Token(type="IDENT", value="x"),
Token(type="ASSIGN", value="="),
Token(type="INT_LIT", value="42"),
Token(type="SEMI", value=";"),
]
# 语法分析:Token 流 → AST
ast = VarDecl(
type=Type("int"),
name="x",
init=IntLit(42)
)
# 语义分析:AST + 类型检查 → 带类型的 AST
typed_ast = VarDecl(
type=Type("int"),
name="x",
init=IntLit(42, type=Type("int")), # 类型已标注
symbol=Symbol(name="x", type=Type("int"), scope=0)
)

2.3 中端:优化#

中端负责优化 IR——在不改变程序语义的前提下,让程序运行得更快、占用更少资源。中端的输入和输出都是 IR,但它做了关键的变换:

// 优化前
int foo(int x) {
int a = 10; // 常量
int b = 20; // 常量
int c = a + b; // 常量折叠 → c = 30
int d = x * 0; // 乘零消除 → d = 0
int e = d + c; // 常量传播 → e = 30
return e; // 死代码消除后 → return 30
}
// 优化后
int foo(int x) {
return 30;
}

中端优化是编译器最复杂的部分,在 优化基础循环优化寄存器分配 中逐步深入。

2.4 后端:代码生成#

后端负责生成目标代码——将优化后的 IR 转换为目标平台的机器码或汇编。后端包含三个阶段:

  1. 指令选择第 9 章):将 IR 操作映射到目标指令
  2. 指令调度第 9 章):安排指令执行顺序以利用流水线
  3. 寄存器分配第 8 章):将虚拟寄存器映射到物理寄存器
flowchart LR IR["LLVM IR<br/>%1 = add i32 %x, 10"] -->|指令选择| ASM1["mov eax, edi<br/>add eax, 10"] ASM1 -->|寄存器分配| ASM2["mov eax, edi<br/>add eax, 10"] ASM2 -->|指令调度| FINAL["add eax, 10<br/>mov ecx, eax"] style IR fill:#e3f2fd,stroke:#1565c0 style FINAL fill:#e8f5e9,stroke:#2e7d32

2.5 三段式架构的对比#

阶段输入输出核心任务平台相关
前端源码IR理解源码语义否(语言相关)
中端IR优化后的 IR提升性能
后端优化后的 IR机器码生成目标代码是(平台相关)

三、编译流水线全貌#

把整个编译流水线展开,看看一个简单的 C 程序从源码到可执行文件经历了什么:

3.1 一个完整的编译旅程#

hello.c
#include <stdio.h>
int add(int a, int b) {
return a + b;
}
int main() {
int result = add(3, 4);
printf("result = %d\n", result);
return 0;
}
flowchart TB SRC["hello.c<br/>源码"] --> PRE["预处理<br/>#include 展开"] PRE --> LEX["词法分析<br/>字符→Token"] LEX --> SYN["语法分析<br/>Token→AST"] SYN --> SEM["语义分析<br/>类型检查"] SEM --> IR_GEN["IR 生成<br/>AST→LLVM IR"] IR_GEN --> OPT["优化<br/>常量折叠/内联/..."] OPT --> ISEL["指令选择<br/>IR→机器指令"] ISEL --> REG["寄存器分配<br/>虚拟→物理寄存器"] REG --> SCHED["指令调度<br/>重排指令"] SCHED --> CODE["代码发射<br/>生成 .o 文件"] CODE --> LINK["链接<br/>合并 .o + 库"] LINK --> EXE["a.out<br/>可执行文件"] style SRC fill:#e3f2fd,stroke:#1565c0 style PRE fill:#e8eaf6,stroke:#283593 style LEX fill:#e8eaf6,stroke:#283593 style SYN fill:#e8eaf6,stroke:#283593 style SEM fill:#e8eaf6,stroke:#283593 style IR_GEN fill:#e0f2f1,stroke:#00695c style OPT fill:#e0f2f1,stroke:#00695c style ISEL fill:#fff3e0,stroke:#e65100 style REG fill:#fff3e0,stroke:#e65100 style SCHED fill:#fff3e0,stroke:#e65100 style CODE fill:#fff3e0,stroke:#e65100 style LINK fill:#fce4ec,stroke:#c62828 style EXE fill:#e8f5e9,stroke:#2e7d32

3.2 预处理阶段#

预处理是 C/C++ 特有的阶段(其他语言不一定有),主要处理以 # 开头的预处理指令:

# 查看预处理输出
gcc -E hello.c -o hello.i
# 预处理做了什么
# 1. #include <stdio.h> → 展开 stdio.h 的全部内容(约 800 行)
# 2. #define MAX 100 → 文本替换
# 3. #ifdef DEBUG → 条件编译
# 4. 注释删除

3.3 用 Clang 观察每个阶段#

Clang 提供了丰富的选项来观察编译的每个阶段:

# 预处理
clang -E hello.c -o hello.i
# 词法分析(查看 Token)
clang -dump-tokens hello.c
# 语法分析(查看 AST)
clang -ast-dump hello.c
# 生成 LLVM IR
clang -emit-llvm -S hello.c -o hello.ll
# 查看优化后的 LLVM IR
clang -emit-llvm -S -O2 hello.c -o hello_opt.ll
# 生成汇编
clang -S hello.c -o hello.s
# 生成目标文件
clang -c hello.c -o hello.o
# 查看目标文件的段
objdump -h hello.o
# 查看反汇编
objdump -d hello.o
# 链接生成可执行文件
clang hello.o -o hello

3.4 各阶段的数据格式对比#

阶段输入格式输出格式数据量变化人可读性
预处理.c 源码.i 展开源码急剧膨胀(头文件展开)
词法分析.i 文本Token 流略微压缩(去除空白注释)
语法分析Token 流AST结构化
语义分析AST带类型 AST略微膨胀(类型标注)
IR 生成带类型 ASTLLVM IR结构化线性化
优化LLVM IR优化后 IR通常缩减
代码生成优化后 IR机器码压缩
链接.o + 库可执行文件膨胀(合并库)极低

四、编译器的设计哲学#

4.1 速度 vs 优化质量#

编译器面临一个根本性的权衡:编译速度 vs 优化质量。不同的语言选择了不同的平衡点:

graph LR subgraph 编译速度优先 GO["Go<br/>编译极快<br/>优化一般"] end subgraph 平衡 C["C/C++<br/>编译中等<br/>优化中等"] RUST["Rust<br/>编译较慢<br/>优化较好"] end subgraph 优化质量优先 LLVM_O3["LLVM -O3<br/>编译很慢<br/>优化极好"] end GO --> C --> RUST --> LLVM_O3 style GO fill:#e8f5e9,stroke:#2e7d32 style C fill:#fff3e0,stroke:#e65100 style RUST fill:#fff3e0,stroke:#e65100 style LLVM_O3 fill:#fce4ec,stroke:#c62828
语言/编译器编译速度优化质量设计选择
Go (gc)简化前端、避免慢优化、依赖运行时
C (GCC -O1)快速优化、适合开发期
C (GCC -O2)平衡优化、生产环境默认
Rust (rustc)LLVM 后端、借用检查开销
C (GCC -O3)激进优化、可能增大代码体积

4.2 编译期 vs 运行时检查#

另一个核心权衡是在编译期检查多少

检查类型编译期检查运行时检查代价
类型安全Rust, Haskell, C++Python, Ruby, JS编译期检查增加编译时间
内存安全Rust(借用检查器)Go/Java(GC)编译期检查限制语言灵活性
空指针Kotlin, SwiftC, C++编译期检查需要 Option 类型
数组越界Rust(debug 模式)Python, Java运行时检查有性能开销
整数溢出Rust(debug 模式)C(UB)编译期检查需要额外指令
Warning

C/C++ 的”未定义行为(Undefined Behavior)“是编译期和运行时检查的极端反面——编译器假设 UB 不会发生,并据此做激进优化。这可能导致看似合理的代码产生意外结果。例如,有符号整数溢出在 C 中是 UB,编译器可能假设它不会发生从而删除溢出检查。

4.3 静态类型 vs 动态类型#

# 动态类型(Python)—— 运行时才知道类型
def add(a, b):
return a + b # a 和 b 可以是 int、float、str...
add(1, 2) # 3
add("hello", " world") # "hello world"
add(1, "2") # TypeError at runtime!
// 静态类型(Rust)—— 编译期就知道类型
fn add(a: i32, b: i32) -> i32 {
a + b
}
add(1, 2); // OK
add("hello", " world"); // Compile error!
维度静态类型动态类型
类型错误发现编译期运行时
代码灵活性低(需类型声明)高(鸭子类型)
IDE 支持好(自动补全、重构)差(运行时才知道类型)
运行时开销有(类型检查、分发)
编译器优化可做(类型已知)难(类型未知)

五、编译器如何影响你的代码#

5.1 优化级别的影响#

// 示例:计算斐波那契数列
int fib(int n) {
if (n <= 1) return n;
return fib(n - 1) + fib(n - 2);
}
# -O0:无优化,忠实翻译
clang -O0 -S fib.c -o fib_O0.s
# 结果:直接翻译,每次递归调用都压栈
# -O2:标准优化
clang -O2 -S fib.c -o fib_O2.s
# 结果:尾调用优化、内联、常量传播
# -O3:激进优化
clang -O3 -S fib.c -o fib_O3.s
# 结果:可能展开递归、向量化
优化级别编译时间运行性能代码体积适用场景
-O0最快最慢最大调试
-O1中等中等开发期
-O2中等较小生产环境
-O3最好(可能)可能更大性能关键场景
-Os中等最小嵌入式/移动端
-Oz中等中等极小极度关注体积

5.2 编译器内置函数#

编译器会识别常见的代码模式并替换为更高效的实现:

// 你写的代码
memcpy(dst, src, 16);
// 编译器可能替换为
__builtin_memcpy(dst, src, 16);
// → 如果大小已知且较小,直接生成 SIMD 指令
// → 如果大小未知,调用 libc 的 memcpy
// 你写的代码
for (int i = 0; i < n; i++) dst[i] = src[i];
// 编译器可能自动识别为 memcpy 模式
// → 生成向量化指令或调用 memcpy

5.3 未定义行为的利用#

// 这段代码有 UB:有符号整数溢出
int sum(int n) {
int result = 0;
for (int i = 1; i <= n; i++) {
result += i; // 如果 n 很大,result 会溢出
}
return result;
}
// 编译器假设溢出不会发生,可能:
// 1. 将循环替换为公式 result = n * (n + 1) / 2
// 2. 删除溢出检查
// 3. 如果 n 足够大,结果与预期不同
Tip

理解编译器的 UB 假设是写出正确 C/C++ 代码的关键。使用 -fsanitize=undefined 可以在运行时检测 UB,使用 __attribute__((no_sanitize("undefined"))) 可以在特定函数中禁用 UB 优化。

六、编译器生态系统#

6.1 主流编译器对比#

编译器语言架构许可证特点
GCCC/C++/Fortran/Ada三段式(非严格)GPL历史最悠久、支持平台最多
Clang/LLVMC/C++/ObjC严格三段式Apache 2.0模块化、可扩展、IDE 友好
rustcRustLLVM 后端MIT/Apache借用检查器、MIR
gcGo自有后端BSD编译极快、简单
V8JavaScriptJITBSDIgnition + TurboFan
HotSpotJavaJITGPL分层编译、GraalVM
javacJava字节码编译GPL编译到字节码

6.2 编译器基础设施的层次#

graph TB subgraph 应用层["应用层 — 用户直接使用"] GCC_CLI["gcc / g++"] CLANG_CLI["clang / clang++"] RUSTC["rustc / cargo"] GO_BUILD["go build"] JAVAC["javac"] end subgraph 编译器层["编译器层 — 编译器实现"] GCC_FE["GCC 前端"] CLANG_FE["Clang 前端"] RUST_FE["Rust 前端"] GO_FE["Go 前端"] end subgraph 基础设施层["基础设施层 — 可复用组件"] LLVM_CORE["LLVM Core<br/>IR + 优化 + 后端"] GCC_INT["GCC 内部<br/>GIMPLE + RTL"] GO_BACKEND["Go 后端<br/>SSA + obj"] end subgraph 工具链层["工具链层 — 辅助工具"] OPT["opt<br/>优化器"] LLC["llc<br/>代码生成"] LLD["lld<br/>链接器"] GDB["gdb / lldb<br/>调试器"] end GCC_CLI --> GCC_FE --> GCC_INT CLANG_CLI --> CLANG_FE --> LLVM_CORE RUSTC --> RUST_FE --> LLVM_CORE GO_BUILD --> GO_FE --> GO_BACKEND LLVM_CORE --> OPT LLVM_CORE --> LLC LLVM_CORE --> LLD style 应用层 fill:#e3f2fd,stroke:#1565c0 style 编译器层 fill:#e0f2f1,stroke:#00695c style 基础设施层 fill:#fff3e0,stroke:#e65100 style 工具链层 fill:#fce4ec,stroke:#c62828

七、动手实践:用 Clang 观察编译流水线#

7.1 安装 Clang 和 LLVM#

# Ubuntu/Debian
sudo apt install clang llvm
# macOS(Xcode 自带)
xcode-select --install
# 验证安装
clang --version
llvm-config --version

7.2 逐步观察编译过程#

# 创建示例文件
cat > hello.c << 'EOF'
int add(int a, int b) {
return a + b;
}
int main() {
return add(3, 4);
}
EOF
# 1. 预处理
clang -E hello.c | head -20
# 2. 查看 Token
clang -dump-tokens hello.c 2>/dev/null | head -20
# 3. 查看 AST
clang -ast-dump hello.c 2>/dev/null | head -30
# 4. 生成 LLVM IR(未优化)
clang -emit-llvm -S -O0 hello.c -o hello_O0.ll
# 5. 生成 LLVM IR(优化后)
clang -emit-llvm -S -O2 hello.c -o hello_O2.ll
# 6. 对比优化前后的 IR
diff hello_O0.ll hello_O2.ll
# 7. 生成汇编
clang -S -O2 hello.c -o hello.s
# 8. 生成目标文件
clang -c hello.c -o hello.o
# 9. 查看目标文件信息
readelf -h hello.o # ELF 头
readelf -S hello.o # 段表
objdump -d hello.o # 反汇编
# 10. 链接并运行
clang hello.o -o hello && ./hello
echo $? # 应输出 7

7.3 使用 Compiler Explorer#

如果你不想安装任何东西,可以使用 Compiler Explorer (Godbolt) 在线查看编译器输出:

  1. 打开 https://godbolt.org/
  2. 在左侧输入 C/C++ 代码
  3. 右侧自动显示汇编输出
  4. 可以添加多个编译器对比(GCC vs Clang vs MSVC)
  5. 可以切换优化级别(-O0, -O1, -O2, -O3)
  6. 支持颜色高亮:源码行与汇编行的对应关系

八、本章小结#

概念要点
AOT 编译编译期生成机器码,运行时零开销,平台绑定
解释执行逐行解释执行,启动快但运行慢,跨平台
JIT 编译运行时编译热点代码,兼顾启动速度和峰值性能
三段式架构前端(语言相关)+ 中端(平台无关优化)+ 后端(平台相关),解决 M×N 问题
编译流水线预处理 → 词法分析 → 语法分析 → 语义分析 → IR 生成 → 优化 → 代码生成 → 链接
设计权衡编译速度 vs 优化质量、编译期检查 vs 运行时检查、静态类型 vs 动态类型
优化级别-O0(调试)→ -O2(生产)→ -O3(激进),不同级别影响编译时间和运行性能
未定义行为编译器假设 UB 不会发生,可能做激进优化,需谨慎对待

本章建立了编译器的全景认知。下一章深入编译流水线的第一个阶段——词法分析,看看源码是如何被分解为 Token 流的。


参考#

支持与分享

如果这篇文章对你有帮助,欢迎支持作者或分享给更多人

编译器全景:从源码到机器码的旅程
https://blog.souloss.com/posts/compiler/compiler-overview/
作者
Souloss
发布于
2025-12-05
许可协议
CC BY-NC-SA 4.0

部分信息可能已经过时