LLVM 入门:从编译流程到 IR 结构

编译器是怎么把代码变成机器能懂的指令的?为什么 LLVM 能成为编译器基础设施的“瑞士军刀”?

如果你想深入理解编译器,或者想写自己的编译器 Pass,LLVM IR 是绕不开的一关。

📚 LLVM 核心概念速读

概念 说明 省流
LLVM Low Level Virtual Machine(原名),现为品牌名 模块化编译器基础设施
IR Intermediate Representation 编译器中间表示,连接前后端
SSA Static Single Assignment 每个变量只赋值一次
CFG Control Flow Graph 控制流图,描述程序执行路径
Pass 编译优化/分析单元 对 IR 进行变换的模块
Basic Block 基本块 顺序执行的指令序列,只有一个入口一个出口

🧩 什么是 LLVM

LLVM 不是某个具体编译器的名字,而是一套模块化的编译器基础设施

传统编译器(如 GCC)采用三段式设计

1
2
Frontend → Optimizer → Backend
前端 优化器 后端

问题在于:各阶段耦合严重,想支持新语言要重写整套,想支持新架构也要改一堆。

LLVM 的核心创新:统一的中间表示(IR)

1
2
3
4
Clang (C/C++)    ──→
Swift ──→
Rust ──→ LLVM IR ──→ x86 / ARM / RISC-V ...
Kotlin Native ──→

省流:前端把源码翻译成 IR,后端把 IR 翻译成机器码,中间全是 IR,想加语言加前端,想加架构加后端。

🔧 LLVM 编译流程

以 C 代码为例:

1
2
3
int add(int a, int b) {
return a + b;
}

编译流程:



flowchart LR
    A[源码 .c] --> B[预处理]
    B --> C[词法/语法分析]
    C --> D[语义分析]
    D --> E[生成 IR]
    E --> F[IR 优化]
    F --> G[指令选择]
    G --> H[寄存器分配]
    H --> I[生成机器码 .s/.o]

对应命令:

1
2
3
4
5
# 查看完整流程
clang -emit-llvm -S add.c -o add.ll # 生成人类可读 IR
clang -emit-llvm -c add.c -o add.bc # 生成 bitcode(二进制格式)
llc add.bc -o add.s # IR → 汇编
clang add.s -o add # 汇编 → 可执行文件

注意.ll 是文本格式,.bc 是二进制格式,两者等价可互转。

📖 LLVM IR 结构

IR 文件由三部分组成:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
; 1. 模块级信息
target datalayout = "e-m:e-p270:32:32-p271:32:32-p272:64:64-i64:64-f80:64-n8:16:32:64-S128"
target triple = "x86_64-pc-linux-gnu"

; 2. 全局变量和函数声明
@global_var = global i32 42
declare i32 @printf(i8*, ...)

; 3. 函数定义
define i32 @add(i32 %a, i32 %b) {
entry:
%sum = add i32 %a, %b
ret i32 %sum
}

层级结构

1
2
3
4
5
Module(模块)
└── GlobalVariable(全局变量)
└── Function(函数)
└── BasicBlock(基本块)
└── Instruction(指令)

类型系统

类型 示例 说明
整数 i1, i8, i32, i64 i1 是 bool
浮点 float, double 32/64 位浮点
指针 i32*, i8** 指向某类型的指针
数组 [10 x i32] 固定大小数组
结构体 {i32, float} 匿名结构体
向量 <4 x i32> SIMD 向量类型
函数 i32 (i32, i32)* 函数指针

⚡ IR 指令基础

算术运算

1
2
3
4
5
6
7
8
%a = add i32 %x, %y      ; 加法
%b = sub i32 %x, %y ; 减法
%c = mul i32 %x, %y ; 乘法
%d = sdiv i32 %x, %y ; 有符号除法
%e = urem i32 %x, %y ; 无符号取余
%f = and i1 %p, %q ; 按位与
%g = or i1 %p, %q ; 按位或
%h = xor i1 %p, %q ; 按位异或

内存操作

1
2
3
4
5
6
7
8
9
10
11
12
13
; 分配栈空间
%ptr = alloca i32 ; 分配 4 字节,返回 i32*

; 加载
%val = load i32, i32* %ptr ; 从地址加载 i32

; 存储
store i32 42, i32* %ptr ; 将 42 存入地址

; 获取元素指针(GEP)
%arr = alloca [10 x i32]
%elem = getelementptr [10 x i32], [10 x i32]* %arr, i64 0, i64 5
; 第 0 个数组,第 5 个元素

比较指令

1
2
3
4
5
%cmp = icmp eq i32 %a, %b      ; 相等
%cmp = icmp slt i32 %a, %b ; 有符号小于
%cmp = icmp sgt i32 %a, %b ; 有符号大于
%cmp = icmp sle i32 %a, %b ; 有符号小于等于
%cmp = icmp ne i32 %a, %b ; 不等

控制流

1
2
3
4
5
6
7
8
9
10
11
12
; 无条件跳转
br label %target

