前言
此文为旧文新发,这是我之前翻译文章,因为简书有段时间做内部审查,不能发表文章,所以当时写完放在了其它平台上,今天将这篇移到简书上来。因为没有用无GC
的语言写过上生产的代码,一直有一些遗憾,近年来Rust
崛起了,从TiKV到Libra等项目大胆采用Rust
,这门语言逐渐为人所接受也成熟起来,遂借着翻译的机会,学习这门语言新贵,顺便补一下操作系统欠下的技术债
A Freestanding Rust Binary
独立的Rust
二进制文件
若要编写我们自己的操作系统内核, 第一步便是创建一个不链接标准库(注解:类比为C语言中的stdlib)的Rust可执行文件。这一步是为了在没有底层操作系统支持的情况下, 使得Rust能够在裸机上运行。
这个博客是在GitHub上公开发布的。如果您有任何问题或疑问,欢迎提issue。您也可以在底部留下评论。本文的完整源代码可以在post-01分支中找到。
介绍
要编写操作系统内核,我们需要不依赖于任何操作系统特性的代码。这意味着我们不能使用线程、文件、堆内存、网络、随机数、标准输出或任何其他需要OS抽象或特定硬件的特性。必须如此,因为我们正在尝试编写自己的操作系统和驱动程序。
这意味着我们不能使用大多数Rust标准库,但是我们可以使用很多其它Rust特性。比如我们可以使用迭代器、闭包、模式匹配、选项和结果、字符串格式化,当然还有所有权系统。这些特性让我们能用一种非常有表现力的、高层次的方式编写内核,不必担心未定义的行为或内存安全问题。
为了在Rust中创建操作系统内核,我们需要创建一个可执行文件,它可以在没有底层操作系统运行的情况下运行。这种可执行文件通常被称为"独立的"或"裸金属"可执行文件。
禁用标准库
默认情况下,所有Rust的项目都需要链接标准库,而标准库的特性又取决于操作系统,比如线程、文件或网络。它还依赖于与操作系统紧密交互的C标准库libc
。因为我们的计划正是编写一个操作系统,所以我们不能使用任何依赖于操作系统的库。因此,我们可以通过no_std
属性来禁用标准库。
我们首先要通过创建一个由cargo
管理依赖的(注解:Cargo是Rust程序的包管理器)Rust应用程序。最简单的方法是通过命令行:
cargo new blog_os --bin --edition 2018
我将项目命名为blog_os
,当然您可以选择自己的名称。-—bin
指定我们想要创建一个可执行的二进制文件(与lib库有所不同),--edition 2018
指定我们的项目使用Rust的2018版本。当我们运行该命令时,cargo为我们创建了以下目录结构:
blog_os
├── Cargo.toml
└── src
└── main.rs
Cargo.toml
包含有项目的配置, 比如项目名、作者、版本号及其依赖, src/main.rs
则包括项目根目录及main
函数。您可以通过cargo build
编译您的项目,然后在子目录target/debug
中运行编译后的blog_os
二进制文件。
no_std
属性
现在,我们的项目隐式地链接了标准库。让我们通过添加no_std
属性来禁用它:
// main.rs
#![no_std]
fn main() {
println!("Hello, world!");
}
通过运行cargo build
编译项目时,会发生以下错误:
error: cannot find macro `println!` in this scope
--> src/main.rs:4:5
|
4 | println!("Hello, world!");
|
这个错误的原因是println宏
是标准库的一部分,它会打印标准输出,这是操作系统提供的一个特殊的文件描述符。我们禁用后就不能再打印东西了。
所以,让我们删除打印,将main
函数置空并再次尝试:
// main.rs
#![no_std]
fn main() {}
> cargo build
error: `#[panic_handler]` function required, but not found
error: language item required, but not found: `eh_personality`
如上所示,现在编译器缺少一个#[panic_handler]
函数和一个语言项。
Panic
的实现
panic_handler
是编译器在发生panic
(注解:可理解为异常)时应该调用的函数。标准库提供了自己的panic handler
函数,但是在no_std
环境中,我们需要自己定义它:
// in main.rs
use core::panic::PanicInfo;
/// This function is called on panic.
#[panic_handler]
fn panic(_info: &PanicInfo) -> ! {
loop {}
}
PanicInfo
参数包含发生异常的文件及其对应的行,以及可选的异常消息。该函数应该永远不会返回,因此需要通过返回!
类型将其标记为一个发散函数。在这个函数中我们能做的不多,所以我们就让它无限循环吧。
eh_personality
语言项
语言项是编译器内部需要的特殊函数和类型。例如,Copy
是一个语言项,它告诉编译器哪些类型具有[copy](https://doc.rust-lang.org/nightly/core/marker/trait.Copy.html)
语义。当我们查看实现时,我们看到它具有特殊的#[lang = "copy"]
属性,该属性将其定义为了一个语言项。
提供自己的语言项的实现是可能的,但这只能作为最后的手段。原因是语言项是高度不稳定的实现细节,甚至没有类型检查(因此编译器甚至没有检查函数是否具有正确的参数类型)。幸运的是,有一种更稳定的方法可以修复上述语言项错误。
eh_personality
语言项标记了一个用于实现stackunwinding(注解:?)的函数。默认情况下,Rust使用unwind来运行所有活动堆栈变量的析构函数,以防出现异常。这确保释放所有使用的内存,并允许父线程捕捉异常并继续执行。然而,unwind是一个复杂的过程,需要一些特定于操作系统的库(例如Linux上的libunwind或Windows上的结构化异常处理),所以我们不想在操作系统上使用它。
禁用Unwinding
还有其他一些用例不希望运行unwinding
,所以Rust提供了一个在异常时中止的选项。这禁止生成展开符号信息,从而大大减小了二进制文件的大小。我们可以在多个地方禁用unwind。最简单的方法是在我们的Cargo.toml
加上以下几行。
[profile.dev]
panic = "abort"
[profile.release]
panic = "abort"
这将为dev
概要文件(用于构建开发版本)和release
概要文件(用于构建release
版本)设置终止panic
策略(注解:直接中断,不展示异常堆栈信息)。现在不再需要eh_personality
语言项。
现在我们修正了上面的两个错误。然而,如果我们现在编译它,会发现另外一个错误:
> cargo build
error: requires `start` lang_item
我们的程序缺少定义入口点的start
语言项。
start
属性
有人可能认为main
(注解:一直以来就是这样认为的)函数是运行程序时调用的第一个函数。然而,大多数语言都有一个运行时系统,它负责垃圾收集(例如Java)或软件线程(例如Go中的goroutines)。这个运行时需要在main之前调用,因为它需要初始化自己。
在链接标准库的典型Rust二进制文件中,执行从一个名为crt0
("C runtime zero")的C运行时库开始,该库为C应用程序设置了环境。这包括创建堆栈并将参数放在正确的寄存器中。然后C运行时调用Rust运行时的入口点,该入口点由start language项标记。Rust只有一个非常小的运行时,它负责一些小事,比如设置堆栈溢出保护或在panic上打印回溯。运行时最后调用main
函数。
我们独立的可执行文件不能访问Rust运行时和crt0
,所以我们需要定义自己的入口点。实现start语言项没有帮助,因为它仍然需要crt0。相反,我们需要直接覆盖crt0入口点。
覆盖入口函数
为了告诉Rust编译器我们不想使用普通的入口链接函数,我们添加了#![no_main]
属性。
#![no_std]
#![no_main]
use core::panic::PanicInfo;
/// This function is called on panic.
#[panic_handler]
fn panic(_info: &PanicInfo) -> ! {
loop {}
}
您可能会注意到我们删除了主要功能。 原因是如果没有调用它的底层运行时,main
就没有意义。 我们现在用我们自己的_start
函数覆盖操作系统入口函数:
#[no_mangle]
pub extern "C" fn _start() -> ! {
loop {}
}
通过使用#[no_mangle]
属性,我们禁用名称修改以确保Rust编译器确实输出名为_start
的函数。如果没有该属性,编译器将生成一些神秘的_ZN3blog_os4_start7hb173fedf945531caE
符号,以便为每个函数提供唯一的名称。该属性是必需的,因为我们需要在下一步中告知链接器的入口点函数的名称。
我们还必须将函数标记为extern"C"
,告诉编译器它应该使用此函数的C调用约定(而不是未指定的Rust调用约定)。命名函数_start
的原因是这是大多数系统的默认入口点名称。
符号!
返回类型意味着函数发散,即不允许返回。这是必需的,因为任何函数都不会调用入口点,而是由操作系统或引导加载程序直接调用。因此,入口点不应该返回,而应该是调用操作系统的退出系统调用。在我们的例子中,关闭机器可能是一个合理的操作,因为如果一个独立的二进制返回,没有什么可做的。现在,我们通过无休止地循环来满足要求。
当我们现在cargo build
,我们得到一个丑陋的链接器错误。
链接错误
链接器是一个将生成的代码组合成可执行文件的程序。 由于Linux,Windows和macOS系统的可执行文件格式彼此不同,因此每个系统都有自己的链接器,当然可能会引发不同的错误。 错误的根本原因是相同的:链接器的默认配置假定我们的程序依赖于C运行时。
要解决这些错误,我们需要告诉链接器它不应该包含C运行时。 我们可以通过将一组参数传递给链接器或构建裸机目标来实现。
为裸机编译目标文件
默认情况下,Rust会尝试构建一个能够在当前系统环境中运行的可执行文件。 例如,如果您在x86_64
上使用Windows,Rust会尝试构建使用x86_64指令的.exe Windows可执行文件。 此环境称为“主机”系统。
为了描述不同的环境,Rust使用一个名为target triple
的字符串。 您可以通过运行rustc --version --verbose
来查看主机系统的目标三元组:
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系统。 我们看到主机三元组是x86_64-unknown-linux-gnu,它包括CPU架构(x86_64),供应商(未知),操作系统(linux)和ABI(gnu)。
通过编译我们的主机三元组,Rust编译器和链接器假定有一个底层操作系统,如Linux或Windows,默认情况下使用C运行时,这会导致链接器错误。 因此,为了避免链接器错误,我们可以针对没有底层操作系统的不同环境进行编译。
这种裸机环境的一个例子是thumbv7em-none-eabihf
target triple,它描述了嵌入式ARM系统。 细节并不重要,重要的是目标三元组没有底层操作系统,由目标三元组中的none表示。 为了能够为这个目标进行编译,我们需要在rustup中添加它:
rustup target add thumbv7em-none-eabihf
这将下载系统的标准(和核心)库的副本。 现在我们可以为这个目标构建我们的独立可执行文件:
cargo build --target thumbv7em-none-eabihf
通过传递--target
参数,交叉编译我们的可执行文件用于裸机目标系统。 由于目标系统没有操作系统,链接器不会尝试链接C运行时,并且我们的构建成功而没有任何链接器错误。
这是我们用于构建操作系统内核的方法。 我们将使用描述x86_64裸机环境的自定义目标,而不是thumbv7em-none-eabihf
。 细节将在下一篇文章中解释。
链接参数
除了为裸机系统进行编译之外,还可以通过将一组参数传递给链接器来解决链接器错误。 这不是我们将用于内核的方法,因此本节是可选的,仅提供完整性。 单击下面的“链接器参数”以显示可选内容。
总结
最小的独立Rust二进制文件如下所示:
src/main.rs
:
#![no_std] // don't link the Rust standard library
#![no_main] // disable all Rust-level entry points
use core::panic::PanicInfo;
#[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 {}
}
/// This function is called on panic.
#[panic_handler]
fn panic(_info: &PanicInfo) -> ! {
loop {}
}
Cargo.toml
:
[package]
name = "crate_name"
version = "0.1.0"
authors = ["Author Name <author@example.com>"]
# the profile used for `cargo build`
[profile.dev]
panic = "abort" # disable stack unwinding on panic
# the profile used for `cargo build --release`
[profile.release]
panic = "abort" # disable stack unwinding on panic
要构建这个二进制文件,我们需要根据相应的裸机来编译,例如thumbv7em-none-eabihf
:
cargo build --target thumbv7em-none-eabihf
或者,我们可以通过传递其他链接参数编译成不同的操作系统所所需的可执行文件:
# Linux
cargo rustc -- -C link-arg=-nostartfiles
# Windows
cargo rustc -- -C link-args="/ENTRY:_start /SUBSYSTEM:console"
# macOS
cargo rustc -- -C link-args="-e __start -static -nostartfiles"
请注意,这只是一个独立的Rust二进制文件的最小示例。 这个二进制文件需要各种各样的东西,例如在调用_start函数时初始化堆栈。 因此,对于任何实际使用这种二进制文件,可能需要更多步骤。
下一步
下一篇文章我们将会逐步讨论二进制文件转换为最小操作系统内核所需的步骤。 这包括创建自定义编译目标,将可执行文件与引导加载程序相结合,以及学习如何在屏幕上打印内容。
支持我
如果您喜欢这篇文章并想支持我,可以通过Donorbox,Patreon或Liberapay联系我。 谢谢!
原文: