QEMU 训练营 2026 专业阶段总结¶
主要贡献者
- 作者:rhbfc
背景介绍¶
我是一名工作 4 年的软件开发人员,主要负责实时操作系统和 Type1 虚拟机的开发,之前通过阅读 qemu 源码解决了几个 rtos 和 hypervisor 的 bug,萌生了学习 qemu 的念头,正好趁此训练营深入理解 QEMU。
专业阶段¶
选择的方向是 SOC 建模
1. 完成实验的流程¶
先根据测试逐项完成外设的建模,以最小改动方式在 g233.c 的 virt_machine_init 中添加设备,最后根据硬件手册的寄存器定义修改整个 g233.c 文件。
2. GPIO¶
1. 先从 GPIO 开始分析每个外设建模通用的部分¶
1:TypeInfo¶
QEMU 维护一个全局设备类型注册表。设备必须注册进去,在 g233.c 才能用名字创建它。
注册流程在 g233_gpio.c 文件末尾写一个 TypeInfo 结构体,然后 type_init() 让它在 QEMU 启动时自动执行:
// TypeInfo
static const TypeInfo g233_gpio_info = {
.name = TYPE_G233_GPIO, // 字符串名,qdev_new 的参数
.parent = TYPE_SYS_BUS_DEVICE,
.instance_size = sizeof(G233GPIOState), // 给每个实例分配的内存
.instance_init = g233_gpio_init, // 对象创建初始化
.class_init = g233_gpio_class_init, // 类初始化
};
// 注册函数:把 TypeInfo 塞进全局注册表
static void g233_gpio_register_types(void)
{
type_register_static(&g233_gpio_info);
}
// QEMU 启动时自动调用注册函数
type_init(g233_gpio_register_types)
class_init 设备类型的初始化,只执行一次。在里面挂回调函数:
static void g233_gpio_class_init(ObjectClass *klass, const void *data)
{
DeviceClass *dc = DEVICE_CLASS(klass);
dc->realize = g233_gpio_realize; // 实例化回调
device_class_set_legacy_reset(dc, g233_gpio_reset); // 硬件复位回调
dc->vmsd = &vmstate_g233_gpio; // 迁移/快照支持
}
2:realize / instance_init / reset / SysBusDevice 这些具体的初始化¶
instance_init 设备声明自己有什么资源,对于 gpio 来说就是有一块 mmio 空间和一条中断线
static void g233_gpio_init(Object *obj)
{
G233GPIOState *s = G233_GPIO(obj);
memory_region_init_io(&s->mmio, obj, &g233_gpio_ops, s,
TYPE_G233_GPIO, 0x100); //创建 MMIO 区域
sysbus_init_mmio(SYS_BUS_DEVICE(obj), &s->mmio);//将 MMIO 区域注册到系统总线
sysbus_init_irq(SYS_BUS_DEVICE(obj), &s->irq); //将一条中断线注册到系统总线
}
realize 设备正式激活时调用,应该放里面的内容有:
需要 qdev_get_prop_* 获取用户传入属性的逻辑
连接子设备或后端(chardev、blockdev 等)
分配依赖构造参数的内存
GPIO 没有这个需求,留空即可,后面其他外设建模会用到
reset 每次硬件复位时调用,把所有寄存器和状态设置成复位值,根据《硬件手册》,GPIO 复位值都是 0:
static void g233_gpio_reset(DeviceState *dev)
{
G233GPIOState *s = G233_GPIO(dev);
memset(s->regs, 0, sizeof(s->regs));
}
SysBusDevice 是 QEMU 中所有挂在 SoC 地址空间上的外设基类。
struct G233GPIOState {
SysBusDevice parent_obj;
MemoryRegion mmio; // MMIO 区域(CPU 访问寄存器通过这个空间)
qemu_irq irq; // 中断输出线(连接 PLIC)
uint32_t regs[7]; // 7 个 32-bit 寄存器
};
SysBusDevice 提供四个关键 API:
| API | 调用位置 | 作用 |
|---|---|---|
sysbus_init_mmio() |
外设的 instance_init | 「通知 qemu 有一个 MMIO 区域」 |
sysbus_init_irq() |
外设的 instance_init | 「通知 qemu 有一条中断线」 |
sysbus_mmio_map() |
g233.c | 「把 MMIO 区域映射到某个区域」 |
sysbus_connect_irq() |
g233.c | 「把 IRQ 连到 PLIC 的某个中断引脚」 |
3:MemoryRegion + read/write 回调 —— "CPU 访问地址时调用的函数"¶
真实硬件中,CPU 通过地址总线读写外设。QEMU 用 MemoryRegion 模拟:
CPU 执行 store 0x10012004, 0xFF
↓
QEMU 查找谁注册了 0x10012000 这个地址范围
↓
找到 G233GPIOState.mmio(来自 instance_init 中的 memory_region_init_io)
↓
调用 MemoryRegionOps 中的 .write = g233_gpio_write
↓
g233_gpio_write(opaque, offset, value, size)
↓
在 write 函数里更新对应的寄存器
// 步骤 1:定义 ops —— "读调这个函数,写调那个函数"
static const MemoryRegionOps g233_gpio_ops = {
.read = g233_gpio_read,
.write = g233_gpio_write,
.endianness = DEVICE_NATIVE_ENDIAN,
.impl.min_access_size = 4, // 最小访问宽度 4 字节
.impl.max_access_size = 4, // 最大访问宽度 4 字节
};
// 步骤 2:在 instance_init 创建 MemoryRegion 并绑定 ops
memory_region_init_io(&s->mmio, obj, &g233_gpio_ops, s,
TYPE_G233_GPIO, 0x100);
// 步骤 3:在 instance_init 中注册到 SysBus
sysbus_init_mmio(SYS_BUS_DEVICE(obj), &s->mmio);
read/write:
static uint64_t g233_gpio_read(void *opaque, hwaddr offset, unsigned int size)
{
}
static void g233_gpio_write(void *opaque, hwaddr offset,
uint64_t value, unsigned int size)
{
}
2. 根据硬件手册和测试用例实现 GPIO 建模¶
g233_gpio.c 中其他代码都是外设建模的通用操作,read、write 需要根据硬件手册和测试用例实现
先在 g233.c 的 virt_machine_init 函数中添加 gpio 这个设备,地址和中断号写死,未来再修改
DeviceState *gpio_dev = qdev_new("g233-gpio");
sysbus_realize_and_unref(SYS_BUS_DEVICE(gpio_dev), &error_fatal);
sysbus_mmio_map(SYS_BUS_DEVICE(gpio_dev), 0, 0x10012000);// gpio 基地址
sysbus_connect_irq(SYS_BUS_DEVICE(gpio_dev), 0,
qdev_get_gpio_in(mmio_irqchip, 2));// gpio 中断号
read 根据手册直接返回对应寄存器
static uint64_t g233_gpio_read(void *opaque, hwaddr offset, unsigned int size)
{
G233GPIOState *s = G233_GPIO(opaque);
uint32_t idx = offset / 4; // 寄存器编号(4 字节对齐)
return s->regs[idx];
}
write
先看 test-gpio-basic:需要注意的是 GPIO_IN,该寄存器会受到 GPIO_DIR 和 GPIO_OUT 值的影响。
static void update_state(G233GPIOState *s)
{
uint32_t dir = s->regs[G233_GPIO_DIR];
uint32_t out = s->regs[G233_GPIO_OUT];
out &= dir;
s->regs[G233_GPIO_OUT] = out;
// 根据 GPIO_DIR 和 GPIO_OUT 更新 GPIO_IN
uint32_t in = s->regs[G233_GPIO_IN];
in &= ~dir;
in |= (out & dir);
s->regs[G233_GPIO_IN] = in;
}
static void g233_gpio_write(void *opaque, hwaddr offset,
uint64_t value, unsigned int size)
{
G233GPIOState *s = G233_GPIO(opaque);
uint32_t idx = offset / 4;
switch (idx) {
case G233_GPIO_DIR:
s->regs[G233_GPIO_DIR] = value;
break;
case G233_GPIO_OUT:
s->regs[G233_GPIO_OUT] = value;
break;
case G233_GPIO_IN:
/* 只读寄存器 */
break;
}
update_state(s);
}
然后是 test-gpio-int:修改 GPIO_OUT 时,需要根据 GPIO_TRIG 和 GPIO_POL 触发中断并更新 GPIO_IS 寄存器。需要注意的是硬件手册似乎写反了,和测试用例对不上
// 可以在 write 函数最后面调用
static void g233_gpio_update_irq(G233GPIOState *s)
{
uint32_t is = s->regs[G233_GPIO_IS];
uint32_t ie = s->regs[G233_GPIO_IE];
qemu_set_irq(s->irq, (is & ie) ? 1 : 0);// is 和 ie 都为 1,拉高中断线发出中断
}
边缘中断,需要根据 GPIO_OUT 当前值和上次值确定是否触发
uint32_t old_out = s->regs[G233_GPIO_OUT];
s->regs[G233_GPIO_OUT] = value;
uint32_t changed = old_out ^ value;
// 处理每个 GPIO 的边缘中断
for (int i = 0; i < 32; i++) {
if (!(ie & (1u << i))) {
continue;
}
if (trig & (1u << i)) {
continue;
}
if (!(changed & (1u << i))) {
continue;
}
if (((value >> i) & 1) == ((pol >> i) & 1)) {
s->regs[G233_GPIO_IS] |= (1u << i);
}
}
// 处理每个 GPIO 的电平中断
for (int i = 0; i < 32; i++) {
if (!(ie & (1u << i))) {
s->regs[G233_GPIO_IS] &= ~(1u << i);
continue;
}
if (!(trig & (1u << i))) {
continue;
}
if (((in >> i) & 1) == ((pol >> i) & 1)) {
s->regs[G233_GPIO_IS] |= (1u << i);
} else {
s->regs[G233_GPIO_IS] &= ~(1u << i);
}
}
3. PWM 与 WDT¶
这两个外设比较相似,相比 gpio 多了 timer 相关 API
timer_new_ns:设备实例化时创建定时器,并且设置回调函数 timer_mod:启动定时器,这个定时器是单次模式,每次到时间都要重新设置 timer_del:停止定时器,清除 timer_mod 所设置的未来时间,并不会真的删除定时器 timer_pending:防重入,确保只有未 pending 时才 timer_mod
timer_new_ns 应当在 realize 中调用,这个是 gpio 建模时留空的函数
static void g233_pwm_realize(DeviceState *dev, Error **errp)
{
G233PWMState *s = G233_PWM(dev);
s->timer = timer_new_ns(QEMU_CLOCK_VIRTUAL, g233_pwm_timer_cb, s);
}
reset 时要停止定时器
static void g233_pwm_reset(DeviceState *dev)
{
G233PWMState *s = G233_PWM(dev);
s->glb = 0;
memset(s->ch, 0, sizeof(s->ch));
timer_del(s->timer);
}
每次回调函数中对计数寄存器做递增 (pwm) 或是递减 (wdt),然后设置下次的时间
static void g233_pwm_update_timer(G233PWMState *s)
{
if (s->glb & (PWM_GLB_CH_EN(0) | PWM_GLB_CH_EN(1) |
PWM_GLB_CH_EN(2) | PWM_GLB_CH_EN(3))) {
if (!timer_pending(s->timer)) {
timer_mod(s->timer,
qemu_clock_get_ns(QEMU_CLOCK_VIRTUAL) + NANOSECONDS_PER_SECOND / G233_PWM_FREQ_HZ);
}
} else {
timer_del(s->timer);
}
}
static void g233_pwm_timer_cb(void *opaque)
{
G233PWMState *s = G233_PWM(opaque);
for (int i = 0; i < G233_PWM_CHANNELS; i++) {
if (!(s->glb & PWM_GLB_CH_EN(i))) {
continue;
}
s->ch[i].cnt++;
if (s->ch[i].period > 0 && s->ch[i].cnt >= s->ch[i].period) {
s->ch[i].cnt = 0;
s->glb |= PWM_GLB_CH_DONE(i);
}
}
g233_pwm_update_timer(s);
}
4. SPI¶
1. CS 片选¶
此前每次发送中断都是调用void qemu_set_irq(qemu_irq irq, int level)函数。按我的理解,这个函数和 irq 并没有必然的联系,实际是对一根线或是一个引脚的拉高拉低的功能。能发送中断是拉高了中断线,通知到了 plic 中断控制器。spi 测试中,通过qemu_set_irq 拉低 cs 线通知到 spi 从设备。
2. SSI 总线¶
SPI 控制器不直接操作 Flash,而是通过 QEMU 的 SSI 框架:
G233SPIState (控制器, master)
│
└── SSIBus (spi 总线)
│
└── w25x16 / w25x32 (Flash 芯片, slave, 在 g233.c 中创建 CS 线)
主要用到以下几个 API:
| 函数 | 作用 | 调用位置 |
|---|---|---|
ssi_create_bus |
创建 SSI 总线 | g233_spi_realize |
ssi_transfer |
通过总线发送一个字节给从设备,返回收到的字节 | g233_spi_write (写 DR 时) |
ssi_get_cs |
按 cs_index 查找总线上的设备 |
g233_spi_reset |
3. qdev 设备创建与接线¶
- 板级代码:
qdev_prop_set_uint8设 Flash 的cs_index属性 →qdev_get_child_bus拿到 SPI 总线 →qdev_realize_and_unref实现并挂到总线 - 控制器侧:
g233_spi_realize中qdev_init_gpio_out_named创建命名 CS 输出 →reset时qdev_get_gpio_in_named+qdev_connect_gpio_out_named自动接线
| 函数 | 作用 | 调用位置 |
|---|---|---|
qdev_prop_set_uint8 |
设置设备的 uint8 属性值 | g233.c 板级 |
qdev_get_child_bus |
获取设备的子总线(按名称查找) | g233.c 板级 |
qdev_realize_and_unref |
实现设备 + 释放引用 | g233.c 板级 |
qdev_init_gpio_out_named |
创建一组命名 GPIO 输出引脚(CS 线) | g233_spi_realize |
qdev_get_gpio_in_named |
获取设备的命名 GPIO 输入引脚 | g233_spi_reset |
qdev_connect_gpio_out_named |
连接输出引脚 → 输入引脚(接线) | g233_spi_reset |
4. 实现时要留意的地方¶
realize 中需要创建 ssi 总线
static void g233_spi_realize(DeviceState *dev, Error **errp)
{
G233SPIState *s = G233_SPI(dev);
s->spi = ssi_create_bus(dev, "spi");
//暴露 4 条 CS 线给 SSI 总线
qdev_init_gpio_out_named(DEVICE(dev), s->cs_lines, "cs", G233_SPI_NUM_CS);
}
reset 接线,放这里是保证 flash realize 后才去接线
static void g233_spi_reset(DeviceState *dev)
{
G233SPIState *s = G233_SPI(dev);
memset(s->regs, 0, sizeof(s->regs));
s->tx_data = 0;
s->rx_data = 0;
qemu_set_irq(s->irq, 0);
for (int i = 0; i < G233_SPI_NUM_CS; i++) {
// 复位 cs 初始值
qemu_set_irq(s->cs_lines[i], 1);
// ssi 遍历总线
DeviceState *kid = ssi_get_cs(s->spi, i);
if (kid) {
// 获取 flash 暴露出的 cs 线,
// flash 那边会调用 qdev_init_gpio_in_named 暴露出 in 型的 cs 线,具体看 m25p80.c
qemu_irq cs_line = qdev_get_gpio_in_named(kid, SSI_GPIO_CS, 0);
// flash 的 cs 线接到 realize 时所暴露给 SSI 总线的 CS 线上
qdev_connect_gpio_out_named(DEVICE(s), "cs", i, cs_line);
}
}
}
在 g233.c 的 virt_machine_init 函数中添加 spi 设备外,还要添加测试需要的 Flash
DeviceState *spi_dev = qdev_new("g233-spi");
DeviceState *flash_dev;
/* SPI controller */
sysbus_realize_and_unref(SYS_BUS_DEVICE(spi_dev), &error_fatal);
sysbus_mmio_map(SYS_BUS_DEVICE(spi_dev), 0, 0x10018000);
sysbus_connect_irq(SYS_BUS_DEVICE(spi_dev), 0,
qdev_get_gpio_in(mmio_irqchip, 5));
/* CS0: W25X16 (2MB) */
flash_dev = qdev_new("w25x16");
qdev_prop_set_uint8(flash_dev, "cs", 0);
qdev_realize_and_unref(flash_dev,
qdev_get_child_bus(spi_dev, "spi"),
&error_fatal);
/* CS1: W25X32 (4MB) */
flash_dev = qdev_new("w25x32");
qdev_prop_set_uint8(flash_dev, "cs", 1);
qdev_realize_and_unref(flash_dev,
qdev_get_child_bus(spi_dev, "spi"),
&error_fatal);
总结¶
通过本次实验,理解了 QEMU 设备建模的基本思路。我觉得进行设备建模时,应该先研究测试用例,搞清预期行为后再动手,往往能事半功倍。建模过程中要以硬件的角度去思考——就像发中断是拉高一根信号线——而不是把它当成一个软件驱动来写。