资讯动态

GDB 调试 C 程序实战指南:从断点到 core dump 的完整排查思路

发布时间:2026/10/22 11:20:14 来源:尧图企业网站定制
调试 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 (head0x602010, id1024) at list.c:45 #1 0x00000000004005a1 in process_list (data0x602010) at main.c:122 #2 0x0000000000400550 in main (argc1, argv0x7fffffffe318) 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。i4时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 (head0x0) at app.c:9 9 while (cur-next ! NULL) {这一行信息量巨大。首先它告诉你崩溃发生在append函数第 9 行其次告诉你head0x0。第一件事就是看调用栈(gdb) bt #0 0x00000000004005a6 in append (head0x0) 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 的一大特点是命令可以缩写只要不产生歧义就行。我日常用得最顺手的几个完整命令缩写作用breakb设置断点continuec继续运行nextn单步执行不进入函数steps单步执行进入函数finishfin执行完当前函数printp打印变量backtracebt查看调用栈info localsi lo查看局部变量listl显示源码runr运行程序quitq退出 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 内化成自己的调试习惯而不是停留在听说过但没用过的层面。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