☰
RISC-V特权架构CSR实战:M/S/U三级切换与异常委托机制详解
2026/10/7 7:43:13 网站建设 项目流程

1. 从一条热搜说起:CSR 和邻接表到底是不是一回事

先澄清一个我每次做分享都会被问到的问题。热搜里那条“邻接表和 csr 压缩存储内存空间消耗是同一个量级吗”,其实是个典型的术语撞车。图算法里的 CSR 指的是 Compressed Sparse Row,压缩稀疏行,用来存图的邻接关系;而 RISC-V 里的 CSR 是 Control and Status Register,控制状态寄存器。两个缩写一模一样,领域差了十万八千里。我见过有同学搜 RISC-V CSR 教程,结果翻到一堆图计算论文,看得一头雾水,最后跑来问我“这寄存器怎么还要建图”。所以开篇先把这层窗户纸捅破:这篇聊的是 RISC-V 特权架构里的 CSR,跟图存储没关系。

那 RISC-V 的 CSR 到底是干什么的?一句话概括,它是 CPU 内部一组有编号的专用寄存器,用来记录和控制处理器的运行状态——当前在哪个特权级、中断开没开、异常发生时跳去哪、性能计数器数到几了、浮点单元状态如何,全都靠它。你写的每一行用户态代码,背后都有 CSR 在默默决定它能不能执行、出错后往哪走。这也是为什么搞 RISC-V 底层开发,CSR 是绕不过去的一关。

这篇文章适合谁看?如果你正在做 RISC-V 裸机开发、写操作系统内核、移植 RTOS,或者单纯想搞明白“特权架构 M/S/U 三个模式到底怎么切换”,那这篇就是给你准备的。我会把 CSR 的编号规则、访问指令、M/S/U 三级的职责划分、异常与中断的委托机制、以及实际调试中踩过的坑,全部摊开讲。内容基于 RISC-V 特权架构规范(Privileged Spec)的常见实践整理,具体实现以你手上的芯片手册为准。

2. 特权架构 M/S/U 三级到底怎么分工

2.1 三个特权级的职责边界

RISC-V 特权架构定义了三个特权级,从高到低是 Machine(M)、Supervisor(S)、User(U)。很多人第一次接触会问:为什么非要分三级,两级不够吗?这得从“谁需要保护谁”说起。

M 模式是最高权限,也是唯一必须实现的模式。芯片上电复位后,CPU 一定从 M 模式开始跑。它能看到所有 CSR,能访问所有物理内存,能配置中断控制器、内存保护单元、时钟这些底层硬件。你可以把 M 模式理解成“主板 BIOS + 硬件管家”,它负责把系统初始化好,然后决定要不要把控制权交给下一级。

S 模式是给操作系统内核用的。它有自己的页表基址寄存器(satp)、自己的异常向量、自己的中断使能位。Linux、FreeRTOS 这类内核通常跑在 S 模式,通过 ecall 指令向 M 模式请求服务(比如关机、配置某些硬件)。S 模式看不到 M 模式的 CSR,这是硬件强制的隔离,不是软件约定。

U 模式是最低权限,普通应用程序跑在这里。它连页表都改不了,想访问内核地址会直接触发异常。U 模式的存在意义就是“让应用闯祸时伤不到系统和别的应用”。

三级的分工可以用一张表说清楚:

特权级典型使用者能访问的 CSR 范围关键能力
M固件、BootROM、安全监控全部 CSR配置硬件、委托异常、关中断
S操作系统内核S 级及部分 M 级只读管理页表、处理缺页、调度进程
U应用程序仅 U 级少量 CSR执行普通指令、发起系统调用

2.2 为什么不是所有芯片都有 S 模式

这里有个容易踩的认知坑:M/S/U 三级是规范允许的组合,但不是每颗芯片都实现全。低端 MCU 经常只做 M + U 两级,甚至只有 M 一级。原因很简单——成本。多一个特权级意味着多一套 CSR、多一套异常委托逻辑、多一块地址翻译硬件,对于跑裸机控制逻辑的芯片来说纯属浪费。

所以你在选型或者移植代码时,第一件事是翻芯片手册确认它到底支持哪几级。我见过有人拿着只支持 M+U 的核去移植需要 S 模式的内核,编译能过,一上电就卡在访问 satp 的指令上,因为那条 CSR 根本不存在,直接触发非法指令异常。判断方法很直接:读misa寄存器的 S 位(bit 18),为 1 才说明支持 S 模式。

2.3 特权级的切换只发生在特定时刻

特权级不会随便跳。从低到高只有一条路:触发异常或中断,硬件自动把当前特权级抬到异常处理所在级别。从高到低也只有一条路:执行mret或sret指令,硬件根据 CSR 里记录的返回级别降回去。

这意味着你不可能在 U 模式代码里“主动”跳到 M 模式,只能通过 ecall 发起请求,让 M 模式的异常处理程序来决定要不要帮你办事。这个设计是安全模型的基石——低权限代码永远无法绕过陷阱门直接提权。

3. CSR 速查:编号规则与访问指令

3.1 CSR 地址的 12 位编码逻辑

CSR 用 12 位地址编号,范围 0x000 到 0xFFF。这 12 位不是随便分配的,高 4 位(bit 11:8)编码了“这个寄存器属于哪个特权级、是否只读”,低 8 位才是具体功能编号。搞懂这个编码规则,你看到任何一个 CSR 地址都能立刻判断出它的权限属性。

具体规则是这样的:地址的 bit 11:10 表示该 CSR 允许被哪些特权级访问,00 表示 U 级可访问,01 表示 S 级,11 表示 M 级。bit 9:8 表示读写属性,11 表示只读,00、01、10 表示可读写。举个例子,mstatus的地址是 0x300,二进制是 0011 0000 0000,bit 11:10 = 11 说明是 M 级,bit 9:8 = 00 说明可读写。再看mvendorid地址 0xF11,bit 11:10 = 11 是 M 级,bit 9:8 = 11 是只读,符合它“厂商 ID 只读”的定位。

这个规则的实际价值在于:当你写 CSR 访问代码时,如果地址编码和当前特权级不匹配,硬件会直接抛非法指令异常。比如你在 S 模式读一个 M 级专属 CSR,不用等运行结果,指令译码阶段就被拦下了。调试时遇到莫名其妙的非法指令,先查 CSR 地址的特权位,能省很多时间。

3.2 六条 CSR 访问指令

RISC-V 访问 CSR 就六条指令,分三组:读写、置位、清位,每组又分“立即数版”和“寄存器版”。

csrrw rd, csr, rs1 # 读旧值到 rd,把 rs1 写入 csr csrrs rd, csr, rs1 # 读旧值到 rd,把 rs1 的 1 位置位到 csr csrrc rd, csr, rs1 # 读旧值到 rd,把 rs1 的 1 位清零到 csr csrrwi rd, csr, zimm # 立即数版 csrrw,zimm 是 5 位无符号立即数 csrrsi rd, csr, zimm # 立即数版 csrrs csrrci rd, csr, zimm # 立即数版 csrrc

这里有个非常实用的技巧:当 rd 写成x0时,表示“只写不读”,硬件不会产生读副作用;当 rs1 写成x0时,表示“只读不写”,常用于读取 CSR 当前值。比如csrr x0, mstatus是纯读,csrw mstatus, x0是纯写。编译器通常会把csrr rd, csr伪指令翻译成csrrs rd, csr, x0,把csrw csr, rs翻译成csrrw x0, csr, rs。

注意:csrrs 和 csrrc 的“置位/清位”语义是“只改 rs1 里为 1 的那些位,其他位保持不变”。这个特性在修改 mstatus 这种“一个寄存器里塞了十几个功能位”的场景下极其重要,能避免误伤其他配置。

3.3 常用 CSR 速查表

下面这张表是我平时调试时贴在显示器边上的,按功能分组,覆盖 M 和 S 两级最常用的寄存器。地址都是十六进制,属性栏里 RW 表示可读写,RO 表示只读。

CSR 名称地址级别属性用途
mstatus0x300MRW全局状态:中断使能、特权级、浮点状态
misa0x301MRW指令集架构信息,含扩展位
mie0x304MRW中断使能位
mtvec0x305MRW异常/中断入口地址
mscratch0x340MRW机器模式暂存寄存器
mepc0x341MRW异常返回地址
mcause0x342MRW异常/中断原因
mtval0x343MRW异常附加信息
mip0x344MRW中断挂起位
medeleg0x302MRW异常委托给 S 模式的掩码
mideleg0x303MRW中断委托给 S 模式的掩码
sstatus0x100SRWS 级状态视图
sie0x104SRWS 级中断使能
stvec0x105SRWS 级异常入口
sscratch0x140SRWS 级暂存
sepc0x141SRWS 级异常返回地址
scause0x142SRWS 级异常原因
stval0x143SRWS 级异常附加信息
sip0x144SRWS 级中断挂起
satp0x180SRW页表基址与地址翻译模式
mhartid0xF14MRO硬件线程 ID
mvendorid0xF11MRO厂商 ID
marchid0xF12MRO架构 ID
mimpid0xF13MRO实现版本

这张表里有个细节值得单独说:sstatus、sie、sip这些 S 级寄存器,其实是 M 级对应寄存器的“受限视图”。硬件在实现上通常只有一份物理寄存器,S 级看到的是经过掩码过滤后的子集。比如sstatus里只暴露 S 级关心的位,M 级专属的位读出来是 0。这个设计让上下文切换时不用保存两套状态,省硬件也省软件开销。

4. 异常、中断与委托机制实战

4.1 mcause 编码:一眼看出出了什么事

异常和中断发生时,硬件会把原因写进mcause(如果委托给 S 模式则写scause)。这个寄存器的最高位(XLEN-1)是“中断标志位”,1 表示中断,0 表示异常;剩下的位是原因编号。

常见的原因编号我整理成表,调试时对着查非常快:

编号类型含义
0异常指令地址非对齐
1异常指令访问错误
2异常非法指令
3异常断点
4异常加载地址非对齐
5异常加载访问错误
6异常存储地址非对齐
7异常存储访问错误
8异常U 模式 ecall
9异常S 模式 ecall
11异常M 模式 ecall
12异常指令缺页
13异常加载缺页
15异常存储缺页
0x8000000000000003中断机器软件中断
0x8000000000000007中断机器定时器中断
0x800000000000000B中断机器外部中断
0x8000000000000001中断超级用户软件中断
0x8000000000000005中断超级用户定时器中断
0x8000000000000009中断超级用户外部中断

注意中断编号是“最高位置 1 + 基础编号”,所以机器定时器中断是 0x8000000000000007,而不是 7。这个细节在写异常处理程序做 switch-case 时特别容易写错,我见过有人把中断判断写成if (mcause == 7),结果永远进不去定时器分支。

4.2 委托机制:让 S 模式自己处理该处理的事

medeleg和mideleg是 M 模式手里最重要的两个“放权”寄存器。默认情况下,所有异常和中断都先陷进 M 模式,由 M 模式处理。但这样效率很低——每次缺页、每次系统调用都要 M 模式插手,而 M 模式通常只是个薄薄的固件层,它并不懂操作系统的内存管理。

委托机制就是解决这个问题的。M 模式在启动时把“缺页异常”“S 模式 ecall”“S 级定时器中断”这些委托出去,之后这类事件发生时硬件直接陷进 S 模式,M 模式完全不参与。配置方法很直接:

// 把 S 模式 ecall(编号 9)、指令缺页(12)、加载缺页(13)、存储缺页(15)委托给 S 模式 csr_write(medeleg, (1 << 9) | (1 << 12) | (1 << 13) | (1 << 15)); // 把 S 级软件、定时器、外部中断委托给 S 模式 csr_write(mideleg, (1 << 1) | (1 << 5) | (1 << 9));

注意:委托不是“全有或全无”,而是按位精细控制。你可以只委托缺页异常,把 ecall 留在 M 模式自己处理。实际项目中怎么划分,取决于你的 M 模式固件承担多少职责。如果 M 模式只做最基础的硬件初始化,那尽量多委托;如果 M 模式还要做安全监控,那关键异常就得自己留着。

4.3 中断使能的三层开关

RISC-V 的中断使能是个“三层串联”结构,任何一层没打开,中断都进不来。这三层是:全局中断使能位(mstatus.MIE或sstatus.SIE)、对应中断类型的使能位(mie或sie)、以及中断控制器层面的使能(比如 PLIC 的配置)。

很多新手调中断调不通,就是只开了mie忘了开mstatus.MIE,或者反过来。我建议的调试顺序是:先确认mstatus.MIE为 1,再确认mie对应位为 1,最后查中断控制器。这三步走完,90% 的“中断不响应”问题都能定位。

// 打开 M 模式全局中断 csr_set(mstatus, MSTATUS_MIE); // 打开机器定时器中断 csr_set(mie, MIE_MTIE); // 如果定时器中断委托给了 S 模式,则要操作 sie 和 sstatus csr_set(sstatus, SSTATUS_SIE); csr_set(sie, SIE_STIE);

4.4 异常返回:mret 和 sret 做了什么

mret和sret是特权级下降的唯一通道。执行mret时,硬件会做这几件事:把特权级恢复到mstatus.MPP记录的值,把mstatus.MIE恢复到mstatus.MPIE,然后跳转到mepc指向的地址。sret同理,操作的是sstatus.SPP、sstatus.SIE、sstatus.SPIE和sepc。

这里有个经典陷阱:mstatus.MPP是只读的“当前特权级”吗?不是。MPP是“异常发生前的特权级”,由硬件在陷入时自动写入,软件在返回前可以修改它。如果你在异常处理程序里手动把MPP改成 U 模式,那mret之后就会降到 U 模式。这个特性在实现“从 M 模式启动后跳进 U 模式跑应用”时非常有用。

# 从 M 模式跳转到 U 模式的典型序列 la t0, user_entry csrw mepc, t0 # 设置返回地址 csrci mstatus, 8 # 清除 MPP(设为 00 = U 模式) mret # 返回,特权级降为 U

5. 调试实录:那些年踩过的 CSR 坑

5.1 非法指令异常排查清单

CSR 相关的非法指令异常,我总结下来无非四类原因。第一类是访问了当前特权级不允许的 CSR,比如 U 模式读mstatus。第二类是访问了硬件未实现的 CSR,比如某些精简核没有浮点单元,你读fcsr就炸。第三类是写只读 CSR,比如往mvendorid写数据。第四类是 CSR 地址本身编码非法,比如 bit 11:10 是 10 这种保留组合。

排查顺序建议从“查地址编码”开始,因为这一步不需要运行环境,对着手册就能排除。确认地址合法后,再看当前特权级和 CSR 级别是否匹配。最后查硬件是否实现该 CSR。我遇到过最隐蔽的一次是:芯片手册写着支持mcycle,但实际读出来一直是 0,后来发现是时钟门控没打开,CSR 存在但计数器不走。这种问题只能靠对照手册逐项确认。

5.2 mstatus 位操作的常见误伤

mstatus是个“大杂烩”寄存器,里面塞了中断使能、特权级、浮点状态、内存保护等一堆位。用csrw直接写整个寄存器是灾难性的,因为你很可能把不关心的位写成 0,导致浮点单元被关、地址翻译模式被改。

正确做法永远是“读-改-写”,而且优先用csrrs/csrrc的置位/清位语义:

// 错误示范:直接写,误伤其他位 csr_write(mstatus, 0x8); // 正确示范:只置位 MIE csr_set(mstatus, MSTATUS_MIE); // 正确示范:只清位 MIE csr_clear(mstatus, MSTATUS_MIE);

提示:如果你确实需要写整个 mstatus,先把当前值读出来,改完目标位再写回去,并且写之前确认你完全清楚每一位的含义。我个人的习惯是,除非在启动汇编里做初始化,否则永远不整写 mstatus。

5.3 上下文切换时 CSR 的保存与恢复

做操作系统调度时,进程切换需要保存和恢复 CSR。这里有个容易忽略的点:不是所有 CSR 都需要保存。mepc、mcause、mtval这些是异常发生时硬件自动写入的,切换进程时不需要保存;需要保存的是那些“软件配置后长期生效”的,比如satp(页表基址)、sstatus的部分位、浮点 CSR。

保存顺序也有讲究。以 S 模式为例,典型流程是:先把sstatus读出来存到进程控制块,再存satp,然后切换页表,最后恢复新进程的sstatus和satp。如果顺序反了,可能在切换页表的瞬间还在用旧进程的栈指针,直接跑飞。

// 简化的上下文切换片段 void context_switch(struct task *next) { // 保存当前进程的 CSR current->sstatus = csr_read(sstatus); current->satp = csr_read(satp); // 恢复下一个进程的 CSR csr_write(satp, next->satp); csr_write(sstatus, next->sstatus); // 切换通用寄存器(汇编完成) switch_to(next); }

5.4 性能计数器读出来不准怎么办

mcycle、minstret这些性能计数器在调试性能时很有用,但经常有人反馈“读出来不增长”或者“增长得莫名其妙”。常见原因有三个:一是计数器被mcountinhibit寄存器关掉了,这是 RISC-V 规范里专门用来省电的机制;二是多核环境下读到了别的核的计数器,mcycle是每核独立的;三是计数器溢出,32 位核上mcycle跑几十秒就回绕了。

解决办法:先读mcountinhibit确认对应位是 0,再确认mhartid确认自己在哪个核上,最后如果是 32 位核,用“读两次取差值”的方式规避溢出。

uint64_t read_mcycle_safe(void) { uint32_t hi, lo, hi2; do { hi = csr_read(mcycleh); lo = csr_read(mcycle); hi2 = csr_read(mcycleh); } while (hi != hi2); // 防止读 lo 时 hi 进位 return ((uint64_t)hi << 32) | lo; }

6. 从 M 到 U:一个最小可运行的特权级切换示例

6.1 启动流程的整体设计

光讲理论不够,我搭一个最小示例,演示从 M 模式启动,配置好 CSR,委托异常,最后跳进 U 模式跑一段代码,再通过 ecall 回到 M 模式打印信息。这个流程覆盖了 CSR 操作、委托配置、特权级切换、异常处理四个核心环节,跑通一遍,特权架构的脉络就清楚了。

整体设计是这样的:复位后 CPU 在 M 模式,先设置mtvec指向异常处理入口,配置medeleg把 ecall 委托给 S 模式(这里为了演示委托,实际最小系统可以委托给 M 自己处理),设置mepc指向 U 模式入口,清mstatus.MPP为 U,执行mret降级。U 模式代码执行ecall,触发异常,硬件根据委托配置决定陷进哪一级,异常处理程序读取mcause判断是 ecall,处理后mret返回或停机。

6.2 关键代码逐段解析

先看 M 模式初始化部分:

.section .text.init .globl _start _start: # 设置异常入口 la t0, trap_handler csrw mtvec, t0 # 委托 ecall(编号 8 是 U 模式 ecall)给 S 模式 # 这里演示委托,实际如果没有 S 模式就委托给 M 自己 li t0, (1 << 8) csrw medeleg, t0 # 准备跳转到 U 模式 la t0, user_code csrw mepc, t0 # 清 mstatus.MPP 为 00(U 模式) csrci mstatus, 0x1800 # 执行 mret,降级到 U 模式 mret

这段代码里csrci mstatus, 0x1800是关键。mstatus.MPP占 bit 12:11,掩码是 0x1800。清掉这两位就是把返回特权级设为 U。如果忘了这一步,mret后会回到 M 模式,U 模式代码根本不会以 U 权限执行。

再看 U 模式代码和异常处理:

user_code: # 在 U 模式发起系统调用 ecall # ecall 返回后继续执行(如果处理程序允许) j user_code trap_handler: # 读取异常原因 csrr t0, mcause # 判断是否是 U 模式 ecall(编号 8) li t1, 8 beq t0, t1, handle_ecall # 其他异常,停机 j trap_handler handle_ecall: # 处理系统调用,这里简单打印后返回 # 实际项目中会根据 a0/a1 等寄存器判断调用号 # 返回时 mepc 要 +4,跳过 ecall 指令本身 csrr t0, mepc addi t0, t0, 4 csrw mepc, t0 mret

mepc + 4这个操作是必须的。因为mepc保存的是触发异常的指令地址,也就是ecall那条指令的地址。如果不加 4 直接mret,会再次执行ecall,陷入死循环。这个坑我当年踩了整整一个下午,一开始还以为是委托配置错了。

6.3 验证与观察方法

跑通之后怎么验证特权级真的切换了?最直接的办法是在 U 模式代码里尝试读一个 M 级 CSR,比如mstatus,如果触发非法指令异常,说明确实在 U 模式。另一个办法是读mstatus.MPP或者用调试器看当前特权级。

如果你有 JTAG 调试器,可以在mret前后下断点,观察mstatus的MPP位变化。没有调试器的话,就在异常处理程序里把mcause的值通过串口打出来,看到 8 就说明 U 模式 ecall 成功了。

提示:不同仿真器/芯片对未实现 CSR 的处理可能不同,有的抛非法指令,有的返回 0。验证特权级时优先用“访问权限受限的 CSR”而不是“未实现的 CSR”,前者行为更规范。

7. 几个容易被忽略的细节和扩展方向

7.1 sstatus 和 mstatus 的位映射关系

前面提过sstatus是mstatus的受限视图,但具体哪些位对应,很多人没细究。实际上sstatus里的SIE、SPIE、SPP分别对应mstatus里的SIE、SPIE、SPP,地址相同,只是 S 模式访问时 M 级专属位读出来是 0。而sstatus里的SUM、MXR这些位,在mstatus里也有对应位置。

这个映射关系的实际意义在于:M 模式修改mstatus的某些位,会直接影响 S 模式看到的sstatus。比如 M 模式清了mstatus.SIE,S 模式读sstatus.SIE也是 0。做虚拟化或者安全监控时,这个联动特性要特别注意。

7.2 中断委托后 mtvec 和 stvec 的分工

委托配置好之后,异常和中断的入口就分开了:没委托的走mtvec,委托了的走stvec。这意味着你的异常处理代码要分两套,或者至少入口要能区分。我见过有人只设了mtvec没设stvec,结果委托出去的缺页异常跳到了一个未初始化的地址,直接跑飞。

正确做法是启动时两个入口都设好,stvec指向 S 模式的异常处理,mtvec指向 M 模式的兜底处理。这样即使委托配置有误,未预期的异常也能被 M 模式捕获,方便调试。

7.3 后续可以深入的方向

这篇覆盖了 CSR 和 M/S/U 特权架构的基础,但还有几个方向值得继续挖。一是虚拟化扩展(H 扩展)里的hstatus、hedeleg这些 CSR,做 hypervisor 必须掌握。二是物理内存保护(PMP)配置,它和 M 模式紧密相关,决定了 M 模式能访问哪些内存。三是调试规范里的dcsr、dpc等 CSR,用 JTAG 调试时会用到。

我个人在实际项目中的体会是,CSR 这东西光看手册记不住,必须动手写代码、下断点、看寄存器值,踩过几次非法指令异常和死循环之后,自然就刻在脑子里了。建议你拿一块支持 RISC-V 的开发板,把上面那个最小示例跑一遍,比读十遍规范都管用。

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

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

立即咨询