☰
RISC-V CSR与M/S/U特权架构实战解析:从异常处理到系统调用
2026/10/7 14:42:18 网站建设 项目流程

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

前阵子在一个技术群里看到有人问:“risc-v 里的 CSR,和数据结构里那个 CSR 压缩存储,内存空间消耗是不是一个量级?”底下还真有人认真讨论了半天,最后才发现是两码事——一个是 RISC-V 的特权架构寄存器,一个是稀疏矩阵的压缩格式,只是缩写撞了名。这个误会其实挺典型,因为 RISC-V 的 CSR 在入门阶段确实容易被一笔带过,很多人写了不少裸机代码,对mstatus、mepc、mcause这些寄存器的理解还停留在“抄例程”的层面。

这篇就专门把 RISC-V 的 CSR 和 M/S/U 三级特权架构掰开揉碎讲一遍。我会从“为什么需要特权级”这个最根本的问题出发,把 CSR 的地址编码规则、读写指令、M/S/U 三级的职责划分、异常与中断的硬件跳转流程,以及实际写启动代码和 trap handler 时最容易踩的坑,全部串起来。内容偏向实战,适合已经能跑通 RISC-V 裸机点灯、想进一步理解特权架构和异常处理的开发者,也适合正在看手册但被一堆 CSR 名字绕晕的朋友。看完你至少能做到:拿到一份 RISC-V 手册,知道该翻哪一章、该查哪几个寄存器、trap 进来之后该按什么顺序读状态。

2. 特权架构到底解决了什么问题

2.1 没有特权级的世界有多乱

先想一个最朴素的问题:如果一颗 CPU 只有一种运行模式,所有代码都能执行所有指令、访问所有内存和所有硬件寄存器,会发生什么?答案很简单——一个写错指针的应用程序就能把操作系统内核的数据覆盖掉,一个恶意程序可以直接关掉中断、改掉定时器、甚至把自己提权成“内核”。早期一些简单的单片机确实是这样,程序跑飞了只能靠看门狗复位,谈不上任何隔离。

RISC-V 的设计者显然不满足于此。他们引入特权级(Privilege Level)的核心目的就两个:隔离和受控入口。隔离是指低特权级代码不能随意访问高特权级的资源;受控入口是指低特权级想获得高特权级的服务,必须通过一条明确的、硬件规定的路径跳进去,而不是随便跳。这条路径就是异常/中断机制,而承载这条路径状态信息的,就是 CSR。

2.2 M/S/U 三级的分工

RISC-V 定义了三个特权级,从高到低是 Machine(M)、Supervisor(S)、User(U)。名字听着抽象,其实对应关系很清晰:

  • M 模式:最高权限,固件、BootROM、安全监控代码住在这里。M 模式可以访问所有 CSR,可以配置物理内存保护(PMP),是芯片上电后第一个进入的模式。
  • S 模式:操作系统内核住在这里。Linux、RT-Thread 这类带 MMU 或 MPU 的系统,内核态通常跑在 S 模式,通过sstatus、stvec、sepc这些 S 级 CSR 管理异常。
  • U 模式:普通应用程序住在这里。权限最低,很多 CSR 直接不可访问,访问了会触发非法指令异常。

这里有个关键点很多人一开始会搞混:不是所有 RISC-V 芯片都实现了三级。一颗只有 M 模式的单片机(比如很多低端 MCU)是完全合法的,它只需要实现 M 级 CSR;而一颗能跑 Linux 的应用处理器,通常 M/S/U 三级齐全。所以看手册第一件事,是确认它实现了哪几级,这决定了你能用哪些 CSR、异常会往哪跳。

2.3 特权级切换的本质是“换一套寄存器视角”

我习惯把特权级切换理解成“换一套寄存器视角”。同一份代码,在 M 模式看到的mstatus和在 S 模式看到的sstatus,字段布局高度相似但作用域不同;同一段地址,在 U 模式可能因为 PMP 或页表配置而根本访问不了。切换发生时,硬件会自动做几件事:保存当前 PC 到xepc、保存原因到xcause、更新xstatus里的状态位、然后跳到xtvec指定的入口。这套“自动保存 + 跳转”的机制,就是整个特权架构的骨架。

理解了这一点,后面看 CSR 就不会觉得是一堆孤立的寄存器,而是一套围绕“异常入口”组织起来的状态机。

3. CSR 速查:地址编码、读写指令与权限

3.1 CSR 的 12 位地址不是随便编的

CSR 全称 Control and Status Register,控制状态寄存器。它和通用寄存器(x0-x31)最大的区别是:通用寄存器用 5 位编号,CSR 用12 位地址,所以理论上最多 4096 个。这 12 位不是随便分配的,高 4 位编码了“这个寄存器属于哪个特权级、是否只读”,低 8 位才是具体编号。看下面这张表就明白了:

地址高位 [11:10]含义典型例子
00非特权级,U 模式可访问cycle、time、instret
01S 级,S/U 模式可访问(受sstatus控制)sstatus、stvec、sepc
10保留—
11M 级,仅 M 模式可访问mstatus、mtvec、mepc

而地址位 [9:8] 表示读写权限:00是读写,01是读写但 bit[11:10] 必须为 11(即 M 级读写),10是只读,11是只读且 bit[11:10] 必须为 11。这个编码规则非常实用——你拿到一个 CSR 地址,不用查表就能大致判断它属于哪一级、能不能写。比如0x300是mstatus,0x100是sstatus,0xC00是cycle(只读),一眼就能看出层级。

注意:地址编码只是“约定”,具体某个 CSR 是否实现、字段怎么定义,仍然要以具体芯片手册为准。有些 CSR 在标准里是“可选实现”,手册里没写就是没有,别硬写。

3.2 六条 CSR 读写指令

RISC-V 专门为 CSR 设计了六条指令,分两类:读写和置位/清位。它们都是原子操作,这点在多核或中断场景下很重要。

csrrw rd, csr, rs1 # 读旧值到 rd,同时把 rs1 写入 csr csrrs rd, csr, rs1 # 读旧值到 rd,同时把 rs1 的置位写进 csr csrrc rd, csr, rs1 # 读旧值到 rd,同时把 rs1 的清位写进 csr csrrwi rd, csr, imm # 立即数版本,imm 是 5 位无符号 csrrsi rd, csr, imm csrrci rd, csr, imm

用 C 语言写的时候,编译器提供了 intrinsic 或者内联汇编封装。以 GCC 为例,标准头文件里通常有:

// 读 CSR #define read_csr(reg) ({ unsigned long __tmp; \ asm volatile ("csrr %0, " #reg : "=r"(__tmp)); __tmp; }) // 写 CSR #define write_csr(reg, val) ({ \ asm volatile ("csrw " #reg ", %0" :: "rK"(val)); }) // 置位 #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; })

这里有个细节值得说:csrrs和csrrc的 rs1 如果是 x0,就变成“只读不写”,等价于csrr;反过来 rd 是 x0 就变成“只写不读”。编译器经常利用这个特性生成更紧凑的代码。另外csrrwi这类立即数版本,imm 只有 5 位,所以只能操作低 5 位,写高位字段还是得用寄存器版本。

3.3 常用 CSR 速查表

下面这张表是我自己整理的高频 CSR,按 M/S/U 分组,实际调试时对着查非常省事:

CSR地址级别作用
mstatus0x300M全局状态:中断使能、特权级、FS/VS 等
misa0x301M指令集架构信息,只读
mie0x304M中断使能位
mtvec0x305MM 模式 trap 入口地址
mscratch0x340M临时寄存器,trap 时保存上下文指针
mepc0x341M异常返回地址
mcause0x342M异常/中断原因
mtval0x343M出错地址或指令
mip0x344M中断挂起位
sstatus0x100SS 级全局状态
sie0x104SS 级中断使能
stvec0x105SS 模式 trap 入口
sscratch0x140SS 级临时寄存器
sepc0x141SS 级异常返回地址
scause0x142SS 级异常原因
stval0x143SS 级出错信息
sip0x144SS 级中断挂起
satp0x180S页表基址,控制地址翻译
cycle0xC00U周期计数,只读
time0xC01U实时计数,只读
instret0xC02U已执行指令数,只读

提示:mstatus和sstatus字段布局几乎一样,但sstatus是mstatus的一个“视图”,M 模式改mstatus会影响sstatus的可见部分。调试时如果发现 S 级状态不对,先回头查 M 级是不是把它屏蔽了。

4. M/S/U 三级特权架构的实战拆解

4.1 M 模式:上电后的第一站

芯片上电复位后,PC 指向复位向量,此时一定处于 M 模式。M 模式代码通常做这几件事:初始化时钟和内存、配置 PMP、设置mtvec、准备好 S 模式或 U 模式的运行环境,最后用mret跳过去。这里的关键 CSR 是mstatus里的 MPP 字段(bit[12:11]),它决定mret之后进入哪个特权级。

// 准备跳转到 S 模式 unsigned long mstatus = read_csr(mstatus); mstatus &= ~(3UL << 11); // 清 MPP mstatus |= (1UL << 11); // MPP = 01,表示 S 模式 write_csr(mstatus, mstatus); write_csr(mepc, s_mode_entry); // 设置返回地址 asm volatile ("mret"); // 跳转

这段代码看着简单,但有两个坑。第一,mret之前必须确保mepc指向的地址是合法的、可执行的,否则直接跑飞。第二,如果目标模式是 S 或 U,还要提前配置好 PMP,否则跳过去第一条取指就可能触发访问异常。我见过有人忘了配 PMP,结果mret之后直接进 trap,查了半天以为是mepc写错了。

4.2 S 模式:操作系统内核的主场

S 模式是给操作系统内核用的。它能看到sstatus、stvec、sepc这一套 S 级 CSR,也能通过satp配置页表开启虚拟地址翻译。S 模式最核心的机制是S 级异常委托:M 模式可以通过mideleg和medeleg两个 CSR,把一部分中断和异常“委托”给 S 模式处理,这样 S 模式内核就不用每次都陷回 M 模式,性能更好。

// M 模式代码:把 S 级定时器中断和部分异常委托给 S 模式 set_csr(mideleg, (1UL << 5)); // 委托 S 级定时器中断 set_csr(medeleg, (1UL << 8)); // 委托 U 模式 ecall

委托之后,S 模式收到中断时,硬件会直接跳到stvec,scause里记录原因,sepc记录返回地址。这套机制让 M 模式可以退居幕后,只处理真正需要最高权限的事情,比如 PMP 配置和平台级中断。

4.3 U 模式:应用代码的沙箱

U 模式权限最低,很多 CSR 直接不可访问。它想请求内核服务,只能通过ecall指令主动触发异常,陷入 S 模式。ecall从 U 模式发出时,mcause或scause的值是 8(如果委托给 S 就是scause=8);从 S 模式发出时是 9。这个区别在写系统调用入口时非常重要,因为你要根据来源决定怎么处理。

U 模式还有一个容易被忽略的点:它能不能访问cycle、time这些非特权 CSR,取决于mcounteren和scounteren的配置。默认情况下这些计数器在 U 模式是关闭的,访问会触发非法指令异常。如果你在 U 模式跑性能测试代码,发现读cycle就崩,八成是忘了在 M 模式打开mcounteren。

4.4 三级切换的完整路径

把三级串起来看,一次典型的系统调用路径是这样的:

  1. U 模式应用执行ecall。
  2. 硬件检查medeleg,如果 bit 8 被置位,异常委托给 S 模式。
  3. 硬件保存 PC 到sepc,原因 8 写入scause,跳到stvec。
  4. S 模式内核保存上下文,根据scause分发系统调用。
  5. 处理完毕,执行sret,硬件从sepc恢复 PC,回到 U 模式。

