基于 SBI 服务完成输出和关机#

本节导读#

本节我们将进行构建“三叶虫”操作系统的最后一个步骤,即基于 RustSBI 提供的服务完成在屏幕上打印 Hello world! 和关机操作。事实上,作为对我们之前提到的 应用程序执行环境 的细化,RustSBI 介于底层硬件和内核之间,是我们内核的底层执行环境。本节将会提到执行环境除了为上层应用进行初始化的第二种职责:即在上层应用运行时提供服务。本节的代码涉及的汇编和 Rust 的细节较多,不必完全理解其含义,重点在于将内核成功运行起来。

使用 RustSBI 提供的服务#

之前我们对 RustSBI 的了解仅限于它会在计算机启动时进行它所负责的环境初始化工作,并将计算机控制权移交给内核。但实际上作为内核的执行环境,它还有另一项职责:即在内核运行时响应内核的请求为内核提供服务。当内核发出请求时,计算机会转由 RustSBI 控制来响应内核的请求,待请求处理完毕后,计算机控制权会被交还给内核。从内存布局的角度来思考,每一层执行环境(或称软件栈)都对应到内存中的一段代码和数据,这里的控制权转移指的是 CPU 从执行一层软件的代码到执行另一层软件的代码的过程。这个过程和函数调用比较像,但是内核无法通过函数调用来请求 RustSBI 提供的服务,这是因为内核并没有和 RustSBI 链接到一起,我们仅仅使用 RustSBI 构建后的可执行文件,因此内核对于 RustSBI 的符号一无所知。事实上,内核需要通过另一种复杂的方式来“调用” RustSBI 的服务:

 1// os/src/main.rs
 2mod sbi;
 3
 4// os/src/sbi.rs
 5use core::arch::asm;
 6#[inline(always)]
 7fn sbi_call(which: usize, arg0: usize, arg1: usize, arg2: usize) -> usize {
 8    let mut ret;
 9    unsafe {
10        asm!(
11            "ecall",
12            inlateout("x10") arg0 => ret,
13            in("x11") arg1,
14            in("x12") arg2,
15            in("x17") which,
16        );
17    }
18    ret
19}

我们将内核与 RustSBI 通信的相关功能实现在子模块 sbi 中,因此我们需要在 main.rs 中加入 mod sbi 将该子模块加入我们的项目。在 os/src/sbi.rs 中,我们首先关注 sbi_call 的函数签名, which 表示请求 RustSBI 的服务的类型(RustSBI 可以提供多种不同类型的服务), arg0 ~ arg2 表示传递给 RustSBI 的 3 个参数,而 RustSBI 在将请求处理完毕后,会给内核一个返回值,这个返回值也会被 sbi_call 函数返回。尽管我们还不太理解函数 sbi_call 的具体实现,但目前我们已经知道如何使用它了:当需要使用 RustSBI 服务的时候调用它就行了。

sbi.rs 中我们定义 RustSBI 支持的服务类型常量,它们并未被完全用到:

 1// os/src/sbi.rs
 2#![allow(unused)] // 此行请放在该文件最开头
 3const SBI_SET_TIMER: usize = 0;
 4const SBI_CONSOLE_PUTCHAR: usize = 1;
 5const SBI_CONSOLE_GETCHAR: usize = 2;
 6const SBI_CLEAR_IPI: usize = 3;
 7const SBI_SEND_IPI: usize = 4;
 8const SBI_REMOTE_FENCE_I: usize = 5;
 9const SBI_REMOTE_SFENCE_VMA: usize = 6;
10const SBI_REMOTE_SFENCE_VMA_ASID: usize = 7;
11const SBI_SHUTDOWN: usize = 8;

如字面意思,服务 SBI_CONSOLE_PUTCHAR 可以用来在屏幕上输出一个字符。我们将这个功能封装成 console_putchar 函数:

1// os/src/sbi.rs
2pub fn console_putchar(c: usize) {
3    sbi_call(SBI_CONSOLE_PUTCHAR, c, 0, 0);
4}

