1. 从一条报错说起:为什么你必须搞懂 CSR 和特权级
第一次在 RISC-V 上跑裸机程序,十有八九会撞上一条让人摸不着头脑的异常:mcause = 0x2,也就是 illegal instruction。你盯着反汇编看了半天,指令明明合法,为什么 CPU 说它非法?答案往往藏在 CSR 里——你试图在 U 模式访问一个只有 M 模式才能碰的寄存器,或者忘了在mstatus里打开某个位,导致浮点指令被当成非法指令。
这就是 RISC-V 特权架构的日常。和 ARM 那种动辄上千页的架构手册相比,RISC-V 的特权规范其实相当克制,但它的“克制”是有代价的:很多东西没有默认帮你做好,需要你亲手配置 CSR。CSR(Control and Status Register,控制状态寄存器)是 RISC-V 里软件和硬件对话的唯一窗口,而M/S/U 三个特权级则决定了谁能碰哪些 CSR、谁能执行哪些指令。
这篇内容适合三类人:正在写 RISC-V 裸机启动代码的嵌入式工程师、做操作系统移植或 RTOS 适配的开发者、以及准备自己搓一个 RISC-V 核或者写模拟器的爱好者。我会把 CSR 的编号规则、M/S/U 的切换机制、常见的 CSR 速查表、以及实际调试中踩过的坑,一次性讲清楚。读完你应该能做到:看到任意一个 CSR 名字,能立刻判断它属于哪个特权级、大概管什么;遇到特权级切换的 bug,知道从哪几个寄存器下手排查。
2. CSR 到底是什么:编号规则与访问机制
2.1 CSR 的 12 位地址是怎么分配的
CSR 在指令里用 12 位地址编码,也就是最多 4096 个。这个数字看着不多,但 RISC-V 用了一套非常聪明的位域划分,让地址本身就携带了“权限信息”。你只要记住这张表,看到地址就能反推它的属性:
| 位域 | 名称 | 含义 |
|---|---|---|
| [11:10] | 读写权限 | 00=只读,01=读写,10=只读,11=读写 |
| [9:8] | 最低特权级 | 00=U,01=S,10=H,11=M |
| [7:6] | 只读/读写 | 11 表示只读,其他表示读写 |
| [5:0] | 具体编号 | 同一功能组内的序号 |
这里有个容易混淆的点:bit[11:10] 和 bit[7:6] 都涉及读写属性。实际规范里,bit[11:10]=11 表示该 CSR 是只读的,这是判断只读 CSR 的主要依据。比如misa(机器指令集架构寄存器)地址是0x301,拆开看:bit[11:10]=00,bit[9:8]=11(M 级),bit[7:6]=00,所以它是 M 级可读写……等等,misa其实是只读的。这里我要纠正一下自己:misa的地址0x301中 bit[11:10]=00 表示读写,但规范里misa被定义为 WARL(Write Any Values, Reads Legal Values),写进去的值可能被硬件忽略或修正,所以它“看起来可写,实际由硬件说了算”。
这种细节正是 CSR 让人头疼的地方。我的建议是:别死记地址,记住功能组的高位规律。比如0xC00到0xCFF这一段全是只读的性能计数器(cycle、time、instret 及其高 32 位),0xF11到0xF14是只读的机器信息寄存器(mvendorid、marchid、mimpid、mhartid)。这些在调试时特别有用,后面会细说。
2.2 CSR 指令:六个助记符和它们的隐含语义
访问 CSR 只有六条指令,全部属于 Zicsr 扩展:
csrrw rd, csr, rs1 # 读旧值到 rd,写 rs1 到 csr csrrs rd, csr, rs1 # 读旧值到 rd,将 rs1 的置位写进 csr(set bits) csrrc rd, csr, rs1 # 读旧值到 rd,将 rs1 的置位清零(clear bits) csrrwi rd, csr, uimm # 立即数版本,写 5 位立即数 csrrsi rd, csr, uimm # 立即数版本,置位 csrrci rd, csr, uimm # 立即数版本,清零这里有个非常实用的技巧:当 rd = x0 时,指令不读旧值,只写;当 rs1 = x0 时,指令不写,只读。所以csrrs x0, mstatus, x0实际上是一条“空操作但会触发权限检查”的指令,常被用来探测某个 CSR 是否可访问。而csrr x0, mcause这种写法(伪指令csrr展开为csrrs rd, csr, x0)就是纯粹的读取。
我在写启动代码时,最常用的是csrrs和csrrc的“读-改-写”模式。比如要打开mstatus里的 MIE 位(全局中断使能),标准写法是:
csrrs t0, mstatus, (1 << 3) # 读 mstatus 到 t0,同时置位 bit3注意这里t0拿到的是修改前的旧值,如果你后续还要用旧值做判断,得先保存。这个“读旧值”的特性在保存上下文时特别有用,但也很容易写出 bug——我曾经在一个中断处理里忘了csrrs会覆盖t0,结果把上层保存的变量冲掉了,查了一下午。
2.3 哪些 CSR 是“必须实现”的
RISC-V 特权规范把 CSR 分成“必须实现”和“可选实现”两类。对于 M 模式,必须实现的有:
mstatus、misa、mie、mtvec、mscratch、mepc、mcause、mtval、mipmvendorid、marchid、mimpid、mhartid(只读信息类)mcycle、minstret及其高 32 位(如果实现了性能计数器)
S 模式必须实现的有:sstatus、sie、stvec、sscratch、sepc、scause、stval、sip、satp。U 模式本身没有专属 CSR,它只能通过 S 模式配置的sstatus里的位来间接感知自己的状态。
这里有个坑:很多精简核只实现 M 模式,不实现 S/U。如果你拿到一个核,发现sstatus访问就报 illegal instruction,别怀疑代码,先查misa的 bit[9:8](S 位)和 bit[7:6](U 位)是否为 1。misa的地址是0x301,读出来看低 26 位里有没有 S 和 U 的标志位,这是判断核支持哪些特权级最快的方法。
3. M/S/U 三个特权级:谁管谁,怎么切
3.1 特权级的层级关系与典型职责
RISC-V 定义了三个(严格说是四个,加上 H 扩展的 hypervisor 模式)特权级,编号越大权限越高:
| 特权级 | 编码 | 典型职责 | 能访问的 CSR 范围 |
|---|---|---|---|
| U | 0 | 用户程序 | 仅非特权 CSR(如 cycle、time) |
| S | 1 | 操作系统内核 | S 级 + U 级 CSR |
| M | 3 | 固件、BootROM、安全监控 | 全部 CSR |
注意 H 模式编码是 2,夹在 S 和 M 之间,但普通场景很少用到。M 模式是“最高权限”,它不受任何限制,可以访问所有 CSR、执行所有指令。S 模式是给操作系统用的,它管不了 M 级的 CSR,但可以配置页表、处理异常。U 模式最受限,连mstatus都看不到。
一个常见的误解是“M 模式就是内核态”。其实在典型的 Linux 系统里,内核跑在 S 模式,M 模式留给 OpenSBI 这样的固件。M 模式负责最底层的初始化、中断委托、以及处理那些 S 模式处理不了的异常(比如 S 模式自己出的错)。这种分层设计的好处是:即使 S 模式内核崩了,M 模式固件还能兜底,做安全监控或重启。
3.2 特权级切换的三种触发方式
特权级不会无缘无故变化,只有三种情况会触发切换:
第一种:异常(Exception)。当当前特权级执行出错(非法指令、缺页、断点等),硬件会自动把特权级提升到medeleg/mideleg指定的处理级。默认情况下,所有异常都进 M 模式;如果 S 模式存在且medeleg里对应位被置 1,异常会委托给 S 模式处理。
第二种:中断(Interrupt)。和异常类似,但走的是mip/mie和sip/sie的路径。中断的委托由mideleg控制。这里有个细节:M 模式的中断永远进 M 模式,不能被委托,因为 M 模式中断是给固件用的,比如定时器中断用于任务调度。
第三种:显式跳转。通过mret、sret、uret指令从高特权级返回低特权级。注意没有“从低到高”的显式指令,想进高特权级只能靠异常或中断。这是 RISC-V 安全模型的核心:低特权级无法主动提权,必须通过受控的入口。
切换时,硬件会自动做几件事:把当前 PC 保存到mepc/sepc,把原因写到mcause/scause,把出错地址写到mtval/stval,然后跳转到mtvec/stvec指定的入口。同时,mstatus里的 MIE 位会被保存到 MPIE,MIE 被清零(关中断),特权级更新。返回时mret会反向操作:恢复 MIE,跳回mepc。
3.3 mstatus 里那些容易搞混的位
mstatus是特权架构里最复杂的寄存器之一,位域多且互相影响。我挑几个实际开发中绕不开的位来说:
- MIE(bit 3):M 模式全局中断使能。注意它和
mie寄存器是两回事,mie是各中断源的使能,MIE 是总开关。 - MPIE(bit 7):进入异常时 MIE 的备份。
mret时会自动恢复。 - MPP(bit 12:11):进入异常前的特权级。
mret会根据这个位决定返回到哪个级别。这个位是可写的,所以你可以通过修改 MPP 来“伪造”返回目标,这是实现特权级切换的关键技巧。 - MPRV(bit 17):修改特权级。置 1 后,load/store 会使用 MPP 指定的特权级做权限检查,而不是当前特权级。这在 M 模式代 S 模式访问用户内存时非常有用。
- SUM(bit 18):允许 S 模式访问 U 模式内存。默认是 0,意味着 S 模式不能直接读用户页。很多内核在拷贝用户数据时会临时置 1。
- MXR(bit 19):允许执行读操作时把可执行页也当成可读。这个位在 JIT 场景下有用。
S 模式的sstatus是mstatus的子集,只有 SIE、SPIE、SPP、SUM、MXR 等位。对sstatus的写入会同步到mstatus的对应位,反之亦然。这个联动关系经常让人困惑:你在 S 模式改了sstatus.SIE,切到 M 模式读mstatus会发现 SIE 也变了。这不是 bug,是设计如此。
4. 异常与中断的委托机制:medeleg 和 mideleg
4.1 委托的本质:把处理权下放
medeleg(Machine Exception Delegation)和mideleg(Machine Interrupt Delegation)是两个 M 级 CSR,它们的每一位对应一种异常或中断。置 1 表示“这个异常/中断交给 S 模式处理”,置 0 表示“M 模式自己处理”。
举个例子:如果想让 S 模式处理缺页异常(cause = 13)和系统调用(cause = 8),你需要:
// 假设 medeleg 已经可写 csrrs t0, medeleg, (1 << 13) | (1 << 8)这样当 U 模式或 S 模式触发缺页时,硬件会直接跳到stvec,而不是mtvec。S 模式内核就能自己处理页错误,不用惊动 M 模式固件。
但有几个异常是不能委托的,规范里明确禁止:
- Instruction address misaligned(cause 0)
- Instruction access fault(cause 1)
- Illegal instruction(cause 2)
- Breakpoint(cause 3)
- Load/Store address misaligned(cause 4/6)
- Load/Store access fault(cause 5/7)
- Environment call from M-mode(cause 11)
这些异常如果发生在 M 模式,永远进 M 模式;如果发生在 S/U 模式,且medeleg对应位为 1,可以委托给 S。但 M 模式自己触发的这些异常,委托位无效,必须 M 模式处理。这个规则保证了 M 模式固件的控制权不会被绕过。
4.2 中断委托的特殊性
中断委托比异常委托更微妙。mideleg可以委托 S 模式的外部中断、定时器中断、软件中断,但M 模式的定时器中断(MTI)和软件中断(MSI)不能被委托。原因很简单:M 模式需要定时器来做抢占式调度,如果被委托走了,固件就失去了对时间的控制。
实际配置时,典型的 OpenSBI 会这样设置:
// 委托 S 模式外部中断、定时器中断、软件中断 csrrs t0, mideleg, (1 << 9) | (1 << 5) | (1 << 1)其中 bit 9 是 S 外部中断,bit 5 是 S 定时器中断,bit 1 是 S 软件中断。委托之后,这些中断会走stvec,S 模式内核用sip/sie来管理。
这里有个我踩过的坑:委托了中断但忘了在sie里使能,或者在sstatus里没开 SIE,结果中断来了却没人处理,系统看起来像死机。排查时先读mip看中断是否 pending,再读mie和mideleg确认路由,最后读sip/sie/sstatus确认 S 模式是否真的能收到。这个顺序能帮你快速定位是“没产生”“没委托”还是“没使能”。
4.3 委托后的返回路径
当异常被委托给 S 模式,处理完后用sret返回。sret会从sepc恢复 PC,从sstatus.SPIE恢复 SIE,并根据sstatus.SPP决定返回 U 还是 S 模式。注意sret不能返回到 M 模式,如果SPP被错误地设成了 M 的编码(3),行为是未定义的。所以 S 模式内核在保存上下文时,一定要确保SPP只可能是 0 或 1。
M 模式的mret则灵活得多,它可以根据MPP返回到 U、S 或 M。这也是为什么 M 模式固件能在系统启动时“降级”到 S 模式执行内核——它把MPP设成 1(S),mepc设成内核入口,然后执行mret,硬件就切到 S 模式了。
5. CSR 速查表:按功能分类的实用清单
5.1 机器模式(M)核心 CSR
| 地址 | 名称 | 功能 | 读写 |
|---|---|---|---|
| 0x300 | mstatus | 机器状态 | 读写 |
| 0x301 | misa | 指令集架构 | 只读/WARL |
| 0x302 | medeleg | 异常委托 | 读写 |
| 0x303 | mideleg | 中断委托 | 读写 |
| 0x304 | mie | 中断使能 | 读写 |
| 0x305 | mtvec | 异常入口 | 读写 |
| 0x306 | mcounteren | 计数器使能 | 读写 |
| 0x310 | mstatush | mstatus 高 32 位 | 读写 |
| 0x340 | mscratch | 临时寄存器 | 读写 |
| 0x341 | mepc | 异常 PC | 读写 |
| 0x342 | mcause | 异常原因 | 读写 |
| 0x343 | mtval | 异常值 | 读写 |
| 0x344 | mip | 中断 pending | 读写 |
| 0x3A0 | pmpcfg0 | PMP 配置 0 | 读写 |
| 0x3B0 | pmpaddr0 | PMP 地址 0 | 读写 |
| 0xB00 | mcycle | 周期计数 | 读写 |
| 0xB02 | minstret | 指令计数 | 读写 |
| 0xF11 | mvendorid | 厂商 ID | 只读 |
| 0xF12 | marchid | 架构 ID | 只读 |
| 0xF13 | mimpid | 实现 ID | 只读 |
| 0xF14 | mhartid | 硬件线程 ID | 只读 |
这张表里,mhartid在多核启动时特别重要。每个核读自己的mhartid,如果是 0 就当主核做初始化,其他核先自旋等待。这是 RISC-V 多核启动的标准套路。
5.2 监管者模式(S)核心 CSR
| 地址 | 名称 | 功能 | 读写 |
|---|---|---|---|
| 0x100 | sstatus | 监管者状态 | 读写 |
| 0x102 | sedeleg | 异常委托(H 扩展) | 读写 |
| 0x103 | sideleg | 中断委托(H 扩展) | 读写 |
| 0x104 | sie | 中断使能 | 读写 |
| 0x105 | stvec | 异常入口 | 读写 |
| 0x106 | scounteren | 计数器使能 | 读写 |
| 0x140 | sscratch | 临时寄存器 | 读写 |
| 0x141 | sepc | 异常 PC | 读写 |
| 0x142 | scause | 异常原因 | 读写 |
| 0x143 | stval | 异常值 | 读写 |
| 0x144 | sip | 中断 pending | 读写 |
| 0x180 | satp | 地址转换与保护 | 读写 |
satp是 S 模式最关键的寄存器,它控制页表基址和分页模式。写satp会触发 TLB 刷新,所以切换页表时要注意顺序:先写好页表内存,再写satp,最后用sfence.vma刷新 TLB。顺序错了会出现“页表更新了但 TLB 还是旧映射”的诡异问题。
5.3 非特权 CSR:U 模式也能读的计数器
| 地址 | 名称 | 功能 |
|---|---|---|
| 0xC00 | cycle | 周期计数低 32 位 |
| 0xC01 | time | 定时器低 32 位 |
| 0xC02 | instret | 指令计数低 32 位 |
| 0xC80 | cycleh | 周期计数高 32 位 |
| 0xC81 | timeh | 定时器高 32 位 |
| 0xC82 | instreth | 指令计数高 32 位 |
这些 CSR 的访问受mcounteren和scounteren控制。默认情况下,U 模式读cycle会触发 illegal instruction,除非 M 模式在mcounteren里把对应位置 1。这个设计是为了防止侧信道攻击——通过高精度计数器推测其他进程的行为。实际产品里,很多安全核会直接禁用 U 模式对这些计数器的访问。
6. 实操:从 M 模式降级到 S 模式的完整流程
6.1 启动阶段:M 模式都做了什么
一个典型的 RISC-V 启动流程,M 模式固件(比如 OpenSBI)会按顺序做这些事:
- 设置栈指针。每个核的栈要分开,通常用
mhartid计算偏移。 - 配置
mtvec。把异常入口指向固件的 trap handler。 - 初始化 PMP。设置物理内存保护,至少要让 S 模式能访问所有内存。
- 配置委托。写
medeleg和mideleg,把该给 S 模式的异常和中断委托出去。 - 配置
mcounteren。允许 S 模式读计数器。 - 准备 S 模式的执行环境。设置
mepc为内核入口,mstatus.MPP为 1(S 模式),mstatus.MPIE为 1(返回后开中断)。 - 执行
mret。硬件切换到 S 模式,PC 跳到内核入口。
第 6 步里有个细节:mstatus.MPP是两位,写 1 表示 S 模式。但如果你直接写mstatus,可能会不小心改到其他位。标准做法是读-改-写:
csrr t0, mstatus li t1, ~(3 << 11) # 清除 MPP and t0, t0, t1 li t1, (1 << 11) # 设置 MPP = 1 (S) or t0, t0, t1 csrw mstatus, t0这段代码里,~(3 << 11)生成一个除了 bit12:11 全 1 的掩码,用来清除 MPP。然后置上 S 模式的编码。这种位操作在 CSR 配置里到处都是,建议封装成宏,不然很容易写错。
6.2 S 模式内核的初始化要点
S 模式拿到控制权后,第一件事是设置自己的stvec,然后配置sie和sstatus.SIE。但要注意:在设置stvec之前,如果发生异常,会跳到 M 模式的mtvec,因为 S 模式的 trap 还没准备好。所以顺序很重要:
// 1. 先设置 stvec csrw stvec, (uintptr_t)trap_entry; // 2. 使能需要的中断 csrw sie, SIE_SSIE | SIE_STIE | SIE_SEIE; // 3. 最后开全局中断 csrs sstatus, SSTATUS_SIE;如果顺序反了,在stvec还是 0 的时候来了中断,硬件会跳到地址 0,大概率直接跑飞。这个坑我在第一次移植 RTOS 时踩过,现象是系统启动后随机重启,查了很久才发现是中断使能早了一步。
6.3 特权级切换的调试技巧
调试特权级问题时,最有效的手段是在 trap handler 里打印关键 CSR。我通常会在mtvec和stvec的入口处加一段汇编,把mcause、mepc、mtval、mstatus存到内存的固定位置,然后用调试器读出来。这样即使系统跑飞,也能看到最后一次异常的原因。
另一个技巧是利用mscratch和sscratch做上下文交换。标准做法是:在 trap 入口,先把当前寄存器保存到mscratch指向的内存,然后从mscratch加载内核栈指针。这个交换过程只有几条指令,但能保证不破坏任何用户寄存器。sscratch在 S 模式里做同样的事。
还有一个容易被忽略的点:mepc的写入要保证 4 字节对齐(如果没开 C 扩展)或 2 字节对齐(开了 C 扩展)。如果mepc不对齐,mret会触发 instruction address misaligned 异常,然后你又回到 trap handler,形成死循环。我在写模拟器时遇到过这个问题,现象是mret之后立刻又进异常,mcause一直是 0。
7. 常见问题与排查速查表
7.1 典型问题与解决思路
| 现象 | 可能原因 | 排查方法 |
|---|---|---|
| illegal instruction (mcause=2) | 访问了不存在的 CSR 或权限不足 | 读misa确认支持的特权级,检查 CSR 地址的特权位 |
| 中断不触发 | MIE/SIE 未开,或mie/sie未使能 | 依次读mstatus、mie、mip、sstatus、sie、sip |
| mret 后跑飞 | mepc地址错误或MPP设置错误 | 检查mepc是否指向合法代码,MPP是否为预期值 |
| S 模式访问 U 内存失败 | sstatus.SUM为 0 | 临时置位 SUM 或修改页表权限 |
| 页表切换后映射错乱 | TLB 未刷新 | 写satp后执行sfence.vma |
| 多核启动只有一个核跑 | 其他核未正确自旋或mhartid读取错误 | 检查每个核的启动代码,确认mhartid分支逻辑 |
7.2 几个我踩过的坑和独家技巧
坑一:csrrs的读旧值特性导致变量被覆盖。前面提过,csrrs rd, csr, rs1会把旧值写到rd。如果你用t0做临时寄存器,而t0里正好存着重要数据,就会被冲掉。我的习惯是:CSR 操作统一用t0和t1,并且在操作前确认这两个寄存器是空闲的。在 trap handler 里,先把所有寄存器保存到栈上,再动 CSR。
坑二:mstatus.MPP的写入需要读-改-写。直接csrw mstatus, (1 << 11)会把其他位全清零,包括 MIE、MPIE 等。正确做法是先读出来,用掩码改,再写回去。这个错误在初学者代码里非常常见,后果是中断莫名其妙被关掉。
坑三:委托了异常但没设置stvec。如果medeleg把某个异常委托给 S 模式,但stvec还是 0,异常发生时硬件会跳到地址 0。如果地址 0 是合法代码(比如启动 ROM),可能会执行出奇怪的结果;如果是非法地址,就触发新的异常,可能形成嵌套。委托之前,务必先设置好stvec。
技巧一:用misa探测核的能力。misa的 bit[25:0] 表示支持的扩展,bit[9:8] 是 S 模式,bit[7:6] 是 U 模式。读一次misa,就能知道这个核支持哪些指令集、哪些特权级。这在移植代码到不同核时特别有用,可以写条件编译。
技巧二:用mhartid做多核分支。多核启动时,每个核都从同一个入口开始执行。用csrr a0, mhartid读自己的 ID,然后判断:ID 为 0 的核做全局初始化,其他核跳到等待循环。等待循环里可以用 WFI 指令省电,等主核发 IPI 再唤醒。
技巧三:sfence.vma的参数选择。sfence.vma rs1, rs2可以刷新特定地址或特定 ASID 的 TLB 项。如果 rs1=x0,刷新所有地址;如果 rs2=x0,刷新所有 ASID。全刷新性能差但简单,精确刷新性能好但容易漏。我的建议是:调试阶段用全刷新,性能优化时再精确刷新,并且每次修改页表后都要刷新,不要偷懒。
8. 从 CSR 看 RISC-V 的设计哲学
写到这里,我想聊聊为什么 RISC-V 要把 CSR 和特权级设计成这样。和 x86 的 MSR、ARM 的系统寄存器相比,RISC-V 的 CSR 有几个鲜明的特点。
第一是正交性。CSR 的地址编码本身就携带了权限信息,硬件只需要检查地址的高位就能决定是否允许访问,不需要维护复杂的权限表。这种设计让硬件实现变得简单,也让软件在编译期就能做部分权限检查。
第二是显式委托。异常和中断默认都进 M 模式,S 模式想处理必须显式委托。这和 ARM 的默认路由到 EL1/EL2 不同,RISC-V 把控制权默认留给最高权限级,安全性更高,但启动代码也更啰嗦。这种“默认保守”的设计哲学贯穿整个 RISC-V 规范。
第三是可裁剪。S 模式和 U 模式都是可选的,一个只有 M 模式的核可以做得非常小。这让 RISC-V 能覆盖从 8 位单片机到服务器 CPU 的整个 spectrum。但代价是软件移植时必须先探测核的能力,不能假设 S 模式一定存在。
实际开发中,我越来越觉得这套设计对调试是友好的。CSR 的编号有规律,异常原因有标准编码,trap handler 里读几个寄存器就能还原现场。比起某些架构里“异常原因藏在某个不透明的状态字里”,RISC-V 的mcause/scause直接告诉你发生了什么,mtval/stval还附带出错地址或指令,排查效率高很多。
最后分享一个我常用的调试宏,放在 trap handler 开头,把关键 CSR 一次性 dump 出来:
#define DUMP_TRAP() do { \ uint32_t cause, epc, tval, status; \ asm volatile("csrr %0, mcause" : "=r"(cause)); \ asm volatile("csrr %0, mepc" : "=r"(epc)); \ asm volatile("csrr %0, mtval" : "=r"(tval)); \ asm volatile("csrr %0, mstatus" : "=r"(status)); \ printf("TRAP: cause=%08x epc=%08x tval=%08x status=%08x\n", \ cause, epc, tval, status); \ } while(0)这段代码在 S 模式就把m换成s。有了它,大部分特权级相关的 bug 都能在几分钟内定位。CSR 这东西,刚接触时觉得琐碎,用熟了之后会发现它是 RISC-V 里最“讲道理”的部分——每个位都有明确含义,每个地址都有规律可循,只要你愿意花时间把这张表刻在脑子里。