ARTICLE · 1271959
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 上驱动 linkercargo rustc -- --emit=llvm-ir 的意思是让 Cargo 负责依赖和参数,额外把参数传给当前 package 的 rustc。如果想直接观察一个独立文件,也可以跳过 Cargo:
rustc --edition=2024 src/main.rs --emit=mir1.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 valuepair.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,asm10.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.tomlfeature、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、汇编和链接结果各自能回答一部分问题,没有哪一份输出能单独解释整个程序。















































































