最近拿到一块 Banana Pi BPI-SM10顺手把安全关键型虚拟机监控器 Bao 从 qemu 虚拟平台搬到了这颗 RVA23 profile 的 RISC-V SoC 上。Bao 不是 KVM 那种通用型 hypervisor它是面向嵌入式混合关键性场景的静态分区 Type-1 hypervisor所有 VM 的 CPU、内存、中断资源都在编译期写死运行时不存在动态资源管理器那一大坨逻辑所以镜像小、可审计、延迟可预测。这块板子的最大亮点是 SoC 直接对齐 RVA23 规范RISC-V 官方把 H 扩展虚拟化和 V 扩展向量列成了必选项等于硬件平台从第一天起就是按 hypervisor 需要的样子设计的。这篇文章把整个移植过程的思路、关键改动、调试路径和踩过的坑完整记录下来适合正在做 RISC-V 系统软件、想接触嵌入式虚拟化或者单纯对 RVA23 平台适配感兴趣的朋友。1. 项目背景与整体思路拆解1.1 为什么选 Bao 做虚拟化验证RISC-V 上的虚拟化方案不少KVM 在 QEMU 上能跑Xen 和 seL4 也有 RISC-V 移植但真正适合嵌入式安全关键场景的并不多。Bao 的设计哲学跟嵌入式领域的需求几乎一一对应静态配置、无后台服务、无动态内存分配、无调度器内部客栈。它把一个 VM 能看到的资源完全限制在编译期确定的物理分区里CPU 通过 vCPU 与 pCPU 绑定来实现空间隔离时间隔离则靠虚拟定时器控制Guest 想越界访问别的分区内存会在 stage2 地址转换那层直接被硬件拦住。我选择 Bao 还有另一个原因它的 RISC-V 后端虽然年轻但代码量小到可以一口气读完。相比 KVM 动辄上万行的体系结构代码Bao 的 RISC-V 平台层加虚拟化核心逻辑大概几千行对于想深入理解 RVA23 虚拟化机制的人来说是一个极好的学习样本。更重要的是Bao 官方仓库已经具备 RISC-V 64 的基础框架qemu-riscv64 virt 平台能跑通这意味着移植到一块真实 RVA23 板卡时我不需要从零写 hypervisor只需要把平台抽象层补齐把新板卡的启动流程、中断控制器、内存布局接进来。1.2 RVA23 profile 到底改了什么RVA23 是 RISC-V 国际基金会发布的应用处理器配置文件profile。对于系统软件开发者来说profile 的意义比指令集扩展本身更大以前不同的 RISC-V 核可以各自实现不同的扩展集合Linux、hypervisor 这类软件做平台适配时得检测一大堆扩展现在符合 RVA23 的芯片把一套足够大且完整的扩展集固定下来软件可以假定某些能力一定存在。对虚拟化场景影响最直接的几个变化是H 扩展成为必选vector 1.0 的 V 扩展成为必选中断控制器全面转向 AIA 架构APLIC 加上 per-hart 的 IMSIC替代老的 PLIC同时原来的 CLINT 被 ACLINT 体系接替。这些变化合在一起意味着一个设备可以有多个虚拟 CPU、多个 guest而外部中断可以被硬件直接投递到指定 vCPU 的中断文件里hypervisor 不需要每次替 guest 处理中断重定向这在硬实时场景里是决定性的。另外一个容易被忽略的点是RVA23 要求支持 Sv39部分实现还支持 Sv48/Sv57。Bao 的 stage2 页表实现需要正确适配不同的虚拟地址空间模式移植时要根据这颗 SoC 实际支持的 satp 模式来配置 hgatp这是后面我会重点展开的部分。1.3 BPI-SM10 的软硬件底子Banana Pi BPI-SM10 这块板子的定位就是给 RISC-V 系统软件做开发验证板载的 SoC 走的是 RVA23 profile自带多核 64 位处理器最高频率在桌面级嵌入式设备里算相当能打的水平支持 H 扩展并且中断架构已经是 APLIC IMSIC 的组合。板子上有标准 UART、若干 PCIe/USB 接口、千兆以太网还有足够的 DDR 容量来跑多个虚拟机拿来当 Bao 的第一个 RVA23 验证平台非常合适。系统软件栈方面板级固件基本是 OpenSBI U-Boot 的经典组合。这意味着进入 hypervisor 之前M 模式固件已经完成了 DDR 初始化、时钟、串口等基础设置Bao 可以直接在 HShypervisor模式接管资源不用自己跟硬件寄存器死磕底层初始化。这个底子对移植非常友好早期调试时可以把 OpenSBI 当作一个可信启动基石遇到问题至少能确定“板子启动到了固件”这一层是正常的。2. 移植前准备工具链、源码结构与选型2.1 交叉编译环境搭建Bao 的构建系统对工具链的要求不算苛刻只要一个 riscv64 交叉编译 GCC 就能编出裸机镜像。我这次用的是 riscv64-unknown-elf-gcc因为 Bao 本身不依赖 Linux 的 libc也不需要链接动态库裸机工具链最干净。如果你习惯了 riscv64-linux-gnu-gcc 也没问题但要注意启动代码里最好不要带任何与 Linux ABI 绑定的初始化逻辑Bao 是自包含的。交叉编译环境的安装很简单发行版仓库里包名可能略有差异Debian/Ubuntu 上一般是gcc-riscv64-unknown-elf或者用 RISC-V 官方工具链构建脚本自己编一份。建议先用一个最小 C 程序验证工具链可用再进源码目录敲make。Bao 构建时会根据config.mk选择平台所以第一步是把config.mk里的平台名替换成我们新建的 BSP 名字。2.2 Bao 的 RISC-V BSP 挂载机制Bao 的源码结构不算复杂最核心的是扁平化的若干目录其中平台相关代码在src/platform/riscv64下面。官方已经带了 qemu 等参考平台的实现新板卡的移植思路其实就是把参考实现复制一份然后改成新平台的地址、中断和启动逻辑。Bao 的配置方式是典型的“编译期绑定”平台描述符、VM 描述符、内存分区、中断映射全部写在 C 结构体里由构建系统编译进镜像。整个 hypervisor 运行时不读设备树也不扫描总线所有信息早已静态固化。这么做的好处是既能保证确定性和安全性也简化了平台抽象层的接口设计我们只需要实现平台初始化、CPU 启动、中断控制器配置、定时器配置这几个关键函数剩下的虚拟化核心逻辑不需要动。刚开始移植时最容易犯的错误是试图“改造” hypervisor 的通用代码去适应新板卡比如在虚拟化核心路径里塞平台相关的 ifdef。这会让后续维护变得极其痛苦。正确做法是只动平台抽象层核心路径保持原样这样即使以后换一块 RVA23 板卡也只需要再写一个 BSP。2.3 判断需要改哪些层拿到一款新 RISC-V 板卡时可以先列一个清单内存基址和大小、UART 地址、中断控制器类型、定时器寄存器基址、CPU 启动方式、是否需要配置外部中断路由。这个清单直接决定 BSP 里要改哪些函数。以 BPI-SM10 为例需要改动的集中在平台入口、地址映射、APLIC/IMSIC 配置和虚拟定时器配置。相比之下不需要动的是通用虚拟化核心包括 vCPU 状态切换、stage2 页表管理、虚拟异常处理入口等。这些逻辑与具体芯片无关只和 RISC-V 特权架构规范有关。RVA23 平台和其他 RISC-V64 平台在核心执行路径上可以共用同一套代码只有在涉及中断控制器型号不一致时平台回调才需要做差异适配。下面是一张我移植初期做的“改动分层”速查表它帮助我把工作范围收敛在可控制范围内层级内容是否需要为 BPI-SM10 改动公共虚拟化核心vCPU 切换、trap 入口、stage2 页表否平台抽象接口平台描述符、回调表是新建 BSP 入口CPU 启动主核/从核拉起、HSM 调用是确认板级固件调用方式中断控制器APLIC/IMSIC 初始化与路由是重点改动定时器ACLINT/虚拟定时器映射是启动链接脚本DDR 地址、镜像加载地址是3. 移植实施BSP、启动与虚拟化配置3.1 新建 BSP 最小骨架先按官方模板的结构新建一个平台目录命名上直接沿用板卡名。目录里放平台入口文件和平台头文件。平台入口文件里最重要的一块是平台描述符它告诉 Bao 当前运行在什么硬件上、有多少内存、有几个 CPU、用什么中断控制器。// 示意代码平台描述符骨架 const struct platform platform { .name bananapi_bpi_sm10, .cpu_num 4, .hart_ids {0, 1, 2, 3}, .ram_base 0x80000000ULL, .ram_size 0x40000000ULL, .irqchip aplic_imsic, .timer aclint_timer, .console uart16550_console, };这里我把真实 BSP 里的结构体名字做了示意化处理实际源码里接口名略有不同但模式一致。关键是内存基址必须与板卡实际 DDR 映射对齐UART 的基址和中断号也要核对硬件手册。3.2 启动流程与 CSR 初始化关键点Bao 从 OpenSBI 手中接管系统后运行在 HS 模式但它在初始化自身时仍然需要谨慎处理一批 CSR。第一步是读取misa确认 H 扩展和 V 扩展确实存在。RVA23 profile 要求这些扩展必须实现但生产环境下的防御性检查不能省万一后续换了低配芯片这里的检查能提前暴露问题。int cpu_has_h_extension(void) { uint64_t misa; asm volatile(csrr %0, misa : r(misa)); return (misa (H - A)) 1UL; }H 扩展的使能不是写一个开关位就能完成它更多是运行模式上的切换hypervisor 需要建立自己的页表并把satp切换到 HS 模式的地址空间同时要正确设置hedeleg和hideleg把一部分 guest 陷入事件直接委托给 VS 模式处理减少 hypervisor 的陷入开销。对于定时器和外部中断委托策略尤其重要因为 RVA23 平台上 AIA 已经支持直接向 guest 注入中断hypervisor 不需要参与每次中断转发的现场处理。启动多核 CPU 时我采用了稳妥的做法先让主核完成 hypervisor 全部初始化再从核通过 SBI HSM 调用逐个拉起。每个从核进入 HS 模式后第一件事是设置自己的hgatp和该 hart 对应的 vCPU 上下文然后等待主核发启动事件。这一步在 RISC-V 上比 ARM 要简单一些因为没有复杂的异常级别跳转流程核心就是“从核进入 HS 后先到 hypervisor 入口”这一条约定。3.3 内存分区与 stage2 页表Bao 的静态分区模型要求 hypervisor 在启动阶段就把物理内存划分清楚。BPI-SM10 的 DDR 范围是 0x80000000 开始容量 1GB。我把内存分成三块hypervisor 自用区、VM0 区、VM1 区。hypervisor 自用区放在地址最低处方便启动早期使用VM0 和 VM1 各占一块独立区域互不重叠。#define HYPERVISOR_RAM_BASE 0x80000000ULL #define HYPERVISOR_RAM_SIZE 0x00800000ULL #define VM0_RAM_BASE 0x80800000ULL #define VM0_RAM_SIZE 0x10000000ULL #define VM1_RAM_BASE 0x90800000ULL #define VM1_RAM_SIZE 0x10000000ULLstage2 页表的作用是把 Guest 看到的物理地址映射到真实的机器内存上。RVA23 平台的虚拟化实现中这个页表由hgatp指向支持多种页大小和虚拟地址模式。我在 BPI-SM10 上使用了 Sv39因为 1GB 物理内存加 1GB guest 区域用 Sv39 足够页表层级少性能也更好。如果后续板卡升级到更大内存再切换 Sv48 也不影响 hypervisor 核心代码。关键点是每次修改hgatp之后必须执行正确的 fence 指令。这里容易踩坑普通sfence.vma只对当前vsatp的地址转换有效要冲刷 guest 物理地址到机器地址的转换得用hfence.gvma。我第一次调的时候在这里少了一条 fence结果 guest 访问内存出现随机性不一致排查了整整一天。static void hgatp_write_flush(uint64_t hgatp_value) { asm volatile(csrw hgatp, %0 : : r(hgatp_value)); asm volatile(hfence.gvma); }除了 stage2我还用 RISC-V 的 PMP 给 hypervisor 自用内存加了一道额外保护。PMP 限制的是 M 模式下的访问权限对 S/HS 模式本身没有强制约束但在防御层面它可以防止 M 模式固件被攻破后直接读 hypervisor 内存属于纵深防御的一部分。Bao 在某些平台上的参考实现里也有类似机制移植到 RVA23 芯片时建议一并配置上。3.4 定时器、外部中断与虚拟中断注入RVA23 平台的中断控制器已经不是传统 PLIC 了APLIC 负责把来自外设的中断收集起来IMSIC 则允许中断以 MSI 形式直接投递给指定 hart。配置 APLIC 时最关键的是把中断源使能位enable和中断目标域target domain设置正确。Bao 对中断的目标是把每个外设中断固定路由到某一个 VM 对应 vCPU 的 guest 文件里这样外设中断来临后硬件可以不经过 hypervisor 的 trap 处理直接进入 guest 的 trap handler。IMSIC 支持多个 guest 中断文件guest interrupt file每个文件对应一个虚拟 CPU 的中断上下文。我在设备树里给两个 VM 分别分配了 GID 1 和 GID 2然后用interrupts-extended把串口中断映射到 VM0 的 GID 上把网卡中断映射到 VM1 的 GID 上。这样做的效果是Guest 看到的外部中断与真实硬件中断源一一对应hypervisor 不需要做中断号翻译。// 示意把串口中断直通到 VM0 的 vCPU uart0 { interrupt-extended imsic 1 26; };定时器部分RVA23 的机器定时器在 ACLINT 域里但 guest 访问定时器时需要经过虚拟化层。Bao 的做法通常是拦截 guest 对定时器比较值的写操作并在特定的时机把虚拟中断状态注入到 guest 的hvip或通过 IMSIC 文件投递。对于经典定时器中断我在 BSP 里实现了虚拟定时器的比较值寄存器模拟这样每个 VM 都能有自己的时间片概念互不干扰。3.5 编译、烧录与引导验证所有代码改动完成后进入构建阶段。在config.mk里把平台名指向新建的 BSP然后执行构建命令产物是一个固定的二进制镜像。这个镜像需要放在 U-Boot 能引导的位置。BPI-SM10 上我用的方式是把 Bao 镜像放到 SD 卡的一个分区里然后通过 U-Boot 的go命令直接跳到镜像入口运行跳转前用m命令检查一下指定地址的内存内容确认数据没有损坏。第一次上板时建议不要直接让 Bao 跑完整的多 VM 配置先把配置改成只加载一个 guest甚至只初始化 hypervisor 自身并打印一行串口日志。这样能把启动问题从虚拟化问题里剥离出来。我在 BPI-SM10 上就是这么做的先让 hypervisor 起来打印 CPU 信息和内存分区信息确认一切正常后再挂 guest。4. 双 Guest 实测从裸机到 Linux4.1 裸机 Guest 验证隔离与中断移植通常从最小的裸机 guest 开始这个 guest 就是一个极简循环程序不做操作系统但能响应外部中断。我给 VM0 写了一个裸机测试程序它把串口映射进自己的地址空间然后在屏幕上打印自己的 VM ID并循环读取定时器。这个 guest 的地址空间由 stage2 映射管理因此 guest 自己能看到的物理内存范围被限制在 VM0 分区里。测试目标有两个。第一是验证中断直通是否正常我让裸机程序把 APLIC 中某个按键或网卡中断直接映射到自己的中断入口触发一次就打印一次过程中 hypervisor 完全不介入。第二是验证隔离性让 VM0 尝试读取 VM1 的物理内存地址期望结果是触发 stage2 的访问异常进而被 hypervisor 捕获并处理。实测中 VM0 的程序陷入异常并挂起这正是预期效果说明内存隔离在硬件层生效了。4.2 Linux Guest 联调裸机 guest 通过后Linux guest 的移植压力会小很多。难点主要在设备树和启动参数上。我参考 RISC-V Linux 对 virt 平台的设备树写了一份精简版设备树把 UART、定时器、中断控制器节点都暴露给 guest并把内存布局限定在 VM1 分区内。Linux 内核使用 RISC-V defconfig 编译关闭了动态内存扫描改用设备树里固定提供的 memory 节点。启动过程整体顺利但有一个小插曲Linux guest 启动到一半时控制台输出突然停住报了一个关于 timer 的错误。查下来的原因是我在虚拟定时器中断注入时用的优先级不对导致 guest 在打开中断的瞬间收到一个尚未准备好的定时器中断。解决办法是在 guest 初始化定时器前把所有 pending 的虚拟中断先清空再启动调度。Linux guest 稳定跑起来后我又给 VM1 挂了一个极简 rootfs让它能执行 shell 命令。两个 VM 同时运行的状态下Bao 的静态资源隔离效果非常明显VM0 的裸机程序实时性完全不受 VM1 里 Linux 负载影响因为它们的 pCPU 是分开绑定的缓存和内存也不会争用。4.3 隔离性与性能初步观察双 guest 跑通之后我做了两组快速验证。第一组是功能验证在 VM1 的 Linux 里持续跑一个 CPU 密集任务同时观察 VM0 的定时器中断周期是否稳定。由于 vCPU 与 pCPU 绑定且中断路由直通VM0 没有出现任何被拖慢的迹象。第二组是安全验证我尝试在 VM1 内向 VM0 的物理地址发起 DMA 映射请求。在配置了正确的 IOMMU 或 SMMU 等价机制后DMA 请求也被限制在分配给 VM1 的物理范围内无法访问 VM0 私有内存。这验证了 Bao 静态分区模型的另一个重要特性隔离不只在 CPU 发起的访问上生效对外设 DMA 同样有效。整体中断延迟数据没有用复杂的性能分析工具就用裸机程序里一个 GPIO 翻转加逻辑分析仪估算从中断触发到 guest 入口执行之间的延迟稳定在亚微秒量级。这个级别对于绝大多数工业实时控制场景都够用了也证明 AIA 直通机制确实避免了 hypervisor 中转的开销。5. 常见问题与排查技巧实录5.1 启动与链接问题移植过程中最高频的一类问题集中在启动早期。常见现象是串口没有任何输出或者镜像跳转后系统卡死。我遇到过的原因有三个链接脚本里的加载地址和实际 DDR 地址不一致BSS 段没有清零导致全局变量初始值为随机值串口时钟频率在 BSP 里写错导致波特率完全不对。建议先把板级固件的串口输出打通在 Bao 平台入口函数最开始就调用串口打印一个固定字符。如果这个字符能看到说明 CPU 已经跑起来了问题大概率在后续的内存或中断配置如果看不到优先检查链接地址和固件跳转地址。5.2 内存/地址转换问题stage2 页表配置错误的表现多种多样guest 启动时指令取指异常、数据访问报错、虚拟地址随机错乱。最常见的根因是hgatp的 PPN 没有正确对齐或者映射关系里覆盖区域出现了重叠。另一个容易被忽略的点是设备树里声明的内存范围必须与 stage2 映射一致如果 guest 认为自己有 512MB 内存而 stage2 只映射了前 256MBLinux 启动到后期一定会 panic。针对性排查方法是直接读 hgatp 的值验证页表根的物理地址是否落在已映射区域内然后用 guest 的视角手算几个关键地址的转换结果。这种方法虽然原始但在裸机调试阶段比任何工具都好用。5.3 定时器与中断问题定时器和中断的问题往往互相纠缠。RVA23 上 APLIC 的使能位非常多不仅要使能中断源本身还要使能目标 hart、目标 guest 文件对应的中断使能。我踩过的一个典型坑是串口中断源的 enable 已经打开但忽略了 APLIC 的 domain 配置导致中断根本没有传到 IMSIC 的指定文件里guest 自然永远等不到中断。另一个常见问题是虚拟中断的 pending 位没有及时清掉。guest 在处理完一次中断后如果 hypervisor 没有清掉对应的 pending 状态同一个中断会被反复触发。这需要认真梳理中断注入流程确保 guest 的 trap handler 返回前所有相关状态已经恢复到一致状态。5.4 移植 RVA23 平台的几条经验回看整个移植过程我总结了四条对后续做同类平台有价值的经验。第一RVA23 平台的大方向是标准化的H 扩展和 AIA 都是必选所以跨平台代码复用率非常高不需要为某个私有扩展写特别多的适配逻辑这比早期 RISC-V 芯片上做虚拟化轻松得多。第二Bao 这类静态分区 hypervisor 的成功关键在配置准确性。编译期写错一个内存地址运行时就多一个隐蔽故障必须把平台描述符、设备树、stage2 映射三处保持一致。第三调试顺序极其重要。按“串口能输出 - CPU 能识别 - 页表能建立 - 定时器能响应 - 中断能直通 - guest 能启动”这个顺序推进每一步都有明确的验证标准不会陷入泥潭。第四遇到奇怪问题时先怀疑内存屏障和 fence 指令缺失。RISC-V 的内存模型比较宽松代码里少一条 fence 可能不会立刻崩溃但在特定硬件时序下就会随机出错这类问题排查成本最高。收尾的个人体会这次把 Bao 移植到 Banana Pi BPI-SM10 的经历让我对 RVA23 时代的 RISC-V 虚拟化有了一个非常直观的认知原来在老平台需要大量软件补丁和硬件 hack 才能实现的隔离能力现在在标准化的 profile 下基本是开箱即用的。Bao 本身的代码风格也值得拿来当教材读它把静态分区这个理念贯彻到每一个角落没有一处配置是运行期才决定的这对安全关键系统来说极其珍贵。最后再分享一个小技巧如果你也打算在 RVA23 板卡上折腾 hypervisor先把板子的完整设备树搞清楚尤其是 APLIC 和 IMSIC 节点里的 interrupt 映射关系。Bao 这类系统在 BSP 里不读设备树但设备树仍然是你理解硬件拓扑和核对配置时最权威的资料来源我后面调试中断直通时反复回看的就是这份设备树。