ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

RISC-V M/S/U特权模式与CSR寄存器详解:从机制到实战

2026/10/7 19:58:37 拓冰建站 浏览量
RISC-V M/S/U特权模式与CSR寄存器详解:从机制到实战 1. 为什么要有 M/S/U 三种特权模式从“裸奔”到“带操作系统”的必经之路先从一个每天都在发生的场景说起。你第一次在 RISC-V 开发板上点亮 LED用的是 OpenSBI 或者直接裸机代码程序里随口就能读写mstatus、mepc这类寄存器一切畅通无阻。但等你开始跑 Linux或者移植一个 RTOS想读写同样的寄存器编译器直接抛出一行“非法指令异常Illegal Instruction”然后整个系统卡死。这时候你就该意识到RISC-V 的 M/S/U 三档权限不是为了把简单的事情搞复杂而是为了回答一个最朴素的问题——谁有资格碰哪些东西。M 模式Machine Mode最高权限开机第一行代码就跑在这里。裸机程序、BootROM、固件、OpenSBI 这类“上帝视角”的代码全在 M 模式。所有 CSR 随便读写所有内存随便访问没有例外。S 模式Supervisor Mode操作系统的家。Linux kernel、RTOS 内核通常跑在这里。它能访问一部分关键的 CSR比如stvec、satp但mstatus这类机器模式专属寄存器碰不得。U 模式User Mode应用程序的地盘。你在 Linux 里跑的hello_world、nginx、数据库进程全部在 U 模式下运行。U 模式碰不了satp页表基址改不了中断使能想干点“越权”的事只能通过 ecall 陷入 S 模式或 M 模式求内核帮忙。为什么必须这样分级想象一下如果每个用户程序都能直接修改页表寄存器那任意进程都能把别的进程的内存映射到自己名下或者直接禁用整个系统的中断——这机器一天都活不过三分钟。所以特权架构真正的意义不是“限制”而是“隔离代理”U 模式干不了的事就发出请求让 S 或 M 模式的代码代办。这个请求的通道就是 ecall 指令而通道上传递的“身份凭证”和“现场记录”全部保存在 CSR 里。还有一个容易忽略的点不是所有 RISC-V 实现都支持三种模式。低功耗 MCU 往往只有 M 模式跑个裸机程序就够了支持 Linux 的 SoC 通常是 M/S/U 三档齐全。这也是为什么 CSR 手册里到处写着“若未实现该模式相关 CSR 访问会报非法指令异常”——硬件设计者直接砍掉不需要的电路但砍掉之后软件层面对应的 CSR 也一起消失这是 RISC-V 的可配置特性也是新手最容易踩的“我为什么一读sstatus就炸”类问题的根源。那么M/S/U 三者之间是怎么切换的核心规则就一句话低特权级不能主动进入高特权级只能通过“陷入trap”让高特权级代码接管高特权级代码可以通过 mret/sret 主动“降级”返回低特权级。这个“单向陷入、逐级返回”的模型就是整个特权架构运转的骨架也是接下来理解 CSR 地址空间和访问规则的钥匙。2. CSR 地址空间与访问规则硬件视角下的“权限位图”搞清楚了为什么要分三档接下来必须正面认识 CSR 本身。RISC-V 的 CSR 是独立的地址空间一共 4096 个12 位地址并不跟普通的内存地址混在一起。对 CSR 的读写靠的是csrrw、csrrs、csrrc、csrr、csrw、csrs、csrc这七条指令。这里有个让很多初学者困惑的点既然地址空间有 4096 个位置为什么我们实际见到的 CSR 名字就几十个答案藏在 CSR 地址的编码规则里。12 位地址并非全部用于“编号”其中一部分位段被赋予了权限语义。按 RISC-V 特权规范CSR 地址的[9:8]两位决定这个 CSR 的读写属性[11:10]两位决定访问所需的最低特权级。具体来说地址 bit[11:10] 00代表这个 CSR 是 U 模式可访问的。比如fflags、frm、fcsr、cycle、time、instret这类和用户程序直接相关的寄存器。不过要注意U 模式能不能读到高精度时钟计数器还得看mcounteren这个总开关的脸色。地址 bit[11:10] 01S 模式可访问。sstatus、stvec、satp、sepc、scause等都在这个区间。地址 bit[11:10] 10这一档是保留的规范里明确写了 Reserved实际使用时要避开。地址 bit[11:10] 11M 模式可访问数量最多的一类包括mstatus、mtvec、mepc、mcause、misa等。而读写属性和访客特权的组合方式也很有意思地址 bit[9:8] 组合中01表示读/写11表示只读00和10各自编码不同的访问行为其中不少组合在规范里被列为“保留”。用一句话概括CSR 地址本身就是一张权限表硬件看到 CSR 地址就能立刻判断“这个模式能不能碰它”根本不需要额外查什么表。这里必须展开讲一个非常容易踩坑的语义概念WARL、WLRL、WPRI。WARLWrite Any values, Read Legal values允许软件写入任意值但硬件可以丢弃不支持的位组合读回来时保证是“合法值”。最典型的是misa——你往里面写一个根本不存在的扩展位硬件会自动掩掉读回来还是原来的样子。类似地satp的 ASID 和 PPN 字段在某些实现上也会做 WARL 处理。WLRLWrite Legal values, Read Legal values软件必须写入合法值硬件保证读回合法值。如果写入非法值行为是未定义的可以忽略也可以触发异常。mcounteren这类寄存器一般就是 WLRL。WPRIWrites Preserve values, Reads Ignore values写入会保留旧值读出来全是 0。这类字段本质上“不存在实际的存储位”软件写了也白写。mstatus里很多扩展位就是 WPRI比如mstatus.SD脏状态位在某些实现上就是只读的汇总位读它代表“有没有脏的浮点状态”写它没有任何意义。理解这三个词比背十条 CSR 名称都管用因为几乎所有“写了没反应”“读了和写的不一样”的怪问题根源都在这套语义上。再补充一个必须记住的规则在权限不足、地址不存在、或者字段不支持的情况下访问 CSR触发的是非法指令异常Illegal Instruction而不是简单地返回 0 或忽略。异常会拿mcause记录原因、mtval记录出错指令或 CSR 地址mepc指向出错的那条指令。调试的时候看到mcause 0x00000002非法指令第一反应就应该是我去哪个 CSR 我碰了不该碰的。3. 常用 CSR 速查表M 模式、S 模式、U 模式各看各的厨房速查表是必须的但列一堆寄存器名字和位定义意义不大必须告诉你“每个寄存器是干什么的、什么时候会用上、最重要的位/字段是哪些”。以下是我在实际项目里反复用到的 CSR按模式划分附上我个人的使用场景。3.1 M 模式必备 CSRCSR 名称全称 / 作用关键字段与使用场景mstatusMachine Status Register机器模式状态总开关全局中断使能MIE、上次特权级MPP[12:11]、上次中断使能MPIE、浮点/向量扩展的初始状态FS/VS。改 MPP 是进入 S/U 模式前的必经之路misaMachine ISA Register描述 CPU 支持的扩展读到I、M、A、C、F、D等扩展位。做运行时 CPU 特性检测时用注意 WARL 语义mvendorid/marchid/mimpid/mhartid厂商 ID、架构 ID、实现 ID、硬件线程 ID调试脚本里识别核心型号用比如确认是不是 SiFive、平头哥或 QEMU 的模拟核mtvecMachine Trap-Vector Base-Address Register机器模式中断/异常入口地址写入 trap handler 的地址。注意低两位决定模式直接跳转Direct还是向量表VectoredQEMU 默认常用 Directmepc/mcause/mtvalMachine Exception Program Counter / Cause / Value陷阱现场三件套trap 进来后第一件事就是读这三兄弟mepc告诉你在哪条指令出的事mcause告诉你出了什么事mtval告诉你附加信息比如非法地址、非法 CSR 编码mscratchMachine Scratch Register机器模式临时寄存器典型用法trap handler 第一步先把mepc存到mscratch腾出通用寄存器给后续 C 代码用。裸机 OS 和固件常这么干mie/mip机器模式中断使能 / 中断挂起位域和中断号对齐。MEI机器外部中断、MSI机器软件中断、MTI机器定时器中断。调试时用mip确认中断是否真的挂了mcounteren机器模式计数器使能控制 S/U 模式能不能读cycle、time、instret。想跑 Linux 时这个必须配好否则用户态性能计数全线罢工要特别说明一下mstatus的“三身份”逻辑MPP记录“进入 M 模式之前跑在哪个模式”MPIE记录“进入 M 模式之前中断是否开着”MIE是“当前 M 模式中断开不开”。这三位的配合是 mret 能够“回到原来那个世界”的核心。很多 RTOS 移植翻车就翻在 pit人为改了MPP却忘了同步处理MPIE导致 mret 之后中断使能状态完全不对。3.2 S 模式必备 CSRCSR 名称全称 / 作用关键字段与使用场景sstatusSupervisor Status Register监管者模式状态S 模式下能看到的mstatus子集。重点看SIES 模式中断开关、SPP记录进 S 模式前的特权级SPP0 表示来自 U、SPIES 模式陷入前的中断使能位。sret 就是吃这三个字段的stvecSupervisor Trap Vector Base AddressS 模式中断/异常入口地址。Linux 内核里通常指向handle_arch_el1_sync一类的异常向量写的时候注意模式位sepc/scause/stvalSupervisor Exception Program Counter / Cause / ValueS 模式陷阱三件套和 M 模式完全对应。操作系统收到系统调用后靠scause区分是 ecall 还是缺页靠sepc知道用户程序停在哪一行sscratchSupervisor Scratch Register同上S 模式 trap handler 的临时中转站。Linux 的 entry 代码里大量使用satpSupervisor Address Translation and Protection Register页表基址 ASID 内存翻译模式Sv39 / Sv48 等。这个寄存器是操作系统的命根子想玩裸机以外的任何内存虚拟化都得先写它sie/sipSupervisor Interrupt Enable / PendingS 模式中断相关。SEIE外部中断、STIE定时器中断、SSIE软件中断。Linux 中断子系统操作的就是它们scounterenSupervisor Counter Enable控制 U 模式访问计数器配合 M 模式的mcounteren形成两级开关S 模式 CSR 有一个共性它们基本都是 M 模式对应 CSR 的“过滤版”。比如sstatus只有mstatus的一部分位。这种设计思路是 RISC-V 特有的“子集映射”好处是 M 模式的固件可以严格管控 S 模式能看到什么。遇到“为什么我在 S 模式读某个位是 0”这种问题别怀疑人生去查mstatus的对应位是不是被过滤了。3.3 U 模式可访问的 CSR 与计数器fflags/frm/fcsr浮点状态寄存器。fflags记录浮点异常标志除零、溢出、无效操作等frm控制舍入模式fcsr是二者的组合视图。任何一个用 FPU 的程序都会隐式用到它们——不是程序主动读写而是硬件在执行浮点指令时自动操作。手动修改frm可以换舍入模式这在做数值算法验证时非常有用。ustatus/uepc/ucause/utval/utvecU 模式陷阱相关 CSR属于 N 扩展用户态中断扩展的一部分。这个扩展是可选的绝大多数处理器没实现。如果你在 Linux 里写用户程序时尝试读utvec直接报非法指令别慌纯属正常。目前 RISC-V 用户态中断的应用场景还在早期探索做用户态异步事件机制的人才会关心。cycle/time/instret三个性能计数器。U 模式读它们的权限受mcounteren和scounteren共同控制。这里有经典陷阱默认情况下嵌入式板子禁止 U 模式读cycle但跑 Linux 的系统必须开否则 glibc 内部的时间源、性能剖析工具全都会出问题。顺带说一个我在群里看到过很多次的笑话级误解“CSR 地址空间有 4096 个寄存器那我是不是能把它当 SRAM 用”——不能。CSR 是控制寄存器不是内存它背后是 CPU 的状态机、配置逻辑和计数器电路不是随便读写的存储单元。也别以为 CSR 只有 4096 个条目里的“下发的那一批”很多地址是 HW 保留的你碰了不该碰的地址一样非法指令。4. 模式切换的完整链路ecall、mret 与“现场保存”的艺术知道每个 CSR 的用途还不够得把它们串起来。这一节我用一个最典型的“用户程序发起系统调用”的例子把从 U 模式一路切换到 M 模式的完整链路捋一遍让你看到 CSR 在切换过程中扮演的“书记员”角色。4.1 从 U 模式进入 S 模式操作系统收到 ecall用户程序执行ecall指令的那一刻硬件自动完成以下动作S 模式作为目标特权级时把当前 PC 值保存到sepc——等系统调用处理完sret 要靠它跳回去。把当前特权级U 模式记录到sstatus.SPP位SPP0 表示来自 USPP1 表示来自 S。把sstatus.SIES 模式中断使能保存到sstatus.SPIE然后清零SIE。为什么清零因为进入 trap 处理程序后默认不希望嵌套中断打扰现场。把异常原因写入scause。ecall 从 U 模式发出的原因编码是 8从 S 模式发出的编码是 9。跳转到stvec指向的地址开始执行内核的 trap handler。这套动作里最需要理解的是sepc的语义它保存的是ecall 指令自身的 PC而不是下一条指令的地址。跟 ARM 的异常返回地址通常是 LR指向下一条指令不一样RISC-V 把“当前 PC”原原本本放进sepc处理完异常后如果软件想重新执行 ecall直接 sret 就能复跑如果想跳过 ecall就需要软件自己sepc 4。很多做系统调用的新手在这里栽跟头sret 之后发现系统调用无限重复或者跳到了错误的地址多半就是忘了改sepc。4.2 从 S 模式陷入 M 模式OpenSBI 与固件的介入接着上面的场景。假设操作系统里跑在 S 模式的代码想配置一个设备中断而这个中断控制器只能 M 模式操作那 S 模式只能再发一次 ecall从 S 模式发出的 ecall即“SBI 调用”。此时目标特权级变成 M 模式硬件重复上述动作但把现场记录写到 M 模式的 CSR 上PC 存到mepc来源模式存到mstatus.MPP原中断使能存到mstatus.MPIE再清MIE原因写到mcause跳转到mtvec。注意一个细节S 模式陷入 M 模式时硬件只保存 M 模式视角下的“上一级现场”即mepc里存的是 S 模式那条 ecall 指令的地址而不是用户程序的地址。那用户程序跑在哪里的信息怎么找答案在sepc里——它的值还稳稳地停在用户程序 ecall 那一条指令上。这意味着如果你在 M 模式的 trap handler 里想搞清楚“系统一路从哪来的”你得连环追踪mepc告诉你 S 模式下被打断的位置sepc告诉你 U 模式下被打断的位置。这就是无嵌套异常模型每一级 trap 都只保存当前这一层的现场更基层的现场要靠下一层的 CSR 去挖。好在大多数时候 M 模式的 handler 不需要理会 U 模式的现场交给 S 模式的 kernel 自己处理就完了。4.3 逐级返回mret 和 sret 的对称逻辑返回过程完全是对称的逆操作sret恢复sstatus.SPP里的特权级如果是 0就回到 U 模式恢复SPIE到SIE然后跳转到sepc指向的地址继续执行。mret恢复mstatus.MPP到当前特权级恢复MPIE到MIE跳转到mepc。这里必须提醒一个硬件层面的细节mret有一个特殊行为——如果MPP0即返回 U 模式那么mstatus.MPP会被硬件写成一个“非零的保留值”。这是规范里的要求用来让软件在追查上一级现场时能分辨出“已经发生过一次 mret”。同样sret在返回 U 模式时也会把SPP清零。如果你在调试器里单步 mret 之前打印MPP跟在 mret 之后打印MPP看到的值不一样这属于正常的硬件行为不是寄存器是“写后变脏”。理解了这套“陷入-返回”的完整循环你对 CSR 的认知就从“一堆寄存器”升级成了“一套机制的枢纽”。再回头看那三件套mepc/mcause/mtval你就能体会到它们在链路中的分量——没有这些“书记员”CPU 根本不知道自己是打哪儿来的更不知道该回哪儿去。5. 实操中的坑与经验内嵌汇编、上下文切换与两个真实翻车案例理论知识讲完了这节全部是实战内容。我把这几年在 RISC-V 平台上写裸机、移植 RTOS、调 Linux 启动流程时踩过的坑和验证过的经验原原本本列出来尤其是那些“文档看了没问题、跑起来就炸”的场景。5.1 内嵌汇编里读写 CSR 的标准姿势想在 C 代码里读写 CSR最直接的办法是内嵌汇编。以 RISC-V GCC 为例最常用的是csrr和csrwstatic inline unsigned long read_csr(unsigned long csr) { unsigned long value; __asm__ __volatile__ (csrr %0, %1 : r(value) : i(csr)); return value; } static inline void write_csr(unsigned long csr, unsigned long value) { __asm__ __volatile__ (csrw %1, %0 :: r(value), i(csr)); }注意csr必须是立即数不能用寄存器变量传地址因为 CSR 指令格式里编码的就是 12 位立即数地址这是 RISC-V 指令集层面的硬限制。如果你动态传入一个变量当 CSR 地址编译器会直接报错。要么用宏定义展开要么使用 GCC 的内建函数__builtin_riscv_csrr_*系列每个物理 CSR 都有对应内建函数比如__builtin_riscv_csrr_mstatus()。还有一个常用的“原子性”操作csrrs/csrrcRead and Set / Read and Clear。它们一次性完成“读旧值-改位-写回”适合修改中断使能这类不能被打断的配置。比如开 S 模式外部中断static inline void enable_sei(void) { __asm__ __volatile__ (csrsi sie, %0 :: i(1 9)); // SEIE 是 bit 9 }为什么要用csrsi而不是先csrr sie再csrw sie因为中间那条“读-改-写”的窗口里可能被其他中断插入导致最新配置被旧值覆盖。原子读改写指令就是为此设计的写驱动代码时务必养成习惯。5.2 上下文切换必须保存哪些 CSR如果你在写一个 RTOS 的上下文切换器通用寄存器x1–x31、pc的保存是基本功但 CSR 的保存才是翻车重灾区。根据我的经验以下几类必须由软件显式保存/恢复mstatus/sstatus决定恢复任务后中断使能状态和特权级上下文。特别注意切换任务时要把MIE/SIE位恢复到任务被切换前的样子否则一个中断开关失控整个调度器都会陷入竞争条件。mepc/sepc任务被抢占时的“断点 PC”压栈保存恢复时写回。浮点相关fflags/frm/fcsr如果有 FPU 而且开了mstatus.FS浮点状态属于任务上下文必须跟着切换。很多 RTOS 默认不开FS省了一堆事但一旦用了浮点运算就得把这个坑填上。mscratch/sscratchtrap handler 的临时寄存区通常每个任务要有独立的一份否则中断处理时大家抢一个 scratch数据互相踩踏。有一类 CSR 不需要保存那就是纯配置类的mtvec、stvec、mie、sie、satp在系统初始化时设好之后整个生命周期内不会变上下文切换时动它们反而容易把系统配置弄乱。这一点和 x86 的某些系统寄存器“切任务就要换”的习惯不太一样移植老代码时要格外注意。5.3 翻车案例一修改mstatus.MPP之后 mret 回不去这是我调一个 RISC-V 裸机调度器时遇到的经典问题。当时想在 M 模式切换到 U 模式运行用户任务于是写了这样的代码write_csr(mstatus, read_csr(mstatus) | (1 11)); // MPP 01准备回 S 模式 write_csr(mepc, task_pc); __asm__ __volatile__ (mret);看起来逻辑没错设MPP1把入口地址写进mepc然后 mret 跳到task_pc。结果跑了没几步就Illegal Instruction。排查了很久才发现我改的MPP被打到了MPP的高位bit 12而MPP是两位字段bit[12:11]值01对应 S 模式11对应 M 模式。我那个1 11写的其实是01没问题问题是之前的代码把mstatus里的其他位搞坏了导致中断使能等状态异常最终 mret 之后从头到尾就没有正确进入 S 模式。正确姿势要么用掩码读改写要么直接用 CSR 操作指令只动目标位域。更重要的是修改mstatus这种“全局状态寄存器”前先把它完整读到局部变量里改完再写回并且最好在关中断的前提下进行否则中断一来你的“半成品 mstatus”会被硬件当成真实状态使用那才是真正的灾难现场。5.4 翻车案例二在 U 模式碰cycle计数器直接非法指令跑 Linux 的用户程序里想做个性能测试随手rdcycle指令读 CPU 周期计数编译没报错运行直接崩。原因链条是这样的cycle计数器默认只允许 M 和 S 模式访问U 模式能不能读取决于mcounteren.CY和scounteren.CY是否同时为 1。很多默认固件只开了给 S 模式用没给 U 模式开。解决方法是开机初始化时把这两个寄存器对应位置 1。如果你在写 OpenSBI 层的平台初始化代码记得把mcounteren全开如果你在写内核记得把scounteren也对 U 模式放开。其实这里还藏着一个细节cycle读的是真实的 CPU 周期而time读的是墙上时钟。有些 SoC 里time是平台定时器提供的频率和 CPU 主频不一样需要特别换算。用错了频率基准性能数字能差出好几倍这在调优时真的会误导方向。5.5 附赠一个防混淆提示“CSR”还有别的意思最后说个题外话。搜 RISC-V CSR 资料时你可能会撞见“CSR 压缩存储”这类完全不同的东西——那是图论和稀疏矩阵里的一种数据结构Compressed Sparse Row邻接表的压缩存储格式跟 RISC-V 的控制状态寄存器一毛钱关系都没有。搜索引擎有时会把两者的关键词混在一起。搞清楚你要搜的是“Control and Status Register”还是“Compressed Sparse Row”能省下不少无效阅读时间。RISC-V 语境下CSR 后面永远跟着的是mstatus、mepc、stvec这些名字认准这个特征就不容易被带偏。6. 调试时最实用的三组 CSR 组合拳写了这么多也该收尾了。最后分享三个我在调试现场经常用到的 CSR 组合都是拿过来就能用的“急救包”。组合一异常现场三件套。trap 进来先打印mepc或sepc、mcause或scause、mtval或stval。mepc告诉出错位置mcause告诉异常类型2 是非法指令12/13 是指令/数据缺页8/9 是 ecallmtval补充出错地址或指令编码。这三样出来八成问题能定位。组合二特权级状态诊断。打印mstatus的MPP[12:11]、MPIE[7]、MIE[3]S 模式就看sstatus的SPP[8]、SPIE[5]、SIE[1]。这六个位拼出“当前在哪一层、中断开没开、之前在哪一层”的完整画像。遇到 mret/sret 跳飞、中断不触发这类问题先看这组状态大概率能发现是因为哪一位被改错了。组合三模式穿越追踪。同时打印mepc和sepc能还原出“U 模式在 A 点触发系统调用、S 模式在 B 点陷入 M 模式”的完整路径。这在进行多特权级任务切换、调试 OpenSBI 与内核交互时尤其有效。我自己的体会是RISC-V 的 CSR 体系初看散但一旦建立起“地址即权限、字段分语义、三件套记现场、mret/sret 对称恢复”这个理解框架后面不管是写裸机驱动、移植 Linux 还是调 RTOS 调度器都能比对着文档瞎试要快很多。先从mstatus和mepc这两个最核心的寄存器入手把它们的每一个位都吃透其他 CSR 基本都是按同一套逻辑延伸出来的。下一篇专栏我会把 trap 处理的实际代码从头到尾拆一遍看看这些寄存器在真实的中断处理流程里是怎么被组合使用的。