☰
C/C++调试实战:用GDB快速定位崩溃、死锁与内存问题
2026/10/17 6:21:41 网站建设 项目流程

写了几年C/C++,我越来越觉得,调试能力和写代码能力完全是两码事。很多人写代码飞快,一遇到段错误就开始瞎猜,加printf、注释代码、重启程序,折腾几个小时也不一定定位到问题。其实大多数崩溃、死锁、内存越界,用GDB老老实实走一遍,几分钟就能看到答案。

这篇内容就是我自己日常用GDB的完整沉淀,从编译选项到断点玩法,从core dump分析到多线程死锁排查,全部按实操场景来写。适合刚入门C/C++调试的开发者,也适合那些用了好久GDB但只会break、next、print三板斧的人。看完你应该能独立处理工作中八成以上的疑难问题。

1. 调试前的准备工作:编译选项与启动方式

1.1 编译选项:-g 不是唯一的答案

很多人以为调试只要加个-g就行,其实这里面细节不少。我见过不少同事在release分支上编译生产代码,用-O2 -g,结果调试时变量全是<optimized out>,单步也跳得乱七八糟,第一个反应就是"GDB烂"。真不是GDB的问题,是编译选项的配合问题。

先讲清楚基础。GCC加-g会生成DWARF调试信息,这是GDB能映射机器指令和源码行、变量名的根本。要注意的是:

  • -g:生成基础调试信息,够用。
  • -g3:额外包含宏定义等扩展信息,可以用info macro查看宏展开,排查宏相关Bug很管用。
  • -ggdb:生成面向GDB的调试信息,体积更大,一般不需要。
  • 配合-O0:关闭优化,变量值基本都能看到,单步行为符合源码顺序。
  • 配合-O2:开启优化,变量可能被优化掉、代码重排、函数内联,调试体验会打折扣。

我自己的习惯是:日常开发和自测用-g -O0,排查问题最快;如果怀疑是优化相关Bug,或者要查release崩溃,那就用-g -O2编一份带调试符号的版本,虽然有些变量看不了,但至少崩溃栈是准的。还需要强调一点:-g和优化等级并不冲突,生产环境用-O2 -g编译并保留符号文件,是业界非常常见的做法,既不拖慢线上速度,又能支撑事后分析。

还有个容易踩的坑:只加了-g但忘记关strip。很多构建脚本在打包阶段会执行strip删掉符号,导致GDB提示No symbol table is loaded。遇到这种情况先检查二进制本身,别急着怪GDB。

提示:如果程序用了动态库,编库时也要加-g,否则进库函数就只能看到汇编,看不到源码和变量。我自己就吃过这个亏,查了半天以为自己断点打错了。

1.2 三种启动调试的方式

GDB支持三种启动方式,适用场景完全不同:

第一种:直接启动未运行的程序

gdb ./my_program gdb ./my_program arg1 arg2

如果程序需要传很多参数,或者带了环境变量,可以不用在命令行写,进GDB后用set args设置:

(gdb) set args --input /data/file.txt --verbose (gdb) set env LD_LIBRARY_PATH=/opt/mylib:$LD_LIBRARY_PATH (gdb) run

run不带参数会用set args里设置的内容,这点比每次敲完整命令行方便太多。

第二种:用core文件做崩溃后分析

gdb ./my_program core.12345

这种方式最大的价值在于"事发现场重建"。程序崩了,内核把内存镜像写到core文件里,GDB加载后能直接告诉你崩溃在哪个函数、当时的调用栈是什么、局部变量值是什么。注意:core文件必须和同一个二进制的崩溃现场匹配,最好用当时线上跑的那份二进制(或完全一致的构建产物),否则符号对不上,栈可能全是错的。

第三种:附加到正在运行的进程

(gdb) attach 12345

进程PID可以用pidof my_program查。附加常用于线上问题、死循环、假死类问题。这里有两个硬性前提:一是系统允许ptrace,Linux下要检查/proc/sys/kernel/yama/ptrace_scope,为1时只能attach子进程,有些发行版默认就是1,需要适当调整(自己开发机改成0没问题);二是权限要够,通常需要和进程相同用户或root。

很多新手不知道,GDB里可以直接用gdb -p 12345,等价于启动后执行attach 12345。调试完记得先detach再退出,避免把人家线上进程直接杀掉。

2. 断点的正确玩法:从基础到条件断点

2.1 基础断点的几种设置方式

break命令看似简单,但不同写法适用不同场景:

(gdb) break main # 按函数名下断点,最简单 (gdb) break foo.c:42 # 按文件+行号,适合定位具体代码行 (gdb) break foo.c:42 if x > 5 # 行号+条件 (gdb) break *0x40123c # 按地址下断点,汇编调试或者无符号时用 (gdb) tbreak # 临时断点,命中一次后自动删除 (gdb) rbreak foo_* # 正则匹配,给一批函数下断点

我特别想说一下tbreak的场景。有时候你只是想快速跳过某段逻辑,但不确定这段逻辑会不会被多次执行,用普通断点就会反复停住。tbreak一次命中即消失,非常适合"我就想看一眼第一次执行到这里的状态"这种需求。

rbreak也是好东西。比如怀疑某个模块里所有foo_开头的函数都有问题,一条命令全打上断点,然后continue挨个看,比自己手动下十个断点快得多。

2.2 条件断点与命令组:让断点自动化

条件断点是排查循环类Bug的大杀器。比如一个for循环跑一万次,第5234次才出错,你手动按continue会按疯掉。正确做法:

(gdb) break loop_func (gdb) condition 1 i == 5234 (gdb) run

这里1是断点编号。更细的还有ignore:

(gdb) ignore 1 5233

含义是跳过前5233次命中,第5234次才停下。用ignore比大型条件表达式快,因为它不需要每轮循环都执行条件判断。在极端热循环里,这个性能差异还是能感觉到的。

断点还可以绑定命令组,命中后自动执行一串GDB命令:

(gdb) break foo.c:88 (gdb) commands 2 > printf "x=%d, y=%d\n", x, y > bt 3 > continue > end

这段的意思是:每次命中2号断点,先把x、y和栈顶三层打出来,然后自动继续跑。这就是"GDB版printf",但比写死代码再重新编译靠谱得多。我排查线上问题时经常先来一套这种组合拳,不用改代码、不用重新发布,直接就观察现场。

条件断点还有个容易踩的坑:条件表达式里引用不存在的变量,GDB不会报错,而是把断点变成"无条件断点",每次都停。排查时如果发现断点"不听话",先info breakpoints看条件列是否正常,再用cond重新设置。

2.3 观察点 watch:追踪变量的每一次变化

有时候你根本不知道变量什么时候被改的,只知道值不对。这时候靠断点很被动,观察点(watchpoint)才是正解。

(gdb) watch x # 写观察,x 每次被改都会停 (gdb) watch -l x # 按内存地址观察,解决局部变量失效问题 (gdb) rwatch x # 读观察,每次读取 x 都会停 (gdb) awatch x # 读写都观察

我印象最深的一次是排查一个全局配置被意外篡改的问题。那是个结构体,某天运行到一半某个字段变成乱七八糟的值,根本不知道谁干的。我用watch盯住那个字段,第一次停下看栈,发现是某个模块越界写,把旁边内存覆盖了。这种问题用通读代码的方式去查,没个一天下不来。

观察点有两个硬约束需要记住:

  • 观察点依赖硬件调试寄存器,数量有限,x86上一般是4个左右,同时watch太多变量会报错。
  • 局部变量的观察点在函数退出后自动失效。如果变量在栈上,函数返回后内存会被复用,watch就无意义了。有时需要watch -l按地址观察,但同样要注意地址失效问题。

3. 单步调试与控制流的核心命令

3.1 next、step、finish、until:怎么走才是对的

单步命令看着简单,但很多人从没用对过:

  • next:执行到当前源码行的下一行,如果这行有函数调用,整个函数一口气执行完,不进入函数体。
  • step:执行到下一行,但遇到函数调用会进入函数体,在函数里逐步执行。
  • stepi:按机器指令单步,进入汇编级别,调试无符号代码或者看编译器生成代码时用。
  • finish:一直执行到当前函数返回,然后停下来,适合你已经看完函数前半段、想快速跳出时用。
  • until:执行到指定的源码行;不带参数时,相当于"一直执行到跳出当前循环",在循环里用非常顺手。
  • return:不等函数执行完,直接强制返回,可以指定返回值,适合跳过一段已知有问题的逻辑继续往下走。

我举个例子说明until的妙用。比如一个循环体1000行,你看到第500行时想快速跳到循环结束,按next或者continue都不合适——next会一行行走完整个循环,continue会一路执行到下一个断点。这时候until不带参数,GDB会一直执行到当前循环体退出,一次到位。

finish也有个经典用法:调用一个复杂函数后返回值不对,你可以用step进入函数看内部逻辑,但只看了开头就想出去,finish直接跑完整个函数并停在调用处,返回值一目了然。

3.2 print 命令的花样玩法

print是调试里最高频的命令,但大多数人的用法只开发了它两成功力:

(gdb) print x # 十进制打印 (gdb) print /x x # 十六进制,排查地址和标志位必须 (gdb) print /d x # 强制十进制 (gdb) print /s p # 把指针当字符串打印 (gdb) print *arr@10 # 连续打印数组前10个元素,arr是指针也能用 (gdb) print &arr[0] # 打印地址 (gdb) print x == y # 直接算表达式,甚至可以调用函数

@语法特别实用。你有个指针p,它指向一块分配了100个int的内存,直接print *p只看得到第一个元素,写print *p@100全部打出来。排查越界写时,我经常把相邻区域也打出来对比。

另外print还能调用程序里的函数,比如print my_debug_helper(p)。不过有两点必须提醒:一是调用的函数会真的在调试进程里执行,如果函数本身有副作用或者可能崩溃,会污染现场;二是在没有符号时没法调用。

如果每走一步都想看某个变量,不用每次都敲print,用display:

(gdb) display x (gdb) display /x *p@10

之后每次单步,GDB会自动把这些表达式打出来,和IDE里的"监视窗口"一样。等价的info display看已注册的表达式,undisplay 编号删除。

再说一下GDB的变量操作。调试时可以临时改值,比如想测试某个异常分支:

(gdb) set x = 0 (gdb) set var x = 0

改动之后程序继续跑就会用到新值。这个能力非常强大,但也容易把自己坑了——我见过有人在调试时改坏数据,导致后面一连串误判。改值前先想清楚:这个变量的new值会不会被后续代码继续使用。

3.3 多线程调试:锁定调度与线程视图

多线程程序是GDB调试里真正的重灾区。GDB默认在断点命中时会停止所有线程,但你在一个线程里单步时,其他线程可能还在跑,这会导致你观察的状态是动态变化的,非常容易误判。

关键命令:

(gdb) info threads # 列出所有线程,带编号和当前栈顶 (gdb) thread 3 # 切换到3号线程 (gdb) thread apply 2 3 bt # 对2、3号线程都执行bt (gdb) thread apply all bt # 所有线程打印调用栈,死锁排查第一招 (gdb) set scheduler-locking on # 打开调度锁

set scheduler-locking on是单步多线程程序时最重要的设置。它会确保你单步当前线程时,其他线程全部冻结,这样观察到的任何全局状态都是确定性的。排查完记得set scheduler-locking off恢复。

死锁场景我一般上来就是thread apply all bt,把每个线程当前栈打了,看谁在等锁、锁被谁持有,基本就清楚了。后面第6节我会用一个实际例子完整走一遍。

另一个容易忽略的点是:断点在多线程环境里可能被多个线程同时命中。如果不想在无关线程停下,断点条件里可以加线程号:

(gdb) break foo.c:66 thread 3

这个断点只在3号线程执行到该行时停下来,非常省事。

4. 崩溃现场重建:core dump 分析全流程

4.1 开启 core dump 的正确姿势

Linux下core文件默认经常是关闭的。检查方式:

ulimit -c

输出0就是关着的。临时打开:

ulimit -c unlimited

注意这是shell的内建命令,只对当前shell和它的子进程生效。想永久生效要写到/etc/security/limits.conf或者profile里,但这属于环境配置,不同系统差别大,我只强调一点:开发机随手设成unlimited不亏,因为崩溃分析省下的时间远超那点磁盘。

core文件往哪写,由内核参数决定:

cat /proc/sys/kernel/core_pattern

很多系统默认是core,表示写到当前工作目录,文件名就叫core。也有系统带管道符号,比如|/usr/share/apport/apport,core会被交给外部程序处理,这时你可能在当前目录找不到core,需要去系统日志里看落到了哪。我自己一般会把开发机设成core.%e.%p,这样文件名带了程序名和PID,不容易搞混多份core。

注意:除了内核开关,程序自身也可能通过setrlimit关闭core生成。还有,当进程的工作目录不可写时,core也生成不了。如果你死活等不到core文件,先把这两个方向查了。

4.2 从 core 文件定位段错误的核心步骤

拿到core之后,最经典的操作是:

gdb ./my_program /path/to/core

进去之后第一件事,GDB通常会直接显示崩溃信号和地址,比如SIGSEGV。然后我固定执行两板斧:

(gdb) bt (gdb) frame 0

bt是backtrace,打出从崩溃点到main的完整调用链。frame 0切到最内层栈帧,再看局部变量:

(gdb) info locals (gdb) print variable_name

这里我要强调:崩溃栈的最外层,也就是frame 0,是"案发第一现场",但变量可能已经被破坏。经常需要往上翻几帧看调用者的数据,对比才能判断是"调用方传了错参数"还是"被调方自己写崩了"。我会在bt之后再frame 1、frame 2各看一遍,把关键参数的值记下来,细心的开发者很快就能发现端倪。

还有一个高频问题:段错误发生在库函数里,比如memcpy。单独看这一帧没什么线索,真正的Bug往往在调用方——传了非法指针、长度不对、目标缓冲区太小。所以遇到libc库函数崩溃,先别纠结库内部,直接往上翻一到两帧看调用参数。

定位后如果要看崩溃附近的汇编和寄存器,用:

(gdb) x/10i $pc-16 # 反汇编崩溃点附近指令 (gdb) info registers # 查看寄存器值 (gdb) x/gx $rsp # 看栈上数据

对于段错误,$pc指向的指令往往能直接说明问题:如果是访问某个非法地址,指令里能看出来;如果是call到非法地址,说明函数指针坏了。这套组合在无符号环境下尤其重要。

4.3 优化编译下的崩溃怎么查

工作上的崩溃绝大多数发生在release环境,也就是-O2编译。这种core用GDB打开后,bt经常有告警"Backtrace stopped: previous frame inner to this frame",而且变量大量显示<optimized out>。这不是GDB坏了,是编译器把代码重新编排了,源码映射关系变得不连续。

这种情况我的建议是:

  1. 尽量用与线上同版本的源代码和构建配置重新编一份带-g的二进制,但别用-O2 -g覆盖线上,单独编一份分析用。虽然性能差异不影响崩溃分析,符号信息是准的。
  2. 优先相信汇编。bt显示的函数名即使乱,汇编栈上的返回地址是真实存在的。用x/a $rsp手工读栈也可以找回部分调用链。
  3. 如果内存布局被优化破坏了,但core里寄存器还在,info registers加上反汇编,配合存的源码,仍然能拼出大致现场。

说句实在话,优化模式下有些变量就是拿不回来,这一点GDB做得也确实不如一些商业调试器。但优化崩溃的核心诉求是"知道崩在哪个函数、顺着调用链找到调用者",这一点打开符号的优化版还是能做到的。真到了完全看不到栈的地步,就要考虑在线上代码里加日志、或者在关键函数入口/出口埋点重新发布,这属于另一个话题了。

5. 进阶玩法:TUI 界面、调试脚本与远程调试

5.1 TUI 模式:不用 IDE 也能看源码

很多人不知道GDB自带了半图形化界面TUI(Text User Interface),用法是在GDB里执行:

