构建裸机执行环境¶
有了上一节实现的用户态的最小执行环境,稍加改造,就可以完成裸机上的最小执行环境了。
本节中,我们将把 Hello world!
应用程序从用户态搬到内核态。
裸机启动过程¶
用 QEMU 软件 qemu-system-riscv64
来模拟 RISC-V 64 计算机。加载内核程序的命令如下:
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
处,
执行操作系统的第一条指令。
注解
RustSBI 是什么?
SBI 是 RISC-V 的一种底层规范,RustSBI 是它的一种实现。 操作系统内核与 RustSBI 的关系有点像应用与操作系统内核的关系,后者向前者提供一定的服务。只是SBI提供的服务很少, 比如关机,显示字符串等。
实现关机功能¶
对上一节实现的代码稍作调整,通过 ecall
调用 RustSBI 实现关机功能:
// bootloader/rustsbi-qemu.bin 直接添加的SBI规范实现的二进制代码,给操作系统提供基本支持服务
// os/src/sbi.rs
fn sbi_call(which: usize, arg0: usize, arg1: usize, arg2: usize) -> usize {
let mut ret;
unsafe {
core::arch::asm!(
"ecall",
...
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)。
下一章会进一步阐释具体细节。
编译执行,结果如下:
# 编译生成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// os/.cargo/config
2[build]
3target = "riscv64gc-unknown-none-elf"
4
5[target.riscv64gc-unknown-none-elf]
6rustflags = [
7 "-Clink-arg=-Tsrc/linker.ld", "-Cforce-frame-pointers=yes"
8]
具体的链接脚本 os/src/linker.ld
如下:
1OUTPUT_ARCH(riscv)
2ENTRY(_start)
3BASE_ADDRESS = 0x80200000;
4
5SECTIONS
6{
7 . = BASE_ADDRESS;
8 skernel = .;
9
10 stext = .;
11 .text : {
12 *(.text.entry)
13 *(.text .text.*)
14 }
15
16 . = ALIGN(4K);
17 etext = .;
18 srodata = .;
19 .rodata : {
20 *(.rodata .rodata.*)
21 }
22
23 . = ALIGN(4K);
24 erodata = .;
25 sdata = .;
26 .data : {
27 *(.data .data.*)
28 }
29
30 . = ALIGN(4K);
31 edata = .;
32 .bss : {
33 *(.bss.stack)
34 sbss = .;
35 *(.bss .bss.*)
36 }
37
38 . = ALIGN(4K);
39 ebss = .;
40 ekernel = .;
41
42 /DISCARD/ : {
43 *(.eh_frame)
44 }
45}
第 1 行我们设置了目标平台为 riscv ;第 2 行我们设置了整个程序的入口点为之前定义的全局符号 _start
;
第 3 行定义了一个常量 BASE_ADDRESS
为 0x80200000
,RustSBI 期望的 OS 起始地址;
注意
linker 脚本的语法不做要求,感兴趣的同学可以自行查阅相关资料。
从 BASE_ADDRESS
开始,代码段 .text
, 只读数据段 .rodata
,数据段 .data
, bss 段 .bss
由低到高依次放置,
且每个段都有两个全局变量给出其起始和结束地址(比如 .text
段的开始和结束地址分别是 stext
和 etext
)。
正确配置栈空间布局¶
用另一段汇编代码初始化栈空间:
1# os/src/entry.asm
2 .section .text.entry
3 .globl _start
4_start:
5 la sp, boot_stack_top
6 call rust_main
7
8 .section .bss.stack
9 .globl boot_stack
10boot_stack:
11 .space 4096 * 16
12 .globl boot_stack_top
13boot_stack_top:
在第 8 行,我们预留了一块大小为 4096 * 16 字节,也就是 \(64\text{KiB}\) 的空间,
用作操作系统的栈空间。
栈顶地址被全局符号 boot_stack_top
标识,栈底则被全局符号 boot_stack
标识。
同时,这块栈空间被命名为
.bss.stack
,链接脚本里有它的位置。
_start
作为操作系统的入口地址,将依据链接脚本被放在 BASE_ADDRESS
处。
la sp, boot_stack_top
作为 OS 的第一条指令,
将 sp 设置为栈空间的栈顶。
简单起见,我们目前不考虑 sp 越过栈底 boot_stack
,也就是栈溢出的情形。
第二条指令则是函数调用 rust_main
,这里的 rust_main
是我们稍后自己编写的应用入口。
接着,我们在 main.rs
中嵌入这些汇编代码并声明应用入口 rust_main
:
1// os/src/main.rs
2#![no_std]
3#![no_main]
4
5mod lang_items;
6
7core::arch::global_asm!(include_str!("entry.asm"));
8
9#[no_mangle]
10pub fn rust_main() -> ! {
11 shutdown();
12}
背景高亮指出了 main.rs
中新增的代码。
第 7 行,我们使用 global_asm
宏,将同目录下的汇编文件 entry.asm
嵌入到代码中。
从第 9 行开始,
我们声明了应用的入口点 rust_main
,需要注意的是,这里通过宏将 rust_main
标记为 #[no_mangle]
以避免编译器对它的名字进行混淆,不然在链接时,
entry.asm
将找不到 main.rs
提供的外部符号 rust_main
,导致链接失败。
再次使用上节中的编译,生成和运行操作,我们看到QEMU模拟的RISC-V 64计算机 优雅 地退出了!
清空 .bss 段¶
等一等,与内存相关的部分太容易出错了, 清零 .bss 段 的工作我们还没有完成。
1// os/src/main.rs
2fn clear_bss() {
3 extern "C" {
4 fn sbss();
5 fn ebss();
6 }
7 (sbss as usize..ebss as usize).for_each(|a| {
8 unsafe { (a as *mut u8).write_volatile(0) }
9 });
10}
11
12pub fn rust_main() -> ! {
13 clear_bss();
14 shutdown();
15}
链接脚本 linker.ld
中给出的全局符号 sbss
和 ebss
让我们能轻松确定 .bss
段的位置。
添加裸机打印相关函数¶
在上一节中我们为用户态程序实现的 println
宏,略作修改即可用于本节的内核态操作系统。
详见 os/src/console.rs
。
利用 println
宏,我们重写异常处理函数 panic
,使其在 panic 时能打印错误发生的位置。
相关代码位于 os/src/lang_items.rs
中。
我们还使用第三方库 log
为你实现了日志模块,相关代码位于 os/src/logging.rs
中。
注解
在 cargo 项目中引入外部库 log,需要修改 Cargo.toml
加入相应的依赖信息。
现在,让我们重复一遍本章开头的试验,make run LOG=TRACE
!
至此,我们完成了第一章的实验内容,
注解
背景知识:理解应用程序和执行环境