一个独立的Rust可执行二进制文件

一个独立的Rust可执行二进制文件

创建我们自己的操作系统内核的第一步是创建一个不链接到Rust标准库的Rust可执行文件。这将会让我们能在没有操作系统支持的情况下在裸机上运行Rust代码。

简介

想要编写一个操作系统内核的话,我们需要让我们的代码不依赖任何操作系统的特性。这意味着我们不能使用线程、文件、堆内存、网络、随机数^1、标准输出或者任何其他要求操作系统抽象或者特定硬件的特性。这合乎情理,因为我们要尝试写我们自己的OS和我们自己的硬件驱动。

这就意味着我们不能使用Rust标准库中的绝大部分, 但是我们还是使用很多Rust的特性。例如,我们可以使用迭代器、闭包、模式匹配、可选值(option)和result、格式化字符串,当然还有对象的所有权系统。这些特性使得以一种非常具有表达力,高层次的方式编写内核而无需担心未定义行为或者内存安全性问题。

为了创建一个Rust的操作系统内核,我们需要创建一个无需操作系统支持即可运行的可执行文件。这样一个可执行文件常被称为“独立的(freestanding)”或者“裸机上的(bare-metal)”可执行文件。

这篇文章将会讲述创建一个独立的Rust可执行二进制文件所需的步骤,以及为什么这些步骤是必须的。如果你仅仅想要一个例子,你可以直接跳到总结部分。

禁用标准库

默认情况下,所有Rust crate都会链接到标准库,而标准库依赖于操作系统提供的特性,如线程、文件或网络。它也依赖于C标准库libc,而libc与OS提供的服务有密切的关系。由于我们的计划是自己写操作系统,我们不能使用任何对OS有所依赖的库。因此我们要使用no_std属性禁止自动包含标准库。

我们从创建一个新的cargo应用程序项目开始。最简单的方式是使用下面的命令:

1
> cargo new blog_os --bin

我将这个项目命名为blog_os,当然你们也可以选择你们自己的名字。—bin标志表示我们要创建一个可执行二进制文件(而非一个库)。当我们运行这个命令的时候,cargo会为我们创建下面的目录结构:

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

Cargo.html包含crate的配置,例如crate的名字,作者,semantic版本号,以及依赖。src/main.rs文件包含我们的crate的根模块和我们的main函数。你可以使用cargo build命令来编译你的crate,然后就能在target/debug子文件夹下发现可以编译得到的可以执行的blog_os 二进制文件。

no_std属性

现在我们的crate默认会链接到标准库。我们来试着通过添加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宏是标准库的一部分,而我们现在已经不再包含标准库了。因此我们也不再能print东西了。这合乎情理,因为pringln是向标准输出写东西,而标准输出则是一个操作系统提供的特殊文件描述符。

所以让我们移除输出语句,再试着编译一次空的主函数:

1
2
3
4
5
// main.rs

#![no_std]

fn main() {}
1
2
3
> cargo build
error: language item required, but not found: `panic_impl`
error: language item required, but not found: `eh_personality`

现在编译器在抱怨缺少了一些language itemlanguage item是编译器会在特定情况下会调用的特殊的可插拔(pluggable)函数,例如当程序panic的时候。一般情况下,这些language item是由标准库提供的,但我们禁用了它。

提供我们自己的 language item 实现是有可能的,但这是没有办法的办法。因为 language item 是高度不稳定的实现细节,甚至不会有针对它们的类型检查(也就是说编译器甚至不检查它们接受了适当的参数类型)。

幸运的是,有一些更稳定的方法来修复这些与 language item 有关的错误。

禁用堆栈解退

eh_personality language item 是用来实现堆栈解退的。默认情况下,Rust使用堆栈解退机制来在panic时运行栈上所有还在生存期内的变量的析构函数。这确保了所有用到的内存都被释放,并且允许父线程捕获到这个panic,并继续运行。然而堆栈解退是一个复杂的过程,而且要求一些系统相关的库(例如Linux上的libunwind或Windows上的结构化异常处理),所以我们不会在我们的操作系统中使用它。