(gdb) layout src (gdb) layout asm (gdb) layout split

也支持启动时直接gdb -tui ./my_program。TUI模式下,窗口上半部分显示当前源码或汇编,下半部分是命令交互区,单步时上面的高亮行会跟着走,体验比纯命令行好太多。常用快捷键:

  • Ctrl-x a:退出/进入TUI。
  • Ctrl-x 1:单窗口布局。
  • Ctrl-x 2:双窗口布局(源码+汇编)。
  • Ctrl-l:重绘屏幕,窗口花屏时救命的。

TUI偶尔会出现花屏或者输入错乱,终端宽度不够也会导致排版崩坏。真遇到就按Ctrl-l重绘;再不行就退出TUI回到纯命令行,功能一点都不损失。

5.2 GDB 脚本与自定义命令

GDB支持启动时自动读取.gdbinit文件,里面可以放常用配置。我个人的.gdbinit大概是这样的:

set pagination off set print pretty on set print array-indexes on set print elements 100 set print repeats 32 handle SIGPIPE nostop noprint

逐个解释下:

  • set pagination off:输出长列表时不翻页,方便日志保存。刚学时翻页经常卡得不知所措。
  • set print pretty:结构体按字段格式化打印,不然全挤在一行里根本看不清嵌套。
  • set print elements:限制打印字符串/数组长度,防止超大数组把终端刷爆。
  • set print repeats:数组重复元素折叠显示。
  • handle SIGPIPE nostop noprint:忽略SIGPIPE信号,程序写管道时很常见,没人希望调试时总是中断在这里。

.gdbinit还可以定义自定义命令,相当于给GDB加宏。举个例子,我想快速打印一个指针指向的字符串数组:

define ps if $argc == 1 print *(char**)$arg0@10 else printf "usage: ps <ptr>\n" end end document ps Print next 10 string pointers from an array. end

这里有个关键语法点:$arg0代表第一个参数,$argc是参数个数。定义好之后,ps arr一下就能打印数组里的多个字符串指针,比反复敲print arr[0]、print arr[1]高效得多。

复杂的调试流程也可以直接写脚本文件,在GDB里source debug.cmd加载执行。我把排查崩溃的整套命令就放到一个脚本里,拿到core后一条source全跑完,几句输出就能定位七八成的问题。

5.3 附加运行中进程与远程调试

附加运行中进程前面提到过,这里补充一个实践场景:程序假死或者死循环,但没崩。先用gdb -p PID附加,然后thread apply all bt看每个线程在哪。如果是死循环,栈顶通常停在某几个函数里反复转;如果是死锁,会有多个线程同时卡在pthread_mutex_lock或者条件变量等待上。定位后detach退出,程序继续跑,线上影响很小。

远程调试也是工作里经常用到的。目标机上跑gdbserver:

gdbserver :1234 ./my_program

开发机上用:

gdb ./my_program (gdb) target remote 目标机IP:1234

这样就能像调试本机程序一样调试远端程序,尤其适合嵌入式、容器里不方便交互的环境。有一点要留意:gdbserver方式加载的是开发机上的本地符号,所以本地的二进制和远端必须一致,否则行号对不上。

远程调试另一个常见场景是调试启动即崩溃的程序。目标机上直接gdbserver启动,程序会在启动早期就停下来,GDB连上后再continue,可以观察到崩溃前所有状态,这比改代码加日志快得多。

6. 常见问题与排查技巧实录

6.1 调试符号加载不上的几种原因

调试中最常见的报错是No symbol table is loaded或者Breakpoint 1 failed。我总结下来就那么几个原因,按概率排:

  1. 编译时没加-g,或者加了之后被strip了。用file 二进制可以看出来是否带debug info。
  2. 程序用了动态库,GDB没找到库的符号文件。用set solib-search-path指定库路径,或directory加源码路径。
  3. 二进制和源码路径不一致。代码在别的机器/目录编译的,GDB找不到源码文件,这时用directory /path/to/src把源码路径指过去。

判断依据很简单,info sources会列出GDB已知的源文件,啥也没有就是符号没加载。

