使用Rust编写操作系统 - 1.1 - Rust独立二进制程序
创建我们自己的操作系统内核的第一步,是创建一个不链接标准库的Rust可执行程序。这样就可以在没有底层操作系统的情况下在裸机上运行Rust代码。
这个博客是在GitHub上公开开发的。如果你有任何问题或疑问,请在那里开一个issue。你也可以在底部留言。这篇文章的完整源代码可以在post-01分支中找到。
介绍
要编写操作系统内核,我们需要写出的代码不能依赖任何操作系统提供的功能。这意味着我们不能使用线程、文件、堆内存、网络、随机数、标准输出或任何其他需要操作系统抽象或需要特定硬件的功能。这是有道理的,毕竟我们正在尝试编写自己的操作系统和自己的驱动程序。
这意味着我们不能使用大多数Rust标准库,但是可以使用很多Rust功能。例如,我们可以使用迭代器、闭包、模式匹配、Option、Result、格式化字符串,当然还有所有权系统。这些功能使我们能够以一种非常有表现力的高级方式编写内核,而不必担心未定义行为或内存安全。
为了使用Rust创建OS内核,我们需要创建一个无需底层操作系统即可运行的可执行文件。这种可执行文件通常称为“独立式”或“裸机”可执行文件。
这篇文章描述了创建一个Rust独立二进制文件的必要步骤,并解释了为什么需要这些步骤。如果您仅对一个最小的示例感兴趣,则可以直接跳转至小结部分。
禁用标准库
默认情况下,所有Rust crate都链接标准库,该库建立在操作系统的线程、文件或网络等功能之上。它还依赖于C标准库libc
,该库与OS服务紧密交互。我们的计划是编写一个操作系统,因此不能使用任何依赖于OS的库。我们必须通过no_std
属性禁用自动引用标准库。
首先创建一个新的cargo项目。最简单的方法是通过命令行:
1 | cargo new blog_os --bin --edition 2018 |
我将项目命名为blog_os
,你当然可以选择自己喜欢的名称。--bin
标志意为创建可执行二进制文件(与创建库的--lib
不同),而--edition 2018
参数指定crate需要使用2018版的Rust。 当我们运行命令时,cargo为我们创建以下目录结构:
1 | blog_os |
Cargo.toml
包含crate配置,例如crate名称、作者、语义版本号和相关依赖。 src/main.rs
文件包含crate的根模块和main
函数。 您可以通过cargo build
来编译crate,然后在target/debug
子文件夹中运行已编译的blog_os
二进制文件。
no_std
属性
现在,我们的crate隐式链接了标准库。让我们尝试通过添加no_std
属性来禁用此功能:
1 |
|
当我们尝试立即构建(通过运行cargo build
)时,会发生以下错误:
1 | error: cannot find macro `println!` in this scope |
发生此错误是因为println
宏是标准库的一部分,我们不能再使用它,也就是说我们无法再打印东西。这是合理的,因为println
写入标准输出,这也是由操作系统提供的特殊文件描述符。
那么让我们删除打印语句,然后使用空的main函数再试一次:
1 |
|
1 | > cargo build |
现在,编译器指出缺少#[panic_handler]
函数和一个语言项。
实现Rust的panic
panic_handler
属性定义的函数在发生panic时会被编译器调用。标准库提供了自己的panic处理函数,但那是在no_std
环境中,我们需要自己定义它:
1 | use core::panic::PanicInfo; |
PanicInfo
类型的参数包含产生panic时的文件和行以及可选的panic消息。该函数永不返回,因此使用!
定义函数返回“never”类型,以将其标记为发散函数。目前我们还不能在此函数中执行太多操作,因此在其中写一个无限循环。
eh_personality
语言项
语言项是编译器内部所需的特殊功能和类型。例如,Copy
trait是一种语言项,它告诉编译器哪些类型具有可复制语义
。在查看其实现时,会看到特殊的#[lang = "copy"]
属性,该属性将其定义为语言项。
虽然我们自己提供语言项的自定义实现是可能的,但仅应将其作为最后的手段。原因是语言项是非常不稳定的实现细节,甚至不会进行类型检查(编译器甚至不检查函数是否具有正确的参数类型)。幸运的是,有一种更稳定的方法可以解决上述的语言项错误。
被eh_personality
语言项标记的函数用于实现栈展开功能。默认情况下,在出现panic时,Rust使用栈展开为所有活动的栈变量执行析构函数。这样可以确保释放所有使用的内存,并允许父线程捕获panic并继续执行。但是,栈展开是一个复杂的过程,需要一些特定的OS库支持(例如,Linux上的libunwind或Windows上的结构化异常处理),因此我们不希望将其用于我们的操作系统。
禁用栈展开
在一些其他场景中同样不希望使用栈展开,因此Rust提供了一个选择,可以在发生panic时中止操作。这禁用了栈展开标志信息的生成,也会大大减小二进制程序的大小。禁用栈展开功能有多种方式,最简单的方法是将以下行添加到Cargo.toml
中:
1 | [profile.dev] |
这将为dev
profile(用于cargo build
)和release
profile(用于cargo build --release
)设置中止panic策略。现在,编译器应该不提醒缺少eh_personality
语言项了。
我们修复了以上两个错误。但是,如果现在尝试编译,则会发生另一个错误:
1 | > cargo build |
我们的程序缺少定义入口点的start
语言项。
start
语言项
你可能会认为main
函数是程序运行时调用的第一个函数。但是,大多数语言都有一个运行时系统,负责诸如垃圾回收(如Java)或软件线程(如Go中的goroutines)之类的事情。该运行时需要在main
函数之前调用,因为它需要初始化自己。
在链接标准库的典型Rust二进制文件中,执行过程从名为crt0
(“C runtime zero”)的C运行时库开始,该库为C应用程序设置了环境。其中包括创建堆栈并将参数放置在正确的寄存器中。然后,C运行时调用Rust运行时的入口点,该入口点被start
语言项标记。Rust的运行时非常短,它可以处理一些小事情,例如设置栈溢出防护,或是在panic时打印回溯信息。之后,运行时才会调用main
函数。
我们的独立可执行文件无法访问Rust运行时和crt0
,因此我们需要定义自己的入口点。自己实现start
语言项并没有什么帮助,因为它仍然需要crt0
。而我们要做的是直接覆盖crt0
入口点。
重写入口点
为了告诉Rust编译器我们不想使用普通的入口点链,需要添加了#![no_main]
属性。
1 |
|
你可能会注意到,我们删除了main
函数,这是因为没有了底层的运行时调用,main
就失去意义。而我们现在使用自己的_start
函数覆盖操作系统入口点:
1 |
|
通过使用#[no_mangle]
属性,我们禁用了名称重整,以确保Rust编译器确实输出名称为_start
的函数。如果没有该属性,则编译器会生成一些神秘的诸如_ZN3blog_os4_start7hb173fedf945531caE
的名称,用以为每个函数赋予唯一的名称。该属性是必需的,因为我们需要在下一步中将入口点函数的名称告知链接器。
我们还必须将函数标记为extern "C"
,以告知编译器该函数应使用C调用约定(而不是默认的Rust调用约定)。将函数命名为_start
的原因也是因为这是大多数系统的默认入口点名称。
这里的!
返回类型表示这是一个发散函数,即永不返回。这是必需的,因为该入口点不会被任何函数调用,而是由操作系统或bootloader直接调用。因此该函数不会返回,而会调用操作系统的退出系统调用。在我们的项目中,关闭计算机可能是一个合理的操作,因为如果独立二进制程序返回后无需执行任何操作。目前,我们也通过无限循环来实现。
现在,当我们运行cargo build
时,我们会看到一个难看的链接器错误。
我们使用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)。
链接器错误
链接器是一个将生成的代码组合成可执行文件的程序。由于Linux、Windows和macOS的可执行文件格式不同,因此每个系统都有自己的链接器,引发不同的错误。 而错误的本因是相同的:链接器的默认配置假定我们的程序依赖于C运行时,而实际上并非如此。
为了解决这个错误,我们需要告诉链接器它不应该引用C运行时。我们可以通过将一组特定的参数传递给链接器或通过构建裸机目标程序来实现。
构建逻辑目标程序
默认情况下,Rust将尝试构建一个能够在你当前的系统环境中运行的可执行文件。例如,如果你在x86_64
硬件上使用Windows,Rust会尝试构建一个使用x86_64
指令的.exe
Windows可执行程序。该环境也称为您的“宿主机”系统。
Rust使用被称为目标三元组的字符串来描述不同的编译环境。你可以通过运行rustc --version --verbose
来查看宿主机的目标三元组:
1 | rustc 1.35.0-nightly (474e7a648 2019-04-07) |
这些输出来自一个x86_64上的Linux系统。我们看到host
三元组是x86_64-unknown-linux-gnu
,这包括了CPU架构(x86_64
),供应商(unknown
),操作系统linux
和ABI(gnu
)。
为了以我们的宿主机三元组为目标编译程序,Rust编译器和链接器会假定存在默认情况下使用C运行时的底层操作系统(例如Linux或Windows),而这会导致链接器错误。因此,为避免链接器错误,我们可以针对没有基础操作系统的其他环境进行编译。
这种裸机环境的一个例子是thumbv7em-none-eabihf
目标三元组,它描述了嵌入式ARM系统。细节并不重要,重要的是这个目标中的none
表明该目标三元组也没有底层操作系统。为了能够为此目标进行编译,我们需要使用rustup添加这个目标所需文件:
1 | rustup target add thumbv7em-none-eabihf |
这将下载系统的标准(和核心)库的。如此,我们可以为该目标构建独立可执行程序了:
1 | cargo build --target thumbv7em-none-eabihf |
通过指定--target
参数,我们交叉编译了裸机目标系统的可执行程序。由于目标系统并没有操作系统,因此链接程序不会尝试链接C运行时,也因此构建将没有任何链接程序错误,提示构建成功。
这便是我们即将用于OS内核构建的方法。这一次,我们将使用描述x86_64
裸机环境的自定义目标三元组来代替thumbv7em-none-eabihf
。我们将在下一篇文章中做出详细介绍。
链接器参数
除了以裸机为目标进行编译之外,我们还可以通过将一组特定的参数传递给链接器来解决链接器错误。这不并是我们将用于内核编译的方法,因此本节是可选的,仅出于完整性考虑而提供。
在本小节中,我们讨论在Linux,Windows和macOS上发生的链接器错误,并说明如何通过将其他参数传递给链接器来解决这些错误。请注意,操作系统之间的可执行程序格式和链接器有所不同,因此每个系统都需要不同的参数集。
Linux
在Linux上,发生以下链接程序错误(部分):
1 | error: linking with `cc` failed: exit code: 1 |
其问题在于,链接器默认情况下引用C运行时的启动例程,也称为_start
。这需要已经被no_std
属性禁用的C标准库libc
中的一些符号,因此链接器无法解析这些引用。为了解决这个问题,我们可以通过传递-nostartfiles
参数来告诉链接器它不应链接C启动例程。
其中一种使用cargo向传递链接器参数的方法是cargo rustc
命令。该命令的行为与cargo build
相同,同时允许将选项传递给底层的Rust编译器rustc
。rustc
具有-C link-arg
标志,该标志将参数传递给链接程序。综上,我们的新构建命令应为:
1 | cargo rustc -- -C link-arg=-nostartfiles |
如此,我们的crate便构建为Linux上的独立可执行程序了!
我们并不需要显式指定入口点函数的名称,因为链接器默认情况下就会查找名称为_start
的函数。
Windows
在Windows上,发生了另一个链接器错误(部分):
1 | error: linking with `link.exe` failed: exit code: 1561 |
“entry point must be defined”的错误意味着链接器找不到入口点。在Windows上,默认入口点名称取决于所使用的子系统。对于CONSOLE
子系统,链接器将寻找一个名为mainCRTStartup
的函数,对于WINDOWS
子系统,它将寻找一个名为WinMainCRTStartup
的函数。要覆盖默认值以告诉链接器应查找名为_start
的函数,可以将/ENTRY
参数传递给链接器:
1 | cargo rustc -- -C link-arg=/ENTRY:_start |
从不同的参数格式中,我们可以清楚地看到Windows链接器是与Linux链接器完全不同的程序。
现在出现另一个链接器错误:
1 | error: linking with `link.exe` failed: exit code: 1221 |
发生该错误的原因是Windows可执行程序可以使用不同的子系统。对于普通程序,根据入口点名称进行推断:如果入口点名为main
,则使用CONSOLE
子系统;如果入口点名为WinMain
,则使用WINDOWS
子系统。 由于_start
函数是另一个名称,因此我们需要显式指定子系统:
1 | cargo rustc -- -C link-args="/ENTRY:_start /SUBSYSTEM:console" |
我们在这里使用CONSOLE
子系统,指定WINDOWS
子系统也可以工作。与其多次传递-C link-arg
,不如使用-C link-args
,它使用空格分隔的参数列表。
使用这个命令,我们成功的在Windows上构建了可执行程序。
macOS
在macOS上,发生以下链接器错误(部分):
1 | error: linking with `cc` failed: exit code: 1 |
该错误消息告诉我们,链接器无法找到默认名称为main
的入口点函数(由于某些原因,在macOS上所有函数均带有_
前缀)。要将入口点设置为·函数,我们需要传递链接器参数-e
:
1 | cargo rustc -- -C link-args="-e __start" |
-e
标志指定入口点函数的名称。由于所有函数在macOS上都有一个附加的_
前缀,因此我们需要将入口点设置为__start
而不是_start
。
这次又出现以下链接器错误:
1 | error: linking with `cc` failed: exit code: 1 |
macOS并不正式支持静态链接的二进制文件,并且默认情况下要求程序链接libSystem
库。要覆盖它并链接静态二进制文件,我们将-static
标志传递给链接器:
1 | cargo rustc -- -C link-args="-e __start -static" |
这仍然不够,出现了第三个链接器错误:
1 | error: linking with `cc` failed: exit code: 1 |
该错误出现的原因是在默认情况下macOS上的程序链接到crt0
(“C runtime zero”)。这类似于我们在Linux上遇到的错误,也可以通过添加-nostartfiles
链接器参数来解决:
1 | cargo rustc -- -C link-args="-e __start -static -nostartfiles" |
现在我们的程序应该可以在macOS上成功构建了。
统一构建命令
现在,根据主机平台,我们有不同的构建命令,这并不方便。我们可以创建一个名为.cargo/config.toml
的文件,用以包含针对不同平台的特定编译参数:
1 | in .cargo/config.toml |
rustflags
键包含的参数会自动添加到rustc
的每次调用中。有关.cargo/config.toml
文件的更多信息,请查看官方文档。
现在,我们的程序应该可以在这三个平台上以简单的cargo build
方式构建了。
应该这要做吗
虽然可以为Linux、Windows和macOS构建独立的可执行程序,但这可能不是一个好主意。原因是我们的可执行程序仍然需要各种各样的东西,例如,在调用_start
函数时初始化堆栈。没有C运行时,可能无法满足其中一些要求,这可能导致我们的程序出错,例如 通过分段故障。
如果要创建在现有操作系统(包括libc
)之上运行的最小二进制文件,并按此处所述设置#[start]
属性,可能是一个更好的主意。
小结
一个最小的独立Rust二进制程序如下所示:
1 | // 不链接Rust标准库 |
1 | [package] |
要构建此二进制程序,我们需要针对裸机目标进行编译,例如thumbv7em-none-eabihf
:
1 | cargo build --target thumbv7em-none-eabihf |
此外,我们也可以通过向链接器传递其他参数来为宿主机系统编译它:
1 | # Linux |
请注意,这只是Rust独立二进制程序的最小示例。该二进制程序还需要更多操作,例如,在调用_start
函数时初始化堆栈。因此,对于此类二进制程序的任何在实际场景中的使用,都需要继续添加更多的内容。
下期预告
下一篇文章将介绍将我们的独立二进制程序转换为最小操作系统内核所需的步骤。其中包括创建自定义编译目标,将我们的可执行文件与bootloader结合以及学习如何在屏幕上打印内容。
支持本项目
创建和维护这个博客和相关库是一项繁重的工作,但我真的很喜欢。通过支持我,您可以让我在新内容、新功能和持续维护上投入更多时间。
支持我的最好方式是在GitHub上赞助我,因为他们不收取任何中间费用。如果你喜欢其他平台,我也有Patreon和Donorbox账户。后者是最灵活的,因为它支持多种货币和一次性捐款。
感谢您的支持!