C++和Rust_[译]用Rust写操作系统(2)最小Rust内核

最小Rust内核

原文 https:// os.phil-opp.com/minimal -rust-kernel/
原作者 phil-opp
译者 readlnh
翻译项目地址 https:// github.com/readlnh/Writ ing-an-OS-in-Rust-Second-Edition-zh_CN

在这篇文章里我们将在x86架构上创建一个最小的64位Rust内核。我们将在上一篇文章一个独立的rust二进制程序的基础上创建一个磁盘启动镜像,并将一些内容显示在屏幕上。

这个系列的blog在GitHub上开放开发,如果你有任何问题,请在这里开一个issuse来讨论。当然你也可以在底部留言。你可以在这里找到这篇文章的完整源码。

Boot进程

当你打开计算机时,计算机会执行存储在主板ROM里的固件代码。这些代码扮演了一个上电自检的角色,它会检查可用的RAM,预初始化CPU和硬件。然后,它会查找可引导磁盘并开始引导操作系统内核。

在x86上,有两种固件标准:"简单输入/输出系统" (BIOS)和"统一可扩展固件接口" (UEFI)。BIOS标准比较古老且已经过时了,但是它很简单并且自1980年代起就对x86机器有着良好的支持。相反,UEFI则更现代,拥有更多的功能,但是配置起来也更复杂(至少在我看来是这样)。

目前,我们只提供BIOS支持,但是UEFI支持也在计划之中。如果你希望帮助我实现它,请确认这个 Github issue.。

BIOS Boot

几乎所有的x86系统都支持BIOS启动,包括比较新的基于UEFI的机器也会通过模拟BIOS的方式来支持。这很棒,因为你可以和从上世纪出现的各种老机器保持一致的启动逻辑。然而这种广泛的兼容性也是BIOS启动最大的缺点,因为这意味着在启动前CPU会处于被称为实模式的16位兼容模式下,正因如此那些1980年代的古老的bootloader才仍然能够工作。

不过,现在让我们从头开始吧:

当你打开计算机时,它会从主板上某些特殊的flash(闪存)里加载BIOS。BIOS会运行硬件自检和初始化程序,然后,它会寻找可引导的磁盘。当它找到时,它会将控制权转交给bootloader--一段存储在磁盘开头的512字节的代码。大部分bootloader都比512字节要大,所以一般会把bootloader分为第一小段(为了适应512字节)和第二段(随后从第一级加载)。

bootloader必须确定内核映像在磁盘上的位置,并将其加载到内存中。它还需要先将CPU置为16位实模式,再将其切换到32位保护模式,最后切换到64位[长模式],只有在64位模式下,64位寄存器和完整的内存才可以使用。它的第三个任务则是从BIOS查询一些信息(例如内存映射)并将其传递给操作系统内核。

实现一个bootloader有一点麻烦因为这会需要使用汇编语言以及很多没什么意义的工作例如"将一些魔数写到寄存器里"。因此,我们不会在本文中讲解如何实现一个bootloader,而是提供了一个叫做bootimage的工具,该工具可以把bootloader自动添加到内核里。

如果你对构建一个自己的bootloader感兴趣,请继续关注该教程,我已经计划了一系列关于此内容的文章!

Multiboot 标准

为了避免每个操作系统都实现一个自己的bootlaoder,每个bootloader都只兼容单个操作系统,自由软件基金会于1995年建立了一个叫[Multibooot]的开放bootloader标准。该标准定义了bootloader和操作系统之间的接口,所以任何Multiboot兼容的bootloader可以加载任何Multiboot兼容的操作系统。最典型的参考实现就是GNU GRUB,它也是目前在Linux系统中最受欢迎的bootloader。

