搞了这么多年Linux开发,我见过太多同事遇到段错误还在疯狂加printf,改一次编译一次,跑一次崩一次,一上午就耗在一个空指针上。也不是说printf不行,但当你面对的是一台不能随随便便重启的线上服务器、一个跑了几百个线程的复杂进程、或者一堆交叉编译出来的嵌入式二进制时,printf根本插不进去手。这时候gdb就是唯一靠谱的救命稻草。这篇文章我就从实际调试的视角出发,把gdb从基础命令到断点进阶、崩溃现场分析、多线程死锁排查、甚至反汇编定位这些场景完整串一遍,希望能给还停留在printf阶段的朋友一些启发。
1. 调试前的基本功:编译选项、启动方式与断点思维
1.1 编译选项是调试的起点,不是gdb的问题
很多人抱怨“gdb看不到变量”“gdb显示的行号对不上”,其实是编译的时候就埋了坑。gdb能不能看到源码、变量名、行号,完全取决于可执行文件里有没有调试符号。你编译的时候加-g,gdb才能把机器码和源代码对应起来;你没加,gdb就只能对着汇编和一堆地址发呆。
这里我要特别强调一个经验:调试版本一定要用-O0,不要开优化。我早期吃过一次大亏,代码里有个变量,gdb里用print怎么看都是<optimized out>,后来才发现是-O2优化之后变量直接进了寄存器,或者被编译器完全消除掉。你硬着头皮在优化版上调试,等于跟编译器做智力竞赛,没必要。生产环境你可以用-O2 -g,保留优化性能的同时带上符号,但日常调试一定要-O0。
还有一个判断技巧,拿到一个二进制先看它有没有调试信息:
bash复制file ./a.out
readelf -S ./a.out | grep debug_info
file输出里会出现with debug_info或者not stripped字样。如果没有调试信息,那就要么重新编译,要么只能硬着头皮搞反汇编(后面会讲)。
1.2 启动gdb的三种姿势
最基本的是直接加载可执行文件:
bash复制gdb ./a.out
但程序往往要带启动参数,有几种方式:
bash复制# 方式一:加载后run时再给参数
gdb ./a.out
(gdb) run -a --port=8080
# 方式二:gdb --args直接预置参数
gdb --args ./a.out -a --port=8080
还有一种非常实用但容易被忽略的启动方式——attach到正在运行的进程。程序跑在服务器上出了问题,你不能直接重启它,那就先找到PID,然后:
bash复制# 先看进程号
ps -ef | grep server
# attach上去
gdb -p 12345
attach成功之后你会看到进程挂住,相当于被gdb接管了,这时候你可以continue让它继续跑,或者interrupt中断下来看当前状态。这个操作在线上问题排查里是真正的救命技能。需要注意的是,Linux下attach需要同用户权限,而且如果/proc/sys/kernel/yama/ptrace_scope设为1,跨进程attach会受限,Docker容器里通常要加--cap-add=SYS_PTRACE才能正常调试。
1.3 断点的基本操作,但别只会break
break(简写b)打断点,大多数人会,但细节很多人不清楚。打断点可以直接指行号、函数名、文件:行号:
gdb复制(gdb) b main
(gdb) b 10
(gdb) b server.c:128
(gdb) b Server::HandleRequest
这里有个实用的点:如果源代码有多个同名函数(比如重载),b Server::HandleRequest可能命中多个候选断点,gdb会问你编号选择。这时候如果只想给特定参数类型的那一个打断点,记得写全函数签名,比如b Server::HandleRequest(const std::string&)。
还有一些不那么常用的断点变体:
- 临时断点
tbreak:只生效一次,命中后自动删除。适合在循环里只想停一次确认行为。 - 正则断点
rbreak:按正则批量打断点。比如要在某个文件所有函数入口停下,rbreak server.c:.会匹配所有函数。 - 禁用断点
disable和enable:断点可以留着但暂时不启用,比反复delete/add要方便。
info breakpoints查看当前所有断点,delete 编号删除对应断点。这个不复杂,但每次重新编译之后断点可能因为行号偏移变得不准,所以我在重新编译后通常会info breakpoints看一眼,必要时全部删掉重打。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 断点也能玩出花:条件断点、观察点与批量命令
2.1 条件断点:不要浪费时间在无关的10000次循环上
在循环里打断点,但只想在特定条件满足时停下来——这是新手和老手调试效率的分水岭。直接给断点加条件就行:
gdb复制(gdb) break server.c:128 if retry_count == 3
(gdb) condition 2 err_code != 0
第一种方式在设置断点时直接就带条件;第二种方式是先设断点,事后用condition 断点编号 条件去改。我更喜欢第二种,因为有时候调着调着,发现断点停的次数太多,想再加个条件缩小范围,这时候不用删了重建,直接condition改一下就行。
实际场景举例:某个函数被调了几千次,每次传入的指针都不同,你只关心指针为NULL那一次。如果不用条件断点,你得一次次按continue,按到手抽筋。用了条件断点,gdb自动跳过不满足条件的命中,效率天差地别。
但这里有一个容易忽略的性能坑:条件断点每次命中都要做条件求值,如果断点落在热点路径上,程序运行速度会慢得离谱。我遇到过条件断点使程序慢了十倍的情况。所以条件表达式尽量写得简单,别在条件里调用函数,那会让每次命中开销高到怀疑人生。
2.2 观察点:盯着变量什么时候被改
断点是“停在指定位置”,观察点是“停在指定变量发生变化时”。前者是空间定向,后者是时间定向。排查“某个变量被神秘修改”的问题时,观察点是神器。
gdb复制(gdb) watch count
(gdb) rwatch count # 被读取时停
(gdb) awatch count # 被读或被写时都停
watch最常见的用法是监控全局变量或者堆上的内存。如果变量在某个函数里被修改了,gdb会停在该函数内,你马上就能看到是谁干的。
但有个限制需要牢记:硬件观察点数量有限(x86架构通常也就4个左右),调试器会优先尝试用硬件调试寄存器,如果不行就退化为单步执行加内存比较,整个程序会像龟爬。如果你发现设置watch之后程序慢到不能忍,大概率是退化了。这时候减少观察点数量,或者用watch监控一个特定地址(比如watch *(int*)0x7fff1234),而不是监控整个结构体,速度会好很多。
2.3 commands命令脚本:命中断点后自动干活
这个命令用的人非常少,但我觉得它是gdb里最被低估的功能之一。它可以指定某个断点命中后自动执行一串命令,而不需要你一次次手动重复操作。
gdb复制(gdb) break HandlePacket
(gdb) commands 1
> silent
> printf "HandlePacket called, len=%d\n", len
> bt 3
> continue
> end
这段的意思是:命中断点1时,静默执行——不打印普通断点提示,打印一行日志,打印前3层堆栈,然后自动继续运行。这对于在循环里看函数调用序列、跟踪一段逻辑的连续执行过程,非常有效。我经常用它来打印每一次调用传入的参数,比在代码里埋printf强多了——不用改代码、不用重新编译,对线上二进制也能用。
3. 程序崩溃后的第一反应:堆栈回溯与core dump分析
3.1 段错误后,先别慌,run一下然后bt
很多人的调试流程是:程序崩了,一头雾水,打开源码猜。正确的流程其实很固定:用gdb启动程序,让它自然地崩,然后看一眼堆栈。
bash复制gdb ./a.out
(gdb) run
程序崩溃后gdb会停住,显示类似Program received signal SIGSEGV, Segmentation fault.这时候输入bt:
gdb复制(gdb) bt
#0 0x00007ffff7a8d2b6 in __memmove_avx_unaligned_erms () from /lib64/libc.so.6
#1 0x0000000000400715 in my_strcpy (dst=0x0, src=0x4008a0 "hello") at test.c:8
#2 0x0000000000400735 in main () at test.c:15
解释一下怎么看:堆栈从下往上是调用顺序,#0是崩溃发生点,#1是调用#0的函数,以此类推。这里能看到是在my_strcpy里调用memmove时崩的,dst=0x0说明传进来的目标指针是空指针,问题就出在main函数第15行调用my_strcpy时传入了一个NULL指针。
如果默认的bt信息不够,用bt full,它会把每一层函数的局部变量也打出来,往往一眼就能看出是哪根指针有问题。还有个常用组合:frame 2(或简写f 2)切换到某一帧,然后info locals看该层函数的所有局部变量,info args看函数参数。排查崩溃时,这套组合拳能回答90%的“为什么空指针”问题。
3.2 core dump的配置与加载:线上崩溃的终极王牌
程序崩溃时,操作系统会把进程内存镜像写到一个core文件里,这个文件等价于“事故现场照片”。你拿到core文件之后,可以在任意时间用gdb分析,不需要复现问题。
但很多人遇到的问题是:崩溃了,然而找不到core文件。这几乎都是系统配置问题。先检查:
bash复制# 查看core文件大小限制,0表示禁止生成
ulimit -c
# 设为不限制
ulimit -c unlimited
ulimit -c unlimited只在当前shell生效,建议写进~/.bashrc。生产环境通常还有/proc/sys/kernel/core_pattern决定core文件生成到哪个目录、叫什么名字,有的系统配置了管道给systemd-coredump之类的工具统一管理。如果core_pattern里带|符号,说明core被系统服务接管了,这种情况下可能要用coredumpctl list去捞。
拿到core文件后:
bash复制gdb ./a.out core.12345
gdb加载core之后,直接bt就能看到崩溃堆栈。就算程序已经“死”了,你也可以用frame、info locals、info registers查看崩溃那一刻的所有状态,就像时间冻结了一样。
提示:线上部署的程序,一定要保留带调试符号的二进制文件,否则core文件再完整,你也只能看到一堆十六进制地址,什么都做不了。我见过太多团队把带符号的二进制扔了,只留一个strip过的,出了事只能干瞪眼。
3.3 崩溃现场快速定位的几条经验
拿到崩溃堆栈后,还有一些快速定位的经验。
第一,看栈顶是系统库还是你自己的代码。如果栈顶是libc里的memcpy、strlen、free这类函数,说明问题很可能出在参数上——比如传给free的指针非法、传给strcpy的源或目标地址不对。这时候要往下翻栈帧,看你自己的代码里哪一行调用了这个库函数,然后把那一帧的局部变量都打出来看看。
第二,栈溢出和堆破坏会让堆栈本身变得不可信。如果bt打印的栈地址长得稀奇古怪,或者出现了Cannot access memory at address 0x...,大概率是缓冲区溢出把返回地址覆盖了。这种问题靠gdb只能判断个大概,接下来得靠AddressSanitizer这类工具在本地复现,后面案例部分我再细说。
第三,多线程程序崩溃时,别只盯着崩溃线程。先执行:
gdb复制(gdb) thread apply all bt
把所有线程的堆栈都打印出来。有时候A线程崩了,真正的罪魁祸首是B线程改了共享内存。看看其他线程当时在干什么,往往能发现线索。
4. 调试多线程问题:线程切换、锁竞争与死锁定位
4.1 多线程调试基础:info threads和thread切换
现在的服务端程序几乎都是多线程的。gdb默认调试的是“当前线程”,其他线程在后台继续跑,这一点一定要理解。当程序停在你设的断点上时,停住的只是当前线程,其他线程可能还在运行。
查看所有线程:
gdb复制(gdb) info threads
Id Target Id Frame
* 1 Thread 0x7ffff7fc1740 (LWP 12345) "server" 0x00007ffff7a8d2b6 in ...
2 Thread 0x7ffff77e0640 (LWP 12346) "server" ...
带*号的是当前线程。thread 2切换当前线程,切换后再bt就是看这个线程的堆栈。如果你希望在某个断点处只针对某个线程生效,可以:
gdb复制(gdb) break server.c:200 thread 2
这样只有线程2命中这个断点时才停,其他线程不受影响。
4.2 死锁定位:thread apply all bt是最大的杀器
死锁是典型的“程序卡住但没有崩溃”,用gdb挂上去看每个线程卡在哪,问题立刻现形。调试指令就三个:
gdb复制# 切换到目标进程
gdb -p 12345
# 全部线程的堆栈都打一遍
(gdb) thread apply all bt
死锁的特征非常明显:两个或多个线程都阻塞在pthread_mutex_lock、futex_wait这类等待锁的函数上,而且彼此的栈显示它们在等不同的锁,但这两把锁又被彼此持有。只要看到这个模式,基本可以断定死锁。
光看堆栈还不行,你还得判断每个线程持有哪些锁。gdb里直接看不太直观,但你可以通过frame切换到这里线程等待锁的那一层,用info args或者打印锁对象的内存,看看锁的状态。如果锁是pthread_mutex_t,可以打印它的__owner字段:
gdb复制(gdb) p my_mutex.__data.__owner
$1 = 12346
这个字段记录着当前持有锁的线程LWP号,配合info threads里的LWP信息,就能知道这把锁被谁占着。这一招在线上排查死锁时非常好用。
值得一提的是,gdb并不是检测死锁的唯一工具,像helgrind或者ThreadSanitizer能检测更多类型的线程错误,但它们对运行环境有要求,线上一般不好用。gdb是“能拿着就用”的工具,优先级最高。
4.3 时序问题:scheduler-locking解决调试时其他线程抢跑
调试多线程时有个非常坑的现象:你在当前线程单步执行,但其他线程还在后台疯跑,导致变量值在你眼前变来变去,断点也总在奇怪的地方被触发。解决办法是:
gdb复制(gdb) set scheduler-locking on
打开这个选项后,单步执行时会锁定其他线程,让它们也停下来,只有当前线程在动,这样你才能在一个干净的环境里观察单一线程的行为。调试完记得改回off,不然正常调试会受影响。
这个参数在调试竞态条件时尤其有用。比如怀疑“线程A写完成后线程B读,但有时候B先读到了旧值”,你可以让两个线程轮流单步执行,用最笨的人肉方式复现时序问题。虽然效率低,但往往能锁定到底哪行代码的先后顺序出了问题。
5. 反汇编视角:当源码信息不够时怎么继续排查
5.1 为什么你最终还是需要懂一点汇编
你可能会问:都用上gdb了,为什么还要看汇编?因为很多场景下你没有源码级的调试信息。常见的有三类:
- 只有发布版strip过的二进制,没有带符号的版本;
- 你在调试第三方库、驱动或者内核模块,根本没有源码;
- 崩溃栈显示崩溃点在库函数内部,你想看调用约定和寄存器值来推断参数。
在这种局面下,disassemble就是你的下一个武器。gdb能反汇编指定函数或地址范围:
gdb复制(gdb) disassemble my_func
(gdb) disassemble /r my_func # 同时显示机器码
(gdb) disassemble /m my_func # 尽量混合显示源码(有时不准确)
5.2 用寄存器和内存查看命令追线索
反汇编出来之后,你还要会看寄存器。用info registers(简写info reg)看所有寄存器的当前值。x86-64的调用约定是参数依次放到rdi、rsi、rdx、rcx、r8、r9,所以看到崩溃点在某个函数内部时,rdi就是第一个参数,rsi是第二个,对照一下就能判断调用方传进来的值是不是合法的。
看内存用x命令,格式是x/数量格式地址。比如:
gdb复制(gdb) x/8gx 0x7fffffffdbd0 # 十六进制显示8个8字节
(gdb) x/20bx 0x7fffffffdbd0 # 十六进制显示20个单字节
(gdb) x/s 0x4008a0 # 当作字符串显示
我当年第一次用这个组合解决过一个线上问题:崩溃栈指向一个回调函数,但所有参数看起来都是合法的。我info reg发现rip指向的地址在函数内部偏移0x26处,disassemble看这个偏移位置是一条mov指令,访问的基地址是从一个注册表里读出来的,但该寄存器值为0。往前翻几条指令才发现,前面的函数调用返回后没有检查返回值,直接把可能为NULL的指针当基址用了。这种问题,不看汇编根本定位不到。
5.3 TUI模式:汇编和源码分屏查看
gdb有个字符界面模式,按Ctrl+X然后按A开启,或者启动时用gdb -tui。它能像IDE一样把源码、汇编、寄存器分屏显示,单步执行时看得到指令跳转。
不过说实话,TUI模式在终端里偶尔会花屏,字符渲染不太稳定,我在SSH环境里遇到过乱码,但本地终端用起来还行。如果你习惯了纯命令行的简洁,也可以不用。我一般是在要反复看汇编时开一下,平时还是命令行为主。set disassembly-flavor intel可以把汇编显示成Intel风格(mov eax, ebx),比AT&T风格(mov %ebx, %eax)对新手友好得多。
6. 提高调试效率的几个习惯与技巧
6.1 .gdbinit和自定义命令:把高频操作变成一步到位
每次打开gdb都要敲一堆重复命令实在浪费时间。~/.gdbinit文件会在gdb启动时自动执行,你可以把常用配置写进去:
text复制set pagination off
set confirm off
set print pretty on
set print array on
set print elements 0
set substitute-path /build/path /local/path
这行set substitute-path解决的是一个非常常见的问题:编译机上源码路径是/home/jenkins/build/...,你本地源码在/home/me/project/...,不配置这个gdb死活找不到源码。配置之后gdb会做路径替换,源码就能正常加载了。
更进一步的技巧是自定义命令。比如“查看当前函数所有局部变量和参数、打印调用栈、查看关键指针”,这样一条龙的操作可以封装成一个自定义命令:
text复制define plist
info locals
info args
bt 5
x/8gx $rdi
end
定义好之后,每次输入plist就自动执行这五条命令。你在调试那种变量特别多的类成员函数时,这一下能省不少事。类似的自定义命令我攒了一堆,每个花半小时总结一次,之后调试效率直线上升。
6.2 打印与日志设置:set logging让调试过程可回溯
复杂问题经常要调试很久,中间可能切来切去看各种变量,回头想复盘某些输出却发现早被滚屏刷掉了。gdb自带的日志功能可以保留所有调试输出:
text复制set logging file debug.log
set logging on
设置后,所有gdb输出都会同时写入debug.log。调完再用编辑器慢慢翻,这在分析“几次关键输出的前后关系”时特别好用。
还有几个打印选项也值得关注:
set print pretty on:结构体输出自动换行缩进,读起来清爽很多。set print elements 0:字符串/数组元素不限制显示长度,默认只显示前200个左右字符。display /x counter:每次停下来自动打印counter的值,相当于一个自动更新的观察列表。
6.3 调试符号缺失时的补丁方案
如果碰到一个没有调试符号的二进制,除了硬看汇编,还有几个补救办法:
- 用
info sharedlibrary查看动态库加载了哪些,是否有对应的带符号版本。 - 如果系统是Fedora/RHEL系,可以看
debuginfod是否可用,配置DEBUGINFOD_URLS后gdb会自动下载对应的debuginfo包,但网络受限的离线环境就别想了。 - 如果只是某些系统库没符号,可以只下载/编译那个库的debug版本,其他库不管。gdb对每个库的符号是独立加载的,不需要全有。
7. 一次线上问题完整复盘:从core文件到根因的排查链路
7.1 问题现象
有一段时间,我们一个长连接服务每隔一两天就会崩溃一次,没有任何规律,流量高峰低谷都出现过。因为服务是无状态的,崩溃后自动拉起,影响不算特别大,但每次都得人工跟进,而且一直找不到原因,像个定时炸弹一样吊着。
因为问题不频繁复现,直接从线上gdb attach不现实,我们只能等下一次崩溃,然后从core文件和带符号的生产二进制里找线索。
7.2 拿到core之后的排查路径
崩溃发生后,运维把core文件和二进制都捞了出来。我用gdb启动:
bash复制gdb ./server /var/crash/core.server.12345
进去先执行了最常用的一套:
gdb复制(gdb) thread apply all bt
输出里大部分线程都停在epoll_wait、pthread_cond_wait这种正常的等待点上,但有一个线程明显不对劲,栈顶停在memcpy内部,再往上是我自己代码里一个网络消息解析函数。看帧里的局部变量,发现解析消息的缓冲区指针正常,但是长度字段大得出奇,是一个接近INT_MAX的数。
再往下翻,发现这个长度字段不是从网络数据里读出来的,而是从一块已经回收的内存里读的。也就是说,这块内存在别处被释放了,但消息处理流程仍然持有它的指针。memcpy拿这个超大的长度去复制,自然越界崩溃。
7.3 根因确认:gdb定位方向,ASan确认细节
gdb告诉了我们崩溃的直接原因,但问题是:这块内存是谁释放的?为什么释放后还在用?
这就要提到一种非常实用的工具——AddressSanitizer(ASan)。用-fsanitize=address -g重新编译服务,在本地用一个脚本高并发跑压力测试,ASan很快就抓到了具体代码行:某个socket断线清理逻辑里,对消息对象执行了delete,但消息处理队列里还有一个未完成的回调持有同一个指针,回调执行时才发生悬垂指针访问。
整个排查链路走下来,gdb负责定位“表面死因”,ASan负责揪出“幕后黑手”,两者配合才最终根除问题。如果一开始只盯着崩溃堆栈猜,很可能修完一个表面问题,过几天又换一个地方崩。
7.4 这件事给我留下的几个教训
第一,生产二进制的符号一定要留存。我们公司后来专门建了个目录,每个版本的二进制和对应的符号文件都归档,这个习惯直接决定了很多线上问题能不能被快速排查。
第二,core文件配置要提前确认。很多默认环境是不产core的,等到崩了才发现core没开,那就晚了。
第三,gdb不是万能的,但它一定是你发现问题时的第一个工具。用它锁定嫌疑区域,再决定要不要上更重的工具,整个排查效率才会高。
最后分享一点个人习惯
用了这么多年gdb,我最大的感受是:它不是一个“会用指令”就能玩好的工具,而是一种“调试思维”。你只有理解了程序的运行时状态、理解了内存布局、理解了多线程的并发模型,才能真正把这条命令链用出效果。我也建议新手别怕命令多,先掌握bt、frame、info locals、thread apply all bt这几个核心组合,已经能解决八成以上的问题。剩下的,等你真的遇到了,再回来查一查,记一记,慢慢就内化成自己的经验了。
