RISC-V 专栏写到第三篇,总算敢碰 CSR 和特权架构这块硬骨头了。它在整个体系里属于“地基中的地基”——写裸机固件要碰,跑 Linux 内核要碰,做调试器、模拟器、RTOS 移植也绕不开。但资料往往碎得离谱,尤其是 M/S/U 三种特权模式相互切换的细节,不把状态寄存器捋清楚,光是一个中断返回就能让你怀疑人生。这篇就当一份能直接抄作业的 CSR 速查手册,把 RISC-V 的特权架构和模式切换机制讲透,顺带分享我实测过的代码和踩坑记录。适合正在写底层软件、搞模拟器或者第一次接触 RISC-V 操作系统的开发人员。
1. 特权架构:为什么 RISC-V 要搞出 M/S/U 三个世界
1.1 三个模式各管什么事
很多初学者第一次看到 Machine / Supervisor / User 三个模式,第一反应都是“这也太麻烦了吧”。但你先想一个问题:如果所有代码都运行在同一个特权级,一个普通应用随手改掉操作系统的内存,或者直接篡改中断向量表,整个系统瞬间就崩了。RISC-V 设计三个模式的目的,就是做一套“最小可用”的隔离机制:不搞复杂的硬件虚拟化墙,而是先规定清楚——你现在是哪一级身份,你能动哪些寄存器,你能执行哪些指令,你能踩哪些内存。
这里我用一个生活化的类比:一栋楼的门禁。U 模式是普通住户,只能在自己家里、楼道里活动;S 模式是物业管理员,能打开设备间、看监控、协调公共设施;M 模式是安保总控中心,钥匙最多,权限最高,所有人都得听它的。对应到实际系统里,U 模式跑用户应用程序,S 模式跑操作系统内核(Linux、RTOS 都行),M 模式跑固件、Bootloader、OpenSBI 这类最底层的代码。芯片上电复位后,硬件会自动进入 M 模式,因为此时还没有任何“更底层”的代码能帮系统完成初始化。
1.2 不同模式到底差在哪
抛开抽象概念,特权级的差异主要体现在三个维度上。
第一是 CSR 访问权限。M 模式能访问绝大多数 CSR,S 模式只能访问 S 模式以及更低级别的 CSR,U 模式能访问的 CSR 少得可怜,基本就是几个计数器。低特权模式访问高特权 CSR,硬件会直接抛出一个非法指令异常(Illegal Instruction)。这条规则是硬件强制执行的,不是靠软件约定,这也是 RISC-V 特权安全能立得住的根基。
第二是特权指令的执行权利。比如 SFENCE.VMA 指令用于刷新地址转换缓存,它只能在 S 模式或 M 模式下执行;WFI 这类指令则在不同模式下有不同的等待行为。像 mret、sret 这样的异常返回指令,更是直接与特权模式强绑定。
第三是物理地址访问控制。S 模式开启分页后,通过 satp 寄存器指向页表;M 模式通常忽略地址翻译,直接以物理地址访问。U 模式下如果没开虚实地址转换,用户程序可以随便访问物理内存,那隔离就无从谈起——所以实际系统中,只要跑 Linux,S 模式一定会把 satp 配置好。
1.3 模式切换的三种入口
模式与模式之间,不存在“想切就切”的快捷方式。RISC-V 定义了 trap(异常与中断的统称)、ecall 指令、mret/sret 指令这几条通道。
- 异常(Exception):比如非法指令、访问非对齐地址、页故障。trap 发生时,芯片会跳转到更高特权模式的异常入口。
- 中断(Interrupt):比如定时器中断、外部中断。中断发生后,同样由当前硬件上下文决定陷入哪个特权模式。
- ecall 环境调用:U 模式执行
ecall会陷入 S 模式(如果委托配置正确)或 M 模式;S 模式执行ecall会陷入 M 模式。这相当于应用程序主动“敲门”请操作系统办事。 - mret 与 sret:分别从 M 模式和 S 模式的 trap 处理程序中返回到原来的特权模式。
理解了入口还不够,你最终还是要落到“谁记录了我从哪来、要去哪”这个状态上。这就是 CSR 里一堆状态位存在的意义。
2. CSR 速查:寄存器一大堆,先抓住这张核心表
2.1 先搞懂 CSR 地址空间
CSR(Control and Status Register)是 CPU 内部的“控制与状态寄存器集合”,它们不在普通内存地址空间里,而是拥有独立编码。硬件通过专用的 CSR 指令来访问,而不是 load/store 指令。每个 CSR 有一个 12 位的地址,所以最多可以定义 4096 个。
更关键的是,这 12 位地址的分布不是随机的,它和特权模式强相关:
| 地址范围 | 对应的特权层 | 典型用途 |
|---|---|---|
| 0xC00 - 0xC1F | U 模式 | 用户态可读的计数器和定时器 |
| 0x000 - 0x0FF | U 模式(特定实现) | 用户自定义或扩展功能 |
| 0x100 - 0x1FF | S 模式 | 操作系统内核相关 CSR |
| 0x200 - 0x2FF | H 模式(虚拟化扩展) | Hypervisor 相关 CSR |
| 0x300 - 0x3FF | M 模式 | 机器模式核心 CSR |
| 0xB00 - 0xBFF | M 模式(调试/触发器) | 硬件调试相关 |
| 0xF00 - 0xFFF | M 模式(性能监控) | 计数器与事件选择 |
用一个简单的判断方法:你看到一个 CSR 地址,看最高两位十六进制数就能猜出它属于哪层。比如0x300开头一定是 M 模式的,0x100开头一定是 S 模式的。这样设计的好处是,硬件在做权限检查时非常快——低特权模式下访问高地址段的 CSR,直接判非法。
2.2 机器模式(M 模式)常用寄存器
这是 RISC-V 世界里最核心的一组寄存器,裸机开发和 Bootloader 天天都要碰。
| 地址 | 名称 | 一句话作用 |
|---|---|---|
| 0x300 | mstatus | 机器模式状态:全局中断开关、进入 trap 前的模式记录、内存特权等 |
| 0x301 | misa | 描述 CPU 支持的 ISA 扩展,按位表示是否支持 M/A/F/D/C/V 等 |
| 0x302 | medeleg | 把一部分同步异常委托给 S 模式处理 |
| 0x303 | mideleg | 把一部分中断委托给 S 模式处理 |
| 0x304 | mie | 机器模式中断使能:MSIE、MTIE、MEIE 分别对应软件/定时器/外部中断 |
| 0x305 | mtvec | 机器模式下 trap 入口地址,低 2 位表示直接模式还是向量模式 |
| 0x306 | mcounteren | 控制 S/U 模式能否读取 cycle/time/instret 等计数器 |
| 0x340 | mscratch | 机器模式暂存器,典型用法是保存 trap 处理时用的上下文指针 |
| 0x341 | mepc | 记录 trap 发生时被打断的指令地址 |
| 0x342 | mcause | 记录 trap 原因,最高位 1 为中断,低 12 位为具体编号 |
| 0x343 | mtval | trap 附加信息,比如非法指令的编码、出错的内存地址 |
| 0x344 | mip | 机器模式挂起中断状态 |
| 0x320 | mcountinhibit | 暂停某个计数器计数 |
要注意一个特别容易混淆的点:定时器比较寄存器mtime和mtimecmp并不是 CSR,它们通常被映射在内存空间,一般通过 load/store 访问。很多新手以为写 mtvec 就能控制定时器,结果定时器中断永远不来,就是卡在这里。
2.3 监管者模式(S 模式)常用寄存器
S 模式的 CSR 可以理解为 M 模式 CSR 的“精简视图”。它把和操作系统内核最相关的功能单独拎了出来。
| 地址 | 名称 | 一句话作用 |
|---|---|---|
| 0x100 | sstatus | S 模式状态,mstatus 的一个子集视图 |
| 0x104 | sie | S 模式中断使能 |
| 0x105 | stvec | S 模式下 trap 入口地址 |
| 0x106 | scounteren | S 模式控制 U 模式访问计数器 |
| 0x140 | sscratch | S 模式暂存器 |
| 0x141 | sepc | S 模式下 trap 返回地址 |
| 0x142 | scause | S 模式下 trap 原因 |
| 0x143 | stval | S 模式下 trap 附加信息 |
| 0x144 | sip | S 模式挂起中断状态 |
| 0x180 | satp | 地址转换与保护:页表根地址、ASID、地址翻译模式 |
其中satp是 Linux 内核切换进程地址空间时一定会写的寄存器。你修改了页表之后,还需要执行SFENCE.VMA指令让硬件刷新 TLB,否则新映射不生效。
还有几个 U 模式可见的性能计数器值得记住:cycle(0xC00)、time(0xC01)、instret(0xC02)。在 RV32 上还有对应的cycleh、timeh、instreth高 32 位寄存器。程序在 U 模式下默认不一定能读它们,必须由高特权模式在mcounteren/scounteren中把对应位置 1 放行。
2.4 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 | csrrw 的立即数版本 |
| csrrsi rd, csr, imm | csrrs 的立即数版本 |
| csrrci rd, csr, imm | csrrc 的立即数版本 |
在汇编中常见的csrr t0, mstatus其实是csrrs t0, mstatus, x0的伪指令,因为用 x0 做源寄存器时不会修改 CSR。类似地,csrw mtvec, t0是csrrw x0, mtvec, t0。
这里有个实操技巧:你想单独打开 mstatus 里的 MIE 位,不要用“读-改-写”三步。直接csrsi mstatus, 0x8或者csrs mstatus, 0x8,硬件一条指令搞定,既省指令又避免中间窗口被中断打断。
3. 模式切换的本质:一场 trap 的完整旅程与中断委托
3.1 从异常发生到处理函数执行
我们用一个最常见的例子:M 模式下发生了一次定时器中断。硬件按以下顺序自动完成状态记录:
- 把当前特权模式和中断使能状态保存进 mstatus。具体来说,当前特权模式(这里是 M)会被记录到 mstatus.MPP 字段,当前 MIE 值会被记录到 MPIE,随后 MIE 被清零——防止 trap 处理过程中再次被中断打扰。
- 把被打断的指令地址写入 mepc。
- 把中断/异常原因写入 mcause,附加信息写入 mtval。
- 把 PC 跳转到 mtvec 指向的地址,开始执行 trap handler。
- handler 通过 mepc 知道该回哪,通过 mcause 知道发生了什么,通过 mstatus.MPP 知道自己应该恢复成哪个模式。
- 执行 mret 指令时,硬件把 mepc 恢复到 PC,把 mstatus.MPP 恢复到当前特权模式,把 MPIE 恢复到 MIE,然后清掉 MPP(通常设为 U),完成返回。
这套流程在手册里写得干巴巴的,但你只要走一遍实际代码就会明白:所谓“切换到更高特权模式”,本质不是软件随便跳转,而是硬件把旧的上下文锁进 CSR,再跳到一个由高特权模式预设好的入口。
3.2 mstatus 里那几位到底怎么用
很多人卡在 mstatus 上,就是因为不知道 MPP/MPIE/MPRV 是在什么时候被改的。我做一个速查:
| 位段 | 含义 |
|---|---|
| MIE(bit 3) | M 模式全局中断使能。为 0 时,所有 M 模式中断都不响应 |
| MPIE(bit 7) | 记录进入 trap 前 MIE 的值 |
| MPP(bit 12:11) | 记录进入 trap 前的特权模式:00 表示 U,01 表示 S,11 表示 M |
| MPRV(bit 17) | 内存特权修改位。置 1 时,M 模式下 load/store 的内存权限按 MPP 指定模式解释 |
MPRV 是很多人忽略的细节。它的典型用途是:M 模式代码想访问一个“按 S 模式权限规则”应该被页表拦下的地址,可以通过 MPRV 临时模拟 S 模式访存,而不需要真正切到 S 模式。OpenSBI 在做一些地址翻译模拟时会用到它。
3.3 中断委托:为什么不让所有 trap 都进 M 模式
如果系统里既有 M 模式固件,又有 S 模式操作系统,那么每次系统调用都先陷入 M 模式再转发到 S 模式,效率非常低。RISC-V 的答案是在硬件层面增加“中断委托”机制——通过medeleg和mideleg两个寄存器,把一部分 trap 直接路由到 S 模式。
委托的原理很简单:medeleg的每一位对应一个异常编号,mideleg的每一位对应一类中断。你在 M 模式把对应位置 1,之后 S 模式下发生的这类 trap 就直接进入 S 模式的stvec,M 模式完全看不到。典型做法是把“来自 U 模式的 ecall”和“指令页故障/数据页故障”委托给 S 模式,这样 Linux 内核处理系统调用和缺页异常时不用每次去打扰 M 模式固件。
中断委托还有一个漂亮的联动效果:被委托的中断可以在 S 模式下用sie/sip单独开关和查询,M 模式的mie/mip完全不管。这等于硬件给每个特权层都配了一套独立的中断视图。
3.4 mret 与 sret 背后的状态机
搞懂了 trap 流程,再看 mret/sret 就简单了。mret会从 M 模式回到 MPP 记录的模式,sret会从 S 模式回到 SPP(sstatus 中的对应字段)记录的模式。如果你在 M 模式下强行执行两次 mret,第二次会因为 MPP 已被清零而回到 U 模式——这就是很多裸机程序莫名其妙从 S 模式“掉”到 U 模式的元凶。
更隐蔽的一个坑是:如果你想从 M 模式直接切到 S 模式,又不经过任何 trap,正确的做法是先手动设置 mstatus.MPP = 01,然后执行 mret。因为 mret 是按 MPP 恢复特权级的,它不只是“从 handler 返回”,还是一种合法的显式模式切换手段。很多嵌入式引导代码就是用这一招从 M 模式跳到 S 模式,再启动 Linux。
4. 实操演示:在 QEMU 上把 M/S/U 三个模式玩明白
4.1 环境准备与最小启动代码
我这里的实验环境是 QEMU virt 平台,RV64 配置,交叉编译器用riscv64-unknown-elf-gcc。先用一个最小启动文件跑起来:
.section .text.init .globl _start _start: la sp, _stack_top call main 1: wfi j 1b .section .text .globl trap_entry trap_entry: csrr t0, mcause csrr t1, mepc # 简单处理:死循环打印现场 j trap_entry链接脚本里把入口地址放在0x80000000,栈顶放在入口后面 2MB 的位置。这一步不用写太复杂,关键是先把 trap 入口地址设置到 mtvec:
#include <stdint.h> #define CSR_MSTATUS 0x300 #define CSR_MTVEC 0x305 static inline uint64_t read_csr(uint64_t csr) { uint64_t val; asm volatile("csrr %0, %1" : "=r"(val) : "i"(csr)); return val; } static inline void write_csr(uint64_t csr, uint64_t val) { asm volatile("csrw %1, %0" : : "r"(val), "i"(csr)); } extern void trap_entry(void); void main(void) { write_csr(CSR_MTVEC, (uint64_t)trap_entry); // 故意触发一个异常 asm volatile("unimp"); while (1); }这段代码跑起来后,QEMU 会因为unimp指令触发非法指令异常,跳到trap_entry。你用调试器看mcause的值,会发现是 2(Illegal Instruction),mtval里就是那条非法指令的编码。这就完成了一个最原始的 trap 链路验证。
4.2 通过 mret 实现 M 模式到 S 模式的切换
接下来我们做正经的模式切换。在 main 里把 mstatus.MPP 改为 S 模式,再执行 mret:
void switch_to_s_mode(void) { uint64_t mstatus = read_csr(CSR_MSTATUS); // 清零 MPP 两位,然后设为 01(S 模式) mstatus &= ~(3UL << 11); mstatus |= (1UL << 11); // 关掉 M 模式全局中断,防止切换过程中被干扰 mstatus &= ~(1UL << 3); write_csr(CSR_MSTATUS, mstatus); // 设置 S 模式的 trap 入口 write_csr(0x105, (uint64_t)trap_entry_s); // 执行 mret,跳转到下一个地址,但特权级变为 S asm volatile("mret"); }执行完 mret 后,PC 会继续执行 mret 后面的那条指令,但当前模式已经从 M 变成了 S。怎么验证?你在 S 模式下读一下satp,如果能正常读,说明确实是 S 模式——因为 U 模式读 satp 会触发异常。读出来的值应该为 0,因为我们还没开分页。
4.3 在 S 模式下触发异常并委托给 S 模式处理
先用代码验证“U 模式读 satp 会非法”。我们从 S 模式切到 U 模式的方式是修改 sstatus.SPP 后执行 sret:
void switch_to_u_mode(void) { uint64_t sstatus = read_csr(0x100); sstatus &= ~(1UL << 8); // SPP = 0,表示返回 U 模式 write_csr(0x100, sstatus); asm volatile("sret"); }进入 U 模式后,尝试读 satp:
void user_code(void) { uint64_t val; asm volatile("csrr %0, 0x180" : "=r"(val)); (void)val; }这条指令在 U 模式下必然触发非法指令异常。如果中断委托没有配置,异常会直接进 M 模式的trap_entry,你在 M 模式 handler 里读 mcause 就能看到异常编码 2,mtval 是 0x180。这说明权限检查是硬件层做的,软件没有半点商量余地。
如果想验证中断委托,在 M 模式 main 函数里设置medeleg把“来自 U 模式的 ecall”委托给 S 模式:
// mcause 8 对应 U 模式 ecall,把第 8 位置 1 委托给 S 模式 write_csr(0x302, read_csr(0x302) | (1UL << 8));之后 U 模式执行ecall,异常会直接跳到 S 模式的trap_entry_s,M 模式完全不知情。这种委托机制在跑 Linux 的系统上就是系统调用的核心通道——应用程序执行 ecall,内核在 S 模式拿到控制权。
4.4 开启定时器中断所需的完整配置链
定时器中断是很多裸机开发者的第一道坎,我把完整流程贴出来。这里的 mtime 和 mtimecmp 是 MMIO 映射寄存器,以 QEMU virt 平台为例,mtime 地址是0x200BFF8,mtimecmp 是0x2004000。
#define MTIME (*(volatile uint64_t *)0x200BFF8) #define MTIMECMP (*(volatile uint64_t *)0x2004000) void enable_timer_interrupt(void) { // 设置 mtimecmp 为当前时间 + 100000 MTIMECMP = MTIME + 100000; // 打开 M 模式定时器中断使能位(MTIE 对应 bit 7) write_csr(0x304, read_csr(0x304) | (1UL << 7)); // 打开 M 模式全局中断使能 uint64_t mstatus = read_csr(0x300); mstatus |= (1UL << 3); write_csr(0x300, mstatus); }中断触发后,进入 trap_entry,mcause 的最高位为 1(表示中断),低 12 位为 7(Machine Timer Interrupt)。处理完业务后,要通过写 mtimecmp 清除中断条件,否则退出 handler 后会再次触发。正确的清中断方式是让 mtimecmp 大于当前 mtime:
void timer_handler(void) { MTIMECMP = MTIME + 100000; }这段链路看似简单,但每个环节都可能出问题。接下来我把实际调试中遇到的坑集中列出来。
5. 实操中的坑与排查技巧实录
5.1 mtvec 没对齐导致 trap 入口飞了
RISC-V 规范要求 mtvec 四字节对齐,向量模式下还要十六字节对齐。很多人用 C 函数地址直接赋给 mtvec,结果函数入口没对齐,一旦触发中断,PC 跳到一个非指令边界,立刻又触发异常,形成死循环。
解决方案是在汇编里显式对齐:
.section .text .align 4 .globl trap_entry trap_entry: csrr t0, mcause ...写 mtvec 之前先用csrr t0, mtvec检查写进去的值是否和预期一致。如果芯片实现了向量模式(mtvec 低 2 位为 1),则每个中断会跳到BASE + 4 * code,你必须保证每个 4 字节槽都放了一条跳转指令或 handler 地址,否则同样会跑飞。
5.2 mret 后死机:MPP 设错是最大元凶
我见过很多初学者在 M 模式下执行 mret 想“返回”到 U 模式,结果死机或者掉进非法指令异常。原因很简单:mret 的去向由 mstatus.MPP 决定,如果之前没经过任何 trap,MPP 默认可能是 0(U 模式)或者随机值,你把 MIE 和 MPP 没配对,自然出问题。
正确做法是,在 mret 之前手动组装好完整的 mstatus,而不是只改其中一位。特别是从 M 切 S 时,MPP=01、MPIE=0、MIE=0 是推荐组合,因为切换过程中你不想被打断;进入 S 模式后,S 模式自己的 sstatus.SIE 再单独打开。
5.3 U 模式访问 satp 触发异常,反而说明系统是安全的
很多人在用户态程序里不小心访问了特权 CSR,然后发现程序被杀掉,第一反应是“CPU 是不是坏了”。实际上这正是特权架构工作的表现。调试时你可以故意触发一次,然后在 M 模式 handler 里读 mcause 和 mtval,确认异常原因和 CSR 地址都对得上。
这其实也是一条有用的检测手段:如果你在 S 模式下能正常读 satp,但切换到 U 模式后一读就非法,说明权限边界是生效的。如果你想实现“用户态能不能访问某个资源”的自检,完全可以在 U 模式故意访问,再让高特权模式把这个事件作为测试结果记录下来。
5.4 定时器中断不触发,按这个顺序排查
定时器中断不触发是高频问题,我整理了一套排查顺序,建议按下面八步走:
| 排查点 | 检查方法 |
|---|---|
| mtime 是否在递增 | 连续读两次比较值,看有没有变化 |
| mtimecmp 是否设为未来时间 | 必须大于当前 mtime,否则中断条件立刻成立 |
| mie.MTIE 是否置 1 | csrr t0, mie,看 bit 7 |
| mstatus.MIE 是否置 1 | csrr t0, mstatus,看 bit 3 |
| mstatus.MPP 是否正确 | trap 返回后可能会改变,确认没有被意外改掉 |
| mtvec 是否指向有效 handler | 在 handler 第一行打断点,看是否进入 |
| mip.MTIP 是否置 1 | qemu 下用-d int可以看到中断挂起状态 |
| handler 是否清除了 mtimecmp | 如果不清楚,返回后立刻再次触发,表现为看似死循环 |
其中-d int是 QEMU 调试中断的利器,能在日志里看到每次中断进入和返回的详细过程。硬件开发板上则用逻辑分析仪抓中断引脚,或者直接在 handler 里翻转一个 GPIO 观察电平。
5.5 链接脚本与中断向量表的地址错位
裸机程序最常见的问题是:中断向量表定义在一个段里,但链接脚本没有把它放到实际运行的地址上。比如 QEMU virt 平台固件运行在0x80000000,你却把向量表放在0x0,然后往 mtvec 里写了一个低地址。PC 一跳过去就是空指针。
正确做法是先用la指令取运行时地址:
la t0, trap_entry csrw mtvec, t0不要直接用汇编 label 或者 C 函数名当作绝对地址,除非你确定所有段都按运行地址链接。
5.6 委托之后,M 模式的 mie 变得“不灵了”
把定时器中断委托给 S 模式后,如果还按 M 模式思维去开mie.MTIE,会发现中断还是不触发。这是因为委托之后,对应中断的使能已经由 S 模式的sie.STIE控制,M 模式的mie对应位虽然存在,但不再参与门控。
正确配置是:在 M 模式完成mideleg委托,然后切到 S 模式,打开sstatus.SIE和sie.STIE。如果遇到中断不触发,优先检查这两个 S 模式位,而不是回到 M 模式折腾。
5.7 完整调试序列:我的个人习惯
最后分享一个我自己的调试顺序,算是多年踩坑换来的习惯。遇到特权架构相关的问题,我从不直接猜,而是按“现场还原”的思路走:
第一步,确认当前特权模式。读 mstatus.MPP,或者干脆读一个只有某模式能访问的 CSR,看到底是权限问题还是模式判断错。
第二步,确认 CSR 写入是否生效。csrw 指令写只读字段会被静默忽略,不报错。写完后马上读回来,对比值是否等于预期。如果不等,检查字段是否 WPRI(Write Preserve, Read Ignore)或者是否被硬件自动改写。
第三步,确认 trap 是否真的进入了 handler。在 handler 第一行放一条死循环或者打印代码,如果没进来,用调试器查 mtvec、mie、mstatus;如果进来了但不是预期 handler,回头看 mcause 和 mtval,确认异常编号。
第四步,确认返回是否正常。单步执行 mret,观察 PC 是否跳到 mepc,当前特权模式是否等于 MPP。这一步能抓出绝大多数“跑飞”问题。
这套流程我用了很多年,在 QEMU 上和真实开发板上同样有效。特权架构的东西,看着复杂,但只要你把 CSR 当成“现场记录本”而不是“黑盒配置项”,问题就都会变成查字典式的体力活。RISC-V 最可爱的点也在这里:它没有把机制藏起来不让看,而是把所有状态都摆在你面前,让你有机会真正搞懂每一步。