注意我们并未使用 sbi_call 的返回值,因为它并不重要。如果同学们有兴趣的话,可以试着在 rust_main 中调用 console_putchar 来在屏幕上输出 OK 。接着在 Qemu 上运行一下,我们便可看到由我们自己输出的第一条 log 了。

类似上述方式,我们还可以将关机服务 SBI_SHUTDOWN 封装成 shutdown 函数:

1// os/src/sbi.rs
2pub fn shutdown() -> ! {
3    sbi_call(SBI_SHUTDOWN, 0, 0, 0);
4    panic!("It should shutdown!");
5}

实现格式化输出#

console_putchar 的功能过于受限,如果想打印一行 Hello world! 的话需要进行多次调用。能否像本章第一节那样使用 println! 宏一行就完成输出呢?因此我们尝试自己编写基于 console_putcharprintln! 宏。

 1// os/src/main.rs
 2#[macro_use]
 3mod console;
 4
 5// os/src/console.rs
 6use crate::sbi::console_putchar;
 7use core::fmt::{self, Write};
 8
 9struct Stdout;
10
11impl Write for Stdout {
12    fn write_str(&mut self, s: &str) -> fmt::Result {
13        for c in s.chars() {
14            console_putchar(c as usize);
15        }
16        Ok(())
17    }
18}
19
20pub fn print(args: fmt::Arguments) {
21    Stdout.write_fmt(args).unwrap();
22}
23
24#[macro_export]
25macro_rules! print {
26    ($fmt: literal $(, $($arg: tt)+)?) => {
27        $crate::console::print(format_args!($fmt $(, $($arg)+)?));
28    }
29}
30
31#[macro_export]
32macro_rules! println {
33    ($fmt: literal $(, $($arg: tt)+)?) => {
34        $crate::console::print(format_args!(concat!($fmt, "\n") $(, $($arg)+)?));
35    }
36}

我们在 console 子模块中编写 println! 宏。结构体 Stdout 不包含任何字段,因此它被称为类单元结构体(Unit-like structs,请参考 1 )。 core::fmt::Write trait 包含一个用来实现 println! 宏很好用的 write_fmt 方法,为此我们准备为结构体 Stdout 实现 Write trait 。在 Write trait 中, write_str 方法必须实现,因此我们需要为 Stdout 实现这一方法,它并不难实现,只需遍历传入的 &str 中的每个字符并调用 console_putchar 就能将传入的整个字符串打印到屏幕上。

