Rust 编译器与运行时揭秘
前言:站到编译器的视角
前言:站到编译器的视角
"Zero-cost abstractions: what you don't use, you don't pay for. And further: what you do use, you couldn't hand code any better." — Bjarne Stroustrup, later embraced by Rust
写作动机:和编译器搏斗的那十年
每一个 Rust 开发者都经历过这样的时刻:编译器报了一个 lifetime 错误,你盯着那几行代码看了十分钟,试了各种写法,终于编译通过了——但你不知道为什么。
fn longest<'a, 'b>(x: &'a str, y: &'b str) -> &'a str {
if x.len() > y.len() { x } else { y }
// ^ error: lifetime mismatch
}
你学会了"和编译器搏斗"。你知道在这里加一个 'a,在那里加一个 clone(),在某些地方用 Arc<Mutex<T>>。你的代码能编译、能运行、性能不错。
但有一个问题始终萦绕在脑海:
编译器到底在做什么?
- 所有权的 move 语义,在编译器内部对应的是什么操作?
- 借用检查器是怎么判断一个引用会不会悬垂的?它用的是什么算法?
- 泛型函数说是"零成本抽象",编译器具体做了什么来消除运行时开销?
async fn明明返回的是一个impl Future,为什么编译器能在编译期确定它的大小?dyn Trait的 vtable 里到底存了什么?一个 trait object 在内存里长什么样?- 为什么
Option<&T>和&T在内存里一样大?这个"Niche 优化"到底是怎么实现的?
这些问题,Rust 官方文档不会告诉你,《Rust 程序设计语言》(The Book)不会告诉你,绝大多数 Rust 教程也不会告诉你。因为它们教的是"怎么用 Rust",而不是"Rust 怎么工作"。
这本书填补的就是这个空白。
一个典型的"开窍"场景
Rust 工程师常遇到这样一段代码,折磨好几小时:
async fn process(items: Vec<String>) -> Vec<String> {
let mut futs = Vec::new();
for item in items.iter() { // ← 编译器报错从这里开始蔓延
futs.push(async move { transform(item).await });
}
futures::future::join_all(futs).await
}
报错 "borrowed value does not live long enough"。按网上找到的各种建议试一圈:items.clone()、items.into_iter()、用 Arc<Vec<_>>... 凑巧有一个能过,但不知道为什么它能过。
打开 cargo +nightly rustc -- -Zunpretty=mir 看 MIR——一瞬间豁然开朗:
// MIR 显示 async 块被编译器生成为一个状态机 struct
// 状态机需要捕获 `item`,而 `item: &String` 的 lifetime 绑定到 `items.iter()`
// 但状态机要 'static 才能送给 join_all
// 于是编译器要求 item 被 owned —— 所以 `into_iter()` 才能过
一切都清楚了。不是"碰对了",而是编译器在按一个可理解的规则工作。规则可学,搏斗可以结束。
本书想帮你达到这个"开窍"的时刻——不是通过更多的经验主义技巧,而是通过站到编译器的视角。
这本书讲什么
本书不教 Rust 语法。如果你还不会写 Rust,请先阅读 The Rust Programming Language。
本书讲的是 Rust 编译器的内部行为——从你写下一行 Rust 代码到最终生成机器码的过程中,编译器对每一个核心语言特性做了什么。
具体来说,本书回答以下问题:
所有权与内存
- move 语义在 MIR(中间表示)中是如何表达的?Copy 和 Move 的区别在编译产物中长什么样?
- 借用检查器使用的 NLL(Non-Lexical Lifetimes)算法 是怎么工作的?它如何构建控制流图、如何计算借用的活跃区间?Polonius 算法会如何改写这一切?
- 编译器是怎么推导 lifetime 的?什么时候需要你手动标注、什么时候不需要?lifetime elision 规则背后的判断树长什么样?
struct、enum、union在内存中是怎么排列的?Niche 优化是怎么让Option<&T>和&T一样大的?repr(C)/repr(packed)/repr(transparent)各自改变了什么?
类型系统与泛型
- 单态化(monomorphization)的展开过程是什么?一个
fn foo<T>(x: T)被三种类型调用后,编译器生成了几份代码?二进制体积膨胀的根因和止损策略? - 静态分发和动态分发的区别在 LLVM IR 层面具体是什么?性能差距来自哪里?
dyn Trait的 fat pointer 里存了什么?vtable 的结构是怎样的?为什么dyn Iterator<Item = T>需要 T 静态化?- Trait 一致性(coherence)规则——孤儿规则(orphan rule)——为什么这样设计?它防止了什么灾难?
异步
async fn被编译器展开成了什么?生成的状态机有几个状态、每个状态存了哪些字段?状态机的总大小由什么决定?- Pin 解决的"自引用结构"问题到底是什么?为什么
&mut self不够用?为什么需要PhantomPinned? - Waker 的注册和唤醒机制是怎么和 Tokio 的 reactor 配合的?为什么
poll返回Pending前必须注册 waker?
闭包、unsafe 与 FFI
- 闭包捕获变量时,编译器生成的匿名 struct 长什么样?
Fn、FnMut、FnOnce的区别在编译产物中如何体现? unsafe块的边界在编译器实现中是如何划定的?编译器在 unsafe 块内关闭了哪些检查?"unsafe 不是关闭所有安全检查"——它具体关闭了哪些?- 跨 FFI 边界调用 C 函数时,参数是怎么传递的?ABI 约定如何影响内存布局?
宏与元编程
- 声明宏(
macro_rules!)和过程宏(proc_macro)的展开发生在编译的哪个阶段? - 过程宏拿到的
TokenStream和最终的 AST 之间经历了什么?为什么过程宏只能"看到 Token"看不到类型信息?
编译器后端
- MIR 层做了哪些优化?常量传播、死代码消除、内联是怎么工作的?
- MIR 是怎么被翻译成 LLVM IR 的?Rust 的类型信息在 LLVM IR 中还存在吗?
- 增量编译是怎么决定"哪些代码需要重新编译"的?Query system 的工作原理?
本书的方法论:对照法(Comparison Method)
本书的核心方法是**"你写的 vs 编译器做的"对照**。
每一个核心概念,我们都会先给出一段简洁的 Rust 代码,然后展示编译器对这段代码的处理结果——可能是 MIR、可能是 LLVM IR、可能是内存布局图、可能是状态机的展开形式。
示例:async/await 的状态机展开
// 你写的
async fn fetch(url: &str) -> String {
let resp = http_get(url).await;
let body = resp.text().await;
body.to_uppercase()
}
编译器内部生成的(简化,实际更复杂):
// 编译器生成的
enum FetchFuture<'a> {
Start { url: &'a str },
WaitingHttpGet { url: &'a str, fut: HttpGetFuture<'a> },
WaitingText { fut: TextFuture },
Done,
}
impl<'a> Future for FetchFuture<'a> {
type Output = String;
fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<String> {
// 状态转移逻辑:
// 如果当前是 Start → 调用 http_get → 转到 WaitingHttpGet
// 如果当前是 WaitingHttpGet → poll 子 future → 完成则转到 WaitingText
// 如果当前是 WaitingText → poll 子 future → 完成则转到 Done
...
}
}
这种对照让你同时看到"抽象"和"实现"。你不需要去猜编译器做了什么——你直接看到了。
示例:Move 语义的 MIR 形式
// 你写的
fn main() {
let s = String::from("hello");
let t = s; // move
// println!("{}", s); // 注释掉这行才能编译——为什么?
}
MIR 展开(关键部分):
bb0: {
_1 = String::from(const "hello") -> bb1;
}
bb1: {
_2 = move _1; // 关键:move _1 把所有权转移到 _2
StorageDead(_1); // _1 被标记为已消耗
return;
}
StorageDead(_1) 这一行解释了一切——所有权转移之后,编译器已经把原变量标记为"死亡"。你再试图用 s,编译器查到它是 StorageDead 状态,直接拒绝。
如何自己生成这些输出
书中所有的 MIR 和 LLVM IR 输出都是通过以下命令实际生成的,不是凭空编造:
# 查看 MIR(需要 nightly)
cargo +nightly rustc -- -Zunpretty=mir
# 查看 LLVM IR
cargo rustc --release -- --emit=llvm-ir
# 查看汇编
cargo rustc --release -- --emit=asm
# 查看内存布局(需要 nightly)
cargo +nightly rustc -- -Zprint-type-sizes
# 查看编译器的借用检查分析
RUSTC_LOG=rustc_borrowck cargo rustc 2>&1
# 查看 trait selection 的过程
RUSTC_LOG=rustc_trait_selection cargo rustc 2>&1
你可以用同样的命令在自己的代码上验证本书的每一个结论。本书鼓励你边读边实验——这是远比被动阅读更高效的学习方式。
本书的组织:从表层到深层的八部分
全书分为八个部分,按照 Rust 语言特性从基础到高级组织:
graph TD
P1[第一部分<br/>编译全景<br/>ch01]
P2[第二部分<br/>所有权与内存<br/>ch02-05]
P3[第三部分<br/>类型系统与泛型<br/>ch06-08]
P4[第四部分<br/>异步机制<br/>ch09-10]
P5[第五部分<br/>闭包/unsafe/FFI<br/>ch11-13]
P6[第六部分<br/>宏与元编程<br/>ch14]
P7[第七部分<br/>编译器后端<br/>ch15-17]
P8[第八部分<br/>设计哲学<br/>ch18]
P1 --> P2 --> P3 --> P4 --> P5 --> P6 --> P7 --> P8
style P1 fill:#dbeafe,stroke:#3b82f6
style P2 fill:#dbeafe,stroke:#3b82f6
style P3 fill:#fef3c7,stroke:#f59e0b
style P4 fill:#fef3c7,stroke:#f59e0b
style P5 fill:#fecaca,stroke:#dc2626
style P6 fill:#fecaca,stroke:#dc2626
style P7 fill:#dcfce7,stroke:#22c55e
style P8 fill:#f3e8ff,stroke:#a855f7
第一部分:编译全景(第 1 章)——Rust 代码从源文件到可执行文件的完整旅程:解析 → HIR → MIR → LLVM IR → 机器码。这一章建立全局地图,后续章节在这张地图上逐个区域深入。
第二部分:所有权与内存(第 2-5 章)——Rust 最核心的创新。我们会深入 MIR 看 move/copy 的实现,拆解 NLL 借用检查算法,理解 lifetime 推导的规则,以及编译器如何决定一个类型在内存中的排列方式。
第三部分:类型系统与泛型(第 6-8 章)——零成本抽象的真正含义。单态化如何消除泛型开销,trait 的两种分发策略在底层有什么区别,vtable 的结构和代价。
第四部分:异步机制(第 9-10 章)——Rust 异步模型的编译器实现。async/await 的状态机展开、Pin 的必要性、Waker 的协作机制。这两章是后续阅读 Tokio 源码的前置知识。
第五部分:闭包、unsafe 与 FFI(第 11-13 章)——编译器的"柔软面"和"硬边界"。闭包的匿名类型生成、unsafe 的精确边界、跨 ABI 的函数调用。
第六部分:宏与元编程(第 14 章)——编译前的编译。声明宏的模式匹配和过程宏的 token 流处理。
第七部分:编译器后端与优化(第 15-17 章)——从 MIR 到机器码的最后一程。MIR 层的优化 pass、LLVM IR 的生成与优化、增量编译的缓存策略。
第八部分:设计哲学(第 18 章)——回到"为什么"。Rust 的核心设计决策背后的哲学:编译期 vs 运行时、安全 vs 灵活、零成本 vs 易用。以及这些设计模式如何迁移到其他系统的设计中。
每一章的写作节奏
本书每一章都按同一个节奏组织:
- 问题域:这一章要解决的语言机制是什么?它想达到什么目标?
- 朴素方法:最直接的实现思路是什么?为什么不够?
- Rust 的选择:Rust 编译器选择了什么方案?设计权衡是什么?
- 真实实现:MIR / LLVM IR / 数据结构层面具体是什么?
- 工程含义:这对你写 Rust 代码意味着什么?什么时候该用、什么时候避免?
这种结构保证了既有理论深度,又有实用价值——你读完不仅理解了"它怎么工作",还知道"我该怎么做"。
本书读者
你需要
- 至少写过几千行 Rust 代码
- 了解所有权、借用、trait、async/await 的基本用法(不需要理解原理)
- 有过"和编译器搏斗"的挫败经验(这是学习动力的来源)
- 基本的计算机系统概念:栈、堆、指针、函数调用
你不需要
- 编译器开发经验——本书会从零开始解释每一个编译器概念
- LLVM 知识——本书讲解 LLVM IR 时会配套解释
- 计算机科学学位——本书避免形式化理论,用工程视角解释
- 阅读 rustc 源码的经验——本书会告诉你哪些文件值得读
你将获得
- 遇到 lifetime 错误时,知道编译器的判断逻辑,能直接写出正确的代码而不是靠试
- 在
dyn Trait和泛型之间做选择时,知道两者在底层的精确代价 - 读 Tokio、Axum、TiKV 等项目源码时,不会在
Pin<Box<dyn Future>>上卡住 - 理解"零成本抽象"不是营销口号,而是编译器用单态化、内联、LLVM 优化实现的具体工程
- 能读懂同事/社区的 lifetime 和 trait 高级代码
- 能设计自己的 zero-overhead API——因为你知道编译器会怎么优化
与后续书籍的关系
本书是"Rust 后端系列"的第一本。后续书籍将深入 Tokio、Axum、Pingora、MeiliSearch、TiKV 等 Rust 基础设施项目的源码。
在那些书中,你会频繁遇到本书讲解过的机制:
| 后续书籍 | 重点依赖本书哪几章 |
|---|---|
| Tokio 源码解析 | 第 9-10 章(async 状态机、Pin/Waker) |
| Axum 架构剖析 | 第 6-8 章(单态化、trait 分发、trait object) |
| TiKV 核心实现 | 第 12-13 章(unsafe、FFI) + 第 6-8 章 |
| MeiliSearch 存储引擎 | 第 12-13 章(unsafe)+ 第 2-5 章(所有权) |
| 任何 Rust 项目阅读 | 第 2-5 章(所有权、借用检查、内存布局) |
把这本书当作整个系列的"解码器"——先装好它,后面的书读起来会顺畅得多。
源码版本与验证
编译器源码对照
本书基于 Rust 1.88 stable(2026 年 4 月)分析。编译器源码引用来自 rust-lang/rust 仓库。关键的源码文件位置:
| 主题 | 源码位置 |
|---|---|
| 借用检查器 | compiler/rustc_borrowck/ |
| MIR 定义 | compiler/rustc_middle/src/mir/ |
| MIR 优化 | compiler/rustc_mir_transform/ |
| Trait 系统 | compiler/rustc_trait_selection/ |
| 类型推导 | compiler/rustc_hir_typeck/ |
| 单态化 | compiler/rustc_monomorphize/ |
| 代码生成 | compiler/rustc_codegen_llvm/ |
| async 展开 | compiler/rustc_mir_transform/src/coroutine.rs |
| 闭包展开 | compiler/rustc_hir_typeck/src/upvar.rs |
| 增量编译 | compiler/rustc_query_system/ |
本书不要求你读懂这些源码——但会在关键处指出"你想深入就去这里"。
实验工具链
书中所有编译器输出(MIR、LLVM IR、类型布局)均可通过标准工具链复现:
# 安装 nightly(用于部分诊断输出)
rustup install nightly
# 查看 MIR
cargo +nightly rustc -- -Zunpretty=mir
# 查看 LLVM IR
cargo rustc --release -- --emit=llvm-ir
# 查看类型大小和布局
cargo +nightly rustc -- -Zprint-type-sizes
# 查看借用检查日志
RUSTC_LOG=rustc_borrowck=debug cargo rustc 2>&1 | less
# 查看汇编(目标平台相关)
cargo rustc --release -- --emit=asm -C target-cpu=native
还有几个社区工具会让这个过程更轻松:
# cargo-show-asm - 查看某个函数的汇编
cargo install cargo-show-asm
cargo asm <crate>::<function>
# cargo-llvm-lines - 统计每个函数生成了多少 LLVM IR 行
cargo install cargo-llvm-lines
cargo llvm-lines | head -50
# rust-demangler - 把 mangled name 变成可读的
cargo install rustc-demangle
一个约定:不要只读,要动手
不要只读——动手验证。 把书中的示例代码粘贴到你的项目里,用上面的命令看编译器的实际输出,和书中的分析对照。这是最快的学习方式。
每一章结尾都会提供 3-5 个"动手实验"——小练习,让你在自己的机器上重现书中的观察。花 10 分钟做实验,比多读 30 分钟书更有价值。
关于"理解编译器"的本质
最后说几句题外话——关于为什么理解编译器比记住语法规则更重要。
Rust 的语法规则很多:lifetime 的五条省略规则、trait 的孤儿规则、impl Trait 的位置约束、dyn Trait 的对象安全要求... 你可以把它们全部背下来。但背规则有两个问题:
- 规则在演进——今天的 NLL 可能明年变成 Polonius,今天的
impl Trait in argument position明年会加入返回位置 - 规则之间有例外——孤儿规则有例外(fundamental trait),自动 trait 推导有例外(Send/Sync 的反向实现)
而规则背后的原理不会变。
所有权是一个图论问题(资源的获取关系是有向无环图);借用检查是一个活跃变量分析问题(compile-time 的静态分析);单态化是编译期的代码特化(partial evaluation 的应用);async 状态机是编译期的 CPS 变换(continuation-passing style)。
这些原理一旦掌握,未来 Rust 语言加的任何新特性,你都能自己推断出它会怎么工作、代价是什么。
这就是本书试图给你的——不是一套规则清单,而是一副看穿 Rust 的眼镜。
戴上之后,编译器不再是你的对手,而是你最有耐心的老师。
翻页,让我们开始这场旅程。
杨艺韬 2026 年 4 月 · 于北京
延伸阅读的推荐起点
- Rust Reference(语言规范):https://doc.rust-lang.org/reference/
- Rustc Dev Guide(编译器内部开发指南):https://rustc-dev-guide.rust-lang.org/
- RFC Book(语言特性的设计历史):https://rust-lang.github.io/rfcs/
- rust-lang/rust 源码仓库:https://github.com/rust-lang/rust
- The Rustonomicon(unsafe 圣经):https://doc.rust-lang.org/nomicon/
- Jon Gjengset 的 Crust of Rust 视频系列:https://www.youtube.com/@jonhoo
- 杨艺韬讲堂 Rust 后端系列首页:https://yangyitao.com/books/rust-compiler/