; 条件跳转
br i1 %cond, label %if_true, label %if_false

; 函数调用
%result = call i32 @func(i32 %arg1, i32 %arg2)

; 返回
ret i32 %result
ret void ; 无返回值

🔄 SSA 与变量

SSA(Static Single Assignment)是 LLVM IR 的核心特性。

规则:每个变量只能被赋值一次。

1
2
3
4
5
6
7
; ❌ 非 SSA 形式(不允许)
%x = add i32 %a, %b
%x = add i32 %x, %c ; 错误!%x 不能再赋值

; ✅ SSA 形式
%x1 = add i32 %a, %b
%x2 = add i32 %x1, %c

这有什么好处?

  • 简化数据流分析
  • 便于检测未定义变量
  • 优化 Pass 更容易实现

比喻:SSA 就像“一次性的便签纸”,写完一张就换新的,不用擦掉重写。

那循环怎么办?

1
2
3
4
5
// C 代码
int sum = 0;
for (int i = 0; i < 10; i++) {
sum = sum + i; // sum 被多次赋值!
}

LLVM 用 phi 指令 解决这个问题。

🔀 phi 指令

phi 指令根据控制流来源选择不同的值。

1
2
3
4
; phi 指令语法
%result = phi i32 [ %val1, %block1 ], [ %val2, %block2 ]
; 如果从 block1 跳转过来,result = val1
; 如果从 block2 跳转过来,result = val2

示例:循环求和

1
2
3
4
int sum = 0;
for (int i = 0; i < 3; i++) {
sum += i;
}

