11.5 调试内核模块:当符号表藏进内存里
上一节我们算是「黑」进了一个正在跑着的内核,看着它在 start_kernel 里醒来。但老实说,那种感觉更多像是在看舞台剧——我们只是观众,看导演安排好的剧情。
真正的地狱模式,是你自己写的代码正在台上发疯,而你手里只有一只没对好焦的手电筒。
你把内核模块 insmod 进去,如果运气好,屏幕一黑,系统重启;如果运气不好,什么都没发生,就像这个模块从未存在过。你 printk 加了一堆,但还是搞不清它到底在哪一行把内存踩烂了。
我们需要 KGDB。
用 KGDB 调试内核模块,跟调试内核本身很像,但有一个关键区别:GDB 根本不知道你的模块在内存的哪个角落。
内核代码是固定的,开机就在那里,不悲不喜。但模块是动态插拔的——它是一个加载进内存的 ELF 文件,内核会随意把它扔到某个空闲的虚拟地址上。GDB 再神算,也算不出内核当下的心情(和内存分配器)。
所以,我们必须当那个「告密者」——把模块在内存里的确切位置告诉 GDB。
告诉 GDB:你的模块在哪
内核其实很贴心,虽然它没直接告诉 GDB,但它把所有信息都摊开在 sysfs 里了。
去看看 /sys/module/<module-name>/sections/。这里面的每一个以点(.)开头的伪文件,都记录了该模块 ELF 段在内核虚拟内存里的加载地址。
假设我们要调试 usbhid 这个模块(先 lsmod 看看它加载了没),我们可以这样列出它的段信息:
ls -a /sys/module/usbhid/sections/
# 输出类似于:
./ ../ .rodata .symtab .bss .init.text .text
.data .exit.text [...]
这可不是普通的目录。把这些「文件」的内容读出来(需要 root 权限),你就能拿到地址。
来,读几个关键的段看看:
cd /sys/module/usbhid/sections
cat .text .rodata .data .bss
在我的 x86_64 Ubuntu 20.04 上,输出大概是这样(为了可读性我稍微调整了格式):
0xffffffffc033b000 # .text 的起始地址
0xffffffffc0348060 # .rodata 的起始地址
0xffffffffc034e000 # .data 的起始地址
0xffffffffc0354f00 # .bss 的起始地址
拿到这串数字,我们就能给 GDB 下药了。
我们需要用 add-symbol-file 命令。语法有点长,但逻辑很简单:先给它代码段的地址,然后挨个告诉它数据段在哪。
(gdb) add-symbol-file </path/to/>usbhid.ko 0xffffffffc033b000 \
-s .rodata 0xffffffffc0348060 \
-s .data 0xffffffffc034e000 \
[...]
一旦这行命令跑通,GDB 就像突然装上了透视眼——它能看懂模块里的函数名、变量名,甚至能让你在模块代码里下断点。
但手敲这行命令太痛苦了,容易出错,而且每次模块重载地址都会变。
好在,我们可以「作弊》。我改写了一个来自 LDD3(Linux Device Drivers 3)的经典脚本 ch11/gdbline.sh。它的工作原理很简单:遍历 /sys/module/<module>/sections/ 下的所有段文件,然后把那串超长的 add-symbol-file 命令自动拼好,直接打印出来。
你只需要复制、粘贴、回车。自动化万岁。
动手实战:捕捉一个会捣乱的模块
光说不练假把式。我们要真刀真枪地 KGDB 调试一个有 bug 的内核模块。
我们准备了一个实验小模块 ch11/kgdb_try。它很简单,但很致命:
- 初始化:启动一个延迟工作队列(Delayed Workqueue)。
- 执行:2.5 秒后,工作队列函数
do_the_work被调用。 - 捣乱:在这个函数里,我们故意写了一个溢出循环,把一个只有 10 个字节的局部数组
buf,硬是写了 11 个字节。
// ch11/kgdb_try/kgdb_try.c
static int __init kgdb_try_init(void)
{
pr_info("Generating Oops via kernel bug in a delayed workqueue function\n");
INIT_DELAYED_WORK(&my_work, do_the_work);
schedule_delayed_work(&my_work, msecs_to_jiffies(2500));
return 0;
}
static void do_the_work(struct work_struct *work)
{
u8 buf[10];
int i;
pr_info("In our workq function\n");
/* The bug: loop goes one too far! */
for (i = 0; i <= 10; i++)
buf[i] = (u8)i;
print_hex_dump_bytes("", DUMP_PREFIX_OFFSET, buf, 10);
// [...]
}
为什么要延迟 2.5 秒? 这是为了救你的命。如果没有这个延迟,模块一加载瞬间崩溃,你连输入 GDB 命令的机会都没有。这 2.5 秒是你的「调试窗口期」。
这个 bug 看起来不起眼,但在我的测试机上,它直接导致了内核 Panic——不仅杀死了进程,整个系统都冻结了。这就是内核态栈溢出的威力:没有什么 Exception 能接住它,只能听天由命。
这一节我们换个口味,不玩 ARM 了,我们在 x86_64 上玩这个游戏。步骤有点多,跟紧了。
Step 1:准备工作 —— 备齐弹药
要打这场仗,我们需要三样东西:
- 一个开启 KGDB 的 x86_64 内核。
- 一个能跑起来的根文件系统(Rootfs),用来放我们的模块和脚本。
- 针对这个内核编译好的
kgdb_try.ko模块。
Step 1.1:编译目标内核
简略说一下流程:
- 下载源码:为了演示,我用的是 5.10.109 LTS。假设你解压到了
~/linux-5.10.109。 - 配置内核:
make menuconfig。这一步必须把 KGDB 相关的选项选上(参考前一节)。为了省事,我保存了一个配置文件ch11/kconfig_x86-64_target。 - 处理小坑:如果你用比较新的 5.10+ 内核,编译可能会报错,提示找不到什么
debian/canonical-revoked-certs.pem。这是证书检查的问题,关掉就行:scripts/config --disable SYSTEM_REVOCATION_KEYSscripts/config --disable SYSTEM_TRUSTED_KEYS - 编译:
make -j[n] all。 成功后,你会得到两个重要东西:arch/x86/boot/bzImage:压缩后的内核镜像。vmlinux:巨大的未压缩镜像,带满调试符号。这是 GDB 要吃的那盘菜。
Step 1.2:准备根文件系统
从零捏一个 Rootfs 太痛苦了。我直接帮你捏好了一个基于 Debian Stretch 的镜像。
你在书里的资源目录下能找到 rootfs_deb.img.7z。解压它:
7z x rootfs_deb.img.7z
# 解压后得到 images/rootfs_deb.img
这是一个 512MB 的硬盘镜像。里面已经放好了我们需要的一切:模块、脚本、工具。
Step 1.3:编译测试模块
注意!这个模块不能在你主机当前内核下编译,必须在目标内核(那个 5.10.109)的源码树下编译。
打开 ch11/kgdb_try/Makefile,你会看到我特意加的一行注释和配置:
#@@@@@@@@@@@@ NOTE! SPECIAL CASE @@@@@@@@@@@@@@@@@
# We specify the build dir as the linux-5.10.109 kernel src tree
KDIR ?= ~/linux-5.10.109
Makefile 会自动去那个目录下找内核构建头文件。如果你的路径不一样,记得改这里。
然后在 ch11/kgdb_try 下执行 make,你就得到了 kgdb_try.ko。
(注意:如果你改了代码重新编译,记得把新的 .ko 文件拷回那个 rootfs 镜像里。挂载镜像 -> 拷贝 -> 卸载,这是一套连招)。
Step 2:启动目标,原地待命
一切就绪,把虚拟机跑起来。我们依然用 QEMU。
书里提供了一个现成的脚本 run_target.sh,但为了你看清楚参数,我把它拆开写:
cd <book_src>/ch11
qemu-system-x86_64 \
-kernel ~/linux-5.10.109/arch/x86/boot/bzImage \
-append "console=ttyS0 root=/dev/sda earlyprintk=serial rootfstype=ext4 rootwait nokaslr" \
-hda images/rootfs_deb.img \
-nographic -m 1G -smp 2 \
-S -s
这里有两个老朋友:
-S:Freeze。QEMU 启动后立马冻结 CPU,死等 GDB 连上来。-s:Shorthand。简写为-gdb tcp::1234,在 1234 端口开个监听。
另外,如果你的宿主机支持 KVM(硬件虚拟化),加上 -enable-kvm 会让速度快得飞起,但要注意嵌套虚拟化的问题。
这时候,QEMU 窗口应该是一片死寂,但 CPU 在那里待命,像个听话的士兵。
Step 3:GDB 登场,建立连接
回到宿主机(或者你的 Ubuntu 虚拟机),进入内核源码树,启动 GDB。
cd ~/linux-5.10.109
gdb ./vmlinux
GDB 启动时,会自动读取你的 ~/.gdbinit 文件。我在书里提供的配置里预置了一个宏 connect_qemu:
define connect_qemu
target remote :1234
hbreak start_kernel
hbreak panic
#hbreak do_init_module
end
在 GDB 里输入 connect_qemu。
啪!连接上了。GDB 自动停在了 start_kernel。
Step 4:生死时速 —— 加载模块并注入符号
好戏开场了。
现在 GDB 停在 start_kernel。输入 c(continue)让它继续跑。内核会启动,直到打印出登录提示。直接回车,进入一个简陋的 Shell。
这是关键的一步:我们要运行那个会爆炸的脚本。
在这个目标系统的 Shell 里,有一个脚本 /myprj/doit。它干三件事:
- 告诉内核「Oops 了就 Panic,别试图挽救」(
panic_on_oops = 1)。 - 加载我们的捣乱模块
kgdb_try.ko。 - 自动生成那个超长的
add-symbol-file命令。
现在,在目标系统里运行它:
# Inside the QEMU target console
cd /myprj
./doit
一旦回车按下,倒计时就开始了。你有 2.5 秒。
脚本会吐出一大段 GDB 命令,长得吓人,被夹在 ---snip--- 标记之间。
动作要快!
- 把目标窗口里那段 GDB 命令复制到剪贴板。
- 瞬间切换到宿主机的 GDB 窗口。
- 按 Ctrl+C。这会强制中断目标内核的运行,把它从崩溃边缘拉回来,暂停在某个随机位置。不管它在哪,只要停了就好。
- 在 GDB 里
cd到模块源码目录(ch11/kgdb_try/),这样 GDB 才能找到源文件。 - 粘贴刚才那个超长命令,回车。GDB 问你是不是真的要加载,输入
y。
现在,你的 GDB 已经全副武装了。它知道 kgdb_try 模块在哪,也知道它的符号表。
最后一步,在断点处下套:
(gdb) hbreak do_the_work
Hardware assisted breakpoint 3 at 0xffffffffc004a000: file [...]/kgdb_try.c, line 43.
Step 5:见证崩溃
输入 c 让目标系统继续跑。
这次它真的开始执行了。计时器在走…… 2.5 秒一到,工作队列函数触发。
啪!
GDB 瞬间捕获到了断点,停在 do_the_work 的入口。
现在你可以像调试普通 C 程序一样,单步(s 或 n),看变量(p i),或者看栈(bt)。
我们知道 bug 在循环里,但我们不想傻乎乎地按 10 次 F10。 来点高级的:条件断点。
(gdb) b 49 if i==8
这行命令的意思是:「跑到第 49 行时,只有当变量 i 等于 8 时才停下来」。
再输入 c 继续。
完美。停下来了。此时 i 是 8。
再按几次 s(单步进入),你会看到 i 变成 9,变成 10……
就在 buf[10] 被写入的那一刻,你破坏了栈帧。
如果你继续执行,内核的栈保护机制(Stack Canary)会立刻察觉,并触发一个极其壮观的 Kernel Panic。
GDB 里会显示:
Kernel panic - not syncing: stack-protector: Kernel stack is corrupted in: ffffffffa0010030
而在目标系统的控制台上,也会弹出那一串著名的恐慌堆栈。
你看,这就是源码级调试的快感。你不仅知道了它「炸」了,还亲眼看着它是怎么把自己炸死的。
灵魂拷问:怎么调试 init 函数?
刚才我们用延迟工作队列(2.5秒)给自己留了条后路。
但如果你的模块一加载,init 函数里直接就炸了,连 0.001 秒都不给你留,怎么办?
这时候再去断 do_the_work 已经来不及了——你连输入命令的机会都没有。
你需要一个更早的切入点。
还记得 GDB 连接时那个 ~/.gdbinit 文件吗?
里面有一行被注释掉的命令:
#hbreak do_init_module
把它打开。
do_init_module 是内核内部负责调用模块 init 函数的总管。任何模块的加载,都必须经过它。
如果你在这里下了断点,不管哪个模块加载,内核都会暂停。此时你可以查看 mod 参数(指向 struct module 的指针),打印它的名字(p *mod 或 x/s mod->name),看看是不是你要调试的那个模块。如果是,再单步进去(s),你就直接进了模块的 init 函数。
这是调试那些「见光死」模块的终极手段。
练习题
练习 11.5 ⭐⭐(应用)
如果你在调试一个模块时,忘记加载符号表(add-symbol-file)直接下了断点 hbreak my_func,会发生什么?请尝试在你的实验环境中复现这个现象,并解释 GDB 给出的反馈。
提示:GDB 可能会报错说找不到符号,或者(更糟)断点设置在了错误的地址。查看
info breakpoints输出。