在裸机上运行rust程序

Uncategorized
4.9k words

曾经提到过,编程语言的标准库或三方库的某些功能会直接或间接的用到操作系统提供的系统调用。但目前我们所选的目标平台不存在任何操作系统支持,于是 Rust 并没有为这个目标平台支持完整的标准库 std。类似这样的平台通常被我们称为 裸机平台 (bare-metal)。这意味着在裸机平台上的软件没有传统操作系统支持。

Rust语言标准库std和核心库core

Std库

Rust 语言标准库–std 是让 Rust 语言开发的软件具备可移植性的基础,类似于 C 语言的 LibC 标准库。它是一组小巧的、经过实践检验的共享抽象,适用于更广泛的 Rust 生态系统开发。它提供了核心类型,如 Vec 和 Option、类库定义的语言原语操作、标准宏、I/O 和多线程等。默认情况下,我们可以使用 Rust 语言标准库来支持 Rust 应用程序的开发。但 Rust 语言标准库的一个限制是,它需要有操作系统的支持。所以,如果你要实现的软件是运行在裸机上的操作系统,就不能直接用 Rust 语言标准库了。

Core库

Rust 有一个对 Rust 语言标准库–std 裁剪过后的 Rust 语言核心库 core。core库是不需要任何操作系统支持的,它的功能也比较受限,但是也包含了 Rust 语言相当一部分的核心机制,可以满足我们的大部分功能需求。Rust 语言是一种面向系统(包括操作系统)开发的语言,所以在 Rust 语言生态中,有很多三方库也不依赖标准库 std 而仅仅依赖核心库 core。对它们的使用可以很大程度上减轻我们的编程负担。它们是我们能够在裸机平台挣扎求生的最主要倚仗,也是大部分运行在没有操作系统支持的 Rust 嵌入式软件的必备。

于是,我们知道在裸机平台上我们要将对于标准库 std 的引用换成核心库 core。

代码实现

运用标准依赖库

首先我们先运用cargo new os生成一个rust的项目文件,在os/src/main.rs有rust格式的源代码生成,如下所示

1
2
3
4
//  os/src/main.rs
fn main() {
println!("Hello, world!");
}

在os目录下输入cargo run运行,得到了Hello,world!的结果,但是这是运用了std标准库才得到的输出结果,println! 宏所在的 Rust 标准库 std 需要通过系统调用获得操作系统的服务,而如果要构建运行在裸机上的操作系统,就不能再依赖标准库。

在裸机上通过编译

os 目录下新建 .cargo 目录,并在这个目录下创建 config 文件,并在里面输入如下内容:

1
2
3
# os/.cargo/config
[build]
target = "riscv64gc-unknown-none-elf"

这会对于 Cargo 工具在 os 目录下的行为进行调整:现在默认会使用 riscv64gc 作为目标平台而不是原先的默认 x86_64-unknown-linux-gnu

现在我们尝试将std标准库删除后尝试重新build编译,运行发现没有办法使用std标准依赖库

1
2
//   os
cargo build

1.出现报错:缺少std标准库

解决方式:在main.rs添加#![no_std],移除std库

1
2
3
4
5
//  os/src/main.rs
#![no_std]
fn main() {
println!("Hello, world!");
}

出现报错:无println!20

解决方式:简单粗暴地直接注释掉println!宏命令,解决报错

2.出现报错:panic_handler没有被找到,

在标准库 std 中提供了关于 panic! 宏的具体实现,其大致功能是打印出错位置和原因并杀死当前应用。但本章要实现的操作系统不能使用还需依赖操作系统的标准库std,而更底层的核心库 core 中只有一个 panic! 宏的空壳,并没有提供 panic! 宏的精简实现。因此我们需要自己先实现一个简陋的 panic 处理函数,这样才能编译通过。

1
2
3
4
5
6
7
// os/src/lang_items.rs
use core::panic::PanicInfo;

#[panic_handler]
fn panic(_info: &PanicInfo) -> ! {
loop {}
}

panic 处理函数的函数签名需要一个 PanicInfo 的不可变借用作为输入参数,它在核心库中得以保留。

1
2
3
4
5
6
//  os/src/main.rs
#![no_std]
mod lang_items;
fn main() {
println!("Hello, world!");
}

3.出现main函数报错:

1
2
3
4
5
6
7
//  os/src/main.rs
#![no_std]
#![no_main]//移除main得以通过编译
mod lang_items;
fn main() {
println!("Hello, world!");
}

脱离了标准库,通过了编译器的检验,但是伤筋动骨,将原有的很多功能弱化甚至直接删除,接下来会以自己的方式来重塑这些基本功能,并最终完成我们的目标

得到在 Qemu 上成功运行的内核镜像

首先我们需要通过链接脚本调整内核可执行文件的内存布局,使得内核被执行的第一条指令位于地址 0x80200000 处,同时代码段所在的地址应低于其他段。这是因为 Qemu 物理内存中低于 0x80200000 的区域并未分配给内核,而是主要由 RustSBI 使用。

