☰
RISC-V 特权架构与 CSR 寄存器完全指南:从 M/S/U 模式到异常切换
2026/10/30 7:47:00 网站建设 项目流程

刚接触 RISC-V 的第一周,我就被两个字劝退过:CSR。后来为了在自己写的核上把中断调通,我把特权规范和 CSR 手册翻来覆去看了好几遍,才慢慢理出一条线。顺便先说个题外话:如果你是从图算法那篇“邻接表和 CSR 压缩存储”的搜索词跑进来的,那这里得先对个暗号——RISC-V 语境里的 CSR 是 Control and Status Register,控制与状态寄存器,跟稀疏矩阵存储完全是两码事,千万别混。

这篇专栏聊三件事:特权架构为什么是 M/S/U 三级,日常开发里逃不掉的那十几个 CSR 到底负责什么,以及特权切换时硬件替你做了哪些关键动作。无论你是写 bare-metal 启动代码、给 RTOS 移植上下文切换,还是自己设计一个 RISC-V CPU 的 CSR 模块,这套模型基本是通用的。我会尽量把每个“为什么”都拆开讲,遇到容易踩坑的地方也会标记出来。

1. 三级特权架构:为什么偏偏是 M/S/U

1.1 单权限模式下会失控成什么样

先做一个思想实验:如果 CPU 只有一种特权等级,所有程序都天生拥有最高权限,会发生什么?最简单的情况,用户程序里一条简单的非法跳转或一次越界写,就可能把系统的中断向量表、页表基址、定时器配置全部冲掉。更麻烦的是,操作系统想保护自己不被应用程序破坏,根本没有“隔离”这个手段,因为指令集层面就不存在权限的差别。

ARM 和 x86 对这个问题给出的答案是复杂的异常级别或环(ring)设计,RISC-V 则选择了一条非常克制的路线:只保留三个基本等级,分别是 Machine Mode(机器态,M)、Supervisor Mode(监管态,S)、User Mode(用户态,U),再加上一个留给虚拟化扩展的 H 态预留位置。选三个而不是更多,是因为从工程角度看,三态已经覆盖了“固件、操作系统、用户程序”这三个最常见的软件层次。多余的等级带来的复杂度和硬件开销会不成比例地上升。

这三个等级的权限是递减的:M 态最高,能访问所有 CSR、执行所有指令;S 态次之,能访问 S 级 CSR;U 态最低,只能访问 U 级 CSR 和少部分公共信息。低等级想用高等级的资源,唯一的正规通道是触发异常或中断,把控制权交给高等级软件。这就是整个特权架构的核心逻辑:能力靠等级隔离,通信靠 trap 完成。

1.2 M、S、U 各自扮演的角色

如果把整台计算机比作一栋楼,M 态就是物业总控室,掌握门禁总开关、水电总闸、监控系统。开机时第一个运行的代码一定是 M 态,比如芯片 Boot ROM 或引导程序。M 态也负责安全启动、功耗管理、复位初始化这类底层事务。在简单裸机系统里,可能整个程序都跑在 M 态,不需要 S 和 U。

S 态是操作系统的地盘。Linux、BSD 这类需要虚拟内存、进程隔离的系统,内核主体跑在 S 态。它有权配置页表、处理缺页异常、管理进程调度,但无权直接改动机器态专属的寄存器。S 态和 M 态之间的互操作,依赖异常委托机制。比如机器态把“来自用户态的 ecall”委托给 S 态,用户程序发起系统调用时,就直接进内核而不是每次都要掉进 M 态。

U 态就是普通应用程序的世界。它只能使用普通的计算指令、访问用户态 CSR,所有涉及特权资源的操作都算非法。CPU 在 U 态下会检查地址翻译、权限位,任何越界行为都会被拦截并抛给更高等级处理。RTOS 的场景稍微特别一点:很多轻量级 RTOS 全家都跑在 M 态,不使用 S/U 态,但这不意味着特权架构没有意义,而是因为这类系统本身不需要隔离多个信任域。

1.3 一次典型异常里的特权迁移轨迹

理解特权架构最直观的方式,是盯住一次异常的生命周期。比如一个 U 态程序执行ecall想发起系统调用,硬件会做如下动作:

  1. 把当前 PC 保存到mepc或sepc(取决于异常被委托到哪一级)。
  2. 把异常原因写进mcause或scause,这里就是“来自 U 态的环境调用”编号。
  3. 记录来源特权等级到mstatus.MPP或sstatus.SPP。
  4. 关闭更多中断使能位,防止异常处理过程中被打断。
  5. 把 PC 跳转到mtvec或stvec指向的入口。

