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 知识结构总览
下面这张图,把本章的核心知识点串起来了。你可以把它当作调试时的路线图。
2.7 实战建议
说了这么多,最后给几条实在的建议:
- 先想清楚再动手。别一上来就设一堆断点。先分析现象,推测可能的原因,再用GDB验证。调试是科学,不是瞎蒙。
- 条件断点优先于普通断点。能用条件过滤的,就别手动
continue。省时间,也省脑子。 - 观察点是你的第三只眼。变量被谁改了?不知道就用
watch。但注意性能,别在频繁访问的变量上设软件观察点。 - Core Dump是离线调试的利器。产线上的问题,别指望现场复现。配好
core_pattern,让系统帮你保存现场。 - 多线程调试,先看锁。死锁、竞态条件,十有八九跟锁有关。
info threads加bt,能解决80%的问题。
我的习惯:每次调试前,先在.gdbinit里配好常用别名。比如alias bt2 = bt 2、alias t = info threads。省得每次敲长命令。你想想看,调试时每秒钟都是宝贵的,能少敲几个键就少敲几个。