1. 项目概述前些年在做 RISC-V 相关项目时经常需要在不同权限域之间做状态隔离比如用户态程序意外访问到硬件调试相关的控制寄存器或者嵌入式场景里固件误改了某些原本只该由机器模式管理的状态位。这类问题排查起来非常痛苦因为你根本不知道是哪个环节把状态摸了一下系统随机崩溃又很难复现。后来 RISC-V 官方规范引入了 Smstateen/Ssstateen 扩展这套扩展就是为了解决“状态使能访问控制”这个让人头疼的问题。简单说Smstateen/Ssstateen 扩展定义了一组状态使能寄存器用于控制对各类扩展状态位的访问权。这里的“状态”涵盖范围很广不仅包括浮点、向量寄存器状态还包括计数器、调试相关寄存器、间接访问控制等高级特性的状态。它解决的问题是在不同权限级的代码之间如何防止非授权访问某些硬件状态和控制寄存器从而提升系统整体安全性和健壮性。对于做 RISC-V 最小系统、RTOS 移植、跑 Linux 内核裁剪、固件启动代码开发的工程师来说这几乎是绕不开的一个话题。这套规范值得谁读如果你是写裸机启动代码的那么你必须了解 mstateen 在启动初期怎么配置如果你在移植 Linux 内核需要关注的是 Ssstateen 的启用和上下文切换细节如果你做的是异构多核或者安全芯片类的产品那么状态使能隔离的机制可能直接决定你系统能不能扛住攻击。这篇文章我会尽量不用“规范复读机”的方式而是结合项目实际经验把 Smstateen/Ssstateen 从设计意图到寄存器细节再到软硬件协同改造点完整拆开讲清楚。我最初接触这套扩展时也被绕晕过其中最大的理解门槛在于“为什么既要有 M 模式的状态使能又要有 S 模式的状态使能还要有影子状态”把这几层关系理清了整个规范的脉络就出来了。2. 状态使能问题的由来2.1 为什么必须限制状态访问在 RISC-V 世界里从机器模式M 模式到监督模式S 模式再到用户模式U 模式权限是逐级下降的。早期的 RISC-V 设计里很多 CSR控制状态寄存器访问权限比较粗放比如机器模式下的 mstatus 寄存器S 模式和 U 模式都可以通过某些条件去读甚至在不同配置下有能力改写。从系统设计的角度看这其实埋了不少隐患一段 U 模式的用户代码如果拿到了不该有的状态访问能力它就能观测整个系统运行的侧面信息乃至通过修改状态位来干扰其他软件组件的运行。举个例子浮点单元 FPU 的名字好听但它的寄存器上下文如果在任务切换时不彻底隔离一个任务留下的浮点状态可能泄露给下一个任务这在数据隐私要求高的场景里是不能接受的。为了在不破坏整体性能的前提下完成隔离我们需要一个“门口守卫”机制权限高的代码设置规则权限低的代码必须按规则访问。Smstateen 扩展里的 stateen 寄存器干的就是这个活儿。每一级权限模式都有属于自己的控制点其中 M 模式通过 Smstateen 提供的 mstateen 寄存器来限制 S 模式和 U 模式以及 M 模式自身某些可选范围对各种状态的访问S 模式通过 Ssstateen 提供的 sstateen 寄存器在 S 模式下进一步控制 U 模式对状态的访问。注意这里的“影子状态”概念sstateen 是 mstateen 的映射或受限视图但它和单纯的读写位复制不一样因为每一级都可以收紧权限而不能放开更高权限设定的限制。2.2 一个回归历史上的真实痛点接触过老版本 RISC-V 平台的人可能都有印象早年跑 Linux 的时候用户态通过 rdcycle 指令读 CPU 周期计数器是默认允许的。有的测试工程师喜欢用周期计数器做性能分析这本身没问题但安全团队不愿意了用户态可以精确测量指令时序这往往是侧信道攻击的第一步。后来我们适配某 RISC-V 板卡时为了让用户态彻底不能读 cycle 计数器就得打补丁改权限解析非常别扭。如果是现在这套扩展可以优雅地解决。通过设置 mstateen0 中对应计数器访问控制的位可以让 U 模式访问 cycle 或 time 的行为变成非法指令异常这么做既灵活又彻底。关键不只是能关还能按需局部放开例如保留 time 的访问因为用户态虚拟化时钟需要它却关掉 cycle 或 instret。2.3 状态访问管控的核心维度要真正理解这套扩展可以从三个维度去拆解第一个维度是控制对象。stateen 寄存器不是一大块无差别的状态位它按功能区块划分比如浮点状态、向量状态、计数器、间接 CSR 访问、调试相关状态等。每个功能区块占一个使能位具体位置由规范或者平台扩展约定。第二个维度是控制层级。从 M 模式到 S 模式再到 U 模式使能控制是从高到低单向传递的。下级不能开启上级关闭的访问只能在自己权限范围内进一步限制。这个设计借鉴了经典权限模型清晰且不容易绕过。第三个维度是异常行为。当某个状态下被禁止访问时访问指令的执行会触发非法指令异常而不是静默返回错误数据。这种“硬失败”有利于软件快速发现配置错误也便于虚拟化场景下的陷入处理。这三个维度对应到具体寄存器字段和访问规则就能把整个 Smstateen/Ssstateen 的机制框起来。接下来我们深入寄存器层面看细节。3. 状态使能相关寄存器详解3.1 mstateen0/1/2 寄存器构成根据规范M 模式下的状态使能寄存器至少包含 mstateen0、mstateen1、mstateen2 这三个实现还可以按需增加更高编号。这三个寄存器都是 64 位宽RV64 下RV32 下访问高 32 位需要额外注意访问规则。在实际实现中平台可以只实现必要字段未实现的位必须硬连为 0读出来是 0写进去无效果。mstateen0 的低位字段是最常用的部分包括指向浮点状态对应 0 位、向量状态对应 1 位、以及若干计数器相关的位。mstateen1 和 mstateen2 主要是给后续扩展留下来的增长空间具体的位分配由平台或后续规范填充。这一点在移植时非常重要你不能假设所有位都有物理意义必须对照当前平台的手册确认哪些位是实际存在的。在实际项目中我踩过一个坑移植时想当然地往 mstateen0 里写全 1以为可以把所有状态都放开结果发现某些位在硬件上是保留位写 1 会被忽略读回来为 0。后来查平台手册才知道未实现的位本身就是 0这和一般 CSR 的保留位行为稍有不同——多数保留位要求写入忽略但读回来可能是上次写入的值。这里直接在硬件层面清零更安全。3.2 sstateen0/1/2 与影子状态机制Ssstateen 扩展引入了 sstateen0、sstateen1、sstateen2对应 mstateen 的“影子寄存器”概念。所谓影子状态指的是 sstateen 的某些位是 mstateen 对应位的受限投射其行为符合这样的规则若 mstateen 中某位为 0则任何模式下读取 sstateen 对应位都返回 0若 mstateen 中某位为 1S 模式读取 sstateen 对应位时能够得到实际值S 模式写 sstateen 时只能把 1 写成 0即收紧权限不能把 0 写成 1放开权限。这里我多说一句。第一次听说“影子状态”这个概念时我还以为它就是个简单的副本或者镜像后来测试才发现“影子”真正的含义是“由更高权限模式支配的受限视图”。更准确地说sstateen 的权限表现是一种单向收紧机制高权限方可以一次性放开低权限方只能在已放开的范围内做减法。这有点像公司的门禁卡系统物业管理员能给所有楼层授权某家公司拿到授权后可以把授权限制在自己的楼层但不能给自家员工打开自己都没权限的楼层。3.3 各个字段的具体功能分配为了便于大家在真实板卡上操作我整理了一个简化版的字段对照具体实现要以官方手册为准这里只做通用梳理寄存器位域控制对象典型含义mstateen0位 0浮点状态F 扩展控制浮点寄存器与 CSR 访问mstateen0位 1向量状态V 扩展控制向量寄存器与 CSR 访问mstateen0位 2间接 CSR 访问控制 mseccfg 等间接访问机制mstateen0位 6~12计数器访问控制控制 cycle/time/instret 等计数器mstateen12~63平台自定义扩展由具体平台定义mstateen22~63更高编号扩展由具体平台定义在准备调试代码时我通常会先做一次“状态巡检”把 mstateen0 的当前值读出来逐个位对照手册检查每个功能区块的开关状态。这个习惯帮我避免了很多“为什么浮点指令突然触发异常”这类问题。3.4 平台上下文切换中的寄存器保存对操作系统内核来说上下文切换是高频操作。sstateen 寄存器作为 S 模式可访问的 CSR必须纳入上下文切换的保存和恢复流程。之前在某款支持向量扩展的板卡上移植 RTOS任务切换时忘记保存 sstateen导致一个任务关掉了向量使能切换到另一个任务后向量指令全部异常。不过调试了半天才发现问题是“任务搞坏了别人家的门锁”。对于 M 模式固件来说mstateen 寄存器通常只在启动阶段配置一次后续不会频繁改动。但如果你的系统里有多个 M 模式软件组件比如安全固件和非安全固件那么它们之间交互时也应当显式保存和恢复 mstateen防止组件间的状态污染。4. 设计和实现背后的关键考量4.1 为什么采用“逐级收紧”而不用“全局开关”我最早在想这套扩展时脑子里冒出的方案其实是“一个全局寄存器统一控制所有权限模式”这样不是更简单吗但仔细考虑后会发现全局开关虽然实现简单却缺少灵活性。不同权限模式下运行的软件安全需求不一样U 模式可能要跑插件化的用户程序S 模式跑内核M 模式跑固件。统一开关会让安全策略不好做例如某个安全监测功能只需要放开给 M 模式而不想让 S 模式通过任何路径看到全局开关做不到这种粒度。另一个角度是虚拟化场景。如果上层跑着 Hypervisor它需要为不同的虚拟机提供不同的状态访问能力。逐级收紧的架构天然支持这种多层级的差异化授权因为每一级都可以在上一级给定的范围内再做限制形成权限链。这种设计思路和 RISC-V 的 H 扩展Hypervisor 扩展是搭配设计、协同工作的。4.2 异常处理的兜底价值RISC-V 是一个极度依赖异常和陷入机制来处理边缘情况的架构。Smstateen/Ssstateen 的优势之一是当访问被禁止时系统能够得到一致的异常信号而不是硬件层面的静默忽略。对软件来说非法指令异常具有标准化行为处理起来非常顺畅。在实际调试中我就利用这一点构建过一套“状态访问审计工具”。原理很简单把关键状态位全部关闭然后运行测试程序谁触发访问异常谁就是依赖这个状态的代码。这个工具在后来的驱动开发中帮了大忙因为某些老驱动代码里隐藏着对遗留 CSR 的访问平时不触发一旦状态位关闭立刻暴露。4.3 对硬件设计的约束与释放从硬件设计视角看这套扩展也给实现者提供了灵活性。因为每个状态位都可以独立控制SoC 设计者可以根据自己的安全模型决定哪些子系统的状态位暴露给软件哪些直接由硬件私有逻辑管理。未被实现的位硬连为 0可以降低 CSR 硬件实现的复杂度。同时设计者可以通过自定字段值扩展自己的私有状态控制不破坏标准兼容性。不过这个灵活性也带来一个隐患软件如果跑在一个实现不完整的平台上可能因为某个字段缺失导致功能不正常而代码本身是标准兼容的。因此软件启动阶段最好做一次能力探测逐个检查字段是否可用而不是盲写全 1 或全 0。5. 实操如何正确配置和规避常见陷阱5.1 启动阶段配置参考代码我提供一个实际项目里用过的 M 模式启动片段经过裁剪保留了核心逻辑。这个片段在 M 模式固件早期执行作用是“先全关后按需开”防止脏状态带进后续系统。// 伪代码实际实现需根据平台寄存器定义调整 void stateen_early_init(void) { // 1. 先读取当前值而不是直接写 0 unsigned long mstateen0_val csr_read(mstateen0); unsigned long mstateen1_val csr_read(mstateen1); unsigned long mstateen2_val csr_read(mstateen2); // 2. 关闭所有已知的控制位 mstateen0_val ~(BIT(0) | BIT(1) | BIT(2)); mstateen1_val ~KNOWN_MS1_MASK; mstateen2_val ~KNOWN_MS2_MASK; // 3. 按需开启例如只打开浮点单元先不开向量 mstateen0_val | BIT(0); // 允许 FPU 访问 // 4. 写回寄存器 csr_write(mstateen0, mstateen0_val); csr_write(mstateen1, mstateen1_val); csr_write(mstateen2, mstateen2_val); }要注意的是步骤 1 里先读取而不是直接写 0是为了保留硬件实现自定义位和未来规范定义的兼容性。虽然未实现位读出来为 0但规范也说未来的新版本可能在这些位上有新含义直接写全 0 可能把未来要用的位误伤。因此建议的操作是把当前已知的控制位清除保留未知位。5.2 S 模式操作系统初始化的衔接在 S 模式内核启动时需要检查 mstateen 中对应位的状态再据此初始化 sstateen。如果 M 模式固件没有开放某些状态S 模式内核无论怎么写 sstateen 都无法打开。一个合格的内核启动代码应该包含类似这样的检查逻辑static inline int stateen_is_fpu_enabled(void) { // 读取 sstateen 对应位判断 M 模式是否放行 return (csr_read(sstateen0) BIT(0)) ! 0; // 注意如果这个位读为 0既可能是 M 模式关掉了也可能是硬件不支持 }在实际移植时我建议在启动日志里打印每个状态位的开关状态这样后续问题定位的时候可以少很多猜测。例如[stateen] mstateen0 0x... fpu1 vec1 cnt0 ...5.3 上下文切换中的一个隐藏天坑在支持向量扩展或者浮点扩展的嵌入式系统上任务切换时需要做“延迟上下文保存”的优化。所谓延迟保存就是在任务切换时先不保存浮点/向量寄存器等到新任务真正使用它们时通过异常触发再保存。问题来了如果 M 模式关闭了 FPU 或向量状态的访问而内核的任务切换代码并没有随之关闭延迟保存机制那么新任务第一次触发延迟保存时异常处理里会去访问浮点/向量寄存器这时因为 stateen 关闭又触发一个二级异常搞不好系统直接死锁。这类 bug 的隐蔽性极高因为它们只在特定的“关闭状态下切换”时才会出现。我的建议是在引入 stateen 控制时要同步审查任务切换代码里所有与浮点/向量状态相关的分支确保异常流不会依赖被关闭的硬件状态。5.4 调试计数器访问控制的实战流程如果你要限制用户态读周期计数器可以按以下步骤来验证读取 mstateen0 当前值记录计数器相关位现状将计数器对应的位写 1也就是允许访问在 S 模式下将 sstateen0 对应位写 0收紧 U 模式访问写一个 U 模式小程序执行 rdcycle 指令确认触发非法指令异常将 sstateen0 对应位恢复为 1再次运行程序确认可以正常读数。第 3 步是很多初学者容易漏掉的。仅仅是 mstateen 打开还不够如果 sstateen 里对应位是 0U 模式访问依然会被拒绝。因此实际做权限收紧时通常是 M 模式全开S 模式按需收紧这样既灵活又安全。6. 常见问题排查与踩坑记录6.1 非法指令异常但不确定是不是 stateen 导致遇到非法指令异常第一步要看的不是异常本身而是引起异常的指令是什么以及当前特权级是什么。最快的定位办法是查看 mtval 或者 stval 寄存器里面会记录触发异常的指令编码或地址。如果触发异常的指令是浮点/向量/计数器相关那就要重点怀疑 stateen 配置。有个场景我印象很深一个同事把浮点编译选项打开但引导代码里忘记开 mstateen0 对应的 FPU 位结果用户态所有浮点运算都在第一条指令直接爆炸。命令行编译没有任何警告运行阶段疯狂报 illegal instruction。排查了一个小时最后发现是引导代码里漏了一行。所以现在我的默认习惯是新板卡上电后第一件事就是验证 FPU 和向量是否可访问。6.2 配置了 sstateen 但访问还是被允许这个现象看起来有点反直觉因为常见思路是“我在 S 模式关掉了怎么用户态还是能访问”原因一般有两种第一你的 M 模式固件可能根本没有实现对应的 mstateen 位或者对应位恒为 0/1第二你的内核可能在某些路径上做了 sstateen 的恢复把关闭覆盖掉了。排查这个问题的技巧是写一个简单的 S 模式模块打印 sstateen0 实际值并在访问前后各读一次比较差异。如果前后不一致说明有其他代码路径在改写它。如果读出来始终是 1那就要回头确认 mstateen 那边是否真的支持这个位。6.3 上下文切换后状态权限错乱这个问题和 5.3 提到的延迟保存有关联但也有独立版本在一个 RTOS 里任务 A 允许 FPU任务 B 不允许 FPU。如果调度器在切换时没有正确更新 sstateen就会出现“B 任务跑完A 任务一用 FPU 就异常”。本质原因就是 sstateen 没有作为任务上下文的一部分保存和恢复。解决起来其实不复杂把 sstateen 的值放入任务控制块的上下文结构体在切换时做 load/store。但要特别留意如果任务 A 在切换时恰好处于 FPU 活跃状态那么你还需要配合延迟保存机制一起闪避否则可能把现场搞乱。6.4 在 QEMU 模拟器上调不通不少人在 QEMU 上跑 RISC-V Linux 时会遇到 stateen 相关寄存器读不到或者写无效的问题。QEMU 对扩展的实现可能比硬件慢很多尤其是一些边缘配置组合模拟器可能并不完全遵照规范。遇到这种情况先不要怀疑自己的代码可以先在 QEMU 的 monitor 里查看寄存器是否被实现或者换一个机器模型如virt和sifive_u对比验证。我碰到过一次 QEMU 对 mstateen0 的某一位忽略写入后来换到virt机器模型就好了属于模拟器实现差异。7. 实际应用场景与影响评估7.1 安全启动与可信执行环境在安全启动流程里我们通常希望在引导的早期就锁定敏感状态防止后续阶段的固件随意修改。借助 mstateen可以在 ROM 代码阶段就把所有安全关键状态位关闭并保持关闭直到下一个可信组件接管。这种机制相比传统“靠约定不访问”的方式有一个极大的优势即使后续固件代码有漏洞攻击者也无法直接打开这些状态位因为没有权限修改 mstateen 本身。需要说明的是mstateen 本身属于 M 模式 CSR如果 M 模式代码没有问题攻击者拿不到这个写权限。但如果 M 模式固件本身存在可利用漏洞那就另当别论了stateen 不是万能药但它是纵深防御体系里不错的一环。7.2 虚拟化与多租户场景S 模式之上的虚拟化场景配置策略就更有意思了。Hypervisor 在切换虚拟机时可以针对每个虚拟机的安全等级设置不同的 sstateen让它们各自看不到不该看的状态信息。这套机制配合 RISC-V 的 H 扩展可以做到比较细粒度的权限隔离。比如给普通租户虚拟机关闭计数器访问给可信租户虚拟机打开计数器访问。同一个物理机上不同虚拟机的状态访问视图完全不同而实现成本只是每次切换时额外保存恢复几个 CSR开销非常小。7.3 对软件生态的影响Smstateen/Ssstateen 并不是一个“一提出来就全行业立刻用起来”的扩展。它在 RISC-V 生态中的渗透是一个渐进过程从 U-Boot 等引导程序到 Linux 内核、再到各类 RTOS适配速度不一。对于坚持做平台软件的人来说越早理解这套机制越能在适配新平台时占据主动。当前主流的内核和引导代码基本都已经包含了 stateen 相关处理尤其对 Linux 主线版本支持已经很完善。如果你在移植旧内核会发现它可能压根没有操作这些寄存器的代码这也是很多嵌入式项目在从旧内核切换到新内核时遇到兼容性差异的一大来源。8. 实操心得与后续扩展思路从我自己的实践来看这套扩展真正上手之后最大的收益并不是“多了几个寄存器”而是改变了系统级权限设计的思考方式。以前我们做嵌入式系统对于状态访问控制总是“睁一只眼闭一只眼”觉得反正没有恶意外部输入没必要做得那么复杂。但当你开始用一个统一的、逐级收紧的模型来组织权限后整个系统会变得清晰很多出问题时的排查路径也更短。如果你后续想在自研项目里加入这套机制我建议按下面的顺序推进先把目标平台手册里关于 stateen 的描述完整读一遍标记出所有已实现的位在启动代码中加入 stateen 的初始化和日志打印确保每次启动都能看到状态在操作系统的上下文切换代码中引入 sstateen 的保存/恢复在异常处理代码中添加对状态相关异常的捕获和打印便于快速定位最后再逐步收紧权限测出系统在最小权限集下能正常运行的最小配置。另外还有一个非常实用的扩展思路如果你在做安全审计或者漏洞挖掘完全可以把 stateen 当作“硬件特性探测工具”。在关闭所有状态位的前提下逐一打开每一个位观察哪些模块的运行行为发生变化。这样你能画出一张该平台软件组件依赖状态的精确图谱后续攻击面分析会清晰很多。最后分享一个小技巧。调试这类 CSR 配置时一定记得用 JTAG 或者硬件调试器抓到最原始的寄存器值不要完全依赖软件打印。因为软件打印本身可能因为访问权限问题出现误读调试器读取的才是硬件真实状态。我遇到过几次“软件读到 0调试点上能读到 1”的情况后来发现是软件跑在 S 模式下读 mstateen 的权限不够读了个寂寞。先确认读取路径再分析具体值能少走不少弯路。