跳转至

QEMU 训练营 2026 专业阶段总结

主要贡献者


背景介绍

本人是一名计科大三学生

参加 QEMU 训练营之前,我对 QEMU 的理解更多停留在“它可以启动虚拟机”这一层。真正进入训练营后才慢慢意识到,QEMU 不只是一个命令行工具,它背后有一整套模拟硬件、连接设备、解释或翻译指令、组织测试用例的工程体系。

我选择继续完成专业阶段,一方面是想把基础阶段里学到的 QOM、MMIO、PCI、qtest 等概念真正落到代码上;另一方面也希望借这个机会接触 GPGPU 方向。

专业阶段

我选择的是 GPGPU 实验方向。这个方向的目标不是实现一个真实的高性能 GPU,而是在 QEMU 中模拟一块教学用 PCI GPGPU 设备,让它能够通过 qtest 中的 17 个测试点。测试覆盖了设备识别、VRAM 访问、DMA、中断、SIMT 线程上下文、kernel 执行、浮点运算和低精度浮点格式转换。

从 PCI 设备和 MMIO 开始

实验里的 GPGPU 首先是一块 PCI 设备。对 guest 或 qtest 来说,访问设备主要依赖 BAR:

  • BAR0 是控制寄存器区域,用来读写设备 ID、VRAM 大小、全局控制、DMA 参数、中断状态和 kernel 启动参数。
  • BAR2 是 VRAM 区域,可以理解为这块模拟 GPGPU 自己的显存。
  • BAR4 是 doorbell 区域,在本实验中不是最核心的部分。

一开始我容易把“寄存器”理解成 CPU 里的通用寄存器。做完实验后才更清楚:这里的设备寄存器其实是一组被映射到地址空间里的控制字段。测试程序往某个偏移写入一个值,QEMU 的 MemoryRegionOps 回调就会被触发,设备模型根据这个偏移和数值改变内部状态。

因此,设备建模的第一步不是急着写复杂逻辑,而是把寄存器行为定义清楚:

  • 哪些寄存器只读,哪些可写;
  • 写入控制位后应该触发什么动作;
  • 状态位什么时候置位,什么时候清除;
  • 越界访问或非法配置应该如何报告错误。

这个思路对后面的 DMA 和 kernel dispatch 都很重要。

VRAM 和 DMA:先把数据搬对

VRAM 是 GPGPU 的数据存放区域。测试既会通过 BAR2 直接读写 VRAM,也会要求设备通过 DMA 在主机内存和 VRAM 之间搬运数据。

DMA 部分让我理解到,很多设备行为并不是 CPU 自己一条条搬数据,而是 CPU 把源地址、目标地址、长度和方向写到设备寄存器里,然后让设备执行搬运。真实硬件中 DMA 往往是异步的;本实验为了匹配 qtest 预期,可以在写入 START 位后同步完成搬运,再更新完成状态和中断状态。

这里要特别注意边界检查。VRAM 地址和长度如果越界,设备不能继续静默写内存,而应该设置错误状态。这个细节看似普通,但它体现了设备模型的一个基本原则:模拟器不只是让正确路径能跑,也要让错误路径有可观察、可测试的行为。

SIMT:多个线程执行同一段 kernel

GPGPU 实验后半部分的核心是 SIMT。可以把 SIMT 简单理解为:很多线程执行同一段程序,但每个线程有自己的编号和数据。

实验中需要维护的上下文包括:

  • thread id:线程编号;
  • block id:线程块编号;
  • warp id:warp 编号;
  • lane id:warp 内部编号;
  • active mask:当前 warp 中哪些 lane 有效;
  • mhartid:kernel 里读取当前线程身份的来源。

真实 GPU 会让一个 warp 中的多个 lane 近似并行执行;本实验为了表达语义,可以逐个 lane 解释执行。只要每个 lane 的寄存器、线程编号和访问地址正确,最终结果就能和测试预期一致。

我觉得这里最值得记住的是:GPU 编程模型里的“并行”,在教学模拟里可以先拆成“很多独立上下文反复执行同一段代码”。这样理解后,kernel 执行就不再神秘,本质上是:

  1. 根据 grid 和 block 维度枚举所有 block;
  2. 根据 block 维度枚举所有 thread;
  3. 为每个 warp/lane 准备寄存器和身份信息;
  4. 从 kernel 入口地址开始解释指令;
  5. 遇到 ebreak 或错误时结束当前 lane。

指令解释器:只实现测试真正需要的子集

