4、LLVM中间表示(IR)基础:IR的三种形式、基本语法、类型系统与指令集概览

LLVM IR(Intermediate Representation)是整个编译器框架的核心枢纽。前端(如 Clang)将源代码翻译为 IR,优化器对 IR 执行数十道分析与变换,后端再将 IR 降级为目标机器码。理解 IR 的三种载体形式、类型系统与指令集,是掌握 LLVM 架构的第一步,也是后续学习 Pass 编写、优化流程与后端移植的基石。本章将系统拆解 IR 的内存表示、文本表示与 bitcode 表示,并深入剖析其语法、类型体系与常用指令。

LLVM IR 三种形式与核心组成 C/C++/Rust 源码 Clang / rustc 前端 内存表示(In-Memory) llvm::Module / Function / Instruction 优化器(Opt) Pass 管理器 后端(CodeGen) 指令选择/寄存器分配 文本表示(.ll 文件) 人类可读,用于调试与测试 llvm-as / llvm-dis 转换 Bitcode(.bc 文件) 二进制序列化,紧凑高效 跨版本兼容,JIT 常用 类型系统 i32, float, ptr, struct, array 第一类类型 / 标签类型 指令集概览(按功能分类) 终结指令 ret / br / switch 算术/逻辑 add / sub / mul / and 内存访问 load / store / alloca 类型转换 trunc / zext / bitcast 其他 phi / call / icmp 三种形式可互相转换,内存表示是优化与变换的核心载体

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),这是实现数据流分析的基础。

核心要点:内存表示是唯一能够被 Pass 直接修改的形式。文本和 bitcode 只是它的序列化视图,任何修改最终都要通过 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 的关键技能。

经验提示:在编写 IR 时,如果发现某个变量在多个基本块中被赋值,应该考虑使用 phi 指令。不要试图在 IR 中模拟可变变量——那是违反 SSA 规则的。

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:从函数返回,可带返回值或 void
  • br:无条件跳转或条件跳转
  • switch:多路分支,类似 C 的 switch
  • indirectbr:间接跳转(用于实现跳转表)
  • 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,可以发现几个典型问题:

  1. 大量 alloca/load/store:变量 total 和 i 被存储在栈上,每次访问都要 load/store,效率低下。
  2. 冗余的 sext 指令:i 从 i32 符号扩展到 i64 用于 GEP 索引。
  3. 循环结构复杂: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 合并了冗余指令,使循环结构更加紧凑。

复盘要点:未优化的 IR 忠实反映了源代码的结构,但包含大量"内存化"的临时变量。优化 Pass 的核心工作之一就是将这些内存操作提升为寄存器操作,并利用 phi 指令维护 SSA 约束。理解这一过程,有助于编写高效的 Pass 和诊断性能问题。

4.6 常见误区与总结

在学习 LLVM IR 时,初学者常犯以下错误:

误区一:混淆文本 IR 与汇编语言。IR 虽然看起来像汇编,但它是强类型、SSA 形式的中间表示,包含丰富的类型信息和数据流关系。不要试图用汇编的思维去理解 IR。