2、GDB高级技巧:条件断点、观察点、回溯追踪、Core Dump分析、多线程调试

调试这事儿,说白了就是跟Bug斗智斗勇。GDB这工具,你要是只会设个普通断点、打个print,那真有点浪费。我做了十几年嵌入式,见过太多同事在同一个坑里反复跳——其实用好GDB的高级功能,很多问题几分钟就能定位。

这一章,我把平时用得最多的几个技巧串起来讲。条件断点、观察点、回溯追踪、Core Dump分析、多线程调试,每一个都是实战利器。

核心观点:调试不是碰运气,而是有章法的推理。GDB的高级技巧,就是帮你把推理过程自动化。

2.1 条件断点:只在需要的时候停下来

普通断点有个毛病——循环里每次都停。比如你有个for(i=0; i<10000; i++),你只想看i==5000时的情况。手动按continue按到手指抽筋?别傻了。

条件断点就是干这个的。语法很简单:

(gdb) break function_name if condition
(gdb) break file.c:line_number if condition

举个例子。我之前调一个网络协议栈,有个packet_handler()函数,每次收到包都会调用。但只有某个特定端口的包才会出问题。我这么设:

(gdb) break packet_handler if port == 8080
(gdb) run

嗯,这样只有端口8080的包进来时才会触发断点。省了多少时间?你自己算算。

小技巧:条件表达式里可以用函数返回值。比如break x if func() > 0。但注意,每次断点触发时都会执行一次func(),如果函数有副作用,可能会改变程序行为。

还有一种常见场景——调试内存泄漏。我习惯在malloc上设条件断点,只跟踪特定大小的分配:

(gdb) break malloc if size == 1024
(gdb) commands
  > print __builtin_return_address(0)
  > continue
  > end

这样每次分配1024字节时,都会打印调用地址,然后继续运行。配合info registers,你能画出完整的调用链。

2.2 观察点:盯住变量的变化

断点只能停在代码的某个位置。但有时候,你根本不知道变量是在哪里被改的。比如一个全局变量g_flag,莫名其妙变成了0。你翻遍所有赋值语句,就是找不到。

这时候就该观察点上场了。观察点监视内存地址,一旦值发生变化,立即暂停。

(gdb) watch g_flag
(gdb) watch *(int*)0x7fffffffabcd

硬件观察点依赖CPU的调试寄存器,数量有限(通常4个)。软件观察点则通过单步执行模拟,速度慢但数量不限。

类型 命令 触发条件 性能
写观察点 watch expr 变量被写入 快(硬件)
读观察点 rwatch expr 变量被读取 快(硬件)
读写观察点 awatch expr 变量被读或写 快(硬件)

我曾经遇到一个悬案——一个结构体里的字段,每次打印都是对的,但某个函数调用后就变了。我设了watch my_struct.field,结果发现是DMA控制器在后台偷偷改了内存。没有观察点,这种问题查一辈子都查不出来。

注意:观察点对局部变量要小心。局部变量在函数返回后就不存在了,GDB会报Watchpoint cannot be inserted。建议观察全局变量或堆上的内存。

2.3 回溯追踪:从崩溃现场逆推

程序崩溃了,你拿到一个Segmentation fault。然后呢?

第一件事,看调用栈。GDB的backtrace命令(简写bt)能告诉你程序是怎么走到这一步的。

(gdb) bt
#0  0x00005555555546a7 in crash_func (p=0x0) at test.c:10
#1  0x00005555555546c5 in middle_func () at test.c:15
#2  0x00005555555546d8 in main () at test.c:20

每一帧都包含:帧编号、地址、函数名、参数、文件和行号。从#0开始看,crash_func里传了个空指针p=0x0,问题就在这。

但有时候,栈帧太多,你只想看关键信息。用bt full可以显示每个帧的局部变量:

(gdb) bt full
#0  0x00005555555546a7 in crash_func (p=0x0) at test.c:10
        local_var = 42
#1  0x00005555555546c5 in middle_func () at test.c:15
        buf = "hello"
#2  0x00005555555546d8 in main () at test.c:20
        argc = 1

我个人的习惯是,先bt看概貌,再frame N切换到具体帧,用info locals和info args细查。比如:

(gdb) frame 1
(gdb) info locals
buf = "hello"
(gdb) info args
(void)

这样一层层剥开,就像侦探看现场一样。

避坑指南:我曾经遇到栈被踩坏的情况,bt打出来的调用栈全是乱码。这时候别慌,试试bt N只看最近N帧,或者用frame手动切到$sp附近的地址。有时候,info registers里的pc和sp能给你线索。

2.4 Core Dump分析:事后诸葛亮也是诸葛亮

