☰
RISC-V裸机开发:M/S/U特权架构与CSR访问体系详解
2026/10/7 7:40:38 网站建设 项目流程

做 RISC-V 裸机或者内核开发的同学,迟早都要面对两样东西:CSR 和特权架构。今天这篇就是专栏第三篇,把 M/S/U 三种模式下的 CSR 访问体系彻底盘一遍。CSR 全称 Control and Status Register,中文叫控制状态寄存器,它承载了异常处理、中断开关、地址翻译、性能计数等几乎所有“软硬交互”的关键信息。而 M/S/U 特权架构则是理解这些 CSR 的地图:什么时候能读,什么时候不能读,读写之后会发生什么,全都由当前所处的特权模式决定。这篇内容适合刚把手边 RISC-V 开发板跑起来、正要开始写 RTOS 或者移植内核的人,也适合那些看启动代码时被一堆 0x300、0x140 宏定义绕晕的人。看完之后,你至少能回答这几个问题:mstatus 里的 MPP 是什么?为什么要搞一个 medeleg?U 模式访问 satp 为什么直接 Illegal Instruction?

1. 特权架构 M/S/U:三种模式到底在管什么

1.1 为什么需要特权模式

你可以把 RISC-V 的三种特权模式想象成公司里的三种门禁卡:普通员工卡(U)、部门主管卡(S)、公司管理员卡(M)。没有门禁的开放办公室,任何代码都能直接改硬件配置、清中断、改页表,一个应用出 bug 就能把整个系统拖垮。有了门禁之后,普通程序只能访问自己权限范围内的东西,越权动作会被 CPU 拦住,从而把危害限制在局部。

RISC-V 规范定义了三个特权等级:Machine Mode(M)权限最高,通常运行 Bootloader、固件、安全监控程序;Supervisor Mode(S)权限中等,通常是 Linux、RTOS 内核所在的特权层;User Mode(U)权限最低,跑的就是普通应用程序。等级关系是 M > S > U,高等级能访问低等级的资源,但低等级不能直接访问高等级的资源。所以 U 模式想请求内核做事时,必须通过ecall指令发起环境调用,陷入 S 或 M 模式,由内核代为完成,然后通过sret或mret返回。

这套设计解决的核心问题是隔离与安全。应用不知道自己是不是在被调试,内核不知道底层固件做了哪些安全检查,M 模式固件也不需要关心上层跑的是 Linux 还是裸机。每一层只需要暴露给下一层一个“受控入口”,而不是把所有寄存器统统摊开。理解这一点,你就能理解为什么会有那么多“委托”“代理”机制,本质上都是在不同门禁之间开一个小窗口。

1.2 M/S/U 模式的能力边界与切换

三种模式能访问的 CSR 范围有明确划分。M 模式可以访问所有 CSR,包括机器模式、监管模式、用户模式三套。S 模式能访问 S 和 U 两套 CSR,但碰 M 模式 CSR 会触发非法指令异常。U 模式最低,只能访问 U 模式的 CSR,碰 S 或 M 都会被拦下来。

我整理了一个简化对照表,方便你快速定位:

模式典型软件特权等级可直接访问的 CSR 空间
M固件、Bootloader、安全监控最高M + S + U 全部 CSR
SLinux/RTOS 内核中间S + U 系列 CSR(若启用 U 模式)
U应用程序最低U 系列 CSR(通常很少)

模式切换不是靠软件任意改一个状态位,而是通过专门的指令和硬件自动动作。U 想进 S/M,用ecall;S 想进 M,用ecall;从异常/中断返回时,M 模式用mret,S 模式用sret。ecall会触发异常,硬件根据异常委托设置跳到对应的异常入口,通用寄存器现场由软件自己保存,CSR 里的关键信息由硬件自动更新。

1.3 模式切换的硬件机制

发生异常或中断时,硬件会自动把当前模式记录到mstatus的 MPP 字段或sstatus的 SPP 字段,然后把目标模式设置好,跳转到mtvec或stvec指向的入口向量。以 M 模式的mret为例:执行mret时,CPU 会从 MPP 恢复之前的模式,设置 MIE=MPIE,把 PC 设置为mepc的值。这就解释了为什么你在 Bootloader 中跳转 OS 时,会先写mstatus.MPP=1、再写mepc=OS入口地址、最后执行mret。

这种设计看着绕,实际上是为了支持嵌套和恢复。异常发生时,硬件并不知道软件要处理多久,所以它只保存恢复所需的最小状态到 CSR,剩下的通用寄存器由软件自行压栈。如果你写过裸机中断服务程序,可能已经发现:ISR 开头的第一件事往往是从mepc读出返回地址保存到内存,否则嵌套异常会把mepc覆盖掉。这就是硬件机制带来的“坑”,但也正是因为这些规则,RISC-V 的上下文切换才变得非常可预测。

