ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

QEMU自定义Machine开发指南:从零构建虚拟硬件平台

2026/8/19 13:34:29 拓冰建站 浏览量
QEMU自定义Machine开发指南:从零构建虚拟硬件平台 1. 从零开始为什么要在QEMU里新增一个“machine”如果你玩过QEMU不管是用来跑一个完整的Linux发行版还是用来调试一个裸机程序你大概率用过-machine这个参数。比如-machine virt或者-machine pc。这个参数告诉QEMU“给我创建一个什么样的虚拟计算机”。这个“计算机”的定义就是所谓的“machine”。它决定了这个虚拟世界里有什么CPU是哪个型号的、内存怎么布局、有哪些总线比如PCIe、ISA、以及最重要的——有哪些设备比如网卡、显卡、磁盘控制器以及它们挂在总线的哪个位置。那么什么情况下需要自己新增一个machine呢这可不是为了炫技。在实际的开发和生产中有几个非常硬核的需求会把你推到这一步。场景一为特定的硬件原型或开发板建模。这是最常见也最直接的需求。假设你所在的团队正在设计一款新的物联网芯片配套一块评估板。在流片Tape-out之前软件团队需要提前开发Bootloader、操作系统内核和驱动程序。没有真实的硬件怎么办用QEMU模拟一个。但市面上没有现成的、和你板子一模一样的machine。这时你就需要基于QEMU的框架创建一个新的machine类型精确地定义你板子上的CPU、内存映射、外设连接关系。这样软件工程师就可以在一个高度仿真的环境中进行开发、调试和测试大大加速了软硬件协同开发的进程。场景二简化或定制化仿真环境。有时候现成的machine太“重”了。比如标准的virtmachine包含了很多你可能用不到的虚拟设备启动流程也相对复杂。如果你只是想验证一个极简的内核或者教学时希望学生从最干净的“计算机”开始理解你会希望有一个只包含最必要组件比如一个CPU、一段内存、一个串口的machine。新增一个极简的machine可以去除噪音让焦点更清晰。场景三研究、教学或安全测试。在操作系统、编译原理、体系结构等课程中一个定制化的、功能清晰的machine是绝佳的教学工具。在安全研究领域比如模糊测试Fuzzing你可能需要创建一个“怪异”的machine其设备地址空间或中断行为与标准不同以测试目标软件如固件的健壮性。著名的AFLAmerican Fuzzy Lop就支持QEMU模式其底层就需要对QEMU的设备仿真有深入的理解和定制能力。所以新增一个machine本质上是在QEMU这个强大的“数字乐高”平台上按照你自己的蓝图搭建一台独一无二的虚拟计算机。这个过程会强迫你深入理解计算机系统从加电到启动的完整链条从硬件视角看软件是一次非常扎实的修炼。2. 解剖一只麻雀理解QEMU machine的代码结构在动手新增之前我们必须先搞清楚一个machine在QEMU的源代码世界里长什么样。QEMU的代码库非常庞大但结构清晰。我们以ARM架构下最常用的virtmachine为例来一次快速的代码游览。理解它就等于拿到了自己搭建的图纸。首先找到入口。QEMU中每种架构ARCH的代码通常在hw/目录下比如ARM的就是hw/arm/。virtmachine的定义就在hw/arm/virt.c这个文件里。一个machine的核心是一个MachineClass和一个MachineState。MachineClass 这是一个“类”定义了这种machine的元数据和通用行为。比如它的名字、描述、默认的CPU类型、初始化入口函数等。它是在类型系统初始化时被注册的。MachineState 这是machine的一个“实例”。当用户命令行指定-machine virt时QEMU会根据MachineClass创建一个MachineState对象它承载了这次模拟运行时的具体状态。在virt.c中你会看到类似下面的代码经过简化// 定义machine的初始化函数 static void virt_machine_init(MachineState *machine) { VirtMachineState *vms VIRT_MACHINE(machine); // 1. 创建CPU create_cpus(vms); // 2. 初始化内存布局创建RAM create_memory(vms); // 3. 创建系统总线如Platform Bus create_platform_bus(vms); // 4. 创建设备树DTB的骨架对于ARM create_device_tree(vms); // 5. 逐个创建并挂载虚拟设备GIC中断控制器、UART串口、RTC时钟、PCIe主机控制器、GPIO、等等。 create_gic(vms); create_uart(vms); // ... 更多设备 // 6. 最终生成完整的设备树并传递给内核 complete_device_tree(vms); } // 定义MachineClass static void virt_machine_class_init(ObjectClass *oc, void *data) { MachineClass *mc MACHINE_CLASS(oc); mc-desc QEMU ARM Virtual Machine; mc-init virt_machine_init; // 关键指定初始化函数 mc-max_cpus 255; mc-default_cpu_type ARM_CPU_TYPE_NAME(cortex-a15); // ... 其他属性设置 } // 注册这个machine类型 static const TypeInfo virt_machine_typeinfo { .name MACHINE_TYPE_NAME(virt), .parent TYPE_MACHINE, .class_init virt_machine_class_init, .instance_size sizeof(VirtMachineState), }; // 在模块初始化时注册 static void virt_machine_init_register_types(void) { type_register_static(virt_machine_typeinfo); } type_init(virt_machine_init_register_types)这个流程是理解一切的关键类型注册通过type_init宏在QEMU启动早期将virt_machine_typeinfo注册到QEMU的对象系统中。类初始化当类型被加载时调用virt_machine_class_init填充MachineClass其中最重要的就是设置了mc-init virt_machine_init。实例创建与初始化用户选择-machine virt后QEMU创建VirtMachineState实例并调用其init函数也就是virt_machine_init。硬件组装在virt_machine_init函数中按照固定顺序像搭积木一样创建CPU、内存、总线最后把所有设备挂载到正确的位置。注意不同架构的machine细节差异很大。x86的pcmachine可能更依赖ACPI表来向操作系统描述硬件而ARM的virtmachine则严重依赖设备树Device Tree。你的新machine需要遵循目标架构的约定俗成。3. 实战一步步创建你的第一个自定义machine理论说得再多不如动手搭一个。我们的目标是创建一个名为myboard的极简ARM machine它只包含一个Cortex-A53 CPU、256MB内存和一个PL011串口。通过这个串口我们可以输出“Hello, MyBoard!”。3.1 环境准备与代码定位首先你需要一个QEMU的源代码环境。从官网或Git镜像克隆代码并配置编译。这里假设你已经有了一定的Linux和编译基础。git clone https://gitlab.com/qemu-project/qemu.git cd qemu ./configure --target-listaarch64-softmmu # 我们只编译ARM 64位系统模拟 make -j$(nproc)我们的新machine代码将放在hw/arm/目录下。创建一个新文件hw/arm/myboard.c。3.2 定义MachineState和MachineClass在myboard.c的开头我们需要定义自己的状态结构体并实现类初始化和实例初始化函数。#include qemu/osdep.h #include hw/boards.h #include hw/arm/boot.h #include hw/sysbus.h #include hw/char/pl011.h // 我们要用的PL011串口设备 #include hw/qdev-properties.h #include qapi/error.h #define TYPE_MYBOARD_MACHINE MACHINE_TYPE_NAME(myboard) // 定义我们自己的MachineState结构体继承自MachineState typedef struct { MachineState parent_obj; // 必须放在首位这是C语言实现“继承”的方式 // 我们可以在这里添加myboard特有的状态 // 比如保存我们创建的设备引用方便后续使用 MemoryRegion *ram; DeviceState *uart; } MyBoardMachineState; // 定义宏方便进行类型转换 #define MYBOARD_MACHINE(obj) \ OBJECT_CHECK(MyBoardMachineState, (obj), TYPE_MYBOARD_MACHINE) // --- 核心machine的初始化函数 --- static void myboard_machine_init(MachineState *machine) { MyBoardMachineState *mms MYBOARD_MACHINE(machine); MachineClass *mc MACHINE_GET_CLASS(machine); Error *err NULL; // 1. 创建CPU // 我们只创建一个CPU。mc-default_cpu_type 会在类初始化时设置 Object *cpuobj object_new(mc-default_cpu_type); Object *cpu_container container_get(qdev_get_machine(), /peripheral); object_property_add_child(cpu_container, cpu, cpuobj); object_unref(cpuobj); // 2. 初始化CPU设置属性如复位地址 object_property_set_int(cpuobj, reset-cbar, 0x80000000, error_abort); object_property_set_bool(cpuobj, realized, true, err); if (err) { error_report_err(err); exit(1); } // 3. 创建并映射RAM256MB MemoryRegion *system_memory get_system_memory(); mms-ram g_new(MemoryRegion, 1); memory_region_init_ram(mms-ram, NULL, myboard.ram, 256 * MiB, error_fatal); memory_region_add_subregion(system_memory, 0, mms-ram); // 映射到物理地址0 // 4. 创建并映射串口 (PL011) // PL011的典型基地址是0x9000000 mms-uart qdev_new(TYPE_PL011); SysBusDevice *sbd SYS_BUS_DEVICE(mms-uart); sysbus_realize_and_unref(sbd, error_fatal); sysbus_mmio_map(sbd, 0, 0x09000000); // 将设备的第0个MMIO区域映射到物理地址 sysbus_connect_irq(sbd, 0, qdev_get_gpio_in_named(cpuobj, gic-irq, 3)); // 连接中断这里简化处理 // 5. 为内核启动设置参数如果需要直接启动内核 // arm_load_kernel(ARM_CPU(first_cpu), machine, ...); } // --- MachineClass的初始化 --- static void myboard_machine_class_init(ObjectClass *oc, void *data) { MachineClass *mc MACHINE_CLASS(oc); mc-desc My Custom ARM Board; mc-init myboard_machine_init; // 绑定初始化函数 mc-max_cpus 1; mc-default_cpus 1; mc-default_cpu_type ARM_CPU_TYPE_NAME(cortex-a53); // 默认CPU型号 // 注意我们没有设置 default_ram_size因为我们在init里自己创建了RAM。 // 更规范的做法是设置 mc-default_ram_size 256 * MiB并在init中使用 machine-ram。 } // --- 定义类型信息并注册 --- static const TypeInfo myboard_machine_typeinfo { .name TYPE_MYBOARD_MACHINE, .parent TYPE_MACHINE, .class_init myboard_machine_class_init, .instance_size sizeof(MyBoardMachineState), }; static void myboard_machine_init_register_types(void) { type_register_static(myboard_machine_typeinfo); } type_init(myboard_machine_init_register_types)3.3 将新machine集成到编译系统光有C文件不行需要让QEMU的构建系统知道它。编辑hw/arm/meson.build文件在源文件列表中添加myboard.c。找到类似system_ss.add(files(...))的语句把你的文件加进去。# 在 hw/arm/meson.build 中 system_ss.add(files( ... myboard.c, # 添加这一行 virt.c, ... ))然后回到QEMU根目录重新编译增量编译即可make -j$(nproc)3.4 测试你的新machine编译成功后使用-machine参数指定你的新板子并尝试运行一个简单的内核。首先你需要一个内核。可以编译一个极简的ARM64内核。这里假设你有一个编译好的Image文件。# 查看你的machine是否在支持列表中 ./build/qemu-system-aarch64 -machine help | grep myboard # 应该输出myboard My Custom ARM Board # 使用你的machine启动一个内核 ./build/qemu-system-aarch64 \ -machine myboard \ -cpu cortex-a53 \ -kernel /path/to/your/Image \ -nographic \ -append consolettyAMA0 earlyconpl011,0x09000000 # 告诉内核串口信息如果内核配置正确你应该能看到内核启动日志从串口输出。恭喜你你的第一台自定义虚拟计算机已经成功跑起来了踩坑点中断连接sysbus_connect_irq是新手最容易出错的地方。在上面的例子中我们简化了中断连接。在真实的virtmachine中会先创建一个GIC通用中断控制器然后将所有设备的中断线连接到GIC再将GIC的输出连接到CPU的IRQ/FIQ引脚。你需要根据目标架构的中断控制器模型来正确连接。错误的连接会导致设备无法产生中断驱动无法工作。4. 超越Hello World添加设备与定义地址空间一个只有串口的板子用处有限。现代SoC包含大量外设。在QEMU中添加一个设备本质上是实例化一个已经存在的设备模型并将其映射到正确的总线地址上。4.1 添加一个简单的SysBus设备GPIO控制器假设我们要添加一个虚拟的GPIO控制器。QEMU源码中有一个简单的模型hw/gpio/gpio_key.c这是一个接收键盘事件的GPIO我们用它举例。添加步骤如下包含头文件在myboard.c中添加#include hw/gpio/gpio_key.h。在初始化函数中创建设备// 在 myboard_machine_init 函数中 DeviceState *gpio; gpio qdev_new(TYPE_GPIO_KEY); // 创建设备对象 SysBusDevice *sbd_gpio SYS_BUS_DEVICE(gpio); // 设置设备属性比如按键数量 qdev_prop_set_uint32(gpio, nr-gpios, 8); // 实现设备完成内部初始化 sysbus_realize_and_unref(sbd_gpio, error_fatal); // 映射到地址空间假设基地址为 0x8000000 sysbus_mmio_map(sbd_gpio, 0, 0x08000000); // 连接中断假设中断号是5 sysbus_connect_irq(sbd_gpio, 0, qdev_get_gpio_in_named(cpuobj, gic-irq, 5));4.2 定义完整的Memory Map一个专业的machine需要一个清晰的地址空间布局。通常我们会定义一个头文件myboard.h来集中管理这些“魔法数字”。// hw/arm/myboard.h #ifndef MYBOARD_H #define MYBOARD_H #define MYBOARD_RAM_BASE 0x00000000 #define MYBOARD_RAM_SIZE (256 * 1024 * 1024) // 256MB #define MYBOARD_UART_BASE 0x09000000 #define MYBOARD_UART_IRQ 3 #define MYBOARD_GPIO_BASE 0x08000000 #define MYBOARD_GPIO_IRQ 5 // 可以继续定义其他设备地址... #endif然后在myboard.c中包含这个头文件并使用这些宏使得代码清晰且易于维护。4.3 添加PCIe总线对于更复杂的设备如网卡、显卡它们通常通过PCI或PCIe总线连接。在QEMU中添加PCIe主机控制器需要更多步骤创建PCIe主机桥在ARM的virtmachine中这是通过gpci设备实现的。你需要查找对应架构的PCIe主机控制器模型。初始化并映射创建控制器设备将其映射到地址空间通常包括ECAM空间和MMIO空间。创建总线从主机控制器对象中获取PCIe总线对象。挂载设备将其他PCIe设备如e1000e网卡、VGA显卡挂载到这条总线上。这个过程代码量较大建议直接参考hw/arm/virt.c中create_pcie函数的实现。核心逻辑是先创建主机控制器然后在其下游总线上创建设备。经验之谈设备树Device Tree是ARM/Linux世界的基石。对于任何严肃的ARM machine你最终都需要生成一个准确的设备树二进制文件DTB并传递给内核。QEMU的create_device_tree函数族在hw/arm/boot.c和hw/arm/virt.c中负责这个过程。你需要深入学习设备树语法并理解QEMU是如何根据你创建的设备动态生成对应节点的。一个错误的reg地址范围或interrupts属性都会导致内核驱动无法识别设备。5. 调试、测试与进阶挑战5.1 如何调试你的machineQEMU Monitor使用-monitor stdio参数启动QEMU进入监控界面。命令info mtree可以打印整个内存树检查你的设备是否被正确映射。info qtree可以查看设备树。info irq可以查看中断状态。这是最直接的调试手段。GDB调试QEMU本身如果你怀疑machine初始化代码有逻辑错误可以用GDB附加到qemu-system-aarch64进程在你的myboard_machine_init函数中设置断点单步跟踪。Guest OS内核日志确保内核的earlycon和console参数正确指向你的串口地址。内核启动时关于设备探测如of_platform或pci的日志至关重要它会告诉你是否找到了设备以及驱动初始化是否成功。5.2 测试策略单元测试QEMU有自己的测试框架qtest。可以为你的新machine编写qtest用例模拟CPU读写设备寄存器验证设备响应是否符合预期。集成测试启动一个最小的Linux发行版如Buildroot构建的或一个裸机程序看它能否正确识别并使用你添加的设备。兼容性测试尝试不同版本的内核、不同的驱动程序确保你的machine行为稳定。5.3 进阶挑战与方向当你掌握了基本流程后可以尝试更有挑战性的任务这能让你对系统软硬件的理解再上一个台阶实现一个自定义的简单设备不满足于使用现有模型可以尝试在hw/下创建一个全新的设备。例如一个只包含几个寄存器的“LED控制器”。你需要定义设备的属性、MMIO读写回调函数、中断触发逻辑并将其集成到你的machine中。模拟非标准硬件这是新增machine最硬核的用途。比如公司有一款自研的加密加速器IP。你需要在QEMU中为其建模让驱动开发团队能在仿真环境中提前工作。这需要你精确理解该IP的寄存器手册、数据传输协议和中断机制。与外部工具链集成将你的自定义machine与Clang/LLVM、AFL等工具集成。例如配置AFL的QEMU模式使其能够对你machine上的特定设备驱动进行模糊测试。这需要对QEMU的TCGTiny Code Generator和插桩机制有深入了解。性能与准确性权衡QEMU的设备模拟可以是“功能级”的只保证行为正确也可以是“周期级”的追求时序准确。思考你的machine需要哪种精度过度的准确性会极大降低仿真速度。新增一个QEMU machine从看懂virt.c开始到成功启动内核再到添加复杂设备最后实现自定义IP是一条漫长但收获巨大的学习路径。它模糊了硬件工程师和软件工程师的界限让你能站在一个全局的视角去审视计算机系统是如何一步步构建并运行起来的。每一次对地址映射的调整每一次中断连接的调试都是对“计算机组成原理”最生动的实践。