使用Rust编写操作系统(一):独立式可执行程序

使用Rust编写操作系统(一):独立式可执行程序

我们的第一步,是在不连接标准库的前提下,创建独立的Rust可执行文件。无需底层操作系统的支撑,这将能让在裸机bare metal)上运行Rust代码成为现实。

简介

要编写一个操作系统内核,我们的代码应当不基于任何的操作系统特性。这意味着我们不能使用线程、文件、堆内存、网络、随机数、标准输出,或其它任何需要特定硬件和操作系统抽象的特性;这其实讲得通,因为我们正在编写自己的硬件驱动和操作系统。

实现这一点,意味着我们不能使用Rust标准库的大部分;但还有很多Rust特性是我们依然可以使用的。比如说,我们可以使用迭代器闭包模式匹配OptionResult字符串格式化,当然还有所有权系统。这些功能让我们能够编写表达性强、高层抽象的操作系统,而无需操心未定义行为内存安全

为了用Rust编写一个操作系统内核,我们需要独立于操作系统,创建一个可执行程序。这样的可执行程序常被称作独立式可执行程序(freestanding executable)或裸机程序(bare-metal executable)。

在这篇文章里,我们将逐步地创建一个独立式可执行程序,并且详细解释为什么每个步骤都是必须的。如果读者只对最终的代码感兴趣,可以跳转到本篇文章的小结部分。

禁用标准库

在默认情况下,所有的Rust(crate)都会链接标准库standard library),而标准库依赖于操作系统功能,如线程、文件系统、网络。标准库还与Rust的C语言标准库实现库(libc)相关联,它也是和操作系统紧密交互的。既然我们的计划是编写自己的操作系统,我们就可以不使用任何与操作系统相关的库——因此我们必须禁用标准库自动引用(automatic inclusion)。使用no_std属性可以实现这一点。

我们可以从创建一个新的cargo项目开始。最简单的办法是使用下面的命令:

1
> cargo new blog_os

这里,我把项目命名为blog_os,当然读者也可以选择自己的项目名称。这里,cargo默认为我们添加了--bin选项,说明我们将要创建一个可执行文件(而不是一个库);cargo还为我们添加了--edition 2018标签,指明项目的包要使用Rust的2018版次2018 edition)。当我们执行这行指令的时候,cargo为我们创建的目录结构如下:

1
2
3
4
blog_os
├── Cargo.toml
└── src
    └── main.rs

在这里,Cargo.toml文件包含了包的配置(configuration),比如包的名称、作者、semver版本和项目依赖项;src/main.rs文件包含包的根模块(root module)和main函数。我们可以使用cargo build来编译这个包,然后在target/debug文件夹内找到编译好的blog_os二进制文件。

no_std属性

现在我们的包依然隐式地与标准库链接。为了禁用这种链接,我们可以尝试添加no_std属性

1
2
3
4
5
6
7
// main.rs

#![no_std]

fn main() {
    println!("Hello, world!");
}

看起来非常顺利。但我们使用cargo build来编译时,却出现了下面的错误:

1
2
3
4
5
error: cannot find macro `println!` in this scope
 --> src\main.rs:4:5
  |
4 |     println!("Hello, world!");
  |     ^^^^^^^

出现这个错误的原因是,println!宏是标准库的一部分,而我们的项目不再依赖标准库。我们选择不再打印字符串。这也能解释得通,因为println!将会向标准输出standard output)打印字符,它依赖于特殊的文件描述符;这个特性是由操作系统提供的。

所以我们可以移除这行代码,这样main函数就是空的了。再次编译:

1
2
3
4
5
// main.rs

#![no_std]

fn main() {}
1
2
3
> cargo build
error: `#[panic_handler]` function required, but not found
error: language item required, but not found: `eh_personality`

现在我们发现,代码缺少一个#[panic_handler]函数和一个语言项(language item)。

实现panic处理函数

panic_handler属性被用于定义一个函数;在程序panic时,这个函数将会被调用。标准库中提供了自己的panic处理函数,但在no_std环境中,我们需要定义自己的panic处理函数:

1
2
3
4
5
6
7
8
9
// in main.rs

use core::panic::PanicInfo;

/// 这个函数将在panic时被调用
#[panic_handler]
fn panic(_info: &PanicInfo) -> ! {
    loop {}
}

类型为PanicInfo的参数包含了panic发生的文件名、代码行数和可选的错误信息。这个函数从不返回,所以他被标记为发散函数diverging function)。发散函数的返回类型称作Never类型"never" type),记为!。对这个函数,我们目前能做的事情很少,所以我们只需编写一个无限循环loop {}

eh_personality语言项

语言项是一些编译器需求的特殊函数或类型。举例来说,Rust的Copy trait是一个这样的语言项,告诉编译器哪些类型需要遵循复制语义copy semantics)——当我们查找Copy trait的实现时,我们会发现,一个特殊的#[lang = "copy"]属性将它定义为了一个语言项,达到与编译器联系的目的。

我们可以自己实现语言项,但这只应该是最后的手段:目前来看,语言项是高度不稳定的语言细节实现,它们不会经过编译期类型检查(所以编译器甚至不确保它们的参数类型是否正确)。幸运的是,我们有更稳定的方式,来修复上面的语言项错误。

eh_personality语言项标记的函数,将被用于实现栈展开stack unwinding)。在使用标准库的情况下,当panic发生时,Rust将使用栈展开,来运行在栈上活跃的所有变量的析构函数(destructor)——这确保了所有使用的内存都被释放,允许调用程序的父进程(parent thread)捕获panic,处理并继续运行。但是,栈展开是一个复杂的过程,如Linux的libunwind或Windows的结构化异常处理structured exception handling, SEH),通常需要依赖于操作系统的库;所以我们不在自己编写的操作系统中使用它。

禁用栈展开

在其它一些情况下,栈展开不是迫切需求的功能;因此,Rust提供了panic时中止abort on panic)的选项。这个选项能禁用栈展开相关的标志信息生成,也因此能缩小生成的二进制程序的长度。有许多方式能打开这个选项,最简单的方式是把下面的几行设置代码加入我们的Cargo.toml

1
2
3
4
5
[profile.dev]
panic = "abort"

[profile.release]
panic = "abort"

这些选项能将dev配置(dev profile)和release配置(release profile)的panic策略设为abortdev配置适用于cargo build,而release配置适用于cargo build --release。现在编译器应该不再要求我们提供eh_personality语言项实现。

现在我们已经修复了出现的两个错误,可以信心满满地开始编译了。然而,尝试编译运行后,一个新的错误出现了:

1
2
> cargo build
error: requires `start` lang_item

start语言项

这里,我们的程序遗失了start语言项,它将定义一个程序的入口点(entry point)。

我们通常会认为,当运行一个程序时,首先被调用的是main函数。但是,大多数语言都拥有一个运行时系统runtime system),它通常为垃圾回收(garbage collection)或绿色线程(software threads,或green threads)服务,如Java的GC或Go语言的协程(goroutine);这个运行时系统需要在main函数前启动,因为它需要让程序初始化。

一个典型的使用标准库的Rust程序,它的运行将从名为crt0的运行时库开始。crt0意为C runtime zero,它能建立一个适合运行C语言程序的环境,这包含了栈的创建和可执行程序参数的传入。这之后,这个运行时库会调用Rust的运行时入口点,这个入口点被称作start语言项("start" language item)。Rust只拥有一个极小的运行时,它只拥有较少的功能,如爆栈检测和打印堆栈轨迹(stack trace)。这之后,运行时将会调用main函数。

我们的独立式可执行程序并不能访问Rust运行时或crt0库,所以我们需要定义自己的入口点。实现一个start语言项并不能解决问题,因为这之后程序依然要求crt0库。所以,我们要做的是,直接重写整个crt0库和它定义的入口点。

重写入口点

要告诉Rust编译器我们不使用预定义的入口点,我们可以添加#![no_main]属性。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
#![no_std]
#![no_main]

use core::panic::PanicInfo;

/// 这个函数将在panic时被调用
#[panic_handler]
fn panic(_info: &PanicInfo) -> ! {
    loop {}
}

读者也许会注意到,我们移除了main函数。很显然,既然没有底层已有的运行时调用它,main函数将不会被运行。为了重写操作系统的入口点,我们转而编写一个_start函数:

1
2
3
4
#[no_mangle]
pub extern "C" fn _start() -> ! {
    loop {}
}

我们使用no_mangle标记这个函数,来对它禁用名称重整name mangling)——这确保Rust编译器输出一个名为_start的函数;否则,编译器可能最终生成名为_ZN3blog_os4_start7hb173fedf945531caE的函数,无法让链接器正确辨别。

我们还将函数标记为extern "C",告诉编译器这个函数应当使用C语言的调用约定,而不是Rust语言的调用约定。函数名为_start,是因为大多数系统默认使用这个名字作为入口点名称。

与前文的panic函数类似,这个函数的返回值类型为!——它定义了一个发散函数,或者说一个不允许返回的函数。这一点是必要的,因为这个入口点不将被任何函数调用,但将直接被操作系统或引导程序(bootloader)调用。所以作为函数返回的替换,这个入口点应该调用,比如操作系统提供的exit系统调用"exit" system call)函数。在我们编写操作系统的情况下,关机应该是一个合适的选择,因为当一个独立式可执行程序返回时,不会留下任何需要做的事情(there is nothing to do if a freestanding binary returns)。暂时来看,我们可以添加一个无限循环,这样可以符合返回值的类型。

如果我们现在编译这段程序,会出来一大段不太好看的链接器错误(linker error)。

链接器错误

链接器(linker)是一个程序,它将生成的目标文件组合为一个可执行文件。不同的操作系统如Windows、macOS、Linux,规定了不同的可执行文件格式,因此也各有自己的链接器,抛出不同的错误;但这些错误的根本原因还是相同的:链接器的默认配置假定程序依赖于C语言的运行时环境,但我们的程序并不依赖于它。

为了解决这个错误,我们需要告诉链接器,它不应该包含(include)C语言运行环境。我们可以选择提供特定的链接器参数(linker argument),也可以选择编译为裸机目标(bare metal target)。

编译为裸机目标

在默认情况下,Rust尝试适配当前的系统环境,编译可执行程序。举个栗子,如果你使用x86_64平台的Windows系统,Rust将尝试编译一个扩展名为.exe的Windows可执行程序,并使用x86_64指令集。这个环境又被称作你的宿主系统("host" system)。

为了描述不同的环境,Rust使用一个称为目标三元组(target triple)的字符串。要查看当前系统的目标三元组,我们可以运行$ rustc --version --verbose

1
2
3
4
5
6
7
rustc 1.35.0-nightly (474e7a648 2019-04-07)
binary: rustc
commit-hash: 474e7a6486758ea6fc761893b1a49cd9076fb0ab
commit-date: 2019-04-07
host: x86_64-unknown-linux-gnu
release: 1.35.0-nightly
LLVM version: 8.0

上面这段输出来自于x86_64平台下的Linux系统。我们能看到,host字段的值为三元组x86_64-unknown-linux-gnu,它分为以下几个部分:CPU架构x86_64;供应商unknown;操作系统linux二进制接口gnu

Rust编译器尝试为当前系统的三元组编译,并假定底层有一个类似于Windows或Linux的操作系统提供C语言运行环境——这将导致链接器错误。所以,为了避免这个错误,我们可以另选一个底层没有操作系统的运行环境。

这样的运行环境被称作裸机环境,例如目标三元组thumbv7em-none-eabihf描述了一个ARM嵌入式系统embedded system)。我们暂时不需要了解它的细节,只需要知道这个环境底层没有操作系统——这是由三元组中的none描述的。我们需要用rustup安装这个目标:

1
rustup target add thumbv7em-none-eabihf

这行命令将为目标下载一个标准库和core库。这之后,我们就能为这个目标构建独立式可执行程序了:

1
cargo build --target thumbv7em-none-eabihf

我们传递了--target参数,来为裸机目标系统交叉编译cross compile)我们的程序。我们的目标并不包括操作系统,所以链接器不会试着链接C语言运行环境,因此构建过程成功完成,不会产生链接器错误。

我们将使用这个方法编写自己的操作系统内核。我们不将编译到thumbv7em-none-eabihf,而是使用描述x86_64环境的自定义目标custom target)。在下篇文章中,我们将详细描述一些相关的细节。

链接器参数

我们也可以选择不编译到裸机系统,因为传递特定的参数也能解决链接器错误问题。虽然我们不将在后文中使用这个方法,为了教程的完整性,我们也撰写了专门的短文,来提供这个途径的解决方案。

链接器参数

小结

一个用Rust编写的最小化的独立式可执行程序应该长这样:

src/main.rs

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
#![no_std] // 不链接Rust标准库
#![no_main] // 禁用所有Rust层级的入口点

use core::panic::PanicInfo;

#[no_mangle] // 不重整函数名
pub extern "C" fn _start() -> ! {
    // 因为编译器会寻找一个名为`_start`的函数,所以这个函数就是入口点
    // 默认命名为`_start`
    loop {}
}

/// 这个函数将在panic时被调用
#[panic_handler]
fn panic(_info: &PanicInfo) -> ! {
    loop {}
}

Cargo.toml

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
[package]
name = "crate_name"
version = "0.1.0"
authors = ["Author Name <author@example.com>"]

# 使用`cargo build`编译时需要的配置
[profile.dev]
panic = "abort" # 禁用panic时栈展开

# 使用`cargo build --release`编译时需要的配置
[profile.release]
panic = "abort" # 禁用panic时栈展开

选用任意一个裸机目标来编译。比如对thumbv7em-none-eabihf,我们使用以下命令:

1
cargo build --target thumbv7em-none-eabihf

要注意的是,现在我们的代码只是一个Rust编写的独立式可执行程序的一个例子。运行这个二进制程序还需要很多准备,比如在_start函数之前需要一个已经预加载完毕的栈。所以为了真正运行这样的程序,我们还有很多事情需要做。

下篇预告

基于这篇文章的成果,下一篇文章要做的更深。我们将详细讲述编写一个最小的操作系统内核需要的步骤:如何配置特定的编译目标,如何将可执行程序与引导程序拼接,以及如何把一些特定的字符串打印到屏幕上。

updatedupdated2024-05-052024-05-05