其次,我们需要将内核可执行文件中的元数据丢掉得到内核镜像,此内核镜像仅包含实际会用到的代码和数据。这则是因为 Qemu 的加载功能过于简单直接,它直接将输入的文件逐字节拷贝到物理内存中,因此也可以说这一步是我们在帮助 Qemu 手动将可执行文件加载到物理内存中。

编写内核的第一条指令(用于验证内核镜像是否对接到了Qemu中)

1
2
3
4
5
# os/src/entry.asm
.section .text.entry //.text.entry 的段
.globl _start //全局符号
_start:
li x1, 100

第 3 行我们告知编译器 _start 是一个全局符号,因此可以被其他目标文件使用。

第 2 行表明我们希望将第 2 行后面的内容全部放到一个名为 .text.entry 的段中。一般情况下,所有的代码都被放到一个名为 .text 的代码段中

改写main.rs 添加

1
2
use core::arch::global_asm;
global_asm!(include_str!("entry.asm"));

main.rs 中嵌入这段汇编代码

调整内核布局

1
2
3
4
5
6
7
8
// os/.cargo/config
[build]
target = "riscv64gc-unknown-none-elf"

[target.riscv64gc-unknown-none-elf]
rustflags = [
"-Clink-arg=-Tsrc/linker.ld", "-Cforce-frame-pointers=yes"
]

由于链接器默认的内存布局并不能符合我们的要求,为了实现与 Qemu 正确对接,我们可以通过 链接脚本 (Linker Script) 调整链接器的行为,使得最终生成的可执行文件的内存布局符合Qemu的预期,即内核第一条指令的地址应该位于 0x80200000 。

连接脚本

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
37
38
39
40
41
42
43
44
45
46
47
48
49
50
#os/src/linker.ld

OUTPUT_ARCH(riscv)
ENTRY(_start)
BASE_ADDRESS = 0x80200000;

SECTIONS
{
. = BASE_ADDRESS;
skernel = .;

stext = .;
.text : {
*(.text.entry)
*(.text .text.*)
}

. = ALIGN(4K);
etext = .;
srodata = .;
.rodata : {
*(.rodata .rodata.*)
*(.srodata .srodata.*)
}

. = ALIGN(4K);
erodata = .;
sdata = .;
.data : {
*(.data .data.*)
*(.sdata .sdata.*)
}

. = ALIGN(4K);
edata = .;
.bss : {
*(.bss.stack)
sbss = .;
*(.bss .bss.*)
*(.sbss .sbss.*)
}

. = ALIGN(4K);
ebss = .;
ekernel = .;

/DISCARD/ : {
*(.eh_frame)
}
}

在os目录下运用cargo build生成内核的可执行文件

1
cargo build --release

将ELF 可执行文件加载到Qemu的内存之中

24

由于生成的可执行文件中存在一些元数据,而这一些的数据没有办法被qemu给正确利用,为了使得section字段起始位于BASE_ADDR:0x80200000上,我们需运用如下的代码去除元数据字段

1
rust-objcopy --strip-all target/riscv64gc-unknown-none-elf/release/os -O binary target/riscv64gc-unknown-none-elf/release/os.bin
GDB验证启动

os 目录下通过以下命令启动 Qemu 并加载 RustSBI 和内核镜像:

1
2
3
4
5
6
qemu-system-riscv64 \
-machine virt \
-nographic \
-bios ../bootloader/rustsbi-qemu.bin \
-device loader,file=target/riscv64gc-unknown-none-elf/release/os.bin,addr=0x80200000 \
-s -S

打开另一个终端,启动一个 GDB 客户端连接到 Qemu :

1
2
3
4
gdb-multiarch \
-ex 'file target/riscv64gc-unknown-none-elf/release/os' \
-ex 'set arch riscv:rv64' \
-ex 'target remote localhost:1234'

在这里我运用的Debug工具是gdb-multiarch而非教程中的riscv64-unknown-elf-gdb

成功启动了程序调试

25

其中初始化的地址是0x00001000,对应这qemu初始化的地址

运用x/10i $pc,从当前 PC 值的位置开始,在内存中反汇编 10 条指令

26

0x101a 处的数据 0x8000 是能够跳转到 0x80000000 进入启动下一阶段的关键

在执行位于 0x1010 的指令之前,寄存器 t0 的值恰好为 0x80000000 ,随后通过 jr t0 便可以跳转到该地址。我们可以通过单步调试来复盘这个过程

27

在1014后执行下一步跳转到了0x80000000 ,这意味着我们即将把控制权转交给 RustSBI

在这里不深入Rustsbi到镜像内核的过程,通过设置断点&&c的方式跳转到0x8020000地址的指令验证

28

回到之前编写的asm汇编代码,其内容实现的是将100的立即数赋值给x1寄存器

1
2
3
4
 .section .text.entry
.globl _start
_start:
li x1, 100

在0x8020000地址反转译查看后续的十条汇编指令,si单步执行

29

查看到了x1的十进制数为100,验证完成

我们可以检查此时栈指针 sp 的值,可以发现它目前是 0,接下来我们将设置好栈空间,使得内核代码可以正常进行函数调用,随后将控制权转交给 Rust 代码。

Comments