第十二章 没有标准库的 Rust
Rust 旨在成为一种系统编程语言,但并不总是很清楚这到底意味着什么。 至少,一种系统编程语言通常被期望允许程序员编写不依赖操作系统的程序,并能直接在硬件上运行,不管是千核超级计算机还是带有时钟速度为 72MHz、内存为 256KiB 的单核 ARM 处理器的嵌入式设备。
在这一章中,我们将看看如何在非正统的环境中使用 Rust,比如那些没有操作系统的环境,或者那些甚至没有动态分配内存能力的环境!我们的大部分讨论将集中在 #![no_std] 属性上,但我们也会研究 Rust 的 alloc 模块、Rust 运行时(是的,Rust 在技术上确实有一个运行时),以及一些你为在这种环境中使用 Rust 二进制文件而必须玩的技巧。
选择不使用标准库(Opting Out of the Standard Library)
作为一种语言,Rust 由多个独立部分组成。首先是编译器,它决定了 Rust 语言的语法,并实现了类型检查、借用检查,以及最终转换为机器可运行的代码。然后是标准库,std,它实现了大多数程序需要的所有有用的通用功能--像文件和网络访问、时间概念、打印和读取用户输入的设施,等等。但 std 本身也是一个复合体,它建立在另外两个更基本的库之上,称为 core 和 alloc。事实上,std 中的许多类型和函数都是从这两个库中重新输出的。
core 库位于标准库金字塔的底部,包含了除了 Rust 语言本身和所产生的程序运行的硬件之外的任何功能--如排序算法、标记类型、基本类型(如 Option 和 Result )、低级操作(如原子内存访问方法)和编译器提示。core 库的工作方式就像操作系统不存在一样,所以没有标准输入,没有文件系统,也没有网络。同样,也没有内存分配器,所以像 Box、Vec 和 HashMap 这样的类型也无处可寻。
core 部分的上方是 alloc,它拥有所有依赖于动态内存分配的功能,比如集合、智能指针和动态分配的字符串(String)。我们将在下一节回到 alloc。
大多数情况下,由于 std 重新输出了 core 和 alloc 中的所有东西,开发者不需要知道这三个库之间的区别。这意味着即使 Option 在技术上存在于 core::option::Option 中,你也可以通过 std::option::Option 访问它。
然而,在一个非正统的环境中,例如在没有操作系统的嵌入式设备上,这种区别是至关重要的。虽然使用 Iterator 或对数字列表进行排序没有问题,但嵌入式设备可能根本就没有访问文件(因为这需要一个文件系统)或打印到终端(因为这需要一个终端)的有意义的方法--所以没有 File 或 println! 此外,设备的内存可能非常小,动态内存分配是你无法承受的奢侈品,因此任何在飞行中分配内存的东西都是不可能的--和 Box 和 Vec 说再见吧。
与其强迫开发者在这样的环境中小心翼翼地避开那些基本结构,Rust 提供了一种方法来选择不使用任何东西,但语言的核心功能除外:#![no_std] 属性。这是一个 crate 级的属性(#!),它将 crate 的 prelude(见第 213 页的方框)从 std::prelude 切换到 core::prelude,这样你就不会意外地依赖任何在目标环境中可能无法工作的核心之外的东西。
然而,这就是 #![no_std] 属性的全部作用--它不会阻止你用 extern std 明确地引入标准库。这可能令人惊讶,因为这意味着标记为 #![no_std] 的 crate 事实上可能与不支持 std 的目标环境不兼容,但这个设计决定是有意的:它允许你将你的 crate 标记为不兼容 std,但在启用某些特性时仍然使用标准库的特性。例如,许多 crate 都有一个名为 std 的特性,当它被启用时,可以访问更复杂的 API,并与 std 中的类型进行整合。这使得 crate 作者既可以为受限制的使用案例提供核心实现,又可以为更多标准平台上的消费者添加一些小玩意。
注意:由于特性应该是相辅相成的,所以最好是启用
std的特性而不是禁用std的特性。否则,如果消费者的依赖关系图中的任何 crate 启用了no-std特性,所有消费者将只能访问没有 std 支持的原始 API,这可能意味着他们所依赖的 API 不可用,导致他们不再编译。
预导入(prelude)
你有没有想过,为什么有些类型和特性--如 Box、Iterator、Option 和 Clone--在每个 Rust 文件中都有,而你却不需要 use 它们?或者为什么你不需要 use 标准库中的任何宏(比如 vec![])?原因是每个 Rust 模块都会自动导入 Rust 标准 prelude,并隐含使用 std::prelude::rust_2021::*(或其他版本的类似内容),这就把 crate 所选版本的 prelude 的所有输出都纳入了范围。前奏模块本身除了自动包含之外并没有什么特别之处--它们只是关键类型、特征和宏的 pub use 语句的集合,Rust 的开发者希望它们能被普遍使用。
动态内存分配
正如我们在第一章中所讨论的,一台机器有许多不同的内存区域,每一个区域都有不同的用途。静态内存用于存放程序代码和静态变量,栈用于存放函数本地变量和函数参数,而堆则用于存放其他东西。堆支持在运行时分配不同大小的内存区域,而且这些分配的内存可以在你希望的时间内存在。这使得堆内存的用途非常广泛,因此,你会发现它无处不在。Vec、String、Arc 和 Rc 以及集合类型都是在堆内存中实现的,这使得它们可以随着时间的推移而增长和缩小,并且可以从函数中返回而不需要借用检查器。
在幕后,堆实际上只是一大块连续的内存,由分配器管理。分配器提供了堆中不同分配的假象,确保这些分配不会重叠,不再使用的内存区域会被重新使用。默认情况下,Rust 使用系统分配器,这通常是由标准 C 库决定的。这对大多数使用情况来说是很好的,但如果有必要,你可以通过 GlobalAlloc 特性和 #[global_allocator] 属性来覆盖 Rust 将使用的分配器,这需要实现一个 alloc 方法来分配一个新的内存段,并通过 dealloc 将过去的分配返回给分配器来重新使用。
在没有操作系统的环境中,标准 C 库一般也无法使用,所以标准系统分配器也无法使用。由于这个原因,#![no_std] 也排除了所有依赖动态内存分配的类型。但是,由于完全有可能在没有完整的操作系统的情况下实现内存分配器,Rust 允许你只选择进入 Rust 标准库中需要分配器的部分,而不需要通过 alloc crate 选择进入所有的 std。alloc crate 是标准 Rust 工具链的一部分(就像 core 和 std 一样),包含了大多数你喜欢的堆分配类型,比如 Box、Arc、String、Vec 和 BTreeMap。HashMap 不在其中,因为它的密钥散列依赖于随机数生成,而随机数生成是操作系统的一种设施。要在 no_std 上下文中使用来自 alloc 的类型,你所要做的就是把这些类型的任何导入都替换成 use std::,而不是 use alloc::。但请记住,依赖 alloc 意味着你的 #![no_std] crate 将不再能被任何不允许动态内存分配的程序使用,因为它没有分配器,或者因为它的内存太少,首先就不允许动态内存分配。
注意:一些编程领域,比如 Linux 内核,可能只允许动态内存分配,但必须优雅地处理超出内存的错误(也就是说,不恐慌)。 对于这样的用例,你要为你暴露的可能分配的任何方法提供
try_版本。try_方法应该使用任何内部类型的易错方法(比如目前不稳定的Box::try_new或Vec::try_reserve),而不是那些只是恐慌的方法(比如Box::new或Vec::reserve),并将这些错误传播给调用者,后者可以适当地处理它们。
你可能会觉得很奇怪,写出只使用 core 的非实质性的板块是可能的。毕竟,它们不能使用集合、String 类型、网络或文件系统,而且它们甚至没有时间的概念。只用 core 的 crate 的诀窍是利用栈和静态分配。例如,对于一个无堆的向量,你在前面分配足够的内存--无论是在静态内存中还是在函数的栈帧中--用于你期望向量能够容纳的最大数量的元素,然后用一个跟踪它目前容纳的元素数量的 usize 来增加它。要向向量推送,你要写到(静态大小的)数组中的下一个元素,并递增一个跟踪元素数量的变量。如果向量的长度达到了静态大小,下一次推送就会失败。 清单 12-1 给出了一个使用 const 泛型实现的无堆向量类型的例子。
#![allow(unused)] fn main() { struct ArrayVec<T, const N: usize> { values: [Option<T>; N], len: usize, } impl<T, const N: usize> ArrayVec<T, N> { fn try_push(&mut self, t: T) -> Result<(), T> { if self.len == N { return Err(t); } self.values[self.len] = Some(t); self.len += 1; return Ok(()); } } // 清单 12-1:一个无堆的矢量类型 }
我们使 ArrayVec 在其元素的类型 T 和元素的最大数量 N 上通用,然后将向量表示为 N 个可选 T 的数组。这个结构总是存储 N 个 Option<T>,所以它的大小在编译时就已经知道了,并且可以存储在堆栈中,但是它仍然可以通过使用运行时信息来告知我们如何访问数组而像一个矢量一样行动。
注意:我们可以使用
[MaybeUninit<T>; N]来实现ArrayVec,以避免Option的开销,但这需要使用不安全的代码,这对这个例子来说是不值得的。
Rust 运行时
你可能听说过 Rust 没有运行时的说法,虽然这在很大程度上是真的--它没有垃圾收集器,没有解释器,也没有内置的用户级调度器--但从严格意义上来说,这并不是真的。具体来说,Rust 确实有一些特殊的代码,在你的 main 函数之前运行,并对你的代码中的某些特殊条件做出反应,这确实是一种裸奔的运行时间。
恐慌的处理程序(The Panic Handler)
这种特殊代码的第一部分是 Rust 的恐慌处理程序。当 Rust 代码通过调用 panic! 或 panic_any 发生恐慌时,恐慌处理程序决定了接下来会发生什么。当 Rust 运行时可用时--就像大多数提供 std 的目标一样--恐慌处理程序首先调用通过 std::panic::set_hook 设置的恐慌钩子,默认情况下,它会打印一条消息和一个回溯到标准错误。然后,它要么展开当前线程的堆栈,要么中止进程,这取决于当前编译时选择的恐慌设置(通过 Cargo 配置或直接传递给 rustc 的参数)。
然而,并不是所有的目标都提供了一个恐慌处理程序。例如,大多数嵌入式目标都不提供,因为不一定有一个单一的实现能让这样的目标在所有用途中都有意义。对于那些没有提供恐慌处理程序的目标,Rust 仍然需要知道当恐慌发生时应该怎么做。为此,我们可以使用 #[panic_handler] 属性来装饰程序中的一个签名为 fn(&PanicInfo) -> ! 的函数。这个函数在程序调用恐慌时被调用,并以 core::panic::PanicInfo 的形式传递关于恐慌的信息。该函数如何处理这些信息是完全没有规定的,但它永远不能返回(正如 ! 返回类型所表示的)。这一点很重要,因为 Rust 编译器假定不会运行恐慌之后的代码。
恐慌处理程序有许多有效的方法来避免返回。 标准的恐慌处理程序会展开线程的栈,然后终止线程,但恐慌处理程序也可以使用 loop{} 来停止线程,中止程序,或者做任何其他对目标平台有意义的事情,甚至可以重置设备。
程序初始化(Program Initialization)
与人们的想法相反,main 函数并不是 Rust 程序中第一个运行的东西。相反,Rust 二进制文件中的 main 符号实际上指向了标准库中一个名为 lang_start 的函数。 该函数为 Rust 运行时进行了(相当少的)设置,包括将程序的命令行参数存放在 std::env::args 可以获取的地方,设置主线程的名称,在主函数中处理恐慌,在程序退出时刷新标准输出,并设置信号处理程序。lang_start 函数反过来调用你的 crate 中定义的主函数,这样就不需要考虑 Windows 和 Linux 在命令行参数传递方式上的不同。
这种安排在所有这些设置都是合理的并被支持的平台上效果很好,但在嵌入式平台上却出现了问题,因为程序启动时可能无法访问主内存。在这样的平台上,你通常希望使用 #![no_main] crate 级别属性来选择完全不使用 Rust 初始化代码。这个属性完全省略了 lang_start,这意味着你作为开发者必须弄清楚程序应该如何启动,比如通过声明一个 #[export_name = "main"] 的函数来匹配目标平台的预期启动顺序。
注意:在跳转到定义的开始符号之前真正不运行任何代码的平台上,如大多数嵌入式设备,静态变量的初始值甚至可能与源代码中指定的内容不一致。在这种情况下,你的初始化函数将需要用程序二进制中指定的初始数据值明确地初始化各种静态内存段。
内存不足的处理程序(The Out-of-Memory Handler)
如果你写的程序希望使用 alloc,但它所处的平台没有提供分配器,你必须使用本章前面提到的 #[global_allocator] 属性来决定使用哪个分配器。但是你还必须指定如果全局分配器不能分配内存会发生什么。具体来说,你需要定义一个内存不足的处理程序,说明如果像 Vec::push 这样的无懈可击的操作需要分配更多的内存,但分配器不能提供,应该怎么办。
在支持 std 的平台上,内存不足处理程序的默认行为是向标准错误打印一条错误信息,然后中止进程。然而,在一个平台上,例如,没有标准错误,这显然是行不通的。在撰写本文时,在这样的平台上,你的程序必须使用不稳定属性 #[lang = "oom"] 明确定义一个内存不足处理程序。 请记住,处理程序几乎肯定会阻止将来的执行,否则,试图分配内存的代码将继续执行,而不知道它没有收到它所请求的内存!
注意:当你读到这篇文章时,内存外处理程序可能已经稳定在一个永久的名字之下(
#[alloc_error_handler],很可能)。我们也正在努力为默认的 std 内存外处理程序提供与 Rust 的 panic 处理程序相同的 "钩子" 功能,这样代码就可以通过set_alloc_error_hook这样的方法来改变内存外行为。
低级别的内存访问(Low-Level Memory Accesses)
在第 10 章中,我们讨论了这样一个事实:编译器在如何将你的程序语句转化为机器指令方面有相当大的回旋余地,而 CPU 也被允许有一些回旋余地来执行不按顺序的指令。通常情况下,编译器和 CPU 可以利用的捷径和优化对程序的语义来说是不可见的--你一般无法知道,比如说,两个读数是否被重新排序,或者从同一内存位置的两个读数是否实际导致了两条 CPU 的加载指令。这是设计上的问题。语言和硬件设计者仔细地规定了程序员在代码运行时通常期望的语义,因此你的代码通常会做你期望的事情。
然而,no_std 编程有时会让你超越 "隐形优化" 的通常边界。特别是,你经常会通过内存映射与硬件设备进行通信,设备的内部状态在内存中被精心选择的区域内提供。例如,当你的计算机启动时,内存地址范围 0xA0000-0xBFFFF 映射到一个粗糙的图形渲染管道;对该范围内的单个字节的写入将改变屏幕上的特定像素(或块,取决于模式)。
当你与设备映射的内存进行交互时,设备可能会对该内存区域的每个内存访问实施自定义行为,因此你的 CPU 和编译器对常规内存加载和存储的假设可能不再成立。例如,硬件设备的内存映射寄存器在被读取时被修改是很常见的,这意味着读取有副作用。在这种情况下,如果你连续两次读取相同的内存地址,编译器就不能安全地避开内存存储操作!
当程序的执行突然以代码中没有体现的方式被转移,从而使编译器无法预期时,就会出现类似的问题。如果没有底层操作系统来处理处理器的异常或中断,或者如果一个进程收到中断执行的信号,那么执行就可能被转移。在这些情况下,活动代码段的执行被停止,而 CPU 开始执行事件处理程序中的指令,以代替触发分流的事件。通常情况下,由于编译器可以预测所有可能的执行,它安排了它的优化,使执行不能观察到操作已经被执行的顺序错误或被优化掉。然而,由于编译器不能预测这些特殊的跳转,它也不能计划让它们对它的优化视而不见,所以这些事件处理程序实际上可能会观察到与原始程序代码中不同顺序运行的指令。
为了处理这些特殊情况,Rust 提供了易失性内存操作,这些操作不能与其他易失性操作一起被省略或重新排序。这些操作的形式是 std::ptr::read_volatile 和 std::ptr::write_volatile。易失性操作正好适合访问内存映射的硬件资源:它们直接映射到内存访问操作,不需要编译器的技巧,而且易失性操作相对于其他操作不排序的保证,确保了有可能产生副作用的硬件操作不会不按顺序发生,即使它们通常看起来是可以互换的(比如一个地址的加载和一个不同地址的存储)。只要任何触及在特殊情况下访问的内存的代码只使用易失性内存操作,无排序保证也有助于特殊情况的执行。
注意:还有一个
std::sync::atomic::compiler_fence函数可以防止编译器对非易失性内存访问进行重新排序。你很少需要编译器栅栏,但它的文档是一个有趣的阅读。
包括汇编代码
这些天来,你很少需要下降到编写汇编代码来完成任何特定的任务。但是对于那些需要在启动时初始化 CPU 或发出奇怪指令来操作内存映射的低级硬件编程,有时仍然需要汇编代码。在写这篇文章的时候,有一个 RFC,并且在夜间的 Rust 上有一个基本完整的内联汇编语法的实现,但是还没有稳定下来,所以我不会在这本书中讨论这个语法。
在稳定的 Rust 上编写汇编仍然是可能的,你只需要有一点创造力。特别是,还记得第 11 章的构建脚本吗?好吧,Cargo 的构建脚本可以向标准输出发出某些特殊指令,以增强 Cargo 的标准构建过程,包括 cargo:rustc-link-lib=static=xyz 来链接静态库文件 libxyz.a 到最终的二进制文件,以及 cargo:rustc-link-search:/some/path 来将 /some/path 加入链接对象的搜索路径中。利用这些,我们可以在项目中添加一个 build.rs,用目标平台的编译器将独立的汇编文件(.s)编译成对象文件(.o),然后用适当的归档工具(通常是 ar)将其重新打包成静态档案(.a)。然后,项目发出这两条 Cargo 指令,指出它在 OUT_DIR 中放置静态归档文件的位置,我们就可以开始工作了 如果目标平台没有变化,你甚至可以在发布你的 crate 时包括预编译的 .a,这样消费者就不需要重建它了。
抗误用的硬件抽象(Misuse-Resistant Hardware Abstraction)
Rust 的类型系统擅长于将不安全的、多毛的和其他不愉快的代码封装在安全的、符合人体工程学的接口后面。 这一点在臭名昭著的复杂的低级系统编程世界中更为重要,它充满了从晦涩的手册中提取的神奇的硬件定义值和神秘的无文档的汇编指令咒语,以使设备进入正确的状态。而所有这一切都发生在一个运行时错误可能会导致不止一个用户程序崩溃的空间里。
在 no_std 程序中,正如我们在第三章中讨论的那样,使用类型系统来使非法状态不可能被表示出来,这是非常重要的。如果某些寄存器值的组合不能同时出现,那么就创建一个单一的类型,其类型参数表示相关寄存器的当前状态,并在其上只实现合法的转换,就像我们在清单 3-2 中的火箭例子那样。
注意:请确保同时回顾第三章中关于 API 设计的建议--所有这些建议也适用于
no_std程序的背景。
例如,考虑一对寄存器,在任何给定的时间点上,最多只有一个寄存器应该是 "打开 "的。清单 12-2 显示了你如何在一个(单线程)程序中表示这一点,使之不可能写出违反该不变性的代码。
#![allow(unused)] fn main() { // raw register address -- private submodule mod registers; pub struct On; pub struct Off; pub struct Pair<R1, R2>(PhantomData<(R1, R2)>); impl Pair<Off, Off> { pub fn get() -> Option<Self> { static mut PAIR_TAKEN: bool = false; if unsafe { PAIR_TAKEN } { None } else { // Ensure initial state is correct. registers::off("r1"); registers::off("r2"); unsafe { PAIR_TAKEN = true }; Some(Pair(PhantomData)) } } pub fn first_on(self) -> Pair<On, Off> { registers::set_on("r1"); Pair(PhantomData) } // .. and inverse for -> Pair<Off, On> } impl Pair<On, Off> { pub fn off(self) -> Pair<Off, Off> { registers::set_off("r1"); Pair(PhantomData) } } // .. and inverse for Pair<Off, On> // 清单 12-2:静态地确保正确的操作 }
在这段代码中,有几个值得注意的模式。首先,我们通过在唯一的构造函数中检查一个私有的静态布尔值并使所有的方法都消耗 self 来确保 Pair 的单一实例永远存在。然后,我们确保初始状态是有效的,并且只有有效的状态转换才有可能被表达出来,因此该不变式必须在全局上成立。
清单 12-2 中第二个值得注意的模式是,我们使用 PhantomData 来利用零大小的类型,静态地表示运行时信息。也就是说,在代码的任何给定点,这些类型告诉我们运行时的状态必须是什么,因此我们不需要在运行时跟踪或检查任何与寄存器有关的状态。当我们被要求启用 r1 时,不需要检查 r2 是否已经打开,因为这些类型可以防止编写出这样的程序。
交叉编译
通常,你会在一台运行着成熟的操作系统和现代硬件的所有好处的计算机上编写 no_std 程序,但最终会在一个只有 93/4 比特的内存和一个 CPU 的袜子的小硬件设备上运行它。这就需要交叉编译--你需要在你的开发环境中编译代码,但要为袜套编译。不过,这并不是交叉编译很重要的唯一情况。例如,让一个构建管道为所有消费者平台产生二进制工件,而不是为你的消费者可能使用的每一个平台都有一个构建管道,这意味着使用交叉编译,这种情况越来越普遍。
注意:如果你实际上是为内存有限的类似袜子的东西进行编译,甚至是像土豆一样花哨的东西,你可能想把
opt-levelCargo 配置设置为 "s",以优化较小的二进制大小。
交叉编译涉及两个平台:主机平台和目标平台。主机平台是进行编译的平台,而目标平台是最终将运行编译输出的平台。我们将平台指定为目标三要素,其形式为 machine-vendor-os 。machine部分决定了代码将在哪种机器架构上运行,例如 x86_64、armv7 或 wasm32,并告诉编译器对发出的机器代码使用什么指令集。vendor部分通常在 Windows 上取值为 pc,在 macOS 和 iOS 上取值为 apple,在其他地方取值为 unknown,并且不会以任何有意义的方式影响编译;它大部分是不相关的,甚至可以不取。os 部分告诉编译器对最终的二进制工件使用什么格式,所以 linux 的值决定了 Linux .so 文件,windows 决定了 Windows .dll 文件,等等。
注意:默认情况下,Cargo 假设目标平台与主机平台相同,这就是为什么你通常不需要告诉 Cargo,例如,当你已经在 Linux 上时,为 Linux 进行编译。有时你可能想使用
--target,即使目标的 CPU 和操作系统是相同的,例如,目标是libc的musl实现。
要告诉 Cargo 进行交叉编译,你只需将 --target <targettriple> 参数和你选择的三元组传给它。然后,Cargo 会把这些信息转发给 Rust 编译器,使其生成的二进制文件能在指定的目标平台上运行。Cargo 也会注意为该平台使用适当版本的标准库--毕竟,标准库包含很多条件性编译指令(使用 #[cfg(..)]),以便调用正确的系统调用并使用正确的特定架构实现,所以我们不能在目标平台上使用主机平台的标准库。
目标平台也决定了标准库的哪些组件是可用的。例如,虽然 x86_64-unknown-linuxgnu 包括完整的 std 库,但像 thumbv7m-one-eabi 这样的东西却没有,甚至没有定义一个分配器,所以如果你在没有明确定义分配器的情况下使用 alloc,你会得到一个构建错误。这对于测试你写的代码是否真的不需要 std 很有帮助(记得即使使用 #![no_std],你仍然可以使用 std::,因为 no_std 只选择了 std 的 prelude)。如果你让你的持续集成管道用 --target thumbv7m-non-eabi 来构建你的 crate,那么任何试图从核心以外的地方访问组件的行为都会触发构建失败。 重要的是,这也会检查你的 crate 是否意外地引入了那些本身使用 std(或 alloc)项目的依赖。
平台支持
标准的 Rust 安装程序 Rustup 并没有为 Rust 默认支持的所有目标三元组安装标准库。相反,你必须使用
rustup target add命令来为额外的目标安装相应的标准库版本。如果你的目标平台没有标准库的版本,你就必须自己从源代码中编译它,方法是添加rust-srcRustup 组件,并使用 Cargo 的(目前还不稳定)build-std 功能,在构建任何 crate 时也构建 std(和/或 core and alloc)。如果你的目标不被 Rust 编译器支持,也就是说,如果 Rustc 甚至不知道你的目标三元组,你就必须更进一步,用自定义目标规范来教 Rustc 关于三元组的属性。如何做到这一点,目前还不稳定,也超出了本书的范围,但搜索 "自定义目标规范 json "是一个不错的开始。
总结
在这一章中,我们已经介绍了标准库下面的东西,或者更准确地说,是 std 下面的东西。我们已经介绍了 core 的内容,如何用 alloc 来扩展 no-std 的范围,以及 Rust 运行时(很小)为你的程序添加了什么来使 fn main 工作。 我们还看了如何与设备映射的内存进行交互,以及如何处理在硬件编程的最底层可能发生的非正统的执行模式,以及如何在 Rust 类型系统中安全地封装至少一些硬件的怪异现象。接下来,我们将通过讨论如何浏览、理解,甚至为更大的 Rust 生态系统做出贡献,来从很小的地方走向很大的地方。