最小化内核
本章代码对应分支:min-kernel
概要
本章我们将把上一章创建的 独立可执行程序 编译为内核,并和 bootloader 链接成为可以被 qemu 加载的 bootimage 。为此我们将介绍:
- 使用 目标三元组 描述目标操作系统。
- 使用 cargo xbuild 和 目标三元组 编译内核。
- 将 内核 和 bootloader 链接成 bootimage 。
- 修改 _start ,使其能够对堆栈进行一些简单的初始化。
建立编译目标三元组和 linker.ld
cargo 在编译内核时,可以用过 --target <target triple>
支持不同的系统。 target triple 包含:cpu 架构、供应商、操作系统和 ABI 。
由于我们在编写自己的操作系统,所以所有目前的 目标三元组 都不适用。幸运的是,rust 允许我们用 JSON 文件定义自己的 目标三元组 。
首先我们来看一下 x86_64-unknown-linux-gnu 的 JSON 文件:
{
"llvm-target": "x86_64-unknown-linux-gnu",
"data-layout": "e-m:e-i64:64-f80:128-n8:16:32:64-S128",
"arch": "x86_64",
"target-endian": "little",
"target-pointer-width": "64",
"target-c-int-width": "32",
"os": "linux",
"executables": true,
"linker-flavor": "gcc",
"pre-link-args": ["-m64"],
"morestack": false
}
因为我们的主要目的是编写 os ,所以这里直接给出目标文件的实现:
// in riscv32-os.json
{
"llvm-target": "riscv32",
"data-layout": "e-m:e-p:32:32-i64:64-n32-S128",
"target-endian": "little",
"target-pointer-width": "32",
"target-c-int-width": "32",
"os": "none",
"arch": "riscv32",
"cpu": "generic-rv32",
"features": "+m,+a,+c",
"max-atomic-width": "32",
"linker": "rust-lld",
"linker-flavor": "ld.lld",
"pre-link-args": {
"ld.lld": ["-Tsrc/boot/linker.ld"]
},
"executables": true,
"panic-strategy": "abort",
"relocation-model": "static",
"eliminate-frame-pointer": false
}
对文件各参数细节感兴趣的读者可以自行研究,这里只对 pre-link-args 进行解释:
"pre-link-args": {
"ld.lld": ["-Tsrc/boot/linker.ld"]
},
这里我们需要使用指定的链接器,这里同样直接给出 linker.ld 的实现,请自行创建好 src/boot/linker.ld 文件:
/* Copy from bbl-ucore : https://ring00.github.io/bbl-ucore */
/* Simple linker script for the ucore kernel.
See the GNU ld 'info' manual ("info ld") to learn the syntax. */
OUTPUT_ARCH(riscv)
ENTRY(_start)
BASE_ADDRESS = 0x80400000;
SECTIONS
{
/* Load the kernel at this address: "." means the current address */
. = BASE_ADDRESS;
start = .;
.text : {
stext = .;
*(.text.entry)
*(.text .text.*)
. = ALIGN(4K);
etext = .;
}
.rodata : {
srodata = .;
*(.rodata .rodata.*)
. = ALIGN(4K);
erodata = .;
}
.data : {
sdata = .;
*(.data .data.*)
edata = .;
}
.stack : {
*(.bss.stack)
}
.bss : {
sbss = .;
*(.bss .bss.*)
ebss = .;
}
PROVIDE(end = .);
}
运行 cargo build --target riscv32-os.json
,发现编译失败了:
error[E0463]: can't find crate for `core`
错误的原因是:no_std 的程序会隐式地链接到 rust 的 core 库 。 core 库 包含基础的 Rust 类型,如 Result、Option 和迭代器等。Rust 工具链中默认只为原生的目标三元组提供了预编译好的 core 库,而我们在编写 os 时使用的是自定义的目标三元组 。
因此我们需要为这些目标重新编译整个 core 库 。这时我们就需要 cargo xbuild 。
使用 Cargo xbuild 重新编译 core 库
这个工具封装了 cargo build 。同时,它将自动交叉编译 core 库 和一些 编译器内建库(compiler built-in libraries) 。我们可以用下面的命令安装它:
cargo install cargo-xbuild
现在运行命令来编译目标程序:
cargo xbuild --target riscv32-os.json
但我们发现产生了编译错误:
error: The sysroot can't be built for the Stable channel. Switch to nightly.
note: run with `RUST_BACKTRACE=1` for a backtrace
使用 nightly rust toolchains
这个错误是由于没有使用 nightly 版本的工具链。相比较于 stable rust(default) ,nightly rust 可以使用一些 unsafe 的功能,这在编写 os 时是不可避免的。为此需要在项目目录下建立一个 rust-toolchain
文件,文件内容是工具链的版本信息:
nightly-2019-09-02
也可以直接切换 toolchain
这样,在后续编译 rust 程序时,将采用上述版本的 rust 工具链。重新执行 cargo xbuild --target riscv32-os.json
,发现我们的内核已经可以正确编译了。接下来的任务就是将他和 bootloader 链接,得到可以被 qemu 加载的 os 。
检查一下编译出来的内核镜像
$ cargo xbuild --target riscv32-os.json
# 编译成功后, 执行如下命令可以查看内核信息
$ file target/riscv32-os/debug/os
target/riscv32-os/debug/os: ELF 32-bit LSB executable, UCB RISC-V, version 1 (SYSV), statically linked, with debug_info, not stripped
使用 bootloader 创建引导镜像
编写一个 bootloader 并不是一个简单的事情,所以我们直接使用已有的 bootloader:OpenSBI。
我们从它的 GitHub 上下载最新的 预编译版本(2019 年 7 月的最新版是 v0.4),解压后将其中的 platform/qemu/virt/firmware/fw_jump.elf
复制为 os/opensbi/virt.elf
。
有了 bootloader ,接下来将其与我们的内核链接成 引导映像 就可以了。为了以后能够方便的进行编译链接,我们编写一个 Makefile
文件(与 Cargo.toml
位于同级目录):
target := riscv32-os
mode := debug
kernel := target/$(target)/$(mode)/os
bin := target/$(target)/$(mode)/kernel.bin
usr_path := usr
export SFSIMG = $(usr_path)/rcore32.img
.PHONY: all clean run build asm qemu kernel
all: build
build: $(bin)
run: build qemu
kernel:
@cargo xbuild --target riscv32-os.json
$(bin): kernel
@riscv64-unknown-elf-objcopy $(kernel) --strip-all -O binary $@
asm:
@riscv64-unknown-elf-objdump -d $(kernel) | less
qemu:
@qemu-system-riscv32 -nographic -machine virt \
-kernel opensbi/virt.elf \
-device loader,file=$(bin),addr=0x80400000
这里我们还需要安装 RISC-V GCC 工具链,可以使用 SiFive 公司提供的 预编译版本。使用方式可参考 how to use "Prebuilt RISC‑V GCC Toolchain"
执行 make kernel
生成的 kernel.bin
就是我们需要的 可以被 bootloader 引导的 os 二进制文件 。
执行 make run
,可以看到 bootloader 输出的信息:
OpenSBI v0.4 (Jul 2 2019 11:54:59)
____ _____ ____ _____
/ __ \ / ____| _ \_ _|
| | | |_ __ ___ _ __ | (___ | |_) || |
| | | | '_ \ / _ \ '_ \ \___ \| _ < | |
| |__| | |_) | __/ | | |____) | |_) || |_
\____/| .__/ \___|_| |_|_____/|____/_____|
| |
|_|
Platform Name : QEMU Virt Machine
Platform HART Features : RV32ACDFIMSU
Platform Max HARTs : 8
Current Hart : 0
Firmware Base : 0x80000000
Firmware Size : 108 KB
Runtime SBI Version : 0.1
PMP0: 0x80000000-0x8001ffff (A)
PMP1: 0x00000000-0xffffffff (A,R,W,X)
至此,我们的 最小内核 已经“成功”跑起来了!!!吗???
退出 qemu 的方法是,按下 Ctrl+A 之后再按 X 即可。当然你也可以直接杀掉 qemu 进程
killall qemu-system-riscv32
适配 SBI
SBI 是 Supervisor Binary Interface 的简称,OpenSBI 除了作为 bootloader 还实现了 RISCV SBI(即部分 syscall),比如打印、读取字符等(本质上已经悄悄的实现了一个中断处理机制)。
暂时看不懂就先抄代码,后面会专门讲解 syscall ,可以理解为我们需要 OpenSBI 帮我们进行输出字符的工作。更多内容请参考 官方文档
为了便于使用,我们将 syscall 包一层,将其写为函数形式:
#![allow(dead_code)]
#[inline(always)]
fn sbi_call(which: usize, arg0: usize, arg1: usize, arg2: usize) -> usize {
let ret;
unsafe {
asm!("ecall"
: "={x10}" (ret)
: "{x10}" (arg0), "{x11}" (arg1), "{x12}" (arg2), "{x17}" (which)
: "memory"
: "volatile");
}
ret
}
pub fn console_putchar(ch: usize) {
sbi_call(SBI_CONSOLE_PUTCHAR, ch, 0, 0);
}
pub fn console_getchar() -> usize {
sbi_call(SBI_CONSOLE_GETCHAR, 0, 0, 0)
}
pub fn shutdown() {
sbi_call(SBI_SHUTDOWN, 0, 0, 0);
}
pub fn set_timer(stime_value: u64) {
#[cfg(target_pointer_width = "32")]
sbi_call(
SBI_SET_TIMER,
stime_value as usize,
(stime_value >> 32) as usize,
0,
);
#[cfg(target_pointer_width = "64")]
sbi_call(SBI_SET_TIMER, stime_value as usize, 0, 0);
}
pub fn clear_ipi() {
sbi_call(SBI_CLEAR_IPI, 0, 0, 0);
}
pub fn send_ipi(hart_mask: usize) {
sbi_call(SBI_SEND_IPI, &hart_mask as *const _ as usize, 0, 0);
}
pub fn remote_fence_i(hart_mask: usize) {
sbi_call(SBI_REMOTE_FENCE_I, &hart_mask as *const _ as usize, 0, 0);
}
pub fn remote_sfence_vma(hart_mask: usize, _start: usize, _size: usize) {
sbi_call(SBI_REMOTE_SFENCE_VMA, &hart_mask as *const _ as usize, 0, 0);
}
pub fn remote_sfence_vma_asid(hart_mask: usize, _start: usize, _size: usize, _asid: usize) {
sbi_call(
SBI_REMOTE_SFENCE_VMA_ASID,
&hart_mask as *const _ as usize,
0,
0,
);
}
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;
打印字符
直接在 _start
里进行过多的操作并不是一件优雅的事情,而把所有代码都堆在 main 文件里更不好,所以趁着我们的文件还不多,先把他们整理一下吧:
main.rs
#![no_std] // don't link the Rust standard library
#![no_main] // disable all Rust-level entry points
#[allow(unused_imports)]
use os;
boot/extry.asm
我们通过汇编代码来实现 _start
。由于我们使用了 OpenSBI ,所以此时 sp 被设置为指向 M 态内存空间。这里 _start
只做了两件事:
- 开辟一段新的内存空间作为
rust_main
的运行的堆栈,并使 sp 指向它 - 跳转到
rust_main
.section .text.entry
.globl _start
_start:
lui sp, %hi(bootstacktop) # 将栈指针 sp 置为栈顶地址
call rust_main
.section .bss.stack
.align 12 # PGSHIFT
.global bootstack
bootstack:
.space 4096 * 4 # 开辟一块栈空间(4个页)
.global bootstacktop
bootstacktop:
init.rs
use crate::sbi;
global_asm!(include_str!("boot/entry.asm")); // 引入 _start
static HELLO: &[u8] = b"Hello World!";
#[no_mangle]
pub fn rust_main() -> ! {
for &c in HELLO {
sbi::console_putchar(c as usize);
}
loop {}
}
lang_items.rs
use core::panic::PanicInfo;
#[panic_handler]
fn panic(_info: &PanicInfo) -> ! {
loop {}
}
#[no_mangle]
pub extern "C" fn abort() {
panic!("abort!");
}
lib.rs
#![no_std]
#![feature(asm)]
#![feature(global_asm)]
mod init;
mod lang_items;
mod sbi;
那么,接下来,就是见证奇迹的时刻:
> make run
...
Hello World!
以后若无特殊说明,编译运行的命令就是
make run
预告
最黑暗的日子已经过去,我们已经完成了一个可以正常运行的 最小内核 !下一章我们将在此基础上,实现 rust 中最常用的宏: println!
,用于后续的调试和输出。