应用程序执行环境与平台支持

用 Cargo 工具创建 Rust 项目。

1
2
3
4
5
6
7
8
9
10
11
$ cargo new os

此时,项目的文件结构如下:

$ tree os
os
├── Cargo.toml
└── src
└── main.rs

1 directory, 2 files

其中 Cargo.toml 中保存了项目的库依赖、作者信息等。

编译器在编译、链接得到可执行文件时需要知道,程序要在哪个 平台 (Platform) 上运行
目标三元组 (Target Triplet) 描述了目标平台的 CPU 指令集、操作系统类型、标准运行时库

我们希望把程序移植到 RISC-V 目标平台 riscv64gc-unknown-none-elf 上运行


1
2
3
4
5
6
7
{
$ cargo run --target riscv64gc-unknown-none-elf
Compiling os v0.1.0 (/home/shinbokuow/workspace/v3/rCore-Tutorial-v3/os)
error[E0463]: can't find crate for `std`
|
= note: the `riscv64gc-unknown-none-elf` target may not be installed
}

报错的原因是目标平台上确实没有 Rust 标准库 std,也不存在任何受 OS 支持的系统调用。 这样的平台被我们称为 裸机平台 (bare-metal)。

除了 std 之外,Rust 还有一个不需要任何操作系统支持的核心库 core, 它包含了 Rust 语言相当一部分核心机制,为了以裸机平台为目标编译程序,我们要将对标准库 std 的引用换成核心库 core

移除标准库依赖


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

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

这将使 cargo 工具在 os 目录下默认会使用 riscv64gc-unknown-none-elf 作为目标平台。 这种编译器运行的平台(x86_64)与可执行文件运行的目标平台不同的情况,称为 交叉编译 (Cross Compile)。


移除 println! 宏

main.rs 开头加入 #![no_std] ,告诉 Rust 编译器不使用 Rust 标准库 std 转而使用核心库 core。
println! 宏是由标准库 std 提供的,且会使用到一个名为 write 的系统调用,我们先将这行代码注释掉。


提供语义项 panic_handler

标准库 std 提供了 Rust 错误处理函数 #[panic_handler],其大致功能是打印出错位置和原因并杀死当前应用。 但核心库 core 并没有提供这项功能,得靠我们自己实现。
新建一个子模块 lang_items.rs,在里面编写 panic 处理函数,通过标记 #[panic_handler] 告知编译器采用我们的实现:


#[panic_handler] 是一个 Rust 属性(attribute),用于指定一个函数作为 panic 发生时的处理函数。当程序发生 panic(即遇到了无法恢复的错误)时,Rust 运行时会调用这个指定的函数来处理 panic。
PanicInfo 结构体位于 core::panic 模块中,它包含了一些有用的方法,可以用于获取有关 panic 的信息。一些常用的方法包括:

  • payload(): 返回一个引用,指向 panic 的 payload 数据,即 panic 的具体信息。
  • message(): 返回一个 Option<&Any>,其中包含了对 panic 的描述信息,如果有的话。
  • location():返回一个 Option<&Location>,其中包含了 panic 发生的源代码位置信息,包括文件名、行号和列号。

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

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

目前我们遇到错误什么都不做,只在原地 loop


移除 main 函数

我们缺少一个名为 start 的语义项。 start 语义项代表了标准库 std 在执行应用程序之前需要进行的一些初始化工作。由于我们禁用了标准库,编译器也就找不到这项功能的实现了
在 main.rs 的开头加入设置 #![no_main] 告诉编译器我们没有一般意义上的 main 函数, 并将原来的 main 函数删除。这样编译器也就不需要考虑初始化工作了。


分析被移除标准库的程序

通过 file 工具对二进制程序 os 的分析,它好像是一个合法的 RISC-V64 执行程序, 但 rust-readobj 工具告诉我们它的入口地址 Entry 是 0。 再通过 rust-objdump 工具把它反汇编,没有生成任何汇编代码。 可见,这个二进制程序虽然合法,但它是一个空程序,原因是缺少了编译器规定的入口函数 _start

