Rust 编译器与运行时揭秘

第16章 LLVM 代码生成:从 MIR 到机器码

作者 杨艺韬 · 8,559 字

第16章 LLVM 代码生成:从 MIR 到机器码

“当代码到达 LLVM 时,Rust 的安全保证已经完成了它的使命——剩下的只是把正确的代码变快。”

本章要点

  • Rust 的代码生成采用双层架构rustc_codegen_ssa(后端无关抽象层)+ rustc_codegen_llvm(LLVM 具体实现)
  • MIR → LLVM IR 翻译过程中,Rust 的所有权、借用、lifetime 信息全部丢弃
  • LLVM IR 中的类型极其简单:i32i64ptrfloat 等——Rust 的复杂类型被降级为原始内存布局
  • 泛型在代码生成阶段完成单态化,每个具体类型实例生成独立的机器码
  • LLVM 优化流水线包含多个阶段:PreLink → LTO → PostLink,不同优化级别差异巨大
  • 链接是代码生成的最后一环:将目标文件、元数据、分配器 shim 合并为最终二进制
  • Cranelift 作为替代后端,以编译速度换取运行时性能,适合开发阶段
  • 调试信息(DWARF/CodeView)在整个代码生成过程中被精心维护

16.1 代码生成的全局架构

Rust 编译器的代码生成并非一步完成,而是一条精心设计的流水线。从上一章我们了解到,MIR 是 Rust 编译器中最后一种”Rust 味”的中间表示。从 MIR 开始,编译器进入了一个截然不同的世界——后端代码生成的世界。

16.1.1 双层架构的设计哲学

Rust 编译器的代码生成架构可以用”抽象 + 实现”的经典模式来概括。这个架构分布在两个关键 crate 中:

rustc_codegen_ssa——后端无关的抽象层。这个 crate 定义了一套完整的 trait 体系,规定了任何代码生成后端必须实现的接口。它包含 MIR 到后端 IR 的翻译逻辑、单态化收集的结果处理、以及链接协调等功能。名字中的 “ssa” 表示 Static Single Assignment,因为大多数现代编译器后端都基于 SSA 形式。

rustc_codegen_llvm——LLVM 后端的具体实现。这个 crate 实现了 rustc_codegen_ssa 定义的所有 trait,将抽象操作映射到具体的 LLVM C API 调用。它是 Rust 编译器的默认后端,也是目前最成熟、优化能力最强的后端。

graph TB
    subgraph "rustc_codegen_ssa(后端无关层)"
        T1["CodegenBackend trait"]
        T2["BuilderMethods trait"]
        T3["TypeCodegenMethods trait"]
        T4["DebugInfoCodegenMethods trait"]
        T5["MIR → 后端 IR 翻译逻辑"]
        T6["链接协调逻辑"]
    end

    subgraph "rustc_codegen_llvm(LLVM 实现)"
        L1["LlvmCodegenBackend"]
        L2["Builder(LLVM IR Builder)"]
        L3["CodegenCx(LLVM 上下文)"]
        L4["debuginfo 模块"]
        L5["back::write(优化 + 输出)"]
        L6["back::lto(LTO 支持)"]
    end

    subgraph "rustc_codegen_cranelift(替代实现)"
        C1["Cranelift 后端"]
    end

    T1 --> L1
    T2 --> L2
    T3 --> L3
    T4 --> L4
    T1 --> C1

    style T1 fill:#6366f1,color:#fff,stroke:none
    style T2 fill:#6366f1,color:#fff,stroke:none
    style T3 fill:#6366f1,color:#fff,stroke:none
    style T4 fill:#6366f1,color:#fff,stroke:none
    style T5 fill:#6366f1,color:#fff,stroke:none
    style T6 fill:#6366f1,color:#fff,stroke:none
    style L1 fill:#f59e0b,color:#fff,stroke:none
    style L2 fill:#f59e0b,color:#fff,stroke:none
    style L3 fill:#f59e0b,color:#fff,stroke:none
    style L4 fill:#f59e0b,color:#fff,stroke:none
    style L5 fill:#f59e0b,color:#fff,stroke:none
    style L6 fill:#f59e0b,color:#fff,stroke:none
    style C1 fill:#10b981,color:#fff,stroke:none

这种双层设计带来的核心好处是:MIR 到后端 IR 的翻译逻辑只需要编写一次。无论使用 LLVM 还是 Cranelift,codegen_statementcodegen_rvaluecodegen_terminator 等核心翻译函数都是共享的——它们通过 trait 方法调用后端特定的 IR 构建操作。

16.1.2 trait 体系全景

rustc_codegen_ssa/src/traits/ 目录下,定义了后端必须实现的全部 trait:

Trait职责
CodegenBackend后端入口,驱动整个代码生成流程
BuilderMethodsIR 指令构建器,每个基本块一个
TypeCodegenMethodsRust 类型 → 后端类型映射
DebugInfoCodegenMethods调试信息生成
PreDefineCodegenMethods函数/全局变量的前向声明
WriteBackendMethods优化、LTO、目标文件写入

这些 trait 通过关联类型实现了类型级别的抽象。BuilderMethods 定义了 type Valuetype BasicBlocktype Function 等关联类型,对于 LLVM 后端它们对应 LLVM 的 Value*BasicBlock*Function*;对于 Cranelift 后端则对应 Cranelift 自己的 IR 类型。

16.1.3 CodegenCxFunctionCx——两层上下文

代码生成过程中有两个关键的上下文对象:

CodegenCx(在 LLVM 后端中定义于 rustc_codegen_llvm/src/context.rs)是模块级上下文。每个 codegen unit 拥有一个独立的 CodegenCx,其中包含:

  • 一个 LLVM Context(llcx)和一个 LLVM Module(llmod
  • TyCtxt 引用(访问类型信息和查询系统)
  • 已声明的函数和全局变量的缓存
  • 调试信息上下文

源码中的定义非常清晰地展示了这种结构:

// rustc_codegen_llvm/src/context.rs
pub(crate) struct FullCx<'ll, 'tcx> {
    pub tcx: TyCtxt<'tcx>,
    pub scx: SimpleCx<'ll>,
    pub use_dll_storage_attrs: bool,
    pub tls_model: llvm::ThreadLocalMode,
    pub codegen_unit: &'tcx CodegenUnit<'tcx>,
    // ... 更多字段
}

FunctionCx(定义于 rustc_codegen_ssa/src/mir/mod.rs)是函数级上下文。每当翻译一个 MIR 函数时,就会创建一个 FunctionCx

// rustc_codegen_ssa/src/mir/mod.rs
pub struct FunctionCx<'a, 'tcx, Bx: BuilderMethods<'a, 'tcx>> {
    instance: Instance<'tcx>,          // 当前翻译的泛型实例
    mir: &'tcx mir::Body<'tcx>,        // MIR 函数体
    llfn: Bx::Function,               // 后端函数对象
    fn_abi: &'tcx FnAbi<'tcx, Ty<'tcx>>, // 函数 ABI 信息
    cx: &'a Bx::CodegenCx,            // 模块级上下文的引用
    cached_llbbs: IndexVec<mir::BasicBlock, CachedLlbb<Bx::BasicBlock>>,
    locals: locals::Locals<'tcx, Bx::Value>,  // 局部变量的存储位置
    // ...
}

FunctionCx 维护了 MIR 基本块到后端基本块的映射(cached_llbbs),以及每个局部变量的存储位置(locals)。局部变量可能存储在栈上的 alloca(Place),也可能被提升为 SSA 寄存器(Operand)——这是一个重要的优化决策。

16.2 Codegen Unit:并行编译的基本单位

编译器不会把整个 crate 放进一个 LLVM Module。它将所有 mono item(单态化后的函数、静态变量等)划分为多个 Codegen Unit(CGU),每个 CGU 独立编译为一个目标文件。CGU 的划分服务于两个目标:并行编译(不同 CGU 在不同线程上同时编译)和增量编译(未变化的 CGU 可复用上次结果)。默认 debug 构建使用 256 个 CGU,release 构建使用 16 个,可通过 -C codegen-units=N 控制。

16.2.1 CGU 的编译流程

每个 CGU 的编译流程如下(compile_codegen_unit 函数):

graph LR
    A["创建 LLVM Module"] --> B["创建 CodegenCx"]
    B --> C["预定义所有符号"]
    C --> D["逐个翻译 mono item"]
    D --> E["完成调试信息"]
    E --> F["LLVM 优化"]
    F --> G["写入目标文件"]

    style A fill:#6366f1,color:#fff,stroke:none
    style B fill:#6366f1,color:#fff,stroke:none
    style C fill:#8b5cf6,color:#fff,stroke:none
    style D fill:#a855f7,color:#fff,stroke:none
    style E fill:#c084fc,color:#fff,stroke:none
    style F fill:#f59e0b,color:#fff,stroke:none
    style G fill:#10b981,color:#fff,stroke:none

rustc_codegen_llvm/src/base.rs 中,compile_codegen_unit 函数展示了这个完整流程:

// rustc_codegen_llvm/src/base.rs(简化)
fn module_codegen(tcx: TyCtxt<'_>, cgu_name: Symbol) -> ModuleCodegen<ModuleLlvm> {
    let cgu = tcx.codegen_unit(cgu_name);
    let llvm_module = ModuleLlvm::new(tcx, cgu_name.as_str());
    {
        let mut cx = CodegenCx::new(tcx, cgu, &llvm_module);

        // 第一遍:预定义所有函数和静态变量的符号
        let mono_items = cgu.items_in_deterministic_order(tcx);
        for &(mono_item, data) in &mono_items {
            mono_item.predefine::<Builder<'_, '_, '_>>(
                &mut cx, data.linkage, data.visibility);
        }

        // 第二遍:翻译每个 mono item 的完整定义
        for &(mono_item, _) in &mono_items {
            mono_item.define::<Builder<'_, '_, '_>>(&mut cx);
        }

        // 完成调试信息
        cx.debuginfo_finalize();
    }
    ModuleCodegen { name: cgu_name.to_string(), module_llvm: llvm_module }
}

预定义和定义分为两遍,因为函数之间可能存在相互引用。预定义阶段只声明符号(类似 C 的前向声明),定义阶段才填充函数体。

16.3 MIR → LLVM IR 翻译

翻译的核心入口是 codegen_mir 函数(rustc_codegen_ssa/src/mir/mod.rs),接收单态化后的 Instance<'tcx>,将其 MIR 翻译为后端 IR。

16.3.1 函数框架的建立

// rustc_codegen_ssa/src/mir/mod.rs(简化)
pub fn codegen_mir<'a, 'tcx, Bx: BuilderMethods<'a, 'tcx>>(
    cx: &'a Bx::CodegenCx,
    instance: Instance<'tcx>,
) {
    let llfn = cx.get_fn(instance);
    let mir = tcx.instance_mir(instance.def);
    let fn_abi = cx.fn_abi_of_instance(instance, ty::List::empty());

    // 创建调试信息上下文
    let debug_context = cx.create_function_debug_context(instance, fn_abi, llfn, &mir);

    // 创建入口基本块
    let start_llbb = Bx::append_block(cx, llfn, "start");
    let mut start_bx = Bx::build(cx, start_llbb);

    // 分析哪些局部变量必须在栈上(不能提升为 SSA 值)
    let memory_locals = analyze::non_ssa_locals(&fx, &traversal_order);

    // 为每个局部变量分配存储空间
    // ... alloca 或 operand ...
}