处理完成后,软件不是简单地“跳回去”,而是要执行mret或sret。这个返回指令会做和进入异常几乎镜像的操作:恢复 PC、恢复特权等级、恢复中断使能状态。整个流程本质上就是一次显式的、由硬件帮助完成的“压栈出栈”,只不过栈帧不是放在内存里,而是放在 CSR 里。

2. CSR 速查:先分清谁在管什么

2.1 十二位地址里的访问规则

RISC-V 的 CSR 地址一共 12 位,理论上最多 4096 个。这个地址本身不是随便排的,前两位csr[11:10]表示读写属性:00或01表示该 CSR 可读可写,10或11表示只读。也就是说,如果向只读 CSR 发起写操作,会直接触发非法指令异常。

紧接着的两位csr[9:8]表示访问该 CSR 所需的最低特权等级:00对应 U 态、01对应 S 态、10保留给 H 态、11对应 M 态。硬件在每条 CSR 指令执行时,会把当前特权等级和地址里这几位做比较,不满足条件就抛异常。

举个例子,mstatus的地址是0x300,二进制低四位信息显示它是 M 态可访问;sstatus的地址是0x100,S 态可访问。所以 S 态代码能自由读sstatus,但碰到mstatus就会触发非法指令。这个设计的好处是,即使不查手册,光看地址也能猜出某个 CSR 属于哪一级、能不能写,对 CPU 设计者来说尤其友好。

2.2 机器态核心寄存器清单

打开任何一份 RISC-V 手册,M 态 CSR 都是最长的名单,但真正常用的其实就那么十几只。我把最核心的整理成一张速查表,标记了地址和用途。

CSR 名称地址最低访问等级作用
mstatus0x300M机器态状态控制字,全局中断、特权切换信息都在这
misa0x301MISA 与扩展信息,告诉软件当前 CPU 支持什么
medeleg0x302M异常委托:把哪些异常交给 S 态处理
mideleg0x303M中断委托:把哪些中断交给 S 态处理
mie0x304M机器态中断使能开关
mtvec0x305M机器态异常入口地址,中断/异常跳到这里
mscratch0x340M机器态临时寄存器,常用来保存 trap 上下文指针
mepc0x341M异常返回地址,保存被打断的 PC
mcause0x342M异常原因,说明是什么类型的 trap
mtval0x343M异常附加信息,例如访存错误时的目标地址
mip0x344M机器态中断挂起状态,哪些中断正在等待
mhartid0xF14M当前硬件线程 ID,多核启动时靠它区分核

mstatus需要单独多看一眼。它里面包含MIE(机器态全局中断使能)、MPIE(进入异常前的中断使能状态)、MPP(进入异常前的特权等级)这些关键位。硬件在 trap 时自动更新MPIE和MPP,返回时mret又用它们恢复现场,所以千万不要在异常处理程序里随意改动这几个位,否则返回时会彻底跑飞。

2.3 监管态核心寄存器清单

跑 Linux 或有完整 MMU 的系统时,S 态才是真正的“前线”。S 态的核心 CSR 和 M 态一一对应,但权限更低,地址也在0x100段到0x180段。

CSR 名称地址作用
sstatus0x100S 态状态视图,相当于 M 态 mstatus 的子集
sie0x104S 态中断使能
stvec0x105S 态异常入口地址
sscratch0x140S 态临时寄存器
sepc0x141S 态异常返回地址
scause0x142S 态异常原因
stval0x143S 态异常附加信息
satp0x180S 态页表基址与地址翻译模式

satp是整个虚拟内存系统的总开关,里面包含 MODE 字段、ASID 和 PPN。如果你在用sv39或sv48,satp的高位会写着翻译模式,中间是 ASID,低位是根页表的物理页号。对 CPU 设计者来说,这个寄存器基本决定了 MMU 的地址翻译起点。

2.4 容易被忽略的委托机制 medeleg/mideleg

很多初学 RISC-V 的人会困惑:我已经在 M 态把中断处理写好,为什么 S 态还是收不到系统调用?原因很可能就是没有配置medeleg和mideleg。

默认情况下,所有异常和中断都会掉进 M 态,S 态和 U 态软件根本没有机会直接处理。但现代操作系统希望用户态的ecall直接进入 S 态内核,而不是每次都被 M 态截胡。做法是在 M 态启动阶段把异常号对应的 bit 写入medeleg,把中断号对应的 bit 写入mideleg。例如,用户态ecall的异常编号是 8,那么把1 << 8写进medeleg,以后再发生 U 态ecall,硬件就会直接把 trap 丢给 S 态。