这次实验没有要求实现完整 RISC-V CPU,而是实现 kernel 中会用到的一小部分指令。整数部分包括 lwswaddiandislliaddluicsrrs mhartidebreak;浮点部分包括 fcvt.s.wfcvt.w.sfadd.sfmul.sfmv.w.xfmv.x.w 等。

这个过程让我对“解释执行”有了更直观的认识。解释器每次从 VRAM 中取 32 位指令,根据 opcode、funct3、funct7 等字段判断是哪条指令,然后修改当前 lane 的寄存器或 VRAM。比如 lw 是从 VRAM 读一个 32 位值到通用寄存器,sw 是把通用寄存器的值写回 VRAM,csrrs mhartid 则让 kernel 能知道自己是第几个线程。

这也解释了为什么测试可以用很小的 kernel 检查设备行为:只要线程身份、内存读写和几条算术指令正确,kernel 就能把结果写到 VRAM,qtest 再读回结果进行断言。

低精度浮点:范围、精度和饱和

最后几个测试集中在低精度浮点格式转换,包括 BF16、E4M3、E5M2 和 E2M1。

普通 float32 有 1 位符号、8 位指数和 23 位尾数。低精度格式把指数位和尾数位大幅减少,因此会带来两个直接后果:

  • 可表示的数字范围变小;
  • 相邻可表示数字之间的间隔变大,精度变低。

比如 E2M1 只有很少的指数和尾数,能表达的值非常有限。把 float32 转成 E2M1 时,很多输入都只能近似到最近的可表示值。如果输入太大,实验要求使用 saturate 行为,也就是夹到该格式能表示的最大有限值,而不是变成无穷大。

这一部分让我真正体会到浮点格式不是一个抽象概念,而是由二进制位直接决定的工程权衡:指数位更多,范围更大;尾数位更多,精度更高;位数越少,硬件和存储成本越低,但误差和溢出处理就更重要。

调试和验证

这次专业阶段我主要依赖 qtest 来验证行为。17 个测例各自对应一个小目标,比直接跑大程序更适合定位问题。我的验证顺序大致是:

  1. 先让设备 ID、VRAM 大小、全局控制等基础寄存器通过;
  2. 再补 BAR2 VRAM 读写和 DMA;
  3. 然后处理中断状态和清除逻辑;
  4. 接着实现 SIMT 上下文;
  5. 最后完成 kernel 执行和低精度浮点转换。

构建时还遇到过一个环境问题:在 Windows 文件系统中构建 QEMU 时,符号链接相关文件可能被当成普通文本处理,导致通用头文件包含失败。最后在 Linux 文件系统中重新构建验证,能够避免这类环境干扰。这个经历也提醒我,遇到编译错误时不能只盯着业务代码,有时要先判断是代码问题、构建系统问题,还是文件系统语义问题。

最终本地逐项运行 GPGPU qtest,17 个测例全部通过;推送后远端 CI 中 GPGPU、Rust、SoC、CPU 四组 job 也全部通过。

总结

专业阶段最大的收获,是把几个以前只听过的词串成了一条完整链路:

PCI 设备发现
  -> BAR 映射
  -> MMIO 寄存器读写
  -> VRAM / DMA 数据流动
  -> SIMT 上下文
  -> kernel 指令解释执行
  -> qtest 验证结果
除此之外 我觉得:

第一,不要一开始就追求理解完整 QEMU。先从测试用例入手,明确每个测试在读写哪些寄存器、期望哪些状态变化,再回到设备代码实现。

第二,调试时把问题拆小。寄存器不对就先别看 kernel,VRAM 不对就先别看浮点,DMA 不对就先检查地址、长度和方向。qtest 的好处就是每个测试点都比较聚焦。

第三,理解原理比只让测试通过更重要。比如低精度浮点转换,如果只硬编码几个测试值,短期可能能过;但理解 sign、exponent、mantissa 之后,再处理 E4M3、E5M2、E2M1 和 saturate 行为就会自然很多。

第四,保留自己的学习记录。专业阶段涉及的概念很多,写下“这个寄存器为什么这样更新”“这个测试为什么失败”“这个格式最大值是多少”,后面回看时会比只看最终代码更有帮助。

完成这次实验后,我对 QEMU 的认识从“能跑虚拟机的工具”推进到了“可以用 C 代码描述硬件行为并被测试验证的模拟平台”。这也是我觉得训练营最有价值的地方:它把系统软件、硬件接口和工程测试放在同一个小而完整的闭环里,让初学者能真正摸到一遍从模型到验证的过程。