实现一个 RISC-V 虚拟机监控器 (Hypervisor)
实现一个 RISC-V 虚拟机监控器 (Hypervisor)
2025-05-17
为了将 Linux 无缝集成到 Starina 中,我决定采用类似于 WSL2 的 Linux 轻量级 VM 方法。 这意味着我需要实现一个可以运行 Linux 的虚拟机监控器 (Hypervisor)。
之前我实现了一个基于 Intel VT-x 的虚拟机监控器,但这次我想尝试一些不同的东西:基于 RISC-V H-extension 的虚拟机监控器!
这篇文章是我逐步编写 RISC-V 虚拟机监控器之旅的日记。
RISC-V H-extension
RISC-V H-extension 引入了新的 CPU 模式和更多 CSR(所谓的控制寄存器)来实现硬件辅助虚拟化。 它的设计类似于 Intel VT-x,因为宿主机和客户机模式都有自己的内核模式和用户模式。 这种设计使得宿主机操作系统和客户机可以轻松地一起运行,也就是说,客户机表现为正常的宿主机进程(例如 QEMU 和 Firecracker)。
如何在 macOS 上测试虚拟机监控器?
与基于 Linux KVM 的虚拟机监控器(更具体地说,是虚拟机监视器)不同,Starina 是一个新的操作系统,已经在 QEMU 上进行了测试。
在这种情况下,你通常需要使用嵌套虚拟化,其中硬件辅助虚拟化由宿主机操作系统模拟。 我对 Intel VT-x 就是这样做的。
好消息是:QEMU 本身可以模拟 RISC-V H-extension! 你只需要在 QEMU 命令行中添加 -cpu rv64,h=true
。 我认为这要归功于 RISC-V 的简洁性和设计者的远见卓识(当然还有 QEMU 开发人员的努力!)。
当你要从头开始编写新的操作系统时,在 QEMU 中进行软件仿真是一个关键的推动因素,因为你可以将 GDB 附加到 QEMU 以调试操作系统。
第一步:进入客户机
首先要做的是进入客户机状态。 在 RISC-V 中,客户机内核模式称为 VS-mode。 在 RISC-V 中,你只需要填写一些 CSR。 具体来说,在 sret
指令之前,hstatus.SPV
应该设置为 1:
内核出现了一个有趣的错误名称:instructionguest -page fault。 是的,CPU 已经进入客户机模式!
第二步:第一个 ecall
下一步是在客户机模式下运行一些东西。 让我们从一个简单的 ecall
开始:
const BOOT_CODE: &[u8] = &[
0x73, 0x00, 0x00, 0x00, // ecall
];
为了使其工作,我们需要准备客户机的页表,该页表将客户机物理地址映射到宿主机物理地址,以便 CPU 可以读取 BOOT_CODE
中的指令。
RISC-V 定义了另一种分页模式,称为 Sv39x4/Sv48x4/Sv57x4,它们与 Sv39/Sv48/Sv57 大致相同。 唯一的注意事项是对于内核页面,U 位也需要设置为 1。
设置 hgatp
后,我收到了另一个 trap 原因:
第三步:来自客户机的 Hello World!
现在我们准备好运行 Hello World 程序了。 我用汇编写了一个简单的程序:
.section .text
.global _start
_start:
li a0, 'H'
li a7, 1
ecall
li a0, 'i'
li a7, 1
ecall
li a0, '!'
li a7, 1
ecall
li a0, '\n'
li a7, 1
ecall
unimp
这假设虚拟机监控器的 ecall
处理程序实现了 SBI,这是一个 RISC-V 的 BIOS 接口。 此代码段调用所谓的 putchar
API,最后调用无效指令 (unimp
) 以触发 trap。
构建这个微小的客户机操作系统很容易:
$ clang --target=riscv64 -march=rv64g -nostdlib -Wl,-Ttext=0x80200000 guest.S -o guest.elf
$ llvm-objcopy -O binary guest.elf guest.bin
它奏效了!
第四步:启动 Linux
我们的最小客户机 Hello World 已经可以工作,所以现在可以尝试使用 Linux 了。
这是我启用的一些内核配置选项:
CONFIG_SERIAL_EARLYCON_RISCV_SBI=y
CONFIG_RISCV_SBI_V01=y
CONFIG_HVC_RISCV_SBI=y
CONFIG_RISCV_TIMER=y
可以使用以下命令构建 RISC-V 的 Linux 内核:
make ARCH=riscv CROSS_COMPILE=riscv64-linux-gnu- Image
启动映像格式在此处有记录:here,它基本上是原始二进制文件。 只需将映像文件复制到客户机内存中,并在 CPU 进入客户机模式时跳转到开头即可。
看起来 Linux 启动了,但它因空指针解引用而崩溃:
根据 sepc
值,内核在 __pi_fdt32_ld
处 panic:
$ gobjdump -d linux/vmlinux | grep -A 5 801cace8 | head
ffffffff801cace8 <__pi_fdt32_ld>:
ffffffff801cace8: 00054783 lbu a5,0(a0) <-- null deferecence here!
ffffffff801cacec: 00154703 lbu a4,1(a0)
ffffffff801cacf0: 0187979b slliw a5,a5,0x18
第五步:设备树
在 RISC-V 中,我们需要提供一个设备树,这是一个树形数据结构,用于定义计算机中可用的设备。
幸运的是,我们可以使用一个 Rust crate 来实现:vm-fdt,这是来自 RustVMM 项目(以 Firecracker 闻名)的设备树构建器。 该库支持 no_std,因此在 Starina 中使用它非常容易(谢谢,谢谢 RustVMM 的人!)。
为了使 Linux 正常工作,设备树至少应包括:可用内存区域(device_type = memory
中的 reg
)以及带有 RISC-V ISA 扩展的 CPU (riscv,isa-extensions
)。
第六步:rdtime
支持
添加设备树后,我得到了另一个 trap。 看起来 Linux 尝试读取 rdtime
,但由于未设置 hcounteren
而失败:
hcounteren 寄存器已清除,当 V=1 时尝试读取 cycle、time、instret 或 hpmcounter n 寄存器将导致 virtual-instruction 异常
解决方法是填充 hcounteren
CSR。 简单得很。
第七步:定时器支持
启动时,Linux 内核尝试探测 CPU 和外围设备的功能。 该初始化步骤主要由它自己完成,无需虚拟机监控器的任何帮助。 但是,有一个令人费解的部分:定时器速度检测。
在此步骤中,Linux 内核等待 jiffies
取得进展,如果虚拟机监控器没有实现定时器,则看起来好像挂起了。
在 RISC-V 中,有两种方法可以实现定时器:
sbi_set_timer
: 调用虚拟机监控器 (SBI) 以设置定时器。sstc
扩展: 一种 CPU 扩展,无需虚拟机监控器的帮助即可触发定时器中断。
此步骤也是我第一次需要将中断注入到客户机中。 虽然 RISC-V 规范很清晰,但我完全迷失了如何做到这一点。 设置 hideleg
是我需要做的,但我对 RISC-V Advanced Interrupt Architecture 感到困惑,该架构扩展了 RISC-V 中断处理。
RISC-V 扩展有时看起来像是对基础规范的“补丁”。 RISC-V Unified Database 是了解扩展如何扩展基础规范的重要资源。
一旦我启用了 sstc
扩展并正确实现了中断处理,内核就成功启动了:
第八步:MMIO 支持
Linux 在 Starina 的虚拟机监控器上成功启动。 下一步是提供设备。 在 RISC-V 中,与其他架构一样,设备映射到物理地址(内存映射 I/O)。 需要模拟的设备有:
- 一个中断控制器。 客户机内核需要它来读取待处理的中断并确认它们。 RISC-V 中的 PLIC。
- 磁盘设备: 根文件系统的存储位置。 Virtio-blk 是一种流行的选择。
- 网络设备: 用于网络。 Virtio-net 是最流行的选择。
在虚拟机监控器中,MMIO 是通过 不 在地址范围内映射任何内容来实现的。 也就是说,每次客户机尝试访问地址范围时:
- CPU 尝试读取/写入 MMIO 地址,但未映射。 触发客户机页面错误。
- 虚拟机监控器检查地址范围是否为 MMIO 地址。
- 解码指令以确定目标/源寄存器以及访问宽度。
- 模拟 MMIO 操作。
- 如果是读取,则将值写入寄存器。
- 将程序计数器前进到下一条指令,并恢复客户机。
这听起来不是很 hack 吗? 在 MMIO 中,虚拟机监控器的行为类似于 CPU 解释器。 幸运的是,RISC-V 非常简单,并在 htinst
CSR 中为我们提供了指令摘要。 在 x86-64 中,... 祝你好运!
对于未来的 RISC-V 虚拟机监控器开发人员,以下是我遇到的一些陷阱:
- 写入
htval
的客户机物理地址向右移动了 2 位。 如果你忘记了这一点,你将看到一个奇怪的地址。 htinst
可能是压缩指令。 这意味着指令宽度不总是 4 字节,而有时是 2 字节。
第九步:virtio-fs
这是本文的最后一步。 虽然 virito-blk 是提供根文件系统的典型选择,但我选择了一些不同的东西:virtio-fs。
为什么? 因为它允许与 Starina 更无缝地集成。 我计划写另一篇文章来介绍这种集成,但简而言之,Starina 为客户机提供了一些虚拟文件。
Virtio-fs 是 Virtio 上的虚拟文件系统。 更具体地说,它使用 FUSE(Userspace 文件系统)协议与客户机内核通信。
关于此步骤,我没有什么可说的,因为我们已经完成了虚拟机监控器特定的步骤! 设计一个好的 virtio 库,在其上实现 virtio-fs 仿真,你就完成了:
哎呀,就是这样!
提示:调试虚拟机监控器/客户机世界
Starina 支持 Unikernel 模式,其中微内核和应用程序构建到单个 ELF 可执行文件中。 这不仅是为了性能,而且是为了调试。
这是一个 gdbinit
脚本,使我能够观察 Starina 内的 VMM、虚拟机监控器和客户机内的 Linux 内核:
# 加载 Starina(虚拟机监控器的)调试信息
file build/kernel/debug/kernel
# 加载 Linux(客户机的)调试信息
add-symbol-file path/to/vmlinux
看! 你正在看到客户机的内核堆栈跟踪!
这就是为什么我还没有在 Starina 中实现堆栈跟踪的原因:你只需要附加 GDB 并键入 bt
。
— Written by Seiya NutaCC BY 4.0