如果异常没有被委托,第 2 步就会走 M 模式,跳到mtvec,用mepc/mcause。理解这条路径,调试 trap 问题时就能快速判断“到底该看哪一级的 CSR”。

5. 异常与中断:trap 机制的硬件细节

5.1 trap 发生时硬件自动做了什么

很多人写 trap handler 时习惯性地“先保存所有寄存器”,但其实硬件已经帮你做了一部分。trap 发生时,硬件自动完成:

  • 把当前 PC 写入xepc(x 是 m 或 s)。
  • 把异常原因写入xcause,最高位区分中断(1)和异常(0)。
  • 把出错地址或指令写入xtval(部分异常才有意义)。
  • 更新xstatus里的状态位,比如把当前特权级存入 MPP/SPP。
  • 关闭中断使能(mstatus.MIE或sstatus.SIE清零)。
  • 跳到xtvec指定的入口。

所以 handler 里第一件事不是保存 PC,而是保存通用寄存器和必要的临时状态。xepc、xcause这些硬件已经存好了,你直接读就行。

5.2 mtvec/stvec 的两种模式

mtvec和stvec的低两位决定入口模式:

  • Direct 模式(低两位 = 00):所有 trap 都跳到BASE地址。
  • Vectored 模式(低两位 = 01):中断按编号跳到BASE + 4 * cause,异常仍然跳到BASE。

Vectored 模式的好处是中断响应快,不用在 handler 里再判断一次原因。但它要求BASE对齐到 4 字节边界,而且中断编号不能太大,否则会跳到非法地址。实际项目里 Direct 模式更常见,因为灵活、好调试。

// 设置 mtvec 为 Direct 模式 write_csr(mtvec, (unsigned long)trap_entry & ~0x3UL);

注意:mtvec的 BASE 字段有对齐要求,具体对齐位数看手册。有些实现要求 4 字节对齐,有些要求更严格。写之前一定确认,否则低两位会被硬件忽略或触发异常。

5.3 mcause/scause 编码速查

mcause和scause的编码规则一样:最高位是 1 表示中断,0 表示异常,低位是编号。下面这张表是高频原因码:

编号类型含义
0异常指令地址非对齐
1异常指令访问错误
2异常非法指令
3异常断点
4异常加载地址非对齐
5异常加载访问错误
6异常存储地址非对齐
7异常存储访问错误
8异常U 模式 ecall
9异常S 模式 ecall
11异常M 模式 ecall
3中断M 软件中断
7中断M 定时器中断
11中断M 外部中断
5中断S 定时器中断
9中断S 外部中断

写 handler 时,先读xcause判断最高位,再按编号分发。这里有个经验:不要用 switch-case 硬编码所有编号,因为不同芯片可能实现不同的中断源。更好的做法是只处理你关心的几个,其余统一记录并返回,避免漏掉未知中断导致系统卡死。

5.4 上下文保存与恢复的实操

trap handler 的上下文保存是性能敏感区。最朴素的做法是把 x1-x31 全部压栈,但这样开销大。实际项目里通常分两级:第一级只保存 caller-saved 寄存器(x1、x5-x7、x10-x17、x28-x31),因为被中断的函数本来就不指望这些寄存器跨调用保持;如果 handler 里要调用 C 函数,再保存 callee-saved 寄存器。

trap_entry: addi sp, sp, -128 sd x1, 0(sp) sd x5, 8(sp) sd x6, 16(sp) sd x7, 24(sp) sd x10, 32(sp) # ... 保存其余 caller-saved csrr a0, mcause csrr a1, mepc call trap_handler # ... 恢复寄存器 addi sp, sp, 128 mret

这里有个细节:mepc在返回前可能需要 +4,因为ecall这类异常发生时,mepc指向的是ecall指令本身,如果不加 4 就会无限循环。但中断发生时mepc指向的是下一条要执行的指令,不能加。所以 handler 里要根据mcause判断是否调整mepc。

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