还有一些情况下堆栈解退也不是合适的处理方法,所以Rust提供了一个替代的的“abort on panic”选项。这会禁用堆栈解退符号信息的生成,因此会可观地减小二进制文件的大小。我们有多种方法可以禁用堆栈解退。最简单的方法是将下面这几行加到我们的Cargo.toml

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

[profile.release]
panic = "abort"

这将会将dev构建模式( cargo build)和release构建模式下( cargo build --release)的panic处理策略均设置为about。这样eh_personality language item应该就不再需要了。

Panic实现

panic_impl language item 定义了编译器在panic出现时会调用的函数。我们可以使用[panic_handler] 属性来的定义一个panic函数。

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

use core::panic::PanicInfo;

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

PanicInfo参数包含了panic发生的文件和行以及可选的panic信息。这个函数应该永远不会返回,所以我们让它返回一个“never type”!,把它标记为一个发散函数。我们现在不能在这个函数中多写些什么了,所以我们仅仅只是让它在里面无限循环。

现在我们修好了这两个 language item 错误。然而,如果我们现在试着编译,编译器要求要有另外一个 language item :

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

start属性

你可能曾经认为main函数是我们运行一个程序时调用的第一个函数。然而,大部分语言独有一个运行时系统,负责垃圾回收(例如Java)或者软件实现的线程(例如Go中的gorotine)。这个运行时需要在main调用前被调用,因为它需要初始化它自己。

在一个链接到了标准库的Rust二进制文件中,运行过程开始于一个被称为crt0 (“C runtime zero”)的C运行时库,它会为C程序设置环境。这些设置包含了创建一个栈和将参数放在合适的寄存器里。然后这个C运行时会调用Rust运行时的入口,这个入口就是被start属性所标记的。Rust的运行时非常小,它只负责一些小事,比如设置栈溢出的保护或者在panic时要打印的回朔信息。这个运行时最终会调用main函数。

我们的独立可执行文件不能使用Rust运行时和crt0,所以我们需要定义我们自己的入口。实现start language item 并没有用,因为它还是会需要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没有意义。作为替代,我们现在会覆写操作系统的入口。

入口的调用规范取决于你的操作系统。我建议你阅读Linux部分即使你用的是另一个操作系统,因为我们在我们的内核中将会使用这种规范。

Linux

在Linux上,默认的入口被称为_start。连接器仅仅只是找到一个有这个名字的函数,然后将其设置为可执行文件的入口。所以,要覆写入口,我们要定义我们自己的_start函数:

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

我们使用了no_mangle属性禁用了名字修饰,这很重要,否则编译器会生成一些加密过的像_ZN3blog_os4_start7hb173fedf945531caE的符号,这会使得链接器认不出来。我们也将这个函数标记为 extern "C" 来告诉编译器使用C调用规范(而非不确定的Rust调用规范)。

!返回类型表示这个函数是一个“发散函数”,即永远不允许返回的函数。这是必要的,因为我们的入口并非是有其他函数调用,而是直接被操作系统或是bootloader调用。所以我们应该使用诸如进行exit系统调用的方式而非返回。在我们这个情况下,关机可能是一个合理的行为,因为我们的独立二进制文件执行完之后就没有什么能做的事了。我们现在暂时先放一个无穷循环来满足需要。

