Rustc Dev Guide 中文翻译
Rust编译器开发指南(Rustc Dev Guide) 的中文翻译已经启动。因为原项目还在变动期,为了翻译方便,所以此翻译项目组织结构就不和原项目保持一致了。
志愿者招募要求:
- 热爱 Rust,对 Rust 已经有一定了解
- 想深入了解 Rust 编译器
- 想为 Rust 编译器做贡献
- 业余时间充足
如何参与
- 认领感兴趣到章节
- 找到对应到 markdown 文件
- 直接发 PR
- 或者帮忙审校别人的 PR
Q & A:
-
如何避免每个人翻译上的冲突呢,需要提前pr说翻译哪一章节吗?
其实没必要怕冲突,对于参与翻译的来说,翻译本身也是一次学习过程,是有收获的。了解编译器工作原理对理解 Rust 概念也有帮助的。如果同一篇有多个翻译,那我这边选翻译更好的就可以了。
这个项目倡导参与者自组织,但为了更加方便大家协作,还是来设置一个规则避免大家冲突。为了大家认领方便,特别创建了认领打卡的 issues,都去这里打一下卡:【翻译认领】避免翻译冲突,来此打卡。
如果你想发一个自己专属的「认领issue」也没问题,可以给该issue打上「已认领」标签。开一个独立的issue好处是可以有一个专属的地方讨论你翻译章节内容里的各种问题。
-
为什么要翻译 《Rust 编译器开发指南》 ?
年初的时候,我立下一个五年的 Flag : 五年内要为 Rust 语言发 1000 个 PR。
然后社区里的朋友就帮我做了一个计算:五年 1000 个,那么每年 200 个,那么一天就得 0.5 个。也有朋友说,Rust 的 PR 每次 Review 周期都很长,就算你能一年提 200 个 PR,官方也不可能给你合并那么多。
这样的计算,确实很有道理。这个目标,确实很难完成。但其实这个 Flag 我并没有打算个人完成,而是想推动社区对 Rust 感兴趣对朋友一起完成。如果五年内,我能推动 1000 个人参与,那么每个人只提交一个 PR,那么这个 1000 个 PR 的 Flag 就轻松完成了。
所以,翻译 《Rust 编译器开发指南》就成了我完成这个 Flag 的第一步。希望大家踊跃参与。
关于
本指南旨在记录rustc(Rust编译器)的工作方式,并帮助新的开发者参与到rustc的开发中来。
本指南分为六个部分:
- 构建和调试
rustc
: 包含有关构建,调试,性能分析等方面的有用信息,无论您以何种方式进行贡献。 - 为
rustc
做贡献: 包含有关贡献代码的步骤,稳定功能等方面有用的信息,无论您以何种方式进行贡献。 - 编译器架构概要: 讨论编译器的高级架构和编译过程的各个阶段。
- 源码表示: 描述了获取用户的代码,并将其转换为编译器可以使用的各种形式的过程。
- 静态分析: 讨论编译器如何分析代码,从而能够检查代码的各种属性并告知编译过程的后续阶段(例如,类型检查)。
- 从MIR到二进制: 如何连接生成的可执行机器码。
- 附录: 在本指南的结尾提供了一些有关的参考信息,如词汇表、推荐书目等。
持续更新
请记住,这 rustc
是一种真正的生产质量管理工具,由大量的贡献者不断进行研究贡献。因此,它有相当一部分代码库变更和技术上的欠缺。此外,本指南中讨论的许多想法都是尚未完全实现的理想化设计。所有这些使本指南在所有方面都保持最新,这非常困难!
该指南本身当然也是开源的,可以在GitHub存储库中找到这些源(译者注: 这里的Github存储库为此文档的英文原文链接)。 如果您在指南中发现任何错误,请提出相关问题,甚至更好的是,打开带有更正的PR!
如果您想要为本指南(译者注: 指英文版)作出帮助,请参阅本指南中。 有关编写文档的相应小节.
“‘All conditioned things are impermanent’ — when one sees this with wisdom, one turns away from suffering.” The Dhammapada, verse 277
其他查找信息的网站
以下站点可能对你有所帮助:
- rustc API docs -- 编译器的rustdoc文档。
- Forge -- 包含有关Rust的补充文档。
- compiler-team -- rust编译器团队的主页,描述了开发的过程,活动工作组,团队日历等。
如何构建并运行编译器
编译器是使用 x.py
工具进行构建。需要安装Python才能运行它。在此之前,如果您打算修改 rustc
的代码,则需要调整编译器的配置。默认配置面向的是编译器用户而非开发人员。有关如何安装 Python 和其他依赖,请参阅下一章。
获取源代码
修改rustc
的第一步是 clone 其代码仓库:
git clone https://github.com/rust-lang/rust.git
cd rust
创建一个 config.toml
首先先将 config.toml.example
复制为 config.toml
:
cp config.toml.example config.toml
然后,您将需要打开这个文件并修改以下配置(根据需求不同可能也要修改其他的配置,例如llvm.ccache
):
[llvm]
# Indicates whether the LLVM assertions are enabled or not
assertions = true
[rust]
# Whether or not to leave debug! and trace! calls in the rust binary.
# Overrides the `debug-assertions` option, if defined.
#
# Defaults to rust.debug-assertions value
#
# If you see a message from `tracing` saying
# `max_level_info` is enabled and means logging won't be shown,
# set this value to `true`.
debug-logging = true
# Whether to always use incremental compilation when building rustc
incremental = true
如果您已经构建过了rustc
,那么您可能必须执行rm -rf build
才能使配置更改生效。
请注意,./x.py clean
不会导致重新构建LLVM。
因此,如果您的配置更改影响LLVM,则在重新构建之前,您将需要手动rm -rf build /
。
x.py
是什么?
x.py
是用于编排 rustc
代码仓库中的工具的脚本。 它可以构建文档,运行测试以及编译 rustc
的脚本。现在它替代了以前的makefile,是构建rustc
的首选方法。下面将会介绍使用x.py
来有效处理常见任务的不同方式。
注意本章将侧重于如何把 x.py
用起来,因此介绍的内容比较基础。如果您想了解有关 x.py
的更多信息,请阅读其README.md。 要了解有关引导过程以及为什么需要使用 x.py
的更多信息,请阅读这一章。
更方便地运行x.py
在 src/tools/x
中有一个 x.py
的二进制封装。它只是调用 x.py
,但是它可以直接在整个操作系统范围内安装并可以从任何子目录运行。 它还会查找并使用适当版本的 python
。
您可以使用 cargo install --path src/tools/x
安装它。
构建编译器
要完整构建编译器,请运行 ./x.py build
。这将构建包括 rustdoc
在内的 stage1 编译器,并根据您签出的源代码生成可用的编译器工具链。
请注意,构建将需要相对大量的存储空间。推荐预留 10 到 15 GB 以上的可用空间来构建编译器。
x.py
有很多选项,这些选项可以帮助你减少编译时间或者适应你对其他内容的修改:
Options:
-v, --verbose use verbose output (-vv for very verbose)
-i, --incremental use incremental compilation
--config FILE TOML configuration file for build
--build BUILD build target of the stage0 compiler
--host HOST host targets to build
--target TARGET target targets to build
--on-fail CMD command to run on failure
--stage N stage to build
--keep-stage N stage to keep without recompiling
--src DIR path to the root of the rust checkout
-j, --jobs JOBS number of jobs to run in parallel
-h, --help print this help message
如果你只是在 hacking 编译器,则通常构建stage 1编译器就足够了,但是对于最终测试和发布,则需要使用stage 2编译器。
./x.py check
可以快速构建 rust 编译器。 当您在执行某种“基于类型的重构”(例如重命名方法或更改某些函数的签名)时,它特别有用。
创建config.toml
之后,就可以运行x.py
了。 虽然 x.py
有很多选项,但让我们从本地构建 rust 的最佳“一键式”命令开始:
./x.py build -i library/std
看起来好像这只会构建std
,但事实并非如此。
该命令的实际作用如下:
- 使用 stage0 编译器构建
std
(增量构建) - 使用 stage0 编译器构建
rustc
(增量构建)- 产生的编译器即为 stage1 编译器
- 使用 stage1 编译器构建
std
(不能增量构建)
最终产品 (stage1编译器 + 使用该编译器构建的库)是构建其他 rust 程序所需要的(除非使用#![no_std]
或#![no_core]
)。
该命令自动启用 -i
选项,该选项启用增量编译。这会加快该过程的前两个步骤:如果您的修改比较小,我们应该能够使用您上一次编译的结果来更快地生成stage1编译器。
不幸的是,stage1 库的构建不能使用增量编译来加速。这是因为增量编译仅在连续运行同一编译器两次时才起作用。
由于我们每次都会构建一个 新的 stage1 编译器 ,旧的增量结果可能不适用。
因此您可能会发现构建 stage1 std
对您的工作效率来说是一个瓶颈 —— 但不要担心,这有一个(hacky的)解决方法。请参阅下面“推荐的工作流程”部分。
请注意,这整个命令只是为您提供完整 rustc 构建的一部分。完整的 rustc 构建(即 ./x.py build --stage 2 compiler/rustc
命令)还有几个步骤:
- 使用 stage1编译器构建 rustc。
- 此处生成的编译器为 stage2 编译器。
- 使用 stage2 编译器构建
std
。 - 使用 stage2 编译器构建
librustdoc
和其他内容。
构建特定组件
- 只构建 core 库
./x.py build library/core
- 只构建 core 库和
proc_macro
库
./x.py build library/core library/proc_macro
有时您可能只想测试您正在处理的部分是否可以编译。
使用这些命令,您可以在进行较为完整的构建之前进行测试。
如前所示,您还可以在命令的最后传递选项,例如 --stage
。
创建一个rustup工具链
成功构建rustc之后,您在构建目录中已经创建了一堆文件。为了实际运行生成的rustc
,我们建议创建两个rustup工具链。 第一个将运行stage1编译器(上面构建的结果)。第二个将执行stage2编译器(我们尚未构建这个编译器,但是您可能需要在某个时候构建它;例如,如果您想运行整个测试套件)。
rustup toolchain link stage1 build/<host-triple>/stage1
rustup toolchain link stage2 build/<host-triple>/stage2
<host-triple>
一般来说是以下三者之一:
- Linux:
x86_64-unknown-linux-gnu
- Mac:
x86_64-apple-darwin
- Windows:
x86_64-pc-windows-msvc
现在,您可以运行构建出的rustc
。 如果使用-vV
运行,则应该可以看到以-dev
结尾的版本号,表示从本地环境构建的版本:
$ rustc +stage1 -vV
rustc 1.48.0-dev
binary: rustc
commit-hash: unknown
commit-date: unknown
host: x86_64-unknown-linux-gnu
release: 1.48.0-dev
LLVM version: 11.0
其他 x.py
命令
这是其他一些有用的x.py
命令。其中一部分我们将在其他章节中详细介绍:
- 构建:
./x.py build --stage 1
– 使用stage 1 编译器构建所有东西,不止是std
./x.py build
– 构建 stage2 编译器
- 运行测试 (见 运行测试 章节):
-
./x.py test --stage 1 src/libstd
– 为libstd
运行#[test]
测试 -
./x.py test --stage 1 src/test/ui
– 运行ui
测试套件 -
./x.py test --stage 1 src/test/ui/const-generics
- 运行ui
测试套件下的const-generics/
子文件夹中的测试 -
./x.py test --stage 1 src/test/ui/const-generics/const-types.rs
- 运行
ui
测试组下的const-types.rs
中的测试
- 运行
-
清理构建文件夹
有时您可能会想要清理掉一切构建的产物并重新开始,一般情况下这么做并没有必要,如果你想要这么做的原因是 rustbuild
无法正确执行,你应该报告一个 bug 来告知我们什么出错了。
如果确实需要清理所有内容,则只需运行一个命令!
./x.py clean
rm -rf build
也能达到效果,但这也会导致接下来你要重新构建LLVM,即使在相对快的计算机上这也会花费比较长的时间。
Prerequisites
Suggested Workflows
Distribution artifacts
Documenting Compiler
Rustdoc 概述
Rustdoc 实际上直接使用了 rustc 的内部功能。 它与编译器和标准库一起存在于代码树中。 本章是关于它如何工作的。 有关Rustdoc功能及其使用方法的信息,请参见 Rustdoc book。 有关rustdoc如何工作的更多详细信息,请参见 [“Rustdoc 内部工作原理” 一章]。
[“Rustdoc 内部工作原理” 一章]:./rustdoc-internals.md
Rustdoc 完全在 librustdoc
crate 中实现。
它可以运行编译器来获取 crate 的内部表示(HIR),
以及查询项目类型的一些信息。
HIR 和 [查询] 在相应的章节中进行了讨论。
librustdoc
主要执行两个步骤来渲染一组文档:
- 将 AST “清理”为更适合于创建文档的形式(并且稍微更耐编译器中的“搅动”)。
- 使用此清理后的 AST 一次渲染一个 crate 的文档。
当然实际上并不仅限于此,这样描述简化了许多细节,但这只是一个高层次的概述。
(注意:librustdoc
是一个库 crate!
"rustdoc" 二进制文件是使用 src/tools/rustdoc
中的项目创建的。注意所有上述操作都是在 librustdoc
crate 的 lib.rs
中的 main
函数中执行的。)
Cheat sheet
-
使用
./x.py build
制作一个可以在其他项目上运行的 rustdoc。- 添加
library/test
之后才能使用rustdoc --test
。 - 如果您以前使用过
rustup toolchain link local /path/to/build/$TARGET/stage1
,则在执行上一个构建命令后,cargo +local doc
将可以正常工作。
- 添加
-
使用
./x.py doc --stage 1 library/std
来用这个 rustdoc 来生成标准库文档。- 生成的文档位于
build/$TARGET/doc/std
, 但这个生成出来的 bundle 期望你将其从doc
文件夹拷贝到一个 web 服务器上,以便首页和 CSS/JS 可以正常加载。
- 生成的文档位于
-
使用
x.py test src/test/rustdoc*
来用 stage1 rustdoc 运行测试。- 参见 [“Rustdoc 内部工作原理” 一章] 来了解更多和测试有关的信息。
-
大多数 HTML 打印代码位于
html/format.rs
和html/render.rs
中。 它主要由一堆fmt::Display
实现和补充函数构成。 -
上面实现了
Display
的类型是在clean/mod.rs
中定义的, 就在自定义Clean
trait 旁边,该 trait 用于将这些类型的对象从 rustc HIR 中提取出来。 -
使用 rustdoc 进行测试的代码在
test.rs
中。 -
Markdown 渲染器位于
html/markdown.rs
中,包括用于从给定的 Markdown 块中提取文档测试的功能。 -
rustdoc 输出 上的测试位于
src/test/rustdoc
中,由 rustbuild 的测试运行器和补充脚本src/etc/htmldocck.py
处理。 -
搜索索引生成的测试位于
src/test/rustdoc-js
中,是一系列 JavaScript 文件,用于对标准库搜索索引和预期结果的查询进行编码。
ctags
Adding a new target
编译器测试框架
Rust项目可以运行各种不同的测试,(它们)由构建系统(x.py test
)编排。测试编译器本身的主要测试工具是一个叫做compiletest的工具(位于src/tools/compiletest
目录)。本节简要介绍如何设置测试框架,然后将会详细介绍如何运行测试和如果添加新测试
Compiletest测试套件
compiletest测试位于src/test
的目录树中。您会在其中看见一系列的子目录(例如ui
,run-make
等)。每一个这样的目录都被称为测试套件-它们包含一组以不同模式运行的测试。
下列是一个对于测试套件及其含义的简要概述。在某些情况下,测试套件会连接到手册的各部分,以提供更多信息细节。
ui
-从编译和/或运行测试中检查正确的stdout/stderr的测试run-pass-valgrind
-应该与valgrind一起运行的测试pretty
-针对Rust的“打印美化器”进行测试,从AST生成有效的Rust代码debuginfo
-在gdb或lldb中运行并查找调试信息的测试codegen
-编译然后测试生成的LLVM代码,以确保我们预期的优化生效的测试。欲了解如何编写此类测试的信息,请参见LLVM docscodegen-units
-有关单态化和CGU分区的测试assembly
-与codegen
测试类似,但会验证程序集输出以确保LLVM目标后端可以处理提供的代码。mir-opt
-检查部分生成的MIR,以确保我们在正确构建事物并且正在进行我们期望的优化的测试。incremental
-针对增量编译的测试,检查当执行某些特定修改后,我们能否重用以前的编译结果run-make
-基本上只执行Makefile
的测试,非常的灵活但是编写起来也会非常麻烦。rustdoc
-针对rustdoc的测试,确保生成的文件中含有期望的文档内容。rustfix
-应用了 diagnostic suggestions和rustfix
crate的测试*-fulldeps
-与上述相同,单表示测试依赖除std
有以外的东西(因此必须构建这些东西)
其它测试
Rust构建系统可以处理其它的各种测试,包括:
- Tidy-这是一个自定义的工具,用于验证源代码风格和编码规范,例如拒绝长行。在[关于编码规范部分](../conventions.html#formatting)有更多的信息。
范例:./x.py test tidy
- 格式-Rustfmt与构建系统集成在一起,用以在整个编译器中实施统一的样式。在CI中,我们检查格式是否正确。 格式检查也可以通过上述Tidy工具自动运行。
- 单元测试-Rust标准库和许多Rust软件包都包含典型的Rust
#[test]
单元测试。在后台,x.py
将对每个软件包运行cargo test
来运行所有测试。
范例:./x.py test library/std
- 文档测试-嵌入在Rust文档中的示例代码是通过
rustdoc --test
执行的。例如:
./x.py test src/doc
-对所有在src/doc
中的运行rustdoc --test
。
./x.py test --doc library/std
-在标准库上运行rustdoc --test
。
- 链接检查-一个用于验证文档中的
href
链接的小工具。 - 分发检查-用于验证由构建系统创建的源代码分发压缩包的解压、构建和运行所有测试。
范例:./x.py test distcheck
-
工具测试-Rust随附的软件包也都可以正常运行(通常通过在目录中运行
cargo test
)。这包括诸如cargo,clippy,rustfmt,rls,miri,bootstrap(测试Rust构建系统本身)之类的东西。 -
Cargo测试- 这是一个小型的工具,它在一些重要项目(如
servo
,ripgrep
,tokei
等)上运行cargo test
,以确保没有他们没有任何显著回归。
范例:- ./x.py test src/tools/cargotest
测试基础架构
当GitHub上一个提交请求(Pull Request)被打开之后,GitHub Actions将自动启动一个构建,这个构建会在某些配置(x86_64-gnu-llvm-8 linux. x86_64-gnu-tools linux, mingw-check linux)下运行所有测试。本质上,在每个配置构建之后,它会运行./x.py test
。
集成机器人bors用于协调主分支的合并,当一个PR被批准后,它将会进入一个[队列],在这里将会使用GitHub Actions在一组广泛的平台上一个个地测试这些合并。由于并行作业数量的限制,除PR外,我们在rust-lang-ci组织下运行CI。大多数平台仅仅运行构建步骤,一些平台会运行一组受限的测试,只有一个子集可以运行全套的测试(参见 Rust的platform tiers)
使用Docker镜像进行测试
Rust树包含src/ci/docker
中GitHub Actions所使用的平台的Docker镜像定义。src/ci/docker/run.sh
被用于构建、运行Docker镜像,在镜像中构建Rust,然后运行测试。
您可以在本地开发计算机上运行这些映像。这对于测试与本地系统不同的环境可能会有所帮助。首先,您需要在Linux,Windows或macOS系统上安装Docker(通常Linux将比Windows或macOS快得多,因为稍后将使用虚拟机来模拟Linux环境)。想要在容器中启动bash shell进入交互模式,请运行src/ci/docker/run.sh --dev <IMAGE>
,其中<IMAGE>
是src/ci/docker
中目录名称之一(例如x86_64-gnu
是一个相当标准的Ubuntu环境)。
docker脚本将以只读模式挂载本地rust源树,以读写模式挂载obj
目录。所有的编译器工件都将被存储在obj
目录中。shell将会从obj
目录开始。从那里,您可以运行../src/ci/run.sh
,这将运行镜像定义的构建。
另外,您可以运行单个命令来执行特定的任务。例如,您可以运行python3 ../x.py test src/test/ui
来仅运行UI测试。请注意[src / ci / run.sh
]脚本中有一些配置可能需要重新创建。特别是,在您的config.toml
中设置submodules=false
,以便它不会尝试修改只读目录。
有关使用Docker镜像的一些其他说明:
-
一些std测试需要IPv6的支持。Linux上的Docker似乎默认禁用了它。在创建容器之前,运行
enable-docker-ipv6.sh
中的命令以启用IPv6。这仅需要执行一次。 -
当您退出shell之后,容器将自动删除,但是构建工件仍然保留在
obj
目录中。如果您在不同的Docker映像之间切换,则存储在obj
目录中的先前环境中的工件可能会混淆构建系统。有时候在容器内构建之前,您需要删除部分或全部obj
目录。 -
容器是一个只有最小数量的包的准系统,您可能需要安装
apt install less vim
之类的东西。 -
您可以在容器内打开多个shell。首先您需要知道容器的名字(一个简短的哈希),它显示在shell的提示符中,或者您可以在容器外部运行
docker container ls
列出可用的容器。使用容器名称运行docker exec -it <CONTAINER> /bin/bash
,其中<CONTAINER>
是例如4ba195e95cef
的容器名称。
在远程计算机上运行测试
测试可以在远程计算机上运行(例如:针对不同的架构测试构建)。这通过使用构建计算机上的remote-test-client
向remote-test-server
发送测试程序并在远程计算机上运行实现。remote-test-server
执行测试程序并且将结果返回给构建计算机。remote-test-server
提供未经身份验证的远程代码执行,所以在使用它的时候请务必小心。
为此,首先为远程计算机构建remote-test-server
,例如,用RISC-V
./x.py build src/tools/remote-test-server --target riscv64gc-unknown-linux-gnu
二进制文件将在./build/$HOST_ARCH/stage2-tools/$TARGET_ARCH/release/remote-test-server
被创建。将该文件复制到远程计算机。
在远程计算机上,运行带有remote
参数的remote-test-server
(以及可选的-v表示详细输出)。 输出应如下所示:
$ ./remote-test-server -v remote
starting test server
listening on 0.0.0.0:12345!
您可以通过连接到远程测试服务器并发送ping\n
来测试其是否正常工作。 它应该回复pong
:
$ nc $REMOTE_IP 12345
ping
pong
要使用远程运行程序运行测试,请设置TEST_DEVICE_ADDR
环境变量,然后照常使用x.py
。例如,要对IP地址为1.2.3.4
的RISC-V计算机运行ui
测试,请使用
export TEST_DEVICE_ADDR="1.2.3.4:12345"
./x.py test src/test/ui --target riscv64gc-unknown-linux-gnu
如果remote-test-server
是使用详细标志运行的,则测试计算机上的输出可能类似于
[...]
run "/tmp/work/test1007/a"
run "/tmp/work/test1008/a"
run "/tmp/work/test1009/a"
run "/tmp/work/test1010/a"
run "/tmp/work/test1011/a"
run "/tmp/work/test1012/a"
run "/tmp/work/test1013/a"
run "/tmp/work/test1014/a"
run "/tmp/work/test1015/a"
run "/tmp/work/test1016/a"
run "/tmp/work/test1017/a"
run "/tmp/work/test1018/a"
[...]
测试实在运行x.py
的计算机上构建的而不是在远程计算机上。意外构建错误的测试可能会失败,并且将无需在远程计算机上运行。
在模拟器上测试
某些平台已通过仿真器针对尚不可用的体系结构进行了测试。对于良好支持标准库和宿主系统支持TCP/IP网络的体系结构,请参见上述有关在远程计算机上测试的说明(在这种情况下将模拟远程计算机)
这是一组用于在仿真环境中协调运行测试的工具。设置了诸如 arm-android
和arm-unknown-linux-gnueabihf
之类的平台,以在GitHub Actions的仿真下自动运行测试。接下来我们将窥探一下如何在仿真下运行目标测试。
armhf-gnu的Docker镜像包含QEMU来模拟ARM CPU架构.Rust树中包含的工具remote-test-client和remote-test-server是将测试程序和库发送到仿真计算机,并在仿真计算机中运行测试并读取结果的程序。Docker被设置为启动remote-test-server
,并且用remote-test-server
来构建工具与服务器通信以协调正在运行的测试。(请参阅src/bootstrap/test.rs)
TODO: 是否支持使用IOS模拟器?
同时我也也不清楚wasm或asm.js测试如何运行
Crater
Crater是一个为crates.io中的每个测试进行编译和运行的工具。它主要用于当实施潜在的重要更改时,检查破坏的程度,并且通过运行beta和stable编译器版本来确保没有破坏。
何时运行Crater
如果您的PR对编译器造成了很大更改或者可能导致损坏,那么您应该运行crater。如果您不确定,请随时询问您的PR审阅者。
要求运行Crater
rust小组维护了一些机器,这些机器可以用来PR引入修改下运行crater。如果您的PR需要运行cater,请在PR线中为会审小组留下评论。请告知团队是否需要运行check-only
crater,运行build-only
crater或者运行build-and-test
crater。区别主要时间。保守选项(如果您不确定)是运行build-and-test。如果您的修改仅在编译时(例如,实现新trait)起作用,那么您只需要check run。
会审小组会将您的PR入队,并且在结果准备好时将结果发布。check run大约需要3~4天,其它两个平均需要5~6天。
尽管crater非常有用,但注意一些注意事项也很重要:
- 并非所有代码都在crates.io上! 也有很多代码在GitHub和其它地方的仓库中。此外,公司可能不希望发布其代码。因此,crater运行成功并不是万无一失的神奇绿灯。您仍然需要小心。
- Crater仅在x86_64上运行Linux构建。 因此,其它体系结构和平台没有测试。最重要的是,这包括Windows。
- 许多crate未经测试。许多crate未经测试。这可能有很多原因,包括crate不再编译(例如使用的旧的nightly特性),测试失败或不稳定,需要网络访问或其他原因。
- 在crater运行之前,必须先使用
@bors try
来成功构建工件。这意味着,如果您的代码无法编译,则无法运行crater。
性能运行
为了改善编译器的性能并防止性能下降,需要进行大量工作。“性能运行”用于比较大量流行crate在不同配置下编译器的性能。不同的配置包括“新构建”,带有增量编译的构建等。
性能运行的结果是两个版本的编译器之间的比较(通过它们的提交哈希(commit hash))。
如果您的PR可能会影响性能,尤其是可能对性能产生不利影响,则应请求进行性能测试。
进一步阅读
以下博客文章也可能会引起您的兴趣:
- brson的经典文章[“如何测试Rust”] howtest
运行测试
您可以使用x.py来运行测试。这是最基本的命令-您几乎永远不想使用它!–如下:
./x.py test
这将构建第1阶段的编译器,然后运行整个测试套件。 您可能不想经常执行此操作,因为这需要很长时间,并且无论如何bors/GitHub Actions都会为您执行此操作。(通常,在打开我认为已完成的PR后,我会在后台运行此命令,但很少这样做。-nmatsakis)
测试结果将被缓存,并且在测试过程中以前成功的测试将被忽略
。stdout/stderr内容以及每个测试的时间戳文件都可以在build/ARCH/test/下找到。要强制重新运行测试(例如,如果测试运行程序未能注意到更改),您只需删除时间戳文件即可。
请注意,某些测试需要启用支持Python的gdb。您可以通过在gdb中使用python
命令来测试gdb安装是否支持Python。调用后,您可以输入一些Python代码(例如print("hi")
),然后返回,然后再按CTRL + D执行它。如果要从源代码构建gdb,则需要使用--with-python = <path-to-python-binary>
进行配置。
运行部分测试套件
在特定PR上工作时,您通常将需要运行少量测试。例如,可以在修改rustc之后使用一个好的“冒烟测试”,以查看事物是否正常运行,如下所示:
./x.py test src/test/{ui,compile-fail}
这将运行ui
和compile-fail
测试套件。当然,测试套件的选择有些随意,并且可能不适合您正在执行的任务。例如,如果您正在使用debuginfo进行调试,那么使用debuginfo测试套件可能会更好:
./x.py test src/test/debuginfo
如果您只需要为任何给定的测试套件测试特定的测试子目录,则可以将该目录传递给x.py test
:
./x.py test src/test/ui/const-generics
同样,您可以通过传递单个文件的路径来测试该文件:
./x.py test src/test/ui/const-generics/const-test.rs
只运行整洁测试脚本
./x.py test tidy
在标准库上运行测试
./x.py test --stage 0 library/std
运行整洁测试脚本并且在标准库上运行测试
./x.py test --stage 0 tidy library/std
使用阶段1编译器在标准库上运行测试
./x.py test library/std
通过列出要运行的测试套件,可以避免为根本没有更改的组件运行测试。
警告:请注意,bors仅在完整的第2阶段构建中运行测试;因此,尽管测试在第1阶段通常可以正常进行,但仍有一些局限。
运行单个测试
人们想要做的另一件事是运行单个测试,通常是他们试图修复的测试。如前所述,您可以传递完整的文件路径来实现这一目标,或者可以使用--test-args
选项调用x.py
:
./x.py test src/test/ui --test-args issue-1234
在后台,测试运行程序调用标准rust测试运行程序(与您在#[test]
中获得的运行程序相同),因此此命令将最终筛选出名称中包含issue-1234
的测试。(因此,--test-args
是运行相关测试集合的好方法。)
编辑和更新参考文件
如果您有意更改了编译器的输出,或者正在进行新的测试,那么您可以将--bless
传递给test子命令。例如,如果src/test/ui
中的某些测试失败,则可以运行
./x.py test src/test/ui --bless
来自动调整.stderr
,.stdout
或者.fixed
文件中的所有测试。当然,您也可以使用--test-args your_test_name
标志来定位特定的测试,就像运行测试时一样。
传递--pass $mode
通过UI测试现在具有三种模式:check-pass
, build-pass
和run-pass
。当传递--pass $mode
时,这些测试将被强制在给定的$mode
下运行,除非指令测试文件存在指令//ignore-pass
。您可以将src/test/ui
中的所有测试作为check-pass
运行:
./x.py test src/test/ui --pass check
通过传递--pass $mode
,可以减少测试时间。对于每种模式,请参见此处。
使用增量编译
您可以进一步启用--incremental
标志,以在以后的重建中节省更多时间:
./x.py test src/test/ui --incremental --test-args issue-1234
如果您不想在每个命令中都包含该标志,则可以在config.toml
中启用它:
[rust]
incremental = true
请注意,增量编译将使用比平常更多的磁盘空间。如果您担心磁盘空间,则可能需要不时地检查build
目录的大小。
使用不同的“比较模式”运行测试
UI测试可能会有不同的输出,具体取决于编译器所处的特定“模式”。例如,当处于“非词法作用域生命周期”("non-lexical liftimes",NLL)模式时,测试foo.rs
将首先在foo.nll.stderr
中寻找期望的输出,如果没有找到,则回到寻常的foo.stderr
。要以NLL模式运行UI测试套件,可以使用以下命令:
./x.py test src/test/ui --compare-mode=nll
其它比较模式的示例是"noopt","migrat"和revisions。
手动运行测试
有时候,手动进行测试会更容易,更快捷。 大多数测试只是rs
文件,因此您可以执行操作类似
rustc +stage1 src/test/ui/issue-1234.rs
这要快得多,但并不总是有效。例如,某些测试包含指定特定的编译器标志或依赖于其它crate的指令,并且如果没有这些选项,它们可能无法以相同的方式运行。
添加新测试
总体而言,我们希望每个修复rustc错误的PR都能够有一些相应的回归测试 。这些测试在修复之前是错误的但是在PR之后应该是通过的。这些测试能有效的防止我们重复过去的错误。
为了添加新测试,通常要做的第一件事是创建一个文件,往往是Rust源文件。测试文件有特定的结构:
- 它们应该包含一些解释测试内容的注释;
- 接下来,它们应该有一个或多个头部命令,这些头部命令是能够让测试解释器知道如何解释特殊的注释,。
- 最后,它们应该有Rust源码。源码可能包含不同的错误注释,这些错误指示预期的编译错误或警告。
根据测试套件的不同,可能还其它一些需要注意的细节:
- 对于
ui
测试套件,您需要生成参考输出文件。
我应该添加哪种测试
知道该使用哪种测试是十分困难的。这里有一些粗略的启发:
- 一些测试特殊的需求
- 需要运行gdb或者lldb?使用
debuginfo
测试套件 - 需要检查LLVM IR或者MIR IR?使用
codegen
或者mir-opt
测试套件 - 需要运行rustdoc?首选
rustdoc
或者rustdoc-ui
测试,有时,您也需要rustc-js
- 需要以某种方式检查生成的二进制文件?请使用
use-make
- 需要运行gdb或者lldb?使用
- 库测试应该放在
library/${crate}/tests
中(其中的${crate}
通常是core
,alloc
,std
)。库测试应该包括:- API是否正常运行,包括接受各种类型或者具有某些运行时行为的测试
- 是否存在任何与测试不相关的编译器警告的测试
- 当使用一个API时给出的错误与它真正的错误无关时的测试。这些测试在代码块中应该有一个错误编号,用于确保它是正确的错误信息。
- 对于剩余的大多数,首选
ui
(或者ui-fulldeps
)测试ui
测试同时包含run-pass
,compile-fail
,和parse=fail
测试- 在警告或错误的情况下,
ui
测试会捕获全部输出,这使得评审变得更容易,同时也有助于防止输出中的"隐藏"回归
命名您的测试
传统上,对于测试名字,我们并没有太多的结构。并且,在很长一段时间中,rustc测试运行程序不支持子目录(现在可以了),所以测试套件譬如src/test/ui
中有很多文件。这并不是一个理想的设置。
对于回归测试-基本上,一些随机的来自于互联网上的代码片段-我们经常用问题(issue)加上简短的说明来命名这些测试。理想情况下,应该将测试添加到目录中,这样能够帮助我们确定哪段代码正在被测试(例如src/test/ui/borrowck/issue-54597-reject-move-out-of-borrow-via-pat.rs
)如果您已经尝试过但是找不到更相关的地方,这个测试可以被添加到src/test/ui/issues/
。同样,请在某处添加上问题编号(issue numbeer)。但是,请尽量避免把您的测试放在那,因为这样会使目录中的测试过多,造成语义组织不佳。
当在编写一个新特性时候时,请创建一个子目录用于存放您的测试。例如,如果您要实现RFC1234("Widgets"),那么最好将测试放在类似src/test/ui/rfc1234-widgets
的目录。
在其它情况下,可能已经存在合适的目录。(被正确使用的目录结构实际上是一个活跃的讨论区)
注释说明测试内容
当您在创建测试文件时,请在文件的开头添加总结测试要点的注释。注释应该突出显示哪一部分测试更为重要,以及这个测试正在解决什么问题。引用问题编号通常非常有帮助。
该注释不必过于广泛,类似"Regression test for #18060: match arms were matching in the wrong order."的注释就已经足够了。
以后当您的测试崩溃时,这些注释对其他人非常有用,因为它们通常已经突出显示了问题所在。当出于某些原因测试需要重构时,这些注释也同样有用,因为它能让其他人知道哪一部分的测试是重要的(通常,必须重写测试,因为它不再测试它曾经被用于测试的内容,所以知道测试曾经的含义是十分有用的)
头部指令: 配置rustc
头部指令是一种特殊的注释,它让测试运行程序知道如何解释。在测试中,它们必须出现在Rust源代码之前。它们通常被放在段注释后,这些注释用来解释本测试的关键点。例如,这个测试使用了//compile-flags
指令,该指令在编译测试时给rustc指定了自定义的标志。
// Test the behavior of `0 - 1` when overflow checks are disabled.
// compile-flags: -C overflow-checks=off
fn main() {
let x = 0 - 1;
...
}
忽略测试
下列是用于在某些情况下忽略测试,这意味着测试不会被编译或者运行
ignore-X
其中X是会忽略相应测试的目标细节或阶段(见下文)only-X
和ignore-X
相似,不过只会在那个目标或阶段下运行测试ignore-pretty
将不会编译打印美化的测试(这样做是为了测试打印美化器,但它并不总是有效)ignore-test
总是忽略测试ignore-lldb
和ignore-gdb
会跳过调试器的调试信息ignore-gdb-version
当使用某些gdb版本时,可以使用它来忽略测试
一些关于ignore-X
中X
的例子:
- 架构:
aarch64
,arm
,asmjs
,mips
,wasm32
,x86_64
,x86
, ... - OS:
android
,emscripten
,freebsd
,ios
,linux
,macos
,windows
, ... - 环境(即目标三元组("target-triple")的第四个词):
gnu
,msvc
,musl
. - 指针宽度:
32bit
,64bit
. - 阶段:
stage0
,stage1
,stage2
. - 当交叉编译时:
compare-mode-nll
- 当使用远程测试时:
remote
- 当启用调试断言时:
debug
- 当测试特定的调试器时:
cdb
,gdb
,lldb
- 特定比较模式时:
compare-mode-nll
,compare-mode-polonius
其它头部指令
这是一份关于其它头部指令的列表。该表并不详尽,您通常可以通过浏览来自compiletest源的[header.rs
]中的TestProps
找到头部命令。
run-rustfix
,该命令是用于UI测试,表示测试产生结构化建议。测试编写者应该创建一个.fixed
文件,其中包含应用了建议的源码。当运行测试时,compiletest 首先检查正确的lint/warning是否产生。然后,它应用建议并且与.fixed
(两者必须匹配)比较。最后,fixed源码被编译,并且此次编译必须成功。.fixed
文件可以通过bless
选项自动生成,在本节进行了介绍min-gdb-version
指定了本测试所需的最低gdb版本。min-lldb-version
指定了本测试所需的最低lldb版本。no-system-llvm
,如果使用系统llvm,该命令会导致测试被忽略min-system-llvm-version
指定最低的系统llvm版本;如果系统llvm被使用并且未达到所需的最低版本,那么本测试会被忽略。当一个llvm功能被反向移植到rust-llvm时,这条命令十分有效。ignore-llvm-version
,当特定的LLVM版本被使用时,该命令可以用于跳过测试。它需要一个或两个参数。第一个参数是第一个被忽略的版本,如果没有第二个参数,那么后续版本都会被忽略;否则,第二个参数就是被忽略的最后一个版本。build-pass
适用于UI测试,该命令表示测试应该成功编译和链接,与此相反的是默认情况下测试应该会出错。compile-flags
将额外的命令行参数传递给编译器,例如compile-flags -g
会强制启用debuginfoedition
控制测试应该使用的版本(默认为2014)。用法示例// edition:2018
。should-fail
表示测试应该失败;被用于元测试("meta testing"),该测试是我们测试compiletest程序本身是否能够在适当的情况下产生错误。在格式美化测试中该头部命令会被忽略。gate-test-X
中的X
是一个特性,该命令把测试标记为对于特性X的"门控测试"("gate test")。此类测试应该确保当尝试使用门控功能而没有正确的#![feature(X)]
标签时,编译器会发生错误。每个不稳定的语言特性都需要一个门测试。needs-profiler-support
-需要profiler运行时,例如,rustc的config.toml
中的profiler=true
。needs-sanitizer-support
-需要sanitizer运行时,例如,rustc的config.toml
中的sanitizers = true
。needs-sanitizer-{address,leak,memory,thread}
-表示该测试需要一个目标分别支持AddressSanitizer, LeakSanitizer,MemorySanitizer 或者 ThreadSanitizer。error-pattern
像ERROR
注释一样检查诊断,而不指定错误行。当错误没有给出任何范围时,这个命令十分有用。
[header.rs
]: https://github.com/rust-lang/rust/tree/master/src/tools/compiletest/src/header.rs
[bless]: ./running.md#editing-and-updating-the-reference-files
错误注释示例
这是一些UI测试源上不同的错误注释示例。
置于错误行上
使用//~ERROR
语法
fn main() {
let x = (1, 2, 3);
match x {
(_a, _x @ ..) => {} //~ ERROR `_x @` is not allowed in a tuple
_ => {}
}
}
置于错误行下
使用//~^
语法,字符串中插入号(^
)的数量表示上方的行数。在下面这个例子中,错误行在错误注释行的上四行位置,因此注释中有四个插入号。
fn main() {
let x = (1, 2, 3);
match x {
(_a, _x @ ..) => {} // <- the error is on this line
_ => {}
}
}
//~^^^^ ERROR `_x @` is not allowed in a tuple
使用与上面错误注释行相同的错误行
使用//~|
语法定义与上面错误注释行相同的错误行
struct Binder(i32, i32, i32);
fn main() {
let x = Binder(1, 2, 3);
match x {
Binder(_a, _x @ ..) => {} // <- the error is on this line
_ => {}
}
}
//~^^^^ ERROR `_x @` is not allowed in a tuple struct
//~| ERROR this pattern has 1 field, but the corresponding tuple struct has 3 fields [E0023]
无法指定错误行时
让我们思考一下这个测试
fn main() {
let a: *const [_] = &[1, 2, 3];
unsafe {
let _b = (*a)[3];
}
}
我们想要确保它显示"超出索引范围"("index out of bounds"),但是我们不能使用ERROR
注释,因为这个错误没有范围。那么是时候使用error-pattern
:
// error-pattern: index out of bounds
fn main() {
let a: *const [_] = &[1, 2, 3];
unsafe {
let _b = (*a)[3];
}
}
但是对于严格测试,请尽量使用ERROR
注释。
错误等级
您可以拥有的错误等级是:
ERROR
WARNING
NOTE
HELP
andSUGGESTION
*
* 注意: SUGGESTION
必须紧随HELP
之后
版本
某些测试类支持"版本"("revision")(截至本文撰写之时,这包括编译失败,运行失败和增量测试,虽然增量测试有些差异)。版本允许将一个测试文件用于多个测试。这通过在文件顶部添加一个特殊的头部来完成:
#![allow(unused)] fn main() { // revisions: foo bar baz }
这会导致测试被编译(和测试)三次,一次使用--cfg foo
,一次使用--cfg bar
,一次使用--cfg baz
。因此您可以在测试中使用#[cfg(foo)]
等来调整每个结果。
您也可以将头部和期望的错误信息来自定义为特定的修订。为此,您需要在//
注释后添加[foo]
(或者bar
,baz
等),如下所示
#![allow(unused)] fn main() { // A flag to pass in only for cfg `foo`: //[foo]compile-flags: -Z verbose #[cfg(foo)] fn test_foo() { let x: usize = 32_u32; //[foo]~ ERROR mismatched types } }
请注意,并非所有的头部在被自定义为版本时都有意义。例如,ignore-test
头部(和所有的ignore
头部)目前只适用于整个测试而不适用于特定的版本。当被自定义为版本时,唯一真正起作用的头部只有错误模式(error patterns)和编译器标志(compiler flags)。
UI测试指南
UI测试旨在抓取编译器完整的输出,这样我们可以测试可以测试表现的各个方面。它们通过编译文件(例如ui/hello_world/main.rs
),捕获输出,然后进行一些标准化(参见下文)。然后将标准化的结果与名为ui/hello_world/main.stderr
和ui/hello_world/main.stdout
的参考文件进行比较。如果其中任意一文件不存在,那么输出必须为空(实际上是该特定测试的实例)。如果测试运行失败,我们将打印出当前输出,但是输出也被保存在build/<target-triple>/test/ui/hello_world/main.stdout
(这个路径会被当作测试失败信息的一部分而打印出来),这样你就可以通过运行diff
等命令来比较。
现在我们有大量的UI测试并且一些目录中的条目过多。这是一个问题,因为它对editor/IDE是不友好的并且GitHub UI也不会显示超过1000个的目录。为了解决这个问题并组织语义结构,我们有一个整洁检查(tidy check),用以确保条目数小于1000,我们为每个目录设置了不同的上限。所以,请避免将新测试放在这,并且尝试去寻找更相关的位置。例如,你的测试和闭包相关,你应该把它放在src/test/ui/closures
。如果你不确定哪里最佳的位置.添加到src/test/ui/issues/
也是可以的。当到达上限时,你可以通过调整这来增加上限。
不会导致编译错误的测试
默认情况下,预期UI测试不会编译(在这种情况下,应该至少包含一个//~ERROR
注释)。但是,您也可以在期望编译成功的地方进行UI测试,甚至还可以运行生成的程序。只需要添加任意下列头部命令:
// check-pass
-编译应该成功,但是跳过代码生成(它的代价是昂贵的,在大部分情况下不应该失败)// build-pass
-编译和链接应该成功但是不运行生成的二进制文件// run-pass
-编译应该成功并且我们应该运行生成的二进制文件
标准化
编译器的输出被标准化以消除不同平台输出的差异,主要和文件名相关。
下面的字符串会被替换成相应的值:
$DIR
:被定义为测试的目录- 例如:
/path/to/rust/src/test/ui/error-codes
- 例如:
$SRC_DIR
:源码根目录- 例如:
/path/to/rust/src
- 例如:
$TEST_BUILD_DIR
:测试输出所在的基本目录- 例如:
/path/to/rust/build/x86_64-unknown-linux-gnu/test/ui
- 例如:
此外,会进行以下更改:
-
$SRC_DIR
中的行号和列号被LL:CC
代替。例如,/path/to/rust/library/core/src/clone.rs:122:8
被替代为$SRC_DIR/core/src/clone.rs:LL:COL
。注意:指向测试的
-->
行的行号和列号是未规范的,并保持原样。这确保编译器继续指向正确的位置并且保持stderr文件的可读性。理想情况下,所有行和列的信息都被保留,但是源的小变化会造成巨大的差异,更为频繁的合并冲突和测试错误。另请参见下面的-Z ui-testing
,它适用于附加的行号规范化。 -
\t
被替换为实际的制表符 -
错误行注释例如
// ~Error some messgage
被移除 -
反斜杠(
\
)在路径内转换为正斜杠(/
)(使用启发式)。这有助于规范Windows样式路径的差异。 -
CRLF换行符被转换为LF。
此外,编译器使用-Z ui-testing
标志运行,这导致编译器本身对诊断输出进行一些修改以使其更适合于UI测试。例如,它将匿名化输出中的行好(每行源代码前的行号会被替换为LL
)。在极少数情况下,可以使用头部命令// compile-flags: -Z ui-testing=no
来禁用此模式。
有时,这些内置的规范化并不够。在这种情况下,你可以提供通过头部命令自定义的规范规则,例如
#![allow(unused)] fn main() { // normalize-stdout-test: "foo" -> "bar" // normalize-stderr-32bit: "fn\(\) \(32 bits\)" -> "fn\(\) \($$PTR bits\)" // normalize-stderr-64bit: "fn\(\) \(64 bits\)" -> "fn\(\) \($$PTR bits\)" }
这告诉测试,在32位平台上,只要编译器将fn() (32 bits)
写入stderr时,都应该被标准化为读取fn() ($PTR bits)
。64位同样如此。替换是由正则表达式完成,它使用由regex
crate提供的默认正则风格。
相应的参考文件将使用规范化的输出来测试32位和64位平台:
...
|
= note: source type: fn() ($PTR bits)
= note: target type: u16 (16 bits)
...
请参阅[ui/transmute/main.rs
][mrs]和 [main.stderr
][]了解具体的用法示例。
[mrs]: https://github.com/rust-lang/rust/blob/master/src/test/ui/transmute/main.rs
[main.stderr
]: https://github.com/rust-lang/rust/blob/master/src/test/ui/transmute/main.stderr
除了normalize-stderr-32bit
和-64bit
,在这里也可以使用 ignore-X
支持的任何目标信息或阶段(例如normalize-stderr-windows
或简单地使用normalize-stderr-test
进行无条件替代)
Using compiletest commands to control test execution
Debugging the Compiler
Profiling the compiler
with the linux perf tool
crates.io Dependencies
About the compiler team
Using Git
Mastering @rustbot
Walkthrough: a typical contribution
Bug Fix Procedure
Implementing new features
Stability attributes
Stabilizing Features
Feature Gates
Coding conventions
Notification groups
ARM
Cleanup Crew
LLVM
RISC-V
Windows
Licenses
第三部分:高层编译器架构
本指南的剩余部分将会讨论编译器是如何工作的。他们会从编译器高层结构的角度逐一介绍编译的每个阶段是如何工作的。对于那些对端到端的编译过程感兴趣的读者, 以及 想要了解自己希望作出贡献的特定系统的读者,这些指南会是友好的。如果觉得有不清楚的事情,尽管在rustc-dev-guide 仓库中提出 issue,或者联系在第一章这个部分中提到的编译器团队。
在这个部分,我们将会着眼于高层编译器架构。特别的,我们会将目光放在影响整个编译器的三个总体设计上:查询系统,增量编译以及驻留。
编译器概览
这一章是关于编译程序时的总体过程 —— 所有东西是如何组合起来的。
rust的编译器在两方面独具特色:首先它会对你的代码进行别的编译器不会进行的操作(比如借用检查),并且有许多非常规的实现选择(比如查询)。 我们将会在这一章中逐一讨论这些,并且在指南接下来的部分,我们会更深入细节的审视所有单独的部分。
编译器对你的代码做了什么
首先,我们来看看编译器对你的代码做了些什么。现在,除非必须,我们会避免提及编译器是如何实现这些步骤的;我们之后才会讨论这些。
-
编译步骤从用户编写Rust程序文本并且使用
rustc
编译器对其进行处理开始。命令行参数指明了编译器需要做的工作。 举个例子,我们可以启用开发版特性(-Z
标识),执行check
——仅执行构建,或者得到LLVM-IR而不是可执行机器码。 通过使用cargo
,rustc
的执行可能是不直接的。 -
命令行参数解析在
rustc_driver
中发生。这个 crate 定义了用户请求的编译配置 并且将其作为一个rustc_interface::Config
传给接下来的编译过程。 -
原始的 Rust 源文本被位于
rustc_lexer
的底层词法分析器分析。在这个阶段,源文本被转化成被称为 tokens 的 原子源码单位序列。 词法分析器支持 Unicode 字符编码。 -
token 序列传给了位于
rustc_parse
的高层词法分析器以为编译流程的下一个阶段做准备。StringReader
结构体在这个阶段被用于执行一系列的验证工作并且将字符串转化为驻留符号(稍后便会讨论 驻留)。 [字符串驻留] 是一种将多个相同的不可变字符串只存储一次的技术。 -
词法分析器有小的接口并且不直接依赖于
rustc
中的诊断基础设施。反之,它提供在rustc_parse::lexer::mod
中被发送为真实诊断 的作为普通数据的诊断。 -
词法分析器为 IDE 以及 过程宏 保留有全保真度的信息。
-
解析器 将从词法分析器中得到的token序列转化为抽象语法树(AST)。它使用递归下降(自上而下)的方式来进行语法解析。 解析器的 crate 入为
rustc_parse::parser::item
中的Parser::parse_crate_mod()
以及Parser::parse_mod()
函数。 外部模块解析入口为rustc_expand::module::parse_external_mod
。 以及宏解析入口为Parser::parse_nonterminal()
。 -
解析经由一系列
Parser
工具函数执行,包括fn bump
,fn check
,fn eat
,fn expect
,fn look_ahead
。 -
解析是由要被解析的语义构造所组织的。分离的
parse_*
方法可以在rustc_parse
parser
文件夹中找到。 源文件的名字和构造名相同。举个例子,在解析器中能找到以下的文件:expr.rs
pat.rs
ty.rs
stmt.rs
-
这种命名方案被广泛地应用于编译器的各个阶段。你会发现有文件或者文件夹在解析、降低、类型检查、THIR降低、以及MIR源构建。
-
宏展开、AST验证、命名解析、以及程序错误检查都在编译过程的这个阶段进行。
-
解析器使用标准
DiagnosticBuilder
API 来进行错误处理,但是我们希望在一个错误发生时, 尝试恢复、解析Rust语法的一个超集。 -
rustc_ast::ast::{Crate, Mod, Expr, Pat, ...}
AST节点从解析器中被返回。 -
我们接下来拿到AST并且将其转化为高级中间标识(HIR)。这是一种编译器友好的AST表示方法。 这包括到很多如循环、
async fn
之类的解糖化的东西。 -
我们使用 HIR 来进行[类型推导]。 这是对于一个表达式,自动检测其类型的过程。
-
TODO:也许在这里还有其他事情被完成了?我认为初始化类型检查在这里进行了?以及 trait 解析?
-
HIR之后 被降低为中级中间标识(MIR)。
- 同时,我们构造 THIR ,THIR是更解糖化的 HIR。THIR被用于模式和详尽性检验。 同时,它相较于 HIR 更容易被转化为MIR。
-
MIR被用于[借用检查]。
-
我们(想要)在 MIR 上做许多优化因为它仍然是通用的, 并且这样能改进我们接下来生成的代码,同时也能加快编译速度。
- MIR 是高级(并且通用的)表示形式,所以在 MIR 层做优化要相较于在 LLVM-IR 层更容易。
举个例子,LLVM看起来是无法优化
simplify_try
这样的模式,而mir优化则可以。
- MIR 是高级(并且通用的)表示形式,所以在 MIR 层做优化要相较于在 LLVM-IR 层更容易。
举个例子,LLVM看起来是无法优化
-
Rust 代码是 单态化 的,这意味着对于所有所有通用代码进行带被具体类型替换的类型参数的拷贝。 要做到这一点,我们要生成一个列表来存储需要为什么具体类型生成代码。这被称为 单态集合。
-
我们接下来开始进行被依稀称作 代码生成 或者 codegen。
- 代码生成(codegen)是将高等级源表示转化为可执行二进制码的过程。
rustc
使用LLVM来进行代码生成。第一步就是将 MIR 转化为 LLVM 中间表示(LLVM IR)。 这是 MIR 依据我们由上一步生成的列表来真正被单态化的时候。 - LLVM IR 被传给 LLVM,并且由其进行更多的优化。之后它产生机器码, 这基本就是添加了附加底层类型以及注解的汇编代码。(比如一个 ELF 对象或者 wasm)。
- 不同的库/二进制内容被链接以产生最终的二进制内容。
- 代码生成(codegen)是将高等级源表示转化为可执行二进制码的过程。
编译器是怎么做的
好,我们现在已经从高层视角看了编译器对你的代码做了什么,那让我们再从高层视角看看编译器是 怎么 做到这些的。 这里有很多编译器需要满足/优化的限制以及冲突目标。举个例子,
- 编译速度:编译一份程序有多快。更多/好的编译时分析通常意味着编译会更慢。
- 与此同时,我们想要支持增量编译,因此我们需要将其纳入考虑。
我们怎样才能衡量哪些工作需要被重做,以及当用户修改程序时哪些东西能被重用?
- 与此同时,我们不能在增量缓存中存储太多东西,因为这样会花费很多时间来从磁盘上加载 并且会占用很多用户的系统空间……
- 与此同时,我们想要支持增量编译,因此我们需要将其纳入考虑。
我们怎样才能衡量哪些工作需要被重做,以及当用户修改程序时哪些东西能被重用?
- 编译器内存占用:当编译一份程序时,我们不希望使用多余的内存。
- 程序运行速度:编译出来的程序运行得有多快。更多/好的编译时分析通常意味着编译器可以做更好的优化。
- 程序大小:编译出来的二进制程序有多大?和前一个点类似。
- 编译器编译速度:编译这个编译器要花多长的时间?这影响着贡献者和编译器的维护。
- 实现复杂度:制造一个编译器是一个人/组能做到的最困难的事之一,并且 Rust 不是一门非常简单的语言, 那么我们应该如何让编译器的代码基础便于管理?
- 编译正确性:编译器创建的二进制程序应该完成输入程序告诉要做的事, 并且应该不论后面持续发生的大量变化持续进行。
- 整合工作:编译器需要对以不同方式使用编译器的其他工具(比如 cargo,clippy,miri,RLS)提供支持。
- 编译器稳定性:发布在 stable channel 上的编译器不应该无故崩溃或者出故障。
- Rust 稳定性:编译器必须遵守 Rust 的稳定性承诺,保证之前能够编译的程序不会因为编译器的实现的许多变化 而无法编译。
- 其他工具的限制:rustc 在后端使用了 LLVM ,一方面我们希望借助 LLVM 的一些好处来优化编译器, 另一方面我们需要针对它的一些限制/坏处做一些处理。
总之,当你阅读指南的接下来的部分的时候,好好记住这些事。他们将通常会指引我们作出选择。
中间形式表示
和大多数编译器一样,rustc
使用了某种中间表示(IRs)来简化计算。通常,
直接用源代码来进行我们的工作是极度不方便并且容易出错的。源代码通常被设计的对人类友好,有复意的,
但是当做一些工作,比如类型检查的时候会较为不方便。
因此大多数编译器,包括rustc
,根据源代码创建某种便于分析的 IR 。rust
有一些 IRs,
其各自根据不同的目的做了优化:
- Token 序列:词法分析器根据源代码直接生成了一个 token 序列。这个 token 序列相较于原始文本 更便于解析器处理。
- 抽象语法树(AST):抽象语法树根据词法分析器生成的 token 序列创建。它几乎表示的就是用户所写的。 它帮助进行句法健全性检查(比如检查用户是否在正确的位置写了所期望的类型)。
- 高级 IR(HIR):它是一些解糖的 AST。从句法的角度上,它仍然接近于用户所写的内容, 但是它包含了一些诸如省略了的生命周期之类的信息。这种 IR 可以被用于类型检查。
- 类型化的 HIR(THIR):这是介于 HIR 与 MIR 之间的中间形式,曾被称为高级抽象 IR (HAIR)。 它类似于 HIR 但是它完整地类型化了并且稍微更加地解糖化(比如方法调用以及隐式解引用在这里被完全地显式化)。 此外,相较于HIR,THIR更容易降低化到 MIR。
- 中级 IR(MIR):这种 IR 基本属于控制流程图(CFG)。控制流程图是一种展示程序基础块以及控制流是如何在其间流通的图表。 同时,MIR 也有一些带有简单类型化语句的基础块(比如赋值语句、简单计算语句等等)以及链接其他基础块的控制流边 (比如调用语句、丢弃值等等)。MIR 被用于借用检查和其他重要的基于数据流的检查,比如检查未初始化的值。 它同样被用来做一系列优化以及常值评估(通过 MIRI)。因为 MIR 仍然是普通形式,比起在单态化之后我们在这里可以做更多分析。
- LLVM IR:这是 LLVM 编译器所有输入的标准形式。LLVM IR 是一些带有许多注解的类型化的汇编语言。 它是所有使用 LLVM 的编译器的标准格式(比如 C 编译器 clang 同样输出 LLVM IR)。
另一件要注意的事是,许多在编译器中的值被 驻留 了。这是一种性能和内存优化手段, 我们将值收集到一个特殊的被称作 arena 的收集器中。之后,我们将引用逐个对应到 arena 中收集的值上。 这使得我们可以保证相同的值(比如你程序中的类型)只被收集一次并且可以廉价地使用指针进行比较。 许多内部表示都被驻留了。
查询
第一个主要的选择是 查询 系统。rust 编译器使用了一种不同于大多数书本上的所写编译器的查询系统, 后者是按顺序执行的一系列代码传递组织的。而 rust 编译器这样做是为了能够做到增量编译 ── 即, 当用户对其程序作出修改并且重新编译,我们希望尽可能少地做(与上一次编译所做的)相重复的工作来创建新的二进制文件。
在rustc
中,所有以上这些主要步骤被组织为互相调用的一些查询。举个例子。假如有一条查询负责询问某个东西的类型,
而另一条查询负责询问某个函数的优化后的 MIR。这些查询可以相互调用并且由查询系统所跟踪。
查询的角果被缓存于硬盘上,这样我们就可以分辨相较于上次编译,哪些查询的结果改变了,并且仅重做这些查询。
这就是增量编译是如何工作的。
理论上讲,对于查询化步骤,我们独立完成上述每一项工作。举个例子,我们会将 HIR 带入一个函数 并且使用查询来请求该 HIR 的 LLVM IR。这驱动了优化 MIR 的生成,MIR 驱动了借用检查器,借用 检查器又驱动了 MIR 的生成,等等。
……除了那以外,这是非常过于简化的。事实上,有些查询并不是缓存于磁盘上的,并且编译器的某些部分
需要对所有代码运行正确性检查,即便是代码是无效的(比如借用检查器)。举个例子,目前对于一个crate的所有函数mir_borrowck
查询是第一个运行的。
之后代码生成器后端触发collect_and_partition_mono_items
查询,它首先递归地对所有可达函数
请求optimized_mir
,而接下来对函数运行mir_borrowck
并且之后创建代码生成单元。
这种分割将需要保留下来以保证不可达的函数仍然将他们的错误发送出来。
此外,编译器建造之初是不使用查询系统的;查询系统是被加装到编译器中的,所以它有些部分还没被查询化。 同时,LLVM不是我们的代码,所以它也不是查询化的。计划是将前些部分所列举的步骤最终全部查询化, 但是对于本文,只有介于 HIR 和 LLVM-IR 之间的步骤是被查询化了的。这意味着对于整个程序, 词法分析以及解析都是被一次性完成的。
另一件这里要提到的事是非常重要的“类型上下文”,TyCtxt
,它是一个相当巨大的结构体,
是所有东西的中心。(注意它的名字极其有历史性。这 不 是指类型理论中的Γ
或Δ
一类的东西。
这个名字被保留下来是因为它就是源代码中结构体的名称。)所有查询都被定义为在TyCtxt
类型上
的方法,并且内存中的查询缓存也同样被存储在此。在代码中,通常会有一个名为tcx
变量,它是
类型上下文上的一个句柄。有同样会见到名为'tcx
的生命周期,这意味着有东西被和TyCtxt
的
生命周期绑定在了一起(通常它会被存储或者被驻留化)。
ty::Ty
类型在 Rust 中相当重要,并且他们形成了许多编译器分析的核心。用于表示类型(在用户程序中)的
主要类型(在编译器中)是 rustc_middle::ty::Ty
。它是如此的重要以至于我们为其
设置了一整章ty::Ty
,但是对于现在而言,我们只想提到它存在并且是rustc
用来表示类型的方法!
同样注意到rustc_middle::ty
模块定义了我们之前提到的TyCtxt
结构体。
并行性
编译器表现是我们希望改进的一个问题(并且一直为之努力)。一个方面便是将 rustc
自身并行化。
目前,rustc 只有一个部分已经实现了并行化:代码生成。在单态化的过程中,编译器会将所有的代码 分割生成为叫做 代码生成单元 的小块。它们之后由独立的 LLVM 实例生成。由于它们都是独立的, 我们可以并行地运行它们。最后,运行链接器来组合所有地代码生成单元成为一个二进制文件。
但是,编译器余下的部分仍然是未并行化的。我们已经为此付出了很多努力,但是它始终是一个难题。
目前的方法是把 RefCell
s 转化为一些 Mutex
s —— 那代表着我们转换到了线程安全的内部可变性。
但是仍然有许多在途的挑战比如锁争夺、维护并发下的查询系统不变量以及代码库的复杂性。
你可以通过在config.toml
中启用并行编译来尝试并行工作。它仍处于早期阶段,但是有一些
有保障的性能改进。
自举
rustc
自身是由 Rust 编写的。所以我们如何编译编译器?我们使用一个较老的编译器来编译
更新的编译器。这被称作 自举。
自举有许多有趣的含义。举个例子,它意味着 Rust 一个主要用户是 Rust 编译器,所以我们 持续的测试我们自己的软件(“吃我们自己的狗粮”)。
对于更多关于自举的细节,详见这份指导书的自举部分。
未被解决的问题
- LLVM 在 debug 建造的时候做优化了吗?
- 我如何在我自己的资源下浏览编译的各个过程(词法分析器、解析器、HIR 等等)?—— 比如,
cargo rustc -- -Z unpretty=hir-tree
允许你查看 HIR 表示 - 什么是
X
的主要入口点? - 交叉翻译到不同平台的机器码时,哪个阶段发生了分歧?
参考
- 命令行解析
- 指南: Rustc驱动以及接口
- 驱动定义:
rustc_driver
- 主要入口点:
rustc_session::config::build_session_options
- 词法分析:将用户程序转化为 token 流
- 指南: 词法分析以及解析
- 词法分析器定义:
rustc_lexer
- 主要入口:
rustc_lexer::first_token
- 解析:将 token 流解析为抽象语法树(AST)
- 指南: 词法分析以及解析
- 解析定义:
rustc_parse
- 入口点:
- AST 定义:
rustc_ast
- 展开: TODO
- 命名解析: TODO
- 特征门控: TODO
- 早期程序检查: TODO
- 高级中间表示 (HIR)
- 指南: HIR
- 指南: HIR中的标识
- 指南: HIR映射
- 指南: 将AST低转为HIR
- 如何查看你代码的 HIR 表示
cargo rustc -- -Z unpretty=hir-tree
- Rustc HIR 定义:
rustc_hir
- 主要入口点: TODO
- 后期程序检查: TODO
- 类型推导
- 指南: 类型推导
- 指南: ty模块:表示类型 (语义学)
- 主要入口点 (类型推导):
InferCtxtBuilder::enter
- 主要入口点 (类型检查主题):
typeck
查询- 这两个函数不能被解耦。
- 中级中间表示 (MIR)
- 指南: MIR (中级 IR)
- 定义:
rustc_middle/src/mir
- 操纵 MIR 的源代码定义:
rustc_mir
- 借用检查器
- 指南: MIR 借用检查
- 定义:
rustc_mir/borrow_check
- 主要入口点:
mir_borrowck
查询
- MIR 优化
- 指南: MIR Optimizations
- 定义:
rustc_mir/transform
- 主要入口点:
optimized_mir
查询
- 代码生成
- 指南: 代码生成
- 使用 LLVM 通过 LLVM IR 生成机器代码 - TODO: 参考?
- 主要入口点:
rustc_codegen_ssa::base::codegen_crate
- 它单态化并且产出 LLVM IR给一个代码生成单元。 它之后启动一个后台线程来运行一个之后必须被接合的LLVM。
- 单态化通过
FunctionCx::monomorphize
懒启动以及rustc_codegen_ssa::base::codegen_instance
编译器源代码概览
注意:代码仓库的结构正在经历许多转变。特别是,我们希望最终顶层目录下具有编译器、构建系统、标准库等的单独目录,而不是一个庞大的
src/
目录。 自2021年 1月起,标准库已移至library/
,构成rustc
编译器本身的 crate 已移至compiler/
。
现在,我们已经大体了解了编译器的工作,让我们看一下 rust-lang/rust 仓库内容的结构。
Workspace 结构
rust-lang/rust
存储库由一个大型cargo workspace组成,该 Workspace 包含编译器,标准库(core
、 alloc
、 std
、 proc_macro
等)和 rustdoc
,以及构建系统以及用于构建完整 Rust 发行版的一些工具和子模块。 在撰写本文时,此结构正在逐步进行一些转换,以使其变得不再是一个巨大的代码仓库且更易于理解,尤其是对于新手。 该存储库由三个主要目录组成:
compiler/
包含了rustc
的源代码。它包含了组成编译器的一系列crate。library/
包含标准库 (core
、alloc
、std
、proc_macro
、test
)以及 Rust 运行时(backtrace
、rtstartup
、lang_start
)src/
包含rustdoc
、clippy
、cargo
、 构建系统、语言文档等等。
标准库
标准库 crate 都在 library/
中。它们的名称都非常直观,如 std
、core
、alloc
等。还有 proc_macro
,test
和其他运行时库。这些代码和其他 Rust crate 非常相似,区别在于它们必须以特殊的方式构建,因为其中可以使用不稳定的功能。
编译器
建议先阅读概述章节,它概述了编译器的工作方式。
本节中提到的 crate 组成了整个编译器,它们位于
compiler/
中。
compiler/
下的 crate 们的名称均以rustc_ *
开头。这里有大约 50 个或大或小,相互依赖的 crate。 还有一个 rustc
crate,它是实际的二进制文件入口点(即 main
函数)所在之处; 除了调用rustc_driver
crate之外,rustc
crate实际上并不做任何事情,rustc_driver
crate 会驱动其他 crate 中的各个部分来进行编译。
这些 crate 之间的依赖关系很复杂,但大体来说:
rustc
(二进制文件入口点)调用rustc_driver::main
rustc_driver
依赖许多其他 crate,其中最主要的是rustc_interface
。rustc_interface
依赖于大多数其他编译器 crate。它是用于驱动整个编译的相当通用的接口。-
大部分其他
rustc_*
crates 依赖于rustc_middle
,rustc_middle
中定义了编译器中的许多核心数据结构rustc_middle
和编译器中大多数其他部分都依赖一些代表了编译中更早的阶段的 crate(例如 parser),基础数据结构(如Span
),或者错误报告相关的内容:rustc_data_structures
,rustc_span
,rustc_errors
,等等
-
您可以通过读取各个 crate 的 Cargo.toml
来查看确切的依赖关系,就像普通的Rust crate一样。
最后一件事:src/llvm-project
是指向我们自己的 LLVM fork的子模块。 在bootstrap过程中,将会构建LLVM, compiler/rustc_llvm
是LLVM(用C++编写)的 Rust 包装,以便编译器可以与其交互。 本书的大部分内容是关于 Rust 编译器的,因此在这里我们将不对这些 crate 做任何进一步的解释。
Big Picture
这种由多个 crate 互相依赖的代码结构受两个主要因素的强烈影响:
- 组织。编译器是一个 巨大的 代码库;将其放在一整个大 crate 中是不可能的。依赖关系结构部分反映了编译器的代码结构。
- 编译时间。通过将编译器分成多个 crate,我们可以更好地利用 cargo 进行增量/并行编译。特别是,我们尝试使板条箱之间的依赖关系尽可能少,这样,如果您更改一个 crate,我们就不必重新构建大量的 crate。
在依赖关系树的最底部是整个编译器使用的少数 crate(例如 rustc_span
)。编译过程中的非常早期的部分(例如,parsing 和 AST)仅取决于这些。
构建AST之后不久,编译器的 查询系统 就建立好了。查询系统是使用函数指针以巧妙的方式设置的。这使我们可以打破 crate 之间的依赖关系,从而可以并行地进行更多编译。 但是,由于查询系统是在 rustc_middle
中定义的,编译器的几乎所有后续部分都依赖于此 crate。这是一个非常大的 crate,导致其编译时间极长。我们已经做出了一些努力来将内容从其中移出,但效果有限。另一个不幸的副作用是,有时相关功能分散在不同的 crate 中。例如,linting 功能分散在板条箱的较早部分 rustc_lint
,rustc_middle
和其他地方。
一般而言,在理想世界中,应当使用更少的,更内聚的板条箱,使用增量和并行编译确保编译时间保持合理。 但是,我们的增量和并行编译暂时还没有那么好用,所以目前为止我们的解决方案只能是东西分进单独的 crate。
在依赖树的顶部是 rustc_interface
和 rustc_driver
板条箱。
rustc_interface
是一个不稳定的查询系统的包装,用于帮助推动编译的各个阶段。
其他编译器中的的消费者(例如 rustdoc
或者甚至是 rust-analyzer)可以以不同的方式使用此接口。
rustc_driver
crate 首先解析命令行参数,然后使rustc_interface
驱动编译完成。
rustdoc
rustdoc
的大部分位于 librustdoc
中。 但是,rustdoc
二进制文件本身 src/tools/rustdoc
,除了调用 rustdoc::main
外,它什么都不做。 在 [src/tools/ rustdoc-js
] 和 src/tools/rustdoc-themes
中,还有 rustdocs 的 javascript 和 CSS。 您可以在本章中阅读有关 rustdoc 的更多信息。
测试
以上所有内容的测试套件都在 src/test/
中。 您可以在本章中了解有关测试套件的更多信息。 测试工具本身在 src/tools/compiletest
中。
构建系统
代码仓库中有许多工具,可用于构建编译器,标准库,rustdoc,以及进行测试,构建完整的 Rust 发行版等。 主要工具之一是 src/bootstrap
。 您可以在这一章中了解有关 bootstrap的更多信息。 构建过程重还可能使用 src/tools/
中的其他工具,例如 [tidy] 或 [compiletest]。
其他
在 rust-lang/rust
仓库中还有很多其他与构建完整 Rust 发行版有关的东西。 大多数时候,您无需关心它们。 这些包括:
- [
src/ci
]:CI配置。 这里的代码实际上相当多,因为我们在许多平台上运行了许多测试。 - [
src/doc
]:各种文档,包括指向几本书的submodule。 - [
src/etc
]:其他实用程序。 - [
src/tools/rustc-workspace-hack
],以及其他:各种变通方法以使 cargo 在bootstrapping 过程中运行。 - 以及更多……
Bootstrapping
查询: 需求驱动的编译
如编译器高级概述中所述,Rust编译器当前(2021年1月 )仍然正在从传统的“基于 pass”的编译过程过渡到“需求驱动”的编译过程。**编译器查询系统是我们新的需求驱动型编译过程的关键。**背后的想法很简单。 您可以使用各种查询来计算某一输入的相关信息 – 例如,有一个名为type_of(def_id)
的查询,传入某项的 def-id ,它将计算该项的类型并将其返回给您。
查询执行是记忆化的 —— 因此,第一次调用查询时,它将进行实际的计算,但是下一次,结果将从哈希表中返回。 此外,查询执行非常适合“增量计算”; 大致的想法是,当您执行查询时,可能会通过从磁盘加载存储的数据来将结果返回给您(这是一个单独的主题,我们将不在此处进一步讨论)。
总体上我们希望最终整个编译器控制流将由查询驱动。由一个顶层的查询("compile")来驱动一个crate上的编译;这会依次要求这个crate的各种信息。例如:
- 此 "compile" 查询可能需要获取代码生成单元列表(即需要由LLVM编译的模块)。
- 但是计算代码生成单元列表将调用一些子查询,该子查询返回 Rust 源代码中定义的所有模块的列表。
- 这些子查询会要求查询HIR。
- 就这样越推越远,直到我们完成实际的 parsing。
这一愿景尚未完全实现。尽管如此,编译器的大量代码(例如生成MIR)已经完全像这样工作了。
增量编译的详细说明
增量编译的详细说明一章提供了关于什么是查询及其工作方式的深入描述。 如果您打算编写自己的查询,那么可以读一读这一章节。
调用查询
调用查询很简单。 tcx
(“类型上下文”)为每个定义好的查询都提供了一种方法。 因此,例如,要调用type_of
查询,只需执行以下操作:
let ty = tcx.type_of(some_def_id);
编译器如何执行查询
您可能想知道调用查询方法时会发生什么。
答案是,对于每个查询,编译器都会将结果缓存——如果您的查询已经执行过,那么我们将简单地从缓存中复制上一次的返回值并将其返回(因此,您应尝试确保查询的返回类型可以低成本的克隆;如有必要,请使用Rc
)。
Providers
但是,如果查询不在缓存中,则编译器将尝试找到合适的 provider。 provider 是已定义并链接到编译器的某个函数,其包含用于计算查询结果的代码。
Provider是按crate定义的。
编译器(至少在概念上)在内部维护每个 crate 的 provider 表。
目前,实际上 provider 分为了两组:用于查询“本crate”的 provider(即正在编译的crate)和用于查询“外部crate”(即正在编译的crate的依赖) 的 provider。
请注意,确定查询所在的crate的类型不是查询的类型,而是键。
例如,当您调用 tcx.type_of(def_id)
时,它可以是本地查询,也可以是外部查询,
这取决于def_id
所指的crate(请参阅self::keys::Key
trait 以获取有关其工作原理的更多信息)。
Provider 始终具有相同的函数签名:
fn provider<'tcx>(
tcx: TyCtxt<'tcx>,
key: QUERY_KEY,
) -> QUERY_RESULT {
...
}
Provider 接受两个参数:tcx
和查询键,并返回查询结果。
如何初始化 provider
创建 tcx 时,它的创建者会使用Providers
结构为它提供provider。
此结构是由此处的宏生成的,但基本上就是一大堆函数指针:
struct Providers {
type_of: for<'tcx> fn(TyCtxt<'tcx>, DefId) -> Ty<'tcx>,
...
}
目前,我们为本地 crate 和所有外部 crate 各提供一份该结构的副本,最终计划是为每个crate提供一份。
这些 Provider
结构最终是由 librustc_driver
创建并填充的,它通过调用各种provide
函数,将工作分配给其他rustc_*
crate。这些函数看起来像这样:
pub fn provide(providers: &mut Providers) {
*providers = Providers {
type_of,
..*providers
};
}
也就是说,他们接收一个 &mut Providers
并对其进行原地修改。
通常我们使用上面的写法只是因为它看起来比较漂亮,但是您也可以 providers.type_of = type_of
,这是等效的。
(在这里,type_of
将是一个顶层函数,如我们之前看到的那样定义。)
因此,如果我们想为其他查询添加 provider,比如向前面的 crate 添加一个 fubar
,我们可以这样修改 provide
函数:
pub fn provide(providers: &mut Providers) {
*providers = Providers {
type_of,
fubar,
..*providers
};
}
fn fubar<'tcx>(tcx: TyCtxt<'tcx>, key: DefId) -> Fubar<'tcx> { ... }
注意:大多数 rustc_*
crate仅提供 本crate provider。
几乎所有的外部 provider 都会通过 rustc_metadata
crate 进行处理,后者会从 crate 元数据中加载信息。
但是在某些情况下,某些crate可以既提供本地也提供外部crate查询,在这种情况下,他们通过provide_both
定义了 provide
和 provide_extern
函数,供rustc_driver
调用。
添加一种新的查询
假设您想添加一种新的查询,您该怎么做? 定义查询分为两个步骤:
- 首先,必须指定查询名称和参数; 然后,
- 您必须在需要的地方提供查询提供程序。
要指定查询名称和参数,您只需将条目添加到
compiler/rustc_middle/src/query/mod.rs
中的大型宏调用之中,类似于:
rustc_queries! {
Other {
/// Records the type of every item.
query type_of(key: DefId) -> Ty<'tcx> {
cache { key.is_local() }
}
}
...
}
查询分为几类(Other
,Codegn
,TypeChecking
等)。
每组包含一个或多个查询。 每个查询的定义都是这样分解的:
query type_of(key: DefId) -> Ty<'tcx> { ... }
^^ ^^^^^^^ ^^^^^ ^^^^^^^^ ^^^
| | | | |
| | | | 查询修饰符
| | | 查询的结果类型
| | 查询的 key 的类型
| 查询名称
query 关键字
让我们一一介绍它们:
-
query关键字: 表示查询定义的开始。
-
**查询名称:**查询方法的名称(
tcx.type_of(..)
)。也用作生成的表示此查询的结构体的名称(ty::queries::type_of
)。 -
**查询的 key 的类型:**此查询的参数类型。此类型必须实现
ty::query::keys::Key
trait,该trait定义了如何将其映射到 crate 等等。 -
查询的结果类型: 此查询产生的类型。 这种类型应该
(a)不使用
RefCell
等内部可变性模式,并且 (b)可以廉价地克隆。对于非平凡的数据类型,建议使用 Interning 方法或使用Rc
或Arc
。- 一个例外是
ty::steal::Steal
类型,该类型用于廉价地修改MIR。有关更多详细信息,请参见Steal
的定义。不应该在不警告@rust-lang/compiler
的情况下添加对Steal
的新的使用。
- 一个例外是
-
查询修饰符: 用于自定义查询处理方式的各种标志和选项(主要用于增量编译)。
因此,要添加查询:
- 使用上述格式在
rustc_queries!
中添加一个条目。 - 通过修改适当的
provide
方法建立和 provider 的关联; 或根据需要添加一个新文件,并确保rustc_driver
会调用它。
查询结构体和查询描述
对于每种类型,rustc_queries
宏都会生成一个以查询名字命名的“查询结构体”。
此结构体是描述查询的一种占位符。 每个这样的结构都要实现self::config::QueryConfig
trait,
该 trait 上有该特定查询的 键/值 的关联类型。
基本上,生成的代码如下所示:
// Dummy struct representing a particular kind of query:
pub struct type_of<'tcx> { data: PhantomData<&'tcx ()> }
impl<'tcx> QueryConfig for type_of<'tcx> {
type Key = DefId;
type Value = Ty<'tcx>;
const NAME: QueryName = QueryName::type_of;
const CATEGORY: ProfileCategory = ProfileCategory::Other;
}
您可能希望实现一个额外的trait,称为 self::config::QueryDescription
。
这个 trait 在发生循环引用错误时会被使用,为查询提供一个“人类可读”的名称,以便我们可以探明循环引用发生的情况。
如果查询键是 DefId
,则可以不实现这个 trait,但是如果不实现它,则会得到一个相当普遍的错误(“processing foo
...”)。
您可以将新的 impl 放入config
模块中。 像这样:
impl<'tcx> QueryDescription for queries::type_of<'tcx> {
fn describe(tcx: TyCtxt, key: DefId) -> String {
format!("computing the type of `{}`", tcx.def_path_str(key))
}
}
另一个选择是添加desc
修饰符:
rustc_queries! {
Other {
/// Records the type of every item.
query type_of(key: DefId) -> Ty<'tcx> {
desc { |tcx| "computing the type of `{}`", tcx.def_path_str(key) }
}
}
}
rustc_queries
宏会自动生成合适的 impl
。
The Query Evaluation Model in Detail
Incremental compilation
Incremental compilation In Detail
Debugging and Testing
Profiling Queries
Salsa
Rustc 中的内存管理
Rustc 在内存管理方面相当谨慎。编译器在整个编译过程中需要分配 大量 的数据结构,如果我们不够谨慎,这将会耗费大量时间和空间。
使用 arenas 和 interning 是编译器管理内存的主要方式之一。
Arenas 和 Interning
在编译期间我们需要创建大量的数据结构。出于对性能的考虑,我们通常从全局内存池中分配这些数据结构; 每个数据结构都从一个长期 arena 中分配一次。这就是所谓的 arena allocation。这个系统减少了内存的分配/释放。它还允许简单地比较类型是否相等: 对每个 interned 类型 X
实现了 X
的 PartialEq
,因此我们只比较指针就可以判断是否相等。 CtxtInterners
类型包含一系列 interned 类型和 arena 本身的映射。
例: ty::TyS
以 ty::TyS
为例,它表示编译器中的类型(在这里了解更多)。每当我们想要构造一个类型时,编译器都不会傻乎乎地直接从缓冲区分配。相反,编译器检查是否构造过该类型。如果构造过的话,只需要获取一个指向之前构造个的类型的指针,否则,就会创建一个新的指针。对于这个设计,如果想知道两种类型是否相同,只需要比较两个指针。TyS
是精心设计的,所以你永远无法在栈上构造 TyS
。你只能从这个 arena 分配并 intern TyS
,所以它是独一无二的。
在编译开始时,我们会创建一个缓冲区,每当需要分配一个类型时,就从缓冲区中使用这些类型。如果用完了,就会再创建一个。缓冲区的生命周期为 'tcx
。我们的类型绑定到该生命周期,因此当编译完成时,与该缓冲区相关的所有内存都被释放,'tcx
的引用将无效。
除了类型之外,还可以分配很多其它的 arena-allocated 数据结构,这些数据结构可以在该模块中找到。以下是一些例子:
Substs
,分配给mk_substs
– 这会 intern 一个切片类型,通常用于指定要替换泛型的值(例如HashMap<i32, u32>
将被表示为切片&'tcx [tcx.types.i32, tcx.types.u32]
)。TraitRef
,通常通过值传递 – 一个 trait 引用 包含一个引用的 trait 及其各种类型参数(包括Self
),如i32: Display
(这里 def-id 会引用Display
trait,并且子类型包含i32
)。 注意def-id
的定义及讨论在AdtDef and DefId
部分。Predicate
定义 trait 系统要保证的东西 (见traits
模块)。
tcx 和怎样使用生命周期
tcx
(“typing context”)是编译器中的中枢数据结构。它是用于执行各种查询的上下文。 TyCtxt
结构体定义了对这个共享上下文的引用:
tcx: TyCtxt<'tcx>
// ----
// |
// arena lifetime
如你所见,TyCtxt
类型使用生命周期参数。当你看到类似 'tcx
生命周期的引用时,你就知道它指的是 arena-allocated 的数据(或者说,数据的生命周期至少与 arenas 一样长)。
关于生命周期
Rust 编译器是一个相当大的程序,包含大量的大数据结构(如 AST、 HIR 和类型系统),因此非常依赖于 arenas 和引用(references)来减少不必要的内存使用。这体现在使用插入编译器(例如 driver)的方式上,倾向于使用“push”风格(回调)的 API ,而不是 Rust-ic 风格的“pull”风格(考虑 Iterator
trait)。
编译器通过大量使用线程本地存储和 interning 来减少复制,同时也避免了无处不在的生命期而导致的用户不友好。rustc_middle::ty::tls
模块用于访问这些线程局部变量,尽管你很少需要接触。
Rustc 中的序列化
Rustc 需要在编译期 序列化 和反序列化各种数据。特别是:
- "Crate 元数据",主要是查询输出,在编译 crate 时,以二进制格式序列化并输出到
rlib
和rmeta
文件中,由依赖该库的 crate 将这些文件反序列化。 - 某些查询输出以二进制格式序列化为持久化增量编译结果。
-Z ast-json
和-Z ast-json-noexpand
标记以 json 格式序列化 AST, 并将结果输出到标准输出。CrateInfo
使用-Z no-link
标记时被序列化到 json,使用-Z link-only
标志时,从 json 反序列化。
Encodable
和 Decodable
trait
rustc_serialize
crate 为可序列化类型定义了两个 trait:
pub trait Encodable<S: Encoder> {
fn encode(&self, s: &mut S) -> Result<(), S::Error>;
}
pub trait Decodable<D: Decoder>: Sized {
fn decode(d: &mut D) -> Result<Self, D::Error>;
}
还为整型,浮点型,bool
,char
,str
和各种通用标准库类型都定义了这两个 trait 的实现。
由这些类型组合成的类型,通常通过 derives 实现 Encodable
和 Decodable
。这些生成的实现将结构体或枚举中的字段反序列化。对于一个结构体的实现像下面这样:
#![feature(rustc_private)]
extern crate rustc_serialize;
use rustc_serialize::{Decodable, Decoder, Encodable, Encoder};
struct MyStruct {
int: u32,
float: f32,
}
impl<E: Encoder> Encodable<E> for MyStruct {
fn encode(&self, s: &mut E) -> Result<(), E::Error> {
s.emit_struct("MyStruct", 2, |s| {
s.emit_struct_field("int", 0, |s| self.int.encode(s))?;
s.emit_struct_field("float", 1, |s| self.float.encode(s))
})
}
}
impl<D: Decoder> Decodable<D> for MyStruct {
fn decode(s: &mut D) -> Result<MyStruct, D::Error> {
s.read_struct("MyStruct", 2, |d| {
let int = d.read_struct_field("int", 0, Decodable::decode)?;
let float = d.read_struct_field("float", 1, Decodable::decode)?;
Ok(MyStruct { int, float })
})
}
}
编码和解码 arena allocated 类型
Rustc 有许多 arena allocated 类型。如果不访问分配这些类型的 arena 就无法反序列化这些类型。TyDecoder
和 TyEncoder
trait 是允许访问 TyCtxt
的 Decoder
和 Encoder
的 super trait。
对于包含 arena allocated 类型的类型,则将实现这些 trait 的 Encodable
和 Decodable
的类型参数绑定在一起。例如
impl<'tcx, D: TyDecoder<'tcx>> Decodable<D> for MyStruct<'tcx> {
/* ... */
}
TyEncodable
和 TyDecodable
derive 宏 将其扩展为这种实现。
解码实际的 arena allocated 类型比较困难,因为孤儿规则导致一些实现无法编写。为解决这个问题,rustc_middle
中的定义的 RefDecodable
trait。可以给任意类型实现。TyDecodable
宏会调用 RefDecodable
去解码引用,但是对不同的泛型代码实际上需要特定的类型解码器 Decodable
。
对 interned 类型而言,使用新的类型包装器,如 ty::Predicate
和手动实现 Encodable
和 Decodable
可能更简单,而不是手动实现 RefDecodable
。
Derive 宏
rustc_macros
crate 定义各种 drive,帮助实现 Decodable
和 Encodable
。
Encodable
和Decodable
宏会生成适用于所有Encoders
和Decoders
的实现。这些应该用在不依赖rustc_middle
的 crate 中,或必须序列化但没有实现TyEncoder
的类型。MetadataEncodable
和MetadataDecodable
生成仅允许通过rustc_metadata::rmeta::encoder::EncodeContext
和rustc_metadata::rmeta::decoder::DecodeContext
解码的实现。这些用在包含rustc_metadata::rmeta::Lazy
的类型中。TyEncodable
和TyDecoder
生成适用于任意TyEncoder
或TyDecoder
的实现。这些仅用于 crate 元数据和/或增量缓存中序列化类型,rustc_middle
中大多数是可序列化类型。
Shorthands
Ty
可以深度递归,如果每个 Ty
被编码会导致 crate 元数据变的非常大。为解决这个问题,每个 TyEncoder
的输出中都有序列化类型的位置缓存。如果要编码的类型在缓存中,则编码写入文件的字节偏移量,而不是像通常那样的序列化类型。类似的方案用于 ty::Predicate
。
Lazy<T>
在创建 TyCtxt<'tcx>
之前先加载 crate 元数据,因此一些反序列化需要推迟到元数据的初始化载入。Lazy<T>
类型将(相对)偏移量包装在了已序列化的 T
的 crate 元数据中。
Lazy<[T]>
和 Lazy<Table<I, T>>
类型提供了一些功能 Lazy<Vec<T>>
和 Lazy<HashMap<I, T>>
:
- 可以直接从迭代器编码
Lazy<[T]>
,无需事先收集到Vec<T>
中。 - 索引到
Lazy<Table<I, T>>
不需要解码除正在读取条目以外的条目。
注意: 不缓存 Lazy<T>
第一次反序列化后的值。相反,查询系统是缓存这些结果的主要方式。
Specialization
少数类型,特别是 DefId
,针对不同的 Encoder
需要采用不同的实现。目前,这是通过 ad-hoc 专门处理:
DefId
有一个 default
实现 Encodable<E>
和一个专有的 Encodable<CacheEncoder>
。
并行编译
大多数编译器都不是并行的,这是一个提高编译器性能的机会。
截止 2021 年 1 月,用于显式并行化编译器的工作已停止。有很多设计和正确性的工作需要完成。
可以在 config.toml
中启用它来尝试当前的并行编译器工作。
这项工作有一些基本思路:
- 编译器中有很多循环,它们只是迭代一个 crate 中的所有项,。这些都可能可以并行化。
- 我们可以使用(一个自定义分支)
rayon
并行运行任务。自定义分支允许执行 DAG 任务,而不仅仅是树。 - 目前有许多全局数据结构需要设置为线程安全的。这里的一个关键策略是将内部可变的数据结构(如: Cell) 转换为与它们同级的线程安全结构(如: Mutex)。
截至 2021 年 2 月,由于人力不足,大部分这方面的努力被搁置。我们有一个可以正常工作的原型,在许多情况下都有很好的性能收益。然而,有两个障碍:
-
目前尚不清楚哪些并发需要保持不变的不变性。审核工作正在进行中,但似乎已停滞不前。
-
有很多锁竞争,随着线程数增加到 4 以上,实际上会降低性能。
这里有一些可以用来学习更多的资源(注意其中一些有点过时了):
Rustdoc 内部工作原理
本页介绍了 rustdoc 的 pass 和模式。有关rustdoc的概述, 请参阅“Rustdoc概述”一章。
从 crate 到 clean
在 core.rs 中有两个主要项目:DocContext
结构和 run_core
函数。
后者会让 rustdoc
调用 rustc
将 crate 编译到 rustdoc
可以接手的地步。
前者是状态容器,用于在 crate 中爬取信息时收集其文档。
crate 爬取的主要过程是通过几个在 clean/mod.rs
中的 Clean
trait 实现完成的。
Clean
trait 是一个转换 trait,它定义了一个方法:
pub trait Clean<T> {
fn clean(&self, cx: &DocContext) -> T;
}
clean/mod.rs
还定义了稍后用于渲染文档页面的 “clean 过的” AST 类型。
通常,对于每个 Clean
的实现,都会从 rustc 中获取一些 AST 或 HIR 类型,
并将其转换为适当的“clean 过的”的类型。
更“大型”的构造(例如模块或相关项目)可能会在其 Clean
实现中进行一些额外的处理,
但是在大多数情况下,这些实现都是直接的转换。
该模块的入口是 impl Clean<Crate> for visit_ast::RustdocVisitor
,由上面的 run_core
调用。
您看,我实际上前面撒了一点小谎:
在 clean/mod.rs
中的事件发生之前,还有另一个AST转换。
在 visit_ast.rs
中的 RustdocVisitor
类型实际上抓取了一个
rustc_hir::Crate
以获取第一个中间表示形式,
该中间表示形式在 doctree.rs
中定义。
此过程主要是为了获得有关 HIR 类型的一些中间包装,并处理可见性和内联。
这是处理 #[doc(inline)]
、 #[doc(no_inline)]
和 #[doc(hidden)]
的地方,
以及决定 pub use 是否应该渲染为一整页还是模块页面中的“Reexport”行。
在 clean/mod.rs
中发生的另一件主要事情是将 doc 注释和 #[doc=""]
属性收集到 Attributes 结构的单独字段中,
这个字段出现在任何需要手写文档的地方。这使得之后容易收集此文档。
该过程的主要输出是一个 clean::Crate
,其中有一个项目树描述了目标 crate 中有公开文档的项目。
Hot potato
在继续进行下一步之前,在文档会上有一些重要的“pass”。
这些操作包括将单独的“属性”组合为单个字符串并去除前导空格,
以使文档能更容易地被 markdown 解析器解析,
或者删除未公开的项目或使用 #[doc(hidden)]
故意隐藏的项目。
这些都在 passes/
目录中实现,每文件一个 pass。
默认情况下,所有这些 pass 都会在 crate 进行,
但是与私有/隐藏的条目有关的 pass 可以通过将 --document-private-items
传入 rustdoc来绕过。
请注意,与之前的 AST 转换组不同,这些 pass 是在 cleaned crate 上运行的。
(严格来说,您可以微调 pass 甚至添加自己的pass,但是我们正在尝试 deprecate 这种行为。 如果您需要对这些 pass 进行更细粒度的控制,请告诉我们!)
以下是截至 2021年2月的 pass 列表:
-
calculate-doc-coverage
计算--show-coverage
使用的信息。 -
check-code-block-syntax
验证 Rust 代码块的语法 (```rust
) -
check-invalid-html-tags
检测 doc comments 中的不合法 HTML(如没有被正确关闭的<span>
)。 -
check-non-autolinks
检测可以或者应该使用尖括号写的链接 (这些代码应该由 nightly-only 的 lint 选项non_autolinks
开启)。 -
collapse-docs
将所有文档 attributes 拼接成一个文档 attribute。 这是必须的,因为每行文档注释都是单独的文档 attribute,collapse-docs
会将它们合并成单独的一个字符串,其中每个 attribute 之间都有换行符连接。 -
collect-intra-doc-links
解析 intra-doc links。 -
collect-trait-impls
为 crate 中的每个项目收集 trait 提示。 例如,如果我们定义一个实现 trait 的结构,则此过程将注意到该结构实现了该 trait。 -
doc-test-lints
在 doctests 上运行各种 lint。 -
propagate-doc-cfg
将#[doc(cfg(...))]
传递给子 item。 -
strip-priv-imports
删去所有私有导入语句(use
、extern crate
)。 这是必需的,因为 rustdoc 将通过将项目的文档内联到模块中或创建带有导入的 “Reexport” 部分来处理 公有 导入。 这个 pass 保证了这些导入能反应在文档上。 -
strip-hidden
和strip-private
从输出中删除所有doc(hidden)
和 私有 item。strip-private
包含了strip-priv-imports
。基本上,目标就是移除和公共文档无关的 item。 -
unindent-comments
移除了注释中多余的缩进,以使得 Markdown 能被正确地解析。 这是必需的,因为编写文档的约定是在///
或//!
标记与文档文本之间空一格,但是 Markdown 对空格敏感。 例如,具有四个空格缩进的文本块会被解析为代码块,因此如果我们不移除注释中的缩进,这些列表项/// A list: /// /// - Foo /// - Bar
会被违反用户期望地解析为代码块。
passes/
中也有一个 stripper
模块,但其中是一些 strip-*
pass 使用的工具函数,它并非是一个 pass。
从 clean 到 crate
这是 rustdoc 中“第二阶段”开始的地方。
这个阶段主要位于 html/
文件夹中,并且以 html/render.rs
中的 run()
开始。
该代码在渲染这个 crate 的所有文档前会负责设置渲染期间使用的 Context
、SharedContext
和 Cache
,
并复制每个渲染文档集中的静态文件(字体,CSS 和 JavaScript 等保存在 html/static/
中的文件),
创建搜索索引并打印出源代码渲染。
直接在 Context
上实现的几个函数接受 clean::Crate
参数,
并在渲染项或其递归模块子项之间建立某种状态。
从这里开始,通过 html/layout.rs
中的巨大 write!()
调用,开始进行“页面渲染”。
从项目和文档中实际生成HTML的部分发生在一系列 std::fmt::Display
实现和接受 &mut std::fmt::Formatter
的函数中。
写出页面正文的顶层实现是 html/render.rs
中的 impl <'a> fmt::Display for Item <'a>
,
它会基于被渲染的 Item
调用多个 item_*
之一。
根据您要查找的渲染代码的类型,您可能会在 html/render.rs
中找到主要项目,
例如 “结构体页面应如何渲染” 或者对于较小的组件,对应项目可能在 html/format.rs
中,
如“我应该如何将 where 子句作为其他项目的一部分进行打印”。
每当 rustdoc 遇到应在其上打印手写文档的项目时,
它就会调用 html/markdown.rs
中的与 Markdown 部分的接口。
其中暴露了一系列包装了字符串 Markdown 的类型,
并了实现 fmt::Display
以输出 HTML 文本。
在运行 Markdown 解析器之前,要特别注意启用某些功能(如脚注和表格)并在 Rust 代码块中添加语法高亮显示(通过 html/highlight.rs
)。
这里还有一个函数(find_testable_code
),
该函数专门扫描Rust代码块,以便测试运行程序代码可以在 crate 中找到所有 doctest。
从 soup 到 nuts
(另一个标题: "An unbroken thread that stretches from those first Cell
s to us")
重要的是要注意,AST 清理可以向编译器询问信息
(至关重要的是,DocContext
包含 TyCtxt
),
但是页面渲染则不能。在 run_core
中创建的 clean::Crate
在传递给
html::render::run
之前传递到编译器上下文之外。
这意味着,在项目定义内无法立即获得的许多“补充数据”,
例如哪个 trait 是语言使用的 Deref
trait,需要在清理过程中收集并存储在 DocContext
中,
并在 HTML 渲染期间传递给 SharedContext
。
这表现为一堆共享状态,上下文变量和 RefCell
。
还要注意的是,某些来自“请求编译器”的项不会直接进入 DocContext
中 ——
例如,当从外部 crate 中加载项时,
rustdoc 会询问 trait 实现并基于该信息生成新的 Item
。
它直接进入返回的 Crate
,而不是通过 DocContext
。
这样,就可以在呈现 HTML 之前将这些实现与其他实现一起收集。
其他技巧
所有这些都描述了从 Rust crate 生成HTML文档的过程,
但是 rustdoc 可以以其他几种主要模式运行。
它也可以在独立的 Markdown 文件上运行,也可以在 Rust 代码或独立的 Markdown 文件上运行 doctest。
对于前者,它直接调用 html/markdown.rs
,可以通过选项将目录插入到输出 HTML 的模式。
对于后者,rustdoc 运行类似的部分编译以获取在 test.rs
中的文档的相关信息。
但是它并不经过完整的清理和渲染过程,而是运行了一个简单得多的 crate walk,仅抓取手写的文档。
与上述 html/markdown.rs
中的 find_testable_code
结合,它会建立一组要运行的测试,然后再将其交给测试运行器。
test.rs
中一个值得注意的的位置是函数 make_test
,在该函数中,手写 doctest
被转换为可以执行的东西。
可以在这里找到一些关于 make_test
的更多信息。
Dotting i's and crossing t's
所以简而言之,这就是rustdoc的代码,但是 repo 中还有很多事情要处理。
由于我们手头有完整的 compiletest
套件,因此在 src/test/rustdoc
中有一组测试可以确保最终的 HTML 符合我们在各种情况下的期望。
这些测试还使用了补充脚本 src/etc/htmldocck.py
,
该脚本允许它使用 XPath 表示法浏览最终的 HTML,以精确查看输出结果。
rustdoc测试可用的所有命令的完整说明(例如 @has
和 @matches
)位于 htmldocck.py
中。
要在 rustdoc 测试中使用多个 crate,请添加 // aux-build:filename.rs
到测试文件的顶部。应该将 filename.rs
放置在相对于带有注释的测试文件的 auxiliary
目录中。
如果您需要为辅助文件构建文档,请使用 // build-aux-docs
。
此外,还有针对搜索索引和 rustdoc 查询它的能力的独立测试 。
src/test/rustdoc-js
中的文件每个都包含一个不同的搜索查询和预期结果(按“搜索”标签细分)。
这些文件由 src/tools/rustdoc-js
和 Node.js 运行时中的脚本处理。
这些测试没有详尽的描述,但是可以在 basic.js
中找到一个包含所有选项卡结果的宽泛示例。
基本思想是,将给定的 QUERY
与一组 EXPECTED
结果相匹配,并附上每个 item 的完整路径。
本地测试
生成的 HTML 文档的某些功能可能需要跨页面使用本地存储,如果没有 HTTP 服务器,这将无法正常工作。 要在本地测试这些功能,可以运行本地 HTTP 服务器,如下所示:
$ ./x.py doc library/std --stage 1
# The documentation has been generated into `build/[YOUR ARCH]/doc`.
$ python3 -m http.server -d build/[YOUR ARCH]/doc
现在,您可以像浏览 Internet 上的文档一样浏览本地文档。 例如,std
的网址将是 /std/
。
See also
第三部分:源码表示
这部分描述了从用户那里获取原始源代码并将其转换为编译器可以轻松处理的各种形式的过程。他们被称作中间表示(IRs)。
此过程首先从编译器了解用户的要求开始:解析给定的命令行参数并确定要编译的内容。之后,编译器将用户输入转换为一系列 IR ,这些 IR 看起来越来越不像用户写的内容。
命令行参数
命令行参数记录在 rustc book 中。 所有稳定的参数都应在此处记录。不稳定的参数应记录在 unstable book 中。
有关添加新命令行参数的过程的详细信息,请参见 forge guide for new options 。
指南
- 参数应彼此正交。例如,如果我们有多个操作,如
foo
和bar
,具有生成 json 的变体,则添加额外的--json
参数比添加--foo-json
和--bar-json
更好。 - 避免使用带有
no-
前缀的参数。相反,使用parse_bool
函数,比如-C embed-bitcode=no
。 - 考虑参数被多次传递时的行为。在某些情况下,应该(按顺序)累积值。在另一些情况下,后面的参数应覆盖前面的参数(例如,lint-level 参数)。如果多个参数的含义太模糊,那么一些参数(比如
-o
)应该生成一个错误。 - 如果仅为了编译器脚本更易于理解,请始终为选项提供长的描述性名称。
--verbose
参数用于向 rustc 的输出中添加详细信息。例如,将其与--version
参数一起使用可提供有关编译器代码哈希值的信息。- 实验性参数和选项必须放在
-Z unstable-options
后面。
Rustc Driver 和 Rustc Interface
rustc_driver
本质上是 rustc
的 main()
函数。它使用 rustc_interface
crate 中定义的接口,按正确顺序执行编译器各个阶段。
rustc_interface
crate 为外部用户提供了一个(不稳定的)API,用于在编译过程中的特定时间运行代码,从而允许第三方有效地使用 rustc
的内部代码作为库来分析 crate 或在进程中模拟编译器(例如 RLS 或 rustdoc )。
对于那些将 rustc
作为库使用的用户,rustc_interface::run_compiler()
函数是编译器的主要入口点。它接受一个编译器配置参数,以及一个接受 Compiler
参数的闭包。run_compiler
从配置中创建一个 Compiler
并将其传递给闭包。在闭包内部,您可以使用 Compiler
来驱动查询以编译 crate 并获取结果。这也是 rustc_driver
所做的。您可以在这里看到有关如何使用 rustc_interface
的最小示例。
您可以通过 rustdocs 查看 Compiler
当前可用的查询。您可以通过查看 rustc_driver
的实现,特别是 rustc_driver::run_compiler
函数(不要与 rustc_interface::run_compiler
混淆)来查看如何使用它们的示例。rustc_driver::run_compiler
函数接受一堆命令行参数和一些其他配置,并推动编译完成。
rustc_driver::run_compiler
还接受一个 Callbacks
,它是一个允许自定义编译器配置以及允许一些自定义代码在编译的不同阶段之后运行的 trait 。
警告: 本质来说,编译器内部 API 总是不稳定的,但是我们会尽力避免不必要的破坏。
示例:通过 rustc_interface
进行类型检查
rustc_interface
允许您在编译的各个阶段与 Rust 代码交互。
获取表达式的类型
// 完整程序见 https://github.com/rust-lang/rustc-dev-guide/blob/master/examples/rustc-driver-interacting-with-the-ast.rs 。 let config = rustc_interface::Config { input: config::Input::Str { name: source_map::FileName::Custom("main.rs".to_string()), input: "fn main() { let message = \"Hello, world!\"; println!(\"{}\", message); }" .to_string(), }, /* 其他配置 */ }; rustc_interface::run_compiler(config, |compiler| { compiler.enter(|queries| { // 分析 crate 并检查光标下的类型。 queries.global_ctxt().unwrap().take().enter(|tcx| { // 每次编译包含一个单独的 crate 。 let krate = tcx.hir().krate(); // 遍历 crate 中的顶层项,寻找 main 函数。 for (_, item) in &krate.items { // 使用模式匹配在 main 函数中查找特定节点。 if let rustc_hir::ItemKind::Fn(_, _, body_id) = item.kind { let expr = &tcx.hir().body(body_id).value; if let rustc_hir::ExprKind::Block(block, _) = expr.kind { if let rustc_hir::StmtKind::Local(local) = block.stmts[0].kind { if let Some(expr) = local.init { let hir_id = expr.hir_id; // hir_id 标识字符串 "Hello, world!" let def_id = tcx.hir().local_def_id(item.hir_id); // def_id 标识 main 函数 let ty = tcx.typeck(def_id).node_type(hir_id); println!("{:?}: {:?}", expr, ty); // 打印出 expr(HirId { owner: DefIndex(3), local_id: 4 }: "Hello, world!"): &'static str } } } } } }) }); });
示例:通过 rustc_interface
获取诊断信息
rustc_interface
允许您拦截将被打印到 stderr 的诊断信息。
获取诊断信息
要从编译器获取诊断信息,请配置 rustc_interface::Config
将诊断信息输出到一个缓冲区,然后运行 TyCtxt.analysis
:
#![allow(unused)] fn main() { // 完整程序见 https://github.com/rust-lang/rustc-dev-guide/blob/master/examples/rustc-driver-getting-diagnostics.rs 。 let buffer = sync::Arc::new(sync::Mutex::new(Vec::new())); let config = rustc_interface::Config { opts: config::Options { // 将编译器配置为以紧凑的JSON格式发出诊断信息。 error_format: config::ErrorOutputType::Json { pretty: false, json_rendered: rustc_errors::emitter::HumanReadableErrorType::Default( rustc_errors::emitter::ColorConfig::Never, ), }, /* 其他配置 */ }, // 重定向编译器的诊断信息输出到一个缓冲区。 diagnostic_output: rustc_session::DiagnosticOutput::Raw(Box::from(DiagnosticSink( buffer.clone(), ))), /* 其他配置 */ }; rustc_interface::run_compiler(config, |compiler| { compiler.enter(|queries| { queries.global_ctxt().unwrap().take().enter(|tcx| { // 在本地 crate 上运行分析阶段以触发类型错误。 tcx.analysis(rustc_hir::def_id::LOCAL_CRATE); }); }); }); // 读取缓冲区中的诊断信息。 let diagnostics = String::from_utf8(buffer.lock().unwrap().clone()).unwrap(); }
语法和 AST
直接使用源代码(source code)是非常不方便的和容易出错的,因此在我们做任何事之前,我们需要将源代码(raw source code)转换成抽象语法树(AST)。事实证明,即使我们这样做,仍需要做大量的工作,包括包括词法分析(lexing),解析(parsing),宏展开(macro expansion),名称解析(name resolution),条件编译(conditional compilation),功能门检查(feature-gate checking)和抽象语法树的验证(validation of the AST)。在这一章,我们来看一看所有这些步骤。
值得注意的是,这些工作之间并不总是有明确顺序的。例如,宏展开(macro expansion)依赖于名称解析(name resolution)来解析宏和导入的名称。解析(parsing)需要宏展开(macro expansion),这又可能需要解析宏的输出(output of the macro)。
分析(lexing)和解析(parsing)
2021年一月,词法分析器(lexer)和解析器(parser)正在进行重构, 以允许将它们提取到库(libraries)中。编译器要做的第一件事是将程序(Unicode字符)转换为比字符串更方便编译器使用的内容。这发生在词法分析(lexing)和解析(parsing)阶段。
词法分析(lexing)接受字符串并将其转换成 tokens 流(streams of tokens)。例如,
a.b + c
会被转换成 tokens a
, .
, b
, +
, c
。该词法分析器(lexer)位于
[rustc_lexer
] 中。
解析(Parsing)接受 tokens 流(streams of tokens)并将其装换位结构化的形式,这对于编译器来说更加容易使用,通常成为抽象语法树(Abstract
Syntax Tree,AST)。AST 镜像内存中的Rust 程序的结构(structure),使用 Span
将特定的 AST 节点链接(link)回其源文本。
在 rustc_ast
中定义了 AST ,此外还有一些关于 tokens 和 tokens 流(tokens and token streams)的定义,用于变异的(mutating) ASTs 数据结构/特征(traits),以及用于编译器的其他 AST 相关部分的共享定义(如词法分析器和宏扩张)。
解析器(parser)是在 rustc_parse
中定义的,以及词法分析器(lexer)的高级接口和在宏展开后运行的一些验证例行程序。特别是,rustc_parse::parser
包含了解析器(parser)的实现
解析器的主入口是通过各种在 parser crate 中的parse_*
函数和其他函数。它们允许你将SourceFile
(例如单个文件的源文件)转换为 token 流(token stream ),从 token 流(token stream )创建解析器(parser),然后执行解析器(parser)去获得一个Crate
(AST 的 root 节点)
为了减少复制的次数,StringReader
和 Parser
的生命周期都绑定到父节点 ParseSess
。它包含了解析时所需要的所有信息以及 SourceMap
本身。
注意,在解析时,我们可能遇到宏定义或调用,我们把这些放在一旁以进行展开 (见 本章)。展开本身可能需要解析宏的输出,这可能会涉及到更多需要展开的宏,等等。
更多源于词法分析(Lexical Analysis)
词法分析的代码被分为两个箱子(crates):
-
rustc_lexer
crate 负责将&str
分解为组成标记的块。将分析器(lexer)作为生成的有限状态机来实现是很流行的,但rustc_lexer
重的分析器(lexer)是手写的。 -
来自于
rustc_ast
的StringReader
将rustc_lexer
与rustc
详细的数据结构集成在一起。具体来说,它将Span
信息添加到rustc_lexer
和 interns 标识符返回的 tokens 中。
宏展开
rustc_ast
,rustc_expand
, 和rustc_builtin_macros
都在重构中,所以本章节中的部分链接可能会打不开。
Rust 有一个非常强大的宏系统。在之前的章节中,我们了解了解析器(parser)如何预留要展开的宏(使用临时的占位符 )。这个章节将介绍迭代地展开这些宏的过程,直到我们的 crate 会有一个完整的 AST,且没有任何未展开的宏(或编译错误)。
首先,我们将讨论宏展开和集成并输出到 ASTs 中的算法。随后,我们将看到健全的(hygiene)数据是如何被收集的。最后,我们将研究展开不同种类宏的细节。
非常多的算法和数据结构都在 rustc_expand
中,基础数据结构在 rustc_expand::base
中。
还要注意的是,cfg
和 cfg_attr
是其他宏中被特殊处理的,并在 rustc_expand::config
中处理。
展开和 AST 集成
首先,展开是发生在 crate 层面。给定一个 crate 的原始代码,编译器将生成一个包含所有宏展开、所有模块内联、等的巨大的 AST。这个过程的主要入口是在 MacroExpander::fully_expand_fragment
方法中。除了少数例外情况,我们整个 crate 上都使用这个方法(获得更详细的关于边缘案例的扩展的讨论,请参考 "eager-expansion",)。
在更高层次上,fully_expand_fragment
在迭代(反复)运行的,我们将保留一个未解析的宏调用队列(即尚未找到定义的宏)。我们反复地在队列中选择一个宏,对其进行解析,扩展,并将其集成回去。如果我们无法在迭代中取得进展,这代表着存在编译错误。算法如下 algorithm:
- 初始化一个队列(
queue
)用于保存未解析的宏调用。 - 反复直到队列(
queue
)晴空(或者没有任何进展,即有错误)- 尽可能地在我们已部分构建的 create 中解析(Resolve) 导入(imports)。
- 从我们部分已构建的 crate (类似方法、属性、派生)中尽可能多得收集宏
调用
,并将它们添加到队列中。 - 将第一元素从队列中取出,并尝试解析它。
- 如果它被成功解析:
-
运行宏扩展器(macro's expander)函数,该函数消费(consumes)一个
TokenStream
或 AST 并生成一个TokenStream
或AstFragment
(取决于宏的种类). (TokenStream
是一个TokenTree
s 的集合, 每一个都是一个 token (标点、标识符或文字)或被分隔的组合(在()
/[]
/{}
中的任何内容) 现在,我们以及知道了宏本身的一切,并且可以调用set_expn_data
去填满全局数据重的属性;这是与ExpnId
相关的 hygiene data 。(见下文"hygiene"章节) -
将 AST 集成到一个现有的大型的 AST 中。从本质上讲,这是“类似 token 的块” 变成适当的固定的 AST 并带有 side-tables。 它的发生过程如下:
- 如果宏产生 tokens(例如 proc macro),我们将其解析为 AST ,这可能会产生解析错误。
- 在展开的过程中,我们构建
SyntaxContext
s (hierarchy 2). (见下文"hygiene"章节) - 这三个过程在每个刚从宏展开的 AST 片段上依次地发生:
NodeId
s 由InvocationCollector
分配的。这还会从新的 AST 片段中收集新的宏调用,并将它们添加到队列中。- "Def paths" 被创建,同时
DefId
s 由DefCollector
分配的。 - 名字由
BuildReducedGraphVisitor
放入模块中(从解析器(resolver's)的角度来看)。
-
在展开单个宏并集成输出后,继续执行
fully_expand_fragment
的下一个迭代。
-
- 如果它没有被成功解析:
- 将宏放回队列中
- 继续下一个迭代。
错误恢复
如果我们在一次迭代中没有取得任何进展,那么我们就遇到了编译错误(例如一个未定义的宏或导入)。为了进行诊断,我们尝试从错误(未解析的宏或导入)中恢复。这允许编译在第一个错误之后继续进行,这样我们就可以一次报告更多错误。恢复不能使得编译通过。我们知道在这一节点上它会失败。恢复是通过将未成功解析的宏展开为 ExprKind::Err
来实现的。
名称解析
注意,这里涉及到名称解析:我们需要解析上述算法中的导入和宏名。这在 rustc_resolve::macros
中完成,它解析宏路径,验证这些解析,并报告各种错误(例如:“未找到”或“找到了,但它不稳定(unstable)”或“预期的x,但发现的y”)。但是,我们还没有尝试解析其他名称。这将在后面发生,我们将在下一章中看到。
Eager Expansion
Eager expansion 代表着我们在展开宏调用之前,先展开宏调用的参数。这仅对少数需要文字的特殊内置宏实现;首先对其中的一些宏展开参数可以获得更流畅的用户体验。作为一个例子,请考虑下属情况:
macro bar($i: ident) { $i }
macro foo($i: ident) { $i }
foo!(bar!(baz));
lazy expansion 会首先扩展 foo!
,eager expansion 会扩展 bar!
。
Eager expansion 不是一个普遍的(通用的) Rust 特性(feature)。实现更加普遍的 eager expansion 是具有挑战性的,但是为了用户体验,我们为一些内置宏实现了它(eager expansion)。内置宏是在 rustc_builtin_macros
实现,还有一些其他早期的代码生成工具,例如注入标准库的导入或生成测试的工具。在 [rustc_expand::build
] 有一些额外的帮助工具来构建 AST 片段(fragments)。Eager expansion 通常执行 lazy (normal) expansion 来展开子集。它是通过只在一个部分的 crate 的上来调用 fully_expand_fragment
来完成的。(与我们通常使用整个 crate 来调用相反)。
其他数据结构
以下是涉及到扩展和扩展的其他重要数据结构
ResolverExpand
- 一个用来阻隔(break)crate 的依赖的 trait。这允许解析服务在rustc_ast
中使用,虽然rustc_resolve
和 几乎所有其他的东西都依赖于rustc_ast
。ExtCtxt
/ExpansionData
- 用来保存在处理过程中各种中间数据。Annotatable
- 可以作为属性目标的 AST 片段。几乎和 AstFragment 相同,除了类型和可以由宏生成但不能用属性注释。MacResult
- 一个“多态的” AST 片段,可以根据他的AstFragmentKind
(item、expression、pattern)转换成不同的AstFragment
。
hygiene 和结构层次
如果您曾使用过 C/C++ 预处理器宏,就会知道有一些烦人的、难以调试的陷阱!例如,考虑以下代码:
#define DEFINE_FOO struct Bar {int x;}; struct Foo {Bar bar;};
// Then, somewhere else
struct Bar {
...
};
DEFINE_FOO
大多数人都避免这样写 C - 因为他无法通过编译。宏定义的 struct Bar
与代码中的结构 struct Bar
定义冲突。请再考虑以下代码:
#define DO_FOO(x) {\
int y = 0;\
foo(x, y);\
}
// Then elsewhere
int y = 22;
DO_FOO(y);
你看到任何问题了吗?我们想去生成调用 foo(22, 0)
但是我们得到了 foo(0, 0)
,因为在宏中已经定义了 y
!
这两个都是 macro hygiene 问题的例子。 Hygiene 关于如何处理名字定义在宏中。特别是,一个健康的宏系统可以防止由于宏中引入的名称而产生的错误。Rust 宏是卫生的(hygienic),因为不允许编写上述的 bugs。
在更高层次上,rust 编译器的卫生(hygiene)性是通过跟踪定义(引入)和使用名称的上下文来保证的。然后我们可以根据上下文消除名字的歧义。宏系统未来的迭代将允许宏的编写者更好地控制该上下文。例如宏的编写者可能想在宏调用的上下文中定义(引入)一个新的名称。另一种情况是,宏的编写者只在宏的作用域内使用变量(也就是说在宏的外部不可见)。
上下文被添加到 AST 节点。所有由宏生成的 AST 节点都附加了上下文。此外,可能还有些具有上下文的节点,例如一些解析语法糖(非宏展开节点被认为只有 root 上下文,将在后面阐述)。这个编译器,我们使用 rustc_span::Span
s 定位代码的位置。这个结构同样有卫生(hygiene)性信息,我们将在后面看到。
因为宏调用和定义可以是嵌套的,所以节点的语法上下文也必须是有层次的。比如说,如果我们扩展一个宏,有一个宏调用或者定义在生成的输出中,那么语法上下文应该反映出嵌套。
然而,事实证明,出于不同目的,我们实际上需要跟踪一些类型的上下文。因此一个 crate 的卫生(hygiene)信息不只是由一个而是由三个扩展层次构成的。
所有层次结构都需要某种 "macro ID" 来标识展开链中的单个元素。这个 ID 是 ExpnId
。所有的宏收到一个整数 ID ,当我们发现新的宏调用时,从 0 开始自增。所有层次结构都是从 ExpnId::root()
开始的(当前层次的父节点)。
rustc_span::hygiene
包含了所有卫生(hygiene)相关的算法(Resolver::resolve_crate_root
中的一些 hacks 在除外)和卫生(hygiene)相关的数据结构,这些结构都保存在全局数据中。
实际的层次结构存储在 HygieneData
中。这是一个全局数据,包含将装修和展开信息,可以从任意的 Ident
访问,无需任何上下文。
展开顺序层次结构
第一,层次结构将跟踪展开的顺序,即宏调用出现在另一个宏的输出中。
在这里,层次结构中的子元素将被标记为“最内层的”,ExpnData
结构自身包含宏定义和宏调用的属性子集,这些属性是全局可用的。ExpnData::parent
在当前层次结构中,跟踪 子节点 -> 父节点的链接。
例如
macro_rules! foo { () => { println!(); } }
fn main() { foo!(); }
在代码中,AST 节点最终会生成以下层次结构。
root
expn_id_foo
expn_id_println
宏定义的结构层次
第二,层次结构将跟踪宏定义的顺序。即我们展开一个宏,在其输出中出现另一个宏定义。这个层次结构比其他两个结构层次更复杂,更棘手。
SyntaxContext
通过 ID 表示此层次结构中的整个链。SyntaxContextData
包含了与给定的
SyntaxContext
相关的数据;大多数情况下,它是一个缓存,用于以不同方式过滤该链的结果。SyntaxContextData::parent
是此处 子节点-> 父节点 的链接,SyntaxContextData::outer_expns
是链中的各个元素。“链接运算符”在编译器代码中是SyntaxContext::apply_mark
。
上述提到的 Span
实际上只是代码位置和 SyntaxContext
的紧凑表现。同样的,Ident
只是
Symbol
+ Span
(即一个被替换的字符串+健全性数据)
对于内置宏,我们使用 SyntaxContext::empty().apply_mark(expn_id)
上下文,这样的宏是被认为是定义在 root 层次结构纸上。我们为 proc-macros 做一样的事,因为我们还没有实现跨 crate 并保证其卫生(hygiene)。
如果 token 在宏生成之前有上下文 X
,那么在宏生成后上下文会有 X -> macro_id
。以下是一些例子:
Example 0:
macro m() { ident }
m!();
这里 ident
有最初的上下文 SyntaxContext::root()
。在 m
生成后,ident
会有上下文 ROOT -> id(m)
。
Example 1:
macro m() { macro n() { ident } }
m!();
n!();
这个例子中,ident
有最初的 ROOT
,在第一个宏被展开后上下文变为 ROOT -> id(m)
,继续展开后得到上下文 ROOT -> id(m) -> id(n)
。
Example 2:
注意,这些链并不完全由他们最后的一个元素决定,换句话来说 ExpnId
和 SyntaxContext
不是同构的。
macro m($i: ident) { macro n() { ($i, bar) } }
m!(foo);
在所有展开后,foo
有上下文 ROOT -> id(n)
,bar
有上下文
ROOT -> id(m) -> id(n)
。
最后要提的一点是,目前的结构层次受限于 "context transplantation hack" 。基本上,更现代(实现性的)宏(macro
) 比旧的 MBE 系统有更强的卫生(hygiene)性,但这可能导致两者之间奇怪的交互。这种 hack 实现是为了让所有事暂时“正常工作”。
调用的结构层次
第三也是最后一个,结构层次是跟踪宏调用的位置。
在结构层次 ExpnData::call_site
中是 子节点 -> 父节点 的链接。
这里有一个例子:
macro bar($i: ident) { $i }
macro foo($i: ident) { $i }
foo!(bar!(baz));
对于 baz
AST 节点是最后输出的,第一个结构层次是 ROOT -> id(foo) -> id(bar) -> baz
,而第三结构层次是 ROOT -> baz
。
宏回溯
在 rustc_span
中实现了宏回溯,其使用了 rustc_span::hygiene
的健全机制。
产生宏输出
上述内容中我们看到了中的宏的输出如何被集成到用于 crate 的 AST 中,我们还看到了如何为一个 crate 生成卫生(hygiene)数据。但是我们如何实际产生一个宏的输出呢?这将取决于宏的类型。
Rust 中有两种类型的宏:macro_rules!
类型(或称 示例宏( Macros By Example,MBE))和过程宏(procedural macros)(或 proc macros;包括自定义派生)。在解析阶段,正常的 Rust 解析器将保留宏及其调用内容。稍后将使用这部分代码将宏展开。
这里有一些重要的数结构和接口:
SyntaxExtension
- 一个更底层的宏表示,包含了它扩展函数,他将一个 token 流(TokenStream
)或 AST 转换成另一个TokenStream
或 AST 加上一些额外信息,例如稳定性,或在宏内允许使用的不稳定特性的列表。SyntaxExtensionKind
- 展开方法可能会有很多不同的函数签名(接受一个 token 流,或者两个;或者接受一部分 AST 等等)。这是一个列出他们的枚举。ProcMacro
/TTMacroExpander
/AttrProcMacro
/MultiItemModifier
- traits 用于标识展开函数的签名
示例宏(Macros By Example)
MBEs 有自己等等解析器,不同于普通的 Rust 解析器。当宏展开时,我们可以调用 MBE 解析器去解析和展开宏。反过来,MBE 解析器在解析宏调用的内容时需要绑定元变量(例如$my_expr
),这可能会调用普通的 Rust 解析器。宏展开的代码在 compiler/rustc_expand/src/mbe/
示例
有个例子供参考提供是有助的。在本章的其他部分,每当我们提到 "示例 定义" 时,我们指得失以下内容:
macro_rules! printer {
(print $mvar:ident) => {
println!("{}", $mvar);
};
(print twice $mvar:ident) => {
println!("{}", $mvar);
println!("{}", $mvar);
};
}
$mvar
是一个 元变量 。与正常的变量不同,元变量不是绑定到计算中的值,而是在 编译时 绑定到 tokens 树。 token 是一个单独的语法“单元”,例如标识符(例 foo
)或者标点符号(例 =>
)。还有其他特殊的 tokens,例如 EOF
他表示没有其他更多的 tokens。Token 树由类似成对的圆括号的字符((
...)
,
[
...]
, 和 {
...}
) - 他们包括了 open 和 close,以及它们之间的所有标记(我们确实要求类似括号的字符需要保持要平衡)。让宏展开操作 token 流而不是源文件的原始字节,从而减少复杂性。宏扩展器(以及编译器的其余大多数)实际上并不十分在乎代码中某些语法构造的确切行和列。它只关心代码中使用了哪些构造。使用 tokens 使得我们可以关心 什么 而不必担心在 哪里 ,关于 tokens 跟多内容,可以参考本书 Parsing 一章。
当我们提到 “示例 调用” ,我们指以下代码片段:
printer!(print foo); // Assume `foo` is a variable defined somewhere else...
将宏调用展开为语法树的过程 println!("{}", foo)
,然后展开成
Display::fmt
调用成为 宏展开 ,是本章的主题。
示例宏 (MBE) 解析器
MBE 展开包括两个部分:解析定义和解析调用。有趣的是,两者都是由宏解析器完成的。
基本上,MBE 解析器类似于基于 NFA 的正则解析器。它使用的算法本质上类似于 Earley parsing
algorithm 。 宏解析器定义在 compiler/rustc_expand/src/mbe/macro_parser.rs
。
宏解析器的接口如下(稍作简化):
fn parse_tt(
parser: &mut Cow<Parser>,
ms: &[TokenTree],
) -> NamedParseResult
我们在宏解析器中使用这些项:
parser
是一个对普通 Rust 解析器的引用,包括了 token 流和解析会话(parsing session)。Token 流是我们将请求 MBE 解析器解析的内容。我们将使用原始的 token 流,将元变量绑定到对应的 token 树。解析会话(parsing session)可用于报告解析器错误。ms
是一个 匹配器 。这是一个 token 树序列,我们希望以此来匹配 token 树。
类似于正则解析器,token 流是输入,我们将其与 pattern ms
匹配。使用我们的示例,token 流可以是包含示例 调用 print foo
内部的 token 流,ms
可以是 token(树)print $mvar:ident
。
解析器的输出是 NamedParseResult
,它指示发生了三种情况中的哪一种:
- 成功:token 流匹配给定的匹配器
ms
,并且我们已经产生了从元变量到响应令牌树的绑定。 - 失败:token 流与
ms
不匹配。浙江导致出现错误消息,例:“No rule expected token blah” - 错误: 解析器中发生了一些致命的错误。例如,如果存在多个模式匹配,则会发生这种情况,因为这表明宏不明确。
所有的接口定义在 这里。
宏解析器的工作与普通的正则解析器几乎相同,只有一个例外:为了解析不通的元变量,例如ident
, block
, expr
等,宏解析器有时候必须回调到普通的 Rust 解析器。
如上所述,宏的定义和调用都使用宏解析器进行解析。这是非常不直观和自引用的。
解析宏的代码定义在 compiler/rustc_expand/src/mbe/macro_rules.rs
中。它定义用于匹配宏定义模式为 $( $lhs:tt => $rhs:tt );+
。换句话说,一个 macro_rules
定义在其主体中应最少出现一个 token 树,后面跟着 =>
,然后是另一个 token 树。当编译器遇到 macro_rules
定义时,它使用这个模式来匹配定义中每个规则的两个 token 树, 并使用宏解析器本身 。在示例定义中,元变量 $lhs
将会匹配 partten (print $mvar:ident)
和 (print twice $mvar:ident)
。 $rhs
将匹配 { println!("{}", $mvar); }
和 { println!("{}", $mvar); println!("{}", $mvar); }
partten 的主体。解析器将保留这些内容,以便在需要展开宏调用时使用。
当编译器遇到宏调用时,它会使用上述基于 NFA 的宏解析器解析该调用。但是,使用的匹配器是从宏定义的 arms 中提取的第一个 token 树($lhs
),使用我们的示例,我们尝试匹配 token 流中的 print foo
(来自匹配器的) print $mvar:ident
和从前面定义中提取的 print twice $mvar:ident
。算法是完全相同的,但是当宏解析器在当前匹配其中需要匹配非 non-terminal (例如 $mvar:ident
) 时,它会回调正常的 Rust 解析器以获取该非终结符的内容。这种情况下,Rust 会寻找一个 ident
token,它会找到 foo
并返回给宏解析器。然后,宏解析器照常进行解析。另外,请注意来自于不同 arms 的匹配器应该恰好有一个匹配调用;如果有多个匹配项,则该解析有二义性,而如果根本没有匹配项,则存在语法错误。
跟多关于解析器实现的信息请参考 compiler/rustc_expand/src/mbe/macro_parser.rs
macro
s and Macros 2.0
改进 MBE 系统,为它提供更多与卫生(hygiene)性相关的功能,更好的范围和可见性规则等,这是一个古老的,几乎没有文献记载的工作。不幸的是,最近在这方面还没有进行很多工作。 在内部,宏使用与当今的 MBE 相同的机制。 它们只是具有附加的语法糖,并且允许在名称空间中使用。
过程(Procedural)宏
如上所述,过程宏也在解析过程中进行了扩展。 但是,它们使用了一种完全不同的机制。 过程宏不是作为编译器中的解析器,而是作为自定义的第三方 crate 实现的。 编译器将在其中编译 proc macro crate 和带有特殊注释的函数(即 proc macro 本身),并向它们传递 tokens 流。
然后 proc macro 可以转换 token 流和输出新的 token 流,该 token 流被合称为 AST。
值得注意的是,proc macros 使用的 token 流类型是 稳定的 ,因此rustc
不在内部使用它(因为内部数据结构是不稳定的)。 像以前一样,编译器的 token 流为 rustc_ast::tokenstream::TokenStream
。 这将转换为稳定的 proc_macro::TokenStream
并返回 rustc_expand::proc_macro
和rustc_expand::proc_macro_server
。 因为 Rust ABI 不稳定,所以我们使用 C ABI 进行转换。
TODO: more here.
Custom Derive
自定义派生是 proc macro 的一种特殊类型。
TODO: more?
名称解析
在上一个章节,我们看到了如何在展开所有宏的情况下构建 AST。这个过程需要用名称解析(name resolution)来解析导入和宏的名字。在本章中,我们将展示这是如何实现的。
事实上,我们在展开宏的过程中并不做完全的名称解析 -- 我们只解析导入和宏在这个过程中。这要求知道什么是需要展开。在这之后,在获得整个 AST 之后,我们将执行全名解析来解析 crate 中的所有名称。这发生在 rustc_resolve::late
中。与宏展开不同,在这个后期展开中,我们只需要尝试解析一个名称一次,因为没有新增的名字,如果失败了,那么将抛出一个编译错误。
名称解析可能很复杂。这里有几个不同的命名空间(例如,宏,值,类型,生命周期)和名称可能在不同(嵌套的)范围。此外,针对不同类型的名称解析,其解析失败的原因也可能会不同。例如,在一个模块的作用域内,模块中存在尚未展开的宏和未解析的 glob 导入会导致解析失败。另一方面,在函数内,在我们所在的 block 中,外部作用域和全局作用域中都没有该名称会导致解析失败。
基础
在我们的程序中,我们可以通过给一个变量名,来引用 变量,类型,函数等等。这些名字并不总是唯一的。例如,下面是一个有效 Rust 程序:
#![allow(unused)] fn main() { type x = u32; let x: x = 1; let y: x = 2; }
我们是如何知道 第三行 x
是一个类型 (u32) 还是 数值 (1)。这个冲突将在名称解析中被解析。在这个特殊栗子中,名称解析定义 类型名称(type names) 和 变量名称(variable names) 在独立的命名空间,所以他们可以共存。
Rust 中的名称解析分为两个阶段。在第一阶段,将运行宏展开,我们将构建一个模块的书结构和解析导入。宏展开和名字解析通过ResolverAstLowering
特性(trait)来通信。
输入的第二阶段的输入是语法树,它通过解析输入文件和展开宏。这个阶段将 从链接源文件中所有的名称到相关关联的地方(即名称被引用的地方)。它还会生成有用的错误信息,如输入错误建议,要导入的特性(trait)或 lints 关于未使用的项。(or lints about unused items)
成功运行第二阶段(Resolver::resolve_crate
) 将创建一个索引,剩余的编译部分可以使用它来查询当前的名称(通过 hir::lowering::Resolver
接口)
名称解析在 rustc_resolve
crate 中,部分内容位于 lib.rs
中,其他模块中有一些帮助程序或 symbol-type logic 。
命名空间
不同类型的符号存在于在不同的命名空间中。例如 类型的名称 不与 变量的名称 冲突。这通常不会发生,因为变量以小写字母开头,而类型以大写字母开头。但这仅仅是一种约定。以下 Rust 代码是合法的,并通过编译(带有 warnings):
#![allow(unused)] fn main() { type x = u32; let x: x = 1; let y: x = 2; // See? x is still a type here. }
为了应对这种情况,并使用这些名称的作用域规则少有不同,解析起将它们分开,并为他们创建不同的机构。
也就是说,当(code)代码谈到命名空间,它并不代表模块的结构层次,它是 类型 vs 值 vs 宏。
作用域和 ribs
名称只在源代码的特定区域可见。这形成了一个层次结构,但不一定是简单的-如果一个作用域是另一个作用域的一部分,这不意味在外部可见的名称也是在内部可见的,或指的是同一样内容。
为了处理这种情况,编译器引入一个 Ribs 概念。这是一种抽象作用域。每每次可见名称可能发生变化时,一个新的 rib 被推入栈中。可能发生这种情况的地方包括:
- 明显的地方 - 花括号包围块,函数边界,模块。
- 通过 let 绑定引入 - shodow 另一个同名变量。
- 宏展开边缘 - 因对宏的卫生(hygiene)
ribs 栈会由内到外的搜索名称。这有助于找到名称最近的意思(这个名称不会被其他任何东西覆盖)。向外过渡 rib 也可能会改则未使用名称的规则 - 如果这里又一个嵌套的函数(非闭包),内层作用域内不能访问参数和进行本地绑定外层作用域的内容,即使他们在常规作用域规则中应该可见。一个例子:
#![allow(unused)] fn main() { fn do_something<T: Default>(val: T) { // <- New rib in both types and values (1) // `val` is accessible, as is the helper function // `T` is accessible let helper = || { // New rib on `helper` (2) and another on the block (3) // `val` is accessible here }; // End of (3) // `val` is accessible, `helper` variable shadows `helper` function fn helper() { // <- New rib in both types and values (4) // `val` is not accessible here, (4) is not transparent for locals) // `T` is not accessible here } // End of (4) let val = T::default(); // New rib (5) // `val` is the variable, not the parameter here } // End of (5), (2) and (1) }
因为对于不同作用域的规则有所不同,每个作用域会有他自己独立的与命名空间并行构造的 rib 栈。此外,也有对于那些没有完整命名空间的 local lables 也有一个 rib 栈(例如 loops 或者 blocks 的名称)。
总体策略
为了执行整个 crate 的名称解析,自上而下的遍历语法树,并解析每个遇到的名称。这适用于大多数类型的名称,因为在使用名称时,已经在 Rib 层次结构中引入了该名称。
这里有一些例外,一些会有些棘手,因为它们甚至可以在遇到之前就可以使用 - 因此需要扫描每一个项去填满 Rib。
其他甚至更有问题的导入,需要递归的定点解析和宏,需要在处理剩下代码之前进行解析和展开。
因此,名称解析是在多个阶段执行的。
Speculative crate loading
为了给出有用的错误,rustc 建议将未找到的路径导入作用域中。它是怎么做到的呢?他会检查每一个 crate 的每一个模块,并寻找可能的匹配项。这甚至包括还没有加载的 crate。
为尚未加载的导入的提供导入建议被称为_speculative crate loading_,因为不应报告任何遇到的错误:决定去加载这些导入的并非用户。执行此功能的函数是在 rustc_resolve/src/diagnostics.rs
中的lookup_import_candidates
。
为了 speculative loads 和用户的加载,解析通过传递一个 record_used
参数,当 speculative loads 时候,值为 false。
TODO:
这是第一遍学习代码的结果。绝对是不完整的,不够详细的。在某系地方也可能不准确。不过,它可能在将来能提供有用的帮助。
- 它究竟链接到什么?后续的编译阶段如何发布和使用该链接? 谁调用它以及如何实际使用它。
- 它是通过,然后仅使用结果,还是可以递增计算(例如,对于RLS)?
- 总体策略描述有点模糊。
- Rib这个名字来自哪里?
- 这东西有自己的测试,还是仅作为某些端到端测试的一部分进行测试?
#[test]
属性
今天,Rust 程序员依赖于称为 #[test]
的内置属性。您需要做的只是将一个函数标记为测试(test),并包含一些 断言(asserts),如下所示:
#[test]
fn my_test() {
assert!(2+2 == 4);
}
当程序使用 rustc --test
或 cargo test
命令进行编译的时候,它将生成可运行该程序及其他测试函数的可执行文件。这种测试方式允许所有测试于代码并存。你甚至可以将测试放入到私有模块中:
mod my_priv_mod {
fn my_priv_func() -> bool {}
#[test]
fn test_priv_func() {
assert!(my_priv_func());
}
}
此外,可以很容易的测试私有项目,而不用担心如何将它们导出给任何类型的外部测试设备。这是 Rust 中 工效(ergonomics)测试的关键。然而从语法上讲,这是相当奇怪的。如果这些函数是不可见的(private)的,主函数如何调用他们呢?rustc --test
是怎么做到的?
编译器中的 rustc_ast
crate 为 #[test]
实现了语法转译。本质上这是一个 fancy 的宏,它通过三个步骤重写了 crate。
Step 1: 重新导出(Re-Exporting)
如前所述,测试可以存在于私有模块内部,因此我们需要一种在不破坏现有代码的情况下将其暴露给主函数。因此 rustc_ast
将创建一个名为 __test_reexports
的本地模块,该模块递归地重复导出(Re-Exporting)测试。此扩展的代码示例转换为:
mod my_priv_mod {
fn my_priv_func() -> bool {}
pub fn test_priv_func() {
assert!(my_priv_func());
}
pub mod __test_reexports {
pub use super::test_priv_func;
}
}
现在,可以通过 my_priv_mod::__test_reexports::test_priv_func
访问我们的测试。对于更深的模块结构,__test_reexports
讲重新导出包含测试模块,因此位于 a::b::my_test
将变成 a::__test_reexports::b::__test_reexports::my_test
。尽管此过程看起来很安全,但是如果当前已存在 __test_reexports
模块会怎么样?答案:并不要紧。
为了解释,我们需要了解 AST 如何表示标识符(how the AST represents
identifiers)。每个函数,变量,模块的名称都不直接存储为 string,而是存储为不透明的 Symbol,它本质是每个标识符的 ID 号。编译器保留一个独立的哈希表,使我们可以在必要时(例如在打印语法错误时)恢复人类可读的 Symbol 名称。当编译器生成 __test_reexports
模块是,它会为标识符生成一个新的符号,因此尽管编译器生成的__test_reexports
可能与您创建的包共享一个名称,但不会共享一个 Symbol 。此技术可以防止在代码生成过程中发生名称冲突,这是 Rust 宏卫生(hygiene)的基础
Step 2: Harness Generation
现在我们可以从 crate 根目录访问我们的测试,我们需要对它们进行一些操作。 rustc_ast
生成如下模块:
#[main]
pub fn main() {
extern crate test;
test::test_main_static(&[&path::to::test1, /*...*/]);
}
其中 path::to::test1
是类型 test::TestDescAndFn
的常量。
尽管这种转换很简单,但它使我们对测试的实际运行方式有很多了解。将测试汇总到一个数组中,然后传递给名称为 test_main_static
的测试运行器。我们将返回到 TestDescAndFn
到底是什么,但是现在,关键点是有一个名为 test
crate,它是 Rust Core 的一部分,他实现了测试所有运行时,test
接口是不稳定的,所以与它交互的唯一方式是通过 #[test]
宏。
Step 3: Test Object Generation
如果您以前用 Rust 编写过测试,那么您可能熟悉一些测试函数上可用的一些可选属性。例如,如果我们预测测试会 panic ,可以用 #[should_panic]
来注释测试。看起来是如下的:
#[test]
#[should_panic]
fn foo() {
panic!("intentional");
}
这意味着我们的测试不仅仅是简单的函数,它们也有配置信息。test
将这个配置数据编码到一个名为 TestDesc
的结构体中。对于 crate 中的每一个测试函数,rustc_ast
将解析其属性并生成 TestDesc
实例。然后它将 TestDesc
和 test 函数组合到可预测名称的 TestDescAndFn
结构体中,test_main_static
对其进行操作。对于给定的测试,生成 TestDescAndFn
实例如下:
self::test::TestDescAndFn{
desc: self::test::TestDesc{
name: self::test::StaticTestName("foo"),
ignore: false,
should_panic: self::test::ShouldPanic::Yes,
allow_fail: false,
},
testfn: self::test::StaticTestFn(||
self::test::assert_test_result(::crate::__test_reexports::foo())),
}
一旦我们构建了这些测试对象的数组,它们就会通过步骤2中生成的管理传递给测试运行器。
检查生成的代码
在 nightly rust 中,有一个不稳定的标签叫做 unpretty
,你可以使用它在宏展开后打印出模块的源代码:
$ rustc my_mod.rs -Z unpretty=hir
rust 中的 panic
步骤1: 调用 panic!
宏
实际上有两个 panic 宏 - 一个定义在 core
中,一个定义在 std
中。这是因为 core
中的代码可能 panic。core
是在 std
之前构建的,但不管是 core
或 std
中的 panic,我们希望运行时使用相同的机制。
core 中 panic! 的定义
core
panic!
宏最终调用如下 (在 library/core/src/panicking.rs
):
#![allow(unused)] fn main() { // 注意 这个函数永远不会越过 FFI 边界; 这是一个 Rust 到 Rust 的调用 extern "Rust" { #[lang = "panic_impl"] fn panic_impl(pi: &PanicInfo<'_>) -> !; } let pi = PanicInfo::internal_constructor(Some(&fmt), location); unsafe { panic_impl(&pi) } }
实际上解决该问题需要通过几个间接层:
-
在
compiler/rustc_middle/src/middle/weak_lang_items.rs
中,panic_impl
用rust_begin_unwind
声明标记为 '弱 lang 项'。在rustc_typeck/src/collect.rs
中将实际符号名设置为rust_begin_unwind
。注意
panic_impl
被声明在一个extern "Rust"
块中,这意味着 core 将尝试调用一个名为rust_begin_unwind
的外部符号(在链接时解决) -
在
library/std/src/panicking.rs
中,我们有这样的定义:
#![allow(unused)] fn main() { /// core crate panic 的进入点。 #[cfg(not(test))] #[panic_handler] #[unwind(allowed)] pub fn begin_panic_handler(info: &PanicInfo<'_>) -> ! { ... } }
特殊 panic_handler
属性是通过 compiler/rustc_middle/src/middle/lang_items
解析。extract
函数将 panic_handler
属性转换一个 panic_impl
lang 项。
现在,我们在 std
中有一个匹配的 panic_handler
lang 项。这个函数与定义在 core
中的 extern { fn panic_impl }
经过相同的过程,最终得到一个名为 rust_begin_unwind
的符号。在链接时,core
中的符号引用将被解析为 std
中的定义(Rust 源中调用 begin_panic_handler
)。
因此,控制流将在运行时从 core 传递到 std。 这允许来自 core 的 panic 使用和其它 panic 相同的基础结构(panic 钩子,unwinding 等)
std 中 panic! 的实现
这就是真正的 panic 相关逻辑开始的地方。在 library/std/src/panicking.rs
,控制传递给 rust_panic_with_hook
。这个方法负责调用全局 panic 钩子,并检查是否出现双重 panic。最后,调用由 panic 运行时提供的 __rust_start_panic
。
对 __rust_start_panic
的调用非常奇怪 - 它被传递给 *mut &mut dyn BoxMeUp
,转换成一个 usize
。一起分解一下这种类型:
-
BoxMeUp
是一个内部 trait。它是给PanicPayload
(用户提供的有效负载类型的包装器)实现的,并且有一个方法fn box_me_up(&mut self) -> *mut (dyn Any + Send)
。这个方法获取用户提供的有效负载 (T: Any + Send
),将其打包,并将其转换为一个原始指针。 -
当我们调用
__rust_start_panic
时,会得到一个&mut dyn BoxMeUp
。但是,这是一个胖指针 (是usize
的两倍大)。为了跨 FFI 边界上将其传递给 panic 运行时,我们对的可变引用 (&mut &mut dyn BoxMeUp
)进行可变引用,并将其转换为原始指针(*mut &mut dyn BoxMeUp
)。外部的原始指针是一个瘦指针,它指向一个Sized
类型 (一个可变引用)。因此,可以将这个瘦指针转换为一个usize
,它适用于跨 FFI 边界传递。
最后,调用使用 usize
调用 __rust_start_panic
。现在进入 panic 运行时。
步骤 2: panic 运行时
Rust 提供两个 panic 运行时: panic_abort
和 panic_unwind
。用户可以在构建时通过 Cargo.toml
在它们之间进行选择
panic_abort
非常简单: 正如你所期望的那样,它实现 __rust_start_panic
只为中断。
panic_unwind
是更有趣的情况。
在它的实现 __rust_start_panic
中,我们使用 usize
,将其转换回 *mut &mut dyn BoxMeUp
,解引用它,并调用 &mut dyn BoxMeUp
上的 box_me_up
。在这个指针中,我们有一个指向负载本身的原始指针 (一个 *mut (dyn Send + Any)
): 即一个指向调用 panic!
的用户提供真实值的原始指针。
至此,与平台无关的代码结束。现在,我们现在调用特定于平台的展开逻辑 (例如 unwind
)。这个代码负责展开栈,运行与每个帧(当前,运行析构函数)相关联的所有 'landing pads',并将控制权转移到 catch_unwind
帧。
请注意,所有 panic 要么中止进程,要么被调用的 catch_unwind
捕获: 在 library/std/src/rt.rs
中,调用用户提供的
main
函数是包装在 catch_unwind
中。
AST Validation
Feature Gate Checking
HIR
HIR ——“高级中间表示” ——是大多数 rustc 组件中使用的主要IR。
它是抽象语法树(AST)的对编译器更为友好的表示形式,该结构在语法分析,宏展开和名称解析之后生成(有关如何创建HIR,请参见Lowering)。
HIR 的许多部分都非常类似于普通 Rust 的语法,但是 Rust 中的的某些表达式已被“脱糖”。
例如,for
循环将转换为了 loop
,因此在HIR中不会出现 for
。 这使HIR比普通AST更易于分析。
本章介绍了HIR的主要概念。
您可以通过给 rustc 传递 -Zunpretty=hir-tree
标志来查看代码的 HIR 表示形式:
cargo rustc -- -Zunpretty=hir-tree
Out-of-band 存储和Crate
类型
HIR中的顶层数据结构是 Crate
,它存储当前正在编译的 crate 的内容(我们从来就只为当前 crate 构造 HIR)。
在 AST 中,crate 数据结构基本上只包含根模块,而 HIR Crate
结构则包含许多map 和其他用于组织 crate 内容以便于访问的数据。
例如,HIR 中单个项目(例如模块、函数、trait、impl等)的内容不能在其父级中直接访问。
因此,例如,如果有一个包含函数 bar()
的模块 foo
:
#![allow(unused)] fn main() { mod foo { fn bar() { } } }
那么在模块 foo
的HIR中表示(Mod
结构)中将只有bar()
的**ItemId
** I
。
要获取函数 bar()
的详细信息,我们将在 items
映射中查找 I
。
这种表示形式的一个好处是,可以通过遍历这些映射中的键值对来遍历 crate 中的所有项目(而无需遍历整个HIR)。 对于 trait 项和 impl 项以及“实体”(如下所述)也有类似的map。
使用这种表示形式的另一个原因是为了更好地与增量编译集成。
这样,如果您访问 &rustc_hir::Item
(例如mod foo
),不会同时立即去访问函数bar()
的内容。
相反,您只能访问 bar()
的id,必须将 id 传入某些函数来查找 bar
的内容。 这使编译器有机会观察到您访问了bar()
的数据,然后记录依赖。
HIR 中的标识符
有许多不同的标识符可以引用HIR中的其他节点或定义: 简单来说有:
DefId
表示对任何其他 crate 中的一个定义的引用。LocalDefId
表示当前正在编译的 crate 中的一个定义的引用。HirId
表示对 HIR 中任何节点的引用。
更多详细信息,请查看有关标识符的章节。
HIR Map
在大多数情况下,当您使用HIR时,您将通过 HIR Map 进行操作,该map可通过tcx.hir()
在tcx中访问(它在hir::map
模块中定义)。
HIR map 包含多个方法,用于在各种 ID 之间进行转换并查找与 HIR 节点关联的数据。
例如,如果您有一个 DefId
,并且想将其转换为 NodeId
,则可以使用 tcx.hir().as_local_node_id(def_id)
。
这将返回一个 Option<NodeId>
—— 如果 def-id 引用了当前 crate 之外的内容(因为这种内容没有HIR节点),则将为None
;
否则这个函数将返回 Some(n)
,其中 n
是定义对应的节点ID。
同样,您可以使用tcx.hir().find(n)
在节点上查找NodeId
。
这将返回一个Option<Node<'tcx>>
,其中Node
是在map中定义的枚举。
通过对此枚举进行 match ,您可以找出 node-id 所指的节点类型,并获得指向数据本身的指针。
一般来说,您已经事先知道了节点 n
是哪种类型——例如,如果您已经知道了 n
肯定是某个 HIR 表达式,
则可以执行tcx.hir().expect_expr(n)
,它将试图提取并返回&hir::Expr
,此时如果n
实际上不是一个表达式,那么会程序会 panic。
最后,您可以通过 tcx.hir().get_parent_node(n)
之类的调用,使用HIR map来查找节点的父节点。
HIR Bodies
rustc_hir::Body
代表某种可以执行的代码,例如函数/闭包的函数体或常量的定义。
body 与一个所有者相关联,“所有者”通常是某种Item(例如,fn()
或const
),但也可以是闭包表达式(例如, |x, y| x + y
)。
您可以使用 HIR 映射来查找与给定 def-id(maybe_body_owned_by
)关联的body,或找到 body 的所有者(body_owner_def_id
)。
Lowering
Lowering 步骤将 AST 转换为 HIR。 这意味着许多在类型分析或类似的语法无关分析中没有用的代码在这一阶段被删除了。 这种结构的例子包括但不限于
- 括号
- 无需替换,直接删除,树结构本身就能明确运算顺序
for
循环和while (let)
循环- 转换为
loop
+match
和一些let
binding
- 转换为
if let
- 转换为
match
- 转换为
- Universal
impl Trait
- 转换成范型参数(会添加flag来标志这些参数不是由用户写的)
- Existential
impl Trait
- 转换为虚拟的
existential type
声明
- 转换为虚拟的
Lowering 需要遵守几点,否则就会违反 src/librustc_middle/hir/map/hir_id_validator.rs
中的制定的检查规则:
- 如果创建了一个
HirId
,那就必须使用它。 因此,如果您使用了lower_node_id
,则必须使用生成的NodeId
或HirId
(两个都可以,因为检查HIR
中的NodeId
时也会检查是否存在现有的HirId
) - Lowering
HirId
必须在对 item 有所有权的作用域内完成。 这意味着如果要创建除当前正在 Lower 的 item 之外的其他 item,则需要使用with_hir_id_owner
。 例如,在lower existential的impl Trait
时会发生这种情况. - 即使其
HirId
未使用,要放入HIR结构中的NodeId
也必须被 lower。 此时一个合理的方案是调用let _ = self.lower_node_id(node_id);
。 - 如果要创建在
AST
中不存在的新节点,则必须通过调用next_id
方法为它们创建新的 ID。 该方法会生成一个新的NodeId
并自动为您 lowering 它,以便您获得HirId
。
如果您要创建新的 DefId
,由于每个 DefId
需要具有一个对应的 NodeId
,建议将这些 NodeId
添加到 AST
中,这样您就不必在lowering时生成新的DefId
。
这样做的好处是创建了一种通过 NodeId
查找某物的 DefID
的方法。
如果 lower 操作需要在多个位置使用该 DefId
,则不能在所有这些位置生成一个新的 NodeId
,因为那样的话,您将获得多余的的 DefId
。
而对于来自AST的NodeId
来说,这些就都不是问题了。
有一个 NodeId
也使得 DefCollector
可以生成 DefId
,而不需要立即进行操作。
将 DefId
的生成集中在一个地方可以使重构和理解变得更加容易。
Debugging
The MIR (Mid-level IR)
MIR 的构建
从 HIR lower到 MIR 的过程会在下面(可能不完整)这些item上进行:
- 函数体和闭包体
static
和const
的初始化- 枚举 discriminant 的初始化
- 任何类型的胶水和补充代码
- Tuple 结构体的初始化函数
- Drop 代码 (即没有手动调用的
Drop::drop
函数的对象的drop
) - 没有显式实现
Drop
的对象的drop
Lowering 是通过调用 mir_built
查询触发的。
MIR 构建器实际上并不使用 HIR,而是对 THIR 中的表达式进行递归处理。
Lowering会为函数签名中指定的每个参数创建局部变量。
接下来,它为指定的每个绑定创建局部变量(例如, (a, b): (i32, String)
)产生3个绑定,一个用于参数,两个用于绑定。
接下来,它生成字段访问,该访问从参数读取字段并将其值写入绑定变量。
在解决了初始化之后,lowering为函数体递归生成 MIR( Block
表达式)并将结果写入 RETURN_PLACE
。
unpack!
所有东西
生成 MIR 的函数有两种模式。 第一种情况,如果该函数仅生成语句,则它将以基本块作为参数,这些语句应放入该基本块。 然后可以正常返回结果:
fn generate_some_mir(&mut self, block: BasicBlock) -> ResultType {
...
}
但是还有其他一些函数会生成新的基本块。
例如,lowering 像 if foo { 22 } else { 44 }
这样的表达式需要生成一个小的“菱形图”。
在这种情况下,函数将在其代码开始处使用一个基本块,并在代码生成结束时返回一个(可能是)新的基本块。
BlockAnd
类型用于表示此类情况:
fn generate_more_mir(&mut self, block: BasicBlock) -> BlockAnd<ResultType> {
...
}
当您调用这些函数时,通常有一个局部变量 block
,它实际上是一个“光标”。 它代表了我们要添加新的MIR的位置。
当调用 generate_more_mir
时,您会想要更新该光标。
您可以手动执行此操作,但这很繁琐:
let mut block;
let v = match self.generate_more_mir(..) {
BlockAnd { block: new_block, value: v } => {
block = new_block;
v
}
};
因此,我们提供了一个宏,可让您通过如下方式完成更新:
let v = unpack!(block = self.generate_more_mir(...))
。
它简单地提取新的块并覆盖在 unpack!
中指明的变量 block
。
将表达式 Lowering 到 MIR
本质上一个表达式可以有四种表示形式:
Place
指一个(或一部分)已经存在的内存地址(本地,静态,或者提升过的)Rvalue
是可以给一个Place
赋值的东西Operand
是一个给像+
这样的运算符或者一个函数调用的参数- 一个存放了一个值的拷贝的临时变量
下图简要描绘了这些表示形式之间的交互:
我们首先将函数体 lowering 到一个 Rvalue
,这样我们就可以为 RETURN_PLACE
创建一个赋值,
这个 Rvalue
的 lowering 反过来会触发其参数的 Operand
lowering(如果有的话)
lowering Operand
会产生一个 const
操作数,或者移动/复制出 Place
,从而触发 Place
lowering。
如果 lowering 的表达式包含操作,则 lowering 到 Place
的表达式可以触发创建一个临时变量。
这是蛇咬自己的尾巴的地方,我们需要触发 Rvalue
lowering,以将表达式的值写入本地变量。
Operator lowering
内置类型的运算符不会 lower 为函数调用(这将导致无限递归调用,因为这些 trait 实现包含了操作本身)。
相反,对于这些类型已经存在了用于二元和一元运算符和索引运算的 Rvalue
。
这些 Rvalue
稍后将生成为 llvm 基本操作或 llvm 内部函数。
所有其他类型的运算符都被 lower 为对运算符对应 trait 的实现中的的函数调用。
无论采用哪种 lower 方式,运算符的参数都会 lower 为Operand
。
这意味着所有参数都是常量或者引用局部或静态位置中已经存在的值。
方法调用的 lowering
方法调用被降低到与一般函数调用相同的TerminatorKind
。
在MIR中,方法调用和一般函数调用之间不再存在差异。
条件
不带字段变量的 enum
的 if
条件判断和 match
语句都会被lower为 TerminatorKind::SwitchInt
。
每个可能的值(如果为 if
条件判断,则对应的值为 0
和 1
)都有一个对应的 BasicBlock
。
分支的参数是表示if条件值的 Operand
。
模式匹配
具有字段的 enum
上的 match
语句也被 lower 为 TerminatorKind::SwitchInt
,但是其 Operand
是一个 Place
,可以在其中找到该值的判别式。
这通常涉及将判别式读取为新的临时变量。
聚合构造
任何类型的聚合值(例如结构或元组)都是通过 Rvalue::Aggregate
建立的。
所有字段都 lower 为 Operator
。
从本质上讲,这等效于对每个聚合上的字段都会有一个赋值语句,如果必要的话还会再加上一个对 enum
的判别式的赋值。
MIR visitor
MIR visitor 是遍历 MIR 并查找事物或对其进行更改的便捷工具。Visitor trait 是在 the rustc_middle::mir::visit
module 中定义的-其中有两个是通过单个宏生成的:Visitor
(工作于 &Mir
之上,返回共享引用)和 MutVisitor
(工作于 &mut Mir
之上,并返回可变引用)。
要实现 Visitor,您必须创建一个代表您的 Visitor 的类型。 通常,此类型希望在处理 MIR 时“挂”到您需要的任何状态上:
struct MyVisitor<...> {
tcx: TyCtxt<'tcx>,
...
}
然后为该类型实现 Visitor
或 MutVisitor
:
impl<'tcx> MutVisitor<'tcx> for NoLandingPads {
fn visit_foo(&mut self, ...) {
...
self.super_foo(...);
}
}
如上所示,在实现过程中,您可以覆盖任何 visit_foo
方法(例如,visit_terminator
),以便编写一些代码,这些代码将在遇到foo
时执行。如果要递归遍历foo的内容,则可以调用 super_foo
方法。 (注意:您永远都不应该覆盖 super_foo
)
一个非常简单的 Visitor 示例可以在 NoLandingPads
中找到。该 Visitor 甚至不需要任何状态:它仅访问所有终止符并删除其“展开”的后继者。
遍历
除了 Visitor 之外,rustc_middle::mir::traversal
模块 也包含一些有用的函数,用于以不同的标准顺序(例如,前序,反向后序,依此类推)遍历 MIR CFG。
MIR passes: getting the MIR for a function
Identifiers in the Compiler
rustc中的闭包扩展
这一节描述了rustc是如何处理闭包的。Rust中的闭包实际上沦为了来自其创建者栈帧的结构体,该结构体包含了他们使用的值(或使用值的引用)。rustc的工作是要弄清楚闭包使用了哪些值,以及是如何使用的,这样他就可以决定是通过共享引用,可变引用还是通过移动来捕获给定的变量。rustc也需要弄清楚闭包能够实现哪种闭包特征(Fn
,FnMut
,或FnOnce
)。
让我们来从一个小例子开始:
示例 1
首先,让我们来看一下以下示例中的闭包是如何实现的:
fn closure(f: impl Fn()) { f(); } fn main() { let x: i32 = 10; closure(|| println!("Hi {}", x)); // 闭包仅仅读取了x变量. println!("Value of x after return {}", x); }
假设上面是名为immut.rs
文件的内容。如果我们用以下的命令来编译immut.rs
,-Z dump-mir=all
参数将会使rustc
生成MIR并将其转储到mir_dump
目录中。
> rustc +stage1 immut.rs -Z dump-mir=all
在我们执行了这个命令之后,我们将会看到在当前的工作目录下生成了一个名为mir_dump
的新目录,其中包含了多个文件,如果我们打开rustc.main.-------.mir_map.0.mir
文件将会发现,除了其他内容外,还包括此行:
_4 = &_1;
_3 = [closure@immut.rs:7:13: 7:36] { x: move _4 };
请注意在这节的MIR示例中,_1
就是x
。
在第一行_4 = &_1;
中,mir_dump
告诉我们x
作为不可变引用被借用了。这是我们希望的,因为我们的闭包需要读取x
。
示例 2
这里是另一个示例:
fn closure(mut f: impl FnMut()) { f(); } fn main() { let mut x: i32 = 10; closure(|| { x += 10; // The closure mutates the value of x println!("Hi {}", x) }); println!("Value of x after return {}", x); }
_4 = &mut _1;
_3 = [closure@mut.rs:7:13: 10:6] { x: move _4 };
这一次,在第一行_4 = &mut _1;
中,我们可以看到借用变成了可变借用。这是十分合理的,使得闭包可以将x
加10。
示例 3
又一个示例:
fn closure(f: impl FnOnce()) { f(); } fn main() { let x = vec![21]; closure(|| { drop(x); // 在这之后使x不可用 }); // println!("Value of x after return {:?}", x); }
_6 = [closure@move.rs:7:13: 9:6] { x: move _1 }; // bb16[3]: scope 1 at move.rs:7:13: 9:6
这里, x
直接被移入了闭包内,因此在闭包代码块之后将不允许访问这个变量了。
编译器中的推断
现在,让我们深入研究rustc的代码,看看编译器是如何完成所有这些推断的。
首先,我们先定义一个术语upvar,它在我们之后的讨论中会经常使用到。upvar是定义闭包的函数的本地变量。所以,在上述示例中,x对于闭包来说是一个upvar。它们有时也会被称为空闲变量以表示它们并未绑定到闭包的上下文中。compiler/rustc_middle/src/ty/query/mod.rs
为此定义了一个被成为upv.rs_mentioned的查询。
除了懒调用,另一个将闭包区别于普通函数的特征就是它可以从上下文中借用这些upvar;因此编译器必须确定upvar的借用类型。基于这个用途,编译器从分配一个不可变的借用类型开始,可以根据需要来减少限制(将它从不可变变成可变,再变成移动)。 在上述的示例1中,闭包仅仅将变量用于打印,而不以任何方式对其进行修改,因此在mir_dump
中,我们发现借用类型的upvar变量x
是不可变的。但是,在示例2中,闭包修改了x
并将其加上了某个值。由于这种改变,编译器从将x
分配为不可变的引用类型开始,必须将其调整为可变的引用。同样的,在示例3中,闭包释放了向量x
,因此要求将变量x
移入闭包内。依赖于借用类型,闭包需要实现合适的特征:Fn
特征对应不可变借用, FnMut
对应可变借用,FnOnce
对应于移动语义。
大多数与闭包相关的代码在compiler/rustc_typeck/src/check/upvar.rs
文件中,数据结构定义在compiler/rustc_middle/src/ty/mod.rs
文件中。
在我们进一步深入之前,一起讨论下如何通过rustc代码库来检测控制流。对于闭包来说,像下面一样设置RUST_LOG
环境变量并在文件中收集输出。
> RUST_LOG=rustc_typeck::check::upvar rustc +stage1 -Z dump-mir=all \
<.rs file to compile> 2> <file where the output will be dumped>
这里使用了stage1编译器,并为rustc_typeck::check::upvar
模块启用了debug!
日志。
另一种选择是使用lldb或gdb逐步执行代码。
rust-lldb build/x86_64-apple-darwin/stage1/bin/rustc test.rs
- 在lldb中:
b upvar.rs:134
// 在upvar.rs文件中的某行上设置断点r
// 一直运行程序直到打到了该断点上
让我们从upvar.rs
开始. 这个文件有一个叫euv::ExprUseVisitor
的结构,该结构遍历闭包的源码并为每一个被借用,被更改,被移动的upvar触发了一个回调。
fn main() { let mut x = vec![21]; let _cl = || { let y = x[0]; // 1. x[0] += 1; // 2. }; }
在上面的示例中,我们的访问器将会调用两次,对于标记了1和2的代码行,一个用于共享借用,另一个用于可变借用。它还会告诉我们借用了什么。
通过实现Delegate
特征来定义回调。InferBorrowKind
类型实现了Delegate
并维护了一个map来记录每个upvar需要哪种捕获方式。捕获的方式可以是ByValue
(被移动)或者是ByRef
(被借用)。对于ByRef
借用,BorrowKind
可能是定义在compiler/rustc_middle/src/ty/mod.rs
中的ImmBorrow
,UniqueImmBorrow
,MutBorrow
。
Delegate
定义了一些不同的方法(不同的回调):
consume方法用于移动变量,borrow方法用于某种(共享的或可变的)借用,而当我们看到某种事物的分配时,则调用mutate方法。
所有的这些回调都有一个共同的参数cmt,该参数代表类别,可变形和类型。他定义在compiler/rustc_middle/src/middle/mem_categorization.rs
中。代码注释中写到:“cmt
是一个值的完整分类,它指明了该值的起源和位置,以及存储该值的内存的可变性”。根据这些回调(consume,borrow等),我们将会调用相关的adjust_upvar_borrow_kind_for_<something>
并传递cmt
。一旦借用类型有了调整,我们将它存储在表中,基本上说明了每个闭包都借用了什么。
self.tables
.borrow_mut()
.upvar_capture_map
.extend(delegate.adjust_upvar_captures);
The ty module: representing types
Generics and substitutions
TypeFolder and TypeFoldable
Generic arguments
Type inference
Trait solving
Early and Late Bound Parameters
Higher-ranked trait bounds
Caching subtleties
Specialization
Chalk-based trait solving
Lowering to logic
Goals and clauses
Canonical queries
类型检查
rustc_typeck
crate 包含"类型收集"和"类型检查"的源代码,和其它一些相关功能。(它很大程度依赖于type inference和trait solving。)
类型收集
类型"收集"是将用户写入的语法内容 HIR(hir::Ty
) 中的类型转化为编译器使用的内部表示(Ty<'tcx>
)的过程 – 对 where 子句和函数签名的其他位也进行类似的转换。
为了尝试并感受到这种差异,请考虑下面的函数:
struct Foo { }
fn foo(x: Foo, y: self::Foo) { ... }
// ^^^ ^^^^^^^^^
这两个参数 x
和 y
有相同的类型: 但他们是不懂的 hir::Ty
节点。这些节点有不同的 span,当然它们的编码路径也有所不同。但它们一旦"被收集"到 Ty<'tcx>
节点,它们会使用完全相同的内部类型。
集合被定义为计算关于正在编译的 crate 中的各种函数、特性和其他项的信息的一组查询。请注意,每个查询都与过程间事物有关——例如,对于函数定义,集合将计算出函数的类型和签名,但它不会以任何方式访问函数体,也不会检查局部变量的类型注释(这是类型检查的工作)。
更多有关详细信息,请参阅 collect
模块。
TODO: 实际上谈到类型检查...
Method Lookup
Variance
Opaque Types
Pattern and Exhaustiveness Checking
数据流分析
如果您在MIR上进行开发,将会频繁遇到各种数据流分析。
rustc
通过数据流发现未初始化变量,确定生成器yield
中的存活变量,以及在控制流图指定位置计算借用Place
。
数据流分析在现代编译器中是一个基础概念,该主题的知识对潜在贡献者十分有帮助。
注意,本文档不是对数据流分析的一般介绍。只用于对rustc
中分析框架的描述。
它假设读者熟悉一些核心思想和基本概念,比如转换函数(transfer function),不动点(fixpoint)和格(lattice)。
如果您不熟悉这些术语,或者想要快速复习,那么Anders Møller和Michael I. Schwartzbach撰写的静态程序分析是一本很好的免费教材。
对于您喜欢视听学习,法兰克福歌德大学已经在YouTube上用英语发布了一系列简短的讲座,非常容易上手。
定义一个数据流分析
数据流分析的接口被分解成三个trait。首先是AnalysisDomain
,所有分析都必须实现它。
除了定义数据流状态类型,该trait还定义了每个基本块入口处状态的初始值,以及向前分析或者向后分析的方向。
数据流分析的域(domain)必须是一个有正确join
操作符的格 (严格来讲是连接语义)。
更多内容可以参考lattice
和JoinSemiLattice
的文档。
您必须提供一个Analysis
的直接实现或者是一个GenKillAnalysis
代理的实现。后者用于所谓的"gen-kill" problems,该问题可以通过一类简单的转换函数解决。
如果一个分析的域不是BitSet
(位图),或者转换函数无法使用“gen”、“kill”操作实现,那么必须直接实现Analysis
,这样的实现可能会变慢。
而所有的GenKillAnalysis
将会通过默认的impl
自动实现Analysis
。
AnalysisDomain
^
| | = has as a supertrait
| . = provides a default impl for
|
Analysis
^ ^
| .
| .
| .
GenKillAnalysis
转换函数与作用函数
rustc
的数据流分析框架允许基本块内的每条语句(包括终结语句terminator)定义自己的转换函数。
简便起见,这些独立的转换函数在下面被成为“作用函数”。
每个作用函数都以数据流顺序依次执行,并且它们共同定义了整个基本块的传递函数。
也可以为终结语句(terminator)的特定的传出边(译注:分支跳转的特定边)定义一个“作用函数”(例如apply_call_return_effect
作用于Call
指令的后继边),
这些被称为“单边作用”(per-edge effects)。
GenKillAnalysis
的方法与Analysis
的方法之间唯一有意义的区别(除“apply”前缀之外)是Analysis
对数据流状态可以直接修改,
而GenKillAnalysis
仅能看到GenKill
trait的实现者,即只允许gen
和kill
操作可变。
“前序”作用
细心的读者可能会注意到,每个语句实际上都有两种可能的作用函数,即“前序”作用("before" effects)和“非前序”作用(主要作用,effects)。 无论分析的方向如何,“前序”作用都会在主要作用函数之前应用。 换句话说,后向分析将在计算基本块的传递函数时先应用“前序”作用函数,然后调用“主要”作用函数,就像正向分析一样。
大多数分析仅使用“主要”作用,如果每条语句具有多个作用函数,会使得调用者难以抉择。 但是,“前序”作用在某些情况下很有用,例如当必须将赋值语句左右表达式分开考虑时。
终止条件
您的分析必须能收敛到不动点,否则会一直执行下去。 不动点的收敛是一种“达到平衡”的方式。为了达到平衡,您的分析必须满足某些定律。其中之一的定律是任意值与底值(bottom)结合等于该值,即满足如下等式:
bottom join x = x
另一条定律是您的分析需要有一个满足如下等式的顶值(top):
top join x = top
顶值可以确保半格的高度是有限的,并且上述的定律保证了一旦数据流状态到达顶值,将不在发生变化。
例子
本节提供了一个简单的数据流分析。它没有解释您需要了解的所有内容,但希望它将使本页面的其余部分更加清晰。
假设我们要做一个简单的分析,以确定程序中的某个点是否已经调用过mem::transmute
。
我们的分析域将是一个布尔变量,它表示到目前为止是否已调用了transmute
。
底值是false
,因为初始情况下未调用transmute
。
顶值是true
,因为一旦我们确定调用了transmute
,分析就完成了。
我们的join运算符是OR(||)运算符。使用OR而不是AND是由于存在以下场景:
let x = if some_cond {
std::mem::transmute<i32, u32>(0_i32); // transmute 被调用!
} else {
1_u32; // transmute 未被调用
};
// 此处 transmute 被调用了吗? 保守结果认为是true
println!("x: {}", x);
检查结果
如果您实现了一个分析,您必须将它传给引擎Engine
。
这个功能通过定义Analysis
中的into_engine
函数,比Engine::new_gen_kill
来构造效率更高。
调用Engin
中的iterate_to_fixpoint
可以返回Results
,该结构中包含每个基本块进入时不动点的数据流状态。
当拥有一个Results
结构,您可以在CFG的任意位置检查不动点的数据流状态。如果只需要少量位置的状态,可以使用ResultsCursor
。
如果需要每个位置的状态,使用ResultsVisitor
更加高效。
Analysis
|
| into_engine(…)
|
Engine
|
| iterate_to_fixpoint()
|
Results
/ \
into_results_cursor(…) / \ visit_with(…)
/ \
ResultsCursor ResultsVisitor
下方是ResultsVisitor
的示例代码:
// Assuming `MyVisitor` implements `ResultsVisitor<FlowState = MyAnalysis::Domain>`...
let mut my_visitor = MyVisitor::new();
// inspect the fixpoint state for every location within every block in RPO.
let results = MyAnalysis::new()
.into_engine(tcx, body, def_id)
.iterate_to_fixpoint()
.visit_in_rpo_with(body, &mut my_visitor);
ResultsCursor
示例代码:
let mut results = MyAnalysis::new()
.into_engine(tcx, body, def_id)
.iterate_to_fixpoint()
.into_results_cursor(body);
// Inspect the fixpoint state immediately before each `Drop` terminator.
for (bb, block) in body.basic_blocks().iter_enumerated() {
if let TerminatorKind::Drop { .. } = block.terminator().kind {
results.seek_before_primary_effect(body.terminator_loc(bb));
let state = results.get();
println!("state before drop: {:#?}", state);
}
}
Graphviz图
当需要调试数据流分析结果的时候,可以使用可视化工具。通过MIR调试命令中的-Z dump-mir
来完成。
以-Z dump-mir=F -Z dump-mir-dataflow
开头,其中F
是"all"或是您感兴趣的MIR函数的名称。
这些.dot
文件将保存在您的mir_dump
目录中,并将分析的NAME
(例如,maybe_inits
)作为其文件名的一部分。
每个可视化文件将在每个基本块的入口和出口显示完整的数据流状态,以及每个语句和终止语句中发生的任何更改。请参见下面的示例:
The borrow checker
Tracking moves and initialization
Move paths
MIR type checker
Region inference
Constraint propagation
Lifetime parameters
Member constraints
Placeholders and universes
Closure constraints
Error reporting
Two-phase-borrows
Parameter Environments
Errors and Lints
Creating Errors With SessionDiagnostic
LintStore
Diagnostic Codes
MIR优化
MIR优化是指在代码生成之前,为了产生更好的MIR指令而执行的优化。 这些优化十分重要,体现在两个方面: 首先,它使得最终生成的可执行代码的质量更好;其次,这意味着LLVM需要的工作量更少,编译速度更快。 请注意,由于MIR是通用的(不是[monomorphized] monomorph)所以这些优化特别有效,我们可以优化通用代码,使得所有代码的特化版本同样受益!
MIR的优化执行在借用检查之后。通过执行一系列的pass不断优化MIR。
一些pass需要执行在全量的代码上,一些pass则不执行实际的优化操作只进行代码检查,还有些pass只在release
模式下适用。
调用optimized_mir
来 查询为给定的DefId
生成优化的MIR,该查询确保借用检查器已运行并且已经进行了一些校验。
然后,窃取MIR,执行优化后,返回被优化后的MIR。
定义优化Passes
优化pass的声明和执行顺序由run_optimization_passes
函数定义。
它包含了一组待执行的pass,其中的每个pass都是一个实现了MirPass
trait的结构体。通过一个元素类型为&dyn MirPass
的数组实现。
这些pass通常在rustc_mir::transform
模块下完成自己的实现。
下面有一些pass的示例:
CleanupNonCodegenStatements
: 清理那些只用于分析而不用于代码生成的信息;ConstProp
: 常量传播。
您可以查看关于MirPass
实现的相关章节中的更多示例。
MIR优化 levels
MIR优化有不同程度的行为。
实验性的优化可能会导致错误编译或增加编译时间。
这样的pass包含在nightly
版本中,以收集反馈并修改。要启用这些缓慢的或实验性的优化,可以指定-Z mir-opt-level
调试标志。
您可以在compiler MCP中找到这些级别的定义。
如果您正在开发MIR优化pass,并且想查询您的优化是否应该运行,可以使用tcx.sess.opts.debugging_opts.mir_opt_level
检查输入的级别。
优化参数fuel
fuel
是一个编译器选项 (-Z fuel=<crate>=<value>
),可以精细地控制在编译过程中的优化情况:每次优化将fuel
减少1,当fuel
达到0时不再进行任何优化。
fuel
的主要用途是调试那些可能不正确或使用不当的优化。通过更改选项,您可以通过二分法定位到发生错误的优化。
一般来讲,MIR优化执行过程中会通过调用tcx.consider_optimizing
来检查fuel
,如果fuel
为空则跳过优化。
有如下注意事项:
- 如果认为一个优化行为是有保证的(即,为了结果的正确性每次编译都要执行),那么
fuel
是可以跳过的,比如PromoteTemps
。 - 在某些情况下,需要执行一个初始pass来收集候选,然后对他们迭代执行以达到优化的目的。在这种情况下,我们应该让初始pass对
fuel
的值尽可能地突变。 这可以获得最佳的调试体验,因为可以确定候选列表中的某个优化pass可能未正确调用。 例如InstCombine
和ConstantPropagation
。
Debugging
Constant evaluation
miri const evaluator
Monomorphization
降级 MIR 到 Codegen IR
现在我们有了一个要从收集器生成的符号列表,我们需要生成某种类型的代码 codegen IR。在本章中,我们将假设是 LLVM IR,因为这是 rustc 常用的。实际的单态化是在我们翻译的过程中进行的。
回想一下,后端是由 rustc_codegen_ssa::base::codegen_crate
开始的。最终到达 rustc_codegen_ssa::mir::codegen_mir
,从 MIR 降级到 LLVM IR。
该代码被分成处理特定 MIR 原语的模块:
rustc_codegen_ssa::mir::block
将处理翻译块及其终结符。这个模块做的最复杂也是最有趣的事情是为函数调用生成代码,包括必要的展开处理 IR。rustc_codegen_ssa::mir::statement
翻译 MIR 语句。rustc_codegen_ssa::mir::operand
翻译 MIR 操作。rustc_codegen_ssa::mir::place
翻译 MIR 位置参考。rustc_codegen_ssa::mir::rvalue
翻译 MIR 右值。
在转换一个函数之前,将运行一些简单的和基本的分析步骤以帮助我们生成更简单、更有效的 LLVM IR。这种分析方法的一个例子是找出哪些变量类似于 SSA,这样我们就可以直接将它们转换为 SSA,而不必依赖 LLVM 的 mem2reg
来处理这些变量。分析可以在 rustc_codegen_ssa::mir::analyze
中找到。
通常一个 MIR 基本块会映射到一个 LLVM 基本块,除了极少数的例外: 内部调用或函数调用以及较少的基本的像 assert
这样 MIR 语句可能会产生多个基本块。这是对代码生成中不可移植的 LLVM 特定部分的完美诠释。内部生成是相当容易理解的,因为它涉及的抽象级别很低,可以在rustc_codegen_llvm::intrinsic
中找到。
其他的都将使用builder interface,这是在 rustc_codegen_ssa::mir::*
模块中调用的代码。
TODO: 讨论常量是如何生成的
代码生成
代码生成或"codegen"是编译器生成可执行二进制文件的一部分。通常,rustc 使用 LLVM 来生成代码; 它也支持 Cranelift。关键是 rustc 本身并不实现 codegen。但是值得注意的是,在 rust 源代码中,后端的许多部分在名称中都有 codegen
(没有严格的界限)。
注意: 如果您正在寻找关于如何调试代码生成错误的提示,请参阅调试章节的这一部分。
LLVM 是什么?
LLVM 是“模块化和可重用的编译器和工具链技术的集合”。特别是,LLVM 项目包含一个可插拔的编译器后端(也称为"LLVM") ,许多编译器项目都使用它,包括 clang
C 编译器和我们心爱的 rustc
。
LLVM 接受 LLVM IR 的形式输入。它基本上是带有附加的低级类型和注释的汇编代码。这些注释有助于对 LLVM IR 和输出的机器代码进行优化。所有这一切的最终结果是(最终)一些可执行的东西(例如一个 ELF 对象、一个 EXE 或者一个 wasm)。
使用 LLVM 有几个好处:
- 不需要编写一个完整的编译器后端,减少了实现和维护的负担。
- 从 LLVM 项目收集的大量高级优化套件中受益。
- 可以自动将 Rust 编译到 LLVM 支持的任何平台上。例如,一旦 LLVM 添加了对 wasm 的支持,瞧!Rustc,clang,和一堆其他语言都能编译成 wasm!(嗯,还有一些额外的工作要做,但我们已经完成了90%)。
- 我们和其他编译器项目互相受益. 例如, 当Spectre 和 Meltdown 安全漏洞被发现,只需要修补 LLVM。
运行 LLVM, 链接和元数据生成
一旦建立了所有函数和静态等的 LLVM IR,就可以开始运行 LLVM 并进行优化。LLVM IR 分为“模块”。可以同时编写多个“模块”,以帮助实现多核使用。这些“模块”就是我们所说的 codegen units。这些单元是在单态化收集阶段建立起来的。
一旦 LLVM 从这些模块生成对象,这些对象就会被传递给链接器,还可以选择生成元数据对象和归档文件或可执行文件。
运行优化的不一定是上面描述的代码原阶段。对于某些类型的 LTO,优化可能发生在链路时间。在将对象传递到链接器之前还可能进行一些优化,而在链接过程中也可能进行一些优化。
这些都发生在编译的最后阶段。代码可以在 rustc_codegen_ssa::back
和 rustc_codegen_llvm::back
中找到。遗憾的是,这段代码与 LLVM 相关的代码并没有很好地分离; rustc_codegen_ssa
包含了大量特定于 LLVM 后端的代码。
一旦这些组件完成了它们的工作,您的文件系统中就会出现许多与您所请求的输出相对应的文件。
Updating LLVM
Debugging LLVM
Backend Agnostic Codegen
Implicit Caller Location
Profile-guided Optimization
LLVM Source-Based Code Coverage
Sanitizers Support
Debugging Support in the Rust Compiler
附录 B: Glossary
Glossary
术语 | 中文 | 意义 |
---|---|---|
arena/arena allocation | 竞技场分配 | arena 是一个大内存缓冲区,从中可以进行其他内存分配,这种分配方式称为竞技场分配。 |
AST | 抽象语法树 | 由rustc_ast crate 产生的抽象语法树。 |
binder | 绑定器 | 绑定器是声明变量和类型的地方。例如,<T> 是fn foo<T>(..) 中泛型类型参数 T 的绑定器,以及 |a | ... 是 参数a 的绑定器。 |
BodyId | 主体ID | 一个标识符,指的是crate 中的一个特定主体(函数或常量的定义)。 |
bound variable | 绑定变量 | "绑定变量 "是在表达式/术语中声明的变量。例如,变量a 被绑定在闭包表达式中|a | a * 2 。 |
codegen | 代码生成 | 由 MIR 转译为 LLVM IR。 |
codegen unit | 代码生成单元 | 当生成LLVM IR时,编译器将Rust代码分成若干个代码生成单元(有时缩写为CGU)。这些单元中的每一个都是由LLVM独立处理的,实现了并行化。它们也是增量编译的单位。 |
completeness | 完整性 | 类型理论中的一个技术术语,它意味着每个类型安全的程序也会进行类型检查。同时拥有健全性(soundness)和完整性(completeness)是非常困难的,而且通常健全性(soundness)更重要。 |
control-flow graph | 控制流图 | 程序的控制流表示。 |
CTFE | 编译时函数求值 | 编译时函数求值(Compile-Time Function Evaluation)的简称,是指编译器在编译时计算 "const fn "的能力。这是编译器常量计算系统的一部分。 |
cx | 上下文 | Rust 编译器内倾向于使用 "cx "作为上下文的缩写。另见 "tcx"、"infcx "等。 |
ctxt | 上下文(另一个缩写) | 我们也使用 "ctxt "作为上下文的缩写,例如, TyCtxt ,以及 cx 或 tcx。 |
DAG | 有向无环图 | 在编译过程中,一个有向无环图被用来跟踪查询之间的依赖关系 |
data-flow analysis | 数据流分析 | 静态分析,找出程序控制流中每一个点的属性。 |
DeBruijn Index | 德布鲁因索引 | 一种只用整数来描述一个变量被绑定的绑定器的技术。它的好处是,在变量重命名下,它是不变的。 |
DefId | 定义Id | 一个识别定义的索引(见rustc_middle/src/hir/def_id.rs )。DefPath 的唯一标识。 |
discriminant | 判别式 | 与枚举变体或生成器状态相关的基础值,以表明它是 "激活的(avtive)"(但不要与它的"变体索引"混淆)。在运行时,激活变体的判别值被编码在tag中。 |
double pointer | 双指针 | 一个带有额外元数据的指针。同指「胖指针」。 |
drop glue | drop胶水 | (内部)编译器生成的指令,处理调用数据类型的析构器(Drop )。 |
DST | DST | Dynamically-Sized Type的缩写,这是一种编译器无法静态知道内存大小的类型(例如:str'或 [u8])。这种类型没有实现 Sized,不能在栈中分配。它们只能作为结构中的最后一个字段出现。它们只能在指针后面使用(例如: &str或 &[u8]`)。 |
early-bound lifetime | 早绑定生存期 | 一个在其定义处被替换的生存期区域(region)。绑定在一个项目的Generics'中,并使用 Substs'进行替换。与late-bound lifetime形成对比。 |
empty type | 空类型 | 参考 "uninhabited type". |
fat pointer | 胖指针 | 一个两字(word)的值,携带着一些值的地址,以及一些使用该值所需的进一步信息。Rust包括两种 "胖指针":对切片(slice)的引用和特质(trait)对象。对切片的引用带有切片的起始地址和它的长度。特质对象携带一个值的地址和一个指向适合该值的特质实现的指针。"胖指针 "也被称为 "宽指针",和 "双指针"。 |
free variable | 自由变量 | 自由变量 是指没有被绑定在表达式或术语中的变量; |
generics | 泛型 | 通用类型参数集。 |
HIR | 高级中间语言 | 高级中间语言,通过对AST进行降级(lowering)和去糖(desugaring)而创建。 |
HirId | HirId | 通过结合“def-id”和 "intra-definition offset"来识别HIR中的一个特定节点。 |
HIR map | HIR map | 通过tcx.hir() 访问的HIR Map,可以让你快速浏览HIR并在各种形式的标识符之间进行转换。 |
ICE | ICE | 内部编译器错误的简称,这是指编译器崩溃的情况。 |
ICH | ICH | 增量编译哈希值的简称,它们被用作HIR和crate metadata等的指纹,以检查是否有变化。这在增量编译中是很有用的,可以查看crate的一部分是否发生了变化,应该重新编译。 |
infcx | 类型推导上下文 | 类型推导上下文(InferCtxt )。 |
inference variable | 推导变量 | 在进行类型或区域推理时,"推导变量 "是一种特殊的类型/区域,代表你试图推理的内容。想想代数中的X。例如,如果我们试图推断一个程序中某个变量的类型,我们就创建一个推导变量来代表这个未知的类型。 |
intern | intern | intern是指存储某些经常使用的常量数据,如字符串,然后用一个标识符(如`符号')而不是数据本身来引用这些数据,以减少内存的使用和分配的次数。 |
intrinsic | 内部函数 | 内部函数是在编译器本身中实现的特殊功能,但向用户暴露(通常是不稳定的)。它们可以做神奇而危险的事情。 |
IR | IR | Intermediate Representation的简称,是编译器中的一个通用术语。在编译过程中,代码被从原始源码(ASCII文本)转换为各种IR。在Rust中,这些主要是HIR、MIR和LLVM IR。每种IR都适合于某些计算集。例如,MIR非常适用于借用检查器,LLVM IR非常适用于codegen,因为LLVM接受它。 |
IRLO | IRLO | IRLO 或irlo 有时被用作internals.rust-lang.org的缩写。 |
item | 语法项 | 语言中的一种 "定义",如静态、常量、使用语句、模块、结构等。具体来说,这对应于 "item"类型。 |
lang item | 语言项 | 代表语言本身固有的概念的项目,如特殊的内置特质,如同步 和发送 ;或代表操作的特质,如添加 ;或由编译器调用的函数。 |
late-bound lifetime | 晚绑定生存期 | 一个在其调用位置被替换的生存期区域。绑定在HRTB中,由编译器中的特定函数替代,如liberate_late_bound_regions 。与早绑定的生存期形成对比。 |
local crate | 本地crate | 目前正在编译的crate。这与 "上游crate"相反,后者指的是本地crate的依赖关系。 |
LTO | LTO | 链接时优化(Link-Time Optimizations)的简称,这是LLVM提供的一套优化,在最终二进制文件被链接之前进行。这些优化包括删除最终程序中从未使用的函数,例如。_ThinLTO_是LTO的一个变种,旨在提高可扩展性和效率,但可能牺牲了一些优化。 |
LLVM | LLVM | (实际上不是一个缩写 :P) 一个开源的编译器后端。它接受LLVM IR并输出本地二进制文件。然后,各种语言(例如Rust)可以实现一个编译器前端,输出LLVM IR,并使用LLVM编译到所有LLVM支持的平台。 |
memoization | memoization | 储存(纯)计算结果(如纯函数调用)的过程,以避免在未来重复计算。这通常是执行速度和内存使用之间的权衡。 |
MIR | 中级中间语言 | 在类型检查后创建的中级中间语言,供borrowck和codegen使用。 |
miri | mir解释器 | MIR的一个解释器,用于常量计算。 |
monomorphization | 单态化 | 采取类型和函数的通用实现并将其与具体类型实例化的过程。例如,在代码中可能有Vec<T> ,但在最终的可执行文件中,将为程序中使用的每个具体类型有一个Vec 代码的副本(例如,Vec<usize> 的副本,Vec<MyStruct> 的副本,等等)。 |
normalize | 归一化 | 转换为更标准的形式的一般术语,但在rustc的情况下,通常指的是关联类型归一化。 |
newtype | newtype | 对其他类型的封装(例如,struct Foo(T) 是T 的一个 "新类型")。这在Rust中通常被用来为索引提供一个更强大的类型。 |
niche | 利基 | 一个类型的无效位模式可用于布局优化。有些类型不能有某些位模式。例如,"非零*"整数或引用"&T "不能用0比特串表示。这意味着编译器可以通过利用无效的 "利基值 "来进行布局优化。这方面的一个应用实例是Discriminant elision on Option -like enums,它允许使用一个类型的niche作为一个enum 的"标签",而不需要一个单独的字段。 |
NLL | NLL | 这是非词法作用域生存期的简称,它是对Rust的借用系统的扩展,使其基于控制流图。 |
node-id or NodeId | node-id or NodeId | 识别AST或HIR中特定节点的索引;逐渐被淘汰,被HirId 取代。 |
obligation | obligation | 必须由特质系统证明的东西。 |
placeholder | placeholder | 注意:skolemization被placeholder废弃一种处理围绕 "for-all "类型的子类型的方法(例如,for<'a> fn(&'a u32) ),以及解决更高等级的trait边界(例如,for<'a> T: Trait<'a> )。 |
point | point | 在NLL分析中用来指代MIR中的某个特定位置;通常用来指代控制流图中的一个节点。 |
polymorphize | 多态化 | 一种避免不必要的单态化的优化。 |
projection | 投影 | 一个 "相对路径 "的一般术语,例如,x.f 是一个 "字段投影",而T::Item 是一个"关联类型投影" |
promoted constants | 常量提升 | 从函数中提取的常量,并提升到静态范围 |
provider | provider | 执行查询的函数。 |
quantified | 量化 | 在数学或逻辑学中,存在量词和普遍量词被用来提出诸如 "是否有任何类型的T是真的?"或 "这对所有类型的T都是真的吗?"这样的问题 |
query | 查询 | 编译过程中的一个子计算。查询结果可以缓存在当前会话中,也可以缓存到磁盘上,用于增量编译。 |
recovery | 恢复 | 恢复是指在解析过程中处理无效的语法(例如,缺少逗号),并继续解析AST。这可以避免向用户显示虚假的错误(例如,当结构定义包含错误时,显示 "缺少字段 "的错误)。 |
region | 区域 | 和生存期精彩使用的另一个术语。 |
rib | rib | 名称解析器中的一个数据结构,用于跟踪名称的单一范围。 |
scrutinee | 审查对象 | 审查对象是在match 表达式和类似模式匹配结构中被匹配的表达式。例如,在match x { A => 1, B => 2 } 中,表达式x 是被审查者。 |
sess | sess | 编译器会话,它存储了整个编译过程中使用的全局数据 |
side tables | side tables | 由于AST和HIR一旦创建就不可改变,我们经常以哈希表的形式携带关于它们的额外信息,并以特定节点的ID为索引。 |
sigil | 符号 | 就像一个关键词,但完全由非字母数字的标记组成。例如,& 是引用的标志。 |
soundness | 健全性 | 类型理论中的一个技术术语。粗略的说,如果一个类型系统是健全的,那么一个进行类型检查的程序就是类型安全的。也就是说,人们永远不可能(在安全的Rust中)把一个值强加到一个错误类型的变量中。 |
span | span | 用户的源代码中的一个位置,主要用于错误报告。这就像一个文件名/行号/列的立体元组:它们携带一个开始/结束点,也跟踪宏的扩展和编译器去糖。所有这些都被装在几个字节里(实际上,它是一个表的索引)。 |
substs | 替换 | 给定的通用类型或项目的替换(例如,HashMap<i32, u32> 中的i32'、 u32')。 |
sysroot | sysroot | 用于编译器在运行时加载的构建工件的目录。 |
tag | tag | 枚举/生成器的 "标签 "编码激活变体/状态的判别式(discriminant)。 标签可以是 "直接的"(简单地将判别式存储在一个字段中)或使用"利基"。 |
tcx | tcx | "类型化上下文"(TyCtxt ),编译器的主要数据结构。 |
'tcx | 'tcx | TyCtxt'所使用的分配区域的生存期。在编译过程中,大多数数据都会使用这个生存期,但HIR数据除外,它使用 'hir`生存期。 |
token | 词条 | 解析的最小单位。词条是在词法运算后产生的 |
TLS | TLS | 线程本地存储。变量可以被定义为每个线程都有自己的副本(而不是所有线程都共享该变量)。这与LLVM有一些相互作用。并非所有平台都支持TLS。 |
trait reference | trait 引用 | 一个特质的名称,以及一组合适的输入类型/生存期。 |
trans | trans | 是 "转译"的简称,是将MIR转译成LLVM IR的代码。已经重命名为codegen。 |
Ty | Ty | 一个类型的内部表示。 |
TyCtxt | TyCtxt | 在代码中经常被称为tcx的数据结构,它提供对会话数据和查询系统的访问。 |
UFCS | UFCS | 通用函数调用语法(Universal Function Call Syntax)的简称,这是一种调用方法的明确语法。 |
uninhabited type | 孤类型 | 一个没有值的类型。这与ZST不同,ZST正好有一个值。一个孤类型的例子是enum Foo {} ,它没有变体,所以,永远不能被创建。编译器可以将处理孤类型的代码视为死代码,因为没有这样的值可以操作。! (从未出现过的类型)是一个孤类型。孤类型也被称为 "空类型"。 |
upvar | upvar | 一个闭合体从闭合体外部捕获的变量 |
variance | 型变 | 确定通用类型/寿命参数的变化如何影响子类型;例如,如果T 是U 的子类型,那么Vec<T> 是Vec<U> 的子类型,因为Vec 在其通用参数中是协变的。 |
variant index | 变体索引 | 在一个枚举中,通过给它们分配从0开始的索引来识别一个变体。这纯粹是内部的,不要与"判别式"相混淆,后者可以被用户覆盖(例如,enum Bool { True = 42, False = 0 } )。 |
wide pointer | 宽指针 | 一个带有额外元数据的指针。 |
ZST | ZST | 零大小类型。这种类型,其值的大小为0字节。由于2^0 = 1 ,这种类型正好有一个值。例如,() (单位)是一个ZST。struct Foo; 也是一个ZST。编译器可以围绕ZST做一些很好的优化。 |