局部变量的存储决策是一个关键优化。non_ssa_locals 分析判断每个局部变量是否需要栈分配。如果变量类型是”立即数”类型、从未被取地址、且没有出现在 place path 中(如 tmp.field),则可以提升为 SSA 寄存器值,避免不必要的 alloca/load/store

16.3.2 语句的翻译

MIR 语句翻译在 rustc_codegen_ssa/src/mir/statement.rs 中。核心是对 statement.kind 的模式匹配:Assign 根据目标位置是 Place(栈上)还是 PendingOperand(SSA 值),分别调用 codegen_rvaluecodegen_rvalue_operandStorageLive/StorageDead 翻译为 LLVM 的 llvm.lifetime.start/llvm.lifetime.end intrinsic,告诉 LLVM 栈变量的活跃范围以复用栈空间。

16.3.3 右值(Rvalue)的翻译

右值的翻译是最复杂的部分,在 rustc_codegen_ssa/src/mir/rvalue.rs 中实现。几个典型的映射关系:

MIR RvalueLLVM IR
Use(operand)load + store(或直接传递值)
BinaryOp(Add, a, b)add i32 %a, %b
BinaryOp(Lt, a, b)icmp slt i32 %a, %b(有符号)或 icmp ult(无符号)
Ref(place)getelementptr 计算地址
Cast(IntToInt, ..)trunczextsextbitcast
Cast(Unsize, ..)构造胖指针(数据指针 + vtable/长度)
Repeat(elem, count)循环或 memset
Aggregate(...)一系列 insertvalue 或直接内存写入

比较运算的翻译(rustc_codegen_ssa/src/base.rs)精确区分了有符号和无符号:

pub(crate) fn bin_op_to_icmp_predicate(op: BinOp, signed: bool) -> IntPredicate {
    match (op, signed) {
        (BinOp::Eq, _) => IntPredicate::IntEQ,
        (BinOp::Lt, true) => IntPredicate::IntSLT,   // 有符号小于
        (BinOp::Lt, false) => IntPredicate::IntULT,  // 无符号小于
        // ...
    }
}

16.3.4 终结符(Terminator)的翻译

每个 MIR 基本块以一个终结符结尾。终结符的翻译在 rustc_codegen_ssa/src/mir/block.rs 中实现:

MIR TerminatorLLVM IR
Goto { target }br label %target
SwitchInt { discr, targets }switch i32 %discr, label %otherwise [...]
Returnret <type> %retval
Call { func, args, dest }call <type> @func(args...) + br label %dest
Drop { place, target }调用 drop_in_place + br label %target
Assert { cond, target, cleanup }br i1 %cond, label %target, label %panic_bb
Unreachableunreachable
UnwindResumeresume 或调用 personality 函数

函数调用的翻译尤其复杂。编译器需要根据 FnAbi 决定如何传递每个参数:直接传值(PassMode::Direct)、通过引用传递(PassMode::Indirect)、还是通过类型强转(PassMode::Cast)。后面的 ABI 章节会详细讨论这些。

16.4 类型映射:Rust 类型 → LLVM 类型

Rust 的类型系统远比 LLVM 的类型系统复杂。代码生成阶段的一个核心任务是将 Rust 类型”降级”为 LLVM 类型。

16.4.1 标量类型映射

基础类型的映射相对直接:

Rust 类型LLVM 类型说明
booli8(不是 i1内存中用 i8 表示,运算时可能用 i1
u8 / i8i8
u16 / i16i16
u32 / i32i32
u64 / i64i64
u128 / i128i128
usize / isizei64(64 位平台)取决于目标平台指针宽度
f32float
f64double
*const T / *mut TptrLLVM opaque pointer
&T / &mut T(瘦引用)ptr

16.4.2 复合类型:struct 的布局

结构体被翻译为 LLVM 的 struct 类型。翻译逻辑在 rustc_codegen_llvm/src/type_of.rs 中的 uncached_llvm_type 函数实现:

// 简化的 struct 翻译逻辑
fn uncached_llvm_type<'a, 'tcx>(
    cx: &CodegenCx<'a, 'tcx>,
    layout: TyAndLayout<'tcx>,
    defer: &mut Option<(&'a Type, TyAndLayout<'tcx>)>,
) -> &'a Type {
    match layout.backend_repr {
        BackendRepr::Scalar(_) => bug!("handled elsewhere"),
        BackendRepr::SimdVector { element, count } => {
            return cx.type_vector(element, count);
        }
        BackendRepr::Memory { .. } | BackendRepr::ScalarPair(..) => {}
    }
    // ...
    match layout.fields {
        FieldsShape::Primitive | FieldsShape::Union(_) => {
            // union:用字节填充到正确大小
            let fill = cx.type_padding_filler(layout.size, layout.align.abi);
            cx.type_struct(&[fill], packed)
        }
        FieldsShape::Array { count, .. } => {
            cx.type_array(layout.field(cx, 0).llvm_type(cx), count)
        }
        FieldsShape::Arbitrary { .. } => {
            // 普通 struct:按字段生成
            let (llfields, packed) = struct_llfields(cx, layout);
            cx.type_struct(&llfields, packed)
        }
    }
}

struct_llfields 函数按字段偏移顺序遍历,在字段之间插入填充字节,并在对齐要求无法满足时标记 packed。例如 struct Example { a: u8, b: u32, c: u16 } 对应 LLVM 类型 { i8, [3 x i8], i32, i16, [2 x i8] },其中 [3 x i8][2 x i8] 是编译器插入的填充字节。

16.4.3 枚举的表示

