
QEMU SoC 外设建模与 QTest 验证
转载声明:本文转载自 QEMU Camp 2026 训练营博客,版权归原作者所有。
原文作者:@jieniguiemmm
原文链接:https://qemu.gevico.online/blogs/2026/professional/qemu-camp-2026-jieniguiemmm/
背景介绍
本人网安大三,去年也参加了 qemu 训练营,不过由于和期末撞车了遗憾没有完成,看到这次不仅项目丰富了许多而且时间上也宽裕了不少,没有和学校的事撞车。狠狠报名,势必要完成项目阶段。专业阶段这里我选择的是 SOC 一方面去年接触了一点点,另一方面项目阶段的大模型建模外设比较吸引我。
专业阶段
1. 借助 QTest 理解 SoC 实验
原理
SoC 建模的核心是“把一块芯片的可观察行为模拟出来”。对于软件来说,外设通常不是通过函数调用访问的,而是通过 MMIO 访问的,即 CPU 对某个物理地址读写,硬件外设根据这个地址和数据改变内部状态,或者返回某个寄存器值。
QEMU 里的外设模型也是如此。我们需要在 QEMU 的 system memory 中挂载 MemoryRegion。当测试或 guest 对这个地址范围执行读写时,QEMU 会回调我们实现的 read / write 函数。外设模型内部保存寄存器状态,并在合适时机拉高中断线。
QTest 则是 QEMU 的一种测试方式。它不需要启动完整客户机操作系统,而是由测试程序直接控制 QEMU,执行类似下面的操作:
qtest_writel(qts, GPIO_DIR, 0x1);
g_assert_cmpuint(qtest_readl(qts, GPIO_DIR), ==, 0x1);
所以,QTest 实际上把硬件需求变成了一组非常直接的断言:某个寄存器写入后应该读回什么值,某个事件发生后中断是否 pending,某个状态位是否能被清除。
实现细节
SoC 实验的测试入口是:
make -f Makefile.camp test-soc
这个目标会运行 10 个测试:
test-board-g233
test-gpio-basic
test-gpio-int
test-pwm-basic
test-wdt-timeout
test-spi-jedec
test-flash-read
test-flash-read-interrupt
test-spi-cs
test-spi-overrun
我首先阅读 tests/gevico/qtest/ 目录,把每个测试访问的地址和寄存器行为整理出来。测试中用到的 G233 外设 MMIO 地址如下:
WDT 0x10010000
GPIO 0x10012000
PWM 0x10015000
SPI 0x10018000
项目中已经存在 G233 machine:
hw/riscv/g233.c
include/hw/riscv/g233.h
原始 g233.c 已经有 DRAM、CLINT、PLIC、UART、RTC、VirtIO、PCIe、pflash 等基础设施,还没有实现测试需要的 G233 专用 GPIO、PWM、WDT 和 SPI 控制器。因此我的主要改动集中在:
hw/riscv/g233.c
2. 总体结构
原理
在 QEMU 中,一个 machine 负责搭建整块虚拟主板。它会创建 CPU、内存、中断控制器和各种外设,并把外设映射到物理地址空间。对于 MMIO 外设来说,关键步骤是:
定义设备内部状态
定义 MemoryRegionOps
初始化 MemoryRegion
把 MemoryRegion 添加到 system memory
把设备 IRQ 连接到中断控制器
中断连接同样重要。设备内部设置了状态位并不代表 CPU 能看到中断。设备模型必须通过 qemu_set_irq() 或类似接口把中断线拉到 PLIC,PLIC 才会在 pending 寄存器中体现出来。
实现细节
这里我选择把这几个教学外设作为 G233 板卡私有的 MMIO 模型集中写在 g233.c 中,而不是为每个外设单独新建 QOM 设备文件。这个选择更适合本阶段,因为测试范围明确,外设规模较小,集中实现更方便对照测试。
新增的状态结构包括:
G233GpioState
G233PwmState
G233WdtState
G233SpiState
G233SpiFlash
新增统一初始化函数:
static void g233_create_mmio_devices(RISCVG233State *s, DeviceState *irqchip)
它负责:
创建 GPIO / PWM / WDT / SPI 的 MemoryRegion
映射到 G233 手册和测试要求的 MMIO base
把 GPIO / WDT / SPI 的 IRQ 接到 PLIC
初始化两片 SPI Flash 的内存状态
最后在 G233 machine 初始化流程中调用:
g233_create_mmio_devices(s, mmio_irqchip);
3. GPIO
原理
GPIO 是最基础的 SoC 外设之一。它的作用是让软件控制芯片引脚,或者读取外部引脚电平。一个 GPIO 控制器通常至少需要保存三类信息:
方向:当前 pin 是输入还是输出
数据:输出值或输入值
中断:某个 pin 的电平或边沿是否触发中断
GPIO 中断一般分为两种:
边沿触发:只在 0->1 或 1->0 的瞬间触发
电平触发:只要电平保持在目标状态,中断就保持有效
这两种模式的差别非常关键。边沿触发需要比较旧值和新值;电平触发只看当前值。
细节
GPIO 的寄存器布局如下:
base: 0x10012000
0x00 GPIO_DIR direction, 0=input, 1=output
0x04 GPIO_OUT output data
0x08 GPIO_IN input data
0x0C GPIO_IE interrupt enable
0x10 GPIO_IS interrupt status, write-1-to-clear
0x14 GPIO_TRIG trigger type, 0=edge, 1=level
0x18 GPIO_POL polarity, 0=low/falling, 1=high/rising
基础测试会检查 reset value、方向寄存器、输出寄存器和多 pin 写入。这里最重要的是 GPIO_IN。测试会先把 pin 配成 output,然后写 GPIO_OUT,再从 GPIO_IN 读回。因此我们让输入值反映输出方向上的输出状态:
GPIO_IN = GPIO_OUT & GPIO_DIR
中断实现中,我保存旧输入值,再根据写入后的新输入值判断触发条件:
TRIG=0, POL=1:上升沿触发
TRIG=0, POL=0:下降沿触发
TRIG=1, POL=1:高电平触发
TRIG=1, POL=0:低电平触发
GPIO_IE 用来屏蔽未使能的 pin。GPIO_IS 是中断状态寄存器,支持 write-1-to-clear。GPIO 的 PLIC 中断号是 2,因此只要 GPIO_IS 非 0,就拉高中断线:
qemu_set_irq(s->irq, s->is != 0);
4. PWM
原理
PWM 的本质是周期性计数和比较。真实硬件中,PWM 计数器会按时钟递增,当计数值达到周期或比较值时,输出波形或设置状态位。软件通过配置 period 和 duty 来控制波形。
在本实验里,测试并不观察真实波形,只观察:
寄存器能否保存 period / duty
channel enable 后全局寄存器是否反映 enable
counter 是否随着虚拟时间前进
周期完成后 DONE 标志是否置位
DONE 是否能被 write-1-to-clear
因此我们不需要模拟完整 PWM 输出,只需要用 QEMU virtual clock 推导计数器和 DONE 状态。
实现细节
PWM 的 MMIO base 是:
0x10015000
测试关注 4 个 channel:
0x00 PWM_GLB
CHn at 0x10 + n * 0x10:
+0x00 CHn_CTRL
+0x04 CHn_PERIOD
+0x08 CHn_DUTY
+0x0C CHn_CNT
PWM_GLB 的低 4 位是各 channel 的 enable 镜像,高 4 位是 DONE 标志:
bits[3:0] CHn_EN
bits[7:4] CHn_DONE, write-1-to-clear
counter 的计算方式:
ticks = (now - start_ns) / G233_TICK_NS
counter = ticks % period
我的定义:
#define G233_TICK_NS 1000000ULL
也就是 1 tick = 1 ms。测试只要求 qtest_clock_step() 后 counter 大于 0,所以这个精度已经足够。
第一次实现后,test_pwm_done_clear 失败了。原因是写 PWM_GLB 清除 DONE 后,下一次读寄存器时又根据旧 start_ns 发现 elapsed time 已经超过 period,于是 DONE 立即重新置位。
修复方法是:清 DONE 的同时重置对应 channel 的计时起点:
s->done &= ~clear;
s->ch[i].start_ns = now;
这个细节说明,计时类设备清状态位时,通常还要考虑下一次事件从什么时候重新开始计算。
5. WDT
原理
WDT,也就是 watchdog timer,通常用于检测系统是否卡死。软件需要周期性“喂狗”。如果长时间没有喂狗,计数器归零,硬件会触发中断或复位。
本实验的 WDT 需要模拟这些行为:
enable 后开始倒计时
读 VAL 能看到剩余值减少
写 feed key 能重新加载计数
写 lock key 后配置不能再被修改
超时后设置 TIMEOUT
如果 INTEN 打开,则触发 PLIC 中断
同样,测试不要求真实硬件 timer 后台持续运行;只要读寄存器或推进 QTest 时钟后,模型能根据 virtual clock 算出正确状态即可。
实现细节
WDT 的 MMIO base 是:
0x10010000
测试使用的寄存器:
0x00 WDT_CTRL
0x04 WDT_LOAD
0x08 WDT_VAL
0x0C WDT_SR
0x10 WDT_KEY
WDT_CTRL 中:
bit0 EN
bit1 INTEN
WDT_LOAD 设置倒计时初值,WDT_VAL 根据 virtual clock 计算剩余值。当经过的 tick 大于等于 load 时,WDT_SR.TIMEOUT 置位。如果同时打开 INTEN,则拉高 PLIC IRQ 4。
WDT 支持两个 key:
0x5A5A5A5A feed
0x1ACCE551 lock
feed 的语义是重新喂狗:
start_ns = now
sr.TIMEOUT = 0
irq = low
lock 的语义是锁定配置。测试会 lock 后尝试写 WDT_CTRL = 0,期望 enable bit 仍然保持,因此实现中在 locked 状态下忽略对 CTRL 和 LOAD 的修改。
WDT 也遇到了和 PWM 一样的状态位清除问题:test_wdt_timeout_clear 失败。原因是清除 WDT_SR.TIMEOUT 后,下一次读 WDT_SR 又因为旧 elapsed time 超过 load 而重新 timeout。修复方法是清标志时刷新计时基准:
s->sr &= ~(uint32_t)value;
if (!(s->sr & 1)) {
s->start_ns = qemu_clock_get_ns(QEMU_CLOCK_VIRTUAL);
qemu_set_irq(s->irq, 0);
}
6. SPI
原理
SPI 是一种主从式串行总线。主机通过片选 CS 选择某个从设备,然后一边发送字节,一边接收字节。在 QEMU 模型里,不需要真的模拟每一根时钟线和 MOSI/MISO 电平。对本实验来说,只需要在每次写 SPI_DR 时处理一个字节,并根据当前命令 phase 返回一个接收字节。
实现细节
SPI 的 MMIO base 是:
0x10018000
寄存器:
0x00 SPI_CR1
0x04 SPI_CR2
0x08 SPI_SR
0x0C SPI_DR
测试使用的关键 bit:
CR1:
bit0 SPE
bit2 MSTR
bit5 ERRIE
bit6 RXNEIE
bit7 TXEIE
CR2:
bits[1:0] CS select
SR:
bit0 RXNE
bit1 TXE
bit4 OVERRUN, write-1-to-clear
测试里的 SPI 传输函数基本是:
spi_wait_txe(qts);
qtest_writel(qts, SPI_DR, tx);
spi_wait_rxne(qts);
return qtest_readl(qts, SPI_DR);
所以可以简化为:
TXE 初始为 1
写 SPI_DR 后立即生成一个 RX byte
设置 RXNE
读 SPI_DR 返回 RX byte,并清 RXNE
overrun 测试会故意在 RXNE 未清除时继续写 DR。实现中只要写 DR 时发现 RXNE 已经为 1,就设置 OVERRUN:
if (s->sr & SPI_SR_RXNE) {
s->sr |= SPI_SR_OVERRUN;
}
SPI 的 PLIC IRQ 是 5。中断条件包括:
TXEIE && TXE
RXNEIE && RXNE
ERRIE && OVERRUN
每次写 CR1、写 DR、读 DR 或清 SR 后,都重新计算 IRQ level。
7. SPI Flash
原理
SPI Flash 本质上是挂在 SPI 总线后的存储设备。软件不是直接读写它的内存数组,而是通过命令协议访问它。例如读 JEDEC ID 要发送 0x9F,读数据要发送 0x03 + 地址,写数据前要先发送 write enable。
真实 NOR Flash 还有一个重要特性:写入只能把 bit 从 1 变成 0,不能直接从 0 变回 1。要把 bit 恢复成 1,需要先擦除 sector。
本实验测试了两个片选:
CS0: W25X16, 2MB
CS1: W25X32, 4MB
因此 SPI 控制器内部需要为两片 flash 分别保存独立数据和命令状态。
实现细节
我在 G233SpiState 中维护了两个简化的 flash:
CS0: W25X16, 2MB, JEDEC EF 30 15
CS1: W25X32, 4MB, JEDEC EF 30 16
每片 flash 用一块内存数组表示,初始化为 0xFF。支持测试需要的命令:
0x9F JEDEC ID
0x05 Read Status
0x06 Write Enable
0x20 Sector Erase
0x02 Page Program
0x03 Read Data
0x9F 后返回 JEDEC ID。0x06 设置 write enable latch。0x20 按 4KB sector 擦除。0x02 执行 page program。为了更贴近 NOR Flash,program 使用:
data[addr] &= tx;
也就是说只能把 bit 从 1 写成 0。测试在 program 前都会 erase,所以读回结果能和写入 buffer 一致。
双片选测试会在 CS0 和 CS1 之间反复切换,因此每次 SPI_CR2 改变 CS 时,都需要重置当前 flash 的命令状态,避免上一片 flash 的命令 phase 影响下一片。
8. symlink 问题
QEMU 源码里有不少符号链接,尤其是子项目中会通过 symlink 引用顶层目录的公共头文件。在 Linux 环境中,这通常没有问题。但如果仓库在 Windows 或某些配置下检出,Git 可能把 symlink 检出成普通文本文件,文件内容只是目标路径。
一旦这种情况发生,编译器会把路径文本当成 C 代码读取,于是出现非常诡异的语法错误,或者找不到原本应该通过 symlink 指向的头文件。
实现细节
第一次构建时,我遇到的错误是 QEMU 子项目头文件找不到:
fatal error: standard-headers/linux/virtio_ring.h: No such file or directory
fatal error: linux-headers/linux/virtio_ring.h: No such file or directory
subprojects/libvduse/include/atomic.h: expected identifier before '.' token
检查后发现,一些 Git symlink 被检出成了普通文本文件。例如 subprojects/libvduse/include/atomic.h 的内容是一行路径:
../../../include/qemu/atomic.h
这说明 Git 配置中 core.symlinks=false。修复方式是把这些 Git 索引中类型为 120000 的路径恢复成真实 symlink:
git config core.symlinks true
git checkout -- <symlink paths>
恢复后,构建继续进行并通过。
9. 测试迭代和最终结果
写设备模型时,很难一次就完全正确。QTest 的价值在于它能给出非常具体的失败点。与其猜测哪里错了,不如先跑测试,看哪条断言失败,再回到对应的寄存器语义上检查模型。
对于本实验,失败点主要集中在计时状态位清除后又立刻被重新置位。这类问题从代码上看可能不明显,但从测试断言能很快定位。
第一次完整跑 test-soc 时,结果是:
--- SoC experiment: 8/10 tests passed ---
失败项:
test-pwm-basic
test-wdt-timeout
具体失败点:
test_pwm_done_clear
test_wdt_timeout_clear
这两个问题本质相同:
状态位已经被 W1C 清除
但计时基准没有更新
下一次读寄存器时,时间条件仍然成立
状态位马上又被置位
修复后再次运行:
make -f Makefile.camp test-soc
最终结果:
test-board-g233 OK 4 subtests passed
test-gpio-basic OK 4 subtests passed
test-gpio-int OK 5 subtests passed
test-pwm-basic OK 7 subtests passed
test-wdt-timeout OK 7 subtests passed
test-spi-jedec OK 3 subtests passed
test-flash-read OK 4 subtests passed
test-flash-read-interrupt OK 4 subtests passed
test-spi-cs OK 6 subtests passed
test-spi-overrun OK 2 subtests passed
--- SoC experiment: 10/10 tests passed ---
总结
这次 SoC 实验让我对 QEMU 硬件建模有了更具体的理解。之前看 QEMU 时,很容易被庞大的目录结构和各种抽象吓住;但通过这个实验,我从一个很小的切口进入:一个 MMIO 地址、一组寄存器、一个中断号、一条测试断言,逐渐弄清楚了 QEMU 硬件建模的细节和原理。
我最大的收获有几点:
第一,测试是理解硬件模型需求的最好入口。手册告诉我们设备应该是什么样,测试则告诉我们当前阶段必须实现哪些行为。先读测试,可以避免一开始就陷入过度设计。
第二,MMIO 外设建模的本质是状态机。GPIO 要记录 DIR、OUT、IE、IS;SPI 要记录 RXNE、TXE、OVERRUN 和当前 flash 命令 phase;WDT 和 PWM 要记录计时起点和状态位。只要状态设计清楚,读写寄存器的逻辑就会自然很多。
第三,中断建模一定要把设备侧状态和中断控制器连接起来。设备内部设置了状态位还不够,还要通过 qemu_set_irq() 把中断线拉到 PLIC,否则测试读取 pending bit 时不会看到变化。
第四,计时类设备要特别注意“清标志”和“重新计时”的关系。PWM DONE 和 WDT TIMEOUT 的失败都来自同一个原因:状态位虽然清了,但时间条件仍然成立,导致下一次读寄存器又触发。
对后续同学的建议是:不要一开始就试图“完整模拟硬件”,先从测试出发,把每个寄存器的读写语义实现准确。QEMU 的抽象很多,但每个 QTest 断言背后其实都是一个很具体的问题。把这些具体问题一个一个解决,整个设备模型就会慢慢长出来。
完结撒花!!!


