3、LLVM IR基础语法:模块、函数、基本块、指令、操作数、类型系统与常量
LLVM IR(Intermediate Representation)是LLVM编译框架的核心枢纽,它将高级语言(如C/C++、Rust、Swift)与目标机器指令(如x86、ARM、RISC-V)解耦。理解LLVM IR的语法与结构,是编写高效、正确Pass的前提。本章将系统拆解IR的六大核心要素:模块(Module)、函数(Function)、基本块(BasicBlock)、指令(Instruction)、操作数(Operand)以及类型系统与常量(Type System & Constant)。我们将从静态结构出发,结合动态数据流视角,帮助你建立对IR的立体认知。
上图展示了LLVM IR的层次化结构。最外层是Module,它像一个容器,装载了所有全局实体。Module内部包含多个Function,每个Function又由若干BasicBlock组成,BasicBlock内是线性排列的Instruction。右侧展示了类型系统与常量的分类,它们是IR的"静态骨架"。指令与操作数之间通过SSA(Static Single Assignment)形式建立数据依赖关系。下面我们逐一深入剖析每个要素。
3.1 模块(Module):IR的顶层容器
核心原理:Module是LLVM IR的编译单元,对应一个源文件或一个链接单元。它负责管理全局命名空间,包括全局变量、函数声明与定义、目标特性(如数据布局、目标三元组)以及元数据。在Pass编写中,我们通常以Module为入口遍历所有函数。
关键组成:
- 全局变量:以
@开头,如@count = global i32 0,可指定constant修饰符。 - 函数声明与定义:声明只有签名(如
declare i32 @printf(i8*, ...)),定义包含函数体。 - 目标特性:
target datalayout和target triple,影响数据对齐与ABI。 - 元数据:以
!开头,如!llvm.module.flags,用于传递编译选项或调试信息。
实战场景:在编写一个"函数内联"Pass时,你需要遍历Module中所有Function,检查其属性(如alwaysinline),并操作CallInst指令。Module提供了getFunctionList()接口,这是所有函数级Pass的起点。
常见误区:初学者容易混淆全局变量与局部变量。全局变量使用@前缀,存储在静态内存区;局部变量使用%前缀,是SSA虚拟寄存器。在IR中,全局变量是GlobalVariable类,局部变量是AllocaInst或SSA值。
3.2 函数(Function)与调用约定
核心原理:Function在IR中是一等公民,它包含函数签名(返回类型、参数列表、调用约定)和函数体(由BasicBlock组成)。函数可以声明或定义,声明用于外部符号链接。
函数结构拆解:
| 组成部分 | 示例 | 说明 |
|---|---|---|
| 返回类型 | i32 | 可以是void、整数、指针、结构体等 |
| 函数名 | @add | 全局唯一,以@开头 |
| 参数列表 | (i32 %a, i32 %b) | 每个参数有类型和名称 |
| 调用约定 | ccc, fastcc | 影响参数传递方式(寄存器/栈) |
| 函数属性 | #0 | 如nounwind, readonly |
| 基本块列表 | entry:, loop: | 第一个基本块是入口块 |
操作流程:在Pass中获取函数信息,通常使用Function类的API:arg_begin()遍历参数,getEntryBlock()获取入口块,getCallingConv()获取调用约定。
经验提醒:当处理递归函数或间接调用时,注意FunctionType与PointerType的区别。函数指针类型是ptr指向函数类型,例如ptr @func_ptr。
3.3 基本块(BasicBlock)与控制流
核心原理:BasicBlock是单入口、单出口的指令序列。它只有一个终止指令(Terminator),如br、ret、switch、unreachable。基本块之间通过显式跳转形成控制流图(CFG)。
基本块特性:
- 入口唯一:只能从块首开始执行,不能从中间进入。
- 出口唯一:最后一条指令必须是终止指令。
- PHI节点:位于块首,用于合并来自不同前驱的值。
- 前驱与后继:通过
getPredecessors()和getSuccessors()遍历。
案例拆解:考虑以下C代码片段:
int max(int a, int b) {
if (a > b) return a;
else return b;
}
对应的IR基本块结构如下:
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块通过br指令分支到then或else,两个分支块都直接返回。注意icmp指令产生i1类型的比较结果,作为分支条件。
常见误区:基本块不能以普通指令结尾,必须以终止指令结尾。如果Pass修改了控制流,必须确保每个基本块仍然满足这一约束,否则IR验证会失败(verifyModule报错)。
3.4 指令(Instruction)与操作数(Operand)
核心原理:指令是IR的执行单元,分为几大类:终结指令(ret, br, switch)、二元运算(add, sub, mul)、内存访问(load, store, alloca)、类型转换(trunc, zext, bitcast)、比较指令(icmp, fcmp)等。操作数是指令的输入,可以是常量、基本块标签、函数参数或其他指令的结果。
SSA形式:每个指令定义一个新值(除非是store等无结果指令),且只能被赋值一次。这简化了数据流分析,但引入了PHI节点来处理控制流合并。
操作数遍历方法:
// 遍历指令的操作数
for (Use &U : I.operands()) {
Value *V = U.get();
// V 可以是 Constant、Argument、Instruction 等
}
实战场景:在实现"常量传播"Pass时,你需要检查每条指令的操作数是否为ConstantInt。如果是,则尝试在编译期计算结果并替换指令。例如:
%x = add i32 2, 3 ; 操作数都是常量,可折叠为 5
案例闭环:假设我们有一个简单的算术表达式a = b + 1; c = a * 2;。在IR中,这会产生两条指令:%1 = add i32 %b, 1和%2 = mul i32 %1, 2。如果通过分析得知%b恒为10,那么%1可折叠为11,%2可折叠为22。这个过程中,操作数的类型(i32)和常量值(1和2)是折叠的关键输入。实际操作时,我们调用ConstantExpr::getAdd()创建新常量,然后调用replaceAllUsesWith()替换旧值。最终,两条指令被删除,%b的使用点直接得到常量22。这个优化减少了运行时计算,但代价是需要额外的分析时间。在-O2级别,LLVM会自动执行此优化。
3.5 类型系统(Type System)详解
核心原理:LLVM IR是强类型语言,每个值都有明确的类型。类型系统分为第一类类型(First-class types)和非第一类类型。第一类类型可以作为指令的操作数,包括整数、浮点、指针、向量、聚合类型等。
类型分类表:
| 类型类别 | 语法示例 | 说明 |
|---|---|---|
| 整数类型 | i1, i8, i32, i64 | 位宽任意,i1用于布尔 |
| 浮点类型 | half, float, double | 对应IEEE 754 |
| 指针类型 | ptr | LLVM 15+统一为ptr,不再区分指向类型 |
| 向量类型 | <4 x i32> | SIMD操作,元素类型和数量固定 |
| 数组类型 | [10 x i8] | 固定长度,元素类型相同 |
| 结构体类型 | {i32, i32} | 可包含不同类型,可命名 |
| 函数类型 | i32 (i32, i32) | 返回类型 + 参数类型列表 |
| 标签类型 | label | 用于分支目标,不是值 |
| 元数据类型 | !{...} | 附加信息,不影响语义 |
类型转换规则:不同类型之间不能隐式转换,必须使用显式指令:trunc(截断)、zext(零扩展)、sext(符号扩展)、fptrunc(浮点降精度)、bitcast(位重解释)。
经验提醒:在编写Pass时,经常需要判断两个类型是否一致。使用Type::getIntegerBitWidth()获取整数位宽,使用isa<PointerType>()判断指针类型。注意ptr类型在LLVM 15之后不再携带元素类型信息,这改变了getElementType()的用法。
3.6 常量(Constant)与常量表达式
核心原理:常量是编译期已知的值,它们在IR中是不可变节点。常量不仅包括字面量,还包括全局变量、常量表达式(如getelementptr的常量形式)和undef/poison值。
常量分类:
- ConstantInt:整数常量,如
i32 42。 - ConstantFP:浮点常量,如
double 3.14。 - ConstantArray/ConstantStruct:聚合常量。
- GlobalValue:全局变量或函数地址,也是常量。
- ConstantExpr:常量表达式,如
ptrtoint、getelementptr。 - UndefValue/PoisonValue:表示未定义或毒值。
常量折叠(Constant Folding):LLVM在构建IR时自动执行简单的常量折叠。例如add i32 1, 2会直接生成i32 3。Pass可以利用ConstantFoldInstruction接口手动触发折叠。
实战场景:在实现"全局变量替换"优化时,如果发现全局变量@g只被初始化一次且从未修改,可以将其替换为常量。操作步骤:
- 检查
GlobalVariable是否有constant修饰符或hasInitializer()。 - 遍历所有使用点,确认没有
store指令写入该变量。 - 将
@g的所有load指令替换为初始值常量。 - 删除全局变量声明。
常见误区:不要混淆undef和poison。undef表示值可以是任意位模式,每次读取可能不同;poison表示值已经"中毒",任何依赖它的操作都会产生poison。在优化时,poison比undef更激进,允许更多优化。
3.7 综合案例:从C代码到IR的完整映射
为了巩固上述概念,我们以一个包含循环和数组访问的C函数为例,展示其IR生成过程。
// C源码
int sum_array(int *arr, int n) {
int sum = 0;
for (int i = 0; i < n; i++) {
sum += arr[i];
}
return sum;
}
对应的LLVM IR(简化版,未优化):
define i32 @sum_array(ptr %arr, i32 %n) {
entry:
%sum = alloca i32, align 4
store i32 0, ptr %sum, align 4
%i = alloca i32, align 4
store i32 0, ptr %i, align 4
br label %cond
cond:
%i.load = load i32, ptr %i, align 4
%cmp = icmp slt i32 %i.load, %n
br i1 %cmp, label %body, label %exit
body:
%i.load1 = load i32, ptr %i, align 4
%idx = sext i32 %i.load1 to i64
%elem_ptr = getelementptr i32, ptr %arr, i64 %idx
%val = load i32, ptr %elem_ptr, align 4
%sum.load = load i32, ptr %sum, align 4
%new_sum = add i32 %sum.load, %val
store i32 %new_sum, ptr %sum, align 4
%i.load2 = load i32, ptr %i, align 4
%inc = add i32 %i.load2, 1
store i32 %inc, ptr %i, align 4
br label %cond
exit:
%sum.final = load i32, ptr %sum, align 4
ret i32 %sum.final
}
案例拆解表:
| IR要素 | 实例 | 作用 |
|---|---|---|
| Module | 整个define块 | 包含函数定义 |
| Function | @sum_array | 函数入口 |
| BasicBlock | entry, cond, body, exit | 控制流节点 |
| Instruction | alloca, load, store, icmp, br, add, getelementptr | 执行操作 |
| Operand | %arr, %n, 0, %i.load | 指令输入 |
| Type | ptr, i32, i64, i1 | 数据宽度与语义 |
| Constant | 0, 1 | 循环初始值和步长 |
分析过程:注意这里使用了alloca来模拟局部变量,这是未优化IR的典型特征。在-O1以上,LLVM会通过mem2regPass将alloca提升为SSA寄存器,消除冗余的load/store。这个案例展示了IR的"显式"特性:所有内存访问、类型转换、控制流跳转都清晰可见,这正是Pass可以分析和变换的基础。
复盘总结:理解IR的关键在于把握"静态结构"与"动态语义"的映射。模块-函数-基本块-指令构成静态层次,而SSA数据流和控制流边构成动态执行模型。类型系统是IR的"语法约束",常量是优化的"燃料"。在后续章节中,我们将基于这些基础编写第一个Pass,实现函数级别的变换。