第 22 章 Rust 编译器与语言设计
19 分钟阅读
Chapter 22 Rust 编译器与语言设计
你有没有想过,当你
cargo build的时候,背后究竟发生了什么?Cargo 悠悠地打印出 “Compiling…",然后 Rust 编译器(rustc)就像一台精密的瑞士手表一样开始工作——词法分析、语法解析、类型检查、 borrowck、代码生成……每一个步骤都像交响乐团的乐手,各司其职,配合得天衣无缝。本章我们就来拆解这台"编译器交响乐团"的乐器组,聊聊 Rust 编译器架构、语言设计决策背后的哲学,以及你如何参与到这个伟大项目中去。
22.1 编译器架构
想象一下,rustc 不是一个单一的巨型怪物,而是一个由多个组件构成的工具链生态。从你敲下 rustc main.rs 的那一刻起,代码就开始了一场跨越多个阶段(phase)的奇幻漂流。
22.1.1 编译工作流程
Rust 编译器的"长征"大致可以分为以下几个阶段:
flowchart LR
A["源代码\nmain.rs"] --> B["词法分析\nLexer"]
B --> C["语法解析\nParser"]
C --> D["AST\n抽象语法树"]
D --> E["名称解析\nName Resolution"]
E --> F["类型检查\nType Check"]
F --> G["HIR\n高级中间表示"]
G --> H["MIR\n中级中间表示"]
H --> I["LLVM\n代码生成"]
I --> J["目标文件\n.o / 可执行文件"]下面我们逐一拆解每个阶段——别担心,不会枯燥,我保证至少每节有一个段子。
1. 词法分析(Lexing)——文字的"拆快递"阶段
词法分析器(lexer)就像一个拆快递的机器人,它把一整行源代码这个"大包裹"拆成一个个词素(token)。例如:
| |
lexer 会把它拆成:
| |
有趣的是,Rust 的 lexer 还有一个隐藏天赋:它会顺手检测到"图方便"写出的
!=和==,并在心里默默记下——这个人类可能想做什么。需要注意的是,lexer 并不知道i32是个"类型”——它只是把它当作一个标识符(IDENT)来处理。类型注解的身份是 Parser 在后续阶段才赋予它的。
2. 语法解析(Parsing)——把 token 排列成"语法树"
Parser 接收词素序列,将它们组装成一棵 AST(抽象语法树)。这时候编译器就开始检查:“嗯,let 后面应该跟一个模式(pattern),然后是 : 类型注解……”
如果你的代码有语法错误,Parser 会以那种"我早就知道你会这样写"的语气报错:
| |
3. 名称解析与宏展开
名称解析器负责把所有的标识符(identifier)和它们"真正定义的地方"对上号。宏在这个阶段被展开——所以你写的 println!("Hello") 实际上在解析时已经变成了一大堆你从未见过的 AST 节点。
宏展开就像魔术师的揭秘时刻:你以为你只写了一行代码,结果编译器给你变出了一整个图书馆。
4. 类型检查——Rust 类型系统的"安检门"
这是 Rust 最引以为傲的部分之一。类型检查器会验证每个表达式的类型是否兼容,每个函数调用的参数是否匹配,每个 trait 约束是否满足。
类型检查主要基于 AST(抽象语法树)进行——解析器把 token 组织成 AST 后,类型检查器就在 AST 上施展它的魔法。但 AST 保留了太多源代码的"语法糖"(比如 for 循环、? 操作符),不方便直接做复杂分析。于是 AST 会被"降级"成 HIR(High-level Intermediate Representation,高级中间表示),去掉那些语法装饰,让后续的借用检查、优化等阶段能更干净地处理 Rust 语义。
5. MIR 生成与 Borrowck
MIR 是 Rust 编译过程中的"中转站",我们后面会专门讲它。简单说,MIR 把复杂的 Rust 语义简化为一种控制流图(Control Flow Graph, CFG),让 borrowck 和优化阶段能够更方便地"走查"代码逻辑。
6. LLVM 代码生成——最后的"翻译官"
最终,Rust 的"内心世界"需要被翻译成机器能理解的"外语"。Rust 本身并不直接生成机器码,而是委托 LLVM 来完成这项工作。LLVM 拿到 Rust 的中间表示后,会做大量优化,最终吐出目标文件(.o)或可执行文件。
22.1.2 rustc 驱动
rustc 不仅仅是一个编译器——它是一个驱动(driver),负责协调上述所有阶段,并根据不同的命令行参数和目标平台来配置编译行为。
rustc 的命令行参数体系
| |
--crate-type可能是你见过最灵活的参数之一:它可以是bin(可执行文件)、lib(库)、rlib(Rust 静态库)、dylib(动态库)、甚至proc-macro(过程宏)。
编译器驱动的工作流程
rustc 驱动的工作流程大致如下:
flowchart TD
A["rustc 入口\nmain.rs"] --> B["命令行解析\n& 配置加载"]
B --> C["编译会话\nCompilerInvocation"]
C --> D{"编译模式?"}
D -->|incremental| E["增量编译\n缓存管理"]
D -->|full| F["全量编译\n从头开始"]
E --> G["执行各个编译阶段"]
F --> G
G --> H["链接\n(Linker)"]
H --> I["产出 artefact\n可执行文件/库"]LTO:链接时优化
rustc 还支持一种叫 LTO(Link-Time Optimization,链接时优化) 的高级特性。传统编译是"各自为营"——每个编译单元独立优化,不知道其他单元的存在。LTO 则打破了这个壁垒,让链接器在最后关头还能做全局优化。
| |
打个比方:普通编译就像每个厨师各自做菜,不知道其他人做什么;LTO 则是在上菜前让所有厨师再碰个头,统一调整口味,菜与菜之间更加和谐。
22.2 编译器内部
如果说 22.1 是"编译器外部看到的风景",那本节就是"深入 compiler-construction 地下室的探险"。我们要聊的是几个 Rust 编译器内部的核心组件:Chalk、Polonius、MIR、Borrowck 和 LLVM。
别被这些名字吓到——它们每一个都是为了解决一个具体问题而诞生的。Rust 能有今天的安全保证,全靠这些"幕后英雄"。
22.2.1 Chalk 逻辑编程引擎
Chalk 是一个基于逻辑编程(Logic Programming)的 trait 系统求解器。它的名字来自希腊北部地区 Chalkidiki(哈尔基季基)——没错,就是亚里士多德的出生地。虽然你可能觉得一个用 Prolog 风格逻辑推理的求解器和一个希腊半岛之间没什么关联,但 Rust 团队给项目起名字的品味就是这么文艺。
Chalk 解决的是什么问题?
Rust 的 trait 系统允许非常复杂的约束,比如:
| |
Chalk 的工作是:给定这些 trait 约束,自动推导出 T 需要满足的所有条件,并且检测是否存在矛盾(比如 T: Foo + !Foo)。
Chalk 的工作原理
Chalk 把 Rust 的 trait 约束问题转化为一组逻辑规则,然后用 SLG(SLD-NAF with Negation as Failure)算法来求解。
flowchart LR
A["Rust trait 约束"] --> B["Chalk: 转换为逻辑规则"]
B --> C["Prolog-style 求解"]
C --> D["SLG 算法\n(SLD-NAF)"]
D --> E["统一与回溯"]
E --> F["Trait 解决方案"]简单示例:
| |
在编译器中使用 Chalk
Chalk 最初是一个独立的项目,后来被集成到了 rustc 中作为 trait solving engine。当你写了一个泛型函数,rustc 内部就会调用 Chalk 来求解你需要实现的 trait 约束。
想象一下,Chalk 就像编译器里的"福尔摩斯"——它根据蛛丝马迹(trait bounds)推理出你到底需要什么类型。
22.2.2 Polonius 所有权检查器
Polonius 是 rustc 中一个实验性的 借款检查器(borrow checker),它的名字来自莎士比亚《哈姆雷特》中的老臣 Polonius(波洛尼厄斯)——那句著名的"既不要借债,也不要放债"(Neither a borrower nor a lender be)正是这位老臣的家训。这个名字对借款检查器来说简直是天作之合:Polonius 就是来管"借"和"贷"的。
传统的 borrow checker 在做分析时,只考虑"当前时间点"的状态。但 Polonius 采用了 基于生命周期区域的分析(liveness-based analysis),可以更准确地处理一些复杂的借用模式。
传统 borrowck 的"痛点"
传统 borrowck 基于**词法作用域(lexical scope)**做分析——借用"活多久"取决于它写在哪个代码块里。这种方式简单但粗暴,会误杀很多实际上安全的代码。典型场景包括:在函数内部持有引用的数据结构、嵌套的条件分支中返回引用等。borrowck 会说"这个借用跟那个借用撞了",但实际上两者在运行时根本不会重叠。
Polonius 的解决方案
Polonius 会把借用拆分成多个区域(regions),并跟踪每个区域中哪些借用是活跃的。这样它就能更聪明地判断"这个引用在此时此地是否还活着"——而不是"这个引用在代码的哪个大括号里"。
flowchart LR
A["代码中的借用"] --> B["Polonius: 分配生命周期区域"]
B --> C["活跃性分析\n(Liveness Analysis)"]
C --> D["区域推理\nRegion Inference"]
D --> E["更精确的借用检查"]启用 Polonius
Polonius 目前还在实验阶段,可以通过 nightly Rust 开启:
| |
Polonius 的目标不是替代现有的 borrow checker,而是成为一个"更聪明的备选方案"。未来如果它足够稳定,可能会成为默认的 borrow checker。
22.2.3 MIR(中级中间表示)
MIR 是 Rust 编译器的"中转站语言"——它在 HIR(高级中间表示)之后、LLVM 代码生成之前存在。MIR 的全称是 Mid-level Intermediate Representation,它的设计目标是让 Rust 的各种分析和优化更容易实现。
MIR 的设计动机
HIR 太接近 Rust 源代码,很多优化逻辑难以在上面直接实现。LLVM 的输入又太低层,不适合做 Rust 特有的语义分析。MIR 就在这两者之间找到了一个完美的"甜蜜点"。
MIR 的核心概念
MIR 的核心是控制流图(Control Flow Graph, CFG)。每个函数被分解成一系列基本块(Basic Blocks),块与块之间通过**控制流边(edges)**连接。
flowchart TD
A["BasicBlock 0\n函数入口"] --> B{"条件判断"}
B -->|true| C["BasicBlock 1\nthen 分支"]
B -->|false| D["BasicBlock 2\nelse 分支"]
C --> E["BasicBlock 3\n汇合点"]
D --> E
E --> F["BasicBlock 4\n函数出口"]一个实际的 MIR 例子
给一个简单的 Rust 函数:
| |
它的 MIR 大致如下(简化版):
// bb0 是入口基本块
bb0: {
_0 = Add(a, b); // _0 是返回值位置,这里直接对参数做加法
return = bb0; // 控制流终止,将 _0 作为返回值传出
}
严格来说,
copy(a)和copy(b)是 MIR 对"可 Copy 值"的语义标记,表示这里发生了一次隐式复制,但实际的 MIR 语句中通常直接写操作数,不显式调用copy()函数。
MIR 能做什么?
MIR 是很多 Rust 高级特性的基础设施:
- Borrowck:用 MIR 做活跃性分析,判断借用是否安全
- Const eval:编译时求值(
const函数)基于 MIR - Unwinding 和 panic 处理:MIR 记录了每个基本块的终止方式(正常返回 / panic),让 rustc 能正确处理栈展开
- 优化:基于 MIR 的死代码消除、常量传播等
你可以把 MIR 想象成编译器的"通用语言"——不同阶段、不同优化 pass 都围绕着 MIR 展开,就像机场的行李转盘,每个航班都要经过它。
22.2.4 Borrowck 原理
Borrowck(借用检查器)是 Rust 内存安全保证的核心防线。它的任务是:确保所有的借用(borrow)都符合 Rust 的借用规则,且没有数据竞争(data race)和悬垂引用(dangling reference)。
Rust 的借用规则(两条铁律)
- 规则一:任意时刻,你可以拥有任意数量的不可变借用(
&T),或者恰好一个可变借用(&mut T),但不能同时拥有可变借用和任何不可变借用。 - 规则二:所有借用都必须"活得足够长"——不能超过它所引用的值的生命周期。
Borrowck 的工作原理
Borrowck 在 MIR 上进行数据流分析(Dataflow Analysis)。它追踪每个引用的"活跃区间"(liveness),并确保:
&mut借用开始时,之前的&借用都已结束&借用开始时,不存在活跃的&mut借用- 引用不会比它指向的值活得更久
生命周期标注——Borrowck 的"提示器"
当 Rust 的自动生命周期推断不够用时,你需要手动标注生命周期参数:
| |
'a 读作"生命周期 ‘a’",它告诉编译器:“返回的引用的生命周期不会超过输入参数中较短的那个”。
NLL(Non-Lexical Lifetimes):Borrowck 的进化
早期的 Rust borrowck 基于词法作用域——只要在代码块结束前引用还没被使用就算合法。但这导致很多实际上安全的代码被拒绝。
NLL 是对传统 borrowck 的重大改进,它引入了非词法生命周期,只关注引用真正被使用的区域,而不是整个作用域。
| |
NLL 使得很多"看起来有借用冲突但实际上安全"的代码得以编译通过。
22.2.5 LLVM 优化
LLVM 是 Rust 编译工具链的"最后一棒运动员"——它接收 Rust 编译器生成的中间表示(IR),然后在目标机器上生成高度优化的机器码。
Rust + LLVM 的关系
Rust 并不是简单地把代码扔给 LLVM 就完事了。它和 LLVM 之间有一个复杂的"协商过程":
flowchart LR
A["Rust 源代码"] --> B["rustc\n前端"]
B --> C["MIR"]
C --> D["LLVM IR\n(.ll 文件)"]
D --> E["LLVM Passes\n优化"]
E --> F["目标代码\n(.s 汇编)"]
F --> G["机器码\n(二进制)"]常见的 LLVM 优化 Pass
1. 常量折叠(Constant Folding)
| |
2. 内联(Inlining)
| |
3. 向量化(SIMD)
| |
4. 死代码消除(Dead Code Elimination)
| |
Cranelift:未来的替代后端
虽然 LLVM 是目前 Rust 的默认后端,但 Rust 团队也在探索其他选择。Cranelift 是一个更轻量的代码生成器,编译速度比 LLVM 快得多(有时候快 5-10 倍),但生成的代码质量稍逊。
| |
Cranelift 的目标是成为 Rust 的"默认开发后端"——你
cargo build时用 Cranelift 飞快,等发布时再用 LLVM 优化到极致。
22.3 贡献与语言演进
Rust 不是凭空产生的——它是一个由全球数千名贡献者共同雕琢的艺术品。Rust 的语言演进过程以其透明度和社区驱动而著称。任何人都可以提出改进建议,但改变一门语言可不是闹着玩的,所以 Rust 有一套严谨的流程来确保每个决策都经过深思熟虑。
如果说 Rust 编译器是"用代码写成的哲学",那么 Rust 的社区治理就是"用共识写成的宪法"。
22.3.1 RFC 流程
RFC 的全称是 “Request for Comments”(请求评论)。这是 Rust 语言所有重大变更的起点——不管你是想加一个新语法、改一个标准库 API,还是调整编译器的行为,都必须走 RFC 流程。
RFC 的生命周期
flowchart TD
A["起草 RFC\n(in the RFC repo)"] --> B["团队初审\n(Shepherd)"]
B --> C{"审核通过?"}
C -->|No| D["关闭或重新起草"]
C -->|Yes| E["公开讨论期\n(约 60 天)"]
E --> F["最终决策\n(Accept / Close)"]
F --> G["实现\n(Implementation)"]
F --> H[" stabilization\n(稳定化)"]如何撰写 RFC
RFC 文件存放在 rust-lang/rfcs 仓库中。每篇 RFC 都有固定格式:
| |
写 RFC 就像写论文——你得先说服自己,再说服别人。切忌"我觉得这样好,大家照做就行"的态度。社区的 peer review 是 RFC 流程中最有价值的部分。
RFC 的实践案例
比如 Rust 1.55 版本引入的"范围模式"(Range patterns)就是一个 RFC 产物:
| |
22.3.2 Rust Forge
Rust Forge(https://forge.rust-lang.org/)是 Rust 生态的"官方维基百科",专门收录 Rust 项目的元信息、贡献指南、决策记录和未来计划。
Forge 上有哪些宝藏?
1. 编译器的开发指南
| |
2. 追踪各个子团队的进展
Rust 编译器由多个子团队(subteam) 负责:
| 子团队 | 职责 |
|---|---|
| @rust-lang/compiler | 编译器核心(borrowck、miri、 MIR、 LLVM 接口) |
| @rust-lang/lang | 语言设计和 RFC 流程 |
| @rust-lang/library | 标准库和核心 traits |
| @rust-lang/tools | Cargo、rustfmt、clippy 等工具 |
| @rust-lang/release | 编译器发布流程 |
3. 找到"适合新人"的 Issues
Forge 有一个专门的页面列出 good first issues(适合新手的第一个贡献):
“别害怕从小的文档修改或测试用例开始。编译器贡献不一定非要懂 LLVM——有时候修一个拼写错误也能让你成为 Rust 贡献者!”
编译 rustc 并运行测试
| |
编译 rustc 可能会花很长时间——第一次完整编译可能需要一个小时以上。建议先从单个组件的测试开始,而不是每次都跑全量测试。
./x.py是你最好的朋友,敲./x.py --help可以看到所有可用选项。
22.3.3 参与社区
Rust 的社区可能是编程语言世界中最热情、最友善的社区之一。无论你是想提问、分享经验,还是为 Rust 本身做贡献,都能找到属于自己的位置。
几个主要的社区渠道
| 渠道 | 用途 |
|---|---|
| users.rust-lang.org | 用户论坛,提问和讨论 |
| internals.rust-lang.org | 编译器开发者的内部讨论 |
| Discord / Zulip | 实时讨论,氛围轻松 |
| GitHub | 代码贡献、Bug 报告、Issue 讨论 |
| RustConf | 年度技术大会 |
贡献代码的第一步建议
1. 从文档开始
| |
2. 修复一个 Easy Bug
| |
3. 参与 RFC 讨论
不写代码也可以为 Rust 做贡献!阅读正在讨论中的 RFC,用你的经验和视角给出反馈。很多 RFC 的最终形态都因为社区讨论而得到了改进。
贡献者许可证协议(CLA)
在提交第一个 PR 之前,你需要签署 Rust 贡献者许可证协议(Contributor License Agreement, CLA)。这个协议确保你的代码贡献归 Rust 项目所有,并允许项目以 Apache 2.0 / MIT 双重许可证发布。
签署过程是自动化的——当你第一次提交 PR 时,CLA 机器人会引导你完成签署。
保持耐心和谦逊
编译器开发有其特殊性——有时候一个 PR 要花数月才能合并,因为需要考虑向后兼容性、性能影响、与其他特性的交互等。收到 review 意见时,把它们当作学习机会,而不是批评。
Rust 的 motto 是 “Fearless Concurrency”(无畏并发)。但在贡献 Rust 本身时,你可能需要另一个 motto:“Patient Collaboration”(耐心协作)——两者同样重要。
本章小结
本章我们从宏观到微观地探索了 Rust 编译器的内部世界:
编译器架构:从词法分析到 LLVM 代码生成,rustc 经历了多个精心设计的阶段。Cargo 作为构建工具协调这一切,而 rustc 驱动则处理各种编译参数和模式。
编译器内部组件:Chalk 用逻辑编程的思维解决 trait 约束问题;Polonius 用活跃性分析重新定义了借用检查的可能性;MIR 作为中间语言连接了 Rust 语义和 LLVM 优化;Borrowck 通过数据流分析守护着 Rust 的内存安全承诺;LLVM 则在最后一步将一切翻译成高效的机器码。
参与 Rust 演进:RFC 流程确保了语言变更的透明和民主;Rust Forge 是贡献者的知识宝库;社区渠道则为所有人提供了参与的可能——不论你是想修 Bug、写文档,还是参与语言设计讨论。
Rust 编译器的复杂性是其能力的体现,而它的开放性则让每一个使用者都有可能成为塑造者。下一章我们将探讨 Rust 在系统编程领域的实际应用,敬请期待!