手把手搭建RISC-V虚拟化环境:QEMU与OpenSBI实战指南
1. 项目概述与环境准备最近在折腾RISC-V虚拟化发现很多朋友卡在了第一步如何用QEMU快速搭建一个包含OpenSBI的、能跑起来的仿真环境。网上的教程要么太零散要么直接给个命令却不解释为什么导致大家跟着敲完要么启动失败要么一脸懵不知道自己在做什么。今天我就以“创建OpenSBI虚拟化环境”为核心手把手带你走一遍不仅告诉你命令怎么敲更要把每个参数背后的逻辑、可能遇到的坑以及排查思路讲透。这个环境是我们后续深入Linux内核、研究RISC-V虚拟化的基石搭建稳了后面的路才好走。简单来说我们要做的是使用QEMU模拟一台支持虚拟化扩展的RISC-V 64位机器然后让OpenSBI作为M-mode固件先启动为后续加载Hypervisor或操作系统做好准备。听起来简单但涉及QEMU机型选择、设备树传递、OpenSBI编译选项、内存布局等多个关键点任何一个环节配置不当屏幕就可能只剩下一片寂静启动失败。别担心我会把每个环节掰开揉碎了讲。开始前你需要准备好以下“食材”一台Linux开发机Ubuntu 22.04 LTS或更高版本是我的主力环境其他发行版也可但包管理命令可能不同。安装好的QEMU版本建议7.0以上必须包含qemu-system-riscv64这个可执行文件。OpenSBI源代码我们将从官方仓库拉取并自己编译确保可控。基础的命令行操作能力会用git,make,gcc等工具。如果你的QEMU还没安装在Ubuntu上一条命令搞定sudo apt update sudo apt install qemu-system-misc安装后用qemu-system-riscv64 --version验证一下。接下来我们进入核心环节。1.1 核心需求与目标拆解为什么非要自己搭建这个环境直接下载别人做好的镜像不行吗对于学习和深度研究而言不行。预编译的镜像是一个黑盒你无法定制OpenSBI的功能比如开启哪些SBI扩展也不清楚内存的精确布局更无法在启动的最早期OpenSBI阶段插入调试信息。我们的目标不仅仅是“跑起来”而是要建立一个透明、可定制、可调试的仿真实验平台。具体来说这个环境需要满足几个核心目标目标一正确的机器模型。QEMU能模拟多种RISC-V机器virtsifive_u等。我们需要选择一款支持RISC-V Hypervisor扩展H扩展的机器virt机器是最通用、支持最全的选择。目标二完整的固件链。OpenSBI需要被正确编译成QEMU可以加载的格式通常是ELF或bin并放置到模拟内存的特定地址通常是0x80000000。QEMU会在复位后从这里开始执行第一条指令。目标三清晰的调试接口。在环境启动失败时我们必须有手段进行诊断。QEMU的-d参数、-singlestep模式以及OpenSBI自身的串口输出是我们的“眼睛”。目标四为后续扩展留好接口。这个环境最终要能加载一个Hypervisor如KVM或Xvisor或者直接引导Linux内核。因此设备树Device Tree的传递、内存节点的设置必须规范。基于这些目标我们的工作流可以概括为获取源码 - 配置编译 - 组合启动命令 - 验证与调试。下面我们就深入每个步骤的细节。2. 核心组件解析与编译实战2.1 QEMUvirt机器模型深度解析QEMU的命令行参数繁多对于RISC-Vvirt机器有几个参数是构建虚拟化环境的基石理解它们至关重要。qemu-system-riscv64 \ -machine virt \ -cpu rv64,htrue \ -m 1G \ -smp 2 \ -nographic \ -bios opensbi/build/platform/generic/firmware/fw_jump.elf \ -device loader,file./payload.elf,addr0x80200000我们来逐一拆解-machine virt指定机器类型为virt。这是一个由QEMU虚拟的、不针对任何具体硬件的平台它提供了标准化的硬件组件如PLIC中断控制器、CLINT定时器、UART串口、PCIe总线等并且默认支持RISC-V的H扩展虚拟化。这是选择它的首要原因。-cpu rv64,htrue指定CPU模型。rv64是基础指令集RV64GC。htrue是灵魂参数它启用了RISC-V的Hypervisor扩展。没有这个参数CPU将无法进入HS-modeHypervisor扩展的监督者模式虚拟化也就无从谈起。你可以通过-cpu rv64,help查看所有可用的扩展选项。-m 1G为虚拟机分配1GB的物理内存。对于运行OpenSBI和一个轻量级Payload如简单的测试内核来说足够了。-smp 2配置2个CPU核心。多核对研究SMP对称多处理下的启动流程、中断路由很有帮助。-nographic禁用图形界面将所有输出重定向到命令行终端并通过标准输入模拟串口输入。这是服务器环境或SSH连接下的常用模式也是我们调试时的主要界面。-bios ...指定BIOS或固件文件。这里我们指向编译好的OpenSBI ELF文件。QEMU会在机器复位后自动将该文件加载到virt机器约定的固件加载地址通常是0x80000000并开始执行。-device loader,...这是一个非常强大的选项。它允许我们在QEMU启动时将一个额外的二进制文件我们称为payload加载到指定的物理内存地址这里是0x80200000。OpenSBI启动后可以跳转到这个地址执行。这通常用于加载第二阶段引导程序如U-Boot或一个简单的测试内核。注意-bios参数和-kernel参数在QEMU中有区别。-bios用于加载底层固件如OpenSBI而-kernel是让QEMU直接加载一个Linux内核镜像并自动修改设备树使其符合Linux的启动协议。在我们的场景中我们使用-bios加载OpenSBI由OpenSBI去引导后续的payload这样更符合真实硬件的启动链条。2.2 OpenSBI 的获取、配置与编译细节OpenSBI的编译并不复杂但选项的配置决定了生成固件的功能和内存布局。# 1. 获取源代码 git clone https://github.com/riscv-software-src/opensbi.git cd opensbi # 2. 创建编译输出目录并进入 mkdir -p build cd build # 3. 配置编译选项 ../configure \ PLATFORMgeneric \ FW_PAYLOADn \ FW_JUMPy \ FW_TEXT_START0x80000000 \ CROSS_COMPILEriscv64-linux-gnu-关键配置选项解读PLATFORMgeneric这是针对QEMUvirt机器或通用RISC-V平台的配置。不要选错。FW_PAYLOAD和FW_JUMP这是两种不同的固件类型。FW_PAYLOADy会将指定的Payload如Linux内核直接打包进OpenSBI的二进制文件中。生成的是一个“二合一”的镜像。优点是使用简单QEMU只需加载这一个文件。缺点是镜像大小固定Payload更换需要重新编译OpenSBI。FW_JUMPy这是我们推荐的方式。OpenSBI编译生成一个独立的固件fw_jump.elf。它不包含Payload但知道Payload的预期加载地址通过FW_JUMP_ADDR配置或由QEMU的-device loader动态加载。OpenSBI启动后会直接跳转到那个地址执行。这种方式更灵活符合我们使用-device loader动态加载测试程序的需求。FW_TEXT_START0x80000000指定OpenSBI固件自身在内存中的起始地址。对于QEMUvirt机器这个地址是固定的。必须确保QEMU的-bios参数加载的地址与此一致。CROSS_COMPILEriscv64-linux-gnu-指定交叉编译工具链的前缀。你需要预先安装RISC-V 64位的GCC工具链例如通过apt install gcc-riscv64-linux-gnu。如果是在RISC-V原生主机上编译则可以省略或设为空。配置完成后执行编译make -j$(nproc)编译成功后在build/platform/generic/firmware/目录下你会找到几个重要的文件fw_jump.elf/fw_jump.bin我们主要使用的Jump模式固件。fw_payload.elfPayload模式固件。firmware.elf一个包含调试信息的ELF文件可用于GDB调试。实操心得编译时如果遇到“找不到riscv64-linux-gnu-gcc”的错误请确认工具链已安装且路径正确。你可以通过riscv64-linux-gnu-gcc --version来测试。另外建议始终使用FW_JUMP模式因为它给了你最大的灵活性去动态切换不同的测试Payload而无需反复编译OpenSBI。2.3 准备一个简单的测试PayloadOpenSBI需要一个“下一级”程序来跳转。为了验证环境我们编写一个最简单的RISC-V汇编程序它仅仅通过SBI调用由OpenSBI提供在串口上打印一个字符。创建一个文件test_payload.S.section .text.start, ax, progbits .globl _start _start: // 使用SBI控制台输出调用 (SBI_EXT_0_1_CONSOLE_PUTCHAR) li a7, 0x1 // SBI扩展ID: 0x1 (控制台) li a6, 0x0 // SBI函数ID: 0x0 (输出字符) li a0, H // 要输出的字符 H ecall // 触发环境调用进入OpenSBI li a0, i ecall // 使用SBI系统关机调用 (SBI_EXT_0_1_SHUTDOWN) li a7, 0x8 // SBI扩展ID: 0x8 (系统复位) li a6, 0x0 // SBI函数ID: 0x0 (关机) ecall // 如果关机调用失败进入死循环 1: j 1b这个程序做了两件事1) 调用OpenSBI的putchar功能打印“Hi”2) 调用shutdown功能让QEMU退出。接着编译它riscv64-linux-gnu-gcc -nostdlib -nostartfiles -static -mcmodelmedany \ -Ttext0x80200000 \ -o payload.elf \ test_payload.S-Ttext0x80200000至关重要这指定了程序的链接地址即它期望自己被加载到的物理地址。这个地址必须和QEMU命令中-device loader,addr指定的地址完全一致也必须避开OpenSBI自身占用的内存区域0x80000000附近。0x80200000是一个常见的安全偏移。-mcmodelmedany指定代码模型与位置无关代码有关对于这种小程序使用medany通常可以。现在我们有了opensbi/build/platform/generic/firmware/fw_jump.elf和payload.elf。3. 环境启动、验证与深度调试3.1 组合启动命令与首次运行将前面所有的部分组合起来形成最终的启动命令qemu-system-riscv64 \ -machine virt \ -cpu rv64,htrue \ -m 1G \ -smp 2 \ -nographic \ -bios opensbi/build/platform/generic/firmware/fw_jump.elf \ -device loader,file./payload.elf,addr0x80200000 \ -serial mon:stdio执行这条命令如果一切顺利你将在终端看到类似以下的输出然后QEMU进程退出OpenSBI v2.0 ____ _____ ____ _____ / __ \ / ____| _ \_ _| | | | |_ __ ___ _ __ | (___ | |_) || | | | | | _ \ / _ \ _ \ \___ \| _ | | | |__| | |_) | __/ | | |____) | |_) || |_ \____/| .__/ \___|_| |_|_____/|____/_____| | | |_| Platform Name : riscv-virtio,qemu Platform Features : medeleg Platform HART Count : 2 Firmware Base : 0x80000000 Firmware Size : 296 KB Runtime SBI Version : 2.0 Domain0 Name : root Domain0 Boot HART : 0 Domain0 HARTs : 0*,1* Domain0 Region00 : 0x0000000002000000-0x000000000200ffff (I) Domain0 Region01 : 0x0000000080000000-0x000000008007ffff () Domain0 Region02 : 0x0000000000000000-0xffffffffffffffff (R,W,X) Domain0 Next Address : 0x0000000080200000 Domain0 Next Arg1 : 0x0000000082200000 Domain0 Next Mode : S-mode Domain0 SysReset : yes Boot HART ID : 0 Boot HART Domain : root Boot HART ISA : rv64imafdch_zicsr_zifencei_zihintpause_zba_zbb_zbc_zbs_sstc Boot HART Features : scounteren,mcounteren,time Boot HART PMP Count : 16 Boot HART PMP Granularity : 4 Boot HART PMP Address Bits: 54 Boot HART MHPM Count : 0 Boot HART MIDELEG : 0x0000000000000222 Boot HART MEDELEG : 0x000000000000b109 Hi恭喜你成功创建了一个OpenSBI虚拟化环境。输出解读前一部分是OpenSBI的启动横幅和平台信息显示了它检测到的virt机器、2个HART硬件线程、固件位置等信息。Domain0 Next Address : 0x0000000080200000这一行非常关键它表明OpenSBI已经准备好要跳转到0x80200000这个地址正是我们Payload的加载地址去执行并且下一个特权级是S-mode。紧接着的“Hi”就是我们Payload程序通过SBI调用打印出来的字符。打印完成后Payload调用shutdownQEMU正常退出。3.2 高级调试技巧与问题排查实录环境搭建很少一帆风顺。下面是我在无数次失败中总结出的排查工具箱。问题一QEMU启动后无任何输出直接退出或卡住。可能原因1OpenSBI固件加载地址错误。排查检查-bios参数指定的文件路径是否正确。确认编译OpenSBI时FW_TEXT_START是否为0x80000000。virt机器的固件加载地址是固定的。调试在QEMU命令中加入-d in_asm,cpu,exec参数这会将CPU执行的每一条指令的汇编代码打印出来信息量巨大但能定位最早期的执行流。如果看到第一条指令地址就不是0x80000000那肯定是加载出了问题。可能原因2Payload加载地址冲突或错误。排查确认-device loader,addr的地址与Payload链接地址-Ttext完全一致。确认该地址没有覆盖OpenSBI的代码或数据区。OpenSBI的FW_TEXT_START到FW_TEXT_STARTFW_SIZE这块内存是禁区。调试使用-d guest_errors参数让QEMU在访存错误时打印信息。如果Payload跳转后立即触发非法访存很可能是地址问题。可能原因3CPU扩展不支持。排查确认-cpu rv64,htrue中的htrue已添加。没有H扩展OpenSBI可能无法完成某些初始化。调试启动后在QEMU监控台按CtrlA C进入再按CtrlA C返回输入info registers查看misa机器ISA寄存器的值确认H位是否被置位。问题二能看到OpenSBI横幅但看不到“Hi”或打印乱码。可能原因1SBI调用参数错误。排查对照最新的RISC-V SBI规范检查Payload汇编代码中的a7扩展ID、a6函数ID是否正确。早期版本和最新版本的SBI调用号可能有变化。调试在QEMU命令中加入-d sbi参数如果QEMU编译时启用了SBI调试可以打印出所有的SBI调用请求和响应。可能原因2串口输出尚未初始化。分析虽然OpenSBI已经初始化了串口但某些极端情况下Payload可能需要在打印前做额外设置。但我们的简单Payload直接依赖OpenSBI的服务所以此可能性较低。调试尝试在Payload中连续调用多次putchar或者打印一个更长的字符串看是否有部分输出。问题三如何单步调试OpenSBI或Payload的早期代码这是深入理解启动流程的利器。我们需要使用GDB配合QEMU。启动QEMU并等待GDB连接qemu-system-riscv64 -machine virt -cpu rv64,htrue -m 1G -nographic \ -bios fw_jump.elf \ -device loader,filepayload.elf,addr0x80200000 \ -s -S-s是-gdb tcp::1234的简写在TCP的1234端口开启GDB服务器。-S表示启动后暂停CPU等待GDB的continue命令。在另一个终端启动GDBriscv64-linux-gnu-gdb opensbi/build/platform/generic/firmware/fw_jump.elf在GDB shell中(gdb) target remote localhost:1234 (gdb) break *0x80000000 # 在OpenSBI入口处打断点 (gdb) continue此时CPU会在执行第一条指令前停下。你可以使用stepi单步指令、break *addr在地址打断点、x/i $pc查看当前指令等进行调试。调试Payload同样你可以用GDB加载payload.elf的符号然后在0x80200000处打断点单步跟踪SBI调用的执行过程。避坑技巧调试时建议先单独验证OpenSBI的启动不加-device loader再用一个绝对简单的Payload比如只有一条ebreak指令的循环验证跳转流程最后再叠加复杂功能。分而治之是解决复杂系统启动问题的黄金法则。4. 虚拟化环境验证与进阶配置4.1 验证虚拟化扩展H扩展已启用环境跑起来只是第一步。我们搭建的是“虚拟化环境”必须确认H扩展确实可用。OpenSBI的启动信息里已经包含了CPU的ISA字符串。在上面输出的Boot HART ISA一行中如果你看到了h这个字母就证明H扩展已启用。例如rv64imafdch_...中的h。你还可以在Payload中通过读取misa寄存器或尝试执行Hypervisor相关的指令如hfv来进一步验证但对于当前目标OpenSBI的识别信息已经足够权威。4.2 为后续实验铺路设备树Device Tree的传递一个完整的操作系统或Hypervisor需要知道硬件的拓扑结构这是通过设备树DTB传递的。QEMU可以自动生成并传递设备树给内核但在我们的链条中QEMU - OpenSBI - Payload设备树需要由OpenSBI传递给下一级。OpenSBI的Jump模式固件fw_jump设计时期望在a1寄存器中收到上一个阶段即QEMU传递来的设备树地址DTB地址。而QEMU的virt机器在启动时会将DTB的地址放在a1寄存器传递给固件。我们可以让QEMU将DTB文件加载到内存并通过-device loader设置a1寄存器。但更常见的做法是让我们的Payload比如一个简单的Hypervisor或内核具备解析设备树的能力。OpenSBI会在跳转前将DTB地址放置在约定的寄存器通常是a1中。为了验证这一点我们可以修改Payload让它读取a1寄存器并尝试解析DTB的魔数0xd00dfeed来确认。# 首先让QEMU将设备树二进制文件dtb也加载到内存 qemu-system-riscv64 -machine virt -cpu rv64,htrue -m 1G -nographic \ -bios fw_jump.elf \ -device loader,file./payload.elf,addr0x80200000 \ -device loader,file./virt.dtb,addr0x82200000这里我们将一个预先编译好的virt.dtb可以从QEMU源码或Linux内核源码中获取或使用dtc工具编译加载到了0x82200000。OpenSBI在跳转到0x80200000时会将这个地址放入a1寄存器。我们的Payload需要从这个地址去读取设备树。4.3 内存布局规划与避坑指南一个清晰的内存布局图是避免冲突的关键。以下是典型布局地址范围用途说明0x0000_0000-0x3fff_ffffDRAM1GB物理内存的主要部分。0x8000_0000OpenSBI入口QEMU-bios加载固件的地址。0x8000_0000-0x8007_ffffOpenSBI代码/数据区约512KB具体大小看编译输出。0x8020_0000Payload入口我们自定义程序的加载点需对齐2MB。0x8200_0000设备树DTB通常放在Payload之后64KB空间足够。0x1000_0000外设MMIO空间Virtio设备、UART、PLIC等在此区域。重要注意事项地址对齐Payload的加载地址最好按2MB对齐如0x80200000这与RISC-V的大页Megapage和某些引导协议的要求有关能避免不必要的性能损耗和兼容性问题。空间预留在Payload地址和DTB地址之间以及DTB之后要预留足够的空间给Payload的代码、数据、BSS段以及运行时堆栈的增长。不要卡着地址边界放置。使用-device loader检查QEMU的-device loader不会检查地址重叠。如果两个文件加载到同一区域后者会静默覆盖前者导致诡异错误。务必手动规划或编写脚本检查。从简单开始初次搭建可以先忽略DTB只让OpenSBI跳转到Payload并打印字符。成功后再逐步引入设备树、增加内存大小、增加CPU核心数。至此一个功能完整、透明可控、支持调试的RISC-V OpenSBI虚拟化环境就搭建并验证完毕了。这个环境就像一块干净的实验底板接下来你可以尝试加载一个真正的RISC-V Linux内核需要构建Image和dtb或者开始编写你自己的小型Hypervisor去探索RISC-V虚拟化扩展的具体指令和机制。记住所有复杂的系统都是从这样一个能打印“Hi”的简单环境一步步构建起来的。