资讯详情

手写RISC-V操作系统内核:从启动到抢占式调度

📅 2026/9/14 2:47:58 | 华诺云谱 👁 阅读
手写RISC-V操作系统内核:从启动到抢占式调度
简介本资源是《从头写一个RISC-V操作系统》课程的完整配套实践包面向计算机系统、操作系统原理及嵌入式开发方向的中高级学习者旨在通过真实代码工程打通理论与动手能力断层。压缩包共282个文件涵盖86个C源文件内核主体、内存管理、进程调度等核心模块、60个汇编文件RISC-V特权级切换、中断处理等底层实现、43个Makefile分阶段构建脚本、42个头文件接口定义与宏配置及25份PDF文档实验指导、设计说明与RISC-V指令参考整体大小29.04MB。已有184人下载学习资源结构清晰按lab分层组织含QEMU模拟运行配置、GDB调试脚本及完整测试用例支持从零构建可启动的操作系统镜像。读者可直接复现课程全部实验深入理解上下文切换、页表映射、系统调用机制等关键环节并获得适配RISC-V架构的交叉编译链与调试环境一键部署方案。1. 这不是教你怎么装 Linux而是带你亲手造一个能启动、能调度、能响应中断的 RISC-V 内核“从头写一个 RISC-V 操作系统”这个标题常被误读为“用 Rust 写个玩具内核跑在 QEMU 里打个 hello”。但真正吃透这门课配套资源的人会发现它交付的是一套可调试、可断点、可单步、可映射物理内存、可接管 PLIC 中断控制器、可调度两个以上任务并完成上下文切换的最小可行操作系统骨架。它不依赖任何现成 OS 抽象层比如 newlib 或 musl所有代码直面 RISC-V 物理地址空间、CSR 寄存器、S-mode 异常向量表和 CLINT/Plic 硬件模块。适合两类人一是刚学完《计算机组成原理》想验证流水线与异常处理联动机制的学生二是嵌入式/Linux 驱动开发者想跳出 kernel module 框架理解 trap handler 如何从硬件中断跳转到 C 函数、如何保存/恢复浮点寄存器、为什么 sstatus.SIE 位必须在 switch_to 前关闭。配套的.zip资源里没有预编译镜像只有Makefile、link.ld、trap.S、sched.c和uart.c—— 每一行都要求你手动确认 CSR 地址是否匹配 Spike/QEMU 的 RISC-V 20211203 版本每处wfi指令都要知道它触发的是哪个中断源。2. 用 riscv64-unknown-elf-gcc 在本地跑通 RISC-V OS 的最小命令链2.1 为什么必须用 riscv64-unknown-elf-gcc 而不是 gcc-riscv64-linux-gnuRISC-V 操作系统开发分两个阶段裸机bare-metal阶段和 Linux 用户态阶段。课程配套资源属于前者——它运行在 M/S-mode 切换后的 S-mode 下无 MMU 初始化、无动态链接器、无 libc 初始化函数。此时若用gcc-riscv64-linux-gnu编译链接器会默认插入.init_array段调用__libc_start_main而该函数依赖_start符号和SYS_execve系统调用这在 bare-metal 环境中根本不存在。riscv64-unknown-elf-gcc是专为嵌入式目标设计的工具链其ld默认不链接crt0.o且--nostdlib参数能彻底剥离标准启动代码。验证方式很简单# 错误示范用 linux-gnu 工具链编译 riscv64-linux-gnu-gcc -marchrv64imac -mabilp64 -nostdlib -o kernel.elf start.S trap.S sched.c # 输出警告undefined reference to __libc_start_main # 即使加 -static 也会因缺少 syscalls 实现而链接失败 # 正确命令链配套资源 Makefile 实际执行的 riscv64-unknown-elf-gcc -marchrv64imac -mabilp64 -mcmodelmedlow \ -fno-builtin -fno-common -fno-zero-initialized-in-bss -Wall -Werror \ -nostdlib -T link.ld -o kernel.elf start.S trap.S uart.c sched.c提示-mcmodelmedlow是关键参数。RISC-V 的auipcaddi指令对 PC 相对寻址范围有限±2MB若内核代码段超过此范围la t0, _start类指令会生成非法地址。medlow模式强制所有全局符号使用绝对地址加载避免重定位错误。2.2 link.ld 必须显式声明 .text、.rodata、.data 和 .bss 的物理地址布局配套资源中的link.ld不是示意模板而是精确适配 QEMU-machine virt的内存映射。RISC-V virt 机器默认将 DRAM 映射在0x800000002GB起始而 OpenSBI 固件占用0x80000000~0x80200000。因此内核必须从0x80200000开始加载SECTIONS { . 0x80200000; /* 内核入口地址必须与 QEMU -kernel 参数一致 */ _start .; .text : { *(.text.entry) /* start.S 中的 _start 符号必须放在最前 */ *(.text) } .rodata : { *(.rodata) } .data : { *(.data) } .bss : { *(.bss) *(COMMON) } }若.text.entry段未被优先放置QEMU 启动时会直接跳转到随机指令导致Illegal instructiontrap。验证方法riscv64-unknown-elf-objdump -d kernel.elf | head -20第一行反汇编地址必须是80200000。2.3 QEMU 启动命令必须显式指定设备树dtb和 OpenSBI 固件仅qemu-system-riscv64 -kernel kernel.elf无法启动因为 RISC-V S-mode 内核需要 OpenSBI 作为 Supervisor Binary Interface 层来处理 SBI 调用如sbi_console_putchar。配套资源通常包含fw_jump.binOpenSBI和virt.dtb设备树qemu-system-riscv64 \ -M virt \ -m 2G \ -bios fw_jump.bin \ # OpenSBI 固件提供 SBI 接口 -kernel kernel.elf \ # 自研内核通过 sbi_call 进入 S-mode -dtb virt.dtb \ # 设备树描述 UART、CLINT、PLIC 地址 -nographic \ # 禁用图形界面输出重定向到终端 -serial mon:stdio # 将 UART0 输出映射到 stdout注意-bios参数加载 OpenSBI 后QEMU 会先执行 OpenSBI 的jump_to_kernel函数再跳转到kernel.elf的_start。若省略-biosQEMU 会尝试直接加载 kernel 到 M-mode此时csrrw zero, sstatus, t0指令会触发illegal instructionM-mode 无法访问 sstatus CSR。3. trap.S 中的异常向量表必须按 RISC-V spec 对齐并处理四种核心 trap 类型3.1 RISC-V 异常向量表结构每个 trap 入口必须是 4 字节对齐的 jal 指令RISC-V 规范要求异常向量表基址由stvecCSR 指向且每个 trap 入口地址必须是 4 字节对齐。配套资源的trap.S通常采用「direct mode」而非「vectored mode」即stvec指向单一入口函数由软件判断scause寄存器值分发。但无论哪种模式向量表本身必须满足.section .text.trap .global trap_vector trap_vector: # 地址必须是 4 的倍数此处为 0x80200100假设 .text 起始后偏移 0x100 # 第 0 项User software interrupt实际不会发生但必须占位 jal x0, handle_trap # 第 1 项Supervisor software interruptS-mode 软中断用于 yield jal x0, handle_ssi # 第 2 项User timer interrupt用户定时器课程中通常不用 jal x0, handle_uti # 第 3 项Supervisor timer interrupt关键CLINT 的 mtimecmp 触发 jal x0, handle_sti # 第 4 项User external interrupt用户外设中断 jal x0, handle_uei # 第 5 项Supervisor external interruptPLIC 外部中断UART 收发在此处理 jal x0, handle_sei若trap_vector地址未对齐如0x80200101CPU 在 trap 发生时会跳转到非法地址导致死机。3.2 handle_sti 必须完成三件事清 CLINT 中断、更新 next_tick、调用 schedulerSupervisor timer interrupt 是实现抢占式调度的核心。配套资源中handle_sti的典型实现如下handle_sti: # 1. 清除 CLINT 中断标志写 0 到 msip 寄存器 li t0, 0x2000000 csrw sip, t0 # 2. 更新下一次 timer 中断时间假设 10ms tick li t0, 10000000 li t1, 0x2000000 add t0, t0, mtime sw t0, 0(t1) # 写入 mtimecmp 寄存器 # 3. 调用 C 函数进行调度决策 call schedule ret关键点mtimecmp是 64 位寄存器但sw指令只写低 32 位。正确做法是用sdstore doublewordli t1, 0x2000000 sd t0, 0(t1) # 此处必须用 sd否则高 32 位为 0 导致立即再次触发中断3.3 handle_sei 必须轮询 PLIC 并调用 uart_irq_handlerSupervisor external interrupt 由 PLICPlatform Level Interrupt Controller分发。RISC-V virt 机器中UART0 的中断号为 10需在 PLIC 中使能并设置阈值// 在 kernel_init() 中初始化 PLIC void plic_init() { // 设置当前 hart 的优先级阈值为 0接收所有中断 *(uint32_t*)(PLIC_BASE 0x200000) 0; // 使能 UART0 中断中断号 10 *(uint32_t*)(PLIC_BASE 0x2000 (10/32)*4) | (1 (10%32)); // 设置 UART0 优先级为 1 *(uint32_t*)(PLIC_BASE 0x1000 10*4) 1; } // handle_sei 中的 C 调用 void handle_sei() { uint32_t claim *(uint32_t*)(PLIC_BASE 0x200004); if (claim 10) { // UART0 中断 uart_irq_handler(); } *(uint32_t*)(PLIC_BASE 0x200004) claim; // 完成中断服务 }若未调用uart_irq_handler()串口输入将永远阻塞在while(!uart_has_rx())循环中。4. sched.c 的上下文切换必须保存/恢复全部整数与浮点寄存器4.1 task_struct 中的 context 字段必须覆盖 x1~x31 和 f0~f31RISC-V ABI 规定x1ra、x3gp、x4tp为保留寄存器x5~x7、x28~x31为调用者保存寄存器x8~x27为被调用者保存寄存器。浮点寄存器f0~f31在启用Zfinx扩展时也需保存。配套资源的task_struct通常定义为struct task_struct { uint64_t context[32]; // x1~x31x0 无需保存 uint64_t fcontext[32]; // f0~f31双精度浮点 uint64_t sp; // 内核栈指针 int state; };注意x0恒为 0无需保存x2sp在switch_to中由csrrw sp, sscratch, sp交换不存入 context 数组。4.2 switch_to 的汇编实现必须使用 sscratch CSR 交换栈指针RISC-V 的sscratchCSR 是专为 trap 处理设计的临时寄存器switch_to利用它原子交换当前栈指针.globl switch_to switch_to: # 保存当前任务的 x1~x31 到 old-context[0..30] addi t0, a0, 0 # t0 old-context[0] # 逐个保存 x1~x31跳过 x0 csrr t1, sscratch sd t1, 0(t0) # x1 - context[0] csrr t1, sepc sd t1, 8(t0) # sepc - context[1] # ... 省略中间寄存器保存 csrr t1, sstatus sd t1, 240(t0) # sstatus - context[30] # 从 new-context 恢复 x1~x31 addi t0, a1, 0 # t0 new-context[0] ld t1, 0(t0) # x1 - context[0] csrw sscratch, t1 ld t1, 8(t0) # sepc - context[1] csrw sepc, t1 # ... 省略恢复 ld t1, 240(t0) # sstatus - context[30] csrw sstatus, t1 # 关键用 sscratch 交换 sp csrrw sp, sscratch, sp # sp - sscratch完成栈切换 ret提示csrrw sp, sscratch, sp是唯一安全切换栈的方式。若直接mv sp, t0则后续sd指令会写入错误栈位置导致内核崩溃。4.3 schedule() 必须禁用中断、选择 next_task、调用 switch_to、恢复中断抢占式调度的临界区保护不可省略void schedule() { uint64_t sstatus; // 1. 保存并禁用中断 sstatus read_csr(sstatus); clear_csr(sstatus, SSTATUS_SIE); // 2. 选择下一个就绪任务简单轮询 struct task_struct *next pick_next_task(); // 3. 若 next ! current执行切换 if (next ! current) { switch_to(current, next); current next; } // 4. 恢复中断使能 write_csr(sstatus, sstatus); }若省略clear_csr(sstatus, SSTATUS_SIE)在switch_to执行过程中可能被 timer 中断打断导致 context 保存不完整。5. 验证 RISC-V OS 是否真正运行用 GDB 连接 QEMU 并检查三个关键寄存器状态5.1 启动带 GDB stub 的 QEMU 并连接 riscv64-unknown-elf-gdb要确认内核已进入 S-mode 并正确初始化必须用 GDB 实时观测 CSR 寄存器# 启动 QEMU 并监听 gdb 连接端口 1234 qemu-system-riscv64 \ -M virt -m 2G \ -bios fw_jump.bin \ -kernel kernel.elf \ -dtb virt.dtb \ -nographic \ -S -gdb tcp::1234 # -S 表示暂停执行等待 gdb 连接 # 新终端中连接 riscv64-unknown-elf-gdb kernel.elf (gdb) target remote :1234 (gdb) info registers5.2 检查 sstatus、sepc、stvec 三个寄存器的值是否符合预期寄存器期望值不符合的含义sstatus0x0000000200000100SIE1, SPP0, SPIE1SIE 未置位 → 中断被屏蔽SPP1 → 仍在 M-modesepc0x80200000或0x80200100指向 start.S 或 trap_vectorsepc 指向0x0→ 内核未加载成功指向0x80000000→ 仍停留在 OpenSBIstvec0x80200100指向 trap_vector 起始地址stvec0 → trap 向量未设置任何中断都会导致致命错误执行x/10i $sepc可查看当前执行指令确认是否在trap_vector或schedule函数内。5.3 用 GDB 断点验证 timer 中断是否触发 schedule在handle_sti函数开头设断点观察是否被命中(gdb) b handle_sti (gdb) c # 运行几秒后应自动停在 handle_sti (gdb) info registers scause # scause 应为 0x5Supervisor timer interrupt (gdb) p/x *(uint32_t*)0x2000000 # 查看 mtimecmp 值是否已更新若handle_sti从未被触发检查clint_set_timer(10000000)是否在kernel_init()中调用以及stvec是否指向正确的向量表。5.4 用 QEMU monitor 查看 PLIC 状态确认 UART 中断注册成功在 QEMU 启动后按CtrlA然后按C进入 monitor 模式(qemu) info irq # 应显示10: 0 0x00000001 (PLIC) ← 表示 UART0 中断已注册且 pending0 (qemu) info qtree # 查看 virt machine 的设备树确认 interrupt-controller 节点存在若info irq中无10:条目说明plic_init()未执行或 PLIC_BASE 地址错误应为0xc000000。提示plic_init()必须在uart_init()之后调用因为 UART 初始化会设置mie.SEIE位而 PLIC 使能必须在此之前完成否则中断无法送达 CPU。6. 调试 RISC-V OS 的三个硬核技巧用 objdump 分析符号地址、用 spike 比对指令行为、用自定义 printf 定位死锁点6.1 用 riscv64-unknown-elf-objdump -t kernel.elf 查看所有符号的 VMA 地址当schedule()未被调用时仅靠源码无法判断是函数未编译进 ELF 还是跳转逻辑错误。objdump -t可暴露真相riscv64-unknown-elf-objdump -t kernel.elf | grep -E (schedule|switch_to|handle_sti) # 正常输出 # 00000000000001a0 g F .text 000000000000003e schedule # 00000000000001de g F .text 000000000000004a switch_to # 0000000000000228 g F .text 000000000000001c handle_sti若schedule符号缺失说明sched.c未被链接检查 Makefile 中是否遗漏.o文件若地址为0000000000000000说明函数被编译器优化掉加__attribute__((used))强制保留。6.2 用 spike --isarv64imac --extensionsvinval kernel.elf 替代 QEMU 进行指令级比对QEMU 的 RISC-V 实现存在与真实硬件偏差如mtimecmp写入延迟。Spike 是官方参考模拟器行为更严格# 编译 spike需从 riscv-isa-sim 仓库获取 ./spike --isarv64imac --extensionsvinval \ --loginsn \ pk kernel.elf spike.log 21 # 检查 spike.log 中是否有 illegal instruction 或 page-fault若 spike 正常运行而 QEMU 崩溃大概率是 QEMU 的 CLINT 实现 bug此时应改用--deviceclint,version0x00000000参数指定 CLINT 版本。6.3 在关键路径插入 inline asm printf绕过未初始化的 UART 驱动当uart_init()失败导致printf无输出时可用最简ecall直接调用 OpenSBI 的sbi_console_putchar#define SBI_CONSOLE_PUTCHAR 0x1 static inline void debug_putc(char c) { register long a0 asm(a0) c; register long a7 asm(a7) SBI_CONSOLE_PUTCHAR; asm volatile (ecall ::: a0, a7); } // 在 schedule() 开头插入 debug_putc(S); debug_putc(C); debug_putc(H);若串口无输出但SCH出现在 QEMU 终端证明调度器已运行问题在uart.c的寄存器配置如UART_BASE地址应为0x10000000。注意ecall会触发 SBI 调用必须确保 OpenSBI 已正确加载且sbi_console_putchar函数存在检查 OpenSBI 版本是否 ≥ 1.0。本文还有配套的精品资源点击获取
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。