夜雨聆风学习资料网

ARTICLE · 1271959

Rust 编译过程:源码如何经过 HIR、MIR、LLVM 变成机器码

Rust 编译过程:源码如何经过 HIR、MIR、LLVM 变成机器码

所属:Rust 语言深水区 / 编译器原理 / 性能工程

我们每天都在跑 cargo build --release,终端安静一会儿,target/release 里就多出一个二进制文件。中间发生了什么,平时很少需要追究。

可一旦碰上难解的借用错误、突然变大的二进制,或者一段怎么都优化不掉的代码,只知道“Rust 最后交给 LLVM”就不够用了。宏在哪一步展开?借用检查器面对的是 AST、HIR 还是 MIR?同一个泛型函数为什么会在二进制里留下多个版本?

下面用一段很小的 Rust 程序,把这条编译链路从头走一遍。

源文件
  │ lexer + parser
  ▼
AST(语法树)
  │ 宏展开、名称解析、cfg 处理
  ▼
HIR(高层中间表示)
  │ 类型推导、trait 选择、方法解析
  ▼
THIR / 类型检查结果
  │ 降低控制流、插入借用检查所需的信息
  ▼
MIR(中层中间表示)
  │ 借用检查、drop 检查、MIR 优化
  ▼
单态化后的实例
  │ 生成 LLVM 类型和 LLVM 指令
  ▼
LLVM IR
  │ 优化、目标相关指令选择、寄存器分配
  ▼
汇编 / 目标文件(.o)
  │ 链接器合并 crate、系统库和启动代码
  ▼
可执行文件或动态库

这张图为了好读,画成了一条直线。rustc 内部更接近一套由查询系统驱动的依赖网:查询结果可以缓存,输入变动后,编译器沿着依赖关系重新计算受影响的部分。后面分析编译错误、代码膨胀和 --emit 产物,都要靠这些阶段边界。

一、先区分 Cargo、rustc、LLVM 和 linker

1.1 Cargo 管构建,rustc 管编译

Cargo 读取 Cargo.toml、解析依赖、决定要构建哪些 package 和 target、设置环境变量,然后为每个 crate 组织 rustc 命令。Rust 的语法、类型和 trait 检查都在 rustc 里完成:

cargo
  ├─ 解析 workspace 和 Cargo.lock
  ├─ 编译 build script / proc-macro
  ├─ 为每个 crate 组织 rustc 命令
  └─ 让 rustc 在最终 crate 上驱动 linker

cargo rustc -- --emit=llvm-ir 的意思是让 Cargo 负责依赖和参数,额外把参数传给当前 package 的 rustc。如果想直接观察一个独立文件,也可以跳过 Cargo:

rustc --edition=2024 src/main.rs --emit=mir

1.2 LLVM 和链接器各做一段

LLVM 接手 LLVM IR 后,继续做中后端优化,再把 IR 降低成目标架构的汇编或目标文件。链接器随后合并多个 .o、静态库、动态库、启动文件和平台运行时,并解析跨目标文件的符号与重定位:

rustc 前端:Rust 源码 -> HIR -> MIR
rustc 后端:MIR 实例 -> LLVM IR -> .o
linker:      多个 .o + 系统库 -> 可执行文件

rustc --emit=llvm-ir 导出的是链接前的 crate 级产物。链接器、目标平台、链接参数、strip 和 LTO 都还可能改变最终二进制。

二、从源码开始:词法、解析和 AST

编译器先把字符流切成 token,再根据 Rust 语法构造 AST(Abstract Syntax Tree)。例如:

fn score<T: Ord + Copy>(items: &[T], threshold: T) -> usize {
    items.iter().filter(|item| **item >= threshold).count()
}

解析器此时只需要判断语法是否成立。它能认出一个函数、一个泛型参数、两个参数、一串方法调用和一个闭包,却还不知道 T 的具体类型,也没有验证 **item >= threshold 是否满足借用规则。

AST 保留着括号、语法糖和宏调用等源码结构,方便编译器报告语法错误、展开宏。这个阶段主要处理以下工作:

  • • 展开 macro_rules! 和过程宏;
  • • 处理 #[cfg(...)],删除目标平台不启用的代码;
  • • 建立模块、名称和导入的解析结果;
  • • 检查 item、表达式和模式的基本合法性。