如果我们现在试图构建,会发生一个丑陋的链接期错误:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
error: linking with `cc` failed: exit code: 1
|
= note: "cc" "-Wl,--as-needed" "-Wl,-z,noexecstack" "-m64" "-L"
"/…/.rustup/toolchains/nightly-x86_64-unknown-linux-gnu/lib/rustlib/x86_64-unknown-linux-gnu/lib"
"/…/blog_os/target/debug/deps/blog_os-f7d4ca7f1e3c3a09.0.o" […]
"-o" "/…/blog_os/target/debug/deps/blog_os-f7d4ca7f1e3c3a09"
"-Wl,--gc-sections" "-pie" "-Wl,-z,relro,-z,now" "-nodefaultlibs"
"-L" "/…/blog_os/target/debug/deps"
"-L" "/…/.rustup/toolchains/nightly-x86_64-unknown-linux-gnu/lib/rustlib/x86_64-unknown-linux-gnu/lib"
"-Wl,-Bstatic"
"/…/.rustup/toolchains/nightly-x86_64-unknown-linux-gnu/lib/rustlib/x86_64-unknown-linux-gnu/lib/libcore-dd5bba80e2402629.rlib"
"-Wl,-Bdynamic"
= note: /usr/lib/gcc/x86_64-linux-gnu/5/../../../x86_64-linux-gnu/Scrt1.o: In function `_start':
(.text+0x12): undefined reference to `__libc_csu_fini'
/usr/lib/gcc/x86_64-linux-gnu/5/../../../x86_64-linux-gnu/Scrt1.o: In function `_start':
(.text+0x19): undefined reference to `__libc_csu_init'
/usr/lib/gcc/x86_64-linux-gnu/5/../../../x86_64-linux-gnu/Scrt1.o: In function `_start':
(.text+0x25): undefined reference to `__libc_start_main'
collect2: error: ld returned 1 exit status

问题在于我们的链接器仍然试图链接我们的程序到C运行时的启动流程,它需要C标准库libc中的一些符号,但由于我们使用了no_std属性,我们不会链接到libc。我们需要摆脱C的启动流程。我们可以通过给链接器传入 -nostartfiles 标志来这么做。

在使用cargo时传入链接器属性的一个方法是使用 cargo rustc 命令。这个命令和 cargo build做的事情是类似的,但它允许给rustc,cargo后台的Rust编译器,传入选项。 rustc 有(不稳定的) -Z pre-link-arg 标志,这将会给连接器传参。都结合起来的话,我们新的构建命令看起来像这样:

1
> cargo rustc -- -Z pre-link-arg=-nostartfiles

使用这个命令,我们的crate最终被构建成了一个独立的可执行文件!

Windows

在Windows上,链接器要求两个取决于使用的子系统的入口。对于 CONSOLE 子系统,我们需要一个称为mainCRTStartup的函数,它会调用一个称为main的函数。就像在 Linux 上我们所做的那样,我们用自定义的 no_mangle 函数覆写入口点。

1
2
3
4
5
6
7
8
9
#[no_mangle]
pub extern "C" fn mainCRTStartup() -> ! {
main();
}

#[no_mangle]
pub extern "C" fn main() -> ! {
loop {}
}

macOS

macOS不支持静态链接的二进制文件,所以我们需要链接libSystem库。入口点叫main

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

要构建并链接 libSystem,我们要运行下面的命令:

1
> cargo rustc -- -Z pre-link-arg=-lSystem

总结

一个最小化的独立Rust二进制文件看起来像这样:

src/main.rs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
#![no_std] // 不要链接Rust标准库
#![no_main] // 禁止所有Rust-level的入口

use core::panic::PanicInfo;

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

// 在Linux上:
#[no_mangle] // 不要修饰这个函数名
pub extern "C" fn _start() -> ! {
// 这个函数是入口点,因为链接器默认会寻找一个
// 函数名是 `_start`的函数
loop {}
}

// 在Windows上:
#[no_mangle]
pub extern "C" fn mainCRTStartup() -> ! {
main();
}

#[no_mangle]
pub extern "C" fn main() -> ! {
loop {}
}

// 在macOS上:

#[no_mangle]
pub extern "C" fn main() -> ! {
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时的堆栈解退

可以使用下面的命令编译:

1
2
3
4
5
6
# Linux
> cargo rustc -- -Z pre-link-arg=-nostartfiles
# Windows
> cargo build
# macOS
> cargo rustc -- -Z pre-link-arg=-lSystem

注意这只是一个独立Rust二进制程序的最小化例子。这个二进制文件还需要很多东西,例如_start被调用时需要有一个已经被初始化好的栈。所以要是想真的使用这样一个二进制文件,还需要更多的步骤。

接下来?

下一篇文件通过介绍构建一个最小化操作系统内核需要的步骤扩展了我们的最小化独立二进制文件。他解释了如何为目标系统配置内核,如何使用一个bootloader启动它,和如何在屏幕上显示一些什么。