一个最小化的Rust内核
一个最小化的Rust内核
在这篇文章中,我们为x86架构创建了一个最小化的64位Rust内核。 我们基于上一篇文章中的独立Rust二进制文件构建了一个可引导的磁盘映像,它可以将某些内容打印到屏幕上。
这个博客所描述的内核已经公开在Github上。 如果您有任何问题或疑问,请在那里开一个issue。 您也可以在原作者博客的底部留言。
启动过程
当您打开计算机时,它开始执行存储在主板ROM中的固件代码。 这些代码执行开机自检,检测可用RAM,并预初始化CPU和硬件。 然后,它会查找可引导磁盘并开始引导操作系统内核。
在x86上,有两种固件标准:“基本输入/输出系统”(BIOS)和较新的“统一可扩展固件接口”(UEFI)。 自20世纪80年代以来,BIOS标准已经陈旧且过时,但在任何x86机器上都很简单且得到很好的支持。 相比之下,UEFI更现代,功能更多,但设置起来更复杂(至少在我看来)。
目前,我们只提供BIOS支持,但也计划支持UEFI。 如果您想帮助我们,请查看Github issues。
几乎所有x86系统都支持从BIOS启动,包括使用模拟BIOS的基于UEFI的新机器。这很棒,因为您可以在从上个世纪起至今的所有机器上使用相同的启动逻辑。但是这种广泛的兼容性同时也是BIOS启动的最大缺点,因为它意味着CPU在启动之前被置于一种称为实模式的16位兼容模式,因此20世纪80年代的古老引导加载程序仍然可以工作。
但是,让我们从头开始:
当您打开计算机时,它会从主板上的某些特别的ROM中加载BIOS。 BIOS运行硬件的自检和初始化程序,然后查找可引导磁盘。如果找到一个,则跳转到其引导加载程序,该引导加载程序是存储在磁盘开头的512字节的可执行代码。大多数引导加载程序大于512字节,因此引导加载程序通常分为小的第一级(512字节以内)和第二级(由第一级加载)。
引导加载程序必须确定磁盘上内核映像的位置并将其加载到内存中。它还需要将CPU从16位实模式首先切换到32位保护模式,然后再切换到64位长模式,来使64位寄存器和整个主存储器可用。它的第三个任务是从BIOS查询某些信息(例如内存映射)并将其传递给OS内核。
编写引导加载程序有点麻烦,因为这需要汇编语言和许多不太讲道理的步骤,例如“将这个魔数写到这个寄存器里”。因此,我们不会在本文中介绍引导加载程序的创建,而是提供一个名为bootimage的工具,该工具会自动将引导加载程序添加到内核中。
如果您有兴趣构建自己的引导加载程序:请继续关注这个博客,关于此主题的一组帖子已经在计划中了!
多启动标准
为了避免每个操作系统都实现自己的(仅与这一个操作系统兼容的)引导加载程序,自由软件基金会在1995年创建了一个名为Multiboot的开放引导加载程序标准。该标准定义了引导加载程序和操作系统之间的接口,以便任何多重引导兼容的引导加载程序可以加载任何符合Multiboot的操作系统。参考实现是GNU GRUB,它是Linux系统中最流行的引导加载程序。
要使内核与Multiboot兼容,只需在内核文件的开头插入一个所谓的Multiboot头。这使得在GRUB中启动OS非常容易。但是,GRUB和Multiboot标准也存在一些问题:
它们仅支持32位保护模式。这意味着您仍然必须执行CPU配置以切换到64位长模式。
它们旨在使引导加载程序简单而不是内核。例如,内核需要与调整后的默认页面大小链接,因为否则GRUB无法找到Multiboot标头。另一个例子是传递给内核的引导信息包含许多与体系结构相关的结构,而不是干净的抽象。
GRUB和Multiboot的文档都很匮乏。
需要在主机系统上安装GRUB以从内核文件创建可引导的磁盘映像。这使得在Windows或Mac上的开发更加困难。
由于这些缺点,我们决定不使用GRUB或Multiboot标准。但是,我们计划在我们的bootimage工具中添加Multiboot支持,这样就可以在GRUB系统上加载内核。如果您对编写符合Multiboot的内核感兴趣,请查看本博客系列的第一版。
UEFI
(我们目前不提供UEFI支持,但我们很乐意这样做!如果您想提供帮助,请在Github issues中告诉我们。)
最小化内核
现在我们大致知道计算机是如何启动的了,现在是时候创建我们自己的最小化内核了。 我们的目标是创建一个磁盘映像,在启动时将“Hello World!”打印到屏幕上。 为此,我们在上一篇文章中建立了独立的Rust二进制文件。
您可能还记得,我们通过cargo构建了独立的二进制文件,但根据操作系统的不同,我们需要使用不同的入口点名称和编译选项。 这是因为默认情况下cargo为有操作系统的环境构建,即为您正在运行的系统构建。 我们想要的内核不是这样,因为一个内核运行在一个操作系统,例如Windows,上没有多大意义。 相反地,我们想要为明确定义的目标系统进行编译。
目标定义
cargo通过--target
参数支持不同的目标系统。 目标由所谓的目标三元组描述,其描述了CPU架构,供应商,操作系统和ABI。 例如,x86_64-unknown-linux-gnu
目标三元组描述的系统具有x86_64 CPU,没有明确的供应商和具有GNU ABI的Linux操作系统。 Rust支持许多不同的目标三元组,包括用于Android的arm-linux-androideabi
或用于WebAssembly的wasm32-unknown-unknown
。
但是,对于我们的目标系统,我们需要一些特殊的配置参数(例如,没有底层操作系统),因此现有的目标三元组都不适合。 幸运的是,Rust允许我们通过JSON文件定义自己的目标。 例如,描述x86_64-unknown-linux-gnu
目标的JSON文件如下所示:
1 | { |
大多数字段是为了让LLVM为该平台生成代码的。 例如,data-layout
字段定义了各种整数,浮点和指针类型的大小。 然后有Rust用于条件编译的字段,例如target-pointer-width
。 第三种字段定义了如何构建crate。 例如,pre-link-args
字段指定传递给链接器的参数。
我们的内核目标位x86_64系统,因此我们的目标定义看起来与上面的非常类似。 让我们首先创建一个x86_64-blog_os.json
文件(你也可以选择您喜欢的任何名称):
1 | { |
请注意,我们将llvm-target
和os
字段中的操作系统更改为none
,因为我们将在裸机上运行。
我们添加以下与构建相关的字段:
1 | "linker-flavor": "ld.lld", |
我们使用Rust附带的跨平台LLD链接器来链接我们的内核,而不是使用平台默认的链接器(可能不支持Linux目标)。
1 | "panic-strategy": "abort", |
此设置指定目标在panic
时不进行堆栈解退,而是直接中止。 这与我们的Cargo.toml
中的panic = "abort"
选项具有相同的效果,因此我们可以删除那里的panic = "abort"
。
1 | "disable-redzone": true, |
我们正在编写内核,因此我们需要在某些时候处理中断。 为了安全地执行此操作,我们必须禁用称为“红色区域”的特定堆栈指针优化,否则会导致堆栈损坏。 有关更多信息,请参阅有关禁用红色区域的单独帖子。
1 | "features": "-mmx,-sse,+soft-float", |
features字段启用/禁用目标的功能。 我们通过在前面添加减号来禁用mmx和sse功能,并通过在前面添加加号来启用软浮点功能。
mmx和sse功能决定了对单指令多数据(SIMD)指令的支持,这通常可以显着加快程序的速度。 但是,大型SIMD寄存器会导致操作系统内核出现性能问题,因为内核必须在每个硬件中断上备份它们。 为了避免这种情况,我们为内核禁用了SIMD(不适用于运行在其上的应用程序!)。
禁用SIMD带来的一个问题是x86_64上的浮点运算默认需要SIMD寄存器。 为了解决这个问题,我们添加了soft-float功能,它通过软件模拟所有浮点运算。
有关更多信息,请参阅有关禁用SIMD的帖子。
现在把它们放在一起
1 | { |
构建我们的内核
我们的新目标将使用Linux约定来编译(我不太清楚为什么,我认为它是LLVM的默认值)。 这意味着我们需要一个名为_start
的入口点,如上一篇文章所述:
1 | // src/main.rs |
请注意,无论您的主机操作系统如何,都需要将入口点称为_start
。 你应该删除上一篇文章中介绍的Windows和macOS入口点。
我们现在可以通过将JSON文件的名称作为--target
参数传递来以我们的新目标构建内核:
1 | cargo build --target x86_64-blog_os.json |
它炸了…… 该错误告诉我们Rust编译器找不到core
或compiler_builtins
库。 两个库都隐式链接到所有no_std
包。 core
包含基本的Rust类型,如Result
,Option
和迭代器类型,而compiler_builtins
库提供LLVM需要的各种底层函数,例如memcpy
。
问题是core
库与Rust编译器一起作为预编译库分发。 因此它仅对受支持的主机三元组(例如x86_64-unknown-linux-gnu
)有效,但不适用于我们的自定义目标。 如果我们想为其他目标编译代码,我们需要首先为这些目标重新编译core
。
Cargo xbuild
这就是cargo xbuild
的用武之地了。它是cargo
的一层包装,可以自动交叉编译核心和其他内置库。 我们可以通过执行下面的命令来安装它:
1 | cargo install cargo-xbuild |
该命令取决于Rust源代码,我们可以使用rustup
组件添加rust-src
来安装。
现在我们可以用xbuild
而不是build
重新运行上面的命令:
1 | cargo xbuild --target x86_64-blog_os.json |
我们看到cargo xbuild
为我们的新自定义目标交叉编译了core
,compiler_builtin
和alloc
库。 由于这些库在内部使用了许多不稳定的功能,因此这仅适用于nightly版本的Rust编译器。 最终,cargo xbuild
成功编译了我们的blog_os crate。
现在我们可以为裸机目标构建内核。 但是,我们的_start
入口点(将由引导加载程序调用)仍为空。 所以让我们在这里输出一些内容。
输出到屏幕
现阶段将文本打印到屏幕的最简单方法是使用VGA文本缓冲区。 它是映射到VGA硬件的一块特殊存储区,包含屏幕上显示的内容。 它通常由25行组成,每行包含80个字符单元格。 每个字符单元格显示带有一些前景色和背景色的ASCII字符。 屏幕输出如下所示:
我们将在下一篇文章中讨论VGA缓冲区的确切布局,我们为此编写第一个小驱动程序。 对于打印“Hello World!”,我们只需要知道VGA缓冲区位于地址0xb8000
,并且每个字符单元由ASCII字节和颜色字节组成。
实现方式看起来像这样:
1 | static HELLO: &[u8] = b"Hello World!"; |
首先,我们将整数0xb8000
转换为裸指针。然后我们遍历静态字节字符串HELLO的每个字节。我们使用enumerate
方法来另外获取索引变量i
。在for循环的主体中,我们使用offset方法写入字符串字节和相应的颜色字节(0xb
是浅青色)。
请注意,所有内存写入都要写在unsafe
块中。原因是Rust编译器无法证明我们创建的原始指针是有效的。他们可以指向任何地方并导致数据损坏。通过将它们放入unsafe
的块中,我们向编译器保证我们绝对确定操作是有效的。请注意,unsafe
块不会关闭Rust的安全检查。它只多允许你做四件事。
我想强调,这不是我们想要的在Rust中做事的方式!在unsafe
块中使用裸指针时很容易搞砸,例如,如果我们不够小心,我们可以轻松地在缓冲区之外进行写入。
因此,我们希望尽可能减少unsafe
的使用。 Rust使我们能够通过创建安全的抽象来实现这一目标。例如,我们可以创建一个VGA缓冲区类型,封装所有不安全的内容,并确保不可能从外部做任何错误。这样,我们只需要极少量的不安全因素,并确保我们不会违反内存安全性。我们将在下一篇文章中创建这样一个安全的VGA缓冲区抽象。
创建一个可引导的磁盘映像
现在我们有了一个真的可以干点什么的可执行文件,是时候把它变成可引导的磁盘映像了。 正如我们在关于引导的部分中所学到的,我们需要一个引导加载程序,它初始化CPU并加载我们的内核。
我们使用bootloader
包,而不是自己编写我们自己的引导加载程序,这个crate
是一个独立的项目。 它实现了一个基本的BIOS引导加载程序,只用了Rust
和内联汇编,而没有任何对C的依赖。 要使用它来引导我们的内核,我们需要添加一个依赖项:
1 | # in Cargo.toml |
将引导加载程序添加为依赖项并不足以实际创建可引导的磁盘映像。 问题是我们需要在编译后将引导加载程序与内核结合起来,但是在成功编译之后cargo
不支持额外的构建步骤(有关更多信息,请参阅此issue)。
为了解决这个问题,我们创建了一个名为bootimage
的工具,它首先编译内核和引导程序,然后将它们组合在一起以创建可引导的磁盘映像。 要安装该工具,请在终端中执行以下命令:
1 | cargo install bootimage --version "^0.5.0" |
^0.5.0
是所谓的caret
要求,表示“版本0.5.0或更高的兼容版本”。 因此,如果我们发现了Bug并发布版本0.5.1或0.5.2
,cargo
将自动使用最新版本,只要它仍然是0.5.x
版本。 但是,它不会选择版本0.6.0
,因为它不被视为兼容。 请注意,默认情况下,Cargo.toml
中的依赖项是caret
要求,因此相同的规则将应用于我们的引导加载程序依赖项。
安装bootimage工具后,创建可引导磁盘映像只需执行:
1 | bootimage build --target x86_64-blog_os.json |
您会看到该工具使用cargo xbuild
重新编译内核,所以它会自动获取您所做的任何更改。 之后它会编译引导加载程序,这可能需要一段时间。 与所有crate
依赖项一样,它只构建一次然后会使用缓存中的内容,因此后续构建将更快。 最后,bootimage
将引导加载程序和内核组合到可引导的磁盘映像中。
执行该命令后,您应在target/x86_64-blog_os/debug
目录中看到名为bootimage-blog_os.bin
的可引导磁盘映像。 您可以在虚拟机中启动它或将其复制到USB驱动器以在真实硬件上启动它。 (请注意,这不是CD映像,它具有不同的格式,因此将其刻录到CD不会起作用)。
它是怎么工作的
bootimage
工具在幕后执行了以下步骤:
- 它将我们的内核编译为ELF文件。
- 它将引导加载程序依赖项编译为独立的可执行文件。
- 它将内核ELF文件的字节附加到引导加载程序。
- 引导时,引导加载程序读取并解析附加的ELF文件。 然后,它将程序段映射到页表中的虚拟地址,将
.bss
部分归零,并设置堆栈。 最后,它读取入口点地址(我们的_start
函数)并跳转到它。
Bootimage配置
可以通过Cargo.toml
文件中的[package.metadata.bootimage]
表配置bootimage
工具。 我们可以添加一个default-target
选项,这样我们就不再需要传递--target
参数:
1 | # in Cargo.toml |
现在我们可以省略--target
参数并运行bootimage build
。
启动!
我们现在可以在虚拟机中启动磁盘映像。 要在QEMU中引导它,请执行以下命令:
1 | qemu-system-x86_64 -drive format=raw,file=bootimage-blog_os.bin |
或者,您可以调用bootimage工具的run子命令:
1 | bootimage run |
默认情况下,它会调用与上面完全相同的QEMU命令。 在--
之后可以传递额外的QEMU选项。 例如,bootimage run -- --help
将显示QEMU帮助。 也可以通过Cargo.toml
中package.metadata.bootimage
表中的run-command
键更改默认命令。 有关更多信息,请参阅--help
输出或Readme。
真机
也可以将其写入USB设备并在真机上启动它:
1 | dd if=target/x86_64-blog_os/debug/bootimage-blog_os.bin of=/dev/sdX && sync |
其中sdX是USB设备的设备名称。 请务必注意选择正确的设备名称,因为该设备上的所有内容都会被覆盖。
接下来?
在下一篇文章中,我们将更详细地探索VGA文本缓冲区,并为其编写一个安全的接口。 我们还将添加对println
宏的支持。