2.1 宏展开为什么发生得早

过程宏接收 token stream,输出新的 token stream。为了让新生成的代码继续参与名称解析和类型检查,宏必须在后续阶段之前展开:

#[derive(Debug)]
struct
 User {
    id: u64,
}

derive(Debug) 会在编译期生成一段 impl Debug for User,后续阶段会把它当作普通代码继续处理。Serde derive、thiserror 和 clap derive 也走这条路,和运行时反射没有关系。

宏展开后,rustc 才能对生成的 item 做名称解析和类型检查。宏错误通常发生在这一阶段,类型错误则发生在更后面;这也是为什么编译器有时会把错误定位到宏调用点,并附带展开上下文。

三、HIR:把“适合写代码的树”变成“适合分析的树”

进入 HIR(High-level Intermediate Representation)后,Rust 的高层语义还在,许多只服务于源码书写的细节已经被清理。类型检查、trait 选择、方法解析、可见性和模式检查都围绕 HIR 查询展开。

3.1 HIR 做了哪些归一化

下面的代码包含多个语法糖:

fn first_positive(values: &[i32]) -> Option<i32> {
    for
 &value in values {
        if
 value > 0 {
            return
 Some(value);
        }
    }
    None

}

在 HIR 层,编译器已经知道:

  • • for 循环对应 IntoIterator::into_iter 和迭代器协议;
  • • &[i32] 的迭代器产生 &i32,模式 &value 会复制出 i32;
  • • return Some(value) 的返回类型必须统一为 Option<i32>;
  • • None 需要根据函数返回类型完成类型推导。

HIR 仍保留函数、模式和表达式这类高层结构,所以错误信息还能映射回源码。把控制流拆成基本块,要等到 MIR 阶段。

3.2 名称解析和类型检查的边界

名称解析回答“这个名字指向哪个定义”,类型检查回答“这个表达式的类型和 trait 约束是否成立”。例如:

fn show<T: std::fmt::Display>(value: T) {
    println!
("{value}");
}

解析阶段找到 std::fmt::Display 和 println! 对应的宏;类型检查阶段确认 T: Display,并为格式化参数选择正确的实现。方法调用如 items.iter() 还要经过候选方法搜索、自动借用、自动解引用和 trait 选择。

此时 T 可能还是泛型,编译器会把 iter 的解析信息记入类型检查结果。等代码生成前确定了具体类型,再为实际用到的类型生成实例。

3.3 为什么需要 THIR

THIR(Typed High-level IR)把类型检查结果落到具体的表达式和模式上,供模式穷尽性检查、常量求值和 MIR 构建使用。它是 rustc 内部连接类型化 HIR 与 MIR 构建器的一层,不面向用户,也不保证跨版本稳定。

顺着数据的变化看会清楚一些。HIR 保留 Rust 语义和源码关联,THIR 给高层表达式带上类型信息,MIR 再把执行路径铺成可分析的控制流图。

四、MIR:借用检查器在这里工作

MIR(Mid-level Intermediate Representation)是一张由基本块组成的控制流图。程序执行被写成 local、place、statement 和 terminator,大部分语法糖已经消失。

BasicBlock0:
    _2 = &(*_1);
    switchInt((*_2 > const 0_i32)) -> [true: BasicBlock1, false: BasicBlock2]

BasicBlock1:
    _0 = Some((*_2));
    return;

BasicBlock2:
    goto -> BasicBlock3;

上面的格式经过了简化。实际 dump 里还有更多临时变量和作用域信息,但观察重点没变:.await、match 分支、循环跳转和 return 都会落到明确的 terminator 或基本块边上。

4.1 Place 和 move 是什么

MIR 用 place 表示存储位置,例如 _1、_1.field、(*_2)[index]。这样一来,结构体字段、切片元素和借用投影都能分别追踪,不必把一个变量粗略地看成整体。

let pair = (String::from("left"), String::from("right"));
let
 left = pair.0;
println!
("{left}");
// println!("{}", pair.0); // use of moved value

pair.0 被 move 后,MIR 会更新这个字段的初始化状态。借用检查和 drop 检查便能单独处理它,pair 的其他字段依然可以使用。

