LLVM IR 实战

本页目录

LLVM IR 是优化器与后端共同处理的有类型中间语言。它包含 SSA 值、显式控制流和显式内存操作,但并不表示所有值最后都放物理寄存器,也不等价于某种源语言的全部语义。

下面例子采用现代 ptr 不透明指针语法,可使用支持该语法的同版本 LLVM 工具验证。语言参考规定每条指令的准确语义,不能只凭名称推断。LLVM Language Reference

三种表示与工具分工

文本 .ll 便于阅读,bitcode .bc 用于序列化,内存 IR 由编译器 API 操作。llvm-as 把文本组装为 bitcode,llvm-dis 反向显示;opt 分析和变换 IR,llc 生成目标代码。并不是所有工具都在任意三种表示间互转。

可以先用 clang -S -emit-llvm 观察 C 代码的 IR,再用小型手写模块理解具体指令。不同优化级别会明显改变输出结构。

名字、类型与基本块

@name 常引用全局变量或函数,%name 可以引用局部值、参数及其他局部标识。整数类型 i32 表示 32 位,不自带“有符号”属性;icmp slt、icmp ult 和不同除法指令分别选择有符号或无符号解释。

基本块以终结指令结束,如 br、ret 或 switch。普通指令不能放到终结指令之后继续执行;φ 位于块开头,并为前驱提供输入。间接函数调用使用 call 的函数指针目标,不能把 indirectbr 当成普通虚函数调用指令。

一个可执行的模块

将下面保存为 example.ll:

%Pair = type { i32, double }

define i32 @choose_add(i1 %take_a, i32 %a, i32 %b) {
entry:
  br i1 %take_a, label %left, label %right
left:
  %a1 = add i32 %a, 1
  br label %merge
right:
  %b1 = add i32 %b, 1
  br label %merge
merge:
  %result = phi i32 [ %a1, %left ], [ %b1, %right ]
  ret i32 %result
}

define double @read_weight(ptr %p) {
entry:
  %field = getelementptr %Pair, ptr %p, i64 0, i32 1
  %value = load double, ptr %field
  ret double %value
}

define i32 @main() {
entry:
  %pair = alloca %Pair
  %weight = getelementptr %Pair, ptr %pair, i64 0, i32 1
  store double 2.500000e+00, ptr %weight
  %got = call double @read_weight(ptr %pair)
  %a = call i32 @choose_add(i1 true, i32 40, i32 9)
  %b = call i32 @choose_add(i1 false, i32 40, i32 9)
  %ok_a = icmp eq i32 %a, 41
  %ok_b = icmp eq i32 %b, 10
  %ok_w = fcmp oeq double %got, 2.500000e+00
  %ok_ab = and i1 %ok_a, %ok_b
  %ok = and i1 %ok_ab, %ok_w
  %status = select i1 %ok, i32 0, i32 1
  ret i32 %status
}
llvm-as example.ll -o example.bc
opt -passes=verify -disable-output example.bc
lli example.bc
opt -passes='default<O2>' -S example.bc -o optimized.ll

main 用两条分支的实际结果和结构体字段读取结果构造退出状态,成功为 0。验证器检查 IR 结构与部分语义条件,但不会证明任意指针在运行时都有效。

Opaque pointer:指针类型不携带 pointee type

现代 ptr 不编码指向 i32 还是 double,读写宽度由 load/store 指令给出,地址计算布局由 GEP 的源元素类型给出。地址空间仍然是指针类型的重要部分,不应把所有 ptr 当作相同物理表示。Opaque Pointers

因此 load i32, ptr %p 不是运行时检查 p 真的指向 i32,而是前端声明这次读取采用 i32 语义。有效对象、对齐、生命周期和别名等要求仍需满足。

GEP:第一个索引不等于“解引用”

GEP 计算位置,load 才读取该位置的内容 ptr %base 指向 i32 元素序列 GEP i32,索引 3 按布局前进 3 个元素 ptr %element 仍然是地址 随后 load i32, ptr %element 才产生读取的 i32 值。

对 %Pair,getelementptr %Pair, ptr %p, i64 0, i32 1 先选择第 0 个 Pair,再选择字段 1。字段偏移包含目标 DataLayout 的对齐与填充,不是简单把前面字段大小相加。

第一个索引可以非零:把 %p 当作连续 Pair 序列的基址时,索引 2 表示按 Pair 的分配大小前进两个元素。普通数组元素访问也经常使用非零索引,opaque pointer 没有把它变成 no-op。

GEP 本身不访存,不意味着任意计算出的地址都能读取。inbounds 还添加更强的对象与计算约束,违反时可能产生 poison,不能为了“让优化更好”无依据地添加。

Pair 数组:元素步长与字段偏移要一起计算

在常见 x86-64 System V 布局下,{ i32, double } 的字段 0 占字节 [0,4),接着有 4 字节填充,字段 1 占 [8,16),整个对象大小为 16。若访问 Pair 数组的第 2 个元素的 weight,偏移为 2*16+8=40,不是 2*(4+8)+4=28。

计算部分该目标上的字节数来源
一个 Pair 的分配大小16字段布局与整体对齐
第 2 个元素起点32按分配大小跳过两个元素
weight 在元素内偏移8double 字段对齐产生填充
最终相对地址40元素起点加字段偏移

可用相同目标的 C 布局作交叉核对。这里静态断言明确固定预期,不声称这些数值适用于任意 DataLayout:

#include <stddef.h>
struct Pair { int tag; double weight; };
_Static_assert(sizeof(int) == 4, "requires 32-bit int");
_Static_assert(sizeof(struct Pair) == 16, "different target layout");
_Static_assert(offsetof(struct Pair, weight) == 8, "different field layout");
int main(void) {
    struct Pair pairs[3] = {{0, 0.0}, {0, 0.0}, {7, 2.5}};
    return pairs[2].weight != 2.5;
}

