Recently, I've been looking at how C++, Rust, and Go are compiled.
At first, it's easy to get stuck on a pile of terms:
- GCC
- Clang
- LLVM
- AST
- IR
- SSA
- MIR
- GIMPLE
- RTL
- Linker
- Loader
Each term may make a little sense on its own. But once you put them into a single diagram, everything quickly becomes confusing.
Later, I realized the problem wasn't that there were too many terms, but that I hadn't started from an actual program.
A compiler doesn't simply “translate source code into assembly.” More precisely, it keeps lowering the program's level of abstraction:
Each intermediate representation is there to make a particular class of problem easier to handle.
Below, we'll use a tiny C++ program to connect the major compilation pipeline end to end.
This program deliberately preserves a few interesting points:
A program this small covers most of the mainstream compilation pipeline.
1. Source code isn't what the compiler actually wants
Programmers see:
It's easy for a person to understand that it roughly means:
But the compiler can't interpret it that way from the outset.
Source code is just a stream of characters:
i n t 空格 x 空格 = 空格 I N C ...
The compiler has to turn those characters into a more stable structure first.
This is why modern compilation pipelines don't jump directly from source code to assembly. Source code is suited to human readers, not machine analysis.
2. Preprocessing: C/C++'s earliest text-processing layer
The first step in C/C++ is usually preprocessing.
clang++ -E main.cpp -o main.ii
The preprocessor mainly handles:
#include
#define
#ifdef
#pragma
In this program, the most obvious example is:
After preprocessing, the core code becomes something like this:
There's an important point to understand at this stage:
Preprocessing is not semantic analysis.
It doesn't understand types.
It doesn't know whether add is a function.
Nor does it know whether INC(add(1, 2)) makes sense.
It merely expands the text according to rules.
This is also why C/C++ macros are so prone to causing problems. A macro isn't a function, and it doesn't follow the type-checking and scope rules that functions do.
For example:
If you write:
it expands to:
rather than:
Many strange C/C++ problems never even make it into the real compilation stage; the landmines are already planted during preprocessing.
3. Lexical analysis: Cutting characters into tokens
After preprocessing, the compiler begins lexical analysis.
The source snippet:
is split into something like:
This step is only responsible for “tokenizing.”
It knows that int is a keyword.
It knows that x is an identifier.
It knows that 1 is a numeric literal.
But it doesn't yet know:
x 是局部变量
add 是函数
printf 是外部函数
1 能不能传给 add
None of these is lexical analysis's responsibility.
You can use Clang to inspect the tokens:
clang++ -Xclang -dump-tokens -fsyntax-only main.cpp
If a compilation error reports an illegal character, an unterminated string, or a malformed numeric literal, the problem usually lies around this stage.
4. Parsing: From tokens to an AST
Tokens are still linear.
The compiler needs to understand the program's structure.
For example:
After parsing, it becomes a tree. Simplified, it looks roughly like:
This is the AST, or abstract syntax tree.
The purpose of an AST isn't to make things look sophisticated; it's to give source code a stable structure.
Source code is text. An AST is structure.
At this point, the compiler finally knows:
这是一个变量声明
变量名是 x
初始化表达式是一个加法
加法左边是函数调用
加法右边是整数 1
But an AST still can't prove that a program is correct.
For example:
Even if foo doesn't exist at all, the AST can still be constructed.
Syntactically, there's no problem. Semantically, that's where the problem lies.
You can use Clang to inspect the AST:
clang++ -Xclang -ast-dump -fsyntax-only main.cpp
A real C++ AST will be extremely long. Templates, overloads, implicit conversions, constructors, and destructors will all appear in it.
This is also why the C++ frontend is so complex. Not because tokenizing is difficult or drawing syntax trees is difficult, but because the semantics are so complex.
5. Semantic analysis: Where the compiler actually starts to understand the program
Semantic analysis deals with questions such as:
add 是否存在
add 参数数量是否正确
add 参数类型是否匹配
printf 是否声明过
int x 的初始化是否合法
函数返回值是否符合声明
For this code:
the preprocessed form is:
Semantic analysis confirms:
add 的类型是 int(int, int)
add(1, 2) 返回 int
返回值可以和 1 做加法
结果可以赋给 int x
If you write:
the syntax is still valid.
The AST can still be built.
But semantic analysis reports an error because the argument types don't match.
Many developers use “compilation error” as a catch-all, but it can be broken down further:
语法错误:代码结构不合法
语义错误:结构合法,但意思不合法
链接错误:单个文件能编译,但最终符号找不到
These three kinds of errors occur at completely different stages.
Rust's borrow checker also belongs to this broader category.
For example:
let r;
{
let x = 1;
r = &x;
}
println!("{}", r);
This isn't a syntax error.
Its structure is perfectly valid.
The real problem is semantic: x is out of scope, and r can no longer reference it.
So Rust's ownership, borrowing, and lifetimes shouldn't be understood as syntax features, but as part of a stronger form of semantic analysis.
This is also one reason the Rust compiler is much more complex than the Go compiler.
6. AST isn't suitable for optimization, so we need IR
Many people's first model of the compilation pipeline is:
源码
↓
AST
↓
汇编
This model is too coarse.
Modern compilers don't remain in AST form for long while optimizing.
The reason is simple:
AST is too close to source code.
For example:
The AST retains a lot of source-level structure.
But what the optimizer really cares about is:
这个值从哪里来
这个值被谁使用
这个计算有没有副作用
这个分支是否可达
这个变量能不能放进寄存器
这个函数能不能内联
An AST isn't particularly well suited to those questions.
So the compiler lowers the AST into IR.
IR stands for Intermediate Representation.
For example, this:
can be viewed as lowered into three-address code:
t1 = call add, 1, 2
t2 = t1 + 1
x = t2
call printf, "%d\n", x
return 0
This is better suited to optimization.
The expression tree is broken into simple instructions.
Data dependencies also become clearer.
LLVM IR looks more like this, simplified:
There are several points worth noting here.
In C++, add may be compiled to:
_Z3addii
This is C++ name mangling.
Because C++ supports function overloading, the linker can't rely on seeing only a single name, add.
For example:
Both functions are named add in the source, but the linker has to distinguish them at link time.
And because printf is declared as:
it isn't renamed by C++ and remains:
printf
One of the practical meanings of extern "C" is to control the symbol name, making it possible to interface with a C ABI.
7. Optimization: The compiler starts rewriting the program
If optimization is disabled, the compiler preserves the program's structure fairly faithfully.
If optimization is enabled:
clang++ -O2 -S -emit-llvm main.cpp -o main.ll
this code is likely to be optimized into something like:
define i32 @main() {
entry:
call i32 (ptr, ...) @printf(ptr @.str, i32 4)
ret i32 0
}
add(1, 2) is gone.
The + 1 left by expanding INC is gone too.
x is gone as well.
All that's left is:
printf("%d\n", 4)
This isn't the compiler arbitrarily changing the code.
It has proved that these rewrites don't change observable behavior.
Several categories of typical optimization occur here:
函数内联:add(1, 2) 被展开
常量折叠:1 + 2 + 1 变成 4
死代码删除:中间变量 x 不再需要
If you've looked at Java JIT, Go SSA, or Rust's LLVM optimizations, you'll notice the same optimization names coming up again and again.
The reason is simple: the basic problems of program optimization are fundamentally similar.
8. SSA: Why modern optimizers love “variables assigned only once”
SSA is key to understanding modern compiler optimization.
SSA stands for Static Single Assignment.
In other words, each variable is assigned only once in the static program.
Look at this example:
In ordinary code, x is assigned twice.
As soon as the control flow becomes complicated, the optimizer has trouble determining where x came from at a particular point.
SSA rewrites it into something like:
x1 = 1
if cond goto then else merge
then:
x2 = 2
goto merge
merge:
x3 = phi(x1, x2)
return x3
Here:
phi(x1, x2)
means:
如果从未进入 if 的路径来,x3 = x1
如果从 then 分支来,x3 = x2
The value of SSA is that it turns “a variable may change” into an explicit data-flow relationship.
This is extremely important for optimization.
For example:
常量传播
死代码删除
公共子表达式消除
循环不变代码外提
值编号
逃逸分析
all benefit from SSA.
LLVM IR is in SSA form.
The Go compiler uses SSA internally.
HotSpot C2 also uses a Sea of Nodes IR that's close to SSA.
Many database optimizers don't call it SSA, but they maintain similar data-dependency relationships.
This isn't a trick specific to one compiler; it's a foundational method behind modern optimization systems.
9. Backend: From platform-independent IR to machine-specific IR
LLVM IR is still platform-independent.
For example:
%sum = add i32 %a, %b
This doesn't specify:
x86 用哪条指令
ARM 用哪条指令
结果放哪个寄存器
参数从哪里来
调用约定是什么
These are the backend's responsibility.
The backend handles:
指令选择
寄存器分配
指令调度
调用约定
栈帧布局
目标平台 ABI
The same addition may look like this on x86-64:
mov eax, edi
add eax, esi
ret
and like this on ARM64:
add w0, w0, w1
ret
The IR is the same, but the target machine differs, so the final instructions differ.
This is where LLVM's value lies.
Language implementers only need to generate LLVM IR to reuse LLVM's backend capabilities.
Otherwise, every new language would have to support all of this itself:
x86-64
ARM64
RISC-V
Windows ABI
Linux ABI
macOS ABI
寄存器分配
指令选择
调试信息
异常处理
which is virtually impossible.
10. Object files: Machine code isn't the final program
After the backend generates assembly, the assembler produces an object file:
clang++ -c main.cpp -o main.o
main.o contains machine code, but it still isn't a complete program.
You can run:
nm main.o
to see the symbols.
You might see something like:
0000000000000000 T _Z3addii
0000000000000010 T main
U printf
The meanings are roughly:
_Z3addii 已定义
main 已定义
printf 未定义
U printf isn't an error.
It simply means that the current object file doesn't contain an implementation of printf, so the linking stage needs to find one elsewhere.
This is where many people commonly misunderstand undefined reference when they encounter it for the first time.
It isn't a syntax error.
Nor is it a type error.
It's a failure to resolve a symbol during linking.
11. Linking: Assembling scattered binary pieces into a program
What the linker does can be understood roughly as:
main.o
+
libc
+
启动代码
+
运行时库
↓
可执行文件
It needs to handle:
符号解析
地址分配
重定位
静态库选择
动态库记录
入口点设置
If you're writing C++, it also involves:
name mangling
ODR
模板实例化
静态初始化
异常表
RTTI
虚表
For example:
undefined reference to `foo'
means the linker can't find an implementation of foo.
multiple definition of `foo'
means multiple object files all provide the same strong symbol.
undefined reference to `_Z3addii'
means the C++ symbol names don't line up. This could be because the declaration and definition differ, or because of a problem mixing C and C++ ABIs.
The linker is another major system independent of the compiler frontend.
Many build problems in large C++ projects aren't compiler problems at all; they're linker problems.
12. Loading: The OS takes over before the program runs
Running:
./a.out
doesn't mean the CPU simply starts at main.
On Linux, the process roughly goes through:
内核读取 ELF
映射程序段到虚拟内存
加载动态链接器
加载共享库
处理重定位
初始化运行时
调用 main
The program's memory layout roughly includes:
.text 代码段
.rodata 只读数据
.data 已初始化全局变量
.bss 未初始化全局变量
heap 堆
stack 栈
So, strictly speaking, the complete path isn't:
源码 → 可执行文件
but rather:
源码 → 目标文件 → 可执行文件 → 进程
The compiler solves how to generate the program.
The linker solves how to assemble the program.
The loader solves how to turn the program into a process.
13. Clang, LLVM, and GCC: How they fit into one diagram
Now, these names make a lot more sense.
Clang + LLVM work like this:
C++ Source
↓
Clang Frontend
↓
Clang AST
↓
LLVM IR
↓
LLVM Optimizer
↓
LLVM Backend
↓
Object File
↓
Linker
↓
Executable
GCC follows a different pipeline:
These pipelines solve the same kinds of problems, but use different internal representations.
Clang isn't another name for LLVM. LLVM isn't another name for Clang either.
More precisely:
Clang 是 C/C++/Objective-C 前端。
LLVM 是中端和后端基础设施。
GCC 是另一套完整编译器系统。
You can remember the correspondence like this:
Clang 负责读懂 C++。
LLVM 负责优化和生成机器码。
GCC 自己既有前端,也有中端和后端。
14. Why Rust has HIR and MIR, and why it uses LLVM
Rust's pipeline is roughly:
Rust Source
↓
AST
↓
HIR
↓
THIR
↓
MIR
↓
Borrow Check
↓
LLVM IR
↓
LLVM Backend
↓
Machine Code
These layers in Rust aren't there to make the compiler look complicated.
Each has a clear responsibility.
The AST stays close to source code.
HIR lowers some syntax sugar and surface-level structures, making the program's shape more stable.
THIR is better suited to expression analysis after type checking.
MIR is a crucial Rust layer, well suited to expressing semantics such as control flow, move, borrow, drop, and lifetimes.
LLVM IR takes over for general-purpose optimization and machine-code generation.
There's an important distinction here:
MIR 是 Rust 语义层的 IR。
LLVM IR 是机器代码生成层的 IR。
The borrow checker can't directly depend on LLVM IR.
Because LLVM IR is already too low-level; Rust's ownership, borrow, and drop semantics are no longer suitable as primary analysis targets at that level.
It can't directly depend on AST either.
Because an AST is too close to the syntactic surface and contains too much syntax sugar for strict data-flow and control-flow analysis.
That's why Rust needs MIR.
This is the real value of an intermediate representation: not adding another layer of abstraction, but providing the right program shape for a particular analysis.
15. Why Go looks much simpler
Go's compilation pipeline is roughly:
Go Source
↓
Parser
↓
AST
↓
Type Check
↓
SSA
↓
Go Backend
↓
Machine Code
Go doesn't use LLVM by default. It has its own SSA form and backend.
This is tied to the goals behind Go's design.
The Go language itself deliberately stays simple:
没有模板元编程
没有复杂宏系统
没有 Rust 那种 ownership 检查
没有 C++ 那种重载和隐式规则
So the Go compiler can take a more direct approach.
This is one important reason Go compiles so quickly.
Of course, “simple” doesn't mean the Go compiler has no technical sophistication.
Go's escape analysis, inlining, SSA optimizations, stack growth, and GC-related metadata generation all involve plenty of engineering detail.
But compared with C++ and Rust, the semantic complexity of the Go frontend is indeed much lower.
16. Why modern compilers keep inventing new IRs
At this point, we can answer the original question.
Why does GCC have GIMPLE and RTL?
Why does LLVM have LLVM IR, SelectionDAG, and Machine IR?
Why does Rust have HIR, THIR, and MIR?
Why does Go have SSA?
Because no single representation is suitable for every phase.
AST is good for representing source structure.
HIR is good for stripping away surface syntax.
MIR is good for expressing language semantics and control flow.
LLVM IR is good for platform-independent optimization.
Machine IR is good for register allocation and instruction scheduling.
RTL is good for describing operations close to the machine in the GCC backend.
The same program needs to be seen as different things at different stages.
To a person, it's business logic.
To the frontend, it's a syntax tree.
To the type system, it's a set of constraints.
To the optimizer, it's a data-flow graph.
To the backend, it's a problem of instruction selection and register allocation.
To the linker, it's symbols and relocation records.
To the loader, it's ELF sections and dynamic-library dependencies.
The complexity of compilers comes precisely from switching between these perspectives.
17. The mainstream pipeline worth remembering
If you keep only the mainstream path and don't get lost in every detail, remember this line:
Mapped onto a few major compilers:
This diagram is more useful than memorizing AST, IR, and SSA in isolation.
Because it shows where each system fits within the same industrial pipeline.
18. What studying this is useful for in engineering
This isn't pure theory.
When you run into a macro problem, you know to inspect the preprocessing output:
clang++ -E main.cpp
When you encounter syntax and semantic problems, you know they occur in the frontend:
clang++ -Xclang -ast-dump -fsyntax-only main.cpp
To see the differences before and after optimization, you know to inspect LLVM IR:
clang++ -O0 -S -emit-llvm main.cpp -o main_O0.ll
clang++ -O2 -S -emit-llvm main.cpp -o main_O2.ll
When a symbol can't be found, you know to inspect the object file:
nm main.o
To inspect the ELF structure:
readelf -a a.out
To inspect the assembly:
objdump -d a.out
These tools aren't for showing off.
Each one corresponds to a different stage of the compilation pipeline.
Once you can pinpoint the stage, half the problem is already solved.
19. What compilers are really valuable for
The most valuable thing about compilers isn't a handful of terms, but a way of looking at complex systems.
A complex input isn't executed directly.
Instead, it first becomes some kind of intermediate representation.
Then it gets analyzed, constrained, optimized, and lowered before finally reaching the execution layer.
This pattern isn't unique to compilers.
Databases turn SQL into logical and physical plans.
The JVM turns bytecode into an internal IR, then hands it to the JIT for optimization.
React organizes UI updates into Fiber structures.
The Kubernetes Scheduler turns scheduling requests into resource constraints and scoring models.
These systems look different, but the underlying ideas are remarkably similar:
输入不是直接执行的。
先建立表示。
再基于表示做分析和优化。
最后执行。
Compilers are simply the most classic and complete version of this idea.
If you can make sense of the thread running through C++, Rust, Go, GCC, and LLVM, then turn back to JVMs, databases, frontend frameworks, and scheduling systems, many designs will no longer seem like isolated terms.