2. CSR 地址空间与分类速查

2.1 CSR 的编号规则与地址空间

CSR 采用 12 位地址编码,理论上最多 4096 个。高两位[11:10]用于标识所属特权等级:11表示 Machine,01表示 Supervisor,00表示 User。中间位还包含读写属性等编码,但日常使用中你不需要精确解析每一位,只要记住“高位决定权限、地址决定功能”就够了。

实际工程里最常见的规律是:机器模式 CSR 集中在0x300到0x3FF,0xB00附近是计数类;监管模式 CSR 集中在0x100到0x1FF,地址翻译相关的satp是0x180;用户模式 CSR 在0x000到0x0FF,虽然规范里定义了一套,但大多数用户程序根本碰不到。

这里有一个很容易踩的坑:某个 CSR 是否存在、是 RV32 还是 RV64 专用、是否被当前硬件实现,直接读可能得到 0,也可能触发异常。所以做移植时需要查对应 CPU 的手册,而不能只背 RISC-V 规范。比如misa寄存器会告诉软件当前 CPU 支持哪些扩展,但如果硬件没有实现某些扩展,对应的 CSR 位可能是固定的 0,读出来的结果和你预想的不一样。

2.2 常用机器模式 CSR 分类表

机器模式 CSR 是 M 模式软件的“主战场”。以下是我在裸机开发中最常用到的一组,地址是标准规范里的固定值:

地址名称作用简述
0x300mstatus全局中断使能、MPP/MPIE、扩展状态等
0x301misaCPU 支持的指令集扩展位图
0x302medeleg将哪些同步异常委托给 S 模式处理
0x303mideleg将哪些中断委托给 S 模式处理
0x304mie机器模式中断使能,每一个 bit 对应一种中断
0x305mtvec机器模式异常/中断入口地址
0x340mscratch机器模式暂存寄存器,常用于快速保存现场
0x341mepc异常发生时保存的 PC,mret 返回的目标
0x342mcause异常/中断原因编码
0x343mtval异常附加信息,如访存地址
0x344mip机器模式中断挂起位
0xB00mcycle机器模式周期计数器
0xB02minstret机器模式已执行指令计数

我在调试无中断不响应问题时,第一件事就是检查mstatus.MIE和mie.MEIE/MTIE。这两个位一个管全局总闸,一个管具体中断源。很多人只开了mie对应位,忘了mstatus.MIE,结果中断一直不进来,排查半天才发现是全局开关没打开。

另外注意mstatus是一个“大杂烩”寄存器,不同 bit 含义完全独立。你不能直接给mstatus赋一个普通数值,因为那样可能同时改变 MPP、MPIE、FS 等状态。正确做法是读出来,修改需要的位,再写回去。这也是为什么 RISC-V 专门提供了 CSRRS/CSRRC 这类读-改-写指令。

2.3 监管模式与用户模式 CSR

S 模式 CSR 完整对应 M 模式中的一套“子集”,地址普遍是 M 模式地址减去 0x200。比如sstatus是0x100,sie是0x104,stvec是0x105,sepc是0x141,scause是0x142,stval是0x143,sip是0x144,satp是0x180。

sstatus只包含mstatus中与 S 模式相关的位,比如 SIE、SPIE、SPP、SUM 等。操作系统内核运行在 S 模式下,读写一大堆s*寄存器。这个子集设计有两个好处:一是 S 模式软件不需要关心 M 模式固件的状态,减少误操作;二是如果 M 模式把中断委托给 S,S 模式的异常处理代码就直接操作sie/stvec/sepc/scause,逻辑上完全对称。

U 模式 CSR 有ustatus、utvec、uepc、ucause等,地址以0x00开头。但绝大多数场景下,U 模式软件并不会直接触碰这些寄存器,因为用户态程序靠系统调用与内核交互,异常处理通常交给内核完成。如果 U 模式程序里出现未处理异常,CPU 会按照委托设置跳转到内核异常入口,而不是在 U 模式原地处理。

3. CSR 访问指令与权限规则

3.1 四条指令与伪指令:CSRRW/CSRRS/CSRRC/CSRRWI

