「LLVM 与 LLVM IR 后端」

系统讲解以 LLVM 为核心的后端路线:LLVM IR 的 SSA 形式与 phi 节点、IRBuilder 的增量生成、优化 Pass 管线、SelectionDAG 目标代码生成,以及语言前端 IR 到 LLVM IR 的桥接与降级,附带 Python 与 C++ 示例。

1. LLVM 生态与 IR 概览

一句话总结: LLVM 提供语言无关的中间表示与一整套优化、代码生成基础设施,让前端专注语言、后端专注机器。

LLVM 的定位是「编译器的编译器」:它把「把语言翻译成机器码」这件事里的公共部分抽出来——中间表示、优化器、指令选择、寄存器分配、汇编与链接——做成可复用组件。一个语言前端只要把源码降级成 LLVM IR,就能免费获得跨平台目标代码、通用优化管线、调试信息与链接器支持。Rust、Swift、Clang、Julia 都走这条路线。

源码 (Swift/Rust/...) 
   │  前端: 词法/语法/语义 → 语言专有 IR
   ▼
LLVM IR (SSA, 三地址码风格, 语言无关)
   │  优化器: opt / Pass Manager
   ▼
优化后 IR
   │  SelectionDAG / GlobalISel → 指令选择
   ▼
目标机器码 + 目标文件 (x86/ARM/RISC-V)
组件工具职责
IR 生成llvm-gcc 前端、Clang源码 → LLVM IR
优化opt、Pass Manager在 IR 上跑 pass 序列
汇编llcIR → 目标汇编
链接lld、系统链接器目标文件 → 可执行文件

LLVM IR 是显式的三地址码:每行指令形如 %tmp = add i32 %a, %b,寄存器(虚拟寄存器)以 % 为前缀,类型紧随其后。IR 同时保留人类可读的文本形式与紧凑的 bitcode 形式,文本形式用于调试与测试,bitcode 用于跨阶段传递。理解 IR 的静态单赋值(SSA)性质是使用 LLVM 的关键前提。

2. SSA 形式与 phi 节点

一句话总结: SSA 要求每个变量只赋值一次,合并路径时引入 phi 节点,这让数据流显式化并简化大量优化。

静态单赋值(Static Single Assignment, SSA)的核心规则:每个虚拟寄存器恰好被定义一次。这样任意使用点都能立刻回溯到唯一的定义点,数据流信息被编码进程序本身。分支汇合处(if-else 之后、循环头)同一个变量有不同的候选值,于是引入 phi(φ)节点:%x = phi i32 [ %a, %then ], [ %b, %else ],表示「来自哪条路径就取哪个值」。

; C: int f(int c) { int x; if (c) x = 1; else x = 2; return x; }
define i32 @f(i32 %c) {
entry:
  %cmp = icmp ne i32 %c, 0
  br i1 %cmp, label %then, label %else

then:
  br label %merge

else:
  br label %merge

merge:
  %x = phi i32 [ 1, %then ], [ 2, %else ]
  ret i32 %x
}
# 用 Python 对 SSA 形式做一次简单的到达定义分析:
# 在 SSA 中, 每个 use 的唯一 def 就是它的定义点, 无需数据流迭代。
def reaching_def(ir_instrs):
    defs = {}
    uses = []
    for idx, instr in enumerate(ir_instrs):
        if instr.startswith("%") and "=" in instr:
            name = instr.split(" ")[0]
            defs[name] = idx
        elif "%" in instr:
            uses.append((idx, [tok for tok in instr.split()
                                if tok.startswith("%")]))
    return defs, uses

phi 节点让控制流图(CFG)上的数据流变成程序结构的一部分:优化器看到 %x 的定义就是唯一的 phi,不再需要维护庞大的 use-def 数据库。把普通程序转成 SSA 的过程叫 SSA 构造(支配树 + 支配边界),把 SSA 转回普通形式的过程叫 phi 消除(并行 copy 传播)。LLVM 对用户透明地处理这两步,前端只需产出合规的 SSA 即可。

3. IRBuilder 生成 IR

一句话总结: IRBuilder 是增量生成 IR 的插入点 API,前端一边遍历自己的 AST 一边往 builder 当前插入点追加指令。

LLVM 的 C++ API 里,IRBuilder 维护一个「当前插入点」——某条 IR 指令之前或之后。前端遍历 AST 时每翻译一个节点就调用 builder.CreateAdd(...) 等工厂方法,指令被自动追加到当前块,并返回指向结果的 Value*。这种流式风格让前端代码读起来像在逐句生成汇编,而插入点可以随时切换(例如进入新基本块时)。

// 用 LLVM C++ API 生成 add 函数: int add(int a, int b) { return a + b; }
llvm::LLVMContext ctx;
llvm::IRBuilder<> builder(ctx);
auto *module = new llvm::Module("demo", ctx);

auto *i32 = llvm::Type::getInt32Ty(ctx);
auto *fnType = llvm::FunctionType::get(i32, {i32, i32}, false);
auto *fn = llvm::Function::Create(
    fnType, llvm::Function::ExternalLinkage, "add", module);