构建用户态执行环境


执行环境初始化

给 Rust 编译器编译器提供入口函数 _start() , 在 main.rs 中添加如下内容:


#[no_mangle] 是 Rust 语言中的一个属性(attribute),用于告诉编译器禁止修改函数名或符号名的规则。当使用 #[no_mangle] 属性修饰一个函数时,编译器将保持函数名或符号名不变,不会将其进行重命名或修饰。
在 Rust 中,默认情况下,编译器会对函数名进行修饰,以确保函数名在二进制中的唯一性。这是为了支持 Rust 的模块系统和名称空间隔离。修饰的方式可能包括添加前缀、后缀、哈希值等,具体取决于编译器和平台。
有些情况下,我们可能需要在 Rust 代码中使用具有特定名称的函数,例如与其他语言进行交互或链接到外部库。这时,我们可以使用 #[no_mangle] 属性来告诉编译器不要修改函数名。
在代码示例中,extern "C" fn _start() 函数前面的 #[no_mangle] 属性确保编译器不会对 _start 函数进行任何修饰或重命名。这是因为在一些操作系统或嵌入式系统中,使用 _start 函数作为程序的入口点是一种约定。


1
2
3
4
5
// os/src/main.rs
#[no_mangle]
extern "C" fn _start() {
loop{};
}

程序正常退出

把 _start() 函数中的循环语句注释掉,重新编译并分析,看起来是合法的执行程序。但如果我们执行它,却会引发 segment fault
这个简单的程序导致 qemu-riscv64 崩溃了!为什么会这样?


QEMU有两种运行模式:

User mode 模式,即用户态模拟,如 qemu-riscv64 程序, 能够模拟不同处理器的用户态指令的执行,并可以直接解析ELF可执行文件, 加载运行那些为不同处理器编译的用户级Linux应用程序。
System mode 模式,即系统态模式,如 qemu-system-riscv64 程序, 能够模拟一个完整的基于不同CPU的硬件系统,包括处理器、内存及其他外部设备,支持运行完整的操作系统。


目前的执行环境还缺了一个退出机制,我们需要操作系统提供的 exit 系统调用来退出程序

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
// os/src/main.rs

const SYSCALL_EXIT: usize = 93;

fn syscall(id: usize, args: [usize; 3]) -> isize {
let mut ret;
unsafe {
core::arch::asm!(
"ecall",
inlateout("x10") args[0] => ret,
in("x11") args[1],
in("x12") args[2],
in("x17") id,
);
}
ret
}

pub fn sys_exit(xstate: i32) -> isize {
syscall(SYSCALL_EXIT, [xstate as usize, 0, 0])
}

#[no_mangle]
extern "C" fn _start() {
sys_exit(9);
}

有显示支持的用户态执行环境

Rust 的 core 库内建了以一系列帮助实现显示字符的基本 Trait 和数据结构,函数等,我们可以对其中的关键部分进行扩展,就可以实现定制的 println! 功能


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
use core::fmt::Write
use core::fmt

//封装一下对 `SYSCALL_WRITE` 系统调用
const SYSCALL_WRITE: usize = 64;

pub fn sys_write(fd: usize, buffer: &[u8]) -> isize {
syscall(SYSCALL_WRITE, [fd, buffer.as_ptr() as usize, buffer.len()])
}

//实现基于 `Write` Trait 的数据结构,并完成 `Write` Trait 所需要的 `write_str` 函数,并用 `print` 函数进行包装
struct Stdout;

impl Write for Stdout {
fn write_str(&mut self, s: &str) -> fmt::Result {
sys_write(1, s.as_bytes());
Ok(())
}
}

pub fn print(args: fmt::Arguments) {
Stdout.write_fmt(args).unwrap();
}

