Rust编译器原理-第1章 编译管线全景:从源码到机器码的完整旅程

7 阅读27分钟

《Rust 编译器原理》完整目录

第1章 编译管线全景:从源码到机器码的完整旅程

"要理解一个系统,先画出它的地图。" —— Fred Brooks

:::tip 本章要点

  • Rust 编译器不是一条线性流水线,而是一个基于查询的按需驱动系统
  • 从源码到机器码,数据流经十个关键阶段:源码 → Token → AST → 名称解析/宏展开 → HIR → 类型检查 → MIR → MIR 优化/借用检查 → 单态化收集 → LLVM IR → 机器码 → 链接
  • 每个阶段承担什么职责、丢弃什么信息、新增什么信息
  • 查询系统如何让编译器实现增量编译和并行执行
  • 跟踪一个真实的 fn main() { println!("hello"); } 走完编译器的每一站 :::

1.1 为什么需要这张地图

当你写下一段 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 源文件到最终可执行文件的完整数据流:

graph LR
    A["源码<br/>.rs 文件"] --> B["词法分析<br/>rustc_lexer"]
    B --> C["语法分析<br/>rustc_parse"]
    C --> D["名称解析<br/>宏展开<br/>rustc_resolve<br/>rustc_expand"]
    D --> E["AST → HIR<br/>rustc_ast_lowering"]
    E --> F["类型检查<br/>rustc_hir_typeck<br/>rustc_hir_analysis"]
    F --> G["HIR → MIR<br/>rustc_mir_build"]
    G --> H["借用检查<br/>MIR 优化<br/>rustc_borrowck<br/>rustc_mir_transform"]
    H --> I["单态化收集<br/>rustc_monomorphize"]
    I --> J["代码生成<br/>rustc_codegen_ssa<br/>rustc_codegen_llvm"]
    J --> K["链接<br/>最终二进制"]

    style A fill:#64748b,color:#fff,stroke:none
    style B fill:#8b5cf6,color:#fff,stroke:none
    style C fill:#7c3aed,color:#fff,stroke:none
    style D fill:#6366f1,color:#fff,stroke:none
    style E fill:#4f46e5,color:#fff,stroke:none
    style F fill:#3b82f6,color:#fff,stroke:none
    style G fill:#0ea5e9,color:#fff,stroke:none
    style H fill:#10b981,color:#fff,stroke:none
    style I fill:#f59e0b,color:#fff,stroke:none
    style J fill:#ef4444,color:#fff,stroke:none
    style K fill:#dc2626,color:#fff,stroke:none
阶段负责 crate输入输出核心职责
词法分析rustc_lexer源码文本Token 流将字符流切分为词法单元
语法分析rustc_parseToken 流AST (rustc_ast::Crate)构建语法树,检查语法正确性
名称解析与宏展开rustc_resolve + rustc_expandAST展开后的 AST + 解析结果展开所有宏,解析所有名称到定义
AST → HIRrustc_ast_lowering展开后的 ASTHIR脱糖,消除语法糖
类型检查rustc_hir_typeck + rustc_hir_analysisHIR带类型的 HIR类型推导、trait 解析、一致性检查
HIR → MIRrustc_mir_buildHIRMIR控制流图化,模式匹配编译
借用检查与 MIR 优化rustc_borrowck + rustc_mir_transformMIR优化后的 MIRNLL 借用检查,常量传播,内联等优化
单态化收集rustc_monomorphizeMIR单态化实例集合 + codegen unit 分区确定需要生成代码的所有泛型实例
代码生成rustc_codegen_ssa + rustc_codegen_llvmMIR + 单态化实例LLVM IR → 目标文件翻译为 LLVM IR,LLVM 优化,生成机器码
链接系统链接器目标文件可执行文件 / 库符号解析,重定位,生成最终二进制

接下来我们逐一拆解每个阶段。

1.3 词法分析:从字符到 Token

编译的第一步是词法分析(Lexing),将源码文本切分为 Token 流。

// 源码
let x: i32 = 42 + y;
// Token 流
Keyword(Let) Ident("x") Colon Ident("i32") Eq Literal(42) Plus Ident("y") Semi

Rust 的词法分析器位于 rustc_lexer crate 中。这个 crate 有一个非常独特的设计——它是零依赖的纯函数库,不依赖编译器的任何其他部分。它直接操作 &str,产生的 Token 只是一个类型标签加上在原始文本中的长度:

// 来自 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。几个关键细节:

  1. StripTokens::ShebangAndFrontmatter —— 解析器自动去除 shebang 行和 frontmatter
  2. parse_crate_mod() 是顶层解析入口,产出完整的 crate AST
  3. 命令行 --cfg 属性在解析后被注入到 AST 中

AST 的结构

AST 忠实地保留了源码的语法结构,包括所有的语法糖:

// 源码
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 机制来控制这个行为:

// 来自 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 函数驱动整个过程。这个函数是编译器前端最复杂的入口之一,它协调了宏展开和名称解析的交替执行。关键流程:

  1. 注册内建宏rustc_builtin_macros::register_builtin_macros(resolver) 注册 println!vec! 等内建宏
  2. 标准库注入:自动添加 extern crate std;use std::prelude::v1::*;
  3. 宏展开ecx.monotonic_expander().expand_crate(krate) 递归展开所有宏调用,有递归深度限制(recursion_limit
  4. AST 验证rustc_ast_passes::ast_validation::check_crate 检查展开后的 AST 是否满足语法约束
  5. 名称解析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 负责。

这个阶段在编译器中通过查询系统注册:

// 来自 compiler/rustc_interface/src/passes.rs
providers.queries.hir_crate = rustc_ast_lowering::lower_to_hir;

脱糖:消除语法糖

降级的核心操作是脱糖(desugaring)——将语法糖转换为更基本的构造:

for 循环脱糖

// 你写的
for item in collection {
    process(item);
}
// HIR 中的真实形式
{
    let result = match IntoIterator::into_iter(collection) {
        mut iter => loop {
            match Iterator::next(&mut iter) {
                Some(item) => { process(item); }
                None => break,
            }
        },
    };
    result
}

? 操作符脱糖

// 你写的
let value = some_result?;
// 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 的关键区别

graph TB
    subgraph "AST 保留的信息"
        A1["for 循环语法糖"]
        A2["? 操作符"]
        A3["if let / while let"]
        A4["async fn 语法"]
        A5["括号表达式"]
        A6["精确的 Span 信息"]
    end

    subgraph "HIR 新增/变更"
        H1["所有循环 → loop + match"]
        H2["? → match Try::branch"]
        H3["if let → match"]
        H4["async fn → fn + impl Future"]
        H5["括号被消除"]
        H6["每个节点有唯一 HirId"]
    end

    A1 -->|脱糖| H1
    A2 -->|脱糖| H2
    A3 -->|脱糖| H3
    A4 -->|脱糖| H4
    A5 -->|消除| H5
    A6 -->|转换| H6

    style A1 fill:#6366f1,color:#fff,stroke:none
    style A2 fill:#6366f1,color:#fff,stroke:none
    style A3 fill:#6366f1,color:#fff,stroke:none
    style A4 fill:#6366f1,color:#fff,stroke:none
    style A5 fill:#6366f1,color:#fff,stroke:none
    style A6 fill:#6366f1,color:#fff,stroke:none
    style H1 fill:#3b82f6,color:#fff,stroke:none
    style H2 fill:#3b82f6,color:#fff,stroke:none
    style H3 fill:#3b82f6,color:#fff,stroke:none
    style H4 fill:#3b82f6,color:#fff,stroke:none
    style H5 fill:#3b82f6,color:#fff,stroke:none
    style H6 fill:#3b82f6,color:#fff,stroke:none

脱糖之后,编译器面对的是一个更简单但更冗长的表示——语法糖被消除了,所有的控制流都变成了显式的 matchloopbreak。这大大简化了后续类型检查的工作,因为类型检查器只需要处理少数几种基本构造,而不需要知道 for 循环或 ? 操作符的特殊语义。

1.7 类型检查与推导

类型检查是 Rust 编译器最复杂的阶段之一,涉及两个核心 crate:

  • rustc_hir_analysis:负责 well-formedness 检查、trait 一致性检查、impl 验证等crate 级别的分析
  • rustc_hir_typeck:负责函数体内部的类型推导和检查

Hindley-Milner 风格的类型推导

Rust 的类型推导基于 Hindley-Milner 类型系统的变体。当你写下:

let mut map = HashMap::new();
map.insert("key", 42);

编译器的推导过程是:

  1. 看到 HashMap::new(),知道返回 HashMap<K, V>,但 KV 尚未确定,创建类型变量 ?K?V
  2. 看到 map.insert("key", 42),推断 ?K = &str?V = i32(整数字面量默认推导为 i32
  3. 回溯统一:map 的类型最终确定为 HashMap<&str, i32>

这个过程使用统一算法(Unification)——当两个类型必须相同时,尝试找到一组类型变量的赋值使它们一致。如果找不到,就产生类型错误。

Trait 解析

当遇到 a + b 时,编译器需要确定调用的是哪个 Add trait 的实现。这个过程叫做 trait resolution,它需要:

  1. 查找所有可见的 Add 实现
  2. 根据 ab 的类型,选择匹配的实现
  3. 处理自动解引用(Deref)链——如果直接类型没有实现,尝试解引用后的类型
  4. 处理 trait 约束传播——如果在泛型上下文中,可能需要推迟到单态化时再解析

方法解析与自动解引用

当你写 foo.bar() 时,编译器会沿着自动解引用链(T&T&mut TDeref::Target)逐层查找固有方法和 trait 方法。这解释了为什么 String 可以直接调用 &str 的方法——因为 String: Deref<Target = str>

类型检查的输出

类型检查完成后,每个 HIR 表达式节点都被标记了它的具体类型。这些类型信息存储在 TypeckResults 中,通过查询系统按需获取。从这一步开始,编译器拥有了完整的类型信息,后续所有阶段都可以通过 TyCtxt(类型上下文)查询任何表达式的类型。

这一阶段的检查也包括在 run_required_analyses 中注册的全量类型分析:

// 来自 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)。

// 源码
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 的关键区别

特性HIRMIR
结构树形(嵌套表达式)控制流图(基本块 + 跳转)
变量有名称匿名编号(_0, _1, ...)
表达式可嵌套完全展平,中间结果存入临时变量
控制流if/match/loopswitchInt/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)依次执行以下检查:

  1. unsafe 检查check_unsafety):验证 unsafe 块的正确使用
  2. 借用检查mir_borrowck):核心的 NLL 借用检查
  3. transmute 检查check_transmutes):验证类型转换的安全性
  4. FFI unwind 检查has_ffi_unwind_calls):检测跨 FFI 边界的 unwind
  5. 活跃性分析check_liveness):检测未使用的变量
  6. 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 负责。

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 函数:

// 源码
fn add(a: i32, b: i32) -> i32 {
    a + b
}
; 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 上的 ldlld,macOS 上的 ld64),但可以通过 -C linker=... 指定替代链接器。使用 lld 通常能显著加速链接阶段。

1.13 查询系统:按需驱动,而非流水线驱动

到目前为止,我们一直在用"管线"(pipeline)的隐喻来描述编译过程。但事实上,Rust 编译器内部并不是一条线性流水线。它使用的是一个基于查询的按需驱动系统(Demand-Driven Query System)。

什么是查询系统

传统编译器是流水线式的——先执行所有的解析,再执行所有的类型检查,再执行所有的代码生成。每个阶段必须完整处理完所有代码,才能进入下一阶段。

Rust 编译器的查询系统则不同。它将编译器的每个计算步骤定义为一个"查询"(Query),每个查询有一个输入(key)和一个输出(value)。查询之间形成依赖关系——当一个查询需要另一个查询的结果时,它会"拉取"(pull)那个查询的执行。

graph TB
    A["codegen_crate"] --> B["collect_and_partition_mono_items"]
    B --> C["optimized_mir(fn_def_id)"]
    C --> D["mir_borrowck(fn_def_id)"]
    D --> E["mir_built(fn_def_id)"]
    E --> F["typeck(fn_def_id)"]
    F --> G["hir_crate"]
    G --> H["resolver_for_lowering"]
    H --> I["crate_for_resolver"]

    C --> J["type_of(def_id)"]
    F --> K["adt_def(struct_id)"]
    F --> L["impl_trait_ref(impl_id)"]

    style A fill:#ef4444,color:#fff,stroke:none
    style B fill:#f59e0b,color:#fff,stroke:none
    style C fill:#10b981,color:#fff,stroke:none
    style D fill:#10b981,color:#fff,stroke:none
    style E fill:#0ea5e9,color:#fff,stroke:none
    style F fill:#3b82f6,color:#fff,stroke:none
    style G fill:#4f46e5,color:#fff,stroke:none
    style H fill:#6366f1,color:#fff,stroke:none
    style I fill:#7c3aed,color:#fff,stroke:none
    style J fill:#3b82f6,color:#fff,stroke:none
    style K fill:#3b82f6,color:#fff,stroke:none
    style L fill:#3b82f6,color:#fff,stroke:none

查询系统的注册

DEFAULT_QUERY_PROVIDERS 中,编译器注册了所有的查询提供者。每个 crate 通过 provide 函数注册自己提供的查询——rustc_borrowck::provide 注册借用检查查询,rustc_hir_typeck::provide 注册类型检查查询,rustc_monomorphize::provide 注册单态化查询,等等。总共有数十个 crate 注册了数百个查询。

当运行时某个查询被首次请求时,对应的 provider 函数才会被调用。

查询缓存与增量编译

查询系统的另一个关键特性是缓存。每个查询的结果都被自动缓存——如果同一个查询被多次请求,只有第一次会实际计算,后续请求直接返回缓存的结果。

这个缓存机制是增量编译的基础。当源码发生变化时,编译器可以:

  1. 检测哪些查询的输入(依赖)发生了变化
  2. 只重新计算那些受影响的查询
  3. 复用所有未受影响的查询结果

例如,如果你只修改了一个函数的实现,那么:

  • 该函数的 typeckmir_borrowckoptimized_mir 需要重新计算
  • 其他函数的查询结果可以复用
  • 如果该函数的签名没变,依赖它的其他函数可能也不需要重新检查

全局上下文 TyCtxt

所有查询都通过 TyCtxt(Type Context)访问。TyCtxt<'tcx> 是编译器最核心的数据结构,几乎所有编译阶段都需要它。它在 create_and_enter_global_ctxt 函数中被创建,所有编译工作都在其闭包内通过 tcx 驱动。

TyCtxt 使用了 Rust 的生命周期系统来保证安全性——'tcx 生命周期确保所有通过 TyCtxt 获取的数据都在编译器的 arena 中存活。这是一个精妙的设计:gcx_cellarenahir_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 优化和编译。这是编译中最耗时的部分,也是并行化收益最大的地方。

graph LR
    subgraph "分析阶段(共享 TyCtxt)"
        A["类型检查<br/>并行 per-function"] --> B["借用检查<br/>并行 per-function"]
    end

    subgraph "单态化"
        B --> C["收集所有<br/>mono items"]
        C --> D["分区为<br/>Codegen Units"]
    end

    subgraph "代码生成(并行 per-CGU)"
        D --> E1["CGU 1<br/>MIR → LLVM IR"]
        D --> E2["CGU 2<br/>MIR → LLVM IR"]
        D --> E3["CGU 3<br/>MIR → LLVM IR"]
        D --> E4["CGU N<br/>MIR → LLVM IR"]
    end

    subgraph "LLVM 后端(并行 per-CGU)"
        E1 --> F1["LLVM 优化<br/>+ 目标文件 1"]
        E2 --> F2["LLVM 优化<br/>+ 目标文件 2"]
        E3 --> F3["LLVM 优化<br/>+ 目标文件 3"]
        E4 --> F4["LLVM 优化<br/>+ 目标文件 N"]
    end

    subgraph "链接"
        F1 --> G["链接器<br/>生成最终二进制"]
        F2 --> G
        F3 --> G
        F4 --> G
    end

    style A fill:#3b82f6,color:#fff,stroke:none
    style B fill:#10b981,color:#fff,stroke:none
    style C fill:#f59e0b,color:#fff,stroke:none
    style D fill:#f59e0b,color:#fff,stroke:none
    style E1 fill:#ef4444,color:#fff,stroke:none
    style E2 fill:#ef4444,color:#fff,stroke:none
    style E3 fill:#ef4444,color:#fff,stroke:none
    style E4 fill:#ef4444,color:#fff,stroke:none
    style F1 fill:#dc2626,color:#fff,stroke:none
    style F2 fill:#dc2626,color:#fff,stroke:none
    style F3 fill:#dc2626,color:#fff,stroke:none
    style F4 fill:#dc2626,color:#fff,stroke:none
    style G fill:#9333ea,color:#fff,stroke:none

并行编译的限制

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 跟踪一个真实程序走完编译器的每一站

让我们跟踪一个具体的程序,看它在编译器的每一站变成了什么样子:

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_expandprintln! 展开为实际的代码。展开后的 AST 大致等价于:

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 调用 _printbb2 返回;bb3 是 unwind 清理块。每步操作都是一条语句,函数调用变成了终结器(可能跳转到返回基本块或 unwind 清理块)。你可以用 cargo +nightly rustc -- -Zunpretty=mir 亲眼看到完整的 MIR。

第7站:借用检查与 MIR 优化

借用检查器分析 MIR 的控制流图。在这个简单例子中,没有借用冲突。

MIR 优化器随后简化代码——可能内联 Arguments::new_const,消除临时变量等。

第8站:单态化收集

收集器从 main 出发,确定需要生成代码的所有函数实例:

  • main
  • std::io::_print(已经是具体类型,不需要单态化)
  • core::fmt::Arguments::new_const
  • _print 内部调用的所有函数...

第9站:LLVM IR 生成

每个函数实例被翻译为 LLVM IR。LLVM 执行优化后生成目标平台的机器码。

第10站:链接

链接器将 main.o 和标准库链接在一起,生成最终的可执行文件。执行它:

$ ./target/debug/example
hello

1.17 动手验证:查看每个阶段的输出

你不需要相信本书的任何结论——你可以自己看。以下是查看每个阶段输出的命令:

# 查看宏展开后的代码(接近 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 的核心语言特性对应起来,就得到了本书的全景地图:

graph TB
    subgraph "前端 Frontend"
        A["源码"] --> B["Token → AST"]
        B --> C["名称解析 + 宏展开"]
        C --> D["AST → HIR"]
        D --> E["类型检查"]
    end

    subgraph "中端 Middle-end"
        E --> F["HIR → MIR"]
        F --> G["借用检查 + MIR 优化"]
    end

    subgraph "后端 Backend"
        G --> H["单态化收集"]
        H --> I["MIR → LLVM IR"]
        I --> J["LLVM 优化 → 机器码"]
        J --> K["链接"]
    end

    M1["宏系统<br/>第14章"] -.-> C
    M2["类型系统 · Trait 解析<br/>第6-8章"] -.-> E
    M3["所有权 · 借用检查 · NLL<br/>第2-5章"] -.-> G
    M4["MIR 优化 Pass<br/>第15章"] -.-> G
    M5["Async/Await 状态机<br/>第9-10章"] -.-> F
    M6["泛型与单态化<br/>第6章"] -.-> H
    M7["LLVM 代码生成<br/>第16章"] -.-> I
    M8["增量编译 · 查询系统<br/>第17章"] -.-> K

    style A fill:#64748b,color:#fff,stroke:none
    style B fill:#8b5cf6,color:#fff,stroke:none
    style C fill:#6366f1,color:#fff,stroke:none
    style D fill:#4f46e5,color:#fff,stroke:none
    style E fill:#3b82f6,color:#fff,stroke:none
    style F fill:#0ea5e9,color:#fff,stroke:none
    style G fill:#10b981,color:#fff,stroke:none
    style H fill:#f59e0b,color:#fff,stroke:none
    style I fill:#ef4444,color:#fff,stroke:none
    style J fill:#dc2626,color:#fff,stroke:none
    style K fill:#9333ea,color:#fff,stroke:none

从下一章开始,我们将沿着这张地图,逐个区域深入。先从 Rust 最核心的创新开始——所有权系统在编译器中的实现。

:::info 本章回顾 本章建立了 Rust 编译器的全景地图:

  1. 词法分析rustc_lexer)将源码切分为 Token,零依赖的纯函数设计
  2. 语法分析rustc_parse)构建 AST,手写递归下降,支持错误恢复
  3. 名称解析与宏展开rustc_resolve + rustc_expand)交替进行,消除所有宏调用
  4. AST → HIR 降级rustc_ast_lowering)脱糖,消除语法糖
  5. 类型检查rustc_hir_typeck + rustc_hir_analysis)Hindley-Milner 风格推导,trait 解析
  6. HIR → MIR 降级rustc_mir_build)构建控制流图
  7. 借用检查rustc_borrowck)在 MIR 上执行 NLL 算法
  8. MIR 优化rustc_mir_transform)常量传播、内联、死代码消除等
  9. 单态化收集rustc_monomorphize)确定所有需要生成代码的泛型实例,分配到 Codegen Unit
  10. 代码生成rustc_codegen_ssa + rustc_codegen_llvm)MIR → LLVM IR → 机器码
  11. 链接:系统链接器拼装最终二进制

整个系统由查询系统驱动,支持增量编译和并行执行。TyCtxt 是贯穿所有阶段的核心数据结构。 :::