Rust 的枚举翻译是所有类型映射中最复杂的。编译器根据枚举的变体数量和有效载荷类型,选择最紧凑的表示。

无数据枚举(C-like enum):

enum Color { Red, Green, Blue }
// LLVM 类型:i8(或 i32,取决于变体数量)

带数据枚举

enum Shape {
    Circle(f64),        // 变体 0
    Rectangle(f64, f64), // 变体 1
}
// LLVM 类型大致为:{ i8, [7 x i8], double, double }
// i8 = discriminant(判别值)
// [7 x i8] = 填充
// double, double = 最大变体 Rectangle 的有效载荷

Niche 优化——Rust 编译器最著名的类型布局优化之一:

enum Option<&T> {
    None,
    Some(&T),
}
// LLVM 类型:ptr
// None 用空指针(0x0)表示,不需要额外的 discriminant 字段
// 这就是为什么 Option<&T> 和 &T 大小完全相同

16.4.4 胖指针(Fat Pointer)

Rust 中某些引用包含额外的元数据,在 LLVM 中表示为 ScalarPair

Rust 类型LLVM 表示内容
&[T]{ ptr, i64 }数据指针 + 长度
&str{ ptr, i64 }数据指针 + 字节长度
&dyn Trait{ ptr, ptr }数据指针 + vtable 指针
Box<dyn Trait>{ ptr, ptr }同上

vtable 本身是一个全局常量,布局为:

vtable: {
    ptr,   // drop_in_place 函数指针
    i64,   // size(对象大小)
    i64,   // align(对象对齐)
    ptr,   // trait 方法 1
    ptr,   // trait 方法 2
    ...    // 更多 trait 方法
}

16.4.5 BackendRepr——类型表示的分类

编译器不是对每个类型都从头推导 LLVM 表示。在布局计算阶段,每个类型都会被归类为一种 BackendRepr(后端表示):

BackendRepr含义例子
Scalar(s)单个标量值i32, f64, *const T, bool
ScalarPair(s1, s2)两个标量值&[T](ptr + len), &dyn Trait(ptr + vtable)
SimdVector { element, count }SIMD 向量__m256
Memory { sized }内存中的聚合类型struct, 大 enum

ScalarScalarPair 类型可以直接在寄存器中传递和操作,不需要通过内存。这是函数参数传递和局部变量优化的关键判断依据。

16.5 函数 ABI 与调用约定

16.5.1 单态化(Monomorphization)

Rust 的泛型在代码生成时完成单态化:每个泛型函数与每组具体类型参数的组合,都会生成独立的机器码副本。

fn add<T: std::ops::Add<Output = T>>(a: T, b: T) -> T {
    a + b
}

fn main() {
    add(1i32, 2i32);   // 生成 add::<i32>
    add(1.0f64, 2.0);  // 生成 add::<f64>
}

在 LLVM IR 中,这会生成两个完全独立的函数:add::<i32> 使用 add i32add::<f64> 使用 fadd doubleFunctionCx::monomorphize 方法通过 instantiate_mir_and_normalize_erasing_regions 在翻译过程中替换泛型参数。

16.5.2 FnAbi 与参数传递模式

FnAbi 决定了每个参数和返回值如何在机器级别传递。每个参数有一个 PassMode,定义在 rustc_target/src/callconv/mod.rs 第 39 行:

pub enum PassMode {
    Ignore,                                  // ZST 或 uninhabited
    Direct(ArgAttributes),                   // Scalar / Vector(寄存器)
    Pair(ArgAttributes, ArgAttributes),      // ScalarPair(两个寄存器)
    Cast { pad_i32: bool, cast: Box<CastTarget> },  // 整形拍平,如 struct{u8,u8,u8,u8} → i32
    Indirect { attrs, meta_attrs, on_stack },// 超出寄存器大小时传指针
}

五种模式的选择逻辑并不复杂,但有几个Rust 特有的细节值得关注:

1. Indirect 模式的两种姿态——on_stack: false 时传的是普通 pointer;on_stack: true 时对应 LLVM 的 byval attribute,callee 按栈偏移取值。byval 的字节数组会被编译器手动填充,不靠 LLVM 自己推 struct 布局(line 66 源码注释原话:“which ensures that padding is preserved and that we do not rely on LLVM’s struct layout”)——因为 LLVM 对 struct 的 padding 推断在某些目标平台不完全符合 Rust 期望的 ABI。

2. Indirect 自动具备的属性(line 406–410)——凡是通过隐式指针传递的参数,编译器给指针自动加 NoAlias + CapturesAddress + NonNull + NoUndef。这些属性把”callee 拿到的是一份栈上独占副本”这一 Rust ABI 约定显式告诉 LLVM。

3. meta_attrs 只对 unsized 参数有值——trait object、str[T] 这些胖指针作为参数时,meta_attrs 存胖指针第二字(vtable/length)的属性。Indirect唯一支持 unsized 参数的 PassMode。

4. make_direct_deprecated(line 422)——源码里一个带 #[track_caller] 标记的”遗憾”函数,专门用来处理 issue #115666:历史上某些 wasm 目标错把 Aggregate 当 Direct 传,已经泄漏到 ABI 里,没法改回来。这种带着伤疤写出来的兼容代码,比任何教科书都更直接地告诉你”ABI 一旦定好、改起来要付出什么代价”。

16.5.3 LLVM 函数属性:Rust 保证转化为优化机会

属性的加装代码在 rustc_ty_utils/src/abi.rsadjust_for_pointee 分支。常听到的简化说法是”&Treadonly&mut Tnoalias”——这个说法有重要边界条件,简化掉之后会误导优化直觉。真实源码的判定(line 423–438):

