QEMU 训练营 2026 专业阶段总结¶
主要贡献者
- 作者:@sutter099
背景介绍¶
我是一名应届硕士毕业生,平时对操作系统和芯片架构比较感兴趣。参加这次 QEMU 训练营,一方面是想了解一些虚拟化相关的知识,另一方面也希望借助真实的实验场景,加深自己对 QEMU 代码结构和 RISC-V 软硬件协作方式的理解。
前一段时间因为一些个人原因,训练营内容的推进速度稍慢。现在撰写这篇博客时,我更希望将学习过程中的核心理解与技术细节整理清楚。
专业阶段¶
CPU & SoC
CPU 实验¶
TCG: Tiny Code Generator
TCG 本质上是一套动态翻译/JIT 机制:它把 guest CPU 指令实时翻译成 QEMU 的中间表示,再生成宿主机可以直接执行的机器码。这样一来,QEMU 就不只是“模拟器”,而是一个可以把目标架构指令转换成本机指令的执行框架。它最早是一个 GCC 后端,现在逐渐演化成 qemu 的加速器之一
TCG 前端与后端¶
前端:负责 Guest 指令的语义化解析。
- RISC-V 的前端入口位于
target/riscv/translate.c:riscv_translate_code()。 - 在主循环
translate_loop()中,每条指令通过ops->translate_insn(db, cpu)进行翻译。对于 RISC-V 架构,该函数指针指向riscv_tr_translate_insn。 - 在
riscv_tr_translate_insn()内部会调用decode_opc()对指令进行解码。其中,32 位指令的解码来自target/riscv/insn32.decode编译生成的decode_insn32,匹配命中后会调用对应的trans_*函数。 decodetree机制:.decode文件是开发者编写的声明式描述,decodetree.py脚本读取它并生成.c.inc格式的解码文件,在编译时直接插入到 C 源码中。
后端:负责实现从 TCG IR 到 Host 机器码的翻译与发射。
- TCG 操作码(Opcode)定义在
include/tcg/tcg-opc.h。 - 通用代码生成入口位于
tcg/tcg.c:tcg_gen_code(),各个 Host 架构的指令发射器实现于tcg/<host>/tcg-target.c.inc。 - 翻译块(TB,Translation Block)的生成最终由
accel/tcg/translate-all.c:tb_gen_code()完成。
示例:add x5, x6, x7 的翻译流程¶
add x5, x6, x7 (0x007302B3)
│
▼ decode_insn32() 匹配 opcode → funct3 → funct7
│
▼ decode_insn32_extract_r() 提取字段
│ → arg_r { .rd=5, .rs1=6, .rs2=7 }
│
▼ trans_add(ctx, &arg_r)
│ → gen_arith() → tcg_gen_add_tl(dest, src1, src2)
│
▼ TCG 后端编译为 host 指令并执行
example: 支持 cube 指令¶
Helper 实现¶
分为四步:
- 在
.decode文件中定义 cube 指令的二进制格式 - 在 helper.h 中声明 cube helper 函数
- 在
op_helper.c中定义 cube helper 函数 - 在
.c.inc文件的trans_cube函数中使用 helper
TCG 实现¶
分为两步:
- 在
.decode文件中定义 cube 指令的二进制格式 - 在
.c.inc文件trans_cube函数中使用 TCG IR 实现 cube 操作
SoC 实验¶
如果说 CPU 方向让我理解的是“指令怎么跑”,那 SoC 方向让我理解的是虚拟硬件是怎么搭出来的。
几个重要的概念¶
当我们使用 QEMU 命令行时,-machine 参数决定了主板的骨架,-cpu 决定了处理器模型。参数解析完毕后,QEMU 会实例化 MachineState,并在初始化阶段调用 mc->init()。
在 qemu 设备建模过程中,有几个至关重要的概念需要厘清:
- MachineClass / TypeInfo
- MachineState
- class_init / instance_init / realize
- 设备注册、MMIO 映射、中断连接
在 qemu 中新建一个机器类型,就需要创建对应的 QOM,MachineClass, MachineState 以及相关的初始化函数。
属于机器的方法和静态元数据会放到 MachineClass 中。属于机器的动态数据和运行状态,则会放到 MachineState 结构体中。
需要实现的初始化相关函数: - class_init:定义这个类的蓝图; - instance_init:给实例准备默认状态; - realize:等属性都确定后,真正把设备挂到板子上; - board init:把 CPU、内存、总线、外设、中断关系串起来。
example
假设我们运行了 qemu-system-x86_64 -m 2G -M q35:
- 编译与注册阶段:QEMU 源码中
hw/i386/pc_q35.c里的TypeInfo结构体通过type_init()宏被注册进 QEMU 系统。 - 类初始化:QEMU 启动时解析到
-M q35,找到对应的TypeInfo,为其分配并创建MachineClass,并调用class_init填充好虚函数(比如将mc->init指向pc_q35_init)。 - 实例创建:QEMU 分配一个
MachineState结构体空间。 - 属性解析:解析
-m 2G,将内存大小等参数写入这个MachineState实例中。 - 启动机器:最终,QEMU 调用
MachineClass->init(MachineState)。在这个init函数中,QEMU 会读取MachineState里的配置(如 2G 内存、CPU 数量),开始真正的硬件模拟初始化。
G233 机器类型¶
hw/riscv/g233.c 实现了 g233 机器类型。定义了虚拟硬件布局。
硬件设备包括:
- CPU/内存
- 中断:PLIC, CLINT/ACLINT, APLIC/IMSIC
- 外设
- UART
- PCIe host
- VirtIO MMIO slots
- Goldfish RTC
- Dual CFI Flash
- Syscon poweroff/reset
machine 初始化中包含了 class_init 以及 instance_init。
class_init 中设置了 MachineClass 对象,设置了 MachineClass->init() 函数,这个函数初始化每个 cpu、创建中断,设置 SoC 内部硬件布局
instance_init 中初始化了机器的状态RISCVG233State
前端模拟是 QEMU 向虚拟机 Guest OS 呈现的虚拟硬件设备,包括设备的寄存器接口、中断线、MMIO 地址空间和 DMA 能力,让 Guest 认为自己在与真实硬件交互。 后端模拟是 QEMU 在宿主机侧处理 Guest IO 请求的真实资源实现。
g233_pwm¶
代码分成三部分
- QOM 骨架
- MMIO
- 时钟更新与中断触发
QOM 骨架
参考 g233_gpio.c 完成,class_init 注册类型,instance_init 注册总线、MMIO、irq 等,realize 注册回调。除此之外,再完成 build 选项 (Kconfig, meson.build) 以及 g233.c 中的地址空间分配、设备创建、fdt 创建。
MMIO
注册 read/write 两个函数,读写寄存器,增加修改寄存器后的副作用 (硬件行为)
时钟更新与中断触发
duty, period, cnt 的关系:
- duty < period
- 当 cnt 计数达到 duty 后进入低电平,当 cnt 计数达到 period 后触发中断。
g233_wdt¶
val寄存器中的值随时间递减。
喂狗时,val寄存器重置为load
spi¶
在完成 spi 实验时,参考了 ASPEED 的 spi 结构:
+-------------------------------------------+
| CPU / System Bus |
+-------------------------------------------+
| |
(MMIO R/W) | | (AHB Memory R/W)
Control Regs | | Flash Data Window
V V
+===================================================================================+
| AspeedSMCState |
| |
| +--------------------------+ +----------------------------+ |
| | 寄存器访问接口 | | Flash 内存映射窗口 | |
| | (aspeed_smc_read/write) | | (aspeed_smc_flash_read/ | |
| | | | aspeed_smc_flash_write) | |
| | [0x00] 配置寄存器 | 控制工作模式 | | |
| | [0x04] CE 控制寄存器 | ------------------> | 将 CPU 的直接内存读写 | |
| | [0x10] CEx 控制寄存器 | | 动态翻译为 SPI 读写时序 | |
| | [0x..] 其他状态寄存器 | | | |
| +--------------------------+ +----------------------------+ |
| | | |
| +----------------------+--------------------------+ |
| | |
| +-----------------------+ |
| | SPI 核心序控器 | <--- AspeedSMCFlash (0..N) |
| | (SPI Core Sequencer) | 保存每个 CS 的起始地址、 |
| | | 模式(User/Normal)等上下文 |
| +-----------------------+ |
| (qdev_set_irq / GPIO) | | (ssi_transfer / SSI Bus) |
| +-----------------------------+ +-----------------------------------+ |
| | CS0 | CS1 | ... | CSn | | MOSI / MISO / CLK | |
+==|=====|===========|=======|===============|===================================|==+
| | | | | | |
v v v v v v v
+---------+ +---------+ +-----------+ +-----------+ +-----------+
| Flash | | Flash | | Flash 0 | | Flash 1 | | Flash N |
| CS 0 | | CS 1 | ... | (m25p80) | | (m25p80) | ... | (m25p80) |
| (GPIO) | | (GPIO) | | SPI Data | | SPI Data | | SPI Data |
+---------+ +---------+ +-----------+ +-----------+ +-----------+
spi 的接收和发送是同时发生的,当发生了一个 transmit,要设置缓冲区空以及接收区非空的标志。
问题记录¶
如何支持一个新的外设?
参考现有的设备
- QOM 注册类型,并定义变体
- 状态结构中保存寄存器、FIFO、IRQ、时钟
- instance_init 配置 MMIO/IRQ/时钟
- realize 绑定 chardev 回调
- MMIO 读写驱动寄存器与 FIFO
- 机型侧完成地址映射与中断连接
外设建模过程 https://qemu.gevico.online/tutorial/2026/ch2/qemu-hw/
三个 init 函数的作用
- class_init (e.g.
virt_machine_class_init)
执行时间:每个 type 都会注册一个 class_init,在 QEMU 启动时注册类型,所有的 class_init 都通过链表组织
作用:它用于定义“蓝图”
此时还不存在设备
- instance_init (e.g.
virt_machine_instance_init)
执行时间:它是每个对象创建的第一步,在用户的 CLI 参数被应用之前
作用:给特定的板级对象设置默认状态
- MachineClass->init (e.g.
virt_machine_init)
执行时间:在用户 CLI 参数被解析后
作用:实际构建了板级模型,分配内存,创建 cpu,设置中断等
| 特性 | class_init | instance_init | realize |
|---|---|---|---|
| 执行频次 | 每类(Class)一次 | 每实例(Instance)一次 | 每实例一次 |
| 执行前置条件 | 类的首次加载 | 对象内存分配后 | 所有外部属性被赋值后 |
| 是否允许失败 | 否(不可中断) | 否(返回 void) |
是(通过 Error **errp 报错) |
| 主要操作对象 | 虚函数表、类元数据 | 内部数据、子组件、默认状态 | 系统总线、外部后端、MMIO/IRQ |
中断的输出有几种形式?
在 QEMU 硬件模拟中,中断(或者说硬件信号的输出)主要分为两种:
- 传统线中断(Wire IRQ / Pin):通过 qemu_irq 模拟真实的物理导线电平变化。这是最常见的形式,调用
qemu_set_irq(irq, level)来改变电平(0 变 1,或 1 变 0)。PL011、各种 GPIO 模块发给 CPU PLIC 的中断都属于这种。 - 消息信号中断(MSI / MSI-X):常见于 PCI/PCIe 设备。它不是通过专门的物理线,而是设备直接向特定的内存地址(如 APIC/IMSIC 地址)发起一次 DMA 内存写入操作来触发中断。
中断的输入有几种形式?
设备接收外部硬件信号(输入)主要对应上面的第一种:
- 作为 SysBus 设备接收中断线:如果你的设备是中断控制器(比如 PLIC),它会暴露出很多“输入引脚”供其他外设连接(使用 qdev_init_gpio_in 注册回调函数接收电平跳变事件)。
- 通过内存映射监听 MSI:如果你的设备是 IMSIC 等高级中断控制器,它的“输入”其实就是监听别人往它的 MMIO 寄存器里“写数据”。
qdev_init_gpio_in / qdev_init_gpio_out 的作用
qdev_init_gpio_in(dev, handler, n):为设备暴露 n 个匿名的输入引脚。当外部通过连线改变了这根引脚的电平,QEMU 会自动调用我们注册的 handler 函数(在我们的例子里就是 g233_gpio_set)。qdev_init_gpio_out(dev, pins, n):为设备申请 n 个匿名的输出引脚,并将它们绑定到你的 qemu_irq 数组 pins 上。你可以通过qemu_set_irq(pins[i], 1)来驱动连在这个引脚上的下游设备。
写好的 g233_gpio 怎么注册到 g233 board 中
注册过程通过 sysbus_create_simple("g233_gpio", base_addr, irq)函数实现。这个函数在底层做了三件事:
- 实例化 TYPE_G233_GPIO 这个类(调用其 realize)。
- 把它的 mmio 区域映射到物理地址 base_addr(如 0x10012000)。
- 连接中断线:把 qdev_get_gpio_in(mmio_irqchip, GPIO_IRQ) 作为第三个参数传给上面。这相当于拿了一根“虚拟导线”,把 g233_gpio 的 sysbus_init_irq 的输出端,和 PLIC(mmio_irqchip)的 GPIO_IRQ 号输入管脚相连接了。
qemu_fdt_setprop_cells(ms->fdt, name, "interrupts", GPIO_IRQ, 0x4) 中的 0x4 作用
在 Device Tree 的中断绑定规范中,第二个单元(Cell)通常用来描述触发类型(Trigger Type)。 在 RISC-V PLIC 的标准里:
- 0x1:上升沿触发 (Edge-Rising)
- 0x4:高电平触发 (Level-High)
由于在 QEMU 内部我们的 s->irq 通常只用 0 和 1 代表低/高电平,这里标明 0x4 是告诉 Guest OS:“这个 GPIO 的综合中断是以高电平持续有效的形式发送给 PLIC 的”。
deposit32 以及 extract32 的作用是什么
它们是 QEMU 提供的位操作安全宏。
- extract32(value, start, length):从 value 的第 start 位开始,连续提取 length 个比特,并把它移到最低位返回。
- deposit32(value, start, length, fieldval):把 fieldval 的低 length 位,安全地塞进 value 的第 start 位开始的位置中,并保持 value 其他位不变,返回新值。 这比手写 (val & ~(mask << shift)) | (new_val << shift) 这种位运算更安全。
总结编写 gpio device model 过程中使用的重要 API
- QOM 框架:DECLARE_INSTANCE_CHECKER, type_init, device_class_set_props(声明类和属性)。
- 系统总线与内存:memory_region_init_io(绑定读写回调),sysbus_init_mmio(暴露内存)。
- 中断与连线:sysbus_init_irq(向上级控制器暴露中断输出),qdev_init_gpio_in/out(与平级外部设备交换电平),qemu_set_irq(发送电平变化)。
- 状态保存:VMSTATE_UINT32(用于 QEMU 休眠/快照时保存现场)。
qemu fdt 的作用是什么?
Flattened Device Tree (FDT) 是嵌入式 Linux 启动的核心。 在没有 ACPI 的 RISC-V 和 ARM 世界,CPU 刚上电时对周围的外设一无所知。QEMU 动态拼接出一个 FDT,在启动前把它放进某块约定好的内存里。内核启动时,顺着 FDT,动态发现内存和外设的布局,从而动态挂载相应的驱动。
使用 qemu 调试 linux 内核¶
为什么
搭建一个零成本、可重复、安全的内核调试环境,不需要真实硬件,不怕损坏系统
优势分析
| 方法 | 优势 | 劣势 |
|---|---|---|
| printk |
简单 | 重编译,能使用的场景少 |
| ftrace |
动态,无需重编译 | 学习难 |
| GDB + QEMU | 源码级调试 | 需要虚拟化环境? |
| crash / kdump | 事后 coredump | 仅限崩溃 |
| /proc, /sys |
运行时观察 | 粗粒度 |
配置
# 保留调试符号
CONFIG_DEBUG_INFO=y
CONFIG_DEBUG_INFO_DWARF5=y
# 禁用地址随机化,方便断点
CONFIG_RANDOMIZE_BASE=n
# GDB 辅助脚本
CONFIG_GDB_SCRIPTS=y
# 保留帧指针
CONFIG_FRAME_POINTER=y
# 防止变量被优化掉
CONFIG_CC_OPTIMIZE_FOR_SIZE=y
启动参数
-s --> -gdb tcp::1234
-S 启动后暂停 CPU,等待 gdb 连接
qemu 中实现了一个 gdb server
在内核压缩、MMU 启动后,需要对符号重定向才能对应上代码
调试内核模块
内核模块是动态加载的,qemu 不知道它的符号地址
两步找到符号地址:
/sys/module/mini_rdma/sections/.text文件中找到模块加载地址- 在 gdb 中加载模块符号
gdb 中查看设备结构体与自定义结构体(可以用括号做类型强转)
内核 GDB 调试脚本
加载内核辅助脚本
常用命令
| 常用命令 | 作用 |
|---|---|
| lx-dmesg | 查看 dmesg 日志 |
| lx-lsmod | 列出模块 |
| lx-ps | 列出进程 |
| lx-cmdline | 内核命令行 |
| lx-cpus | CPU 信息 |
| lx-mount | 挂载点 |
| lx-iomem | IO 内存映射 |
| lx-device-list-bus bus_type | 设备列表 |
能直接读内核数据结构,信息比 /proc 更多
条件断点
创建断点时添加条件
给现有断点添加条件
(gdb) info break # 查看断点编号,如Breakpoint 1
(gdb) condition 1 num == 5 # 只在num=5时触发
(gdb) condition 1 # 删除条件,变回普通断点
其他技巧
- 多条件:
b 30 if (x > 10 && y < 20) - 内核参数:
b func_a if arg1 > 0 - 内存地址:
watch *0x7fff5fbff000 if *(int*)0x7fff5fbff000 == 42 - 临时断点:
tbreak start_kernel - 忽略 100 次触发:
ignore 1 100
用硬件断点
hbreak或数据监视点watch if可以优化性能硬件断点使用 CPU 的 4 个调试寄存器(x86),数据监视点也利用 CPU 硬件监控数据访问
命令
finish执行到当前函数返回until <line_number>执行到指定行bt调用栈i locals查看局部变量i registers查看寄存器x/16xg $rsp查看内存disassemble /s反汇编
总结¶
我觉得在学习阶段,有意识地少用 ai coding 是好的,因为目的是学习,可以亲手码一码,印象会更深。
参考资料¶
- QEMU 主板建模流程: https://qemu.gevico.online/tutorial/2026/ch2/qemu-machine/
- QOM 深度解析博客: https://martins3.github.io/qemu/qom.html
- QEMU 官方 qdev API 文档: https://www.qemu.org/docs/master/devel/qdev-api.html
- Airbus Seclab QEMU 技术博客: https://github.com/airbus-seclab/qemu_blog
- 【QEMU 训练营 | 基于 QEMU 调试 Linux 内核手把手教学】: https://www.bilibili.com/video/BV14NAPzjEPW/