ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

RISC-V特权架构与CSR速查:M/S/U模式及中断处理实战

2026/10/7 14:44:56 拓冰建站 浏览量
RISC-V特权架构与CSR速查:M/S/U模式及中断处理实战 说实话第一眼看到 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 范围U00应用程序、不受信任的用户态代码仅 U 级 CSR一般不启用S01操作系统内核仅 S 级及以下 CSRM11固件、OpenSBI、安全监控、裸机主程序全部 CSR1.3 模式切换的入口与出口模式之间不是随便跳的靠的是一套陷入trap机制。当异常或中断发生时处理器根据当前特权级别和目标模式把当前 PC 保存到目标模式的 mepcM 模式或 sepcS 模式把原因写入 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 模式可访问01M/S 模式都可访问10M/S/U 模式都可访问11U 模式即可访问这是个非常实用的规律。比如 mstatus 的地址是 0x300二进制高两位是 00因此是 M 模式专属sstatus 是 0x100高两位是 01S 和 M 都能看。你只要打开一份寄存器手册先扫一眼地址基本就能判断某个 CSR 在哪个模式下能用不用把整本手册背下来。另外CSR 地址的低 8 位通常有语义0x300 段的 0x0-0x3 是状态与控制0x1 段是陷入相关0x4 段是中断控制0x8 段是计数器。这样编排也是一种自解释设计。2.2 M 模式核心 CSR从 mstatus 到 mhartidM 模式 CSR 是所有模式下最全的实际开发里我最常碰的就是下面这几个mstatus0x300是绝对的主角世界状态都在它里面。需要盯住几个位域MIE[3]M 模式全局中断使能MIE0 时所有 M 模式中断都不响应MPIE[7]发生 trap 前 MIE 的值mret 时按这个值恢复MPP[12:11]发生 trap 前的特权模式mret 时按这个值恢复模式FS[14:13]浮点单元状态裸机里要手动置成 Initial01或 Clean00否则用浮点可能触发非法指令MPRV[17]、SUM[18]、TVM[20]、TW[21]、TSR[22]内存访问和 S 模式行为控制调试虚拟内存时会碰到misa0x301用于判断硬件到底实现了哪些扩展读出来之后检查 A 位原子操作、C 位压缩指令、F/D 位浮点等。写裸机初始化代码时我习惯第一件事读 misa根据结果决定哪些功能能用别盲目信编译器参数。mtvec0x305是 trap 入口地址寄存器它和普通地址寄存器不一样最低两位是模式位00 表示 Direct 模式所有异常都跳转到同一个 base01 表示 Vectored 模式跳转到 base 4 * 异常原因号。写之前要做好对齐通常要求 4 字节对齐vectored 模式要求更高。mepc0x341保存异常发生时的 PC。注意对于 ecall、非法指令这类异常mepc 指向触发异常的指令本身对于中断mepc 指向被中断的指令。返回前是否需要加偏移取决于异常类型这是 trap handler 最经典的坑。mcause0x342是异常原因编码。读它之前先看最高位为 1 表示中断为 0 表示异常。低位数对应具体原因后面我会给一张快速速查表。mtval0x343记录辅助信息地址异常时是出错地址非法指令时可能是指令编码。调页错误、非法地址时这个寄存器就是救命稻草。还有两个高频使用的mscratch0x340在 M 模式 trap handler 里通常用来临时保存指针避免破坏通用寄存器上下文mhartid0xF14表示当前硬件线程 ID多核启动时每个核都靠它区分自己该跑哪段代码。mhartid 是只读 CSR用 csrw 写它会被判定非法指令这个我踩过一次后面会细说。2.3 S/U 模式 CSR 与委托机制进入 S 模式之后能用的 CSR 一下子就瘦身了。S 模式最重要的几个sstatus0x100是 mstatus 的投影子集S 模式程序只能看到并修改里面允许暴露的位比如 SIE、SPIE、SPP、SUM、FS。S 模式想开全局中断写的是 sstatus.SIE不是 mstatus.MIE因为后者根本不可访问。sie0x104和 sip0x144是 S 模式的中断使能与等待状态对应 M 模式的 mie/mip。stvec0x105、sepc0x141、scause0x142、stval0x143用途和 M 模式一一对应不再赘述。satp0x180是 S 模式最独特的 CSR它管虚拟地址转换。satp 的高位是 MODE比如 Sv39 模式编码 8Sv48 编码 9中间是 ASID低 44 位是根页表的物理页号 PPN。S 模式下开启 MMU 就是往 satp 里写这个值。写完之后通常要执行sfence.vma刷新 TLB否则旧映射可能残留。关于异常和中断委托默认情况下所有 trap 都进 M 模式。但 Linux 这类操作系统不希望每次中断都陷入 M 模式再绕回 S 模式那样性能太差。RISC-V 提供了 medeleg0x302异常委托和 mideleg0x303中断委托把对应原因号对应的位置 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原子替换整个 CSRcsrrs rd, csr, rs1rd 旧值csr | rs1原子置位csrrc rd, csr, rs1rd 旧值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。所以读取必须用 csrrcsrrs 变体不能用 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都会触发非法指令异常mcause2。遇到这种问题先看一眼当前是不是跑在你想的那个模式下——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 是否等于 11M-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.MPP01让 mret 后进入 S 模式 // MPP 是 [12:11]要写 0x800 clear_csr(mstatus, 0x1800); // 先清零 MPP 两位 set_csr(mstatus, 0x800); // MPP01 // 5. mret 跳转 mret这里最容易翻车的是顺序。如果先开 MMU写 satp再配 stvec/sepc万一中间某个访存触发 page faulttrap 入口还没配好CPU 不知道去哪。我习惯先配 stvec、sepc再配 satp最后才 mret。而且第一次做内核启动时建议先用 satp0Bare 模式不开 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 -Sqemu-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 不存在时访问浮点相关 CSRfcsr 等也会非法指令。确认是否写了只读 CSR。mhartid、misa 这种只读的任何写操作都会非法。我自己踩过一次很隐蔽的在 32 位 RISC-V 上使用了一个 64 位 CSR 指令CSRR 带双字编译器生成了非法编码导致每次运行都 trap。后来发现是编译器的默认 arch 参数没对齐芯片能力指定-marchrv32imac之后就好了。5.2 trap 进 handler 出不来先查 mepc 和 mstatus症状程序第一次触发 ecall 或中断之后就再也不返回正常流程了反复卡在 trap handler 里。十有八九是这两个原因mepc 没有正确调整或者 mstatus 的 MPP/MPIE 被自己写乱了。ecall 的情况最经典。mepc 保存的是 ecall 自己不处理就会无限循环。解决办法前面说过handler 里给 mepc 加 4。这里有个细节不是所有异常都要加 4。非法指令、地址访问错误这类同步异常mepc 指向肇事指令有时候你真的希望返回后重新执行它比如某种模拟器场景大部分裸机调试我是直接在 handler 里统一打印后停住不返回省心。只有在系统调用场景才做 mepc4 并返回。另一个常见问题是你在 trap handler 里用 mstatus 保存/恢复现场时不小心把 MIE、MPP 改了导致 mret 后进入了一个不存在的模式或者中断使能状态完全反了。建议 handler 进入后第一件事就把整个 mstatus 压栈退出前恢复不要依赖它没被改过。5.3 中断一直触发停不下来症状定时器中断配好之后系统一直在进中断主循环根本没机会跑。先查三层开关最外层是 mstatus.MIE全局中断总闸。中间层是 mie 的对应位比如定时器中断使能位是 MTIE[7]。最里面是中断源本身是否 pendingmip.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 权限不足、未实现 CSR3断点ebreak 指令4Load 地址未对齐非法对齐的 Load5Load 访问错误读非法物理地址6Store/AMO 地址未对齐非法对齐的 Store7Store/AMO 访问错误写非法物理地址8U 模式 ecallU 模式发起环境调用9S 模式 ecallS 模式发起环境调用11M 模式 ecallM 模式发起一般很少见12指令页错误取指虚拟地址转换失败13Load 页错误读虚拟地址转换失败15Store/AMO 页错误写虚拟地址转换失败中断部分要看 mcause 最高位是否 1低位数对应中断源1 是软中断3 是机器定时器中断7 是外部中断9 是 S 模式软中断IPI11 是 S 模式定时器13 是 S 模式外部中断。裸机最先接触到的就是 3定时器和 7外部中断。5.5 顺带澄清此 CSR 非彼 CSR最后说个容易混的事。RISC-V 里的 CSR 是 Control and Status Register控制与状态寄存器但计算机领域还有一个同样缩写为 CSR 的名词Compressed Sparse Row压缩稀疏行常用于图论、稀疏矩阵存储。有人问邻接表和 CSR 压缩存储内存空间消耗是同一个量级吗问的就是后者。结论先给从渐进复杂度看两者都是 O(VE)确实是同一个量级。但从实际内存开销和缓存表现差距可以很大。邻接表如果用链表存边每个边节点除了目标顶点还要存一个 next 指针而且每个顶点独立链表会引入大量 allocator 开销碎片和内存峰值比 CSR 高三倍以上很常见。CSR 用两个连续数组行偏移数组 邻接数组就能表示完整图结构除了数组本身几乎没有额外开销遍历时缓存命中也好很多。如果你图特别大、内存敏感选 CSR 这类紧凑存储更稳如果你频繁插入删除边邻接表更灵活。都属于图存储领域的话题别和 RISC-V 的控制状态寄存器混在一起看。我自己的体会是碰到这种同名缩写一定要先看上下文讲处理器、讲到 mtvec/mstatus 的时候CSR 一定是控制状态寄存器讲图算法、提到邻接表稀疏矩阵的时候CSR 大概率是压缩稀疏行。跨领域查资料时这个区分能省不少时间。RISC-V 特权架构的学习曲线不算平缓但核心就一句话想清楚当前在哪个模式、想访问哪个 CSR、这个 CSR 属于哪个模式剩下的都是熟能生巧。我建议每个新手都从裸机 M 模式开始先写好 trap handler再往前推进到 S 模式和 MMU。等你在 QEMU 里把 M→S 的切换跑通再回头看 OpenSBI 的代码很多地方都能对上了。