let no_alias = match kind {
    PointerKind::SharedRef  { frozen }       => frozen,              // ① 见下
    PointerKind::MutableRef { unpin }        => unpin && noalias_mut_ref,  // ②
    PointerKind::Box { unpin, global }       => unpin && global && noalias_for_box,
};
// ③ 返回值位置永远不加 NoAlias —— LLVM 语义陷阱
if no_alias && !is_return {
    attrs.set(ArgAttribute::NoAlias);
}

// ④ ReadOnly 只给 frozen 共享引用,且也不加在返回值
if matches!(kind, PointerKind::SharedRef { frozen: true }) && !is_return {
    attrs.set(ArgAttribute::ReadOnly);
    attrs.set(ArgAttribute::CapturesReadOnly);
}

四条关键边界,每一条都对应一个容易被教学材料掩盖的 Rust/LLVM 交互真相:

&T 得到 NoAlias/ReadOnly 的前提是 frozen: true——即 T 类型不含内部可变性UnsafeCell)。&Mutex<T>&Cell<T>&AtomicI32 这些共享引用传入函数时,由于 UnsafeCell 承诺”外部共享的也可能被改”,LLVM 不能认为它是 readonly,也不能认为它 no-alias。这把 Rust “&T 不可变”这句口号和实际 ABI 保证的差距精确地暴露出来:口头上 &T 不变、ABI 层面是否”真不变”还要看类型是否 frozen

&mut T 得到 NoAlias 还要再过两道关unpin: true(类型不是 !Unpin,即不包含 PhantomPinned/自引用 future)且 noalias_mut_ref feature flag 开启。这条也是历史包袱——早期版本曾因为 async fn 生成的自引用状态机开启 noalias 导致 miscompile,回退过一次(issue #63818),之后通过 !Unpin 把这类类型摘出来。

③ 返回值位置永不加 NoAlias——源码行上方的注释引用了 unsafe-code-guidelines issue #385:LLVM 对 return-position noalias 有非常反直觉的语义(基本等于”调用者保证拿回的指针没被别处记住”),实际 Rust 函数做不到这个承诺,索性一律不加。

&TReadOnlyCapturesReadOnly——后者是 LLVM 17+ 引入的精细化 Capture 模型属性,表示”就算指针被存了下来、它被当作 readonly 指针用”。这让 LLVM 可以放心对 &T 参数做 CSE 和 hoist,即使它被放进某个全局表里。

除此之外,编译器还会根据类型的 PointeeInfo 自动算 dereferenceable(N)(引用指向的内存至少 N 字节有效,N 就是 pointee 的 layout size)和 align(N)(对齐)。noundef 则来自”Rust 保证初始化过”这一语言级不变式。综合起来,一个 &i32 参数最终的 LLVM 属性签名大致是 noalias noundef readonly align(4) dereferenceable(4) nonnull——这是单一 Rust 类型符号能压缩进去的最多优化信息,也是为什么带泛型、零开销抽象的 Rust 代码最终能达到接近手写 C 的运行性能。

重要提醒:这一整套属性只在 OptLevel != No 时生效。Debug 构建下全部省略,一来减少 codegen 工作,二来避免 LLVM 错误诊断把”其实是编译器属性算错了”误报成用户代码 UB。

16.6 LLVM 优化流水线

LLVM 的优化是 Rust 程序高性能的关键。Rust 编译器通过 LLVMRustOptimize 函数调用 LLVM 的新 Pass Manager 来执行优化。

16.6.1 优化级别映射

Rust 的 -C opt-level 直接映射到 LLVM 优化级别:0 → O0、1 → O1、2 → O2(Release 默认)、3 → O3、s → Os(优化大小)、z → Oz(激进优化大小)。其中 Os 和 Oz 使用 Default 代码生成级别但额外传递 SizeDefault/SizeAggressive 标志。

16.6.2 优化的阶段划分

LLVM 优化分为多个阶段:PreLinkNoLTOPreLinkThinLTOPreLinkFatLTO(预链接)和 ThinLTOFatLTO(后链接)。分阶段是因为如果后面还要做 LTO,预链接阶段的某些优化(如函数内联)应适度克制,把跨模块优化机会留给 LTO。

16.6.3 LLVM 关键优化 Pass

LLVM 的优化 pipeline 包含数十个 pass,以下是对 Rust 代码最重要的几个:

mem2reg——将 alloca 栈变量提升为 SSA 寄存器,消除不必要的 load/store。这是最基础的优化,例如 alloca i32; store 42; load 被简化为直接使用常量 42

SROA(Scalar Replacement of Aggregates)——将 struct 拆分为独立标量字段,使每个字段可以独立优化。例如 alloca { double, double } 被消除,字段直接作为 SSA 值使用。

GVN(Global Value Numbering)——消除冗余计算,两个计算相同值的表达式只保留一个。

函数内联——对 Rust 尤为重要。迭代器链、泛型抽象、闭包都依赖内联消除抽象开销。例如 vec.iter().map(|x| x*2).filter(|x| *x > 10).sum() 在内联后融合为一个高效循环。

自动向量化——将标量循环转换为 SIMD 指令,在 -C opt-level=2 及以上启用。

循环优化——包括展开(Unrolling)、不变量外提(LICM)、强度削减等。在 -Os/-Oz 下循环展开被禁用以控制代码大小。

16.6.4 Sanitizer 集成

Rust 支持多种 LLVM sanitizer(Address、Memory、Thread、CFI 等)。Sanitizer instrumentation 仅在 Pre-Link 阶段插入,LTO 阶段不再重复。编译器通过 SanitizerOptions 结构将配置传递给 LLVMRustOptimize

16.6.5 Debug 构建 vs Release 构建

graph LR
    subgraph "Debug 构建(opt-level=0)"
        D1["MIR"] --> D2["LLVM IR<br/>无优化"]
        D2 --> D3["机器码<br/>每行对应源码"]
    end
    subgraph "Release 构建(opt-level=2)"
        R1["MIR"] --> R2["LLVM IR"]
        R2 --> R3["mem2reg + SROA"]
        R3 --> R4["内联 + GVN"]
        R4 --> R5["循环优化 + 向量化"]
        R5 --> R6["机器码<br/>高度优化"]
    end

    style D3 fill:#f59e0b,color:#fff,stroke:none
    style R6 fill:#10b981,color:#fff,stroke:none
维度Debug(O0)Release(O2)O3OsOz
函数内联几乎不内联大量内联更激进内联适度内联最少内联
循环展开更激进禁用禁用
自动向量化禁用
mem2reg/SROA
编译速度最快慢 3-5x慢 5-10x慢 3-5x慢 3-5x
运行速度慢 10-100x最优略优于 O2略慢于 O2更慢
二进制大小最大中等最大较小最小

16.7 LTO:跨 crate 边界的全局优化

16.7.1 为什么需要 LTO

默认情况下,每个 CGU 独立编译为 LLVM Module,独立执行优化。这意味着 LLVM 无法跨 CGU 或跨 crate 进行内联和其他优化。对于性能敏感的程序,这可能导致显著的性能损失。

Link-Time Optimization(LTO)打破了这个边界:在链接阶段,将多个 LLVM Module 合并,然后在合并后的大 Module 上运行全局优化。

16.7.2 三种 LTO 模式

# Cargo.toml
[profile.release]
lto = false     # 默认:crate 内 ThinLocal LTO
lto = "thin"    # Thin LTO:跨 crate,保持并行性
lto = true      # Fat LTO:完全合并所有模块

ThinLocal(默认)只在同一 crate 的不同 CGU 之间做 LTO。Thin LTO 跨 crate 进行分析和有选择的导入,但不完全合并模块。Fat LTO 将所有模块合并为一个巨大的 LLVM Module 再优化,提供最大优化机会但编译最慢。实现在 rustc_codegen_llvm/src/back/lto.rs——从每个上游 rlib 归档中提取 bitcode,合并后统一优化。

16.7.3 LTO 与符号可见性

LTO 能够进行死代码消除,但需要知道哪些符号是”外部可见”的。编译器维护 symbols_below_threshold 列表,包含所有导出符号、rust_eh_personality 以及 profiling 相关的弱符号(如 __llvm_profile_raw_version),确保 LTO 不会错误地消除它们。

16.8 从 LLVM IR 到机器码:目标文件生成

16.8.1 TargetMachine 的创建

LLVM 需要 TargetMachine 对象描述目标平台。target_machine_factory 函数(rustc_codegen_llvm/src/back/write.rs)收集所有平台参数并创建它。关键参数包括:目标三元组(如 x86_64-unknown-linux-gnu)、CPU 型号(如 genericapple-m1)、CPU 特性(如 +sse4.2,+avx2)、重定位模型(Static、PIC、PIE)、代码模型(Small、Medium、Large)等。

16.8.2 目标文件的写入

优化完成后,codegen 函数通过 write_output_file 将 LLVM Module 写入目标文件。它创建一个 PassManager,添加分析 pass 和库信息,然后调用 LLVMRustWriteOutputFile 生成输出。输出格式有两种:ObjectFile.o 目标文件)和 AssemblyFile.s 汇编文件,通过 --emit=asm 触发)。

