我干了十几年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 *arr@len打印数组开头len个元素。display 变量名:每次单步之后自动刷新显示,适合反复观察某个变量。info locals:查看当前栈帧内所有局部变量。info args:查看当前函数的所有参数。
查看指针时,直接p ptr只会显示地址,可能误导你;想看指针指向的数据,要p *ptr。数组也类似,虽然p arr对数组名可以打印整个数组,但只要是从堆里申请的动态内存,就得用p *arr@n来打印指定长度。
还有一个容易被忽略的调试手段: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 (data=0x0) 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文件生成后,进入GDB:
gdb ./demo_null ./core进去先bt:
(gdb) bt #0 0x00005555555551a5 in process_input (data=0x0) at demo_null.c:5 #1 0x000055555555521e in main () at demo_null.c:14看到data=0x0,说明在process_input里收到了空指针。接着frame 0切换到最内层栈帧,再info locals确认局部变量,然后list查看源码:
(gdb) frame 0 #0 0x00005555555551a5 in process_input (data=0x0) 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*)&var |
| core文件没生成 | 崩溃后没有core文件 | ulimit -c为0 | 执行ulimit -c unlimited |
| attach失败 | ptrace: Operation not permitted | 内核yama限制 | 用sudo,或临时修改ptrace_scope |
| 源码显示乱码 | 中文注释显示乱码 | 文件编码与GDB默认不一致 | 源码统一为UTF-8,设置set charset UTF-8 |
| 动态库找不到符号 | 断点加载失败 | .so没有调试符号或路径不对 | 使用带debug的so,设置set solib-search-path |
5.2 容易忽略的坑
第一个坑:GDB加载动态库(.so)时,如果库路径或符号不对,会出现断点无法设置的情况。这时候要用set solib-search-path /path/to/libs指定查找目录,确保GDB能找到带符号的库。线上依赖的库经常不在标准路径,这一步不能省。
第二个坑:编译器的优化不仅影响变量显示,还可能导致断点位置和源码行号错位。-O2 -g下设置在某一行的断点,实际可能停在相邻几行。调试复杂问题建议用无优化环境;如果必须在优化环境下调试,可以用汇编级断点break *地址,再配合disassemble看实际汇编。
第三个坑:多线程调试时,操作顺序会被GDB影响。你在调试时,虽然GDB暂停了当前线程,但其他线程可能还在运行,导致实际执行顺序与线上不一致。用set scheduler-locking on能减少这种干扰。
第四个坑:忘记环境变量。程序运行依赖某些export XXX=1,但你只在终端里跑的时候设了,进了GDB却没有,于是行为不一致。可以用set env XXX=1在GDB里设置环境变量,或者用gdb --args ./myprog arg1 arg2统一配置启动参数。
5.3 我的一个日常习惯
调试日志和GDB,不是二选一。printf调试适合“程序在正常运行但结果不对”的场景,因为它可以在每次循环都输出信息;而GDB适合“程序异常崩溃、卡住、数据被改”的场景,因为它能看到瞬时的内存和变量状态。线上日志要精简、低频,复杂问题的排查交给GDB和core文件。我自己写代码的习惯是,需要验证某个边界条件时先上GDB,等确认逻辑正确后,再决定是否保留必要日志。
最后补一个小技巧:如果你要在一个循环里迭代十万次,想在最后一次迭代时停下,除了条件断点,还有一条更轻量的路——先把断点下在循环体内,用ignore 断点编号 次数,比如ignore 1 99998,让前99998次都直接跳过,最后一次正好命中。这个命令用得好,节省的不仅是按continue的次数,而是整个调试过程的等待时间。