aboutbloggithub

实现一个 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:

first-inst-guest-page-fault

内核出现了一个有趣的错误名称: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 原因:

first ecall

第三步:来自客户机的 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

它奏效了!

minimal hello world

第四步:启动 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 启动了,但它因空指针解引用而崩溃:

null dereference in device tree

根据 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 中,有两种方法可以实现定时器:

此步骤也是我第一次需要将中断注入到客户机中。 虽然 RISC-V 规范很清晰,但我完全迷失了如何做到这一点。 设置 hideleg 是我需要做的,但我对 RISC-V Advanced Interrupt Architecture 感到困惑,该架构扩展了 RISC-V 中断处理。

RISC-V 扩展有时看起来像是对基础规范的“补丁”。 RISC-V Unified Database 是了解扩展如何扩展基础规范的重要资源。

一旦我启用了 sstc 扩展并正确实现了中断处理,内核就成功启动了:

linux-hello-world

第八步:MMIO 支持

Linux 在 Starina 的虚拟机监控器上成功启动。 下一步是提供设备。 在 RISC-V 中,与其他架构一样,设备映射到物理地址(内存映射 I/O)。 需要模拟的设备有:

在虚拟机监控器中,MMIO 是通过 在地址范围内映射任何内容来实现的。 也就是说,每次客户机尝试访问地址范围时:

  1. CPU 尝试读取/写入 MMIO 地址,但未映射。 触发客户机页面错误。
  2. 虚拟机监控器检查地址范围是否为 MMIO 地址。
  3. 解码指令以确定目标/源寄存器以及访问宽度。
  4. 模拟 MMIO 操作。
  5. 如果是读取,则将值写入寄存器。
  6. 将程序计数器前进到下一条指令,并恢复客户机。

这听起来不是很 hack 吗? 在 MMIO 中,虚拟机监控器的行为类似于 CPU 解释器。 幸运的是,RISC-V 非常简单,并在 htinst CSR 中为我们提供了指令摘要。 在 x86-64 中,... 祝你好运!

对于未来的 RISC-V 虚拟机监控器开发人员,以下是我遇到的一些陷阱:

第九步:virtio-fs

这是本文的最后一步。 虽然 virito-blk 是提供根文件系统的典型选择,但我选择了一些不同的东西:virtio-fs。

为什么? 因为它允许与 Starina 更无缝地集成。 我计划写另一篇文章来介绍这种集成,但简而言之,Starina 为客户机提供了一些虚拟文件。

Virtio-fs 是 Virtio 上的虚拟文件系统。 更具体地说,它使用 FUSE(Userspace 文件系统)协议与客户机内核通信。

关于此步骤,我没有什么可说的,因为我们已经完成了虚拟机监控器特定的步骤! 设计一个好的 virtio 库,在其上实现 virtio-fs 仿真,你就完成了:

virtio-fs

哎呀,就是这样!

提示:调试虚拟机监控器/客户机世界

Starina 支持 Unikernel 模式,其中微内核和应用程序构建到单个 ELF 可执行文件中。 这不仅是为了性能,而且是为了调试。

这是一个 gdbinit 脚本,使我能够观察 Starina 内的 VMM、虚拟机监控器和客户机内的 Linux 内核:

# 加载 Starina(虚拟机监控器的)调试信息
file build/kernel/debug/kernel
# 加载 Linux(客户机的)调试信息
add-symbol-file path/to/vmlinux

看! 你正在看到客户机的内核堆栈跟踪!

gdb

这就是为什么我还没有在 Starina 中实现堆栈跟踪的原因:你只需要附加 GDB 并键入 bt。 — Written by Seiya NutaCC BY 4.0