auto *entry = llvm::BasicBlock::Create(ctx, "entry", fn);
builder.SetInsertPoint(entry);
auto *a = fn->getArg(0);
auto *b = fn->getArg(1);
auto *sum = builder.CreateAdd(a, b, "sum");
builder.CreateRet(sum);
# 概念等价物: 一个极简的 IR 生成器, 记录指令序列
class IRBuilderMini:
    def __init__(self):
        self.instrs = []

    def add(self, a, b):
        self.instrs.append(f"%t = add i32 {a}, {b}")
        return f"%t"

    def ret(self, val):
        self.instrs.append(f"ret i32 {val}")

builder = IRBuilderMini()
sum_ = builder.add("%a", "%b")
builder.ret(sum_)
print(builder.instrs)

IRBuilder 之上的高级封装是 llvm::IRBuilder 的命名参数、块级 helper 与 CreateBinOp 等重载;更复杂的调用约定、内联钩子则需要直接操作 Function、Module 与 Attributes。前端要牢记一条纪律:不要在 IR 生成层做优化,IR 优化交给 pass 管线,前端只负责语义正确、类型显式的直译。

4. 优化 Pass 管线

一句话总结: Pass 管线是按依赖与收益排序的优化器队列,Pass Manager 负责调度、复用分析与维护不变式。

LLVM 优化器由一个个 pass 组成:常量折叠、死代码消除、内联、循环展开、向量化、全局值编号等。opt -O2 实际上展开成一个精心排序的 pass 序列。Pass Manager 的职责是记录 pass 之间依赖(某 pass 需要哪些分析结果、会 invalidate 哪些结果)、合并可复用的分析(如支配树、活跃变量),并保证每个 pass 执行前后 IR 不变式(SSA 合法、类型正确)成立。

-O2 的部分真实序列 (新 PM):
  SimplifyCFG → SROA → InstCombine → EarlyCSE
  → FunctionAttrs → Inline → ... → LoopRotate → LICM
  → LoopUnswitch → ... → GVN → SCCP → ... → DCE
# 示意: 一个迷你 pass 管线, 支持依赖声明与迭代至收敛
class PassManager:
    def __init__(self):
        self.passes = []

    def add(self, name, run, needs=()):
        self.passes.append((name, run, needs))

    def run(self, ir):
        # 实际 LLVM 会做依赖排序与分析缓存复用
        for name, run, needs in self.passes:
            print(f"running pass: {name}")
            ir = run(ir)
        return ir

def constant_fold(ir):
    # 示例: 把 add i32 1, 2 折叠为 3
    return ir.replace("%t = add i32 1, 2", "%t = 3")

pm = PassManager()
pm.add("constfold", constant_fold)
pm.add("dce", lambda ir: ir, needs=("constfold",))

现代 LLVM 的 Pass Manager 分模块、函数、循环三个层级,pass 可在任意层级注册。新 PM(-fexperimental-new-pass-manager 起)相比老 PM 的改进是可缓存分析更细粒度、pass 之间顺序更可复现。优化序列的设计本质是「先做低开销、高收益的清理,再做循环与全局优化,最后清理一次」,收敛条件由 pass 内部迭代与整体序列共同保证。

5. 目标代码生成

一句话总结: 后端把优化后的 IR 通过指令选择、寄存器分配与指令调度变成目标汇编,LLVM 默认用 SelectionDAG 与 GlobalISel 两条路线。

llc 把 LLVM IR 翻译成目标机器汇编。现代 LLVM 走两条指令选择路线:成熟的 SelectionDAG 与新一代 GlobalISel。两者都把 IR 语义转换成目标指令,区别在于 GlobalISel 用更规则的匹配与更可预测的编译时间,正在逐步成为默认。指令选择之后是寄存器分配(图着色 / 线性扫描),把无限虚拟寄存器映射到有限物理寄存器,必要时溢出(spill)到栈上。

; 输入 IR
define i32 @scale(i32 %x) {
  %m = mul i32 %x, 3
  %s = add i32 %m, 1
  ret i32 %s
}
; x86-64 输出 (节选, 优化开启)
scale:
        leal    (%rdi,%rdi,2), %eax   ; x*3 用 LEA 一次完成
        addl    $1, %eax
        ret
# 寄存器分配的活跃区间示例: 简单线性扫描
intervals = [  # (变量, 起始指令, 结束指令)
    ("%x", 0, 3), ("%m", 1, 2), ("%s", 2, 3),
]
def linear_scan(intervals, regs=2):
    active, reg_map = [], {}
    for name, start, end in sorted(intervals, key=lambda i: i[1]):
        active = [iv for iv in active if iv[2] > start]
        if len(active) >= regs:
            spill = max(active, key=lambda iv: iv[2])
            reg_map[spill[0]] = "spill"
            active.remove(spill)
        reg = regs - len(active)
        reg_map[name] = f"r{reg}"
        active.append((name, start, end))
    return reg_map

目标代码生成还必须处理目标特有的细节:ABI 参数传递规则、栈帧布局、指令调度(重排指令利用流水线)、窥孔优化(指令序列局部替换)以及内建函数(如 x86 的 pclmulqdq、rdtsc)。一旦 IR 层与目标层分离,新增一个架构主要是新增一个 TargetMachine 后端,而语言前端完全不用改动。