16.9 链接:从目标文件到最终二进制

16.9.1 链接的总体流程

代码生成的最后一步是链接。link_binary 函数(rustc_codegen_ssa/src/back/link.rs)协调整个链接过程:

graph TB
    subgraph "编译产出"
        OBJ1["CGU1.o"]
        OBJ2["CGU2.o"]
        OBJ3["CGU3.o"]
        META["metadata.o"]
        ALLOC["allocator_shim.o"]
    end

    subgraph "外部依赖"
        RLIB["上游 crate(.rlib)"]
        NATIVE["系统库(-lm, -lpthread)"]
        CRT["CRT 启动文件"]
    end

    subgraph "链接器"
        LINKER["ld / lld / link.exe"]
    end

    subgraph "产出"
        BIN["最终二进制"]
    end

    OBJ1 --> LINKER
    OBJ2 --> LINKER
    OBJ3 --> LINKER
    META --> LINKER
    ALLOC --> LINKER
    RLIB --> LINKER
    NATIVE --> LINKER
    CRT --> LINKER
    LINKER --> BIN

    style LINKER fill:#f59e0b,color:#fff,stroke:none
    style BIN fill:#10b981,color:#fff,stroke:none

16.9.2 链接器与 rlib

Rust 支持多种链接器:cc(gcc/clang,默认)、lld(LLVM 链接器,速度快)、link.exe(Windows MSVC)、rust-lld(Rust 自带的 lld)。

Rust 的 rlib 格式是一个 ar 归档,包含:元数据文件(lib.rmeta,供下游 crate 编译时使用)、目标文件(*.o,编译后的机器码)、以及可选的 LLVM bitcode(供 LTO 使用)。链接时,编译器从 rlib 中提取目标文件传递给链接器。

16.9.3 分配器 shim

Rust 程序需要一个全局分配器。编译器生成特殊的 allocator_module,将 __rust_alloc__rust_dealloc 等方法桥接到实际的分配器实现(默认是系统分配器)。

16.10 调试信息生成

16.10.1 DWARF 与 CodeView

Rust 支持两种调试信息格式:DWARF(Linux、macOS 等 Unix 平台)和 CodeView/PDB(Windows MSVC)。调试信息由 rustc_codegen_llvm/src/debuginfo/ 模块负责,核心上下文 CodegenUnitDebugContext 包含 LLVM DIBuilder、文件缓存、类型缓存和命名空间映射。

