11.4 用 KGDB 狠狠地调试内核
上一节我们已经把这只名为「ARM VExpress」的虚拟羊养活了。它能在 QEMU 里欢快地跑起来,吐出一堆启动日志,最后乖乖地给你一个 shell 提示符。
但这还不够。
作为一个内核开发者,光是看着它跑起来是远远不够的——你得能把它按住,切开,看看它脑子里现在到底在想什么。这一节,我们要把这只有些飘忽的虚拟羊变成一个透明的玻璃标本,每一个函数调用、每一次指针跳转都要在我们的掌控之中。
这就引出了我们在这一章真正要讲的硬菜:KGDB。
唤醒野兽:让内核在启动时停下来
现在的内核像是一列出站后就加速狂奔的火车,如果不采取点措施,它是不会主动停下来等你的。
我们现在的目标是:让内核在启动的最早期就暂停下来,像个听话的学生一样坐在那里等老师(GDB)来提问。
如果你翻过内核文档,你会知道为了在真实硬件上做到这一点,通常需要两步走:
- 编译内核时开启 KGDB 支持,并指定一个 I/O 驱动(比如
kgdboc),告诉内核:「用串口跟外界说话」。 - 在启动参数里加上
kgdbwait,告诉内核:「一启动就给我等着,别动」。
但在 QEMU 的虚拟世界里,这一切可以简单得多——或者说,更「黑客」一点。QEMU 本身就是上帝视角,它不需要串口这种慢吞吞的物理介质,可以直接在内存里跟 GDB 打交道。
运行目标系统:给 QEMU 上点手段
打开刚才跑 QEMU 的终端,我们需要给那条长长的命令加点佐料。
QEMU 提供了两个极其方便的参数(请务必记住它们,你会用得很频繁):
-S:Freeze CPU at startup。冻结 CPU,不让它跑。相当于按下了暂停键。-s:Shorthand for-gdb tcp::1234。在 1234 端口上开一个监听,等着 GDB 连进来。
好了,现在把你的 QEMU 启动命令修改一下。注意,我们在上一节的基础上增加了 -S -s,并且给内核传递了 nokaslr 参数——这一点至关重要,因为 KASLR(内核地址空间随机化)会让你的函数地址飘忽不定,调试起来就像在打移动靶,所以我们要先把它这层防御卸掉。
$ qemu-system-arm -m 512 \
-M vexpress-a9 -smp 4,sockets=2 \
-kernel <...>/seals_staging_vexpress/images/zImage \
-drive file=<...>/seals_staging_vexpress/images/rfs.img,if=sd,format=raw \
-append "console=ttyAMA0 rootfstype=ext4 root=/dev/mmcblk0 init=/sbin/init nokaslr" \
-nographic -no-reboot -audiodev id=none,driver=none \
-dtb <...>/seals_staging_vexpress/images/vexpress-v2p-ca9.dtb \
-S -s
回车敲下去。
你会发现,这次没有滚动的 log,没有 Freeing unused kernel memory,屏幕一片死寂。
这就对了。这只羊已经被冻住了。
⚠️ 注意 如果在你的机器上这一步有什么奇怪的行为,检查一下是不是后台已经跑着一个 QEMU 进程在占用资源。虽然纯软件模拟的 ARM(跑在 x86 上)通常不太会撞车,但总是保险一点好。
连接客户端:GDB 登场
现在,舞台交给了宿主机上的 GDB。这里有一个极其容易混淆的点,我必须强调一下:
我们要用的是交叉编译工具链里的 GDB,不是你系统里那个原生的 gdb!
为什么?因为这是在跑 ARM 代码,你用 x86 的 GDB 去调试 ARM 的世界,那就是鸡同鸭讲。
开一个新的终端窗口(因为 QEMU 那个窗口已经被占用了),把调试器叫出来:
$ arm-none-linux-gnueabihf-gdb -q <...>/seals_staging_vexpress/linux-5.10.109/vmlinux
Reading symbols from <...>/seals_staging_vexpress/linux-5.10.109/vmlinux...
看到那句 Reading symbols 了吗?这太重要了。
注意这里加载的是 vmlinux——那个未压缩的、胖胖的、带着满肚子调试符号的内核 ELF 文件,而不是我们刚才启动用的瘦子 zImage。GDB 现在正在把内核里所有的函数地址、变量结构统统吃到脑子里去。
一旦符号加载完毕,我们就有了这只羊的完整地图。现在,只需要把地图和实地连起来:
(gdb) target remote :1234
Remote debugging using :1234
0x60000000 in ?? ()
(gdb)
那个 0x60000000 是什么?那是程序计数器(PC)停下的地方。虽然现在还没加载符号所以它显示 ??,但这标志着连接成功了。
第一次断点:像魔法一样暂停
现在,你是上帝。
你可以让这只野兽继续跑,也可以让它随时停下。让我们试个最经典的:在网络驱动初始化的地方设个卡子。
(gdb) b register_netdev
Breakpoint 2 at 0x80754bc8: file net/core/dev.c, line 10238.
输入 c(continue),让内核继续跑。
屏幕上开始滚动了。但没过多久,就像撞上了一堵看不见的墙——它停住了。GDB 跳了出来,告诉你断点触发了:
(gdb) c
Continuing.
Breakpoint 2, register_netdev (dev=0x818a0800) at net/core/dev.c:10238
10238 {
(gdb)
这简直太爽了。
输入 bt(backtrace),你会看到一长串调用栈——从底层的汇编启动代码,一直到 C 语言的网络子系统,层层叠叠,清晰可见。
(gdb) bt
#0 register_netdev (dev=0x818a0800) at net/core/dev.c:10238
#1 0x8085b8a4 in smsc911x_probe (...) at drivers/net/ethernet/smsc/smsc911x.c:2466
#2 0x8047d1f8 in platform_drv_probe (...) at drivers/base/platform.c:645
...
(gdb)
这就是 register_netdev 函数的现场。你现在就站在这个函数的门口,手里拿着手术刀。
实战演练:透视数据结构
既然停下来了,我们不妨做点更刺激的。
register_netdev 的参数是一个 struct net_device * 指针。这个结构体是网络子系统的核心,大得离谱。让我们看看它长什么样:
(gdb) p dev
$1 = (struct net_device *) 0x818a0800
只是一个地址。不够劲。把内容全打出来:
(gdb) p *dev
$2 = {name = "eth%d\000...", name_node = 0x0, ifalias = 0x0, mem_end = 0, ...}
如果你在终端里看到这一坨东西挤成一团,估计要头大。GDB 默认的输出格式确实有点反人类。
但我们可以调整它。输入这行命令,你的世界会清静很多:
(gdb) set print pretty
(gdb) p *dev
$3 = {
name = "eth%d\000\000\000\000\000\000\000\000\000\000",
name_node = 0x0,
ifalias = 0x0,
mem_end = 0,
mem_start = 0,
base_addr = 0,
irq = 30,
state = 4,
...
}
(gdb)
这就舒服多了。
你可以看到这个设备的 IRQ 号是 30,名字还是模板字符串 eth%d(因为还没注册完)。这就是内核在启动那一瞬间的真实面貌——没有任何秘密可言。
再次出发:让它跑完,再把它抓回来
看完了现场,我们还得让系统活命。继续输入 c。
系统会完成剩下的启动流程,直到你再次看到那个熟悉的 / # 提示符。
你以为调试结束了吗?不。
只要 QEMU 还在跑,只要 GDB 还连着,你随时可以把这只羊拽回来。在 GDB 的窗口里按下 Ctrl + C。
(gdb) c
Continuing.
^C
Program received signal SIGINT, Interrupt.
cpu_v7_do_idle () at arch/arm/mm/proc-v7.S:78
78 ret lr
(gdb)
看,它停在了 cpu_v7_do_idle。这是 ARM 处理器没事干的时候打盹的地方。
用 bt 看一眼栈,你会看到它确实是在 idle 进程里待着呢。
这就是源码级远程调试的威力。你不是在猜它为什么慢,你是在看着它干活。
收尾:像绅士一样关机
玩够了,别像杀进程那样粗暴地干掉 QEMU。
虽然直接杀掉也行,但既然我们现在是在模拟一个正经的 Linux 系统,就尽量按规矩来。在那个串口终端里输入 poweroff,看着它优雅地关闭:
/ # poweroff
[ ... ] System halted.
好了,这一章的“开膛手”课程只是个热身。我们学会了怎么把内核按住,但真正的挑战往往是那些你自己写的、一加载就炸的内核模块。
下一节,我们就来对付那些捣乱的模块。