GDB调试实战指南:从崩溃定位到多线程排查的完整解析
我干了十几年C/C过去最讨厌听到的一句话是“程序崩了你过来看下”。以前我也靠printf排查打印打得满屏都是运气好能猜到问题运气不好为了追一个空指针能熬到后半夜。后来被现实反复教育才老老实实把GDB用熟练。这篇文章就围绕GDB调试这个主题把我踩过的坑、常用的命令和真正解决问题的思路一次性讲清楚。你如果刚接触Linux下的崩溃排查、或者只会用GDB的break和next那这篇文章应该能帮你省下不少弯路。1. 调试前的准备工作1.1 编译这一步决定你调试时是地狱还是天堂很多人拿到GDB第一件事是gdb ./myprogram然后回车却得到一堆No symbol table is loaded还以为工具坏了。其实问题在编译阶段你没有加-g参数。-g的作用是让编译器把调试符号写进最终的可执行文件。这些符号包括源码文件名、行号、变量名、函数名、结构体类型定义等等是GDB做源码级调试的基础。没有这个参数GDB就只能看到内存地址和汇编指令除非你是汇编级排查专家否则基本没法调试。更讲究一点我自己写代码做实验时基本用gcc -O0 -g -o demo demo.c。为什么强调-O0因为高优化等级比如-O2会让编译器做各种操作变量可能被优化进寄存器、循环可能被展开、冗余的中间计算被删掉。运行的时候你想打印一个局部变量GDB告诉你value optimized out这就是被优化掉的结果。开发和调试阶段先用-O0上线前再用-O2重新编译做验证这是比较规范的做法。另外要注意-g不会影响程序运行效率只是让二进制文件变大一些。正式发布时可以用strip删掉符号表来减小体积但线上如果需要排障保留了符号的那个版本建议在构建服务器上打好包存档。我在某个项目组就是这么干的内部保留-g -O2的unstripped版本发布出去的是strip后的版本一旦线上进程崩溃拿core文件和unstripped的二进制一起做定位调用栈完整得多。这个习惯强烈推荐因为线上问题最怕的就是“现场被破坏”。1.2 三种方式启动GDB加载、看core、附加进程启动GDB最常见的有三种场景gdb ./myprogram从头调试一个新运行的程序路径是常规做法。gdb ./myprogram core调试崩溃后的core文件适合线上问题离线分析。gdb -p PID或者先启动GDB再输入attach PID附加到一个已经运行的进程适合排查服务卡住、死锁这类运行时问题。个人经验在本地复现bug直接加载最方便线上问题优先看core文件除非你确定可以现场复现否则不要乱attach线上进程当你判断一个服务“卡住不响应”时attach进去看所有线程的栈是最有效的一步。使用attach方式有个前提操作系统的ptrace_scope限制。很多Linux发行版默认不允许普通用户attach别的进程要么把/proc/sys/kernel/yama/ptrace_scope设置为0要么用sudo gdb -p PID或者从同一个调试会话里启动程序。这个放在后面常见问题里详细说。还有一种场景是远程调试用gdbserver在目标机器上启动程序然后在开发机上用gdb远程连接。适合目标机器没有完整调试工具链、或者程序跑在嵌入式板子上的情况后面有机会再单独展开。1.3 自定义启动环境一份顺手的.gdbinit真的能提升效率每次进GDB默认的交互界面对于新人来说有点不够友好一次输出太多、长字符串被截断、数组默认只显示一部分元素。我一般会在HOME目录下写一个.gdbinit文件内容就这几行set pagination off set print pretty on set print elements 200 set print array-indexes on set print thread-events on set confirm off set history save on解释一下每行的用意。pagination off关闭分页信息再多也不依赖你一直按回车print pretty打印结构体时每个成员单独一行嵌套结构看起来更清晰print elements 200把字符串和数组的输出上限放大到200个元素默认值比较保守经常不够用thread-events能看到线程创建和退出的事件调试多线程程序时很需要confirm off就是让你执行delete之类的命令时少问几句“Are you sure”。这些都是按真实使用频率筛出来的配置。调试过程里最烦的不是命令不会写而是输出又长又乱关键时刻还被分页卡住。把这些细节提前配好效率提升是实打实的。2. 核心调试操作速通2.1 断点不只是“在函数名上break一下”最基础的三类断点写法break main在入口函数下断点从main开始看流程。break 文件:行号比如break test.c:23在指定行停下。break 函数名比如break sum_list函数入口处停下。真正实用的是条件断点break sum_list if total 1000。去调试大循环时非常舒服不用一遍遍continue看变量有没有到阈值程序会在满足条件的那一刻自动停下。调试时我会配合info breakpoints查看所有断点的编号和启用状态再用disable 1临时禁用某个断点或者delete 1彻底删除。还有两个好用的命令值得记一下临时断点tbreak只命中一次然后自动删除适合“进这个函数看一眼”的场景正则表达式断点rbreak可以一次对符合正则的所有函数下断点比如rbreak ^create_对一组命名规整的函数批量加断点。一个很容易踩的坑断点打在不执行的代码路径上半天不命中。你怀疑手误其实是因为代码里有分支if (cond) {...}你在这个分支里下了断点但cond一直是false程序根本不会走到那里。遇到这种情况别着急换断点先检查逻辑路径是否正确。2.2 单步调试next、step、finish的差别要记清楚next是“单步跳过”执行当前行遇到函数调用会整个函数一步跑完step是“单步进入”遇到函数调用会走进函数内部。新手最容易搞混明明想看函数内部的执行过程却用了next一行就划过去了什么细节都没看到。finish的意思是“一直执行到当前函数返回”适合函数内部已经看完、想快速回到上一层的场景。until 行号表示在当前函数内一直执行到指定行跳过不关心的循环迭代很有用。还有一个命令是skip function 函数名调试时让GDB“跳过”某个函数遇到它直接执行不会被单步卡住比如调试时不希望进入某个不关心的库函数。实际操作中我的走法是先用break 文件:行号定位到附近位置然后next横向推进当看到一个关键函数调用时用step进去看清内部逻辑看完用finish抽身出来继续next。这套组合基本覆盖了90%的单步调试场景。2.3 看变量print家族与自动刷新print 变量名也可以简写成p var打印变量。p/x表示十六进制p/d表示十进制p/o表示八进制p/t表示二进制。p *p打印指针指向的结构体而不是指针地址本身。p *arrlen打印数组开头len个元素。display 变量名每次单步之后自动刷新显示适合反复观察某个变量。info locals查看当前栈帧内所有局部变量。info args查看当前函数的所有参数。查看指针时直接p ptr只会显示地址可能误导你想看指针指向的数据要p *ptr。数组也类似虽然p arr对数组名可以打印整个数组但只要是从堆里申请的动态内存就得用p *arrn来打印指定长度。还有一个容易被忽略的调试手段set var 变量值。你在断点处停下来直接改掉某个变量的值让程序走不同的分支。比如set var is_debug 1或者把循环计数器改大跳过一段耗时逻辑。这不是“作弊”而是用最小的代价测试不同路径比改代码重新编译快得多。相同思路call可以在GDB里执行函数调用比如call free_list(head)。3. 进阶调试技巧对付真正麻烦的问题3.1 崩溃现场core文件与调用栈配合段错误英文 Segmentation fault常伴随提示 core dumped。第一次遇到的人会慌“我的代码到底哪里崩了”其实GDB加core文件是定位崩溃最快的方式。首先要确保能生成core文件。在shell里执行ulimit -c unlimited这个命令放开当前会话的core文件大小限制。很多人程序崩了却没留下core文件十有八九是ulimit限制默认值往往是0。如果还不行检查一下/proc/sys/kernel/core_pattern确认core文件的输出路径和命名格式。有的系统默认会放到某个统一目录下别找错了地方。拿到core文件后执行gdb ./myprogram ./core进去以后第一件事是敲bt这是 backtrace 的缩写。bt会打印从崩溃点一直到main的完整函数调用栈。比如(gdb) bt #0 process_input (data0x0) at demo_null.c:5 #1 0x000055555555521e in main () at demo_null.c:14这就很直观崩溃发生在 demo_null.c 的第5行process_input内它是在第14行被main调用的。再配合frame 0切换栈帧、info locals查看局部变量、list查看源码基本一两分钟能判断出是哪一行崩了、为什么崩。重要提示core文件必须和原始可执行文件匹配才能得到准确行号。如果程序中间重新编译过core文件对不上GDB给出的行号可能会错位甚至完全无意义。线上出现core第一件要做的事是冻结对应版本的二进制再复制现场去分析。3.2 观察点抓“谁改了我的变量”断点适合“代码执行到这里停一下”但有一种bug很难缠你发现某个变量值不对但不知道是哪个地方改的。或者这个变量在整个程序里会变一千次你想在它变成某个状态时停下。观察点就是为这种情况设计的。watch 变量名表示这个变量每次发生变化时暂停执行rwatch和awatch分别是读时暂停、读写都暂停。典型场景(gdb) watch my_global_value Hardware watchpoint 1: my_global_value当某个线程某一行的代码把my_global_value改成其他值时GDB立刻停下并告诉你是哪一行、哪个线程改的。这个功能在排查数据被串改、内存踩踏、并发写同一变量时非常好用。注意硬件观察点有数量限制一般4个或更少。一旦超限GDB会退化为软件观察点用单步模拟速度会变得很慢。所以不要一次开太多优先盯最重要的变量。3.3 多线程调试锁、调度、scheduler-locking多线程程序的bug是出了名的难查。GDB下info threads列出当前进程的所有线程thread 线程号切换目标线程thread apply all bt一次性打印所有线程的调用栈。这个命令在判断“服务是不是死锁”时尤其好用如果多个线程全部阻塞在某个锁申请上调用栈会一目了然。默认情况下GDB在调试某个线程时其他线程还会继续运行。如果你不希望它们跑就设置set scheduler-locking on把调度锁住只让当前被调试线程运行。这在复现时序相关bug时很有帮助。我遇到过一个很有代表性的问题线程A停在断点线程B还在跑打印的全局变量状态总是对不上。后来用set scheduler-locking on锁住其他线程才看清是另外一个线程在没加锁的地方改写了数据。多线程调试只要涉及共享变量“其他线程还在跑”这个隐含前提就是最大的干扰源记住先锁调度。3.4 fork之后的调试follow-fork-mode如果你写的程序用fork()创建子进程GDB默认只会调试父进程子进程里发生了什么你根本看不到。相关设置set follow-fork-mode parent默认跟随父进程。set follow-fork-mode child让GDB跟随子进程。set detach-on-fork on/off控制另一个分支是否被GDB挂起。经典场景父进程fork子进程子进程跑业务逻辑并崩溃但GDB默认停在父进程。你知道出了事却看不到子进程里的细节。把follow-fork-mode设为child再跑一次就能像调试普通程序一样断点、单步子进程了。3.5 反向调试让程序“倒着走”GDB有实验性的反向执行功能用record记录执行轨迹然后reverse-step或reverse-next往回走。这在“多走了几步导致状态被破坏想回头确认是哪一步改的”场景里非常有用。它靠的是指令和内存执行的记录性能和内存开销都大所以只适合短时间、小范围的复现问题。我在本地一个单机模拟程序里试过效果挺惊艳但线上attach场景不适用适合在测试环境深挖问题。4. 两个实战复盘从崩溃到定位4.1 空指针崩溃的完整排查先写一个会自动复现空指针崩溃的代码// demo_null.c #include stdio.h void process_input(int *data) { *data 42; // 这里会崩data是空指针 } int main() { int *p NULL; int flag 0; // 模拟某个逻辑分支没有给p赋值 if (flag 0) { int x 10; p x; } process_input(p); printf(done\n); return 0; }编译指令gcc -g -O0 -o demo_null demo_null.c直接运行大多数Linux机器会输出Segmentation fault (core dumped)。然后执行ulimit -c unlimited ./demo_null确认core文件生成后进入GDBgdb ./demo_null ./core进去先bt(gdb) bt #0 0x00005555555551a5 in process_input (data0x0) at demo_null.c:5 #1 0x000055555555521e in main () at demo_null.c:14看到data0x0说明在process_input里收到了空指针。接着frame 0切换到最内层栈帧再info locals确认局部变量然后list查看源码(gdb) frame 0 #0 0x00005555555551a5 in process_input (data0x0) at demo_null.c:5 5 *data 42;问题就定位在这一行。继续往上切一层(gdb) frame 1 #1 0x000055555555521e in main () at demo_null.c:14 14 process_input(p);再看p的值是0x0根因是前面那个if (flag 0)分支没有执行p一直是NULL。整个流程几分钟就能走完。如果没有GDB全靠日志和眼神真的可能要看半天。4.2 变量被意外改写的定位观察点实战有一种场景不是崩溃但是结果不对某个全局变量在某个时间点被改了你怀疑有地方误写。看代码看不出来因为修改点可能藏在某个很少被关注的分支里。这时候用观察点最合适。比如这个代码// demo_watch.c #include stdio.h #include stdlib.h typedef struct Node { int data; struct Node *next; } Node; Node *head NULL; void create_list(int n) { head (Node*)malloc(sizeof(Node)); head-data n; head-next NULL; } void loose_pointer() { // 模拟某处误操作把全局head置空程序其他部分会因此出问题 Node **pp head; if (*pp ! NULL) { head NULL; } } void show_head() { if (head ! NULL) { printf(%d\n, head-data); } else { printf(head is null\n); } } int main() { create_list(5); loose_pointer(); show_head(); return 0; }loose_pointer里的head NULL就是罪魁祸首。真实项目里这种误写可能藏在几个文件的深处。我先在main的最后一行之前打断点等程序执行到show_head()附近时设置观察点(gdb) break main (gdb) run (gdb) next ... (gdb) watch head Hardware watchpoint 1: head (gdb) continueGDB会在执行到loose_pointer里的head NULL这一行时停下来直接告诉你Hardware watchpoint 1: head Old value 0x5555555592a0 New value 0x0 loose_pointer () at demo_watch.c:19 19 head NULL;这一下就够了你知道是loose_pointer这个函数在19行修改了head。如果没有观察点你可能排查日志半天都找不到具体位置。4.3 服务卡住之后attach上去看线程栈还有一个非常常见的排查场景后台服务突然不响应了。先找到进程号ps -ef | grep my_server假设PID是 12345然后sudo gdb -p 12345进去之后第一件事不要乱按直接thread apply all bt把所有线程的调用栈打出来(gdb) thread apply all bt Thread 3 (Thread 0x7f0a...): #0 futex_wait () at ... #1 __lll_lock_wait () at ... #2 pthread_mutex_lock () at ... #3 worker_func () at worker.c:45 ... Thread 2 (Thread 0x7f0b...): #0 futex_wait () at ... #1 __lll_lock_wait () at ... #2 pthread_mutex_lock () at ... #3 node_handler () at node.c:78 ...如果多个线程全部卡在pthread_mutex_lock上基本可以判断是锁竞争或者死锁。接着看它们分别等待的锁地址再结合源码找到锁的申请顺序就能定位死锁环。看完现场后执行detach让进程恢复运行。attach的一瞬间进程会暂停所有线程的状态是一个相对一致的快照这对判断“整体卡在哪”非常有价值。5. 常见问题速查与独家避坑5.1 高频问题一览表我在实际使用中常遇到的问题列成一张表方便你对照排查问题现象原因快速处理没有调试符号No symbol table is loaded编译时没加-g用gcc -g -O0重新编译断点不生效断点在源文件某行没停inline函数、行号不匹配、路径错误用info breakpoints查看状态修改断点位置变量显示被优化value optimized out编译器优化掉了局部变量调试阶段用-O0或尝试p *(type*)varcore文件没生成崩溃后没有core文件ulimit -c为0执行ulimit -c unlimitedattach失败ptrace: Operation not permitted内核yama限制用sudo或临时修改ptrace_scope源码显示乱码中文注释显示乱码文件编码与GDB默认不一致源码统一为UTF-8设置set charset UTF-8动态库找不到符号断点加载失败.so没有调试符号或路径不对使用带debug的so设置set solib-search-path5.2 容易忽略的坑第一个坑GDB加载动态库.so时如果库路径或符号不对会出现断点无法设置的情况。这时候要用set solib-search-path /path/to/libs指定查找目录确保GDB能找到带符号的库。线上依赖的库经常不在标准路径这一步不能省。第二个坑编译器的优化不仅影响变量显示还可能导致断点位置和源码行号错位。-O2 -g下设置在某一行的断点实际可能停在相邻几行。调试复杂问题建议用无优化环境如果必须在优化环境下调试可以用汇编级断点break *地址再配合disassemble看实际汇编。第三个坑多线程调试时操作顺序会被GDB影响。你在调试时虽然GDB暂停了当前线程但其他线程可能还在运行导致实际执行顺序与线上不一致。用set scheduler-locking on能减少这种干扰。第四个坑忘记环境变量。程序运行依赖某些export XXX1但你只在终端里跑的时候设了进了GDB却没有于是行为不一致。可以用set env XXX1在GDB里设置环境变量或者用gdb --args ./myprog arg1 arg2统一配置启动参数。5.3 我的一个日常习惯调试日志和GDB不是二选一。printf调试适合“程序在正常运行但结果不对”的场景因为它可以在每次循环都输出信息而GDB适合“程序异常崩溃、卡住、数据被改”的场景因为它能看到瞬时的内存和变量状态。线上日志要精简、低频复杂问题的排查交给GDB和core文件。我自己写代码的习惯是需要验证某个边界条件时先上GDB等确认逻辑正确后再决定是否保留必要日志。最后补一个小技巧如果你要在一个循环里迭代十万次想在最后一次迭代时停下除了条件断点还有一条更轻量的路——先把断点下在循环体内用ignore 断点编号 次数比如ignore 1 99998让前99998次都直接跳过最后一次正好命中。这个命令用得好节省的不仅是按continue的次数而是整个调试过程的等待时间。