16.10.2 DWARF 版本与平台适配

编译器根据目标平台选择 DWARF 版本(macOS 和 Android 需要较旧版本)。对于 PDB 平台则设置 CodeView 标志。在 LTO 合并多个 CGU 时,DWARF 版本取各模块的最大值。

16.10.3 源码位置与类型调试信息

每条 MIR 语句都携带 SourceInfo,在翻译时通过 set_debug_loc 映射到 LLVM 的 debug location,使调试器可以在源码行级别设置断点。编译器为每个 Rust 类型生成对应的 DWARF/CodeView 类型描述,type_map 缓存确保每个类型只生成一次。对于递归类型(如链表节点),使用前向声明打破循环依赖。

调试信息的详细程度由 -C debuginfo= 控制:0 不生成、1 只生成行号、2 完整调试信息。

16.11 代码大小优化

16.11.1 -Os-Oz

当使用 -C opt-level=s-C opt-level=z 时,LLVM 会切换到代码大小优化模式。关键变化:

// 在 Os/Oz 模式下禁用增加代码大小的优化
let unroll_loops = opt_level != config::OptLevel::Size
    && opt_level != config::OptLevel::SizeMin;
// vectorize_loop 和 vectorize_slp 在 Oz 下也被禁用

16.11.2 #[inline] 属性的影响

属性行为对代码大小的影响
无属性LLVM 自行决定是否内联中性
#[inline]提示 LLVM 内联,且允许跨 CGU 内联可能增大
#[inline(always)]强制内联通常增大
#[inline(never)]禁止内联通常减小
#[cold]标记为冷路径,降低内联优先级通常减小

#[inline] 的一个重要副作用是:它使函数的 MIR 被序列化到 rlib 中,允许下游 crate 在自己的编译过程中内联该函数。没有 #[inline] 的函数只能在同一 crate 内被内联。

16.12 Cranelift 替代后端

16.12.1 为什么需要替代后端

LLVM 是一个强大的优化编译器,但它有一个显著的缺点:编译速度慢。对于大型项目,即使是 debug 构建(-C opt-level=0),LLVM 也要花费大量时间将 LLVM IR 转换为机器码。

Cranelift 是由 Bytecode Alliance 开发的一个代码生成后端,目标是在保持合理运行时性能的前提下,大幅提升编译速度。它通过 rustc_codegen_cranelift 集成到 Rust 编译器中。

16.12.2 LLVM vs Cranelift 对比

Cranelift 后端同样实现了 rustc_codegen_ssa 定义的 trait 体系,但底层使用 Cranelift 的 IR。其源码位于 compiler/rustc_codegen_cranelift/src/,包含 base.rs(核心翻译)、abi/(ABI处理)、debuginfo/(调试信息)等模块。

维度LLVMCranelift
编译速度慢(debug 构建也慢)快 2-5 倍
运行时性能最优比 LLVM 慢 10-30%
优化级别O0 到 O3,完整优化有限优化
LTO 支持完整支持不支持
SIMD 支持完整有限
平台支持几乎所有平台x86_64、aarch64
成熟度生产级实验性
使用场景release 构建、生产环境开发阶段的快速迭代

16.12.3 使用 Cranelift

rustup component add rustc-codegen-cranelift-preview
RUSTFLAGS="-Z codegen-backend=cranelift" cargo build

推荐在 debug 构建时使用 Cranelift 加速编译,release 构建时切回 LLVM 获取最佳性能。

16.13 实战:Rust 代码 → LLVM IR → 汇编

16.13.1 查看 LLVM IR

# 查看未优化的 LLVM IR
cargo rustc -- --emit=llvm-ir
# 查看优化后的 LLVM IR
cargo rustc --release -- --emit=llvm-ir
# 生成的文件在 target/{debug,release}/deps/*.ll

16.13.2 示例一:简单函数

pub fn add(a: i32, b: i32) -> i32 {
    a + b
}

LLVM IR(未优化):

define i32 @_ZN7example3add17h1234567890abcdefE(i32 %a, i32 %b) {
start:
  %a.dbg.spill = alloca [4 x i8], align 4
  %b.dbg.spill = alloca [4 x i8], align 4
  store i32 %a, ptr %a.dbg.spill, align 4
  store i32 %b, ptr %b.dbg.spill, align 4
  %result = add i32 %a, %b
  ret i32 %result
}

注意未优化的 IR 中有 allocastore——这是为了调试器能够查看参数值。

LLVM IR(优化后):

define i32 @_ZN7example3add17h1234567890abcdefE(i32 %a, i32 %b) {
  %result = add i32 %a, %b
  ret i32 %result
}

优化后,allocastoremem2reg 消除。

x86-64 汇编:

example::add:
    lea eax, [rdi + rsi]
    ret

16.13.3 示例二:结构体与引用属性

pub struct Point { x: f64, y: f64 }
pub fn distance(p: &Point) -> f64 {
    (p.x * p.x + p.y * p.y).sqrt()
}

优化后的 LLVM IR 中,p 的参数签名为 ptr noalias readonly align 8 dereferenceable(16)Point 变成 { double, double },字段通过 getelementptr 访问。对应的 x86-64 汇编只有 6 条指令:两个 movsd 加载、两个 mulsd 平方、一个 addsd 求和、一个 sqrtsd 开方。

16.13.4 示例三:迭代器链的零成本抽象

pub fn sum_even_doubled(v: &[i32]) -> i32 {
    v.iter()
        .filter(|&&x| x % 2 == 0)
        .map(|&x| x * 2)
        .sum()
}

