rcore 笔记 第一个裸机程序(二)https://developer.aliyun.com/article/1391889
调用规范
调用规范(Calling Convention) 约定在某个指令集架构上,某种编程语言的函数调用如何实现。它包括了以下
内容:
- 函数的输入参数和返回值如何传递;
- 函数调用上下文中调用者/被调用者保存寄存器的划分;
- 其他的在函数调用流程中对于寄存器的使用方法。
调用规范是对于一种确定的编程语言来说的,因为一般意义上的函数调用只会在编程语言的内部进行。当一种语言想要调用用另一门编程语言编写的函数接口时,编译器就需要同时清楚两门语言的调用规范,并对寄存器的使用做出调整。
|寄存器组|保存者|功能|
|---------|------------|--------------|
|a0a7(x10x17 )|调用者保存|用来传递输入参数。其中的a0和a1还用来保存返回值。|
|t0~t6( x5x7,x28x31 )|调用者保存作为临时寄存器使用,在被调函数中可以随意使用无需保存。|
|s0~s11( x18~x27 ,x8~x9,)|被调用者保存作为临时寄存器使用,被调函数保存后才能在被调函数中使用。|
剩下的5个通用寄存器情况如下:
• zero( x0 ) 之前提到过,它恒为零,函数调用不会对它产生影响;
• ra( x1 ) 是被调用者保存的。被调用者函数可能也会调用函数,在调用之前就需要修改ra使得这次调用能正确返回。因此,每个函数都需要在开头保存ra到自己的栈中,并在结尾使用ret返回之前将其恢复。栈帧是当前执行函数用于存储局部变量和函数返回信息的内存结构。
• sp( x2 ) 是被调用者保存的。这个是之后就会提到的栈指针(StackPointer)寄存器,它指向下一个将要
被存储的栈顶位置。
• fp( s0 ),它既可作为s0临时寄存器,也可作为栈帧指针(FramePointer)寄存器,表示当前栈帧的起
始位置,是一个被调用者保存寄存器。fp指向的栈帧起始位置和sp指向的栈帧的当前栈顶位置形成了
所对应函数栈帧的空间范围。
• gp( x3 ) 和tp( x4 ) 在一个程序运行期间都不会变化,因此不必放在函数调用上下文中。
分配并使用启动栈
我们在entry.asm中分配启动栈空间,并在控制权被转交给Rust入口之前将栈指针sp设置为栈顶的位置。
# os/src/entry.asm .section .text.entry .globl _start _start: la sp, boot_stack_top call rust_main .section .bss.stack .globl boot_stack_lower_bound boot_stack_lower_bound: .space 4096 * 16 .globl boot_stack_top boot_stack_top:
我们在第11行在内核的内存布局中预留了一块大小为4096*16字节也就是64KiB的空间用作接下来要运行的程序的栈空间。在RISC-V架构上,栈是从高地址向低地址增长。因此,最开始的时候栈为空,栈顶和栈底位于相同的位置,我们用更高地址的符号boot_stack_top来标识栈顶的位置。同时,我们用更低地址的符号boot_stack_lower_bound来标识栈能够增长到的下限位置,它们都被设置为全局符号供其他目标文件使用。如下图所示:
回到entry.asm,可以发现在控制权转交给Rust入口之前会执行两条指令,它们分别位于entry.asm的第5、6行。第5行我们将栈指针sp设置为先前分配的启动栈栈顶地址,这样Rust代码在进行函数调用和返回的时候就可以正常在启动栈上分配和回收栈帧了。在我们设计好的内存布局中,这块启动栈所用的内存并不会和内核的其他代码、数据段产生冲突。第6行我们通过伪指令call调用Rust编写的内核入口点rust_main将控制
权转交给Rust代码,该入口点在main.rs中实现:
// os/src/main.rs #[no_mangle] pub fn rust_main()-> ! { loop {} }
这里需要注意的是需要通过宏将rust_main标记为#[no_mangle]以避免编译器对它的名字进行混淆,不然在链接的时候,entry.asm将找不到main.rs提供的外部符rust_main从而导致链接失败。在
rust_main 函数的开场白中,我们将第一次在栈上分配栈帧并保存函数调用上下文,它也是内核运行全程中最底层的栈帧。
在内核初始化中,需要先完成对.bss段的清零。这是内核很重要的一部分初始化工作,在使用任何被分配到.bss段的全局变量之前我们需要确保.bss段已被清零。我们就在rust_main的开头完成这一工作,由于控制权已经被转交给Rust,我们终于不用手写汇编代码而是可以用Rust来实现这一功能了:
//os/src/main.rs #[no_mangle] pubfn rust_main()->! { clear_bss(); loop{} } fnbclear_bss(){ extern "C"{ fn sbss(); fn ebss(); } (sbssas usize..ebss asusize).for_each(|a| { unsafe{ (aas *mutu8).write_volatile(0)} }); }
在函数clear_bss中,我们会尝试从其他地方找到全局符号sbss和ebss,它们由链接脚本linker.ld给出,并分别指出需要被清零的.bss段的起始和终止地址。接下来我们只需遍历该地址区间并逐字节进行清零即可。
基于SBI服务完成输出和关机
使用 RustSBI 提供的服务
我们对 RustSBI 的了解仅限于它会在计算机启动时进行它所负责的环境初始化工作,并将计算机控制权移交给内核。但实际上作为内核的执行环境,它还有另一项职责:即在内核运行时响应内核的请求为内核提供服务。当内核发出请求时,计算机会转由 RustSBI 控制来响应内核的请求,待请求处理完毕后,计算机控制权会被交还给内核。从内存布局的角度来思考,每一层执行环境都对应到内存中的一段代码和数据,这里的控制权转移指的是 CPU 从执行一层软件的代码到执行另一层软件的代码的过程。这个过程和函数调用比较像,但是内核无法通过函数调用来请求 RustSBI 提供的服务,这是因为内核并没有和 RustSBI 链接到一起,我们仅仅使用 RustSBI 构建后的可执行文件,因此内核对于 RustSBI 的符号一无所知。事实上,内核需要通过另一种复杂的方式来“调用” 。
// os/src/main.rs mod sbi; // os/src/sbi.rs use core::arch::asm; #[inline(always)] fn sbi_call(which: usize, arg0: usize, arg1: usize, arg2: usize)-> usize { let mut ret; unsafe { asm!( "ecall", inlateout("x10") arg0 => ret, in("x11") arg1, in("x12") arg2, in("x17") which, ); } ret }
我们将内核与RustSBI通信的相关功能实现在子模块sbi中,因此我们需要在main.rs中加入mod sbi将该子模块加入我们的项目。在os/src/sbi.rs中,我们首先关注sbi_call的函数签名,which表示请求RustSBI 的服务的类型(RustSBI可以提供多种不同类型的服务),arg0~arg2表示传递给RustSBI的3 个参数,而RustSBI在将请求处理完毕后,会给内核一个返回值,这个返回值也会被sbi_call函数返回。
在sbi.rs中我们定义RustSBI支持的服务类型常量,它们并未被完全用到:
// os/src/sbi.rs #![allow(unused)] // 此行请放在该文件最开头 const SBI_SET_TIMER: usize = 0; const SBI_CONSOLE_PUTCHAR: usize = 1; const SBI_CONSOLE_GETCHAR:usize= 2; const SBI_CLEAR_IPI:usize= 3; const SBI_SEND_IPI:usize= 4; const SBI_REMOTE_FENCE_I:usize= 5; const SBI_REMOTE_SFENCE_VMA:usize= 6; const SBI_REMOTE_SFENCE_VMA_ASID:usize= 7; const SBI_SHUTDOWN:usize= 8;
如字面意思,服务SBI_CONSOLE_PUTCHAR可以用来在屏幕上输出一个字符。我们将这个功能封装成console_putchar函数:
//os/src/sbi.rs pub fn console_putchar(c:usize){ sbi_call(SBI_CONSOLE_PUTCHAR,c,0, 0); }
类似上述方式,我们还可以将关机服务SBI_SHUTDOWN封装成shutdown函数:
//os/src/sbi.rs pub fn shutdown()->! { sbi_call(SBI_SHUTDOWN,0, 0,0); panic!("Itshouldshutdown!"); }
实现格式化输出
onsole_putchar的功能过于受限,如果想打印一行Helloworld!的话需要进行多次调用。能否像本章第一节那样使用println!宏一行就完成输出呢?因此我们尝试自己编写基于console_putchar的println!宏。
// os/src/main.rs #[macro_use] mod console; // os/src/console.rs 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(); } #[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)+)?) => { $crate::console::print(format_args!(concat!($fmt, "\n") $(, $($arg)+)?)); } }
处理致命错误
错误处理是编程的重要一环,它能够保证程序的可靠性和可用性,使得程序能够从容应对更多突发状况而不至于过早崩溃。
// os/src/lang_items.rs use core::panic::PanicInfo; #[panic_handler] fn panic(_info: &PanicInfo) -> ! { loop {} }
可以看到,在目前的实现中,当遇到不可恢复错误的时候,被标记为语义项 #[panic_handler] 的 panic 函数将会被调用,然而其中只是一个死循环,会使得计算机卡在这里。借助前面实现的 println! 宏和 shutdown 函数,我们可以在 panic 函数中打印错误信息并关机:
// os/src/main.rs #![feature(panic_info_message)] // os/src/lang_item.rs use crate::sbi::shutdown; use core::panic::PanicInfo; #[panic_handler] fn panic(info: &PanicInfo) -> ! { if let Some(location) = info.location() { println!( "Panicked at {}:{} {}", location.file(), location.line(), info.message().unwrap() ); } else { println!("Panicked: {}", info.message().unwrap()); } shutdown() }
我们尝试打印更加详细的信息,包括 panic 所在的源文件和代码行数。我们尝试从传入的 PanicInfo 中解析这些信息,如果解析成功的话,就和 panic 的报错信息一起打印出来。我们需要在 main.rs 开头加上 #![feature(panic_info_message)] 才能通过 PanicInfo::message 获取报错信息。当打印完毕之后,我们直接调用 shutdown 函数关机。
为了测试我们的实现是否正确,我们将 rust_main 改为:
// os/src/main.rs #[no_mangle] pub fn rust_main() -> ! { clear_bss(); println!("Hello, world!"); panic!("Shutdown machine!"); }