搞嵌入式或底层系统的人都知道,RISC-V 里有一片区域绕不开——CSR。Control and Status Register,控制状态寄存器,不是图算法里那个 Compressed Sparse Row,别一搜“CSR 内存空间消耗”搜到图论那边去了。它本质上是 CPU 内部的一组特殊寄存器,负责管特权模式、管中断、管异常、管地址翻译,甚至管调试和性能计数器。没有 CSR,你连“当前 CPU 在哪个特权级”都不知道。
这个专栏走到第三篇,我不想再给你灌一堆历史背景,直接上干货。RISC-V 之所以比 ARM 那套繁杂的异常模型好啃,核心就在于它的特权架构非常干净:Machine 模式、Supervisor 模式、User 模式,三层,M/S/U。CSR 就按这三层权限分配地址空间,每个 CSR 该谁读、该谁写,地址一出来基本就知道。这篇我会把常用的 CSR 按机器模式和监督模式分开拆细,把地址分区的规律、读写指令的语义边界、以及几个我实际踩过坑的字段一起讲清楚。这篇文章适合正在写 RISC-V 模拟器、移植 RTOS、或者调底层引导固件的人参考。
1. 三种特权模式到底在管什么事:M/S/U 的权限设计与切换链路
1.1 机器模式是唯一的全权限模式
RISC-V 定死的规则很简单:M 模式权限最高,S 其次,U 最低。M 模式一般跑固件、安全监控器、Bootloader,比如 OpenSBI、RustSBI 这类;S 模式跑操作系统内核,Linux 的 kernel 就跑在这一层;U 模式跑普通应用程序。
这里有个和 ARM 的明显区别:ARM 的 EL3、EL2、EL1、EL0 层级多,而且很多 SoC 默认就把 Linux 直接放 EL1,启动链一路从 EL3 往下切。RISC-V 把 M 模式单拿出来当“绝对控制层”,S 模式只比 U 多一个访问敏感 CSR 和配置页表的权限。也就是说:即使 Linux 内核崩了,在 M 模式跑的固件也死不了,这是设计上刻意保留的“最后一道防线”。
M 模式的“唯一全权限”体现在它能干所有事:读写所有 CSR,配置 PMP 物理内存保护,访问全部物理地址空间,还能把 S/U 模式产生的异常接管过来。而 S 模式不能碰 M 模式的任何 CSR,U 模式连 S 模式的 CSR 也别想碰,一碰就是非法指令异常。
1.2 ecall 和 mret/sret:陷阱进出的两条链路
光说模式没意思,关键是模式怎么切换。RISC-V 的切换不靠“特权指令”一条条切,而是靠异常和中断这个统一机制——所有进高权限模式的行为都叫 trap,返回低权限模式靠一条指令。
用户程序想进内核,执行ecall,在 U 模式下会触发“Environment Call from U-mode”异常,编号 8。这个异常默认直接进 M 模式,但通常在启动时 OpenSBI 已经通过medeleg把它委托给 S 模式,于是系统调用就直接进 S 模式的异常入口。S 模式处理完调用sret返回 U 模式。M 模式处理完中断或异常,执行mret返回发生 trap 之前那个模式。整个过程用一张图描述就是这样:
- 应用程序执行 ecall → 硬件自动切换到 S 模式,跳转 stvec 指向的入口
- OS 内核处理完系统调用 → 执行 sret → 切回 U 模式,从 ecall 的下一条指令继续执行
- 如果发生的是外部中断,且 S 模式下能处理 → 还是进 stvec
- 如果 S 模式处理不了或者没开 S 模式,中断进 M 模式 → 跳转 mtvec,处理完 mret 返回
切换过程中,硬件负责保存关键上下文,软件负责读 CSR。具体来说:
- 当前 PC 被写入
mepc或sepc - 异常原因写入
mcause或scause - 附加信息写入
mtval或stval - 全局中断使能位被清零,之前的使能状态保存到
mstatus.MPIE或sstatus.SPIE - 之前所在模式记录到
mstatus.MPP或sstatus.SPP
这一套配合得非常机械,所以很多人第一次写中断处理程序时被 mstatus 那一堆位搞晕。但换个角度想:硬件已经把“从哪里来、为什么来、去哪处理”都写好了,你只要按规范读出来用就行,规则比 ARM 的 CPSR 那一大堆 save/restore 要直白得多。
2. CSR 地址空间的分区规则:0x000 到 0xFFF 的“门牌号”逻辑
2.1 地址分区如何向开发者传达权限信息
CSR 的地址是 12 位,从 0x000 到 0xFFF,一共 4096 个编号。这 4096 个编号不是随便分的,高几位直接编码了访问权限。整个布局我整理成了下面这张表:
| 地址区间 | 所属权限 | 典型用途 |
|---|---|---|
| 0x000 - 0x0FF | 用户模式 | 浮点状态、用户中断扩展、向量扩展状态 |
| 0x100 - 0x1FF | 监督模式 | sstatus、stvec、satp 等操作系统必用寄存器 |
| 0x200 - 0x2FF | 虚拟化扩展 | Hypervisor 相关寄存器,H 扩展保留 |
| 0x300 - 0x3FF | 机器模式 | mstatus、mtvec、mepc、mcause 等核心 CSR |
| 0x400 - 0x7FF | 机器模式自定义 | 厂商私有扩展、Debug 模块 |
| 0x800 - 0x8FF | 用户自定义 | 用户态扩展专用 |
| 0x900 - 0x9FF | 监督自定义 | S 模式下的私有扩展 |
| 0xB00 - 0xBFF | 机器自定义 | mcycle、minstret 等也可视为只读计数器 |
| 0xC00 - 0xCFF | 用户只读 | cycle、time、instret 三个计数器 |
| 0xF00 - 0xFFF | 机器只读 | 只读性能计数器、机器标识寄存器 |
为什么这么分?因为硬件实现太爽了。做 RISC-V 核的时候,只需要把 12 位地址的高 2 到 3 位拉出来和当前特权级做一次比较,就能判断这次访问有没有权限。地址本身就是权限标签,不用像某些架构那样再单独做一张特权表。比如地址 0x105 开头是 0x100 段,天然就是 S 模式的 stvec;地址 0x305 是 0x300 段,天然就是 M 模式的 mtvec。看到编号,脑子里就能反应出它在哪层。
2.2 读无权限 CSR 会发生什么:别抱侥幸心理
访问一个你当前模式没权限碰的 CSR,结果是抛出非法指令异常(Illegal Instruction)。U 模式去读mstatus,读不了,直接异常。企业级一点的做法是:写不存在或无权限的 CSR 都是 trap,保证恶意程序没法猜 CSR 内容。但也有个别平台实现选择了更宽松的“读不可见返回 0、写不可见写空气”策略,这个在 RISC-V 特权规范里是被允许的,目的是为了将来扩展兼容。实际开发中,QEMU 和大多数开源核都走 trap 路线,你真碰到宽松实现也别奇怪,以你 SoC 的文档为准。
这个“地址段决定权限”的规律还有个实际价值:当你在 Linux 下写一个用户态工具想去读 cycle 计数器时,你能用csrr读 0xC00,因为它是用户只读段;但你去读 0x900(S 自定义段)就不行,一跑就段错误。定位 CSR 访问问题的时候,先看地址段,再查寄存器定义,十有八九能省一半时间。
3. 机器模式核心 CSR 速查:从 mstatus 到 mie 的字段拆解
3.1 一张表记住机器模式常用 CSR
机器模式是 M/S/U 三层里的核心,绝大多数嵌入式开发其实只跑 M 模式,所以这些 CSR 是最高频出现的。
| CSR 名称 | 地址 | 位数 | 作用 |
|---|---|---|---|
| mstatus | 0x300 | XLEN | 全局状态:中断开关、权限模式记录、扩展状态 |
| misa | 0x301 | XLEN | 描述 CPU 支持哪些 ISA 扩展 |
| medeleg | 0x302 | XLEN | 异常委托位图,决定哪些异常直接交给 S 模式 |
| mideleg | 0x303 | XLEN | 中断委托位图,决定哪些中断直接交给 S 模式 |
| mie | 0x304 | XLEN | 机器模式中断使能 |
| mtvec | 0x305 | XLEN | 机器模式陷阱向量:异常入口地址 |
| mscratch | 0x340 | XLEN | 机器模式临时寄存器,通常用它保存上下文指针 |
| mepc | 0x341 | XLEN | 进入 M 模式 trap 之前那条指令的 PC |
| mcause | 0x342 | XLEN | 本次 trap 的原因,最高位是中断标志 |
| mtval | 0x343 | XLEN | 本次 trap 的附加信息,比如触发异常的访存地址 |
| mip | 0x344 | XLEN | 机器模式中断挂起状态 |
| pmpcfg0 | 0x3A0 | 64 位/RV32 下分两个 | 物理内存保护规则配置 |
| mcycle / minstret | 0xB00 / 0xB02 | 64 位 | 周期计数和指令数计数 |
这里我重点展开三个出场率最高的:mstatus、mtvec、mcause。因为写中断处理程序大概率只用这三件套加mepc。
3.2 mstatus 到底有哪些位值得背
mstatus位太多,真正值得背的就下面这些:
- MIE(bit 3):M 模式的全局中断使能。发生 trap 时硬件自动清零,返回时由你恢复。
- MPIE(bit 7):trap 发生前 MIE 的值,mret 时硬件会用 MPIE 恢复 MIE。
- MPP(bit 12:11):记录进入 M 模式之前 CPU 在哪个特权级。00 是 U,01 是 S,11 是 M。mret 会根据这个值切回对应模式。
- SIE(bit 1):S 模式的全局中断使能,S 模式下用软件开关。
- SPIE(bit 5):trap 发生前 SIE 的值,sret 时会恢复 SIE。
- SPP(bit 8):记录进入 S 模式之前是在 U 还是在 S。sret 根据它决定返回目标。
- FS(bit 14:13)和 XS(bit 16:15):浮点单元和扩展单元的状态,主要给操作系统做上下文保存优化用,Trap 时若 FS=00 说明没有脏状态,可以少存一堆寄存器。
- MPRV(bit 17):加载和存储地址翻译模式的修改位,平时不用,跑模拟器或者写内核临时访问物理地址时有用。
- SD(最高位):汇总 FS/XS 状态的脏标志,给 OS 快速判断是否需要保存浮点上下文。
注意一点:RV32 和 RV64 的mstatus布局有差异,64 位下高 32 位里还有 SXL、UXL 这种用来控制下级模式的 XLEN 的位。32 位下扩展部分放mstatush(0x310)。移植操作系统的人一定要按目标位宽查对应字段。
3.3 mtvec、mepc、mcause、mtval 的配合逻辑
mtvec是 trap 入口地址,低 2 位是 MODE。MODE 为 0 时,所有 trap 都跳到同一个基地址;MODE 为 1 时,按中断号做偏移:入口地址 = BASE + 4 × cause。向量中断模式让每个中断源有独立 handler,省去在 C 入口里再做一次 switch,但要求 BASE 必须按中断数对齐,用的时候要小心。
mepc保存的是被中断打断的指令地址。处理完中断后执行 mret,CPU 会从 mepc 接着跑。但注意一个坑:如果 trap 发生在压缩指令上,mepc 写入时硬件会清掉最低位保证 2 字节对齐;如果发生在非压缩指令上,最低位固定为 0。你如果想在 handler 里回滚一条指令再重新执行,就要自己去改 mepc,别默认它指向下一条。
mcause是最容易看错的寄存器,它的最高位是 Interrupt 标志,低 12 位才是异常编号。比如外部机器中断是0x8000000B,而 M 模式 ecall 是0x0000000B。两者低 12 位都是 11,但性质完全不同。判断逻辑应该是“先看最高位是不是 1,再用低 12 位匹配原因”,而不是把整个 32 位拿来比较。
mtval是辅助信息寄存器:地址不对齐异常时记录出错地址,指令访问错误时记录指令地址,断点异常记录触发断点的 PC。页表缺页时它也记录访存虚拟地址,这对写缺页处理函数简直是救命稻草。
中断使能和委托这里一并说掉:mie各位对应不同中断源,外部中断 MEI 是 bit 11,软件中断 MSI 是 bit 3,定时器中断 MTI 是 bit 7。mip的位布局和 mie 一样,表示当前有哪些中断已经挂起。判断“为啥我的中断没触发”,第一条要查的就是 mip 挂了没、mie 开了没、mstatus.MIE 置位没,三者缺一,中断都进不来。
mideleg和medeleg控制把中断和异常直接“让利”给 S 模式。位号对位号,比如把 mideleg 的 bit 7 置 1,机器定时器中断就直接进 S 模式时钟中断处理函数,M 模式固件根本不用碰。OpenSBI 启动时一般会把这些都配好,裸机自己写时别漏,否则你会发现自己的 RTOS 里等了半天定时器也没反应。
4. 监督模式 CSR 速查:sstatus 与 satp,以及和 M 模式的镜像关系
4.1 S 模式 CSR 列表:几乎是 M 模式的“半套镜像”
S 模式上一共有十几个标准 CSR,先列速查表:
| CSR 名称 | 地址 | 说明 |
|---|---|---|
| sstatus | 0x100 | mstatus 的 S 模式视图,只能看到允许 S 模式控制的位 |
| sie | 0x104 | S 模式中断使能 |
| stvec | 0x105 | S 模式陷阱入口地址 |
| scounteren | 0x106 | 控制 U 模式能否读性能计数器 |
| sscratch | 0x140 | S 模式临时寄存器 |
| sepc | 0x141 | S 模式 trap 前的 PC |
| scause | 0x142 | S 模式 trap 原因 |
| stval | 0x143 | S 模式 trap 附加信息 |
| sip | 0x144 | S 模式中断挂起 |
| satp | 0x180 | 地址翻译和控制寄存器,虚拟内存的开关 |
你会发现这些名字几乎就是机器模式那群 CSR 换个前缀。sstatus 的位布局和 mstatus 高度重合,但它的 MIE、MPIE、MPP 这些 M 模式专用的位在 sstatus 里根本不出现,被规范标记为只读 0 或者保留。这种“高权限视角覆盖低权限视角”的镜像设计,写代码时很舒服:你写内核驱动只要读 sstatus 就够了,没必要去动 mstatus。
S 模式还有sepc、scause、stval,用法和 M 模式一样。两者唯一的微妙差异是:S 模式的 trap 自己就是发生在“S 模式下”,所以 sepc 的值总是一条完整合法的指令地址,不存在 M 模式那种“还要从 VU 模式换算”的过程。S 模式的处理程序就是标准的“保存现场、查 scause、处理、恢复现场、sret”。
4.2 satp:从物理内存切换到虚拟内存的关键
satp是 S 模式最重要的寄存器。它管的是地址翻译,结构大致是:
- 高 4 位(RV64 下 bit 63:60)是 MODE:0 表示 Bare(物理地址直通),8 表示 Sv39,9 表示 Sv48,10 表示 Sv57。
- 中间一段是 ASID:地址空间标识符,用来区分不同进程的页表缓存。
- 低 44 位(Sv39 下)是 PPN:根页表的物理页号。
很多人第一次写启动代码,把 satp 一整个寄存器塞进去一个“页表基址”就等着开 MMU,结果 boot 直接崩。原因多半是 MODE 字段被写成了 0,或者页表基址没按页对齐导致 PPN 错位。正确流程是:
- 先把内核页表建好,确保地址映射覆盖当前 PC 和栈。
- 往 satp 写入 (MODE << 60) | (ASID << 44) | (root_page_phys >> 12)。
- 执行
sfence.vma刷新 TLB。 - 此后所有取指和数据访问都走虚拟地址。
写 satp 一定要把 MODE 带上。很多人调试时用 GDB 一读 satp,发现 MODE 是 0,但程序居然跑得挺好,因为此时页表映射里物理地址和虚拟地址相同。一旦要跑到高位虚拟地址,就原形毕露。
S 模式的中断委托还有一个点容易被忽略:sie的位布局和mideleg的位布局是对应的。如果想把 S 模式外部中断 SIG 打开,需要同时确认 mideleg 把 9 号中断委托给了 S 模式,否则你开了 sie 也没用,中断还是会被 M 模式截胡。这也是很多初学者把外设中断配到一半发现“中断进不来”的原因。
5. 六条 CSR 指令与读写权限的边界规则
5.1 CSRRW/CSRRS/CSRRC 的原子语义:读改写不分家
CSR 不像普通寄存器只能用 load/store 访问,RISC-V 专门定义了六条指令来读写,全部在 Zicsr 扩展里。关键点:它们全都是“原子读改写”。
| 指令 | 行为 |
|---|---|
| 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, uimm | csrrw 的立即数版本 |
| csrsi rd, csr, uimm | csrrs 的立即数版本 |
| csrci rd, csr, uimm | csrrc 的立即数版本 |
这里最容易搞混的是 csrrs 和 csrrc。它们不是直接覆盖,而是“按位操作”。rs1 中是 1 的位才会改 CSR,0 的位保持原状。这在嵌入式配寄存器时极其好用:你想打开某个中断而不管其他位是什么,用csrrsi x0, mie, 0x80,只置位 bit 7,其他中断使能位原封不动。如果想全量覆盖,就得用 csrrw。
5.2 汇编伪指令与 C 内嵌汇编的写法
汇编器贴心地给出了伪指令,底层就是上面六条:
csrr rd, csr等价于csrrs rd, csr, x0,rs1 为 x0 时表示“我不改任何位,只想读”。csrw csr, rs等价于csrrw x0, csr, rs,rd 为 x0 表示“我不想读旧值,只想写”。csrs csr, rs等价于csrrs x0, csr, rs,置位。csrc csr, rs等价于csrrc x0, csr, rs,清位。- 立即数版本对应 csrwi、csrsi、csrci。
C 里直接用内嵌汇编就好:
static inline uintptr_t csr_read(uintptr_t csr) { uintptr_t value; asm volatile("csrr %0, %1" : "=r"(value) : "i"(csr)); return value; } static inline void csr_write(uintptr_t csr, uintptr_t value) { asm volatile("csrw %0, %1" : : "i"(csr), "r"(value)); } static inline void csr_set(uintptr_t csr, uintptr_t mask) { asm volatile("csrs %0, %1" : : "i"(csr), "r"(mask)); }注意约束用的是"i",必须是常量,因为 CSR 编号是立即数编码进指令里的。想动态传编号?没门,CSR 指令的编号在指令里是 12 位立即数,不能放寄存器,这点和 ARM 的 mrc/mcr 完全不一样。我见过有人把 CSR 地址存在变量里,然后asm volatile("csrw %0, %1")传变量,编译直接报错。
5.3 权限检查与只读保护:写错了会怎么样
CSR 访问最终由处理器内部的权限检查单元裁决:
- 当前特权级低于 CSR 地址段要求的权限 → 非法指令异常。
- CSR 编号不存在 → 非法指令异常。
- 写入一个只读 CSR 或只读字段 → 非法指令异常(部分字段是 WPRI,写进去会被忽略)。
这套规则在模拟器上尤其重要。写 RISC-V 模拟器时,很多人一开始图省事直接把 CSR 当成普通内存段映射,忘了做权限检查,结果跑 Linux 时用户程序一条csrrw就能把机器模式的上下文读出来,安全模型直接报废。正确做法是在译码阶段拿到 CSR 编号后,同时检查三个东西:这个编号存不存在、当前模式有没有权限、是不是只读。三个全过才允许执行,否则直接抛 Illegal Instruction 异常。
再说一个经验:读 CSR 的指令虽然原子,但如果你在中断 handler 里用csrrw去修改寄存器,要小心那个“旧值到 rd”的行为。有时你只想去写一个值,却无意中把旧值读到了 rd,覆盖了一个本不该动的通用寄存器。所以纯写操作记得 rd 用 x0,纯读操作记得 rs1 用 x0,这个习惯能避免很多诡异 bug。
6. 三个典型翻车现场:我如何把 CSR 用错
6.1 mstatus 的 MPP 被覆盖导致 mret 回不到 S 模式
有次我写一个跑在 S 模式的微内核,在时钟中断的 handler 里想临时切到 M 模式读一个机器模式 CSR。为了现场保护,我把整个 mstatus 存到了一个全局变量里,然后在返回前用那个值恢复 mstatus,最后mret。看起来天衣无缝,但跑起来发现:每次中断返回后,代码都掉进了 U 模式的用户程序里,内核直接权限崩溃。
排查了很久才发现,问题出在恢复 mstatus 的顺序上。我恢复 mstatus 时,MPP 字段被我从保存值里原样写回了,但当时保存的 MPP 是“S 模式”,这本没错。错的是我为了开 M 模式的临时访问,在 handler 里用csrw mstatus, t0全量覆盖时把 MPP 改成了 M 模式,后来恢复全局变量时又把一个已经被污染的值写回去……说白了我根本没意识到 MPP 这个字段只要被覆盖,mret 的返回目标就变了。
正确做法是:需要用mret离开时,mstatus 的 MPP 必须精确等于“陷进来之前那个模式”,MPIE 必须等于“我允许中断再次使能的状态”。如果你在 handler 里改过 MPP,返回前务必单独用 csrrc/csrsi 把 MPP 清到目标模式,而不是盲目整段恢复一个旧快照。
6.2 把 ecall 异常号弄混,系统调用直接错位
另一个让我挠头的坑在异常号匹配。当时写一个 S 模式系统调用入口,scause 的判断逻辑写成了if (scause == 9) 处理系统调用。结果用户程序一执行 ecall,整个内核 panic。我一度以为是委托配置坏了,后来单步打 scause 才发现值竟然是 8。
原因特别低级:U-mode ecall 的异常编码就是 8,S-mode ecall 才是 9,M-mode ecall 是 11。我从 ARM 带过来的习惯是“SVC 指令都进同一个超调用号”,到了 RISC-V 里不同特权级的 ecall 是不同异常号。所以判断系统调用入口时,先确认这次 trap 是从 U 进来的,再看 scause 的低 12 位是 8。如果把 9 当用户调用,那只有 S 模式自己调用才进得来,用户永远调不动你。
类似的还有断点异常号 3、非法指令异常号 2,这些号在 mcause 和 scause 里是通用的。建议在头文件里定义好常量,命名区分,例如CAUSE_USER_ECALL 8、CAUSE_SUPERVISOR_ECALL 9,别靠裸数字。
6.3 读 satp 时忽略 MODE 字段,整段代码裸奔
最后这个坑是帮别人调试时遇到的。对方在 U 模式想读“当前进程的页表基址”,直接用csrr读了 satp 然后右移 12 位拿去计算物理地址。结果算出来的“根页表地址”对不上,一查,satp 的 MODE 字段是 8(Sv39),PPN 是 44 位,右移 12 之后混进去了 MODE 和 ASID 的残留,地址当然是错的。
正确拆法应该是:先(satp >> 60) & 0xF拿 MODE,确认是 Sv39/Sv48,再(satp & 0x0FFFFFFFFFFF) << 12拿物理页基址。ASID 那段要单独隔离开。搞虚拟内存系统的人,satp 的位域操作最好直接写成宏,每次都用,别临时手算。
另外一个隐藏坑:当你把 satp 的 MODE 从 Sv39 改成 Bare,或反过来,必须立刻执行sfence.vma,否则 TLB 里还残留旧翻译,后续取指可能抓到物理地址也找不着北。这一步漏了,你会看到程序有时候能跑有时候随机崩溃,而且很难复现。
最后再分享一条我自己的习惯:RISC-V 特权规范里的 CSR 表格就是最权威的速查手册,地址分区、位定义、权限要求全在那张表里,真不确定的情况直接去翻 Privileged Spec 1.12 的 CSR Address Allocation 一节,比我记任何笔记都靠谱。做模拟器的朋友,建议把地址分区那张表直接做成单元测试的断言行,能挡住一大半非法访问 bug。