//实现基于 `print` 函数,实现Rust语言 格式化宏(formatting macros)
#[macro_export]
macro_rules! print {
($fmt: literal $(, $($arg: tt)+)?) => {
$crate::console::print(format_args!($fmt $(, $($arg)+)?));
}
}

#[macro_export]
macro_rules! println {
($fmt: literal $(, $($arg: tt)+)?) => {
print(format_args!(concat!($fmt, "\n") $(, $($arg)+)?));
}
}

调整一下应用程序,让它发出显示字符串和退出的请求


1
2
3
4
5
#[no_mangle]
extern "C" fn _start() {
println!("Hello, world!");
sys_exit(9);
}

构建裸机执行环境


裸机启动过程

用 QEMU 软件 qemu-system-riscv64 来模拟 RISC-V 64 计算机:


1
$ qemu-system-riscv64 -machine virt -nographic -bios $(BOOTLOADER) -device loader,file=$(KERNEL_BIN),addr=$(KERNEL_ENTRY_PA)
  • -bios $(BOOTLOADER) 意味着硬件加载了一个 BootLoader 程序,即 RustSBI
  • -device loader,file=$(KERNEL_BIN),addr=$(KERNEL_ENTRY_PA) 表示硬件内存中的特定位置 $(KERNEL_ENTRY_PA) 放置了操作系统的二进制代码 $(KERNEL_BIN) $(KERNEL_ENTRY_PA) 的值是 0x80200000

执行包含上述启动参数的 qemu-system-riscv64 软件,就意味给这台虚拟的 RISC-V64 计算机加电了。 此时,CPU 的其它通用寄存器清零,而 PC 会指向 0x1000 的位置,这里有固化在硬件中的一小段引导代码, 它会很快跳转到 0x80000000 的 RustSBI 处。 RustSBI完成硬件初始化后,会跳转到 $(KERNEL_BIN) 所在内存位置 0x80200000 处, 执行操作系统的第一条指令
![[Pasted image 20240428224902.png]]


SBI(Rust Supervisor Binary Interface)是 RISC-V 的一种底层规范,RustSBI 是它的一种实现。 操作系统内核与 RustSBI 的关系有点像应用与操作系统内核的关系,后者向前者提供一定的服务。只是SBI提供的服务很少, 比如关机,显示字符串等。


实现关机功能

通过 ecall 调用 RustSBI 实现关机功能


1
// bootloader/rustsbi-qemu.bin 直接添加的SBI规范实现的二进制代码,给操作系统提供基本支持服务

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
// os/src/sbi.rs
fn sbi_call(which: usize, arg0: usize, arg1: usize, arg2: usize) -> usize {
    let mut ret;
    unsafe {
        asm!(
            "li x16, 0",
            "ecall",
            inlateout("x10") arg0 => ret,
            in("x11") arg1,
            in("x12") arg2,
            in("x17") which,
        );
    }
    ret
}

const SBI_SHUTDOWN: usize = 8;

pub fn shutdown() -> ! {
sbi_call(SBI_SHUTDOWN, 0, 0, 0);
panic!("It should shutdown!");
}

// os/src/main.rs
#[no_mangle]
extern "C" fn _start() {
shutdown();
}

应用程序访问操作系统提供的系统调用的指令是 ecall ,操作系统访问 RustSBI提供的SBI调用的指令也是 ecall , 虽然指令一样,但它们所在的特权级是不一样的。 简单地说,应用程序位于最弱的用户特权级(User Mode), 操作系统位于内核特权级(Supervisor Mode), RustSBI位于机器特权级(Machine Mode)
编译执行:


1
2
3
4
5
6
7
8
9
10
# 编译生成ELF格式的执行文件
$ cargo build --release
Compiling os v0.1.0 (/media/chyyuu/ca8c7ba6-51b7-41fc-8430-e29e31e5328f/thecode/rust/os_kernel_lab/os)
Finished release [optimized] target(s) in 0.15s
# 把ELF执行文件转成bianary文件
$ rust-objcopy --binary-architecture=riscv64 target/riscv64gc-unknown-none-elf/release/os --strip-all -O binary target/riscv64gc-unknown-none-elf/release/os.bin

