QEMU 设备模型入门:QOM、初始化流程与MemoryRegion
好,我们开始第4章。这一章是QEMU设备模拟的基石,说白了就是搞清楚QEMU怎么“认识”一个设备,怎么把它“造”出来,以及它怎么跟CPU和内存打交道。
我个人觉得,很多做驱动开发的朋友一开始看QEMU源码会觉得晕,就是因为没搞懂这三件事:对象模型(QOM)、设备初始化流程、内存区域(MemoryRegion)。今天咱们就把它们拆开揉碎了讲清楚。
4.1 QOM:QEMU的对象模型
QEMU本身是用C写的,但它需要模拟出各种复杂的硬件设备,这些设备之间有继承、有组合、有接口。纯C的struct搞不定这种面向对象的需求,所以QEMU自己搞了一套对象模型——QOM(QEMU Object Model)。
你可以把QOM想象成一个轻量级的C++对象系统。它用宏和函数指针实现了:
- 类的定义与继承:一个设备类型可以继承另一个设备类型。
- 实例化:从类创建具体的设备对象。
- 属性系统:可以动态地给对象添加/读取属性(比如寄存器地址、中断号)。
- 接口:类似C++的纯虚类,定义一组必须实现的方法。
核心概念:
- TypeImpl:类型的内部表示,每个设备类型对应一个TypeImpl。
- ObjectClass:类的结构体,存放所有实例共享的数据和方法(比如设备复位函数)。
- Object:对象实例的结构体,存放每个设备独有的数据(比如寄存器状态)。
嗯,这里要注意,QOM的注册是通过宏 type_init 完成的。每个设备模块在加载时,都会调用这个宏来告诉QEMU:“嘿,我这里有个新设备类型,你记一下。”
// 一个典型的QOM设备类型注册
static const TypeInfo my_uart_type_info = {
.name = "my-uart",
.parent = TYPE_SYS_BUS_DEVICE, // 继承自系统总线设备
.instance_size = sizeof(MyUARTState), // 实例结构体大小
.instance_init = my_uart_init, // 实例初始化函数
.class_init = my_uart_class_init, // 类初始化函数
};
static void my_uart_register_types(void)
{
type_register_static(&my_uart_type_info);
}
type_init(my_uart_register_types);
我在项目中遇到过一个问题:自己写了一个设备,注册了类型,但QEMU启动时就是找不到。后来发现是 type_init 的拼写错了,少了个‘i’。这种低级错误,查了一下午才找到,真是血泪教训。
4.2 设备初始化流程:从类型注册到设备就绪
一个QEMU设备从“概念”到“能用”,到底经历了什么?我习惯把它分成三个阶段:
- 类型注册阶段:QEMU启动时,遍历所有通过
type_init注册的类型,建立类型继承树。 - 实例化阶段:当QEMU解析命令行(比如
-device my-uart)时,调用object_new()或qdev_create()创建设备实例。 - realize阶段:实例化完成后,调用
DeviceClass::realize函数,完成设备的最终配置(比如分配内存区域、连接中断线)。
为什么要有 realize 这一步?你想想看,实例化只是分配了内存,但设备具体要映射到哪个地址、用哪个中断号,这些信息在实例化时可能还不知道。realize 就是做这些“后绑定”工作的。
我的习惯: 在写新设备时,我会把 instance_init 只做最基础的变量初始化(比如置零),把所有需要外部参数(如地址、中断号)的配置都放到 realize 里。这样代码更清晰,也更容易调试。
下面这张图,是我自己画的一个设备初始化流程,你可以对照着看:
4.3 MemoryRegion:设备与CPU的桥梁
设备要工作,CPU得能读写它的寄存器。这个“读写”在QEMU里就是通过 MemoryRegion 来实现的。你可以把它理解成一块“虚拟地址空间”,CPU访问这块空间时,QEMU会回调你注册的函数。
说白了,MemoryRegion 就是QEMU模拟设备寄存器的核心机制。你告诉QEMU:“我的设备寄存器在地址0x10000000开始,大小0x1000,读的时候调用我的 read 函数,写的时候调用我的 write 函数。”
我曾经踩过的坑: 刚开始写设备时,我注册了一个MemoryRegion,但忘了设置 size 为2的幂次。结果CPU访问时,QEMU内部地址翻译出错,设备死活不工作。后来查文档才发现,MemoryRegion的size必须是2的幂次,而且起始地址要对齐到size边界。切记!
MemoryRegion 有几种常见类型:
| 类型 | 用途 | 示例 |
|---|---|---|
| IO MemoryRegion | 模拟设备寄存器,读写触发回调 | UART、GPIO、定时器 |
| RAM MemoryRegion | 模拟普通内存,读写直接访问 | 板载RAM、显存 |
| ROM MemoryRegion | 模拟只读内存 | Boot ROM、固件 |
| Alias MemoryRegion | 将一个区域映射到另一个地址 | 外设地址重映射 |
下面是一个典型的IO MemoryRegion创建和注册代码:
// 在设备结构体中定义 MemoryRegion
typedef struct {
SysBusDevice parent_obj;
MemoryRegion iomem;
uint32_t regs[256]; // 模拟的寄存器数组
} MyUARTState;
// 实现读写回调
static uint64_t my_uart_read(void *opaque, hwaddr addr, unsigned size)
{
MyUARTState *s = (MyUARTState *)opaque;
// 地址偏移转成寄存器索引
uint32_t idx = addr >> 2;
return s->regs[idx];
}
static void my_uart_write(void *opaque, hwaddr addr, uint64_t data, unsigned size)
{
MyUARTState *s = (MyUARTState *)opaque;
uint32_t idx = addr >> 2;
s->regs[idx] = data;
// 根据寄存器值执行模拟逻辑
}
// 在 realize 函数中初始化并注册
static void my_uart_realize(DeviceState *dev, Error **errp)
{
MyUARTState *s = MY_UART(dev);
memory_region_init_io(&s->iomem, OBJECT(dev),
&my_uart_ops, s,
"my-uart", 0x1000); // 4KB 地址空间
sysbus_init_mmio(SYS_BUS_DEVICE(dev), &s->iomem);
// 之后在板级代码中调用 sysbus_mmio_map 映射到具体地址
}
这里有个细节:memory_region_init_io 的第三个参数是一个 MemoryRegionOps 结构体,里面定义了读写函数指针和字节序、对齐要求等。我习惯在 ops 里设置 .min_access_size = 4 和 .max_access_size = 4,强制32位访问,这样能避免很多对齐问题。
4.4 三者如何协同工作?
好,现在我们把QOM、初始化流程和MemoryRegion串起来。一个完整的设备模拟,流程是这样的:
- QOM注册:通过
type_init告诉QEMU有这个设备类型。 - 命令行解析:QEMU解析
-device my-uart,调用qdev_create创建实例。 - 属性设置:设置设备属性(如
chardev、addr)。 - realize调用:调用
qdev_realize,触发DeviceClass::realize。 - MemoryRegion创建:在
realize中创建IO MemoryRegion,并注册到系统总线。 - CPU访问:当Guest OS读写设备地址时,QEMU通过MemoryRegion回调,执行模拟逻辑。
一句话总结: QOM负责“认识”设备,初始化流程负责“造出”设备,MemoryRegion负责“连接”设备。三者缺一不可。
我个人建议,刚开始学的时候,不要急着看复杂的设备(比如网卡、GPU)。先从一个简单的GPIO或者定时器开始,把QOM注册、realize、MemoryRegion这三板斧练熟。等你看到 sysbus_create_simple 这种函数时,能条件反射地想到它背后发生了什么,那就算入门了。