3、QEMU虚拟机调试环境:QEMU安装与配置、内核与根文件系统制作、GDB远程调试连接

说实话,搞内核调试最头疼的是什么?不是代码看不懂,而是环境搭不起来。你想想看,在真机上调试内核,一个断点下去系统就挂了,还得拿串口线、JTAG调试器,麻烦得要命。

QEMU 虚拟机就是来解决这个痛点的。它能在你的笔记本上模拟出一台完整的 ARM 或 x86 机器,内核跑在里面,GDB 在外面调试。我这些年做内核驱动开发,几乎天天跟这套环境打交道。今天就把这套环境的搭建方法完整过一遍。

核心思路:QEMU 模拟硬件 → 内核在虚拟机中运行 → GDB 通过 1234 端口远程连接 → 实现源码级调试

3.1 QEMU 安装与配置

QEMU 的安装其实很简单,但有个坑——你得装带调试支持的版本。默认的包管理器安装的 QEMU 可能不带 gdbstub 功能,那就没法远程调试了。

我个人习惯从源码编译,这样能确保所有调试特性都打开:

# 下载 QEMU 源码(我用的是 7.2.0 版本,比较稳定)
wget https://download.qemu.org/qemu-7.2.0.tar.xz
tar -xf qemu-7.2.0.tar.xz
cd qemu-7.2.0

# 配置时一定要打开调试支持
./configure --target-list=aarch64-softmmu,x86_64-softmmu \
            --enable-debug \
            --enable-gdbstub \
            --enable-kvm

# 编译安装
make -j$(nproc)
sudo make install

小提示:如果你只是临时用用,apt install qemu-system-arm 也能装。但我建议还是源码编译一次,因为后面调试时可能会需要 QEMU 的调试符号。

验证安装是否成功:

qemu-system-aarch64 --version
# 输出应该包含 "QEMU emulator version 7.2.0"
# 并且确认 --gdbstub 参数可用
qemu-system-aarch64 --gdbstub help

嗯,这里要注意:如果你是在 WSL 或容器里装 QEMU,KVM 可能用不了。没关系,纯软件模拟也能调试,就是慢一点。我在项目中遇到过这种情况,调试一个网络驱动,每步执行要等好几秒,但总比没有强。

3.2 内核编译与根文件系统制作

QEMU 装好了,接下来得准备两样东西:内核镜像和根文件系统。说白了,就是让虚拟机里能跑 Linux,并且能登录进去。

3.4.1 编译带调试符号的内核

调试内核,最关键的是编译时打开 DEBUG_INFO。我曾经因为忘了开这个选项,折腾了一下午才发现 GDB 看不到源码——全是汇编,那叫一个痛苦。

# 以 ARM64 为例
export ARCH=arm64
export CROSS_COMPILE=aarch64-linux-gnu-

# 配置内核
make defconfig

# 手动打开调试选项
make menuconfig
# Kernel hacking  --->
#   [*] Compile-time checks and compiler options
#   [*]     Debug information
#   [*]         Provide GDB scripts for kernel debugging

# 编译内核(-j 后面跟 CPU 核心数)
make -j$(nproc) Image.gz
make -j$(nproc) dtbs

编译完成后,你会得到这些文件:

文件 用途
arch/arm64/boot/Image.gz 压缩后的内核镜像,QEMU 启动用
vmlinux 未压缩的内核,带调试符号,GDB 连接用
arch/arm64/boot/dts/.../*.dtb 设备树文件,描述硬件信息

警告:vmlinux 文件很大(几百 MB),但千万别删!GDB 调试全靠它。Image.gz 才是 QEMU 真正加载的内核。

3.4.2 制作根文件系统

根文件系统说白了就是虚拟机里的 "/" 目录。最简单的做法是用 BusyBox 做一个最小系统。我刚开始学的时候,用 buildroot 一键生成,但后来发现还是手动搭更能理解原理。

# 下载 BusyBox
wget https://busybox.net/downloads/busybox-1.36.0.tar.bz2
tar -xf busybox-1.36.0.tar.bz2
cd busybox-1.36.0

# 配置为静态编译(省去动态库的麻烦)
make defconfig
make menuconfig
# Settings  --->
#   [*] Build static binary (no shared libs)

# 编译安装
make -j$(nproc)
make install CONFIG_PREFIX=../rootfs

# 创建必要的目录和设备节点
cd ../rootfs
mkdir -p proc sys dev etc/init.d

# 创建初始化脚本
cat > etc/init.d/rcS << 'EOF'
#!/bin/sh
mount -t proc none /proc
mount -t sysfs none /sys
mknod /dev/ttyAMA0 c 204 64
echo "Welcome to QEMU Debug Environment!"
/bin/sh
EOF

chmod +x etc/init.d/rcS

# 打包成 cpio 格式(QEMU 可以直接加载)
find . | cpio -o --format=newc > ../rootfs.cpio
gzip -c ../rootfs.cpio > ../rootfs.cpio.gz

避坑指南:我曾经在制作根文件系统时,忘了创建 /dev/ttyAMA0 设备节点,结果 QEMU 启动后控制台一片黑。后来查了半天才发现是串口设备没创建。记住,ARM64 的默认串口是 ttyAMA0,x86 是 ttyS0。

3.3 GDB 远程调试连接

好,现在内核和根文件系统都准备好了,该进入正题了——用 GDB 远程调试内核。

3.3.1 启动 QEMU 并等待 GDB 连接

启动 QEMU 时加上 -s 参数,它会在 1234 端口开启 gdbstub 服务:

qemu-system-aarch64 \
  -machine virt \
  -cpu cortex-a57 \
  -smp 1 \
  -m 512M \
  -kernel Image.gz \
  -initrd rootfs.cpio.gz \
  -append "console=ttyAMA0 root=/dev/ram rdinit=/sbin/init nokaslr" \
  -nographic \
  -s \
  -S

这里有两个关键参数:

  • -s:等价于 -gdb tcp::1234,开启 GDB 远程调试端口
  • -S:启动时暂停 CPU,等待 GDB 连接后再开始执行

加上 nokaslr 参数是为了禁用内核地址空间布局随机化,这样调试时地址不会变来变去。我在项目中遇到过,没加这个参数,每次断点地址都不一样,根本没法调试。

3.3.2 用 GDB 连接 QEMU

另开一个终端,启动 GDB:

# 使用带调试符号的 vmlinux
aarch64-linux-gnu-gdb vmlinux

# 在 GDB 中连接 QEMU
(gdb) target remote :1234
Remote debugging using :1234
0x0000000040000000 in ?? ()

# 设置源码路径(如果内核源码不在当前目录)
(gdb) set substitute-path /path/to/kernel/source /your/actual/path

# 打个断点试试
(gdb) hbreak start_kernel
Hardware assisted breakpoint 1 at 0xffffffc000080000: file init/main.c, line 537.

# 让内核继续执行
(gdb) continue
Continuing.

嗯,这里要注意:内核调试建议用 hbreak(硬件断点)而不是 break。因为内核在启动早期,内存管理还没初始化,软件断点可能不生效。我刚开始用 break 打不上断点,还以为是 GDB 坏了,后来才发现是断点类型的问题。

3.3.3 常用调试命令

连接成功后,你就可以像调试普通程序一样调试内核了。下面是我最常用的一些命令:

命令 用途 示例
hbreak 硬件断点 hbreak do_fork
watch 监视变量变化 watch current->pid
info registers 查看 CPU 寄存器 info registers x0 x1
bt 查看调用栈 bt
list 显示源码 list kernel/fork.c:100
p (print) 打印变量值 p current->comm

调试小技巧:调试内核模块时,可以用 add-symbol-file 命令加载模块的调试符号。比如:(gdb) add-symbol-file mymodule.ko 0xffffffc000100000,后面的地址是模块加载的基地址,可以从 /sys/module/mymodule/sections/.text 获取。

3.4 完整调试流程示例

说了这么多,来个完整的例子。假设我们要调试 do_fork 函数,看看进程创建时发生了什么:

# 终端1:启动 QEMU
qemu-system-aarch64 \
  -machine virt -cpu cortex-a57 \
  -smp 1 -m 512M \
  -kernel Image.gz \
  -initrd rootfs.cpio.gz \
  -append "console=ttyAMA0 root=/dev/ram rdinit=/sbin/init nokaslr" \
  -nographic -s -S

# 终端2:启动 GDB
aarch64-linux-gnu-gdb vmlinux

# GDB 命令序列
(gdb) target remote :1234
(gdb) hbreak do_fork
(gdb) continue
# 内核开始启动,直到遇到 do_fork 断点

# 查看调用栈
(gdb) bt
#0  do_fork (clone_flags=...)
#1  kernel_thread (fn=..., arg=..., flags=...)
#2  ...

# 查看参数
(gdb) p clone_flags
$1 = 0x1200011

# 单步执行
(gdb) step
(gdb) list

# 继续执行
(gdb) continue

你看,整个过程就像调试普通程序一样流畅。内核在 QEMU 里跑,GDB 在外面控制,断点、单步、查看变量,样样都行。

注意:调试过程中如果 QEMU 卡住了,可以用 Ctrl+A 然后按 X 退出。千万别直接关终端,那样可能留下僵尸进程。

3.5 常见问题与解决

最后,分享几个我踩过的坑:

  1. GDB 连接不上:检查 QEMU 是否真的在监听 1234 端口。用 netstat -tlnp | grep 1234 看看。
  2. 断点打不上:试试 hbreak 而不是 break。如果还不行,检查内核是否编译了 CONFIG_DEBUG_INFO。
  3. 源码显示乱码:用 set substitute-path 命令映射源码路径。GDB 默认找的是编译时的路径,跟你本地的路径可能不一样。
  4. QEMU 启动后黑屏:检查 console 参数是否正确。ARM64 用 ttyAMA0,x86 用 ttyS0。

这套环境搭好之后,调试内核就变得非常方便了。不管是分析内核崩溃、跟踪系统调用,还是调试驱动模块,都能在 QEMU + GDB 的环境里轻松完成。我个人现在做内核开发,几乎都是在这套环境里先验证,再部署到真机上。


公众号:蓝海资料掘金营,微信deep3321