说实话,第一眼看到 RISC-V 特权架构文档的时候,我是有点懵的:CSR、M/S/U、mstatus、satp……一堆缩写砸过来,看着像天书。但拆开看其实没那么恐怖。作为一个写了几年嵌入式固件、也折腾过 RISC-V 裸机和系统启动的人,我可以负责任地说,搞懂 CSR 和 M/S/U 三级特权模式,是 RISC-V 开发绕不开的第一道坎。翻过这道坎之后,读 OpenSBI、跑 RTOS、甚至自己写个微型内核,都会顺手很多。这篇文章把我平时查的东西整理成一份速查笔记,核心是三个层次:特权架构 M/S/U 到底在解决什么问题、CSR 如何按权限存取、以及调试时最常见的坑。新手可以通读,老手可以直接跳到后面的速查表和坑位清单。
1. 特权架构 M/S/U:三扇门背后的设计逻辑
1.1 为什么要分特权等级
先回答一个最朴素的问题:为什么不能所有的代码都在一个模式下跑?
早期的单片机裸机程序,整个程序就是一个大循环,所有寄存器随便摸,确实没有"权限"的概念。这种模式在单任务下没问题,但一旦引入操作系统、引入用户程序,就会出大事:用户程序往内核的关键寄存器里写一个错误值,整个系统就崩了。所以处理器设计者引入"特权等级",本质上就是给不同信任级别的代码开门禁。
打个比方:普通读者只能翻书架(U 模式),图书管理员能改台账和借阅记录(S 模式),而馆长能修改馆里所有安全制度和门禁系统(M 模式)。系统里同时跑着多段代码,总得有个办法限制越权行为,这就是特权架构存在的意义。RISC-V 没有照搬 ARM 的 EL0-EL3,而是设计成了更精简的 M/S/U 三层(外加可选的 H 扩展做虚拟化),并且允许根据产品需求裁剪掉 S/U 层——比如一颗资源紧张的 MCU 完全可以只实现 M 模式,跑裸机或简单调度器。
1.2 三级权限各管什么
三种模式的分工,基本和操作系统理论里的上下级关系一一对应。
U 模式(User/Application,编码 00)是权限最低的一层,跑普通应用程序。它不能直接操作任何系统级 CSR,不能执行 mret、sret 这类特权指令,访问受保护的地址会被 MMU 或物理内存保护拦下来。一个跑在 U 模式的进程想让内核干活,唯一正规途径是ecall指令发起环境调用。
S 模式(Supervisor,编码 01)跑操作系统内核。它负责配置页表(satp)、处理虚拟内存、管理进程地址空间,也能操作 stvec、sepc、scause 这套陷入相关 CSR。但 S 模式仍然不能动 M 模式专属的东西,比如直接读写 mepc,或者修改 mstatus 里只有 M 模式才能改的字段。S 模式需要更高层服务时,同样要ecall到 M 模式。
M 模式(Machine,编码 11)是最高等级,通常跑固件、BootROM、安全监控或 Hypervisor。M 模式可以访问全部 CSR,控制整个机器的全局状态,也是唯一能关闭全局中断、修改物理地址映射、重启机器的模式。RISC-V 规范里 M 模式是必须实现的,S/U 都可以按需裁剪。
| 模式 | 编码 | 典型角色 | 能访问的 CSR 范围 |
|---|---|---|---|
| U | 00 | 应用程序、不受信任的用户态代码 | 仅 U 级 CSR,一般不启用 |
| S | 01 | 操作系统内核 | 仅 S 级及以下 CSR |
| M | 11 | 固件、OpenSBI、安全监控、裸机主程序 | 全部 CSR |
1.3 模式切换的入口与出口
模式之间不是随便跳的,靠的是一套"陷入(trap)"机制。
当异常或中断发生时,处理器根据当前特权级别和目标模式,把当前 PC 保存到目标模式的 mepc(M 模式)或 sepc(S 模式),把原因写入 mcause/scause,然后跳转到 mtvec/stvec 指定的 handler。返回动作由mret(返回 M 模式)和sret(返回 S 模式)完成。
这里最容易被忽略的是 mstatus 里的 MPP 字段:发生 trap 进入 M 模式时,当前特权模式会被自动存入 MPP[12:11],当前中断使能位 MIE 存入 MPIE,然后把 MIE 清零。执行 mret 时,处理器从 MPP 恢复原模式,从 MPIE 恢复中断使能。裸机启动代码里如果直接写 mret 却跑飞了,十有八九是 MPP 没设置对。记住一句话:mret 不是"跳回当前模式",它跳回 MPP 记录的模式。
2. CSR 速查:控制寄存器的全家桶
2.1 先看懂 CSR 地址空间的规矩
RISC-V 用 12 位地址来寻址 CSR,理论上最多 4096 个。这 4096 个地址不是乱排的,地址高两位[11:10]直接编码了"这个 CSR 至少需要什么特权级才能访问":
- 00:仅 M 模式可访问
- 01:M/S 模式都可访问
- 10:M/S/U 模式都可访问
- 11:U 模式即可访问
这是个非常实用的规律。比如 mstatus 的地址是 0x300,二进制高两位是 00,因此是 M 模式专属;sstatus 是 0x100,高两位是 01,S 和 M 都能看。你只要打开一份寄存器手册先扫一眼地址,基本就能判断某个 CSR 在哪个模式下能用,不用把整本手册背下来。
另外,CSR 地址的低 8 位通常有语义:0x300 段的 0x0-0x3 是状态与控制,0x1 段是陷入相关,0x4 段是中断控制,0x8 段是计数器。这样编排也是一种自解释设计。
2.2 M 模式核心 CSR,从 mstatus 到 mhartid
M 模式 CSR 是所有模式下最全的,实际开发里我最常碰的就是下面这几个:
mstatus(0x300)是绝对的主角,世界状态都在它里面。需要盯住几个位域:
- MIE[3]:M 模式全局中断使能,MIE=0 时所有 M 模式中断都不响应
- MPIE[7]:发生 trap 前 MIE 的值,mret 时按这个值恢复
- MPP[12:11]:发生 trap 前的特权模式,mret 时按这个值恢复模式
- FS[14:13]:浮点单元状态,裸机里要手动置成 Initial(01)或 Clean(00),否则用浮点可能触发非法指令
- MPRV[17]、SUM[18]、TVM[20]、TW[21]、TSR[22]:内存访问和 S 模式行为控制,调试虚拟内存时会碰到
misa(0x301)用于判断硬件到底实现了哪些扩展:读出来之后检查 A 位(原子操作)、C 位(压缩指令)、F/D 位(浮点)等。写裸机初始化代码时,我习惯第一件事读 misa,根据结果决定哪些功能能用,别盲目信编译器参数。
mtvec(0x305)是 trap 入口地址寄存器,它和普通地址寄存器不一样,最低两位是模式位:00 表示 Direct 模式,所有异常都跳转到同一个 base;01 表示 Vectored 模式,跳转到 base + 4 * 异常原因号。写之前要做好对齐,通常要求 4 字节对齐,vectored 模式要求更高。
mepc(0x341)保存异常发生时的 PC。注意:对于 ecall、非法指令这类异常,mepc 指向触发异常的指令本身;对于中断,mepc 指向被中断的指令。返回前是否需要加偏移,取决于异常类型,这是 trap handler 最经典的坑。
mcause(0x342)是异常原因编码。读它之前先看最高位:为 1 表示中断,为 0 表示异常。低位数对应具体原因,后面我会给一张快速速查表。
mtval(0x343)记录辅助信息:地址异常时是出错地址,非法指令时可能是指令编码。调页错误、非法地址时,这个寄存器就是救命稻草。
还有两个高频使用的:mscratch(0x340)在 M 模式 trap handler 里通常用来临时保存指针,避免破坏通用寄存器上下文;mhartid(0xF14)表示当前硬件线程 ID,多核启动时每个核都靠它区分自己该跑哪段代码。mhartid 是只读 CSR,用 csrw 写它会被判定非法指令,这个我踩过一次,后面会细说。
2.3 S/U 模式 CSR 与委托机制
进入 S 模式之后,能用的 CSR 一下子就"瘦身"了。S 模式最重要的几个:
sstatus(0x100)是 mstatus 的投影子集,S 模式程序只能看到并修改里面允许暴露的位,比如 SIE、SPIE、SPP、SUM、FS。S 模式想开全局中断,写的是 sstatus.SIE,不是 mstatus.MIE,因为后者根本不可访问。
sie(0x104)和 sip(0x144)是 S 模式的中断使能与等待状态,对应 M 模式的 mie/mip。stvec(0x105)、sepc(0x141)、scause(0x142)、stval(0x143)用途和 M 模式一一对应,不再赘述。
satp(0x180)是 S 模式最独特的 CSR:它管虚拟地址转换。satp 的高位是 MODE(比如 Sv39 模式编码 8,Sv48 编码 9),中间是 ASID,低 44 位是根页表的物理页号 PPN。S 模式下开启 MMU 就是往 satp 里写这个值。写完之后通常要执行sfence.vma刷新 TLB,否则旧映射可能残留。
关于异常和中断委托:默认情况下,所有 trap 都进 M 模式。但 Linux 这类操作系统不希望每次中断都陷入 M 模式再绕回 S 模式,那样性能太差。RISC-V 提供了 medeleg(0x302,异常委托)和 mideleg(0x303,中断委托):把对应原因号对应的位置 1,该 trap 就会直接进入 S 模式,走 stvec/sepc/scause。这个机制在跑系统时很关键,OpenSBI 启动 Linux 之前干的主要事情之一就是配这两个委托寄存器。
U 模式也有对应的 ustatus/uepc/ucause 等一套 CSR,在纯 U 模式下跑用户程序时可用,但大多数场景 U 模式访问不到这些 CSR(需要 M 模式显式开启),所以用户程序实际上通过 ecall 陷入内核来处理系统调用,自己很少直接碰 CSR。
3. CSR 访问指令:读、写、置位、清除一次搞定
3.1 六条指令的语义与关键区别
RISC-V 提供了 6 条 CSR 指令,本质都是"读-改-写",但组合方式不同,写代码时很容易弄混。我把它们列成表:
| 指令 | 语义 | 典型用法 |
|---|---|---|
| csrrw rd, csr, rs1 | 读旧值到 rd,再把 rs1 写入 csr | 原子替换整个 CSR |
| csrrs rd, csr, rs1 | rd = 旧值,csr |= rs1 | 原子置位 |
| csrrc rd, csr, rs1 | rd = 旧值,csr &= ~rs1 | 原子清位 |
| csrrwi rd, csr, zimm[4:0] | csrrw 的立即数版 | 写入低 5 位立即数 |
| csrrsi rd, csr, zimm[4:0] | csrrs 的立即数版 | 用立即数位置位 |
| csrrci rd, csr, zimm[4:0] | csrrc 的立即数版 | 用立即数位清除 |
配合 x0 寄存器,还产生了一组伪指令:
csrr rd, csr等价于csrrs rd, csr, x0,就是只读,不改写,这是读取 CSR 最常用的写法csrw csr, rs1等价于csrrw x0, csr, rs1,只写,不关心旧值csrsi csr, imm、csrci csr, imm分别等价于csrrsi x0, csr, imm、csrrci x0, csr, imm
注意一个关键差异:csrrw 即使 rd 是 x0,写操作也照常发生;而 csrrs 在 rs1 是 x0 时只做读取,不修改 CSR。所以"读取"必须用 csrr(csrrs 变体),不能用 csrrw,否则会把 x0 写进 CSR,一个不小心的初始化 bug 就可能从这里出来。
3.2 在 C 语言里操作 CSR 的常用姿势
汇编里操作 CSR 很直接,但在 C 里要靠内联汇编。我在 Solo5 和 OpenSBI 的项目里见过一套很好用的写法,抄下来放头文件里就能用:
#define read_csr(reg) ({ unsigned long __tmp; \ asm volatile ("csrr %0, " #reg : "=r" (__tmp)); \ __tmp; }) #define write_csr(reg, val) \ asm volatile ("csrw " #reg ", %0" :: "rK" (val)) #define set_csr(reg, bits) \ asm volatile ("csrrs x0, " #reg ", %0" :: "rK" (bits)) #define clear_csr(reg, bits) \ asm volatile ("csrrc x0, " #reg ", %0" :: "rK" (bits))使用起来是这样的:
uintptr_t mstatus = read_csr(mstatus); write_csr(mtvec, (uintptr_t)&trap_entry); set_csr(mie, 0x80); // 置位 MTIE,使能 M 模式定时器中断 clear_csr(mip, 0x80); // 清除定时器中断 pending这个宏有个细节:寄存器约束用 "rK",它告诉编译器寄存器也可以直接给立即数,这样set_csr(mie, 0x80)能直接编码成csrsi mie, 0x80,比先 load 到寄存器再 csrrs 省一条指令,实测下来对启动代码体积有帮助。
3.3 权限不足、只读、未实现的坑
CSR 访问失败的常见原因有三类:
第一类是权限不足。S 模式下访问 mstatus,或者 U 模式下访问任何特权 CSR,都会触发非法指令异常(mcause=2)。遇到这种问题,先看一眼当前是不是跑在你想的那个模式下——QEMU/GDB 里直接看当前的 privilege level 寄存器。
第二类是对只读 CSR 执行写操作。比如 mhartid、misa 都是只读的,你拿csrw mhartid, t0,硬件直接产生非法指令异常。有时候这个异常在异常 handler 里又写入同一个只读 CSR,就变成双重故障,reset 都进不去,表现是"程序突然死了,串口没输出"。
第三类是访问了没有实现的 CSR。RISC-V 允许硬件管理员按用途裁 CSR 集合,某些 vendor 芯片没有实现某些标准 CSR。结果就是你在一个芯片上调试好好的程序,换一个芯片就非法指令了。排查方法:先读 misa 确认扩展是否存在,再查芯片手册确认 CSR 列表,别默认所有标准地址都存在。
4. 实操:从 M 模式启动到 S 模式入口
4.1 最小 M 模式 trap handler 模板
写裸机 RISC-V 程序,第一步就是把 trap handler 建好,否则任何异常都会让 CPU 跳到 0 地址或者某个未知地址,调试无从下手。
我常用的最小 handler 长这样:
.section .text .align 4 .globl trap_entry trap_entry: # 保存现场,这里以 t0/t1 示意,真实代码需要保存更多寄存器 csrr t0, mcause csrr t1, mepc # 如果是 ecall 触发,需要把 mepc 加 4,否则 mret 后会再次进入 ecall # 判断 mcause 是否等于 11(M-mode ecall) li t2, 11 bne t0, t2, 1f addi t1, t1, 4 1: csrw mepc, t1 # 恢复现场 mret然后在 C 或汇编启动代码里挂上它:
write_csr(mtvec, (uintptr_t)&trap_entry);这里强调两点:
第一,mtvec 的地址必须对齐。Direct 模式至少 4 字节对齐;Vectored 模式要求更高。我建议 trap_entry 总是加.align 4,启动代码里再打印一次对齐检查,省得后面莫名其妙跑飞。
第二,ecall 返回地址的问题。ecall 指令本身执行后,mepc 保存的是 ecall 指令的地址,如果 handler 不修,mret 回来还会执行同一条 ecall,又进 trap,死循环。所以 handler 里要么按异常原因判断并给 mepc 加 4,要么你自己在设计系统调用时约定返回地址已调整。这个坑我至少踩过两次,一次是裸机演示程序,一次是自己写的微型内核,症状一模一样:程序进 ecall 后卡死,但看 pc 又一直在跳。
4.2 跳进 S 模式前必须配好的三样东西
从 M 模式启动后,把主程序切到 S 模式运行,是跑 Linux 或自己写 OS 的必经环节。这个切换看似就一个 mret,实际要配好多东西,核心是三样:satp、stvec、sepc。
完整流程一般是这样的:
// 1. 建立 S 模式页表,把根页表物理地址写到 satp // 假设使用 Sv39,根页表页号是 root_pt_pa >> 12 write_csr(satp, (8UL << 60) | (root_pt_pa >> 12)); // 2. 设置 S 模式 trap 入口 write_csr(stvec, (uintptr_t)&s_trap_entry); // 3. 设置 S 模式入口地址 write_csr(sepc, (uintptr_t)&kernel_entry); // 4. 配置 mstatus.MPP=01,让 mret 后进入 S 模式 // MPP 是 [12:11],要写 0x800 clear_csr(mstatus, 0x1800); // 先清零 MPP 两位 set_csr(mstatus, 0x800); // MPP=01 // 5. mret 跳转 mret这里最容易翻车的是顺序。如果先开 MMU(写 satp)再配 stvec/sepc,万一中间某个访存触发 page fault,trap 入口还没配好,CPU 不知道去哪。我习惯先配 stvec、sepc,再配 satp,最后才 mret。而且第一次做内核启动时,建议先用 satp=0(Bare 模式,不开 MMU)验证 S 模式入口和 stvec 能正常工作,再一步步开 MMU。直接一步到位开虚拟内存,出问题时会同时面对"页表错"和"模式切换错"两个变量,排查难度翻倍。
另外,写完 satp 之后通常要执行sfence.vma刷新 TLB。如果 S 模式页表改动了还要再发一次,否则旧映射会一直缓存,表现为"改了页表但地址访问结果没变"。
4.3 用 QEMU 验证模式切换和 CSR 访问
我调试 RISC-V 裸机和内核最喜欢用 QEMU 的 virt 机器,启动快速,而且能直接看 CSR。一条命令就能跑起来:
qemu-system-riscv64 -machine virt -nographic -bios none -kernel your_bare.elf如果想用 GDB 调试,加-s -S:
qemu-system-riscv64 -machine virt -nographic -bios none -kernel your_bare.elf -s -S # 另一个终端 riscv64-unknown-elf-gdb your_bare.elf (gdb) target remote :1234 (gdb) load (gdb) break trap_entry (gdb) continueGDB 对 RISC-V 的 CSR 支持取决于版本。新版本可以用p/x $mepc、p/x $mstatus这类方式读,但我也遇到过只支持通用寄存器的 GDB,这种情况下不要浪费时间,直接让程序自己把 mcause/mepc/mtval 打印到串口更高效。我自己的习惯是:启动代码里预留一个dump_trap_csr函数,trap 时打印三个值,省得每次连调试器。
实测下来 QEMU 对 RISC-V 的 CSR 模型实现得很"教科书",绝大多数标准 CSR 都能用。但它也有裁剪,比如一些 vendor CSR 根本不存在,访问就会非法指令;另外 QEMU 默认模拟的 CPU 不一定支持你代码里用到的所有扩展,可以用-cpu rv64gcsu这类参数显式指定 CPU 能力,避免"换台机器就崩"的错觉。
5. 常见问题与排查技巧实录
5.1 读 CSR 直接崩了 / 非法指令
症状:程序执行到csrr就跳进 trap handler,或者干脆跑飞重启。
排查思路按顺序走:
- 看 mcause,是不是 2(非法指令)。如果是,说明指令编码本身无问题,大概率是 CSR 访问出了问题。
- 确认当前特权模式。S 模式读 mstatus 必然非法,M 模式读一切正常。QEMU 里可以用
info registers查看当前特权级,真硬件上就看 mstatus.MPP。 - 确认 CSR 是否实现。读 misa 检查相关扩展:比如 FPU 不存在时,访问浮点相关 CSR(fcsr 等)也会非法指令。
- 确认是否写了只读 CSR。mhartid、misa 这种只读的,任何写操作都会非法。
我自己踩过一次很隐蔽的:在 32 位 RISC-V 上使用了一个 64 位 CSR 指令(CSRR 带双字),编译器生成了非法编码,导致每次运行都 trap。后来发现是编译器的默认 arch 参数没对齐芯片能力,指定-march=rv32imac之后就好了。
5.2 trap 进 handler 出不来:先查 mepc 和 mstatus
症状:程序第一次触发 ecall 或中断之后,就再也不返回正常流程了,反复卡在 trap handler 里。
十有八九是这两个原因:mepc 没有正确调整,或者 mstatus 的 MPP/MPIE 被自己写乱了。
ecall 的情况最经典。mepc 保存的是 ecall 自己,不处理就会无限循环。解决办法前面说过:handler 里给 mepc 加 4。这里有个细节:不是所有异常都要加 4。非法指令、地址访问错误这类同步异常,mepc 指向肇事指令,有时候你真的希望返回后重新执行它(比如某种模拟器场景)?大部分裸机调试,我是直接在 handler 里统一打印后停住,不返回,省心。只有在系统调用场景才做 mepc+4 并返回。
另一个常见问题是:你在 trap handler 里用 mstatus 保存/恢复现场时,不小心把 MIE、MPP 改了,导致 mret 后进入了一个不存在的模式,或者中断使能状态完全反了。建议 handler 进入后第一件事就把整个 mstatus 压栈,退出前恢复,不要依赖"它没被改过"。
5.3 中断一直触发停不下来
症状:定时器中断配好之后,系统一直在进中断,主循环根本没机会跑。
先查三层开关:
- 最外层是 mstatus.MIE,全局中断总闸。
- 中间层是 mie 的对应位,比如定时器中断使能位是 MTIE[7]。
- 最里面是中断源本身是否 pending,mip.MTIP[7]。
如果三层都是 1,但 handler 里没清中断源,就会不停进 trap。RISC-V 定时器中断的处理方式和 ARM 不同:它不是硬件帮你清事件,你要主动往定时器比较器写入下一个触发时间,再清 mip 里的 pending 位,或者干脆等下一次比较器触发时重新开始。很多移植过来的固件开发者在 ARM 上养成了"进中断就自动清"的习惯,到 RISC-V 上就翻车。
处理方式一般是:
// 进入 handler 后 clear_csr(mip, 0x80); // 清除定时器中断 pending // 重新设置下一次触发的比较值 *(uint64_t *)CLINT_MTIMECMP = current_time + interval;5.4 异常原因编码速查
调试时最常看的寄存器就是 mcause/scause,下面这张表我打印出来贴在工位:
| mcause 低位值 | 异常含义 | 常见触发场景 |
|---|---|---|
| 0 | 指令地址未对齐 | pc 不是 4/2 字节对齐 |
| 1 | 指令访问错误 | 取指访问非法物理地址 |
| 2 | 非法指令 | 非法编码、CSR 权限不足、未实现 CSR |
| 3 | 断点 | ebreak 指令 |
| 4 | Load 地址未对齐 | 非法对齐的 Load |
| 5 | Load 访问错误 | 读非法物理地址 |
| 6 | Store/AMO 地址未对齐 | 非法对齐的 Store |
| 7 | Store/AMO 访问错误 | 写非法物理地址 |
| 8 | U 模式 ecall | U 模式发起环境调用 |
| 9 | S 模式 ecall | S 模式发起环境调用 |
| 11 | M 模式 ecall | M 模式发起,一般很少见 |
| 12 | 指令页错误 | 取指虚拟地址转换失败 |
| 13 | Load 页错误 | 读虚拟地址转换失败 |
| 15 | Store/AMO 页错误 | 写虚拟地址转换失败 |
中断部分要看 mcause 最高位是否 1,低位数对应中断源:1 是软中断,3 是机器定时器中断,7 是外部中断,9 是 S 模式软中断(IPI),11 是 S 模式定时器,13 是 S 模式外部中断。裸机最先接触到的就是 3(定时器)和 7(外部中断)。
5.5 顺带澄清:此 CSR 非彼 CSR
最后说个容易混的事。RISC-V 里的 CSR 是 Control and Status Register(控制与状态寄存器),但计算机领域还有一个同样缩写为 CSR 的名词:Compressed Sparse Row(压缩稀疏行),常用于图论、稀疏矩阵存储。有人问"邻接表和 CSR 压缩存储,内存空间消耗是同一个量级吗",问的就是后者。
结论先给:从渐进复杂度看,两者都是 O(V+E),确实是同一个量级。但从实际内存开销和缓存表现,差距可以很大。邻接表如果用链表存边,每个边节点除了目标顶点还要存一个 next 指针,而且每个顶点独立链表会引入大量 allocator 开销,碎片和内存峰值比 CSR 高三倍以上很常见。CSR 用两个连续数组(行偏移数组 + 邻接数组)就能表示完整图结构,除了数组本身几乎没有额外开销,遍历时缓存命中也好很多。如果你图特别大、内存敏感,选 CSR 这类紧凑存储更稳;如果你频繁插入删除边,邻接表更灵活。都属于图存储领域的话题,别和 RISC-V 的控制状态寄存器混在一起看。
我自己的体会是,碰到这种同名缩写,一定要先看上下文:讲处理器、讲到 mtvec/mstatus 的时候,CSR 一定是控制状态寄存器;讲图算法、提到"邻接表""稀疏矩阵"的时候,CSR 大概率是压缩稀疏行。跨领域查资料时这个区分能省不少时间。
RISC-V 特权架构的学习曲线不算平缓,但核心就一句话:想清楚当前在哪个模式、想访问哪个 CSR、这个 CSR 属于哪个模式,剩下的都是熟能生巧。我建议每个新手都从裸机 M 模式开始,先写好 trap handler,再往前推进到 S 模式和 MMU。等你在 QEMU 里把 M→S 的切换跑通,再回头看 OpenSBI 的代码,很多地方都能对上了。