4.2 借用检查器的输入

现代 Rust 借用检查器基于 MIR,结合非词法生命周期(NLL)和数据流分析,沿控制流追踪几类事实:

创建引用 -> 引用在哪些基本块仍然活跃
读/写 place -> 是否与活跃借用冲突
move place -> 后续是否仍被读取或 drop
函数返回 -> 返回的引用是否满足生命周期约束

例如:

fn push_after_read(values: &mut Vec<i32>) {
    let
 last = values.last();
    if
 last.is_some() {
        values.push(1);
    }
}

last 经过 if 条件后没有再次使用。NLL 可以在这里结束借用,后面的 push 也就成立了。早期规则按词法作用域计算,往往会把借用保守地延长到整个代码块;MIR 数据流能找到引用的最后一次使用。

4.3 async fn 如何进入 MIR

async fn 会被改写成一个实现 Future 的状态机。每个可能暂停的 .await 都对应一个状态,跨暂停点继续使用的局部变量则要保存在状态机字段里:

调用 async fn
    -> 创建匿名 Future(还没有执行函数体)
poll Future
    -> 状态 0:执行到第一个 await
    -> Pending:保存局部变量和下一次继续的位置
再次 poll
    -> 状态 1:从 await 之后继续

如果 .await 前有一个仍然活跃的借用,那个引用指向的状态也可能成为 Future 的字段。于是编译器可以在 Send 检查中发现:这个 Future 是否可能跨线程移动,内部保存的类型是否实现 Send。

因此,在某些 tokio::spawn 场景中,让 std::sync::MutexGuard 活过 .await 会触发编译错误。guard 进入了 Future 的状态,而这个 Future 又需要在线程间传递。

五、MIR 优化和 drop:安全语义怎样落到执行路径

借用检查结束后,rustc 还会整理和优化 MIR,常见动作有:

  • • 删除无用的临时变量和存储;
  • • 简化分支和常量条件;
  • • 合并基本块;
  • • 传播常量;
  • • 消除不会执行的 drop;
  • • 为后续单态化和 LLVM 代码生成准备更简单的控制流。

5.1 drop 要覆盖每一条退出路径

fn consume(flag: bool) {
    let
 first = String::from("a");
    let
 second = String::from("b");
    if
 flag {
        drop
(first);
    }
    println!
("{second}");
}

如果 first 已经被显式移动,编译器不能再 drop 一次;second 则要在所有返回路径上被清理。构造过程发生 panic 时,已经初始化的字段也不能漏掉。MIR 用 drop terminator 和 drop flags 把这些路径明确记录下来。

这和 101 篇讨论的析构顺序直接相关。Rust 会先在 MIR 中分析 move、drop 和借用,再把已经确定的执行语义交给后端。LLVM 无须推测 Rust 的析构规则。

5.2 unsafe 只接管一部分证明责任

unsafe 允许程序员使用裸指针、调用 unsafe 函数或实现 unsafe trait,类型检查、MIR 构建、基本借用规则和后端代码生成照常进行。风险来自没有满足 unsafe API 的前置条件,例如解引用悬空指针,或者给 from_raw_parts 传入错误的地址和长度。

safe Rust:编译器证明更多不变量
unsafe Rust:程序员接管部分证明责任
LLVM:      仍假设已经满足 Rust/LLVM 的未定义行为约束

一旦触发未定义行为,LLVM 会基于“这种情况不会发生”的前提重排、删除或合并指令。写下 unsafe,意味着有一部分安全条件要由程序员证明,和代码离机器码有多近无关。

六、泛型、trait 和单态化:MIR 如何变成具体函数

考虑泛型函数:

#[inline]
fn
 twice<T>(value: T) -> (T, T)
where

    T: Copy,
{
    (value, value)
}

fn
 demo() {
    let
 _ = twice(7_u32);
    let
 _ = twice(true);
}

类型检查阶段只验证 T: Copy 足以构造返回值。到了代码生成前,编译器发现程序实际需要 twice::<u32> 和 twice::<bool>,于是执行单态化(monomorphization):

