简介:这是一份与《RISC-V体系结构编程与实践》第二章配套的实验代码包,面向嵌入式开发者、RTOS工程师及操作系统学习者,目标是借助MySBI启动接口与BenOS微型内核,打通RISC-V底层启动、中断处理与内核调度等关键实践环节。压缩包共17个文件,体积仅7KB,包含sbi_main.c、boot.S、kernel.c等C/汇编源码,以及用于链接的ld脚本、头文件和Makefile,还有riscv64-benos_defconfig内核配置文件,结构精炼,适合直接对照书本章节进行编译与调试。目前已有304人学习下载。通过研读并运行这些代码,可以直观理解MySBI如何完成硬件初始化和系统调用分发,看清BenOS中任务调度、内存管理、中断服务等核心机制的落地实现,从而将RISC-V架构与操作系统原理真正串成一条可实践的技能链路。 去年年底我把一套实验代码翻出来重构了一遍,就是标题里这套“MySBI与BenOS实验代码”。说白了,它是在 RISC-V 64 位环境下从零写的一个最小化 SBI 实现(MySBI),外加一个跑在它上面的微型操作系统内核(BenOS)。SBI 全称 Supervisor Binary Interface,是 RISC-V 架构里 M 态(机器态)和 S 态(监管者态)之间的约定层,类似 x86 世界里 BIOS/UEFI 和操作系统之间的接口,但比 BIOS 简单、干净得多。这篇文章就把这套代码的核心思路、实现细节和踩坑过程完整拆开讲一遍,适合对操作系统启动流程、RISC-V 特权级切换、裸机编程感兴趣的开发者参考,哪怕你之前只写过用户态程序,跟着走一遍也能把整个启动链路串明白。
1. 内容整体设计与思路拆解
1.1 为什么需要 SBI:从裸机到 OS 的“第一公里”
很多初次接触 RISC-V 底层开发的人会困惑:我写了一个内核,为什么不能直接扔到 QEMU 里跑?这里的关键在于特权级设计。RISC-V 规定 S 态的操作系统不能直接访问 M 态的 CSR(控制和状态寄存器),比如mstatus、mepc、mtvec这些。但开机时 CPU 默认跑在 M 态,内存初始化、时钟配置、控制台输出这些基础能力都留在 M 态。没有 SBI,内核连往屏幕上打一个字符都做不到,因为串口寄存器往往被映射在 M 态才能安全访问的地址空间里。
MySBI 的作用就是充当这个“翻译官”。它跑在 M 态,初始化硬件环境,然后降级到 S 态把控制权交给 BenOS。之后 BenOS 每次想干特权操作,比如关中断、读时间、输出字符,就通过ecall指令陷入 M 态,由 MySBI 代为完成。这种架构把硬件相关代码和内核逻辑彻底隔离,也让同一份内核可以在不同平台间迁移——只要每个平台提供一个符合规范的 SBI 就行。RustSBI、OpenSBI 都是这么做的,MySBI 是这个思路的最小化复刻,整个实现不到 500 行汇编加 C。
1.2 方案选型:为什么全套走“最小可用”路线
我在这套实验代码里刻意做了一堆减法。首先是语言选择:MySBI 全部用汇编和少量 C 实现,关键路径(trap 入口、上下文切换)用汇编,初始化逻辑用 C。原因是 trap 处理需要精确控制栈指针和寄存器保存顺序,汇编最直白;而 C 代码可读性好,适合写初始化设备、解析启动参数这类逻辑。内核侧 BenOS 同样保持极简,只实现了中断开启、定时器、串口输出和进程调度雏形,运行级别拿到 32MB 内存,直接做线性映射。
另一个关键决策是面向 QEMUvirt机器做适配。QEMU 的 virt 平台是 RISC-V 开发的事实标准,调试方便,支持-nographic模式把串口输出重定向到终端,还能用 GDB 直接连上去单步调试。选择它意味着我不需要考虑真实开发板上的 DDR 初始化、Flash 烧录这些事,能聚焦在特权级切换和 SBI 规范本身。这对实验性质的项目来说是最优解:五分钟就能跑起来,十分钟就能用 GDB 看清每次 trap 的进出。
提示:如果你手头有真实 RISC-V 开发板,比如全志 D1 或者 SiFive 的 HiFive 系列,这套代码的 SBI 部分稍作修改(主要是内存基址和串口地址)也能跑。但第一步务必先在 QEMU 上把逻辑跑通,真实硬件的问题排查成本至少翻三倍。
2. 核心细节解析:MySBI 的 trap 入口与上下文切换
2.1 第一次进入 M 态:启动汇编到底干了什么
MySBI 的入口在start.S,CPU 上电后从 0x80000000 开始执行(QEMU virt 平台把复位向量指向这里)。这段汇编只做三件事:设置栈指针、清零 BSS 段、跳转到 C 代码的sbi_main()。但细看每一行都有讲究。
.section .text.init .globl _start _start: csrr a0, mhartid li t0, 1 bgeu a0, t0, .Lmhartid_ok csrr a1, mscratch bnez a1, .Linit_done la sp, _stack_top call sbi_main第 1 条csrr a0, mhartid读取当前硬件线程 ID,QEMU 默认只开了一个核,所以这里拿到的值必然是 0。但代码里依然做了多核对齐的检查,目的有二:一是防止在配置了多核的 QEMU 参数下执行出错,二是展示一个规范 SBI 应有的姿态——它要能识别主核和从核,只让主核走完整初始化。mscratch这个 CSR 后续会被用作 trap 时保存上下文指针的临时存储,在启动早期先检查它是否为 0,是为了区分是冷启动还是 warm reset,这个细节在调真实板子的时候很重要。
2.2 trap 分发程序:一进一出,寄存器必须完整
trap 处理是整个 SBI 的核心,也是最容易写崩的地方。无论 CPU 收到中断还是异常,硬件会自动跳转到mtvec指向的地址,同时保存mepc(发生 trap 时的指令地址)和mcause(trap 原因)。但其余 31 个通用寄存器硬件一概不管,必须由软件自己保存,所以“进入 trap 时把全部寄存器压栈、退出时原样恢复”就成了写这个程序的铁律。
我实现的trap_entry做了三件事:
- 通过
csrrw sp, mscratch, sp交换栈指针,把真实栈顶存进mscratch,同时从mscratch拿出预先存好的上下文结构指针作为临时栈。 - 把所有通用寄存器压到临时栈上,保存
mstatus、mepc、mcause、mtval到固定偏移量。 - 把
sp指向的上下文结构体的地址放入a0,调用trap_handler_c。
trap_entry: csrrw sp, mscratch, sp sd ra, 0*8(sp) sd gp, 1*8(sp) sd tp, 2*8(sp) sd t0, 3*8(sp) # ... 逐个保存 sd sp, 31*8(sp) csrr t0, mepc sd t0, 32*8(sp) csrr t0, mstatus sd t0, 33*8(sp) csrr t0, mcause sd t0, 34*8(sp) mv a0, sp call trap_handler_c这里有个反直觉的点:sd sp, 31*8(sp)这行保存的并不是当前栈指针的值,而是csrrw交换之前的原始用户栈指针。原因在于交换后sp已经指向了上下文结构体,而mscratch里才是用户态的栈顶。硬件设计者用这种机制让 M 态代码能快速获取被中断者的栈,同时保证自己不会污染对方的栈空间。我第一次写的时候没仔细想这层,导致恢复上下文时 sp 被覆盖成上下文结构体地址,整个栈彻底错乱,进程一跑就“神秘崩溃”。
2.3 ecall 分发:识别“你是谁,你要干什么”
trap_handler_c里先通过mcause判断 trap 类型:最高位置 1 表示这是中断,否则是异常。MySBI 只处理两类异常:ecall from U-mode和ecall from S-mode。前者留给 BenOS 的用户程序做系统调用,后者是 BenOS 请求 SBI 服务的通道。
uintptr_t trap_handler_c(trap_frame_t *tf) { uintptr_t mcause = tf->mcause; uintptr_t mepc = tf->mepc; if ((mcause & 0x8000000000000000ULL) == 0) { uintptr_t code = mcause & 0xFFF; if (code == 9) { // ecall from S-mode sbi_ecall_handler(tf->a0, tf->a1, tf->a2, tf->a3); tf->mepc += 4; // 关键:跳过 ecall 指令 } else if (code == 8) { // ecall from U-mode syscall_handler(tf); } } return mepc; }看懂这段代码需要先理解一个硬性事实:ecall触发 trap 后mepc指向的是ecall这条指令本身,如果不把它加 4,返回后会无限重复执行同一条ecall,形成死循环。所以每个异常处理程序入口处都习惯性做mepc + 4。如果你在自己的代码里看到奇怪的重复踩 trap 现象,十有八九是把这行漏了。另外注意a0到a7这 8 个寄存器正好能传 8 个参数,这是 RISC-V 调用约定的天然优势,SBI 规范里sbi_ecall就是靠a7传功能号、a0-a3传具体参数区分不同服务的。
3. 在 MySBI 上启动 BenOS:约束与适配
3.1 RustSBI vs 自研 MySBI:启动流程对比
如果你之前用过 RustSBI 或 OpenSBI,对下面这条启动链应该很熟:ROM -> SBI -> boot loader -> kernel。RustSBI 会把下一阶段代码的入口地址通过a0(hartid)、a1(DTB 地址)传下去,内核再通过这些信息自举。MySBI 走差不多的路线,但因为整个系统都是自己写的,把 DTB 省了,用固定地址直接告诉 BenOS:“你的入口在 0x80200000,内存范围我写在头文件里了”。
这让 BenOS 的启动变得异常简单:它的_start只做三件事——关中断、设栈顶、清 BSS,然后跳进kernel_main()。实际上 BenOS 的第一行代码甚至不用主动关中断,因为 MySBI 在mret之前已经保证mstatus.MIE = 0,中断在下述时刻之前都是关着的。但代码里我还是显式加了一句清sie,这是防御性编程习惯,防止未来改动了 SBI 侧逻辑导致中断状态失控。
void kernel_main(uintptr_t hartid, uintptr_t dtb) { // 1. 初始化串口:通过 SBI 的调试控制台扩展 // 2. 设置中断委托:把 S 态时钟中断直接下放给内核 // 3. 设置首次时钟中断 // 4. 打印启动 banner // 5. 进入 idle 循环,等待下一次中断 }3.2 SBI 调用规则:a7 传功能号,a0-a6 传参数
BenOS 里的sbi_call()是标准的 RISC-V SBI 调用封装,直接内联汇编,避免函数调用开销。原版代码如下:
static inline long sbi_call(long a0, long a1, long a2, long a3, long a4, long a5, long a6, long a7) { register long r_a0 __asm__("a0") = a0; register long r_a1 __asm__("a1") = a1; register long r_a2 __asm__("a2") = a2; register long r_a3 __asm__("a3") = a3; register long r_a4 __asm__("a4") = a4; register long r_a5 __asm__("a5") = a5; register long r_a6 __asm__("a6") = a6; register long r_a7 __asm__("a7") = a7; __asm__ __volatile__("ecall" : "+r"(r_a0) : "r"(r_a1), "r"(r_a2), "r"(r_a3), "r"(r_a4), "r"(r_a5), "r"(r_a6), "r"(r_a7) : "memory"); return r_a0; }这套规范是 SBI 规范文档里定义的:扩展 ID 放在a7,函数 ID 放在a6,参数从a0开始。比如sbi_console_putchar的功能号是 1,sbi_set_timer的功能号是 0。每次写完一个 SBI 扩展,我都建议对照规范文档核对一遍功能号,不要凭记忆写。我踩过一次坑:把sbi_set_timer功能号写成了 0x54494D45(“TIME”的 ASCII),这就是自定义扩展才会用的命名方案,标准扩展撞上这个值直接静默失败,搞了我一晚上。
3.3 BenOS 的链接脚本:内存布局一错全盘崩
内核的链接脚本是这个项目里最容易出“离奇 bug”的部分。MySBI 把 BenOS 的加载地址定在 0x80200000,所以链接脚本必须以这个地址为起点:
OUTPUT_ARCH(riscv) ENTRY(_start) BASE_ADDRESS = 0x80200000; SECTIONS { . = BASE_ADDRESS, .text : { *(.text.init) *(.text) } . = ALIGN(4K); .data : { *(.data) } . = ALIGN(4K); .bss : { *(.bss) } . = ALIGN(4K); _stack_top = .; . = . + 16K; }注意几个细节:ENTRY(_start)告诉链接器入口符号;.text.init段要放在最前面,因为 CPU 一跳进来就要执行这段代码;栈顶_stack_top定义在 BSS 后面,栈向下生长,所以这里 16K 的栈空间实际上覆盖了 BSS 的末尾部分。如果你把栈定义在 BSS 前面,初始化过程中一旦调用函数就会踩到后续未初始化的数据段,产生“局部变量莫名其妙被清零”的灵异事件。
4. 实操过程与核心环节实现
4.1 环境准备与编译工具链
整个项目只需要两个核心工具链:RISC-V GCC 工具链和 QEMU。Ubuntu/Debian 系可以直接装:
sudo apt install gcc-riscv64-unknown-elf qemu-system-misc gdb-multiarchGCC 用riscv64-unknown-elf-前缀,QEMU 用qemu-system-riscv64,调试器用gdb-multiarch(因为它支持多架构)。我最初图省事装了gdb-riscv64,后来发现它对 RISC-V 的支持不如gdb-multiarch全面,特别是向量寄存器显示上差别很大,所以建议直接上 multiarch 版本。
编译命令很简单,但有两个必须注意的 flag:
riscv64-unknown-elf-gcc -march=rv64gc -mabi=lp64 -mcmodel=medany -nostdlib -fno-builtin -Iinclude -c src/start.S -o build/start.o riscv64-unknown-elf-gcc -march=rv64gc -mabi=lp64 -mcmodel=medany -nostdlib -fno-builtin -Iinclude -c src/sbi_main.c -o build/sbi_main.o riscv64-unknown-elf-ld -T linker.ld -o build/my_sbi.elf build/start.o build/sbi_main.o riscv64-unknown-elf-objcopy -O binary build/my_sbi.elf build/my_sbi.bin-march=rv64gc是告诉编译器生成 RV64 通用指令集代码,-mcmodel=medany则让你能在不依赖高 20 位地址固定的情况下访问整个 64 位地址空间——裸机上这个选项基本是必需品。-nostdlib去掉标准库,因为我们的链接脚本里根本没有 libc。如果某个文件报“undefined reference to memxxx”,大概率是编译器插入了内建函数调用,需要显式加-fno-builtin再加-ffreestanding让它完全进入独立环境模式。
4.2 运行与验证:QEMU 里的完整启动链路
用下面的命令把整个系统拉起来:
qemu-system-riscv64 -machine virt -bios my_sbi.bin -kernel benos.bin -nographic-bios指定我们的 MySBI 固件,-kernel指定 BenOS 内核镜像,-nographic把串口重定向到当前终端。启动后你会看到类似这样的输出:
MySBI v0.1.0 => Runtime: 0x80000000 - 0x80020000 => BenOS: 0x80200000 - 0x80400000 BenOS: kernel booting... [0] Hello from BenOS on hart 0 BenOS: timer interrupt fired, ticks = 1 BenOS: timer interrupt fired, ticks = 2这里有一个隐含的过程值得展开:QEMU 在-bios和-kernel同时指定时,会把两个镜像分别加载到 0x80000000 和 0x80200000,随后把 PC 设置为 0x80000000 开始执行。MySBI 初始化完成后,通过mret跳转至 0x80200000 进入 BenOS。这条链路里没有引导程序,因为 QEMU 的virt平台 ROM 默认已经把跳板做好了。如果你在-kernel里指定的是 ELF 文件而不是 bin,QEMU 会自动解析 ELF 的入口地址并加载所有段,这比手工 objcopy 转 bin 更省心——但有一个缺点:无法控制加载地址,如果你把 BenOS 做成了位置无关可执行文件,还是建议统一走 bin 流程,避免地址错乱。
4.3 关键场景一:sbi_console_putchar 如何把字符送到串口
sbi_console_putchar看起来最简单,但它恰好是贯穿 M 态和 S 态的典型案例。BenOS 里的 printf 最终会逐字符调用它:
void putchar(char c) { sbi_call(c, 0, 0, 0, 0, 0, 0, 1); // a7 = 1 => console_putchar }MySBI 侧对应的处理函数如下:
static void sbi_console_putchar(uintptr_t c) { while ((read_reg(UART16550_LSR) & UART_LSR_THRE) == 0) ; write_reg(UART16550_THR, c); }等等,这里有个我特别想强调的细节:为什么 QEMU virt 平台用 16550 的 UART,但我们却能在sbi_console_putchar里直接往内存映射寄存器写字符,而不是像真正裸机那样先初始化串口时钟?答案藏在 QEMU 的virt机器定义里:这台虚拟机器在出厂状态就配置好了 UART 的时钟和波特率,我们只需要等待 THR(发送保持寄存器)空闲即可。真机上不是这回事,你得先配置 UART 的时钟分频、数据位、停止位、校验位,才能开始输出第一个字符。
这也是为什么我说“先跑 QEMU 再碰真板”是绝对的黄金法则:把串口初始化这部分暂时当成一个黑盒,能让你把所有注意力集中在特权级切换和 SBI 语义上。等你在 QEMU 上把整个 trap 流程调通后,再回头补齐真机的uart_init(),排查范围会小很多。
4.4 关键场景二:时钟中断与 mret 的配合
时钟中断是第二个关键场景。BenOS 里设置了一个周期性的定时器:
static void timer_init() { uint64_t interval = 1000000; // 约 1ms(取决于平台时钟频率) sbi_call(interval, 0, 0, 0, 0, 0, 0, 0); // a7=0 => set_timer }MySBI 侧的sbi_set_timer会操作 RISC-V 的time比较寄存器来触发下一次中断:
static void sbi_set_timer(uint64_t stime_value) { write_csr(mtimecmp, stime_value); }但这里有个容易踩的大坑:mtimecmp是 M 态才可访问的寄存器,S 态内核不能直接用,所以必须通过 SBIecall来设置。而time寄存器本身在 S 态是可以用rdtime指令读的,所以 BenOS 可以在用户态用rdtime做时间戳。这一进一出的区别,正好体现了 SBI 设计的核心哲学——可读的能力尽量下放,可写的高危操作全部收口到 M 态。真要在 S 态直接读mtimecmp,会触发 illegal instruction 异常,而不是返回一个随机值,这一点我在调试时反复确认过。
当中断发生时,如果mstatus.MIE = 1且mstatus.MPIE是 0,CPU 会在 trap 入口自动把 MIE 清零,防止嵌套中断。等mret执行时,硬件又会把 MPIE 的值恢复回 MIE。所以mret这条指令本质上是“原子地恢复先前状态并跳转”,你必须保证 trap 入口保存的mstatus完整无缺,恢复时机不能有偏移,否则中断开关状态会错乱。我因为这个偏移问题写错过一版:每触发一次定时器中断,CPU 的中断使能状态就反相一次,表现就是内核跑着跑着就“假死”,间隔长短完全随机,排查了好久才用 GDB 对比寄存器恢复前后的值找出问题。
5. 工具链调试:GDB + QEMU 组合拳
5.1 搭建调试会话:从启动到断点
没有调试器的裸机开发约等于闭眼开车。QEMU 的 GDB stub 是我用过的最顺手的调试方式,在启动 QEMU 时加一个-s参数就能开启端口 1234 的监听:
qemu-system-riscv64 -machine virt -bios my_sbi.bin -kernel benos.bin -nographic -s另开一个终端,连接 GDB:
gdb-multiarch build/my_sbi.elf (gdb) target remote :1234 (gdb) hbreak *0x80200000 (gdb) continuehbreak是硬件断点,在还没有初始化任何断点相关硬件之前它是稳定可用的。如果不用硬件断点,break软件断点需要先把目标地址的指令替换成ebreak,这在没有 MMU 初始化的早期阶段很容易出问题。另一个好用的调试姿势是查看当前特权级,RISC-V 没有直接读取当前特权级的指令,但可以通过mstatus的MPRV、MPP字段推算。
5.2 一条命令定位 trap 死循环
在我调试过程中,最常用的排障路径是:在trap_entry入口设断点,然后检查mcause的值。因为一旦出现意外 trap,说明代码里某个行为触发了异常。例如:
(gdb) x/20i $pc => 0x80200100 <trap_entry>: csrrw sp,mscratch,sp 0x80200104 <trap_entry+4>: sd ra,0(sp) ... (gdb) p/x $mcause $1 = 0x2mcause值为 0x2 表示 illegal instruction。这时候你需要检查mepc指向的指令,十有八九是一条在 S 态不可执行的指令,比如直接写mstatus而不是通过 SBI,或者把sret当mret用了。这种问题在用户态程序里根本不会出现,但在裸机层几乎是日常。
5.3 实测:一次中断丢失的排查记录
讲一个我亲身经历过的 bug:BenOS 启动后,第一个时钟中断能正常触发,但之后再也没有第二个。我在trap_entry入口和sbi_set_timer两个地方都设了断点,发现 trap 入口能进,但sbi_set_timer断点永远不触发。这说明 BenOS 没有再次调用 SBI 设置下一次定时器。
进一步查看 BenOS 的中断处理代码,发现问题出在“调度器线程模型”的设计:第一次中断到来时,调度器把当前任务切换出去,但新任务没有重新设置定时器。硬件的中断是电平触发还是边沿触发,决定了你“设置一次”和“持续设置”的区别。在 RISC-V 上,定时器中断必须每次都重新编程mtimecmp,否则下一次永远不会来。这跟 ARM 的 private timer 不太一样,后者可以配置成自动重载。所以正确做法是在每次时钟中断处理后,立即安排下一次中断:
void timer_tick() { // 处理本轮 tick sbi_set_timer(get_cycle() + INTERVAL); // 切换到下一个任务 }这类 bug 在书面上几乎不可能被描述清楚,只有断开点、单步、反复观察寄存器才能找到根因。所以当你写这类底层代码时,一定不要吝啬时间在 GDB 上,特别是在第一次把整个系统跑通的那个阶段。
6. 常见问题与排查技巧实录
6.1 问题速查表
下面这张表是我把这段时间遇到的高频问题整理出来的速查表,遇到“灵异现象”时先对着查一遍,通常能省下半天时间:
| 现象 | 可能原因 | 排查手段 |
|---|---|---|
| 程序一跳进 S 态就崩溃 | mret的mstatus.MPP字段没配置成 S 态 | GDB 查看跳转前的mstatus,确认MPP为 0b01 |
| 串口有输出但乱码 | UART 时钟配置不对或波特率不匹配 | QEMU 里可以直接读 UART 寄存器确认配置 |
| 中断只触发一次,之后不再触发 | 没有在中断处理中重新设置定时器或未清 pending | 在trap_entry和sbi_set_timer同时设断点 |
ecall后返回地址错乱 | mepc没有加 4,回到了原ecall | 检查trap_handler中mepc的修正逻辑 |
| 栈变量莫名其妙被覆盖 | 栈和 BSS 重叠或者栈溢出 | 查看链接脚本的布局,确认_stack_top定义在 BSS 后 |
| 编译报 “undefined reference to memcpy” | 缺少-fno-builtin或-ffreestanding | 编译选项检查 |
| GDB 连不上 QEMU | 端口被占用或 QEMU 没开-s | ss -lntp查看端口 |
6.2 一些可能省下你几个通宵的技巧
调试裸机程序时,最重要的一个经验其实就四个字:二分排除。硬件环境引入的不确定性远大于软件,所以先让它“假一点、笨一点”:把中断关掉、把优化关掉、把随机性关掉,确保你能在最小状态下看到确定性的行为,然后再逐步打开。我第一次调这个系统时,一上来就把所有功能全打开,结果遇到异常根本分不清是 trap 入口写错还是调度器状态搞坏,白白浪费了两天。后来我改成一次只开一个功能,先单独验证串口输出,再单独验证定时器,最后再把它们组合起来,整个流程顺畅了很多。
另一个有用的小技巧是:在启动早期加入一个“执行标记”。不需要用专门的调试串口,直接在当前串口上输出一个递增的数字:
void early_debug_mark(int n) { putchar('['); putchar('0' + n); putchar(']'); }这样无论哪一步崩了,你都能立即知道“崩溃发生在第 n 步之前”,比任何日志框架都好使。对裸机编程来说,能精确定位崩溃点,问题就解决了一半。
还有一点关于 QEMU 的建议:-d int,cpu_reset这个参数会打印每次中断和 CPU 复位的详细信息,基本相当于给 CPU 装了一个“生命体征检测仪”。如果你怀疑某个中断异常触发,开这个参数看输出,能看到中断号和 trap 发生的地址,比自己在代码里加打印要快得多。
7. 迭代与扩展:这套代码还能往哪走
7.1 加入虚拟内存支持:从“实模式”到“分页模式”
目前 BenOS 整个运行在物理地址空间,没有开启地址转换。这是迷你内核最简单的形态,但也极大地限制了它的扩展性。下一步最有价值的改进就是把 MMU 打开,建立页表映射,让内核态运行在虚拟地址空间里。
RISC-V 的 Sv39 分页机制是常见的教学起点:39 位虚拟地址,三级页表,每级 9 位索引。开启方式是设置satp寄存器,然后执行sfence.vma刷新 TLB。一旦开启了分页,SBI 侧也会多一项工作:处理缺页异常。mcause值为 12(instruction page fault)或 13(load page fault)时,内核要把缺失的页从磁盘或缓存中加载到物理内存。
这一步的工作量不可小觑,但其价值是全方位的:你将真正理解操作系统的虚拟地址空间布局、用户态和内核态的隔离、写时复制等一系列进阶概念。建议分两步走:第一步只开恒等映射(虚拟地址=物理地址),验证 MMU 开启过程没有 bug;第二步再做非恒等映射,把内核链接地址改到 0xffffffe000000000 附近,彻底脱离物理地址的束缚。
7.2 为 BenOS 增加更多 SBI 服务扩展
你可以按 SBI 规范继续扩展 MySBI 的功能。比如实现sbi_hart_start/sbi_hart_stop来支持多核启动:BenOS 可以唤醒其他核,让它们各自进入调度循环。实现这个扩展的核心是 IPI(处理器间中断),在 QEMU 里通过 CLINT 的msip寄存器触发。
更实用的是实现sbi_system_reset,这样 BenOS 就能通过一个 SBI 调用来正常关机重启,而不是让 QEMU 进程一直挂在那里。这个扩展的实现相当简单,主要是往 QEMU 的test设备寄存器写值。
7.3 把 MySBI 替换成 RustSBI
当你的实验进入更复杂阶段时,你可能会想试试 RustSBI 提供的更丰富的功能(比如设备树传递、多核引导、更快的控制台输出)。这时候 MySBI 的“教学价值”就体现出来了:因为你已经理解了 SBI 的每一个环节,切换到一个工业级实现时,你能立刻看懂它的代码并找出它的设计决策的理由。反过来,如果你一上来就用 RustSBI,你大概只会把它当作一个“黑盒子固件”,永远不知道它内部发生了什么。这正是我先写自研 MySBI 再对比 RustSBI 的原因。
从实验代码到真正可用的小型操作系统,中间还有很长的路。但从零到一这一段,也就是从 CPU 上电到内核打印出第一行字符的这一段路,是操作系统学习中信息密度最大、也最容易出成就感的一段。MySBI 与 BenOS 这套实验代码存在的意义,就是让你不用背负 Linux 那样庞大的历史包袱,也能把这段路完整走一遍。它的代码量、调试难度、学习曲线都处于一个恰到好处的位置:对于教学和自学来说,多一分则繁,少一分则空。
如果你也正在折腾 RISC-V 底层开发,可以照着我这套流程走一遍,不管最终是把代码改成自己的风格,还是直接用 RustSBI 做底座,那段“从零到打印出 Hello”的经历都会是你对操作系统理解最深刻的一课。我这套实验代码的完整目录我放在最后,有兴趣的可以直接拿去跑:sbi/放 MySBI 的启动和 trap 处理,benos/放内核的启动和调度,linker.ld是链接脚本,Makefile一键编译。有问题随时在评论里交流。
本文还有配套的精品资源,点击获取