引言
非常简单的“Hello, world”应用程序,实际上有着多层硬件和软件工具和支撑环境隐藏在它背后,才让我们不必付出那么多努力就能够创造出功能强大的应用程序。生成应用程序二进制执行代码所依赖的是以 编译器 为主的开发环境;运行应用程序执行码所依赖的是以 操作系统 为主的执行环境。
这次本文章梳理文档,要在裸机上实现输出Hello,world!
代码树
./os/src
Rust 4 Files 119 Lines
Assembly 1 Files 11 Lines├── bootloader(内核依赖的运行在 M 特权级的 SBI 实现,本项目中我们使用 RustSBI)
│ ├── rustsbi-k210.bin(可运行在 k210 真实硬件平台上的预编译二进制版本)
│ └── rustsbi-qemu.bin(可运行在 qemu 虚拟机上的预编译二进制版本)
├── LICENSE
├── os(我们的内核实现放在 os 目录下)
│ ├── Cargo.toml(内核实现的一些配置文件)
│ ├── Makefile
│ └── src(所有内核的源代码放在 os/src 目录下)
│ ├── console.rs(将打印字符的 SBI 接口进一步封装实现更加强大的格式化输出)
│ ├── entry.asm(设置内核执行环境的的一段汇编代码)
│ ├── lang_items.rs(需要我们提供给 Rust 编译器的一些语义项,目前包含内核 panic 时的处理逻辑)
│ ├── linker-k210.ld(控制内核内存布局的链接脚本以使内核运行在 k210 真实硬件平台上)
│ ├── linker-qemu.ld(控制内核内存布局的链接脚本以使内核运行在 qemu 虚拟机上)
│ ├── main.rs(内核主函数)
│ └── sbi.rs(调用底层 SBI 实现提供的 SBI 接口)
├── README.md
├── rust-toolchain(控制整个项目的工具链版本)
└── tools(自动下载的将内核烧写到 k210 开发板上的工具)
├── kflash.py
├── LICENSE
├── package.json
├── README.rst
└── setup.py
平台与目标三元组
通过 目标三元组 (Target Triplet) 来描述一个目标平台。它一般包括 CPU 架构、CPU 厂商、操作系统和运行时库。
修改为riscv64gc-unknown-none-elf
交叉编译 (Cross Compile)
os/.cargo/config
[build]
target = "riscv64gc-unknown-none-elf"
移除标准库
在 main.rs 的开头加上一行 #![no_std] 来告诉 Rust 编译器不使用 Rust 标准库 std 转而使用核心库 core(core库不需要操作系统的支持)
提供panic_handler功能应对致命错误
在标准库 std 中提供了关于 panic! 宏的具体实现,其大致功能是打印出错位置和原因并杀死当前应用。但我们要实现的操作系统是不能使用还需依赖操作系统的标准库std,而更底层的核心库 core 中只有一个 panic! 宏的空壳,并没有提供 panic! 宏的精简实现。因此我们需要自己先实现一个简陋的 panic 处理
// os/src/lang_items.rs
use core::panic::PanicInfo;
#[panic_handler]
fn panic(_info: &PanicInfo) -> ! {
loop {
}
}
移除main函数
语言标准库和三方库作为应用程序的执行环境,需要负责在执行应用程序之前进行一些初始化工作,然后才跳转到应用程序的入口点(也就是跳转到我们编写的 main 函数)开始执行。
start
语义项代表了标准库 std 在执行应用程序之前需要进行的一些初始化工作。由于我们禁用了标准库,编译器也就找不到这项功能的实现了。
在 main.rs 的开头加入设置 #![no_main] 告诉编译器我们没有一般意义上的 main 函数,并将原来的 main 函数删除。在失去了 main 函数的情况下,编译器也就不需要完成所谓的初始化工作了。
编写内核第一条指令
.text.entry 区别于其他 .text 的目的在于我们想要确保该段被放置在相比任何其他代码段更低的地址上。这样,作为内核的入口点,这段指令才能被最先执行。
# os/src/entry.asm
.section .text.entry
.globl _start
_start:
li x1, 100
在 main.rs 中嵌入这段汇编代码,这样 Rust 编译器才能够注意到它,不然编译器会认为它是一个与项目无关的文件:
// os/src/main.rs
#![no_std]
#![no_main]
mod lang_item;
use core::arch::global_asm;
global_asm!(include_str!("entry.asm"));
调整内核的内存布局
通过 链接脚本 (Linker Script) 调整链接器的行为,使得最终生成的可执行文件的内存布局符合我们的预期。
第3 行定义了一个常量 BASE_ADDRESS 为 0x80200000 ,也就是我们之前提到的初始化代码被放置的地址;
从第 5 行开始体现了链接过程中对输入的目标文件的段的合并。
因为所有的段都从 BASE_ADDRESS 也即 0x80200000 开始放置,这就能够保证内核的第一条指令正好放在 0x80200000 从而能够正确对接到 Qemu 上。
UTPUT_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)
}
}
手动加载内核可执行文件
我们不能将其直接提交给 Qemu ,因为它除了实际会被用到的代码和数据段之外还有一些多余的元数据,这些元数据无法被 Qemu 在加载文件时利用,且会使代码和数据段被加载到错误的位置。
使用如下命令可以丢弃内核可执行文件中的元数据得到内核镜像:
rust-objcopy --strip-all target/riscv64gc-unknown-none-elf/release/os -O binary target/riscv64gc-unknown-none-elf/release/os.bin
分配并使用启动栈
分配启动栈空间,并在控制权被转交给 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
boot_stack:
.space 4096 * 16
.globl boot_stack_top
boot_stack_top:
控制权转交给 Rust 入口之前会执行两条指令,它们分别位于 entry.asm 的第 5、6 行。第 5 行我们将栈指针 sp 设置为先前分配的启动栈栈顶地址,这样 Rust 代码在进行函数调用和返回的时候就可以正常在启动栈上分配和回收栈帧了。第 6 行我们通过伪指令 call 调用 Rust 编写的内核入口点 rust_main 将控制权转交给 Rust 代码。
在 rust_main 函数的开场白中,我们将第一次在栈上分配栈帧并保存函数调用上下文,它也是内核运行全程最深的栈帧。
我们顺便完成对 .bss 段的清零。这是内核很重要的一部分初始化工作,在使用任何被分配到 .bss 段的全局变量之前我们需要确保 .bss 段已被清零。我们就在 rust_main 的开头完成这一工作,由于控制权已经被转交给 Rust ,我们终于不用手写汇编代码而是可以用 Rust 来实现这一功能了:
// os/src/main.rs
#[no_mangle]
pub fn rust_main() -> ! {
clear_bss();
loop {
}
}
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) }
});
}
使用 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
}
服务 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!("It should shutdown!");
}
实现格式化输出
console_putchar 的功能过于受限,如果想打印一行 Hello world! 的话需要进行多次调用。自己编写基于 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)+)?));
}
}
处理致命错误
借助前面实现的 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()
}
最终测试
参考:rCore-Tutorial文档