7.4 精准打击:用 objdump 和 GDB 定位罪魁祸首
上一节我们把尸检报告读完了。我们知道了 do_the_work 函数挂了,也知道 RIP 寄存器停在了偏移量 0x124 的地方。
但这还不够。
问题是「精准定位」。我们想知道究竟是源代码的第几行、哪个语句导致了这场灾难。是那个 pr_info 吗?还是后面那句赋值?
这就需要把 RIP 地址(或者偏移量)反向映射到源代码行。这就是本节要做的事:从「大概知道」到「确切知道」。
调试前的准备:给内核装上窃听器
在开始实战之前,有一个先决条件:你的二进制文件里必须有调试符号。
没有符号的二进制文件就像去了标签的药瓶——你只知道里面有药,但不知道哪颗是止疼药,哪颗是毒药。
要获得最完整的调试体验,你必须用 CONFIG_DEBUG_INFO=y 重新编译你的 buggy 内核或模块。简单来说,就是在调试内核上启动,并在那里构建你的模块。
打开我们所谓的「加强版 Makefile」,找到这个变量:
MYDEBUG := y
它默认是 n,你需要把它改成 y。
重新构建模块。你会注意到,生成的 .ko 文件变大了——有时候是大得多了。这很正常,因为它现在不仅包含机器码,还包含了一份详尽的「地图」,告诉调试器哪段机器码对应哪一行 C 代码。
objdump:反汇编界的显微镜
objdump 是个强力工具,它能把 ELF 目标文件(包括未压缩的内核镜像 vmlinux 和模块 .ko 文件)拆得七零八落,让你看清里面的每一根血管。
最常用的组合拳是 -dS 选项:
-d(--disassemble):反汇编可执行段。-S(--source):尽可能把源代码插在汇编里面显示(前提是你用-g编译过)。
第一步:找到模块在内存里的家址
如果你的模块还在内存里跑着(或刚挂掉),你可以先从 /proc/modules 里抓到它的加载基址。
普通用户看这个文件时,内核为了安全(防止信息泄露),会把地址藏起来:
$ grep oops_tryv2 /proc/modules
oops_tryv2 16384 0 - Live 0x0000000000000000 (OE)
全是零。这没用。
你需要 root 权限来窥探真相:
$ sudo grep oops_tryv2 /proc/modules
oops_tryv2 16384 0 - Live 0xffffffffc0604000 (OE)
看到了吗?0xffffffffc0604000。这就是我们的模块被加载到的内核虚拟地址(VMA)。
第二步:生成带地址的反汇编文件
有了基址,我们就可以让 objdump 动手了。关键在于 --adjust-vma 参数,它告诉 objdump:「请假装这个模块是从这个地址开始的」,这样输出的左侧地址就会和真实运行时的内核地址一一对应。
$ objdump -dS --adjust-vma=0xffffffffc0604000 ./oops_tryv2.ko > oops_tryv2.disas
打开生成的 oops_tryv2.disas,你会看到类似这样的输出:
static void do_the_work(struct work_struct *work)
{
ffffffffc0604000: e8 00 00 00 00 callq ffffffffc0604005 <do_the_work+0x5>
ffffffffc0604005: 55 push %rbp
[...]
左侧是绝对地址,中间是机器码,右边是汇编和夹杂的 C 源码。
第三步:算出精确的死亡地址
还记得上一节我们从 Oops 消息里抠出来的 RIP 值吗?
RIP: 0033:[<ffffffffc0604124>]
这是一个绝对地址。但因为我们在 objdump 里已经指定了模块基址,或者更简单点,我们直接用 Oops 消息里提示的偏移量——也就是 <do_the_work+0x124> 里的 0x124。
计算公式非常简单:
模块基址 (从 /proc/modules 来) + 偏移量 (从 Oops RIP 来) = 精确崩溃地址
0xffffffffc0604000 + 0x124 = 0xffffffffc0604124
第四步:按图索骥
拿着计算出来的 0xffffffffc0604124,在刚才生成的 oops_tryv2.disas 文件里搜。
你会找到这一段(请盯着左边的地址看):
(此处对应原图 7.15/7.16,展示 objdump 输出)
在那一堆十六进制代码里,你会发现 0xffffffffc0604124 这一行的上方,正是那行罪魁祸首的 C 代码:
61 pr_info("Generating Oops by attempting to write to an invalid kernel memory pointer\n");
62 oopsie->data = 'x';
找到了。就是第 62 行。
通过这个方法,我们把 RIP 寄存器里的冷冰冰的数字,变成了具体的一行 C 代码。这比瞎猜要快一万倍。
⚠️ 注意
如果你不是在现网(live run)调试,也就是说手头没有 /proc/modules 的基址,直接用 objdump 也可以,只是不要加 --adjust-vma 参数。这时你需要手动把 Oops 里的偏移量(如 0x124)跟 objdump 输出里的相对地址对应起来。原理一模一样。
题外话:如果是内核本身崩了?
如果罪魁祸首不是模块,而是内核核心代码,方法也一样。只不过输入文件换成了未压缩的 vmlinux 镜像:
${CROSS_COMPILE}objdump -dS <path/to/kernel-src/>/vmlinux > vmlinux.disas
当然,前提是你的 vmlinux 是带着调试信息编译的。这是一劳永逸的事——除非你更新了内核,否则这份反汇编文件可以一直用。
GDB:不用反汇编文件也能查
如果你觉得手算地址或者 grep 反汇编文件太麻烦,GDB 提供了一种更「自动化」的查询方式。
前提还是一样:你的模块得带着调试符号编(也就是前面 Makefile 里的 MYDEBUG := y)。
为了方便 GDB 调试,我们的 Makefile 在开启 MYDEBUG 时,其实悄悄加上了一堆编译选项:
ccflags-y += -DDEBUG -g -ggdb -gdwarf-4 -Og -Wall -fno-omit-frame-pointer -fvar-tracking-assignments
(-g 是关键,-Og 是为了优化但不至于乱序太严重,方便调试)
把 GDB 指向你的 .ko 文件(注意,这里是 .ko,不是 .o):
$ gdb -q ./oops_tryv2.ko
Reading symbols from ./oops_tryv2.ko...
(gdb) list *do_the_work+0x124
注意这个语法:list *函数名+偏移量。这里 0x124 就是我们从 Oops 消息里拿到的 RIP 偏移。
GDB 会立刻吐出这一段:
0x160 is in do_the_work (<...>/ch7/oops_tryv2/oops_tryv2.c:62).
[...]
61 pr_info("Generating Oops by attempting to write to an invalid kernel memory pointer\n");
62 oopsie->data = 'x';
63 }
64 kfree(gctx);
(gdb)
完美命中。又是第 62 行。
GDB 的好处是它懂文件格式(ELF)和调试信息(DWARF),不用你自己去算十六进制加减法。只要你给它正确的模块文件和偏移量,它就能把你带到案发现场。
addr2line:简单粗暴的地址转换器
如果你觉得 GDB 太重,或者你只想写个脚本来批量处理地址,addr2line 是个更轻量的选择。
顾名思义,它的功能就是:地址 -> 文件:行号。
用法很直接。你得给它指定可执行文件(-e),然后给它一个地址(或者多个):
$ addr2line -e ./oops_tryv2.o -p -f 0x124
注意:这里 addr2line 通常用在 .o 文件或者带符号的 ELF 上,有时 .ko 也可以,取决于符号是否被 strip。如果 .ko 不行,试试构建中间生成的 .o 文件。
输出:
do_the_work at <...>/ch7/oops_tryv2/oops_tryv2.c:62
参数解释:
-e:指定二进制文件。-f:显示函数名。-p:美化输出格式,让它更易读。
依然是第 62 行。工具换了,真相只有一个。
如果是内核崩溃,手里拿着 vmlinux 和一个内核虚拟地址,用法也是一样的:
addr2line -e </path/to/>vmlinux -p -f <faulting_kernel_address>
⚠️ 注意:KASLR 的坑
这里有个历史遗留问题。如果你的系统开启了 KASLR(内核地址空间布局随机化,Kernel Address Space Layout Randomization),也就是那个为了安全让内核基址每次启动都随机的特性,那么 addr2line 就瞎了。
为什么?因为 Oops 里的地址是「随机化后的绝对地址」,而 vmlinux 里的符号是「编译时的相对地址」。addr2line 不知道这次启动的随机偏移量是多少。
遇到这种情况,你有两个选择:
- 关掉 KASLR:启动参数加
nokaslr。 - 用
faddr2line:这是专门为对付 KASLR 设计的脚本,我们下一节会讲到。
到这里,我们已经把定位 Oops 位置的三板斧——objdump、GDB、addr2line——都过了一遍。掌握了这些,你就再也不用对着 Oops 里的十六进制地址干瞪眼了。