委托机制的巧妙之处在于,它让 M 态固件只保留真正需要机器态处理的事务,其余低频、可委托的都交给 S 态,层级职责更清晰。但注意,委托 CSR 本身只能由 M 态写,S 态没有资格修改,否则系统权限模型就崩了。

2.5 用户态寄存器与硬件计数器

U 态能访问的 CSR 非常少,通常只有ustatus、utvec、uepc、ucause、utval、uip这一组,以及时钟周期计数器cycle、time、instret这些只读序列。绝大多数时候,用户程序不会真正去碰这些,因为系统调用已经把所有特权操作封装好了。

不过计数器值得单独提一句。rdcycle、rdtime、rdinstret这类指令在 U 态可用性受mcounteren和scounteren控制。如果 M 态没开对应使能位,U 态读计数器也会触发非法指令。做性能分析软件时,经常会在这里踩坑。

3. 实操:CSR 读写与特权切换的完整过程

3.1 六条 CSR 指令,记住三个就够

RISC-V 的基础指令集本身不包含 CSR 操作,它们属于 Zicsr 扩展,不过现在绝大多数工具链默认打开这个扩展。CSR 指令一共有六条,但日常使用只需要记住以下三条的语义。

csrrw rd, csr, rs1是原子读改写,把 CSR 的旧值读到rd,然后把rs1的值写入 CSR。csrrs rd, csr, rs1是读后置位,只把rs1中为 1 的位写到 CSR,为 0 的位保持不变。csrrc rd, csr, rs1是读后清除,只把rs1中为 1 的位清掉。

还有三个立即数版本csrrwi、csrrsi、csrrci,把寄存器源换成 5 位立即数。实际写汇编时,GNU 汇编器还提供了一批简洁伪指令,比如csrr t0, mstatus其实会被展开成csrrs t0, mstatus, x0,因为源寄存器 x0 全零,写入动作没有任何效果。再比如csrw mie, t1展开成csrrw x0, mie, t1,不关心旧值。

写 CSR 时有个习惯非常推荐:如果只想改某一位,别用csrrw整个覆盖,先用csrrs/csrrc按位操作。因为mstatus这类寄存器里不同位代表不同含义,很多位还保留给未来的规范,直接整字覆盖很容易把不该动的位改坏。

3.2 ecall:最典型的软陷入流程

ecall是 RISC-V 基础指令集里自带的环境调用指令,它的作用就是主动触发一次异常。如果你当前在 U 态执行ecall,硬件会记录到“来自 U 态的环境调用”(异常号 8);在 S 态执行则是异常号 9;在 M 态执行是异常号 11。

一个最小可运行的例子是:M 态启动时把mtvec设置成自己的异常入口,然后跳转到 U 态的一段代码,U 态执行ecall,控制权自动回到 M 态入口。处理完再执行mret返回到ecall下一条指令。整个过程中,硬件已经自动完成了最麻烦的部分:PC 保存、原因记录、特权等级记忆、中断关闭。

实际的系统调用实现,通常会在ecall之前按约定把系统调用号放进a7,参数放进a0到a5,在内核入口处再统一解析。RISC-V 的 Linux 内核就是这样做的。底层的 trap 入口千篇一律:先用csrrw sp, mscratch, sp换栈,保存a0等寄存器,再读取scause/sepc判断原因,处理完成后恢复现场回到用户态。

3.3 xRET 返回时发生了什么

mret和sret是特权切换的“返回键”,它们的动作非常对称。执行mret时,CPU 把PC设为mepc的值,把当前特权等级切换为mstatus.MPP里记录的值,再让MIE恢复为MPIE里保存的旧状态,最后把MPIE置 1、MPP置回默认值,为下一次 trap 腾出干净空间。

这里最容易犯的错,是把mret理解成普通函数返回。它不只是改 PC,还会同步改特权等级和中断使能位。如果mstatus.MPP被错误写成 S 或 U,执行mret就会跑到错误等级,轻则权限异常,重则整个系统失控。同理,mret和sret都有权限要求:在 U 态执行mret或sret会触发非法指令异常。

3.4 最小切换骨架:从 M 态出发,去 U 态转一圈

下面这段伪代码式的流程,展示了从 M 态切换到 U 态、再通过ecall回到 M 态的完整闭环。

# M 态启动阶段 la t0, m_trap_entry csrw mtvec, t0 # 设置 M 态异常入口 la t0, user_code csrw mepc, t0 # 让 mret 返回用户代码 li t0, 0x1800 csrw mstatus, t0 # MPP=U,同时把中断状态准备好 mret # 切到 U 态,PC=user_code # U 态用户代码 user_code: li a7, 1 ecall # 主动陷入,回到 M 态入口 nop # M 态异常入口 m_trap_entry: csrrw sp, mscratch, sp # 换栈,避免破坏用户栈 csrr t0, mepc # 读回异常 PC csrr t1, mcause # 读回异常原因 # 根据 mcause 做对应处理 csrrw sp, mscratch, sp # 换回用户栈 mret # 恢复 U 态,PC=ecall 下一条

