Bao Hypervisor移植到RISC-V:link.ld与虚拟化扩展实战

发布时间:2026/9/28 19:35:17
Bao Hypervisor移植到RISC-V:link.ld与虚拟化扩展实战 前阵子把手头一块 Banana Pi BPI-SM10 开发板翻出来板载芯片的指令集扩展符合 RVA23 profileH 扩展和 Sstc 都齐全。装完官方 Linux 之后我决定干一件有点“不务正业”的事把 Bao hypervisor 从熟悉的 ARM64 世界搬到这块 RISC-V 板子上。Bao 是一个静态分区 Type-1 hypervisor代码量小隔离性硬核。这篇文章就是这次移植的完整记录重点放在 RISC-V 特有、也是最劝退的 link.ld 链接脚本、RISC-V 指令集的虚拟化扩展以及 BPI-SM10 上从无输出到多 guest 同时跑的排坑过程。考虑到板子批次和文档版本可能不一样凡是涉及具体地址或寄存器值的地方我都会说明“以你的 BSP 实际值为准”而不是死记一个数字。1. 项目背景与整体移植思路1.1 为什么选 Bao 而不是 KVM / Xen说到 RISC-V 上的 hypervisor很多人第一反应是 KVM 或者 Xen。KVM 在 Linux 里已经比较成熟Xen 的社区也在推进 RISC-V 支持但这两个都是重量级方案动态调度、动态内存管理、设备模型代码量几十万行级别。对于我这种想在一块嵌入式开发板上快速搞出“多个隔离系统同时跑”的人来说太重了。Bao 的路子完全不同。它是静态分区 hypervisor开机后 CPU 核心、内存区间、外设中断全部在编译时定死运行时不做动态创建、不做抢占调度。这种模式特别适合两类场景一类是汽车和航空里的硬实时系统要的是确定性不允许调度抖动另一类是安全隔离几个 guest 之间内存彻底隔开没有共享页表攻击面小。Bao 的代码量很小核心代码几万行就能说清楚移植到新平台时可控性强。这几点加起来让我决定把 Bao 作为这次移植的主角。对比下来维度BaoKVMXen调度模型静态固定动态调度动态调度内存隔离编译时静态分区页表动态管理页表动态管理代码量很小很大很大适合场景嵌入式 / 功能安全服务器虚拟化服务器 / 桌面虚拟化RISC-V 移植工作量中中高高1.2 RVA23 profile 给 hypervisor 铺了哪条路RVA23 是 RISC-V International 发布的应用级 profile里面把一系列扩展固定成了“必须实现”。对我们做 hypervisor 的人来说最关键的是一条H 扩展hypervisor extension是 RVA23 的必选项。这意味着 BPI-SM10 这类板子在设计时就带上了完整的硬件虚拟化能力包括 hgatp 两阶段地址翻译、guest 外部中断注入、异常委托这些机制。在 RVA23 之前很多 RISC-V 芯片虽然变成了“支持虚拟化”但实际就靠 trap-and-emulateguest 一碰敏感寄存器就陷入 hypervisor 软件模拟。RVA23 把 H 扩展列为必须同时把 Sstcsupervisor timer 比较寄存器、Svinval高效 TLB 刷新的指令、Zicbom / Zicboz缓存管理操作这些和虚拟化关系密切的扩展也一并纳入。对移植者来说最大的好处是不用再纠结“这颗芯片到底有没有虚拟化硬件”只要确认它标注了 RVA23Bao 就能用上原生的虚拟化能力。1.3 移植路径从编译通过到多 guest 启动这次移植我给自己定了四个阶段每个阶段有明确的验收标准搭好交叉编译环境让 Bao 源码在PLATFORMbpism10下编译出 elf 和 bin。适配 link.ld 链接脚本让_start能在 BPI-SM10 的 DDR 地址上稳定执行串口能看到 hello 输出。完成 platform 描述和设备树规划让 Bao 能识别核心、内存、UART、中断控制器。加载两个 guest一个裸机串口打印程序和一个裁剪过的 Linux验证静态分区隔离。实际操作中第 2 步花的时间比第 3、4 步加起来还多。RISC-V 的“文化”和 ARM64 不太一样ARM 那边一般有人给你整包的 BSPlink.ld 都是现成的RISC-V 这边经常要自己从零搭链接脚本这就是为什么 “risc-v link.ld” 会成为一个热词。2. 环境准备交叉工具链与运行固件2.1 工具链选型与编译参数RISC-V 的交叉工具链有好几种选择我这次用的是riscv64-linux-gnu-gcc。虽然是 Linux 工具链但 Bao 本身不依赖 libc是纯裸机代码所以带 glibc 的工具链也完全没问题。如果你手头有riscv64-unknown-elf-gcc当然更好区别不大。安装方式sudo apt install gcc-riscv64-linux-gnu # 或者从 bootlin 官方下载预编译工具链解压后加到 PATH做 hypervisor 裸机编译有两个编译参数建议显式加进 Baom 的 CFLAGS 里一个是-mcmodelmedany一个是-fno-pic。medany是 RISC-V 特有的寻址模型它会让编译器生成相对当前 PC 的地址访问不依赖绝对地址到高 4GB 之外。-fno-pic避免生成位置无关代码因为 Bao 链接地址就是运行地址不需要重定位。-march参数上不要贪多。我见过有人直接写-marchrv64imafdcv带上向量扩展 V结果链路上下文里多了一堆向量寄存器保存恢复代码还容易触发工具链版本差异。RVA23 平台通常有 V 扩展但 Bao 用不到我建议用CFLAGS -marchrv64imafdc_zba_zbb_zbs_zicbom_zicboz_zihintpause_sstc后面几个小扩展是 RVA23 里常见的基础扩展不影响大局但能让工具链生成更干净的代码。2.2 启动链OpenSBI、U-Boot 与特权级RISC-V 有明确的特权级设计M 模式machine、HS 模式hypervisor-supervisor、VS 模式virtual-supervisor。BPI-SM10 的启动链通常是片上 ROM - OpenSBIM 模式 - U-BootHS 模式 - 用户程序这里有个关键概念当 CPU 实现了 H 扩展原本的 S 模式就升级成 HS 模式S 模式下的 Linux 能跑是因为它用 SBI 接口和 M 模式的 OpenSBI 通信。U-Boot 如果编译成 S-mode 版本它实际跑在 HS 模式。Bao 作为 hypervisor要求的正是 HS 模式。所以最简单的加载方式就是让 U-Boot 用go命令直接跳转到 Bao 入口不需要再经过 OpenSBI 切一趟。个中原因是 H 扩展的 CSR比如 hstatus、hgatp只能在高于等于 HS 模式时访问U-Boot 已经在 HSBao 继承这个状态即可。2.3 获取 Bao 源码与目录结构Bao 的源码托管在 GitHubgit clone https://github.com/bao-project/bao-hypervisor.git cd bao-hypervisor git checkout 你验证过的稳定分支源码结构大致如下src/核心代码内存管理、vCPU 切换、中断处理。arch/riscv64/RISC-V 体系结构相关代码CSR 操作、异常入口。platforms/板卡相关代码每种板一个目录。config/VM 配置通常用 DTS 描述。Bao 的 VM 配置在较新版本里已经演进成用 DTS 描述编译时生成 DTB 嵌入镜像。这样好处很明显硬件资源划分写在文本格式里可读性好不用每次改 guest 分区都重新动 C 源码。3. 移植第一关RISC-V 的 link.ld 链接脚本3.1 为什么 RISC-V 的 link.ld 这么让人头疼如果你在网上搜过 “riscv link.ld”会发现提问特别多因为 RISC-V 的链接脚本有几个和指令集绑定的特殊约束和 x86/ARM 完全不是一个套路。第一个坑是全局指针寄存器 gp。RISC-V 编译器默认会把小数据放进.sdata、.sbss段然后用 gp 寄存器做相对寻址。如果链接脚本里没有定义这两个段或者它们在内存里的位置不合适编译时就会报一堆R_RISCV_GPREL_Irelocation truncated。很多新手第一次写 RISC-V 链接脚本就栽在这。第二个坑是启动阶段的寻址模型。hypervisor 在打开分页之前运行在物理地址上所有代码必须链接到真实物理地址。ARM64 早期也有类似要求但 ARM 的启动代码通常有__boot_args之类的封装RISC-V 这边你得自己在_start里处理 a0hartid、a1DTB 地址以及 sp、gp、tp 的初始化。第三个坑是对齐。RISC-V 的指令编码里跳转指令auipcjalr是 4 字节对齐但这不代表你的段可以随意 4 字节对齐放。有些原子操作和缓存行优化需要 16 字节甚至更大对齐链接脚本里不加. ALIGN(16);后面调试时会出现诡异问题。所以说link.ld 不是可有可无的“构建配置”它是 RISC-V 移植的第一道关卡。3.2 内存布局Bao、Guest、DTB 怎么放在动手写链接脚本前必须先规划整个内存布局。BPI-SM10 的 DDR 起始地址一般是0x80000000低地址区间被 OpenSBI 和 U-Boot 占用。我给这块板规划的布局如下区间地址范围用途固件区0x80000000 - 0x801FFFFFOpenSBI / U-Boot / DTBBao 区0x80200000 - 0x80FFFFFFBao hypervisor 自身Guest0 区0x81000000 - 0x81FFFFFF第一个 guestRTOS / 裸机Guest1 区0x82000000 - 0x82FFFFFF第二个 guestLinux共享区0x83000000 - 0x83FFFFFF共享内存、调试通道为什么挑0x80200000作为 Bao 的加载地址两个原因。第一这是 RISC-V Linux 内核惯用的加载地址U-Boot 和 OpenSBI 对它很熟悉不容易踩到固件尾巴。第二它离 DDR 起点有 2MB 距离给固件、DTB、页表预留了充足空间。如果你用 QEMU 验证virt机器的内存也是从0x80000000开始这套布局完全兼容。3.3 一份可用的 RISC-V link.ld 骨架直接给一份我实际调试过的链接脚本骨架按 Bao 的风格精简OUTPUT_ARCH(riscv) ENTRY(_start) BASE_ADDRESS 0x80200000; SECTIONS { . BASE_ADDRESS; _start .; .text : { *(.text._start) *(.text*) } . ALIGN(16); _etext .; .rodata : { *(.rodata*) *(.srodata*) } . ALIGN(16); _erodata .; .data : { *(.data*) *(.sdata*) } . ALIGN(16); _edata .; .bss (NOLOAD) : { __bss_start .; *(.bss*) *(.sbss*) *(COMMON) . ALIGN(16); __bss_end .; } . ALIGN(16); _end .; }几个关键点ENTRY(_start)告诉链接器入口符号是_start它必须放在镜像最开头。为了保险我把启动汇编单独放进.text._start段避免其他代码段挤到前面。.srodata、.sdata、.sbss这些 gp 相关小段必须显式列出否则编译器生成的 gp 相对寻址找不到段轻则链接失败重则运行时访问错误数据。bss用了NOLOAD只在 ELF 里标记一段内存区域不把几百 KB 的零填充塞进 bin 文件。没有这一行生成的 bin 会膨胀到不可思议。所有段之间都加ALIGN(16)这是给后续页表映射和缓存维护做铺垫。链接脚本最后导出的_end、__bss_start这些符号C 代码里的memset清 BSS、内存分配器都会用到。少了它们等着链接错误找到你。3.4 我踩过的四个 link.ld 实战坑先说第一个坑rodata对齐不足。最开始我的.rodata用了ALIGN(8)编译出来的内核一启动就指令异常。后来用调试器看是一条ld指令的立即数寻址越界改成 16 字节对齐就没了。第二个坑gp 段缺失。有一版链接脚本我图省事没写.sdata结果链接器直接报一堆 relocation truncated。开始我还以为是内存地址太高了后来才发现是 gp 寻址表没建起来。第三个坑_start里的a0和a1被编译器破坏了。RISC-V 启动规范要求 OpenSBI 传入的 a0 是 hartida1 是 DTB 物理地址但有些汇编代码里第一句就执行了其他操作把 a1 覆盖了。正确做法是入口处先保存a0、a1到内存再干别的。第四个坑链接地址和加载地址不一致。这多半发生在你从 U-Boot 加载时用了0x80200000但 link.ld 里BASE_ADDRESS写成了0x80000000。启动后第一条指令能跑但一跳转到 C 函数就飞了串口毫无输出。排查方式是在_start开头直接串口打印 PC 值和链接地址对照一下即可。4. 平台描述、设备树与资源规划4.1 在 Bao 里新增平台描述Bao 的架构把平台相关逻辑抽象成一套描述结构。你在platforms/riscv64/下新建一个bpism10目录里面放平台描述 C 文件、链接脚本、Makefile 片段。平台描述文件的核心是给出这些信息struct bao_platform_desc { const char *name; uint64_t hart_num; uint64_t hart_freq; uint64_t region_base; uint64_t region_size; uint64_t uart_base; uint32_t uart_irq; uint32_t intc_type; /* APLIC / PLIC / IMSIC */ };不同版本的字段名会有出入但思路是一样的告诉 Bao 有多少个 hart、内存从哪里开始多大、串口和外设在哪里。这些值必须从 BPI-SM10 的原理图和数据手册里逐个抠出来不能凭空猜。如果你找不到数据手册直接看官方 BSP 的 dts 文件里面的memory、uart、plic节点会告诉你准确地址。4.2 从数据手册或 DTS 里读哪些参数移植 hypervisor 时要关注的参数比移植普通裸机程序多因为要分配中断和内存给多个 guest。我列了一张自查表参数来源说明DDR 起始地址SoC memory map通常是 0x80000000DDR 总大小板卡设计决定你分区怎么切UART 基址SoC 外设地址表不是所有板子都叫 uart0UART 中断号中断控制器表Bao 配置和 DTB 都得用中断控制器类型SoC 特性RVA23 平台多为 APLIC IMSIC定时器类型是否支持 Sstc影响虚拟定时器实现缓存行大小数据手册Zicbom 相关的 DMA 配置BPI-SM10 这类板子的 BSP 一般会在内核 dts 里把 SoC 地址写得很清楚。你需要的不是照着抄而是把范围缩小到 hypervisor 和 guest 真正用到的部分避免 guest 拿到过大的内存描述。4.3 设备树是 guest 的“世界观”必须裁剪这里说一个很多教程没提的细节Bao 是静态分区 hypervisor但 guest 里跑 Linux 时Linux 会去解析自己收到的设备树。如果你把 U-Boot 传过来的完整 DTB 直接给 guestLinux 会认为自己拥有整个 DDR 的权限然后疯狂初始化它看到的所有外设包括其他 guest 占用的内存。这次第的结果不是启动失败而是两个系统互相踩内存连串口打印都是乱的。所以正确做法是给每个 guest 一份“裁剪过的世界观”。用设备树工具链操作# 反编译 U-Boot 的完整 DTB dtc -I dtb -O dts -o full.dts u-boot.dtb # 手工编辑 full.dts只保留当前 guest 需要的内容 # 修改 memory 节点为 guest 分区对应的地址范围 # 删除其他 guest 独占的设备节点 # 重新编译成 DTB dtc -I dts -O dtb -o guest0.dtb guest0.dts裁剪的核心原则是“按需分配”guest 的 DTB 里只描述它自己用到的 UART、定时器、PLIC/APLIC 节点内存范围严格控制在 Bao 给它划分的区间内。第一次做 Linux guest 时我建议先把 DTB 里的节点删到最简跑通了再逐步加设备排查起来容易得多。5. RISC-V 虚拟化扩展中断与定时器的底层细节5.1 hgatp 和两个地址世界RISC-V H 扩展最核心的机制是两阶段地址翻译。guest 自己用satp做虚拟地址到“guest 物理地址”的翻译hypervisor 用hgatp做“guest 物理地址”到“机器物理地址”的翻译。硬件会自动把两级页表串起来guest 完全感受不到第二级的存在。打个比方guest 以为自己的内存地图是一套真实户型图但实际上那只是中介给你看的美化版hypervisor 手里的hgatp才是楼盘真实图纸标注了每一块地实际归谁。Bao 的静态内存隔离就是通过给每个 VM 建立独立的 G-stage 页表实现的一个 VM 的 guest 物理地址 0x80000000 可能对应机器物理地址 0x81000000另一个 VM 的 0x80000000 对应 0x82000000。换个 vCPU 时要注意刷新 TLB。普通程序切satp用sfence.vma但 hypervisor 切hgatp后需要执行hfence.gvma或hfence.vvma这些 H 扩展特有的 fence 指令。RVA23 的 Svinval 扩展提高了这个操作的效率但前提是你的代码真的调用了正确的 fence。5.2 guest 外部中断hgeip / hgeie 怎么用中断注入是 hypervisor 最容易出 bug 的地方。RISC-V 在这里提供了一对专门用于虚拟化的 CSRhgeiphypervisor guest external interrupt pending和hgeieenable。当物理中断控制器APLIC / PLIC / IMSIC把某个中断定向到一个 vCPU 时硬件会设置hgeip的对应位。如果你的平台把某个外设完全直通给了 guestBao 会开启hgeie让这个中断直接以虚拟外部中断的形式呈现给 guesthypervisor 不用每次中断都介入延迟很低。如果某个中断需要 hypervisor 处理Bao 就把该位关掉物理中断 trap 到 HS 模式软件处理后再重新分发。实际配置时最容易犯的错是没把 PLIC/APLIC 的中断目标配成 guest vCPU 所在的 hart 和 context导致中断来了之后hgeip永远不置位。排查时要同时检查中断控制器的 target 寄存器与hgeie两边都对了中断才通。5.3 虚拟定时器Sstc 扩展带来的质变RISC-V 的定时器体系这几年变化很大。早期实现里S 模式要通过 SBI 调用请 M 模式的 OpenSBI 帮忙设置下一个定时器一趟 ecall 来回开销不小。Sstc 扩展把stimecmp这个比较寄存器直接开放给 S/HS 模式大家各自设置定时器不再每次打断 M 模式。但在虚拟化场景里guest 自己写stimecmp会在 VS 模式触发 illegal instruction陷入 HS 模式的 hypervisor。Bao 的做法通常是 trap-and-emulate捕获 guest 对stimecmp的读写换算成物理定时器的绝对时间再设置真实的比较寄存器。由于 Bao 是静态分区guest 对定时器的写操作频率很低trap 开销完全可以接受。RVA23 平台的 Sstc 是硬件必选所以不用担心没有这个扩展。如果你是往更老的 RISC-V 板卡上移植没有 Sstc 时就得走 SBI timer 兼容路径代码要多一些分支。6. 编译、加载与启动实测6.1 编译步骤与常见编译错误环境准备好之后编译命令很简单export CROSS_COMPILEriscv64-linux-gnu- make PLATFORMbpism10输出文件在bin/下面bao.elf是带调试信息的 ELFbao.bin是纯二进制镜像加载时用 bin。编译报错最常见的有两类。一类是relocation truncated to fit: R_RISCV_GPREL_I处理方式去检查 link.ld 里.sdata和.srodata段是否声明、是否离 gp 定义位置太远。另一类是undefined reference to _end或__bss_start说明链接脚本的符号导出和你 C 代码里的 extern 声明对不上。这些问题基本都能在 link.ld 里找到答案。6.2 U-Boot 下加载 Bao 并跳转把bao.bin放到 SD 卡 FAT 分区进入 U-Boot 命令行setenv loadaddr 0x80200000 fatload mmc 0:1 ${loadaddr} bao.bin go ${loadaddr}fatload从 mmc 0 的第一个分区读取文件到 0x80200000go直接跳转执行。注意go和bootm不一样bootm会解析镜像头go是裸跳所以必须加载 raw bin。跳转后如果串口没有任何输出优先怀疑三个地方加载地址和链接地址不一致、UART 基址配置不对、_start汇编在设置串口前就崩了。我会在下一节给完整的排查思路。6.3 启动日志逐段解读正常启动时Bao 会打印类似下面的日志Bao hypervisor v0.x - CPU: 8 hart(s) 1.0 GHz - RAM: 0x80000000 - 0x83FFFFFF - VM[0]: OK, entry0x81000000 - VM[1]: OK, entry0x82000000 - VM[2]: OK, entry0x83000000这段日志说明三件事第一Bao 已经从 HS 模式接管了所有 hart第二G-stage 页表建立完成内存分区成功第三每个 VM 的 vCPU 上下文和中断配置都已就绪。如果日志卡在 “CPU: 8 hart(s)” 这一行之后不再往下走通常是次要 hart 没有正确进入 C 代码需要检查多核启动汇编里按 hartid 分配栈的逻辑。6.4 多核启动的陷阱RVA23 平台往往有多个 hart甚至几十个。OpenSBI 跳转到 Bao 时每个 hart 都会执行_start。如果_start代码里没有对 hartid 做区分所有核会一起抢同一个栈、同一个初始化锁直接崩掉。我采用的模式是入口先把 a0 hartid 存起来根据 hartid 计算每个核的独立栈地址主 hart通常是 hart0负责初始化内存管理、页表、中断控制器其他 hart 自旋等待主 hart 发布启动信号。Bao 的架构里已经集成了类似机制你要做的是在平台描述里正确提供栈空间大小和基址别让不同核的栈重叠。7. 问题排查与实测心得7.1 问题排查速查表整理一张表方便后续参考现象优先排查工具 / 方法跳转后串口无输出链接地址 vs 加载地址、UART 基址、BSS 未清在_start打印 PC 值执行到 C 代码前非法指令当前模式不是 HS或指令集扩展不匹配读 mstatus / hstatus进入 Bao 后 guest 一跑就 Page Faulthgatp 页表未建、guest 内存越界、DTB 越界检查 VM 配置的 memory 范围中断风暴 / 反复 traphgeie 配置错误、PLIC target 配置错误检查hgeip值Linux guest 启动卡死DTB 里内存范围超过分区、串口冲突裁剪 DTB 后逐个验证性能比裸机差很多切换 vCPU 时 TLB 没刷对确认使用hfence.gvma7.2 先用 QEMU 再上板子效率翻倍移植 hypervisor 到新板卡最痛苦的是每次调试都要烧 SD 卡、拔插、看串口。我的实践是先在 QEMU 的virt机器上把平台描述、链接脚本、中断路径全部验证一遍再上板子。QEMU 支持 RISC-V 虚拟化扩展命令如下qemu-system-riscv64 -machine virt -m 1G -nographic \ -bios opensbi.elf \ -device loader,filebao.bin,addr0x80200000QEMU 里跑通之后板子上大概率只是地址、时钟、串口这类设备差异不会再有体系结构级别的 bug。这一步帮我省了至少三分之二的时间。7.3 实测中断延迟与后续扩展在 BPI-SM10 上我让 guest0 跑一个裸机程序定时翻转 GPIO用逻辑分析仪量得中断响应大约 1.5 微秒左右。这个数字已经非常接近裸机水平说明 Bao 在 RISC-V 上走的是一条低开销的直通路径没有不必要的 trap。接下来可以做的事还很多给 guest 加 DMA 直通需要配合平台 IOMMU 做地址映射跑完整 Linux guest需要把 rootfs 和 DTB 进一步裁剪用 Bao 的多 VM 配置把 RTOS、Linux、裸机程序混合部署形成典型的安全隔离方案。每一步都不容易但基础一旦打好后面都是体力活。我个人在实际操作中的体会是移植 hypervisor 到新的 RISC-V 板卡真正卡人的不是 C 代码而是 link.ld 和启动汇编这段“低频知识”。只要入口能稳定执行 C 代码、串口能打印日志剩下的都是时间问题。最后再分享一个小技巧我在_start里永远保留一个三行汇编写的串口打印函数每次调整链接脚本或者内存布局后先验证这个函数输出一个固定字符串再继续往下调。这个习惯已经救了我无数次。