6.1 trap 进去出不来,先查这五个地方

trap 死循环是新手最常见的坑。我整理了一个排查顺序,基本能覆盖 90% 的情况:

排查项检查方法常见错误
xtvec是否设置读回mtvec/stvec忘了设,或地址没对齐
xepc是否正确读mepc/sepc返回前没 +4,或指向非法地址
栈指针是否有效看sp是否在合法 RAM 范围栈溢出或未初始化
中断使能是否合理查mstatus.MIE、miehandler 里没关中断导致嵌套
PMP 是否放行查pmpcfg/pmpaddr跳转目标被 PMP 拦住

我自己的习惯是:trap handler 第一行先把mcause、mepc、mtval打印出来(如果串口可用),这三个值基本能定位大部分问题。mcause告诉你发生了什么,mepc告诉你从哪来,mtval告诉你出错地址。

6.2 CSR 写入不生效?可能是权限或只读位

有时候你明明写了write_csr(mstatus, val),读回来却不一样。原因通常有三个:第一,某些位是只读的(WARL 或 WLRL 字段),硬件会忽略你的写入;第二,当前特权级不够,写 M 级 CSR 时如果不在 M 模式,会触发非法指令异常;第三,字段之间有联动,比如改 MPP 可能受其他位约束。遇到这种情况,先查手册确认字段的读写属性,再用csrrs/csrrc只改你关心的位,避免误伤其他字段。

6.3 中断嵌套与优先级处理

RISC-V 的中断优先级由硬件和平台定义,标准本身不规定。实际写嵌套中断时,要注意两点:第一,进入 handler 后硬件已经关了中断使能,如果要支持嵌套,得手动重新打开,但要先保存好上下文;第二,mip/sip里的挂起位是只读的,你只能通过清除中断源来让它消失,不能直接写。我见过有人试图写mip清中断,结果毫无效果,就是因为这个位是只读的。

6.4 从 M 模式跳 S 模式后串口不输出

这个问题很典型。M 模式串口能输出,mret跳到 S 模式后就没反应了。原因通常是 S 模式没有配置自己的 trap 入口,或者 PMP 没放行串口寄存器地址。排查方法:先在 S 模式入口点个灯确认代码在跑,再检查stvec是否设置、PMP 是否覆盖串口地址范围。如果 S 模式还要开页表,那还得确认satp配置正确,否则取指都可能出错。

6.5 独家避坑清单

  • mstatus.MPRV别乱用:这个位能让 M 模式用 MPP 指定的特权级做 load/store,调试时很有用,但忘了清会污染后续访问。
  • mscratch是救命稻草:trap 进来时通用寄存器可能全是脏的,mscratch是唯一能安全拿到上下文指针的地方,启动时一定初始化。
  • 委托异常前先确认 S 级能处理:medeleg一开,异常就归 S 管了,如果 S 级 handler 还没准备好,系统直接崩。
  • 计数器使能要逐级打开:U 模式读cycle需要mcounteren和scounteren同时放行,少一个都不行。
  • fence和 CSR 的顺序:改完satp或 PMP 后,记得加fence或sfence.vma,否则流水线可能用旧配置。

7. 写在最后的一点个人体会

CSR 和特权架构这块,我自己的学习路径是“先跑通再深挖”。一开始不用把每个字段都背下来,先把mtvec、mepc、mcause这三个用熟,能写一个打印异常原因的 handler,就已经超过很多人了。等真正要做系统隔离、跑多任务或者移植操作系统时,再回头啃mstatus的字段布局和委托机制,会顺畅很多。

另外提醒一句,不同芯片对 CSR 的实现差异比想象中大。标准里写“可选”的,实际可能没有;标准里写“必须”的,不同版本也可能有细微差别。所以手册永远比记忆可靠,遇到不确定的字段,翻手册确认一遍,比在网上搜半天强。

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

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

立即咨询