在此之后 Stdout 便可调用 Write trait 提供的 write_fmt 方法并进而实现 print 函数。在声明宏(Declarative macros,参考 2print!println! 中会调用 print 函数完成输出。

现在我们可以在 rust_main 中使用 print!println! 宏进行格式化输出了,如有兴趣的话可以输出 Hello, world! 试一下。

注解

Rust Tips:Rust Trait

在 Rust 语言中,trait(中文翻译:特质、特征)是一种类型,用于描述一组方法的集合。trait 可以用来定义接口(interface),并可以被其他类型实现。 举个例子,假设我们有一个简单的Rust程序,其中有一个名为 Shape 的 trait,用于描述形状:

1trait Shape {
2    fn area(&self) -> f64;
3}

我们可以使用这个 trait 来定义一个圆形类型:

1struct Circle {
2    radius: f64,
3}
4
5impl Shape for Circle {
6    fn area(&self) -> f64 {
7        3.14 * self.radius * self.radius
8    }
9}

这样,我们就可以使用 Circle 类型的实例调用 area 方法了。

1let c = Circle { radius: 1.0 };
2println!("Circle area: {}", c.area());  // 输出: Circle area: 3.14

处理致命错误#

错误处理是编程的重要一环,它能够保证程序的可靠性和可用性,使得程序能够从容应对更多突发状况而不至于过早崩溃。不同于 C 的返回错误编号 errno 模型和 C++/Java 的 try-catch 异常捕获模型,Rust 将错误分为可恢复和不可恢复错误两大类。这里我们主要关心不可恢复错误。和 C++/Java 中一个异常被抛出后始终得不到处理一样,在 Rust 中遇到不可恢复错误,程序会直接报错退出。例如,使用 panic! 宏便会直接触发一个不可恢复错误并使程序退出。不过在我们的内核中,目前不可恢复错误的处理机制还不完善:

1// os/src/lang_items.rs
2use core::panic::PanicInfo;
3
4#[panic_handler]
5fn panic(_info: &PanicInfo) -> ! {
6    loop {}
7}

可以看到,在目前的实现中,当遇到不可恢复错误的时候,被标记为语义项 #[panic_handler]panic 函数将会被调用,然而其中只是一个死循环,会使得计算机卡在这里。借助前面实现的 println! 宏和 shutdown 函数,我们可以在 panic 函数中打印错误信息并关机:

 1// os/src/main.rs
 2#![feature(panic_info_message)]
 3
 4// os/src/lang_item.rs
 5use crate::sbi::shutdown;
 6use core::panic::PanicInfo;
 7
 8#[panic_handler]
 9fn panic(info: &PanicInfo) -> ! {
10    if let Some(location) = info.location() {
11        println!(
12            "Panicked at {}:{} {}",
13            location.file(),
14            location.line(),
15            info.message().unwrap()
16        );
17    } else {
18        println!("Panicked: {}", info.message().unwrap());
19    }
20    shutdown()
21}

我们尝试打印更加详细的信息,包括 panic 所在的源文件和代码行数。我们尝试从传入的 PanicInfo 中解析这些信息,如果解析成功的话,就和 panic 的报错信息一起打印出来。我们需要在 main.rs 开头加上 #![feature(panic_info_message)] 才能通过 PanicInfo::message 获取报错信息。当打印完毕之后,我们直接调用 shutdown 函数关机。

为了测试我们的实现是否正确,我们将 rust_main 改为:

1// os/src/main.rs
2#[no_mangle]
3pub fn rust_main() -> ! {
4    clear_bss();
5    println!("Hello, world!");
6    panic!("Shutdown machine!");
7}

使用 Qemu 运行我们的内核,运行结果为:

[RustSBI output]
Hello, world!
Panicked at src/main.rs:26 Shutdown machine!

可以看到,panic 所在的源文件和代码行数被正确报告,这将为我们后续章节的开发和调试带来很大方便。到这里,我们就实现了一个可以在Qemu模拟的计算机上运行的裸机应用程序,其具体内容就是上述的 rust_main`函数,而其他部分,如 `entry.asmlang_items.rsconsole.rssbi.rs 则形成了支持裸机应用程序的寒武纪“三叶虫”操作系统 – LibOS 。

注解

Rust Tips:Rust 可恢复错误

在有可能出现错误时,Rust 函数的返回值可以属于一种特殊的类型,该类型可以涵盖两种情况:要么函数正常退出,则函数返回正常的返回值;要么函数执行过程中出错,则函数返回出错的类型。Rust 的类型系统保证这种返回值不会在程序员无意识的情况下被滥用,即程序员必须显式对其进行分支判断或者强制排除出错的情况。如果不进行任何处理,那么无法从中得到有意义的结果供后续使用或是无法通过编译。这样,就杜绝了很大一部分因程序员的疏忽产生的错误(如不加判断地使用某函数返回的空指针)。

在 Rust 中有两种这样的特殊类型,它们都属于枚举结构:

  • Option<T> 既可以有值 Option::Some<T> ,也有可能没有值 Option::None

  • Result<T, E> 既可以保存某个操作的返回值 Result::Ok<T> ,也可以表明操作过程中出现了错误 Result::Err<E>

我们可以使用 Option/Result 来保存一个不能确定存在/不存在或是成功/失败的值。之后可以通过匹配 if let 或是在能够确定 的场合直接通过 unwrap 将里面的值取出。详细的内容可以参考 Rust 官方文档 3

1

https://doc.rust-lang.org/book/ch05-01-defining-structs.html#unit-like-structs-without-any-fields

2

https://doc.rust-lang.org/book/ch19-06-macros.html#declarative-macros-with-macro_rules-for-general-metaprogramming

3

https://doc.rust-lang.org/book/ch09-02-recoverable-errors-with-result.html