ARTICLE DETAIL

建站实战干货

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

RISC-V特权架构与CSR详解:从M/S/U模式到中断切换实战

2026/10/7 1:41:08 拓冰建站 浏览量
RISC-V特权架构与CSR详解:从M/S/U模式到中断切换实战 写这篇专栏之前我先坦白一件事RISC-V 的 CSRControl and Status Register控制与状态寄存器是我早期最头疼的部分。不是说它难而是资料太散规范文档翻起来像查字典今天记三个明天忘两个真正写启动代码或者看 OpenSBI 的时候又发现自己根本没搞懂特权级之间是怎么切来切去的。后来我把 M/S/U 特权架构和 CSR 这摊事串起来理解才有一种“通了”的感觉。这篇文章就把这套脉络整理给你——risc-v 的 CSR 速查只是表面重点是特权架构 M/S/U 三条权限线如何通过一组寄存器互相制衡、配合、切换以及你在实操中会遇到哪些真正会卡住人的细节。1. 特权架构 M/S/URISC-V 把“权限”拆成了三层1.1 为什么必须有三层模式很多玩单片机出身的人第一次接触 RISC-V 特权架构都会问一句我裸奔跑一个程序要 M 模式就够了搞出 M/S/U 三种模式不是自找麻烦吗这个问题得放到真实场景里看。你要是只写一个 LED 闪烁程序当然不需要特权级。但只要你打算在芯片上跑一个像样的操作系统比如 Linux就不可能让用户程序和操作系统内核拥有同样的权限。否则用户程序一个非法指令就能把内核打穿更别说访问物理内存、改页表、操作中断控制器这些高危行为了。RISC-V 把权限抽象成三种模式M 模式Machine Mode最高权限裸机代码、BootROM、OpenSBI、安全监控固件都在这一层跑能访问所有 CSR 和全部物理内存。S 模式Supervisor Mode中等权限跑操作系统内核。可以操作页表、处理大多数异常和中断但有些 M 模式的寄存器摸不到。U 模式User Mode最低权限跑普通应用程序。访问不了特权 CSR只能通过 ecall 陷入更高权限层请求服务。如果不需要跑 Linux很多小芯片根本不实现 S 模式只有 M 和 U 两层。RISC-V 的设计里没有“必须三级全有”的压力你可以裁剪成 M/U 两态甚至只有 M 一态。这种可裁剪性正是 RISC-V 能在嵌入式和高性能处理器两个极端并存的原因。1.2 三种模式各自的能力边界从 RISC-V 规范的角度看特权模式之间的能力差异主要体现在三件事上能否访问特定 CSR、能否执行特定特权指令、能否访问特定内存区域通过 PMP 或页表机制。先说 CSR 访问。CSR 地址本身编码了它所属的特权层级M 模式的 CSR 只有 M 模式能读S 模式和 U 模式读了直接触发非法指令异常。S 模式 CSR 可以被 M 模式读但 U 模式不行。这就是一个典型的权限金字塔高特权能访问低特权和自己的资源低特权碰不了上面的资源。再说特权指令。比如 mret 是 M 模式专用返回指令sret 是 S 模式专用返回指令U 模式执行这些指令同样会触发非法指令异常。ecall 指令则是设计给 U 模式“主动认输”用的——用户程序想请求内核服务就执行 ecall硬件自动跳到高特权级的异常入口。最后是内存隔离。M 模式可以用 PMPPhysical Memory Protection限制 S/U 模式访问物理内存的权限。S 模式则通过页表来隔离不同用户进程。这一层层的限制本质上是让每一层只拥有完成自己任务所需的最小权限出了事不会波及整个系统。1.3 隐藏在规范里的第四个特权域顺带提一句RISC-V 特权规范其实还有一个 H 模式Hypervisor它在 S 模式之下、U 模式之上的虚拟化扩展用来支持虚拟机监视器。不过目前绝大多数面向嵌入式或桌面场景的实现实际只用到 M/S/U 三级。H 模式相关的 CSR 地址空间保留在那里但没开虚拟化扩展的核完全不用关心。我建议初学者先把 M/S/U 三者的关系搞清楚H 模式等你真要去调虚拟机再翻不迟。2. CSR 到底是什么控制状态寄存器的结构、地址编排与速查表2.1 CSR 地址空间怎么编排12 位地址背后的门道CSR 是 RISC-V 里一类特殊的寄存器它不在通用寄存器堆里而是通过独立的地址空间访问。这个地址空间只有 12 位所以最多 4096 个 CSR。听起来不多但 RISC-V 的 CSR 地址设计是有讲究的——它不仅是一个标识还约定了访问权限。12 位地址的最高两位bit[11:10]用来声明这个 CSR 属于哪个特权级11开头的是 M 模式 CSR01开头的是 S 模式 CSR00开头的是 U 模式 CSR10开头的是预留的 H 模式 CSR。硬件在执行 CSR 读写指令时会检查当前特权级是否满足这个编码要求U 模式去访问11开头的寄存器直接报 illegal instructionS 模式访问11开头也要报错但 M 模式可以访问01和00开头的寄存器。还有一个容易忽略的细节CSR 地址的中间位也不是乱排的。bit[9:8] 范围涵盖了“这个寄存器是否只读”的信息具体见规范。我建议初学阶段不必把所有位都背下来但一定要理解高两位的含义因为它直接影响你会不会踩“访问了不该访问的 CSR”这种 bug。表CSR 地址高两位与特权级对应关系地址高两位所属特权级谁会访问11Machine仅 M 模式01SupervisorM 模式、S 模式00UserM/S/U 均可10Hypervisor预留虚拟化场景使用2.2 CSR 读写指令与语法访问 CSR 靠的是专门的指令不是普通的 load/store。RISC-V 提供了 4 类 CSR 指令csrrw读后写、csrrs读后置位、csrrc读后清位以及带立即数的csrrwi、csrrsi、csrrci。# 读 CSR把 mtvec 读到 t0 csrr t0, mtvec # 写 CSR把 t0 写入 mtvec csrw mtvec, t0 # 置位将 mstatus 的 MIE 位置 1保留其他位不变 csrsi mstatus, 0x8 # 清位将 mstatus 的 MIE 位清零保留其他位不变 csrci mstatus, 0x8这里我特别想提醒一件事csrrs和csrrc这类读-改-写指令非常适合用来单独操作某个 CSR 的特定位因为直接csrrw会把你读到的旧值和写进去的新值搞混。以前我见过新手在中断使能时图省事写csrw mstatus, t0结果把别的状态位也冲掉了排查半天。正确做法是先读回、再改需要的位、再写回或者直接用csrrs/csrci这一类指令。2.3 常用 M 模式 CSR 速查M 模式的 CSR 数量最多但真正频繁使用的就那么十几个。我做了一张速查表把名字、地址和用途浓缩在几行里CSR地址作用mstatus0x300记录全局中断使能、上一次特权级、历史中断使能状态等misa0x301CPU 支持的 ISA 扩展位图告诉软件这款核支持哪些指令mie0x304机器模式局部中断使能控制哪些中断能触发 M 模式处理mtvec0x305机器模式异常入口地址指定 trap handler 跳去哪mscratch0x340机器模式备用寄存器通常用来临时保存栈指针或 context 指针mepc0x341发生异常/中断时被打断指令的 PCmcause0x342异常/中断原因编号告诉 handler 是因为什么跳进来的mtval0x343异常附加信息比如访问内存出错的地址或非法指令编码mip0x344机器模式中断挂起位反映哪些中断源当前有 pendingmhartid0xF14当前硬件线程 ID多核环境下用来区分“我是哪个核”medeleg0x302异常委托寄存器哪些异常直接交给 S 模式处理mideleg0x303中断委托寄存器哪些中断直接交给 S 模式处理这里面mhartid是只读的而且它在启动流程里特别重要——多核芯片上每个核都有自己的mhartidBootROM 或固件靠它判断自己是主核还是从核再做不同的初始化跳转。misa也是只读寄存器写它是无效的。2.4 常用 S/U 模式 CSR 速查如果系统里实现了 S 模式那么 S 模式自己的 CSR 也不多大多是 M 模式寄存器的“子集视图”。所谓子集视图意思是寄存器名称不同、地址不同但内部字段与对应 M 模式 CSR 的某些字段一一对应。比如读sstatus实际上是在读写mstatus的一部分位。CSR地址作用sstatus0x100S 模式状态只暴露 mstatus 中 S 模式可见的位sie0x104S 模式中断使能stvec0x105S 模式异常入口地址sscratch0x140S 模式备用寄存器sepc0x141S 模式异常返回地址scause0x142S 模式异常原因stval0x143S 模式异常附加信息satp0x180S 模式页表根地址寄存器控制地址翻译U 模式的 CSR 很少通常只包括计时器和性能计数器比如cycle0xC00、time0xC01、instret0xC02。这些寄存器 U 模式也能读因为它们本身就是设计给用户程序做性能分析和计时的。但注意在某些实现里这些计数器是否被真实统计还得看硬件是否实现了相应扩展。3. 从 M 到 S 再到 U特权级切换与 CSR 访问规则3.1 特权级切换的本质几个状态位的联动很多初学者对 M/S/U 的理解停留在“三种模式”这个概念上一遇到实际的模式切换就懵了程序怎么知道自己现在在哪个模式切换模式又是谁干的答案是当前特权级是硬件内部的一个状态软件无法直接写一个寄存器来改变它。特权级切换只发生在两种情况里——异常/中断的进入与返回。进入异常当前模式被打断硬件自动跳转到高特权级通常是 M 模式或 S 模式的异常入口同时把之前的模式和中断状态存进状态寄存器。异常返回执行mret或sret时硬件从状态寄存器里恢复之前的模式和中断状态。所以特权级切换不是软件“想切就切”而是顺着异常机制走的。有一次我看一个跑在 U 模式下的测试程序想直接访问 M 模式的mstatus结果一跳进去就 illegal instruction原因就是它根本没走异常入口去提升特权级而是在原地硬闯。3.2 mstatus 里的关键位MPP、MPIE、MIE 到底怎么配合mstatus是 M 模式最核心的状态寄存器它的字段太多但最关键的几个位必须彻底弄懂MIEMachine Interrupt EnableM 模式的全局中断使能位。它为 0 时所有 M 模式中断都被屏蔽除了不可屏蔽的 NMI。MPIEMachine Previous Interrupt Enable进入 M 模式异常前硬件会把原MIE的值存到这里。MPPMachine Previous Privilege进入 M 模式异常前硬件把原特权级编码存在这里2 位宽。取值为 0 表示 U 模式1 表示 S 模式3 表示 M 模式。MPRVMachine Privilege Relaxation置位时数据访问内存的权限检查会临时按MPP代表的特权级执行而不是当前模式。这主要用于需要代表低特权级进行内存访问的固件代码。配套的还有SPIE和SPP对应 S 模式的中断状态与来源特权级。S 模式下读sstatus看到的就是mstatus中这些 S 相关位的投影。3.3 一次完整的中断进入与返回硬件做了什么我把一个 M 模式中断的完整流程写出来你照着这个过程去理解后面写代码会顺很多程序运行在 U 模式mstatus.MIE1全局中断打开。某个外部中断到达硬件检测到开始 trap。硬件把当前 PC 写入mepc。硬件把当前特权级 U编码 0写入mstatus.MPP。硬件把当前MIE的值写入MPIE然后把MIE清零这样在异常入口里不会立刻被新的中断打断。硬件把当前特权级切到 M 模式。硬件根据异常原因写mcause、mtval。硬件跳转到mtvec指向的入口地址开始执行 M 模式的 trap handler。handler 处理完事情后执行mret。硬件会做反向操作把mstatus.MPP恢复为当前特权级。把mstatus.MPIE写入MIE。把MPP置为 U或者恢复到最低用户特权级MPIE置 1。跳转到mepc指向的地址继续执行被中断的任务。整个过程里硬件做的都是固定动作而“切换模式”三个字背后其实就是MPP、MPIE、MIE这一组位在联动。理解了这个联动你调试很多奇怪问题时就不会一头雾水。3.4 陷入指令 ecallU 模式主动“交权”的唯一方式U 模式程序想请求更高级别的服务唯一干净的方式就是执行ecall。ecall会触发一个“环境调用”异常异常编号根据来源模式不同而不同U 模式下是 8S 模式下是 9M 模式下是 11。异常入口的 handler 再根据mcause或者scause里的编号判断是谁发起的请求然后分发到对应的服务。这里有个我踩过的坑很多人以为ecall会直接跳到某个指定的函数其实它只是触发异常。你必须在异常入口里自己判断mcause再从某个约定好的地址/表里找到服务函数。也就是说U 模式和 M 模式之间怎么通信完全由你软件定义RISC-V 硬件本身只是把球踢给了异常 handler。4. 中断与异常在特权架构下的落地4.1 mtvec/stvec 异常入口怎么配mtvec和stvec分别控制 M 模式和 S 模式的异常入口。它们的低 2 位是模式位0表示直接跳转模式所有异常都跳到同一个基地址1表示向量模式异常号会乘以 4 加到基地址上每个中断源对应一个独立入口。向量模式在嵌入式实时场景很常见因为某些中断延迟敏感希望每个中断源立刻进入自己的处理函数。但注意一点使用向量模式时必须在基地址处写好一张跳转表每个表项是一条跳转指令而不是处理器自动帮你调用。我第一次配向量模式时只写了一个 handler以为硬件会自动根据中断号选择结果所有中断全跑进了同一个入口排查了好久才发现是跳转表没建。# mtvec 直接模式入口为 trap_entry csrw mtvec, trap_entry # mtvec 向量模式基地址为 vector_base低 2 位写 1 csrw mtvec, (vector_base | 1)4.2 中断委托M 模式什么事都管反而拖慢系统如果系统里跑着 Linux你希望中断尽量由 S 模式的内核处理而不是每次都陷进 M 模式的 OpenSBI 再转回来。这就要用mideleg和medeleg寄存器做中断/异常委托。mideleg是中断委托寄存器某一位写 1表示对应中断直接进入 S 模式由stvec处理不经过 M 模式的 trap handler。medeleg同理控制异常的委托。委托机制的实际意义是减少模式切换的开销同时让 M 模式固件保持“最小介入”原则只处理真正必须由它处理的事情比如安全相关的异常和不可屏蔽事件。我第一次看 OpenSBI 的代码时发现它会做一堆csrw mideleg的操作当时没理解为什么需要“主动放弃”这些中断的处理权。后来想想如果每个定时器中断都先进 M 模式再回复到 S 模式那性能损耗非常可观而且 M 模式固件代码里根本没有 Linux 的调度逻辑处理不了这些中断。委托其实是“把正确的事情交给正确的层去办”。4.3 嵌套中断为什么第一次跑挂不意外RISC-V 的异常进入流程里硬件会清掉MIE所以在默认情况下进入中断 handler 后不会响应新的中断。你要支持嵌套中断就必须在软件里手动重新打开MIE并且保证现场保存和恢复的顺序正确。我见过不少人在实现嵌套中断时踩坑在 handler 开头贸然csrsi mstatus, 0x8打开了MIE却没有保存被打断前的状态结果第二次中断嵌套进来时把外层 handler 已经保存好的上下文冲掉了。正确做法是先完整保存现场再打开MIE并且中断返回时按严格逆序恢复现场最后执行mret。这里面一个隐藏的细节是嵌套中断会把MPP、MPIE反复改写所以如果你的保存现场里没有把这些状态也保存下来返回时就会跳错模式。5. 别被同名“CSR”带偏邻接表与 CSR 压缩存储是另一回事写这篇专栏之前我看到有个热搜问“邻接表 和 CSR 压缩存储 内存空间消耗是同一个量级吗”。我得说这个问题里的“CSR”跟 RISC-V 的 CSR 完全是两码事——这里的 CSR 是 Compressed Sparse Row图计算里一种稀疏矩阵/图的存储格式全称一模一样缩写也一样但背景完全不同。如果你在搜索引擎里顺藤摸瓜很容易被绕糊涂。回到图计算的问题邻接表存有向图时通常每个顶点维护一个链表或动态数组存放它所有的出边。总空间是 O(V E)。CSR 压缩存储则用两个数组一个 row offset 数组长度 V1记录每个顶点的出边在列索引数组中的起始位置一个 col index 数组长度 E记录每条边的终点。总空间同样是 O(V E)。所以从渐进复杂度的角度说两者确实是同一个量级。但常数上有明显差异邻接表如果用链表实现每个节点还得额外存一个 next 指针如果用 vector 实现vector 扩容预留的容量也可能浪费内存。而 CSR 是纯数组没有指针开销也没有扩容浪费内存效率通常更高。再加上 CSR 在内存中是连续存储的遍历某个顶点的出边时缓存友好度更好所以工程实践里做图计算、稀疏矩阵运算时CSR 会比邻接表更常用。区分这两个概念的关键是看语境RISC-V 的 CSR 是控制状态寄存器地址 12 位、需要特权级校验图计算的 CSR 是稀疏数据结构跟两个数组打交道。如果你在 RISC-V 社区看到csrrw、mstatus这类词那就是寄存器没错如果看到“row offset”“列索引”那一定是在讲稀疏矩阵。6. 实操心得与踩坑清单调试特权级与 CSR 的几个实用建议6.1 用 QEMU 练手时怎么观察 CSR 状态变化我强烈建议初学者不要一上来就碰真实硬件。QEMU 的-machine virt虚拟机和 OpenSBI 的组合是一个非常友好的环境你可以在里面直接调试 M/S/U 三态切换所有 CSR 都能在调试器里读出来。用 GDB 连接 QEMU 时info registers all可以直接看到所有架构寄存器和部分 CSRp $mstatus这类表达式也能快速读取。真机调试最麻烦的地方是没有合适的观测手段。很多便宜的开发板不支持 JTAG 调试你只能靠串口打印和 GPIO 翻转来推测 CSR 状态效率很低。所以在 QEMU 里把逻辑理清楚再上真机会省掉大量无效排查时间。6.2 常见问题速查现象可能原因解决方法访问 CSR 时报 illegal instruction当前特权级低于 CSR 所属特权级检查 CSR 地址高两位确认模式是否匹配mret执行后跳错地址mepc被嵌套异常改写但没保存进入 handler 时第一时间保存mepcmret执行后特权级不对MPP恢复错误或被上下文恢复覆盖在恢复现场时同步恢复MPP开中断后却没响应MIE/SIE没打开或mtvec/stvec配错逐位检查中断使能链确认入口地址中断反复触发死循环中断 pending 位没有清在 handler 末尾清除对应mip位trap handler 里开了嵌套后现场错乱没有保存MEPC/MPP/MPIE等状态完善上下文结构体保存所有模式状态6.3 一段初始化 M 模式 CSR 的最小模板下面是一段极简的启动汇编初始化mtvec、打开 M 模式全局中断、设置中断委托然后把 CPU 扔进一个小循环。写法上我刻意保持“能用但够简”你可以在 QEMU 里跑起来验证.section .text.init .globl _start _start: # 设置异常入口 la t0, trap_handler csrw mtvec, t0 # 开启 M 模式全局中断 csrr t0, mstatus ori t0, t0, 0x8 # MIE 1 csrw mstatus, t0 # 设置机器模式中断使能以外部中断为例 csrr t0, mie ori t0, t0, 0x800 # MEIE 1 csrw mie, t0 # 可选的委托配置比如把软件中断委托到 S 模式 # csrr t0, mideleg # ori t0, t0, 0x2 # csrw mideleg, t0 # 主循环 1: j 1b这个模板里最容易被忽略的是mstatus.MIE和mie的关系——很多新手只配了mie忘记mstatus.MIE导致中断依然不触发。调试中断时最基本的排查顺序永远是“全局使能 → 局部使能 → 挂起位 → 入口地址”漏掉任何一环都会表现为“中断到了但没响应”。6.4 调试 CSR 时值得养成的习惯最后分享几个我实际写代码时会做的事情。第一所有 CSR 读写最好封装成函数或宏不要散落到处乱写第二在关键路径上增加编译期断言确保 CSR 地址和位宽正确第三若条件允许把关键的 CSR 值打印到串口配合上位机脚本分析。这些习惯看起来琐碎但能显著降低你在这个领域踩坑的频率。写 CSR 相关代码最怕的不是不懂规范而是以为自己懂了结果在某个不常用的状态位里翻车。调试这类问题的时候我会同时翻开 RISC-V 特权规范原文、厂商手册和实际反汇编出来的指令三样东西对照着看三者能对齐的时候问题基本就定位了。把 M/S/U 这条主线和 CSR 这张大网理清楚之后你再去看 OpenSBI、看 Linux 启动流程、甚至自己去写一个小的 RTOS都会有一种豁然开朗的感觉。