应用程序执行环境与平台支持 用 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)+)?)) } }