3. QEMU 源码编译与调试:从源码编译QEMU、添加自定义调试信息、使用GDB单步调试QEMU
做嵌入式模拟开发,最怕的就是黑盒。QEMU 虽然强大,但如果你只把它当个黑盒子用,遇到诡异的外设行为或者莫名其妙的崩溃,基本就抓瞎了。我个人习惯,拿到一个新平台,第一件事就是自己从源码编译一遍 QEMU,再配上 GDB 调试环境。这就像你开一辆车,至少得知道引擎盖怎么打开,对吧?
这一章,我们就来亲手做这件事。从拉取源码、配置编译,到加入自己的调试打印,最后用 GDB 单步跟踪 QEMU 的执行流程。走完这一遍,你对 QEMU 的掌控力会上一个台阶。
3.1 为什么非要自己编译?
直接用 apt-get 安装 QEMU 多省事啊。嗯,确实省事,但代价是什么?
- 调试符号被剥离:发行版为了省空间,通常会把 debug 符号去掉。你没法用 GDB 设置断点,看变量值。
- 无法加自定义代码:你想在某个外设的读写函数里加个打印,看看 Guest OS 到底发了什么命令。二进制包做不到。
- 版本锁定:你想尝鲜新特性,或者修复某个老版本的 bug,只能等发行版更新。
核心观点:自己编译 QEMU,你获得的是对模拟器的完全控制权。这是后续所有外设模拟开发的基础。
3.2 编译前的准备:依赖与源码
先搞定编译工具链和依赖库。我在 Ubuntu 22.04 上操作,其他发行版大同小异。
# 安装基础编译工具
sudo apt-get install build-essential git python3 ninja-build
# 安装 QEMU 依赖库
sudo apt-get install libglib2.0-dev libpixman-1-dev libfdt-dev
# 可选,但强烈建议安装(用于图形界面和加速)
sudo apt-get install libsdl2-dev libslirp-dev
小提示:libfdt 是 Flattened Device Tree 的支持库。如果你要模拟 RISC-V 这种依赖设备树的平台,这个是必须的。我曾经因为漏装这个库,编译出来的 QEMU 启动 RISC-V 内核时直接报设备树错误,排查了半天。
然后拉取 QEMU 源码。我们以当前稳定分支为例:
git clone https://gitlab.com/qemu-project/qemu.git
cd qemu
git checkout stable-8.2 # 或者你喜欢的版本
3.3 配置与编译:精细控制
QEMU 的配置系统基于 meson。你可以通过 configure 脚本精细控制要编译哪些目标架构。我们只关心 RISC-V,所以可以这样:
mkdir build
cd build
../configure \
--target-list=riscv64-softmmu \
--enable-debug \
--enable-debug-info \
--enable-slirp \
--enable-sdl
几个关键选项解释一下:
| 选项 | 作用 |
|---|---|
--target-list=riscv64-softmmu |
只编译 RISC-V 64位系统模拟器,大幅缩短编译时间 |
--enable-debug |
开启 QEMU 自身的断言和调试检查 |
--enable-debug-info |
生成 GDB 调试符号,这是单步调试的前提 |
--enable-slirp |
用户态网络支持,方便 Guest 上网 |
注意:--enable-debug 会降低 QEMU 的运行速度,因为它会插入很多运行时检查。但调试阶段完全值得。发布时再关掉就好。
配置完成后,开始编译:
make -j$(nproc)
第一次编译大概需要 5-10 分钟。编译完成后,你会在 build 目录下看到 qemu-system-riscv64 可执行文件。
3.4 添加自定义调试信息:让 QEMU 开口说话
编译好了,怎么验证它真的在跑我们的代码?加个打印呗。我们找个典型的外设文件来动手。RISC-V 的 CLINT(Core Local Interruptor)是一个基础外设,负责定时器和软件中断。它的代码在 hw/intc/riscv_clint.c。
打开这个文件,找到 clint_write 函数。这个函数在 Guest 写 CLINT 寄存器时被调用。我们加一行打印:
static void clint_write(void *opaque, hwaddr addr, uint64_t value,
unsigned size)
{
// ... 原有代码 ...
// 我加的调试信息:打印写入的地址和值
qemu_log_mask(LOG_GUEST_ERROR,
"CLINT: write addr 0x%" HWADDR_PRIx
" value 0x%" PRIx64 "\n",
addr, value);
// ... 原有代码 ...
}
这里用了 qemu_log_mask,它是 QEMU 提供的日志系统。比直接用 printf 更规范,而且可以通过 -d 参数控制是否输出。
重新编译:
make -j$(nproc)
然后启动 QEMU 时加上 -d guest_errors 参数:
./qemu-system-riscv64 -M virt -kernel your_kernel.elf -d guest_errors
你就能看到类似这样的输出:
CLINT: write addr 0x2000000 value 0x1234
CLINT: write addr 0x2000004 value 0x5678
经验之谈:LOG_GUEST_ERROR 只是其中一种日志级别。QEMU 还提供了 LOG_TRACE、LOG_UNIMP(未实现功能)等。我一般用 LOG_UNIMP 来标记那些我还没写完的外设函数,方便快速定位。
3.5 GDB 单步调试 QEMU:深入模拟器内部
打印日志虽然方便,但有时候我们需要更精细的控制——比如看某个变量在每次循环中的变化。这时候就得请出 GDB 了。
调试 QEMU 本身,和调试普通程序一样。我们启动 QEMU 时,让它等待 GDB 连接:
gdb --args ./qemu-system-riscv64 -M virt -kernel your_kernel.elf
然后在 GDB 里设置断点。比如我们想停在刚才的 clint_write 函数:
(gdb) break clint_write
(gdb) run
QEMU 会启动,然后停在第一次调用 clint_write 的地方。这时候你可以:
print addr:查看地址print value:查看写入的值backtrace:查看调用栈,了解是谁触发了这次写操作next或step:单步执行
下面这张图展示了整个调试流程:
3.6 进阶技巧:条件断点与观察点
有时候 clint_write 会被调用成千上万次,我们只关心某一次特定的调用。这时候条件断点就派上用场了。
比如,我们只关心对地址 0x2000000 的写入:
(gdb) break clint_write if addr == 0x2000000
或者,我们想监控某个内存地址的变化,可以用观察点:
(gdb) watch *(uint32_t*)0x2000000
这样,只要 Guest 写入这个地址,GDB 就会停下来,不管是通过哪个函数写的。
避坑指南:我曾经在调试一个 DMA 控制器时,用观察点监控了传输完成标志位。结果发现 QEMU 的 DMA 模型在更新标志位时,会先写一个临时值再写最终值,导致观察点被触发了两次。后来我改用条件断点,判断值等于特定标志才停下,才解决了问题。
3.7 调试 QEMU 与调试 Guest 的区别
这里容易混淆,我特意列个表说清楚:
| 调试对象 | 调试器 | 目标 | 典型场景 |
|---|---|---|---|
| QEMU 本身 | GDB(Host 上运行) | QEMU 进程 | 调试外设模拟代码、修复 QEMU 崩溃 |
| Guest 内核/驱动 | GDB(通过 QEMU 的 -gdb 参数) | Guest 内部程序 | 调试 Linux 驱动、分析内核 panic |
这一章我们做的是第一种——调试 QEMU 本身。第二种我们会在后面的章节专门讲。
3.8 本章小结
我们从零开始,完成了 QEMU 的源码编译、自定义调试信息添加,以及 GDB 单步调试。这些技能是后续所有外设模拟开发的基石。你想想看,当你的外设行为异常时,能直接断点到对应的读写函数,查看调用栈和参数,是不是比瞎猜高效得多?