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 |
| 01 | S 级,S/U 模式可访问(受sstatus控制) | sstatus、stvec、sepc |
| 10 | 保留 | — |
| 11 | M 级,仅 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 | 地址 | 级别 | 作用 |
|---|---|---|---|
mstatus | 0x300 | M | 全局状态:中断使能、特权级、FS/VS 等 |
misa | 0x301 | M | 指令集架构信息,只读 |
mie | 0x304 | M | 中断使能位 |
mtvec | 0x305 | M | M 模式 trap 入口地址 |
mscratch | 0x340 | M | 临时寄存器,trap 时保存上下文指针 |
mepc | 0x341 | M | 异常返回地址 |
mcause | 0x342 | M | 异常/中断原因 |
mtval | 0x343 | M | 出错地址或指令 |
mip | 0x344 | M | 中断挂起位 |
sstatus | 0x100 | S | S 级全局状态 |
sie | 0x104 | S | S 级中断使能 |
stvec | 0x105 | S | S 模式 trap 入口 |
sscratch | 0x140 | S | S 级临时寄存器 |
sepc | 0x141 | S | S 级异常返回地址 |
scause | 0x142 | S | S 级异常原因 |
stval | 0x143 | S | S 级出错信息 |
sip | 0x144 | S | S 级中断挂起 |
satp | 0x180 | S | 页表基址,控制地址翻译 |
cycle | 0xC00 | U | 周期计数,只读 |
time | 0xC01 | U | 实时计数,只读 |
instret | 0xC02 | U | 已执行指令数,只读 |
提示:
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 三级切换的完整路径
把三级串起来看,一次典型的系统调用路径是这样的:
- U 模式应用执行
ecall。 - 硬件检查
medeleg,如果 bit 8 被置位,异常委托给 S 模式。 - 硬件保存 PC 到
sepc,原因 8 写入scause,跳到stvec。 - S 模式内核保存上下文,根据
scause分发系统调用。 - 处理完毕,执行
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、mie | handler 里没关中断导致嵌套 |
| 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 的实现差异比想象中大。标准里写“可选”的,实际可能没有;标准里写“必须”的,不同版本也可能有细微差别。所以手册永远比记忆可靠,遇到不确定的字段,翻手册确认一遍,比在网上搜半天强。