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:单步执行

下面这张图展示了整个调试流程:

QEMU GDB 调试流程 QEMU GDB 单步调试流程 1. gdb --args qemu-system-riscv64 2. (gdb) break clint_write 3. (gdb) run 4. 断点命中,QEMU 暂停执行 5. print addr / print value / backtrace / next 💡 说明: GDB 调试 QEMU 本身,与调试普通 Linux 程序完全一致。你可以设置条件断点、观察点,甚至修改内存。

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 单步调试。这些技能是后续所有外设模拟开发的基石。你想想看,当你的外设行为异常时,能直接断点到对应的读写函数,查看调用栈和参数,是不是比瞎猜高效得多?