调试 C 程序遇到崩溃或者逻辑不符,绝大多数人第一反应是加打印、看日志、printf 大法走起。但程序大了以后,这种方式成本其实非常高:改代码、重新编译、复现、看输出,一个循环下来几十分钟就没了,而且打日志一不小心还会改掉时序相关的 bug。真到了排查指针问题、内存踩踏、段错误这种硬骨头的时候,GDB (GNU Debugger)才是真正解决问题的工具。这篇文章不是讲 GDB 的 help 手册,而是把我这些年实际调试 C 项目时最常用的命令、最实用的思路、还有踩过的坑,完完整整分享出来。无论你是刚接触 GDB 的新手,还是已经用过一段时间但总觉得不顺手的老手,这篇文章都能给你一些可以直接抄走的经验。
1. 调试前的准备:编译参数决定你能不能调
很多人第一次用 GDB 会遇到一个很尴尬的情况:程序可以启动,但list看不到源码,print变量提示No symbol。这不是 GDB 的问题,是编译时没带调试信息,相当于 GDB 手里只有机器码,没有人名和地名的地图。
1.1 编译时必须加的选项
调试信息靠编译器的-g选项生成。对于 C 语言,编译和链接阶段都要带上:
gcc -g -o myapp main.c utils.c加上-g之后,可执行文件里会多出 DWARF 格式的调试信息,包括源码行号、变量名、变量类型、函数签名、结构体定义等。GDB 靠这些信息才能把地址翻译回源码位置,把内存内容翻译回变量值。
实际工程里还要注意一个坑:优化选项会严重影响调试体验。-O2甚至-O3编译出来的程序,编译器可能对变量做了内联、寄存器分配、常量折叠,导致你用 GDB 看变量时出现 "value optimized out",或者单步执行顺序和源码不一致。所以调试用的构建和发布用的构建应该分开:
- 调试构建:
-g -O0,关闭优化,调试信息最完整 - 发布构建:
-g -O2,保留优化,出现崩溃还能用 core 文件离线分析(后面专门讲)
如果你希望调试更便捷,再加两个选项:
gcc -g -O0 -rdynamic -o myapp main.c-rdynamic会把符号表也导出到动态符号表里,这样即使程序崩了,backtrace也能显示出函数名而不是一串十六进制地址。这个选项在排查崩溃问题时非常关键。
1.2 开启 core dump 才能事后分析
程序崩了之后,光看屏幕上的Segmentation fault是远远不够的。真正的做法是让系统在崩溃时生成 core dump 文件,然后离线用 GDB 分析,不用重新模拟现场。大部分 Linux 系统默认是关闭 core dump 的,打开方式:
ulimit -c unlimited这个命令只在当前 shell 会话生效。想长期生效就写到 shell 配置里。之后运行程序,一旦崩溃,就会在当前目录生成core或core.进程号文件。分析时执行:
gdb ./myapp core这个命令会直接停在崩溃的位置,然后你可以立刻查看当时的调用栈、局部变量、寄存器,效率比现场重新调试高得多。我处理线上问题基本都用这种方式,完全不打扰正在运行的服务,只需要确保系统 dump 功能是开的。
1.3 编译期额外的调试信息细节
-g还分级别:-g1、-g2、-g3。默认-g等同于-g2,包含我们常用的源码、行号、变量信息。-g3还会包含宏定义,这对调试那些依赖宏展开逻辑的问题很有帮助。如果你在代码里遇到这种困扰:
#define MAX_LEN 1024 #define CHECK(ptr) do { if (!ptr) abort(); } while (0)用-g3编译后,GDB 里可以直接p MAX_LEN看到宏的值。用-g2则看不到。所以遇到宏相关的问题,记得用-g3重新编译一次。
2. 启动与断点:让程序停在你想停的地方
GDB 的基本使用路径是:启动程序 → 设断点 → 运行 → 停在断点 → 检查变量和内存 → 单步或继续 → 找到问题。
2.1 三种启动方式
最常规的方式是直接启动:
gdb ./myapp (gdb) run如果程序需要命令行参数:
gdb ./myapp (gdb) run -a --port 8080或者启动时直接带上参数也行:
gdb --args ./myapp -a --port 8080 (gdb) run如果程序已经在运行了,而且怀疑是某个运行中的进程出了问题,可以用附加调试的方式:
gdb -p 12345这样 GDB 会挂到 PID 为 12345 的进程上,然后你可以continue让它继续跑,或者先暂停下来检查状态。注意附加调试需要权限,生产环境最好确认一下 ptrace 权限是否受限。
2.2 断点类型与设置方式
断点是 GDB 的看家本领,但很多新手只会用break 行号。实际工作中,断点有好几种用法,选对了效率翻倍。
按函数名设置断点是最稳定的方式,因为行号会随着代码改动漂移:
(gdb) break process_packet (gdb) b main如果代码有很多同名函数(static 函数在不同文件里可能有同名),可以用文件:函数格式精确指定:
(gdb) break net.c:process_packet (gdb) break utils.c:128还有一种条件断点,这个是你处理循环问题时的大杀器。比如你调试一个遍历链表找特定节点的逻辑,只关心id == 1024的情况,不用手动按 c 几千次:
(gdb) break find_node if id == 1024每到一个位置 GDB 都会判断条件,条件为真才停下来。条件里还可以调用函数,比如if strcmp(name, "hello") == 0,但注意条件表达式里的函数是运行在程序上下文里的,有崩溃风险,慎用。
临时断点tbreak只在第一次命中时生效,适合你不想手动删断点的场景。禁用和启用断点用:
(gdb) disable 1 (gdb) enable 1 (gdb) delete 1我一般习惯在调试会话开始前,用info breakpoints确认一下断点的完整状态,特别是断点命中的次数。GDB 会统计每个断点的 hit 次数,如果设置了断点但一直没有被命中,可能说明代码路径压根没走到这里——这本身就是一个非常有价值的排查线索。
2.3 硬件断点与观察点
GDB 除了普通断点(软件断点),还支持硬件断点和观察点。软件断点的原理是把断点位置的指令替换成一条触发调试器的特殊指令(x86 上通常是 int3),程序执行到这里就会停下来。硬件断点基于 CPU 的调试寄存器实现,不修改代码,在嵌入式调试和只读内存区域调试时更可靠。
观察点(watchpoint)是我强烈推荐深入使用的功能。普通断点是"停在某一行",观察点是"某个变量的值发生变化时停住",这两种思路完全不同。看下面这个例子:
void update_stats(int *stats, int index, int delta) { stats[index] += delta; }如果你怀疑stats[index]被写坏了,但不知道是哪个调用者干的,普通断点很难排查,因为调用点可能很多。观察点可以直接监控整个数组:
(gdb) watch stats程序运行时只要stats这块内存的内容发生变化,GDB 立即停下来,并且显示修改这个值的那条指令。之后你用bt查看调用栈,就能抓到"凶手"。观察点也支持条件化:
(gdb) watch stats[2] if stats[2] > 100注意观察点基于硬件支持的话是免费的,如果硬件不支持或观察的内存过大,GDB 会退化成软件模拟,步进速度会慢到让人怀疑人生。
2.4 断点背后的原理:信号与交互
GDB 和程序之间本质上是通过操作系统的 ptrace 系统调用进行交互的。当你run一个程序时,被调试程序实际上是作为一个子进程运行,GDB 对这个程序有完全的监控权。断点触发时,操作系统会给被调试进程发送 SIGTRAP 信号,GDB 捕获到这个信号后,暂停程序执行,把控制权交回给你。
理解这个机制对排查"奇怪行为"特别有帮助。比如你在 GDB 里跑多线程程序,默认配置下任何一个线程命中断点,整个进程都会停下来。这时候其他线程也会被冻结,你看到的状态是"某一瞬间的快照",而不是动态变化的实时状态。对于有经验的人来说,多线程环境的调试要考虑set scheduler-locking on,让当前线程单独运行,其他线程全部锁住,这样单步时不会跳来跳去。这个命令在分析多线程资源竞争时很有用,稍微提一句,后面还会细说。
3. 运行控制与查看状态:单步、栈帧和变量
断点只是让程序停下来,真正解决问题靠的是停下来之后的信息收集。很多初学者把 GDB 当断点工具用,停下来了却不知道该看什么,最后又回到 printf 大法。这一节讲清楚停下来之后该看什么、怎么看。
3.1 单步执行:next 和 step 的区别
几乎所有教程都会提next和step,但有人分不清。next是"执行当前行,不进入函数内部",step是"执行当前行,如果有函数调用就进到函数内部"。实际操作中我建议你按情况选择:
- 调试主线逻辑,用
next快速推进,避免陷入一层层的函数调用 - 怀疑某函数内部逻辑有问题,用
step进入函数 - 已经进了函数深处想一口气跑完,用
finish执行完当前函数并停到调用处
单步命令还可以带参数:next 5表示连续执行 5 行。until 行号表示继续运行直到指定行号,适合跳出循环体,比如你想执行完当前循环剩下的迭代后停在下一段代码:
(gdb) until 88在循环内部排查时,until比按好多次next高效得多。
3.2 查看调用栈 backtrace
程序执行到当前断点时,GDB 可以用backtrace(缩写bt)查看函数调用链:
(gdb) bt #0 find_node (head=0x602010, id=1024) at list.c:45 #1 0x00000000004005a1 in process_list (data=0x602010) at main.c:122 #2 0x0000000000400550 in main (argc=1, argv=0x7fffffffe318) at main.c:28每一帧就是一个函数调用层。frame 1切换到第 1 帧,也就是process_list的上下文,然后你就能在这个函数里查看它的局部变量:
(gdb) frame 1 (gdb) info locals这条路径极其常用。尤其是 core dump 分析时,bt是第一个命令,先搞清楚崩溃发生在哪个调用链上,再分析局部变量的值是否已经不正常。
3.3 打印变量和内存的进阶技巧
print(缩写p)是最常用的变量查看命令,但很多人不知道它有很多高级用法。直接p 变量名显示变量值。p/x按十六进制显示,p/o八进制,p/t二进制,p/c字符,p/f浮点。看内存地址和指针时,十六进制几乎是必须的:
(gdb) p/x ptr $1 = 0x7fffffffe1e0看指针指向的内容,用*:
(gdb) p *ptr $2 = { value = 42, next = 0x0 }看结构体里的某个字段:
(gdb) p node->left->value看数组的一串元素:
(gdb) p arr[0]@10这个语法表示从arr[0]开始连续打印 10 个元素。排查缓冲区溢出时非常好用,比如你怀疑一个数组越界写了,可以把它前后相邻的内存也打印出来对比。
info locals查看当前函数所有局部变量,info args查看当前函数参数,info registers查看 CPU 寄存器状态。程序崩溃后,寄存器的值经常能直接告诉你问题所在。比如程序跳到了一个非法地址执行,pc(或rip)寄存器的值,就能看出是否出现了栈或堆被写坏导致跳转异常。
查看当前执行位置。list显示断点附近源码,加上行号查看具体某段源码:
(gdb) list 20, 503.4 修改变量:调试器的“悔棋”功能
GDB 不仅能看,还能改。用set命令直接修改变量的值,可以跳过一些繁琐的复现步骤。比如你调试一个解析函数,想看看type == 2时会不会出错,不用改代码重新编译,直接:
(gdb) set type = 2 (gdb) continue程序就会带着type = 2继续运行。甚至可以修改返回地址、跳过某段逻辑。比如set $pc = 0x400560这种直接改程序计数器的做法,属于比较激进的调试手段,但有时候排查特定路径时意外地有效。注意 GDB 里$前缀代表寄存器或 GDB 内置变量,$pc是指令指针寄存器,特别提醒:这属于高级技巧,新手要慎用,操作前务必确认自己知道在干什么。
4. 实战排查:数组越界与空指针段错误
理论讲再多都不如直接看一次实战。这里我用两个最常见的 panic 场景演示完整的排查路径。这两个场景是我帮别人调试时遇到最多的:一个数组越界写入导致后续逻辑全乱,一个是空指针解引用直接崩溃。
4.1 场景一:数组越界写入的完整排查
假设某程序的代码逻辑如下:
#include <stdio.h> #include <string.h> int main() { int a[4] = {1, 2, 3, 4}; int b[4] = {10, 20, 30, 40}; int i; for (i = 0; i <= 4; i++) { b[i] = a[i] * 2; } printf("b[0]=%d\n", b[0]); return 0; }这段代码的问题在于i <= 4,数组b只有 4 个元素,合法下标是 0 到 3。i=4时,b[4]和a[4]都是越界访问。有趣的是,这段代码在有些编译器、有些优化等级下可能不会立刻崩溃,会"默默地"运行完成,但内存里某些值已经被破坏了。
怎么用 GDB 发现它?先在可疑的循环处下断点,然后单步循环,观察i的变化和数组内容:
gdb ./demo (gdb) break demo.c:8 (gdb) run (gdb) print i $1 = 0 (gdb) print a[i] $2 = 1 (gdb) continue ... (gdb) print i $3 = 3 (gdb) print a[4] $4 = 0当i变成 4 时,继续单步,执行b[i] = a[i] * 2这行,然后立刻打印b[3]的值。你会发现b[3]原本应该是a[3] * 2 = 8,但越界写入b[4]的数据很可能覆盖了紧邻的内存区域。在这个例子里,a和b在栈上是相邻的,b[4]实际上就写到了a[0]的位置,把a[0]从 1 改成了 8。这也就是为什么 printf 输出的结果会让人摸不着头脑——你改的是另一个变量,却想不通它为什么变了。
这个例子充分说明了 GDB 另一个极其重要的调试思路:当你发现某个变量值"莫名其妙"变化时,先看它前后左右的内存。很多时候不是这个变量本身被代码写坏了,而是相邻内存被其他越界写操作破坏了。用watch观察变量或内存,配合断点,可以准确定位是哪个语句干的。
4.2 场景二:空指针解引用崩溃分析
空指针解引用最常见的崩溃表现是Segmentation fault。试看这个例子:
#include <stdlib.h> typedef struct node { int id; struct node *next; } node_t; void append(node_t *head, int id) { node_t *cur = head; while (cur->next != NULL) { // 如果 head 是 NULL,这里就崩了 cur = cur->next; } cur->next = malloc(sizeof(node_t)); cur->next->id = id; cur->next->next = NULL; } int main() { node_t *head = NULL; append(head, 100); return 0; }直接运行会直接段错误。用 GDB 启动,它会自动捕获崩溃点:
gdb ./app (gdb) run Program received signal SIGSEGV, Segmentation fault. 0x00000000004005a6 in append (head=0x0) at app.c:9 9 while (cur->next != NULL) {这一行信息量巨大。首先它告诉你崩溃发生在append函数第 9 行,其次告诉你head=0x0。第一件事就是看调用栈:
(gdb) bt #0 0x00000000004005a6 in append (head=0x0) at app.c:9 #1 0x00000000004005f0 in main () at app.c:20定位到main调用append(head, 100)时head还是 NULL。再回到当前帧查看寄存器内容:虽然head已经看到了,但再往深挖,崩溃根本原因是cur也是 NULL:
(gdb) frame 0 (gdb) print cur $1 = (node_t *) 0x0到这里问题已经清清楚楚了:append函数没有处理head == NULL的情况,解引用空指针导致段错误。
4.3 段错误场景下的通用排查套路
从上面的实战可以总结出一套排查段错误的通用步骤:
第一,用 GDB 启动或分析 core,确认崩溃的具体位置和收到信号。第二,用bt查看完整调用栈。第三,切换到崩溃帧(默认就是当前帧),用info locals查看所有局部变量,看哪个指针是 NULL 或非法值。第四,用info registers查看寄存器,特别是pc/rip和sp/rsp,确认是从哪条指令崩的。第五,结合源码和变量值判断是空指针解引用、数组越界写、还是栈溢出、还是访问了已释放的内存。
如果是访问已释放的内存,GDB 看不太出来,因为这块内存可能还在且内容未被覆盖。这时候你可能需要配合 Valgrind 之类的工具来做内存非法访问检测。GDB 解决的是"定位问题"和"观察状态",Valgrind 解决的是"动态追踪内存访问",两者配合,排查内存问题几乎无往不利。
5. 从交互调试到自动化:脚本与效率技巧
GDB 上手之后,你会发现日常调试中很多操作是重复的:启动、设断点、打印某些变量、检查某些条件。这些重复操作完全可以脚本化。用好 GDB 的脚本能力,调试效率可以再上一个台阶。
5.1 .gdbinit 与启动脚本
GDB 启动时会读取~/.gdbinit文件,你可以把通用配置写在这里,省去每次重复输入:
set pagination off set print pretty on set print array-indexes on set history save on set history size 10000set pagination off关闭分页,否则输出一屏就停一下。set print pretty on让结构体打印的时候换行缩进,可读性大幅提升。set print array-indexes on打印数组时带上下标索引。
如果某个项目有固定的调试流程,可以为项目建一个专属脚本文件debug.gdb,然后通过-x参数加载:
gdb -x debug.gdb ./myapp脚本内容示例:
break main break process_packet run -c config.ini info breakpoints continue这样每条命令都会按顺序执行,不用手动一步步敲。批量跑测试用例时可以省不少时间。
5.2 GDB 内置命令的自动化
GDB 支持自定义命令和条件逻辑。用define可以定义一个复合命令:
define show_packet print packet->id print packet->len print packet->data[0]@packet->len end之后调试时输入show_packet就会打印出一个网络包的所有关键字段。如果你经常需要重复查看一个复杂的结构体,这种自定义命令非常节省时间。
GDB 还支持把一系列命令挂到某个断点上,命中断点时自动执行:
(gdb) break net_packet_handler (gdb) commands > silent > print packet->id > continue > end这个配置的效果是:每次命中net_packet_handler断点时不打断运行,只是打印出packet->id然后继续执行。相当于做一个动态的轻量级日志。有些场景下比 printf 大法好用,因为不用改代码、重新编译,直接动态在关键路径上加打印点。
还有一种是 GDB 的 Python 脚本扩展,可以用 Python 编写自定义命令和断点回调,实现更复杂的调试逻辑,比如比较两个断点处某个变量的值、自动汇总多次运行的结果。Python 扩展功能比较强大,适合有脚本基础的开发者。
5.3 常用缩写与键盘操作
GDB 的一大特点是命令可以缩写,只要不产生歧义就行。我日常用得最顺手的几个:
| 完整命令 | 缩写 | 作用 |
|---|---|---|
| break | b | 设置断点 |
| continue | c | 继续运行 |
| next | n | 单步执行(不进入函数) |
| step | s | 单步执行(进入函数) |
| finish | fin | 执行完当前函数 |
| p | 打印变量 | |
| backtrace | bt | 查看调用栈 |
| info locals | i lo | 查看局部变量 |
| list | l | 显示源码 |
| run | r | 运行程序 |
| quit | q | 退出 GDB |
用熟了之后,你甚至可以全程盲敲,左手键盘,右手鼠标,调试速度和编码速度几乎可以同步。
6. 高级调试场景:多线程、fork 与远程调试
日常单线程调试用上面这些命令已经足够,但真实的生产环境基本躲不开多线程、多进程和嵌入式这些场景。这里把高级调试场景的几个核心技巧串一遍,不一定每个都能用上,但真遇到的时候知道有这回事,能少走很多弯路。
6.1 多线程调试:线程视角与锁竞争
GDB 默认支持多线程调试。info threads查看所有线程及其状态,thread 编号切换到某个线程。当一个线程命中断点而其他线程继续跑时,用之前提到的set scheduler-locking on可以锁住其他线程,单步调试当前线程时不会被切走。
锁竞争问题的排查比较头疼。比如程序死锁了,其实不用慌张,GDB 可以直接看每个线程的当前调用栈和锁状态:
(gdb) thread apply all bt这个命令会对所有线程执行bt,一次性输出所有线程的调用栈。死锁场景下,你会看到线程 A 持有锁 1 等待锁 2,线程 B 持有锁 2 等待锁 1,死锁点清清楚楚。
如果程序里大量使用互斥锁,GDB 还可以输出锁的信息,比如用p打印锁变量内容,能看到锁的 owner 线程 ID 和等待队列。配合线程调用栈,锁竞争问题的定位效率很高。
6.2 fork/vfork 多进程调试
调试 fork 出来的子进程时,GDB 需要额外设置。默认情况下,GDB 只跟随父进程,子进程会脱离调试单独运行。想调试子进程,启动前设置:
(gdb) set follow-fork-mode child对于 fork 之后两个进程都想继续调试的场景,可以使用:
(gdb) set detach-on-fork on (gdb) set follow-fork-mode parent这表示父进程继续被调试,子进程独立运行。如果你同时想调试两个进程,需要用到 GDB 的多会话功能,或者更常见的做法是给子进程设置断点后,让 GDB 跟随子进程。
这个场景在调试服务端 fork 模型时比较常见。我曾经排查过一个子进程崩溃的问题,就是因为默认跟随父进程,子进程一崩就在系统层面直接退出,GDB 怎么都看不到崩溃现场。改成set follow-fork-mode child之后,崩溃现场直接浮出水面,问题很快就定位了。
6.3 远程调试与嵌入式
GDB 的设计非常经典的一点是支持远程调试。调试的目标程序运行在另一个机器(比如嵌入式开发板、ARM 设备)上,而 GDB 运行在开发机上,两边通过 gdbserver 通信:
目标机上运行:
gdbserver :2345 ./myapp开发机上运行:
gdb ./myapp (gdb) target remote 192.168.1.100:2345这样程序在目标机上运行,所有断点、单步、查看变量的操作都在开发机上完成。对嵌入式开发来说这是标配调试方式。因为 gdbserver 很轻量,目标机上不需要完整安装 GDB,只需一个 gdbserver 可执行文件就行。交叉编译时注意 GDB 和 gdbserver 的架构要匹配,比如目标机是 ARM,就用 arm-linux-gnueabi-gdb 和对应的 gdbserver。
6.4 调试共享库和动态链接问题
有时候崩溃或逻辑错误发生在共享库里。GDB 加载共享库的调试符号需要满足两个条件:共享库编译时带了-g,且没有被 strip 掉符号。如果共享库有调试符号,bt会直接显示共享库内的函数名和源码行号。如果符号不完整,显示的是一堆??,可以先检查是否应该用带符号的版本重新编译,或检查是否需要对共享库执行symbol-file手动加载符号文件。
动态链接的问题也经常遇到:程序启动时报undefined symbol或cannot open shared object file。GDB 里运行这类程序时,可以用set env LD_LIBRARY_PATH设置库路径,还可以用info sharedlibrary查看所有已加载的共享库及其加载地址。如果某共享库加载失败,GDB 会直接标出来。
7. 调试经验总结与避坑指南
最后聊一些我在真实工程里总结出来的调试经验和教训。这些内容不适合写进官方文档,但是真正干过活的人才会意识到它们有多重要。
7.1 能离线分析就不要在线调试
线上服务出问题,第一反应不能是马上 GDB 附加到进程上。那样做一方面容易影响线上服务的稳定性,另一方面也未必能等到问题再次出现。正确的姿势是:确保 core dump 开启,等程序崩溃后,用 core 文件做离线分析。gdb 可执行文件 core进去之后,bt、info locals、info registers这些操作和在线调试完全一样,而且更安全。注意 core 文件要保留好,不要因为"只是个崩溃文件"就随手清理了。
调试信息对不上时,core 文件分析会受限。所以发出问题的可执行文件和 core 文件要一起保存,对应编译源码的版本也要记录好,用 git tag 或者 commit 哈希标注。我就见过有人拿着旧版本的 core 配合新版本的源码去分析,函数名对不上的情况不在少数。
7.2 不要盲目加 printf,先想清楚假设
printf 大法不是不能用,但是要有章法。我见过太多人哪里怀疑就加一个打印,结果到处都是输出,就像开卷考试翻答案一样毫无头绪。你真正应该做的,是先明确一个假设:我觉得是这一步出了问题,然后基于假设设计一个最小验证。如果假设不成立,换下一个假设。GDB 的好处在于它可以随时停下来看中间状态,不用反复改代码。它让你以"假设-验证"的方式排查,而不是漫无目的地打日志碰运气。
7.3 优化符号与内联函数
调试 Release 版本时,即使带了-g,-O2下许多函数会被内联,bt里看不到这些函数的帧。这时可以尝试set debug inline或重新编译一个-O1的版本,损失一些优化但保留更多调试信息。还有一种方式是-fno-omit-frame-pointer,保留栈帧指针,让调用栈分析更准确。发布构建和调试构建分开管理,不要把符号信息和优化选项混在一起调。
7.4 GDB 容易让人迷失的时刻
GDB 信息量很大,尤其是打印复杂结构体和多线程状态时,很容易迷失。我的建议是:调试之前先想清楚你要找什么。一个调试会话尽量聚焦一个问题,不要东看一眼西看一眼。每收集到一个关键事实,就问自己:这个事实支持哪个假设、排除哪个假设。做到这一步,GDB 才不是"调试器",而是你的"证据收集器"。
我个人在调试中最常用的一条路径是:bt看调用栈 →frame切到崩溃帧 →info locals看局部变量 → 如果变量值可疑,用watch或条件断点找到修改它的源头 → 用print对比修改前后的值 → 修复代码 → 用 GDB 重新跑一遍验证。反复跑几轮之后,你对程序状态的变化会形成直觉,以后看到报错几乎能猜到大概原因。这种直觉不是天生的,完全是从一次次 GDB 会话里积累起来的。
调试本身也是一项工程能力,值得认真对待细细打磨。把 GDB 用得越熟,你在面对诡异问题时就越从容。希望这篇文章能帮你把 GDB 内化成自己的调试习惯,而不是停留在"听说过但没用过"的层面。