现场调试当然好,但很多嵌入式设备没法连GDB。比如产线上的设备,跑着跑着就挂了,你总不能蹲在旁边等吧?

这时候,Core Dump就是你的救命稻草。Core Dump是程序崩溃时内存的快照,包含了堆栈、寄存器、全局变量等信息。只要编译时加了-g,就能用GDB离线分析。

首先,确保系统允许生成Core Dump:

$ ulimit -c unlimited
$ echo "/tmp/core.%e.%p" > /proc/sys/kernel/core_pattern

程序崩溃后,会生成一个core文件。然后用GDB加载:

$ gdb ./my_program /tmp/core.my_program.1234

进去之后,跟现场调试一样。先bt看调用栈,再frame切到崩溃点,查变量、查寄存器。区别在于,你不能next或continue了——程序已经死了。

但Core Dump有个隐藏技能——查看所有线程的栈。多线程程序崩溃,往往只有一个线程挂了,其他线程还在。用info threads列出所有线程,然后thread N切换:

(gdb) info threads
  Id   Target Id         Frame
  1    Thread 0x7ffff7fc1700 (LWP 1234) "my_program" 0x... in crash_func ()
* 2    Thread 0x7ffff7fc2700 (LWP 1235) "my_program" 0x... in worker_func ()
(gdb) thread 2
(gdb) bt

我曾经分析过一个Core Dump,主线程在等一个锁,工作线程却因为空指针崩溃了。从Core Dump里,我不仅看到了崩溃点,还看到了主线程在等什么锁——info threads加上bt,真相大白。

关键点:Core Dump分析的核心是“还原现场”。你看到的每一帧、每一个变量,都是崩溃那一瞬间的状态。别急着下结论,多切几个线程看看,多查几个变量。

2.5 多线程调试:别让竞态条件跑了

多线程调试是GDB的进阶玩法。难点在于:线程切换不可控,断点可能被多个线程同时命中,竞态条件难以复现。

GDB默认是all-stop模式:任何一个线程触发断点,所有线程都暂停。这能保证你看到的状态是一致的。但有时候,你只想让某个线程停下来,其他线程继续跑。用set scheduler-locking on:

(gdb) set scheduler-locking on
(gdb) thread 3
(gdb) next

这样,只有线程3在执行next,其他线程都冻结。适合调试线程间的交互逻辑。

另一个常用技巧是线程特定断点。比如你只想在线程2进入worker()时停下:

(gdb) break worker thread 2
(gdb) continue

这样其他线程调用worker()时不会触发断点,减少干扰。

我遇到过最头疼的问题——死锁。两个线程互相等对方的锁,程序卡死。GDB里怎么查?

(gdb) info threads
(gdb) thread 1
(gdb) bt
(gdb) thread 2
(gdb) bt

如果线程1停在pthread_mutex_lock,线程2也停在pthread_mutex_lock,而且它们等的锁地址不同,但互相持有对方的锁——那就是死锁。用pthread_mutex_t的__owner字段可以确认:

(gdb) print mutex1.__data.__owner
$1 = 2
(gdb) print mutex2.__data.__owner
$2 = 1

嗯,线程1持有mutex2,线程2持有mutex1,死锁无疑。

注意:多线程调试时,next和step的行为可能跟单线程不一样。因为线程切换可能发生在单步之间。如果发现行为诡异,试试set scheduler-locking step,只在单步时锁定线程调度。

2.6 知识结构总览

下面这张图,把本章的核心知识点串起来了。你可以把它当作调试时的路线图。

GDB高级调试技巧知识结构 GDB高级调试 条件断点 观察点 回溯追踪 Core Dump分析 多线程调试 break if condition commands 自动化 watch / rwatch / awatch 硬件 vs 软件观察点 bt / bt full / frame info locals / info args ulimit -c unlimited gdb prog core info threads / thread N scheduler-locking

2.7 实战建议

说了这么多,最后给几条实在的建议:

  • 先想清楚再动手。别一上来就设一堆断点。先分析现象,推测可能的原因,再用GDB验证。调试是科学,不是瞎蒙。
  • 条件断点优先于普通断点。能用条件过滤的,就别手动continue。省时间,也省脑子。
  • 观察点是你的第三只眼。变量被谁改了?不知道就用watch。但注意性能,别在频繁访问的变量上设软件观察点。
  • Core Dump是离线调试的利器。产线上的问题,别指望现场复现。配好core_pattern,让系统帮你保存现场。
  • 多线程调试,先看锁。死锁、竞态条件,十有八九跟锁有关。info threads加bt,能解决80%的问题。

我的习惯:每次调试前,先在.gdbinit里配好常用别名。比如alias bt2 = bt 2、alias t = info threads。省得每次敲长命令。你想想看,调试时每秒钟都是宝贵的,能少敲几个键就少敲几个。