1. 从三条指令说起:为什么CSR是RISC-V特权的“总开关”
很多人第一次接触RISC-V特权架构,是从三条指令开始的:csrr、csrw、csrrw。看起来不过是读写几个寄存器,但真正上手写裸机代码或者移植操作系统时才会发现,整个特权级别的切换、异常入口的跳转、中断的使能与屏蔽,全都绕不开CSR这一套机制。你可以把CSR理解成RISC-V处理器内部的一排“控制面板旋钮”,M/S/U三个特权级就是三把不同权限的钥匙,谁能拧哪个旋钮,规则写得清清楚楚。
这篇内容适合两类人看:一类是刚学完RISC-V基础指令集、准备往裸机或RTOS方向深入的朋友;另一类是已经在做RISC-V平台移植,但被CSR编号和特权切换搞得有点晕的工程师。我会把CSR速查、M/S/U三级特权的行为差异、异常与中断的委托机制、以及实际写代码时最容易踩的坑,全部串一遍。核心关键词就四个:RISC-V、CSR、特权架构、M/S/U。读完你至少能做到:拿到一份CSR地址表知道怎么查、看到mstatus的位域知道在控制什么、写trap handler时清楚该配哪些寄存器。
先给一个最直观的类比。CSR就像一栋大楼的物业控制室,M模式是物业总管,S模式是楼层管理员,U模式是普通住户。总管能进所有房间,楼层管理员只能管自己那层,住户连控制室的门都摸不到。而CSR寄存器就是控制室里的各个开关面板,每个面板上还贴着“仅总管可操作”“管理员可读”之类的标签。特权架构的本质,就是这套权限标签体系加上一套“遇到事情找谁汇报”的流程。
2. CSR基础机制与速查方法
2.1 CSR的地址编码规则与读写指令
CSR在RISC-V里是一个独立的12位地址空间,总共4096个寄存器位置。这个12位不是随便定的,它被切成了三段:高6位表示读写权限,中间4位表示特权级别,低2位固定为11表示这是CSR空间。理解这个编码规则,你拿到任何一个CSR地址都能反推出它的权限和所属特权级。
具体来说,CSR地址的[11:10]两位是只读位,00表示可读写,11表示只读。[9:8]两位编码了允许访问的最低特权级:00是U模式,01是S模式,10是保留,11是M模式。这意味着一个CSR如果[9:8]是11,那只有M模式能碰它;如果是01,那S模式和M模式都能访问。
读写CSR的指令一共六条,我列个表方便速查:
| 指令 | 含义 | 典型用途 |
|---|---|---|
csrrw rd, csr, rs1 | 读后写,原子交换 | 修改配置同时取旧值 |
csrrs rd, csr, rs1 | 读后置位 | 使能某个功能位 |
csrrc rd, csr, rs1 | 读后清位 | 关闭某个功能位 |
csrrwi rd, csr, imm | 立即数版读后写 | 写入常量 |
csrrsi rd, csr, imm | 立即数版读后置位 | 置位常量 |
csrrci rd, csr, imm | 立即数版读后清位 | 清位常量 |
这里有个细节值得说:当rd为x0时,指令不会产生读副作用,也就是不读只写;当rs1为x0时,csrrw不会写,退化成纯读。这个设计让csrr和csrw这类伪指令有了实现基础。实际写汇编时,csrr t0, mstatus展开就是csrrs t0, mstatus, x0,csrw mstatus, t0展开就是csrrw x0, mstatus, t0。
注意:CSR读写是原子操作,但多条CSR指令之间不保证原子性。如果你要同时修改一个寄存器的多个位域,最好一次读出来、在通用寄存器里改好、再一次性写回去,避免中间被中断打断导致状态不一致。
2.2 必背CSR速查表:从mstatus到mie
下面这张表是我实际调试时最常翻的,按功能分组,建议先混个眼熟,用多了自然记住。
| CSR名称 | 地址 | 特权级 | 核心作用 |
|---|---|---|---|
mstatus | 0x300 | M | 全局中断使能、特权级栈、FS/VS状态 |
misa | 0x301 | M | 指令集架构信息,只读 |
mie | 0x304 | M | 中断使能位 |
mtvec | 0x305 | M | 异常/中断入口基址 |
mscratch | 0x340 | M | 机器模式暂存寄存器 |
mepc | 0x341 | M | 异常程序计数器 |
mcause | 0x342 | M | 异常原因编码 |
mtval | 0x343 | M | 异常附加信息 |
mip | 0x344 | M | 中断挂起位 |
medeleg | 0x302 | M | 异常委托给S模式 |
mideleg | 0x303 | M | 中断委托给S模式 |
sstatus | 0x100 | S | S模式状态(mstatus子集) |
sie | 0x104 | S | S模式中断使能 |
stvec | 0x105 | S | S模式异常入口 |
sepc | 0x141 | S | S模式异常PC |
scause | 0x142 | S | S模式异常原因 |
satp | 0x180 | S | 地址转换与保护 |
sscratch | 0x140 | S | S模式暂存 |
mstatus是这里面最复杂的一个,位域多且互相影响。我挑几个关键位说:MIE(bit 3)是M模式全局中断使能,SIE(bit 1)是S模式全局中断使能,MPIE和SPIE分别是它们的前值。MPP(bits 12:11)记录进入M模式前的特权级,SPP(bit 8)记录进入S模式前的特权级。还有FS(bits 14:13)控制浮点单元状态,VS(bits 10:9)控制向量单元状态。
实操心得:调试中断不触发的问题时,先查
mstatus.MIE,再查mie对应位,最后查具体外设的中断使能。这三层任何一层没开,中断都进不来。我见过太多人只配了外设忘了开全局。
3. M/S/U三级特权架构深度拆解
3.1 三个特权级的职责边界与切换路径
M模式是最高权限,复位后CPU一定从M模式开始执行。M模式能访问所有CSR、所有物理内存、所有外设。S模式是给操作系统内核用的,能访问S模式和U模式的CSR,但碰不了M模式的。U模式是最低权限,只能访问U模式CSR,连mstatus都读不了。
三级之间的切换不是随便跳的,有明确的触发条件:
- M到S:执行
mret指令,且mstatus.MPP设为01(S模式) - M到U:执行
mret,且mstatus.MPP设为00(U模式) - S到U:执行
sret,且sstatus.SPP为0 - U到S:发生异常或中断,且该异常被委托给S模式
- 任意到M:发生异常或中断,且未被委托,或本身就在M模式
这里的关键是mret和sret的行为差异。mret会从mepc恢复PC,从mstatus.MPP恢复特权级,然后把MPP重置为U(或保持,取决于实现),同时把MPIE恢复到MIE。sret类似,但操作的是sepc、sstatus.SPP和SPIE。
我画不出图,但你可以这样记:每次trap进入高特权级,硬件自动把当前特权级压栈到xPP,把全局中断使能压到xPIE并关闭xIE;每次xret返回,硬件从xPIE恢复xIE,从xPP恢复特权级。这个“压栈-恢复”机制保证了中断嵌套时状态不会丢。
3.2 异常委托:让S模式自己处理该处理的事
medeleg和mideleg这两个寄存器是M模式“放权”的工具。默认情况下所有异常和中断都进M模式,但操作系统内核通常运行在S模式,它希望自己处理页错误、系统调用这些异常,而不是每次都让M模式固件代劳。
medeleg的每一位对应一个异常编号。比如bit 8是U模式环境调用(ecall from U),bit 12是取指页错误,bit 13是加载页错误,bit 15是存储页错误。把这些位置1,对应的异常就会直接进S模式的stvec,而不是M模式的mtvec。
mideleg类似,但对应的是中断。不过要注意,M模式的中断(如机器定时器中断)通常不委托,因为那涉及M模式专属的硬件管理。
常见坑:委托了异常但忘了在S模式配
stvec,结果异常一来PC跳到0地址,直接跑飞。委托和入口配置必须成对出现。
3.3 特权级切换时的寄存器可见性变化
同一个CSR地址,在不同特权级下看到的可能是不同的寄存器。最典型的是sstatus和mstatus。sstatus实际上是mstatus的一个影子视图,S模式读写sstatus时,硬件只暴露那些S模式该看到的位,比如SIE、SPIE、SPP、FS、VS等。M模式专属的MIE、MPIE、MPP在sstatus视图里是只读的,读出来是0或者固定值。
这种设计的好处是S模式代码不需要知道M模式的状态细节,坏处是调试时容易混淆。我曾经遇到一个bug:在S模式里写sstatus想开全局中断,结果写的是SIE位,但实际中断使能还受mstatus.MIE控制,而MIE在S模式视图里根本改不了。最后是在M模式固件里把MIE打开才解决。
4. 实操:从零搭建一个M/S/U三级切换的裸机框架
4.1 启动流程与M模式初始化
复位后第一件事是设置mtvec,把异常入口地址写进去。mtvec的低2位决定模式:00是直接模式,所有异常都跳到同一个地址;01是向量模式,不同中断跳不同偏移。裸机阶段我一般先用直接模式,简单可靠。
# M模式启动初始化 la t0, m_trap_entry csrw mtvec, t0 # 配置mstatus:先清MPP,设为U模式,后续mret进U li t0, 0x1800 csrc mstatus, t0 # 清MPP li t0, 0x0800 csrs mstatus, t0 # MPP = 01 (S模式) # 使能M模式全局中断 li t0, 0x8 csrs mstatus, t0 # MIE = 1这段代码的关键是mstatus.MPP的设置。如果你打算从M模式直接跳到U模式跑用户程序,就把MPP设为00;如果先跳S模式让内核初始化,就设为01。设错了会导致mret后特权级不对,后续所有CSR访问都可能触发非法指令异常。
4.2 配置委托与S模式入口
假设我们要让S模式处理页错误和系统调用,M模式只保留定时器和外部中断。代码大概长这样:
# 委托异常给S模式 li t0, (1 << 8) | (1 << 12) | (1 << 13) | (1 << 15) csrw medeleg, t0 # 委托S模式中断(软件中断和定时器中断) li t0, (1 << 1) | (1 << 5) csrw mideleg, t0 # 设置S模式异常入口 la t0, s_trap_entry csrw stvec, t0 # 准备跳转到S模式 la t0, s_main csrw mepc, t0 li t0, 0x1800 csrc mstatus, t0 li t0, 0x0800 csrs mstatus, t0 # MPP = S mretmret执行后,PC跳到s_main,特权级变成S,mstatus.MIE被MPIE恢复,同时MPIE置1。这时候S模式代码就可以正常跑,遇到委托过来的异常会进s_trap_entry。
4.3 S模式下的CSR操作与U模式切换
S模式能访问的CSR有限,sstatus、sie、stvec、sepc、scause、stval、satp、sscratch这几个是常用的。写S模式trap handler时,第一步通常是读scause判断异常类型,然后读sepc定位出错指令,处理完用sret返回。
s_trap_entry: csrr t0, scause csrr t1, sepc # 根据scause分支处理 ... sret从S模式切到U模式,需要设置sstatus.SPP为0,把用户程序入口写到sepc,然后sret。U模式代码不能访问任何S模式CSR,一旦尝试就会触发非法指令异常,该异常如果被委托给S模式,就会进S模式trap handler。
实操心得:调试特权级切换时,我习惯在每一步后面读一次
mstatus或sstatus,用串口打出来看MPP、SPP、MIE、SIE的实际值。别靠猜,硬件行为有时候和手册描述有细微出入,尤其是不同厂商的实现。
5. 常见问题与排查技巧实录
5.1 中断不触发、异常跑飞、权限报错速查
下面这张表是我这些年攒下来的“症状-原因-解法”对照,覆盖了CSR和特权架构相关的绝大多数常见问题。
| 症状 | 可能原因 | 排查步骤 |
|---|---|---|
| 中断完全不触发 | mstatus.MIE未开 | 读mstatus确认bit 3 |
| 中断不触发 | mie对应位未开 | 读mie确认目标中断位 |
| 中断不触发 | 委托后S模式sie未开 | 读sstatus.SIE和sie |
| 异常跑飞到0地址 | mtvec或stvec未设 | 读对应tvec确认非零 |
| 异常跑飞到0地址 | 委托了但S模式入口未配 | 检查medeleg和stvec |
mret后特权级不对 | mstatus.MPP设错 | 读mstatusbits 12:11 |
| U模式执行CSR报错 | 访问了高特权级CSR | 查mcause是否为非法指令 |
| 中断嵌套丢状态 | 未保存mepc/sepc | trap handler入口先压栈 |
| 写CSR无效果 | 该CSR只读或位域只读 | 查地址编码[11:10] |
5.2 三个我亲自踩过的坑
第一个坑是mstatus.MPP的默认值。很多模拟器复位后MPP是00(U模式),但有些硬件实现复位后是11(M模式)。如果你不显式设置就mret,行为可能和预期不符。我的做法是不管默认值,每次mret前都显式写一遍MPP。
第二个坑是mideleg委托了定时器中断但没配mie。委托只是把中断路由到S模式,但中断本身的使能位还在mie里。正确顺序是:先配mie使能,再配mideleg委托,最后开全局中断。
第三个坑是S模式读mstatus。有些实现允许S模式读mstatus但只返回S模式可见位,有些直接触发非法指令。为了可移植性,S模式代码应该只读sstatus,不要碰mstatus。
5.3 调试工具与验证方法
裸机阶段没有操作系统帮忙,调试全靠串口打印和模拟器。我常用的组合是QEMU加GDB,QEMU支持RISC-V的M/S/U三级模拟,可以用info registers看所有CSR。另外Spike模拟器对特权架构的模拟更严格,适合验证边界行为。
验证特权切换是否成功,最直接的方法是:在目标特权级写一个只有该级别能访问的CSR,然后读回来确认。比如进了S模式后写stvec,如果没触发异常且读回值正确,说明特权级切换成功。
最后分享一个小技巧:如果你不确定某个CSR在当前特权级能不能访问,查地址的
[9:8]位。11是M专属,01是S和M可访问,00是U/S/M都可访问。这个规则比翻手册快得多。
6. 从CSR速查到特权架构的完整认知路径
把CSR和特权架构串起来看,其实就三件事:谁有权访问哪些寄存器、发生事件时控制权交给谁、切换时状态怎么保存和恢复。M/S/U三级不是孤立的,而是一条从高到低的权限链,mret和sret是链上的传送门,medeleg和mideleg是路由开关,mstatus和sstatus是状态快照。
实际做项目时,我的建议是先把M模式固件写稳,把mtvec、medeleg、mideleg、mstatus这几个配好,再往上搭S模式内核。S模式跑通后再考虑U模式用户程序。每加一级,先用最简单的代码验证特权切换和异常路由,确认无误再堆功能。这样出问题时排查范围小,不至于三级搅在一起找不到北。
CSR速查表可以打印出来贴在显示器边上,但真正要记牢的是那套编码规则和切换逻辑。规则记住了,表可以随时查;逻辑没搞懂,表背下来也白搭。