ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

RISC-V RVA23平台Bao Hypervisor移植实战:从ARM到RISC-V的静态分区虚拟化

RISC-V RVA23平台Bao Hypervisor移植实战:从ARM到RISC-V的静态分区虚拟化 1. 为什么要把Bao搬到RVA23的板子上第一次拿到 Banana Pi BPI-SM10 这块板子的时候我盯着它上面那颗支持 RVA23 规范的 RISC-V 芯片看了很久。市面上 RISC-V 开发板不少但真正把 RVA23 这套较新规范落地的硬件并不多而 Bao 这个轻量级虚拟机监控器Hypervisor此前主要活跃在 ARM 生态里把它移植到 RVA23 上本质上是一次跨架构 跨规范版本的双重挑战。先说清楚 Bao 是什么。Bao 是一个静态分区化的 Hypervisor代码量极小主打实时性和隔离性。它不像 KVM 那样追求通用虚拟化的完整功能而是把物理核、内存区域、外设这些资源在编译期就切分好分给不同的 guest客户机。这种设计在工业控制、汽车电子、航空航天这类对确定性要求极高的场景里非常吃香——因为静态分区意味着运行时几乎没有动态调度开销时间抖动可控。那为什么偏偏选 RVA23RVA23 是 RISC-V 应用处理器配置文件的一个较新版本相比早期的 RVA20它强制要求了一批此前可选的扩展比如向量扩展V、Hypervisor 扩展H的完整实现、以及更严格的原子操作和内存模型要求。对 Bao 来说H 扩展是命根子——没有它Hypervisor 根本没法在 S-mode 和 VS-mode 之间做上下文切换。而 RVA23 把 H 扩展变成了强制项这反而降低了移植时猜硬件支持什么的成本。Banana Pi BPI-SM10 这块板子的具体参数我这里不逐条罗列核心是它搭载的芯片实现了 RVA23 规范带 H 扩展有多核内存和外设资源足够跑起一个 Hypervisor 加两三个 guest 的演示环境。我拿到手的第一反应是这不就是给 Bao 量身定做的实验平台吗移植这件事的价值在哪往小了说是让 Bao 多支持一个架构、多覆盖一批硬件往大了说RVA23 是未来几年 RISC-V 应用处理器的主流规范谁先把 Hypervisor 层的东西跑通谁就能在后续的实时系统、混合关键性系统Mixed-Criticality System落地上占先机。我这次移植的目标很明确让 Bao 能在 BPI-SM10 上启动能创建至少两个 guest 分区能完成基本的上下文切换和中断路由。适合谁看这篇内容如果你正在做 RISC-V 底层开发、Hypervisor 移植、或者实时操作系统与虚拟化结合的课题这篇会有直接参考价值。如果你只是刚接触 RISC-V建议先把特权架构手册里 S-mode、H 扩展那几章啃一遍再往下看不然很多细节会卡住。2. 动手前必须搞清楚的 RVA23 与 H 扩展细节2.1 RVA23 到底给 Hypervisor 带来了什么硬性约束很多人以为 RVA23 只是多加了几个扩展实际上它对 Hypervisor 开发者的影响是结构性的。我梳理了一下移植过程中真正卡住我的几个强制项第一H 扩展的完整实现。RVA23 要求hstatus、hedeleg、hideleg、hvip、hgatp这些 CSR 必须存在且行为符合规范。早期一些 RISC-V 芯片虽然标称支持 H 扩展但hgatp的 G-stage 地址翻译只支持 Sv39不支持 Sv48 或 Sv57。BPI-SM10 这颗芯片我实测下来 G-stage 支持到 Sv48这意味着 guest 物理地址空间可以给得比较宽裕不用像在 Sv39 平台上那样精打细算。第二内存模型的强化。RVA23 对 RVWMORISC-V Weak Memory Ordering的约束更明确特别是涉及fence指令和原子操作的部分。Bao 在做 vCPU 上下文切换时需要保证内存访问的顺序性如果芯片的内存模型实现有偏差切换过程中就可能出现脏数据。我在调试初期遇到过一次 guest 启动后随机崩溃的问题最后定位到就是fence指令的粒度没配对。第三向量扩展的强制要求。这一点对 Bao 本身影响不大因为 Bao 核心代码里几乎不用向量指令但它影响 guest。如果你打算在 guest 里跑带向量优化的负载那 RVA23 的 V 扩展就是白送的福利。不过要注意Bao 在保存/恢复 vCPU 上下文时如果 guest 用了向量寄存器你得确保vstart、vl、vtype这些 CSR 也被正确保存否则 guest 切出去再切回来向量状态就乱了。2.2 Bao 的静态分区模型和 RVA23 的适配点Bao 的分区模型是编译期决定一切。每个 guest 分到哪些物理核、哪段内存、哪些中断全在配置文件里写死。这种设计在 RVA23 上有个天然优势RVA23 的 H 扩展允许你把中断直接委托给 VS-mode也就是 guest 自己处理Hypervisor 不用每次都陷进去。Bao 正好利用这一点把大部分外设中断通过hideleg委托下去自己只保留定时器和 IPI 这类核心中断。但这里有个坑。RVA23 规范里对hideleg的可委托位有明确限制不是所有中断都能往下扔。比如机器级定时器中断MTI就不能直接委托给 VS-mode必须经过 Hypervisor 转发。我在配置中断路由时一开始想把所有中断都委托下去图省事结果 guest 里的定时器完全不工作查了半天才发现是委托位设错了。另一个适配点是核间中断IPI。Bao 用 IPI 来做 vCPU 之间的同步RVA23 平台上 IPI 的发送和接收机制和 ARM 的 SGI 不太一样需要走 CLINT 或 ACLINT。BPI-SM10 用的是 ACLINT寄存器布局和 CLINT 有差异移植时这部分代码基本重写了。2.3 工具链和编译环境的准备移植之前工具链必须选对。我推荐用支持 RVA23 的 GCC 或 LLVM 交叉编译工具链具体版本上GCC 13 以上、LLVM 17 以上对 RVA23 的支持比较完整。如果你用的工具链太老编译时可能不认-marchrv64gcv这类带向量扩展的选项或者生成的代码里缺少必要的fence指令。编译选项上我建议至少开-marchrv64imafdch如果不需要向量或-marchrv64gcvh需要向量-mabilp64d。注意h扩展在-march里的写法有些工具链版本要求写成h有些要求hypervisor这个得看你实际用的工具链文档。链接脚本link.ld是另一个重点。Bao 的链接脚本需要把 Hypervisor 自身的代码、数据和各个 guest 的镜像区域严格分开。在 RVA23 平台上因为 G-stage 翻译的存在guest 看到的物理地址和真实物理地址之间隔了一层链接脚本里 guest 区域的地址要按 guest 物理地址来写不能直接写真实物理地址。我一开始没注意这点guest 镜像加载后跑飞排查了两天才发现是链接地址和实际映射对不上。3. 从零开始的移植实操链路3.1 源码获取与目录结构梳理Bao 的源码托管在公开仓库里直接 clone 下来就行。拿到源码后先别急着改花半小时把目录结构摸清楚。核心目录大概是这样src/下面放的是 Hypervisor 主体代码包括 vCPU 管理、内存管理、中断处理。src/arch/是按架构分的ARM 的代码在这里你要做的是新建一个riscv/子目录把 RISC-V 相关的实现放进去。configs/里是各个平台的配置文件你需要为 BPI-SM10 新建一份。platforms/下面是平台相关的驱动和初始化代码。我的做法是先复制一份 ARM 的架构目录作为骨架然后把里面 ARM 特有的东西比如 GIC、SMC 调用全部替换成 RISC-V 的对应实现。这个过程不要想着一次改完先让代码能编译通过再逐步填功能。3.2 启动流程的改写从 ARM 的 EL2 到 RISC-V 的 HS-modeARM 上 Bao 跑在 EL2RISC-V 上对应的是 HS-modeHypervisor Supervisor mode。启动流程的改写是整个移植里最核心的部分。RISC-V 上电后通常先跑在 M-mode 的固件比如 OpenSBI里然后由固件把控制权交给 HS-mode 的 Bao。所以 Bao 的入口点实际上是 OpenSBI 跳转过来的。你需要确认几件事第一OpenSBI 的版本要支持 H 扩展。老版本 OpenSBI 可能没有正确初始化hstatus、hgatp这些 CSR导致 Bao 一启动就异常。我用的 OpenSBI 是较新的版本编译时开了PLATFORM_RISCV_XLEN64和 H 扩展相关选项。第二Bao 的入口汇编需要重写。ARM 上入口是_start做的事情包括设置栈指针、保存启动参数、跳到 C 入口。RISC-V 上类似但寄存器约定不同a0通常放 hartida1放设备树地址。Bao 需要从a1拿到设备树解析出内存布局和外设信息。第三页表的建立。RISC-V 的页表格式和 ARM 完全不同Sv39/Sv48 的页表项结构、多级页表的遍历方式都得重写。Bao 需要建立两套页表一套是 HS-mode 自己的G-stage 的宿主页表一套是给 guest 用的 G-stage 页表。我在这部分花了最多时间因为 G-stage 页表的hgatp配置一旦出错guest 根本跑不起来。3.3 中断控制器的对接从 GIC 到 ACLINT/PLICARM 平台上 Bao 用 GIC 做中断控制RISC-V 上对应的是 PLIC平台级中断控制器加 ACLINT核心本地中断控制器。这两者的编程模型差异很大。PLIC 负责外部中断每个中断源有个优先级和使能位Hart 通过plic_claim和plic_complete来认领和完成中断。Bao 需要把 PLIC 的中断路由到对应的 vCPU 上。我的做法是在设备树里解析出 PLIC 的基地址和中断源映射然后在 Bao 里维护一张中断源到 vCPU 的路由表。ACLINT 负责定时器和 IPI。定时器部分Bao 需要给每个 vCPU 维护一个虚拟定时器当 guest 设置定时器时Bao 把它转换成对物理定时器的编程并在定时器到期时注入虚拟定时器中断。IPI 部分Bao 用aclint_msend寄存器发送核间中断用来触发 vCPU 调度和 TLB 刷新。这里有个实测经验ACLINT 的mtime和mtimecmp是内存映射的访问延迟比 CSR 高。如果你的 guest 对定时器精度要求高建议在 Bao 里做一层缓存减少对mtime的频繁读取。3.4 内存管理与 G-stage 翻译的落地G-stage 翻译是 RISC-V Hypervisor 的核心机制。简单说guest 里的虚拟地址先经过 VS-stage 翻译成 guest 物理地址再经过 G-stage 翻译成真实物理地址。Bao 负责建立和维护 G-stage 页表。移植时我先把 G-stage 页表的创建封装成几个函数gstage_map、gstage_unmap、gstage_init。gstage_init在 Bao 启动时调用为每个 guest 建立初始的地址映射。映射的粒度我选的是 2MB 大页因为 BPI-SM10 的内存不算特别大用大页可以减少页表层级降低 TLB miss。有个细节要注意RVA23 要求 G-stage 页表的 PTE 里U位和X位的语义和 VS-stage 不同。G-stage 的 PTE 里U位实际上表示是否允许用户态访问而X位表示是否可执行。我在初期配置时把这两个位搞反了导致 guest 里的代码段不可执行一跑就 instruction fault。另外TLB 刷新在 G-stage 下更复杂。当 Bao 修改了 G-stage 页表需要用hfence.gvma指令刷新相关 TLB 项。这个指令的粒度可以细到单个虚拟地址也可以粗到整个地址空间。我建议在性能敏感的场景下用细粒度刷新避免全量刷新带来的性能抖动。4. 调试过程中踩过的坑和排查思路4.1 guest 启动后卡死从串口日志倒推问题移植完成后第一次启动 guest串口只输出了一行 Bao: starting guest 0然后就没了。这种卡死最让人头疼因为没有任何错误信息。我的排查思路是分三步走第一步确认 Bao 自身是否还活着。我在 Bao 的定时器中断处理里加了一个心跳打印发现心跳还在说明 Bao 没死是 guest 卡住了。第二步确认 guest 卡在哪。我在 guest 的入口点加了一条ecall指令让 guest 主动陷入 Bao然后 Bao 打印出 guest 的 PC 和寄存器状态。发现 guest 卡在页表初始化之后的第一个内存访问上。第三步检查 G-stage 映射。用 Bao 的调试接口 dump 出 G-stage 页表发现 guest 的代码段被映射到了错误的物理地址。根因是链接脚本里 guest 区域的地址写的是真实物理地址但 G-stage 翻译时又做了一次偏移导致地址对不上。把链接脚本改成 guest 物理地址后问题解决。这个坑的教训是RISC-V 的 G-stage 翻译下guest 看到的所有地址都是 guest 物理地址链接脚本、镜像加载地址、页表映射地址三者必须统一到 guest 物理地址空间里。4.2 中断丢失PLIC 优先级和阈值配置的坑guest 跑起来之后发现串口输入没反应。查下来是 UART 中断没被正确路由到 guest。PLIC 有个优先级和阈值的概念。每个中断源有优先级每个 Hart 有阈值只有优先级高于阈值的中断才会被递给 Hart。我一开始把阈值设成了 0以为这样所有中断都能进来但实际上 PLIC 规范里阈值 0 表示屏蔽所有中断而不是允许所有中断。把阈值改成 1 之后中断正常了。另一个坑是中断的 claim/complete 流程。Bao 在把中断注入 guest 之前需要先claim中断处理完后再complete。如果忘了completePLIC 会认为这个中断还在处理中后续同源中断不会再递上来。我在调试时因为漏了一个complete导致 guest 的串口只能收一个字符就卡住。4.3 多核启动时的竞态问题BPI-SM10 是多核芯片Bao 需要管理多个物理核。多核启动时我遇到了一个典型的竞态两个核同时去初始化共享的 PLIC 寄存器导致配置被覆盖。解决办法是引入一个简单的自旋锁在访问共享外设寄存器前先拿锁。RISC-V 的原子指令amoswap.w或lr/sc对可以用来实现自旋锁。我用的是amoswap.w因为它更简单不需要处理lr/sc失败重试的逻辑。还有一个多核相关的问题是 TLB 一致性。当一个核修改了页表其他核的 TLB 里可能还有旧映射。RISC-V 用hfence.gvma配合 IPI 来解决这个问题修改页表的核向所有相关核发送 IPI收到 IPI 的核执行hfence刷新本地 TLB。我在实现这个机制时一开始只刷新了当前核的 TLB导致其他核上的 guest 访问到了旧映射出现了随机崩溃。4.4 性能调优减少陷入次数功能跑通之后我开始关注性能。Bao 的性能瓶颈主要在 guest 陷入 Hypervisor 的次数上。每次 guest 执行ecall、访问未映射的 CSR、或者触发中断都会陷入 Bao这个开销不小。优化手段有几个一是把能委托的中断尽量委托下去。前面提到hideleg可以委托大部分外部中断这样 guest 处理中断时不用经过 Bao。二是减少 CSR 访问的陷入。RVA23 允许把一些 CSR 访问直接委托给 VS-mode比如sstatus、sie、stvec这些。Bao 通过hedeleg配置委托位让 guest 自己读写这些 CSR。三是优化上下文切换路径。vCPU 切换时Bao 需要保存和恢复一堆寄存器。我把不常用的寄存器比如浮点寄存器改成惰性保存只有在 guest 真正用到时才保存减少了切换开销。实测下来经过这几轮优化guest 的上下文切换延迟从最初的几微秒降到了亚微秒级别对于实时性要求高的场景已经够用了。5. 移植完成后的验证与可扩展方向5.1 功能验证两个 guest 并行运行的实测移植完成后我配置了两个 guest一个跑裸机程序一个跑轻量级 RTOS。两个 guest 分在不同的物理核上各自有独立的内存区域和串口。验证步骤是这样的Bao 启动打印版本信息和配置摘要。Bao 初始化 PLIC、ACLINT、G-stage 页表。Bao 加载两个 guest 的镜像到各自的内存区域。Bao 启动两个 vCPU分别跳到两个 guest 的入口。两个 guest 各自输出启动日志通过各自的串口打印。在 guest 0 里触发一个定时器观察 guest 1 是否受影响。实测结果是两个 guest 互不干扰guest 0 的定时器抖动在可接受范围内guest 1 的串口输出稳定。这说明静态分区的基本隔离已经生效。5.2 已知限制和后续可以做的事目前这个移植还有几个限制。一是只支持 Sv48 的 G-stage 翻译Sv39 和 Sv57 没测。二是中断委托的配置还比较保守有些可以委托的中断还在 Bao 里处理。三是没有实现 guest 之间的通信机制两个 guest 目前是完全隔离的。后续可以扩展的方向一是加入 guest 间通信比如共享内存或消息队列让分区之间能协作二是支持动态内存分配让 guest 能在运行时申请更多内存三是把 Bao 和 FreeRTOS 结合在 guest 里跑 FreeRTOS验证混合关键性场景。5.3 给后来者的几条实操建议如果你也要做类似的移植我有几条建议第一先把 OpenSBI 和工具链搞定这两样不稳后面全是坑。第二G-stage 页表是核心花时间把它吃透比什么都值。第三调试时多用ecall主动陷入比盲猜高效得多。第四多核场景下锁和 TLB 一致性是两个必须处理好的点别偷懒。最后分享一个我在调试时常用的小技巧在 Bao 里加一个调试模式通过串口命令可以 dump 出当前所有 vCPU 的寄存器状态、G-stage 页表、中断路由表。这个功能在排查问题时省了我大量时间建议你在移植早期就加上。
返回列表