操作系统: 乌班图22.04
rust: rustc 1.75.0-nightly
QEMU emulator version 7.0.0
我们先在Linux上开发并运行一个简单的 “Hello, world” 应用程序,看看一个简单应用程序从开发到执行的全过程。
cargo new os --bin
我们加上了 --bin 选项来告诉 Cargo 我们创建一个可执行程序项目而不是函数库项目。此时,项目的文件结构如下:
tree os
os
├── Cargo.toml
└── src
└── main.rs
1 directory, 2 files
其中 Cargo.toml 中保存着项目的配置,包括作者的信息、联系方式以及库依赖等等。显而易见源代码保存在 src 目录下,目前为止只有 main.rs 一个文件,让我们看一下里面的内容:
最简单的 Rust 应用
fn main() {
println!("Hello, world!");
}
进入 os 项目根目录下,利用 Cargo 工具即可一条命令实现构建并运行项目:
cargo run
Compiling os v0.1.0 (/home/shinbokuow/workspace/v3/rCore-Tutorial-v3/os)
Finished dev [unoptimized + debuginfo] target(s) in 1.15s
Running `target/debug/os`
Hello, world!
如我们预想的一样,我们在屏幕上看到了一行 Hello, world! 。
如下图所示,现在通用操作系统(如 Linux 等)上的应用程序运行需要下面多层次的执行环境栈的支持,图中的白色块自上而下(越往下则越靠近底层,下层作为上层的执行环境支持上层代码的运行)表示各级执行环境,黑色块则表示相邻两层执行环境之间的接口。
我们的应用位于最上层,它可以通过调用编程语言提供的标准库或者其他三方库对外提供的函数接口,使得仅需少量的源代码就能完成复杂的功能。但是这些库的功能不仅限于此,事实上它们属于应用程序执行环境(Execution Environment) 的一部分。在我们通常不会注意到的地方,这些软件库还会在执行应用之前完成一些初始化工作,并在应用程序执行的时候对它进行监控。我们在打印Hello, world!时使用的println!
宏正是由Rust标准库std提供的。
从操作系统内核的角度看来,它上面的一切都属于用户态软件,而它自身属于内核态软件。无论用户态应用如何编写,是手写汇编代码,还是基于某种高级编程语言调用其标准库或三方库,某些功能总要直接或间接的通过操作系统内核提供的系统调用(SystemCall)来实现。因此系统调用充当了用户和内核之间的边界。内核作为用户态软件的执行环境,它不仅要提供系统调用接口,还需要对用户态软件的执行进行监控和管理。
对于一份用某种编程语言实现的应用程序源代码而言,编译器在将其通过编译、链接得到可执行文件的时候需要知道程序要在哪个平台(Platform)上运行。这里平台主要是指CPU类型、操作系统类型和标准运行时库的组合。从上面给出的应用程序执行环境栈可以看出:
• 如果用户态基于的内核不同,会导致系统调用接口不同或者语义不一致;
• 如果底层硬件不同,对于硬件资源的访问方式会有差异。特别是如果ISA不同,则向软件提供的指令集和寄存器都不同。
它们都会导致最终生成的可执行文件有很大不同。需要指出的是,某些编译器支持同一份源代码无需修改就可编译到多个不同的目标平台并在上面运行。这种情况下,源代码是跨平台的。而另一些编译器则已经预设好了一个固定的目标平台。
println! 宏所在的Rust标准库std需要通过系统调用获得操作系统的服务,而如果要构建运行在裸机上的操作系统,就不能再依赖标准库了。所以我们第一步要尝试移除println!宏及其所在的标准库。
由于后续实验需要rustc编译器缺省生成RISC-V64的目标代码,所以我们首先要给rustc添加一个target: riscv64gc-unknown-none-elf 。这可通过如下命令来完成:
rustup target add riscv64gc-unknown-none-elf
然后在os目录下新建.cargo目录,并在这个目录下创建config文件,并在里面输入如下内容:
# os/.cargo/config
[build]
target = "riscv64gc-unknown-none-elf"
这会对于Cargo工具在os目录下的行为进行调整:现在默认会使用riscv64gc作为目标平台而不是原先的默认x86_64-unknown-linux-gnu。
我们重新编译简单的os程序,之前的println宏缺失的错误消失了,但又出现了如下新的编译错误:
$ cargo build
Compiling os v0.1.0 (/home/shinbokuow/workspace/v3/rCore-Tutorial-v3/os)
error: `#[panic_handler]` function required, but not found
在使用Rust编写应用程序的时候,我们常常在遇到了一些无法恢复的致命错误(panic),导致程序无法继续向下运行。这时手动或自动调用panic!宏来打印出错的位置,让软件能够意识到它的存在,并进行一些后续处理。
在标准库std中提供了关于panic!宏的具体实现,其大致功能是打印出错位置和原因并杀死当前应用。但本章要实现的操作系统不能使用还需依赖操作系统的标准库std,而更底层的核心库core中只有一个panic!宏的空壳,并没有提供panic!宏的精简实现。因此我们需要自己先实现一个简陋的panic处理函数.
注解: #[panic_handler]
#[panic_handler] 是一种编译指导属性,用于标记核心库core中的panic!宏要对接的函数(该函数实现对致命错误的具体处理)。该编译指导属性所标记的函数需要具有fn(&PanicInfo)-> !函数签名,函数可通过PanicInfo数据结构获取致命错误的相关信息。这样Rust编译器就可以把核心库core中的panic!宏定义与#[panic_handler]指向的panic函数实现合并在一起,使得no_std程序具有类似std库的应对致命错误的功能。
我们创建一个新的子模块lang_items.rs实现panic函数,并通过#[panic_handler]属性通知编译器
用panic 函数来对接panic!宏。为了将该子模块添加到项目中,我们还需要在main.rs的#![no_std]的下方加上mod lang_items;,相关知识可参考Rust模块编程:
// os/src/lang_items.rs
use core::panic::PanicInfo;
#[panic_handler]
fn panic(_info: &PanicInfo)-> ! {
loop {}
}
在把panic_handler配置在单独的文件os/src/lang_items.rs后,需要在os/src/main.rs文件中添加以下内容才能正常编译整个软件:
// os/src/main.rs
#![no_std]
mod lang_items;
// ... other code
我们再次重新编译简单的os程序,之前的#[panic_handler]函数缺失的错误消失了,但又出现了如下新的编译错误:… error:
.. code-block::
$ cargo build
Compiling os v0.1.0 (/home/shinbokuow/workspace/v3/rCore-Tutorial-v3/os)
error: requires `start` lang_item
编译器提醒我们缺少一个名为start的语义项。我们回忆一下,之前提到语言标准库和三方库作为应用程序的执行环境,需要负责在执行应用程序之前进行一些初始化工作,然后才跳转到应用程序的入口点开始执行。事实上start语义项代表了标准库std在执行应用程序之前需要进行的一些初始化工作。由于我们禁用了标准库,编译器也就找不到这项功能的实现了。
最简单的解决方案就是压根不让编译器使用这项功能。我们在main.rs的开头加入设置#![no_main]告诉编译器我们没有一般意义上的main函数,并将原来的main函数删除。在失去了main函数的情况下,编译器也就不需要完成所谓的初始化工作了。
# 编译
cargo build --release
# 运行
qemu-system-riscv64 -machine virt -nographic -bios ../bootloader/rustsbi-qemu.bin -device loader,file=target/riscv64gc-unknown-none-elf/release/os,addr=0x80200000
在我们将源代码编译为可执行文件之后,它就会变成一个看似充满了杂乱无章的字节的一个文件。但我们知道这些字节至少可以分成代码和数据两部分,在程序运行起来的时候它们的功能并不相同:代码部分由一条条可以被 CPU 解码并执行的指令组成,而数据部分只是被 CPU 视作可读写的内存空间。事实上我们还可以根据其功能进一步把两个部分划分为更小的单位: 段 (Section) 。不同的段会被编译器放置在内存不同的位置上,这构成了程序的 内存布局 (Memory Layout)。一种典型的程序相对内存布局如下所示:
已初始化数据段保存程序中那些已初始化的全局数据,分为 .rodata 和 .data 两部分。前者存放只读的全局数据,通常是一些常数或者是 常量字符串等;而后者存放可修改的全局数据。
未初始化数据段 .bss 保存程序中那些未初始化的全局数据,通常由程序的加载者代为进行零初始化,即将这块区域逐字节清零;
堆 (heap)区域用来存放程序运行时动态分配的数据,如 C/C++ 中的 malloc/new 分配到的数据本体就放在堆区域,它向高地址增长;
栈 (stack)区域不仅用作函数调用上下文的保存与恢复,每个函数作用域内的局部变量也被编译器放在它的栈帧内,它向低地址增长。
编译流程
从源代码得到可执行文件的编译流程可被细化为多个阶段(虽然输入一条命令便可将它们全部完成):
汇编器输出的每个目标文件都有一个独立的程序内存布局,它描述了目标文件内各段所在的位置。而链接器所做的事情是将所有输入的目标文件整合成一个整体的内存布局。在此期间链接器主要完成两件事情:
• 第一件事情是将来自不同目标文件的段在目标内存布局中重新排布。如下图所示,在链接过程中,分别来自于目标文件1.o和2.o段被按照段的功能进行分类,相同功能的段被排在一起放在拼装后的目标文件output.o中。注意到,目标文件1.o和2.o的内存布局是存在冲突的,同一个地址在不同的内存布局中存放不同的内容。而在合并后的内存布局中,这些冲突被消除。
• 第二件事情是将符号替换为具体地址。这里的符号指什么呢?我们知道,在我们进行模块化编程的时候,每个模块都会提供一些向其他模块公开的全局变量、函数等供其他模块访问,也会访问其他模块向它公开的内容。要访问一个变量或者调用一个函数,在源代码级别我们只需知道它们的名字即可,
这些名字被我们称为符号。取决于符号来自于模块内部还是其他模块,我们还可以进一步将符号分成内部符号和外部符号。然而,在机器码级别(也即在目标文件或可执行文件中)我们并不是通过符号来找到索引我们想要访问的变量或函数,而是直接通过变量或函数的地址。例如,如果想调用一个函数,那么在指令的机器码中我们可以找到函数入口的绝对地址或者相对于当前PC的相对地址。
首先,我们需要编写进入内核后的第一条指令,这样更方便我们验证我们的内核镜像是否正确对接到Qemu
上。
# os/src/entry.asm
.section .text.entry
.globl _start
_start:
li x1, 100
实际的指令位于第5行,也即li x1, 100。li是LoadImmediate的缩写,也即将一个立即数加载到某个寄存器,因此这条指令可以看做将寄存器x1赋值为100。第4行我们声明了一个符号_start,该符号指向紧跟在符号后面的内容——也就是位于第5行的指令,因此符号_start的地址即为第5行的指令所在的
地址。第3行我们告知编译器_start是一个全局符号,因此可以被其他目标文件使用。第2行表明我们希望将第2行后面的内容全部放到一个名为.text.entry的段中。一般情况下,所有的代码都被放到一个名为.text的代码段中,这里我们将其命名为.text.entry从而区别于其他.text的目的在于我们想要确保该段被放置在相比任何其他代码段更低的地址上。这样,作为内核的入口点,这段指令才能被最先执行。
接着,我们在main.rs中嵌入这段汇编代码,这样Rust编译器才能够注意到它,不然编译器会认为它是一个与项目无关的文件:
//os/src/main.rs
#![no_std]
#![no_main]
mod lang_item;
usecore::arch::global_asm;
global_asm!(include_str!("entry.asm"));
第8行,我们通过include_str!宏将同目录下的汇编代码entry.asm转化为字符串并通过global_asm!宏嵌入到代码中。
由于链接器默认的内存布局并不能符合我们的要求,为了实现与Qemu正确对接,我们可以通过链接脚本(LinkerScript)调整链接器的行为,使得最终生成的可执行文件的内存布局符合Qemu的预期,即内核第一条指令的地址应该位于0x80200000。我们修改Cargo的配置文件来使用我们自己的链接脚本os/src/linker.ld而非使用默认的内存布局:
//os/.cargo/config
[build]
target ="riscv64gc-unknown-none-elf"
[target.riscv64gc-unknown-none-elf]
rustflags= [
"-Clink-arg=-Tsrc/linker.ld", "-Cforce-frame-pointers=yes"
]
链接脚本os/src/linker.ld如下:
OUTPUT_ARCH(riscv)
ENTRY(_start)
BASE_ADDRESS =0x80200000;
SECTIONS
{
. = BASE_ADDRESS;
skernel = .;
stext= .;
.text:{
*(.text.entry)
*(.text.text.*)
}
. = ALIGN(4K);
etext= .;
srodata = .;
.rodata:{
*(.rodata .rodata.*)
*(.srodata .srodata.*)
}
. = ALIGN(4K);
erodata = .;
sdata= .;
.data:{
*(.data.data.*)
*(.sdata.sdata.*)
}
. = ALIGN(4K);
edata= .;
.bss:{
*(.bss.stack)
sbss=.;
*(.bss.bss.*)
*(.sbss.sbss.*)
}
. = ALIGN(4K);
ebss= .;
ekernel = .;
/DISCARD/ :{
*(.eh_frame)
}
}
第1行我们设置了目标平台为riscv;第2行我们设置了整个程序的入口点为之前定义的全局符号_start;第3行定义了一个常量BASE_ADDRESS为0x80200000,也就是我们之前提到内核的初始化代码被放置的地址;
从第5行开始体现了链接过程中对输入的目标文件的段的合并。其中.表示当前地址,也就是链接器会从它指向的位置开始往下放置从输入的目标文件中收集来的段。我们可以对.进行赋值来调整接下来的段放在哪里,也可以创建一些全局符号赋值为.从而记录这一时刻的位置。我们还能够看到这样的格式:
.rodata: {
*(.rodata)
}
冒号前面表示最终生成的可执行文件的一个段的名字,花括号内按照放置顺序描述将所有输入目标文件的哪些段放在这个段中,每一行格式为(SectionName),表示目标文件ObjectFile的名为SectionName的段需要被放进去。我们也可以使用通配符来书写和分别表示可能的输入目标文件和段名。因此,最终的合并结果是,在最终可执行文件中各个常见的段.text,.rodata.data,.bss从低地址到高地址按顺序放置,每个段里面都包括了所有输入目标文件的同名段,且每个段都有两个全局符号给出了它的开始和结束地址。
第12行我们将包含内核第一条指令的.text.entry段放在最终的.text段的最开头,同时注意到在最终内存布局中代码段.text又是先于任何其他段的。因为所有的段都从BASE_ADDRESS也即0x80200000开始放置,这就能够保证内核的第一条指令正好放在0x80200000从而能够正确对接到Qemu上。
上面得到的内核可执行文件完全符合我们对于内存布局的要求,但是我们不能将其直接提交给Qemu,因为它除了实际会被用到的代码和数据段之外还有一些多余的元数据,这些元数据无法被Qemu在加载文件时利用,且会使代码和数据段被加载到错误的位置。如下图所示:
图中,红色的区域表示内核可执行文件中的元数据,深蓝色的区域表示各个段(包括代码段和数据段),而浅蓝色区域则表示内核被执行的第一条指令,它位于深蓝色区域的开头。图示的上半部分中,我们直接将内核可执行文件os提交给Qemu,而Qemu会将整个可执行文件不加处理的加载到Qemu内存的0x80200000处,由于内核可执行文件的开头是一段元数据,这会导致Qemu内存0x80200000处无法找到内核第一条指令,也就意味着RustSBI无法正常将计算机控制权转交给内核。相反,图示的下半部分中,将元数据丢弃得到的内核镜像os.bin被加载到Qemu之后,则可以在0x80200000处正确找到内核第一条指令。
使用如下命令可以丢弃内核可执行文件中的元数据得到内核镜像:
rust-objcopy--strip-all target/riscv64gc-unknown-none-elf/release/os-O binary
指令集必须给用于函数调用的跳转指令一些额外的能力,而不只是单纯的跳转。在RISC-V架构上,有两条指令即符合这样的特征:
指令 | 指令功能 |
---|---|
jal rd, imm[20 : 1] | rd ←pc+4 pc ←pc+imm |
jalr rd, (imm[11 : 0])rs | rd ←pc+4 pc ←rs+imm |
从中可以看出,这两条指令在设置pc寄存器完成跳转功能之前,还将当前跳转指令的下一条指令地址保存在rd寄存器中,即rd←pc+4这条指令的含义。在RISC-V架构中,
通常使用ra寄存器(即x1寄存器)作为其中的rd对应的具体寄存器,因此在函数返回的时候,只需跳转回ra所保存的地址即可。事实上在函数返回的时候我们常常使用一条汇编伪指令(PseudoInstruction)跳转回调用之前的位置:ret。它会被汇编器翻译为jalr x0, 0(x1),含义为跳转到寄存器ra保存的物理地址,由于x0是一个恒为0的寄存器,在rd中保存这一步被省略。
由于每个CPU只有一套寄存器,我们若想在子函数调用前后保持函数调用上下文不变,就需要物理内存的帮助。确切的说,在调用子函数之前,我们需要在物理内存中的一个区域保存(Save)函数调用上下文中的寄存器;而在函数执行完毕后,我们会从内存中同样的区域读取并恢复(Restore)函数调用上下文中的寄存器。
实际上,这一工作是由子函数的调用者和被调用者(也就是子函数自身)合作完成。函数调用上下文中的寄存器被分为如下两类:
• 被调用者保存(Callee-Saved)寄存器:被调用的函数可能会覆盖这些寄存器,需要被调用的函数来保存的寄存器,即由被调用的函数来保证在调用前后,这些寄存器保持不变;
• 调用者保存(Caller-Saved)寄存器:被调用的函数可能会覆盖这些寄存器,需要发起调用的函数来保存的寄存器,即由发起调用的函数来保证在调用前后,这些寄存器保持不变。
调用规范(Calling Convention) 约定在某个指令集架构上,某种编程语言的函数调用如何实现。它包括了以下
内容:
剩下的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段的起始和终止地址。接下来我们只需遍历该地址区间并逐字节进行清零即可。
我们对 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!");
}