如果你做过一段时间的 RISC-V 裸机或者内核开发,大概率会被 CSR 这个东西搞得又爱又恨。CSR 的全称是 Control and Status Register,翻译过来就是控制与状态寄存器,它不占通用寄存器组,每条 CSR 都对应一个 12 位地址,用来存放机器状态、中断控制、异常原因、地址翻译配置这类“杂七杂八但每个都要命”的信息。而在 CSR 之上,M/S/U 三个字母代表了 RISC-V 特权架构里最核心的三个运行等级:Machine、Supervisor、User。
这篇是【risc-v专栏】的第三篇,主题是 CSR 速查与特权架构。我最初接触这套东西的时候,最崩溃的不是记不住寄存器名字,而是搞不清楚什么时候该写 M 态寄存器,什么时候该写 S 态寄存器,以及为什么一个 breakpoint 异常会从 U 态一路跳到 M 态。这篇内容就是把我在实际开发中反复用到的那部分掏出来,整理成一张真正能查得动、查完能上手的速查体系。
1. 特权模式不是权限等级,而是三套几乎独立的运行环境
1.1 三个模式的“出场分工”
很多人第一次看 M/S/U 三个模式,容易套用 x86 的 Ring0/Ring3 那套“权限高低”的直觉。但在 RISC-V 里,更准确的理解是:这三个模式是三套几乎独立的运行环境,每一套都有自己的 CSR 集合、自己的异常入口、自己的返回指令和相对独立的状态视图。
- M 模式:Machine Mode,机器态。芯片上电复位后默认进入 M 模式,它拥有最高权限,可以读写所有 CSR、配置 PMP 物理内存保护、操作所有外设。BootROM、OpenSBI、Coreboot、裸机 RTOS 通常都跑在 M 模式。
- S 模式:Supervisor Mode,监管态。对应现代操作系统内核所在的层级,能访问页表、配置 satp、处理来自 U 模式系统调用,是 Linux 内核在 RISC-V 上的主场。
- U 模式:User Mode,用户态。应用程序只能待在这里,访存和指令执行受 M/S 模式设置的规则约束,想干点“出格”的事只能通过 ecall 或异常进入更高模式,由内核处理。
这三个模式之间的关系,我用一个不太严谨但很好记的类比:M 模式像房东,拿的是总钥匙;S 模式像物业公司,管理楼里的日常运营;U 模式像租客,只能在自己房间里折腾。
1.2 从复位到用户态:一条典型路径
一个跑 Linux 的系统,特权模式的切换路径通常是这样的:
- 上电,硬件复位,跳转到 M 模式固件入口;
- M 模式固件(比如 OpenSBI)完成 DDR、时钟、串口、中断控制器等基础初始化;
- OpenSBI 通过 mret 切到 S 模式,跳转 Linux 内核入口;
- Linux 内核在 S 模式配置 satp、异常向量 stvec,建立进程地址空间;
- 内核通过 sret 回到 U 模式,开始运行用户进程;
- 用户进程执行系统调用 ecall,产生环境调用异常,CPU 陷入 S 模式交给内核;
- 内核处理完,再次 sret 返回 U 模式。
在这条链路里,M 模式并不参与每一次系统调用,这就是后面要说的“委托”机制的功劳。如果每次 ecall 都先落到 M 模式,再让 M 模式软件呼哧带喘地跳回 S 模式,性能上是灾难。
1.3 不是所有 RISC-V 芯片都三件套齐活
这里要特别提醒一点:M/S/U 三个模式不是 RISC-V 的强制配置。规范允许只实现 M 模式,这种情况下芯片就是一颗简单的裸机控制核,没有虚拟内存也没有用户态隔离。常见的情况有:
- 只实现 M:很多 MCU 级 RISC-V 核,比如 SiFive 的 E31/E76、蜂鸟 E203 这类面向 IoT 场景的核,通常就只有一个 M 模式;
- 实现 M+U:可能支持 User 模式但没做 S 模式下的内存管理;
- 完整 M+S+U:跑 Linux 的目标基本都是这个组合。
所以在看一颗芯片的 CSR 手册之前,第一步是确认它实现了哪几个模式。如果没有 S 模式,那所有 S 态 CSR 地址访问都会触发非法指令异常,调试时很容易被误判成“CPU 有问题”。
2. 12位CSR地址本身就是一张速查表
2.1 地址区间速记法
CSR 地址是 12 位,范围 0x000 到 0xFFF,总共 4096 个编号。虽然大部分地址是保留项,但这 4096 个编号在布局上是有规律的。很多教程喜欢直接扔一张“CSR 地址编码规则”的表格,但说实话,作为一个靠“背规律”过日子的人,我更喜欢按地址段来记。下面这张表是我干活时经常对照的:
| 地址段 | 段类型 | 典型 CSR | 谁能访问 |
|---|---|---|---|
| 0x000 - 0x0FF | 用户态 CSR | fflags 0x001、frm 0x002、fcsr 0x003 | U 模式及以上 |
| 0x100 - 0x1FF | 监管态 CSR | sstatus 0x100、stvec 0x105、sepc 0x141、satp 0x180 | S 模式及以上 |
| 0x200 - 0x2FF | Hypervisor 扩展预留 | vsstatus 0x200、hedeleg 0x602 等 | H 扩展启用时 |
| 0x300 - 0x3FF | 机器态 CSR | mstatus 0x300、mtvec 0x305、mepc 0x341、pmpcfg0 0x3A0 | M 模式 |
| 0xB00 - 0xBFF | 机器态计数器 | mcycle 0xB00、minstret 0xB02 | M 模式 |
| 0xC00 - 0xCFF | 用户态计数器 | cycle 0xC00、time 0xC01、instret 0xC02 | U 模式及以上 |
| 0xF00 - 0xFFF | 机器态只读信息 | mvendorid 0xF11、marchid 0xF12、mhartid 0xF14 | M 模式只读 |
这个分段有什么用?看到 0x305 就知道是机器态 trap 向量,看到 0x141 就知道是监管态异常 PC,看到 0xC00 就知道这是用户态能读的 cycle 计数器。后面的速查表里,我列出的每一个地址都有对应的段位,查起来心里会非常有底。
2.2 只读与只写的识别门道
除了按模式分段,CSR 地址还有一个很实用的观察角度:0xF00 - 0xFFF 段的 CSR 基本都是只读的。mvendorid(厂商 ID)、marchid(架构 ID)、mimpid(实现 ID)、mhartid(硬件线程 ID)这几个是机器态只读信息的代表。
另外,0x7B0 - 0x7FF 段是 Debug 模块使用的 CSR,比如 dcsr 0x7B0、dpc 0x7B1、dscratch0 0x7B2 这些,虽然可读可写,但一般只有调试器或者 M 模式调试固件会碰它,普通应用开发完全不涉及。这也是为什么很多人的速查表里永远看不到 0x7 段的原因。
2.3 用 misa 一分钟确认 CPU 能力清单
misa(Machine ISA)这个 CSR 地址是 0x301,主要负责告诉你当前 CPU 支持哪些指令集扩展。misa 的每一位对应一个英文字母,例如:
- bit 0:A,原子操作扩展;
- bit 2:C,压缩指令扩展;
- bit 3:D,双精度浮点扩展;
- bit 5:F,单精度浮点扩展;
- bit 8:I,整数基础指令集;
- bit 12:M,整数乘除法扩展;
- bit 18:S,S 模式;
- bit 20:U,U 模式;
- bit 21:V,向量扩展。
在 M 模式下执行csrr t0, misa就能读到这些位。我排查“为什么这颗核不支持某条指令”时,第一件事就是先把 misa 读出来,看目标扩展位有没有置 1。如果连 S/U 位都没置 1,那后面的虚拟内存相关调试可以直接跳过,因为没有硬件支持。
3. 常用 CSR 速查表:按使用场景分组
3.1 机器态核心状态寄存器
这是我在裸机开发中打交道最多的几个 CSR,也是理解其它 CSR 的基础。这里直接列一张核心表:
| 地址 | 名称 | 访问 | 说明 |
|---|---|---|---|
| 0x300 | mstatus | RW | 机器态状态,包含全局中断开关、异常发生前特权级等 |
| 0x301 | misa | RW | 指令集扩展信息 |
| 0x304 | mie | RW | 机器态中断使能,每一位对应一个中断类型 |
| 0x344 | mip | RW | 机器态中断等待标志,软件可置位/清除软件中断 |
| 0x320 | mcountinhibit | RW | 计数器启停控制,置 1 对应计数器停止计数 |
mstatus 里最常用的字段集中在低十几位:
| 字段 | 位段 | 作用 |
|---|---|---|
| MIE | bit 3 | M 模式全局中断使能,0 表示关闭所有 M 模式中断 |
| MPIE | bit 7 | 进入 trap 之前 MIE 的旧值,mret 时用它恢复 |
| MPP | bit 12:11 | 进入 trap 之前的特权级,mret 返回时用它决定切到哪个模式 |
| SIE | bit 1 | S 模式全局中断使能 |
| SUM | bit 18 | 允许 S 模式访问 U 模式页面 |
| MPRV | bit 17 | 内存访问保护位,置 1 后 M 模式访存按 MPP 指定的特权级检查 |
有一个非常常见的坑:很多人只设了 mie 里的某个中断使能位,却没把 mstatus.MIE 置 1,以为中断就能进来。结果是中断一直 pending,但 CPU 完全不响应。全局开关和局部开关是“与”的关系,任何一个没打开都不行。
3.2 trap 处理相关寄存器
处理异常和中断,是 CSR 的另一个主战场。以下的“四件套”是每次 trap 都要用到的:
| 地址 | 名称 | 访问 | 说明 |
|---|---|---|---|
| 0x305 | mtvec | RW | M 模式 trap 入口地址,低 2 位是模式选择 |
| 0x341 | mepc | RW | 异常发生时被中断/异常指令的 PC |
| 0x342 | mcause | RW | 异常原因,最高位区分中断与异常,低 31/63 位是原因编号 |
| 0x343 | mtval | RW | 异常相关附加信息,比如非法指令编码、访存地址、页表出错地址 |
mtvec 的布局很简单:基地址按 4 字节对齐放在高位,最低 2 位是模式。MODE=0 表示所有 trap 都跳转到同一个基地址,MODE=1 表示向量模式,中断会根据中断号偏移跳转。向量模式的偏移公式是BASE + 4 × cause。
mcause 的格式要单独记一下:最高位为 1 表示这是中断,为 0 表示这是异常。RV64 下最高位是 bit 63,RV32 下是 bit 31。异常原因编号和中断编号不是一套编号。比如:
| 异常码 | 异常类型 |
|---|---|
| 0 | 指令地址未对齐 |
| 1 | 指令取指访问错误 |
| 2 | 非法指令 |
| 3 | 断点 |
| 4 | 加载地址未对齐 |
| 5 | 加载访问错误 |
| 6 | 存储地址未对齐 |
| 7 | 存储访问错误 |
| 8 | 来自 U 模式的 ecall |
| 9 | 来自 S 模式的 ecall |
| 11 | 来自 M 模式的 ecall |
| 12 | 指令页错误 |
| 13 | 加载页错误 |
| 15 | 存储页错误 |
中断类 mcause 的编号则对应:1 是 S 模式软件中断,3 是 M 模式软件中断,5 是 S 模式定时器中断,7 是 M 模式定时器中断,9 是 S 模式外部中断,11 是 M 模式外部中断。
3.3 中断委托与控制类 CSR
真正让 RISC-V 的多模式协作变得灵活的,是委托机制相关的两个寄存器:medeleg 和 mideleg。
| 地址 | 名称 | 访问 | 说明 |
|---|---|---|---|
| 0x302 | medeleg | RW | 机器态异常委托,每一位对应一个异常码 |
| 0x303 | mideleg | RW | 机器态中断委托,每一位对应一个中断号 |
| 0x306 | mcounteren | RW | 机器态计数器访问使能,控制 S 模式能否读 cycle/time/instret |
| 0x106 | scounteren | RW | 监管态计数器访问使能,控制 U 模式能否读计数器 |
把 medeleg 的 bit 8 置 1,U 模式发出的 ecall 就不会跳到 M 模式,而是直接进入 S 模式;把 mideleg 的 bit 5 置 1,S 模式定时器中断也直接由 S 模式处理。这个设计在跑 Linux 的系统里是必然配置,因为整个系统调用和大部分中断都希望由内核自己消化,而不是每次都回 M 模式绕一圈。
3.4 监管态寄存器别名
M 模式有的很多 CSR,S 模式都有对应版本,只是把 m 换成 s,功能类似:
| 地址 | 名称 | 说明 |
|---|---|---|
| 0x100 | sstatus | S 模式状态,是 mstatus 的一个低权限视图 |
| 0x104 | sie | S 模式中断使能 |
| 0x105 | stvec | S 模式 trap 入口 |
| 0x141 | sepc | S 模式异常 PC |
| 0x142 | scause | S 模式异常原因 |
| 0x143 | stval | S 模式异常附加信息 |
| 0x144 | sip | S 模式中断等待 |
| 0x180 | satp | S 模式页表基地址与 MMU 模式 |
这些寄存器里,satp 是开启虚拟内存的关键。它包含 MODE 字段、ASID 字段和 PPN 字段。在 RV64 的 Sv39 模式下,MODE 字段是 8,PPN 指向根页表的物理页号。启动 Linux 时,内核会在 S 模式配置好 satp 才敢真正开启虚存,然后在 sret 返回 U 模式时,用户进程看到的已经是虚拟地址空间了。
3.5 用户态一眼就能认的 CSR
U 模式能直接访问的 CSR 不多,但有两个场景绕不开:浮点状态和计数器。
- 0x001 fflags:浮点异常标志
- 0x002 frm:浮点舍入模式
- 0x003 fcsr:浮点控制状态寄存器,是 fflags 和 frm 的打包视图
- 0xC00 cycle:CPU 周期计数器
- 0xC01 time:墙上时钟计数器
- 0xC02 instret:已退休指令计数器
U 模式读计数器不是无条件的,S 模式要先通过 scounteren 放行,M 模式还要通过 mcounteren 放行。如果你在 U 模式读 time 返回 0 或者触发非法指令,别急着怀疑硬件,先检查这两级使能位。
4. CSR 访问指令:CSRRW/CSRRS/CSRRC 的原子语义
4.1 六条指令一张表
RISC-V 给 CSR 访问设计了专门的指令,所有 CSR 操作都必须通过这些指令完成。它们不是普通 load/store,不能用寄存器间接寻址 CSR 地址,CSR 地址要作为立即数编码在指令里。
| 指令 | 语义 | 典型伪指令 |
|---|---|---|
| csrrw rd, csr, rs1 | 原子读旧值到 rd,再写入 rs1 | csrw csr, rs1(rd 为 x0) |
| csrrs rd, csr, rs1 | 原子读旧值到 rd,再用 rs1 置位 | csrr rd, csr(rs1 为 x0) |
| csrrc rd, csr, rs1 | 原子读旧值到 rd,再用 rs1 清位 | csrc csr, rs1(rd 为 x0) |
| csrrwi rd, csr, uimm | csrrw 的立即数版本 | csrwi csr, uimm |
| csrrsi rd, csr, uimm | csrrs 的立即数版本 | csrsi csr, uimm |
| csrrci rd, csr, uimm | csrrc 的立即数版本 | csrci csr, uimm |
注意 csrr 其实是伪指令,它会被汇编器翻译成csrrs rd, csr, x0,因为 rs1 是 x0,所以只读不置位。csrw 则是csrrw x0, csr, rs1,把目标寄存器写成 x0,表示读出的旧值直接丢弃,只做写操作。
4.2 读-改-写为什么必须是原子的
中断处理代码里经常需要对 CSR 的某几个位单独修改,而不影响其它位。比如想开定时器中断,得在 mie 里把 MTIE 位置 1,但你不能把整个 mie 覆盖成 0,否则其它中断源会被误关。如果没有 csrrs 这种读-改-写指令,就得手动读出、修改、写回三步。这三步之间可能正好来一个中断,导致修改丢失。
csrrs/csrrc 把“读旧值,按掩码置位/清位,写回”合并成一条不可分割的原子操作,这正是它们存在的意义。在硬件实现上,这通常等价于读端口和写端口同时动作,中间天然不会被其它事件插入。
4.3 权限不够会怎样
CSR 访问是有特权检查的。你在 U 模式去读 0x300 地址的 mstatus,CPU 会直接抛出一个非法指令异常,而不是温和地返回 0。同理,你在 S 模式去写 0x300 的 mstatus,也会非法。
所以当调试器里出现 “Illegal Instruction” 而对应的指令看起来完全正常时,第一反应应该是:这条 CSR 的访问权限是否匹配当前特权级?我踩过最无语的一次,是在 U 模式测试代码里顺手读了一下 mhartid,本来想确认核的 ID,结果整个进程被 SIGILL 干掉,查了半天才发现特权级不对。
4.4 C 代码里的正确读写姿势
在 C 里操作 CSR,通常用内联汇编封装。由于 CSR 编号是立即数,编译器要求它是编译期常量,不能用一个变量传进来:
static inline unsigned long csr_read(unsigned long csr_num) { unsigned long val; asm volatile("csrr %0, %1" : "=r"(val) : "i"(csr_num)); return val; } static inline void csr_write(unsigned long csr_num, unsigned long val) { asm volatile("csrw %0, %1" : : "i"(csr_num), "r"(val)); } static inline void csr_set_bits(unsigned long csr_num, unsigned long bits) { asm volatile("csrs %0, %1" : : "i"(csr_num), "r"(bits)); } static inline void csr_clear_bits(unsigned long csr_num, unsigned long bits) { asm volatile("csrc %0, %1" : : "i"(csr_num), "r"(bits)); }用法示例:
csr_write(0x305, (unsigned long)trap_vector); // 设置 mtvec csr_set_bits(0x304, 0x80); // 打开 M 模式定时器中断 MTIE csr_set_bits(0x300, 0x8); // 打开全局中断 MIE5. 模式切换的完整链路:ecall、中断、mret/sret
5.1 一次 trap 里硬件到底干了什么
当异常或中断发生时,硬件不是只跳个转就完事。以 U 模式触发 ecall 且目标为 M 模式为例,硬件自动完成这几件事:
- 将当前 PC 存入 mepc;
- 将原因编码存入 mcause,这里是 8;
- 根据 mtvec 跳转到 trap 入口;
- 把当前特权级记录到 mstatus.MPP,也就是 U 模式;
- 把当前 mstatus.MIE 的值挪到 MPIE 保存,然后清零 MIE,关闭中断;
- 把特权级切换到 M 模式。
这一整串动作是硬件完成的,不需要软件参与。trap 入口的汇编代码要做的事,则是保存所有可能被破坏的通用寄存器,然后跳到 C 处理函数,处理完再恢复寄存器,最后执行 mret。
mret 的动作基本是上面流程的逆过程:PC 切回 mepc、特权级切回 mstatus.MPP、MIE 从 MPIE 恢复,并把 MPP 置回 U 模式。sret 同理,只不过作用于 sepc 和相关 S 模式状态。
5.2 委托机制:让 U 模式的 ecall 直接进 S 模式
如果没有委托,每次 U 模式的系统调用都会落入 M 模式。OpenSBI 这类 M 模式固件就得先把 trap 记下来,再跳回 S 模式让内核处理,处理完还要跳回 M 模式,再 mret 回 U 模式。这种“绕道”没有任何意义,还白白增加切换成本。
有了 medeleg,代码可以把 U 模式 ecall 这个异常直接委托给 S 模式。这样:
- U 模式执行 ecall;
- CPU 检查 medeleg.bit8,发现该异常被委托给 S 模式;
- 硬件执行的是 S 模式 trap 动作:PC 存入 sepc,原因存入 scause,跳转到 stvec,特权级切到 S 模式;
- Linux 内核在 S 模式正常处理系统调用,处理完 sret 回 U 模式。
整个过程完全不经过 M 模式。类似地,mideleg 控制哪些中断可以只在 S 模式处理。跑 Linux 的板子上,S 模式定时器中断和外部中断几乎必然被委托。
需要注意的是,M 模式自身产生的异常和中断(比如 M 模式 ecall、M 模式外部中断)不能被委托给低特权模式,因为委托的目的是把低特权层的事交给上一层直接处理,不能把更高特权层的事甩回给低特权层。
5.3 向量模式的真相
mtvec 的 MODE 字段如果设成 1,就启用了向量化 trap。但这里有个容易误解的点:向量化并不是所有异常都按 cause 偏移跳转。规范里的规则是,同步异常仍然统一跳到基地址,只有中断才会根据中断 cause 做偏移跳转。
也就是说,在向量模式下,如果来了一个 M 模式定时器中断,cause 是 7,CPU 会跳到BASE + 4 × 7 = BASE + 28。如果你在 BASE 处写了通用异常处理函数,在 BASE+28 处写了定时器中断处理函数,那么中断触发时就直接进了定时器处理函数,省去在软件里判断 cause 的步骤。
但同步异常,比如非法指令、缺页,仍然全部进 BASE 这个通用入口。这个设计是因为中断需要低延迟分发,而同步异常通常还要保留统一处理逻辑。
5.4 没有 banked 寄存器,上下文切换要自己扛
从 ARM 转过来的朋友可能会有一个惯性认知:异常来了硬件自动切换一组影子寄存器。RISC-V 明确不做这件事。trap 发生后,所有通用寄存器几乎都和进入 trap 前一样,软件必须自行把现场保存到栈里。
这里就有一个经典的“先有鸡还是先有蛋”问题:trap 进来时,连 sp 都可能还在被中断的上下文里,怎么保证能用栈保存现场?答案是用 mscratch 或者 sscratch 这类 scratch 寄存器。常见的套路是:
- 在正常执行时,把真实栈指针保存在 mscratch 里;
- 进入 trap 后,先用 csrrw 把 mscratch 和某个临时寄存器互换,把真实 sp 换出来;
- 用这个 sp 在栈上保存所有通用寄存器;
- 处理完成后恢复寄存器,再用 csrrw 把 sp 和 mscratch 换回去。
这个“scratch 换寄存器”的手法是 RISC-V 裸机的必修课。如果 trap 入口上来就直接用 sp,你保存的其实是原来用户程序那个栈,一旦用户栈是无效的,整个现场都会被写坏。
6. 会被名字坑到的“另一个CSR”:压缩稀疏行存储
6.1 两种 CSR 完全不是一回事
写这篇文章之前,我在查资料时看到有个热搜词特别有意思:“邻接表 和csr压缩存储 内存空间消耗是同一个量级吗”。第一反应是,这跟 RISC-V 的 CSR 速查有什么关系?后来想明白了,因为图算法里也有一个简称 CSR 的术语:Compressed Sparse Row,压缩稀疏行存储。它和图结构里的邻接表是同一类东西,都是用来存稀疏图的。
这俩一个叫 Control and Status Register,一个叫 Compressed Sparse Row,中文里都能简写成 CSR,但完全不搭界。看 RISC-V 相关文档时搜“CSR”,经常会搜出一堆图算法的博客,刚入门的朋友很容易被绕晕。所以这里单独拎出来讲清楚:
| 对比项 | RISC-V CSR | 图算法 CSR |
|---|---|---|
| 全称 | Control and Status Register | Compressed Sparse Row |
| 领域 | 指令集架构 | 数据结构/图存储 |
| 作用 | 控制 CPU 状态、处理异常中断 | 压缩存储稀疏矩阵/图的邻接关系 |
| 典型载体 | mstatus、mtvec、satp 等 | row_ptr、col_idx、val 数组 |
6.2 邻接表和 CSR 压缩存储,空间复杂度怎么比
回到那个问题本身:邻接表和 CSR 压缩存储的内存空间消耗是同一个量级吗?结论先说:渐进量级一样,都是 O(|V| + |E|),但常数项有明显差距,实际占用不同。
邻接表的典型实现是:一个长度为 |V| 的头指针数组,每条边对应一个链表节点,节点里存邻接顶点编号、下一条边指针,可能还有权值。无向图里每一条边要在两个端点的链表里各出现一次。
CSR 压缩存储则是三个数组:row_ptr 长度为 |V|+1,记录每一行邻接表在 col_idx 里的起止位置;col_idx 长度为总边数(无向图也是两倍),按顶点编号顺序连续存放邻接顶点;如果带权,再加一个等长的 val 数组。
两者空间差距主要来自邻接表的 next 指针。CSR 把邻接关系变成顺序数组,省掉了每个节点存 next 指针的开销,内存布局也更连续,遍历邻接顶点时 cache 命中率明显更好。邻接表的好处是动态增删边方便,CSR 则更适合静态图和大规模图。
我举个具体数字:假设 100 万顶点、200 万条无向边,带 32 位权值。
- 邻接表:头指针数组约 4MB,边节点 400 万个,每个节点按 12 字节算约 48MB,合计 52MB 上下;
- CSR:row_ptr 约 4MB,col_idx 400 万 × 4 字节约 16MB,val 同样 16MB,合计 36MB 左右。
所以同样是 O(V+E) 量级,CSR 通常能省掉百分之二三十的空间,而且顺序访问性能更稳。这也解释了为什么大规模图计算框架普遍采用 CSR 而不是邻接表。
7. 调试与踩坑记录:读 CSR 之前先确认自己身在哪里
7.1 平台差异比想象中大
最后这部分是我在多个 RISC-V 环境里跑代码总结出来的经验,值得每一个刚从模拟器上手的人看一看。
不同平台对 CSR 的支持范围差异很大。QEMU/Spike 这类模拟器对特权规范支持比较完整,很多 CSR 都能访问。但真实芯片就未必了:蜂鸟 E203 这类 MCU 级核没有 S 模式,你去读 satp 大概率得到非法指令;某些 RV32 核的计数器高位 CSR(比如 mcycleh)可能也存在;还有的平台对 misa 的某些扩展位置 0,导致依赖某条指令的代码直接崩。
所以在写通用代码时,不要假设所有 CSR 都存在。访问之前可以用 misa 探测能力。如果是在 Linux 内核里,那 S 模式相关的 CSR 基本都有硬件兜底,但如果是裸机,就得老老实实按芯片手册筛一遍。
我建议拿到一块新板子,先做一件非常简单的事:写一个 M 模式的测试程序,把 mstatus、misa、mvendorid、marchid、mimpid、mhartid 这几个 CSR 读出来,串口打印。这一步能确认三件事:当前代码确实跑在 M 模式、CPU 支持哪些扩展、能否在 M 模式读到机器信息。跑通之后,再开始动中断和地址翻译。
7.2 中断进不来的排查过程
裸机开发里,“我开了中断但它就是不响应”这个问题出现频率极高。我整理一个标准的排查链路,按顺序检查:
- 确认 mtvec 已设置,且指向的地址确实是可执行代码。常见错误是 mtvec 写了一个指针变量,但地址还没有完成重定位;
- 确认 mstatus.MIE 置 1。这是全局总开关,漏掉它一切都白搭;
- 确认 mie 中对应的中断类型位也置 1。比如用定时器中断,就要置 MTIE;
- 确认产生中断的外设确实在 pending。读 mip,定时器中断位 MTIP 应该为 1;
- 如果用了 S 模式委托,还要同时检查 mideleg 和 sie/stvec 是否正确;
- 如果中断源挂在外部中断控制器上,比如 PLIC,还要检查 M 模式外部中断使能 MEIE 以及 PLIC 内部的使能和优先级配置。
我有一个朋友调试了两天,最后发现是第 4 步没做:定时器根本没有启动,mip 里一直是 0,全局中断开了也白开,因为它压根没有中断可以响应。
7.3 其他高发坑位清单
- mret 后跳飞:检查 mstatus.MPP 是否正确记录。如果 MPP 是 0(U 模式),但 U 模式代码栈和入口还没准备好,mret 就会跳到一个不可执行的地方。更稳妥的做法是在切换模式之前先确认目标模式的环境已经初始化完毕。
- 读计数器读到 0:在 U 模式读 cycle/time,需要 mcounteren 和 scounteren 两级放行,少一级都不行。
- 改完 satp 就死:写 satp 等于告诉 CPU “页表结构变了”,如果没有先把根页表准备好、ASID 管理好,CPU 下一步取指就会触发页错误。调试时最好关闭 MMU 逐步开启。
- 在 U 模式访问 M 模式 CSR:直接 SIGILL,不是返回值不对的问题。
- 向量模式陷阱:设了 mtvec 的低 2 位为 01,却只在基地址放了处理函数,没有在对应偏移处放中断处理函数,导致中断跳到一个空地址。
最后再分享一个我个人的体会:CSR 这东西,光靠背列表是背不下来的,但如果你把“地址区段对应特权级”和“trap 流程对应 CSR 分工”这两条主线抓住了,大部分常用 CSR 都能自己推出来,遇到不确定的再翻规范。这篇速查表是我自己在做中断控制器驱动和启动代码时最常用的“家底清单”,下一篇专栏我打算拆解 satp 和虚拟内存地址翻译,那篇会更依赖今天这张表的第 3 章,建议先把基础内容过一遍。