跳转到内容

最简的操作系统内核

从本章开始,我们将正式进入操作系统的实验部分. 在本章中,我们将分析实验源码,理清最小化 OS 内核和批处理系统的工作原理.

拉取我们的实验代码:

git clone https://github.com/USTB-806/ustb-os-kernel.git
git checkout lab2

如果你对于手册某些具体内容的实现感到疑惑,我们建议你可以通过阅读 commits 来查看我们的更改.

一个基础的概念是,我们在学习操作系统的过程中会大量讨论内核态与用户态. 正如你在课上所学的,执行一个程序的时候,我们就在内核态与用户态之间不断进行上下文切换. 在完成实验的过程中我们会逐步加深对其的理解. 此处我们先讨论一个最简单的用户态程序.

首先我们需要学习一下 QEMU 是什么,QEMU 是一种模拟器,拥有以下模式:

  • User mode:又称作“用户模式”,在这种模式下,QEMU 运行针对不同指令编译的单个 Linux 或 MacOS 程序. 系统调用与 32/64 位接口适应. 在这种模式下,我们可以实现交叉编译 (cross-compilation) 与交叉侦错 (cross-debugging).
  • System mode:“系统模式”,在这种模式下,QEMU模拟一个完整的计算机系统,包括外部设备. 它可以用于在一台计算机上提供多台虚拟计算机的虚拟主机. QEMU可以实现许多客户机 OS 的引导,比如 x86, MIPS, 32-bit ARMv7 等等.

比如我们使用 qemu-riscv64 <user_file> 就可以运行 64 位的 RISC-V 架构的 <user_file> 文件,注意这里执行的是用户程序. 同理 qemu-system-riscv64 <os_kernel> 用来启动我们的内核,执行的是内核态的程序.

在学习 C 或 C++ 语言的过程中,我们都知道 main() 函数是程序执行开始的入口,通过前面的学习你已经知道在 Rust 语言中也是如此. 但是你有没有想过不依赖 main() 函数进行执行呢?答案是肯定的.

严格来说,main 只是高级语言层面的“约定入口”. 真正被 CPU 跳转到的入口地址来自可执行文件头 (例如 ELF 的入口地址字段) 以及链接脚本中的 ENTRY 设置.

操作系统内核处于硬件与应用程序之间的底层环境 (裸机),而不是基于现有的操作系统来支撑标准库中的线程、文件 I/O、堆内存管理等抽象功能. 所以对内核这类没有标准库 no_std 程序而言,我们通常不走语言运行时 (runtime) 的标准启动流程,而是直接定义一个裸入口 _start

  • 它是链接器和启动汇编约定使用的符号.
  • 它负责完成最早期初始化 (栈、.bss 清零、跳转 Rust 主逻辑等).
  • 它不依赖标准库,因此可用于裸机执行环境.

现在你知道了 Rust 编译器要找的入口函数是 _start() ,于是我们可以在 main.rs 中添加如下内容.

// kernel/src/main.rs
#[no_mangle]
extern "C" fn _start() {
    loop{};
}

这里的 extern "C" 起到 FFI 的作用,将 Rust 的 ABI 转换为 C 的 ABI. 采用 C ABI 约定,以便于与汇编入口和链接器符号对接. 你可以理解为我们显式导出 _start 这个符号,当控制流跳转到该符号时,就会执行 _start() { ... } 中的逻辑.

注意,如果要这么写,需要添加 #![no_main],告诉编译器不要生成默认入口. #[no_mangle] 则用于禁止符号名改写,确保最终产物里确实存在名为 _start 的符号,供链接器与启动代码准确定位.

这里简单写一个死循环 loop{},我们执行一下,发现:

$ qemu-riscv64 target/riscv64gc-unknown-none-elf/debug/kernel
  段错误 (核心已转储)

出错了,这是因为目前的执行环境还缺了一个退出机制. 此处我们暂时不加解释地给出解决方案,在之后的篇章中我们将更详细讨论这个问题:

// kernel/src/main.rs
pub fn syscall(id: usize, args: [usize; 3]) -> isize {
    let mut ret: isize;
    unsafe {
        core::arch::asm!(
            "ecall",
            inlateout("x10") args[0] => ret,
            in("x11") args[1],
            in("x12") args[2],
            in("x17") id
        );
    }
    ret
}

pub fn sys_exit(xstate: i32) -> isize {
    syscall(SYSCALL_EXIT, [xstate as usize, 0, 0])
}

#[no_mangle]
extern "C" fn _start() {
    sys_exit(9); // sigkill
}

即可使用.

$ qemu-riscv64 target/riscv64gc-unknown-none-elf/debug/kernel; echo $?
9

