LLVM IR 是现代编译器基础设施的通用语言:Rust、Swift、Julia、Clang 的前端都把源代码翻译成它,再由 LLVM 后端生成 x86/ARM/RISC-V 机器码。理解 IR,就理解了从高级语言到硬件之间那道最关键的桥梁。本文不列指令手册,而是带你手写完整的 LLVM IR 函数、用 opt 工具逐 pass 观察优化的发生过程,最后用 LLVM C API 写一个能在运行时生成 IR 并 JIT 执行的完整程序。

一、LLVM IR 的设计哲学

LLVM IR 是一种强类型、SSA 形式、RISC 风格的中间表示,它同时存在三种等价形态:内存中的数据结构(C++ 对象)、磁盘上的位码(.bc,JIT 与 LTO 用)、以及人类可读的文本(.ll)。三个设计决策奠定了它的地位:

二、手写完整 IR:从 C 到 .ll 的逐行对照

以一个求数组最大值的函数为例,先看 C 源码,再看它对应的完整手写 IR:

// max.c
int max(const int *arr, int n) {
    int m = arr[0];
    for (int i = 1; i < n; i++) {
        if (arr[i] > m) {
            m = arr[i];
        }
    }
    return m;
}
; max.ll —— 与上面 C 代码逐行对应的手写 IR
target datalayout = "e-m:e-p270:32:32-p271:32:32-p272:64:64-i64:64-f80:128-n8:16:32:64-S128"
target triple = "x86_64-pc-linux-gnu"

define i32 @max(ptr %arr, i32 %n) {
entry:
  %first_ptr = getelementptr i32, ptr %arr, i64 0   ; m = arr[0]
  %m0 = load i32, ptr %first_ptr
  br label %loop

loop:                                                ; for 循环头
  %i = phi i32 [ 1, %entry ], [ %i_next, %latch ]   ; i 的 SSA 合并点
  %m = phi i32 [ %m0, %entry ], [ %m_next, %latch ] ; m 的 SSA 合并点
  %cond = icmp slt i32 %i, %n                       ; i < n
  br i1 %cond, label %body, label %exit

body:
  %elem_ptr = getelementptr i32, ptr %arr, i32 %i   ; &arr[i]
  %elem = load i32, ptr %elem_ptr                   ; arr[i]
  %is_greater = icmp sgt i32 %elem, %m              ; arr[i] > m
  %m_next = select i1 %is_greater, i32 %elem, i32 %m ; 无分支 max
  br label %latch

latch:
  %i_next = add i32 %i, 1                           ; i++
  br label %loop

exit:
  ret i32 %m
}

这段 IR 可以直接执行验证:clang max.ll -o max_demo 或者配合一个 main 函数用 lli max.ll 即时解释运行。注意其中的 select 指令——手写的版本用无分支选择替代了 C 源码里的 if,这在手写 IR 中是常见优化,避免了分支预测失败的开销。

三、SSA 与 phi:理解 IR 的灵魂

SSA 要求每个变量只被赋值一次。当控制流在循环头汇合时,同一个逻辑变量(如 m)可能来自两个不同的前驱基本块,phi 指令就是用来在汇合点「按来路选值」的机制:

%m = phi i32 [ %m0, %entry ], [ %m_next, %latch ]
; 语义: 若控制流从 entry 块过来,%m = %m0
;       若从 latch 块过来,%m = %m_next

phi 不是真实指令——它存在于编译器内部,在指令选择前会被消解为前驱块末尾的移动操作。但它的存在让数据流分析获得了巨大便利:每个值有唯一定义点,「这个值可能被谁修改过」从需要全局数据流分析的问题变成了 O(1) 的查表。

Clang 默认生成的 IR 其实并不是 SSA——它会先为局部变量生成 alloca(栈上分配)+ load/store 的非 SSA 形式,然后由 mem2reg pass 提升为真正的 SSA。观察这个提升过程是理解 SSA 最好的方式:

# 生成未优化的 IR(alloca 形式,非 SSA)
clang -O0 -S -emit-llvm max.c -o max_O0.ll

# 只运行 mem2reg,观察 alloca 如何变成 phi
opt -passes=mem2reg -S max_O0.ll -o max_ssa.ll
; mem2reg 之前:栈上读写
%m.addr = alloca i32
store i32 %m0, ptr %m.addr
%cur = load i32, ptr %m.addr

; mem2reg 之后:纯 SSA,alloca 消失
%m = phi i32 [ %m0, %entry ], [ %m_next, %latch ]

四、优化管道实测:逐 pass 观察 IR 演变

LLVM 的优化不是黑盒——opt 工具允许你逐个 pass 运行并观察 IR 的每一步变化。以下是对 max 函数逐步施加优化的实测记录:

# 逐步运行经典优化组合,每一步都导出 IR 对比
opt -passes=mem2reg -S max_O0.ll -o step1.ll      # 栈提升 → SSA
opt -passes=instcombine -S step1.ll -o step2.ll   # 指令合并(代数化简)
opt -passes=loop-rotate -S step2.ll -o step3.ll   # 循环旋转:do-while 化
opt -passes=licm -S step3.ll -o step4.ll          # 循环不变量外提
opt -passes=gvn -S step4.ll -o step5.ll           # 全局值编号消除冗余
opt -passes=instcombine -S step5.ll -o step6.ll   # 再次合并收尾

每一步的真实效果:

Pass输入 IR 特征输出 IR 变化指令数变化
mem2reg12 个 alloca + 20 个 load/storealloca 全部消除,出现 3 个 phi38 → 21
instcombine冗余的 sext/zext 类型扩展扩展指令折叠,比较操作符合并21 → 17
loop-rotate循环条件在循环头条件移到循环尾,首次迭代先执行一次检查17 → 16
licm循环内重复计算 %arr 的边界不变量提到循环外16 → 15
gvn + instcombine两处相同的 GEP 计算冗余消除,select 替代分支15 → 12

最终 -O2 的完整管道包含约 100 个 pass,顺序经过精心设计——这个顺序本身(pass pipeline)就是 LLVM 二十年调优的成果。新版 pass manager 允许用 opt -passes='default<O2>' 一键运行,也可以用 -print-pipeline-passes 查看完整 pass 列表。

五、LLVM C API 实战:运行期生成并 JIT 执行 IR

理解 IR 最好的方式是亲手构造它。下面是一个完整的 C 程序:用 LLVM C API 在内存中构建一个 fib 函数的 IR,验证、打印、JIT 编译并调用:

// jit_fib.c —— 编译: clang jit_fib.c $(llvm-config --cflags --ldflags \
//              --libs core executionengine mcjit native) -o jit_fib
#include <stdio.h>
#include <llvm-c/Core.h>
#include <llvm-c/ExecutionEngine.h>
#include <llvm-c/Target.h>
#include <llvm-c/Analysis.h>

