很多人第一次翻RISC-V特权手册Volume II时,都会直接跳过Chapter 4,因为“Smstateen/Ssstateen”这个组合词实在不像什么热门话题。直到你真的开始写hypervisor,或者想把某个用户态程序的权限压到只剩基本指令,才会意识到这一章的分量。我当年第一次调通“让U-mode读不到fcsr”这个功能时,才真正明白什么叫“一颗状态位抵得过十行异常处理”。
这篇文章就围绕Chapter 4的Smstateen/Ssstateen扩展Version 1.0展开,把stateen这类CSR的设计逻辑、权限委派链、上下文切换套路和实战中的高频坑一次性讲透。适合正在看RISC-V特权架构的嵌入式开发者、虚拟化方向的研究者,以及所有需要在低特权模式里做细粒度状态隔离的人。
1. 从mstatus.FS/VS到stateen:一个“游离状态”的收口问题
1.1 mstatus里的粗粒度开关为什么不够用
RISC-V从诞生起就有两个大家很熟悉的“大开关”:mstatus.FS管浮点,mstatus.VS管向量。它们定义了Off、Initial、Clean、Dirty四态,用来描述一组寄存器堆和指令是否可用、是否脏了。
这套设计对普通操作系统够用,但一进虚拟化场景就露怯。举个例子:FPU可以整体关闭,但fcsr、fflags、frm这三个浮点控制CSR呢?当FS字段处于Clean或Dirty状态时,这些CSR是跟着开放的。问题在于,虚拟化场景里你经常只想拦截“对浮点控制CSR的访问”,而并不想把整个浮点指令流都拦下来做模拟。mstatus.FS做不到这种粒度的区分,因为它是把“寄存器堆状态”和“控制寄存器状态”打包管理的。
向量扩展也是一样。mstatus.VS管住了向量寄存器堆和向量指令,但vstart、vxsat、vxrm这些向量控制CSR的访问权限并不会被VS字段单独收口。对于想监控客户机向量状态变化的宿主机来说,这就是一个明显的盲区。
1.2 “游离状态”具体指什么
这类没有被mstatus.FS/VS单独管理的状态,Smstateen规范里并没有用一个正式术语统一称呼,但我习惯把它们叫做“游离状态”。它们的共同特征是:不完全等同于某个特权字段描述的宏状态,却又实实在在能影响执行结果和侧信道信息。
最常见的就是浮点控制CSR。fcsr里同时装着fflags和frm,用户程序改一个舍入模式、读一个异常标志位,都会立刻改变后续浮点行为。然后是向量控制CSR,vstart、vxsat、vxrm这类寄存器值既会影响向量指令的执行轨迹,也会暴露当前向量单元内部状态。除此之外,性能计数器溢出相关的控制位、调试触发的部分可见位,也都可以归入这一类。
这一大堆“游离状态”如果没有任何统一收口机制,就会出现一个尴尬局面:S-mode或M-mode想让U-mode“可以执行浮点指令,但不能读fcsr”,或者“可以跑向量代码,但看不到vstart”。这在指令集层面原本是做不到的,因为你会发现没有任何一个现成的位能表达这种限制。
1.3 谁最需要这套开关
需要这套机制的其实不是普通RTOS,而是以下几类场景。
第一类是安全沙箱。你想给U-mode一段计算任务,但它不许碰浮点环境配置,不许改舍入模式,也不许读向量状态。这类“运行时环境隔离”用mstatus.FS直接关FPU太粗暴,用Smstateen逐位收口才是正路。
第二类是hypervisor的trap-and-emulate。宿主机为了追踪客户机行为,需要拦截客户机对fcsr这类轻量CSR的访问。问题是:一旦客户机改了fcsr,宿主机就要知道它的上下文变脏了;但如果宿主机能提前拦截写操作,就可以在写真正生效前模拟或者做记账。如果不做拦截,就得无脑保存恢复全部浮点上下文,开销完全是另一个数量级。
第三类是异构多核系统。不同核上浮点和向量的实现细节可能不同,一个核上允许的CSR访问,在另一个核上可能触发的行为不一样。用stateen把每个核的开放策略统一起来,比在软件里到处判断特性简单得多。
2. mstateen0~3的寄存器结构:位、宽、复位值与WARL陷阱
2.1 四个CSR而不是一个
Smstateen引入的M-mode控制寄存器不是单个CSR,而是一组四个:mstateen0、mstateen1、mstateen2、mstateen3。设计四个的原因很简单:单个64位CSR不够分。RISC-V后续扩展要逐位分配,与其未来反复加新CSR,不如一次性留够空间。
RV64上每个mstateen CSR都是64位,RV32上则是32位。我在实现中看到的惯例是:RV32下已经分配的位与RV64完全一致,只不过高32位根本没机会出现,直接视为恒零。这意味着写驱动时不要再假设某个位在32位和64位下编号不同,位编号是跨XLEN一致的。
这里必须强调WARL属性。stateen的大部分位不是简单RW,而是WARL——也就是说,你写0和1进去,硬件可能给你保留,也可能因为某些特性没实现而把位硬连线成固定值。最典型的就是向量位:如果这个核根本没实现V扩展,那么mstateen0.1无论你写什么,读回来都可能是0。软件在初始化时要先探测,不要想当然地认为“写完1就一定读回1”。
2.2 1.0版已分配的关键位
以Chapter 4对应的Version 1.0规范为准,已分配的位并不多,我把常用到的整理成了表格。这里的编号是相对每个stateen CSR自身而言的,比如mstateen0.0就表示mstateen0的第0位。
| CSR | 位 | 控制对象 | 访问被拦截时的影响 |
|---|---|---|---|
| mstateen0.0 | 0 | fflags、frm、fcsr 三个浮点控制CSR | 低特权模式读写这三个CSR触发非法指令异常 |
| mstateen0.1 | 1 | vstart、vxsat、vxrm、vcsr 等向量控制CSR | V扩展实现时,低特权模式访问向量控制CSR被拦截 |
| mstateen1.0 | 0 | Sscofpmf引入的计数器溢出控制CSR | 低特权模式访问计数器溢出相关CSR被拦截 |
在1.0版规范里,mstateen2和mstateen3基本是保留状态,给未来的扩展用。但注意一个现实:不少SoC会在保留位上塞自己的私有控制,所以你在不同厂商手册里看到的“位定义”可能长得不完全一样。最稳妥的做法是:把这张表当作起点,把SoC手册当作最终裁决。
写stateen这类CSR还有一个隐蔽陷阱:它们和普通CSR一样是读-改-写友好的,但千万不要用“先csrw清零再写入整个值”的方式。正确操作是用csrrs或csrrc指令,只把你要改的那一位置1或清0,其他位保持不动。否则你在一颗还没定义完全位的核上,很容易把别人依赖的保留位冲掉。
2.3 “1放行、0拦截”的语义和异常路由
stateen的位语义非常直白:1表示允许低特权模式访问对应状态,0表示拦截。而且这个“拦截”不是静默忽略,是直接抛非法指令异常。
一个需要从第一章就记住的原则是:stateen只管比当前模式更低的特权模式。M-mode自己永远不受mstateen0~3的限制,Debug Mode同样不受影响。这条语义继承自RISC-V最基本的特权等级嵌套关系,后面讲委派链时还会反复用到。
当访问因为stateen位被清0而失败时,异常在被访问方所在的特权级产生。比如U-mode读fcsr被拦,U-mode就会收到非法指令异常;如果这个异常被S-mode委派,S-mode的中断入口会看到;如果没被委派,最终会走到M-mode的入口。这种“先在本地抛异常,再按委派规则上报”的设计,让TEE、hypervisor和各种监控器都能用标准异常路径介入,不需要额外发明一套通知机制。
3. 一条三层委派链:mstateen/sstateen/vsstateen怎么组合生效
3.1 M、S、VS三层各自管谁
仅靠mstateen0~3,S-mode没法给自己的用户态单独设策略。因为mstateen是M-mode的寄存器,S-mode读不到也写不了。于是Ssstateen扩展在Smstateen之上补了第二组CSR:sstateen0~3,给S-mode(在虚拟化场景里也就是HS-mode)控制更低特权模式用。再往下一层,VS-mode还有vsstateen0~3,用来给VU-mode设限制。
这三组CSR形成了非常清晰的逐级委派关系:M-mode管所有人,S/HS-mode管U、VS、VU,VS-mode只管VU。而这个链条有一个重要前提:Ssstateen是建立在Smstateen之上的,也就是说,如果硬件没实现Smstateen,那sstateen/vsstateen访问本身就会触发非法指令异常,讨论后面这些组合规则没有意义。
3.2 五级模式下的组合判定规则
计算“某个访问到底放不放行”时,关键是看发起访问的模式落在哪一级。我把规则整理成一张表,这张表基本可以当作实现状态机时的参考。
| 发起访问的模式 | 必须为1的stateen位 |
|---|---|
| M-mode | 不需要任何stateen位 |
| S-mode / HS-mode | 对应mstateen位 |
| U-mode(无虚拟化) | 对应mstateen位 + 对应sstateen位 |
| VS-mode | 对应mstateen位 + 对应sstateen位 |
| VU-mode | 对应mstateen位 + 对应sstateen位 + 对应vsstateen位 |
用伪代码表达就是这样:
bool access_allowed(int current_mode, int csr_idx, int bit) { if (current_mode == PRV_M) return true; if (!(read_csr(mstateen0 + csr_idx) & (1ULL << bit))) return false; if (current_mode == PRV_S || current_mode == PRV_HS) return true; if (!(read_csr(sstateen0 + csr_idx) & (1ULL << bit))) return false; if (current_mode == PRV_U_NO_VIRT) return true; if (current_mode == PRV_VS) return true; if (!(read_csr(vsstateen0 + csr_idx) & (1ULL << bit))) return false; return (current_mode == PRV_VU); }这里面最关键的性质是“高层清零,下层全灭”。M-mode把自己的mstateen位清成0,那S、U、VS、VU全部访问不了;S/HS-mode把sstateen位清成0,那U、VS、VU全部访问不了。反过来,高层置1只是“放开限制”,并不意味着下层就一定能访问,因为下层自己的那个stateen位还有一票否决权。
3.3 异常路由与虚拟化下的委派路径
如果VS-mode里的访客(VU-mode)因为vsstateen位为0而被拦截,这个非法指令异常首先是在VU-mode内产生的。按照RISC-V的异常委派逻辑,这个异常可以被VS-mode的客户端操作系统处理,也可能被HS-mode的hypervisor截获。对于hypervisor来说,它更关心的是sstateen层:只要HS把sstateen的位置0,客户机无论处于VS还是VU模式,所有相关访问都会直接落到HS手里。
实际写hypervisor时,你会发现这条委派链的价值在于“逐层设防”。M-mode可以设置一个全局最低标准,说“浮点CSR我一律不放行”;HS-mode再在自己的sstateen里细调“对于这个客户机,我可以放行到VS层”;而客户机内部的VS-mode还能继续决定“VU-mode能不能用”。三层各自有各自的策略,互不覆盖,这比把所有判断都挤在M-mode里干净得多。
4. 软件实战:初始化、上下文切换和虚拟机恢复的完整套路
4.1 上电后不要赌复位值
很多初学RISC-V的人有个坏习惯:CSR如果没有专门初始化,就默认是0。但mstateen这类“向后兼容优先”的CSR,复位值并不是清一色0。
规范允许实现方自己定义复位值,但绝大多数核为了让老软件不做任何设置也能跑,会把已实现位的默认值置成1。也就是说,上电后mstateen0.0可能是1,fcsr默认放行;也可能某些私有位复位成0,行为完全不同。最靠谱的做法是启动代码里逐位显式设置,而不是赌硬件默认值。我见过不止一次“在模拟器上没问题,换真核就死锁”的案例,原因就是这个。
初始化时,我建议先把mstateen0~3读出来打印,确认硬件到底支持哪些位,再决定写什么样策略。打印这段同样要用csrrs,不要用csrw,避免破坏保留位的硬件状态。
4.2 每个上下文都得有自己的stateen视图
stateen不是全局政策,它应该和进程、虚拟CPU绑定。原因是:一个进程允许不能读fcsr,另一个进程是浮点计算密集型的,允许访问全套浮点控制CSR,它们的stateen视图天然不同。
上下文切换代码里,最朴素的做法是把mstateen0~3和sstateen0~3都纳入保存恢复范围。下面这段是示意性的汇编宏:
.macro SAVE_STATEEN base csrr t0, mstateen0 sd t0, 0(\base) csrr t0, mstateen1 sd t0, 8(\base) csrr t0, mstateen2 sd t0, 16(\base) csrr t0, mstateen3 sd t0, 24(\base) # sstateen 同理,按需保存 .endm真正要命的是恢复顺序。切换到一个新任务时,应该先恢复stateen,再恢复浮点/向量上下文。因为如果先恢复了fcsr、vstart这些CSR,然后才恢复stateen,那这个窗口期里新任务的CSR访问权限还是旧任务的。如果新任务本来不该碰fcsr,这窗口期就等同于一个权限泄露。
反过来,切换离开一个任务时,可以先清stateen再保存浮点上下文,防止保存过程中被什么异常打断,把不属于当前任务的状态混进来。这个“先清再存、先恢复再开”的顺序,是我在实机调试里踩过坑后总结出来的,比任何理论都值钱。
4.3 虚拟CPU里stateen字段的管理
做hypervisor时,每颗虚拟CPU上下文的字段里必然要加stateen的影子。我习惯在vCPU结构体里放一个stateen_cache数组,分别缓存mstateen0~3、sstateen0~3、vsstateen0~3。进入客户机前一次性写回,退出客户机时立即全部保存。
为什么要缓存而不是每次读CSR?因为有些触发场景里,hypervisor需要知道“客户机上一次允许访问什么”来做决策;每次都去读CSR当然也行,但stateen的读操作在部分实现里可能伴随同步开销,能缓存就缓存。
另一个容易被忽略的点是异常重入场景。客户机访问fcsr被拦,异常跑到hypervisor,hypervisor在模拟fcsr之前,很可能需要临时改动自己的stateen以读取物理fcsr。这时候一定要记住“用完还原”,否则下一次进入客户机时,stateen带着临时开放的位进去,就相当于给客户机多开了权限。我见过有人把guest的fcsr状态修好了,却把mstateen0.0留在1整整一个调度周期,U-mode趁机读走整个FP环境。
5. 与其他扩展的互动和五个高频坑
5.1 与Sdtrig、Sscofpmf等扩展的关系
stateen并不是一个孤岛。它在设计上就是给其他扩展“挂载权限位”用的。比如Sscofpmf引入的计数器溢出CSR,就会占用mstateen1.0这一类位。调试相关的Sdtrig扩展部分可访问状态也可能和stateen位产生交集。
这里有一个原则需要理解:stateen负责的是“跨特权级的访问策略”,而扩展本身负责“自己状态存在哪里”。扩展没有独立权限控制时,stateen就补上。扩展本来就有权限位时,stateen通常不会重复设防。所以排查问题时,先问一句:“这个CSR的默认访问规则是什么?stateen是否是这个规则的一部分?”而不是看到非法指令就怀疑stateen,很可能是那个扩展自己就有权限限制。
5.2 陷阱循环:trap handler本身撞上被禁的CSR
最容易让系统死锁的坑是“自己禁自己”。假设你在S-mode的trap handler里开了浮点运算,又事先把sstateen0.0清成0。现在U-mode因为读fcsr触发异常,trap进入S-mode handler,handler第一件事就是清fcsr准备记录现场,结果再次触发异常……单步调试时你只会看到PC在切换异常向量和handler入口之间疯狂跳变。
解决办法只有两个:要么trap handler入口处先临时开放对应stateen位,要么从一开始就禁止handler使用被gate的CSR。前者要小心重入,后者看起来省事但长期维护很痛苦。我个人的习惯是:在最低层的同步异常入口,把所有stateen临时置成全1,处理完现场立即还原,因为那个入口本身在M-mode,不受stateen限制。
5.3 误把“控制CSR”当成“指令状态”
stateen0.1管的是vstart、vxsat这些向量控制CSR,不是向量指令本身。向量指令能不能执行,由mstatus.VS决定。同样,stateen0.0也不替代mstatus.FS的作用:FS=Off时浮点指令本身就是非法的,stateen只管fcsr这一类控制路径。
这个区分最直观的后果是:你不能只靠把mstateen0.1清0来阻止别人执行向量计算,你得同时把VS置成Off。如果你看到“我的向量指令已经触发非法了,为什么还要查vstart?”多半是把两层搞混了。
5.4 访问vsstateen时的“模式错觉”
RISC-V的CSR访问和特权模式绑定得很死。VS-mode能读写的vsstateen,在VU-mode下访问就是非法的;HS-mode能读写的sstateen,在VS-mode下访问也不合法。不要认为“sstateen包含S,所以VS也能读写”——VS-mode在特权架构里属于S级以下,它的权限是继承来的,不是等同来的。
如果客户机OS想在VS-mode里给VU-mode立法,它访问的是vsstateen,不是sstateen。这是整个三层委派链里最容易写错的地方,我在代码评审里至少见过三次把vsstateen和sstateen搞反的提交。
5.5 硬件实现子集不同,软件不能用一套固定值
有些核可能只实现mstateen0,没有实现mstateen1~3;有些核实现了全部四个mstateen,但sstateen只有前两个。这种“实现子集差异”让固定配置几乎不可能跨核通用。
我建议所有stateen驱动代码都做成“运行时探测式”:先逐个读CSR,记录哪些位可写、哪些位恒0,再基于探测结果配置。不要试图在设备树里写死。这样做虽然多一点启动代码,但换来的是同一套镜像在不同核上都能稳定启动。
6. 在自己手上验证stateen:最小实验与排查思路
6.1 搭建最小验证环境
验证stateen不需要完整操作系统,一个最小的bare-metal环境就够。你需要一个支持Smstateen的实现,无论是某个开源模拟器、FPGA原型,还是开发板上的真实核都可以。环境里至少要有M-mode入口和一个能跳进去的U-mode测试程序。
我的做法是:M-mode启动后,先把mstateen0打印出来,确认硬件支持哪些位。然后建立一个极简trap handler,用一段固定的测试程序在U-mode下执行“csrr a0, fcsr”,再根据异常行为判断当前stateen的状态。
6.2 三组实验直接验证委派链
第一组实验测M-mode闸门。把mstateen0.0清0,从U-mode读fcsr,预期触发非法指令异常。如果M-mode没有委派这个异常,你会看到M-mode trap handler接管;如果委派给S-mode,S-mode的异常入口会看到。两种都属于预期行为,只要确认异常原因码是非法指令即可。
第二组实验测sstateen层。把mstateen0.0置1,然后设sstateen0.0为0。此时U-mode读fcsr仍然应该被拦,因为sstateen这一层的位为0。这个实验能清楚证明“mstateen置1只是开放限制,不是最终放行”。
第三组实验测逐级放行。把mstateen0.0和sstateen0.0都置1,U-mode读fcsr应当成功。如果硬件实现了H扩展和vsstateen,你还可以再在VS/VU模式下重复这三组实验,观察vsstateen的最终否决权是否生效。
每次实验结束,我强烈建议把当前模式、csr值、异常原因码一起打到串口上,格式类似“mode=3 mstateen0=... sstateen0=... mcause=2”。不要只看“有没有进trap”,要看“进了哪个trap、最终停在哪一层”。
6.3 遇到“预期拦却没拦”时怎么排查
最常见的原因是位没写进去。WARL属性下,如果硬件不支持该位,你写0和写1结果一样,读回来永远是1或0。这时先读回mstateen0确认。
第二个原因是访问路径不对。比如测试程序本身在M-mode跑,U-mode根本没发生访问,那stateen当然不会触发。要确认当前特权级,最简单的方法是在代码里插入一个读misa或者读当前mode的辅助函数,别靠猜。
第三个原因是异常被更上层的代码悄无声息处理了。比如一个长的异常处理链里,某个中间层把非法指令异常吞掉,直接返回到了下一行指令。这种情况最容易让人误判“stateen没生效”。排查时在每层异常入口都打印一次返回地址,很快就能定位是谁消化了异常。
我自己在反复做这些实验后的体会是:stateen这套机制本身不难,难的是把“哪一层管的”“谁最终看到了异常”“上下文切换顺序对不对”这三件事同时放对。如果你正在调一个虚拟机或安全沙箱,我建议先把它当作独立的调试对象来验证,而不是等到整机跑起来再排查。先把fcsr、vstart这类最常见的控制CSR的开关行为摸清楚,再去碰更复杂的组合,后面踩坑的概率会低很多。