$? 表示执行程序的退出码.

正如我们在操作系统理论课上学到的,操作系统内核支持其上的用户态程序时,本质上是通过系统调用 (syscall) 提供服务。这里我们实现的是 sys_exit.

这里也可以顺便回答一个常见疑问:return 0sys_exit(0) 在“效果”上通常等价,因为语言运行时最终也会调用退出系统调用;但在裸机/无标准运行时场景中,我们往往需要自己显式完成这一步.

查看 Makefile,可以看到我们执行 qemu-system-riscv64 来启动我们的 ${KERNEL}

# kernel/Makefile
QEMU_EXEC = qemu-system-riscv64 -machine virt \
			-kernel ${KERNEL} \
			-nographic \
			-smp 1 \
			-bios default

我们此处不打算展开所有参数细节,在早期学习过程中不必一次性掌握太多. 若你想深入,可查阅 QEMU 官方文档了解每个参数的含义.

其中最关键的是:-kernel 指定内核镜像,-machine virt 选择 QEMU 提供的 RISC-V 虚拟开发板模型,-nographic 让串口输出直接显示在当前终端.

现在你很好奇,打开 main.rs 看了一眼,但是你看到的是:

fn main() {
    clear_bss();
    println!("Hello, world!");
    panic!("Shutdown machine!");
}

如你所见,并不是 _start,接下来我们将讨论这个看似简单的启动过程发生了什么.

回忆你在计算机系统基础学习的知识,我们可以通过 链接脚本 (Linker Script) 调整链接器的行为,使得最终生成的输出文件的内存布局符合我们的预期. 我们需要这么做的原因是,平时我们都使用操作系统帮我们管理好的内存布局,所以大部分情况下我们不需要考虑 .data, .text 这些段是怎么排布的. 但我们在开发操作系统内核时就需要自己管理这些部分.

我们修改 Cargo 的配置文件来使用我们自己的链接脚本 kernel/src/linker.ld 而非使用默认的内存布局.

# kernel/.cargo/config.toml
[build]
target = "riscv64gc-unknown-none-elf"

[target.riscv64gc-unknown-none-elf]
rustflags = [
    "-Clink-arg=-Tsrc/linker.ld", "-Cforce-frame-pointers=yes"
]

具体的链接脚本 kernel/src/linker.ld 如下.

OUTPUT_ARCH(riscv)
ENTRY(_start)
BASE_ADDRESS = 0x80200000;

我们设置了目标平台为 RISC-V,整个程序的入口点为之前定义的全局符号 _start;之后定义了一个常量 BASE_ADDRESS0x80200000.

SECTIONS
{
    . = BASE_ADDRESS;
    skernel = .;

    stext = .;
    .text : {
        *(.text.entry)
        *(.text .text.*)
    }

    . = ALIGN(4K);
    etext = .;
    srodata = .;
    .rodata : {
        *(.rodata .rodata.*)
    }

    . = ALIGN(4K);
    erodata = .;
    sdata = .;
    .data : {
        *(.data .data.*)
    }

    . = ALIGN(4K);
    edata = .;
    .bss : {
        *(.bss.stack)
        sbss = .;
        *(.bss .bss.*)
    }

    . = ALIGN(4K);
    ebss = .;
    ekernel = .;

    /DISCARD/ : {
        *(.eh_frame)
    }
}

冒号前面表示最终生成的可执行文件的一个段的名字,花括号内按照放置顺序将所有输入目标文件的哪些段放在这个段中。符号 . 表示当前地址,同时也是当前链接脚本要管理的地址,链接器会从它指向的位置开始,往下放置一些内容。也就是说,设置当前的地址为 ADDRESS,从输入的目标文件中,收集来的段。我们可以对 . 进行赋值来调整接下来的段放在哪里,也可以创建一些全局符号赋值为 . 从而记录这一时刻的位置。

. = ALIGN(4K);
edata = .;
.bss : {
    *(.bss.stack)
    sbss = .;
    *(.bss .bss.*)
}

.bss.stack 被放入到可执行文件中的 .bss 段中的低地址中,在后面虽然有一个通配符 .bss.* ,但是由于链接脚本的优先匹配规则它并不会被匹配到后面去. 即地址区间 [sbss,ebss) 并不包括栈空间.

因此,最终的合并结果是,在最终可执行文件中各个常见的段 .text, .rodata .data, .bss 从低地址到高地址按顺序放置,每个段里面都包括了所有输入目标文件的同名段, 且每个段都有两个全局符号给出了它的开始和结束地址 (比如 .text 段的开始和结束地址分别是 stextetext ).

ALIGN 用于实现地址对齐. 对齐的主要收益是:

  • 满足硬件或 ABI 对某些数据/段边界的要求;
  • 减少跨页/跨缓存行访问带来的额外开销;
  • 让页表映射、段权限管理等后续机制更规整.

对链接脚本可以查询官方文档.

除此之外我们还需要设置栈空间.

如果不先设置栈空间,函数调用、局部变量、保存返回地址等操作都无法正确工作,因为它们依赖 sp 指向一段有效内存。我们需要在启动阶段分配一段栈,并把 sp 设置到栈顶。注意,RISC-V 的栈从高地址向低地址增长.

// kernel/src/boot.rs
global_asm!(include_str!("link_app.S"));

global_asm!(
    r#"
    .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 {boot_stack_size}
    .globl boot_stack_top
boot_stack_top:
    "#,
    boot_stack_size = const BOOT_STACK_SIZE,
    rust_main = sym super::main,
);

_start 的这一段汇编代码放在 .text 段的 .entry 处(回忆之前我们的链接脚本),boot_stack_lower_boundboot_stack_top 被放在 .bss 段的 .stack 处.

栈空间的栈顶地址被全局符号 boot_stack_top 标识,栈底则被全局符号 boot_stack_lower_bound 标识.

当我们使用 mod boot 时,就会触发 global_asm! 嵌入全局汇编代码.

一般的应用程序的 .bss 段,在程序开始运行之前会被执环境(系统库或操作系统内核)固定初始化为零. 在 ELF 文件中,为了节省磁盘空间,只会记录 .bss 段的位置. 且应用程序的假定在它执行前,其 .bss段 的数据内容都已全是0.

在裸机内核中,这一步通常需要我们自己完成:启动后尽早把 .bss 全部清零. 否则,未初始化静态变量会带着随机值运行,导致非常隐蔽的错误.

// kernel/src/main.rs
pub fn clear_bss() {
    unsafe extern "C" {
        unsafe fn sbss();
        unsafe fn ebss();
    }
    (sbss as usize..ebss as usize).for_each(|a| unsafe { (a as *mut u8).write_volatile(0) });
}

在程序内自己进行清零的时候,我们就不用去解析 ELF,而是通过链接脚本 linker.ld 中给出的全局符号 sbssebss 来确定 .bss 段的位置.

在前面的启动阶段,我们已经完成了入口、栈和 .bss 的初始化. 接下来要解决一个实际问题:如何在没有标准输出设备驱动的情况下打印信息.

RISC-V 定义了这三种特权模式 (实际上是四种,此处我们介绍三种),构成其安全和操作系统支持的框架:M-mode(Machine,机器模式)是最高权限,直接控制硬件;S-mode(Supervisor,监督模式)用于运行操作系统内核;U-mode(User,用户模式)用于运行应用程序.

这里需要引入 SBI (Supervisor Binary Interface). 它是 S 模式内核与更高特权级执行环境 (通常是 M 模式的 OpenSBI) 之间的二进制接口. 内核可以通过 SBI 请求底层服务,例如字符输出、关机等.

在这套分层里,应用程序通过 syscall 向内核请求服务;而内核通过 SBI 向 OpenSBI 请求更底层服务. 两者都可能使用 ecall 指令触发陷入,但语义和服务提供者不同:

  • U -> S:应用程序调用内核 ABI;
  • S -> M:内核调用 SBI.

本实验使用的是 QEMU 自带的 OpenSBI. OpenSBI 运行在 M 模式,内核运行在 S 模式,因此内核执行 SBI ecall 后会进入更高特权级执行,完成请求后再返回.

// kernel/src/utils/sbi.rs
fn sbi_call(which: usize, arg0: usize, arg1: usize, arg2: usize) -> usize {
    let mut ret;
    unsafe {
        asm!(
            "ecall",     // sbi call
            inlateout("x10") arg0 => ret, // sbi call arg0 and return value
            in("x11") arg1, // sbi call arg1
            in("x12") arg2, // sbi call arg2
            in("x16") 0, // for sbi call id args need 2 reg (x16, x17)
            in("x17") which,// sbi call id
        );
    }
    ret
}
// kernel/src/utils/console.rs
use crate::utils::sbi::console_putchar;

struct Stdout;

impl Write for Stdout {
    /// write str to console
    fn write_str(&mut self, s: &str) -> fmt::Result {
        for c in s.chars() {
            console_putchar(c as usize);
        }
        Ok(())
    }
}

我们通过调用 SBI 逐步实现了 Hello, world! 的输出.

从实现路径看,println! 最终会走到 core::fmt::Writewrite_str,而 write_str 再逐字符调用 console_putchar,后者通过 SBI 把字符送到串口设备. 也就是说,println! 依赖了我们自己搭建的格式化输出链路.