twice<T>(泛型 MIR)
   ├─ twice::<u32>(具体 MIR) -> LLVM 函数
   └─ twice::<bool>(具体 MIR) -> LLVM 函数

具体类型一旦确定,LLVM 就有机会内联、消除分支并做针对性优化。相应的成本是多生成几份代码;泛型嵌套很深、调用类型很多时,二进制体积会跟着增长。

dyn Trait 走的是动态分发。它通常用胖指针保存数据指针和 vtable 指针,调用时经过 vtable 间接跳转。泛型单态化采用静态分发。工程里选哪一种,要一起考虑性能、二进制体积和 API 的开放性,静态分发没有无条件胜出的结论。

6.1 #[inline] 只是提示

#[inline] 给编译器一个跨 crate 内联提示。LLVM 还会结合函数大小、调用频率和优化级别作决定。即便写了 #[inline(always)],也不能把它当成绝对保证;加得太多还会增加代码段体积和编译时间。

分析二进制时,可以先确认生成了哪些单态化实例,再考虑 #[inline]、trait object、泛型边界或 LTO。看到函数调用就补 #[inline(always)],很可能只会让链接阶段处理更多代码。

七、从 MIR 到 LLVM IR

Rust 后端会把具体化的 MIR 翻译成 LLVM IR。LLVM IR 是一种强类型、SSA(Static Single Assignment)风格的中间表示,保留控制流、调用、内存访问和属性,但还没有绑定到 x86、AArch64 或 Wasm 的具体寄存器。

一个简单加法在 LLVM IR 中可能接近这样:

define i32 @add(i32 %a, i32 %b) {
start:

  %sum
 = add i32 %a, %b
  ret
 i32 %sum
}

实际运行 rustc --emit=llvm-ir,输出里还会有 mangled symbol、生命周期 intrinsic、函数属性和目标相关的数据布局。LLVM IR 不是稳定 API,不同 Rust/LLVM 版本都可能改变命名、属性和优化后的结构。

7.1 rustc 还要告诉 LLVM 什么

一条加法指令远远不够,rustc 还要向后端传递这些信息:

  • • Rust 类型布局和对齐;
  • • noalias、只读参数等别名信息;
  • • panic 策略和 landing pad;
  • • 整数溢出检查或 wrapping 语义;
  • • Drop、析构和异常边界;
  • • 调试信息、源码位置和内联栈。

例如 debug 构建中,整数 a + b 默认可能包含溢出检查;release 构建会根据 profile 和具体语义选择更少的检查。wrapping_add 则明确要求模 (2^N) 运算,后端可以更直接地使用机器指令。

7.2 LLVM 不负责借用检查

借用检查到 MIR 阶段已经完成。LLVM 不理解 Rust 的“两个可变引用不能同时存在”,它接收 rustc 给出的别名信息和未定义行为假设,再进行通用优化:

Rust 语义优化:借用、move、drop、match、async 状态机
LLVM 通用优化:常量传播、死代码删除、内联、循环优化、向量化
目标后端优化:指令选择、寄存器分配、调度、重定位

调高 LLVM 优化级别解决不了借用错误。Rust 编译通过,也只能说明编译器接受了这段代码,unsafe 代码的前置条件仍要人工核对。

八、LLVM IR 如何变成汇编和机器码

LLVM 后端根据 --target 指定的架构、特性和 ABI 选择指令。同一个 add,x86-64 可能生成 addl,AArch64 可能生成 add w0, w0, w1。源码和 MIR 没有变化,换一个 target triple,机器码就可能完全不同。

rustc --edition=2024 --emit=asm,llvm-ir,obj -C opt-level=3 src/main.rs -o target/pipeline

常用的 --emit 产物有这些:

  • • mir:MIR dump,用来看控制流、借用相关临时量和 drop;
  • • llvm-ir:.ll 文件,能看到 LLVM 层的函数、属性和优化结果;
  • • asm:.s 文件,用来检查目标架构汇编、调用约定和寄存器;
  • • obj:.o 文件,可交给 objdump、nm 和 linker 分析;
  • • metadata:.rmeta 文件,供下游 crate 查询类型和 trait 信息;
  • • dep-info:.d 文件,用于增量构建和依赖追踪。

