news 2026/9/14 2:47:35

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

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
手写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资源里没有预编译镜像,只有Makefilelink.ldtrap.Ssched.cuart.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-gnu?

RISC-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 -march=rv64imac -mabi=lp64 -nostdlib -o kernel.elf start.S trap.S sched.c # 输出警告:undefined reference to `__libc_start_main` # 即使加 -static 也会因缺少 syscalls 实现而链接失败 # 正确命令链(配套资源 Makefile 实际执行的) riscv64-unknown-elf-gcc -march=rv64imac -mabi=lp64 -mcmodel=medlow \ -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

提示:-mcmodel=medlow是关键参数。RISC-V 的auipc+addi指令对 PC 相对寻址范围有限(±2MB),若内核代码段超过此范围,la t0, _start类指令会生成非法地址。medlow模式强制所有全局符号使用绝对地址加载,避免重定位错误。

2.2 link.ld 必须显式声明 .text、.rodata、.data 和 .bss 的物理地址布局

配套资源中的link.ld不是示意模板,而是精确适配 QEMU-machine virt的内存映射。RISC-V virt 机器默认将 DRAM 映射在0x80000000(2GB)起始,而 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.bin(OpenSBI)和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。若省略-bios,QEMU 会尝试直接加载 kernel 到 M-mode,此时csrrw zero, sstatus, t0指令会触发illegal instruction(M-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 interrupt(S-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 interrupt(PLIC 外部中断,UART 收发在此处理) jal x0, handle_sei

trap_vector地址未对齐(如0x80200101),CPU 在 trap 发生时会跳转到非法地址,导致死机。

3.2 handle_sti 必须完成三件事:清 CLINT 中断、更新 next_tick、调用 scheduler

Supervisor 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 位。正确做法是用sd(store doubleword):

li t1, 0x2000000 sd t0, 0(t1) # 此处必须用 sd,否则高 32 位为 0 导致立即再次触发中断

3.3 handle_sei 必须轮询 PLIC 并调用 uart_irq_handler

Supervisor external interrupt 由 PLIC(Platform 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~f31

RISC-V ABI 规定:x1(ra)、x3(gp)、x4(tp)为保留寄存器;x5~x7x28~x31为调用者保存寄存器;x8~x27为被调用者保存寄存器。浮点寄存器f0~f31在启用Zfinx扩展时也需保存。配套资源的task_struct通常定义为:

struct task_struct { uint64_t context[32]; // x1~x31(x0 无需保存) uint64_t fcontext[32]; // f0~f31(双精度浮点) uint64_t sp; // 内核栈指针 int state; };

注意:x0恒为 0,无需保存;x2(sp)在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 registers

5.2 检查 sstatus、sepc、stvec 三个寄存器的值是否符合预期

寄存器期望值不符合的含义
sstatus0x0000000200000100(SIE=1, SPP=0, SPIE=1)SIE 未置位 → 中断被屏蔽;SPP=1 → 仍在 M-mode
sepc0x802000000x80200100(指向 start.S 或 trap_vector)sepc 指向0x0→ 内核未加载成功;指向0x80000000→ 仍停留在 OpenSBI
stvec0x80200100(指向 trap_vector 起始地址)stvec=0 → trap 向量未设置,任何中断都会导致致命错误

执行x/10i $sepc可查看当前执行指令,确认是否在trap_vectorschedule函数内。

5.3 用 GDB 断点验证 timer 中断是否触发 schedule

handle_sti函数开头设断点,观察是否被命中:

(gdb) b handle_sti (gdb) c # 运行几秒后应自动停在 handle_sti (gdb) info registers scause # scause 应为 0x5(Supervisor 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 启动后按Ctrl+A,然后按C进入 monitor 模式:

(qemu) info irq # 应显示:10: 0 0x00000001 (PLIC) ← 表示 UART0 中断已注册且 pending=0 (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 --isa=rv64imac --extension=svinval kernel.elf 替代 QEMU 进行指令级比对

QEMU 的 RISC-V 实现存在与真实硬件偏差(如mtimecmp写入延迟)。Spike 是官方参考模拟器,行为更严格:

# 编译 spike(需从 riscv-isa-sim 仓库获取) ./spike --isa=rv64imac --extension=svinval \ --log=insn \ pk kernel.elf > spike.log 2>&1 # 检查 spike.log 中是否有 "illegal instruction" 或 "page-fault"

若 spike 正常运行而 QEMU 崩溃,大概率是 QEMU 的 CLINT 实现 bug,此时应改用--device=clint,version=0x00000000参数指定 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)。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/14 2:47:08

GPT-6与百万上下文是真是假?揭秘大模型版本命名乱象

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/14 2:46:44

STM32嵌入式开发:从Keil迁移到VS Code+GCC的工程实践

1. 为什么STM32开发者正在集体“逃离”Keil&#xff0c;转向VS Code&#xff1f; 我第一次在客户现场看到工程师用VS Code调试STM32F407时&#xff0c;他正把一个断点打在FreeRTOS的 vTaskDelay() 函数里&#xff0c;同时开着三个终端窗口&#xff1a;一个跑OpenOCD&#xff…

作者头像 李华
网站建设 2026/9/14 2:46:42

工控单板Linux存储、系统升级与恢复出厂实战指南

工控单板跑 Linux&#xff0c;最怕的不是性能不够&#xff0c;而是系统在客户现场出了乱子没人能管。前两篇聊完系统裁剪和启动流程&#xff0c;这篇把存储、升级和恢复出厂这三块一次性讲透。这三件事在开发阶段往往被当成“杂活”&#xff0c;可真上了产线、进了项目&#xf…

作者头像 李华
网站建设 2026/9/14 2:44:42

基于SSM的酒店管理系统:三层架构与并发事务实践

简介&#xff1a;基于SSM框架的酒店管理系统设计与实现项目资源&#xff0c;面向Java Web课程设计、毕业设计及SSM框架初学者。系统采用Spring、SpringMVC、MyBatis三层整合架构&#xff0c;覆盖登录注册、客房管理、订单管理、客户信息维护、财务结算等典型业务场景&#xff0…

作者头像 李华
网站建设 2026/9/14 2:43:52

MATLAB调用GoogLeNet图片分类实战:Inception模块与迁移学习详解

简介&#xff1a;本资源是基于MATLAB实现的GoogLeNet深度卷积神经网络完整工程包&#xff0c;面向具备基础深度学习与MATLAB编程能力的高校学生、科研人员及图像分类实践者&#xff0c;用于快速掌握Inception模块构建、批量归一化应用及端到端图像分类模型训练流程。压缩包共25…

作者头像 李华