第一节 内核启动

各种操作系统内核在最初启动时,通常都需要完成以下的几项工作:

  1. 清零BSS区域
  2. 保存一些必要的信息
  3. 启用内存分页
  4. 设置中断/异常向量表
  5. 建立栈以支持函数调用
  6. ... ...

每一项初始工作都具有特定的作用,并且与后续的工作相关。

这一节作为起步阶段,首先完成第 1 步和第 5 步,其它工作项暂时忽略,我们将在扩展对应的内核功能时,再回头进行补充。

第1步 - 清零 BSS 区域

BSS 是构成内核 Image 的一个特殊的数据区,编译器会把程序中未显式初始化的全局变量放到这个区域,这些全局变量默认值都应当是零。但是编译器本身并不执行 BSS 清零工作,这个通常是 ELF 加载器的工作。对于我们正在开发的内核,既然没有其它程序替我们完成,那就只好自己对 BSS 区域清零。

第5步 - 建立栈以支持函数调用

建立栈支持函数调用之后,我们就可以脱离晦涩的汇编语言,进入 Rust 的编程世界,利用 Rust 高级语言提供的各种先进特性实现内核功能。作为 Unikernel 内核,应用与内核都会使用这同一个栈,所以我们直接预分配 256K 的大栈空间,减少将来因栈溢出而带来的困扰。

现在我们就在 axorigin 中引入引导模块 boot,实现上面的两步:

// axorigin/src/boot.rs
#[no_mangle]
#[link_section = ".text.boot"]
unsafe extern "C" fn _start() -> ! {
    // a0 = hartid
    // a1 = dtb
    core::arch::asm!("
        la a3, _sbss
        la a4, _ebss
        ble a4, a3, 2f
1:
        sd zero, (a3)
        add a3, a3, 8
        blt a3, a4, 1b
2:

        la      sp, boot_stack_top      // setup boot stack

        la      a2, {entry}
        jalr    a2                      // call rust_entry(hartid, dtb)
        j       .",
        entry = sym super::rust_entry,
        options(noreturn),
    )
}

从 _start 入口 开始,上面这段汇编代码分为三个部分:

第 8~15 行:实现了 BSS 清零,BSS 区域的起止地址是 _sbss 和 _ebss,都定义在 linker.lds 中的 .bss 段中,符号可以被汇编直接引用。

bss段截图

第 17 行:初始化寄存器 sp,指向栈空间的最高地址 - 即栈底位置 boot_stack_top,该符号同样是定义在 linker.lds 文件的 .bss 中。如上图,在 .bss 段的开头,预留了 256K 的空间,并且 boot_stack_top 是空间的最高地址。另外,需要注意的是,这段栈空间并包含在由 _sbss 和 _ebss 标记的范围内,因为栈不需要预先初始化。

第 25 和 26 行:在栈准备好的情况下,首次进入 Rust 函数 rust_entry。

先通过 la 指令取得 rust_entry 的入口地址,然后通过 jalr 调用该地址。按照 RiscV 规范,a0 到 a7 寄存器分别作为函数调用的前 8 个参数,当下只有 a0 和 a1 是有效的。这两个寄存器由上一级引导程序 SBI 设置,a0 保存了 HartID,也就是当前 cpu 核的硬件 ID;a1 则指向了一块内存区,其中保存的是描述底层平台硬件信息的设备树,即 dtb。

从上面的那段汇编代码可以看出,内核从启动到调用 Rust 入口函数过程中,没有使用过 a0 和 a1。如果在这个过程中必须使用它们,就必须先暂存它们的值,然后在调用 rust_entry 前恢复回来。这个就是本节开始时,提到的第 2 项任务,将来会看到这种处理的必要性。

最后,看看函数 rust_entry 的定义:

// axorigin/src/main.rs
#![no_std]
#![no_main]
#![feature(asm_const)]

mod lang_items;
mod boot;

unsafe extern "C" fn rust_entry(_hartid: usize, _dtb: usize) {
    core::arch::asm!(
        "wfi",
        options(noreturn)
    )
}

目前 rust_entry 还干不了任何事,所以仍然调用 wfi 指令。注意它的两个参数 _hartid_dtb,就是通过 a0 和 a1 寄存器传入的,暂时还用不到它们,但在将来它们会发挥重要作用。

make run 编译并执行,然后打开 qemu.log 并跳到末尾,查看从 0x80200000 开始直到 0x80200034 这段范围执行的指令序列,确认它们与我们编写的汇编代码等效。

在 qemu.log 中看到的指令,在形式上与我们编写的汇编指令有一点出入。 主要是因为我们用了一些伪指令,大家可通过 RiscV ASM 手册,查阅这些伪指令与实际指令的对应关系。