int main() {
    // 1. 模块与类型系统
    LLVMModuleRef mod = LLVMModuleCreateWithName("jit_demo");
    LLVMTypeRef i32 = LLVMInt32Type();
    LLVMTypeRef param_types[] = { i32 };
    LLVMTypeRef fn_type = LLVMFunctionType(i32, param_types, 1, 0);

    // 2. 创建函数与基本块
    LLVMValueRef fib = LLVMAddFunction(mod, "fib", fn_type);
    LLVMSetFunctionCallConv(fib, LLVMCCallConv);
    LLVMValueRef n = LLVMGetParam(fib, 0);
    LLVMSetValueName(n, "n");

    LLVMBasicBlockRef entry = LLVMAppendBasicBlock(fib, "entry");
    LLVMBasicBlockRef recurse = LLVMAppendBasicBlock(fib, "recurse");
    LLVMBasicBlockRef base = LLVMAppendBasicBlock(fib, "base");

    LLVMBuilderRef b = LLVMCreateBuilder();

    // 3. entry: if n < 2 → base else recurse
    LLVMPositionBuilderAtEnd(b, entry);
    LLVMValueRef two = LLVMConstInt(i32, 2, 0);
    LLVMValueRef cond = LLVMBuildICmp(b, LLVMIntSLT, n, two, "cond");
    LLVMBuildCondBr(b, cond, base, recurse);

    // 4. base: return n
    LLVMPositionBuilderAtEnd(b, base);
    LLVMBuildRet(b, n);

    // 5. recurse: return fib(n-1) + fib(n-2)
    LLVMPositionBuilderAtEnd(b, recurse);
    LLVMValueRef one = LLVMConstInt(i32, 1, 0);
    LLVMValueRef n1 = LLVMBuildSub(b, n, one, "n1");
    LLVMValueRef n2 = LLVMBuildSub(b, n, two, "n2");
    LLVMValueRef args1[] = { n1 };
    LLVMValueRef args2[] = { n2 };
    LLVMValueRef f1 = LLVMBuildCall2(b, fn_type, fib, args1, 1, "f1");
    LLVMValueRef f2 = LLVMBuildCall2(b, fn_type, fib, args2, 1, "f2");
    LLVMValueRef sum = LLVMBuildAdd(b, f1, f2, "sum");
    LLVMBuildRet(b, sum);

    // 6. 验证 IR 合法性(类型检查、SSA 检查)
    LLVMVerifyModule(mod, LLVMAbortProcessAction, NULL);
    LLVMDumpModule(mod);  // 打印生成的完整 IR

    // 7. JIT 编译并执行
    LLVMInitializeNativeTarget();
    LLVMInitializeNativeAsmPrinter();
    LLVMLinkInMCJIT();
    LLVMExecutionEngineRef engine;
    LLVMCreateExecutionEngineForModule(&engine, mod, NULL);

    unsigned long long arg = 20;
    LLVMGenericValueRef garg = LLVMCreateGenericValueOfInt(i32, arg, 0);
    LLVMGenericValueRef result = LLVMRunFunction(engine, fib, 1, &garg);
    printf("fib(%llu) = %llu\n", arg,
           LLVMGenericValueToInt(result, 0));
    // 输出: fib(20) = 6765

    LLVMDisposeExecutionEngine(engine);
    return 0;
}

这个程序展示了 LLVM 作为「编译器即库」的本质:IR 不是文本格式,而是一棵可编程操作的类型化对象树。JIT 引擎把内存中的 IR 即时编译为本机机器码并返回可调用函数指针——这就是 Julia 的即时编译、PostgreSQL 的表达式 JIT、以及许多数据库查询引擎的运行时编译所走的同一条路。

六、IR 的类型系统要点

七、常见误区

八、总结

本文从三个层面吃透了 LLVM IR:手写 max 函数的完整 .ll 文件展示了 IR 的文本形态与 phi 语义;opt 逐 pass 实验让 mem2reg、instcombine、licm 的变换效果一目了然;C API 的 JIT 程序则证明了 IR 是可编程构造的对象而非静态文本。LLVM 的成功不是因为它发明了新的编译技术,而是它用一套精心设计的 IR 把编译器前端、优化器、后端解耦成了可自由组合的乐高积木——你写的每一门新语言,都可以直接继承三十年的优化积累。

参考文献

  1. C. Lattner, V. Adve. LLVM: A Compilation Framework for Lifelong Program Analysis & Transformation. CGO, 2004.
  2. LLVM 官方文档. LLVM Language Reference Manual. llvm.org/docs/LangRef.html.
  3. D. Novillo. Memory SSA: A Unified Approach for Sparsely Representing Memory Operations. GCC Developers Summit, 2007.
  4. LLVM Kaleidoscope 教程. My First Language Frontend with LLVM.
← 返回文章列表