# 加载运行
$ qemu-system-riscv64 -machine virt -nographic -bios ../bootloader/rustsbi-qemu.bin -device loader,file=target/riscv64gc-unknown-none-elf/release/os.bin,addr=0x80200000
# 无法退出,风扇狂转,感觉碰到死循环

通过 rust-readobj 分析 os 可执行程序,发现其入口地址不是 RustSBI 约定的 0x80200000 。我们需要修改程序的内存布局并设置好栈空间


设置正确的程序内存布局

可以通过 链接脚本 (Linker Script) 调整链接器的行为,使得最终生成的可执行文件的内存布局符合我们的预期。

修改 Cargo 的配置文件来使用我们自己的链接脚本 os/src/linker.ld

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"
]

[target.riscv64gc-unknown-none-elf] 部分为特定目标平台的配置提供了 Rust 标志(rustflags)。在这个示例中,使用了两个 Rust 标志:

  1. -Clink-arg=-Tsrc/linker.ld:这个标志通过 -Clink-arg 将 -Tsrc/linker.ld 传递给链接器。它告诉链接器使用位于 src/linker.ld 路径下的链接器脚本文件。
  2. -Cforce-frame-pointers=yes:这个标志通过 -Cforce-frame-pointers 启用了强制使用帧指针(frame pointer)。帧指针是一种用于调试和异常处理的技术,它在函数调用时保存了栈帧的基址,有助于调试器在堆栈中定位函数调用的位置。

具体的链接脚本 os/src/linker.ld 如下:

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
1 OUTPUT_ARCH(riscv)               //设置了目标平台为 riscv
2 ENTRY(_start) //设置了整个程序的入口点为之前定义的全局符号 `_start`
3 BASE_ADDRESS = 0x80200000; //定义了一个常量 `BASE_ADDRESS` 为 `0x80200000` ,RustSBI 期望的 OS 起始地址
4
5 SECTIONS //开始定义节(section)和段(segment)的布局和属性
6 {
7 . = BASE_ADDRESS; //将链接器的当前位置设置为 `BASE_ADDRESS`,即起始地址
8 skernel = .; //定义一个全局变量 `skernel`,表示内核段的开始地址,它被赋值为当前位置(起始地址)
9
10 stext = .; //定义一个全局变量 `stext`,表示代码段(`.text`)的开始地址,它被赋值为当前位置
11 .text : { //代码段 `.text`
12 *(.text.entry) //匹配所有 `.text.entry` 的内容
13 *(.text .text.*) //匹配所有以 `.text` 或 `.text.` 开头的内容
14 }
15
16 . = ALIGN(4K); //将链接器的当前位置对齐到 4KB 边界
17 etext = .; //定义一个全局变量 `etext`,表示代码段的结束地址,它被赋值为当前位置
18 srodata = .; //定义一个全局变量 `srodata`,只读数据段(.rodata)的开始地址,它被赋值为当前位置
19 .rodata : { //只读数据段 `.rodata`
20 *(.rodata .rodata.*) //匹配所有 `.rodata` 或 `.rodata.` 开头的内容
21 }
22
23 . = ALIGN(4K); //将链接器的当前位置对齐到 4KB 边界
24 erodata = .; //定义一个全局变量 `erodata`,表示只读数据段的结束地址,它被赋值为当前位置
25 sdata = .; //定义一个全局变量 `sdata`,表示数据段(`.data`)的开始地址,它被赋值为当前位置
26 .data : { //数据段 `.data`
27 *(.data .data.*) //匹配所有 `.data` 或 `.data.` 开头的内容
28 }
29
30 . = ALIGN(4K); //将链接器的当前位置对齐到 4KB 边界
31 edata = .; //定义一个全局变量 `edata`,表示数据段的结束地址,它被赋值为当前位置
32 .bss : { //bss 段 `.bss`
33 *(.bss.stack) //匹配所有 `.bss.stack` 的内容
34 sbss = .; //定义一个全局变量 `sbss`,表示 bss 段的开始地址,它被赋值为当前位置
35 *(.bss .bss.*) //匹配所有以 `.bss` 或 `.bss.` 开头的内容
36 }
37
38 . = ALIGN(4K); //将链接器的当前位置对齐到 4KB 边界
39 ebss = .; //定义一个全局变量 `ebss`,表示 bss 段的结束地址,它被赋值为当前位置
40 ekernel = .; //定义一个全局变量 `ekernel`,表示内核段的结束地址,它被赋值为当前位置
41
42 /DISCARD/ : { //丢弃所有 `.eh_frame` 段的内容
43 *(.eh_frame)
44 }
45}
46 /*每个段都有两个全局变量给出其起始和结束地址(比如 `.text` 段的开始和结束地址分别是 47 `stext` 和 `etext` )*/