RISC-V 为 CSR 访问设计了 6 条指令,但基础就 4 个语义:CSRRW、CSRRS、CSRRC,以及对应的立即数版本 CSRRWI、CSRRSI、CSRRCI。汇编格式都是指令 rd, csr, rs1/zimm,操作方式如下:

  • CSRRW rd, csr, rs1:把 CSR 当前值读到 rd,再把 rs1 的值写入 CSR,原子交换。
  • CSRRS rd, csr, rs1:把 CSR 当前值读到 rd,再把 CSR 中与 rs1 中为 1 的 bit 置位。
  • CSRRC rd, csr, rs1:把 CSR 当前值读到 rd,再把 CSR 中与 rs1 中为 1 的 bit 清零。

立即数版本就是把 rs1 换成一个 5 位立即数zimm[4:0]。实际写代码时常用伪指令直接操作,比如csrr表示读,csrw表示写,csrs表示置位,csrc表示清位,这些伪指令最终会被汇编器翻译成上述指令。

使用上有两个容易被忽略的约定。第一,当CSRRW的 rd 是x0时,CPU 不会把旧值写入 rd,效果等价于只写。反过来,当CSRRS的 rs1 是x0时,不会有任何置位动作,只读 CSR,所以很多人把csrr rd, csr伪指令翻译成csrrs rd, csr, x0。第二,CSR 操作是原子的,也就是说“读旧值、修改新值”两步之间不会被中断打断,这在并发环境里非常重要。

3.2 访问权限与非法指令异常

CSR 权限规则看似简单,实际判断的时候要非常小心。访问一个 CSR 的合法性由两部分决定:一是当前特权模式是否满足该 CSR 的所属等级,二是该 CSR 是否被当前硬件实现。只要有一条不满足,就会触发 Illegal Instruction 异常,也就是mcause或scause等于 2。

U 模式访问任何 S/M 系的 CSR,S 模式访问 M 系的 CSR,都属于越权。即使你把 S 模式的sstatus当作普通寄存器只读,如果当前 CSR 没有被实现,也可能触发异常。最典型的情况是在某些低成本的裸机核上,硬件根本没有实现 S 模式,代码里却写了csrr s0, sstatus,结果直接掉进异常。

还存在一些“反客为主”的配置位:比如mstatus中的 TVM 位,置 1 后 S 模式访问satp会触发异常;TSR 位置 1 后 S 模式执行sret也会触发异常。这是 M 模式固件用来监管 S 模式的手段,反直觉但很重要。在做虚拟化或者安全启动时,你会遇到这些位,不要把它们当成普通保留位忽略。

3.3 关键操作示例:读/写/置位/清位

我用 C 语言内联汇编写几个宏,实际工程中可以直接抄。下面的代码在 RISC-V GNU 工具链下编译通过:

#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 swap_csr(reg, val) ({ unsigned long __tmp; \ asm volatile("csrrw %0, " #reg ", %1" : "=r"(__tmp) : "rK"(val)); __tmp; }) #define set_csr(reg, bit) ({ unsigned long __tmp; \ asm volatile("csrrs %0, " #reg ", %1" : "=r"(__tmp) : "rK"(bit)); __tmp; }) #define clear_csr(reg, bit) ({ unsigned long __tmp; \ asm volatile("csrrc %0, " #reg ", %1" : "=r"(__tmp) : "rK"(bit)); __tmp; })

使用示例:打开机器模式定时器中断并设置中断向量表:

/* 读取当前 mstatus,设置 MIE 位 */ set_csr(mstatus, MSTATUS_MIE); /* 打开机器模式定时器中断 */ set_csr(mie, MIE_MTIE); /* 设置异常向量表入口 */ write_csr(mtvec, &trap_entry);

这里的set_csr(mstatus, MSTATUS_MIE)直接利用 CSRRS 指令置位,不会动其他 bit,比“读-修改-写”更安全。我建议所有对mstatus、mie、sstatus这类多 bit 寄存器的修改都用set_csr/clear_csr,而不是先读出再整体写回,避免不小心覆盖别的状态。

4. 实操:从启动到任务切换的 CSR 使用链路

4.1 启动流程中的 CSR 配置

一颗 RISC-V 芯片上电后一般从 M 模式开始执行,所以启动代码的第一段是 M 模式代码。我写过一块 FPGA 上的裸机引导,启动流程大致是:

  1. 设置栈指针,因为异常处理需要栈来保存现场。
  2. 写mtvec,把异常向量表地址告诉 CPU。
  3. 设置mstatus.MPIE=0、mstatus.MPP=1,表示mret后进入 S 模式、中断关闭。
  4. 写mepc为 S 模式入口函数地址,也就是 OS 启动函数。
  5. 执行mret,CPU 自动切到 S 模式并从 OS 入口开始运行。