6. 与前端 IR 的桥接

一句话总结: 前端先产出带语言语义的高级 IR,再降级为 LLVM IR,桥接层负责消除语言专属概念与类型。

LLVM IR 是语言无关的,但语言有自己的类型与语义——Python 的动态对象、Rust 的借用检查、Swift 的存在类型都不能直接映射。因此前端通常先构造语言专有的高层 IR(HIR),再写一个降级(lowering)模块把 HIR 翻译成 LLVM IR。桥接层的核心工作有三件:类型映射、所有权与引用语义的翻译、以及特殊运行时时序的安插。

# 前端 HIR 节点 → LLVM IR 的桥接分派示意
class Lowerer:
    def lower(self, node):
        match node:
            case IntLit(v):
                return self.builder.const_i32(v)
            case VarRef(name):
                return self.builder.load(self.env[name])
            case Add(a, b):
                l = self.lower(a)
                r = self.lower(b)
                return self.builder.add(l, r)
            case Call(f, args):
                fn = self.lower(f)
                params = [self.lower(a) for a in args]
                return self.builder.call(fn, params)
语言概念降级策略
栈对象/结构体内存分配 + GEP 访问
字符串指针 + 长度结构或 i8* + strlen
异常landingpad + invoke + personality
闭包环境结构体打包 + 函数指针
GC 对象内建 GC root 接口 + 写屏障

桥接层还要负责处理不可直接翻译的结构:如 Python 的属性查找要降级成字典查表 + 方法分派,Rust 的 trait 对象要降级成 vtable 指针。这一层最容易出错的地方是生命周期:LLVM IR 没有 GC 概念,前端必须在分配与回收点显式生成对应调用。设计良好的 HIR 能让桥接层保持机械翻译(mechanical translation),把语义决策留在 HIR 阶段。

7. 调试信息与内联汇编

一句话总结: 调试信息把 IR 指令映射回源码行列与变量,内联汇编则让前端在 IR 无法表达时直插目标指令。

只有 IR 不足以支撑良好的调试体验:调试器需要知道「这条指令来自源码哪一行」「这个寄存器对应哪个变量」。LLVM 通过 debug metadata 附加到 IR 指令上,-g 选项让 llc 把这些信息编码进 DWARF。前端生成 IR 时必须同步产出 DILocation、DILocalVariable 等 metadata,否则用户只能在没有符号的汇编上调试。

; 带调试信息的 IR (示意)
define i32 @f(i32 %x) !dbg !7 {
entry:
  %y = add i32 %x, 1, !dbg !8
  ret i32 %y, !dbg !9
}

!llvm.dbg.cu = !{!0}
!0 = distinct !DICompileUnit(language: DW_LANG_C, file: !1)
!7 = distinct !DISubprogram(name: "f", file: !1, line: 1)
!8 = !DILocation(line: 3, column: 5, scope: !7)
# 生成 debug 元数据是"每指令带位置"的簿记工作
class DebugEmitter:
    def __init__(self):
        self.line_map = {}

    def record(self, ir_instr, source_line):
        self.line_map[ir_instr] = source_line

    def emit_dwarf(self, out):
        for instr, line in self.line_map.items():
            out.write(f"{instr} -> source line {line}\n")

内联汇编让前端绕过 IR 直写目标指令,常见于访问特殊寄存器、利用 CPU 原子指令或接入内建函数。LLVM 用 asm 语法把内联汇编块嵌入函数,并提供约束字符串描述操作数绑定。这是后端的逃生舱口:正常情况下前端应当避免依赖内联汇编,因为会丧失可移植性与优化机会,仅在标准库或平台接口处使用。

8. 总结

一句话总结: LLVM 后端把「从 IR 到机器码」的庞大工程收敛为可复用组件,前端只需要产出合规 SSA 并降级语言语义。

主题核心结论
生态定位语言无关 IR + 优化器 + 后端,前端专注语言
SSA 形式单次赋值 + phi 节点,数据流结构显式化
IRBuilder插入点增量生成,流式翻译 AST
Pass 管线Pass Manager 调度依赖与分析缓存,分层迭代
目标生成SelectionDAG/GlobalISel 指令选择 + 寄存器分配
前端桥接HIR 降级为 IR,映射类型、闭包、异常与 GC
调试与汇编debug metadata 映射行列,内联汇编作逃生口

选择 LLVM 后端意味着用「接入成本」交换「全平台能力」:前端要承担 HIR 设计、类型映射与调试信息的生成,但换来的是一流的优化器、成熟的指令后端与庞大的生态工具链。理解 LLVM IR 的 SSA 本质、IRBuilder 的生成节奏与 Pass Manager 的调度模型,是任何语言接入 LLVM 的第一步,也是读懂 rustc、swiftc 与 clang 后端日志的基础。

延伸阅读

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「compiler」更多文章

  1. 「错误恢复与诊断」
  2. 「运行时与内存管理」
  3. 「现代优化 Pass 管线」