-O2 下,LLVM 会将整个迭代器链内联并融合为一个简单的循环。最终的汇编大致等价于:

pub fn sum_even_doubled_manual(v: &[i32]) -> i32 {
    let mut sum = 0;
    for &x in v {
        if x % 2 == 0 {
            sum += x * 2;
        }
    }
    sum
}

这就是 Rust “零成本抽象”的核心——高级迭代器 API 和手写循环生成完全相同的机器码。这一切归功于 LLVM 的内联和优化能力。

16.14 代码生成的完整流水线

graph TB
    MIR["MIR(已通过借用检查)"] --> MONO["单态化收集"]
    MONO --> PART["CGU 划分"]
    PART --> PAR["并行翻译 + LLVM 优化"]
    PAR --> LTO_CHECK{"LTO?"}
    LTO_CHECK -->|否| LINK["链接"]
    LTO_CHECK -->|是| LTO_MERGE["LTO 合并优化"]
    LTO_MERGE --> LINK
    LINK --> BINARY["最终二进制"]

    style MIR fill:#6366f1,color:#fff,stroke:none
    style MONO fill:#8b5cf6,color:#fff,stroke:none
    style PAR fill:#f59e0b,color:#fff,stroke:none
    style LTO_MERGE fill:#ef4444,color:#fff,stroke:none
    style BINARY fill:#10b981,color:#fff,stroke:none

每一步都经过精心设计:单态化收集确保只生成实际使用的代码;CGU 划分平衡并行度和优化粒度;双遍翻译解决相互引用;SSA 提升减少不必要的内存操作;分阶段优化让 PreLink 和 LTO 各司其职;调试信息全程维护。

16.14.1 实测:rustc 代码生成基础设施 ~78,800 行——三后端 + SSA 共享层

把本章贯穿讨论的代码生成相关 crate 实测——

crate角色
compiler/rustc_codegen_llvm29468LLVM 后端实现(§16.1.1 双层架构的 LLVM 端)
compiler/rustc_codegen_ssa27069共享 SSA 层——MIR→IR 翻译框架 + linker + 跨后端 trait(§16.1.2 trait 体系全景)
compiler/rustc_codegen_cranelift22221§16.12 提到的 Cranelift 替代后端(debug 构建快速编译用)
本章主题合计~78,758

rustc_codegen_llvm 内部最大文件——

文件角色
intrinsic.rs3312处理 LLVM intrinsic(size_of / unreachable / SIMD ops 等数百个)
llvm/ffi.rs2587LLVM C API 的 Rust FFI 绑定
builder.rs1982实现 BuilderMethods trait(§16.3 一切翻译的最终落脚点)
debuginfo/metadata.rs1819§16.10 调试信息生成的 DWARF metadata
asm.rs1468inline asm 处理
back/write.rs1394写出 .o 目标文件
va_arg.rs1195C variadic args 跨平台处理

rustc_codegen_ssa 内部最大文件——

文件角色
back/link.rs3624§16.9 链接主流程——所有 platform 的链接器调用编排
back/write.rs2329输出写出(区别于 llvm 的 back/write.rs)
mir/block.rs2165§16.3.4 终结符翻译 + 基本块编排
back/linker.rs2169链接器抽象(gnu / lld / msvc / wasm-ld 等)
errors.rs1290错误诊断
mir/operand.rs1158§16.3.3 右值 / OperandRef
base.rs1143顶层入口

两条值得记住的物理事实——

  1. intrinsic.rs 3312 行 = rustc_codegen_llvm 11%——单一文件处理 LLVM intrinsic 翻译——是 §6.10.1 实测的 InstanceKind::Intrinsic 变体在 codegen 阶段的对应——印证 “Rust 标准库里几行 core::intrinsics::xxx 调用、背后是 3000+ 行 codegen 翻译表
  2. 链接器编排 back/link.rs 3624 + back/linker.rs 2169 = 5793 行——是 ssa 共享层最大的部分(21%)——链接的复杂度比代码生成本身还重——印证 §16.9 标题”链接是最不被重视的环节”——这 5793 行处理 platform-specific 的 linker flag、rlib 解包、deny-list 符号过滤、native lib 链接顺序等——Rust 跨平台编译承诺背后的真实工程量

串联本书 rustc 章节实测——ch04 借用 34476 + ch05 内存布局 8024 + ch06 单态化 6675 + ch09 协程 2011 + ch10 Pin/Waker/Future 4260 + ch11 闭包 6964 + ch17 增量编译 8827 + 本节 codegen 78758 = ~150,000 行——是 rustc 实现 “Rust 类型系统 + 编译性能 + 多后端代码生成” 的核心工程量;codegen 一项就占 52%——印证它是 rustc 后半段(从 MIR 到 .o)的工程主体。

16.15 小结

本章我们深入剖析了 Rust 编译器代码生成阶段的全貌。从架构设计到实现细节,我们看到了:

双层架构使核心翻译逻辑与后端实现解耦;Rust 的安全属性(noaliasnonnull)作为优化提示传递给 LLVM;从 mem2reg 到 LTO,每层优化各司其职;Cranelift 的引入、CGU 并行编译、Thin LTO 的设计,都是在编译速度和运行时性能之间寻找平衡。

物理事实:rustc 代码生成基础设施 78,758 行(rustc_codegen_llvm 29468 + rustc_codegen_ssa 27069 + rustc_codegen_cranelift 22221);intrinsic.rs 3312 行处理 LLVM intrinsic 是 ch06 §6.10.1 InstanceKind::Intrinsic 在 codegen 阶段对应;链接器编排 link.rs 3624 + linker.rs 2169 = 5793 行印证’链接是最不被重视的环节’——Rust 跨平台编译承诺背后的真实工程量;codegen 占 ch0417 总 ~150K 的 52%。