如果系统里没有 S 模式,只跑裸机 RTOS,那么第 2 步之后直接在主循环里用中断即可。这里要特别注意mret的恢复逻辑:MPP 决定返回后的模式,MPIE 决定是否重新开启中断。很多人把mepc写对了,但 MPP 忘了设置,结果mret后回到了 M 模式,看起来程序没跑。这种问题在仿真器里通常不会暴露,在真机上会非常难查。

另外,如果 M 模式固件希望把某些中断委托给 S 模式处理,就需要在进入 S 模式前配置medeleg和mideleg。标准的做法是把 S 模式需要处理的异常和中断对应位写 1。例如把外部中断委托给 S 模式:write_csr(mideleg, (1 << 11)),这里 11 是外部中断的编号。

4.2 中断与异常处理的 CSR 现场保存

异常一旦发生,硬件会自动把当前 PC 保存到mepc或sepc,把原因保存到mcause或scause,然后跳转到异常入口。如果中断被委托到 S 模式,入口是stvec;否则走mtvec。入口代码接下来要保存通用寄存器。RISC-V 没有硬件压栈机制,所以“保护现场”必须靠软件。我常用的最小处理流程是:

void trap_entry(void) { /* 先保存 mepc 和 mstatus,防止嵌套覆盖 */ unsigned long epc = read_csr(mepc); unsigned long mstatus = read_csr(mstatus); /* 再保存通用寄存器,比如压栈 */ asm volatile("addi sp, sp, -256"); asm volatile("sd x1, 0(sp)"); /* ... 其余寄存器保存 ... */ /* 用 mcause 判断是中断还是异常 */ unsigned long cause = read_csr(mcause); if ((cause >> 63) & 1) { /* 异步中断 */ } else { /* 同步异常 */ } /* 恢复现场 */ write_csr(mepc, epc); write_csr(mstatus, mstatus); /* ... 恢复寄存器 ... */ asm volatile("mret"); }

这里有个经验:mepc在同步异常时指向出错的指令,在异步中断时指向中断发生时的下一条指令?其实是具体实现相关,RISC-V 规范要求mepc指向中断时“被中断的那条指令”,异步中断返回后会重新执行这条指令。很多新手以为 mret 回来会跳过一条指令,于是在 mret 前手动mepc+4,结果导致重复执行。准确说,同步异常如非法指令时,mepc指向触发异常的指令,软件如果想继续执行,就该mepc+4;但异步中断不是程序主动触发,mepc指向被中断的指令,恢复执行时不应该加 4。所以必须用mcause的高位区分中断与异常,再决定是否修改mepc。

关于mscratch:它的典型用法是给异常入口提供一个“备用寄存器”。比如在入口首先csrrw t0, mscratch, t0,把原 t0 存入 mscratch,t0 变成旧 mscratch 的值,这样就有了一个寄存器能用来保存其他上下文,而不会破坏调用方的 t0。我在上下文切换里经常利用这个技巧,让异常入口的压栈过程变得更精炼。

4.3 地址翻译与 satp 配置

S 模式最重要的 CSR 之一是satp,它控制地址翻译是否开启、页表根目录在哪。satp的三个关键字段是 MODE、ASID、PPN。RV64 下典型配置:

  • MODE 位在[63:60],Sv39 模式时值为 8。
  • ASID 在[59:44],用于标识进程地址空间。
  • PPN 在[43:0],是根页表物理地址右移 12 位的页号。

假设你的根页表物理地址是0x80000000,想要开启 Sv39 分页,计算 PPN:0x80000000 >> 12 = 0x80000。那么:

uintptr_t satp_val = (8UL << 60) | (0UL << 44) | 0x80000UL; write_csr(satp, satp_val); asm volatile("sfence.vma");

写satp之后必须执行sfence.vma冲刷 TLB,否则旧翻译可能残留在流水线里,导致很诡异的访问异常。特别是在 S 模式切换进程时,每个进程有自己的根页表,写satp后立刻sfence.vma是标准动作。

在 M 模式固件中,satp默认可能不可用,具体看硬件是否实现。如果实现,M 模式也可以开分页,但绝大多数 bootloader 为了简化,会先把 MMU 关掉,跳到 S 模式后再由内核开启。另外注意mstatus.MPRV位:它可以让 M 模式下的普通访存按照 MPP 指定的低特权模式翻译,用于模拟 S/U 模式访存,调试时很有用,但日常裸机开发很少碰。

5. 常见问题与排查技巧实录

5.1 特权模式与CSR访问的典型错误

我在调试过程中遇到过不少和 CSR 权限有关的“灵异事件”,大部分都归结到几个典型原因:

错误现象原因排查方向
U 模式程序访问sstatus直接异常U 模式无权访问 S 模式 CSR检查是不是在用户态代码里使用了csrr sstatus
S 模式写mstatus被拒绝S 模式无权访问 M 模式 CSR改用sstatus,或在 M 模式下处理
写mtvec无效,中断仍跳转旧地址当前代码实际运行在 S 模式查看当前 mstatus.MPP / 是否已经mret进入 S 模式
mret后 CPU 还是 M 模式mstatus.MPP没有设置为 1检查 MPP 字段,写 1 代表 S 模式
S 模式访问satp触发异常可能mstatus.TVM=1阻止 S 访问M 模式读mstatus,确认 TVM 位为 0

排查 CSR 相关问题时,我建议先读mcause。比如异常码 2 表示非法指令,3 表示断点,12/13 表示指令/数据页错误。拿到mcause后再结合mtval里的附加地址,能很快定位到是访问了不存在或没权限的 CSR。

5.2 中断不响应的排查清单

中断不响应是裸机开发里最常见的“劝退”问题,我给的排查顺序是:

  1. 看全局开关:mstatus.MIE是否为 1,sstatus.SIE同理。
  2. 看外设中断开关:mie/sie里对应中断位是否打开。
  3. 看入口地址:mtvec/stvec是否指向有效的异常处理函数,地址是否对齐。
  4. 看中断是否被委托:如果mideleg把某个中断委托给了 S,而 S 模式没有配置stvec和sie,中断就会“卡住”。
  5. 看中断号:读mip/sip和mcause/scause,确认中断号是多少,是否和外设的中断号一致。
  6. 看代码位置:如果正在ecall或mret的边界,可能因为 CSR 恢复顺序不对导致开关被意外关闭。

我踩过最隐蔽的一个坑是mtvec在mstatus.MIE打开前被覆盖。某个启动代码把mtvec地址写成了一个未初始化变量,后来中断一开就跳到野地址。所以在调中断前,建议用 JTAG 或仿真器先确认mtvec的值是不是预期入口。用 GDB 读 CSR 是一个好习惯:

p/x $mtvec p/x $mstatus p/x $mie p/x $mip

不要只看 C 代码里写没写,硬件 CSR 才是最终事实。

5.3 热词澄清:这个CSR不是那个CSR

最近我在搜索 RISC-V 相关资料时,总会被一个问题带到同一个热搜词下面:“邻接表 和csr压缩存储 内存空间消耗是同一个量级吗?”这里必须明确说明:图算法里的 CSR 和 RISC-V 的 CSR 是完全不同的东西,只是缩写撞了。图算法里的 CSR 是 Compressed Sparse Row,即稀疏矩阵的压缩行存储;RISC-V 里的 CSR 是 Control and Status Register。前者是数据结构,后者是处理器寄存器,二者没有任何关联。

顺便把这个问题也回答了:用 CSR 压缩存储稀疏图和邻接表,在内存空间消耗上确实属于同一个量级,复杂度都是 O(V+E)。区别是常数项和适用场景。CSR 使用三个连续数组row_ptr、col_idx、value,没有链表指针,内存紧凑,适合静态图读密集型操作;邻接表通常用 vector 或链表组织每个顶点的邻接边,添加边灵活,但会多出指针或容器容量开销。所以从大 O 角度它们是同一量级,但实际内存占用 CSR 通常更小,性能也更稳定。你在看 RISC-V CSR 速查的时候,遇到这个热词不要晕,回头看看图的稀疏存储实现,就会明白不是一回事。

最后再分享两个经验

第一是不要把 CSR 当成普通寄存器去记忆,而是要当成“模式切换的协议”来理解。mstatus的每一个位几乎都对应一条规则,比如 MPP 决定mret回哪、MPIE 决定返回时是否开中断、TVM 决定是否允许 S 模式写satp。你记不住全部位没关系,但拿到芯片手册后一定要对着手册看一遍mstatus,这个寄存器比你想象中更重要。

第二是调试异常时,最快的路径不是看波形,也不是猜代码,而是先把mcause和mtval打出来。mcause告诉你发生了哪类异常,mtval告诉你出错时的附加地址。有了这两个值,八成问题都能直接定位。如果加上一个打印当前模式的辅助函数,比如读mstatus.MPP、判断是否在 S/U,调试体验会直线上升。

这篇内容写到这,CSR 速查表和 M/S/U 特权架构的脉络应该已经清楚了。下一个专栏我会继续讲陷阱处理和上下文切换的现场细节,尤其是如何用mscratch写出一个不破坏任何寄存器的最小异常入口。到时见。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询