11.6 [K]GDB 进阶技巧与杂项
上节我们用 hbreak 咬住了 do_init_module,算是彻底解决了「模块加载瞬间消失」的问题。但你一旦真的用 KGDB 跑起来,就会发现它像个深不见底的工具箱——大部分时候你只用螺丝刀,但当你真正需要那个弯头钳子时,最好知道它躺在哪个角落。
GDB 是个庞大的程序,功能多到令人发指。这一节我们不讲基础流程,专门来盘点那些容易被人忽略、但在关键时刻能救命的高级技巧。有些是内核自带的增强脚本,有些是 GDB 原生被低估的特性。
启用内核 GDB 脚本 (CONFIG_GDB_SCRIPTS)
这件事很多人不知道:Linux 内核源码树里其实藏着一堆写好的 Python 脚本,它们能直接在 GDB 里当作命令用。
从 4.0 内核开始,开发者就在 scripts/gdb 目录下塞了这些东西。它们不是普通的脚本,而是挂载在 GDB 内部的钩子。要启用它们,第一步是在内核配置里打开开关:
CONFIG_GDB_SCRIPTS=y
打开这个选项后,内核编译会生成 vmlinux-gdb.py。但 GDB 默认为了安全,不会随便加载自动运行的脚本——怕你哪天不小心下了个恶意内核源码把自家电脑黑了。
所以第二步,你需要告诉 GDB:「嘿,这个目录的脚本是我自家的,放心加载」。
在你的 ~/.gdbinit 文件里加上这一行:
add-auto-load-safe-path /path/to/linux/kernel/scripts/gdb/vmlinux-gdb.py
如果你懒得写绝对路径(或者你经常换内核源码目录),可以用简单粗暴但更通用的写法:
add-auto-load-safe-path /
这就相当于解除了所有安全限制。只要做完这一步,GDB 再次加载 vmlinux 时,就会自动解析这些 Python 脚本。
你凭什么知道它生效了?
在 GDB 里输入:
(gdb) apropos lx-
如果屏幕刷出一堆带 lx- 前缀的命令,那就说明 OK 了。
lx- 前缀代表 Linux,_ 前缀(lx_)代表辅助函数。
图 11.18 显示了这些命令的概貌。你会发现有些名字很眼熟:lx-dmesg(看内核日志)、lx-cmdline(看启动参数)、lx-lsmod(看加载的模块)、lx-iomem(看内存布局)……
这有什么用?太有用了。
以前你要查内核日志,得在目标系统里敲 dmesg,或者还得把日志重定向到文件。现在你在 GDB 调试态下,不用退出,直接 lx-dmesg,日志就在你眼前流出来。这对调试「死机前最后一刻发生了什么」极其有效。
想要某个命令的帮助?GDB 的 help 依然通用:
(gdb) help lx-lsmod
这玩意儿需要 GDB 7.2 或更高版本。官方文档在 这里,建议你看完直接上手试。
真实硬件上的 target remote :1234 为什么不工作?
我们在 QEMU 上跑得很顺,target remote :1234 一连就通。但你一旦换成两台真实机器,一根串口线连着,同样的命令可能会死给你看。
这就是物理世界的恶意——连接层问题。
如果你的 GDB 客户端报错说连不上,或者提示奇怪的警告(比如 warning: unrecognized item "timeout" in "qSupported" response),别怀疑内核配置,先怀疑线。
在折腾 KGDB 之前,先干一件事:确认双向通信是通的。
假设你的串口线接在 Host 的 /dev/ttyUSB0,Target 接在 /dev/ttyS0。
在 Host 上(需要 root 权限):
echo "hello, target" > /dev/ttyUSB0
在 Target 上:
cat /dev/ttyS0
如果你在 Target 的屏幕上看到了 hello, target,说明 Host -> Target 没问题。
反过来,在 Target 上:
echo "hello, host" > /dev/ttyS0
在 Host 上:
cat /dev/ttyUSB0
如果这一步也通了,说明物理链路是好的。
如果链路是好的但 KGDB 还是不工作,可能是因为 USB 转串口的适配器问题。
这里有个经验之谈:Host 端用 USB 转串口通常没问题,但 Target 端如果是通过 USB 转串口接到 KGDB,有些内核驱动配合得不好。Target 最好直接接在主板的原生串口(COM 口)上,或者走网络(Ethernet/Wireless)。
设置 sysroot —— 找不到库怎么办?
当你拿着 x86 电脑去调试 ARM 板子(或者远程交叉调试)时,GDB 会遇到一个尴尬的问题:它知道怎么读 ELF 文件,但它不知道板子上的 /lib 库文件在哪里。
如果你尝试 step 进入一个库函数,GDB 可能会报错说找不到 .so 文件。
解决办法有两个:
-
用交叉工具链里的 GDB 别用系统自带的
gdb,去用你的工具链专用的那个,比如arm-none-linux-gnueabihf-gdb。 -
告诉 GDB 目标根文件系统在哪 在 GDB 里设置
sysroot变量:(gdb) set sysroot /path/to/target/rootfs或者设置
solib-search-path:(gdb) set solib-search-path /path/to/target/rootfs/lib这样 GDB 就知道去哪里找那些依赖库了。这在调试用户态程序(通过内核挂载的 rootfs)时尤为重要。
GDB 的 TUI 模式 —— 告别纯黑底白字
很多人嫌弃 GDB,是因为觉得它复古:一行命令,一行输出,来回翻页累死人。
其实 GDB 有个自带图形界面的模式,叫 TUI(Text User Interface)。不用装插件,不用 IDE,加个参数就行:
gdb -tui -q vmlinux
回车的一瞬间,你的终端变了。
窗口会被切成两半:上面是源代码,下面是 GDB 命令行。你可以一边看代码,一边敲命令,不用再频繁输 list 或 l。
想看三栏?按 Ctrl-x 然后按 2(先按 Ctrl+X,松开,再按 2)。
这就把 CPU 寄存器视图也调出来了。图 11.19 展示了这个效果:寄存器、源码、命令,三位一体。
而且这玩意儿是动态的——如果某个寄存器在上一条指令执行后变了,它会高亮显示。这在单步调试汇编时极其好用。
如果你想切换视图(比如从源码切到汇编),可以用 Ctrl-x 然后按 1(自动)或者 Ctrl-x 再按 a(汇编模式)。
表 11.1 给出了常用的快捷键,值得打印出来贴在显示器边框上。
比如:
Ctrl-x 1: 单列模式(只看源码或汇编)Ctrl-x 2: 双列模式Ctrl-x a: 切换汇编/源码混合模式Ctrl-p / Ctrl-n: 上/下一条命令(历史记录)
这个模式一旦用顺手,你会发现它其实跟那些带 GUI 的 IDE(比如 VS Code, Eclipse)没什么两样,甚至还更轻量。
顺便说一句,单步执行汇编指令用 si (stepi) 或者 ni (nexti)。想看汇编代码用 disas 或 /m 修饰符(disas /m func_name),这些在 TUI 模式下配合起来简直是神器。
遇到 <value optimized out> 怎么办?
这是所有内核新人最恐慌的时刻之一。
你在 GDB 里想看一个局部变量 i:
(gdb) p i
$1 = <value optimized out>
什么鬼?GDB 怼你一脸:这变量被优化掉了。
这是编译器的锅。GCC 打开 -O2 或 -O3 后,它发现你那个 i 其实可以全程放在寄存器里,根本不用去内存里存取。于是,内存里没有它的影子,GDB 去内存里找(这是它默认的行为),自然找不到。
遇到这种情况,别慌。你有三条路:
-
去看寄存器 这就需要懂 CPU 的 ABI(Application Binary Interface)。我们在第 4 章讲 Kprobes 时提到过这个。根据函数调用约定,参数通常是放在前几个寄存器(比如 x86 的
rdi,rsi,rdx...)里的。如果变量没被 spill 到栈上,那它一定在某个寄存器里躺着。 你可以直接info registers看看,或者在 TUI 模式下盯着上面的寄存器窗看。 -
打开 DWARF 4 调试信息 在内核配置里开启
CONFIG_DEBUG_INFO_DWARF4。这个选项会让调试信息更详细,虽然在优化代码下也不能保证 100% 能解析出变量,但成功率会显著提高。 -
换 TUI 模式看汇编 如果你看不到变量名,就看它在干什么。源代码看不懂,就去看对应的汇编指令(
disas /m)。汇编是不会撒谎的,你总能找到那个寄存器在哪里被操作。
GDB 的便利函数与自定义宏
如果你发现自己天天在敲同样的命令序列,你该写宏了。
GDB 的宏语法非常简单,就是 define ... end。你可以把这些宏写在 ~/.gdbinit 里,启动 GDB 时它们会自动加载。
举个例子,每次调试 QEMU 都要连端口、设断点,很烦。写个宏:
# ~/.gdbinit
define connect_qemu
target remote :1234
hbreak start_kernel
hb panic
hb do_init_module
b do_fsync
end
以后进 GDB 只需要敲 connect_qemu,一切全搞定。
再来个实用点的:看栈。
define xs
printf "Examine stack:\n"
x/8x $sp
printf "---\n"
x/8x $sp-32
end
这个宏 xs 会打印出栈指针 $sp 前后的一坨内存,对查栈溢出或者找局部变量很有用。
GDB 还有一些内置的便利函数(Convenience Functions),很多需要 Python 支持:
$_memeq(buf1, buf2, length): 比较两块内存是否一样。$_regex(str, regex): 正则匹配。$_strlen(str): 算字符串长度。
这些都能在 help 文档里找到。更多宏的例子可以去内核文档里抄:Documentation/admin-guide/kdump/gdbmacros.txt。
高级断点:条件断点与观察点
普通的 break 太机械了。有时候你需要更细腻的控制。
条件断点
想象你在调试一个循环,这个循环跑 10 万次。你怀疑 bug 出在最后一次循环。
难道你要按 10 万次 c (continue)?
不。用条件断点:
(gdb) break loop_function if i == 99999
或者如果断点已经设好了:
(gdb) condition 1 i == 99999
这样,只有当 i 等于 99999 时,GDB 才会停下来。这在 Off-by-one 错误排查中简直是救命稻草。
临时断点
有时候你只想「暂停一次」,不想后续总被这个断点烦。用 tbreak:
(gdb) tbreak some_function
它命中一次后就会自动删除,省得你手动 disable 或 delete。
硬件观察点
这是 GDB 最强力的功能之一,甚至比断点还强。
普通断点是「停在这行代码」。观察点是「停在这个变量被修改/读取的时候」。
它用的是 CPU 的硬件调试寄存器(DR0-DR3 on x86),速度极快,不像软件断点那样还要替换指令。
怎么用?
watch <var>: 变量被写入时停下。rwatch <var>: 变量被读取时停下。awatch <var>: 读写都停。
举个例子,jiffies_64 是内核的全局时钟变量,每次时钟中断都会加 1。
如果我们对它下观察点:
(gdb) watch jiffies_64
Hardware watchpoint 1: jiffies_64
(gdb) c
Continuing.
Hardware watchpoint 1: jiffies_64
Old value = 4294888091
New value = 4294888092
0x (... some address in timer interrupt handler ...)
看图 11.20。GDB 会在变量变化的瞬间暂停,告诉你旧值是多少、新值是多少,还会顺便停在修改它的那一行代码上。
这时候你用 bt (backtrace),就能一眼看到是谁改了这个变量。
这对于调试「谁把我的指针改成了 NULL」或者「谁在乱改我结构体的成员」这类幽灵 Bug,是核武器级别的手段。
注意:硬件断点/观察点是有限的资源(x86 通常只有 4 个),用完就没了。如果 GDB 提示 can't set breakpoints,可能就是硬件资源耗尽了。
杂项技巧
最后再丢几个零碎但好用的小技巧:
-
Tab 补全 在 GDB 里敲命令、变量名、函数名,按 Tab 可以补全。如果匹配项太多,按两下 Tab 会列出所有可能。
-
根据 Oops 的地址定位代码 还记得第 7 章 Oops 吗?如果 Oops 给了你一个函数名 + 偏移量(比如
my_func+0x5c),你在 GDB 里可以用:(gdb) list *my_func+0x5c直接跳到那一行代码。
-
在 GDB 里跑 Shell 不用退出 GDB,直接:
(gdb) shell ls -l这在调试需要临时查个文件或者改个东西时很方便。
-
反向调试 GDB 甚至支持「倒带」(record / reverse),不过这个功能非常耗内存,在 KGDB 这种远程调试场景下可能不太现实。如果你在本地调试用户态程序,可以试试
record btrace。
写到这里,GDB(以及 KGDB)的工具箱算是翻到底了。
现在的你,不仅知道怎么连上内核、怎么设断点,还知道怎么用 Python 脚本查日志,怎么用 TUI 模式像 IDE 一样工作,怎么用硬件观察点抓那个乱改变量的「鬼」。
接下来的最后一章,我们将把目光从「怎么调试」收回到「整个系统的设计」——看看还有哪些系统级的调试手段能帮我们把最后的谜题解开。
我会在那里等你。