GDB调试艺术:断点、单步、查看变量、调用栈、core dump分析
说实话,调试这件事,我见过太多人把它当成「玄学」。代码跑崩了,加几个printf,重新编译,再跑一遍。运气好能定位到问题,运气不好就陷入死循环。我年轻时也这么干过,直到有一次,一个内存越界的bug让我加了整整两天的printf,最后发现是少写了一个free——嗯,从那以后,我老老实实学起了GDB。
GDB不是万能的,但它能让你看到程序运行时的一切。说白了,它就是给你一双透视眼,让你能钻进CPU和内存里看代码到底在干什么。今天我们就聊聊GDB的核心用法,从断点到core dump,一条龙讲清楚。
核心观点:调试不是找bug,而是理解程序的行为。GDB让你从「猜」变成「看」。
一、断点:让程序停在你想停的地方
断点是调试的起点。没有断点,你只能看着程序一路狂奔到崩溃。我个人习惯,写复杂逻辑之前,先把关键路径上的断点设好,这样跑起来心里有底。
GDB里设断点的方式有好几种,我列一下最常用的:
// 在函数入口设断点
(gdb) break main
(gdb) b my_function
// 在指定行号设断点
(gdb) break 42
(gdb) b file.c:128
// 条件断点——这个很实用
(gdb) break 42 if count > 100
// 临时断点,命中一次就自动删除
(gdb) tbreak 42
我在项目中遇到过一个问题:一个循环跑了十万次,在第99999次才崩溃。要是没有条件断点,你得手动按多少次continue?想想都手疼。条件断点就是干这个用的。
小技巧:条件断点里的表达式可以是任何C语言表达式,包括函数调用。但注意,每次命中断点都会执行这个表达式,如果表达式很慢,调试会变卡。
二、单步执行:跟着代码一步步走
设好断点,程序停下来了。接下来就是单步执行。GDB提供了几个命令,各有各的用途:
| 命令 | 缩写 | 作用 |
|---|---|---|
| step | s | 进入函数内部 |
| next | n | 不进入函数,直接执行完当前行 |
| finish | fin | 执行完当前函数,返回到调用处 |
| until | u | 执行到指定行号 |
你想想看,step和next的区别是什么?说白了,step是「钻进去看细节」,next是「跳过细节看整体」。我一般这样用:遇到一个不熟悉的函数,用step进去看看它干了什么;如果是自己写的、已经验证过的函数,就用next跳过。
// 示例:调试一个链表插入函数
(gdb) b insert_node
(gdb) run
(gdb) n // 执行到malloc那行
(gdb) p new_node // 看看malloc返回了什么
(gdb) n // 执行赋值
(gdb) p node->data // 检查数据是否正确
我曾经犯过一个错:在调试一个递归函数时,一直用step,结果一头扎进了递归深处,半天出不来。后来学乖了,在递归函数入口设个条件断点,然后用finish一层层返回。嗯,这个经验值钱。
三、查看变量:看穿内存的真相
断点和单步只是手段,查看变量才是目的。GDB里查看变量的方式很灵活:
// 打印变量
(gdb) print count
(gdb) p array[5]
(gdb) p *ptr
// 打印结构体
(gdb) p my_struct
(gdb) p my_struct->field
// 打印数组全部元素
(gdb) p array
// 查看内存地址
(gdb) x/10x ptr // 以十六进制显示10个字节
(gdb) x/10d ptr // 以十进制显示10个整数
(gdb) x/s ptr // 以字符串形式显示
这里有个坑,我提醒一下:print和x命令的区别。print是按变量类型来解析内存,x是直接看内存的原始内容。如果你怀疑某个指针指向的内存被篡改了,用x命令看原始字节,往往能发现问题。
注意:在嵌入式系统中,查看外设寄存器时一定要用x命令,因为寄存器地址可能没有对应的变量名。我曾经在调试一个UART驱动时,用print看寄存器状态,结果GDB报错说找不到符号——其实地址是对的,只是没有变量名而已。
四、调用栈:谁把我叫到这儿的?
程序崩溃时,最想知道的就是「我是怎么走到这一步的」。调用栈就是答案。GDB里用backtrace命令查看:
(gdb) backtrace
#0 handler (sig=11) at crash.c:15
#1 <signal handler called>
#2 0x08048432 in process_data (buf=0x804a008, len=10) at crash.c:28
#3 0x0804846a in main () at crash.c:38
你看这个栈,从下往上看:main调用了process_data,process_data里触发了段错误(信号11),然后进了信号处理函数。一目了然。
我个人习惯,在调试复杂bug时,先看调用栈,再看局部变量。因为调用栈告诉你「发生了什么」,变量告诉你「为什么发生」。顺序不能反。
// 常用调用栈命令
(gdb) bt // 显示完整调用栈
(gdb) bt full // 显示调用栈+每个函数的局部变量
(gdb) frame 2 // 切换到第2层栈帧
(gdb) info locals // 查看当前栈帧的局部变量
为什么会这样?因为有时候bug不在当前函数里,而在它的调用者。比如一个函数传了个空指针进来,你在函数内部怎么查都查不出问题,但一看调用栈,发现是上层函数忘了初始化。这种问题,没有调用栈,你查一天都查不出来。
五、Core Dump分析:事后诸葛亮也是诸葛亮
有些bug不是每次都能复现的。跑了一整夜的测试,第二天早上发现程序崩了,但现场已经没了。这时候就需要core dump。
Core dump是程序崩溃时,操作系统把内存映像保存到磁盘上的文件。你可以用GDB加载它,就像调试一个活着的程序一样:
// 先让系统生成core dump
$ ulimit -c unlimited
$ ./my_program
Segmentation fault (core dumped)
// 然后用GDB分析
$ gdb ./my_program core
(gdb) bt
(gdb) p variable
(gdb) info registers
我在项目中遇到过最经典的一次:一个服务器程序,每天凌晨3点准时崩溃。白天怎么跑都没事,晚上就崩。后来我让运维把core dump保存下来,第二天一分析,发现是一个定时任务在凌晨3点触发,分配了一个超大内存,导致堆溢出。没有core dump,这种问题根本没法查。
实用建议:生产环境一定要开启core dump,并且设置好保存路径。我一般会在/etc/security/limits.conf里加上soft core unlimited,同时在sysctl里设置kernel.core_pattern,让core文件带上进程名和PID,方便区分。
六、知识体系总览
下面这张图,把GDB调试的核心流程串起来了。你可以把它当成调试时的操作指南:
这张图把调试流程分成了六个步骤。你从设置断点开始,运行程序,然后根据情况选择单步执行、查看变量、查看调用栈,或者直接分析core dump。最终目标只有一个:定位问题根源。
七、实战经验总结
聊了这么多,最后分享几个我自己的经验:
- 先复现,再调试。 如果bug不能稳定复现,先想办法造出稳定的复现条件,否则GDB也帮不了你。
- 断点不要设太多。 我见过有人一口气设了20个断点,结果跑两步就停一次,根本看不出逻辑。一般3-5个关键断点就够了。
- 善用条件断点。 尤其是在循环里,条件断点能帮你省下大量时间。
- core dump是最后的救命稻草。 生产环境一定要开,别等到出事了才后悔。
- 调试不是目的,理解才是。 用GDB不是为了把bug改掉,而是为了搞清楚程序为什么会有这个行为。搞清楚了,bug自然就修了。
最后提醒一句:GDB是个工具,不是银弹。有些bug(比如多线程竞态条件、硬件时序问题)用GDB很难调试,因为GDB本身会改变程序的执行时序。这时候你可能需要更专业的工具,比如Trace32、SystemView,或者干脆加日志。但话说回来,90%的bug,GDB都能搞定。先把这90%练熟,剩下的10%再说。
公众号:蓝海资料掘金营,微信deep3321