不要用“指针是 64 位”单独推导整个聚合布局。DataLayout 还规定不同类型的大小与对齐,packed 结构也会改变字段间填充;即使算出地址,load 的对齐承诺也必须符合实际对象。GEP 布局说明

第几个对象、哪个字段,各自贡献多少偏移?

地址式的乘法与加法分别对应两层选择:跨过 i 个完整对象,再进入当前对象的字段。下面保持各行字节标尺,显式画出普通布局的填充与 packed 的短步长。变化的是示例 DataLayout,并没有执行 load;某字段地址不是自然对齐时,也不能无依据地给后续访问添加更强对齐承诺。

正在呈现知识画面
第几个对象、哪个字段,各自贡献多少偏移?

GEP 按对象分配大小跨元素,再加字段偏移。普通示例布局含对齐填充;packed 改变布局但不保证访问自然对齐。地址计算不读取内存。

算术标志与源语言语义

普通整数 add 按位宽处理;nsw、nuw 表达相应无溢出的承诺,并非自动检查溢出抛出源语言异常。若源语言需要有检查加法,前端要生成检测、分支或合适 intrinsic。

同理,不能用无条件求值的 select 简单替代所有短路 &&。若右侧会调用函数、访存或抛错,必须保留源语言的条件执行。选择两份已算好的值与控制是否求值不同。

有检查加法:把溢出标志变成控制流

设接口约定:两个有符号 i32 相加,成功时写入输出槽并返回 1,溢出时不修改输出槽并返回 0。普通 add 没有这个检查行为,add nsw 也不会自动替我们跳到失败分支。保存为 checked.ll:

declare { i32, i1 } @llvm.sadd.with.overflow.i32(i32, i32)

define i32 @checked_add(i32 %a, i32 %b, ptr %out) {
entry:
  %pair = call { i32, i1 } @llvm.sadd.with.overflow.i32(i32 %a, i32 %b)
  %sum = extractvalue { i32, i1 } %pair, 0
  %overflow = extractvalue { i32, i1 } %pair, 1
  br i1 %overflow, label %failure, label %success
success:
  store i32 %sum, ptr %out, align 4
  ret i32 1
failure:
  ret i32 0
}

这个 intrinsic 同时产生截断后的位模式和有符号溢出标志。即使失败分支不使用 %sum,计算标志也没有消失;成功分支才把结果暴露给调用方。这里的接口要求 out 指向有效、至少 4 字节对齐的 i32 输出对象,C 驱动使用普通 int 对象满足本目标要求。有溢出检测的加法

/* checked-main.c:clang checked.ll checked-main.c -o checked-demo */
#include <limits.h>
_Static_assert(INT_MAX == 2147483647, "requires 32-bit int");
extern int checked_add(int, int, int *);
int main(void) {
    int out = 99;
    if (checked_add(20, 22, &out) != 1 || out != 42) return 1;
    if (checked_add(INT_MAX, 0, &out) != 1 || out != INT_MAX) return 2;
    out = 99;
    if (checked_add(INT_MAX, 1, &out) != 0 || out != 99) return 3;
    if (checked_add(INT_MIN, -1, &out) != 0 || out != 99) return 4;
    if (checked_add(INT_MIN, 0, &out) != 1 || out != INT_MIN) return 5;
    return 0;
}

对输入 INT_MAX 与 1,数学和超出有符号范围,返回 0 且保留原 out=99;对 INT_MAX 与 0,边界值本身合法,返回 1。两个分支的差异由显式 IR 定义,优化器可以更换机器实现,但必须保留此接口行为。

溢出检测产生事实,分支决定如何兑现接口 sadd.with.overflow 同时产生 sum 与 overflow 检查 overflow 按 i1 选择后继块 未溢出 store sum;返回 1 溢出 不写 out;返回 0 nsw 是无有符号溢出的承诺;它不是上述检查和错误返回流程的替代品。

同一份位模式,为什么不能代表同一种承诺?

把位模式和接口承诺并排放置,才能避免用“溢出了”概括所有行为。下面缩成 i8 以便看清符号位,但三种语义仍分开:模加法得到截断位模式,nsw 对超出承诺的结果给出 poison,检查接口则通过显式分支决定是否写 out。poison 标签只是结果类别,不模拟后续任意使用的行为。

正在呈现知识画面
同一份位模式,为什么不能代表同一种承诺?

普通 add 保留模 256 位模式;nsw 在有符号溢出时产生 poison,不是抛异常;检查接口显式返回失败,并保留原输出槽 99。

可变局部变量与地址身份

初学前端可以为可变局部变量建立合适的 alloca,使用 load/store,再让 mem2reg 或 SROA 提升可优化部分。并非“对 alloca 取地址”本身就阻止提升,alloca 本来就返回地址;关键是地址的具体使用是否符合提升条件。

若源程序允许对象地址逃逸或可观察身份,前端应从一开始正确建模。不能在优化后看到源代码的 &x 就随意新建一个 alloca 并声称恢复了原对象身份。

模块与 ABI 边界

目标 triple 和 DataLayout 影响布局与生成目标。调用外部函数还需要正确的签名、调用约定、参数属性和 ABI lowering。IR 中一个聚合参数的写法不保证天然符合所有平台 C ABI。

musttail、异常调用、原子操作、volatile 与各种属性都有独立约束;先用最小可信语义生成合法 IR,再根据证明添加优化信息。错误属性可能使优化器合法地产生不符合原意的程序。

参考

Keywords: LLVM IR, bitcode, ptr, opaque pointer, phi, GEP, load, store, DataLayout, poison, mem2reg, verifier