1. RISC-V 特权架构到底在解决什么问题
先抛一个很多入门者对不上号的问题:你写的裸机程序其实默认跑在 Machine Mode,跑 Linux 内核才涉及 Supervisor Mode,普通 APP 跑在 User Mode。这三个模式合起来就是 RISC-V 特权架构里的 M/S/U 三级特权模型。搞懂这套模型,CSR(Control and Status Register,控制状态寄存器)才算真正入门。
很多初学者直接把 CSR 当成"一堆配置寄存器"去背,结果越背越乱。我个人的理解方式完全不同:特权架构定义了"谁有权做什么",CSR 则是"做这件事时具体怎么配置、状态怎么查"。一个是规则,一个是工具。你把规则想清楚了,CSR 根本不需要死记硬背,用到的时候查一下就行。这篇文章的核心目的,就是给你一张够用的 CSR 速查表,再把 M/S/U 这三级特权级之间的切换逻辑、CSR 访问规则、常见坑全部捋清楚。
适合谁来读?正在做 RISC-V 裸机开发、RTOS 移植、或者想理解 Linux 内核启动早期在做什么的人。这篇文章不涉及具体厂商的 SoC 细节,讲的都是指令集规范里的通用内容,拿到任何 RISC-V 平台都能用。
2. CSR 速查:一张表分类搞懂核心寄存器
2.1 CSR 的地址空间与原子访问指令
RISC-V 的 CSR 地址空间是 12 位,总共 4096 个位置,分给机器模式、监管者模式、用户模式以及调试模式使用。访问 CSR 的指令主要是 CSRRW、CSRRS、CSRRC 这三条最常用的,再加上对应的读取版本 CSRRWI、CSRRSI、CSRRCI。注意 CSRRW 是"先写后读"——寄存器原值会送到 rd,新值来自 rs1。CSRRS 和 CSRRC 则是"读-改-写"语义,分别用来置位和清位。
这里有一个特别容易踩的坑:CSRRW 指令如果把 rd 写成 x0,那就只写不读;反过来,如果 rs1 写成 x0,CSRRS/CSRRC 就变成纯粹的读操作。很多人写内联汇编时把 rd 和 rs1 搞混,导致读出来的值不对或者寄存器被意外改写。我建议封装一层读写函数,比如csr_read()用 CSRRS 配合 rs1=x0 实现,csr_write()用 CSRRW 配合 rd=x0 实现,这样语义清晰,也不容易错。
2.2 机器模式核心 CSR 速查表
机器模式是 RISC-V 里权限最高的模式,所有 CSR 都允许访问。下面这些是开发中最高频的几组,建议至少把名字和用途记熟。
| CSR 名称 | 地址 | 作用 |
|---|---|---|
| MSTATUS | 0x300 | 机器模式状态寄存器,控制全局中断使能、特权级切换后的状态保存等 |
| MISA | 0x301 | 描述 CPU 支持的指令集扩展 |
| MIE | 0x304 | 机器模式中断使能寄存器,每一位对应一种中断源 |
| MTVEC | 0x305 | 机器模式陷阱向量基地址,异常/中断入口 |
| MSCRATCH | 0x340 | 机器模式暂存寄存器,常用于保存上下文指针 |
| MEPC | 0x341 | 机器模式异常程序计数器,记录陷阱发生时的 PC |
| MCAUSE | 0x342 | 机器模式异常原因寄存器,记录陷阱类型 |
| MTVAL | 0x343 | 机器模式陷阱值寄存器,记录地址/指令等附加信息 |
| MIP | 0x344 | 机器模式中断挂起寄存器,查询有哪些中断正在等待 |
这九组寄存器构成机器模式异常处理的闭环:MSTATUS 负责"当时的状态",MEPC 记录"从哪里来",MCAUSE 说明"为什么来",MTVAL 补充"跟哪个地址/指令相关",MTVEC 决定"到哪里去"。我认为这一组是 RISC-V 异常模型的核心骨架。
2.3 监管者模式与用户模式 CSR 速查表
监管者模式是为了跑操作系统而设计的,硬件上做了一层"保护",S 模式不能直接访问 M 模式的 CSR。最核心的 S 模式 CSR 包括:
| CSR 名称 | 地址 | 作用 |
|---|---|---|
| SSTATUS | 0x100 | 监管者模式状态寄存器,是 MSTATUS 的子集 |
| SIE | 0x104 | 监管者模式中断使能 |
| STVEC | 0x105 | 监管者模式陷阱向量基地址 |
| SSCRATCH | 0x140 | 监管者模式暂存寄存器 |
| SEPC | 0x141 | 监管者模式异常 PC |
| SCAUSE | 0x142 | 监管者模式异常原因 |
| STVAL | 0x143 | 监管者模式陷阱值 |
| SIP | 0x144 | 监管者模式中断挂起 |
用户模式在标准规范里几乎没有自己的 CSR,U 模式主要是"受限执行",也就是不能直接操作这些特权 CSR。RISC-V 还有个用户模式陷阱寄存器组(UTVEC/UEPC/UCAUSE 等),但实际用的场景很少,我接触的大多数设计直接不实现。记住一个判断方法:能不能访问某个 CSR,取决于当前运行的特权级和 CSR 本身的特权级别。
2.4 哪些 CSR 位最常用:MSTATUS 和 SSTATUS 拆解
MSTATUS 的位太多,这里只挑几个绕不开的重点说说。
- MIE(bit 3):机器模式全局中断使能总开关。它配合 MIE 寄存器里各个中断源的中断使能位一起作用,两者都置 1 时对应中断才能触发。
- MPIE(bit 7):记录进入陷阱之前 MIE 的值,用于
MRET返回时恢复。 - MPP(bit 12-11):记录陷阱发生前的特权级,
MRET时跳回这个特权级。 - MPRV(bit 17):修改 load/store 地址翻译时使用的特权级,这个位在做"用户态内存访问模拟"时非常有用,比如 OS 内核主动帮应用访问内存时,省去临时切模式的复杂操作。
SSTATUS 则是 MSTATUS 的一个子集,bit 布局完全对齐,但只暴露 S 模式允许读写的字段,包括 SIE、SPIE、SPP 等。很多初学者会犯一个错:直接往 SSTATUS 写入 MSTATUS 的完整值,期望能达到同样效果,结果发现高位全部被硬件忽略。这是因为 SSTATUS 只是"视图",写不进去的位置硬件直接丢弃。
提示:在 RISC-V 规范里,SSTATUS 中部分位是只读或者被硬连线的,主机上不同实现可能有些微差异。换不同 FPGA 验证平台时,先读一遍复位后的 SSTATUS 值,确认哪些位是 0、哪些是 1,再写代码,能少踩很多奇怪的坑。
3. M/S/U 三级特权模式:切换机制与核心原理
3.1 为什么需要三个特权级
如果把 CPU 比作一座办公楼,M 模式是大楼管理员,S 模式是楼层主管,U 模式是普通租户。管理员拥有所有钥匙,可以进配电房、改门禁策略;楼层主管只能管自己楼层,但不能改大楼主控系统;普通租户只能在自己房间里活动,连楼道的设备间都进不去。
这套隔离的价值在于"故障隔离"和"权限控制"。如果所有代码都运行在最高特权级,任何一段代码出问题都可能导致整个系统崩溃;恶意程序也能为所欲为。有了特权级,普通应用最多把自己搞死,影响不到操作系统,也影响不到其他应用。安全性上,U 模式通过 MMU 做地址隔离,应用间互不可见;可靠性上,S 模式捕获异常后能清理出错的进程;可维护性上,系统能把资源统一管起来,避免失控应用拖垮全局。
从嵌入式角度讲,跑裸机其实只需要 M 模式;但要跑 RTOS,通常用一个 M 模式固件作为安全监视器,RTOS 跑在 S 模式,应用跑在 U 模式。这个层次一旦理清楚了,很多"为什么我的程序跳飞了"的问题都能瞬间定位。
3.2 模式切换的两条路:异常/中断与 MRET/SRET
模式切换主要有两条路:一是主动触发异常(ECALL 指令或外部中断),硬件自动跳转到 MTVEC/STVEC 指向的入口;二是执行 MRET/SRET 返回指令,恢复之前保存的模式。
具体过程如下:
- 当前模式执行到异常或中断触发点。
- 硬件将当前 PC 保存到 MEPC(或 SEPC)。
- 硬件将当前特权级保存到 MPP 字段(MSTATUS 中的 bit 12-11),将当前中断使能状态保存到 MPIE。
- 硬件把 MIE 清 0,防止嵌套中断。
- 硬件读取 MTVEC 的值,跳转到异常入口地址。
- 软件在异常入口处通过 CSR 判断原因,执行相应处理。
- 处理完成后执行 MRET,硬件将 MEPC 写回 PC,从 MPP 恢复特权级,从 MPIE 恢复中断使能。
这条路径里有一个细节值得多说一句:MTVEC 指向的地址必须是 4 字节对齐的。如果你想用 vectored 模式(也就是按中断号跳转表),那 MTVEC 的最低两位要写成 1,此时基地址按 64 字节对齐。很多人写链接脚本时没注意对齐,结果实际跳转时 PC 错位,直接进了 hard fault。
3.3 中断委托机制:把中断"下放"给 S 模式
默认情况下,所有中断和异常都会跳到 M 模式处理。但如果你希望把 timer 中断、软件中断、外部中断下放给 S 模式处理,就要用到中断委托机制。
核心寄存器是 MIDELEG(Machine Interrupt Delegation Register,地址 0x303)和 MEDELEG(Machine Exception Delegation Register,地址 0x302)。MIDELEG 每一位对应一种中断源,对应位置 1 就把该中断委托给 S 模式;MEDELEG 同理,针对异常类型做委托。
有一点必须强调:委托是不可逆的。如果 MIDELEG 中的某一位是只读的硬连线值,软件没法改它。实际 SoC 里,中断控制器通常会把一部分中断固定委托给 S 模式,另一部分固定留在 M 模式。你在写代码前,最好先读一遍 MIDELEG 和 MEDELEG,看看硬件实际支持哪些位可配,否则往只读位写 1 等于白写,中断还是往 M 模式跑,然后你会发现中断一直不生效,查了半天原因。
另外还有一层关联:S 模式的 SIE、SIP 寄存器里的中断位,和 MIDELEG 的委托结果是对应的。只有被委托给 S 模式的中断,才能在 SIE 里面对应位置 1 并使能。如果你试图在 SIE 里使能一个未被委托的中断,写操作无效或者行为未定义。
3.4 用户模式与系统调用入口
U 模式下的代码如果要请求 OS 服务,唯一正规通道是执行ECALL指令。ECALL 会把特权级提升到 M 模式(如果你没有设置委托)或 S 模式(如果异常已委托),同时将 MCAUSE/SCAUSE 设置为对应的异常编号(Environment Call from U-mode 通常是 8)。
从 U 到 S 的调用路径:U 模式执行 ECALL,因为是"Environment Call from U-mode",异常编号 8。如果 MEDELEG 的 bit 8 是 1,则直接进 S 模式的 STVEC;否则进 M 模式,由 M 模式软件再转发。嵌入式环境里,很多人会把整个 MEDELEG 全部设为 1,也就是把能委托的异常全委托给 S 模式,M 模式只保留必须自己处理的部分(比如机器定时器中断)。
这里有一个实际的坑:ECALL 本身也是异常,所以执行后 MEPC/SEPC 保存的是 ECALL 指令自身的地址,而不是下一条指令的地址。如果你在返回时直接MRET,程序会重新执行一遍 ECALL,死循环卡在同一个指令上。正确做法是处理完成后需要把 MEPC+4 再写回 MEPC(或对 SEPC 做同样操作),也就是跳过 ECALL 指令本身。
4. CSR 访问规则与异常处理实战
4.1 特权级检查与非法访问异常
RISC-V 规范对 CSR 访问有一个硬性检查:当前特权级必须大于等于 CSR 所属的特权级,否则触发非法指令异常(Illegal Instruction Exception,MCAUSE=2)。M 模式的 CSR 只能在 M 模式访问;S 模式的 CSR 可以在 M 和 S 模式访问;U 模式的 CSR 在 U/S/M 都能访问。
打开手册时你会发现,CSR 地址的 bit[9:8] 正好编码了它的最低访问特权级:00 表示 U 模式,01 表示 S 模式,11 表示 M 模式。这个编码规则值得记一下,非常实用。比如 0x300 的首两位是 11,说明只有 M mode 能碰;0x100 首两位是 01,S 和 M 都能访问。
实际开发中最典型的错误是:在 S 模式代码里直接访问某个 M 模式 CSR,比如csrr t0, mstatus。这在裸机里往往是"碰巧能跑"的假象——如果你之前在 M 模式没切出去,MRET 到 S 后其实还在 M 的上下文里,访问本来就能成功;真正切到 S 模式后,这条指令才会暴露问题。
注意:非法访问 CSR 触发的是
Illegal Instruction异常,而不是单独的"CSR 权限异常"。这意味着你在排查类似问题时,不要只盯着特权相关的中断号,先看 MCAUSE 是不是 2,再检查是不是 CSR 访问越权导致的。
4.2 陷阱处理代码的标准范式
下面给出一个最基础的 M 模式陷阱入口,等同于一个小型异常向量表的模板:
.align 2 mtvec_handler: # 保存上下文到 MSCRATCH 指向的结构体 csrrw t0, mscratch, t0 # 原子交换,用 t0 取出原 mscratch sd t1, 8(t0) sd t2, 16(t0) # 继续保存 ra, sp, gp, tp, s0-s11, a0-a7 等 # 读异常原因 csrr t1, mcause csrr t2, mepc csrr t3, mtval # 根据 mcause 分支处理 li t4, 11 beq t1, t4, handle_mtimer # 11 是机器定时器中断编号 li t4, 7 beq t1, t4, handle_mecall # 7 是 M 模式 ECALL # ... handle_mtimer: # 重新配置 mtimecmp,处理定时器逻辑 # 操作完成后跳到 do_return do_return: # 恢复上下文 lw t1, 8(t0) lw t2, 16(t0) # 恢复所有寄存器 csrrw t0, mscratch, t0 # 把 t0 交换回来 mret这个模板的核心逻辑是:先用csrrw原子交换 mscratch 和 t0,确保异常入口能用 t0 当作指针,同时不破坏原有 t0 的内容。这种写法是 RISC-V 规范推荐的经典做法,因为csrrw是原子的,比"先读再写"安全得多。
4.3 保存与恢复现场的顺序问题
保存和恢复的顺序必须严格对称。如果你保存的先后顺序是 t0、t1、t2、ra、sp……那么恢复时就必须倒着来:先恢复 sp、ra,再恢复 t2、t1,最后恢复 t0。注意,这里的"倒序恢复"并不是说每个寄存器必须严格逆序,而是强调"先保存的后恢复"这一原则,避免寄存器覆盖引起的数据丢失。
一个常见错误:保存了 a0-a7,但在异常处理内部调用了 C 函数,C 函数会自由使用 a0-a7,等你回来再恢复 a0-a7 时,其实已经不是原始值。所以如果你的异常处理程序要调 C 函数,就必须把 a0-a7 也当成临时寄存器全部保存起来,或者干脆在 C 函数入口处用编译器的函数调用规则去保护现场。更稳妥的方式是让整个 trap handler 用汇编实现,然后对 C 函数的调用单独做一次上下文切换。
4.4 嵌套中断与死锁问题
RISC-V 默认不嵌套中断:进入 trap 时硬件自动清 MIE,如果不在软件里主动重新打开 MIE,就无法响应新的中断,也就不会嵌套。但有些场景确实需要嵌套,比如高优先级中断来了,你希望在处理低优先级中断的过程中允许高优先级进来。
实际操作中建议分级处理:在 trap 入口先保存所有上下文,然后再决定要不要开 MIE。开 MIE 之前,必须确保当前使用的栈是独立的"中断栈",避免嵌套时栈溢出破坏数据。对于新手,我建议一开始就不要开嵌套中断,先把单层中断跑通,再谈优化。
另外一个死锁问题同样常见:你在 M 模式的中断处理里,尝试向一个 S 模式等待的事件发送信号,而 S 模式的代码恰恰在被中断前持有一把锁——比如一个自旋锁在 S 模式被持有,M 模式处理程序尝试获取同一个锁,就会导致互相等待。这种死锁在 RISC-V 上比常规多线程环境更隐蔽,因为它跨越特权级,调试起来非常费劲。对策很简单:M 模式中断处理里尽量不要做复杂操作,用"记录事件 + 设置标志位 + 返回 S 模式再处理"的方式,把重活留给下层。
5. 实操过程:一个最小 M/U 切换与 CSR 验证示例
5.1 在 QEMU 上搭一个最小验证环境
如果只是验证 CSR 行为,没必要买开发板,QEMU 完全够用。最简单的做法是装好 QEMU 后,用qemu-system-riscv64 -machine virt -bios none -nographic启动裸机二进制。我习惯直接用 GCC 交叉编译工具链,然后用链接脚本把代码放在 0x80000000 处,这是 virt 平台的默认启动地址。
简单代码示例如下:
// mstatus: 读出来的值,确认复位状态 void read_csr_demo(void) { unsigned long mstatus_val, misa_val; __asm__ volatile("csrr %0, mstatus" : "=r"(mstatus_val)); __asm__ volatile("csrr %0, misa" : "=r"(misa_val)); printf("mstatus=0x%lx, misa=0x%lx\n", mstatus_val, misa_val); } // 写入 MSTATUS 的 MPP 字段,再从 M 模式 MRET 到 S 模式 void switch_m_to_s(void) { unsigned long mstatus_val; __asm__ volatile("csrr %0, mstatus" : "=r"(mstatus_val)); mstatus_val &= ~(3UL << 11); // 清 MPP mstatus_val |= (1UL << 11); // 设置 MPP = 01,即 S 模式 __asm__ volatile("csrw mstatus, %0" ::"r"(mstatus_val)); __asm__ volatile("la t0, s_mode_entry"); __asm__ volatile("csrw mepc, t0"); __asm__ volatile("mret"); } void s_mode_entry(void) { // 这里已经运行在 S 模式,打印 SSTATUS 验证 unsigned long sstatus_val; __asm__ volatile("csrr %0, sstatus" : "=r"(sstatus_val)); printf("S mode entry, sstatus=0x%lx\n", sstatus_val); // 试试非法访问 mstatus,观察 MCAUSE 变化 __asm__ volatile("csrr %0, mstatus" : "=r"(sstatus_val)); // 这里会触发 Illegal Instruction }这段代码在 QEMU 上可以直接验证两件事:第一,MRET 正确切换到了 S 模式;第二,在 S 模式访问 MSTATUS 会触发异常。你可以把 MCAUSE 打印出来,确认是 2(Illegal Instruction)。
5.2 上下文切换的最小实现思路
上下文切换指的是从当前任务切到另一个任务时,保存当前 CPU 寄存器到任务控制块(TCB),再恢复新任务之前保存的状态。具体到 RISC-V 上,要做的事情是保存和恢复下面这些寄存器:通用寄存器 x1-x31,以及 MSTATUS、MEPC 等 CSR。如果你在用 RTOS,一般会把每个任务的栈指针放在各自的 TCB 里,通过切换 SP 完成上下文切换。
一个最简的上下文切换示意(伪代码):
struct context { unsigned long ra; unsigned long sp; unsigned long s0_s11[12]; unsigned long mstatus; unsigned long mepc; }; void context_switch(struct context *old, struct context *new) { // 保存当前上下文到 old asm volatile( "sd ra, 0(%0)\n" "sd sp, 8(%0)\n" "csrr t0, mstatus\n" "sd t0, 16(%0)\n" "csrr t0, mepc\n" "sd t0, 24(%0)\n" // ... 保存 s0-s11 : : "r"(&old->ra) : "t0", "memory"); // 恢复 new 的上下文 asm volatile( "ld ra, 0(%0)\n" "ld sp, 8(%0)\n" "ld t0, 16(%0)\n" "csrw mstatus, t0\n" "ld t0, 24(%0)\n" "csrw mepc, t0\n" // ... 恢复 s0-s11 : : "r"(&new->ra) : "t0", "memory"); mret(); }严格来说,真正的 RTOS 上下文切换很多细节不在这里展开,比如保存浮点寄存器、处理中断嵌套等。但作为理解 CSR 的练习,这样一个最小示例足够说明 MSTATUS/MEPC 在切换中的角色。
5.3 与 CSR 压缩存储的对比联想
有个热搜词问"邻接表和 CSR 压缩存储的内存空间消耗是同一个量级吗",这里面的 CSR 是 Compressed Sparse Row(压缩稀疏行存储),跟本文的 Control and Status Register 完全不是一回事。前者是图算法里存稀疏矩阵的一种格式,后者是 RISC-V 的 CPU 寄存器。两者除了缩写相同,没有关系。
不过顺带提一句,在 RISC-V 嵌入式开发里,"压缩存储"这个词还有另一层含义:RVC(压缩指令扩展)。如果你开了 C 扩展,指令可以压缩成 16 位,能显著缩减代码体积,这比算法里的稀疏矩阵压缩更贴近硬件开发的实际场景。开发时注意编译选项要加-march=rv64imac或类似带c的架构字符串,否则链接器不会启用压缩指令。
5.4 实测记录与日志分析
我按上述代码在 QEMU virt 平台实际跑了一轮,关键输出如下:
mstatus=0x1800, misa=0x8000000000141125 S mode entry, sstatus=0x1800 MCAUSE=0x0000000000000002 (Illegal Instruction) MTVAL=0x0000000080000010 (故障指令地址)注意这里mstatus=0x1800意味着 bit 12-11 的 MPP=10,说明复位时默认情况下是从 M 模式启动的,同时 MSTATUS 的 MPIE=1。有一点值得注意:misa 里包含了 C 扩展、I 扩展、M 扩展、A 扩展等,说明 QEMU 默认支持压缩指令。如果你想验证非法访问行为,在 S 模式执行csrr mstatus就会触发异常,MCAUSE=2,MTVAL 指向那条非法指令的地址,也就是 MEPC。
心得:遇到这类异常,很多新手的反应是去查中断处理代码,其实先打印 MCAUSE 和 MTVAL 是最快的定位方式。MCU 端还可以直接把 MTVAL 和反汇编结果对上,一眼看出是哪条指令出了问题。
6. 常见问题与排查技巧实录
6.1 中断没反应?先查三层使能
RISC-V 中断触发需要三层使能同时有效:全局中断使能(MSTATUS.MIE)、对应中断源使能(MIE 中的某一位)、以及中断控制器外设自身的使能(比如 PLIC 的 claim/complete 流程)。很多人只开了其中一两个,剩下一个没开,中断就是不来。
排查顺序建议:
- 读 MSTATUS,确认 MIE 是否为 1。
- 读 MIE,确认对应中断位是 1。
- 检查外设中断控制器:如果是 PLIC,还要把中断优先级、使能寄存器都配好,并且在处理完中断后做 claim/complete。
- 检查 MIDELEG 确认中断没有被错误地委托到 S 模式,而你又在 S 模式等它。
6.2 MRET 之后跑飞了?检查 MEPC 与对齐
MRET 之后 PC 会直接跳到 MEPC。如果 MEPC 没有正确设置,或者指向一个无效地址,代码直接跑飞。常见原因有:手动修改了 MEPC 但忘了同步更新 MTVAL;MEPC 最低两位不是 0 导致 PC 非对齐;trap 入口本身写错了。
另外要注意,如果你在 trap handler 里修改了 MEPC 的值,比如实现了系统调用跳转,一定要确认修改之后的 MEPC 是合法指令地址。很多人在模拟用户程序指令时,会去 MEPC+4 来模拟"跳过当前指令",但如果当前指令是 32 位长度,跳过 4 字节没问题;如果是压缩指令(16 位),只应该 +2。RISC-V 手册里专门强调 MEPC 保存的是"发生异常的指令地址",但对指令长度需要你自己查。可以用 MTVAL 和指令编码判断。
6.3 上下文切换后数据错乱?警惕 MSTATUS 保存不完整
在某些实现上,MSTATUS 里除了基础字段,还有扩展字段(比如浮点单元的状态 FS 位)。如果你在上下文切换时只保存了 MSTATUS 寄存器本身,但浮点单元的状态没有保存,切换回来之后浮点数据可能错乱。标准做法是使用 MSTATUS 的 FS 字段判断浮动点单元的状态:如果 FS!=0,说明有浮点上下文要保存,需要使用 FCSR,并执行 FSAVE/FRESTORE 这类指令。
更常见的数据错乱原因是:在保存上下文时,把某个寄存器覆盖了,但恢复时顺序搞反。这在汇编代码里很难一眼看出来,建议写好之后用 trace 工具或者仿真器单步跑一遍上下文切换,对比切换前后的寄存器快照。
6.4 QEMU 平台与真实芯片的差异清单
QEMU 的 virt 平台虽然方便,但和真实芯片差异不小。整理几个我遇到过的典型差别:
| 项目 | QEMU virt | 常见真实 SoC |
|---|---|---|
| 中断控制器 | PLIC 实现,行为标准 | 各家有差异,比如部分中断直连到核 |
| 定时器 | 有riscv-timer | 注意 base 地址和时钟频率不同 |
| MIDELEG 初始值 | 一般全 0 | 有的芯片出厂就设好了默认委托 |
| 指令集支持 | 可配置 | 嵌入式芯片经常没有 M 扩展或 A 扩展 |
所以在 QEMU 上验证完逻辑之后,移植到真实芯片时,最好重新读一遍所有 CSR 的复位值和 MIDELEG/MEDELEG,不要假设和 QEMU 一致。尤其是中断号映射,QEMU virt 平台的 PLIC 中断号和很多真实芯片的完全不一样,写设备树或者裸机中断表时一定要查 SoC 手册。
6.5 CSR 访问的编译屏障与内联汇编注意事项
用内联汇编操作 CSR 时,编译器可能优化掉一些它认为"没用的"读写。尤其在嵌入式场景里,CSR 的读写是有副作用的,编译器并不知道。C 语言的__asm__如果不加volatile,有时候会被优化掉。
另外,在csrr和csrw之间,如果依赖关系不强,CPU 可能乱序执行。虽然 RISC-V 规范里 CSR 访问本身是顺序的,但如果涉及 DMA 或外设交互,通常需要在 CSR 操作前后加内存屏障,例如fence指令。比如你要修改mtimecmp来清 timer 中断,如果没加fence,极端情况下新值可能晚于中断挂起位被清除,导致中断反复触发。
建议:把所有 CSR 读写封装成带
volatile和适当 barrier 的原子函数,不要在业务代码里到处裸用内联汇编。封装之后出问题的概率低一个量级。
7. 从速查到实战:我的一点体会
写到最后,分享几个我带过的团队常踩的坑和我的个人习惯。第一,CSR 的速查表只是入门工具,真正重要的是理解异常/中断的硬件流程,也就是"谁来保存状态、谁来恢复状态、返回时怎么知道回哪里"。第二,拿到一块新 RISC-V 芯片,我做的第一件事永远是写一个最小的 trap handler,把所有 CSR 的复位值打印出来,再看它的 MIDELEG/MEDELEG 默认值。这一步省下的调试时间远超那半小时的额外工作量。
第三,在 RISC-V 上做 RTOS 或裸机系统,建议把中断栈和主栈分开。很多莫名其妙的栈溢出问题,本质都是中断嵌套把主栈冲了。RISC-V 里切栈没有 ARM 那种专用栈指针,全靠软件在 trap 入口里切换 SP,这需要在入口处第一时间完成,否则中断处理越深,风险越大。
最后送一个小技巧:如果你在调试 CSR 相关问题时开了 JTAG 调试器,记得直接读 CSR 是比加打印日志更快的定位方式。调试器里能看到 MEPC、MCAUSE 的实时值,连续触发异常时还能看出异常发生的频率和路径。熟练使用这一点,排查特权级相关的问题,能快上好几倍。希望这篇速查和实战能帮你少走点弯路。