6.2 优化代码跳来跳去怎么破

-O2下断点的最大痛点是:你打在某一行的断点,GDB会把它映射到最近的可断点指令上,于是断点命中位置和源码行明显对不上。更烦的是有些行被完全消除了,断点直接无效。

给几个实用建议:

  • 如果只是临时分析问题,用-O0 -g编一份,调试完再换回正常版本。同一份源码不可能同时在跑两个版本,这是最省心的方案。
  • 想在生产版本里保留可调试性,可以用-O1 -g折中。-O1的优化破坏性比-O2小,很多变量还保留着。
  • 针对特定函数抑制优化:把要调试的函数单独放到一个编译单元里,用__attribute__((optimize("O0")))标记,但这种方式在不同编译器上兼容性一般,最好只在调试版本里临时用。
  • 真遇到优化到看不了变量,就开汇编窗口,用layout asm配合stepi走指令级,再结合寄存器值判断逻辑。

记住一个核心原则:-O2调试时,看到多少算多少,不用强求每个变量都有值。关键是调用链和指令级别行为,这两样在带-g的优化版本里依然可靠。

6.3 死锁定位实例:线程栈与信号

最后用一个我实际排查过的典型场景带大家串一遍。现象:程序跑着跑着完全卡死,不崩溃、不退出,top看CPU占用几乎为零。这种表现十有八九是死锁或者条件变量等待。

操作流程:

(gdb) attach <pid> (gdb) thread apply all bt

输出大概长这样:

Thread 3 (Thread 0x7f3a:1): #0 __lll_lock_wait () at .../lowlevellock.c #1 __pthread_mutex_lock (mutex=0x7f3a...) #2 worker_thread (arg=0x...) at worker.c:88 #3 start_thread () Thread 2 (Thread 0x7f3b:1): #0 __lll_lock_wait () #1 __pthread_mutex_lock (mutex=0x7f3a...) #2 consumer_thread (arg=0x...) at consumer.c:142 #3 start_thread ()

两个线程都卡在__pthread_mutex_lock上,再往下看锁地址。这里有个实用技巧:mutex=0x7f3a...这个地址,同一个锁在多份输出里地址是一样的。我把两个线程对应的锁地址一对,发现一致,说明它们都在等同一把锁。再看谁持有这把锁?需要一个没在等待的线程,继续往下翻thread apply all bt的完整输出,找找有没有线程正持有相关资源在跑别的操作。如果所有线程都在等锁,而没有一个线程持有锁在推进,典型死锁确认。

确认之后按上面步骤回到代码,看worker.c:88和consumer.c:142两处加锁的顺序是不是配对的。经典死锁往往是两个线程以相反顺序锁两把锁,GDB的栈信息能直接提示哪里出了问题。

附加在死锁现场还有个额外好处:程序还活着,数据都在内存里。你可以切到任意线程用frame、print看业务数据,判断它们是卡在某批数据上还是各种状态异常。这一步往往能直接揭示死锁触发条件,帮助复现。

提示:排查完死锁,别忘了detach让程序退出,别一直占着进程。有些公司还有专门的调试环境策略,现场保留时间有限,有机会就先把bt输出重定向到文件存下来:

(gdb) set logging file /tmp/deadlock_bt.txt (gdb) set logging enabled on (gdb) thread apply all bt (gdb) set logging enabled off

留好现场,后面慢慢分析。

结尾

用GDB这几年,最大的体会是:调试本身是有方法论的,不是瞎试。拿到一个问题,先判断是崩溃、卡死还是逻辑错误,然后选择对应的调试路线——崩溃走core分析,卡死走线程栈,逻辑错误走断点加单步。把路线定清楚了,大部分问题都能一步步缩小范围,最终落到具体代码行。

最后再分享一个小技巧:GDB的命令支持Tab补全和缩写,比如b就是break,c是continue,n是next,p是print,bt是backtrace。刚学时记全称没问题,但用熟了之后全用缩写,手速可以快不少。这对那些需要在现场快速操作的场景特别有用——很多时候,早一分钟拿到现场,就少复现一次Bug。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询