├── .gitignore ├── 01-freestanding-rust-binary.md ├── 02-minimal-rust-kernel.md ├── 03-vga-text-mode.md ├── 04-testing.md ├── 05-cpu-exceptions.md ├── 06-double-fault-exceptions.md ├── 07-hardware-interrupts.md ├── 08-introduction-to-paging.md ├── 09-paging-implementation.md ├── 10-heap-allocation.md ├── 11-allocator-designs.md ├── 12-async-await.md ├── LICENSE ├── README.md ├── appendix-a-linker-arguments.md ├── appendix-b-red-zone.md ├── appendix-c-disable-simd.md ├── dummy.rs └── translation-table.md /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | 3 | -------------------------------------------------------------------------------- /01-freestanding-rust-binary.md: -------------------------------------------------------------------------------- 1 | > 原文:https://os.phil-opp.com/freestanding-rust-binary/ 2 | > 3 | > 原作者:@phil-opp 4 | > 5 | > 译者:洛佳 华中科技大学 6 | 7 | # 使用Rust编写操作系统(一):独立式可执行程序 8 | 9 | 我们的第一步,是在不连接标准库的前提下,创建独立的Rust可执行文件。无需底层操作系统的支撑,这将能让在**裸机**([bare metal](https://en.wikipedia.org/wiki/Bare_machine))上运行Rust代码成为现实。 10 | 11 | ## 简介 12 | 13 | 要编写一个操作系统内核,我们的代码应当不基于任何的操作系统特性。这意味着我们不能使用线程、文件、堆内存、网络、随机数、标准输出,或其它任何需要特定硬件和操作系统抽象的特性;这其实讲得通,因为我们正在编写自己的硬件驱动和操作系统。 14 | 15 | 实现这一点,意味着我们不能使用[Rust标准库](https://doc.rust-lang.org/std/)的大部分;但还有很多Rust特性是我们依然可以使用的。比如说,我们可以使用[迭代器](https://doc.rust-lang.org/book/ch13-02-iterators.html)、[闭包](https://doc.rust-lang.org/book/ch13-01-closures.html)、[模式匹配](https://doc.rust-lang.org/book/ch06-00-enums.html)、[Option](https://doc.rust-lang.org/core/option/)、[Result](https://doc.rust-lang.org/core/result/index.html)、[字符串格式化](https://doc.rust-lang.org/core/macro.write.html),当然还有[所有权系统](https://doc.rust-lang.org/book/ch04-00-understanding-ownership.html)。这些功能让我们能够编写表达性强、高层抽象的操作系统,而无需操心[未定义行为](https://www.nayuki.io/page/undefined-behavior-in-c-and-cplusplus-programs)和[内存安全](https://tonyarcieri.com/it-s-time-for-a-memory-safety-intervention)。 16 | 17 | 为了用Rust编写一个操作系统内核,我们需要独立于操作系统,创建一个可执行程序。这样的可执行程序常被称作**独立式可执行程序**(freestanding executable)或**裸机程序**(bare-metal executable)。 18 | 19 | 在这篇文章里,我们将逐步地创建一个独立式可执行程序,并且详细解释为什么每个步骤都是必须的。如果读者只对最终的代码感兴趣,可以跳转到本篇文章的小结部分。 20 | 21 | ## 禁用标准库 22 | 23 | 在默认情况下,所有的Rust**包**(crate)都会链接**标准库**([standard library](https://doc.rust-lang.org/std/)),而标准库依赖于操作系统功能,如线程、文件系统、网络。标准库还与**Rust的C语言标准库实现库**(libc)相关联,它也是和操作系统紧密交互的。既然我们的计划是编写自己的操作系统,我们就可以不使用任何与操作系统相关的库——因此我们必须禁用**标准库自动引用**(automatic inclusion)。使用[no_std属性](https://doc.rust-lang.org/book/first-edition/using-rust-without-the-standard-library.html)可以实现这一点。 24 | 25 | 我们可以从创建一个新的cargo项目开始。最简单的办法是使用下面的命令: 26 | 27 | ```bash 28 | > cargo new blog_os 29 | ``` 30 | 31 | 这里,我把项目命名为`blog_os`,当然读者也可以选择自己的项目名称。这里,cargo默认为我们添加了`--bin`选项,说明我们将要创建一个可执行文件(而不是一个库);cargo还为我们添加了`--edition 2018`标签,指明项目的包要使用Rust的**2018版次**([2018 edition](https://rust-lang-nursery.github.io/edition-guide/rust-2018/index.html))。当我们执行这行指令的时候,cargo为我们创建的目录结构如下: 32 | 33 | ```text 34 | blog_os 35 | ├── Cargo.toml 36 | └── src 37 | └── main.rs 38 | ``` 39 | 40 | 在这里,`Cargo.toml`文件包含了包的**配置**(configuration),比如包的名称、作者、[semver版本](http://semver.org/)和项目依赖项;`src/main.rs`文件包含包的**根模块**(root module)和main函数。我们可以使用`cargo build`来编译这个包,然后在`target/debug`文件夹内找到编译好的`blog_os`二进制文件。 41 | 42 | ### no_std属性 43 | 44 | 现在我们的包依然隐式地与标准库链接。为了禁用这种链接,我们可以尝试添加[no_std属性](https://doc.rust-lang.org/book/first-edition/using-rust-without-the-standard-library.html): 45 | 46 | ```rust 47 | // main.rs 48 | 49 | #![no_std] 50 | 51 | fn main() { 52 | println!("Hello, world!"); 53 | } 54 | ``` 55 | 56 | 看起来非常顺利。但我们使用`cargo build`来编译时,却出现了下面的错误: 57 | 58 | ```rust 59 | error: cannot find macro `println!` in this scope 60 | --> src\main.rs:4:5 61 | | 62 | 4 | println!("Hello, world!"); 63 | | ^^^^^^^ 64 | ``` 65 | 66 | 出现这个错误的原因是,[println!宏](https://doc.rust-lang.org/std/macro.println.html)是标准库的一部分,而我们的项目不再依赖标准库。我们选择不再打印字符串。这也能解释得通,因为`println!`将会向**标准输出**([standard output](https://en.wikipedia.org/wiki/Standard_streams#Standard_output_.28stdout.29))打印字符,它依赖于特殊的文件描述符;这个特性是由操作系统提供的。 67 | 68 | 所以我们可以移除这行代码,这样main函数就是空的了。再次编译: 69 | 70 | ```rust 71 | // main.rs 72 | 73 | #![no_std] 74 | 75 | fn main() {} 76 | ``` 77 | 78 | ``` 79 | > cargo build 80 | error: `#[panic_handler]` function required, but not found 81 | error: language item required, but not found: `eh_personality` 82 | ``` 83 | 84 | 现在我们发现,代码缺少一个`#[panic_handler]`函数和一个**语言项**(language item)。 85 | 86 | ## 实现panic处理函数 87 | 88 | `panic_handler`属性被用于定义一个函数;在程序panic时,这个函数将会被调用。标准库中提供了自己的panic处理函数,但在`no_std`环境中,我们需要定义自己的panic处理函数: 89 | 90 | ```rust 91 | // in main.rs 92 | 93 | use core::panic::PanicInfo; 94 | 95 | /// 这个函数将在panic时被调用 96 | #[panic_handler] 97 | fn panic(_info: &PanicInfo) -> ! { 98 | loop {} 99 | } 100 | ``` 101 | 102 | 类型为[PanicInfo](https://doc.rust-lang.org/nightly/core/panic/struct.PanicInfo.html)的参数包含了panic发生的文件名、代码行数和可选的错误信息。这个函数从不返回,所以他被标记为**发散函数**([diverging function](https://doc.rust-lang.org/book/first-edition/functions.html#diverging-functions))。发散函数的返回类型称作**Never类型**(["never" type](https://doc.rust-lang.org/nightly/std/primitive.never.html)),记为`!`。对这个函数,我们目前能做的事情很少,所以我们只需编写一个无限循环`loop {}`。 103 | 104 | ## eh_personality语言项 105 | 106 | 语言项是一些编译器需求的特殊函数或类型。举例来说,Rust的[Copy](https://doc.rust-lang.org/nightly/core/marker/trait.Copy.html) trait是一个这样的语言项,告诉编译器哪些类型需要遵循**复制语义**([copy semantics](https://doc.rust-lang.org/nightly/core/marker/trait.Copy.html))——当我们查找`Copy` trait的[实现](https://github.com/rust-lang/rust/blob/485397e49a02a3b7ff77c17e4a3f16c653925cb3/src/libcore/marker.rs#L296-L299)时,我们会发现,一个特殊的`#[lang = "copy"]`属性将它定义为了一个语言项,达到与编译器联系的目的。 107 | 108 | 我们可以自己实现语言项,但这只应该是最后的手段:目前来看,语言项是高度不稳定的语言细节实现,它们不会经过编译期类型检查(所以编译器甚至不确保它们的参数类型是否正确)。幸运的是,我们有更稳定的方式,来修复上面的语言项错误。 109 | 110 | `eh_personality`语言项标记的函数,将被用于实现**栈展开**([stack unwinding](http://www.bogotobogo.com/cplusplus/stackunwinding.php))。在使用标准库的情况下,当panic发生时,Rust将使用栈展开,来运行在栈上活跃的所有变量的**析构函数**(destructor)——这确保了所有使用的内存都被释放,允许调用程序的**父进程**(parent thread)捕获panic,处理并继续运行。但是,栈展开是一个复杂的过程,如Linux的[libunwind](http://www.nongnu.org/libunwind/)或Windows的**结构化异常处理**([structured exception handling, SEH](https://msdn.microsoft.com/en-us/library/windows/desktop/ms680657(v=vs.85).aspx)),通常需要依赖于操作系统的库;所以我们不在自己编写的操作系统中使用它。 111 | 112 | ### 禁用栈展开 113 | 114 | 在其它一些情况下,栈展开不是迫切需求的功能;因此,Rust提供了**panic时中止**([abort on panic](https://github.com/rust-lang/rust/pull/32900))的选项。这个选项能禁用栈展开相关的标志信息生成,也因此能缩小生成的二进制程序的长度。有许多方式能打开这个选项,最简单的方式是把下面的几行设置代码加入我们的`Cargo.toml`: 115 | 116 | ```toml 117 | [profile.dev] 118 | panic = "abort" 119 | 120 | [profile.release] 121 | panic = "abort" 122 | ``` 123 | 124 | 这些选项能将**dev配置**(dev profile)和**release配置**(release profile)的panic策略设为`abort`。`dev`配置适用于`cargo build`,而`release`配置适用于`cargo build --release`。现在编译器应该不再要求我们提供`eh_personality`语言项实现。 125 | 126 | 现在我们已经修复了出现的两个错误,可以信心满满地开始编译了。然而,尝试编译运行后,一个新的错误出现了: 127 | 128 | ```bash 129 | > cargo build 130 | error: requires `start` lang_item 131 | ``` 132 | 133 | ## start语言项 134 | 135 | 这里,我们的程序遗失了`start`语言项,它将定义一个程序的**入口点**(entry point)。 136 | 137 | 我们通常会认为,当运行一个程序时,首先被调用的是`main`函数。但是,大多数语言都拥有一个**运行时系统**([runtime system](https://en.wikipedia.org/wiki/Runtime_system)),它通常为**垃圾回收**(garbage collection)或**绿色线程**(software threads,或green threads)服务,如Java的GC或Go语言的协程(goroutine);这个运行时系统需要在main函数前启动,因为它需要让程序初始化。 138 | 139 | 一个典型的使用标准库的Rust程序,它的运行将从名为`crt0`的运行时库开始。`crt0`意为C runtime zero,它能建立一个适合运行C语言程序的环境,这包含了栈的创建和可执行程序参数的传入。这之后,这个运行时库会调用[Rust的运行时入口点](https://github.com/rust-lang/rust/blob/bb4d1491466d8239a7a5fd68bd605e3276e97afb/src/libstd/rt.rs#L32-L73),这个入口点被称作**start语言项**("start" language item)。Rust只拥有一个极小的运行时,它只拥有较少的功能,如爆栈检测和打印**堆栈轨迹**(stack trace)。这之后,运行时将会调用main函数。 140 | 141 | 我们的独立式可执行程序并不能访问Rust运行时或`crt0`库,所以我们需要定义自己的入口点。实现一个`start`语言项并不能解决问题,因为这之后程序依然要求`crt0`库。所以,我们要做的是,直接重写整个`crt0`库和它定义的入口点。 142 | 143 | ### 重写入口点 144 | 145 | 要告诉Rust编译器我们不使用预定义的入口点,我们可以添加`#![no_main]`属性。 146 | 147 | ```rust 148 | #![no_std] 149 | #![no_main] 150 | 151 | use core::panic::PanicInfo; 152 | 153 | /// 这个函数将在panic时被调用 154 | #[panic_handler] 155 | fn panic(_info: &PanicInfo) -> ! { 156 | loop {} 157 | } 158 | ``` 159 | 160 | 读者也许会注意到,我们移除了`main`函数。很显然,既然没有底层已有的运行时调用它,`main`函数将不会被运行。为了重写操作系统的入口点,我们转而编写一个`_start`函数: 161 | 162 | ```rust 163 | #[no_mangle] 164 | pub extern "C" fn _start() -> ! { 165 | loop {} 166 | } 167 | ``` 168 | 169 | 我们使用`no_mangle`标记这个函数,来对它禁用**名称重整**([name mangling](https://en.wikipedia.org/wiki/Name_mangling))——这确保Rust编译器输出一个名为`_start`的函数;否则,编译器可能最终生成名为`_ZN3blog_os4_start7hb173fedf945531caE`的函数,无法让链接器正确辨别。 170 | 171 | 我们还将函数标记为`extern "C"`,告诉编译器这个函数应当使用[C语言的调用约定](https://en.wikipedia.org/wiki/Calling_convention),而不是Rust语言的调用约定。函数名为`_start`,是因为大多数系统默认使用这个名字作为入口点名称。 172 | 173 | 与前文的`panic`函数类似,这个函数的返回值类型为`!`——它定义了一个发散函数,或者说一个不允许返回的函数。这一点是必要的,因为这个入口点不将被任何函数调用,但将直接被操作系统或**引导程序**(bootloader)调用。所以作为函数返回的替换,这个入口点应该调用,比如操作系统提供的**exit系统调用**(["exit" system call](https://en.wikipedia.org/wiki/Exit_(system_call)))函数。在我们编写操作系统的情况下,关机应该是一个合适的选择,因为**当一个独立式可执行程序返回时,不会留下任何需要做的事情**(there is nothing to do if a freestanding binary returns)。暂时来看,我们可以添加一个无限循环,这样可以符合返回值的类型。 174 | 175 | 如果我们现在编译这段程序,会出来一大段不太好看的**链接器错误**(linker error)。 176 | 177 | ## 链接器错误 178 | 179 | **链接器**(linker)是一个程序,它将生成的目标文件组合为一个可执行文件。不同的操作系统如Windows、macOS、Linux,规定了不同的可执行文件格式,因此也各有自己的链接器,抛出不同的错误;但这些错误的根本原因还是相同的:链接器的默认配置假定程序依赖于C语言的运行时环境,但我们的程序并不依赖于它。 180 | 181 | 为了解决这个错误,我们需要告诉链接器,它不应该包含(include)C语言运行环境。我们可以选择提供特定的**链接器参数**(linker argument),也可以选择编译为**裸机目标**(bare metal target)。 182 | 183 | ### 编译为裸机目标 184 | 185 | 在默认情况下,Rust尝试适配当前的系统环境,编译可执行程序。举个栗子,如果你使用`x86_64`平台的Windows系统,Rust将尝试编译一个扩展名为`.exe`的Windows可执行程序,并使用`x86_64`指令集。这个环境又被称作你的**宿主系统**("host" system)。 186 | 187 | 为了描述不同的环境,Rust使用一个称为**目标三元组**(target triple)的字符串。要查看当前系统的目标三元组,我们可以运行`rustc --version --verbose`: 188 | 189 | ``` 190 | rustc 1.35.0-nightly (474e7a648 2019-04-07) 191 | binary: rustc 192 | commit-hash: 474e7a6486758ea6fc761893b1a49cd9076fb0ab 193 | commit-date: 2019-04-07 194 | host: x86_64-unknown-linux-gnu 195 | release: 1.35.0-nightly 196 | LLVM version: 8.0 197 | ``` 198 | 199 | 上面这段输出来自于`x86_64`平台下的Linux系统。我们能看到,`host`字段的值为三元组`x86_64-unknown-linux-gnu`,它分为以下几个部分:CPU架构`x86_64`;供应商`unknown`;操作系统`linux`和[二进制接口](https://en.wikipedia.org/wiki/Application_binary_interface)`gnu`。 200 | 201 | Rust编译器尝试为当前系统的三元组编译,并假定底层有一个类似于Windows或Linux的操作系统提供C语言运行环境——这将导致链接器错误。所以,为了避免这个错误,我们可以另选一个底层没有操作系统的运行环境。 202 | 203 | 这样的运行环境被称作裸机环境,例如目标三元组`thumbv7em-none-eabihf`描述了一个ARM**嵌入式系统**([embedded system](https://en.wikipedia.org/wiki/Embedded_system))。我们暂时不需要了解它的细节,只需要知道这个环境底层没有操作系统——这是由三元组中的`none`描述的。我们需要用rustup安装这个目标: 204 | 205 | ``` 206 | rustup target add thumbv7em-none-eabihf 207 | ``` 208 | 209 | 这行命令将为目标下载一个标准库和core库。这之后,我们就能为这个目标构建独立式可执行程序了: 210 | 211 | ``` 212 | cargo build --target thumbv7em-none-eabihf 213 | ``` 214 | 215 | 我们传递了`--target`参数,来为裸机目标系统**交叉编译**([cross compile](https://en.wikipedia.org/wiki/Cross_compiler))我们的程序。我们的目标并不包括操作系统,所以链接器不会试着链接C语言运行环境,因此构建过程成功完成,不会产生链接器错误。 216 | 217 | 我们将使用这个方法编写自己的操作系统内核。我们不将编译到`thumbv7em-none-eabihf`,而是使用描述`x86_64`环境的**自定义目标**([custom target](https://doc.rust-lang.org/rustc/targets/custom.html))。在下篇文章中,我们将详细描述一些相关的细节。 218 | 219 | ### 链接器参数 220 | 221 | 我们也可以选择不编译到裸机系统,因为传递特定的参数也能解决链接器错误问题。虽然我们不将在后文中使用这个方法,为了教程的完整性,我们也撰写了专门的短文,来提供这个途径的解决方案。 222 | 223 | [链接器参数](./appendix-a-linker-arguments.md) 224 | 225 | ## 小结 226 | 227 | 一个用Rust编写的最小化的独立式可执行程序应该长这样: 228 | 229 | `src/main.rs`: 230 | 231 | ```rust 232 | #![no_std] // 不链接Rust标准库 233 | #![no_main] // 禁用所有Rust层级的入口点 234 | 235 | use core::panic::PanicInfo; 236 | 237 | #[no_mangle] // 不重整函数名 238 | pub extern "C" fn _start() -> ! { 239 | // 因为编译器会寻找一个名为`_start`的函数,所以这个函数就是入口点 240 | // 默认命名为`_start` 241 | loop {} 242 | } 243 | 244 | /// 这个函数将在panic时被调用 245 | #[panic_handler] 246 | fn panic(_info: &PanicInfo) -> ! { 247 | loop {} 248 | } 249 | ``` 250 | 251 | `Cargo.toml`: 252 | 253 | ```toml 254 | [package] 255 | name = "crate_name" 256 | version = "0.1.0" 257 | authors = ["Author Name "] 258 | 259 | # 使用`cargo build`编译时需要的配置 260 | [profile.dev] 261 | panic = "abort" # 禁用panic时栈展开 262 | 263 | # 使用`cargo build --release`编译时需要的配置 264 | [profile.release] 265 | panic = "abort" # 禁用panic时栈展开 266 | ``` 267 | 268 | 选用任意一个裸机目标来编译。比如对`thumbv7em-none-eabihf`,我们使用以下命令: 269 | 270 | ```bash 271 | cargo build --target thumbv7em-none-eabihf 272 | ``` 273 | 274 | 要注意的是,现在我们的代码只是一个Rust编写的独立式可执行程序的一个例子。运行这个二进制程序还需要很多准备,比如在`_start`函数之前需要一个已经预加载完毕的栈。所以为了真正运行这样的程序,我们还有很多事情需要做。 275 | 276 | ## 下篇预告 277 | 278 | 基于这篇文章的成果,下一篇文章要做的更深。我们将详细讲述编写一个最小的操作系统内核需要的步骤:如何配置特定的编译目标,如何将可执行程序与引导程序拼接,以及如何把一些特定的字符串打印到屏幕上。 279 | -------------------------------------------------------------------------------- /02-minimal-rust-kernel.md: -------------------------------------------------------------------------------- 1 | >原文:https://os.phil-opp.com/minimal-rust-kernel/ 2 | > 3 | >原作者:@phil-opp 4 | > 5 | >译者:洛佳 华中科技大学 6 | 7 | # 使用Rust编写操作系统(二):最小化内核 8 | 9 | 这篇文章将基于**x86架构**(the x86 architecture);我们是试着使用Rust语言,编写一个最小化内核。我们将从独立式可执行程序开始,构建自己的内核。我们将向显示器打印字符串,最终打包内核为能引导启动的**磁盘映像**(disk image)。 10 | 11 | ## 引导启动 12 | 13 | 当我们启动电脑时,主板[ROM](https://en.wikipedia.org/wiki/Read-only_memory)内存储的**固件**(firmware)将会运行:它将负责电脑的**上电自检**([power-on self test](https://en.wikipedia.org/wiki/Power-on_self-test)),**可用内存**(available RAM)的检测,以及CPU和其它硬件的预加载。这之后,它将寻找一个**可引导的存储介质**(bootable disk),并开始引导启动其中的**内核**(kernel)。 14 | 15 | x86架构支持两种固件标准:**BIOS**([Basic Input/Output System](https://en.wikipedia.org/wiki/BIOS))和**UEFI**([Unified Extensible Firmware Interface](https://en.wikipedia.org/wiki/Unified_Extensible_Firmware_Interface))。其中,BIOS标准显得陈旧而过时,但实现简单,并为1980年代后的所有x86设备所支持;相反地,UEFI更现代化,功能也更全面,但开发和构建更复杂(至少从我的角度看是如此)。 16 | 17 | 在这篇文章中,我们暂时只提供BIOS固件的引导启动方式。 18 | 19 | ### BIOS启动 20 | 21 | 几乎所有的x86硬件系统都支持BIOS启动,这也包含新式的、基于UEFI、用**模拟BIOS**(emulated BIOS)的方式向后兼容的硬件系统。这可以说是一件好事情,因为无论是上世纪还是现在的硬件系统,你都只需编写同样的引导启动逻辑;但这种兼容性有时也是BIOS引导启动最大的缺点,因为这意味着在系统启动前,你的CPU必须先进入一个16位系统兼容的**实模式**([real mode](https://en.wikipedia.org/wiki/Real_mode)),这样1980年代古老的引导固件才能够继续使用。 22 | 23 | 让我们从头开始,理解一遍BIOS启动的过程。 24 | 25 | 当电脑启动时,主板上特殊的闪存中存储的BIOS固件将被加载。BIOS固件将会上电自检、初始化硬件,然后它将寻找一个可引导的存储介质。如果找到了,那电脑的控制权将被转交给**引导程序**(bootloader):一段存储在存储介质的开头的、512字节长度的程序片段。大多数的引导程序长度都大于512字节——所以通常情况下,引导程序都被切分为一段优先启动、长度不超过512字节、存储在介质开头的**第一阶段引导程序**(first stage bootloader),和一段随后由其加载的、长度可能较长、存储在其它位置的**第二阶段引导程序**(second stage bootloader)。 26 | 27 | 引导程序必须决定内核的位置,并将内核加载到内存。引导程序还需要将CPU从16位的实模式,先切换到32位的**保护模式**([protected mode](https://en.wikipedia.org/wiki/Protected_mode)),最终切换到64位的**长模式**([long mode](https://en.wikipedia.org/wiki/Long_mode)):此时,所有的64位寄存器和整个**主内存**(main memory)才能被访问。引导程序的第三个作用,是从BIOS查询特定的信息,并将其传递到内核;如查询和传递**内存映射表**(memory map)。 28 | 29 | 编写一个引导程序并不是一个简单的任务,因为这需要使用汇编语言,而且必须经过许多意图并不明显的步骤——比如,把一些**魔术数字**(magic number)写入某个寄存器。因此,我们不会讲解如何编写自己的引导程序,而是推荐[bootimage工具](https://github.com/rust-osdev/bootimage)——它能够自动而方便地为你的内核准备一个引导程序。 30 | 31 | ### Multiboot标准 32 | 33 | 每个操作系统都实现自己的引导程序,而这只对单个操作系统有效。为了避免这样的僵局,1995年,**自由软件基金会**([Free Software Foundation](https://en.wikipedia.org/wiki/Free_Software_Foundation))颁布了一个开源的引导程序标准——[Multiboot](https://wiki.osdev.org/Multiboot)。这个标准定义了引导程序和操作系统间的统一接口,所以任何适配Multiboot的引导程序,都能用来加载任何同样适配了Multiboot的操作系统。[GNU GRUB](https://en.wikipedia.org/wiki/GNU_GRUB)是一个可供参考的Multiboot实现,它也是最热门的Linux系统引导程序之一。 34 | 35 | 要编写一款适配Multiboot的内核,我们只需要在内核文件开头,插入被称作**Multiboot头**([Multiboot header](https://www.gnu.org/software/grub/manual/multiboot/multiboot.html#OS-image-format))的数据片段。这让GRUB很容易引导任何操作系统,但是,GRUB和Multiboot标准也有一些可预知的问题: 36 | 37 | 1. 它们只支持32位的保护模式。这意味着,在引导之后,你依然需要配置你的CPU,让它切换到64位的长模式; 38 | 2. 它们被设计为精简引导程序,而不是精简内核。举个栗子,内核需要以调整过的**默认页长度**([default page size](https://wiki.osdev.org/Multiboot#Multiboot_2))被链接,否则GRUB将无法找到内核的Multiboot头。另一个例子是**引导信息**([boot information](https://www.gnu.org/software/grub/manual/multiboot/multiboot.html#Boot-information-format)),这个包含着大量与架构有关的数据,会在引导启动时,被直接传到操作系统,而不会经过一层清晰的抽象; 39 | 3. GRUB和Multiboot标准并没有被详细地注释,阅读相关文档需要一定经验; 40 | 4. 为了创建一个能够被引导的磁盘映像,我们在开发时必须安装GRUB:这加大了基于Windows或macOS开发内核的难度。 41 | 42 | 出于这些考虑,我们决定不使用GRUB或者Multiboot标准。然而,Multiboot支持功能也在bootimage工具的开发计划之中,所以从原理上讲,如果选用bootimage工具,在未来使用GRUB引导你的系统内核是可能的。 43 | 44 | ## 最小化内核 45 | 46 | 现在我们已经明白电脑是如何启动的,那也是时候编写我们自己的内核了。我们的小目标是,创建一个内核的磁盘映像,它能够在启动时,向屏幕输出一行“Hello World!”;我们的工作将基于上一章构建的独立式可执行程序。 47 | 48 | 如果读者还有印象的话,在上一章,我们使用`cargo`构建了一个独立的二进制程序;但这个程序依然基于特定的操作系统平台:因平台而异,我们需要定义不同名称的函数,且使用不同的编译指令。这是因为在默认情况下,`cargo`会为特定的**宿主系统**(host system)构建源码,比如为你正在运行的系统构建源码。这并不是我们想要的,因为我们的内核不应该基于另一个操作系统——我们想要编写的,就是这个操作系统。确切地说,我们想要的是,编译为一个特定的**目标系统**(target system)。 49 | 50 | ## 安装 Nightly Rust 51 | 52 | Rust语言有三个**发行频道**(release channel),分别是stable、beta和nightly。《Rust程序设计语言》中对这三个频道的区别解释得很详细,可以前往[这里](https://doc.rust-lang.org/book/appendix-07-nightly-rust.html)看一看。为了搭建一个操作系统,我们需要一些只有nightly会提供的实验性功能,所以我们需要安装一个nightly版本的Rust。 53 | 54 | 要管理安装好的Rust,我强烈建议使用[rustup](https://www.rustup.rs/):它允许你同时安装nightly、beta和stable版本的编译器,而且让更新Rust变得容易。你可以输入`rustup override add nightly`来选择在当前目录使用nightly版本的Rust。或者,你也可以在项目根目录添加一个名称为`rust-toolchain`、内容为`nightly`的文件。要检查你是否已经安装了一个nightly,你可以运行`rustc --version`:返回的版本号末尾应该包含`-nightly`。 55 | 56 | Nightly版本的编译器允许我们在源码的开头插入**特性标签**(feature flag),来自由选择并使用大量实验性的功能。举个栗子,要使用实验性的[内联汇编(asm!宏)](https://doc.rust-lang.org/nightly/unstable-book/language-features/asm.html),我们可以在`main.rs`的顶部添加`#![feature(asm)]`。要注意的是,这样的实验性功能**不稳定**(unstable),意味着未来的Rust版本可能会修改或移除这些功能,而不会有预先的警告过渡。因此我们只有在绝对必要的时候,才应该使用这些特性。 57 | 58 | ### 目标配置清单 59 | 60 | 通过`--target`参数,`cargo`支持不同的目标系统。这个目标系统可以使用一个**目标三元组**([target triple](https://clang.llvm.org/docs/CrossCompilation.html#target-triple))来描述,它描述了CPU架构、平台供应者、操作系统和**应用程序二进制接口**([Application Binary Interface, ABI](https://stackoverflow.com/a/2456882))。比方说,目标三元组`x86_64-unknown-linux-gnu`描述一个基于`x86_64`架构CPU的、没有明确的平台供应者的linux系统,它遵循GNU风格的ABI。Rust支持[许多不同的目标三元组](https://forge.rust-lang.org/platform-support.html),包括安卓系统对应的`arm-linux-androideabi`和[WebAssembly使用的wasm32-unknown-unknown](https://www.hellorust.com/setup/wasm-target/)。 61 | 62 | 为了编写我们的目标系统,鉴于我们需要做一些特殊的配置(比如没有依赖的底层操作系统),[已经支持的目标三元组](https://forge.rust-lang.org/platform-support.html)都不能满足我们的要求。幸运的是,只需使用一个JSON文件,Rust便允许我们定义自己的目标系统;这个文件常被称作**目标配置清单**(target specification)。比如,一个描述`x86_64-unknown-linux-gnu`目标系统的配置清单大概长这样: 63 | 64 | ```json 65 | { 66 | "llvm-target": "x86_64-unknown-linux-gnu", 67 | "data-layout": "e-m:e-i64:64-f80:128-n8:16:32:64-S128", 68 | "arch": "x86_64", 69 | "target-endian": "little", 70 | "target-pointer-width": "64", 71 | "target-c-int-width": "32", 72 | "os": "linux", 73 | "executables": true, 74 | "linker-flavor": "gcc", 75 | "pre-link-args": ["-m64"], 76 | "morestack": false 77 | } 78 | ``` 79 | 80 | 一个配置清单中包含多个**配置项**(field)。大多数的配置项都是LLVM需求的,它们将配置为特定平台生成的代码。打个比方,`data-layout`配置项定义了不同的整数、浮点数、指针类型的长度;另外,还有一些Rust是用作条件变编译的配置项,如`target-pointer-width`。还有一些类型的配置项,定义了这个包该如何被编译,例如,`pre-link-args`配置项指定了该向**链接器**([linker](https://en.wikipedia.org/wiki/Linker_(computing)))传入的参数。 81 | 82 | 我们将把我们的内核编译到`x86_64`架构,所以我们的配置清单将和上面的例子相似。现在,我们来创建一个名为`x86_64-blog_os.json`的文件——当然也可以选用自己喜欢的文件名——里面包含这样的内容: 83 | 84 | ```json 85 | { 86 | "llvm-target": "x86_64-unknown-none", 87 | "data-layout": "e-m:e-i64:64-f80:128-n8:16:32:64-S128", 88 | "arch": "x86_64", 89 | "target-endian": "little", 90 | "target-pointer-width": "64", 91 | "target-c-int-width": "32", 92 | "os": "none", 93 | "executables": true, 94 | } 95 | ``` 96 | 97 | 需要注意的是,因为我们要在**裸机**(bare metal)上运行内核,我们已经修改了`llvm-target`的内容,并将`os`配置项的值改为`none`。 98 | 99 | 我们还需要添加下面与编译相关的配置项: 100 | 101 | ```json 102 | "linker-flavor": "ld.lld", 103 | "linker": "rust-lld", 104 | ``` 105 | 106 | 在这里,我们不使用平台默认提供的链接器,因为它可能不支持Linux目标系统。为了链接我们的内核,我们使用跨平台的**LLD链接器**([LLD linker](https://lld.llvm.org/)),它是和Rust打包发布的。 107 | 108 | ```json 109 | "panic-strategy": "abort", 110 | ``` 111 | 112 | 这个配置项的意思是,我们的编译目标不支持panic时的**栈展开**([stack unwinding](http://www.bogotobogo.com/cplusplus/stackunwinding.php)),所以我们选择直接**在panic时中止**(abort on panic)。这和在`Cargo.toml`文件中添加`panic = "abort"`选项的作用是相同的,所以我们可以不在这里的配置清单中填写这一项。 113 | 114 | ```json 115 | "disable-redzone": true, 116 | ``` 117 | 118 | 我们正在编写一个内核,所以我们应该同时处理中断。要安全地实现这一点,我们必须禁用一个与**红区**(redzone)有关的栈指针优化:因为此时,这个优化可能会导致栈被破坏。我们撰写了一篇专门的短文,来更详细地解释红区及与其相关的优化。 119 | 120 | ```json 121 | "features": "-mmx,-sse,+soft-float", 122 | ``` 123 | 124 | `features`配置项被用来启用或禁用某个目标**CPU特征**(CPU feature)。通过在它们前面添加`-`号,我们将`mmx`和`sse`特征禁用;添加前缀`+`号,我们启用了`soft-float`特征。 125 | 126 | `mmx`和`sse`特征决定了是否支持**单指令多数据流**([Single Instruction Multiple Data,SIMD](https://en.wikipedia.org/wiki/SIMD))相关指令,这些指令常常能显著地提高程序层面的性能。然而,在内核中使用庞大的SIMD寄存器,可能会造成较大的性能影响:因为每次程序中断时,内核不得不储存整个庞大的SIMD寄存器以备恢复——这意味着,对每个硬件中断或系统调用,完整的SIMD状态必须存到主存中。由于SIMD状态可能相当大(512~1600个字节),而中断可能时常发生,这些额外的存储与恢复操作可能显著地影响效率。为解决这个问题,我们对内核禁用SIMD(但这不意味着禁用内核之上的应用程序的SIMD支持)。 127 | 128 | 禁用SIMD产生的一个问题是,`x86_64`架构的浮点数指针运算默认依赖于SIMD寄存器。我们的解决方法是,启用`soft-float`特征,它将使用基于整数的软件功能,模拟浮点数指针运算。 129 | 130 | 为了让读者的印象更清晰,我们撰写了一篇关于禁用SIMD的短文。 131 | 132 | 现在,我们将各个配置项整合在一起。我们的目标配置清单应该长这样: 133 | 134 | ```json 135 | { 136 | "llvm-target": "x86_64-unknown-none", 137 | "data-layout": "e-m:e-i64:64-f80:128-n8:16:32:64-S128", 138 | "arch": "x86_64", 139 | "target-endian": "little", 140 | "target-pointer-width": "64", 141 | "target-c-int-width": "32", 142 | "os": "none", 143 | "executables": true, 144 | "linker-flavor": "ld.lld", 145 | "linker": "rust-lld", 146 | "panic-strategy": "abort", 147 | "disable-redzone": true, 148 | "features": "-mmx,-sse,+soft-float" 149 | } 150 | ``` 151 | 152 | ### 编译内核 153 | 154 | 要编译我们的内核,我们将使用Linux系统的编写风格(这可能是LLVM的默认风格)。这意味着,我们需要把前一篇文章中编写的入口点重命名为`_start`: 155 | 156 | ```rust 157 | // src/main.rs 158 | 159 | #![no_std] // 不链接Rust标准库 160 | #![no_main] // 禁用所有Rust层级的入口点 161 | 162 | use core::panic::PanicInfo; 163 | 164 | /// 这个函数将在panic时被调用 165 | #[panic_handler] 166 | fn panic(_info: &PanicInfo) -> ! { 167 | loop {} 168 | } 169 | 170 | #[no_mangle] // 不重整函数名 171 | pub extern "C" fn _start() -> ! { 172 | // 因为编译器会寻找一个名为`_start`的函数,所以这个函数就是入口点 173 | // 默认命名为`_start` 174 | loop {} 175 | } 176 | ``` 177 | 178 | 注意的是,无论你开发使用的是哪类操作系统,你都需要将入口点命名为`_start`。前一篇文章中编写的Windows系统和macOS对应的入口点不应该被保留。 179 | 180 | 通过把JSON文件名传入`--target`选项,我们现在可以开始编译我们的内核。让我们试试看: 181 | 182 | ```text 183 | > cargo build --target x86_64-blog_os.json 184 | 185 | error[E0463]: can't find crate for `core` 186 | (或者是下面的错误) 187 | error[E0463]: can't find crate for `compiler_builtins` 188 | ``` 189 | 190 | 哇哦,编译失败了!输出的错误告诉我们,Rust编译器找不到`core`或者`compiler_builtins`包;而所有`no_std`上下文都隐式地链接到这两个包。[`core`包](https://doc.rust-lang.org/nightly/core/index.html)包含基础的Rust类型,如`Result`、`Option`和迭代器等;[`compiler_builtins`包](https://github.com/rust-lang-nursery/compiler-builtins)提供LLVM需要的许多底层操作,比如`memcpy`。 191 | 192 | 通常状况下,`core`库以**预编译库**(precompiled library)的形式与Rust编译器一同发布——这时,`core`库只对支持的宿主系统有效,而我们自定义的目标系统无效。如果我们想为其它系统编译代码,我们需要为这些系统重新编译整个`core`库。 193 | 194 | ### Cargo xbuild 195 | 196 | 这就是为什么我们需要[cargo xbuild工具](https://github.com/rust-osdev/cargo-xbuild)。这个工具封装了`cargo build`;但不同的是,它将自动交叉编译`core`库和一些**编译器内建库**(compiler built-in libraries)。我们可以用下面的命令安装它: 197 | 198 | ```bash 199 | cargo install cargo-xbuild 200 | ``` 201 | 202 | 这个工具依赖于Rust的源代码;我们可以使用`rustup component add rust-src`来安装源代码。 203 | 204 | 现在我们可以使用`xbuild`代替`build`重新编译: 205 | 206 | ```bash 207 | > cargo xbuild --target x86_64-blog_os.json 208 | Compiling core v0.0.0 (/…/rust/src/libcore) 209 | Compiling compiler_builtins v0.1.5 210 | Compiling rustc-std-workspace-core v1.0.0 (/…/rust/src/tools/rustc-std-workspace-core) 211 | Compiling alloc v0.0.0 (/tmp/xargo.PB7fj9KZJhAI) 212 | Finished release [optimized + debuginfo] target(s) in 45.18s 213 | Compiling blog_os v0.1.0 (file:///…/blog_os) 214 | Finished dev [unoptimized + debuginfo] target(s) in 0.29 secs 215 | ``` 216 | 217 | 我们能看到,`cargo xbuild`为我们自定义的目标交叉编译了`core`、`compiler_builtin`和`alloc`三个部件。这些部件使用了大量的**不稳定特性**(unstable features),所以只能在[nightly版本的Rust编译器](https://os.phil-opp.com/freestanding-rust-binary/#installing-rust-nightly)中工作。这之后,`cargo xbuild`成功地编译了我们的`blog_os`包。 218 | 219 | 现在我们可以为裸机编译内核了;但是,我们提供给引导程序的入口点`_start`函数还是空的。我们可以添加一些东西进去,不过我们可以先做一些优化工作。 220 | 221 | ### 设置默认目标 222 | 223 | 为了避免每次使用`cargo xbuild`时传递`--target`参数,我们可以覆写默认的编译目标。我们创建一个名为`.cargo/config`的[cargo配置文件](https://doc.rust-lang.org/cargo/reference/config.html),添加下面的内容: 224 | 225 | ```toml 226 | # in .cargo/config 227 | 228 | [build] 229 | target = "x86_64-blog_os.json" 230 | ``` 231 | 232 | 这里的配置告诉`cargo`在没有显式声明目标的情况下,使用我们提供的`x86_64-blog_os.json`作为目标配置。这意味着保存后,我们可以直接使用: 233 | 234 | ```text 235 | cargo xbuild 236 | ``` 237 | 238 | 来编译我们的内核。[官方提供的一份文档](https://doc.rust-lang.org/cargo/reference/config.html)中有对cargo配置文件更详细的说明。 239 | 240 | ### 向屏幕打印字符 241 | 242 | 要做到这一步,最简单的方式是写入**VGA字符缓冲区**([VGA text buffer](https://en.wikipedia.org/wiki/VGA-compatible_text_mode)):这是一段映射到VGA硬件的特殊内存片段,包含着显示在屏幕上的内容。通常情况下,它能够存储25行、80列共2000个**字符单元**(character cell);每个字符单元能够显示一个ASCII字符,也能设置这个字符的**前景色**(foreground color)和**背景色**(background color)。输出到屏幕的字符大概长这样: 243 | 244 | ![字符](https://upload.wikimedia.org/wikipedia/commons/6/6d/Codepage-737.png) 245 | 246 | 我们将在下篇文章中详细讨论VGA字符缓冲区的内存布局;目前我们只需要知道,这段缓冲区的地址是`0xb8000`,且每个字符单元包含一个ASCII码字节和一个颜色字节。 247 | 248 | 我们的实现就像这样: 249 | 250 | ```rust 251 | static HELLO: &[u8] = b"Hello World!"; 252 | 253 | #[no_mangle] 254 | pub extern "C" fn _start() -> ! { 255 | let vga_buffer = 0xb8000 as *mut u8; 256 | 257 | for (i, &byte) in HELLO.iter().enumerate() { 258 | unsafe { 259 | *vga_buffer.offset(i as isize * 2) = byte; 260 | *vga_buffer.offset(i as isize * 2 + 1) = 0xb; 261 | } 262 | } 263 | 264 | loop {} 265 | } 266 | ``` 267 | 268 | 在这段代码中,我们预先定义了一个**字节串**(byte string)类型的**静态变量**(static variable),名为`HELLO`。我们首先将整数`0xb8000`**转换**(cast)为一个**裸指针**([raw pointer](https://doc.rust-lang.org/stable/book/second-edition/ch19-01-unsafe-rust.html#dereferencing-a-raw-pointer))。这之后,我们迭代`HELLO`的每个字节,使用[enumerate](https://doc.rust-lang.org/core/iter/trait.Iterator.html#method.enumerate)获得一个额外的序号变量`i`。在`for`语句的循环体中,我们使用[offset](https://doc.rust-lang.org/std/primitive.pointer.html#method.offset)偏移裸指针,解引用它,来将字符串的每个字节和对应的颜色字节——`0xb`代表淡青色——写入内存位置。 269 | 270 | 要注意的是,所有的裸指针内存操作都被一个**unsafe语句块**([unsafe block](https://doc.rust-lang.org/stable/book/second-edition/ch19-01-unsafe-rust.html))包围。这是因为,此时编译器不能确保我们创建的裸指针是有效的;一个裸指针可能指向任何一个你内存位置;直接解引用并写入它,也许会损坏正常的数据。使用`unsafe`语句块时,程序员其实在告诉编译器,自己保证语句块内的操作是有效的。事实上,`unsafe`语句块并不会关闭Rust的安全检查机制;它允许你多做的事情[只有四件](https://doc.rust-lang.org/stable/book/second-edition/ch19-01-unsafe-rust.html#unsafe-superpowers)。 271 | 272 | 使用`unsafe`语句块要求程序员有足够的自信,所以必须强调的一点是,**肆意使用unsafe语句块并不是Rust编程的一贯方式**。在缺乏足够经验的前提下,直接在`unsafe`语句块内操作裸指针,非常容易把事情弄得很糟糕;比如,在不注意的情况下,我们很可能会意外地操作缓冲区以外的内存。 273 | 274 | 在这样的前提下,我们希望最小化`unsafe`语句块的使用。使用Rust语言,我们能够将不安全操作将包装为一个安全的抽象模块。举个栗子,我们可以创建一个VGA缓冲区类型,把所有的不安全语句封装起来,来确保从类型外部操作时,无法写出不安全的代码:通过这种方式,我们只需要最少的`unsafe`语句块来确保我们不破坏**内存安全**([memory safety](https://en.wikipedia.org/wiki/Memory_safety))。在下一篇文章中,我们将会创建这样的VGA缓冲区封装。 275 | 276 | ## 启动内核 277 | 278 | 既然我们已经有了一个能够打印字符的可执行程序,是时候把它运行起来试试看了。首先,我们将编译完毕的内核与引导程序链接,来创建一个引导映像;这之后,我们可以在QEMU虚拟机中运行它,或者通过U盘在真机上运行。 279 | 280 | ### 创建引导映像 281 | 282 | 要将可执行程序转换为**可引导的映像**(bootable disk image),我们需要把它和引导程序链接。这里,引导程序将负责初始化CPU并加载我们的内核。 283 | 284 | 编写引导程序并不容易,所以我们不编写自己的引导程序,而是使用已有的[bootloader](https://crates.io/crates/bootloader)包;无需依赖于C语言,这个包基于Rust代码和内联汇编,实现了一个五脏俱全的BIOS引导程序。为了用它启动我们的内核,我们需要将它添加为一个依赖项,在`Cargo.toml`中添加下面的代码: 285 | 286 | ```toml 287 | # in Cargo.toml 288 | 289 | [dependencies] 290 | bootloader = "0.6.0" 291 | ``` 292 | 293 | 只添加引导程序为依赖项,并不足以创建一个可引导的磁盘映像;我们还需要内核编译完成之后,将内核和引导程序组合在一起。然而,截至目前,原生的cargo并不支持在编译完成后添加其它步骤(详见[这个issue](https://github.com/rust-lang/cargo/issues/545))。 294 | 295 | 为了解决这个问题,我们建议使用`bootimage`工具——它将会在内核编译完毕后,将它和引导程序组合在一起,最终创建一个能够引导的磁盘映像。我们可以使用下面的命令来安装这款工具: 296 | 297 | ```bash 298 | cargo install bootimage --version "^0.7.3" 299 | ``` 300 | 301 | 参数`^0.7.3`是一个**脱字号条件**([caret requirement](https://doc.rust-lang.org/cargo/reference/specifying-dependencies.html#caret-requirements)),它的意义是“0.7.3版本或一个兼容0.7.3的新版本”。这意味着,如果这款工具发布了修复bug的版本`0.7.4`或`0.7.5`,cargo将会自动选择最新的版本,因为它依然兼容`0.7.x`;但cargo不会选择`0.8.0`,因为这个版本被认为并不和`0.7.x`系列版本兼容。需要注意的是,`Cargo.toml`中定义的依赖包版本都默认是脱字号条件:刚才我们指定`bootloader`包的版本时,遵循的就是这个原则。 302 | 303 | 为了运行`bootimage`以及编译引导程序,我们需要安装rustup模块`llvm-tools-preview`——我们可以使用`rustup component add llvm-tools-preview`来安装这个工具。 304 | 305 | 成功安装`bootimage`后,创建一个可引导的磁盘映像就变得相当容易。我们来输入下面的命令: 306 | 307 | ```bash 308 | > cargo bootimage 309 | ``` 310 | 311 | 可以看到的是,`bootimage`工具开始使用`cargo xbuild`编译你的内核,所以它将增量编译我们修改后的源码。在这之后,它会编译内核的引导程序,这可能将花费一定的时间;但和所有其它依赖包相似的是,在首次编译后,产生的二进制文件将被缓存下来——这将显著地加速后续的编译过程。最终,`bootimage`将把内核和引导程序组合为一个可引导的磁盘映像。 312 | 313 | 运行这行命令之后,我们应该能在`target/x86_64-blog_os/debug`目录内找到我们的映像文件`bootimage-blog_os.bin`。我们可以在虚拟机内启动它,也可以刻录到U盘上以便在真机上启动。(需要注意的是,因为文件格式不同,这里的bin文件并不是一个光驱映像,所以将它刻录到光盘不会起作用。) 314 | 315 | 事实上,在这行命令背后,`bootimage`工具执行了三个步骤: 316 | 317 | 1. 编译我们的内核为一个**ELF**([Executable and Linkable Format](https://en.wikipedia.org/wiki/Executable_and_Linkable_Format))文件; 318 | 2. 编译引导程序为独立的可执行文件; 319 | 3. 将内核ELF文件**按字节拼接**(append by bytes)到引导程序的末端。 320 | 321 | 当机器启动时,引导程序将会读取并解析拼接在其后的ELF文件。这之后,它将把程序片段映射到**分页表**(page table)中的**虚拟地址**(virtual address),清零**BSS段**(BSS segment),还将创建一个栈。最终它将读取**入口点地址**(entry point address)——我们程序中`_start`函数的位置——并跳转到这个位置。 322 | 323 | ### 在QEMU中启动内核 324 | 325 | 现在我们可以在虚拟机中启动内核了。为了在[QEMU](https://www.qemu.org/)中启动内核,我们使用下面的命令: 326 | 327 | ```bash 328 | > qemu-system-x86_64 -drive format=raw,file=bootimage-blog_os.bin 329 | ``` 330 | 331 | ![qemu的显示内容](https://os.phil-opp.com/minimal-rust-kernel/qemu.png) 332 | 333 | 我们可以看到,屏幕窗口已经显示出“Hello World!”字符串。祝贺你! 334 | 335 | ### 在真机上运行内核 336 | 337 | 我们也可以使用dd工具把内核写入U盘,以便在真机上启动。可以输入下面的命令: 338 | 339 | ```bash 340 | > dd if=target/x86_64-blog_os/debug/bootimage-blog_os.bin of=/dev/sdX && sync 341 | ``` 342 | 343 | 在这里,`sdX`是U盘的**设备名**([device name](https://en.wikipedia.org/wiki/Device_file))。请注意,**在选择设备名的时候一定要极其小心,因为目标设备上已有的数据将全部被擦除**。 344 | 345 | 写入到U盘之后,你可以在真机上通过引导启动你的系统。视情况而定,你可能需要在BIOS中打开特殊的启动菜单,或者调整启动顺序。需要注意的是,`bootloader`包暂时不支持UEFI,所以我们并不能在UEFI机器上启动。 346 | 347 | ### 使用`cargo run` 348 | 349 | 要让在QEMU中运行内核更轻松,我们可以设置在cargo配置文件中设置`runner`配置项: 350 | 351 | ```toml 352 | # in .cargo/config 353 | 354 | [target.'cfg(target_os = "none")'] 355 | runner = "bootimage runner" 356 | ``` 357 | 358 | 在这里,`target.'cfg(target_os = "none")'`筛选了三元组中宿主系统设置为`"none"`的所有编译目标——这将包含我们的`x86_64-blog_os.json`目标。另外,`runner`的值规定了运行`cargo run`使用的命令;这个命令将在成功编译后执行,而且会传递可执行文件的路径为第一个参数。[官方提供的cargo文档](https://doc.rust-lang.org/cargo/reference/config.html)讲述了更多的细节。 359 | 360 | 命令`bootimage runner`由`bootimage`包提供,参数格式经过特殊设计,可以用于`runner`命令。它将给定的可执行文件与项目的引导程序依赖项链接,然后在QEMU中启动它。`bootimage`包的[README文档](https://github.com/rust-osdev/bootimage)提供了更多细节和可以传入的配置参数。 361 | 362 | 现在我们可以使用`cargo xrun`来编译内核并在QEMU中启动了。和`xbuild`类似,`xrun`子命令将在调用cargo命令前编译内核所需的包。这个子命令也由`cargo-xbuild`工具提供,所以你不需要安装额外的工具。 363 | 364 | ## 下篇预告 365 | 366 | 在下篇文章中,我们将细致地探索VGA字符缓冲区,并包装它为一个安全的接口。我们还将基于它实现`println!`宏。 367 | -------------------------------------------------------------------------------- /03-vga-text-mode.md: -------------------------------------------------------------------------------- 1 | > 原文:https://os.phil-opp.com/vga-text-mode/ 2 | > 3 | > 原作者:@phil-opp 4 | > 5 | > 译者:洛佳 华中科技大学 6 | 7 | # 使用Rust编写操作系统(三):VGA字符模式 8 | 9 | **VGA字符模式**([VGA text mode](https://en.wikipedia.org/wiki/VGA-compatible_text_mode))是打印字符到屏幕的一种简单方式。在这篇文章中,为了包装这个模式为一个安全而简单的接口,我们包装unsafe代码到独立的模块。我们还将实现对Rust语言**格式化宏**([formatting macros](https://doc.rust-lang.org/std/fmt/#related-macros))的支持。 10 | 11 | ## VGA字符缓冲区 12 | 13 | 为了在VGA字符模式向屏幕打印字符,我们必须将它写入硬件提供的**VGA字符缓冲区**(VGA text buffer)。通常状况下,VGA字符缓冲区是一个25行、80列的二维数组,它的内容将被实时渲染到屏幕。这个数组的元素被称作**字符单元**(character cell),它使用下面的格式描述一个屏幕上的字符: 14 | 15 | | Bit(s) | Value | 16 | |-----|----------------| 17 | | 0-7 | ASCII code point | 18 | | 8-11 | Foreground color | 19 | | 12-14 | Background color | 20 | | 15 | Blink | 21 | 22 | 其中,**前景色**(foreground color)和**背景色**(background color)取值范围如下: 23 | 24 | | Number | Color | Number + Bright Bit | Bright Color | 25 | |-----|----------|------|--------| 26 | | 0x0 | Black | 0x8 | Dark Gray | 27 | | 0x1 | Blue | 0x9 | Light Blue | 28 | | 0x2 | Green | 0xa | Light Green | 29 | | 0x3 | Cyan | 0xb | Light Cyan | 30 | | 0x4 | Red | 0xc | Light Red | 31 | | 0x5 | Magenta | 0xd | Pink | 32 | | 0x6 | Brown | 0xe | Yellow | 33 | | 0x7 | Light Gray | 0xf | White | 34 | 35 | 每个颜色的第四位称为**加亮位**(bright bit)。 36 | 37 | 要修改VGA字符缓冲区,我们可以通过**存储器映射输入输出**([memory-mapped I/O](https://en.wikipedia.org/wiki/Memory-mapped_I/O))的方式,读取或写入地址`0xb8000`;这意味着,我们可以像操作普通的内存区域一样操作这个地址。 38 | 39 | 需要注意的是,一些硬件虽然映射到存储器,却可能不会完全支持所有的内存操作:可能会有一些设备支持按`u8`字节读取,却在读取`u64`时返回无效的数据。幸运的是,字符缓冲区都[支持标准的读写操作](https://web.stanford.edu/class/cs140/projects/pintos/specs/freevga/vga/vgamem.htm#manip),所以我们不需要用特殊的标准对待它。 40 | 41 | ## 包装到Rust模块 42 | 43 | 既然我们已经知道VGA文字缓冲区如何工作,也是时候创建一个Rust模块来处理文字打印了。我们输入这样的代码: 44 | 45 | ```rust 46 | // in src/main.rs 47 | mod vga_buffer; 48 | ``` 49 | 50 | 这行代码定义了一个Rust模块,它的内容应当保存在`src/vga_buffer.rs`文件中。使用**2018版次**(2018 edition)的Rust时,我们可以把模块的**子模块**(submodule)文件直接保存到`src/vga_buffer/`文件夹下,与`vga_buffer.rs`文件共存,而无需创建一个`mod.rs`文件。 51 | 52 | 我们的模块暂时不需要添加子模块,所以我们将它创建为`src/vga_buffer.rs`文件。除非另有说明,本文中的代码都保存到这个文件中。 53 | 54 | ### 颜色 55 | 56 | 首先,我们使用Rust的**枚举**(enum)表示一种颜色: 57 | 58 | ```rust 59 | // in src/vga_buffer.rs 60 | 61 | #[allow(dead_code)] 62 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] 63 | #[repr(u8)] 64 | pub enum Color { 65 | Black = 0, 66 | Blue = 1, 67 | Green = 2, 68 | Cyan = 3, 69 | Red = 4, 70 | Magenta = 5, 71 | Brown = 6, 72 | LightGray = 7, 73 | DarkGray = 8, 74 | LightBlue = 9, 75 | LightGreen = 10, 76 | LightCyan = 11, 77 | LightRed = 12, 78 | Pink = 13, 79 | Yellow = 14, 80 | White = 15, 81 | } 82 | ``` 83 | 84 | 我们使用**类似于C语言的枚举**(C-like enum),为每个颜色明确指定一个数字。在这里,每个用`repr(u8)`注记标注的枚举类型,都会以一个`u8`的形式存储——事实上4个二进制位就足够了,但Rust语言并不提供`u4`类型。 85 | 86 | 通常来说,编译器会对每个未使用的变量发出**警告**(warning);使用`#[allow(dead_code)]`,我们可以对`Color`枚举类型禁用这个警告。 87 | 88 | 我们还**生成**([derive](http://rustbyexample.com/trait/derive.html))了 [`Copy`](https://doc.rust-lang.org/nightly/core/marker/trait.Copy.html)、[`Clone`](https://doc.rust-lang.org/nightly/core/clone/trait.Clone.html)、[`Debug`](https://doc.rust-lang.org/nightly/core/fmt/trait.Debug.html)、[`PartialEq`](https://doc.rust-lang.org/nightly/core/cmp/trait.PartialEq.html)和[`Eq`](https://doc.rust-lang.org/nightly/core/cmp/trait.Eq.html) 这几个trait:这让我们的类型遵循**复制语义**([copy semantics](https://doc.rust-lang.org/book/first-edition/ownership.html#copy-types)),也让它可以被比较、被调试打印。 89 | 90 | 为了描述包含前景色和背景色的、完整的**颜色代码**(color code),我们基于`u8`创建一个新类型: 91 | 92 | ```rust 93 | // in src/vga_buffer.rs 94 | 95 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] 96 | #[repr(transparent)] 97 | struct ColorCode(u8); 98 | 99 | impl ColorCode { 100 | fn new(foreground: Color, background: Color) -> ColorCode { 101 | ColorCode((background as u8) << 4 | (foreground as u8)) 102 | } 103 | } 104 | ``` 105 | 106 | 这里,`ColorCode`类型包装了一个完整的颜色代码字节,它包含前景色和背景色信息。和`Color`类型类似,我们为它生成`Copy`和`Debug`等一系列trait。为了确保`ColorCode`和`u8`有完全相同的内存布局,我们添加[repr(transparent)标记](https://doc.rust-lang.org/nomicon/other-reprs.html#reprtransparent)。 107 | 108 | ### 字符缓冲区 109 | 110 | 现在,我们可以添加更多的结构体,来描述屏幕上的字符和整个字符缓冲区: 111 | 112 | ```rust 113 | // in src/vga_buffer.rs 114 | 115 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] 116 | #[repr(C)] 117 | struct ScreenChar { 118 | ascii_character: u8, 119 | color_code: ColorCode, 120 | } 121 | 122 | const BUFFER_HEIGHT: usize = 25; 123 | const BUFFER_WIDTH: usize = 80; 124 | 125 | #[repr(transparent)] 126 | struct Buffer { 127 | chars: [[ScreenChar; BUFFER_WIDTH]; BUFFER_HEIGHT], 128 | } 129 | ``` 130 | 131 | 在内存布局层面,Rust并不保证按顺序布局成员变量。因此,我们需要使用`#[repr(C)]`标记结构体;这将按C语言约定的顺序布局它的成员变量,让我们能正确地映射内存片段。对`Buffer`类型,我们再次使用`repr(transparent)`,来确保类型和它的单个成员有相同的内存布局。 132 | 133 | 为了输出字符到屏幕,我们来创建一个`Writer`类型: 134 | 135 | ```rust 136 | // in src/vga_buffer.rs 137 | 138 | pub struct Writer { 139 | column_position: usize, 140 | color_code: ColorCode, 141 | buffer: &'static mut Buffer, 142 | } 143 | ``` 144 | 145 | 我们将让这个`Writer`类型将字符写入屏幕的最后一行,并在一行写满或收到换行符`\n`的时候,将所有的字符向上位移一行。`column_position`变量将跟踪光标在最后一行的位置。当前字符的前景和背景色将由`color_code`变量指定;另外,我们存入一个VGA字符缓冲区的可变借用到`buffer`变量中。需要注意的是,这里我们对借用使用**显式生命周期**([explicit lifetime](https://doc.rust-lang.org/book/ch10-03-lifetime-syntax.html#lifetime-annotation-syntax)),告诉编译器这个借用在何时有效:我们使用**`'static`生命周期**(['static lifetime](https://doc.rust-lang.org/book/ch10-03-lifetime-syntax.html#the-static-lifetime)),意味着这个借用应该在整个程序的运行期间有效;这对一个全局有效的VGA字符缓冲区来说,是非常合理的。 146 | 147 | ### 打印字符 148 | 149 | 现在我们可以使用`Writer`类型来更改缓冲区内的字符了。首先,为了写入一个ASCII码字节,我们创建这样的函数: 150 | 151 | ```rust 152 | // in src/vga_buffer.rs 153 | 154 | impl Writer { 155 | pub fn write_byte(&mut self, byte: u8) { 156 | match byte { 157 | b'\n' => self.new_line(), 158 | byte => { 159 | if self.column_position >= BUFFER_WIDTH { 160 | self.new_line(); 161 | } 162 | 163 | let row = BUFFER_HEIGHT - 1; 164 | let col = self.column_position; 165 | 166 | let color_code = self.color_code; 167 | self.buffer.chars[row][col] = ScreenChar { 168 | ascii_character: byte, 169 | color_code, 170 | }; 171 | self.column_position += 1; 172 | } 173 | } 174 | } 175 | 176 | fn new_line(&mut self) {/* TODO */} 177 | } 178 | ``` 179 | 180 | 如果这个字节是一个**换行符**([line feed](https://en.wikipedia.org/wiki/Newline))字节`\n`,我们的`Writer`不应该打印新字符,相反,它将调用我们稍后会实现的`new_line`方法;其它的字节应该将在`match`语句的第二个分支中被打印到屏幕上。 181 | 182 | 当打印字节时,`Writer`将检查当前行是否已满。如果已满,它将首先调用`new_line`方法来将这一行字向上提升,再将一个新的`ScreenChar`写入到缓冲区,最终将当前的光标位置前进一位。 183 | 184 | 要打印整个字符串,我们把它转换为字节并依次输出: 185 | 186 | ```rust 187 | // in src/vga_buffer.rs 188 | 189 | impl Writer { 190 | pub fn write_string(&mut self, s: &str) { 191 | for byte in s.bytes() { 192 | match byte { 193 | // 可以是能打印的ASCII码字节,也可以是换行符 194 | 0x20...0x7e | b'\n' => self.write_byte(byte), 195 | // 不包含在上述范围之内的字节 196 | _ => self.write_byte(0xfe), 197 | } 198 | 199 | } 200 | } 201 | } 202 | ``` 203 | 204 | VGA字符缓冲区只支持ASCII码字节和**代码页437**([Code page 437](https://en.wikipedia.org/wiki/Code_page_437))定义的字节。Rust语言的字符串默认编码为[UTF-8](http://www.fileformat.info/info/unicode/utf8.htm),也因此可能包含一些VGA字符缓冲区不支持的字节:我们使用`match`语句,来区别可打印的ASCII码或换行字节,和其它不可打印的字节。对每个不可打印的字节,我们打印一个`■`符号;这个符号在VGA硬件中被编码为十六进制的`0xfe`。 205 | 206 | 我们可以亲自试一试已经编写的代码。为了这样做,我们可以临时编写一个函数: 207 | 208 | ```rust 209 | // in src/vga_buffer.rs 210 | 211 | pub fn print_something() { 212 | let mut writer = Writer { 213 | column_position: 0, 214 | color_code: ColorCode::new(Color::Yellow, Color::Black), 215 | buffer: unsafe { &mut *(0xb8000 as *mut Buffer) }, 216 | }; 217 | 218 | writer.write_byte(b'H'); 219 | writer.write_string("ello "); 220 | writer.write_string("Wörld!"); 221 | } 222 | ``` 223 | 224 | 这个函数首先创建一个指向`0xb8000`地址VGA缓冲区的`Writer`。实现这一点,我们需要编写的代码可能看起来有点奇怪:首先,我们把整数`0xb8000`强制转换为一个可变的**裸指针**([raw pointer](https://doc.rust-lang.org/book/ch19-01-unsafe-rust.html#dereferencing-a-raw-pointer));之后,通过运算符`*`,我们将这个裸指针解引用;最后,我们再通过`&mut`,再次获得它的可变借用。这些转换需要**`unsafe`语句块**([unsafe block](https://doc.rust-lang.org/book/ch19-01-unsafe-rust.html)),因为编译器并不能保证这个裸指针是有效的。 225 | 226 | 然后它将字节 `b'H'` 写入缓冲区内. 前缀 `b`创建了一个字节字面量([byte literal](https://doc.rust-lang.org/reference/tokens.html#byte-literals)),表示单个ASCII码字符;通过尝试写入 `"ello "` 和 `"Wörld!"`,我们可以测试 `write_string` 方法和其后对无法打印字符的处理逻辑。为了观察输出,我们需要在`_start`函数中调用`print_something`方法: 227 | 228 | ```rust 229 | // in src/main.rs 230 | #[no_mangle] 231 | pub extern "C" fn _start() -> ! { 232 | vga_buffer::print_something(); 233 | loop {} 234 | } 235 | ``` 236 | 237 | 编译运行后,黄色的`Hello W■■rld!`字符串将会被打印在屏幕的左下角: 238 | 239 | ![QEMU output with a yellow Hello W■■rld! in the lower left corner](https://os.phil-opp.com/vga-text-mode/vga-hello.png) 240 | 241 | 需要注意的是,`ö`字符被打印为两个`■`字符。这是因为在[UTF-8](http://www.fileformat.info/info/unicode/utf8.htm)编码下,字符`ö`是由两个字节表述的——而这两个字节并不处在可打印的ASCII码字节范围之内。事实上,这是UTF-8编码的基本特点之一:**如果一个字符占用多个字节,那么每个组成它的独立字节都不是有效的ASCII码字节**(the individual bytes of multi-byte values are never valid ASCII)。 242 | 243 | ### 易失操作 244 | 245 | 我们刚才看到,自己想要输出的信息被正确地打印到屏幕上。然而,未来Rust编译器更暴力的优化可能让这段代码不按预期工作。 246 | 247 | 产生问题的原因在于,我们只向`Buffer`写入,却不再从它读出数据。此时,编译器不知道我们事实上已经在操作VGA缓冲区内存,而不是在操作普通的RAM——因此也不知道产生的副作用,即会有几个字符显示在屏幕上。这时,编译器也许会认为这些写入操作都没有必要,甚至会选择忽略这些操作!所以,为了避免这些并不正确的优化,这些写入操作应当被指定为[易失操作](https://en.wikipedia.org/wiki/Volatile_(computer_programming))。这将告诉编译器,这些写入可能会产生副效应,不应该被优化掉。 248 | 249 | 为了在我们的VGA缓冲区中使用易失的写入操作,我们使用[volatile](https://docs.rs/volatile)库。这个**包**(crate)提供一个名为`Volatile`的**包装类型**(wrapping type),它的`read`、`write`方法;这些方法包装了`core::ptr`内的[read_volatile](https://doc.rust-lang.org/nightly/core/ptr/fn.read_volatile.html)和[write_volatile](https://doc.rust-lang.org/nightly/core/ptr/fn.write_volatile.html) 函数,从而保证读操作或写操作不会被编译器优化。 250 | 251 | 要添加`volatile`包为项目的**依赖项**(dependency),我们可以在`Cargo.toml`文件的`dependencies`中添加下面的代码: 252 | 253 | ```toml 254 | # in Cargo.toml 255 | 256 | [dependencies] 257 | volatile = "0.2.3" 258 | ``` 259 | 260 | `0.2.3`表示一个**语义版本号**([semantic version number](http://semver.org/)),在cargo文档的[《指定依赖项》章节](https://doc.rust-lang.org/cargo/reference/specifying-dependencies.html)可以找到与它相关的使用指南。 261 | 262 | 现在,我们使用它来完成VGA缓冲区的volatile写入操作。我们将`Buffer`类型的定义修改为下列代码: 263 | 264 | ```rust 265 | // in src/vga_buffer.rs 266 | 267 | use volatile::Volatile; 268 | 269 | struct Buffer { 270 | chars: [[Volatile; BUFFER_WIDTH]; BUFFER_HEIGHT], 271 | } 272 | ``` 273 | 274 | 在这里,我们不使用`ScreenChar`,而选择使用`Volatile`——在这里,`Volatile`类型是一个**泛型**([generic](https://doc.rust-lang.org/book/ch10-01-syntax.html)),可以包装几乎所有的类型——这确保了我们不会通过普通的写入操作,意外地向它写入数据;我们转而使用提供的`write`方法。 275 | 276 | 这意味着,我们必须要修改我们的`Writer::write_byte`方法: 277 | 278 | ```rust 279 | // in src/vga_buffer.rs 280 | 281 | impl Writer { 282 | pub fn write_byte(&mut self, byte: u8) { 283 | match byte { 284 | b'\n' => self.new_line(), 285 | byte => { 286 | ... 287 | 288 | self.buffer.chars[row][col].write(ScreenChar { 289 | ascii_character: byte, 290 | color_code: color_code, 291 | }); 292 | ... 293 | } 294 | } 295 | } 296 | ... 297 | } 298 | ``` 299 | 300 | 正如代码所示,我们不再使用普通的`=`赋值,而使用了`write`方法:这能确保编译器不再优化这个写入操作。 301 | 302 | ### 格式化宏 303 | 304 | 支持Rust提供的**格式化宏**(formatting macros)也是一个相当棒的主意。通过这种途径,我们可以轻松地打印不同类型的变量,如整数或浮点数。为了支持它们,我们需要实现[`core::fmt::Write`](https://doc.rust-lang.org/nightly/core/fmt/trait.Write.html) trait;要实现它,唯一需要提供的方法是`write_str`,它和我们先前编写的`write_string`方法差别不大,只是返回值类型变成了`fmt::Result`: 305 | 306 | ```rust 307 | // in src/vga_buffer.rs 308 | 309 | use core::fmt::Write; 310 | 311 | impl fmt::Write for Writer { 312 | fn write_str(&mut self, s: &str) -> fmt::Result { 313 | self.write_string(s); 314 | Ok(()) 315 | } 316 | } 317 | ``` 318 | 319 | 这里,`Ok(())`属于`Result`枚举类型中的`Ok`,包含一个值为`()`的变量。 320 | 321 | 现在我们就可以使用Rust内置的格式化宏`write!`和`writeln!`了: 322 | 323 | ```rust 324 | // in src/vga_buffer.rs 325 | 326 | pub fn print_something() { 327 | use core::fmt::Write; 328 | let mut writer = Writer { 329 | column_position: 0, 330 | color_code: ColorCode::new(Color::Yellow, Color::Black), 331 | buffer: unsafe { &mut *(0xb8000 as *mut Buffer) }, 332 | }; 333 | 334 | writer.write_byte(b'H'); 335 | writer.write_string("ello! "); 336 | write!(writer, "The numbers are {} and {}", 42, 1.0/3.0).unwrap(); 337 | } 338 | ``` 339 | 340 | 现在,你应该在屏幕下端看到一串`Hello! The numbers are 42 and 0.3333333333333333`。`write!`宏返回的`Result`类型必须被使用,所以我们调用它的[`unwrap`](https://doc.rust-lang.org/core/result/enum.Result.html#method.unwrap)方法,它将在错误发生时panic。这里的情况下应该不会发生这样的问题,因为写入VGA字符缓冲区并没有可能失败。 341 | 342 | ### 换行 343 | 344 | 在之前的代码中,我们忽略了换行符,因此没有处理超出一行字符的情况。当换行时,我们想要把每个字符向上移动一行——此时最顶上的一行将被删除——然后在最后一行的起始位置继续打印。要做到这一点,我们要为`Writer`实现一个新的`new_line`方法: 345 | 346 | ```rust 347 | // in src/vga_buffer.rs 348 | 349 | impl Writer { 350 | fn new_line(&mut self) { 351 | for row in 1..BUFFER_HEIGHT { 352 | for col in 0..BUFFER_WIDTH { 353 | let character = self.buffer.chars[row][col].read(); 354 | self.buffer.chars[row - 1][col].write(character); 355 | } 356 | } 357 | self.clear_row(BUFFER_HEIGHT - 1); 358 | self.column_position = 0; 359 | } 360 | 361 | fn clear_row(&mut self, row: usize) {/* TODO */} 362 | } 363 | ``` 364 | 365 | 我们遍历每个屏幕上的字符,把每个字符移动到它上方一行的相应位置。这里,`..`符号是**区间标号**(range notation)的一种;它表示左闭右开的区间,因此不包含它的上界。在外层的枚举中,我们从第1行开始,省略了对第0行的枚举过程——因为这一行应该被移出屏幕,即它将被下一行的字符覆写。 366 | 367 | 所以我们实现的`clear_row`方法代码如下: 368 | 369 | ```rust 370 | // in src/vga_buffer.rs 371 | 372 | impl Writer { 373 | fn clear_row(&mut self, row: usize) { 374 | let blank = ScreenChar { 375 | ascii_character: b' ', 376 | color_code: self.color_code, 377 | }; 378 | for col in 0..BUFFER_WIDTH { 379 | self.buffer.chars[row][col].write(blank); 380 | } 381 | } 382 | } 383 | ``` 384 | 385 | 通过向对应的缓冲区写入空格字符,这个方法能清空一整行的字符位置。 386 | 387 | ## 全局接口 388 | 389 | 编写其它模块时,我们希望无需随身携带`Writer`实例,便能使用它的方法。我们尝试创建一个静态的`WRITER`变量: 390 | 391 | ```rust 392 | // in src/vga_buffer.rs 393 | 394 | pub static WRITER: Writer = Writer { 395 | column_position: 0, 396 | color_code: ColorCode::new(Color::Yellow, Color::Black), 397 | buffer: unsafe { &mut *(0xb8000 as *mut Buffer) }, 398 | }; 399 | ``` 400 | 401 | 我们尝试编译这些代码,却发生了下面的编译错误: 402 | 403 | ``` 404 | error[E0015]: calls in statics are limited to constant functions, tuple structs and tuple variants 405 | --> src/vga_buffer.rs:7:17 406 | | 407 | 7 | color_code: ColorCode::new(Color::Yellow, Color::Black), 408 | | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 409 | 410 | error[E0396]: raw pointers cannot be dereferenced in statics 411 | --> src/vga_buffer.rs:8:22 412 | | 413 | 8 | buffer: unsafe { &mut *(0xb8000 as *mut Buffer) }, 414 | | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ dereference of raw pointer in constant 415 | 416 | error[E0017]: references in statics may only refer to immutable values 417 | --> src/vga_buffer.rs:8:22 418 | | 419 | 8 | buffer: unsafe { &mut *(0xb8000 as *mut Buffer) }, 420 | | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ statics require immutable values 421 | 422 | error[E0017]: references in statics may only refer to immutable values 423 | --> src/vga_buffer.rs:8:13 424 | | 425 | 8 | buffer: unsafe { &mut *(0xb8000 as *mut Buffer) }, 426 | | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ statics require immutable values 427 | ``` 428 | 429 | 为了明白现在发生了什么,我们需要知道一点:一般的变量在运行时初始化,而静态变量在编译时初始化。Rust编译器规定了一个称为**常量求值器**([const evaluator](https://rust-lang.github.io/rustc-guide/const-eval.html))的组件,它应该在编译时处理这样的初始化工作。虽然它目前的功能较为有限,但对它的扩展工作进展活跃,比如允许在常量中panic的[一篇RFC文档](https://github.com/rust-lang/rfcs/pull/2345)。 430 | 431 | 关于`ColorCode::new`的问题应该能使用**常函数**([`const` functions](https://doc.rust-lang.org/unstable-book/language-features/const-fn.html))解决,但常量求值器还存在不完善之处,它还不能在编译时直接转换裸指针到变量的引用——也许未来这段代码能够工作,但在那之前,我们需要寻找另外的解决方案。 432 | 433 | ### 延迟初始化 434 | 435 | 使用非常函数初始化静态变量是Rust程序员普遍遇到的问题。幸运的是,有一个叫做[lazy_static](https://docs.rs/lazy_static/1.0.1/lazy_static/)的包提供了一个很棒的解决方案:它提供了名为`lazy_static!`的宏,定义了一个**延迟初始化**(lazily initialized)的静态变量;这个变量的值将在第一次使用时计算,而非在编译时计算。这时,变量的初始化过程将在运行时执行,任意的初始化代码——无论简单或复杂——都是能够使用的。 436 | 437 | 现在,我们将`lazy_static`包导入到我们的项目: 438 | 439 | ```toml 440 | # in Cargo.toml 441 | 442 | [dependencies.lazy_static] 443 | version = "1.0" 444 | features = ["spin_no_std"] 445 | ``` 446 | 447 | 在这里,由于程序不连接标准库,我们需要启用`spin_no_std`特性。 448 | 449 | 使用`lazy_static`我们就可以定义一个不出问题的`WRITER`变量: 450 | 451 | ```rust 452 | // in src/vga_buffer.rs 453 | 454 | use lazy_static::lazy_static; 455 | 456 | lazy_static! { 457 | pub static ref WRITER: Writer = Writer { 458 | column_position: 0, 459 | color_code: ColorCode::new(Color::Yellow, Color::Black), 460 | buffer: unsafe { &mut *(0xb8000 as *mut Buffer) }, 461 | }; 462 | } 463 | ``` 464 | 465 | 然而,这个`WRITER`可能没有什么用途,因为它目前还是**不可变变量**(immutable variable):这意味着我们无法向它写入数据,因为所有与写入数据相关的方法都需要实例的可变引用`&mut self`。一种解决方案是使用**可变静态**([mutable static](https://doc.rust-lang.org/book/ch19-01-unsafe-rust.html#accessing-or-modifying-a-mutable-static-variable))的变量,但所有对它的读写操作都被规定为不安全的(unsafe)操作,因为这很容易导致数据竞争或发生其它不好的事情——使用`static mut`极其不被赞成,甚至有一些提案认为[应该将它删除](https://internals.rust-lang.org/t/pre-rfc-remove-static-mut/1437)。也有其它的替代方案,比如可以尝试使用比如[RefCell](https://doc.rust-lang.org/book/ch15-05-interior-mutability.html#keeping-track-of-borrows-at-runtime-with-refcellt)或甚至[UnsafeCell](https://doc.rust-lang.org/nightly/core/cell/struct.UnsafeCell.html)等类型提供的**内部可变性**([interior mutability](https://doc.rust-lang.org/book/ch15-05-interior-mutability.html));但这些类型都被设计为非同步类型,即不满足[Sync](https://doc.rust-lang.org/nightly/core/marker/trait.Sync.html)约束,所以我们不能在静态变量中使用它们。 466 | 467 | ### 自旋锁 468 | 469 | 要定义同步的内部可变性,我们往往使用标准库提供的互斥锁类[Mutex](https://doc.rust-lang.org/nightly/std/sync/struct.Mutex.html),它通过提供当资源被占用时将线程**阻塞**(block)的**互斥条件**(mutual exclusion)实现这一点;但我们初步的内核代码还没有线程和阻塞的概念,我们将不能使用这个类。不过,我们还有一种较为基础的互斥锁实现方式——**自旋锁**([spinlock](https://en.wikipedia.org/wiki/Spinlock))。自旋锁并不会调用阻塞逻辑,而是在一个小的无限循环中反复尝试获得这个锁,也因此会一直占用CPU时间,直到互斥锁被它的占用者释放。 470 | 471 | 为了使用自旋的互斥锁,我们添加[spin包](https://crates.io/crates/spin)到项目的依赖项列表: 472 | 473 | ```toml 474 | # in Cargo.toml 475 | [dependencies] 476 | spin = "0.4.9" 477 | ``` 478 | 479 | 现在,我们能够使用自旋的互斥锁,为我们的`WRITER`类实现安全的[内部可变性](https://doc.rust-lang.org/book/ch15-05-interior-mutability.html): 480 | 481 | ```rust 482 | // in src/vga_buffer.rs 483 | 484 | use spin::Mutex; 485 | ... 486 | lazy_static! { 487 | pub static ref WRITER: Mutex = Mutex::new(Writer { 488 | column_position: 0, 489 | color_code: ColorCode::new(Color::Yellow, Color::Black), 490 | buffer: unsafe { &mut *(0xb8000 as *mut Buffer) }, 491 | }); 492 | } 493 | ``` 494 | 495 | 现在我们可以删除`print_something`函数,尝试直接在`_start`函数中打印字符: 496 | 497 | ```rust 498 | // in src/main.rs 499 | #[no_mangle] 500 | pub extern "C" fn _start() -> ! { 501 | use core::fmt::Write; 502 | vga_buffer::WRITER.lock().write_str("Hello again").unwrap(); 503 | write!(vga_buffer::WRITER.lock(), ", some numbers: {} {}", 42, 1.337).unwrap(); 504 | 505 | loop {} 506 | } 507 | ``` 508 | 509 | 在这里,我们需要导入名为`fmt::Write`的trait,来使用实现它的类的相应方法。 510 | 511 | ### 安全性 512 | 513 | 经过上文的努力后,我们现在的代码只剩一个unsafe语句块,它用于创建一个指向`0xb8000`地址的`Buffer`类型引用;在这步之后,所有的操作都是安全的。Rust将为每个数组访问检查边界,所以我们不会在不经意间越界到缓冲区之外。因此,我们把需要的条件编码到Rust的类型系统,这之后,我们为外界提供的接口就符合内存安全原则了。 514 | 515 | ### `println!`宏 516 | 517 | 现在我们有了一个全局的`Writer`实例,我们就可以基于它实现`println!`宏,这样它就能被任意地方的代码使用了。Rust提供的[宏定义语法](https://doc.rust-lang.org/nightly/book/ch19-06-macros.html#declarative-macros-with-macro_rules-for-general-metaprogramming)需要时间理解,所以我们将不从零开始编写这个宏。我们先看看标准库中[`println!`宏的实现源码](https://doc.rust-lang.org/nightly/std/macro.println!.html): 518 | 519 | ```rust 520 | #[macro_export] 521 | macro_rules! println { 522 | () => (print!("\n")); 523 | ($($arg:tt)*) => (print!("{}\n", format_args!($($arg)*))); 524 | } 525 | ``` 526 | 527 | 宏是通过一个或多个**规则**(rule)定义的,这就像`match`语句的多个分支。`println!`宏有两个规则:第一个规则不要求传入参数——就比如`println!()`——它将被扩展为`print!("\n")`,因此只会打印一个新行;第二个要求传入参数——好比`println!("Rust能够编写操作系统")`或`println!("我学习Rust已经{}年了", 3)`——它将使用`print!`宏扩展,传入它需求的所有参数,并在输出的字符串最后加入一个换行符`\n`。 528 | 529 | 这里,`#[macro_export]`属性让整个包(crate)和基于它的包都能访问这个宏,而不仅限于定义它的模块(module)。它还将把宏置于包的根模块(crate root)下,这意味着比如我们需要通过`use std::println`来导入这个宏,而不是通过`std::macros::println`。 530 | 531 | [`print!`宏](https://doc.rust-lang.org/nightly/std/macro.print!.html)是这样定义的: 532 | 533 | ``` 534 | #[macro_export] 535 | macro_rules! print { 536 | ($($arg:tt)*) => ($crate::io::_print(format_args!($($arg)*))); 537 | } 538 | ``` 539 | 540 | 这个宏将扩展为一个对`io`模块中[`_print`函数](https://github.com/rust-lang/rust/blob/29f5c699b11a6a148f097f82eaa05202f8799bbc/src/libstd/io/stdio.rs#L698)的调用。[`$crate`变量](https://doc.rust-lang.org/1.30.0/book/first-edition/macros.html#the-variable-crate)将在`std`包之外被解析为`std`包,保证整个宏在`std`包之外也可以使用。 541 | 542 | [`format_args!`宏](https://doc.rust-lang.org/nightly/std/macro.format_args.html)将传入的参数搭建为一个[fmt::Arguments](https://doc.rust-lang.org/nightly/core/fmt/struct.Arguments.html)类型,这个类型将被传入`_print`函数。`std`包中的[`_print` 函数](https://github.com/rust-lang/rust/blob/29f5c699b11a6a148f097f82eaa05202f8799bbc/src/libstd/io/stdio.rs#L698)将调用复杂的私有函数`print_to`,来处理对不同`Stdout`设备的支持。我们不需要编写这样的复杂函数,因为我们只需要打印到VGA字符缓冲区。 543 | 544 | 要打印到字符缓冲区,我们把`println!`和`print!`两个宏复制过来,但修改部分代码,让这些宏使用我们定义的`_print`函数: 545 | 546 | ```rust 547 | // in src/vga_buffer.rs 548 | 549 | #[macro_export] 550 | macro_rules! print { 551 | ($($arg:tt)*) => ($crate::vga_buffer::_print(format_args!($($arg)*))); 552 | } 553 | 554 | #[macro_export] 555 | macro_rules! println { 556 | () => ($crate::print!("\n")); 557 | ($($arg:tt)*) => ($crate::print!("{}\n", format_args!($($arg)*))); 558 | } 559 | 560 | #[doc(hidden)] 561 | pub fn _print(args: fmt::Arguments) { 562 | use core::fmt::Write; 563 | WRITER.lock().write_fmt(args).unwrap(); 564 | } 565 | ``` 566 | 567 | 我们首先修改了`println!`宏,在每个使用的`print!`宏前面添加了`$crate`变量。这样我们在只需要使用`println!`时,不必也编写代码导入`print!`宏。 568 | 569 | 就像标准库做的那样,我们为两个宏都添加了`#[macro_export]`属性,这样在包的其它地方也可以使用它们。需要注意的是,这将占用包的**根命名空间**(root namespace),所以我们不能通过`use crate::vga_buffer::println`来导入它们;我们应该使用`use crate::println`。 570 | 571 | 另外,`_print`函数将占有静态变量`WRITER`的锁,并调用它的`write_fmt`方法。这个方法是从名为`Write`的trait中获得的,所以我们需要导入这个trait。额外的`unwrap()`函数将在打印不成功的时候panic;但既然我们的`write_str`总是返回`Ok`,这种情况不应该发生。 572 | 573 | 如果这个宏将能在模块外访问,它们也应当能访问`_print`函数,因此这个函数必须是公有的(public)。然而,考虑到这是一个私有的实现细节,我们添加一个[`doc(hidden)`属性](https://doc.rust-lang.org/nightly/rustdoc/the-doc-attribute.html#dochidden),防止它在生成的文档中出现。 574 | 575 | ### 使用`println!`的Hello World 576 | 577 | 现在,我们可以在`_start`里使用`println!`了: 578 | 579 | ```rust 580 | // in src/main.rs 581 | 582 | #[no_mangle] 583 | pub extern "C" fn _start() { 584 | println!("Hello World{}", "!"); 585 | 586 | loop {} 587 | } 588 | ``` 589 | 590 | 要注意的是,我们在入口函数中不需要导入这个宏——因为它已经被置于包的根命名空间了。 591 | 592 | 运行这段代码,和我们预料的一样,一个 *“Hello World!”* 字符串被打印到了屏幕上: 593 | 594 | ![QEMU printing “Hello World!”](https://os.phil-opp.com/vga-text-mode/vga-hello-world.png) 595 | 596 | ### 打印panic信息 597 | 598 | 既然我们已经有了`println!`宏,我们可以在panic处理函数中,使用它打印panic信息和panic产生的位置: 599 | 600 | ```rust 601 | // in main.rs 602 | 603 | /// 这个函数将在panic发生时被调用 604 | #[panic_handler] 605 | fn panic(info: &PanicInfo) -> ! { 606 | println!("{}", info); 607 | loop {} 608 | } 609 | ``` 610 | 611 | 当我们在`_start`函数中插入一行`panic!("Some panic message");`后,我们得到了这样的输出: 612 | 613 | ![QEMU printing “panicked at 'Some panic message', src/main.rs:28:5](https://os.phil-opp.com/vga-text-mode/vga-panic.png) 614 | 615 | 所以,现在我们不仅能知道panic已经发生,还能够知道panic信息和产生panic的代码。 616 | 617 | ## 小结 618 | 619 | 这篇文章中,我们学习了VGA字符缓冲区的结构,以及如何在`0xb8000`的内存映射地址访问它。我们将所有的不安全操作包装为一个Rust模块,以便在外界安全地访问它。 620 | 621 | 我们也发现了——感谢便于使用的cargo——在Rust中使用第三方提供的包是及其容易的。我们添加的两个依赖项,`lazy_static`和`spin`,都在操作系统开发中及其有用;我们将在未来的文章中多次使用它们。 622 | 623 | ## 下篇预告 624 | 625 | 下一篇文章中,我们将会讲述如何配置Rust内置的单元测试框架。我们还将为本文编写的VGA缓冲区模块添加基础的单元测试项目。 626 | -------------------------------------------------------------------------------- /04-testing.md: -------------------------------------------------------------------------------- 1 | > 原文:https://os.phil-opp.com/testing/ 2 | > 3 | > 原作者:@phil-opp 4 | > 5 | > 译者:readlnh 6 | 7 | # 使用Rust编写操作系统(四):内核测试 8 | 9 | 本文主要讲述了在`no_std`环境下进行单元测试和集成测试的方法。我们将通过Rust的自定义测试框架来在我们的内核中执行一些测试函数。为了将结果反馈到QEMU上,我们需要使用QEMU的一些其他的功能以及`bootimage`工具。 10 | 11 | 12 | 13 | 这个系列的blog在[GitHub]上开放开发,如果你有任何问题,请在这里开一个issue来讨论。当然你也可以在[底部]留言。你可以在[这里][post branch]找到这篇文章的完整源码。 14 | 15 | [GitHub]: https://github.com/phil-opp/blog_os 16 | [at the bottom]: #comments 17 | [post branch]: https://github.com/phil-opp/blog_os/tree/post-04 18 | 19 | 20 | 21 | ## 阅读要求 22 | 23 | 这篇文章替换了此前的(现在已经过时了) [_单元测试(Unit Testing)_] 和 [_集成测试(Integration Tests)_] 两篇文章。这里我将假定你是在2019-04-27日后阅读的[_最小Rust内核_]一文。总而言之,本文要求你已经有一个[设置默认目标]的 `.cargo/config` 文件且[定义了一个runner可执行文件]。 24 | 25 | [_单元测试(Unit Testing)_]: ./second-edition/posts/deprecated/04-unit-testing/index.md 26 | [_集成测试(Integration Tests)_]: ./second-edition/posts/deprecated/05-integration-tests/index.md 27 | [_最小Rust内核_]: ./second-edition/posts/02-minimal-rust-kernel/index.md 28 | [设置默认目标]: ./second-edition/posts/02-minimal-rust-kernel/index.md#set-a-default-target 29 | [定义了一个runner可执行文件]: ./second-edition/posts/02-minimal-rust-kernel/index.md#using-cargo-run 30 | 31 | ## Rust中的测试 32 | 33 | Rust有一个**内置的测试框架**([built-in test framework]):无需任何设置就可以进行单元测试,只需要创建一个通过assert来检查结果的函数并在函数的头部加上`#[test]`属性即可。然后`cargo test`会自动找到并执行你的crate中的所有测试函数。 34 | 35 | [built-in test framework]: https://doc.rust-lang.org/book/second-edition/ch11-00-testing.html 36 | 37 | 不幸的是,对于一个`no_std`的应用,比如我们的内核,这有点点复杂。现在的问题是,Rust的测试框架会隐式的调用内置的[`test`]库,但是这个库依赖于标准库。这也就是说我们的 `#[no_std]`内核无法使用默认的测试框架。 38 | 39 | [`test`]: https://doc.rust-lang.org/test/index.html 40 | 41 | 当我们试图在我们的项目中执行`cargo xtest`时,我们可以看到如下信息: 42 | 43 | ```text 44 | > cargo xtest 45 | Compiling blog_os v0.1.0 (/…/blog_os) 46 | error[E0463]: can't find crate for `test` 47 | ``` 48 | 49 | 由于`test`crate依赖于标准库,所以它在我们的裸机目标上并不可用。虽然将`test`crate移植到一个 `#[no_std]` 上下文环境中是[可能的][utest],但是这样做是高度不稳定的并且还会需要一些特殊的hacks,例如重定义 `panic` 宏。 50 | 51 | [utest]: https://github.com/japaric/utest 52 | 53 | ### 自定义测试框架 54 | 55 | 幸运的是,Rust支持通过使用不稳定的**自定义测试框架**([`custom_test_frameworks`]) 功能来替换默认的测试框架。该功能不需要额外的库,因此在 `#[no_std]`环境中它也可以工作。它的工作原理是收集所有标注了 `#[test_case]`属性的函数,然后将这个测试函数的列表作为参数传递给用户指定的runner函数。因此,它实现了对测试过程的最大控制。 56 | 57 | [`custom_test_frameworks`]: https://doc.rust-lang.org/unstable-book/language-features/custom-test-frameworks.html 58 | 59 | 与默认的测试框架相比,它的缺点是有一些高级功能诸如 [`should_panic` tests]都不可用了。相对的,如果需要这些功能,我们需要自己来实现。当然,这点对我们来说是好事,因为我们的环境非常特殊,在这个环境里,这些高级功能的默认实现无论如何都是无法工作的,举个例子, `#[should_panic]`属性依赖于堆栈展开来捕获内核panic,而我的内核早已将其禁用了。 60 | 61 | [`should_panic` tests]: https://doc.rust-lang.org/book/ch11-01-writing-tests.html#checking-for-panics-with-should_panic 62 | 63 | 要为我们的内核实现自定义测试框架,我们需要将如下代码添加到我们的`main.rs`中去: 64 | 65 | ```rust 66 | // in src/main.rs 67 | 68 | #![feature(custom_test_frameworks)] 69 | #![test_runner(crate::test_runner)] 70 | 71 | #[cfg(test)] 72 | fn test_runner(tests: &[&dyn Fn()]) { 73 | println!("Running {} tests", tests.len()); 74 | for test in tests { 75 | test(); 76 | } 77 | } 78 | ``` 79 | 80 | 我们的runner会打印一个简短的debug信息然后调用列表中的每个测试函数。参数类型 `&[&dyn Fn()]` 是[_Fn()_] trait的 [_trait object_] 引用的一个 [_slice_]。它基本上可以被看做一个可以像函数一样被调用的类型的引用列表。由于这个函数在不进行测试的时候没有什么用,这里我们使用 `#[cfg(test)]`属性保证它只会出现在测试中。 81 | 82 | [_slice_]: https://doc.rust-lang.org/std/primitive.slice.html 83 | [_trait object_]: https://doc.rust-lang.org/1.30.0/book/first-edition/trait-objects.html 84 | [_Fn()_]: https://doc.rust-lang.org/std/ops/trait.Fn.html 85 | 86 | 现在当我们运行 `cargo xtest` ,我们可以发现运行成功了。然而,我们看到的仍然是"Hello World"而不是我们的 `test_runner`传递来的信息。这是由于我们的入口点仍然是 `_start` 函数——自定义测试框架会生成一个`main`函数来调用`test_runner`,但是由于我们使用了 `#[no_main]`并提供了我们自己的入口点,所以这个`main`函数就被忽略了。 87 | 88 | 为了修复这个问题,我们需要通过 `reexport_test_harness_main`属性来将生成的函数的名称更改为与`main`不同的名称。然后我们可以在我们的`_start`函数里调用这个重命名的函数: 89 | 90 | ```rust 91 | // in src/main.rs 92 | 93 | #![reexport_test_harness_main = "test_main"] 94 | 95 | #[no_mangle] 96 | pub extern "C" fn _start() -> ! { 97 | println!("Hello World{}", "!"); 98 | 99 | #[cfg(test)] 100 | test_main(); 101 | 102 | loop {} 103 | } 104 | ``` 105 | 106 | 我们将测试框架的入口函数的名字设置为`test_main`,并在我们的 `_start`入口点里调用它。通过使用**条件编译**([conditional compilation]),我们能够只在上下文环境为测试(test)时调用`test_main`,因为该函数将不在非测试上下文中生成。 107 | 108 | [ conditional compilation ]: https://doc.rust-lang.org/1.30.0/book/first-edition/conditional-compilation.html 109 | 110 | 现在当我们执行 `cargo xtest`时,我们可以看到我们的`test_runner`将"Running 0 tests"信息显示在屏幕上了。我们可以创建第一个测试函数了: 111 | 112 | ```rust 113 | // in src/main.rs 114 | 115 | #[test_case] 116 | fn trivial_assertion() { 117 | print!("trivial assertion... "); 118 | assert_eq!(1, 1); 119 | println!("[ok]"); 120 | } 121 | ``` 122 | 123 | 现在,当我们运行 `cargo xtest`时,我们可以看到如下输出: 124 | 125 | ![QEMU printing "Hello World!", "Running 1 tests", and "trivial assertion... [ok]"](https://os.phil-opp.com/testing/qemu-test-runner-output.png) 126 | 127 | 传递给 `test_runner`函数的`tests`切片里包含了一个 `trivial_assertion` 函数的引用,从屏幕上输出的 `trivial assertion... [ok]`信息可见,我们的测试已被调用并且顺利通过。 128 | 129 | 在执行完tests后, `test_runner`会将结果返回给 `test_main`函数,而这个函数又返回到 `_start`入口点函数——这样我们就进入了一个死循环,因为入口点函数是不允许返回的。这将导致一个问题:我们希望`cargo xtest`在所有的测试运行完毕后,才返回并退出。 130 | 131 | ## 退出QEMU 132 | 133 | 现在我们在`_start`函数结束后进入了一个死循环,所以每次执行完`cargo xtest`后我们都需要手动去关闭QEMU;但是我们还想在没有用户交互的脚本环境下执行 `cargo xtest`。解决这个问题的最佳方式,是实现一个合适的方法来关闭我们的操作系统——不幸的是,这个方式实现起来相对有些复杂,因为这要求我们实现对[APM]或[ACPI]电源管理标准的支持。 134 | 135 | [APM]: https://wiki.osdev.org/APM 136 | [ACPI]: https://wiki.osdev.org/ACPI 137 | 138 | 幸运的是,还有一个绕开这些问题的办法:QEMU支持一种名为 `isa-debug-exit`的特殊设备,它提供了一种从客户系统(guest system)里退出QEMU的简单方式。为了使用这个设备,我们需要向QEMU传递一个`-device`参数。当然,我们也可以通过将 `package.metadata.bootimage.test-args` 配置关键字添加到我们的`Cargo.toml`来达到目的: 139 | 140 | ```toml 141 | # in Cargo.toml 142 | 143 | [package.metadata.bootimage] 144 | test-args = ["-device", "isa-debug-exit,iobase=0xf4,iosize=0x04"] 145 | ``` 146 | 147 | `bootimage runner` 会在QEMU的默认测试命令后添加`test-args` 参数。(对于`cargo xrun`命令,这个参数会被忽略。) 148 | 149 | 在传递设备名 (`isa-debug-exit`)的同时,我们还传递了两个参数,`iobase` 和 `iosize` 。这两个参数指定了一个_I/O 端口_,我们的内核将通过它来访问设备。 150 | 151 | ### I/O 端口 152 | 153 | 在x86平台上,CPU和外围硬件通信通常有两种方式,**内存映射I/O**和**端口映射I/O**。之前,我们已经使用内存映射的方式,通过内存地址`0xb8000`访问了[VGA文本缓冲区]。该地址并没有映射到RAM,而是映射到了VGA设备的一部分内存上。 154 | 155 | [VGA text buffer]: ./second-edition/posts/03-vga-text-buffer/index.md 156 | 157 | 与内存映射不同,端口映射I/O使用独立的I/O总线来进行通信。每个外围设备都有一个或数个端口号。CPU采用了特殊的`in`和`out`指令来和端口通信,这些指令要求一个端口号和一个字节的数据作为参数(有些这种指令的变体也允许发送`u16`或是`u32`长度的数据)。 158 | 159 | `isa-debug-exit`设备使用的就是端口映射I/O。其中, `iobase` 参数指定了设备对应的端口地址(在x86中,`0xf4`是一个[通常未被使用的端口][list of x86 I/O ports]),而`iosize`则指定了端口的大小(`0x04`代表4字节)。 160 | 161 | [list of x86 I/O ports]: https://wiki.osdev.org/I/O_Ports#The_list 162 | 163 | ### 使用退出(Exit)设备 164 | 165 | `isa-debug-exit`设备的功能非常简单。当一个 `value`写入`iobase`指定的端口时,它会导致QEMU以**退出状态**([exit status])`(value << 1) | 1`退出。也就是说,当我们向端口写入`0`时,QEMU将以退出状态`(0 << 1) | 1 = 1`退出,而当我们向端口写入`1`时,它将以退出状态`(1 << 1) | 1 = 3`退出。 166 | 167 | [exit status]: https://en.wikipedia.org/wiki/Exit_status 168 | 169 | 这里我们使用 [`x86_64`] crate提供的抽象,而不是手动调用`in`或`out`指令。为了添加对该crate的依赖,我们可以将其添加到我们的 `Cargo.toml`中的 `dependencies` 小节中去: 170 | 171 | [`x86_64`]: https://docs.rs/x86_64/0.7.5/x86_64/ 172 | 173 | ```toml 174 | # in Cargo.toml 175 | 176 | [dependencies] 177 | x86_64 = "0.7.5" 178 | ``` 179 | 180 | 现在我们可以使用crate中提供的[`Port`] 类型来创建一个`exit_qemu` 函数了: 181 | 182 | [`Port`]: https://docs.rs/x86_64/0.7.0/x86_64/instructions/port/struct.Port.html 183 | 184 | ```rust 185 | // in src/main.rs 186 | 187 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] 188 | #[repr(u32)] 189 | pub enum QemuExitCode { 190 | Success = 0x10, 191 | Failed = 0x11, 192 | } 193 | 194 | pub fn exit_qemu(exit_code: QemuExitCode) { 195 | use x86_64::instructions::port::Port; 196 | 197 | unsafe { 198 | let mut port = Port::new(0xf4); 199 | port.write(exit_code as u32); 200 | } 201 | } 202 | ``` 203 | 204 | 该函数在`0xf4`处创建了一个新的端口,该端口同时也是 `isa-debug-exit` 设备的 `iobase` 。然后它会向端口写入传递的退出代码。这里我们使用`u32`来传递数据,因为我们之前已经将 `isa-debug-exit`设备的 `iosize` 指定为4字节了。上述两个操作都是`unsafe`的,因为I/O端口的写入操作通常会导致一些不可预知的行为。 205 | 206 | 为了指定退出状态,我们创建了一个 `QemuExitCode`枚举。思路大体上是,如果所有的测试均成功,就以成功退出码退出;否则就以失败退出码退出。这个枚举类型被标记为 `#[repr(u32)]`,代表每个变量都是一个`u32`的整数类型。我们使用退出代码`0x10`代表成功,`0x11`代表失败。 实际的退出代码并不重要,只要它们不与QEMU的默认退出代码冲突即可。 例如,使用退出代码0表示成功可能并不是一个好主意,因为它在转换后就变成了`(0 << 1) | 1 = 1` ,而`1`是QEMU运行失败时的默认退出代码。 这样,我们就无法将QEMU错误与成功的测试运行区分开来了。 207 | 208 | 现在我们来更新`test_runner`的代码,让程序在运行所有测试完毕后退出QEMU: 209 | 210 | ```rust 211 | fn test_runner(tests: &[&dyn Fn()]) { 212 | println!("Running {} tests", tests.len()); 213 | for test in tests { 214 | test(); 215 | } 216 | /// new 217 | exit_qemu(QemuExitCode::Success); 218 | } 219 | ``` 220 | 221 | 当我们现在运行`cargo xtest`时,QEMU会在测试运行后立刻退出。现在的问题是,即使我们传递了表示成功(`Success`)的退出代码, `cargo test`依然会将所有的测试都视为失败: 222 | 223 | ```text 224 | > cargo xtest 225 | Finished dev [unoptimized + debuginfo] target(s) in 0.03s 226 | Running target/x86_64-blog_os/debug/deps/blog_os-5804fc7d2dd4c9be 227 | Building bootloader 228 | Compiling bootloader v0.5.3 (/home/philipp/Documents/bootloader) 229 | Finished release [optimized + debuginfo] target(s) in 1.07s 230 | Running: `qemu-system-x86_64 -drive format=raw,file=/…/target/x86_64-blog_os/debug/ 231 | deps/bootimage-blog_os-5804fc7d2dd4c9be.bin -device isa-debug-exit,iobase=0xf4, 232 | iosize=0x04` 233 | error: test failed, to rerun pass '--bin blog_os' 234 | ``` 235 | 236 | 这里的问题在于,`cargo test`会将所有非`0`的错误码都视为测试失败。 237 | 238 | ### 代表成功的退出代码 239 | 240 | 为了解决这个问题, `bootimage`提供了一个 `test-success-exit-code`配置项,可以将指定的退出代码映射到退出代码`0`: 241 | 242 | ```toml 243 | [package.metadata.bootimage] 244 | test-args = […] 245 | test-success-exit-code = 33 # (0x10 << 1) | 1 246 | ``` 247 | 248 | 有了这个配置,`bootimage`就会将我们的成功退出码映射到退出码0;这样一来, `cargo xtest`就能正确的识别出测试成功的情况,而不会将其视为测试失败。 249 | 250 | 我们的测试runner现在会在正确报告测试结果后自动关闭QEMU。我们可以看到QEMU的窗口只会显示很短的时间——我们不容易看清测试的结果。如果测试结果会打印在控制台上而不是QEMU里,让我们能在QEMU退出后仍然能看到测试结果就好了。 251 | 252 | ## 打印到控制台 253 | 254 | 要在控制台上查看测试输出,我们需要以某种方式将数据从内核发送到宿主系统。 有多种方法可以实现这一点,例如通过TCP网络接口来发送数据。但是,设置网络堆栈是一项很复杂的任务——这里我们选择更简单的解决方案。 255 | 256 | ### 串口 257 | 258 | 发送数据的一个简单的方式是通过[串行端口],这是一个现代电脑中已经不存在的旧标准接口(译者注:玩过单片机的同学应该知道,其实译者上大学的时候有些同学的笔记本电脑还有串口的,没有串口的同学在烧录单片机程序的时候也都会需要usb转串口线,一般是51,像stm32有st-link,这个另说,不过其实也可以用串口来下载)。串口非常易于编程,QEMU可以将通过串口发送的数据重定向到宿主机的标准输出或是文件中。 259 | 260 | [串行端口]: https://en.wikipedia.org/wiki/Serial_port 261 | 262 | 用来实现串行接口的芯片被称为 [UARTs]。在x86上,有[很多UART模型],但是幸运的是,它们之间仅有的那些不同之处都是我们用不到的高级功能。目前通用的UARTs都会兼容[16550 UART],所以我们在我们测试框架里采用该模型。 263 | 264 | [UARTs]: https://en.wikipedia.org/wiki/Universal_asynchronous_receiver-transmitter 265 | [很多UART模型]: https://en.wikipedia.org/wiki/Universal_asynchronous_receiver-transmitter#UART_models 266 | [16550 UART]: https://en.wikipedia.org/wiki/16550_UART 267 | 268 | 我们使用[`uart_16550`] crate来初始化UART,并通过串口来发送数据。为了将该crate添加为依赖,我们将我们的`Cargo.toml`和`main.rs`修改为如下: 269 | 270 | [`uart_16550`]: https://docs.rs/uart_16550 271 | 272 | ```toml 273 | # in Cargo.toml 274 | 275 | [dependencies] 276 | uart_16550 = "0.2.0" 277 | ``` 278 | 279 | `uart_16550` crate包含了一个代表UART寄存器的`SerialPort`结构体,但是我们仍然需要自己来创建一个相应的实例。我们使用以下内容来创建一个新的串口模块`serial`: 280 | 281 | ```rust 282 | // in src/main.rs 283 | 284 | mod serial; 285 | ``` 286 | 287 | ```rust 288 | // in src/serial.rs 289 | 290 | use uart_16550::SerialPort; 291 | use spin::Mutex; 292 | use lazy_static::lazy_static; 293 | 294 | lazy_static! { 295 | pub static ref SERIAL1: Mutex = { 296 | let mut serial_port = unsafe { SerialPort::new(0x3F8) }; 297 | serial_port.init(); 298 | Mutex::new(serial_port) 299 | }; 300 | } 301 | ``` 302 | 303 | 就像[VGA文本缓冲区][vga lazy-static]一样,我们使用 `lazy_static` 和一个自旋锁来创建一个 `static` writer实例。通过使用 `lazy_static` ,我们可以保证`init`方法只会在该示例第一次被使用使被调用。 304 | 305 | 和 `isa-debug-exit`设备一样,UART也是用过I/O端口进行编程的。由于UART相对来讲更加复杂,它使用多个I/O端口来对不同的设备寄存器进行编程。不安全的`SerialPort::new`函数需要UART的第一个I/O端口的地址作为参数,从该地址中可以计算出所有所需端口的地址。我们传递的端口地址为`0x3F8` ,该地址是第一个串行接口的标准端口号。 306 | 307 | [vga lazy-static]: ./second-edition/posts/03-vga-text-buffer/index.md#lazy-statics 308 | 309 | 为了使串口更加易用,我们添加了 `serial_print!` 和 `serial_println!`宏: 310 | 311 | ```rust 312 | #[doc(hidden)] 313 | pub fn _print(args: ::core::fmt::Arguments) { 314 | use core::fmt::Write; 315 | SERIAL1.lock().write_fmt(args).expect("Printing to serial failed"); 316 | } 317 | 318 | /// Prints to the host through the serial interface. 319 | #[macro_export] 320 | macro_rules! serial_print { 321 | ($($arg:tt)*) => { 322 | $crate::serial::_print(format_args!($($arg)*)); 323 | }; 324 | } 325 | 326 | /// Prints to the host through the serial interface, appending a newline. 327 | #[macro_export] 328 | macro_rules! serial_println { 329 | () => ($crate::serial_print!("\n")); 330 | ($fmt:expr) => ($crate::serial_print!(concat!($fmt, "\n"))); 331 | ($fmt:expr, $($arg:tt)*) => ($crate::serial_print!( 332 | concat!($fmt, "\n"), $($arg)*)); 333 | } 334 | ``` 335 | 336 | 该实现和我们此前的`print`和`println`宏的实现非常类似。 由于`SerialPort`类型已经实现了`fmt::Write` trait,所以我们不需要提供我们自己的实现了。 337 | 338 | [`fmt::Write`]: https://doc.rust-lang.org/nightly/core/fmt/trait.Write.html 339 | 340 | 现在我们可以从测试代码里向串行接口打印而不是向VGA文本缓冲区打印了: 341 | 342 | ```rust 343 | // in src/main.rs 344 | 345 | #[cfg(test)] 346 | fn test_runner(tests: &[&dyn Fn()]) { 347 | serial_println!("Running {} tests", tests.len()); 348 | […] 349 | } 350 | 351 | #[test_case] 352 | fn trivial_assertion() { 353 | serial_print!("trivial assertion... "); 354 | assert_eq!(1, 1); 355 | serial_println!("[ok]"); 356 | } 357 | ``` 358 | 359 | 注意,由于我们使用了 `#[macro_export]` 属性, `serial_println`宏直接位于根命名空间下——所以通过`use crate::serial::serial_println` 来导入该宏是不起作用的。 360 | 361 | ### QEMU参数 362 | 363 | 为了查看QEMU的串行输出,我们需要使用`-serial`参数将输出重定向到stdout: 364 | 365 | ```toml 366 | # in Cargo.toml 367 | 368 | [package.metadata.bootimage] 369 | test-args = [ 370 | "-device", "isa-debug-exit,iobase=0xf4,iosize=0x04", "-serial", "stdio" 371 | ] 372 | ``` 373 | 374 | 现在,当我们运行 `cargo xtest`时,我们可以直接在控制台里看到测试输出了: 375 | 376 | ```text 377 | > cargo xtest 378 | Finished dev [unoptimized + debuginfo] target(s) in 0.02s 379 | Running target/x86_64-blog_os/debug/deps/blog_os-7b7c37b4ad62551a 380 | Building bootloader 381 | Finished release [optimized + debuginfo] target(s) in 0.02s 382 | Running: `qemu-system-x86_64 -drive format=raw,file=/…/target/x86_64-blog_os/debug/ 383 | deps/bootimage-blog_os-7b7c37b4ad62551a.bin -device 384 | isa-debug-exit,iobase=0xf4,iosize=0x04 -serial stdio` 385 | Running 1 tests 386 | trivial assertion... [ok] 387 | ``` 388 | 389 | 然而,当测试失败时,我们仍然会在QEMU内看到输出结果,因为我们的panic handler还是用了`println`。为了模拟这个过程,我们将我们的 `trivial_assertion` test中的断言(assertion)修改为 `assert_eq!(0, 1)`: 390 | 391 | ![QEMU printing "Hello World!" and "panicked at 'assertion failed: `(left == right)` 392 | left: `0`, right: `1`', src/main.rs:55:5](https://os.phil-opp.com/testing/qemu-failed-test.png) 393 | 394 | 可以看到,panic信息被打印到了VGA缓冲区里,而测试输出则被打印到串口上了。panic信息非常有用,所以我们希望能够在控制台中来查看它。 395 | 396 | ### 在panic时打印一个错误信息 397 | 398 | 为了在panic时使用错误信息来退出QEMU,我们可以使用**条件编译**([conditional compilation])在测试模式下使用(与非测试模式下)不同的panic处理方式: 399 | 400 | [conditional compilation]: https://doc.rust-lang.org/1.30.0/book/first-edition/conditional-compilation.html 401 | 402 | ```rust 403 | // our existing panic handler 404 | #[cfg(not(test))] // new attribute 405 | #[panic_handler] 406 | fn panic(info: &PanicInfo) -> ! { 407 | println!("{}", info); 408 | loop {} 409 | } 410 | 411 | // our panic handler in test mode 412 | #[cfg(test)] 413 | #[panic_handler] 414 | fn panic(info: &PanicInfo) -> ! { 415 | serial_println!("[failed]\n"); 416 | serial_println!("Error: {}\n", info); 417 | exit_qemu(QemuExitCode::Failed); 418 | loop {} 419 | } 420 | ``` 421 | 422 | 在我们的测试panic处理中,我们用 `serial_println`来代替`println` 并使用失败代码来退出QEMU。注意,在`exit_qemu`调用后,我们仍然需要一个无限循环的`loop`因为编译器并不知道 `isa-debug-exit`设备会导致程序退出。 423 | 424 | 现在,即使在测试失败的情况下QEMU仍然会存在,并会将一些有用的错误信息打印到控制台: 425 | 426 | ```text 427 | > cargo xtest 428 | Finished dev [unoptimized + debuginfo] target(s) in 0.02s 429 | Running target/x86_64-blog_os/debug/deps/blog_os-7b7c37b4ad62551a 430 | Building bootloader 431 | Finished release [optimized + debuginfo] target(s) in 0.02s 432 | Running: `qemu-system-x86_64 -drive format=raw,file=/…/target/x86_64-blog_os/debug/ 433 | deps/bootimage-blog_os-7b7c37b4ad62551a.bin -device 434 | isa-debug-exit,iobase=0xf4,iosize=0x04 -serial stdio` 435 | Running 1 tests 436 | trivial assertion... [failed] 437 | 438 | Error: panicked at 'assertion failed: `(left == right)` 439 | left: `0`, 440 | right: `1`', src/main.rs:65:5 441 | ``` 442 | 443 | 由于现在所有的测试都将输出到控制台上,我们不再需要让QEMU窗口弹出一小会儿了——我们完全可以把窗口藏起来。 444 | 445 | ### 隐藏 QEMU 446 | 447 | 由于我们使用`isa-debug-exit`设备和串行端口来报告完整的测试结果,所以我们不再需要QMEU的窗口了。我们可以通过向QEMU传递 `-display none`参数来将其隐藏: 448 | 449 | ```toml 450 | # in Cargo.toml 451 | 452 | [package.metadata.bootimage] 453 | test-args = [ 454 | "-device", "isa-debug-exit,iobase=0xf4,iosize=0x04", "-serial", "stdio", 455 | "-display", "none" 456 | ] 457 | ``` 458 | 459 | 现在QEMU完全在后台运行且没有任何窗口会被打开。这不仅不那么烦人,还允许我们的测试框架在没有图形界面的环境里,诸如CI服务器或是[SSH]连接里运行。 460 | 461 | [SSH]: https://en.wikipedia.org/wiki/Secure_Shell 462 | 463 | ### 超时 464 | 465 | 由于 `cargo xtest` 会等待测试运行器退出,如果一个测试永远不返回那么它就会一直阻塞测试运行器。幸运的是,在实际应用中这并不是一个大问题,因为无限循环通常是很容易避免的。在我们的这个例子里,无限循环会发生在以下几种不同的情况中: 466 | 467 | - bootloader加载内核失败,导致系统不停重启; 468 | - BIOS/UEFI固件加载bootloader失败,同样会导致无限重启; 469 | - CPU在某些函数结束时进入一个`loop {}`语句,例如因为QEMU的exit设备无法正常工作而导致死循环; 470 | - 硬件触发了系统重置,例如未捕获CPU异常时(后续的文章将会详细解释)。 471 | 472 | 由于无限循环可能会在各种情况中发生,因此, `bootimage` 工具默认为每个可执行测试设置了一个长度为5分钟的超时时间。如果测试未在此时间内完成,则将其标记为失败,并向控制台输出"Timed Out(超时)"错误。这个功能确保了那些卡在无限循环里的测试不会一直阻塞`cargo xtest`。 473 | 474 | 你可以将`loop {}`语句添加到 `trivial_assertion`测试中来进行尝试。当你运行 `cargo xtest`时,你可以发现该测试会在五分钟后被标记为超时。超时持续的时间可以通过Cargo.toml中的`test-timeout`来进行[配置][bootimage config]: 475 | 476 | [bootimage config]: https://github.com/rust-osdev/bootimage#configuration 477 | 478 | ```toml 479 | # in Cargo.toml 480 | 481 | [package.metadata.bootimage] 482 | test-timeout = 300 # (in seconds) 483 | ``` 484 | 485 | 如果你不想为了观察`trivial_assertion` 测试超时等待5分钟之久,你可以暂时降低将上述值。 486 | 487 | 此后,我们不再需要 `trivial_assertion` 测试,所以我们可以将其删除。 488 | 489 | ## 测试VGA缓冲区 490 | 491 | 现在我们已经有了一个可以工作的测试框架了,我们可以为我们的VGA缓冲区实现创建一些测试。首先,我们创建了一个非常简单的测试来验证 `println`是否正常运行而不会panic: 492 | 493 | ```rust 494 | // in src/vga_buffer.rs 495 | 496 | #[cfg(test)] 497 | use crate::{serial_print, serial_println}; 498 | 499 | #[test_case] 500 | fn test_println_simple() { 501 | serial_print!("test_println... "); 502 | println!("test_println_simple output"); 503 | serial_println!("[ok]"); 504 | } 505 | ``` 506 | 507 | 这个测试所做的仅仅是将一些内容打印到VGA缓冲区。如果它正常结束并且没有panic,也就意味着`println`调用也没有panic。由于我们只需要将 `serial_println` 导入到测试模式里,所以我们添加了 `cfg(test)` 属性(attribute)来避免正常模式下 `cargo xbuild`会出现的未使用导入警告(unused import warning)。 508 | 509 | 为了确保即使打印很多行且有些行超出屏幕的情况下也没有panic发生,我们可以创建另一个测试: 510 | 511 | ```rust 512 | // in src/vga_buffer.rs 513 | 514 | #[test_case] 515 | fn test_println_many() { 516 | serial_print!("test_println_many... "); 517 | for _ in 0..200 { 518 | println!("test_println_many output"); 519 | } 520 | serial_println!("[ok]"); 521 | } 522 | ``` 523 | 524 | 我们还可以创建另一个测试函数,来验证打印的几行字符是否真的出现在了屏幕上: 525 | 526 | ```rust 527 | // in src/vga_buffer.rs 528 | 529 | #[test_case] 530 | fn test_println_output() { 531 | serial_print!("test_println_output... "); 532 | 533 | let s = "Some test string that fits on a single line"; 534 | println!("{}", s); 535 | for (i, c) in s.chars().enumerate() { 536 | let screen_char = WRITER.lock().buffer.chars[BUFFER_HEIGHT - 2][i].read(); 537 | assert_eq!(char::from(screen_char.ascii_character), c); 538 | } 539 | 540 | serial_println!("[ok]"); 541 | } 542 | ``` 543 | 544 | 该函数定义了一个测试字符串,并通过 `println`将其输出,然后遍历静态 `WRITER`也就是vga字符缓冲区的屏幕字符。由于`println`在将字符串打印到屏幕上最后一行后会立刻附加一个新行(即输出完后有一个换行符),所以这个字符串应该会出现在第 `BUFFER_HEIGHT - 2`行。 545 | 546 | 通过使用[`enumerate`] ,我们统计了变量`i`的迭代次数,然后用它来加载对应于`c`的屏幕字符。 通过比较屏幕字符的`ascii_character`和`c` ,我们可以确保字符串的每个字符确实出现在vga文本缓冲区中。 547 | 548 | [`enumerate`]: https://doc.rust-lang.org/core/iter/trait.Iterator.html#method.enumerate 549 | 550 | 如你所想,我们可以创建更多的测试函数:例如一个用来测试当打印一个很长的且包装正确的行时是否会发生panic的函数,或是一个用于测试换行符、不可打印字符、非unicode字符是否能被正确处理的函数。 551 | 552 | 在这篇文章的剩余部分,我们还会解释如何创建一个_集成测试_以测试不同组建之间的交互。 553 | 554 | 555 | ## 集成测试 556 | 557 | 在Rust中,**集成测试**([integration tests])的约定是将其放到项目根目录中的`tests`目录下(即`src`的同级目录)。无论是默认测试框架还是自定义测试框架都将自动获取并执行该目录下所有的测试。 558 | 559 | [integration tests]: https://doc.rust-lang.org/book/ch11-03-test-organization.html#integration-tests 560 | 561 | 所有的集成测试都是它们自己的可执行文件,并且与我们的`main.rs`完全独立。这也就意味着每个测试都需要定义它们自己的函数入口点。让我们创建一个名为`basic_boot`的例子来看看集成测试的工作细节吧: 562 | 563 | ```rust 564 | // in tests/basic_boot.rs 565 | 566 | #![no_std] 567 | #![no_main] 568 | #![feature(custom_test_frameworks)] 569 | #![test_runner(crate::test_runner)] 570 | #![reexport_test_harness_main = "test_main"] 571 | 572 | use core::panic::PanicInfo; 573 | 574 | #[no_mangle] // don't mangle the name of this function 575 | pub extern "C" fn _start() -> ! { 576 | test_main(); 577 | 578 | loop {} 579 | } 580 | 581 | fn test_runner(tests: &[&dyn Fn()]) { 582 | unimplemented!(); 583 | } 584 | 585 | #[panic_handler] 586 | fn panic(info: &PanicInfo) -> ! { 587 | loop {} 588 | } 589 | ``` 590 | 591 | 由于集成测试都是单独的可执行文件,所以我们需要再次提供所有的crate属性(`no_std`, `no_main`, `test_runner`, 等等)。我们还需要创建一个新的入口点函数`_start`,用于调用测试入口函数`test_main`。我们不需要任何的`cfg(test)` attributes(属性),因为集成测试的二进制文件在非测试模式下根本不会被编译构建。 592 | 593 | 这里我们采用[`unimplemented`]宏,充当`test_runner`暂未实现的占位符;添加简单的`loop {}`循环,作为`panic`处理器的内容。理想情况下,我们希望能向我们在`main.rs`里所做的一样使用`serial_println`宏和`exit_qemu`函数来实现这个函数。但问题是,由于这些测试的构建和我们的`main.rs`的可执行文件是完全独立的,我们没有办法使用这些函数。 594 | 595 | [`unimplemented`]: https://doc.rust-lang.org/core/macro.unimplemented.html 596 | 597 | 如果现阶段你运行`cargo xtest`,你将进入一个无限循环,因为目前panic的处理就是进入无限循环。你需要使用快捷键`Ctrl+c`,才可以退出QEMU。 598 | 599 | ### 创建一个库 600 | 601 | 为了让这些函数能在我们的集成测试中使用,我们需要从我们的`main.rs`中分割出一个库,这个库应当可以被其他的crate和集成测试可执行文件使用。为了达成这个目的,我们创建了一个新文件,`src/lib.rs`: 602 | 603 | ```rust 604 | // src/lib.rs 605 | 606 | #![no_std] 607 | ``` 608 | 609 | 和`main.rs`一样,`lib.rs`也是一个可以被cargo自动识别的特殊文件。该库是一个独立的编译单元,所以我们需要再次指定`#![no_std]` 属性。 610 | 611 | 为了让我们的库可以和`cargo xtest`一起协同工作,我们还需要添加以下测试函数和属性: 612 | 613 | ```rust 614 | // in src/lib.rs 615 | 616 | #![cfg_attr(test, no_main)] 617 | #![feature(custom_test_frameworks)] 618 | #![test_runner(crate::test_runner)] 619 | #![reexport_test_harness_main = "test_main"] 620 | 621 | use core::panic::PanicInfo; 622 | 623 | pub fn test_runner(tests: &[&dyn Fn()]) { 624 | serial_println!("Running {} tests", tests.len()); 625 | for test in tests { 626 | test(); 627 | } 628 | exit_qemu(QemuExitCode::Success); 629 | } 630 | 631 | pub fn test_panic_handler(info: &PanicInfo) -> ! { 632 | serial_println!("[failed]\n"); 633 | serial_println!("Error: {}\n", info); 634 | exit_qemu(QemuExitCode::Failed); 635 | loop {} 636 | } 637 | 638 | /// Entry point for `cargo xtest` 639 | #[cfg(test)] 640 | #[no_mangle] 641 | pub extern "C" fn _start() -> ! { 642 | test_main(); 643 | loop {} 644 | } 645 | 646 | #[cfg(test)] 647 | #[panic_handler] 648 | fn panic(info: &PanicInfo) -> ! { 649 | test_panic_handler(info) 650 | } 651 | ``` 652 | 653 | 为了能在可执行文件和集成测试中使用`test_runner`,我们不对其应用`cfg(test)` 属性,并将其设置为public。同时,我们还将panic的处理程序分解为public函数`test_panic_handler`,这样一来它也可以用于可执行文件了。 654 | 655 | 由于我们的`lib.rs`是独立于`main.rs`进行测试的,因此当该库实在测试模式下编译时我们需要添加一个`_start`入口点和一个panic处理程序。通过使用[`cfg_attr`] ,我们可以在这种情况下有条件地启用`no_main` 属性。 656 | 657 | [`cfg_attr`]: https://doc.rust-lang.org/reference/conditional-compilation.html#the-cfg_attr-attribute 658 | 659 | 我们还将`QemuExitCode`枚举和`exit_qemu`函数从main.rs移动过来,并将其设置为公有函数: 660 | 661 | ```rust 662 | // in src/lib.rs 663 | 664 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] 665 | #[repr(u32)] 666 | pub enum QemuExitCode { 667 | Success = 0x10, 668 | Failed = 0x11, 669 | } 670 | 671 | pub fn exit_qemu(exit_code: QemuExitCode) { 672 | use x86_64::instructions::port::Port; 673 | 674 | unsafe { 675 | let mut port = Port::new(0xf4); 676 | port.write(exit_code as u32); 677 | } 678 | } 679 | ``` 680 | 681 | 现在,可执行文件和集成测试都可以从库中导入这些函数,而不需要实现自己的定义。为了使`println` 和 `serial_println`可用,我们将以下的模块声明代码也移动到`lib.rs`中: 682 | 683 | ```rust 684 | // in src/lib.rs 685 | 686 | pub mod serial; 687 | pub mod vga_buffer; 688 | ``` 689 | 690 | 我们将这些模块设置为public(公有),这样一来我们在库的外部也一样能使用它们了。由于这两者都用了该模块内的`_print`函数,所以这也是让`println` 和 `serial_println`宏可用的必要条件。 691 | 692 | 现在我们修改我们的`main.rs`代码来使用该库: 693 | 694 | ```rust 695 | // src/main.rs 696 | 697 | #![no_std] 698 | #![no_main] 699 | #![feature(custom_test_frameworks)] 700 | #![test_runner(blog_os::test_runner)] 701 | #![reexport_test_harness_main = "test_main"] 702 | 703 | use core::panic::PanicInfo; 704 | use blog_os::println; 705 | 706 | #[no_mangle] 707 | pub extern "C" fn _start() -> ! { 708 | println!("Hello World{}", "!"); 709 | 710 | #[cfg(test)] 711 | test_main(); 712 | 713 | loop {} 714 | } 715 | 716 | /// This function is called on panic. 717 | #[cfg(not(test))] 718 | #[panic_handler] 719 | fn panic(info: &PanicInfo) -> ! { 720 | println!("{}", info); 721 | loop {} 722 | } 723 | 724 | #[cfg(test)] 725 | #[panic_handler] 726 | fn panic(info: &PanicInfo) -> ! { 727 | blog_os::test_panic_handler(info) 728 | } 729 | ``` 730 | 731 | 可以看到,这个库用起来就像一个普通的外部crate。它的调用方法与其它crate无异;在我们的这个例子中,位置可能为`blog_os`。上述代码使用了`test_runner`属性中的`blog_os::test_runner`函数,`cfg(test)`的panic处理中的`blog_os::test_panic_handler`函数。它还导入了`println`宏,这样一来,我们可以在我们的`_start`和`panic`中使用它了。 732 | 733 | 与此同时,`cargo xrun`和`cargo xtest`可以再次正常工作了。当然了,`cargo xtest`仍然会进入无限循环(你可以通过`ctrl+c`来退出)。接下来让我们在我们的集成测试中通过所需要的库函数来修复这个问题吧。 734 | 735 | ### 完成集成测试 736 | 737 | 就像我们的`src/main.rs`,我们的`tests/basic_boot.rs`可执行文件同样可以从我们的新库中导入类型。这也就意味着我们可以导入缺失的组件来完成我们的测试。 738 | 739 | ```rust 740 | // in tests/basic_boot.rs 741 | 742 | #![test_runner(blog_os::test_runner)] 743 | 744 | #[panic_handler] 745 | fn panic(info: &PanicInfo) -> ! { 746 | blog_os::test_panic_handler(info) 747 | } 748 | ``` 749 | 750 | 这里我们使用我们的库中的`test_runner`函数,而不是重新实现一个测试运行器。至于panic处理,调用`blog_os::test_panic_handler`函数即可,就像我们之前在我们的`main.rs`里面做的一样。 751 | 752 | 现在,`cargo xtest`又可以正常退出了。当你运行该命令时,你会发现它为我们的`lib.rs`, `main.rs`, 和 `basic_boot.rs`分别构建并运行了测试。其中,对于 `main.rs` 和 `basic_boot`的集成测试,它会报告"Running 0 tests"(正在执行0个测试),因为这些文件里面没有任何用 `#[test_case]`标注的函数。 753 | 754 | 现在我们可以在`basic_boot.rs`中添加测试了。举个例子,我们可以测试`println`是否能够正常工作而不panic,就像我们之前在vga缓冲区测试中做的那样: 755 | 756 | ```rust 757 | // in tests/basic_boot.rs 758 | 759 | use blog_os::{println, serial_print, serial_println}; 760 | 761 | #[test_case] 762 | fn test_println() { 763 | serial_print!("test_println... "); 764 | println!("test_println output"); 765 | serial_println!("[ok]"); 766 | } 767 | ``` 768 | 769 | 现在当我们运行`cargo xtest`时,我们可以看到它会寻找并执行这些测试函数。 770 | 771 | 由于该测试和vga缓冲区测试中的一个几乎完全相同,所以目前它看起来似乎没什么用。然而,在将来,我们的`main.rs`和`lib.rs`中的`_start`函数的内容会不断增长,并且在运行`test_main`之前需要调用一系列的初始化进程,所以这两个测试将会运行在完全不同的环境中(译者注:也就是说虽然现在看起来差不多,但是在将来该测试和vga buffer中的测试会很不一样,有必要单独拿出来,这两者并没有重复)。 772 | 773 | 通过在`basic_boot`环境里不掉用任何初始化例程的`_start`中测试`println`函数,我们可以确保`println`在启动(boot)后可以正常工作。这一点非常重要,因为我们有很多部分依赖于`println`,例如打印panic信息。 774 | 775 | ### 未来的测试 776 | 777 | 集成测试的强大之处在于,它们可以被看成是完全独立的可执行文件;这也给了它们完全控制环境的能力,使得他们能够测试代码和CPU或是其他硬件的交互是否正确。 778 | 779 | 我们的`basic_boot`测试正是集成测试的一个非常简单的例子。在将来,我们的内核的功能会变得更多,和硬件交互的方式也会变得多种多样。通过添加集成测试,我们可以保证这些交互按预期工作(并一直保持工作)。下面是一些对于未来的测试的设想: 780 | 781 | - **CPU异常**:当代码执行无效操作(例如除以零)时,CPU就会抛出异常。内核会为这些异常注册处理函数。集成测试可以验证在CPU异常时是否调用了正确的异常处理程序,或者在可解析的异常之后程序是否能正确执行; 782 | - **页表**:页表定义了哪些内存区域是有效且可访问的。通过修改页表,可以重新分配新的内存区域,例如,当你启动一个软件的时候。我们可以在集成测试中调整`_start`函数中的一些页表项,并确认这些改动是否会对`#[test_case]`的函数产生影响; 783 | - **用户空间程序**:用户空间程序是只能访问有限的系统资源的程序。例如,他们无法访问内核数据结构或是其他应用程序的内存。集成测试可以启动执行禁止操作的用户空间程序验证认内核是否会将这些操作全都阻止。 784 | 785 | 可以想象,还有更多的测试可以进行。通过添加各种各样的测试,我们确保在为我们的内核添加新功能或是重构代码时,不会意外地破坏他们。这一点在我们的内核变得更大和更复杂的时候显得尤为重要。 786 | 787 | ### 那些应该Panic的测试 788 | 789 | 标准库的测试框架支持允许构造失败测试的[`#[should_panic]` attribute][should_panic]。这个功能对于验证传递无效参数时函数是否会失败非常有用。不幸的是,这个属性需要标准库的支持,因此,在`#[no_std]`环境下无法使用。 790 | 791 | [should_panic]: https://doc.rust-lang.org/rust-by-example/testing/unit_testing.html#testing-panics 792 | 793 | 尽管我们不能在我们的内核中使用`#[should_panic]` 属性,但是通过创建一个集成测试我们可以达到类似的效果——该集成测试可以从panic处理程序中返回一个成功错误代码。接下来让我一起来创建一个如上所述名为`should_panic`的测试吧: 794 | 795 | ```rust 796 | // in tests/should_panic.rs 797 | 798 | #![no_std] 799 | #![no_main] 800 | 801 | use core::panic::PanicInfo; 802 | use blog_os::{QemuExitCode, exit_qemu, serial_println}; 803 | 804 | #[panic_handler] 805 | fn panic(_info: &PanicInfo) -> ! { 806 | serial_println!("[ok]"); 807 | exit_qemu(QemuExitCode::Success); 808 | loop {} 809 | } 810 | ``` 811 | 812 | 这个测试还没有完成,因为它尚未定义`_start`函数或是其他自定义的测试运行器属性。让我们来补充缺少的内容吧: 813 | 814 | 815 | ```rust 816 | // in tests/should_panic.rs 817 | 818 | #![feature(custom_test_frameworks)] 819 | #![test_runner(test_runner)] 820 | #![reexport_test_harness_main = "test_main"] 821 | 822 | #[no_mangle] 823 | pub extern "C" fn _start() -> ! { 824 | test_main(); 825 | 826 | loop {} 827 | } 828 | 829 | pub fn test_runner(tests: &[&dyn Fn()]) { 830 | serial_println!("Running {} tests", tests.len()); 831 | for test in tests { 832 | test(); 833 | serial_println!("[test did not panic]"); 834 | exit_qemu(QemuExitCode::Failed); 835 | } 836 | exit_qemu(QemuExitCode::Success); 837 | } 838 | ``` 839 | 840 | 这个测试定义了自己的`test_runner`函数,而不是复用`lib.rs`中的`test_runner`,该函数会在测试没有panic而是正常退出时返回一个错误退出代码(因为这里我们希望测试会panic)。如果没有定义测试函数,运行器就会以一个成功错误代码退出。由于这个运行器总是在执行完单个的测试后就退出,因此定义超过一个`#[test_case]`的函数都是没有意义的。 841 | 842 | 现在我们来创建一个应该失败的测试: 843 | 844 | ```rust 845 | // in tests/should_panic.rs 846 | 847 | use blog_os::serial_print; 848 | 849 | #[test_case] 850 | fn should_fail() { 851 | serial_print!("should_fail... "); 852 | assert_eq!(0, 1); 853 | } 854 | ``` 855 | 856 | 该测试用 `assert_eq`来断言(assert)`0`和`1`是否相等。毫无疑问,这当然会失败(`0`当然不等于`1`),所以我们的测试就会像我们想要的那样panic。 857 | 858 | 当我们通过`cargo xtest --test should_panic`运行该测试时,我们会发现成功了因为该测试如我们预期的那样panic了。当我们将断言部分(即`assert_eq!(0, 1);`)注释掉后,我们就会发现测试失败并返回了_"test did not panic"_的信息。 859 | 860 | 这种方法的缺点是它只使用于单个的测试函数。对于多个`#[test_case]`函数,它只会执行第一个函数因为程序无法在panic处理被调用后继续执行。我目前没有想到解决这个问题的方法,如果你有任何想法,请务必告诉我! 861 | 862 | ### 无约束测试 863 | 864 | 对于那些只有单个测试函数的集成测试而言(例如我们的`should_panic`测试),其实并不需要测试运行器。对于这种情况,我们可以完全禁用测试运行器,直接在`_start`函数中直接运行我们的测试。 865 | 866 | 这里的关键就是在`Cargo.toml`中为测试禁用 `harness` flag,这个标志(flag)定义了是否将测试运行器用于集成测试中。如果该标志位被设置为`false`,那么默认的测试运行器和自定义的测试运行器功能都将被禁用,这样一来该测试就可以像一个普通的可执行程序一样运行了。 867 | 868 | 现在让我们为我们的`should_panic`测试禁用`harness` flag吧: 869 | 870 | ```toml 871 | # in Cargo.toml 872 | 873 | [[test]] 874 | name = "should_panic" 875 | harness = false 876 | ``` 877 | 878 | 现在我们通过移除测试运行器相关的代码,大大简化了我们的`should_panic`测试。结果看起来如下: 879 | 880 | ```rust 881 | // in tests/should_panic.rs 882 | 883 | #![no_std] 884 | #![no_main] 885 | 886 | use core::panic::PanicInfo; 887 | use blog_os::{QemuExitCode, exit_qemu, serial_println}; 888 | 889 | #[no_mangle] 890 | pub extern "C" fn _start() -> ! { 891 | should_fail(); 892 | serial_println!("[test did not panic]"); 893 | exit_qemu(QemuExitCode::Failed); 894 | loop{} 895 | } 896 | 897 | fn should_fail() { 898 | serial_print!("should_fail... "); 899 | assert_eq!(0, 1); 900 | } 901 | 902 | #[panic_handler] 903 | fn panic(_info: &PanicInfo) -> ! { 904 | serial_println!("[ok]"); 905 | exit_qemu(QemuExitCode::Success); 906 | loop {} 907 | } 908 | ``` 909 | 910 | 现在我们可以通过我们的`_start`函数来直接调用`should_fail`函数了,如果返回则返回一个失败退出代码并退出。现在当我们执行`cargo xtest --test should_panic`时,我们可以发现测试的行为和之前完全一样。 911 | 912 | 除了创建`should_panic`测试,禁用`harness` attribute对复杂集成测试也很有用,例如,当单个测试函数会产生一些边际效应需要通过特定的顺序执行时。 913 | 914 | ## 总结 915 | 916 | 测试是一种非常有用的技术,它能确保特定的部件拥有我们期望的行为。即使它们不能显示是否有bug,它们仍然是用来寻找bug的利器,尤其是用来避免回归。 917 | 918 | 本文讲述了如何为我们的Rust kernel创建一个测试框架。我们使用Rust的自定义框架功能为我们的裸机环境实现了一个简单的`#[test_case]` attribute支持。通过使用QEMU的`isa-debug-exit`设备,我们的测试运行器可以在运行测试后退出QEMU并报告测试状态。我们还为串行端口实现了一个简单的驱动,使得错误信息可以被打印到控制台而不是VGA buffer中。 919 | 920 | 在为我们的`println`宏创建了一些测试后,我们在本文的后半部分还探索了集成测试。我们了解到它们位于`tests`目录中,并被视为完全独立的可执行文件。为了使他们能够使用`exit_qemu` 函数和 `serial_println` 宏,我们将大部分代码移动到一个库里,使其能够被导入到所有可执行文件和集成测试中。由于集成测试在各自独立的环境中运行,所以能够测试与硬件的交互或是创建应该panic的测试。 921 | 922 | 我们现在有了一个在QEMU内部真是环境中运行的测试框架。在未来的文章里,我们会创建更多的测试,从而让我们的内核在变得更复杂的同时保持可维护性。 923 | 924 | ## 下期预告 925 | 926 | 在下一篇文章中,我们将会探索 _CPU异常_ 。这些异常将在一些非法事件发生时由CPU抛出,例如抛出除以零或是访问没有映射的内存页(通常也被称为`page fault`即缺页异常)。能够捕获和检查这些异常,对将来的调试来说是非常重要的。异常处理与键盘支持所需的硬件中断处理十分相似。 927 | -------------------------------------------------------------------------------- /05-cpu-exceptions.md: -------------------------------------------------------------------------------- 1 | # CPU异常 2 | 3 | > 原文:[https://os.phil-opp.com/cpu-exceptions/](https://os.phil-opp.com/cpu-exceptions/) 4 | > 5 | > 原作者:@phil-opp 6 | > 7 | > 译者:[倪广野](https://github.com/niguangye) 8 | 9 | 触发CPU异常的情况多种多样,例如:访问非法内存地址或执行非法指令(除以零)等。为了应对CPU异常,我们需要建立中断描述符表(interrupt descriptor table),它列举了不同异常所对应的处理函数(handler functions)。在博文的最后,我们的内核(kernel)可以捕获断点异常([breakpoint exceptions](https://wiki.osdev.org/Exceptions#Breakpoint))并且恢复CPU的正常运行。 10 | 11 | [TOC] 12 | 13 | ## 概述 14 | 15 | 异常的发生标志着当前正在执行的指令出现了问题。例如:指令试图除以0的时候,CPU会抛出一个异常。当异常发生,CPU会中断(interrupt)它当前的流程,并立即调用该类型异常对应的处理函数。 16 | 17 | 在x86体系结构中,有大约20种不同的CPU 异常类型。常见的如下: 18 | 19 | - **缺页错误(Page Fault)**:缺页错误发生在非法的内存访问操作中。例如:当前指令试图访问没有映射的内存页或试图写入只读的内存页。 20 | 21 | - **非法操作码(Invalid Opcode)**:非法操作码发生在当前指令不正确的情况下。例如:试图在不支持 [SSE 指令集](https://en.wikipedia.org/wiki/Streaming_SIMD_Extensions) 的老旧CPU上使用该指令集。 22 | - **通用保护错误(General Protection Fault)**:这是一个触发原因相对宽泛的异常。试图在用户态程序中执行特权指令或试图写入配置寄存器的保留位等非法访问操作均会触发该异常。 23 | - **双重异常(Double Fault)**:异常发生后,CPU会调用对应的异常处理函数。在调用过程中如果发生另一个异常,CPU会触发双重异常。双重异常也会在找不到对应的异常处理函数的情况下发生。 24 | - **三重异常(Triple Fault)**:如果异常发生在CPU调用双重异常处理函数的过程中,这会导致严重的三重异常。我们不能捕获或者处理三重异常。大多数处理器会选择复位并重启操作系统。 25 | 26 | 你可以在[这里](https://wiki.osdev.org/Exceptions)找到所有的CPU异常列表。 27 | 28 | ### 中断描述符表(interrupt descriptor table) 29 | 30 | 为了捕获并处理CPU异常,我们需要建立所谓的中断描述符表(interrupt descriptor table,IDT)。在IDT中,我们可以为每种异常指定一个处理函数。硬件会直接使用这张表,所以我们需要遵循提前约定好的格式。IDT的每一项(entry)必须是16字节的结构: 31 | 32 | | Type | Name | Description | 33 | | ---- | ---------------- | ------------------------------------------------------------ | 34 | | u16 | 函数指针 [0:15] | 处理函数(handler function)指针的低16位 | 35 | | u16 | GDT 选择子 | [global descriptor table](https://en.wikipedia.org/wiki/Global_Descriptor_Table) 代码段的选择子 | 36 | | u16 | 选项参数 | 参见下文 | 37 | | u16 | 函数指针 [16:31] | 处理函数(handler function)指针的中间16位 | 38 | | u32 | 函数指针 [32:63] | 处理函数(handler function)指针剩下的32位 | 39 | | u32 | 保留位 | | 40 | 41 | 选项参数必须是下面的结构: 42 | 43 | | Bits | Name | Description | 44 | | ----- | -------------------- | ------------------------------------------------------------ | 45 | | 0-2 | 中断栈表索引 | 0: 不切换栈, 1-7:当处理函数被调用时,切换到中断栈表(Interrupt Stack Table)的第n个栈 | 46 | | 3-7 | 保留位 | | 47 | | 8 | 0: 中断门, 1: 陷阱门 | 如果这个bit被设置为0,处理函数被调用的时候,中断会被禁用。 | 48 | | 9-11 | 必须为1 | | 49 | | 12 | 必须为0 | | 50 | | 13‑14 | 特权等级描述符 (DPL) | 允许调用该处理函数的最小特权等级。 | 51 | | 15 | Present | | 52 | 53 | 每个异常都拥有提前约定好的IDT索引。例如:非法操作码的表索引是6,而缺页错误的的表索引是14。因此,硬件可以找到每种异常对应的中断描述符表的条目(interrupt descriptor table entry, IDT entry)。[OSDev wiki](https://wiki.osdev.org/Exceptions)页面的Exception Table的“Vector nr.”列展示了所有异常的IDT索引。 54 | 55 | 当异常发生时,CPU大致遵循下面的流程: 56 | 57 | 1. 将一些寄存器的内容压入栈中,包括当前指令的指针和[RFLAGS](http://en.wikipedia.org/wiki/FLAGS_register)寄存器的内容(我们会在文章的后续部分用到这些值)。 58 | 59 | 2. 读取中断描述符表(IDT)中对应的条目。例如:缺页错误发生时,CPU会读取IDT的第十四个条目。 60 | 3. 检查这个条目是否存在,如果没有则升级为双重错误(double fault)。 61 | 4. 如果条目是一个中断门(第40个bit没有被设置为1),则禁用硬件中断。 62 | 5. 装载指定的GDT 选择子到CS段。 63 | 6. 跳转到指定的处理函数。 64 | 65 | 现在不要担心第四、五步,我们会在未来的文章中研究GDT和硬件中断。 66 | 67 | ## 一个IDT类型(An IDT Type) 68 | 69 | 我们选择使用`x86_64` crate中的 `InterruptDescriptorTable` 结构体,而不是创建自己的 IDT 类型: 70 | 71 | ```rust 72 | #[repr(C)] 73 | pub struct InterruptDescriptorTable { 74 | pub divide_by_zero: Entry, 75 | pub debug: Entry, 76 | pub non_maskable_interrupt: Entry, 77 | pub breakpoint: Entry, 78 | pub overflow: Entry, 79 | pub bound_range_exceeded: Entry, 80 | pub invalid_opcode: Entry, 81 | pub device_not_available: Entry, 82 | pub double_fault: Entry, 83 | pub invalid_tss: Entry, 84 | pub segment_not_present: Entry, 85 | pub stack_segment_fault: Entry, 86 | pub general_protection_fault: Entry, 87 | pub page_fault: Entry, 88 | pub x87_floating_point: Entry, 89 | pub alignment_check: Entry, 90 | pub machine_check: Entry, 91 | pub simd_floating_point: Entry, 92 | pub virtualization: Entry, 93 | pub security_exception: Entry, 94 | // some fields omitted 95 | } 96 | ``` 97 | 98 | `InterruptDescriptorTable`结构体的字段都是[`idt::Entry`](https://docs.rs/x86_64/0.12.1/x86_64/structures/idt/struct.Entry.html)类型,这种类型是一种代表`IDT`条目字段的结构体(见上面的示例)。类型参数`F`定义了预期的处理函数类型。我们可以发现上面的条目字段需要 [`HandlerFunc `](https://docs.rs/x86_64/0.12.1/x86_64/structures/idt/type.HandlerFunc.html) 或 [`HandlerFuncWithErrCode `](https://docs.rs/x86_64/0.12.1/x86_64/structures/idt/type.HandlerFuncWithErrCode.html) 参数。缺页错误甚至拥有它独有的处理函数类型:[`PageFaultHandlerFunc `](https://docs.rs/x86_64/0.12.1/x86_64/structures/idt/type.PageFaultHandlerFunc.html) 。 99 | 100 | 首先,我们探讨一下 `HandlerFunc` 类型: 101 | 102 | ```rust 103 | type HandlerFunc = extern "x86-interrupt" fn(_: &mut InterruptStackFrame); 104 | ``` 105 | 106 | `HandlerFunc ` 是 `extern "x86-interrupt" fn` 的类型别名。`extern` 关键字定义了一个外部调用约定( [foreign calling convention](https://doc.rust-lang.org/nomicon/ffi.html#foreign-calling-conventions) ),它经常被用于链接C语言代码(`extern "C" fn`)。那么,`x86-interrupt`调用约定是什么呢? 107 | 108 | ## 中断调用约定( The Interrupt Calling Convention) 109 | 110 | CPU异常与函数调用非常相似:CPU跳转到调用函数的第一条指令并执行它。然后,CPU跳转到返回地址并继续执行函数的调用者函数(`parent function`)。 111 | 112 | 然而,异常和函数调用有一个重要的区别:函数调用是被编译器生成的 `call` 指令主动发起,而 113 | 114 | 异常可以发生在所有指令的执行过程中。为了理解这个区别的重要性,我们需要更进一步地研究函数调用。 115 | 116 | [调用约定 Calling conventions](https://en.wikipedia.org/wiki/Calling_convention) 明确规定了函数调用的细节。例如,它规定了函数参数的位置( 寄存器还是函数栈)和结果的返回方式。在x86_64 Linux体系中,C语言函数调用适用下面的规则(在[System V ABI](https://refspecs.linuxbase.org/elf/x86_64-abi-0.99.pdf)中规定): 117 | 118 | - 前六个整数参数会被放在寄存器中传递:`rdi`, `rsi`, `rdx`, `rcx`, `r8`, `r9` 119 | - 剩下的参数被放在栈中传递 120 | - 结果被放在 `rax` 和 `rdx` 中返回 121 | 122 | Rust 没有遵顼C ABI (事实上,Rust甚至没有规定的ABI),所以这些规则仅仅适用于声明了 `extern "C" fn` 的函数。 123 | 124 | ### Preserved and Scratch 寄存器 125 | 126 | 调用约定( `calling convention`)将寄存器分为两个部分: *preserved* 和 *scratch* 寄存器。 127 | 128 | 在函数调用的过程中,*preserved*寄存器的值必须保持不变。所以,被调用的函数(`callee`)必须保证会在返回以前会主动复原这些寄存器的原始值,才可以修改这些寄存器的值。因此,这些寄存器被称为被**调用者保存寄存器**(*callee-saved*,译者注:也就是AKA非易失性寄存器)。通行的模式是在函数的开始保存这些寄存器的值到函数栈中,并在函数马上返回的时候复原他们。 129 | 130 | 相比之下,被调用的函数(`callee`)可以无约束地修改 *scratch*寄存器。如果调用者函数希望在函数调用的过程中保留 *scratch*寄存器的值,它需要在调用函数之前备份和复原 *scratch*寄存器的值(例如将这些值压入栈中)。所以,这些寄存器被称为**调用者寄存器**(*caller-saved*,译者注:也就是AKA易失性寄存器)。 131 | 132 | 在x86_64架构中,C语言调用约定明确规定了下面的 preserved and scratch 寄存器: 133 | 134 | | preserved 寄存器 | scratch 寄存器 | 135 | | ----------------------------------------------- | ----------------------------------------------------------- | 136 | | `rbp`, `rbx`, `rsp`, `r12`, `r13`, `r14`, `r15` | `rax`, `rcx`, `rdx`, `rsi`, `rdi`, `r8`, `r9`, `r10`, `r11` | 137 | | *callee-saved* | *caller-saved* | 138 | 139 | 编译器遵顼这些规定生成二进制字节码。例如:绝大多数函数地字节码开始于`push rbp`指令,这个指令会备份`rbp`寄存器地值到函数栈中(因为这是一个`callee-saved`寄存器)。 140 | 141 | ### 保存所有寄存器 142 | 143 | 与函数调用形成鲜明对比的是,异常可以发生在所有指令的执行过程中。大多数情况下,我们甚至不能识别出编译器生成的代码是否会引起异常。例如,编译器不能预见到一个指令是否会引起栈溢出或缺页错误。 144 | 145 | 既然不能预见到异常的发生时机,我们自然也无法做到提前备份任何寄存器的值。这意味着我们不能使用依赖于 `caller-saved` 寄存器的调用约定去处理异常。然而,我们需要一个会保存所有寄存器值的调用约定。`x86-interrupt`调用约定恰恰能够保证所有寄存器会在函数调用结束以前复原到原始值。 146 | 147 | 这并不意味着所有寄存器的值会在函数开始时被保存到函数栈中。相反,编译器(生成的代码)只会备份被函数覆盖的寄存器的值。在这种方式下,较短的函数编译生成的二进制字节码会非常高效,也就是只使用尽可能少的寄存器。 148 | 149 | ### 中断栈帧( The Interrupt Stack Frame) 150 | 151 | 在寻常的函数调用(`call`指令执行)中,CPU跳转到相应的函数之前会将返回地址压入到函数栈中。在函数返回(`ret`指令执行)的时候,CPU会弹出并跳转到这个返回地址。所以,寻常的函数调用栈帧会如下图所示: 152 | 153 | ![function-stack-frame](https://markdown-ngy.oss-cn-beijing.aliyuncs.com/function-stack-frame.svg) 154 | 155 | 然而,异常和中断处理函数并不能将返回地址压入到函数栈中,因为中断处理函数往往运行在不同的上下文(栈指针,CPU flags等)中。相反,在异常发生的时候,CPU会执行以下步骤: 156 | 157 | 1. **对齐栈指针**:中断可以发生在任何指令的执行过程中,栈指针自然也可能是任何值。然而,一些CPU指令集(e.g. 一些 SSE指令集)需要栈指针在16字节边界上对齐,因此CPU会在中断之后靠右对齐栈指针。 158 | 2. **切换栈(在某种情况下)**:CPU特权等级发生改变的时候,栈会被切换,例如CPU 异常发生在用户态程序的时候。用所谓的中断栈表( *Interrupt Stack Table* , 下篇文章解释 )配置特定中断的栈切换也是可行的。 159 | 3. **压入原来的栈指针**:在中断发生的时候(对齐栈指针发生之前),CPU将栈指针(`rsp`)和栈段(`ss`)寄存器压入栈中。如此一来,中断处理函数返回时就可以复原栈指针的原始值。 160 | 4. **压入并更新`RFLAGS`寄存器**:[`RFLAGS`](https://en.wikipedia.org/wiki/FLAGS_register)寄存器保存了多种控制和状态位。进入中断函数时,CPU修改一些位并压入旧的值。 161 | 5. **压入指令指针**:跳转到中断处理函数之前,CPU压入指令指针(`rip`)和代码段(`cs`)。这类似于寻常的函数调用压入返回地址的过程。 162 | 6. **压入错误码(对于部分异常)**:对于缺页错误等特定的异常,CPU会压入解释异常原因的错误码。 163 | 7. **调用中断处理函数**:CPU从IDT对应的字段中读取中断处理函数的地址和段描述符。然后通过加载这些值到`rip`和`cs`寄存器中,调用中断处理函数。 164 | 165 | 所以,中断调用栈帧会如下图所示: 166 | 167 | ![exception-stack-frame](https://markdown-ngy.oss-cn-beijing.aliyuncs.com/exception-stack-frame.svg) 168 | 169 | 在Rust的`x86_64`库中,中断调用栈帧被抽象为[`InterruptStackFrame`](https://docs.rs/x86_64/0.12.1/x86_64/structures/idt/struct.InterruptStackFrame.html)结构体。它会被作为`&mut`传递给中断处理函数,并被用来获取更多的关于异常原因的信息。由于只有小部分异常会压入错误码,所以[`InterruptStackFrame`](https://docs.rs/x86_64/0.12.1/x86_64/structures/idt/struct.InterruptStackFrame.html)并没有设置`error_code`字段。这些异常会另外使用[`HandlerFuncWithErrCode`](https://docs.rs/x86_64/0.12.1/x86_64/structures/idt/type.HandlerFuncWithErrCode.html)函数来处理,这个函数有一个`error_code`参数用来保存错误码。 170 | 171 | ### 幕后工作 172 | 173 | `x86-interrupt`调用约定作为一个优秀的抽象,它几乎隐藏了异常处理过程中所有繁杂的细节。然而,理解幕布后的工作在某些时候是有益的。下面简要概述了`x86-interrupt`调用约定所处理的事情: 174 | 175 | - **抽取参数**:大多数调用约定希望参数被放在寄存器中传递。这对于异常处理函数是不可能的,因为我们不能在保存寄存器的值之前覆盖这些寄存器。然而,`x86-interrupt`调用约定明白这些参数早就被放在栈的某个位置上了。 176 | - **使用`iretq`返回**:既然中断栈帧和寻常函数调用的栈帧是不同的,我们不能使用`ret`指令从中断处理函数中返回。但是可以使用`iretq`指令。 177 | - **处理错误码**:部分特定异常压入的错误码是事情变得更加复杂。它改变了栈对齐(见对齐栈部分)并且需要在返回之前从栈中弹出。`x86-interrupt`调用约定处理了所有难题。但是,它无法获得每种异常对应的处理函数,所以,它需要从函数的参数中推断这些信息。这意味着,程序员有责任使用正确的函数类型处理每种异常。幸运的是,`x86_64`库的`InterruptDescriptorTable`可以确保这一过程不会出错。 178 | - **对齐栈**:一些指令集(尤其是SSE指令集)使用16字节的栈对齐。在异常发生的时候,CPU会确保栈对齐。但是在压入错误码后,栈对齐会再次被破坏。`x86-interrupt`调用约定会通过再次对齐栈解决这个问题。 179 | 180 | 如果你对更多的细节感兴趣:我们也有一系列文章解释了如何使用 [naked functions](https://github.com/rust-lang/rfcs/blob/master/text/1201-naked-fns.md)处理异常。 181 | 182 | ## 实现 183 | 184 | 现在我们理解了这些理论,是时候在我们的内核中实现CPU异常处理了。我们首先在`src/interrupts.rs`中创建一个新的interrupts 模块,其中`init_idt`函数创建了一个新的`InterruptDescriptorTable`: 185 | 186 | ```rust 187 | // in src/lib.rs 188 | 189 | pub mod interrupts; 190 | 191 | // in src/interrupts.rs 192 | 193 | use x86_64::structures::idt::InterruptDescriptorTable; 194 | 195 | pub fn init_idt() { 196 | let mut idt = InterruptDescriptorTable::new(); 197 | } 198 | ``` 199 | 200 | 现在我们可以增加更多的处理函数。我们首先创建断点异常([breakpoint exception](https://wiki.osdev.org/Exceptions#Breakpoint))的处理函数。断点异常是一个绝佳的测试处理异常过程的示例。它唯一的用途是在断点指令`int3`执行的时候暂停整个程序。 201 | 202 | 断点异常通常被用于调试程序(debugger):当用户设置了断点,调试程序会使用`int3`指令覆盖对应位置的指令,当CPU执行到这一位置的时候会抛出断点异常。当用户希望继续执行程序时,调试程序将`int3`指令替换回原来的指令并继续执行。可以从 ["*How debuggers work*"](https://eli.thegreenplace.net/2011/01/27/how-debuggers-work-part-2-breakpoints) 系列获取更多的细节。 203 | 204 | 我们无需覆盖任何指令。因为我们只希望程序在异常指令执行的时候打印一条消息,然后继续执行。让我们创建一个简单的断点异常处理函数(breakpoint_handler)并添加到IDT: 205 | 206 | ```rust 207 | // in src/interrupts.rs 208 | 209 | use x86_64::structures::idt::{InterruptDescriptorTable, InterruptStackFrame}; 210 | use crate::println; 211 | 212 | pub fn init_idt() { 213 | let mut idt = InterruptDescriptorTable::new(); 214 | idt.breakpoint.set_handler_fn(breakpoint_handler); 215 | } 216 | 217 | extern "x86-interrupt" fn breakpoint_handler( 218 | stack_frame: &mut InterruptStackFrame) 219 | { 220 | println!("EXCEPTION: BREAKPOINT\n{:#?}", stack_frame); 221 | } 222 | ``` 223 | 224 | 处理函数只输出了一条消息并美观的打印了中断栈帧。 225 | 226 | 当我们试图编译程序的时候,错误出现了: 227 | 228 | ```rust 229 | error[E0658]: x86-interrupt ABI is experimental and subject to change (see issue #40180) 230 | --> src/main.rs:53:1 231 | | 232 | 53 | / extern "x86-interrupt" fn breakpoint_handler(stack_frame: &mut InterruptStackFrame) { 233 | 54 | | println!("EXCEPTION: BREAKPOINT\n{:#?}", stack_frame); 234 | 55 | | } 235 | | |_^ 236 | | 237 | = help: add #![feature(abi_x86_interrupt)] to the crate attributes to enable 238 | ``` 239 | 240 | 这个错误是因为 `x86-interrupt` 中断调用约定仍然不稳定。我们需要明确地在`lib.rs`顶部增加`#![feature(abi_x86_interrupt)]`去激活它。 241 | 242 | ### 加载IDT 243 | 244 | 为了让CPU使用我们新的中断描述符表,我们需要使用 [`lidt`](https://www.felixcloutier.com/x86/lgdt:lidt) 指令去加载它。`x86_64` 库的`InterruptDescriptorTable` 结构体提供了一个 `load` 方法去实现这个操作: 245 | 246 | ```rust 247 | // in src/interrupts.rs 248 | 249 | pub fn init_idt() { 250 | let mut idt = InterruptDescriptorTable::new(); 251 | idt.breakpoint.set_handler_fn(breakpoint_handler); 252 | idt.load(); 253 | } 254 | ``` 255 | 256 | 当我们试图编译程序的时候,下面的错误出现了: 257 | 258 | ```rust 259 | error: `idt` does not live long enough 260 | --> src/interrupts/mod.rs:43:5 261 | | 262 | 43 | idt.load(); 263 | | ^^^ does not live long enough 264 | 44 | } 265 | | - borrowed value only lives until here 266 | | 267 | = note: borrowed value must be valid for the static lifetime... 268 | ``` 269 | 270 | `load` 方法期望一个 `&'static self`,以确保 `idt` 引用在整个程序生命周期中可用。因为CPU会在每个异常发生的时候访问这张表,直到我们加载了其它的`InterruptDescriptorTable`对象。所以,使用比 `'static` 短的生命周期会导致 use-after-free bug。 271 | 272 | 事实上,情况很明白。我们的 `idt` 在栈上创建,所以它只在 `init` 函数的生命周期中有效。一旦这个栈内存被其它函数重用,CPU会把随机的栈内存当作IDT。幸运的是, `InterruptDescriptorTable::load` 在函数定义中明确要求了必要的生命周期条件(译者注:也就是必须使用 `'static` 生命周期)。所以,Rust 编译器可以在编译期就阻止这个潜在的 bug 。 273 | 274 | 为了解决这个问题,我们需要保存我们的 `idt` 对象到拥有 `'static` 生命周期的地方。我们可以使用 `Box` 把 IDT 分配到堆上,并转换为 `'static` 引用,但是我们是在开发操作系统内核,所以并不会有堆这个概念。 275 | 276 | 作为代替,我们可以把 IDT 保存为 常量(`static`): 277 | 278 | ``` 279 | static IDT: InterruptDescriptorTable = InterruptDescriptorTable::new(); 280 | 281 | pub fn init_idt() { 282 | IDT.breakpoint.set_handler_fn(breakpoint_handler); 283 | IDT.load(); 284 | } 285 | ``` 286 | 287 | 但是,这有一个问题:常量是不可变的,所以我们不能修改来自 `init` 函数的IDT中的断点条目。我们可以使用 [`static mut`](https://doc.rust-lang.org/1.30.0/book/second-edition/ch19-01-unsafe-rust.html#accessing-or-modifying-a-mutable-static-variable) 解决这个问题: 288 | 289 | ```rust 290 | static mut IDT: InterruptDescriptorTable = InterruptDescriptorTable::new(); 291 | 292 | pub fn init_idt() { 293 | unsafe { 294 | IDT.breakpoint.set_handler_fn(breakpoint_handler); 295 | IDT.load(); 296 | } 297 | } 298 | ``` 299 | 300 | 这种变体不会出现编译错误,但是并不符合优雅的编程风格。 `static mut` 非常容易造成数据竞争,所以在每一次访问中都需要使用 [`unsafe`](https://doc.rust-lang.org/1.30.0/book/second-edition/ch19-01-unsafe-rust.html#unsafe-superpowers) 代码块。 301 | 302 | #### 懒加载常量 303 | 304 | `lazy_static` 宏的存在令人庆幸。它可以让常量在被第一次使用的时候被初始化,而不是在编译期。因此,我们可以在初始化代码块中做几乎所有的事情,甚至读取常量在运行时的值。 305 | 306 | 我们已经在 [created an abstraction for the VGA text buffer](https://os.phil-opp.com/vga-text-mode/#lazy-statics) 引用了 `lazy_static` 库。所以我们可以直接使用 `lazy_static!` 宏去创建静态的IDT: 307 | 308 | ``` 309 | // in src/interrupts.rs 310 | 311 | use lazy_static::lazy_static; 312 | 313 | lazy_static! { 314 | static ref IDT: InterruptDescriptorTable = { 315 | let mut idt = InterruptDescriptorTable::new(); 316 | idt.breakpoint.set_handler_fn(breakpoint_handler); 317 | idt 318 | }; 319 | } 320 | 321 | pub fn init_idt() { 322 | IDT.load(); 323 | } 324 | ``` 325 | 326 | 这种方法不需要 [`unsafe`](https://doc.rust-lang.org/1.30.0/book/second-edition/ch19-01-unsafe-rust.html#unsafe-superpowers) 代码块,因为 `lazy_static!` 宏在底层使用了 [`unsafe`](https://doc.rust-lang.org/1.30.0/book/second-edition/ch19-01-unsafe-rust.html#unsafe-superpowers) 代码块,但是抽象出了一个安全接口。 327 | 328 | ### 运行 329 | 330 | 让内核种的异常处理工作的最后一步是在 `main.rs` 中调用 `init_idt` 函数。在 `lib.rs` 中抽象一个总体的 `init` 函数而不是直接调用: 331 | 332 | ``` 333 | // in src/lib.rs 334 | 335 | pub fn init() { 336 | interrupts::init_idt(); 337 | } 338 | ``` 339 | 340 | 这个函数用来放置可以被 `main.rs` , `lib.rs`中的 `_start` 函数和集成测试所共享的初始化代码。 341 | 342 | 在 `main.rs` 中的 `_start` 函数中调用 `init` 函数,并触发一个断点异常。 343 | 344 | ``` 345 | // in src/main.rs 346 | 347 | #[no_mangle] 348 | pub extern "C" fn _start() -> ! { 349 | println!("Hello World{}", "!"); 350 | 351 | blog_os::init(); // new 352 | 353 | // invoke a breakpoint exception 354 | x86_64::instructions::interrupts::int3(); // new 355 | 356 | // as before 357 | #[cfg(test)] 358 | test_main(); 359 | 360 | println!("It did not crash!"); 361 | loop {} 362 | } 363 | ``` 364 | 365 | 使用 `cargo run` 命令在QEMU中运行程序: 366 | 367 | ![qemu-breakpoint-exception](https://markdown-ngy.oss-cn-beijing.aliyuncs.com/qemu-breakpoint-exception.png) 368 | 369 | CPU成功调用了断点异常处理函数,并打印了一些消息,然后返回 `_start` 函数继续打印了 `It did not crash!` 消息。 370 | 371 | 可以发现,中断栈帧显示了中断发生时的指令和栈指针地址。这有助于调试不该发生的异常。 372 | 373 | ### 增加Test 374 | 375 | 增加一个确认上文中CPU继续工作的测试。首先,让 `lib.rs` 的 `_start` 函数同样调用 `init` 函数: 376 | 377 | ``` 378 | // in src/lib.rs 379 | 380 | /// Entry point for `cargo test` 381 | #[cfg(test)] 382 | #[no_mangle] 383 | pub extern "C" fn _start() -> ! { 384 | init(); // new 385 | test_main(); 386 | loop {} 387 | } 388 | ``` 389 | 390 | 既然 `lib.rs` 中的测试完全独立于 `main.rs` ,必须使用命令 `cargo test --lib` 来指定运行 `lib.rs` 中的 `_start` 函数。在`lib.rs` 中的测试运行以前,我们需要调用 `init` 函数去建立IDT。 391 | 392 | 现在,创建一个 `test_breakpoint_exception` 测试: 393 | 394 | ``` 395 | // in src/interrupts.rs 396 | 397 | #[test_case] 398 | fn test_breakpoint_exception() { 399 | // invoke a breakpoint exception 400 | x86_64::instructions::interrupts::int3(); 401 | } 402 | ``` 403 | 404 | 这个测试调用了 `x86_64` 库的 `int3` 函数去触发断点异常。通过检查异常处理后程序继续执行,可以验证断点异常处理函数正常工作。 405 | 406 | 使用 `cargo test` (所有测试)或 `cargo test --lib`(只限于 `lib.rs` 和它的子模块中的测试)命令运行新的测试,应当可以看见: 407 | 408 | ``` 409 | blog_os::interrupts::test_breakpoint_exception... [ok] 410 | ``` 411 | 412 | ## 过于抽象?( Too much Magic?) 413 | 414 | `x86-interrupt` 调用约定和 [`InterruptDescriptorTable`](https://docs.rs/x86_64/0.12.1/x86_64/structures/idt/struct.InterruptDescriptorTable.html) 让异常处理流程变得相当简单愉快。如果你觉得太过抽象或有兴趣学习异常处理更硬核的细节,[“Handling Exceptions with Naked Functions”](https://os.phil-opp.com/first-edition/extra/naked-exceptions/) 系列会告诉你如何在不使用 `x86-interrupt` 调用约定的情况下处理异常并建立自己的IDT类型。在 `x86-interrupt` 调用约定和 `x86_64` 库问世以前,这个系列可以说是最主流的异常处理主体相关的博客。不得不提的是,这些文章基于第一版本的 [Writing an OS in Rust ](https://os.phil-opp.com/first-edition/),所以可能会有些过时。 415 | 416 | ## 接下来? 417 | 418 | 我们成功地触发了第一个异常并从中返回!下一步是确保可以捕获所有异常,因为未被捕获地异常会引发严重的 [triple fault](https://wiki.osdev.org/Triple_Fault) ,继而导致系统复位。下一篇文章解释了如何通过捕获 [双重异常 double faults](https://wiki.osdev.org/Double_Fault#Double_Fault) 来避免这些问题。 419 | 420 | -------------------------------------------------------------------------------- /06-double-fault-exceptions.md: -------------------------------------------------------------------------------- 1 | # 双重异常 2 | 3 | > 原文:[https://os.phil-opp.com/double-fault-exceptions/](https://os.phil-opp.com/double-fault-exceptions/) 4 | > 5 | > 原作者:@phil-opp 6 | > 7 | > 译者:[倪广野](https://github.com/niguangye) 8 | 9 | 这篇文章将深入探究双重异常(*double fault*),这是一个在CPU调用异常处理函数失败的时候触发的异常。通过处理双重异常,可以避免会引起系统复位的三重异常。为了彻底防止各种情况下的三重异常,需要建立中断栈表( *Interrupt Stack Table* )去捕获所有不同内核栈的双重异常。 10 | 11 | 这个博客在 [GitHub](https://github.com/phil-opp/blog_os) 上开源。如果你遇到问题或困难,请到那里提 issue 。或者你也可以在博客的最下方留言。你可以在 [`post-06`](https://github.com/phil-opp/blog_os/tree/post-06) 分支找到这篇文章的完整源码。 12 | 13 | > 译注:中文版请移步[《编写 Rust 语言的操作系统》](https://github.com/rustcc/writing-an-os-in-rust) 14 | 15 | ## 双重异常的定义 16 | 17 | 简单点说,双重异常就是一个在CPU调用异常处理函数失败的时候触发的特定异常。例如,CPU触发缺页异常(*page fault*),但是中断描述符表( *[Interrupt Descriptor Table](https://os.phil-opp.com/cpu-exceptions/#the-interrupt-descriptor-table)* ,*IDT*)中却没有对应处理函数的情况。所以,这和编程语言中捕获所有异常的代码块(*catch-all blocks*)有些相似,例如 C++ 中的 `catch(...)` 或 Java和 C# 中的 `catch(Exception e)` 。 18 | 19 | 双重异常的表现和普通异常区别不大。它拥有一个特定的向量号(*Interrupt Vector Number*) `8` ,我们可以在 *IDT* 中定义一个对应的处理函数。定义双重异常的处理函数十分重要,因为双重异常在不被处理的情况下会引发致命的三重异常。三重异常不能被捕获,而且会引起大多数硬件的系统复位。 20 | 21 | ### 触发一个双重异常 22 | 23 | 让我们通过触发一个没有处理函数的普通异常来引发双重异常: 24 | 25 | ```rust 26 | // in src/main.rs 27 | 28 | #[no_mangle] 29 | pub extern "C" fn _start() -> ! { 30 | println!("Hello World{}", "!"); 31 | 32 | blog_os::init(); 33 | 34 | // trigger a page fault 35 | unsafe { 36 | *(0xdeadbeef as *mut u64) = 42; 37 | }; 38 | 39 | // as before 40 | #[cfg(test)] 41 | test_main(); 42 | 43 | println!("It did not crash!"); 44 | loop {} 45 | } 46 | ``` 47 | 48 | 我们使用 `unsafe` 去写入非法的内存地址 `0xdeadbeef` 。这个虚拟地址没有在页表中被映射到物理地址,这会触发一个缺页异常。而缺页异常的处理函数还没有被定义到 [IDT](https://os.phil-opp.com/cpu-exceptions/#the-interrupt-descriptor-table) ,因此双重异常被触发了。 49 | 50 | 现在启动内核,它会进入到无穷尽的启动循环。原因如下: 51 | 52 | 1. *CPU* 试图写入非法的内存地址 `0xdeadbeef` ,这会触发缺页异常。 53 | 2. *CPU* 查找到 *IDT* 中缺页异常对应的条目,并且没有发现对应的处理函数。因为它不能正常调用缺页异常的处理函数,所以触发了双重异常。 54 | 3. *CPU* 查找到 *IDT* 中双重异常对应的条目,并且也没有发现对应的处理函数。因此,三重异常被触发。 55 | 4. 三重异常是致命的。*QEMU* 像大多数的硬件一样选择系统复位。 56 | 57 | 所以为了阻止三重异常,我们需要提供缺页异常或双重异常的处理函数。我们希望阻止所有情况下的三重异常,因此我们选择建立所有异常未被处理时都会调用的双重异常处理函数。 58 | 59 | ## 双重异常处理函数 60 | 61 | 双重异常由普通异常和错误码组成,所以我们可以像断点异常处理函数那样定义一个双重异常处理函数。 62 | 63 | ```rust 64 | // in src/interrupts.rs 65 | 66 | lazy_static! { 67 | static ref IDT: InterruptDescriptorTable = { 68 | let mut idt = InterruptDescriptorTable::new(); 69 | idt.breakpoint.set_handler_fn(breakpoint_handler); 70 | idt.double_fault.set_handler_fn(double_fault_handler); // new 71 | idt 72 | }; 73 | } 74 | 75 | // new 76 | extern "x86-interrupt" fn double_fault_handler( 77 | stack_frame: &mut InterruptStackFrame, _error_code: u64) -> ! 78 | { 79 | panic!("EXCEPTION: DOUBLE FAULT\n{:#?}", stack_frame); 80 | } 81 | ``` 82 | 83 | 双重异常处理函数打印了一个简短的错误消息和异常栈帧信息。双重异常的错误码通常会是0,所以没有必要打印出来。双重异常处理函数和断点异常处理函数的区别在于,它是一个发散函数( [*diverging*](https://doc.rust-lang.org/stable/rust-by-example/fn/diverging.html))。因为 `x86_64` 体系架构不允许从双重异常中返回。 84 | 85 | 现在启动内核,我们可以看见双重异常处理函数被调用了: 86 | 87 | ![qemu-catch-double-fault](https://markdown-ngy.oss-cn-beijing.aliyuncs.com/qemu-catch-double-fault.png) 88 | 89 | 工作正常!这次发生了什么: 90 | 91 | 1. *CPU* 试图写入非法的内存地址 `0xdeadbeef` ,这会触发缺页异常。 92 | 2. 像上次一样,*CPU* 查找到 *IDT* 中缺页异常对应的条目,并且没有发现对应的处理函数。因为它不能正常调用缺页异常的处理函数,所以触发了双重异常。 93 | 3. *CPU* 跳转到双重异常处理函数——它现在是就绪的了。 94 | 95 | 因为 *CPU* 现在可以正常调用双重异常处理函数,所以三重异常(和启动循环)不会再次出现。 96 | 97 | 这非常容易理解!那么我们为什么需要用整篇文章讨论这个话题? 我们现在可以捕获大多数双重异常,但是在某些情况下,现在的方式并不足够有效。 98 | 99 | 100 | 101 | ## 双重异常的触发原因 102 | 103 | 在探究某个特定的原因之前,我们需要理解双重异常的确切定义。上文中,我们给出了相当粗略的定义: 104 | 105 | > 双重异常就是一个在CPU调用异常处理函数失败的时候触发的特定异常。 106 | 107 | “调用异常处理函数失败”的准确含义是什么? 处理函数不可用? 处理函数被换出( [swapped out](http://pages.cs.wisc.edu/~remzi/OSTEP/vm-beyondphys.pdf))? 并且如果处理函数自身触发了异常会发生什么? 108 | 109 | 例如,下列情况会发生什么: 110 | 111 | 1. 断点异常触发,但是对应的处理函数被换出? 112 | 2. 缺页异常触发,但是缺页异常处理函数被换出? 113 | 3. 除0异常引发了断点异常,但是断点异常处理函数被换出? 114 | 4. 内核栈溢出,同时保护页( *guard page*)被命中(*hit*)? 115 | 116 | 幸运的是,AMD64手册(([PDF](https://www.amd.com/system/files/TechDocs/24593.pdf))给出了明确定义(8.2.9章节)。根据手册的定义,“当第二个异常出现在先前的(第一个)异常处理函数执行期间,双重异常**可能**会被触发”。“**可能**”二字说明:只有特定的异常组合才会导致双重异常。这些组合是: 117 | 118 | | 第一个异常 | 第二个异常 | 119 | | ------------------------------------------------------------ | ------------------------------------------------------------ | 120 | | [Divide-by-zero,除0](https://wiki.osdev.org/Exceptions#Divide-by-zero_Error),
[Invalid TSS,非法任务状态段](https://wiki.osdev.org/Exceptions#Invalid_TSS),
[Segment Not Present,段不存在](https://wiki.osdev.org/Exceptions#Segment_Not_Present),
[Stack-Segment Fault,栈段错误](https://wiki.osdev.org/Exceptions#Stack-Segment_Fault),
[General Protection Fault,一般保护错误](https://wiki.osdev.org/Exceptions#General_Protection_Fault) | [Invalid TSS,非法任务状态段](https://wiki.osdev.org/Exceptions#Invalid_TSS),
[Segment Not Present,段不存在](https://wiki.osdev.org/Exceptions#Segment_Not_Present),
[Stack-Segment Fault,栈段错误](https://wiki.osdev.org/Exceptions#Stack-Segment_Fault),
[General Protection Fault,一般保护错误](https://wiki.osdev.org/Exceptions#General_Protection_Fault) | 121 | | [Page Fault,缺页异常](https://wiki.osdev.org/Exceptions#Page_Fault) | [Page Fault,缺页异常](https://wiki.osdev.org/Exceptions#Page_Fault),
[Invalid TSS,非法任务状态段](https://wiki.osdev.org/Exceptions#Invalid_TSS),
[Segment Not Present,段不存在](https://wiki.osdev.org/Exceptions#Segment_Not_Present),
[Stack-Segment Fault,栈段错误](https://wiki.osdev.org/Exceptions#Stack-Segment_Fault),
[General Protection Fault,一般保护错误](https://wiki.osdev.org/Exceptions#General_Protection_Fault) | 122 | 123 | 所以缺页异常紧跟除0异常不会触发双重异常(缺页异常处理函数被调用),但是一般保护错误紧跟除0异常一定会触发双重异常。 124 | 125 | 参考这张表格,可以得到上述前三个问题的答案: 126 | 127 | 1. 断点异常触发,但是对应的处理函数被换出,缺页异常会被触发,然后调用缺页异常处理函数。 128 | 2. 缺页异常触发,但是缺页异常处理函数被换出,双重异常会被触发,然后调用双重异常处理函数。 129 | 3. 除0异常引发了断点异常,CPU试图调用断点异常处理函数。如果断点异常处理函数被换出,缺页异常会被触发,然后调用缺页异常处理函数。 130 | 131 | 实际上,异常在 *IDT* 中没有对应的处理函数时会遵顼以下方案: 132 | 133 | 当异常发生时,*CPU* 试图读取对应的 *IDT* 条目。如果条目是0,说明这不是一个合法的 *IDT* 条目,一般保护错误会被触发。我们没有定义一般保护错误的处理函数,所以另一个一般保护错误被触发。根据上表,这会导致双重异常。 134 | 135 | ### 内核栈溢出 136 | 137 | 让我们开始探究第四个问题: 138 | 139 | > 内核栈溢出,同时保护页( *guard page*)被命中(*hit*)? 140 | 141 | 保护页是存在栈底的特定内存页,它被用来发现栈溢出。保护页没有映射到任何物理内存页,所以访问它会导致缺页异常而不是无声无息地损坏其它内存。引导程序(*bootloader*)为内核栈建立了保护页,所以内核栈溢出会触发缺页异常。 142 | 143 | 当缺页异常发生,CPU 查找 IDT 中地缺页异常处理函数并将中断栈帧( [interrupt stack frame](https://os.phil-opp.com/cpu-exceptions/#the-interrupt-stack-frame))压入内核栈。然而,当前栈指针依然指向不可用地保护页。因此,第二个缺页异常被触发了,这会引发双重异常(根据上表)。 144 | 145 | CPU 试图调用双重异常处理函数,它当然会试图压入异常栈帧。此时栈指针依然会指向保护页(因为栈溢出了),所以第三个缺页异常被触发了,紧接着三重异常和系统复位也发生了。当前的双重异常处理函数无法阻止这种情形下的三重异常。 146 | 147 | 让我们复现这个情形吧!通过调用无穷的递归函数可以轻易引发内核栈溢出: 148 | 149 | ```rust 150 | // in src/main.rs 151 | 152 | #[no_mangle] // don't mangle the name of this function 153 | pub extern "C" fn _start() -> ! { 154 | println!("Hello World{}", "!"); 155 | 156 | blog_os::init(); 157 | 158 | fn stack_overflow() { 159 | stack_overflow(); // for each recursion, the return address is pushed 160 | } 161 | 162 | // trigger a stack overflow 163 | stack_overflow(); 164 | 165 | […] // test_main(), println(…), and loop {} 166 | } 167 | ``` 168 | 169 | 在QEMU中执行程序的时候,操作系统再次进入无限重启的情况: 170 | 171 | 如何阻止这个问题? 由于压入异常栈帧是CPU硬件的操作,所以我们不能干扰这一步。我们只能以某种方式让内核栈在双重异常触发的时候保持可用(不会溢出)。幸运的是,`x86_64` 架构提供了这个问题的解决方式。 172 | 173 | ## 切换栈 174 | 175 | `x86_64` 架构可以在异常发生时切换到预定义且已知良好的栈中。这个切换发生在硬件级别,所以它可以在*CPU*压入异常栈帧之前完成。 176 | 177 | 切换机制基于中断栈表( *Interrupt Stack Table* ,*IST*)。*IST*由7个指向已知良好的栈的指针组成。Rust风格的伪代码: 178 | 179 | ```rust 180 | struct InterruptStackTable { 181 | stack_pointers: [Option; 7], 182 | } 183 | ``` 184 | 185 | 对于每一个异常处理器,我们可以通过对应 [IDT entry](https://os.phil-opp.com/cpu-exceptions/#the-interrupt-descriptor-table) 中的 `stack_pointers`字段在 *IST* 中找到一个栈。例如,双重异常处理器可以使用 *IST* 中的第一个栈。此后,CPU会主动在双重异常发生时切换到这个栈。切换之前不会由任何东西被压入栈中,所以它可以阻止三重异常的发生。 186 | 187 | ### 中断栈表和任务状态段( The IST and TSS) 188 | 189 | 中断栈表是早期遗留下来的结构体——任务状态段( *[Task State Segment](https://en.wikipedia.org/wiki/Task_state_segment),TSS*)的一部分。在32位模式下,*TSS* 被用来保存任务(*task*)相关的各种信息(例如寄存器的状态),包括硬件上下文切换([hardware context switching](https://wiki.osdev.org/Context_Switching#Hardware_Context_Switching))等。然而,在64位模式下,硬件上下文切换不再被支持,同时 *TSS* 的格式也已经面目全非。 190 | 191 | 在 `x86_64` 架构下,*TSS* 不再保存任何关于任务(*task*)的信息。取而代之的是两个栈表( *IST* 是其中之一)。*TSS* 在32位和64位模式下唯一相同的字段是指向 [I/O port permissions bitmap](https://en.wikipedia.org/wiki/Task_state_segment#I.2FO_port_permissions) 的指针。 192 | 193 | 在64位模式下, *TSS* 的格式如下: 194 | 195 | | Field | Type | 196 | | --------------------------------- | ---------- | 197 | | (保留位) | `u32` | 198 | | 特权栈表(Privilege Stack Table) | `[u64; 3]` | 199 | | (保留位) | `u64` | 200 | | 中断栈表(Interrupt Stack Table) | `[u64; 7]` | 201 | | (保留位) | `u64` | 202 | | (保留位) | `u16` | 203 | | I/O Map Base Address | `u16` | 204 | 205 | 特权栈表会在 *CPU* 改变特权级别的时候被使用。例如,*CPU* 处在用户模式(*user mode*,特权级别 3 )时触发了异常,它通常会在调用异常处理函数之前切换到内核模式(*kernel mode*,特权级别 0 )。在这种情况下,*CPU* 会切换到特权栈表的第0个栈中(因为目标特权级别是0)。我们当前的内核没有运行在用户模式下的程序,所以可以暂时忽略特权栈表。 206 | 207 | ### 创建任务状态段 208 | 209 | 为了创建一个在自身的中断栈表中包含不同双重异常栈的 *TSS* ,我们需要一个 *TSS* 结构体。幸运的是, `x86_64` 模块已经提供了 [`TaskStateSegment` 结构体](https://docs.rs/x86_64/0.12.1/x86_64/structures/tss/struct.TaskStateSegment.html) 供我们使用。 210 | 211 | 我们在新的 `gdt` 模块(下文中解释)中创建 *TSS* 。 212 | 213 | ```rust 214 | // in src/lib.rs 215 | 216 | pub mod gdt; 217 | 218 | // in src/gdt.rs 219 | 220 | use x86_64::VirtAddr; 221 | use x86_64::structures::tss::TaskStateSegment; 222 | use lazy_static::lazy_static; 223 | 224 | pub const DOUBLE_FAULT_IST_INDEX: u16 = 0; 225 | 226 | lazy_static! { 227 | static ref TSS: TaskStateSegment = { 228 | let mut tss = TaskStateSegment::new(); 229 | tss.interrupt_stack_table[DOUBLE_FAULT_IST_INDEX as usize] = { 230 | const STACK_SIZE: usize = 4096 * 5; 231 | static mut STACK: [u8; STACK_SIZE] = [0; STACK_SIZE]; 232 | 233 | let stack_start = VirtAddr::from_ptr(unsafe { &STACK }); 234 | let stack_end = stack_start + STACK_SIZE; 235 | stack_end 236 | }; 237 | tss 238 | }; 239 | } 240 | ``` 241 | 242 | 由于Rust的常量求值器( Rust's const evaluator )并不支持在编译期内完成初始化,所以我们使用了 `lazy_static` 。我们定义了 *IST* 的第0个条目指向双重异常栈( *IST* 的其它条目也可以正常运行),然后向第0个条目写入了双重异常栈的顶部地址(因为 `x86` 机器的栈地址向下扩展,也就是从高地址到低地址)。 243 | 244 | 因为我们的内核还没有实现内存管理机制,所以我们还没有一个像样的方式去分配新的栈。作为替代,我们当前使用 `static mut` 数组作为栈的存储。 `unsafe` 是必要的,因为编译器不能确保可变静态变量被访问时的竞争自由。使用 `static mut` 而不是 `static` 是因为 *bootloader* 会把它映射到只读内存页上。下篇文章中,我们会使用一个像样的栈内存分配方式去取代这个方式,那时就不再需要 `unsafe` 了。 245 | 246 | 不得不提的是,这个双重异常栈没有保护页去避免栈溢出。这意味着我们不能在双重异常处理函数中做任何过度使用函数栈的的事情,以避免栈溢出破坏栈地址以下的内存。 247 | 248 | #### 加载 *TSS* 249 | 250 | 我们需要一种方式让 *CPU* 明白新的 *TSS* 已经可用了。不幸的是,这个过程很复杂,因为 *TSS* 使用了分段系统(历史遗留问题)。既然不能直接加载 *TSS* ,我们需要在全局描述符表( *[Global Descriptor Table](https://web.archive.org/web/20190217233448/https://www.flingos.co.uk/docs/reference/Global-Descriptor-Table/) ,GDT*)中增加一个段描述符。然后就可以通过各自的 *GDT* 指针调用 [`ltr` 指令](https://www.felixcloutier.com/x86/ltr) 去加载 *TSS* 。 251 | 252 | ### 全局描述符表( The Global Descriptor Table,GDT) 253 | 254 | 全局描述符表是分页机制成为事实上的标准之前的一个古老概念,它被用于内存分段( [memory segmentation](https://en.wikipedia.org/wiki/X86_memory_segmentation) )。全局描述符表在64位模式下依然发挥着作用,例如内核/用户模式的配置和 *TSS* 的加载。*GDT* 包含了一个程序的所有分段。在分页机制还没有成为标准的古老体系架构中,*GDT* 被用来隔离所有的程序。你可以检阅开源手册 [“Three Easy Pieces” ](http://pages.cs.wisc.edu/~remzi/OSTEP/) 的同名章节获取更多关于分段机制的信息。虽然64位模式下不再支持分段机制,但是*GDT* 仍然保留了下来。它被用于两件事情:在内核空间和用户空间之间切换,加载 *TSS* 结构体。 255 | 256 | #### 创建GDT 257 | 258 | 创建一个包含用于 `TSS` 静态变量的段的 *GDT* : 259 | 260 | ```rust 261 | // in src/gdt.rs 262 | 263 | use x86_64::structures::gdt::{GlobalDescriptorTable, Descriptor}; 264 | 265 | lazy_static! { 266 | static ref GDT: GlobalDescriptorTable = { 267 | let mut gdt = GlobalDescriptorTable::new(); 268 | gdt.add_entry(Descriptor::kernel_code_segment()); 269 | gdt.add_entry(Descriptor::tss_segment(&TSS)); 270 | gdt 271 | }; 272 | } 273 | ``` 274 | 275 | 由于Rust的常量求值器( Rust's const evaluator )并不支持在编译期内完成初始化,所以我们再次使用了 `lazy_static` 。我们创建了一个包含代码段(code segment)和 *TSS* 段( *TSS* segment)的 *GDT* 。 276 | 277 | #### 加载GDT 278 | 279 | 通过在 `init` 函数调用新建立的 `gdt::init` 函数加载 *GDT* : 280 | 281 | ```rust 282 | // in src/gdt.rs 283 | 284 | pub fn init() { 285 | GDT.load(); 286 | } 287 | 288 | // in src/lib.rs 289 | 290 | pub fn init() { 291 | gdt::init(); 292 | interrupts::init_idt(); 293 | } 294 | ``` 295 | 296 | 现在 *GDT* 已经加载完毕(因为 `_start` 函数调用了 `init` 函数),但是仍然出现了栈溢出引发的启动循环。 297 | 298 | ### 最后的步骤 299 | 300 | 由于段寄存器和 *TSS* 寄存器仍然保存着来自于旧的 *GDT* 的内容,我们新 *GDT* 的分段并没有起作用。所以我们还需要修改双重异常对应的 *IDT* 条目去让它使用新的栈。 301 | 302 | 总的来说,我们需要完成以下步骤: 303 | 304 | 1. **重新装载代码段寄存器**:我们修改了 *GDT* ,所以应当重新装载代码段寄存器—— `cs` 。这是必要的,既然现在旧的段选择子可以指向不同的 *GDT* 描述符(例如 *TSS* 描述符)。 305 | 2. **加载 *TSS*** :我们已经加载了包含 *TSS* 段选择子的 *GDT* ,但是仍然需要告知 *CPU* 使用新的 *TSS* 。 306 | 3. **更新 IDT 条目**:一旦 *TSS* 加载完毕,CPU便访问到了合法的中断栈表( *IST* )。然后通过更新双重异常条目告知 *CPU* 使用新的双重异常栈。 307 | 308 | 第一、二步需要我们在 `gdt::init` 函数中访问 `code_selector` 和 `tss_selector` 变量。为了实现这个操作,我们可以通过一个新的 `Selectors` 结构体将它们包含在 *static* 块中。 309 | 310 | ```rust 311 | // in src/gdt.rs 312 | 313 | use x86_64::structures::gdt::SegmentSelector; 314 | 315 | lazy_static! { 316 | static ref GDT: (GlobalDescriptorTable, Selectors) = { 317 | let mut gdt = GlobalDescriptorTable::new(); 318 | let code_selector = gdt.add_entry(Descriptor::kernel_code_segment()); 319 | let tss_selector = gdt.add_entry(Descriptor::tss_segment(&TSS)); 320 | (gdt, Selectors { code_selector, tss_selector }) 321 | }; 322 | } 323 | 324 | struct Selectors { 325 | code_selector: SegmentSelector, 326 | tss_selector: SegmentSelector, 327 | } 328 | ``` 329 | 330 | 现在可以使用选择子(*selectors*)重新加载 `cs` 段寄存器并加载新的 `TSS` : 331 | 332 | ```rust 333 | // in src/gdt.rs 334 | 335 | pub fn init() { 336 | use x86_64::instructions::segmentation::set_cs; 337 | use x86_64::instructions::tables::load_tss; 338 | 339 | GDT.0.load(); 340 | unsafe { 341 | set_cs(GDT.1.code_selector); 342 | load_tss(GDT.1.tss_selector); 343 | } 344 | } 345 | ``` 346 | 347 | 我们使用 [`set_cs`](https://docs.rs/x86_64/0.12.1/x86_64/instructions/segmentation/fn.set_cs.html) 和 [`load_tss`](https://docs.rs/x86_64/0.12.1/x86_64/instructions/tables/fn.load_tss.html) 函数分别重新加载代码段寄存器和 `TSS` 。我们需要在 `unsafe` 代码块中调用这两个函数,因为它们均被声明为 `unsafe` —— 它们可能由于加载了非法的选择子而破坏内存安全。 348 | 349 | 现在,合法的 *TSS* 和 中断栈表已经加载完毕,我们可以在 *IDT* 中设置用于双重异常的栈指针: 350 | 351 | ```rust 352 | // in src/interrupts.rs 353 | 354 | use crate::gdt; 355 | 356 | lazy_static! { 357 | static ref IDT: InterruptDescriptorTable = { 358 | let mut idt = InterruptDescriptorTable::new(); 359 | idt.breakpoint.set_handler_fn(breakpoint_handler); 360 | unsafe { 361 | idt.double_fault.set_handler_fn(double_fault_handler) 362 | .set_stack_index(gdt::DOUBLE_FAULT_IST_INDEX); // new 363 | } 364 | 365 | idt 366 | }; 367 | } 368 | ``` 369 | 370 | `set_stack_index` 方法是不安全的,因为调用者必须保证使用的指针是合法的并且没有被用于其它异常。 371 | 372 | That's it! *CPU* 会在所有双重异常发生的时候切换到双重异常栈。因此,我们可以捕获所有双重异常——包括在内核栈溢出的情况中。 373 | 374 | ![qemu-double-fault-on-stack-overflow](https://markdown-ngy.oss-cn-beijing.aliyuncs.com/qemu-double-fault-on-stack-overflow.png) 375 | 376 | 从现在起,三重异常永远不会再次出现了!为了确保不会意外地弄坏上面的程序,我们应该加上测试。 377 | 378 | ## 栈溢出测试 379 | 380 | 为了测试新的 `gdt` 模块并且确保在栈溢出的情况下,双重异常处理函数被正确的调用,我们可以增加一个集成测试。思路是在测试函数中触发一个双重异常并验证双重异常处理函数被调用。 381 | 382 | 从最小可用版本开始: 383 | 384 | ```rust 385 | // in tests/stack_overflow.rs 386 | 387 | #![no_std] 388 | #![no_main] 389 | 390 | use core::panic::PanicInfo; 391 | 392 | #[no_mangle] 393 | pub extern "C" fn _start() -> ! { 394 | unimplemented!(); 395 | } 396 | 397 | #[panic_handler] 398 | fn panic(info: &PanicInfo) -> ! { 399 | blog_os::test_panic_handler(info) 400 | } 401 | ``` 402 | 403 | 类似于 `panic_handler` 测试,新的测试不会运行在测试环境下( [without a test harness](https://os.phil-opp.com/testing/#no-harness-tests))。原因在于我们不能在双重异常之后继续执行,所以连续进行多个测试是行不通的。为了禁用测试环境,需要在 `Cargo.toml` 中增加以下配置: 404 | 405 | ```rust 406 | # in Cargo.toml 407 | 408 | [[test]] 409 | name = "stack_overflow" 410 | harness = false 411 | ``` 412 | 413 | 现在,执行 `cargo test --test stack_overflow` 会编译成功。测试当然会由于 `unimplemented` 宏的崩溃(panics)而失败。 414 | 415 | ### 实现 `_start` 416 | 417 | `_start` 函数的实现如下所示: 418 | 419 | ```rust 420 | // in tests/stack_overflow.rs 421 | 422 | use blog_os::serial_print; 423 | 424 | #[no_mangle] 425 | pub extern "C" fn _start() -> ! { 426 | serial_print!("stack_overflow::stack_overflow...\t"); 427 | 428 | blog_os::gdt::init(); 429 | init_test_idt(); 430 | 431 | // trigger a stack overflow 432 | stack_overflow(); 433 | 434 | panic!("Execution continued after stack overflow"); 435 | } 436 | 437 | #[allow(unconditional_recursion)] 438 | fn stack_overflow() { 439 | stack_overflow(); // for each recursion, the return address is pushed 440 | volatile::Volatile::new(0).read(); // prevent tail recursion optimizations 441 | } 442 | ``` 443 | 444 | 调用 `gdt::init` 函数初始化新的 *GDT* 。我们调用了 `init_test_idt` 函数(稍后会解释它)而不是 `interrupts::init_idt` 函数。原因在于我们希望注册一个自定义的双重异常处理函数执行`exit_qemu(QemuExitCode::Success) ` 而不是直接崩溃( *panicking*)。 445 | 446 | `stack_overflow` 函数和 `main.rs` 中的函数几乎一致。唯一的不同是我们在函数末尾增加了额外的 [volatile](https://en.wikipedia.org/wiki/Volatile_(computer_programming)) 读,这是使用 [`Volatile`](https://docs.rs/volatile/0.2.6/volatile/struct.Volatile.html) 类型阻止编译器的尾调用优化( [*tail call elimination*](https://en.wikipedia.org/wiki/Tail_call))。此外,这个优化允许编译器将递归函数转化为循环。因此,函数调用的时候不会有额外的栈帧产生,栈的使用是固定不变的(*译注:上文中提到这个双重异常栈没有保护页去避免栈溢出。如果在双重异常处理函数中做任何过度使用函数栈的的事情,会导致栈溢出破坏栈地址以下的内存*)。 447 | 448 | 在测试场景中,我们希望栈溢出的发生,所以在函数的末尾增加了一个模拟的 *volatile* 读语句,这个语句不会被编译器优化掉。因此,这个函数不再是尾递归,也不会被转化为循环。我们同时使用 `allow(unconditional_recursion)` 属性禁止了编译器的警告——这个函数会无休止地重复。 449 | 450 | ### 测试用IDT 451 | 452 | 如上所述,这个测试需要独立持有双重异常处理函数的 *IDT* 。实现如下: 453 | 454 | ```rust 455 | // in tests/stack_overflow.rs 456 | 457 | use lazy_static::lazy_static; 458 | use x86_64::structures::idt::InterruptDescriptorTable; 459 | 460 | lazy_static! { 461 | static ref TEST_IDT: InterruptDescriptorTable = { 462 | let mut idt = InterruptDescriptorTable::new(); 463 | unsafe { 464 | idt.double_fault 465 | .set_handler_fn(test_double_fault_handler) 466 | .set_stack_index(blog_os::gdt::DOUBLE_FAULT_IST_INDEX); 467 | } 468 | 469 | idt 470 | }; 471 | } 472 | 473 | pub fn init_test_idt() { 474 | TEST_IDT.load(); 475 | } 476 | ``` 477 | 478 | 这个实现和 `interrupts.rs` 中普通的 *IDT* 非常相似。和在普通的 *IDT* 中一样,我们在 *IST* 中将双重异常处理函数设置为独立的栈。 `init_test_idt` 函数通过 `load` 方法加载 *IDT* 到CPU中。 479 | 480 | ### 双重异常处理函数 481 | 482 | 现在唯一缺少的便是双重异常处理函数了。实现如下: 483 | 484 | ```rust 485 | // in tests/stack_overflow.rs 486 | 487 | use blog_os::{exit_qemu, QemuExitCode, serial_println}; 488 | use x86_64::structures::idt::InterruptStackFrame; 489 | 490 | extern "x86-interrupt" fn test_double_fault_handler( 491 | _stack_frame: &mut InterruptStackFrame, 492 | _error_code: u64, 493 | ) -> ! { 494 | serial_println!("[ok]"); 495 | exit_qemu(QemuExitCode::Success); 496 | loop {} 497 | } 498 | ``` 499 | 500 | 当双重异常处理函数被调用,我们退出*QEMU*,并且退出码是代表着测试通过的成功退出码。随着集成测试各自独立地执行完毕,我们需要在测试文件的顶部再次设置 `#![feature(abi_x86_interrupt)]` 属性。 501 | 502 | 现在可以使用 `cargo test --test stack_overflow`命令运行双重异常测试了(或者使用 `cargo test` 直接运行所有测试)。不出所料,控制台输出了 `stack_overflow... [ok]` 消息。如果注释掉 `set_stack_index` 行:测试应当失败。 503 | 504 | ## 总结 505 | 506 | 在这篇文章中,我们学到了双重异常及其触发条件,添加了基本的双重异常处理函数打印错误消息,同时补充了相应的集成测试。 507 | 508 | 我们也让硬件支持了双重异常发生时的栈切换,这让它可以在栈溢出的情况下正常工作。在实现的同时,我们学到了任务状态段(*TSS*),包含其中的中断栈表(*IST*),以及旧体系架构中用于分段机制的全局描述符(*GDT*)。 509 | 510 | ## 接下来? 511 | 512 | 下一篇文章,我们将阐明如何处理来自外部设备的中断,例如时钟、键盘或网卡等。这些硬件中断和异常十分相似,它们也会通过 *IDT* 分发到相应的处理函数。然而与异常不同的是,它们并不直接由 *CPU* 内部产生。中断控制器(*interrupt controller*)会汇总这些中断,然后根据它们的优先级高低发送到 *CPU* 。在下篇文章中,我们会介绍 [Intel 8259](https://en.wikipedia.org/wiki/Intel_8259) (“PIC”) 中断控制器,并学习如何实现键盘支持(*keyboard support*)。 513 | 514 | ## 支持我 515 | 516 | 创建并维护这个[博客](https://os.phil-opp.com/status-update/) 和相关的库是一个繁重的工作,但是我非常喜欢这个它。你的支持可以鼓励我投入更多的时间在新的内容,新的功能以及持续维护工作上。 517 | 518 | 支持我的最好方式是 [*sponsor me on GitHub*](https://github.com/sponsors/phil-opp) 。GitHub 会匹配赞助(*译注:见[GitHub Sponsors Matching Fund](https://docs.github.com/en/free-pro-team@latest/github/supporting-the-open-source-community-with-github-sponsors/about-github-sponsors)*)直到2020年10月!我同时拥有 [*Patreon*](https://www.patreon.com/phil_opp) 和 [*Donorbox*](https://donorbox.org/phil-opp) 账户。最后一种是最灵活的,它支持多国货币和一次性捐赠。 519 | 520 | 谢谢! 521 | 522 | > 译注:《支持我》章节中的“我”均指原作者 [@Philipp Oppermann](https://github.com/phil-opp) 。 523 | 524 | -------------------------------------------------------------------------------- /07-hardware-interrupts.md: -------------------------------------------------------------------------------- 1 | > 原文:https://os.phil-opp.com/hardware-interrupts/ 2 | > 3 | > 原作者:@phil-opp 4 | > 5 | > 译者:尚卓燃(@psiace) 华中农业大学 6 | 7 | # 使用Rust编写操作系统(七):硬件中断 8 | 9 | 在这一章中,我们将会学习如何设置可编程中断控制器(Programmable Interrupt Controller,PIC),以将硬件中断正确转发到 CPU 。为了处理这些中断,需要向中断描述符表(Interrupt Descriptor Table,IDT)中添加新的表项,就像我们实现异常处理程序那样。通过对这一章的学习,你会了解到如何获取周期性定时器中断以及键盘输入。 10 | 11 | ## 简介 12 | 13 | 中断为外部硬件设备提供了向 CPU 发送通知的方法。这样一来,内核就不必定期检查键盘上是否有新字符产生(这一过程称作「[轮询]」),而是由键盘在出现按键事件时通知内核。采用这种方法有两个好处:一是中断处理更高效,因为内核只需要在硬件触发中断后进行响应;二是响应时间更短,因为内核可以即时作出响应,而不是在下一次轮询中进行处理。 14 | 15 | [轮询]: https://en.wikipedia.org/wiki/Polling_(computer_science) 16 | 17 | 要将所有硬件设备都与 CPU 直接连接是不现实的。替代办法是使用一个单独的「中断控制器」(Interrupt Controller)来聚合所有中断,然后再通知 CPU : 18 | 19 | ```text 20 | ____________ _____ 21 | 定时器 ------------> | | | | 22 | 键盘 --------------> | 中断控制器 |---------> | CPU | 23 | 其他硬件 ----------> | | |_____| 24 | 更多... -----------> |____________| 25 | 26 | ``` 27 | 28 | 大多数中断控制器都是可编程的,这意味着它们支持为中断分配不同的优先级。举个例子:如果需要保证计时准确,我们可以为定时器中断设置比键盘中断更高的优先级。 29 | 30 | 与异常不同的是,硬件中断是异步(Asynchronously)发生的。这意味着它们完全独立于执行的代码,并且可能在任何时候发生。因此,内核中就突然出现了一种并发形式,而且我们也不得不面对所有与并发相关的潜在错误。Rust 严格的所有权模型会为我们提供一定帮助,因为它禁止使用可变的全局状态。然而,死锁仍然可能发生,我们在后面也会遇到这种情况。 31 | 32 | ## 8259 可编程中断控制器 33 | 34 | [Intel 8259] 是一款在 1976 年推出的可编程中断控制器(PIC)。早已被新的「[高级可编程中断控制器(APIC)]」所取代,但由于 APIC 保持了较好的向后兼容,所以它的接口仍然在当前系统上得到较好的支持。8259 PIC 比 APIC 更容易设置,所以在我们切换到 APIC 之前,将先使用它来介绍中断。 35 | 36 | [高级可编程中断控制器(APIC)]: https://en.wikipedia.org/wiki/Intel_APIC_Architecture 37 | 38 | 8259 有 8 条中断控制线和几条与 CPU 通信的线。当时的典型系统配备了一主一从两个 8259 PIC 实例,其中从控制器连接到主控制器的一条中断控制线上。 39 | 40 | [intel 8259]: https://en.wikipedia.org/wiki/Intel_8259 41 | 42 | ```text 43 | ____________ ____________ 44 | 实时时钟 ---------> | | 定时器 ------------> | | 45 | ACPI -------------> | | 键盘 --------------> | | _____ 46 | 可用 -------------> | |----------------------> | | | | 47 | 可用 -------------> | 从中断 | 串行端口 2 --------> | 主中断 |---> | CPU | 48 | 鼠标 -------------> | 控制器 | 串行端口 1 --------> | 控制器 | |_____| 49 | 协处理器 ---------> | | 并行端口 2/3 ------> | | 50 | 主 ATA -----------> | | 软盘 --------------> | | 51 | 次 ATA -----------> |____________| 并行端口 1 --------> |____________| 52 | 53 | ``` 54 | 55 | 上图显示了中断控制线的经典分配方案。我们看到剩下 15 条线中的大多数都对应有一个固定的映射,例如从中断控制器的第 4 条中断控制线被分配给了鼠标。 56 | 57 | 每个控制器可以通过两个 [I/O 端口] 进行配置,其中一个是「命令」端口,另一个是「数据」端口。 在主控制器中,这两个端口分别位于 0x20(命令)和 0x21(数据)。 而在从控制器中,分别是 0xa0(命令)和 0xa1(数据)。 如果你想要了解关于「如何配置可编程中断控制器」的更多信息,可以参考 [osdev.org 上的文章]。 58 | 59 | [I/O 端口]: ./04-testing.md#IO-端口 60 | [osdev.org 上的文章]: https://wiki.osdev.org/8259_PIC 61 | 62 | ### 实现 63 | 64 | 不能使用默认的 PIC 配置,因为它将会向 CPU 发送 0-15 范围内的中断类型码。而这些数字已经被 CPU 异常占用了,例如数字 8 对应二重错误。为了解决此重叠问题,我们需要将中断重新映射到不同的中断类型码。实际范围并不重要,只要不与异常重叠就可以,但通常会选择范围 32-47 的数字,因为这是 32 个异常槽之后的第一组空闲数字。 65 | 66 | 配置是通过向 PIC 的命令和数据端口写入特殊值来完成的。幸运的是,已经有一个名为 [`pic8259_simple`] 的包,所以我们不需要自己编写初始化序列。如果您对它的工作原理感兴趣,请查看 [它的源代码][pic crate source] ,它相当小并且有齐全的文档说明。 67 | 68 | [pic crate source]: https://docs.rs/crate/pic8259_simple/0.1.1/source/src/lib.rs 69 | 70 | 要将包添加为依赖项,我们需要将以下内容添加到项目中: 71 | 72 | [`pic8259_simple`]: https://docs.rs/pic8259_simple/0.1.1/pic8259_simple/ 73 | 74 | ```toml 75 | # in Cargo.toml 76 | 77 | [dependencies] 78 | pic8259 = "0.10.0" 79 | ``` 80 | 81 | 这个包提供的主要抽象是 [`ChainedPics`] 结构,它表示我们上面看到的「主/从二级可编程中断控制器」布局。基于它的设计,我们可以按以下方式来使用它: 82 | 83 | [`ChainedPics`]: https://docs.rs/pic8259_simple/0.1.1/pic8259_simple/struct.ChainedPics.html 84 | 85 | ```rust 86 | // in src/interrupts.rs 87 | 88 | use pic8259::ChainedPics; 89 | use spin; 90 | 91 | pub const PIC_1_OFFSET: u8 = 32; 92 | pub const PIC_2_OFFSET: u8 = PIC_1_OFFSET + 8; 93 | 94 | pub static PICS: spin::Mutex = 95 | spin::Mutex::new(unsafe { ChainedPics::new(PIC_1_OFFSET, PIC_2_OFFSET) }); 96 | ``` 97 | 98 | 就像在前面提过的那样,我们将会为 PIC 设置偏移量,使得中断类型码范围为 32-47 。 通过用 `Mutex` 包装 `ChainedPics` 结构,能够获得安全的可变访问(通过 [`lock` 方法][spin mutex lock]),这是下一步所需要的。`ChainedPics::new` 函数不安全,因为错误的偏移量可能会导致未定义行为。 99 | 100 | [spin mutex lock]: https://docs.rs/spin/0.5.2/spin/struct.Mutex.html#method.lock 101 | 102 | 现在我们可以在 `init` 函数中初始化 8259 PIC: 103 | 104 | ```rust 105 | // in src/lib.rs 106 | 107 | pub fn init() { 108 | gdt::init(); 109 | interrupts::init_idt(); 110 | unsafe { interrupts::PICS.lock().initialize() }; // new 111 | } 112 | ``` 113 | 114 | 我们使用 [`initialize`] 函数来执行 PIC 的初始化。 像 `ChainedPics::new` 函数一样,这个函数也不安全,因为如果 PIC 配置错误,它可能导致未定义行为。 115 | 116 | [`initialize`]: https://docs.rs/pic8259_simple/0.1.1/pic8259_simple/struct.ChainedPics.html#method.initialize 117 | 118 | 如果一切顺利,我们应该在执行 `cargo xrun` 时继续看到「It did not crash」这条消息。 119 | 120 | ## 启用中断 121 | 122 | 到目前为止,什么都没有发生,因为 CPU 配置中仍然禁用了中断。 这意味着 CPU 根本没有侦听中断控制器,因此没有中断可以到达 CPU。让我们试着改变这一点: 123 | 124 | ```rust 125 | // in src/lib.rs 126 | 127 | pub fn init() { 128 | gdt::init(); 129 | interrupts::init_idt(); 130 | unsafe { interrupts::PICS.lock().initialize() }; 131 | x86_64::instructions::interrupts::enable(); // new 132 | } 133 | ``` 134 | 135 | `x86_64` 包的 `interrupts::enable` 函数执行特殊的 `sti` 指令(设置中断「set interrupts」)以启用外部中断。现在尝试运行 `cargo xrun` 命令,我们会看到发生双重错误: 136 | 137 | ![QEMU printing `EXCEPTION: DOUBLE FAULT` because of hardware timer](https://os.phil-opp.com/hardware-interrupts/qemu-hardware-timer-double-fault.png) 138 | 139 | 出现这种双重错误的原因是硬件定时器(确切地说是 [Intel 8253])被设置为默认启用,一旦启用中断,我们就会开始接收定时器中断。 由于我们还没有为它定义中断处理程序,因此就会调用双重错误处理程序。 140 | 141 | [intel 8253]: https://en.wikipedia.org/wiki/Intel_8253 142 | 143 | ## 处理定时器中断 144 | 145 | 如 [上文](#8259-可编程中断控制器) 中的图例所示,定时器使用了主 PIC 的第 0 条中断控制线。 这意味着中断会以中断类型码 32( 0 + 偏移量 32 )的形式到达 CPU。 我们不会对索引 32 进行硬编码,而是将它存储在枚举结构(enum) `InterruptIndex` 中: 146 | 147 | ```rust 148 | // in src/interrupts.rs 149 | 150 | #[derive(Debug, Clone, Copy)] 151 | #[repr(u8)] 152 | pub enum InterruptIndex { 153 | Timer = PIC_1_OFFSET, 154 | } 155 | 156 | impl InterruptIndex { 157 | fn as_u8(self) -> u8 { 158 | self as u8 159 | } 160 | 161 | fn as_usize(self) -> usize { 162 | usize::from(self.as_u8()) 163 | } 164 | } 165 | ``` 166 | 167 | Rust 中的枚举是 [c-like 风格的枚举],因此我们可以直接为其内的每个变体指定索引。 `repr(u8)` 属性指定每个变体都以 `u8` 类型表示。 接下来,我们将会为其他中断添加更多的变体。 168 | 169 | [c-like 风格的枚举]: https://doc.rust-lang.org/reference/items/enumerations.html#custom-discriminant-values-for-field-less-enumerations 170 | 171 | 现在我们可以为定时器中断添加一个处理函数: 172 | 173 | ```rust 174 | // in src/interrupts.rs 175 | 176 | use crate::print; 177 | 178 | lazy_static! { 179 | static ref IDT: InterruptDescriptorTable = { 180 | let mut idt = InterruptDescriptorTable::new(); 181 | idt.breakpoint.set_handler_fn(breakpoint_handler); 182 | […] 183 | idt[InterruptIndex::Timer.as_usize()] 184 | .set_handler_fn(timer_interrupt_handler); // new 185 | 186 | idt 187 | }; 188 | } 189 | 190 | extern "x86-interrupt" fn timer_interrupt_handler( 191 | _stack_frame: &mut InterruptStackFrame) 192 | { 193 | print!("."); 194 | } 195 | ``` 196 | 197 | 定时器中断处理程序 `timer_interrupt_handler` 具有与异常处理程序相同的函数签名,因为 CPU 对异常和外部中断的反应是相同的(唯一的区别是有些异常会返回错误代码)。 [`InterruptDescriptorTable`] 结构实现了 [`IndexMut`] 特质(trait),因此我们可以通过数组索引语法访问单个表项。 198 | 199 | [`interruptdescriptortable`]: https://docs.rs/x86_64/0.8.1/x86_64/structures/idt/struct.InterruptDescriptorTable.html 200 | [`indexmut`]: https://doc.rust-lang.org/core/ops/trait.IndexMut.html 201 | 202 | 定时器定时器中断处理程序将会在屏幕上输出一个点 `'.'`。由于定时器中断周期性地发生,我们期望看到每当定时器「滴答」一下就输出一个点。但是,当我们运行程序时,屏幕上只输出了一个点: 203 | 204 | ![QEMU printing only a single dot for hardware timer](https://os.phil-opp.com/hardware-interrupts/qemu-single-dot-printed.png) 205 | 206 | ### 中断结束 207 | 208 | 之所以出现上面的故障,是因为 PIC 期望从错误处理程序得到一个明确的「中断结束」(End of Interrupt,EOI)信号。 这个信号告诉控制器:中断已经被处理,并且系统已经准备好接收下一个中断。 所以 PIC 认为系统仍然忙于处理第一个定时器中断,并在发送下一个中断之前耐心地等待 EOI 信号。 209 | 210 | 为了发送 EOI ,我们再次使用静态结构 `PICS` : 211 | 212 | ```rust 213 | // in src/interrupts.rs 214 | 215 | extern "x86-interrupt" fn timer_interrupt_handler( 216 | _stack_frame: &mut InterruptStackFrame) 217 | { 218 | print!("."); 219 | 220 | unsafe { 221 | PICS.lock() 222 | .notify_end_of_interrupt(InterruptIndex::Timer.as_u8()); 223 | } 224 | } 225 | ``` 226 | 227 | 通知中断结束函数 `notify_end_of_interrupt` 将会指出主控制器或从控制器是否发送中断,然后使用 `命令` 和 `数据` 端口向各控制器发送相应的 EOI 信号。 如果从 PIC 发送了中断,那么需要通知两个 PIC ,因为从 PIC 与主 PIC 的一条输入线相连。 228 | 229 | 我们需要谨慎地使用正确的中断类型码,否则可能会意外地删除重要的未发送中断或导致我们的系统挂起。这也是该函数不安全的原因。 230 | 231 | 现在执行 `cargo xrun` ,我们会看到一些点周期性地出现在屏幕上: 232 | 233 | ![QEMU printing consecutive dots showing the hardware timer](https://os.phil-opp.com/hardware-interrupts/qemu-hardware-timer-dots.gif) 234 | 235 | ### 配置定时器 236 | 237 | 我们使用的硬件定时器是可编程间隔定时器(Progammable Interval Timer, PIT)。 顾名思义,可以配置两个中断之间的间隔。我们不会详细介绍这些,因为我们很快就会切换到 [APIC 定时器],但是 OSDev wiki 上有一篇详细的关于「[如何配置 PIT ]」的文章。 238 | 239 | [APIC 定时器]: https://wiki.osdev.org/APIC_timer 240 | [如何配置 PIT ]: https://wiki.osdev.org/Programmable_Interval_Timer 241 | 242 | ## 死锁 243 | 244 | 现在内核中存在一种并发的情形:定时器中断是异步发生的,因此它们可以随时中断我们的 `_start` 函数。 幸运的是,Rust 的所有权系统可以在编译时防止许多种类型的与并发相关的错误。 但死锁是一个值得注意的例外。 如果一个线程试图获取一个永远不会释放的锁,就会发生死锁。 这样,线程将会无限期地处于挂起状态。 245 | 246 | 当前我们的内核中已经可以引发死锁。请注意,我们的 `println` 宏调用 `vga_buffer::_print` 函数,它使用自旋锁来[锁定一个全局的 WRITER 类][vga spinlock]: 247 | 248 | [vga spinlock]: ./03-vga-text-mode.md#自旋锁 249 | 250 | ```rust 251 | // in src/vga_buffer.rs 252 | 253 | […] 254 | 255 | #[doc(hidden)] 256 | pub fn _print(args: fmt::Arguments) { 257 | use core::fmt::Write; 258 | WRITER.lock().write_fmt(args).unwrap(); 259 | } 260 | ``` 261 | 262 | 它锁定 `WRITER`,调用 `write_fmt`,并在函数的末尾隐式地解锁。现在,我们设想一下,如果在 `WRITER` 被锁定时触发一个中断,同时相应的中断处理程序也试图打印一些东西: 263 | 264 | | 时间 | \_start | interrupt_handler | 265 | | ---------- | --------------------- | --------------------------------------- | 266 | | 0 | 调用 `println!` |   | 267 | | 1 | `print` 锁定 `WRITER` |   | 268 | | 2 | | **中断发生**,处理程序开始运行 | 269 | | 3 | | 调用 `println!` | 270 | | 4 | | `print` 尝试锁定 `WRITER`(已经被锁定) | 271 | | 5 | | `print` 尝试锁定 `WRITER`(已经被锁定) | 272 | | … | | … | 273 | | _无法发生_ | _解锁 `WRITER`_ | | 274 | 275 | 由于 `WRITER` 已经被锁定,所以中断处理程序将会一直等待,直到它被释放。但这种情况永远不会发生,因为 `_start` 函数只有在中断处理程序返回后才继续运行。因此,整个系统就会挂起。 276 | 277 | ### 引发死锁 278 | 279 | 通过在 `_start` 函数末尾的循环中打印一些内容,我们很容易在内核中引发这样的死锁: 280 | 281 | ```rust 282 | // in src/main.rs 283 | 284 | #[no_mangle] 285 | pub extern "C" fn _start() -> ! { 286 | […] 287 | loop { 288 | use blog_os::print; 289 | print!("-"); // new 290 | } 291 | } 292 | ``` 293 | 294 | 当我们在 QEMU 中运行它时,得到的输出如下: 295 | 296 | ![QEMU output with many rows of hyphens and no dots](https://os.phil-opp.com/hardware-interrupts/qemu-deadlock.png) 297 | 298 | 只有有限数量的连字符 `'-'` 被打印,直到第一次定时器中断发生。接着系统挂起,因为定时器中断处理程序试图打印点时引发了死锁。这就是为什么我们在上面的输出中看不到任何点的原因。 299 | 300 | 由于定时器中断是异步发生的,因此连字符的实际数量在两次运行之间会有所不同。这种不确定性使得与并发相关的错误很难调试。 301 | 302 | ### 修复死锁 303 | 304 | 为了避免这种死锁,我们可以采取这样的方案:只要互斥锁 `Mutex` 是锁定的,就可以禁用中断。 305 | 306 | ```rust 307 | // in src/vga_buffer.rs 308 | 309 | /// Prints the given formatted string to the VGA text buffer 310 | /// through the global `WRITER` instance. 311 | #[doc(hidden)] 312 | pub fn _print(args: fmt::Arguments) { 313 | use core::fmt::Write; 314 | use x86_64::instructions::interrupts; // new 315 | 316 | interrupts::without_interrupts(|| { // new 317 | WRITER.lock().write_fmt(args).unwrap(); 318 | }); 319 | } 320 | ``` 321 | 322 | [`without_interrupts`] 函数接受一个 [闭包(closure)],并在无中断的环境中执行。我们使用它来确保只要 `Mutex` 处于锁定状态,就不会发生中断。现在运行内核,就可以看到它一直运行而不会挂起。(我们仍然无法看到任何点,但这是因为他们滚动过快。尝试减慢打印速度,例如在循环中加上 `for _ in 0..10000 {}`)。 323 | 324 | [`without_interrupts`]: https://docs.rs/x86_64/0.8.1/x86_64/instructions/interrupts/fn.without_interrupts.html 325 | [闭包(closure)]: https://doc.rust-lang.org/book/second-edition/ch13-01-closures.html 326 | 327 | 我们可以对串行打印函数进行相同的更改,以确保它不会发生死锁: 328 | 329 | ```rust 330 | // in src/serial.rs 331 | 332 | #[doc(hidden)] 333 | pub fn _print(args: ::core::fmt::Arguments) { 334 | use core::fmt::Write; 335 | use x86_64::instructions::interrupts; // new 336 | 337 | interrupts::without_interrupts(|| { // new 338 | SERIAL1 339 | .lock() 340 | .write_fmt(args) 341 | .expect("Printing to serial failed"); 342 | }); 343 | } 344 | ``` 345 | 346 | 值得注意的是,禁用中断不应该成为一种通用的解决方案。这一方案的弊端是,它会延长最坏情况下的中断等待时间,也就是系统对中断做出反应之前的时间。 因此,应该只在非常短的时间内禁用中断。 347 | 348 | ## 修复竞争条件 349 | 350 | 如果你运行 `cargo xtest`,可能会看到 `test_println_output` 测试失败: 351 | 352 | ```shell 353 | > cargo xtest --lib 354 | […] 355 | Running 4 tests 356 | test_breakpoint_exception...[ok] 357 | test_println... [ok] 358 | test_println_many... [ok] 359 | test_println_output... [failed] 360 | 361 | Error: panicked at 'assertion failed: `(left == right)` 362 | left: `'.'`, 363 | right: `'S'`', src/vga_buffer.rs:205:9 364 | ``` 365 | 366 | 这是由测试和定时器处理程序之间的竞争条件导致的。测试程序是这样的: 367 | 368 | ```rust 369 | // in src/vga_buffer.rs 370 | 371 | #[test_case] 372 | fn test_println_output() { 373 | serial_print!("test_println_output... "); 374 | 375 | let s = "Some test string that fits on a single line"; 376 | println!("{}", s); 377 | for (i, c) in s.chars().enumerate() { 378 | let screen_char = WRITER.lock().buffer.chars[BUFFER_HEIGHT - 2][i].read(); 379 | assert_eq!(char::from(screen_char.ascii_character), c); 380 | } 381 | 382 | serial_println!("[ok]"); 383 | } 384 | ``` 385 | 386 | 测试将一个字符串打印到 VGA 缓冲区,然后通过在缓冲区字符数组 `buffer_chars` 上手动迭代来检查输出。 出现竞争条件是因为定时器中断处理程序可能在 `println` 和读取屏幕字符之间运行。注意,这不是危险的 **数据竞争**,Rust 在编译时完全避免了这种竞争。 更多详细信息,可以参考 [_Rustonomicon_][nomicon-races] 。 387 | 388 | [nomicon-races]: https://doc.rust-lang.org/nomicon/races.html 389 | 390 | 要解决这个问题,我们需要在测试的整个持续时间内保持对 `WRITER` 的锁定状态,这样定时器处理程序就不能在操作之间将 `.` 写入屏幕。修复后的测试看起来像这样: 391 | 392 | ```rust 393 | // in src/vga_buffer.rs 394 | 395 | #[test_case] 396 | fn test_println_output() { 397 | use core::fmt::Write; 398 | use x86_64::instructions::interrupts; 399 | 400 | serial_print!("test_println_output... "); 401 | 402 | let s = "Some test string that fits on a single line"; 403 | interrupts::without_interrupts(|| { 404 | let mut writer = WRITER.lock(); 405 | writeln!(writer, "\n{}", s).expect("writeln failed"); 406 | for (i, c) in s.chars().enumerate() { 407 | let screen_char = writer.buffer.chars[BUFFER_HEIGHT - 2][i].read(); 408 | assert_eq!(char::from(screen_char.ascii_character), c); 409 | } 410 | }); 411 | 412 | serial_println!("[ok]"); 413 | } 414 | ``` 415 | 416 | 我们做了下述改动: 417 | 418 | - 显式地使用 `lock()` 方法来保证 `writer` 在整个测试期间都处于锁定状态。使用 [`writeln`] 宏替代 `println` ,这将会允许打印字符到已锁定的 `writer` 中。 419 | - 为避免再次出现死锁,我们在测试期间禁用中断。 否则,在 `writer` 仍然处于锁定状态时,测试可能会中断。 420 | - 由于计时器中断处理程序仍然可以在测试之前运行,因此我们在打印字符串 `s` 之前再打印一个换行符 `'\n'` 。这样可以避免因计时器处理程序已经将一些 `'.'` 字符打印到当前行而引起的测试失败。 421 | 422 | [`writeln`]: https://doc.rust-lang.org/core/macro.writeln.html 423 | 424 | 经过修改后,`cargo xtest` 现在确实又成功了。 425 | 426 | 这是一个相对无害的竞争条件,它只会导致测试失败。可以想象,由于其他竞争条件的不确定性,它们的调试可能更加困难。幸运的是,Rust 防止了数据竞争的出现,这是最严重的竞争条件,因为它们可以导致各种各样的未定义行为,包括系统崩溃和静默内存损坏。 427 | 428 | ## `hlt` 指令 429 | 430 | 到目前为止,我们在 `_start` 和 `panic` 函数的末尾使用了一个简单的空循环语句。这将导致 CPU 无休止地自旋,从而按预期工作。但是这种方法也是非常低效的,因为即使在没有任何工作要做的情况下,CPU 仍然会继续全速运行。在运行内核时,您可以在任务管理器中看到这个问题: QEMU 进程在整个过程中都需要接近 100% 的 CPU。 431 | 432 | 我们真正想做的是让 CPU 停下来,直到下一个中断到达。这允许 CPU 进入休眠状态,在这种状态下它消耗的能量要少得多。[`hlt` 指令] 正是为此而生。让我们使用它来创建一个节能的无限循环: 433 | 434 | [`hlt` 指令]: https://en.wikipedia.org/wiki/HLT_(x86_instruction) 435 | 436 | ```rust 437 | // in src/lib.rs 438 | 439 | pub fn hlt_loop() -> ! { 440 | loop { 441 | x86_64::instructions::hlt(); 442 | } 443 | } 444 | ``` 445 | 446 | `instructions::hlt` 函数只是汇编指令的 [瘦包装]。这是安全的,因为它不可能危及内存安全。 447 | 448 | [瘦包装]: https://github.com/rust-osdev/x86_64/blob/5e8e218381c5205f5777cb50da3ecac5d7e3b1ab/src/instructions/mod.rs#L16-L22 449 | 450 | 现在,我们可以使用 `hlt_loop` 循环来代替 `_start` 和 `panic` 函数中的无限循环: 451 | 452 | ```rust 453 | // in src/main.rs 454 | 455 | #[no_mangle] 456 | pub extern "C" fn _start() -> ! { 457 | […] 458 | 459 | println!("It did not crash!"); 460 | blog_os::hlt_loop(); // new 461 | } 462 | 463 | 464 | #[cfg(not(test))] 465 | #[panic_handler] 466 | fn panic(info: &PanicInfo) -> ! { 467 | println!("{}", info); 468 | blog_os::hlt_loop(); // new 469 | } 470 | 471 | ``` 472 | 473 | 让我们也更新一下 `lib.rs`: 474 | 475 | ```rust 476 | // in src/lib.rs 477 | 478 | /// Entry point for `cargo xtest` 479 | #[cfg(test)] 480 | #[no_mangle] 481 | pub extern "C" fn _start() -> ! { 482 | init(); 483 | test_main(); 484 | hlt_loop(); // new 485 | } 486 | 487 | pub fn test_panic_handler(info: &PanicInfo) -> ! { 488 | serial_println!("[failed]\n"); 489 | serial_println!("Error: {}\n", info); 490 | exit_qemu(QemuExitCode::Failed); 491 | hlt_loop(); // new 492 | } 493 | ``` 494 | 495 | 现在,用 QEMU 运行内核,我们会发现 CPU 使用率大大降低。 496 | 497 | ## 键盘输入 498 | 499 | 现在已经能够处理来自外部设备的中断,我们终于可以添加对键盘输入的支持。 这将是我们与内核进行的第一次交互。 500 | 501 | > 注意,这里只描述如何处理 [PS/2] 键盘,而不包括 USB 键盘。然而,主板会将 USB 键盘模拟为 PS/2 设备,以支持旧的软件,所以可以放心地忽略 USB 键盘,直到内核中有 USB 支持为止。 502 | 503 | [PS/2]: https://en.wikipedia.org/wiki/PS/2_port 504 | 505 | 与硬件定时器一样,键盘控制器也被设置为默认启用。因此,当你按下一个键时,键盘控制器会向 PIC 发送一个中断,然后由 PIC 将中断转发给 CPU 。CPU 在 IDT 中查找处理程序函数,但是相应的表项是空的。所以会引发双重错误。 506 | 507 | 那么,让我们为键盘中断添加一个处理程序函数。它和我们定义的定时器中断处理程序非常相似,只是使用了一个不同的中断类型码: 508 | 509 | ```rust 510 | // in src/interrupts.rs 511 | 512 | #[derive(Debug, Clone, Copy)] 513 | #[repr(u8)] 514 | pub enum InterruptIndex { 515 | Timer = PIC_1_OFFSET, 516 | Keyboard, // new 517 | } 518 | 519 | lazy_static! { 520 | static ref IDT: InterruptDescriptorTable = { 521 | let mut idt = InterruptDescriptorTable::new(); 522 | idt.breakpoint.set_handler_fn(breakpoint_handler); 523 | […] 524 | // new 525 | idt[InterruptIndex::Keyboard.as_usize()] 526 | .set_handler_fn(keyboard_interrupt_handler); 527 | 528 | idt 529 | }; 530 | } 531 | 532 | extern "x86-interrupt" fn keyboard_interrupt_handler( 533 | _stack_frame: &mut InterruptStackFrame) 534 | { 535 | print!("k"); 536 | 537 | unsafe { 538 | PICS.lock() 539 | .notify_end_of_interrupt(InterruptIndex::Keyboard.as_u8()); 540 | } 541 | } 542 | ``` 543 | 544 | 如 [上文](#8259-可编程中断控制器) 中的图例所示,键盘使用了主 PIC 的第 1 条中断控制线。这意味着中断会以中断类型码 33( 1 + 偏移量 32 )的形式到达 CPU 。我们将这个索引作为新的 `Keyboard` 变体添加到 `InterruptIndex` 枚举中。 我们不需要显式指定这个值,因为它默认为前一个值加 1 ,也就是 33 。 在中断处理程序中,我们输出一个 `k` 并将中断结束信号发送给中断控制器。 545 | 546 | 现在看到,当我们按下一个键时,屏幕上会出现一个 `k` 。 然而,这只适用于按下的第一个键,即使我们继续按键,也不会有更多的 `k` 出现在屏幕上。 这是因为键盘控制器在我们读取所谓的「键盘扫描码(scancode)」之前不会发送另一个中断。 547 | 548 | ### 读取键盘扫描码 549 | 550 | 要找出按了 _哪个_ 键,需要查询键盘控制器。我们可以通过读取 PS/2 控制器的数据端口来实现这一点,该端口属于 [I/O 端口] ,编号为 `0x60` : 551 | 552 | [I/O 端口]: @/second-edition/posts/04-testing/index.md#i-o-ports 553 | 554 | ```rust 555 | // in src/interrupts.rs 556 | 557 | extern "x86-interrupt" fn keyboard_interrupt_handler( 558 | _stack_frame: &mut InterruptStackFrame) 559 | { 560 | use x86_64::instructions::port::Port; 561 | 562 | let mut port = Port::new(0x60); 563 | let scancode: u8 = unsafe { port.read() }; 564 | print!("{}", scancode); 565 | 566 | unsafe { 567 | PICS.lock() 568 | .notify_end_of_interrupt(InterruptIndex::Keyboard.as_u8()); 569 | } 570 | } 571 | ``` 572 | 573 | 我们使用 `x86_64` 包提供的端口类型 [`Port`] 从键盘的数据端口读取一个字节。这个字节就是「[**键盘扫描码**]」,一个表示物理键 按下/松开 的数字。 目前,我们还没有对键盘扫描码进行处理,只是把它打印到屏幕上: 574 | 575 | [`Port`]: https://docs.rs/x86_64/0.8.1/x86_64/instructions/port/struct.Port.html 576 | [**键盘扫描码**]: https://en.wikipedia.org/wiki/Scancode 577 | 578 | ![QEMU printing scancodes to the screen when keys are pressed](https://os.phil-opp.com/hardware-interrupts/qemu-printing-scancodes.gif) 579 | 580 | 上图显示了我正在慢慢地键入字符串 `"123"` 。可以看到,相邻物理键的键盘扫描码也相邻,而 按下/松开 物理键触发的键盘扫描码是不同的。但是我们如何将键盘扫描码转换为实际的按键操作呢? 581 | 582 | ### 解释键盘扫描码 583 | 584 | 键盘扫描码和物理键之间的映射有三种不同的标准,即所谓的「键盘扫描码集」。这三者都可以追溯到早期 IBM 计算机的键盘:[IBM XT]、 [IBM 3270 PC] 和 [IBM AT] 。幸运地是,后来的计算机没有继续定义新的键盘扫描码集的趋势,而是对现有的集合进行模拟和扩展。时至今日,大多数键盘都可以配置为模拟这三种标准中的任何一组。 585 | 586 | [IBM XT]: https://en.wikipedia.org/wiki/IBM_Personal_Computer_XT 587 | [IBM 3270 PC]: https://en.wikipedia.org/wiki/IBM_3270_PC 588 | [IBM AT]: https://en.wikipedia.org/wiki/IBM_Personal_Computer/AT 589 | 590 | 默认情况下,PS/2 键盘模拟键盘扫描码集 1(「XT」)。在这个码集中,每个键盘扫描码的低 7 位字节定义了物理键信息,而最高有效位则定义了物理键状态是按下(「0」)还是释放(「1」)。原始的「IBM XT」键盘上没有的键,如键盘上的 `enter` 键,会连续生成两个键盘扫描码: `0xe0` 转义字节和一个表示物理键的字节。有关键盘扫描码集 1 中的所有键盘扫描码及其对应物理键的列表,请访问 [OSDev Wiki][scancode set 1] 。 591 | 592 | [scancode set 1]: https://wiki.osdev.org/Keyboard#Scan_Code_Set_1 593 | 594 | 要将键盘扫描码转换为按键操作,可以使用 `match` 语句: 595 | 596 | ```rust 597 | // in src/interrupts.rs 598 | 599 | extern "x86-interrupt" fn keyboard_interrupt_handler( 600 | _stack_frame: &mut InterruptStackFrame) 601 | { 602 | use x86_64::instructions::port::Port; 603 | 604 | let mut port = Port::new(0x60); 605 | let scancode: u8 = unsafe { port.read() }; 606 | 607 | // new 608 | let key = match scancode { 609 | 0x02 => Some('1'), 610 | 0x03 => Some('2'), 611 | 0x04 => Some('3'), 612 | 0x05 => Some('4'), 613 | 0x06 => Some('5'), 614 | 0x07 => Some('6'), 615 | 0x08 => Some('7'), 616 | 0x09 => Some('8'), 617 | 0x0a => Some('9'), 618 | 0x0b => Some('0'), 619 | _ => None, 620 | }; 621 | if let Some(key) = key { 622 | print!("{}", key); 623 | } 624 | 625 | unsafe { 626 | PICS.lock() 627 | .notify_end_of_interrupt(InterruptIndex::Keyboard.as_u8()); 628 | } 629 | } 630 | ``` 631 | 632 | 上面的代码转换数字键 0-9 的按键操作,并忽略所有其他键。 它使用 [match] 语句为每个键盘扫描码分配相应的字符或 `None`。 然后它使用 [`if let`] 来解构可选的 `key` 。 通过在模式中使用相同的变量名 `key` ,我们可以 [隐藏] 前面的声明,这是 Rust 中解构 `Option` 类型的常见模式。 633 | 634 | [match]: https://doc.rust-lang.org/book/ch06-02-match.html 635 | [`if let`]: https://doc.rust-lang.org/book/ch18-01-all-the-places-for-patterns.html#conditional-if-let-expressions 636 | [隐藏]: https://doc.rust-lang.org/book/ch03-01-variables-and-mutability.html#shadowing 637 | 638 | 现在我们可以往屏幕上写数字了: 639 | 640 | ![QEMU printing numbers to the screen](https://os.phil-opp.com/hardware-interrupts/qemu-printing-numbers.gif) 641 | 642 | 我们也可以用同样的方式转换其他按键操作。幸运的是,有一个名为 [`pc-keyboard`] 的包,专门用于翻译键盘扫描码集 1 和 2 中的键盘扫描码,因此我们无须自己实现。要使用这个包,需要将它添加到 `Cargo.toml` 内,并导入到 `lib.rs` 中: 643 | 644 | [`pc-keyboard`]: https://docs.rs/pc-keyboard/0.5.0/pc_keyboard/ 645 | 646 | ```toml 647 | # in Cargo.toml 648 | 649 | [dependencies] 650 | pc-keyboard = "0.5.0" 651 | ``` 652 | 653 | 现在我们可以使用这个包来重写键盘中断处理程序 `keyboard_interrupt_handler`: 654 | 655 | ```rust 656 | // in/src/interrupts.rs 657 | 658 | extern "x86-interrupt" fn keyboard_interrupt_handler( 659 | _stack_frame: &mut InterruptStackFrame) 660 | { 661 | use pc_keyboard::{layouts, DecodedKey, HandleControl, Keyboard, ScancodeSet1}; 662 | use spin::Mutex; 663 | use x86_64::instructions::port::Port; 664 | 665 | lazy_static! { 666 | static ref KEYBOARD: Mutex> = 667 | Mutex::new(Keyboard::new(layouts::Us104Key, ScancodeSet1, 668 | HandleControl::Ignore) 669 | ); 670 | } 671 | 672 | let mut keyboard = KEYBOARD.lock(); 673 | let mut port = Port::new(0x60); 674 | 675 | let scancode: u8 = unsafe { port.read() }; 676 | if let Ok(Some(key_event)) = keyboard.add_byte(scancode) { 677 | if let Some(key) = keyboard.process_keyevent(key_event) { 678 | match key { 679 | DecodedKey::Unicode(character) => print!("{}", character), 680 | DecodedKey::RawKey(key) => print!("{:?}", key), 681 | } 682 | } 683 | } 684 | 685 | unsafe { 686 | PICS.lock() 687 | .notify_end_of_interrupt(InterruptIndex::Keyboard.as_u8()); 688 | } 689 | } 690 | ``` 691 | 692 | 我们使用 `lazy_static` 宏来创建一个由互斥锁保护的静态对象 [`Keyboard`]。 我们使用美国键盘布局初始化键盘,并采用键盘扫描码集 1 。 [`HandleControl`] 参数允许将 `ctrl+[a-z]` 映射到 Unicode 字符 `U+0001` - `U+001A` 。 我们不想这样做,所以使用 `Ignore` 选项来像处理普通键一样处理 `ctrl` 键。 693 | 694 | [`Handlecontrol`]: https://docs.rs/pc-keyboard/0.5.0/pc_keyboard/enum.HandleControl.html 695 | 696 | 每当中断发生,我们锁定互斥对象,从键盘控制器读取键盘扫描码并将其传递给 [`add_byte`] 方法,后者将键盘扫描码转换为 `Option` 。 [`KeyEvent`] 包含引发事件的物理键以及它的事件类型——按下或是松开。 697 | 698 | [`Keyboard`]: https://docs.rs/pc-keyboard/0.5.0/pc_keyboard/struct.Keyboard.html 699 | [`add_byte`]: https://docs.rs/pc-keyboard/0.5.0/pc_keyboard/struct.Keyboard.html#method.add_byte 700 | [`keyEvent`]: https://docs.rs/pc-keyboard/0.5.0/pc_keyboard/struct.KeyEvent.html 701 | 702 | 为了解释按键事件,我们将其传递给 [`process_keyevent`] ,该方法将按键事件转换为字符。例如,根据是否按下 `shift` 键,将物理键 `a` 的按下事件转换为对应的小写字符或大写字符。 703 | 704 | [`process_keyevent`]: https://docs.rs/pc-keyboard/0.5.0/pc_keyboard/struct.Keyboard.html#method.process_keyevent 705 | 706 | 有了这个修改过的中断处理程序,我们就可以写一些文本内容: 707 | 708 | ![Typing "Hello World" in QEMU](https://os.phil-opp.com/hardware-interrupts/qemu-typing.gif) 709 | 710 | ### 配置键盘 711 | 712 | 我们也可以对 PS/2 键盘的某些方面进行配置,例如应该使用哪个键盘扫描码集。我们不会在这里讨论它,因为这篇文章已经足够长了,但是 OSDev Wiki 上有一篇关于可能的 [配置命令] 的概述。 713 | 714 | [配置命令]: https://wiki.osdev.org/PS/2_Keyboard#Commands 715 | 716 | ## 小结 717 | 718 | 这篇文章解释了如何启用和处理外部中断。 我们学习了 8259 PIC 和经典的主/从二级布局,中断类型码的重映射,以及「中断结束」信号。 我们实现了硬件定时器和键盘的中断处理程序,并且学习了 `hlt` 指令,它会暂停 CPU 直到触发下一个中断。 719 | 720 | 现在我们可以与内核进行交互,并且有一些基本的构建块,可以用来创建一个小 shell 或简单的游戏。 721 | 722 | ## 下篇预告 723 | 724 | 定时器中断对于操作系统来说是必不可少的,因为它们提供了一种周期性地中断运行进程并使内核重新获得控制权的方法。这样一来,内核就可以切换到不同的进程,并营造出一种多个进程并行运行的错觉。 725 | 726 | 但是在创建进程或线程之前,我们需要一种为它们分配内存的方法。下一篇文章将探讨内存管理,以提供这一基本构建块。 727 | -------------------------------------------------------------------------------- /08-introduction-to-paging.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: 介绍分页 3 | date: 2019-01-31 18:20:38 4 | tags: [Memory Management] 5 | summary: 这篇文章介绍了分页,这是一种非常常见的内存管理方案,我们也将将其用于我们的操作系统。 它解释了为什么需要内存隔离,分段如何工作,虚拟内存是什么,以及分页如何解决内存碎片问题。 它还探讨了x86_64架构上多级页表的布局。 6 | --- 7 | 8 | ## 内存保护 9 | 10 | 操作系统的一个主要任务是将程序彼此隔离。例如,您的Web浏览器不应该干扰您的文本编辑器。为实现此目标,操作系统利用硬件功能来确保一个进程的内存区域不能被其他进程访问。根据硬件和操作系统的实现,有不同的方法来做到这一点。 11 | 12 | 例如,某些ARM Cortex-M处理器(通常用于嵌入式系统)具有内存保护单元(MPU),它允许您定义少量(例如8个)内存区域具有的不同访问权限(例如,无访问权限,只读,读写)。在每次内存访问时,MPU确保该地址位于具有正确访问权限的区域中,否则会抛出一个异常。通过在每次切换进程的时候同时也切换内存区域和访问权限,操作系统可以确保每个进程只访问自己的内存,从而将进程彼此隔离。 13 | 14 | 在x86上,硬件支持两种不同的内存保护方法:分段和分页。 15 | 16 | ## 分段 17 | 18 | 分段在1978年就已经引入了,最初是为了增加可寻址内存的数量。当时的情况是CPU只有16位地址线,这将可寻址内存量限制为64KiB。为了访问这64KiB之外的内存,引入了额外的段寄存器,每个寄存器包含一个偏移地址。 CPU会在每次内存访问时自动添加此偏移量,以便访问高达1MiB的内存。 19 | 20 | 段寄存器由CPU自动选择,具体取决于存储器访问的类型:对于获取指令,使用代码段CS,对于堆栈操作(push/pop),使用堆栈段SS。其他指令使用数据段DS或额外段ES。后来增加了两个额外的段寄存器FS和GS,可以自由使用。 21 | 22 | 在最初的分段机制设计中,段寄存器直接存储偏移量,并且没有访问控制。后来随着保护模式的引入,这一点改变了。当CPU以此模式运行时,段描述符包含本地或全局描述符表的索引,该表除了包含偏移地址外,还包含段大小和访问权限。通过为每个进程加载单独的全局/本地描述符表来限制对进程自身内存区域的内存访问,操作系统可以将进程彼此隔离。 23 | 24 | 通过在实际访问之前修改存储器地址,分段机制已经采用了现在几乎在任何地方使用的技术:虚拟内存。 25 | 26 | ## 虚拟内存 27 | 28 | 虚拟内存背后的想法是从底层物理存储设备中抽象出内存地址。不是直接访问存储设备,而是首先执行转换步骤。对于分段,转换步骤是添加活动段的偏移地址。想象一个程序访问偏移量为`0x1111000`的段中的内存地址`0x1234000`:真正访问的地址是`0x2345000`。 29 | 30 | 为了区分这两种地址类型,转换前的地址称为虚拟地址,转换后的地址称为物理地址。这两种地址之间的一个重要区别是物理地址是独一无二的的,并且始终指向相同的,唯一的存储位置。另一方面,虚拟地址取决于翻译功能。两个不同的虚拟地址完全可能指的是相同的物理地址。此外,相同的虚拟地址在使用不同的转换功能时可以指向不同的物理地址。 31 | 32 | 证明此属性有用的一个例子是并行运行相同的程序两次: 33 | 34 | ![Two virtual address spaces with address 0–150, one translated to 100–250, the other to 300–450](https://os.phil-opp.com/paging-introduction/segmentation-same-program-twice.svg) 35 | 36 | 这里相同的程序被运行了两次,但具有不同的地址映射。 第一个实例的段偏移量为100,因此其虚拟地址0-150被转换为物理地址100-250。 第二个实例具有偏移300,其将其虚拟地址0-150转换为物理地址300-450。 这允许两个程序运行相同的代码并使用相同的虚拟地址而不会相互干扰。 37 | 38 | 另一个优点是,程序现在可以被放置在任意物理内存位置,即使它们使用完全不同的虚拟地址。 因此,OS可以利用全部可用内存而无需重新编译程序。 39 | 40 | ## 碎片 41 | 42 | 虚拟地址和物理地址之间的区别使得分段功能非常强大。 但是,它存在碎片问题。 举个例子,假设我们要运行上面看到的程序的第三个副本: 43 | 44 | ![Three virtual address spaces, but there is not enough continuous space for the third](https://os.phil-opp.com/paging-introduction/segmentation-fragmentation.svg) 45 | 46 | 即使有足够的可用内存,也无法将程序的第三个实例映射到虚拟内存而不会重叠。 问题是我们需要连续的内存,不能使用小的空闲块。 47 | 48 | 解决掉这种碎片的一种方法是暂停执行,将存储器的已使用部分移近一些,更新地址映射,然后恢复执行: 49 | 50 | 51 | 52 | ![Three virtual address spaces after defragmentation](https://os.phil-opp.com/paging-introduction/segmentation-fragmentation-compacted.svg) 53 | 54 | 现在有足够的连续空间来启动我们程序的第三个实例了。 55 | 56 | 这种碎片整理过程的缺点是需要复制大量内存,这会降低系统性能。 在内存变得过于分散之前,还需要定期进行。 这使得系统的行为变得不可预测,因为程序可能在任何时间暂停并且可能无响应[^1]。 57 | 58 | 碎片问题是大多数操作系统不再使用分段的原因之一。 实际上,x86的64位模式甚至不再支持分段。 而是使用分页,这完全避免了碎片问题。 59 | 60 | ## 分页 61 | 62 | 我们的想法是将虚拟和物理内存空间分成小的固定大小的块。 虚拟存储器空间的块称为页面,物理地址空间的块称为帧。 每个页面可以单独映射到一个帧,这样就可以跨越非连续的物理帧分割更大的内存区域。 63 | 64 | 如果我们回顾碎片化内存空间的示例,但是这次使用分页而不是分段,这一点的优势变得明显: 65 | 66 | ![With paging the third program instance can be split across many smaller physical areas](https://os.phil-opp.com/paging-introduction/paging-fragmentation.svg) 67 | 68 | 在这个例子中,我们的页面大小为50字节,这意味着我们的每个内存区域分为三页。 每个页面都单独映射到一个帧,因此连续的虚拟内存区域可以映射到非连续的物理帧。 这允许我们在不执行任何碎片整理的情况下启动程序的第三个实例。 69 | 70 | ## 隐藏的碎片 71 | 72 | 与分段相比,分页使用许多小的固定大小的存储区域而不是几个大的可变大小的区域。由于每个帧具有相同的大小,因此没有太小的帧不能使用,因此不会发生碎片。 73 | 74 | 似乎没有碎片会出现。但是事实上仍然会存在一些隐藏的碎片,即所谓的内部碎片。发生内部碎片是因为并非每个内存区域都是页面大小的精确倍数。想象一下上面例子中一个大小为101的程序:它仍然需要三个大小为50的页面,因此它将比所需的多占用49个字节。为了区分这两种类型的碎片,使用分段时发生的碎片类型称为外部碎片。 75 | 76 | 出现内部碎片是很不幸的,但通常比使用分段时出现的外部碎片更好。它仍然浪费内存,但不需要进行碎片整理,并且可以预测碎片量(平均每个内存区域半页)。 77 | 78 | ## 页表 79 | 80 | 我们看到可能有数百万个页面被映射到单独的一个帧。 此映射信息需要存储在某处。 分段对每个活动的内存区域使用单独的段选择器寄存器,这对于分页是不可能的,因为页面的数量远多于寄存器。 相反,分页使用称为页表的表结构来存储映射信息。 81 | 82 | 对于上面的示例,页表将如下所示: 83 | 84 | ![Three page tables, one for each program instance. For instance 1 the mapping is 0->100, 50->150, 100->200. For instance 2 it is 0->300, 50->350, 100->400. For instance 3 it is 0->250, 50->450, 100->500.](https://os.phil-opp.com/paging-introduction/paging-page-tables.svg) 85 | 86 | 我们看到每个程序实例都有自己的页表。 指向当前活跃的页表的指针存储在特殊CPU寄存器中。 在x86上,该寄存器称为CR3。 在运行每个程序实例之前,操作系统要将指向正确页表的指针加载到该寄存器。 87 | 88 | 在每次访问内存时,CPU从寄存器中读取页表指针,并在表中查找要访问页面的映射到的帧。 这完全由硬件完成,对运行的程序完全透明。 为了加快转换过程,许多CPU架构都有一个特殊的缓存,可以记住上次翻译的结果。 89 | 90 | 在一些体系结构上,页表条目还可以在标志字段中存储诸如访问权限之类的属性。 在上面的例子中,“r/w”标志表示页面既可读又可写。 91 | 92 | ## 多级页表 93 | 94 | 我们刚刚看到的简单页表在较大的地址空间中存在问题:它们本身要占用很多内存。 例如,假设一个程序使用四个虚拟页面0,1_000_000,1_000_050和1_000_100(我们使用_作为千位分隔符): 95 | 96 | ![Page 0 mapped to frame 0 and pages 1_000_000–1_000_150 mapped to frames 100–250](https://os.phil-opp.com/paging-introduction/single-level-page-table.svg) 97 | 98 | 这个程序运行只需要4个物理帧,但页表中有超过一百万个条目。 我们不能省略空条目,因为那样的话CPU在翻译过程中不再能直接跳转到表中的正确条目(例如,不再保证第四页的数据在第四个条目)[^2]。 99 | 100 | 为了减少浪费的内存,我们可以使用**两级页表**。 我们的想法是我们为不同的地址区域使用不同的页表。 另一个称为2级页表的表包含虚拟地址区域和1级页表之间的映射。 101 | 102 | 最好用一个例子来解释。 让我们定义每个1级页面表负责一个大小为10_000的区域。 然后,上面的示例映射将存在以下表: 103 | 104 | ![Page 0 points to entry 0 of the level 2 page table, which points to the level 1 page table T1. The first entry of T1 points to frame 0, the other entries are empty. Pages 1_000_000–1_000_150 point to the 100th entry of the level 2 page table, which points to a different level 1 page table T2. The first three entries of T2 point to frames 100–250, the other entries are empty.](https://os.phil-opp.com/paging-introduction/multilevel-page-table.svg) 105 | 106 | 第0页在第一个10_000字节区域中,因此它使用2级页表的第一个条目。此条目指向1级页表T1,它指出页0指向的是第0帧。 107 | 108 | 页面1_000_000,1_000_050和1_000_100都属于第100个10_000字节区域,因此它们使用2级页面表的第100个条目。该条目指向另一个的1级页表T2,其将三个页面映射到帧100,150和200。注意,1级页表中的页面地址不包括区域偏移,因此例如,一级页表中第1_000_050页的条目就只是50(而非1_000_050)。 109 | 110 | 我们在2级页表中仍然有100个空条目,但比之前的百万个空条目少得多。这种节省的原因是我们不需要为10_000和1_000_000之间的未映射内存区域创建1级页面表。 111 | 112 | 两级页表的原理可以扩展到三级,四级或更多级。然后页表寄存器指向最高级别表,该表指向下一个较低级别的表,再指向下一个较低级别的表,依此类推。然后,1级页面表指向映射的帧。这整个原理被称为多级或分层页表。 113 | 114 | 现在我们知道分页和多级页表如何工作,我们可以看一下如何在x86_64架构中实现分页(我们假设CPU在64位模式下运行)。 115 | 116 | ## x86_64下的分页 117 | 118 | x86_64体系结构使用4级页表,页面大小为4KiB。 每个页表,无论是哪级页表,具有固定大小512个条目。 每个条目的大小为8个字节,因此每个页表大小都为512 * 8B = 4KiB大,恰好能装入一个页面。 119 | 120 | 每个级别的页表索引可以直接从虚拟地址中读出: 121 | 122 | ![Bits 0–12 are the page offset, bits 12–21 the level 1 index, bits 21–30 the level 2 index, bits 30–39 the level 3 index, and bits 39–48 the level 4 index](https://os.phil-opp.com/paging-introduction/x86_64-table-indices-from-address.svg) 123 | 124 | 我们看到每个表索引由9位组成,这是因为每个表有2^9 = 512个条目。 最低的12位是4KiB页面中的偏移(2^12字节= 4KiB)。48到64位没用,这意味着x86_64实际上不是64位,因为它只支持48位地址。 有计划通过5级页表将地址大小扩展到57位,但是还没有支持此功能的处理器。 125 | 126 | 即使48到64位不被使用,也不能将它们设置为任意值。 相反,此范围内的所有位必须是第47位的副本,以保持地址的唯一性,并允许未来的扩展,如5级页表。 这称为符号扩展,因为它与二进制补码中的符号扩展非常相似。 如果地址未正确进行符号扩展,则CPU会抛出异常。 127 | 128 | ## 地址翻译的示例 129 | 130 | 让我们通过一个例子来了解地址翻译过程的详细工作原理: 131 | 132 | ![An example 4-level page hierarchy with each page table shown in physical memory](https://os.phil-opp.com/paging-introduction/x86_64-page-table-translation.svg) 133 | 134 | 当前活跃的的4级页表的物理地址(这个4级页表的基地址)存储在CR3寄存器中。 然后,每个页表条目指向下一级表的物理帧。 然后,1级页表的条目指向映射到的帧。 请注意,页表中的所有地址都是物理的而不是虚拟的,否则CPU也需要转换这些地址,导致永无止境的递归。 135 | 136 | 上面的页表层次结构映射了两个页面(用蓝色表示)。 从页表索引我们可以推断出这两个页面的虚拟地址是`0x803FE7F000`和`0x803FE00000`。 让我们看看当程序试图从地址`0x803FE7F5CE`读取时会发生什么。 首先,我们将地址转换为二进制,并确定页表索引和地址的页面偏移量: 137 | 138 | ![The sign extension bits are all 0, the level 4 index is 1, the level 3 index is 0, the level 2 index is 511, the level 1 index is 127, and the page offset is 0x5ce](https://os.phil-opp.com/paging-introduction/x86_64-page-table-translation-addresses.png) 139 | 140 | 使用这些页表索引,我们现在可以遍历页表层次结构以确定地址的映射帧: 141 | 142 | - 我们首先从CR3寄存器中读取4级页表的地址。 143 | - 4级索引是1,所以我们查看该表的索引1的条目,它告诉我们3级页表存储在物理地址16KiB处。 144 | - 我们从该地址加载3级表,并查看索引为0的条目,它将我们指向物理地址24KiB处的2级页表。 145 | - 2级索引是511,因此我们查看该页面的最后一个条目以找出1级页表的地址。 146 | - 通过级别1表的索引127的条目,我们最终发现页面映射到物理地址为12KiB(或十六进制的0xc000)的帧。 147 | - 最后一步是将页面偏移量加到获得的帧的基地址中,以获得最终的物理地址0xc000 + 0x5ce = 0xc5ce。 148 | 149 | ![The same example 4-level page hierarchy with 5 additional arrows: "Step 0" from the CR3 register to the level 4 table, "Step 1" from the level 4 entry to the level 3 table, "Step 2" from the level 3 entry to the level 2 table, "Step 3" from the level 2 entry to the level 1 table, and "Step 4" from the level 1 table to the mapped frames.](https://os.phil-opp.com/paging-introduction/x86_64-page-table-translation-steps.svg) 150 | 151 | 1级页表中页面的权限是`r`,表示只读。 硬件会强制保证这些权限,如果我们尝试写入该页面,则会抛出异常。 更高级别页面中的权限限制了较低级别的权限,因此如果我们将3级页表中的条目设置为只读,则使用此条目的页面都不可写,即使较低级别指定读/写权限也是如此。 152 | 153 | 重要的是要注意,尽管此示例仅使用每个表的单个实例,但每个地址空间中通常存在多个每个级别页表的实例。 最多,有: 154 | 155 | - 一个4级页表 156 | - 512个3级页表(因为4级页表有512个条目) 157 | - 512*512个2级页表(因为512个3级页表中的每一个都有512个条目) 158 | - 512 * 512 * 512个1级页表(每个2级页表512个条目) 159 | 160 | ## 页表格式 161 | 162 | x86_64体系结构上的页表基本上是512个条目的数组。 在Rust语法中: 163 | 164 | ```rust 165 | #[repr(align(4096))] 166 | pub struct PageTable { 167 | entries: [PageTableEntry; 512], 168 | } 169 | ``` 170 | 171 | 如repr属性所示,页表需要页面对齐,即在4KiB边界上进行内存对齐。 此要求可确保页表始终填充整个页面,并允许进行优化,使条目非常紧凑。 172 | 173 | 每个条目都是8字节(64位)大,具有以下格式: 174 | 175 | | Bit(s) | Name | Meaning | 176 | | ------ | --------------------- | ------------------------------------------------------------ | 177 | | 0 | present | 这个页面是否正在内存中 | 178 | | 1 | writable | 这个页面是否可写 | 179 | | 2 | user accessible | 这个页面是否可以被用户态访问 | 180 | | 3 | write through caching | 对这个页面的写入是否直接进入内存(不经过cache) | 181 | | 4 | disable cache | 是否完全禁止使用cache | 182 | | 5 | accessed | 当这个页面正在被使用时,这个位会被CPU自动设置 | 183 | | 6 | dirty | 当这个页面有被写入时,CPU会自动被CPU设置 | 184 | | 7 | huge page/null | 在 1级和4级页表中必须为0,在3级页表中会创建1GiB的内存页,在2级页表中会创建2MiB的内存页 | 185 | | 8 | global | 地址空间切换时,页面不会被换出cache ( CR4 中的PGE 位必须被设置) | 186 | | 9-11 | available | OS可以随意使用 | 187 | | 12-51 | physical address | 物理帧地址或下一个页表的地址 | 188 | | 52-62 | available | OS可以随意使用 | 189 | | 63 | no execute | 禁止将这个页面上的数据当作代码执行 (EFER寄存器中的NXE位必须被设置) | 190 | 191 | 我们看到只有12-51位用于存储物理帧地址,其余位用作标志或可由操作系统自由使用。 这是因为我们总是指向一个4096字节的对齐地址,可以是页面对齐的页表,也可以是映射到的帧的开头。 这意味着0-11位始终为零,因此没有理由存储这些位,因为硬件可以在使用地址之前将它们设置为零。 对于位52-63也是如此,因为x86_64架构仅支持52位物理地址(类似于它仅支持48位虚拟地址)。 192 | 193 | 让我们仔细看看可用的标志位: 194 | 195 | - `present`标志将映射过的页面与未映射页面区分开来。当主内存已满时,它可用于临时将页面换出到磁盘。随后访问页面时,会发生一个称为缺页异常的特殊异常,操作系统可以从磁盘重新加载缺少的页面然后继续执行该程序。 196 | - `writable`和`no execute`标志分别控制页面内容是否可写和是否包含可执行指令。 197 | - 当对页面进行读取或写入时,CPU会自动设置`accessed`和`dirty`标志。该信息可以被操作系统所利用,例如确定自上次保存到磁盘后要换出的页面或页面内容是否被修改。 198 | - 通过`write through caching`和 `disable cache` 标志的写入允许单独控制每个页面的缓存。 199 | - `user accessible` 标志使页面可用于用户态的代码,否则只有在CPU处于内核态时才可访问该页面。通过在用户空间程序运行时保持内核映射,此功能可用于更快地进行系统调用。但是,Spectre漏洞可以允许用户空间程序读取这些页面。 200 | - `global`标志告诉硬件这个页在所有地址空间中都可用,因此不需要从地址空间切换的高速缓存中删除(请参阅下面有关TLB的部分)。该标志通常与用户可访问标志(设置为0)一起使用,以将内核代码映射到所有地址空间。 201 | - `huge page` 标志允许通过让级别2或级别3页表的条目直接指向映射的帧来创建更大尺寸的页面。设置此位后,对于2级条目,页面大小增加512倍至2MiB = 512 * 4KiB,对于3级条目,页面大小甚至增加到了1GiB = 512 * 2MiB。使用较大页面的优点是需要更少的地址切换缓存行和更少的页表。 202 | 203 | `x86_64` crate为页表及其条目提供了类型,因此我们不需要自己创建这些结构。 204 | 205 | ## 转译后备缓冲器[^3] 206 | 207 | 4级页表使得虚拟地址的转换变得昂贵,因为每次地址翻译需要4次内存访问。为了提高性能,x86_64架构将最后几个转换缓存在所谓的转译后备缓冲器(TLB)中。这允许在仍然某个地址翻译仍然在缓存中时跳过翻译。 208 | 209 | 与其他CPU缓存不同,TLB不是完全透明的,并且在页表的内容发生更改时不会更新或删除缓存的转换规则。这意味着内核必须在修改页表时手动更新TLB。为此,有一个名为`invlpg`的特殊CPU指令(“invalidate page”),用于从TLB中删除指定页面的转换规则,下次访问时这个转换规则将从页表中从新加载。通过重新设置CR3寄存器,假装进行一次地址空间转换,也可以完全刷新TLB。 `x86_64` crate在tlb模块中提供了实现这两个功能的Rust函数。 210 | 211 | 重要的是要记住在每个页表修改时也要同时刷新TLB,否则CPU可能会继续使用旧的转换规则,这可能导致不确定的错误,这些错误很难调试。 212 | 213 | ## 实现 214 | 215 | 我们还没有提到的一件事:**我们的内核已经有分页机制了**。 我们在“A minimal Rust Kernel”那一篇文章中添加的引导加载程序已经设置了一个4级分页层次结构,它将我们内核的每个页面映射到一个物理帧。 bootloader程序执行此操作是因为在x86_64上的64位模式下必须进行分页。 216 | 217 | 这意味着我们在内核中使用的每个内存地址都是一个虚拟地址。 访问地址为0xb8000的VGA缓冲区能用,这是因为bootloader程序已经将该内存页映射到本身了,这意味着它将虚拟页0xb8000映射到物理帧0xb8000。 218 | 219 | 分页使我们的内核已经相对安全,因为每个超出范围的内存访问都会导致页面错误异常,而不是写入随机的物理内存。 引导加载程序甚至为每个页面设置了正确的访问权限,这意味着只有包含代码的页面是可执行的,只有数据页面是可写的。 220 | 221 | ## 页面错误 222 | 223 | 让我们尝试通过访问内核之外的一些内存来导致页面错误。 首先,我们创建一个页面错误处理程序并在我们的IDT中注册它,以便我们看到page fault exception而不是通用的double fault: 224 | 225 | ```rust 226 | // in src/interrupts.rs 227 | 228 | lazy_static! { 229 | static ref IDT: InterruptDescriptorTable = { 230 | let mut idt = InterruptDescriptorTable::new(); 231 | 232 | […] 233 | 234 | idt.page_fault.set_handler_fn(page_fault_handler); // new 235 | 236 | idt 237 | }; 238 | } 239 | 240 | use x86_64::structures::idt::PageFaultErrorCode; 241 | 242 | extern "x86-interrupt" fn page_fault_handler( 243 | stack_frame: &mut ExceptionStackFrame, 244 | _error_code: PageFaultErrorCode, 245 | ) { 246 | use crate::hlt_loop; 247 | use x86_64::registers::control::Cr2; 248 | 249 | println!("EXCEPTION: PAGE FAULT"); 250 | println!("Accessed Address: {:?}", Cr2::read()); 251 | println!("{:#?}", stack_frame); 252 | hlt_loop(); 253 | } 254 | ``` 255 | 256 | `CR2`寄存器由CPU在页面错误时自动设置,并包含导致页面错误的虚拟地址。 我们使用`x86_64` crate 的`Cr2 :: read`函数来读取和打印它。 通常,`PageFaultErrorCode`类型将提供有关导致页面错误的内存访问类型的更多信息,但目前有一个传递无效错误代码的LLVM bug[^4],因此我们暂时忽略它。 我们无法在不解决页面错误的情况下继续执行程序,因此我们最后会进入一个`hlt_loop`。 257 | 258 | 现在我们可以尝试访问内核之外的一些内存: 259 | 260 | ```rust 261 | // in src/main.rs 262 | 263 | #[cfg(not(test))] 264 | #[no_mangle] 265 | pub extern "C" fn _start() -> ! { 266 | use blog_os::interrupts::PICS; 267 | 268 | println!("Hello World{}", "!"); 269 | 270 | // set up the IDT first, otherwise we would enter a boot loop instead of 271 | // invoking our page fault handler 272 | blog_os::gdt::init(); 273 | blog_os::interrupts::init_idt(); 274 | unsafe { PICS.lock().initialize() }; 275 | x86_64::instructions::interrupts::enable(); 276 | 277 | // new 278 | let ptr = 0xdeadbeaf as *mut u32; 279 | unsafe { *ptr = 42; } 280 | 281 | println!("It did not crash!"); 282 | blog_os::hlt_loop(); 283 | } 284 | ``` 285 | 286 | 当我们运行这个程序,我们可以看到页面错误异常的回调函数被调用了: 287 | 288 | ![EXCEPTION: Page Fault, Accessed Address: VirtAddr(0xdeadbeaf), ExceptionStackFrame: {…}](https://os.phil-opp.com/paging-introduction/qemu-page-fault.png) 289 | 290 | `CR2`寄存器确实包含`0xdeadbeaf`,我们试图访问的地址。 291 | 292 | 我们看到当前指令指针是`0x20430a`,所以我们可以知道这个地址指向一个代码页。 代码页由引导加载程序以只读方式映射,因此从该地址读取有效但写入会导致页面错误。 您可以通过将`0xdeadbeaf`指针更改为`0x20430a`来尝试此操作: 293 | 294 | ```rust 295 | // Note: The actual address might be different for you. Use the address that 296 | // your page fault handler reports. 297 | let ptr = 0x20430a as *mut u32; 298 | // read from a code page -> works 299 | unsafe { let x = *ptr; } 300 | // write to a code page -> page fault 301 | unsafe { *ptr = 42; } 302 | ``` 303 | 304 | 注释掉最后一行,我们可以看出读操作成功了,但写操作会导致一个页面异常。 305 | 306 | ## 读取页表 307 | 308 | 让我们试着看看我们的内核运行时用的页面表: 309 | 310 | ```rust 311 | // in src/main.rs 312 | 313 | #[cfg(not(test))] 314 | #[no_mangle] 315 | pub extern "C" fn _start() -> ! { 316 | […] // initialize GDT, IDT, PICS 317 | 318 | use x86_64::registers::control::Cr3; 319 | 320 | let (level_4_page_table, _) = Cr3::read(); 321 | println!("Level 4 page table at: {:?}", level_4_page_table.start_address()); 322 | 323 | println!("It did not crash!"); 324 | blog_os::hlt_loop(); 325 | } 326 | ``` 327 | 328 | `x86_64`的`Cr3::read`函数从`CR3`寄存器返回当前活动的4级页表。 它返回`PhysFrame`和`Cr3Flags`类型的元组。 我们只对`PhysFrame`感兴趣,所以我们忽略了元组的第二个元素。 329 | 330 | 当我们运行它时,我们看到以下输出: 331 | 332 | ```shell 333 | Level 4 page table at: PhysAddr(0x1000) 334 | ``` 335 | 336 | 因此,当前活动的4级页表存储在物理内存中的地址0x1000处,如`PhysAddr`包装器类型所示。现在的问题是:我们如何从内核访问该表? 337 | 338 | 当分页处于活动状态时,无法直接访问物理内存,因为程序可以轻松地绕过内存保护并访问其他程序的内存。因此,访问该表的唯一方法是通过一些映射到地址`0x1000`处的物理帧的虚拟页面。为页表帧创建映射的这个问题是一个普遍问题,因为内核需要定期访问页表,例如在为新线程分配堆栈时。 339 | 340 | 下一篇文章将详细解释此问题的解决方案。现在,只需要知道引导加载程序使用称为递归页表的技术将虚拟地址空间的最后一页映射到4级页表的物理帧就足够了。虚拟地址空间的最后一页是`0xffff_ffff_ffff_f000`,所以让我们用它来读取该表的一些条目: 341 | 342 | ```rust 343 | // in src/main.rs 344 | 345 | #[cfg(not(test))] 346 | #[no_mangle] 347 | pub extern "C" fn _start() -> ! { 348 | […] // initialize GDT, IDT, PICS 349 | 350 | let level_4_table_pointer = 0xffff_ffff_ffff_f000 as *const u64; 351 | for i in 0..10 { 352 | let entry = unsafe { *level_4_table_pointer.offset(i) }; 353 | println!("Entry {}: {:#x}", i, entry); 354 | } 355 | 356 | println!("It did not crash!"); 357 | blog_os::hlt_loop(); 358 | } 359 | ``` 360 | 361 | 我们将最后一个虚拟页面的地址转换为指向`u64`类型的指针。 正如我们在上一节中看到的,每个页表项都是8个字节(64位),因此一个`u64`只代表一个条目。 我们使用`for`循环打印表的前10个条目。 在循环内部,我们使用`unsafe`块来读取原始指针和`offset `方法来执行指针运算。 362 | 363 | 当我们运行它时,我们看到以下输出: 364 | 365 | ![Entry 0: 0x2023, Entry 1: 0x6e2063, Entry 2-9: 0x0](https://os.phil-opp.com/paging-introduction/qemu-print-p4-entries.png) 366 | 367 | 当我们查看页表条目的格式时,我们看到条目0的值`0x2023`意味着该条目`present`,`writable`,由CPU `accessed`,并映射到帧`0x2000`。 条目1映射到帧`0x6e2000`并且具有与条目0相同的标志,并添加了表示页面已写入的`dirty`标志。 条目2-9不`present`,因此这些虚拟地址范围不会映射到任何物理地址。 368 | 369 | 我们可以使用`x86_64`crate 的`PageTable`类型,而不是使用不安全的原始指针: 370 | 371 | ```rust 372 | // in src/main.rs 373 | 374 | #[cfg(not(test))] 375 | #[no_mangle] 376 | pub extern "C" fn _start() -> ! { 377 | […] // initialize GDT, IDT, PICS 378 | 379 | use x86_64::structures::paging::PageTable; 380 | 381 | let level_4_table_ptr = 0xffff_ffff_ffff_f000 as *const PageTable; 382 | let level_4_table = unsafe {&*level_4_table_ptr}; 383 | for i in 0..10 { 384 | println!("Entry {}: {:?}", i, level_4_table[i]); 385 | } 386 | 387 | println!("It did not crash!"); 388 | blog_os::hlt_loop(); 389 | } 390 | ``` 391 | 392 | 这里我们首先将`0xffff_ffff_ffff_f000`指针转换为原始指针,然后将其转换为Rust引用。 此操作仍然需要`unsafe`,因为编译器无法知道访问此地址的有效性。 但是在转换之后,我们有一个安全的`PageTable`类型,它允许我们通过安全的,有边界检查的索引操作来访问各个条目。 393 | 394 | crate还为各个条目提供了一些抽象,以便我们在打印它们时直接看到设置了哪些标志: 395 | 396 | ![ Entry 0: PageTableEntry { addr: PhysAddr(0x2000), flags: PRESENT | WRITABLE | ACCCESSED } Entry 1: PageTableEntry { addr: PhysAddr(0x6e5000), flags: PRESENT | WRITABLE | ACCESSED | DIRTY } Entry 2: PageTableEntry { addr: PhysAddr(0x0), flags: (empty)} Entry 3: PageTableEntry { addr: PhysAddr(0x0), flags: (empty)} Entry 4: PageTableEntry { addr: PhysAddr(0x0), flags: (empty)} Entry 5: PageTableEntry { addr: PhysAddr(0x0), flags: (empty)} Entry 6: PageTableEntry { addr: PhysAddr(0x0), flags: (empty)} Entry 7: PageTableEntry { addr: PhysAddr(0x0), flags: (empty)} Entry 8: PageTableEntry { addr: PhysAddr(0x0), flags: (empty)} Entry 9: PageTableEntry { addr: PhysAddr(0x0), flags: (empty)}](https://os.phil-opp.com/paging-introduction/qemu-print-p4-entries-abstraction.png) 397 | 398 | 下一步是遵循条目0或条目1中的指针到3级页表。 但我们现在将再次遇到`0x2000`和`0x6e5000`是物理地址的问题,因此我们无法直接访问它们。 这个问题将在下一篇文章中解决。 399 | 400 | ## 总结 401 | 402 | 这个帖子介绍了两种内存保护技术:分段和分页。 前者使用可变大小的内存区域并且受到外部碎片的影响,后者使用固定大小的页面,并允许对访问权限进行更细粒度的控制。 403 | 404 | 分页存储具有一个或多个级别的页表中的页面的映射信息。 x86_64体系结构使用4级页表,页面大小为4KiB。 硬件自动遍历页表并在转译后备缓冲区(TLB)中缓存生成的转译规则。 此缓冲区不是透明的,需要在页表更改时手动刷新。 405 | 406 | 我们了解到我们的内核已经在分页之上运行,并且非法内存访问会导致页面错误异常。 我们尝试访问当前活动的页表,但我们只能访问4级表,因为页表存储了我们无法直接从内核访问的物理地址。 407 | 408 | ## 接下来是什么? 409 | 410 | 下一篇文章建立在我们在这篇文章中学到的基础知识的基础上。 它引入了一种称为递归页表的高级技术,以解决从我们的内核访问页表的问题。 这允许我们遍历页表层次结构并实现基于软件的翻译功能。 该帖子还解释了如何在页表中创建新映射。 411 | 412 | [^1]: @各大GC,尤其是某个会Stop the world 的GC 413 | [^2]: 即地址翻译的过程不再是O(1),对于一个每条指令的运行都要进行(甚至进行多次)的工作来说,O(1)真的很重要! 414 | [^3]: 这是维基百科上的译名(还是想吐槽:这什么鬼……),常见的译名是“页表缓存” 415 | [^4]: 我想这就是为啥作者拖更了那么多时间 -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Attribution-NonCommercial 4.0 International 2 | 3 | ======================================================================= 4 | 5 | Creative Commons Corporation ("Creative Commons") is not a law firm and 6 | does not provide legal services or legal advice. Distribution of 7 | Creative Commons public licenses does not create a lawyer-client or 8 | other relationship. Creative Commons makes its licenses and related 9 | information available on an "as-is" basis. Creative Commons gives no 10 | warranties regarding its licenses, any material licensed under their 11 | terms and conditions, or any related information. Creative Commons 12 | disclaims all liability for damages resulting from their use to the 13 | fullest extent possible. 14 | 15 | Using Creative Commons Public Licenses 16 | 17 | Creative Commons public licenses provide a standard set of terms and 18 | conditions that creators and other rights holders may use to share 19 | original works of authorship and other material subject to copyright 20 | and certain other rights specified in the public license below. The 21 | following considerations are for informational purposes only, are not 22 | exhaustive, and do not form part of our licenses. 23 | 24 | Considerations for licensors: Our public licenses are 25 | intended for use by those authorized to give the public 26 | permission to use material in ways otherwise restricted by 27 | copyright and certain other rights. Our licenses are 28 | irrevocable. Licensors should read and understand the terms 29 | and conditions of the license they choose before applying it. 30 | Licensors should also secure all rights necessary before 31 | applying our licenses so that the public can reuse the 32 | material as expected. Licensors should clearly mark any 33 | material not subject to the license. This includes other CC- 34 | licensed material, or material used under an exception or 35 | limitation to copyright. More considerations for licensors: 36 | wiki.creativecommons.org/Considerations_for_licensors 37 | 38 | Considerations for the public: By using one of our public 39 | licenses, a licensor grants the public permission to use the 40 | licensed material under specified terms and conditions. If 41 | the licensor's permission is not necessary for any reason--for 42 | example, because of any applicable exception or limitation to 43 | copyright--then that use is not regulated by the license. Our 44 | licenses grant only permissions under copyright and certain 45 | other rights that a licensor has authority to grant. Use of 46 | the licensed material may still be restricted for other 47 | reasons, including because others have copyright or other 48 | rights in the material. A licensor may make special requests, 49 | such as asking that all changes be marked or described. 50 | Although not required by our licenses, you are encouraged to 51 | respect those requests where reasonable. More_considerations 52 | for the public: 53 | wiki.creativecommons.org/Considerations_for_licensees 54 | 55 | ======================================================================= 56 | 57 | Creative Commons Attribution-NonCommercial 4.0 International Public 58 | License 59 | 60 | By exercising the Licensed Rights (defined below), You accept and agree 61 | to be bound by the terms and conditions of this Creative Commons 62 | Attribution-NonCommercial 4.0 International Public License ("Public 63 | License"). To the extent this Public License may be interpreted as a 64 | contract, You are granted the Licensed Rights in consideration of Your 65 | acceptance of these terms and conditions, and the Licensor grants You 66 | such rights in consideration of benefits the Licensor receives from 67 | making the Licensed Material available under these terms and 68 | conditions. 69 | 70 | 71 | Section 1 -- Definitions. 72 | 73 | a. Adapted Material means material subject to Copyright and Similar 74 | Rights that is derived from or based upon the Licensed Material 75 | and in which the Licensed Material is translated, altered, 76 | arranged, transformed, or otherwise modified in a manner requiring 77 | permission under the Copyright and Similar Rights held by the 78 | Licensor. For purposes of this Public License, where the Licensed 79 | Material is a musical work, performance, or sound recording, 80 | Adapted Material is always produced where the Licensed Material is 81 | synched in timed relation with a moving image. 82 | 83 | b. Adapter's License means the license You apply to Your Copyright 84 | and Similar Rights in Your contributions to Adapted Material in 85 | accordance with the terms and conditions of this Public License. 86 | 87 | c. Copyright and Similar Rights means copyright and/or similar rights 88 | closely related to copyright including, without limitation, 89 | performance, broadcast, sound recording, and Sui Generis Database 90 | Rights, without regard to how the rights are labeled or 91 | categorized. For purposes of this Public License, the rights 92 | specified in Section 2(b)(1)-(2) are not Copyright and Similar 93 | Rights. 94 | d. Effective Technological Measures means those measures that, in the 95 | absence of proper authority, may not be circumvented under laws 96 | fulfilling obligations under Article 11 of the WIPO Copyright 97 | Treaty adopted on December 20, 1996, and/or similar international 98 | agreements. 99 | 100 | e. Exceptions and Limitations means fair use, fair dealing, and/or 101 | any other exception or limitation to Copyright and Similar Rights 102 | that applies to Your use of the Licensed Material. 103 | 104 | f. Licensed Material means the artistic or literary work, database, 105 | or other material to which the Licensor applied this Public 106 | License. 107 | 108 | g. Licensed Rights means the rights granted to You subject to the 109 | terms and conditions of this Public License, which are limited to 110 | all Copyright and Similar Rights that apply to Your use of the 111 | Licensed Material and that the Licensor has authority to license. 112 | 113 | h. Licensor means the individual(s) or entity(ies) granting rights 114 | under this Public License. 115 | 116 | i. NonCommercial means not primarily intended for or directed towards 117 | commercial advantage or monetary compensation. For purposes of 118 | this Public License, the exchange of the Licensed Material for 119 | other material subject to Copyright and Similar Rights by digital 120 | file-sharing or similar means is NonCommercial provided there is 121 | no payment of monetary compensation in connection with the 122 | exchange. 123 | 124 | j. Share means to provide material to the public by any means or 125 | process that requires permission under the Licensed Rights, such 126 | as reproduction, public display, public performance, distribution, 127 | dissemination, communication, or importation, and to make material 128 | available to the public including in ways that members of the 129 | public may access the material from a place and at a time 130 | individually chosen by them. 131 | 132 | k. Sui Generis Database Rights means rights other than copyright 133 | resulting from Directive 96/9/EC of the European Parliament and of 134 | the Council of 11 March 1996 on the legal protection of databases, 135 | as amended and/or succeeded, as well as other essentially 136 | equivalent rights anywhere in the world. 137 | 138 | l. You means the individual or entity exercising the Licensed Rights 139 | under this Public License. Your has a corresponding meaning. 140 | 141 | 142 | Section 2 -- Scope. 143 | 144 | a. License grant. 145 | 146 | 1. Subject to the terms and conditions of this Public License, 147 | the Licensor hereby grants You a worldwide, royalty-free, 148 | non-sublicensable, non-exclusive, irrevocable license to 149 | exercise the Licensed Rights in the Licensed Material to: 150 | 151 | a. reproduce and Share the Licensed Material, in whole or 152 | in part, for NonCommercial purposes only; and 153 | 154 | b. produce, reproduce, and Share Adapted Material for 155 | NonCommercial purposes only. 156 | 157 | 2. Exceptions and Limitations. For the avoidance of doubt, where 158 | Exceptions and Limitations apply to Your use, this Public 159 | License does not apply, and You do not need to comply with 160 | its terms and conditions. 161 | 162 | 3. Term. The term of this Public License is specified in Section 163 | 6(a). 164 | 165 | 4. Media and formats; technical modifications allowed. The 166 | Licensor authorizes You to exercise the Licensed Rights in 167 | all media and formats whether now known or hereafter created, 168 | and to make technical modifications necessary to do so. The 169 | Licensor waives and/or agrees not to assert any right or 170 | authority to forbid You from making technical modifications 171 | necessary to exercise the Licensed Rights, including 172 | technical modifications necessary to circumvent Effective 173 | Technological Measures. For purposes of this Public License, 174 | simply making modifications authorized by this Section 2(a) 175 | (4) never produces Adapted Material. 176 | 177 | 5. Downstream recipients. 178 | 179 | a. Offer from the Licensor -- Licensed Material. Every 180 | recipient of the Licensed Material automatically 181 | receives an offer from the Licensor to exercise the 182 | Licensed Rights under the terms and conditions of this 183 | Public License. 184 | 185 | b. No downstream restrictions. You may not offer or impose 186 | any additional or different terms or conditions on, or 187 | apply any Effective Technological Measures to, the 188 | Licensed Material if doing so restricts exercise of the 189 | Licensed Rights by any recipient of the Licensed 190 | Material. 191 | 192 | 6. No endorsement. Nothing in this Public License constitutes or 193 | may be construed as permission to assert or imply that You 194 | are, or that Your use of the Licensed Material is, connected 195 | with, or sponsored, endorsed, or granted official status by, 196 | the Licensor or others designated to receive attribution as 197 | provided in Section 3(a)(1)(A)(i). 198 | 199 | b. Other rights. 200 | 201 | 1. Moral rights, such as the right of integrity, are not 202 | licensed under this Public License, nor are publicity, 203 | privacy, and/or other similar personality rights; however, to 204 | the extent possible, the Licensor waives and/or agrees not to 205 | assert any such rights held by the Licensor to the limited 206 | extent necessary to allow You to exercise the Licensed 207 | Rights, but not otherwise. 208 | 209 | 2. Patent and trademark rights are not licensed under this 210 | Public License. 211 | 212 | 3. To the extent possible, the Licensor waives any right to 213 | collect royalties from You for the exercise of the Licensed 214 | Rights, whether directly or through a collecting society 215 | under any voluntary or waivable statutory or compulsory 216 | licensing scheme. In all other cases the Licensor expressly 217 | reserves any right to collect such royalties, including when 218 | the Licensed Material is used other than for NonCommercial 219 | purposes. 220 | 221 | 222 | Section 3 -- License Conditions. 223 | 224 | Your exercise of the Licensed Rights is expressly made subject to the 225 | following conditions. 226 | 227 | a. Attribution. 228 | 229 | 1. If You Share the Licensed Material (including in modified 230 | form), You must: 231 | 232 | a. retain the following if it is supplied by the Licensor 233 | with the Licensed Material: 234 | 235 | i. identification of the creator(s) of the Licensed 236 | Material and any others designated to receive 237 | attribution, in any reasonable manner requested by 238 | the Licensor (including by pseudonym if 239 | designated); 240 | 241 | ii. a copyright notice; 242 | 243 | iii. a notice that refers to this Public License; 244 | 245 | iv. a notice that refers to the disclaimer of 246 | warranties; 247 | 248 | v. a URI or hyperlink to the Licensed Material to the 249 | extent reasonably practicable; 250 | 251 | b. indicate if You modified the Licensed Material and 252 | retain an indication of any previous modifications; and 253 | 254 | c. indicate the Licensed Material is licensed under this 255 | Public License, and include the text of, or the URI or 256 | hyperlink to, this Public License. 257 | 258 | 2. You may satisfy the conditions in Section 3(a)(1) in any 259 | reasonable manner based on the medium, means, and context in 260 | which You Share the Licensed Material. For example, it may be 261 | reasonable to satisfy the conditions by providing a URI or 262 | hyperlink to a resource that includes the required 263 | information. 264 | 265 | 3. If requested by the Licensor, You must remove any of the 266 | information required by Section 3(a)(1)(A) to the extent 267 | reasonably practicable. 268 | 269 | 4. If You Share Adapted Material You produce, the Adapter's 270 | License You apply must not prevent recipients of the Adapted 271 | Material from complying with this Public License. 272 | 273 | 274 | Section 4 -- Sui Generis Database Rights. 275 | 276 | Where the Licensed Rights include Sui Generis Database Rights that 277 | apply to Your use of the Licensed Material: 278 | 279 | a. for the avoidance of doubt, Section 2(a)(1) grants You the right 280 | to extract, reuse, reproduce, and Share all or a substantial 281 | portion of the contents of the database for NonCommercial purposes 282 | only; 283 | 284 | b. if You include all or a substantial portion of the database 285 | contents in a database in which You have Sui Generis Database 286 | Rights, then the database in which You have Sui Generis Database 287 | Rights (but not its individual contents) is Adapted Material; and 288 | 289 | c. You must comply with the conditions in Section 3(a) if You Share 290 | all or a substantial portion of the contents of the database. 291 | 292 | For the avoidance of doubt, this Section 4 supplements and does not 293 | replace Your obligations under this Public License where the Licensed 294 | Rights include other Copyright and Similar Rights. 295 | 296 | 297 | Section 5 -- Disclaimer of Warranties and Limitation of Liability. 298 | 299 | a. UNLESS OTHERWISE SEPARATELY UNDERTAKEN BY THE LICENSOR, TO THE 300 | EXTENT POSSIBLE, THE LICENSOR OFFERS THE LICENSED MATERIAL AS-IS 301 | AND AS-AVAILABLE, AND MAKES NO REPRESENTATIONS OR WARRANTIES OF 302 | ANY KIND CONCERNING THE LICENSED MATERIAL, WHETHER EXPRESS, 303 | IMPLIED, STATUTORY, OR OTHER. THIS INCLUDES, WITHOUT LIMITATION, 304 | WARRANTIES OF TITLE, MERCHANTABILITY, FITNESS FOR A PARTICULAR 305 | PURPOSE, NON-INFRINGEMENT, ABSENCE OF LATENT OR OTHER DEFECTS, 306 | ACCURACY, OR THE PRESENCE OR ABSENCE OF ERRORS, WHETHER OR NOT 307 | KNOWN OR DISCOVERABLE. WHERE DISCLAIMERS OF WARRANTIES ARE NOT 308 | ALLOWED IN FULL OR IN PART, THIS DISCLAIMER MAY NOT APPLY TO YOU. 309 | 310 | b. TO THE EXTENT POSSIBLE, IN NO EVENT WILL THE LICENSOR BE LIABLE 311 | TO YOU ON ANY LEGAL THEORY (INCLUDING, WITHOUT LIMITATION, 312 | NEGLIGENCE) OR OTHERWISE FOR ANY DIRECT, SPECIAL, INDIRECT, 313 | INCIDENTAL, CONSEQUENTIAL, PUNITIVE, EXEMPLARY, OR OTHER LOSSES, 314 | COSTS, EXPENSES, OR DAMAGES ARISING OUT OF THIS PUBLIC LICENSE OR 315 | USE OF THE LICENSED MATERIAL, EVEN IF THE LICENSOR HAS BEEN 316 | ADVISED OF THE POSSIBILITY OF SUCH LOSSES, COSTS, EXPENSES, OR 317 | DAMAGES. WHERE A LIMITATION OF LIABILITY IS NOT ALLOWED IN FULL OR 318 | IN PART, THIS LIMITATION MAY NOT APPLY TO YOU. 319 | 320 | c. The disclaimer of warranties and limitation of liability provided 321 | above shall be interpreted in a manner that, to the extent 322 | possible, most closely approximates an absolute disclaimer and 323 | waiver of all liability. 324 | 325 | 326 | Section 6 -- Term and Termination. 327 | 328 | a. This Public License applies for the term of the Copyright and 329 | Similar Rights licensed here. However, if You fail to comply with 330 | this Public License, then Your rights under this Public License 331 | terminate automatically. 332 | 333 | b. Where Your right to use the Licensed Material has terminated under 334 | Section 6(a), it reinstates: 335 | 336 | 1. automatically as of the date the violation is cured, provided 337 | it is cured within 30 days of Your discovery of the 338 | violation; or 339 | 340 | 2. upon express reinstatement by the Licensor. 341 | 342 | For the avoidance of doubt, this Section 6(b) does not affect any 343 | right the Licensor may have to seek remedies for Your violations 344 | of this Public License. 345 | 346 | c. For the avoidance of doubt, the Licensor may also offer the 347 | Licensed Material under separate terms or conditions or stop 348 | distributing the Licensed Material at any time; however, doing so 349 | will not terminate this Public License. 350 | 351 | d. Sections 1, 5, 6, 7, and 8 survive termination of this Public 352 | License. 353 | 354 | 355 | Section 7 -- Other Terms and Conditions. 356 | 357 | a. The Licensor shall not be bound by any additional or different 358 | terms or conditions communicated by You unless expressly agreed. 359 | 360 | b. Any arrangements, understandings, or agreements regarding the 361 | Licensed Material not stated herein are separate from and 362 | independent of the terms and conditions of this Public License. 363 | 364 | 365 | Section 8 -- Interpretation. 366 | 367 | a. For the avoidance of doubt, this Public License does not, and 368 | shall not be interpreted to, reduce, limit, restrict, or impose 369 | conditions on any use of the Licensed Material that could lawfully 370 | be made without permission under this Public License. 371 | 372 | b. To the extent possible, if any provision of this Public License is 373 | deemed unenforceable, it shall be automatically reformed to the 374 | minimum extent necessary to make it enforceable. If the provision 375 | cannot be reformed, it shall be severed from this Public License 376 | without affecting the enforceability of the remaining terms and 377 | conditions. 378 | 379 | c. No term or condition of this Public License will be waived and no 380 | failure to comply consented to unless expressly agreed to by the 381 | Licensor. 382 | 383 | d. Nothing in this Public License constitutes or may be interpreted 384 | as a limitation upon, or waiver of, any privileges and immunities 385 | that apply to the Licensor or You, including from the legal 386 | processes of any jurisdiction or authority. 387 | 388 | ======================================================================= 389 | 390 | Creative Commons is not a party to its public 391 | licenses. Notwithstanding, Creative Commons may elect to apply one of 392 | its public licenses to material it publishes and in those instances 393 | will be considered the “Licensor.” The text of the Creative Commons 394 | public licenses is dedicated to the public domain under the CC0 Public 395 | Domain Dedication. Except for the limited purpose of indicating that 396 | material is shared under a Creative Commons public license or as 397 | otherwise permitted by the Creative Commons policies published at 398 | creativecommons.org/policies, Creative Commons does not authorize the 399 | use of the trademark "Creative Commons" or any other trademark or logo 400 | of Creative Commons without its prior written consent including, 401 | without limitation, in connection with any unauthorized modifications 402 | to any of its public licenses or any other arrangements, 403 | understandings, or agreements concerning use of licensed material. For 404 | the avoidance of doubt, this paragraph does not form part of the 405 | public licenses. 406 | 407 | Creative Commons may be contacted at creativecommons.org. 408 | 409 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # writing-an-os-in-rust 2 | 3 | 《编写 Rust 语言的操作系统》简体中文翻译 4 | 5 | ## 目录 6 | 7 | ### 正文 8 | 9 | | 序号 | 标题 | 链接 | 源文件 | 状态 | 长度 | 10 | | ---- | ---------------- | ------------------------------------------------- | ---------------------------------------- | ------- | ------- | 11 | | 一 | 独立式可执行程序 | [知乎专栏](https://zhuanlan.zhihu.com/p/53064186) | [点我](./01-freestanding-rust-binary.md) | Done | 11 千字 | 12 | | 二 | 最小化内核 | [知乎专栏](https://zhuanlan.zhihu.com/p/56433770) | [点我](./02-minimal-rust-kernel.md) | Done | 19 千字 | 13 | | 三 | VGA 字符模式 | [知乎专栏](https://zhuanlan.zhihu.com/p/53745617) | [点我](./03-vga-text-mode.md) | Done | 21 千字 | 14 | | 四 | 内核测试 | [知乎专栏](https://zhuanlan.zhihu.com/p/90758552) | [点我](./04-testing.md) | Done | 27 千字 | 15 | | 五 | CPU 异常 | 待添加 | [点我](./05-cpu-exceptions.md) | Pending | - | 16 | | 六 | 双重异常 | 待添加 | [点我](./06-double-fault-exceptions.md) | Pending | - | 17 | | 七 | 硬件中断 | 待添加 | [点我](./07-hardware-interrupts.md) | Done | 21 千字 | 18 | | 八 | 内存分页简介 | 待添加 | [点我](./08-introduction-to-paging.md) | Done | 16 千字 | 19 | | 九 | 内存分页实现 | 待添加 | [点我](./09-paging-implementation.md) | Done | 28 千字 | 20 | | 十 | 堆分配 | 待添加 | [点我](./10-heap-allocation.md) | Done | 20 千字 | 21 | | 十一 | 内存分配器设计 | 待添加 | [点我](./11-allocator-designs.md) | Done | 35 千字 | 22 | | 十二 | Async/Await | 待添加 | [点我](./12-async-await.md) | Done | 51 千字 | 23 | 24 | ### 附录 25 | 26 | | 序号 | 标题 | 链接 | 源文件 | 状态 | 长度 | 27 | | ------ | ---------------- | ------------------------------------------------- | ---------------------------------------- | ------- | ------ | 28 | | 附录一 | 链接器参数 | [知乎专栏](https://zhuanlan.zhihu.com/p/69393545) | [点我](./appendix-a-linker-arguments.md) | Done | 6 千字 | 29 | | 附录二 | 禁用红区 | [知乎专栏](https://zhuanlan.zhihu.com/p/53240133) | [点我](./appendix-b-red-zone.md) | Done | 2 千字 | 30 | | 附录三 | 禁用 SIMD | [知乎专栏](https://zhuanlan.zhihu.com/p/53350970) | [点我](./appendix-c-disable-simd.md) | Done | 2 千字 | 31 | | 附录四 | 在安卓系统上构建 | 待添加 | 待添加 | Pending | - | 32 | 33 | ### 译名表 34 | 35 | [点我](./translation-table.md) 36 | 37 | ## 译者 38 | 39 | - 洛佳 (@luojia65),华中科技大学 40 | - 龙方淞 (@longfangsong),上海大学开源社区 41 | - readlnh (@readlnh) 42 | - 尚卓燃 (@psiace),华中农业大学 43 | -------------------------------------------------------------------------------- /appendix-a-linker-arguments.md: -------------------------------------------------------------------------------- 1 | >原文:https://os.phil-opp.com/freestanding-rust-binary/#linker-arguments 2 | > 3 | >原作者:@phil-opp 4 | > 5 | >译者:洛佳 华中科技大学 6 | 7 | # 使用Rust编写操作系统(附录一):链接器参数 8 | 9 | 用Rust编写操作系统时,我们可能遇到一些链接器错误。这篇文章中,我们不将更换编译目标,而传送特定的链接器参数,尝试修复错误。我们将在常用系统Linux、Windows和macOS下,举例编写裸机应用时,可能出现的一些链接器错误;我们将逐个处理它们,还将讨论这种方式开发的必要性。 10 | 11 | 要注意的是,可执行程序在不同操作系统下格式各异;所以在不同平台下,参数和错误信息可能略有不同。 12 | 13 | ## Linux 14 | 15 | 我们在Linux下尝试编写裸机程序,可能出现这样的链接器错误: 16 | 17 | ``` 18 | error: linking with `cc` failed: exit code: 1 19 | | 20 | = note: "cc" […] 21 | = note: /usr/lib/gcc/../x86_64-linux-gnu/Scrt1.o: In function `_start': 22 | (.text+0x12): undefined reference to `__libc_csu_fini' 23 | /usr/lib/gcc/../x86_64-linux-gnu/Scrt1.o: In function `_start': 24 | (.text+0x19): undefined reference to `__libc_csu_init' 25 | /usr/lib/gcc/../x86_64-linux-gnu/Scrt1.o: In function `_start': 26 | (.text+0x25): undefined reference to `__libc_start_main' 27 | collect2: error: ld returned 1 exit status 28 | ``` 29 | 30 | 这里遇到的问题是,链接器将默认引用C语言运行时的启动流程,或者也被描述为`_start`函数。它将使用我们在`no_std`下被排除的C语言标准库实现库`libc`,因此链接器不能解析相关的引用,得到“undefined reference”问题。为解决这个问题,我们需要告诉链接器,它不应该引用C语言使用的启动流程——我们可以添加`-nostartfiles`标签来做到这一点。 31 | 32 | 要通过cargo添加参数到链接器,我们使用`cargo rustc`命令。这个命令的作用和`cargo build`相同,但允许开发者向下层的Rust编译器`rustc`传递参数。另外,`rustc`提供一个`-C link-arg`标签,它能够传递所需的参数到链接器。综上所述,我们可以编写下面的命令: 33 | 34 | ``` 35 | cargo rustc -- -C link-arg=-nostartfiles 36 | ``` 37 | 38 | 这样之后,我们的包应该能成功编译,作为Linux系统下的独立式可执行程序了。这里我们没有显式指定入口点函数的名称,因为链接器将默认使用函数名`_start`。 39 | 40 | ## Windows 41 | 42 | 在Windows系统下,可能有不一样的链接器错误: 43 | 44 | ``` 45 | error: linking with `link.exe` failed: exit code: 1561 46 | | 47 | = note: "C:\\Program Files (x86)\\…\\link.exe" […] 48 | = note: LINK : fatal error LNK1561: entry point must be defined 49 | ``` 50 | 51 | 链接器错误提示“必须定义入口点”,意味着它没有找到入口点。在Windows系统下,默认的入口点函数名[由使用的子系统决定](https://docs.microsoft.com/en-us/cpp/build/reference/entry-entry-point-symbol)。对`CONSOLE`子系统,链接器将寻找名为`mainCRTStartup`的函数;而对`WINDOWS`子系统,它将寻找`WinMainCRTStartup`。我们的`_start`函数并非这两个名称:为了使用它,我们可以向链接器传递`/ENTRY`参数: 52 | 53 | ``` 54 | cargo rustc -- -C link-arg=/ENTRY:_start 55 | ``` 56 | 57 | 我们也能从这里的参数的格式中看到,Windows系统下的链接器在使用方法上,与Linux下的有较大不同。 58 | 59 | 运行命令,我们得到了另一个链接器错误: 60 | 61 | ``` 62 | error: linking with `link.exe` failed: exit code: 1221 63 | | 64 | = note: "C:\\Program Files (x86)\\…\\link.exe" […] 65 | = note: LINK : fatal error LNK1221: a subsystem can't be inferred and must be 66 | defined 67 | ``` 68 | 69 | 产生这个错误,是由于Windows可执行程序可以使用不同的**子系统**([subsystem](https://docs.microsoft.com/en-us/cpp/build/reference/entry-entry-point-symbol))。对一般的Windows程序,使用的子系统将由入口点的函数名推断而来:如果入口点是`main`函数,将使用`CONSOLE`子系统;如果是`WinMain`函数,则使用`WINDOWS`子系统。由于我们的`_start`函数名称与上两者不同,我们需要显式指定使用的子系统: 70 | 71 | ``` 72 | cargo rustc -- -C link-args="/ENTRY:_start /SUBSYSTEM:console" 73 | ``` 74 | 75 | 这里我们使用`CONSOLE`子系统,但`WINDOWS`子系统也是可行的。这里,我们使用复数参数`link-args`代替多个`-C link-arg`,因为后者要求把所有参数依次列出,比较占用空间。 76 | 77 | 使用这行命令后,我们的可执行程序应该能在Windows下运行了。 78 | 79 | ## macOS 80 | 81 | 如果使用macOS系统开发,我们可能遇到这样的链接器错误: 82 | 83 | ``` 84 | error: linking with `cc` failed: exit code: 1 85 | | 86 | = note: "cc" […] 87 | = note: ld: entry point (_main) undefined. for architecture x86_64 88 | clang: error: linker command failed with exit code 1 […] 89 | ``` 90 | 91 | 这个错误消息告诉我们,链接器不能找到默认的入口点函数,它被命名为`main`——出于一些原因,macOS的所有函数名都被加以下划线`_`前缀。要设置入口点函数到`_start`,我们传送链接器参数`-e`: 92 | 93 | ``` 94 | cargo rustc -- -C link-args="-e __start" 95 | ``` 96 | 97 | `-e`参数指定了入口点的名称。由于每个macOS下的函数都有下划线`_`前缀,我们应该命名入口点函数为`__start`,而不是`_start`。 98 | 99 | 运行这行命令,现在出现了这样的链接器错误: 100 | 101 | ``` 102 | error: linking with `cc` failed: exit code: 1 103 | | 104 | = note: "cc" […] 105 | = note: ld: dynamic main executables must link with libSystem.dylib 106 | for architecture x86_64 107 | clang: error: linker command failed with exit code 1 […] 108 | ``` 109 | 110 | 这个错误的原因是,macOS[并不官方支持静态链接的二进制库](https://developer.apple.com/library/content/qa/qa1118/_index.html),而要求程序默认链接到`libSystem`库。要链接到静态二进制库,我们把`-static`标签传送到链接器: 111 | 112 | ``` 113 | cargo rustc -- -C link-args="-e __start -static" 114 | ``` 115 | 116 | 运行修改后的命令。链接器似乎并不满意,又给我们抛出新的错误: 117 | 118 | ``` 119 | error: linking with `cc` failed: exit code: 1 120 | | 121 | = note: "cc" […] 122 | = note: ld: library not found for -lcrt0.o 123 | clang: error: linker command failed with exit code 1 […] 124 | ``` 125 | 126 | 出现这个错误的原因是,macOS上的程序默认链接到`crt0`(C runtime zero)库。这和Linux系统上遇到的问题相似,我们可以添加一个`-nostartfiles`链接器参数: 127 | 128 | ``` 129 | cargo rustc -- -C link-args="-e __start -static -nostartfiles" 130 | ``` 131 | 132 | 现在,我们的程序应该能够在macOS上成功编译了。 133 | 134 | ## 统一所有的编译命令 135 | 136 | 我们的裸机程序已经可以在多个平台上编译,但对每个平台,我们不得不记忆和使用不同的编译命令。为了避免这么做,我们创建`.cargo/config`文件,为每个平台填写对应的命令: 137 | 138 | ```toml 139 | # in .cargo/config 140 | 141 | [target.'cfg(target_os = "linux")'] 142 | rustflags = ["-C", "link-arg=-nostartfiles"] 143 | 144 | [target.'cfg(target_os = "windows")'] 145 | rustflags = ["-C", "link-args=/ENTRY:_start /SUBSYSTEM:console"] 146 | 147 | [target.'cfg(target_os = "macos")'] 148 | rustflags = ["-C", "link-args=-e __start -static -nostartfiles"] 149 | ``` 150 | 151 | 这里,`rustflags`参数包含的内容,将被自动添加到每次`rustc`调用中。我们可以在[官方文档](https://doc.rust-lang.org/cargo/reference/config.html)中找到更多关于`.cargo/config`文件的说明。 152 | 153 | 做完这一步后,我们使用简单的一行指令—— 154 | 155 | ``` 156 | cargo build 157 | ``` 158 | 159 | ——就能在三个不同的平台上编译裸机程序了。 160 | 161 | ## 我们应该这么做吗? 162 | 163 | 虽然通过上文的方式,的确可以面向多个系统编译独立式可执行程序,但这可能不是一个好的途径。这么描述的原因是,我们的可执行程序仍然需要其它准备,比如在`_start`函数调用前一个加载完毕的栈。不使用C语言运行环境的前提下,这些准备可能并没有全部完成——这可能导致程序运行失败,比如说会抛出臭名昭著的段错误。 164 | 165 | 如果我们要为给定的操作系统创建最小的二进制程序,可以试着使用`libc`库并设定`#[start]`标记。[有一篇官方文档](https://doc.rust-lang.org/1.16.0/book/no-stdlib.html)给出了较好的建议。 166 | -------------------------------------------------------------------------------- /appendix-b-red-zone.md: -------------------------------------------------------------------------------- 1 | >原文:https://os.phil-opp.com/red-zone/ 2 | > 3 | >原作者:@phil-opp 4 | > 5 | >译者:洛佳 华中科技大学 6 | 7 | # 使用Rust编写操作系统(附录二):禁用红区 8 | 9 | **红区**(redzone)是System V ABI提供的一种优化的产物,它允许函数无需调整**栈指针**(stack pointer),便能临时使用其**栈帧**(stack frame)下方的128个字节: 10 | 11 | ![](https://os.phil-opp.com/red-zone/red-zone.svg) 12 | 13 | 这张图片展示了一个有`n`个**局部变量**(local variable)的函数的栈帧。在进入函数时,栈指针将被调节到合适的位置,以便为**返回地址**(return address)和局部变量提供内存空间。 14 | 15 | 红区被定义为调整过的栈指针下方128个字节的区域——函数将会使用这个区域,来存放一些无需跨越函数调用的临时数据。因此,在一些情况下,比如在小的**叶函数**(leaf function)[1]中,我们可以优化掉调整栈指针所需的两条指令。 16 | 17 | 然而,当**异常**(exception)或**硬件中断**(hardware interrupt)发生时,这种优化却可能产生严重的问题。为了理解这一点,我们假设,当某个函数正在使用红区时,发生了一个异常: 18 | 19 | ![](https://os.phil-opp.com/red-zone/red-zone-overwrite.svg) 20 | 21 | 当异常发生时,CPU和**异常处理器**(exception handler)会向下**覆写**(overwrite)红区内的数据;这些曾经存在但被覆写的红区数据,却可能仍然将被被中断的函数使用。这之后,当我们从异常处理器中返回时,这个函数不再像它的定义一样正常工作。这个特性可能会产生许多奇怪而隐蔽的bug,甚至需要几周时间才能找到它的成因。 22 | 23 | 为了避免这样的bug发生,我们编写异常处理器时,常常从一开始便禁用红区。因此,要实现这一点,我们可以在编译目标的**配置文件**(configuration file)中,添加一行`"disable-redzone": true`。 24 | 25 | _译者注:红区的产生可能有一定的历史缘由,调整或禁用它的作用还有待发掘,文章如有不当或疏漏敬请谅解,建议阅读维基百科和其它资料上对红区的解释_ 26 | 27 | --- 28 | 29 | [1] **叶函数**(leaf function)指的是不调用其它函数的函数;可以理解为,是函数调用图的叶子节点。特别地,**尾递归函数**(tail recursive function)的尾部可以看作是叶函数。 30 | -------------------------------------------------------------------------------- /appendix-c-disable-simd.md: -------------------------------------------------------------------------------- 1 | >原文:https://os.phil-opp.com/disable-simd/ 2 | > 3 | >原作者:@phil-opp 4 | > 5 | >译者:洛佳 华中科技大学 6 | 7 | # 使用Rust编写操作系统(附录三):禁用SIMD 8 | 9 | **单指令多数据流**([Single Instruction Multiple Data,SIMD](https://en.wikipedia.org/wiki/SIMD))指令能够同时对多个**数据字**(data word)执行同一个操作,这能显著地加快程序运行的速度。`x86_64`架构支持下面的SIMD标准: 10 | 11 | 1. **MMX**([Multi Media Extension](https://en.wikipedia.org/wiki/MMX_(instruction_set)))。MMX指令集发布于1997年,它定义了8个64位的寄存器,从`mm0`到`mm7`。这些寄存器只是**x87浮点数单元**([x87 floating point unit](https://en.wikipedia.org/wiki/X87))所用寄存器的别称; 12 | 2. **SSE**([Streaming SIMD Extensions](https://en.wikipedia.org/wiki/Streaming_SIMD_Extensions))。SSE指令集于1999年发布。它定义了一些全新的寄存器集合,而不是重复使用已有的浮点数单元寄存器。从`xmm0`到`xmm15`,SSE定义了16个全新的寄存器,每个寄存器有128位长; 13 | 3. **AVX**([Advanced Vector Extensions](https://en.wikipedia.org/wiki/Advanced_Vector_Extensions))。2008年发布的AVX又一次扩展了多媒体相关的寄存器,新的寄存器被称作`ymm0`到`ymm15`,长度均为256位。这些寄存器只是`xmm`寄存器的拓展,所以例如`xmm0`只是`ymm0`的低128位。 14 | 15 | 通过使用这些SIMD标准,程序通常能极大地提升速度。一些优秀的编译器拥有**自动矢量化编译技术**([auto-vectorization](https://en.wikipedia.org/wiki/Automatic_vectorization)),能够自动将普通的循环代码转变为使用SIMD指令集的二进制码。 16 | 17 | 然而,庞大的SIMD寄存器可能在操作系统内核层面造成问题。当硬件中断发生时,我们的内核不得不备份所有它使用的寄存器:因为在程序继续时,内核依然需要它原来的寄存器数据。所以如果内核使用了SIMD寄存器,它就不得不额外备份大量的SIMD寄存器数据(可能多达512~1600个字节),这将很显著地降低效率。为了避免效率损失,我们在开发内核时,常常禁用`sse`和`mmx`这两个**CPU特征**(CPU feature)。(`avx`特征是默认被禁用的。) 18 | 19 | 要禁用这两个特征,我们可以修改**目标配置清单**(target specification)的`features`配置项。使用`-`号,我们可以禁用`sse`和`mmx`两个CPU特征: 20 | 21 | ```json 22 | "features": "-mmx,-sse" 23 | ``` 24 | 25 | ## 浮点数 26 | 27 | 关于浮点数,我们有一个好消息和一个坏消息。坏消息是,`x86_64`架构也使用SSE寄存器做一些浮点数运算。因此,禁用SSE环境下的浮点数运算,都将导致LLVM发生错误。Rust的core库基于浮点数运算实现——比如它实现了f32和f64类型——这样的特点,让我们无法通过避免使用浮点数而绕开这个错误。而好消息是,LLVM支持一个称作`soft-float`的特征,它能够基于普通的整数运算软件模拟浮点运算;无需使用SSE寄存器,这让我们在内核中使用浮点数变为可能——只是性能上会慢一点点。 28 | 29 | 为了启用这个特征,我们把`+`号开头的特征名称加入配置项: 30 | 31 | ```json 32 | "features": "-mmx,-sse,+soft-float" 33 | ``` 34 | 35 | 36 | 37 | > *原文链接:* *原作者:@phil-opp* 38 | > 39 | > 译文链接:-- 40 | > 41 | > 译者:洛佳 华中科技大学* 42 | > 43 | > 转载请注明出处,商业转载请联系原作者* -------------------------------------------------------------------------------- /dummy.rs: -------------------------------------------------------------------------------- 1 | fn main() { 2 | println!("Hint that this is a Rust repo!") 3 | } 4 | -------------------------------------------------------------------------------- /translation-table.md: -------------------------------------------------------------------------------- 1 | # 译名表 2 | 3 | 收录书中涉及专有名词的英文单词、短语,给出本书的中文译名。 4 | 5 | 采纳惯用译名为主。没有合适译名的,尽量做到信、达、雅的前提下,给出新翻译。 6 | 7 | 英文部分书名、人名请使用斜体;保留原有的大小写。请按英文字母顺序排序。 8 | 9 | 涉及特定语言、软件和架构等的概念,标注(Rust)(x86)(QEMU)等,以便和其它语言、技术的概念相区分。 10 | 11 | | 英文 | 出现章节 | 中文翻译 | 12 | |:----|:--------|:------| 13 | | append by bytes | 二 | 按字符拼接 | 14 | | Application Binary Interface, ABI | 二 | 应用程序二进制接口 | 15 | | assert | 四 | 断言 | 16 | | attribute (Rust) | 四 | 属性 | 17 | | background color | 二,三 | 背景色 | 18 | | bare metal | 一,二 | 裸机 | 19 | | bare-metal executable | 一 | 裸机程序 | 20 | | block (in contexts) | 三 | 阻塞 | 21 | | bootable disk image | 二 | 可引导的映像 | 22 | | bootloader | 二 | 引导程序 | 23 | | bright bit | 三 | 加亮位 | 24 | | BSS segment | 二 | BSS段 | 25 | | byte literal | 三 | 字节字面量 | 26 | | byte string | 二 | 字节串 | 27 | | caret requirement (Rust) | 二 | 脱字号条件 | 28 | | character cell | 二,三 | 字符单元 | 29 | | C-like enum (Rust) | 三 | 类似于C语言的枚举 | 30 | | Code page 437 | 三 | 代码页437 | 31 | | color code | 三 | 颜色代码 | 32 | | Combinators | 十二 | 组合子 | 33 | | compiler built-in libraries | 二 | 编译器内建库 | 34 | | conditional compilation | 四 | 条件编译 | 35 | | const evaluator | 三 | 常量求值器 | 36 | | const functions (Rust) | 三 | 常函数 | 37 | | Cooperative Multitasking | 十二 | 协作式多任务 | 38 | | copy semantics | 一,三 | 复制语义 | 39 | | CPU exception (x86) | 四 | CPU异常 | 40 | | CPU feature | 二 | CPU特征 | 41 | | crate (Rust) | 三 | 包 | 42 | | crate root (Rust) | 三 | 根模块 | 43 | | cross compile | 一 | 交叉编译 | 44 | | custom target | 一 | 自定义目标 | 45 | | dependency (Rust) | 三 | 依赖项 | 46 | | derive (Rust) | 三 | 生成 | 47 | | destructor | 一 | 析构函数 | 48 | | "dev" profile (Rust) | 一 | dev配置 | 49 | | device name | 二 | 设备名 | 50 | | diverging | 十二 | 发散 | 51 | | diverging function (Rust) | 一 | 发散函数 | 52 | | dynamically dispatch | 十二 | 动态派发 | 53 | | edition (Rust) | 一 | 版次 | 54 | | enum (Rust) | 三 | 枚举 | 55 | | Executable and Linkable Format, ELF | 二 | ELF格式 | 56 | | exit status (QEMU) | 四 | 退出状态 | 57 | | explicit lifetime (Rust) | 三 | 显式生命周期 | 58 | | embedded system | 一 | 嵌入式系统 | 59 | | entry point | 一 | 入口点 | 60 | | entry point address | 二 | 入口点地址 | 61 | | feature flag | 二 | 特性标签 | 62 | | foreground color | 二,三 | 前景色 | 63 | | formatting macros (Rust) | 三 | 格式化宏 | 64 | | freestanding executable | 一 | 独立式可执行程序 | 65 | | garbage collection | 一 | 垃圾回收 | 66 | | generic | 三 | 泛型 | 67 | | green threads | 一 | 绿色线程,软件线程 | 68 | | guest system | 四 | 客户系统 | 69 | | host system | 一,二 | 宿主系统 | 70 | | immutable variable (Rust) | 三 | 不可变变量 | 71 | | integration test | 四 | 集成测试 | 72 | | interior mutability (Rust) | 三 | 内部可变性 | 73 | | I/O port (x86) | 四 | IO端口 | 74 | | kernel | 二 | 内核 | 75 | | language item (Rust) | 一 | 语言项 | 76 | | line feed | 三 | 换行符 | 77 | | linker | 一,二 | 链接器 | 78 | | linker argument | 一 | 链接器参数 | 79 | | lazy initialization | 三 | 延迟初始化 | 80 | | magic number | 二 | 魔术数字 | 81 | | memory-mapped I/O | 三 | 存储器映射输入输出 | 82 | | memory safety | 二 | 内存安全 | 83 | | module (Rust) | 三 | 模块 | 84 | | mutable static variable (Rust) | 三 | 可变静态变量 | 85 | | mutual exclusion | 三 | 互斥条件 | 86 | | name mangling | 一 | 名称重整 | 87 | | "never" type (Rust) | 一 | Never类型 | 88 | | page table | 二 | 分页表 | 89 | | page fault| 四 | 缺页异常 | 90 | | Pin | 十二 | 固定 | 91 | | precompiled library | 二 | 预编译库 | 92 | | Preemptive Multitasking | 十二 | 抢占式多任务 | 93 | | range notation (Rust) | 三 | 区间标号 | 94 | | raw pointer | 二,三 | 裸指针 | 95 | | redzone | 二 | 红区 | 96 | | release channel (Rust) | 二 | 发行频道 | 97 | | "release" profile | 一 | release配置 | 98 | | root module | 一 | 根模块 | 99 | | root namespace | 三 | 根命名空间 | 100 | | rule (in macros, Rust) | 三 | (宏的)规则 | 101 | | runtime system | 一 | 运行时系统 | 102 | | semantic version number, semver | 三 | 语义版本号 | 103 | | serial port | 四 | 串行端口 | 104 | | Single Instruction Multiple Data, SIMD | 二 | 单指令多数据流 | 105 | | spinlock | 三 | 自旋锁 | 106 | | stack trace | 一 | 堆栈轨迹 | 107 | | stack unwinding | 一,二 | 栈展开 | 108 | | standard output | 一 | 标准输出 | 109 | | 'static lifetime (Rust) | 三 | 'static生命周期 | 110 | | static variable | 二 | 静态变量 | 111 | | structured exception handling, SEH | 一 | 结构化异常处理 | 112 | | software threads | 一 | 软件线程,绿色线程 | 113 | | submodule (Rust) | 三 | 子模块 | 114 | | system call | 一 | 系统调用 | 115 | | target triple (Rust) | 一,二 | 目标三元组 | 116 | | target specification | 二 | 目标配置清单 | 117 | | target system | 二 | 目标系统 | 118 | | test runner | 四 | 测试运行器 | 119 | | trait object (Rust) | 四 | trait对象 | 120 | | unsafe block | 二,三 | unsafe语句块 | 121 | | unstable feature | 二 | 不稳定特性 | 122 | | VGA text buffer (x86) | 二,三 | VGA字符缓冲区 | 123 | | VGA text mode (x86) | 三 | VGA字符模式 | 124 | | virtual address | 二 | 虚拟地址 | 125 | | volatile operation | 三 | 易失操作 | 126 | | warning | 三 | 警告 | 127 | | wrapping type | 三 | 包装类型 | 128 | --------------------------------------------------------------------------------