写这篇专栏之前,我先坦白一件事:RISC-V 的 CSR(Control and Status Register,控制与状态寄存器)是我早期最头疼的部分。不是说它难,而是资料太散,规范文档翻起来像查字典,今天记三个明天忘两个,真正写启动代码或者看 OpenSBI 的时候又发现自己根本没搞懂特权级之间是怎么切来切去的。后来我把 M/S/U 特权架构和 CSR 这摊事串起来理解,才有一种“通了”的感觉。这篇文章就把这套脉络整理给你——risc-v 的 CSR 速查只是表面,重点是特权架构 M/S/U 三条权限线如何通过一组寄存器互相制衡、配合、切换,以及你在实操中会遇到哪些真正会卡住人的细节。
1. 特权架构 M/S/U:RISC-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 模式可以用 PMP(Physical 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 模式 CSR,01开头的是 S 模式 CSR,00开头的是 U 模式 CSR,10开头的是预留的 H 模式 CSR。硬件在执行 CSR 读写指令时,会检查当前特权级是否满足这个编码要求:U 模式去访问11开头的寄存器,直接报 illegal instruction;S 模式访问11开头也要报错;但 M 模式可以访问01和00开头的寄存器。
还有一个容易忽略的细节:CSR 地址的中间位也不是乱排的。bit[9:8] 范围涵盖了“这个寄存器是否只读”的信息,具体见规范。我建议初学阶段不必把所有位都背下来,但一定要理解高两位的含义,因为它直接影响你会不会踩“访问了不该访问的 CSR”这种 bug。
表:CSR 地址高两位与特权级对应关系
| 地址高两位 | 所属特权级 | 谁会访问 |
|---|---|---|
| 11 | Machine | 仅 M 模式 |
| 01 | Supervisor | M 模式、S 模式 |
| 00 | User | M/S/U 均可 |
| 10 | Hypervisor | 预留,虚拟化场景使用 |
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 | 地址 | 作用 |
|---|---|---|
| mstatus | 0x300 | 记录全局中断使能、上一次特权级、历史中断使能状态等 |
| misa | 0x301 | CPU 支持的 ISA 扩展位图,告诉软件这款核支持哪些指令 |
| mie | 0x304 | 机器模式局部中断使能,控制哪些中断能触发 M 模式处理 |
| mtvec | 0x305 | 机器模式异常入口地址,指定 trap handler 跳去哪 |
| mscratch | 0x340 | 机器模式备用寄存器,通常用来临时保存栈指针或 context 指针 |
| mepc | 0x341 | 发生异常/中断时,被打断指令的 PC |
| mcause | 0x342 | 异常/中断原因编号,告诉 handler 是因为什么跳进来的 |
| mtval | 0x343 | 异常附加信息,比如访问内存出错的地址或非法指令编码 |
| mip | 0x344 | 机器模式中断挂起位,反映哪些中断源当前有 pending |
| mhartid | 0xF14 | 当前硬件线程 ID,多核环境下用来区分“我是哪个核” |
| medeleg | 0x302 | 异常委托寄存器,哪些异常直接交给 S 模式处理 |
| mideleg | 0x303 | 中断委托寄存器,哪些中断直接交给 S 模式处理 |
这里面mhartid是只读的,而且它在启动流程里特别重要——多核芯片上每个核都有自己的mhartid,BootROM 或固件靠它判断自己是主核还是从核,再做不同的初始化跳转。misa也是只读寄存器,写它是无效的。
2.4 常用 S/U 模式 CSR 速查
如果系统里实现了 S 模式,那么 S 模式自己的 CSR 也不多,大多是 M 模式寄存器的“子集视图”。所谓子集视图,意思是寄存器名称不同、地址不同,但内部字段与对应 M 模式 CSR 的某些字段一一对应。比如读sstatus实际上是在读写mstatus的一部分位。
| CSR | 地址 | 作用 |
|---|---|---|
| sstatus | 0x100 | S 模式状态,只暴露 mstatus 中 S 模式可见的位 |
| sie | 0x104 | S 模式中断使能 |
| stvec | 0x105 | S 模式异常入口地址 |
| sscratch | 0x140 | S 模式备用寄存器 |
| sepc | 0x141 | S 模式异常返回地址 |
| scause | 0x142 | S 模式异常原因 |
| stval | 0x143 | S 模式异常附加信息 |
| satp | 0x180 | S 模式页表根地址寄存器,控制地址翻译 |
U 模式的 CSR 很少,通常只包括计时器和性能计数器,比如cycle(0xC00)、time(0xC01)、instret(0xC02)。这些寄存器 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 模式最核心的状态寄存器,它的字段太多,但最关键的几个位必须彻底弄懂:
MIE(Machine Interrupt Enable):M 模式的全局中断使能位。它为 0 时,所有 M 模式中断都被屏蔽(除了不可屏蔽的 NMI)。MPIE(Machine Previous Interrupt Enable):进入 M 模式异常前,硬件会把原MIE的值存到这里。MPP(Machine Previous Privilege):进入 M 模式异常前,硬件把原特权级编码存在这里,2 位宽。取值为 0 表示 U 模式,1 表示 S 模式,3 表示 M 模式。MPRV(Machine Privilege Relaxation):置位时,数据访问内存的权限检查会临时按MPP代表的特权级执行,而不是当前模式。这主要用于需要代表低特权级进行内存访问的固件代码。
配套的还有SPIE和SPP,对应 S 模式的中断状态与来源特权级。S 模式下读sstatus看到的就是mstatus中这些 S 相关位的投影。
3.3 一次完整的中断进入与返回,硬件做了什么
我把一个 M 模式中断的完整流程写出来,你照着这个过程去理解,后面写代码会顺很多:
- 程序运行在 U 模式,
mstatus.MIE=1,全局中断打开。 - 某个外部中断到达,硬件检测到,开始 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 陷入指令 ecall:U 模式主动“交权”的唯一方式
U 模式程序想请求更高级别的服务,唯一干净的方式就是执行ecall。ecall会触发一个“环境调用”异常,异常编号根据来源模式不同而不同:U 模式下是 8,S 模式下是 9,M 模式下是 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 数组(长度 V+1,记录每个顶点的出边在列索引数组中的起始位置),一个 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可以直接看到所有架构寄存器和部分 CSR,p $mstatus这类表达式也能快速读取。
真机调试最麻烦的地方是没有合适的观测手段。很多便宜的开发板不支持 JTAG 调试,你只能靠串口打印和 GPIO 翻转来推测 CSR 状态,效率很低。所以在 QEMU 里把逻辑理清楚,再上真机,会省掉大量无效排查时间。
6.2 常见问题速查
| 现象 | 可能原因 | 解决方法 |
|---|---|---|
| 访问 CSR 时报 illegal instruction | 当前特权级低于 CSR 所属特权级 | 检查 CSR 地址高两位,确认模式是否匹配 |
mret执行后跳错地址 | mepc被嵌套异常改写但没保存 | 进入 handler 时第一时间保存mepc |
mret执行后特权级不对 | 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,都会有一种豁然开朗的感觉。