RISC-V CSR完全指南:特权模式、地址分区与避坑手册

发布时间:2026/10/7 14:44:44
RISC-V CSR完全指南:特权模式、地址分区与避坑手册 搞嵌入式或底层系统的人都知道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 模式的任何 CSRU 模式连 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 等核心 CSR0x400 - 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因为它是用户只读段但你去读 0x900S 自定义段就不行一跑就段错误。定位 CSR 访问问题的时候先看地址段再查寄存器定义十有八九能省一半时间。3. 机器模式核心 CSR 速查从 mstatus 到 mie 的字段拆解3.1 一张表记住机器模式常用 CSR机器模式是 M/S/U 三层里的核心绝大多数嵌入式开发其实只跑 M 模式所以这些 CSR 是最高频出现的。CSR 名称地址位数作用mstatus0x300XLEN全局状态中断开关、权限模式记录、扩展状态misa0x301XLEN描述 CPU 支持哪些 ISA 扩展medeleg0x302XLEN异常委托位图决定哪些异常直接交给 S 模式mideleg0x303XLEN中断委托位图决定哪些中断直接交给 S 模式mie0x304XLEN机器模式中断使能mtvec0x305XLEN机器模式陷阱向量异常入口地址mscratch0x340XLEN机器模式临时寄存器通常用它保存上下文指针mepc0x341XLEN进入 M 模式 trap 之前那条指令的 PCmcause0x342XLEN本次 trap 的原因最高位是中断标志mtval0x343XLEN本次 trap 的附加信息比如触发异常的访存地址mip0x344XLEN机器模式中断挂起状态pmpcfg00x3A064 位/RV32 下分两个物理内存保护规则配置mcycle / minstret0xB00 / 0xB0264 位周期计数和指令数计数这里我重点展开三个出场率最高的mstatus、mtvec、mcause。因为写中断处理程序大概率只用这三件套加mepc。3.2 mstatus 到底有哪些位值得背mstatus位太多真正值得背的就下面这些MIEbit 3M 模式的全局中断使能。发生 trap 时硬件自动清零返回时由你恢复。MPIEbit 7trap 发生前 MIE 的值mret 时硬件会用 MPIE 恢复 MIE。MPPbit 12:11记录进入 M 模式之前 CPU 在哪个特权级。00 是 U01 是 S11 是 M。mret 会根据这个值切回对应模式。SIEbit 1S 模式的全局中断使能S 模式下用软件开关。SPIEbit 5trap 发生前 SIE 的值sret 时会恢复 SIE。SPPbit 8记录进入 S 模式之前是在 U 还是在 S。sret 根据它决定返回目标。FSbit 14:13和 XSbit 16:15浮点单元和扩展单元的状态主要给操作系统做上下文保存优化用Trap 时若 FS00 说明没有脏状态可以少存一堆寄存器。MPRVbit 17加载和存储地址翻译模式的修改位平时不用跑模拟器或者写内核临时访问物理地址时有用。SD最高位汇总 FS/XS 状态的脏标志给 OS 快速判断是否需要保存浮点上下文。注意一点RV32 和 RV64 的mstatus布局有差异64 位下高 32 位里还有 SXL、UXL 这种用来控制下级模式的 XLEN 的位。32 位下扩展部分放mstatush0x310。移植操作系统的人一定要按目标位宽查对应字段。3.3 mtvec、mepc、mcause、mtval 的配合逻辑mtvec是 trap 入口地址低 2 位是 MODE。MODE 为 0 时所有 trap 都跳到同一个基地址MODE 为 1 时按中断号做偏移入口地址 BASE 4 × cause。向量中断模式让每个中断源有独立 handler省去在 C 入口里再做一次 switch但要求 BASE 必须按中断数对齐用的时候要小心。mepc保存的是被中断打断的指令地址。处理完中断后执行 mretCPU 会从 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 名称地址说明sstatus0x100mstatus 的 S 模式视图只能看到允许 S 模式控制的位sie0x104S 模式中断使能stvec0x105S 模式陷阱入口地址scounteren0x106控制 U 模式能否读性能计数器sscratch0x140S 模式临时寄存器sepc0x141S 模式 trap 前的 PCscause0x142S 模式 trap 原因stval0x143S 模式 trap 附加信息sip0x144S 模式中断挂起satp0x180地址翻译和控制寄存器虚拟内存的开关你会发现这些名字几乎就是机器模式那群 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是 MODE0 表示 Bare物理地址直通8 表示 Sv399 表示 Sv4810 表示 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 写入 csrcsrrs rd, csr, rs1先读旧值到 rd再把 rs1 中为 1 的位“置位”到 csrcsrrc rd, csr, rs1先读旧值到 rd再把 rs1 中为 1 的位“清位”到 csrcsrrwi rd, csr, uimmcsrrw 的立即数版本csrsi rd, csr, uimmcsrrs 的立即数版本csrci rd, csr, uimmcsrrc 的立即数版本这里最容易搞混的是 csrrs 和 csrrc。它们不是直接覆盖而是“按位操作”。rs1 中是 1 的位才会改 CSR0 的位保持原状。这在嵌入式配寄存器时极其好用你想打开某个中断而不管其他位是什么用csrrsi x0, mie, 0x80只置位 bit 7其他中断使能位原封不动。如果想全量覆盖就得用 csrrw。5.2 汇编伪指令与 C 内嵌汇编的写法汇编器贴心地给出了伪指令底层就是上面六条csrr rd, csr等价于csrrs rd, csr, x0rs1 为 x0 时表示“我不改任何位只想读”。csrw csr, rs等价于csrrw x0, csr, rsrd 为 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 的异常编码就是 8S-mode ecall 才是 9M-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 字段是 8Sv39PPN 是 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。