
把Bao这个嵌入式hypervisor搬到一块基于RVA23配置的RISC-V芯片上本来以为只是换块板子跑通编译的事情结果从链接脚本到中断控制器每一步都踩出新花样。我最近在Banana Pi BPI-SM10这块搭载SpacemiT K1的开发板上完成了Bao的完整移植过程中最有价值的不是最终能跑起来而是把RISC-V虚拟化栈从M模式到S模式的每个关节都摸了一遍。这篇文章是这次移植的完整复盘围绕Bao、RVA23、BPI-SM10三者如何匹配展开适合准备做RISC-V系统软件、Hypervisor移植或者BSP开发的工程师参考。我不会从头讲RISC-V是什么但会把手里的工具、配置、代码片段和踩坑记录都摊开。如果你正准备把一个类似的项目从QEMU挪到真实芯片上这篇文章能帮你少走很多弯路。1. 项目背景Bao、RVA23与BPI-SM10的三方交汇1.1 Bao hypervisor在RISC-V生态中的定位Bao是一个走极简路线的嵌入式hypervisor最早在ARMv8生态里被大家熟悉。它不像Xen或者KVM那样带一堆驱动和抽象层而是追求静态配置、确定性调度和极小的可信计算基。这意味着每个虚拟CPU通常独占一个物理CPU内存和中断都在启动前由配置文件静态划分运行期间不做动态迁移。在RISC-V上Bao选择了M模式作为hypervisor的立足点客户操作系统跑在S模式。这个设计和ARM上它跑在EL2不太一样但思路是相通的既然要做安全关键系统那就住进权限最高的房间把底下所有硬件资源管起来。RISC-V的M模式天然拥有读写CSR、控制PMP和操作平台设备的全部权限配合SBI作为客户机的软接口整体实现比ARM上的EL2模型更直接。实际移植中这种模式的好处很明显客户机不需要知道自己被虚拟化了只要它符合S模式规范就能在Bao上面跑。Bao在RISC-V目录下的现有平台比如QEMU的riscv64虚拟机和一些简单开发板都已经证明了这条路径可行。我这次要做的就是让它在K1这颗带硬件虚拟化扩展的新芯片上也成立。1.2 RVA23配置文件的硬门槛RVA23很多人误以为是一颗具体芯片的名字其实它是RISC-V国际基金会定义的Application级配置文件你可以理解成一张“进料验收单”。它把先前散落的几十个指令扩展、CSR寄存器、异常模型和中断模型打包成一个必须满足的集合凡是标称RVA23兼容的芯片就要保证这些能力都有。和Bao移植关系最密切的几项首先是H扩展也就是硬件辅助虚拟化扩展它提供了从S模式进入VS模式、对应的虚拟内存管理CSR和虚实中断转换能力。虽然Bao可以在没有H扩展的平台上用纯M模式S模式的方式跑但有了H扩展客户机的地址翻译和中断注入都更干净。其次是Sv48RVA23要求MMU至少支持Sv48这意味着hypervisor本身的页表需要处理好四级的翻译层次不能再拿Sv39的简化方式来糊弄。再就是缓存维护指令CBORVA23里Zicbom、Zicboz这些扩展基本是标配DMA一致性处理绕不开它们。对移植团队来说最大的思维转换在于不再像早期RISC-V板子那样写死PLIC地址就完事RVA23要求的中断控制器往往是APLIC加IMSIC组合。这套AIA中断架构在代码里要单独适配。我这次移植中中断初始化部分就花了将近三分之一的调试时间。1.3 为什么选BPI-SM10这块K1板子Banana Pi BPI-SM10是一块基于SpacemiT K1处理器的RISC-V开发板K1有八个核心面向边缘AI场景板子本身提供了DDR、千兆网口、USB和HDMI作为Hypervisor开发和演示平台非常合适。选它的理由有三层。第一层是成本确实低比起小众的评估板BPI-SM10量产板的价格容易接受而且社区资料多遇到问题能找到人讨论。第二层是启动链路完整OpenSBI和U-Boot的源码都公开Bao需要接管的内存区域、串口地址、定时器频率都能通过公开资料确认不用去签NDA要手册。第三层是K1的ISA特性覆盖了RVA23的大部分要求尤其H扩展和Vector扩展都齐了这样移植出来的Bao不只是能跑还能跑到有代表性的配置上。当然新板子也有它的麻烦。外设驱动不完整GPU和ISP基本没法用网卡在Linux下的驱动也还在完善中。所以移植初期我建议只依赖串口别让调试手段本身变成瓶颈。2. 移植前的准备工具链、源码与平台摸底2.1 交叉编译工具链与调试链路RISC-V的交叉编译工具链现在很成熟直接装riscv64-linux-gnu-gcc就行。Bao的构建系统对工具链没有太多特殊要求只需要在编译时指定CROSS_COMPILE它自己会去调用的。sudo apt install gcc-riscv64-linux-gnu git clone https://github.com/bao-project/bao-hypervisor.git cd bao-hypervisor make CROSS_COMPILEriscv64-linux-gnu- PLATFORMk1 config make CROSS_COMPILEriscv64-linux-gnu- PLATFORMk1调试链路我建议从第一天就架好两路一路是USB转TTL串口接板上调试串口另一路是JTAG。JTAG不一定每个团队都有但如果你手头有DAPLink或者开源的JTAG调试器强烈建议接上因为在hypervisor启动初期串口驱动还没初始化时JTAG是唯一能看现场的工具。串口这边要注意供电和电平RISC-V开发板多为1.8V或3.3V逻辑买支持宽电平的USB串口线别上来就把板子的串口芯片烧了。还有一个经验如果你打算先在QEMU里验证Bao那就要保证QEMU版本足够新因为Bao对RISC-V的虚拟化支持依赖QEMU的-machine virt和-cpu rv64参数配合老版本跑起来会有各种行为不一致。2.2 拉取Bao源码对照现有RISC-V平台Bao的源码结构不复杂平台相关的代码集中在platforms目录下每个平台一个子目录里面通常有平台描述头文件、链接脚本和构建片段。我移植前做的第一件事是把仓库里现成的RISC-V平台全部扫了一遍尤其是QEMU riscv64那几个参考实现。对照模板是最高效的路径。一个新平台和现有平台的差异绝大多数时候只集中在几个地方内存的物理基址和大小、串口的基地址、中断控制器的类型和地址、CPU核数。把这些信息从板子的设备树里摘出来逐项填进配置文件平台的主体框架就出来了。剩下的工作重心应该放在启动时序和中断路由上而不是重写架构代码。这里有个容易犯的错误直接拿x86或者ARM的经验来套RISC-V。Bao在不同架构上的代码风格一致但底层细节完全不同比如ARM的GIC和RISC-V的PLIC/APLIC配置接口就差了十万八千里。建议移植前把Bao的RISC-V公共部分代码先读一遍至少理解src/riscv下面目录结构不要跳着看。2.3 梳理开发板的启动内存布局在改任何代码之前先把板子的内存布局搞清楚。K1的DDR地址空间、BootROM入口、U-Boot的加载地址、设备树中可用的内存区域这些信息决定了后续所有的地址配置。我的方法是在U-Boot里直接看现场。启动后进入U-Boot命令行执行bdinfo查看内存信息再用fdt list /看设备树的根节点、内存节点和串口节点。设备树给出的reg属性就是物理地址的真实答案比自己翻手册靠谱也避免了手册和硅片实际行为不一致的情况。比如串口地址K1这类面向开发者的芯片串口节点在设备树里都会标明compatible和reg。你用fdt list /soc/serial...就能看到实际的寄存器基址。定时器频率则可以从timebase-frequency属性或者OpenSBI平台配置里查到。这些数据之后都要填进Bao的平台配置里。内存布局上我一般会把hypervisor镜像放在物理内存的低端比如0x80000000附近把客户机Linux的加载地址放在hypervisor之后预留足够空间给BSS和页表。这样链接脚本简单PMP的配置也直观。注意避开U-Boot自身占据的区域通常U-Boot在内存高地址运行如果你把hypervisor放在低地址就不冲突。3. 平台接入config.h、link.ld与启动汇编3.1 新增平台目录写清config.h里的地址与定时器参数Bao的平台移植第一步是在platforms目录下新建一个K1平台目录然后创建平台描述文件。核心配置项主要是#define RAM_BASE 0x80000000 #define RAM_SIZE 0x40000000 #define PLAT_UART_BASE 0x... /* 从设备树获取 */ #define PLAT_UART_BAUD 115200 #define PLAT_CPU_COUNT 8 #define PLAT_TIME_FREQ 1000000RAM_BASE和RAM_SIZE描述hypervisor视角下可用的全部物理内存Bao会基于这个范围来管理页表和PMP区域。PLAT_UART_BASE是调试串口的寄存器基址Bao启动早期要通过它打印日志。PLAT_TIME_FREQ则是定时器频率RISC-V的mtime寄存器以这个频率递增如果填错客户机的时钟会快几倍或者慢几倍表现为Linux启动时时间完全不对。我踩过的坑是把某个平台的定时器频率拿过来直接套用结果K1的timebase-frequency不是这个值guest启动后内核panic在时钟初始化。所以这个值必须从设备树或者OpenSBI的编译配置里现取不能靠猜。另外K1有多个核PLAT_CPU_COUNT要填满。Bao启动时会逐个核执行汇编入口非主核会进入等待状态直到hypervisor完成初步初始化再被唤醒。3.2 link.ld的地址映射与页表对齐细节链接脚本是这次移植中最折磨人的一部分。RISC-V和ARM不同地址空间建立在固定的物理地址上没有ARM那套复杂的AT和LMA/VMA映射。Bao在M模式启动时可以先不开MMU直接执行物理地址处的代码所以链接脚本的基址直接设置成RAM_BASE即可。一个常见的简化版链接脚本结构如下OUTPUT_ARCH(riscv) ENTRY(_start) MEMORY { RAM (rwx) : ORIGIN 0x80000000, LENGTH 0x40000000 } SECTIONS { . 0x80000000; .text : { KEEP(*(.text.entry)) *(.text*) } RAM .rodata : { *(.rodata*) } RAM .data : { *(.data*) } RAM .bss : { __bss_start .; *(.bss*); __bss_end .; } RAM }千万别忽略对齐。RISC-V的页表映射要求页面对齐到4KiB如果使用大页映射至少对齐到2MiB。Bao在启动时会建立自身的页表把整个RAM区域映射起来。如果.bss段的起始地址没有对齐到4KiB清零BSS的循环就会写出越界地址可能踩坏页表本身导致后续注入guest时出现诡异异常。我给每个section末尾都显式加上对齐. ALIGN(4096);编译完成后用riscv64-linux-gnu-readelf -S bao.elf检查每个段的地址偏移确认.bss的起始地址低12位为零。这一步看起来很基础但能省掉之后两小时的debug时间。3.3 早期启动流程与SBI交互Bao在RISC-V的启动入口是汇编入口点就是链接脚本里ENTRY(_start)指定的那个符号。汇编代码主要做这几件事读取mhartid判断当前核是不是主核设置栈指针清零BSS段配置PMP设置mtvec指向中断向量表最后跳转到C代码的初始化函数。PMP的配置是M模式hypervisor的重中之重。你需要把内存区域设置为S模式可读写但把hypervisor自身占据的区域设置为仅M模式可访问。这样即使客户机里有恶意的S模式代码也无法访问hypervisor的内存。RISC-V的PMP粒度一般是4KiB所以链接脚本里的对齐要求又在这里体现了一次。SBI的交互在启动早期也很关键。Bao在M模式但它可以作为SBI的替代实现也可以调用OpenSBI提供的基础服务。我这里采用的方案是让Bao直接管理硬件定时器和串口只在需要跨核同步时用SBI的远程fence或者IPI。这样能减少对OpenSBI版本的依赖。不过要注意客户机Linux在S模式会发起SBI调用Bao必须提供一个SBI兼容的跳板。如果是完整移植可以把客户机的SBI调用直接转发给OpenSBI但前提是OpenSBI在M模式仍有控制权。我是让Bao直接拦截SBI的某些接口比如获取时间、发送IPI、远程fence自己实现而不是转发。这个决策减少了间接层但也意味着你要保证实现逻辑正确。4. 中断与虚拟化从PLIC/APLIC到IMSIC的适配4.1 RISC-V中断架构沿革与RVA23要求早期RISC-V开发板基本都用PLIC作为外部中断控制器它把所有外设中断汇总后按优先级发给各个Hart。PLIC的优点是实现简单缺点是全局共享、缺少MSI支持多核扩展和虚拟化适配都别扭。RVA23配置里中断控制器走向AIA规范典型组合是APLIC加IMSIC。APLIC负责传统的中断线汇总IMSIC则负责MSI消息信号中断。IMSIC每个Hart都有自己的中断文件这样相比PLIC不需要在多个Hart间仲裁一条共享总线延迟和扩展性都好很多。代价是软件初始化变复杂Bao必须搞清楚当前平台用的到底是PLIC、APLIC还是APLICIMSIC不能沿用老一套代码。K1实际启用的是哪套组合从设备树的interrupt-controller节点就能看出来。如果看到riscv,aplic和riscv,imsic兼容字符串就按AIA路径来初始化如果只有sifive,plic-1.0.0那就走PLIC兼容路径。我在移植时没有写死设备树路径而是启动时动态解析这样同一个Bao镜像能在多个平台间复用只是配置每个平台各自的中断控制器基址。4.2 Bao的中断路由与虚拟中断配置Bao的域配置支持把物理中断直接分配给指定虚拟CPU。可以在平台配置的struct domain_config中定义一组interrupt映射。K1上把某个外设中断直通给客户机Linux时要保证这个中断号在guest的设备树中被正确描述并且不与虚拟定时器中断冲突。一个简化的中断路由配置片段如下static const struct irq_config irq_configs[] { { .id 0x3d, /* 物理中断号 */ .target 0, /* 目标vcpu */ .action IRQ_ACTION_FORWARD, }, };如果平台是APLICIMSIC还需要额外配置IMSIC的guest中断文件也就是vgein相关的寄存器。这块很容易出错因为guest侧Linux在访问中断控制器时看到的是一个模拟出来的视图而实际中断可能来自MSI文件。Bao需要把虚拟中断号映射到物理IMSIC通道再开对应的gate。调试这类问题最直观的方法是看guest的/proc/interrupts。如果某个设备中断一直显示为0多半是DOMAIN配置里的物理中断号或者目标vcpu没填对。4.3 虚拟串口与打印输出串口是嵌入式开发的生命线但Hypervisor环境里最烦的问题就是多个domain抢同一个UART。如果没有虚拟串口两个guest同时往物理串口打印日志会直接乱掉。我建议在Bao平台层加一个轻量级vUART给每个domain分配独立的MMIO窗口guest往这个窗口写字符时hypervisor截获后转发到真实串口。这个设计不复杂核心就是一段地址decode和FIFO转发逻辑。但要注意两点一是vUART的寄存器语义要符合Linux串口驱动期望的16550风格否则guest里的console驱动不认二是如果vUART日志和hypervisor自己的日志共用一个物理串口建议在尾巴上加前缀或者颜色区分否则排查问题时分不清日志来源。实际运行中如果客户机的早期启动日志在earlycon阶段正常、但接管ttyS0之后不再输出通常是vUART的中断没接对。Linux在初始化完串口后会切换到中断驱动模式而earlycon只靠轮询。这时的症状就是前几行日志出来了后面全没了排查方向要往中断路由上想。5. 验证与调试让guest Linux真正跑起来5.1 编译、烧录、U-Boot加载与串口观察编译Bao镜像的过程不复杂关键是找到正确的平台名。如果你在自己的平台目录里写了K1的Makefile调用方式就是make CROSS_COMPILEriscv64-linux-gnu- PLATFORMk1得到bao.bin之后放到SD卡或者通过TFTP下载到内存。我习惯用U-Boot来做引导先把bao.bin放到地址0x80000000然后执行booti或者go命令跳进去。Bao启动后会打印自己的banner串口上出现类似下面的输出Bao Hypervisor Init completed如果在banner之后就卡住先别急着怀疑hypervisor代码检查串口波特率、链接脚本中的入口地址和实际加载地址是否一致。大多数启动卡死都源于地址错位。5.2 域配置与客户机内存映射客户机Linux的加载方式有两种一是Bao直接从存储介质读镜像二是利用U-Boot先把Linux Image和DTB加载到内存再由Bao接管。我用的方案是后者因为这样不用在hypervisor里加文件系统驱动调试周期短。在Bao内部域的内存映射通过一份静态配置声明。主要要指定guest物理内存的基址和大小以及hypervisor给它暴露的设备树。关键在于设备树里的memory节点不能包含hypervisor自留的区域chosen节点要设置好bootargs控制台指向vUART或直通串口。如果Linux启动时Unable to handle kernel paging request通常是设备树里的内存范围和Bao分配给domain的PMP区域不一致。RISC-V的Linux对物理地址的合法性非常敏感设备树里多给了1MB它就会尝试访问不属于自己的内存进而触发野兽异常。5.3 稳定性测试与性能观察guest启动成功后别高兴太早。我建议至少做三轮测试第一轮单guest长时间空转验证hypervisor本身不会死锁第二轮双guest同时运行验证核间中断和调度没有互相干扰第三轮在guest里跑内存压力测试把PMP隔离的边界彻底压一遍。性能方面Bao这类静态hypervisor的虚拟化开销主要来自内存隔离和中断注入。在K1上实测guest之间切换的延迟大约几百纳秒到微秒量级普通负载下基本感觉不到虚拟化层存在。如果你观察到吞吐量大幅下降先排查是不是缓存维护指令没开。RVA23上的Zicbom如果没启用DMA操作前不刷Cache网卡驱动会疯狂丢包表现就是网络性能奇差无比。还有一个容易被忽略的点客户机Linux使用的定时器频率必须和Bao配置一致。如果Bao设了1MHzLinux设备树里也是1MHz那一切正常。一旦不一致Linux的时间会跳变调度器和延迟统计全部失真查起来很像死锁其实是时钟漂移。6. 排错实录移植中遇到的坑与解法6.1 链接脚本对齐错误导致的启动流失这次移植我遇到最隐蔽的坑就是BSS段对齐。具体表现是Bao启动日志正常hypervisor初始化看起来全部完成但只要客户机一开始执行就掉进异常向量。用JTAG抓现场发现是客户机的页表踩坏了而罪魁祸首居然是在清BSS时越界写到了页表内存。原因很简单链接脚本没有在每个输出段后面加对齐.bss段的起始地址落在了非页边界上。清零循环按字长写了整个BSS区域却没有从页边界开始最终把邻接的页表数据全部冲掉。修复方法是强制对齐并用readelf -S验证每个段地址。这类问题在QEMU里不一定复现因为QEMU的物理内存分布更宽松真实芯片的PMP和页表对它却非常敏感。6.2 SBI版本差异与调用约定RISC-V的SBI规范迭代很快OpenSBI v1.x和v2.x之间有一些接口行为变化。Bao在编译时会按SBI版本选择调用路径但如果你用的是较老版本的Bao配合新OpenSBI就可能在sbi_probe_extension阶段拿到意外返回值。我遇到的症状是Bao启动后一切正常但客户机里的Linux在初始化SBI时报告SBI extension not found然后挂起。排查下来是Bao拦截了SBI调用但返回的错误码不符合新规范。解决办法是直接在源码里把SBI调用封装改为使用最新规范的接口编号并让OpenSBI作为实际的SBI后端Bao只做转发避免重复实现带来的偏差。6.3 中断失配、页面缓存与DMADMA问题在虚拟化环境里比裸机更折磨人。K1支持缓存维护指令但如果hypervisor没有在内存域切换时执行正确的cache clean操作guest的DMA缓冲区和CPU看到的视图就可能不一致。我在实测千兆网卡时遇到丢包率接近百分之百查了一天最后发现Bao平台配置文件里没有启用Zicbom相关指令路径客户机Linux驱动做DMA时得不到正确的缓存同步。修正方案分两步首先确认设备树里配有dma-coherent或者正确的cache size属性然后在Bao的域配置里为DMA区域显式设置内存属性必要时直接在hypervisor层加上cache flush的wrapper。这块没有通用答案完全取决于硬件是否把DMA一致性做成硬件保证。还有一个同样重要但容易忽略的点IMSIC的MSI中断如果路由到guest要确保客户机的MSI地址被翻译到正确的IMSIC文件。否则网卡产生中断后guest看不到任何中断驱动一直等待表现也是网络卡死。这种问题只能靠逐个寄存器读确认没有捷径。6.4 启动顺序与多核唤醒多核平台的启动时序也有讲究。Bao启动时只让主核执行初始化其他核心必须保持在M模式等待直到收到唤醒信号。有些平台的BootROM或者前级引导器会提前把其他核也拉进运行状态导致它们抢在hypervisor初始化完成前就去访问还没有建立的内存映射引起异常。我在K1上遇到的现象是主核日志正常但整个系统会在启动早期卡死。后来发现是U-Boot已经把所有核放出来了落地就直接跑Bao汇编入口非主核一进_start就去读mhartid然后试图初始化自己的栈结果BSS还没清完栈地址全是垃圾。解决方式是让非主核在汇编入口处简单自旋等待一个内存变量被置位再进入后续流程。Tiny hypervisor看起来简单但多核启动的细节一个都省不了。移植完成后我个人的体会是Bao之所以能在RISC-V上快速落地靠的是极简设计带来的可控性但可控不代表简单。每个地址、每个对齐、每个中断号背后都对应真实的硅片行为。QEMU里能跑通只证明逻辑对真机里能稳定跑才说明时序和资源也对得上。如果你也要在RVA23芯片上接Bao建议从最小的串口hello开始把启动、中断、域配置一层层叠上去。慢慢来踩坑的速度就追不上你了。后面我还想做的是把K1上的AI加速器作为一个虚拟设备透传给guest看能否在hypervisor环境下跑通NPU推理栈那又会是另一个有意思的故事。