优化级别会明显改变输出。调试阶段可以用 -C opt-level=0 -C debuginfo=2 保留更多结构,比较性能时再切到 release profile。汇编里找不到某个高层变量,通常是因为它已经被寄存器替代、常量折叠,或者整段计算都被消除了。

九、链接:从 crate 产物到完整程序

Rust crate 生成的目标文件只是最终程序的一部分。链接阶段还要处理:

  • • 依赖 crate 的 .rlib 或 .rmeta;
  • • std、core、alloc 和目标平台运行时;
  • • C ABI 系统库,如 libc、pthread、dl;
  • • 启动代码、TLS、异常展开和链接脚本;
  • • 符号解析、重定位、section 合并和 dead stripping。

生成最终文件后,可以接着查文件格式、符号和反汇编:

file target/debug/compiler-pipeline-demo
nm -C target/debug/compiler-pipeline-demo | rg 'score|twice|main'
objdump -d target/debug/compiler-pipeline-demo | less

在 macOS 上,常用工具是 otool -L、nm 和 otool -tvV;Windows 可以使用 dumpbin 或 LLVM 的 llvm-objdump。工具名字不同,查的仍是几件具体的事:文件采用什么目标格式、依赖哪些动态库、保留了哪些符号、函数最后生成了哪些指令。

9.1 符号名为什么那么长

泛型、trait 实例和模块路径会编码进 symbol name(name mangling),以避免不同函数冲突。nm -C 或 rustfilt 可以把它们还原成可读形式:

_ZN...twice$LT$u32$GT$...  -> compiler_pipeline_demo::twice::<u32>

mangled name 属于实现细节。Rust 版本、crate hash 或编译选项一变,名字都可能跟着变,用它做长期脚本接口很容易失效。

十、用 demo 观察每一层

进入配套目录:

cd workspaces/compiler-pipeline-demo
cargo test
cargo run --release

这个 demo 包含泛型 twice、match、切片迭代和一个显式 Drop 类型。导出的 MIR 和 LLVM IR 有足够多的结构可看,文件又不会被第三方依赖撑得难以阅读。

10.1 stable 工具链:导出 LLVM、汇编和目标文件

cargo rustc --bin compiler-pipeline-demo -- \
  --emit=llvm-ir,asm,obj \
  -C opt-level=0 \
  -C debuginfo=2

产物一般会出现在 target/debug/deps/,文件名带有 crate hash。用 find 找到它们:

find target/debug/deps -maxdepth 1 -type f \
  \( -name '*.ll' -o -name '*.s' -o -name '*.o' \) \
  -print

接着把优化级别改成 3,比较函数有没有被内联、分支有没有被消除:

cargo rustc --release --bin compiler-pipeline-demo -- \
  --emit=llvm-ir,asm

10.2 nightly 工具链:查看 MIR

MIR dump 属于 rustc 的不稳定调试接口,需要 nightly:

rustup toolchain install nightly
cargo +nightly rustc --bin compiler-pipeline-demo -- \
  -Zdump-mir=all \
  -Zdump-mir-dir=target/mir-dump \
  -Zmir-opt-level=0

本机没有 nightly 也不影响后面的实验,llvm-ir、汇编和目标文件都可以在 stable 上导出。MIR 文件通常按函数和处理阶段拆分,第一次看时聚焦 main、twice 和包含 match 的函数就够了。

也可以用更紧凑的打印方式:

rustc +nightly -Zunpretty=mir src/main.rs

-Zunpretty 和 -Zdump-mir 的输出格式会随 nightly 变化。它们适合临时观察控制流和中间变量,不适合当作稳定快照提交进仓库。

10.3 从实验现象反推阶段

可以用三组小改动验证前面的阶段划分:

  • • 把 twice(7_u32) 改成 twice(true),观察单态化实例和汇编调用的变化;
  • • 修改 match 分支,故意漏掉一种情况,观察编译器给出的穷尽性错误;
  • • 让一个引用跨越 .await,观察 Future 的 Send 约束和 MIR 状态字段。

每轮只改一个变量,同时记下 rustc --version --verbose、target triple、profile 和完整命令。编译输出高度依赖工具链与环境,缺了这些信息,前后两份结果很难比较。