要使内核兼容Multiboot,只需要在内核文件的开头插入所谓的Multiboot header 。这样就可以非常简单的在GRUB里启动操作系统了。然而,GRUB和Multiboot规范也有一些问题:

  • 他们只支持32位保护模式。这意味着你仍然需要进行一些CPU配置来切换到64位长模式。
  • 他们是为了使bootloader更简单而不是为了使内核更简单而设计的。例如,内核需要以调整后的默认页大小(adjusted default page size)链接,否则GRUB找不到Multiboot头。另一个例子则是boot information,它包含大量与架构有关的数据,会被直接传递给内核而不是经过一层更清晰的抽象。
  • GRUB和Multiboot标准的文档说明都很少。
  • 只有在宿主机上安装了GRUB才能从内核文件里创建磁盘引导镜像。这就导致在Windows和Mac环境下的开发变得很困难。

由于这些缺点,我们决定不使用GRUB或是Multiboot规范。但是,我们计划在我们的bootimage工具里添加Multiboot支持,这样你就可以在一个GRUB系统里加载你的内核。如果你对实现一个Multiboot兼容的内核感兴趣,请查看这个博客系列文章的[first editon]。

UEFI

(目前我们不打算支持UEFI,但是我们还是很乐意支持UEFI的!如果你想要帮忙,请在[Github issue]里告诉我们。

最小内核

现在我们已经大致了解了计算机的启动,是时候来创建我们自己的小内核了。我们的目标是创建一个在启动时会在屏幕上输出"Hello World"的磁盘镜像。我们在上一篇博客一个独立的rust二进制程序的基础上来继续。

你也许还记得,我们是通过cargo来构建独立二进制程序的,然而这仍然依赖于操作系统:我们需要不同的入口点,不同的编译选项(flag)。这是因为cargo默认是以宿主系统,即你正在运行的那个操作系统为目标构建平台的。这不是我们想要的内核,因为内核如果要运行在例如Windows这样的操作系统上那就没有意义了。所以,我们需要明确的定义一个目标编译系统(target system)

安装Rust Nightly

Rust有三个发行通道: stable, beta, 和 nightly。<>一书对这些版本之间的区别有详细的解释,你可以花上一分钟来查看一下。为了构建一个操作系统,我们需要一些只有nightly才会提供的实验性功能,所以我们需要安装nightly版的Rust。

我强烈推荐用rustup来管理Rust安装。它允许你同时安装nightly, beta, 和stable版本并很方便的更新它们。通过rustup,你可以执行rustup override add nightly命令实现在当前目录下使用nightly 编译器。另外你可以将内容为nightly的名为rust-toolchain的文件添加到项目的根目录里。你还可以通过运行rust --version命令(版本号的最后应该要包含-nightly)来确认你安装的nightly版本。

nightly允许我们在文件的开头添加所谓的feature flags来选择使用某些实验性的功能。例如,我们可以通过在main.rs文件头部添加例如 #![feature(asm)] 来启用 实验性的内敛汇编[asm! macro(宏)]注意,像这样的实验性功能是完全不稳定的,这也以为着这些功能可能在没有提取说明的情况下就在某个版本里变动甚至被移除了。基于这个理由,我们只在必须的情况下才使用他们。

Target Specification

cargo可以通过 --target参数来支持不同的目标系统。这个目标系统由所谓目标三元组来描述,它描述了CPU的架构,供应商,操作系统以及ABI。举个例子,x86_64-unknown-linux-gnu三元目标组描述了一个基于x86_64 CPU,不明确的供应商,采用GNU ABI的Linux操作系统的结构。Rust支持很多不同的目标三元组,包括安卓的 arm-linux-androideabi和WebAssembly的wasm32-unkonw平台。

对于我们的目标系统,我们需要一些特殊的配置参数(例如,不依赖于底层操作系统),所以现有的目标三元组都不适用。幸运的是,Rust允许我们通过一个JOSN文件来定义我们自己的目标平台。例如,一个描述`x86_64-unknown-linux-gnu目标平台的JSON文件如下:

{
    "llvm-target": "x86_64-unknown-linux-gnu",
    "data-layout": "e-m:e-i64:64-f80:128-n8:16:32:64-S128",
    "arch": "x86_64",
    "target-endian": "little",
    "target-pointer-width": "64",
    "target-c-int-width": "32",
    "os": "linux",
    "executables": true,
    "linker-flavor": "gcc",
    "pre-link-args": ["-m64"],
    "morestack": false
}

大部分字段需要有LLVM来为该平台生成代码。例如, [data-layout]字段定义了各种整型,浮点型,指针类型的大小。还有一些用于Rust条件编译的字段,诸如 target-pointer-width。第三种类型的字段则定义了该如何构建一个crate。例如, pre-link-args 字段就指定了传递给linker(链接器)的参数。

x86_64体系也是我们内核的目标平台之一,所以我们的target specfiation(目标配置)看起来和第一个非常相似。让我们从创建包含如下内容的 x86_64-blog_os.json(你可以任选一个你喜欢的名字)文件开始吧。

{
    "llvm-target": "x86_64-unknown-none",
    "data-layout": "e-m:e-i64:64-f80:128-n8:16:32:64-S128",
    "arch": "x86_64",
    "target-endian": "little",
    "target-pointer-width": "64",
    "target-c-int-width": "32",
    "os": "none",
    "executables": true,
}

注意我们改变了llvm-target中的OS关键字,同时将os字段修改为none,因为在后面我们的操作系统是要跑在裸机上的。

我们添加了以下与构建相关的条目:

"linker-flavor": "ld.lld",
"linker": "rust-lld",

这里我们使用Rust自带的跨平台LLD linker而不是平台默认的linker(平台自带的有可能不支持Linux目标平台)。

"panic-strategy": "abort",

该设置指定了目标平台不支持panic时的栈展开(stack unwinding),相应的,程序在panic时会直接终止。这个设置的效果和Cargo.toml里的panic = "abort"选项效果一样,所以我们可以从Cargo.toml里将其移除。

"disable-redzone": true,

由于我们是在编写内核,所以我们在某些时候会需要处理中断。为了安全的执行该操作,我们必须禁用被称为red zone的堆栈指针优化,否则会导致堆栈损坏。想了解更多的信息,请阅读另一篇文章禁用red zone。

"features": "-mmx,-sse,+soft-float",

feature字段可以启用/禁用目标平台的功能。我们通过添加减号前缀来禁用mmxsse功能,通过添加加号前缀来启用soft-float功能。

mmxsse决定了是否支持单指令多数据(SIMD),SIMD通常可以显著的提高程序运行的速度。然而,在操作系统内核里使用大型SIMD寄存器会导致性能问题。原因是系统在从中断程序返回前必须将所有的寄存器恢复原状。这也意味着,在每次系统调用和硬件中断发生时,内核都必须保存整个SIMD的状态。SIMD的状态非常大(一般在512-1600字节之间)而中断可能又会频繁发生,这些额外的保存/恢复操作会严重影响性能。为了避免这种情况,我们将为我们的内核(不是为上层应用)禁用SIMD。

禁用SIMD会导致的一个问题是在X86_64下浮点运算默认依赖于SIMD寄存器。为了解决这个问题,我们引入了soft-float功能。soft-float可以在通用整型数据的基础上通过软件模拟的方式来实现浮点运算。

想要了解更多信息,请阅读禁用SIMD。

统合起来

我们的target specification(目标平台配置)文件现在看起来应该如下:

{
  "llvm-target": "x86_64-unknown-none",
  "data-layout": "e-m:e-i64:64-f80:128-n8:16:32:64-S128",
  "arch": "x86_64",
  "target-endian": "little",
  "target-pointer-width": "64",
  "target-c-int-width": "32",
  "os": "none",
  "executables": true,
  "linker-flavor": "ld.lld",
  "linker": "rust-lld",
  "panic-strategy": "abort",
  "disable-redzone": true,
  "features": "-mmx,-sse,+soft-float"
}

构建我们的内核

为了编译我们的系统,我们需要使用Linux的规范(我也不清楚为什,我猜可能是LLVM的默认设置?)。这也就意味着我们需要一个像上一篇文章里描述的名为_start的入口点。

// src/main.rs

#![no_std] // don't link the Rust standard library
#![no_main] // disable all Rust-level entry points

use core::panic::PanicInfo;

/// This function is called on panic.
#[panic_handler]
fn panic(_info: &PanicInfo) -> ! {
    loop {}
}

#[no_mangle] // don't mangle the name of this function
pub extern "C" fn _start() -> ! {
    // this function is the entry point, since the linker looks for a function
    // named `_start` by default
    loop {}
}

注意,不管你的宿主机是什么操作系统,你的入口点都必须叫_start。上一篇文章里提到的Windows和macOS的入口点都应该删掉。

我们现在可以通过把这个JSON文件名传入--target参数来编译我们的内核了:

> cargo build --target x86_64-blog_os.json

error[E0463]: can't find crate for `core` OR
error[E0463]: can't find crate for `compiler_builtins`

编译失败!错误信息告诉我们Rust编译器没有找到core或是 compiler_builtins库。这两个库都会隐式的链接到所有的no_std包。[core library]包含了Rust的基础类型诸如Result,Option和迭代器,而 [compiler_builtins library]则提供了很多LLVM需要的底层函数,例如memcpy

现在的问题是core library是作为预编译库和Rust编译器一起发布的。所以它只对它支持的目标三元组宿主(例如x86_64-unknown-linux-gnu)有效,而对我们自定义的平台无效。如果我们想要为我们自己的目标平台编译代码,我们需要先为目标平台重新编译core才行。

Cargo xbuild

这就是我们为什么需要引入 [cargo xbuild]的原因了。它是一个可以自动交叉编译core和其他内置库的cargo build的封装。我们可以通过执行如下命令来安装它:

cargo install cargo-xbuild

这个命令依赖于rust源码,我们可以通过rustup component add rust-src命令来安装rust源码。

我们现在可以把上面的命令中的build替换成xbuild并重新运行:

> cargo xbuild --target x86_64-blog_os.json
   Compiling core v0.0.0 (/…/rust/src/libcore)
   Compiling compiler_builtins v0.1.5
   Compiling rustc-std-workspace-core v1.0.0 (/…/rust/src/tools/rustc-std-workspace-core)
   Compiling alloc v0.0.0 (/tmp/xargo.PB7fj9KZJhAI)
    Finished release [optimized + debuginfo] target(s) in 45.18s
   Compiling blog_os v0.1.0 (file:///…/blog_os)
    Finished dev [unoptimized + debuginfo] target(s) in 0.29 secs

我们看到cargo xbuild为我们自定义的目标平台交叉编译了 core, compiler_builtin, 和 alloc 库。由于这些库用了大量的unstable的功能,所以我们必须使用nightly Rust compiler。最后,我们终于成功编译了我们的blog_oscrate。

我们现在终于可以为裸机构建我们的内核了。然而,我们提供给boot loader调用的_start入口点仍然是空的。所以,我们来让它向屏幕输出一些东西。

设置一个默认的Target

为了避免每次调用cargo-xbuild都得向--target传递参数,我们必须指定一个默认的target。为了达成这个目标,我们在 .cargo/config目录下创建了一个包含如下内容的 cargo configuration 文件:

# in .cargo/config

[build]
target = "x86_64-blog_os.json"

这个配置告诉cargo,当没有明确的参数传递给 --target 时,默认使用我们自己的 x86_64-blog_os.json target。这也意味着我们现在可以使用简单的cargo xbuild来构建我们的内核了。想要了解更多关于cargo配置的可选项等信息,请阅读 official documentation。

向屏幕打印

现阶段像屏幕打印字符的最简单的方式就是通过VGA text buffer了。这是一块映射到VGA硬件的特殊的内存区域,它里面包含了要显示到屏幕上的内容。它通常由25行组成,每行包含80个字符单元。每个字符单元显示一个包含前景和背景色的ASCII字符。在屏幕上显示效果如下:

128ff37e0ac42095ffe73c633e4efdb6.png

我们会在下一章详细讨论VGA缓冲区的内存布局,届时我们将会为其写一个简单的驱动。现在,为了输出"Hello World!",我们只需要知道缓冲区的起始位置为0xb8000且每个字符单元包含一个ASCII字节和一个颜色字节。

代码实现如下:

static HELLO: &[u8] = b"Hello World!";

#[no_mangle]
pub extern "C" fn _start() -> ! {
    let vga_buffer = 0xb8000 as *mut u8;

    for (i, &byte) in HELLO.iter().enumerate() {
        unsafe {
            *vga_buffer.offset(i as isize * 2) = byte;
            *vga_buffer.offset(i as isize * 2 + 1) = 0xb;
        }
    }

    loop {}
}

首先我们将整型数据0xb8000转成一个裸指针。然后,我们迭代遍历静态(satic)HELLO。我们使用 [enumerate]来获得另一个循环变量i。在for循环体中,我们用[offset]方法来将字符串字节和对应的颜色字节写入内存中(0xb代表浅青色)。

注意,在所有的写内存操作外都有一个[unsafe]区块包裹。原因是Rust编译器无法证明我们创建的裸指针是有效的。他们可能会指向任何地方并导致数据损坏。将代码放到unsafe块里基本上可以代表我们告诉编译器我们绝对确定自己做的操作都是合法的。注意unsafe块并没有关闭Rust的安全检查,它只是允许你做四种例外事件:

我要强调随便使用unsafe并不是我们在Rust里的工作方式!在unsafe块中使用裸指针很容易搞砸,举个例子,如果我们不小心的话我们会很容易往缓冲区外的内存写入数据。

所以我们应该尽量最小化unsafe块。Rust给了我们创建安全抽象的能力来实现这个目标。举个例子,我们可以创建一个VGA缓冲区类型将所有的不安全的代码封装起来从而确保在外部操作时不会有任何不安全的错误发生。这样我们只需要最少的unsafe代码从而确保我们不会破坏内存安全。在下一篇文章里,我们将会创建这样的一个安全的VGA缓冲区抽象。

运行我们的内核

现在既然我们已经有了一个可以打印字符的内核,那么是时候来运行它了。首先,我们需要通过链接到bootloader来把我们编译的内核制作成磁盘启动镜像。然后我们可以在qemu虚拟机里运行它或是通过一个U盘让它在物理机上启动。

创建一个启动镜像

为了将我们编译完的内核制作成磁盘启动镜像,我们需要将它和一个bootloader链接起来。就像我们在启动相关的小结了解到的那样,bootlaoder负责初始化CPU并加载我们的内核。

由于这个是我们自己的项目,我们使用[bootloader]crate而不是编写我们自己的bootloader。这个crate没有用任何c代码而是仅仅使用Rust和一些内联汇编实现了一个基本的BIOS bootloader。为了用它来启动我们的内核,我们在依赖里添加如下:

# in Cargo.toml

[dependencies]
bootloader = "0.6.0"

仅仅将bootloader作为依赖添加到项目里不足以创建一个磁盘启动镜像。现在的问题是我们需要在内核编译完成之后将其链接到bootloader,但是cargo却不支持 post-build scripts。

为了解决这个问题,我们创建了一个名为bootimage的工具,它会先编译内核和bootloader,然后将他们链接在一起从而创建一个磁盘启动镜像。你可以通过在终端执行以下命令来安装这个工具:

cargo install bootimage --version "^0.7.3"

^0.7.3 被称为 caret requirement,它的意思是需要"0.7.3版本或是兼容其的更新的版本"。所以如果我们在已发布的版本如0.7.40.7.5上发现了bug,cargo会自动切换到最新的版本,前提是它仍然在0.7.x这个大版本里。相应的,cargo不会选择0.8.0版本,因为它被视为不兼容。注意,Cargo.toml里的依赖项默认都使用的^符号,所以bootloader依赖也不例外,他们应该使用相同的规则。

为了使用bootimage来构建bootloader,你需要先安装rustup组件llvm-tools-preview 。你可以通过执行rustup component add llvm-tools-preview命令添加它。

在安装完bootimage并添加llvm-tools-preview组建后我们可以通过执行下面的命令来创建磁盘启动镜像:

> cargo bootimage

我们可以看到我们的工具使用xcargo build重新编译了我们的内核,也就是说它会自动把你做的修改添加进来。接着,它会编译bootloader,这需要一段时间。就像所有的crate依赖一样,它们只会编译一次,后续都会使用缓存中的内容,所以后续的编译速度将会更快。最后,bootimage会将bootloader和内核结合起来生成一个磁盘启动镜像。

执行完这个命令之后,你可以在 target/x86_64-blog_os/debug目录下看到一个名为bootimage-blog_os.bin的文件。你可以在虚拟机里启动它或是将它通过一个USB设备拷贝到物理机上去。(注意,这不是一个CD镜像,他们有着不同的格式,所以将它烧录到CD上是无法使用的)。

bootimage做了什么呢?

bootimage工具在底层实际做了这些工作:

  • 将内核编译成ELF文件
  • 将bootloader依赖编译成独立的可执行文件
  • 将内核ELF文件按字节和bootloader拼接起来

在启动的时候,bootloader会读取并解析附加的ELF文件。然后它会将代码段映射到页表的虚拟地址里,将.bss小节设置0,并设置堆栈。最后,它会读取入口点的地址(在我们的内核里是_start函数),并跳转到相应位置。

在QEMU里启动内核

现在我们可以在虚拟机里启动这个磁盘镜像。可以通过执行以下命令来使其在QEMU中启动:

> qemu-system-x86_64 -drive format=raw,file=bootimage-blog_os.bin
warning: TCG doesn't support requested feature: CPUID.01H:ECX.vmx [bit 5]

这会打开一个独立的窗口并显示如下画面:

4ea8ab82f8cbdf2bd04158754767e38d.png

我们可以看到"Hello World!"已经显示在屏幕上了。

物理机

我们还可以将其写入到U盘中并在物理机上启动它:

> dd if=target/x86_64-blog_os/debug/bootimage-blog_os.bin of=/dev/sdX && sync

sdx是你的U盘设备名。注意选择正确的设备名,因为指定设备上的数据都将全部被擦除覆写。

在将镜像写入U盘后,你可以在任意机器上通过U盘来启动。为了从U盘启动系统你可能需要设置启动菜单或是在BIOS设置里修改启动顺序。注意,由于bootloadercrate不支持UEFI,所以无法在UEFI的机器上启动。

使用cargo run

为了是在QEMU里运行我们的内核变得更简单方便,我们为crago配置runner关键字:

# in .cargo/config

[target.'cfg(target_os = "none")']
runner = "bootimage runner"

target.'cfg(target_os = "none")'代表所有的目标平台的 "os" 字段的配置文件都被设置为 "none"。这其中也包括我们的x86_64-blog_os.json目标平台。runner关键字指定了由cargo run调用的命令。指定的命令将在成功构建后作为执行路径的第一个参数被传递并运行。具体细节请查看cargo documentation 。

bootimage runner命令是被专门设计为一个可执行的runner的。它会将给定的可执行文件和项目的bootloader依赖相关联,然后启动QEMU。更多的细节和配置选项请查看[Readme of bootimage]。

现在我们可以使用cargo xrun 来编译我们的内核并在QEMU里启动啦。与xbuild一样,xrun字命令会在实际调用cargo命令前构建sysroot crates。子命令同样由cargo-xbuild提供,所以你无需安装其他的附加工具。

下期预告

在下一篇文章中,我们将探索VGA字符缓冲的更多细节并为它写一个安全的接口。我们将会为其添加一个println宏。

  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值