Appearance
第1章 编译管线全景:从源码到机器码的完整旅程
"要理解一个系统,先画出它的地图。" —— Fred Brooks
本章要点
- Rust 编译器不是一条线性流水线,而是一个基于查询的按需驱动系统
- 从源码到机器码,数据流经十个关键阶段:源码 → Token → AST → 名称解析/宏展开 → HIR → 类型检查 → MIR → MIR 优化/借用检查 → 单态化收集 → LLVM IR → 机器码 → 链接
- 每个阶段承担什么职责、丢弃什么信息、新增什么信息
- 查询系统如何让编译器实现增量编译和并行执行
- 跟踪一个真实的
fn main() { println!("hello"); }走完编译器的每一站
1.1 为什么需要这张地图
当你写下一段 Rust 代码:
rust
fn main() {
let s = String::from("hello");
let r = &s;
println!("{}", r);
}然后执行 cargo build,几秒后得到一个可执行文件。这中间发生了什么?
大多数 Rust 教程会告诉你"编译器检查了所有权和借用",然后跳到"程序运行"。但编译器不是一个黑箱——它是一个由数十个 crate 组成的精密系统,每个 crate 都有明确的输入和输出。
理解这个系统的意义不在于学术——而在于实战:
- 当你遇到 lifetime 错误时,知道这个错误发生在 MIR 层的借用检查器中,就知道该从控制流图的角度去思考修复方案
- 当你想优化编译速度时,知道单态化和 LLVM 优化是最耗时的阶段,就知道该用
cargo check而非cargo build、该减少泛型实例化的数量 - 当你读 Tokio、Axum 等项目的源码时,知道
Pin<Box<dyn Future + Send + 'static>>在每一站分别意味着什么,就不会被类型签名吓住 - 当你写过程宏时,知道宏展开发生在 AST 层、在名称解析和类型检查之前,就知道过程宏能操纵什么、不能操纵什么
本章将建立一张完整的地图。后续每一章都是这张地图上某个区域的深入探索。
1.2 编译管线全景
在深入每个阶段之前,先看一张全景图。这张图展示了从 .rs 源文件到最终可执行文件的完整数据流:
| 阶段 | 负责 crate | 输入 | 输出 | 核心职责 |
|---|---|---|---|---|
| 词法分析 | rustc_lexer | 源码文本 | Token 流 | 将字符流切分为词法单元 |
| 语法分析 | rustc_parse | Token 流 | AST (rustc_ast::Crate) | 构建语法树,检查语法正确性 |
| 名称解析与宏展开 | rustc_resolve + rustc_expand | AST | 展开后的 AST + 解析结果 | 展开所有宏,解析所有名称到定义 |
| AST → HIR | rustc_ast_lowering | 展开后的 AST | HIR | 脱糖,消除语法糖 |
| 类型检查 | rustc_hir_typeck + rustc_hir_analysis | HIR | 带类型的 HIR | 类型推导、trait 解析、一致性检查 |
| HIR → MIR | rustc_mir_build | HIR | MIR | 控制流图化,模式匹配编译 |
| 借用检查与 MIR 优化 | rustc_borrowck + rustc_mir_transform | MIR | 优化后的 MIR | NLL 借用检查,常量传播,内联等优化 |
| 单态化收集 | rustc_monomorphize | MIR | 单态化实例集合 + codegen unit 分区 | 确定需要生成代码的所有泛型实例 |
| 代码生成 | rustc_codegen_ssa + rustc_codegen_llvm | MIR + 单态化实例 | LLVM IR → 目标文件 | 翻译为 LLVM IR,LLVM 优化,生成机器码 |
| 链接 | 系统链接器 | 目标文件 | 可执行文件 / 库 | 符号解析,重定位,生成最终二进制 |
接下来我们逐一拆解每个阶段。
1.3 词法分析:从字符到 Token
编译的第一步是词法分析(Lexing),将源码文本切分为 Token 流。
rust
// 源码
let x: i32 = 42 + y;// Token 流
Keyword(Let) Ident("x") Colon Ident("i32") Eq Literal(42) Plus Ident("y") SemiRust 的词法分析器位于 rustc_lexer crate 中。这个 crate 有一个非常独特的设计——它是零依赖的纯函数库,不依赖编译器的任何其他部分。它直接操作 &str,产生的 Token 只是一个类型标签加上在原始文本中的长度:
rust
// 来自 compiler/rustc_lexer/src/lib.rs
/// Parsed token.
/// It doesn't contain information about data that has been parsed,
/// only the type of the token and its size.
pub struct Token {
pub kind: TokenKind,
pub len: u32,
}TokenKind 枚举定义了所有可能的词法单元类型——标识符、关键字、字面量、标点符号、注释、空白等。
词法分析的微妙之处
词法分析看似简单,但 Rust 的词法层有几个值得注意的细节:
- 生命周期与字符字面量的歧义:
'a是生命周期还是字符字面量?词法分析器通过后续字符来区分——如果'后面跟着标识符字符且没有闭合的',则是生命周期 - 原始字符串字面量:
r#"..."#需要计数#数量来确定边界,在LiteralKind中表示为RawStr { n_hashes: Option<u8> } - 文档注释区分:
///(Outer)和//!(Inner)在词法层就被区分为不同的DocStyle - 两层架构:
rustc_lexer产生"原始 Token",还需经过rustc_parse::lexer二次处理,转换为解析器使用的"宽 Token"。这一层处理 token 联合(如将>>合并为>>)等工作
1.4 语法分析:从 Token 到 AST
语法分析器(Parser)将 Token 流组织成抽象语法树(Abstract Syntax Tree, AST)。Rust 的解析器位于 rustc_parse crate 中,是一个手写的递归下降解析器——不使用 yacc、bison 等解析器生成工具。
解析器的入口
编译的起点在 rustc_interface::passes::parse 函数中。它根据输入类型(文件或字符串)创建 Parser 实例,调用 parser.parse_crate_mod() 产出 ast::Crate。几个关键细节:
StripTokens::ShebangAndFrontmatter—— 解析器自动去除 shebang 行和 frontmatterparse_crate_mod()是顶层解析入口,产出完整的 crate AST- 命令行
--cfg属性在解析后被注入到 AST 中
AST 的结构
AST 忠实地保留了源码的语法结构,包括所有的语法糖:
rust
// 源码
fn add(a: i32, b: i32) -> i32 {
a + b
}// AST(简化表示)
FnDecl {
name: "add",
params: [
Param { name: "a", ty: Path("i32") },
Param { name: "b", ty: Path("i32") },
],
return_ty: Path("i32"),
body: Block {
stmts: [],
expr: BinOp(Add, Path("a"), Path("b")),
},
}此时编译器还不知道 i32 是什么类型、a + b 是否合法——这些是后续阶段的事。AST 只关心语法结构是否合法。
错误恢复
Rust 的解析器有一个重要特性——错误恢复(Error Recovery)。当遇到语法错误时,解析器不会立即停止,而是尝试跳过错误部分继续解析,以便一次编译就能报告多个错误。解析器内部有一个 Recovery 机制来控制这个行为:
rust
// 来自 compiler/rustc_parse/src/parser/mod.rs
pub enum Recovery {
Allowed,
Forbidden,
}在正常编译中使用 Recovery::Allowed,而在解析命令行参数等场景中使用 Recovery::Forbidden(因为命令行输入不需要恢复,只需要精确诊断)。
1.5 名称解析与宏展开
AST 构建完成后,编译器进入一个交织的阶段——名称解析(Name Resolution)和宏展开(Macro Expansion)。这两个过程必须交替进行,因为宏可以引入新的名称,而名称解析需要知道哪些标识符是宏调用。
宏展开
宏展开发生在 AST 阶段。当解析器遇到 println!("{}", x) 时,会调用宏展开器将其展开为一棵新的 AST 子树。展开后的代码再次经过语法分析,这个过程可能递归进行。
编译器通过 configure_and_expand 函数驱动整个过程。这个函数是编译器前端最复杂的入口之一,它协调了宏展开和名称解析的交替执行。关键流程:
- 注册内建宏:
rustc_builtin_macros::register_builtin_macros(resolver)注册println!、vec!等内建宏 - 标准库注入:自动添加
extern crate std;和use std::prelude::v1::*; - 宏展开:
ecx.monotonic_expander().expand_crate(krate)递归展开所有宏调用,有递归深度限制(recursion_limit) - AST 验证:
rustc_ast_passes::ast_validation::check_crate检查展开后的 AST 是否满足语法约束 - 名称解析:
resolver.resolve_crate(&krate)将所有名称绑定到它们的定义
名称解析的作用
名称解析器(rustc_resolve)将源码中的每个标识符映射到它所指向的定义——处理模块系统、use 语句、glob 导入、嵌套路径等复杂情况。它的输出(ResolverOutputs)分为 global_ctxt(全局解析结果)和 ast_lowering(为降级准备的信息)两部分。
在名称解析完成后,所有宏都已被展开,所有名称都被绑定到具体定义。后续阶段看到的是一棵完全展开、完全解析的 AST。
1.6 AST → HIR 降级:脱糖的艺术
HIR(High-level Intermediate Representation)是 Rust 编译器的第一层中间表示。从 AST 到 HIR 的转换叫做 lowering(降级),由 rustc_ast_lowering crate 负责。
这个阶段在编译器中通过查询系统注册:
rust
// 来自 compiler/rustc_interface/src/passes.rs
providers.queries.hir_crate = rustc_ast_lowering::lower_to_hir;脱糖:消除语法糖
降级的核心操作是脱糖(desugaring)——将语法糖转换为更基本的构造:
for 循环脱糖:
rust
// 你写的
for item in collection {
process(item);
}rust
// HIR 中的真实形式
{
let result = match IntoIterator::into_iter(collection) {
mut iter => loop {
match Iterator::next(&mut iter) {
Some(item) => { process(item); }
None => break,
}
},
};
result
}? 操作符脱糖:
rust
// 你写的
let value = some_result?;rust
// HIR 中的真实形式
let value = match Try::branch(some_result) {
ControlFlow::Continue(v) => v,
ControlFlow::Break(e) => return FromResidual::from_residual(e),
};async fn 脱糖:async fn fetch(url: &str) -> Response 变为 fn fetch<'a>(url: &'a str) -> impl Future<Output = Response> + 'a,编译器生成一个匿名的 Future 类型。
if let 脱糖:if let Some(x) = opt { use(x); } else { fallback(); } 变为 match opt { Some(x) => { use(x); }, _ => { fallback(); } }。
AST 与 HIR 的关键区别
脱糖之后,编译器面对的是一个更简单但更冗长的表示——语法糖被消除了,所有的控制流都变成了显式的 match、loop、break。这大大简化了后续类型检查的工作,因为类型检查器只需要处理少数几种基本构造,而不需要知道 for 循环或 ? 操作符的特殊语义。
1.7 类型检查与推导
类型检查是 Rust 编译器最复杂的阶段之一,涉及两个核心 crate:
rustc_hir_analysis:负责 well-formedness 检查、trait 一致性检查、impl 验证等crate 级别的分析rustc_hir_typeck:负责函数体内部的类型推导和检查
Hindley-Milner 风格的类型推导
Rust 的类型推导基于 Hindley-Milner 类型系统的变体。当你写下:
rust
let mut map = HashMap::new();
map.insert("key", 42);编译器的推导过程是:
- 看到
HashMap::new(),知道返回HashMap<K, V>,但K和V尚未确定,创建类型变量?K和?V - 看到
map.insert("key", 42),推断?K = &str,?V = i32(整数字面量默认推导为i32) - 回溯统一:
map的类型最终确定为HashMap<&str, i32>
这个过程使用统一算法(Unification)——当两个类型必须相同时,尝试找到一组类型变量的赋值使它们一致。如果找不到,就产生类型错误。
Trait 解析
当遇到 a + b 时,编译器需要确定调用的是哪个 Add trait 的实现。这个过程叫做 trait resolution,它需要:
- 查找所有可见的
Add实现 - 根据
a和b的类型,选择匹配的实现 - 处理自动解引用(
Deref)链——如果直接类型没有实现,尝试解引用后的类型 - 处理 trait 约束传播——如果在泛型上下文中,可能需要推迟到单态化时再解析
方法解析与自动解引用
当你写 foo.bar() 时,编译器会沿着自动解引用链(T → &T → &mut T → Deref::Target)逐层查找固有方法和 trait 方法。这解释了为什么 String 可以直接调用 &str 的方法——因为 String: Deref<Target = str>。
类型检查的输出
类型检查完成后,每个 HIR 表达式节点都被标记了它的具体类型。这些类型信息存储在 TypeckResults 中,通过查询系统按需获取。从这一步开始,编译器拥有了完整的类型信息,后续所有阶段都可以通过 TyCtxt(类型上下文)查询任何表达式的类型。
这一阶段的检查也包括在 run_required_analyses 中注册的全量类型分析:
rust
// 来自 compiler/rustc_interface/src/passes.rs
rustc_hir_analysis::check_crate(tcx);1.8 HIR → MIR 降级:构建控制流图
MIR(Mid-level Intermediate Representation)是 Rust 编译器最独特的创新之一。从 HIR 到 MIR 的转换由 rustc_mir_build crate 负责。
MIR 是一个基于控制流图(Control Flow Graph, CFG)的表示。每个函数被分解为一系列基本块(Basic Block),每个基本块包含一系列语句(Statement)和一个终结器(Terminator)。
rust
// 源码
fn max(a: i32, b: i32) -> i32 {
if a > b { a } else { b }
}// MIR(简化)
fn max(_1: i32, _2: i32) -> i32 {
let mut _0: i32; // 返回值
let mut _3: bool; // 比较结果
bb0: {
_3 = Gt(_1, _2);
switchInt(_3) -> [0: bb2, otherwise: bb1];
}
bb1: { // a > b 为 true
_0 = _1;
goto -> bb3;
}
bb2: { // a > b 为 false
_0 = _2;
goto -> bb3;
}
bb3: {
return;
}
}MIR 与 HIR 的关键区别
| 特性 | HIR | MIR |
|---|---|---|
| 结构 | 树形(嵌套表达式) | 控制流图(基本块 + 跳转) |
| 变量 | 有名称 | 匿名编号(_0, _1, ...) |
| 表达式 | 可嵌套 | 完全展平,中间结果存入临时变量 |
| 控制流 | if/match/loop | switchInt/goto/return/drop |
| 返回值 | 隐式(表达式的值) | 显式变量 _0 |
| Drop | 隐式(作用域结束) | 显式 Drop 终结器 |
MIR 的设计使得许多分析变得简单。例如,借用检查需要知道每个引用在哪些代码路径上是"活跃的"——在控制流图上,这就是一个标准的数据流分析问题。
模式匹配的编译
MIR 构建阶段还负责将复杂的 match 模式编译为一系列条件判断和跳转,形成决策树。这个过程还需要检查模式的穷尽性——确保所有可能的值都被覆盖。
1.9 借用检查与 MIR 优化
借用检查在 MIR 上执行
这是 Rust 编译器最关键的设计决策之一:借用检查在 MIR 上执行,而不是在 AST 或 HIR 上。
在编译器的实际代码中,借用检查通过 par_hir_body_owners 并行地对每个函数体执行。MIR_borrow_checking 阶段对每个函数体(def_id)依次执行以下检查:
- unsafe 检查(
check_unsafety):验证 unsafe 块的正确使用 - 借用检查(
mir_borrowck):核心的 NLL 借用检查 - transmute 检查(
check_transmutes):验证类型转换的安全性 - FFI unwind 检查(
has_ffi_unwind_calls):检测跨 FFI 边界的 unwind - 活跃性分析(
check_liveness):检测未使用的变量 - MIR 后续变换(
mir_drops_elaborated_and_const_checked):Drop 展开和常量检查
所有这些检查通过 par_hir_body_owners 在多个线程上并行执行。
借用检查使用 NLL(Non-Lexical Lifetimes) 算法。MIR 的控制流图结构让借用检查器能够精确地追踪每个引用的活跃区间——一个引用从创建到最后一次使用之间的所有代码路径,而不是简单的词法作用域。我们将在第 3 章深入拆解 NLL 算法的完整工作流程。
MIR 优化
借用检查通过后,rustc_mir_transform 在 MIR 上执行一系列优化 pass:
- 常量传播(Constant Propagation):将编译期可确定的表达式替换为常量
- 死代码消除(Dead Code Elimination):删除永远不会执行的基本块
- 内联(Inlining):将小函数的 MIR 内联到调用点
- 简化控制流(SimplifyCfg):合并只有一个前驱/后继的基本块
- 副本传播(CopyProp):消除不必要的变量复制
- 引用消除(ReferencePropagation):消除不必要的引用/解引用对
- Drop 展开(ElaborateDrops):将高层的
Drop转换为具体的析构序列
这些优化在 LLVM 优化之前执行,可以显著减少传递给 LLVM 的 IR 量,从而加速编译。我们将在第 15 章详细拆解每个优化 pass 的工作方式。
1.10 单态化收集
在生成代码之前,编译器需要确定哪些泛型函数的哪些具体实例需要被编译。这个过程叫做单态化收集(Monomorphization Collection),由 rustc_monomorphize crate 负责。
rust
fn identity<T>(x: T) -> T { x }
fn main() {
identity(42_i32); // 需要生成 identity::<i32>
identity("hello"); // 需要生成 identity::<&str>
identity(3.14_f64); // 需要生成 identity::<f64>
}收集器从入口点(main 函数或库的公开 API)出发,递归地扫描所有被调用的函数,记录每个泛型函数被实例化的具体类型参数。在编译器中,collect_and_partition_mono_items 函数先通过 collector::collect_crate_mono_items 收集所有需要生成代码的实例(有 Eager 和 Lazy 两种策略),然后通过 partition 函数将它们分配到不同的 Codegen Unit 中,同时验证所有符号名的唯一性(assert_symbols_are_distinct)。
收集完成后,单态化的实例被分配到不同的 Codegen Unit(CGU)中。每个 CGU 会独立地被翻译为一个 LLVM 模块,最终编译为一个目标文件。CGU 的数量由 codegen-units 选项控制(默认值根据优化级别不同而不同),这直接影响编译的并行度。
1.11 代码生成:MIR → LLVM IR → 机器码
代码生成阶段将优化后的 MIR 翻译为 LLVM IR,然后由 LLVM 优化并生成目标平台的机器码。
代码生成的入口
start_codegen 函数是代码生成的入口。它首先编码 crate 的 metadata(供其他依赖此 crate 的 crate 使用),然后调用 codegen_backend.codegen_crate() 启动实际的代码生成过程。代码生成是异步的——ongoing_codegen 是一个后台任务句柄,链接阶段会等待它完成。
MIR → LLVM IR
每个单态化的函数实例被翻译为一个 LLVM IR 函数:
rust
// 源码
fn add(a: i32, b: i32) -> i32 {
a + b
}llvm
; LLVM IR
define i32 @_ZN7example3add17h1234567890abcdefE(i32 %a, i32 %b) {
start:
%result = add i32 %a, %b
ret i32 %result
}注意函数名被 name mangling 了——example::add 变成了 _ZN7example3add17h...E,包含了 crate 名、模块路径和一个哈希值,确保全局唯一。
LLVM 优化与机器码生成
LLVM 收到 IR 后,根据优化级别(-O0 到 -O3)执行不同强度的优化——从基本的常量折叠、死代码消除(-O1),到循环优化、激进内联、向量化(-O2),再到循环展开和更激进的向量化(-O3)。LLVM 的优化与 Rust MIR 层的优化是独立的——MIR 优化面向 Rust 特有语义,LLVM 优化则是通用的底层优化。
优化完成后,LLVM 经过指令选择、寄存器分配、指令调度等步骤,最终输出目标文件(.o / .obj)。
1.12 链接:拼装最终二进制
编译的最后一步是链接(Linking)。链接器将多个目标文件和库合并为最终的可执行文件或动态库。
在编译器中,链接由 Linker 结构体协调。它的 link 方法首先通过 codegen_backend.join_codegen() 等待所有 codegen unit 完成编译,然后调用 codegen_backend.link() 驱动系统链接器。
链接器需要处理的工作包括:
- 符号解析:将每个符号引用绑定到它的定义
- 重定位:调整代码和数据中的地址引用
- 合并段:将多个目标文件的
.text、.data、.bss等段合并 - 处理动态链接:生成 PLT/GOT 表项
- 生成最终格式:ELF(Linux)、Mach-O(macOS)、PE/COFF(Windows)
Rust 默认使用系统的链接器(Linux 上的 ld 或 lld,macOS 上的 ld64),但可以通过 -C linker=... 指定替代链接器。使用 lld 通常能显著加速链接阶段。
1.13 查询系统:按需驱动,而非流水线驱动
到目前为止,我们一直在用"管线"(pipeline)的隐喻来描述编译过程。但事实上,Rust 编译器内部并不是一条线性流水线。它使用的是一个基于查询的按需驱动系统(Demand-Driven Query System)。
什么是查询系统
传统编译器是流水线式的——先执行所有的解析,再执行所有的类型检查,再执行所有的代码生成。每个阶段必须完整处理完所有代码,才能进入下一阶段。
Rust 编译器的查询系统则不同。它将编译器的每个计算步骤定义为一个"查询"(Query),每个查询有一个输入(key)和一个输出(value)。查询之间形成依赖关系——当一个查询需要另一个查询的结果时,它会"拉取"(pull)那个查询的执行。
查询系统的注册
在 DEFAULT_QUERY_PROVIDERS 中,编译器注册了所有的查询提供者。每个 crate 通过 provide 函数注册自己提供的查询——rustc_borrowck::provide 注册借用检查查询,rustc_hir_typeck::provide 注册类型检查查询,rustc_monomorphize::provide 注册单态化查询,等等。总共有数十个 crate 注册了数百个查询。
当运行时某个查询被首次请求时,对应的 provider 函数才会被调用。
查询缓存与增量编译
查询系统的另一个关键特性是缓存。每个查询的结果都被自动缓存——如果同一个查询被多次请求,只有第一次会实际计算,后续请求直接返回缓存的结果。
这个缓存机制是增量编译的基础。当源码发生变化时,编译器可以:
- 检测哪些查询的输入(依赖)发生了变化
- 只重新计算那些受影响的查询
- 复用所有未受影响的查询结果
例如,如果你只修改了一个函数的实现,那么:
- 该函数的
typeck、mir_borrowck、optimized_mir需要重新计算 - 其他函数的查询结果可以复用
- 如果该函数的签名没变,依赖它的其他函数可能也不需要重新检查
全局上下文 TyCtxt
所有查询都通过 TyCtxt(Type Context)访问。TyCtxt<'tcx> 是编译器最核心的数据结构,几乎所有编译阶段都需要它。它在 create_and_enter_global_ctxt 函数中被创建,所有编译工作都在其闭包内通过 tcx 驱动。
TyCtxt 使用了 Rust 的生命周期系统来保证安全性——'tcx 生命周期确保所有通过 TyCtxt 获取的数据都在编译器的 arena 中存活。这是一个精妙的设计:gcx_cell、arena、hir_arena 都在同一个栈帧中创建,使得它们的引用共享同一个 'tcx 生命周期。
1.14 并行编译
Rust 编译器在多个粒度上实现了并行化。
函数级并行
许多分析可以对不同的函数并行执行。编译器通过三个关键原语实现并行:
par_fns(&mut [&mut || { ... }, &mut || { ... }])—— 将多个独立的分析任务并行执行tcx.par_hir_body_owners(|def_id| { ... })—— 对所有函数体并行执行某个分析tcx.par_hir_for_each_module(|module| { ... })—— 对所有模块并行执行某个检查
这是一种任务级并行——不同的检查任务在不同的线程上同时运行。
Codegen Unit 级并行
代码生成阶段的并行化通过 Codegen Unit 实现。每个 CGU 被独立地翻译为 LLVM IR,然后独立地由 LLVM 优化和编译。这是编译中最耗时的部分,也是并行化收益最大的地方。
并行编译的限制
Rust 编译器的并行化仍在持续改进中。主要限制包括:查询之间的依赖(类型检查必须先于借用检查)、部分全局数据结构需要 FreezeLock 协调、链接器通常是单线程的。
-C codegen-units=N 控制 CGU 数量,影响代码生成并行度。但 CGU 越多,LLVM 跨函数优化机会越少。release 模式默认使用 1 个 CGU 以获得最佳运行时性能。
1.15 错误恢复:编译器如何在错误后继续
一个优秀的编译器不会在第一个错误处停下——它会尽量继续分析,以便一次编译就能报告尽可能多的错误。Rust 编译器在多个层面实现了错误恢复。
ErrorGuaranteed 类型
Rust 编译器使用 ErrorGuaranteed 类型来标记"已经报告了错误"的状态。这是一个零大小类型(ZST),它的存在本身就是证明——如果你持有一个 ErrorGuaranteed,就意味着已经有至少一个错误被报告给了用户。
许多编译器函数返回 Result<T, ErrorGuaranteed>。当遇到错误时,它们报告错误并返回 Err(guar),调用者可以选择传播错误或进行恢复。
分阶段的错误检查
编译器在关键阶段之间插入错误检查点。例如在 analysis 函数中,run_required_analyses 完成后会检查是否有非 lint 错误——如果有,立即停止,不再执行后续的 lint 检查等分析。
在进入代码生成之前,编译器会做最后的检查:has_errors_or_delayed_bugs()。这确保了代码生成阶段永远不会收到有错误的输入,避免了因为错误数据导致的编译器内部崩溃(ICE)。
"中毒"机制
当类型检查发现一个表达式有错误时,它会将该表达式的类型标记为"错误类型"(TyKind::Error)。这个错误类型有一个特殊属性——它会"传染"所有涉及它的后续计算。例如,如果 x 的类型是 Error,那么 x + 1 的类型也是 Error,不会产生额外的"无法对 Error 类型执行加法"的错误信息。这就是所谓的"中毒"(poisoning)机制,它防止了一个根本错误导致大量衍生错误。
1.16 跟踪一个真实程序走完编译器的每一站
让我们跟踪一个具体的程序,看它在编译器的每一站变成了什么样子:
rust
fn main() {
println!("hello");
}第1站:词法分析
rustc_lexer 将源码切分为 Token 流:
Keyword(Fn) Ident("main") OpenParen CloseParen OpenBrace
Ident("println") Bang OpenParen Literal(Str("hello")) CloseParen Semi
CloseBrace注意 println! 被分为 Ident("println") 和 Bang 两个 Token——词法分析器不知道这是宏调用。
第2站:语法分析
rustc_parse 将 Token 流构建为 AST:
Crate {
items: [
FnItem {
name: "main",
params: [],
return_ty: None, // 推导为 ()
body: Block {
stmts: [
MacroCall {
path: "println",
args: TokenStream["hello"],
}
]
}
}
]
}此时 println!("hello") 仍然是一个未展开的宏调用。
第3站:名称解析与宏展开
rustc_expand 将 println! 展开为实际的代码。展开后的 AST 大致等价于:
rust
fn main() {
{
::std::io::_print(
::core::fmt::Arguments::new_const(&["hello\n"])
);
}
}println!("hello") 变成了对 std::io::_print 的调用,参数是一个 fmt::Arguments 结构体。这个展开过程递归进行——如果展开后的代码中还有宏调用,会继续展开。
同时,rustc_resolve 将所有名称绑定到定义:std::io::_print → 标准库中的具体函数定义,core::fmt::Arguments::new_const → 标准库中的具体方法定义。
第4站:AST → HIR 降级
rustc_ast_lowering 将 AST 转换为 HIR。在这个例子中没有明显的语法糖需要脱糖,主要变化是:
- 每个节点获得唯一的
HirId - 函数返回类型从隐式变为显式
() - 块表达式的结构被规范化
第5站:类型检查
rustc_hir_typeck 推导并检查所有类型:
main的签名:fn() -> ()::core::fmt::Arguments::new_const(&["hello\n"])—— 参数类型&[&str; 1],返回Arguments<'_>::std::io::_print(...)—— 参数类型Arguments<'_>,返回()- 整个块表达式类型
(),与main的返回类型一致
第6站:HIR → MIR 降级
rustc_mir_build 将 HIR 转换为控制流图。main 函数的 MIR 包含 4 个基本块:bb0 构造 fmt::Arguments(含字符串切片的 PointerCoercion(Unsize) 转换);bb1 调用 _print;bb2 返回;bb3 是 unwind 清理块。每步操作都是一条语句,函数调用变成了终结器(可能跳转到返回基本块或 unwind 清理块)。你可以用 cargo +nightly rustc -- -Zunpretty=mir 亲眼看到完整的 MIR。
第7站:借用检查与 MIR 优化
借用检查器分析 MIR 的控制流图。在这个简单例子中,没有借用冲突。
MIR 优化器随后简化代码——可能内联 Arguments::new_const,消除临时变量等。
第8站:单态化收集
收集器从 main 出发,确定需要生成代码的所有函数实例:
mainstd::io::_print(已经是具体类型,不需要单态化)core::fmt::Arguments::new_const_print内部调用的所有函数...
第9站:LLVM IR 生成
每个函数实例被翻译为 LLVM IR。LLVM 执行优化后生成目标平台的机器码。
第10站:链接
链接器将 main.o 和标准库链接在一起,生成最终的可执行文件。执行它:
bash
$ ./target/debug/example
hello1.17 动手验证:查看每个阶段的输出
你不需要相信本书的任何结论——你可以自己看。以下是查看每个阶段输出的命令:
bash
# 查看宏展开后的代码(接近 AST 的文本表示)
cargo +nightly rustc -- -Zunpretty=expanded
# 查看 HIR
cargo +nightly rustc -- -Zunpretty=hir
# 查看 HIR(带类型标注)
cargo +nightly rustc -- -Zunpretty=hir,typed
# 查看 MIR(借用检查前)
cargo +nightly rustc -- -Zunpretty=mir
# 查看 MIR(优化后)
cargo +nightly rustc -- -Zunpretty=mir-cfg
# 查看 LLVM IR(未优化)
cargo rustc -- --emit=llvm-ir
# 查看 LLVM IR(优化后,release 模式)
cargo rustc --release -- --emit=llvm-ir
# 查看最终的汇编
cargo rustc --release -- --emit=asm
# 查看类型的内存布局
cargo +nightly rustc -- -Zprint-type-sizes
# 查看编译时间的详细分解
cargo +nightly rustc -- -Ztime-passes
# 查看查询系统的依赖图
cargo +nightly rustc -- -Zdump-dep-graph建议现在就在你的项目上试一试。写一个简单的函数,然后用这些命令看看编译器在每个阶段的产出。
特别推荐:写一个 for 循环用 -Zunpretty=hir 看脱糖结果;写一个泛型函数并用两种类型调用,用 --emit=llvm-ir 看单态化结果;写一个 async fn 用 -Zunpretty=hir 看状态机雏形。
1.18 全景地图:本书的导航
把所有阶段和 Rust 的核心语言特性对应起来,就得到了本书的全景地图:
从下一章开始,我们将沿着这张地图,逐个区域深入。先从 Rust 最核心的创新开始——所有权系统在编译器中的实现。
本章回顾
本章建立了 Rust 编译器的全景地图:
- 词法分析(
rustc_lexer)将源码切分为 Token,零依赖的纯函数设计 - 语法分析(
rustc_parse)构建 AST,手写递归下降,支持错误恢复 - 名称解析与宏展开(
rustc_resolve+rustc_expand)交替进行,消除所有宏调用 - AST → HIR 降级(
rustc_ast_lowering)脱糖,消除语法糖 - 类型检查(
rustc_hir_typeck+rustc_hir_analysis)Hindley-Milner 风格推导,trait 解析 - HIR → MIR 降级(
rustc_mir_build)构建控制流图 - 借用检查(
rustc_borrowck)在 MIR 上执行 NLL 算法 - MIR 优化(
rustc_mir_transform)常量传播、内联、死代码消除等 - 单态化收集(
rustc_monomorphize)确定所有需要生成代码的泛型实例,分配到 Codegen Unit - 代码生成(
rustc_codegen_ssa+rustc_codegen_llvm)MIR → LLVM IR → 机器码 - 链接:系统链接器拼装最终二进制
整个系统由查询系统驱动,支持增量编译和并行执行。TyCtxt 是贯穿所有阶段的核心数据结构。