跳到主要内容

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 文件。

解决办法有两个:

  1. 用交叉工具链里的 GDB 别用系统自带的 gdb,去用你的工具链专用的那个,比如 arm-none-linux-gnueabihf-gdb

  2. 告诉 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 命令行。你可以一边看代码,一边敲命令,不用再频繁输 listl

想看三栏?按 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 去内存里找(这是它默认的行为),自然找不到。

遇到这种情况,别慌。你有三条路:

  1. 去看寄存器 这就需要懂 CPU 的 ABI(Application Binary Interface)。我们在第 4 章讲 Kprobes 时提到过这个。根据函数调用约定,参数通常是放在前几个寄存器(比如 x86 的 rdi, rsi, rdx...)里的。如果变量没被 spill 到栈上,那它一定在某个寄存器里躺着。 你可以直接 info registers 看看,或者在 TUI 模式下盯着上面的寄存器窗看。

  2. 打开 DWARF 4 调试信息 在内核配置里开启 CONFIG_DEBUG_INFO_DWARF4。这个选项会让调试信息更详细,虽然在优化代码下也不能保证 100% 能解析出变量,但成功率会显著提高。

  3. 换 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

它命中一次后就会自动删除,省得你手动 disabledelete

硬件观察点

这是 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,可能就是硬件资源耗尽了。


杂项技巧

最后再丢几个零碎但好用的小技巧:

  1. Tab 补全 在 GDB 里敲命令、变量名、函数名,按 Tab 可以补全。如果匹配项太多,按两下 Tab 会列出所有可能。

  2. 根据 Oops 的地址定位代码 还记得第 7 章 Oops 吗?如果 Oops 给了你一个函数名 + 偏移量(比如 my_func+0x5c),你在 GDB 里可以用:

    (gdb) list *my_func+0x5c

    直接跳到那一行代码。

  3. 在 GDB 里跑 Shell 不用退出 GDB,直接:

    (gdb) shell ls -l

    这在调试需要临时查个文件或者改个东西时很方便。

  4. 反向调试 GDB 甚至支持「倒带」(record / reverse),不过这个功能非常耗内存,在 KGDB 这种远程调试场景下可能不太现实。如果你在本地调试用户态程序,可以试试 record btrace


写到这里,GDB(以及 KGDB)的工具箱算是翻到底了。

现在的你,不仅知道怎么连上内核、怎么设断点,还知道怎么用 Python 脚本查日志,怎么用 TUI 模式像 IDE 一样工作,怎么用硬件观察点抓那个乱改变量的「鬼」。

接下来的最后一章,我们将把目光从「怎么调试」收回到「整个系统的设计」——看看还有哪些系统级的调试手段能帮我们把最后的谜题解开。

我会在那里等你。