对应的 IR:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
define i32 @loop_sum() {
entry:
br label %loop

loop:
%i = phi i32 [ 0, %entry ], [ %i.next, %loop ]
%sum = phi i32 [ 0, %entry ], [ %sum.next, %loop ]

%sum.next = add i32 %sum, %i
%i.next = add i32 %i, 1
%cond = icmp slt i32 %i.next, 3

br i1 %cond, label %loop, label %exit

exit:
ret i32 %sum.next
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
┌─────────┐
│ entry │
└────┬────┘

┌─────────────────────────────────────┐
│ loop │
│ %i = phi [0, entry], [%i.next, loop] │
│ %sum = phi [0, entry], [%sum.next, loop] │
│ ...计算... │
│ br %cond, loop, exit │
└────┬────────────────────────────────┘
↓ (cond=false)
┌─────────┐
│ exit │
└─────────┘

省流:phi 就是个“多路选择器”,根据你是从哪个块跳过来的,决定用哪个值。

📊 控制流图 CFG

CFG(Control Flow Graph)描述程序可能的执行路径。

  • 节点:基本块(Basic Block)
  • :可能的跳转

基本块规则

  1. 只有一个入口(只能从开头进入)
  2. 只有一个出口(最后一条指令是终结符)
  3. 内部没有分支

终结符指令

指令 说明
ret 函数返回
br 跳转(条件/无条件)
switch 多路分支
unreachable 不可达代码

可视化 CFG

1
2
3
4
# 生成 CFG 图像
opt -passes=dot-cfg add.ll -disable-output
# 生成 add.dot,用 graphviz 查看
dot -Tpng add.dot -o add.png

🌳 Dominator

Dominator(支配)是控制流分析的核心概念。

定义:节点 A 支配 节点 B,当且仅当从入口到 B 的所有路径都经过 A。

1
2
3
4
5
6
7
entry

A
╱ ╲
B C
╲ ╱
D
  • A 支配 B、C、D
  • B 不支配 D(可以从 C 走)
  • D 支配 D 自己

相关概念

概念 定义
支配者(Dominator) 所有到 B 的路径都经过 A,则 A dom B
直接支配者(IDom) 最近的支配者
支配树 以 IDom 关系构建的树
支配边界 A 支配但 A 的支配者不支配的节点集合

用途

  • SSA 构建:phi 指令插入位置由支配边界决定
  • 循环分析:识别循环头和循环体
  • 优化:代码移动、冗余删除

🔩 LLVM Pass

Pass 是对 IR 进行变换或分析的基本单元。

Pass 类型

类型 说明 示例
Analysis Pass 分析 IR,不修改 求支配树、活跃变量分析
Transform Pass 修改 IR 常量折叠、死代码删除
Utility Pass 工具性质 验证 IR 正确性

常用优化 Pass

1
2
3
4
5
6
7
# 运行所有优化
opt -passes=default add.ll -S -o add_opt.ll

# 运行特定 Pass
opt -passes=mem2reg add.ll -S -o add_opt.ll # 提升 alloca 到寄存器
opt -passes=dce add.ll -S -o add_opt.ll # 死代码删除
opt -passes=inline add.ll -S -o add_opt.ll # 函数内联

Pass 管道

1
2
# 组合多个 Pass
opt -passes='mem2reg,dce,gvn' add.ll -S -o add_opt.ll


flowchart LR
    A[原始 IR] --> B[mem2reg]
    B --> C[dce]
    C --> D[gvn]
    D --> E[优化后 IR]

🏷️ metadata

Metadata 是附加在 IR 上的额外信息,不影响程序语义,但能指导优化和代码生成。

语法

1
2
3
4
5
6
7
8
9
10
; 附加到函数
define void @foo() !dbg !4 {
; 附加到指令
%x = add i32 %a, %b, !dbg !7
ret void
}

; 定义 metadata
!0 = !DIFile(filename: "test.c", directory: "/home")
!1 = !{!0}

常见用途

Metadata 用途
!dbg 调试信息
!tbaa 类型别名分析
!llvm.loop 循环优化提示
!nontemporal 非临时内存访问

示例:循环优化提示

1
2
3
4
5
6
7
8
9
for.body:
%i = phi i64 [ 0, %entry ], [ %inc, %for.body ]
; ... 循环体 ...
%inc = add i64 %i, 1
%cmp = icmp ult i64 %inc, %n
br i1 %cmp, label %for.body, label %for.end, !llvm.loop !0

!0 = !{!0, !1}
!1 = !{!"llvm.loop.unroll.count", i32 4} ; 提示展开 4 次

🛠️ LLVM 工具链

工具 用途
clang C/C++ 编译器前端
opt IR 优化器
llc IR → 汇编
llvm-as .ll → .bc(文本转二进制)
llvm-dis .bc → .ll(二进制转文本)
llvm-link 链接多个 bitcode
lli 解释执行 bitcode
llvm-config 查询编译选项

常用命令速查

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# 查看优化级别对应的 Pass
opt -passes='default<O2>' -debug-pass=Structure add.ll -o /dev/null

# 反汇编 bitcode
llvm-dis add.bc -o add.ll

# 链接多个 bitcode
llvm-link a.bc b.bc -o combined.bc

# 直接执行 IR
lli add.bc

# 查看函数属性
opt -passes=print-callgraph add.ll -disable-output

📋 完整 IR 示例

C 源码:

1
2
3
4
5
6
7
8
9
// factorial.c
int factorial(int n) {
if (n <= 1) return 1;
return n * factorial(n - 1);
}

int main() {
return factorial(5);
}

生成并查看 IR:

1
clang -emit-llvm -S -O0 factorial.c -o factorial.ll

完整 IR:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
; ModuleID = 'factorial.c'
source_filename = "factorial.c"
target datalayout = "e-m:e-p270:32:32-p271:32:32-p272:64:64-i64:64-f80:64-n8:16:32:64-S128"
target triple = "x86_64-pc-linux-gnu"

; 函数声明
declare i32 @printf(i8*, ...)

; factorial 函数
define i32 @factorial(i32 %n) {
entry:
%n.addr = alloca i32
store i32 %n, i32* %n.addr

%0 = load i32, i32* %n.addr
%cmp = icmp sle i32 %0, 1
br i1 %cmp, label %if.then, label %if.else

if.then:
br label %return

if.else:
%1 = load i32, i32* %n.addr
%sub = sub nsw i32 %1, 1
%call = call i32 @factorial(i32 %sub)
%2 = load i32, i32* %n.addr
%mul = mul nsw i32 %2, %call
br label %return

return:
%retval.0 = phi i32 [ 1, %if.then ], [ %mul, %if.else ]
ret i32 %retval.0
}

; main 函数
define i32 @main() {
entry:
%call = call i32 @factorial(i32 5)
ret i32 %call
}

优化后(-O2

1
clang -emit-llvm -S -O2 factorial.c -o factorial_opt.ll
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
define i32 @factorial(i32 %n) {
entry:
%cmp = icmp sgt i32 %n, 1
br i1 %cmp, label %if.else, label %return

if.else:
%sub = add nsw i32 %n, -1
%call = tail call i32 @factorial(i32 %sub)
%mul = mul nsw i32 %call, %n
br label %return

return:
%retval.0 = phi i32 [ %mul, %if.else ], [ 1, %entry ]
ret i32 %retval.0
}

define i32 @main() {
entry:
ret i32 120 ; 编译期计算完成!
}

注意main() 的返回值被编译期算出来了(5! = 120),这就是常量传播 + 函数内联的效果。

📋 总结

主题 要点
LLVM 架构 前端→IR→后端,IR 是核心
IR 结构 Module → Function → BasicBlock → Instruction
SSA 每个变量只赋值一次,简化分析
phi 根据控制流来源选择值,解决 SSA 的分支问题
CFG 基本块 + 跳转边,描述程序执行路径
Dominator 支配关系,用于 SSA 构建和优化
Pass IR 变换的基本单元
工具链 clang、opt、llc、lli 等

LLVM IR 看起来繁琐,但它为编译器提供了统一的表达。理解 IR,就拿到了编译器世界的“通用护照”。

下一步?试试写一个 LLVM Pass,或者用 LLVM IR 实现一个简单的语言解释器!

参考资源