十一、增量编译:为什么只改一行也可能很快

rustc 内部的 query system 会把工作拆成许多查询,例如“这个 item 的 HIR 是什么”“这个泛型实例需要哪份 MIR”“这个 crate 的 metadata 是什么”。输入变化后,编译器根据依赖关系让相关结果失效。

源文件 / Cargo 参数
        │
        ▼
query key -> 查询结果 + 依赖边 + 哈希
        │
        ├─ 命中缓存:复用结果
        └─ 失效:重算并传播到下游查询

所以日常构建会出现这些差别:

  • • 改函数体通常只让相关 codegen 单元重编;
  • • 改公共类型或 trait 签名可能让多个下游 crate 重新检查;
  • • 修改 Cargo.toml feature、RUSTFLAGS 或 target 会大面积失效;
  • • cargo clean 会删除构建缓存,不能用来评价日常增量编译速度。

target/debug/deps 里的 .rmeta 保存了下游 crate 查询上游类型和 trait 所需的信息,.rlib 则包含链接和后续代码生成要用的内容。它们都参与 Cargo 与 rustc 的增量构建,手动删除就等于放弃对应缓存。

十二、几组容易混淆的边界

  • • HIR 已经过宏展开、名称解析和语义归一化,不是换了名字的 AST;
  • • 借用检查主要基于 MIR,LLVM 不理解 Rust 生命周期;
  • • MIR 是 Rust 语义层的控制流图,距离寄存器和目标指令还很远;
  • • 泛型函数经过单态化后,会为实际使用的类型生成一个或多个实例;
  • • #[inline] 给优化器提供提示,不保证函数一定内联;
  • • --emit=llvm-ir 得到的是 crate 级产物,链接还没有完成;
  • • release profile 除了调试信息,还会影响优化级别、panic 策略、LTO 和 codegen 配置;
  • • 编译通过无法替程序员证明 unsafe API 的前置条件,代码仍可能触发 UB。

碰到编译变慢、二进制变大或性能回退,我一般沿着产物逐层缩小范围:

rustc/cargo 版本、target 和 profile
  -> 依赖图和 feature
  -> 泛型实例数量、LTO 和 codegen-units
  -> MIR 中的分支、drop 和状态机字段
  -> LLVM IR 中的内联、别名信息和运行时检查
  -> 汇编与 perf/flamegraph 指向的实际热点
  -> linker、strip、容器和启动参数

一份汇编只能回答最后几个阶段的问题。定位到原因后,还要用可重复的 benchmark、体积报告或线上指标验收结果。

十三、日常工程里怎么用

13.1 诊断借用错误

先看报错对应的 HIR 语义,再顺着 MIR 控制流追引用:它在哪个基本块创建,在哪里最后使用,哪条分支仍持有它。碰到 async 代码,还要检查哪些局部变量会被保存进 Future 状态机。

13.2 诊断二进制膨胀

用 cargo tree -e features 和 cargo bloat 找到体积大的 crate,再从 symbol 和单态化实例判断增量来自依赖代码、泛型重复,还是调试信息。来源确定后,才能决定要改 feature、分发方式、LTO 还是 profile。

13.3 诊断性能回归

低优化等级的 LLVM IR 保留了更多源码结构,适合学习和定位;高优化等级的汇编更接近线上行为。debug 汇编不能代表 release 性能,某一版 LLVM 碰巧完成的优化也不构成长期契约。性能数据需要在固定 target、CPU 特性和 workload 下重复测量。

十四、结语

走完一遍后,四层 IR 的分工已经很清楚了:

AST 让编译器看懂源码结构
HIR 让编译器做名称和类型分析
MIR 让编译器证明借用、move、drop 和控制流
LLVM 让已经证明安全的具体实例变成目标架构指令

LLVM 生成目标文件后,linker 还要把依赖、系统库和启动代码拼成完整程序;程序运行时又会受到操作系统、CPU、动态库和运行环境影响。

以后再碰到编译错误、体积膨胀或性能回退,可以先判断问题落在哪一层,再去看对应产物。HIR/MIR、LLVM IR、汇编和链接结果各自能回答一部分问题,没有哪一份输出能单独解释整个程序。

相关学习资料