前三篇讲的都是 RVV 的 ISA 层面:寄存器怎么分组、指令怎么寻址内存。这一篇转到系统软件视角——一个多任务的 Linux 系统里,多个进程都可能用到向量寄存器,内核要怎么在进程切换时保证每个进程看到的向量寄存器状态是正确的、同时又不因为保存恢复这些额外状态拖慢上下文切换本身。一、为什么向量上下文切换是个问题
向量寄存器组本身就不小——VLEN=256 时,32 个向量寄存器加起来就是 1KB,VLEN 更大时开销更高。如果每次进程切换都无条件保存、恢复这一整块状态,对完全不使用向量指令的进程(系统里的大多数进程)来说是纯粹的浪费:切换开销白白增加,却没有任何收益。这和当年 FPU(浮点寄存器)上下文切换要解决的问题是同一类,Linux 内核处理 RVV 用的思路也和处理 FPU 一脉相承。
二、mstatus.VS:硬件提供的"脏位"式状态字段
RISC-V 特权架构在 mstatus[10:9] 里定义了一个 VS(Vector Status)字段,并镜像到 sstatus[10:9] 供内核态直接读写——这和浮点用的 FS 字段是同一套结构,VS 有四种状态:
- Off:向量单元被禁用,任何向量指令、乃至访问 vstart/vtype/vl 等向量 CSR,都会触发非法指令异常
- Initial:向量单元可用,但寄存器内容还是初始状态(未被写过)
- Clean:向量单元可用,寄存器内容和内核保存的副本一致,没有被修改过
- Dirty:向量单元可用,寄存器内容已经被用户态代码修改过,和内核保存的副本不一致
硬件会在执行任何向量指令、且当前状态是 Clean 或 Initial 时,自动把 VS 置为 Dirty。VS 变成 Dirty 时还会连带把 mstatus.SD 位置 1——这是操作系统判断"当前上下文是否存在需要保存的扩展状态"时常用的联动信号,和 x86 XSAVE 机制里的思路是同构的。这个机制的意义在于:内核不需要在每条向量指令执行时插桩去追踪"这个进程有没有用过向量寄存器",只需要在切换进程时读一下 VS 字段的状态就知道要不要保存。
三、惰性保存恢复:只在真正需要时才动
基于 VS 位,内核可以实现"惰性"(lazy)保存恢复策略,大致逻辑是:
- 进程切换发生时,先检查即将被换出的进程的 VS 状态是否为 Dirty
- 如果是 Off 或者 Clean(说明这个进程根本没碰过向量寄存器,或者内容和已保存的副本一致),直接跳过保存,省掉一整块内存拷贝
- 只有 Dirty 状态才真正执行 vector store 指令,把寄存器内容写进这个进程的 thread_struct 里对应的向量状态缓冲区
- 切换到新进程时同理:如果新进程之前从未用过向量指令,可以先把硬件 VS 设为 Off,等它真正执行第一条向量指令触发异常时,再按需恢复,进一步把恢复动作推迟到"确实需要"的那一刻
这种"按需触发、惰性恢复"的设计,让系统里大量不使用向量指令的普通进程完全不受向量上下文切换开销的影响,只有真正使用 RVV 的进程(比如跑向量化编解码、科学计算的进程)才会承担这部分成本。在 Linux 内核实现里,这套判断逻辑对应的是 arch/riscv/kernel/vector.c 里的 riscv_v_vstate_save() / riscv_v_vstate_restore(),两者都是先检查 pt_regs 里保存的状态字段是不是 Dirty(或非 Off)再决定要不要真正搬数据;而"首次使用向量指令触发异常再恢复"这一步,对应的是 riscv_v_first_use_handler(),感兴趣的话可以直接在内核源码里对着这几个函数验证上面的逻辑。
四、内核态使用向量寄存器:kernel_vector_begin/end
上面说的是用户态进程之间切换的场景,但还有一种情况:内核自己也想用向量指令加速某些操作,比如 RAID6 校验计算、部分加密算法(AES/SHA)的向量化实现。这时候会遇到一个新问题——内核态代码借用向量寄存器,不能破坏当前用户态进程本来存在寄存器里的数据。
Linux 提供了一对接口来处理这个场景,通常叫 kernel_vector_begin() 和 kernel_vector_end()(实现在 arch/riscv/kernel/kernel_mode_vector.c),内核代码要用向量指令前后必须成对调用:
- kernel_vector_begin():关闭抢占(防止在使用向量寄存器期间被切换出去导致状态错乱),如果当前用户态进程的向量状态是 Dirty,先把它保存下来,然后把 VS 置为可用状态,让内核代码可以安全地使用向量寄存器
- kernel_vector_end():内核代码用完向量寄存器之后,恢复之前保存的用户态向量状态(如果保存过),重新开启抢占,把控制权和寄存器状态干净地还给用户态进程
这套机制保证了内核态"借用"向量寄存器的行为对用户态进程完全透明——被中断的进程不会感知到自己的向量寄存器内容曾经被内核挪用过。
五、状态缓冲区大小是运行时决定的
和 x86 的 XSAVE 区域大小基本固定不同,RVV 的向量状态缓冲区大小取决于运行时才能确定的 VLEN(可以通过 vlenb CSR 读出每个向量寄存器的字节数)。这意味着内核分配 thread_struct 里向量状态的存储空间时,不能像浮点寄存器那样用一个编译期确定的固定大小结构体,而要在系统启动阶段探测硬件的 VLEN,动态确定每个进程需要分配多大的缓冲区。这也是移植 RVV 内核支持时,和已有 FPU 上下文切换代码路径相比,一个明显不同的复杂点。
同样的道理也体现在信号处理路径上——进程收到信号时,内核需要把包括向量寄存器在内的完整上下文写进信号帧(signal frame)供用户态的信号处理函数访问,这部分的大小同样依赖运行时探测到的 VLEN,不是一个编译期常量。
值得一提的是,进程切换并不是唯一一个和向量寄存器状态相关的边界——系统调用是另一个。RISC-V V 扩展规范里明确写了,执行系统调用会让全部 32 个向量寄存器以及 vl/vtype/vstart 变成 unspecified(未定义),也就是说内核在 syscall 这条路径上理论上完全可以不保存、不恢复向量寄存器,把"保护现场"的责任交给用户态自己——这和进程切换(context switch)要求内核主动保证状态一致,是两个粒度、两种责任划分完全不同的边界,以后有机会可以专门展开讲。
六、小结
这一篇讲了 Linux 内核处理 RVV 上下文切换的核心思路:借助 mstatus.VS(镜像到 sstatus.VS)字段实现类似"脏位"的惰性保存恢复,避免给不使用向量指令的进程增加无谓开销;通过 kernel_vector_begin/end 这对接口,让内核代码安全地临时借用向量寄存器而不破坏用户态状态;向量状态缓冲区的大小因为 VLEN 是运行时变量,需要动态探测分配,这一点和已经成熟的 FPU 上下文切换路径有本质区别。
下一篇是系列最后一篇,回到工具层面——QEMU 是怎么模拟 RVV 的,`-cpu rv64,v=true,vlen=256` 这类参数具体控制什么,以及实际调试向量相关问题时可以用到的方法。