.bss(Block Started by Symbol)是一种在程序中用于存储未初始化的全局变量和静态变量的段。它通常用于存储程序中未初始化的全局变量和静态变量,这些变量在程序启动时会被自动初始化为零或空值


正确配置栈空间布局

初始化栈空间


1
2
3
4
5
6
7
8
9
10
11
12
13
# os/src/entry.asm
.section .text.entry
.globl _start
_start:
la sp, boot_stack_top
call rust_main

.section .bss.stack
.globl boot_stack
boot_stack:
.space 4096 * 16
.globl boot_stack_top
boot_stack_top:

预留了一块大小为 4096 * 16 字节,也就是 64KiB 的空间, 用作操作系统的栈空间。 栈顶地址被全局符号 boot_stack_top 标识,栈底则被全局符号 boot_stack 标识。 同时,这块栈空间被命名为 .bss.stack ,链接脚本里有它的位置


接着,我们在 main.rs 中嵌入这些汇编代码并声明应用入口 rust_main :

1
2
3
4
5
6
7
8
9
10
11
12
13
// os/src/main.rs
#![no_std]
#![no_main]

mod lang_items;

core::arch::global_asm!(include_str!("entry.asm"));
//使用 `global_asm` 宏,将同目录下的汇编文件 `entry.asm` 嵌入到代码中

#[no_mangle]
pub fn rust_main() -> ! {
shutdown();
}

清空.bss段

内存相关的部分太容易出错了, 清零 .bss 段 的工作还没有完成

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// os/src/main.rs
fn clear_bss() {
extern "C" {
fn sbss();
fn ebss();
}
(sbss as usize..ebss as usize).for_each(|a| {
unsafe { (a as *mut u8).write_volatile(0) }
});
}

pub fn rust_main() -> ! {
clear_bss();
shutdown();

添加裸机打印相关函数

在上文中我们为用户态程序实现的 println 宏,略作修改即可用于本节的内核态操作系统。 详见 os/src/console.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
//! SBI console driver, for text output
use crate::sbi::console_putchar;
use core::fmt::{self, Write};

struct Stdout;

impl Write for Stdout {
    fn write_str(&mut self, s: &str) -> fmt::Result {
        for c in s.chars() {
            console_putchar(c as usize);
        }
        Ok(())
    }
}

pub fn print(args: fmt::Arguments) {
    Stdout.write_fmt(args).unwrap();
}

/// Print! to the host console using the format string and arguments.
#[macro_export]
macro_rules! print {
    ($fmt: literal $(, $($arg: tt)+)?) => {
        $crate::console::print(format_args!($fmt $(, $($arg)+)?))
    }
}

/// Println! to the host console using the format string and arguments.
#[macro_export]
macro_rules! println {
    ($fmt: literal $(, $($arg: tt)+)?) => {
        $crate::console::print(format_args!(concat!($fmt, "\n") $(, $($arg)+)?))
    }
}