4、LLVM中间表示(IR)基础:IR的三种形式、基本语法、类型系统与指令集概览
LLVM IR(Intermediate Representation)是整个编译器框架的核心枢纽。前端(如 Clang)将源代码翻译为 IR,优化器对 IR 执行数十道分析与变换,后端再将 IR 降级为目标机器码。理解 IR 的三种载体形式、类型系统与指令集,是掌握 LLVM 架构的第一步,也是后续学习 Pass 编写、优化流程与后端移植的基石。本章将系统拆解 IR 的内存表示、文本表示与 bitcode 表示,并深入剖析其语法、类型体系与常用指令。
4.1 IR 的三种形式:内存、文本与 Bitcode
LLVM IR 并非单一存在,而是以三种等价形式贯穿编译流程。理解这三种形式的差异与转换关系,是使用 LLVM 工具链的基础。
4.1.1 内存表示(In-Memory Representation)
内存表示是 LLVM 在编译过程中真正操作的对象。当 Clang 完成语法分析后,会构建一棵 AST,随后通过 CodeGen 将 AST 翻译为一系列 C++ 对象:llvm::Module 是整个翻译单元的顶层容器,内部包含若干个 llvm::Function,每个函数又由若干 llvm::BasicBlock 组成,而基本块内则是按顺序排列的 llvm::Instruction。这些类之间通过 llvm::User 和 llvm::Value 的继承体系形成一种类似图的结构——SSA(静态单赋值)形式的 def-use 链。
内存表示的核心优势在于:所有优化 Pass 直接操作这些 C++ 对象,通过迭代器遍历指令、修改操作数、插入或删除基本块。由于对象之间通过指针互连,Pass 可以在常数时间内访问指令的使用者(users)和被使用的值(operands),这是实现数据流分析的基础。
Module 对象完成。
4.1.2 文本表示(Textual Form,.ll 文件)
文本表示是 IR 的人类可读形式,以 .ll 为扩展名。它采用类似汇编的语法,但具有更丰富的类型信息。例如,一个简单的加法函数在文本 IR 中如下:
define i32 @add(i32 %a, i32 %b) {
%sum = add i32 %a, %b
ret i32 %sum
}
文本形式主要用于调试、测试和教学。开发者可以通过 llvm-dis 工具将 bitcode 反汇编为文本,或使用 llvm-as 将文本汇编为 bitcode。Clang 也支持通过 -S 选项直接输出文本 IR:
clang -S -emit-llvm foo.c -o foo.ll
文本 IR 的语法设计遵循几个原则:每个指令都有明确的操作码(opcode)和类型标注;寄存器以 % 前缀命名;全局标识符以 @ 前缀命名。这种设计使得 IR 既保留了高级语言的类型信息,又具备底层指令的显式控制流。
4.1.3 Bitcode 表示(Binary Form,.bc 文件)
Bitcode 是 IR 的二进制序列化格式,以 .bc 为扩展名。它采用紧凑的变长编码(VBR)和模块级符号表,体积通常比文本形式小 5-10 倍。Bitcode 的设计目标包括:快速反序列化、跨版本兼容性(LLVM 保证 bitcode 向后兼容)、以及支持增量链接。
Bitcode 的典型应用场景包括:
- 链接时优化(LTO):将多个编译单元以 bitcode 形式存储,链接时统一优化。
- JIT 编译:如 LLVM 的 ORC JIT 直接加载 bitcode 进行即时编译。
- 跨语言互操作:不同前端生成的 bitcode 可以在同一优化流程中处理。
生成 bitcode 的命令为:
clang -emit-llvm -c foo.c -o foo.bc
| 特性 | 内存表示 | 文本表示(.ll) | Bitcode(.bc) |
|---|---|---|---|
| 可读性 | 不可读(C++ 对象) | 人类可读 | 二进制,不可读 |
| 修改能力 | 可直接修改 | 需解析后修改 | 需反序列化后修改 |
| 存储效率 | 内存中 | 中等(文本膨胀) | 高(紧凑编码) |
| 加载速度 | 即时 | 慢(需解析) | 快(直接映射) |
| 典型用途 | Pass 优化 | 调试、测试 | LTO、JIT、分发 |
4.2 IR 基本语法与结构
LLVM IR 的语法设计遵循"显式优于隐式"的原则。每个模块由全局声明、函数定义和元数据组成。理解 IR 的语法结构,需要从模块、函数、基本块和指令四个层级入手。
4.2.1 模块级结构
一个 .ll 文件以目标三元组(target triple)声明开始,随后是全局变量、函数声明和函数定义。例如:
; 模块级注释
target triple = "x86_64-unknown-linux-gnu"
@global_var = global i32 42 ; 全局变量定义
@external_func = declare i32 @printf(i8*, ...) ; 外部函数声明
define i32 @main() {
ret i32 0
}
模块中的标识符分为两类:全局标识符以 @ 开头(如 @global_var),局部标识符以 % 开头(如 %a)。局部标识符可以是显式命名的(如 %sum),也可以是自动编号的(如 %1、%2)。
4.2.2 函数与基本块
函数定义以 define 关键字开始,后跟返回类型、函数名、参数列表和函数体。函数体由若干基本块组成,每个基本块有一个标签(label)作为入口标识,并以终结指令(terminator)结束。基本块内的指令按顺序执行,不允许有跳转进入基本块中间。
define i32 @max(i32 %a, i32 %b) {
entry:
%cmp = icmp sgt i32 %a, %b
br i1 %cmp, label %then, label %else
then:
ret i32 %a
else:
ret i32 %b
}
上述代码展示了三个基本块:entry、then 和 else。entry 块以条件跳转 br 结束,根据比较结果选择跳转到 then 或 else。每个基本块只能有一个终结指令,且终结指令必须是该块的最后一条指令。
4.2.3 SSA 形式与 phi 指令
LLVM IR 强制使用 SSA 形式:每个变量只能被赋值一次。这带来一个挑战——当控制流汇合时,同一个变量可能有多个来源值。解决方案是 phi 指令,它根据前驱基本块选择值:
define i32 @abs(i32 %x) {
entry:
%cmp = icmp slt i32 %x, 0
br i1 %cmp, label %neg, label %pos
neg:
%neg_x = sub i32 0, %x
br label %merge
pos:
br label %merge
merge:
%result = phi i32 [ %neg_x, %neg ], [ %x, %pos ]
ret i32 %result
}
phi 指令的语法为 phi 类型 [值, 前驱块标签], [值, 前驱块标签], ...。它必须位于基本块的开头,且每个前驱块必须对应一个值。理解 phi 指令是阅读和编写 IR 的关键技能。
4.3 类型系统详解
LLVM IR 的类型系统是强类型的,每个值都有明确的类型。类型分为两大类:第一类类型(first-class types)和标签类型(label type)。第一类类型可以作为指令的操作数,而标签类型仅用于基本块标识。
4.3.1 基本类型
| 类型 | 语法 | 说明 | 示例 |
|---|---|---|---|
| 整数 | iN |
N 位整数,N 可为 1-2^23-1 | i32, i64, i1 |
| 浮点 | half/float/double |
16/32/64 位 IEEE 浮点 | float, double |
| 指针 | ptr |
LLVM 14+ 采用不透明指针 | ptr |
| 向量 | <N x T> |
N 个 T 类型元素的向量 | <4 x i32> |
| 数组 | [N x T] |
定长数组 | [4 x i8] |
| 结构体 | { T1, T2, ... } |
异构聚合类型 | { i32, ptr } |
| 函数 | ret_type (arg_types) |
函数签名类型 | i32 (i32, i32) |
| 空类型 | void |
仅用于函数返回 | void |
4.3.2 不透明指针与类型转换
自 LLVM 14 起,指针类型统一为 ptr,不再携带指向类型信息。这一改变简化了 IR,但要求开发者在使用 load/store 时显式指定数据类型。例如:
; 旧式带类型指针(LLVM 13 及以前)
%p = alloca i32*
; 新式不透明指针(LLVM 14+)
%p = alloca ptr
类型转换指令用于在不同类型之间转换:
trunc:整数截断(如 i64 → i32)zext:零扩展(如 i32 → i64)sext:符号扩展bitcast:位级重新解释(如 i32 → float)ptrtoint/inttoptr:指针与整数互转
bitcast 只改变类型解释,不改变底层位模式。如果类型大小不一致,bitcast 会触发断言失败。此时应使用 ptrtoint + inttoptr 组合,或先 trunc/zext 再转换。
4.4 指令集概览与分类
LLVM IR 的指令集约 100 余种,按功能可分为六大类。掌握指令分类有助于快速阅读 IR 和编写 Pass。
4.4.1 终结指令(Terminator Instructions)
终结指令是每个基本块的最后一条指令,决定控制流的去向。主要包括:
ret:从函数返回,可带返回值或 voidbr:无条件跳转或条件跳转switch:多路分支,类似 C 的 switchindirectbr:间接跳转(用于实现跳转表)unreachable:标记不可达代码
4.4.2 算术与逻辑指令
这些指令执行二元或一元运算,结果产生新值。整数运算区分有符号和无符号:
- 整数运算:
add、sub、mul、udiv(无符号除)、sdiv(有符号除)、urem、srem - 浮点运算:
fadd、fsub、fmul、fdiv、frem - 位运算:
and、or、xor、shl(左移)、lshr(逻辑右移)、ashr(算术右移)
4.4.3 内存访问指令
内存操作是 IR 中与硬件最接近的部分:
alloca:在栈上分配内存,返回指针load:从内存读取值store:将值写入内存getelementptr(GEP):计算数组/结构体元素的地址
GEP 是 LLVM 中最容易出错的指令之一。它的语法为:
%elem_ptr = getelementptr i32, ptr %array_ptr, i64 2, i64 5
第一个索引(2)作用于外层指针,第二个索引(5)作用于内层数组。GEP 只计算地址,不访问内存。
4.4.4 比较与转换指令
比较指令 icmp(整数比较)和 fcmp(浮点比较)返回 i1 类型。整数比较支持 eq、ne、ugt、uge、ult、ule、sgt、sge、slt、sle 十种谓词。转换指令已在 4.3.2 节介绍。
4.4.5 其他指令
call:函数调用,支持普通调用和尾调用phi:SSA 汇合选择(见 4.2.3)select:三元选择,类似 C 的?:extractelement/insertelement:向量元素提取/插入extractvalue/insertvalue:聚合类型字段提取/插入
4.5 实战场景:从 C 代码到 IR 的完整流程
为了将上述概念串联起来,我们以一个实际例子演示从 C 代码到 IR 的完整转换过程,并分析关键优化点。
4.5.1 场景设定与初始代码
假设我们有一个简单的 C 函数,计算数组元素之和:
// sum.c
int sum(int *arr, int n) {
int total = 0;
for (int i = 0; i < n; i++) {
total += arr[i];
}
return total;
}
使用 Clang 生成未优化的 IR:
clang -S -emit-llvm sum.c -o sum.ll
生成的 IR 片段(简化后):
define i32 @sum(ptr %arr, i32 %n) {
entry:
%total = alloca i32, align 4
store i32 0, ptr %total, align 4
%i = alloca i32, align 4
store i32 0, ptr %i, align 4
br label %for.cond
for.cond:
%i.load = load i32, ptr %i, align 4
%cmp = icmp slt i32 %i.load, %n
br i1 %cmp, label %for.body, label %for.end
for.body:
%i.load1 = load i32, ptr %i, align 4
%idxprom = sext i32 %i.load1 to i64
%arr_idx = getelementptr i32, ptr %arr, i64 %idxprom
%arr_val = load i32, ptr %arr_idx, align 4
%total.load = load i32, ptr %total, align 4
%add = add i32 %total.load, %arr_val
store i32 %add, ptr %total, align 4
br label %for.inc
for.inc:
%i.load2 = load i32, ptr %i, align 4
%inc = add i32 %i.load2, 1
store i32 %inc, ptr %i, align 4
br label %for.cond
for.end:
%total.load3 = load i32, ptr %total, align 4
ret i32 %total.load3
}
4.5.2 分析未优化 IR 的问题
观察上述 IR,可以发现几个典型问题:
- 大量 alloca/load/store:变量
total和i被存储在栈上,每次访问都要 load/store,效率低下。 - 冗余的 sext 指令:
i从 i32 符号扩展到 i64 用于 GEP 索引。 - 循环结构复杂:for 循环被拆分为 4 个基本块,控制流跳转频繁。
使用 opt 工具应用优化 Pass:
opt -S -mem2reg -instcombine sum.ll -o sum_opt.ll
优化后的 IR 显著改善:
define i32 @sum(ptr %arr, i32 %n) {
entry:
%cmp4 = icmp sgt i32 %n, 0
br i1 %cmp4, label %for.body.preheader, label %for.end
for.body.preheader:
br label %for.body
for.body:
%i.05 = phi i32 [ 0, %for.body.preheader ], [ %inc, %for.body ]
%total.06 = phi i32 [ 0, %for.body.preheader ], [ %add, %for.body ]
%idxprom = sext i32 %i.05 to i64
%arr_idx = getelementptr i32, ptr %arr, i64 %idxprom
%arr_val = load i32, ptr %arr_idx, align 4
%add = add i32 %total.06, %arr_val
%inc = add i32 %i.05, 1
%cmp = icmp slt i32 %inc, %n
br i1 %cmp, label %for.body, label %for.end
for.end:
%total.0.lcssa = phi i32 [ %add, %for.body ]
ret i32 %total.0.lcssa
}
4.5.3 优化效果与复盘
| 指标 | 优化前 | 优化后 | 改善幅度 |
|---|---|---|---|
| 指令总数 | 约 25 条 | 约 12 条 | 减少 52% |
| load/store 指令 | 8 条 | 1 条 load | 减少 87.5% |
| 基本块数量 | 5 个 | 4 个 | 减少 20% |
| phi 指令 | 0 条 | 3 条 | 引入(SSA 必需) |
优化后的 IR 将 total 和 i 提升为 SSA 寄存器(通过 mem2reg Pass),消除了大量内存访问。同时 instcombine 合并了冗余指令,使循环结构更加紧凑。
4.6 常见误区与总结
在学习 LLVM IR 时,初学者常犯以下错误: