应用程序执行环境与平台支持 用 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 标志:
-Clink-arg=-Tsrc/linker.ld:这个标志通过 -Clink-arg 将 -Tsrc/linker.ld 传递给链接器。它告诉链接器使用位于 src/linker.ld 路径下的链接器脚本文件。 
-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)+)?))     } }