这段流程虽然简单,但把特权切换的五个关键点都过了一遍:入口地址设置、特权等级记录、触发异常、保存现场、返回恢复。你在 RTOS 的上下文切换代码里看到的mret,本质上就是这套流程的高频重复版本。

4. 踩坑实录与排查速查表

4.1 一读 CSR 就触发 Illegal Instruction

这是我在自制核上调试时遇到的第一个大坑。代码明明写的是标准csrr t0, mstatus,仿真却直接报非法指令。查到最后,问题出在我复刻的 CPU 核里根本没有实现mstatus的读取通路,硬件在遇到未实现的 CSR 地址时直接返回非法指令异常。

如果你用的是成熟工具链和模拟器,这个现象通常意味着当前特权等级不够。比如 S 态代码去读mstatus,或者 U 态代码去读sie,都会因为地址里编码的权限等级不符而非法。排查时先确认三件事:当前跑在哪个特权级、CSR 地址对应的最小等级是多少、你的 CPU 是否真的实现了这只 CSR。

4.2 现场保存漏了 mstatus 的扩展位

写自定义 trap 入口时,很多人会把现场保存理解成“把通用寄存器压栈”,却忘了mstatus本身就是现场的一部分。尤其是MPIE和MPP这两个字段,如果异常处理程序里执行了其他会改变mstatus的操作,比如临时中断嵌套,返回时mret恢复的就是被改过的值,程序直接跑飞。

我的建议是,在 trap 入口的第一时间就把mstatus、mepc、mcause全部读出来存到内存或专用寄存器区,处理过程中不要再依赖它们的实时值。返回前,再把这些值写回 CSR,最后执行mret。这样即使处理逻辑复杂,也不会破坏硬件的自动保存信息。

4.3 mtvec/stvec 配置错误导致的死循环

mtvec的低两位是 MODE 字段,可以是 Direct(直接模式)或 Vectored(向量模式)。在 Direct 模式下,所有异常都跳到同一个入口;在 Vectored 模式下,按中断号跳到入口地址加偏移。如果在 Direct 模式下基地址低两位不是 0,或者向量表没有按 4 字节对齐,硬件行为就是未定义的,很容易出现跳转后 PC 错位,再触发新的异常,形成死循环。

调试时可以通过仿真器观察 PC 是否卡在同一个地址。更常见的低级错误是:入口函数正常,但入口函数本身又触发了同一个异常。比如mtvec指向的函数没有先处理mcause,每次进入后继续执行非法指令,就会无限陷入。正确做法是在 trap 入口第一时间保存mcause和mepc,再根据原因分流处理。

4.4 常见问题速查表

问题现象可能原因解决办法
读 CSR 报 Illegal Instruction当前特权等级低于 CSR 要求检查 CSR 地址的 csr[9:8] 和当前模式
写 CSR 报 Illegal Instruction该 CSR 是只读,或当前等级不够确认 csr[11:10] 属性,改用正确等级
ecall 没进预期 trap 入口medeleg 委托配置不对检查 medeleg 相应异常位
从 trap 返回后现场全乱mstatus 的 MPP/MPIE 被改动入口读原值,返回前恢复原值
中断使能不生效mstatus.MIE 或 mie 对应位没开先开全局中断,再开具体中断使能
切换 U 态后访问特权 CSR 崩溃代码仍按 M 态逻辑访问 S/M CSR检查汇编指令和编译参数
mtvec 跳转死循环基地址对齐错误或入口重复触发异常核对低两位 MODE 字段和对齐

5. 一点个人体会

如果你也在自己写 RISC-V CPU 或者写底层启动代码,我最大的建议是:不要试图一次性理解所有 CSR,先把mstatus、mtvec、mepc、mcause这一组机器态核心寄存器吃透,配合一个最简单的ecall实验把它们跑通。我自己就是在仿真器里反复观察mstatus.MPP和MPIE在 trap 前后的变化,才真正把特权切换的模型刻进脑子里的。

还有一个很实用的小技巧:调试 CSR 相关问题时,不要只盯波形或打印日志,优先检查当前特权等级。RISC-V 的权限问题常常表现为“非法指令”这样的二次症状,真正的原因早在几行指令之前就埋下了。把 M/S/U 的边界牢牢记在心里,很多玄学问题其实都是权限问题。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询