简介:GDB(GNU调试器)是最常用的命令行调试工具之一,这份PPT面向C/C++、Fortran及汇编程序开发者,重点解决程序调试中“如何逐行跟踪、定位崩溃点、检查变量与调用栈”等实际问题。内容从基础流程讲起,包括编译时添加-g参数、用gdb加载可执行文件、设置断点、使用run/step/next/continue控制执行,以及用print/display查看变量,并进一步覆盖观察点、捕捉点、信号处理、线程中断、条件断点、内存查看和backtrace调用栈分析等高级技巧,配有命令示例与参数说明,适合刚接触GDB的初学者快速上手,也适合日常查询常用调试命令的开发者参考。资源包共1个文件,为PDF格式,大小2.34MB,便携易读;发布至今已有117人学习下载,是理解GDB单步调试全流程的一份精炼资料。
1. GDB 单步调试:从命令清单到能跑通的最小流程
排查段错误还在用 printf 大法?稍微复杂一点的 C/C++ 工程,printf 定位问题的成本会随代码量指数级上升。GDB 单步调试的价值在于:它让你直接看到程序在哪一行停住、变量在哪个赋值语句之后变了、调用栈是从哪里一路跌到崩溃点的。这份文档把 GDB 常用命令串成了一条完整调试流程——从 gcc -g 编译、gdb 启动、断点设置,到 next/step 单步、print 查变量、bt 看栈,全部覆盖,还附了可运行的 tst.c 演示程序。适合刚接触命令行调试的 C/C++ 开发者、准备复试的考研学生,以及从 IDE 调试器转向命令行工具的嵌入式工程师。
2. 编译与启动:-g 参数是入门的第一道坎
2.1 编译阶段:没有调试信息,GDB 看到的全是内存地址
GDB 调试的第一前提,是目标文件里包含符号表和源码行号映射。这一步由编译器的 -g 选项搞定:
# 编译 C 代码 gcc -g tst.c -o tst # 编译 C++ 代码 g++ -g main.cpp -o main不加 -g 也能启动 gdb,但进去之后 list 看不到源码,break 函数名会提示找不到符号,print 变量会直接报错。你面对的是一个黑匣子:所有函数名、变量名都变成了一串十六进制地址,调试体验约等于反汇编。这里有个容易被忽略的细节:-g 只是把调试信息写进目标文件,不会改变程序的实际行为,也不影响代码优化级别。我见过不少人在 Makefile 里只写了 -g 没写 -O0,结果断点设在第 10 行,程序却停在第 12 行——这是因为编译器把几行代码合并优化掉了。所以本地调试的标准配置是 -g -O0:
gcc -g -O0 tst.c -o tst-O0 告诉编译器不要做任何优化,源码行号和机器指令严格一一对应,单步调试才谈得上精准。如果想看宏定义,用 -g3;如果用的 GCC 版本较新,可以指定 -gdwarf-4 或 -g3 来获得更丰富的调试信息格式。
提示:调试完记得用 -O2 重新编译一份做性能验证,别拿 -O0 的二进制去压测,那结果不能代表线上表现。
2.2 启动方式与源代码定位:三种姿势各有用途
启动 gdb 有几种常见姿势,适用场景完全不同:
# 方式一:直接启动并加载可执行文件 gdb tst # 方式二:先进入 gdb,再用 file 命令加载 gdb (gdb) file tst # 方式三:调试 core 文件,程序崩溃后留下的现场 gdb ./tst core # 方式四:附加到正在运行的进程(服务程序排障常用) gdb -p 12345方式三和方式四是很多新手不知道的。程序崩了之后第一时间用ulimit -c unlimited打开 core 生成开关,再用gdb ./tst core加载,能直接定位崩溃点。方式四适合调试守护进程这类不能前台运行的程序,gdb 会自动 attach 上去。
加载完文件后,先用 list 确认源码行号,避免断点设置错位置:
(gdb) list # 从当前行开始列出源码 (gdb) list 1 # 从第 1 行开始列出 (gdb) list main # 从 main 函数开头列出 (gdb) list 10, 20 # 列出 10 到 20 行程序运行参数也要在启动阶段设好。比如测试程序需要读一个配置文件,直接在 gdb 里指定:
(gdb) set args -c config.ini (gdb) show args # 确认参数已生效set args 的优先级和命令行传参一致,gdb --args tst -c config.ini也能达到同样效果。参数设置不检查文件是否存在,拼错了路径程序运行时才会报错,这点注意一下。list 和 set args 是启动阶段最常用的两个命令,前者解决"断点设在哪",后者解决"程序跑起来需要什么环境"。
3. 断点与暂停机制:break、watch、catch 怎么选
3.1 断点设置:行号、函数、条件断点与断点管理
断点是 GDB 最核心的暂停手段,break 命令支持四种形式:
(gdb) break tst.c:16 # 在源码第 16 行停住 (gdb) break func # 进入 func 函数入口时停住 (gdb) break 46 if testsize==100 # 条件断点,满足条件才停 (gdb) break filename:function # 指定文件的函数处设置实际调试中,行号断点是主力,函数断点次之。条件断点在循环里查特定值非常好用——比如要查 i=100 时程序的状态,不用按 100 次 next,直接设置break 8 if i==100。但条件断点有个性能坑:每次执行到该行,GDB 都要计算一次条件表达式。如果这个表达式本身调用了程序里的函数,而那个函数又有副作用,程序状态就会被悄悄改变。我一般只在简单比较表达式上用条件断点,复杂判断用前面提到的 watch 方案。
断点设置完不是就结束了,管理断点同样重要:
(gdb) info breakpoints # 查看所有断点信息 (gdb) delete breakpoint 1 # 删除编号为 1 的断点 (gdb) disable breakpoint 2 # 禁用 2 号断点,保留位置 (gdb) enable breakpoint 2 # 重新启用 (gdb) clear 16 # 清除当前文件第 16 行的所有断点delete 和 disable 的区别是:delete 是删除,disable 是暂时关闭。调试循环里,断点命中几百次后你会发现每次都停会很烦,这时 disable 比 delete 明智——后面可能还要用。info breakpoints 输出的 Enb 列是 y/n,一眼能看出哪些断点处于激活状态。
3.2 观察点、捕捉点与信号:不打断也能盯住变量
断点要求你事先知道"会出问题的那一行在哪",但很多 Bug 的特征是"某个变量在某些时刻被改成了非法值"。这时观察点更合适:
(gdb) watch myvar # 变量被写入时停住 (gdb) rwatch myvar # 变量被读取时停住 (gdb) awatch myvar # 变量被读或被写都停住watch 的原理是硬件断点或软件单步监视,对变量地址做访问监控。它不需要你猜测赋值语句的位置——变量一被改动就触发。代价是性能:如果变量在一段循环里被频繁读写,程序运行会慢到一个量级。能用断点定位的先断点定位,实在查不到再上 watch。watch 对局部变量有作用域限制,出了函数作用域 GDB 会自动删除观察点,这是不少人踩过的坑。
捕捉点 catch 针对的是事件而不是位置:
(gdb) catch throw # C++ 抛出异常时停住 (gdb) catch catch # C++ 捕捉到异常时停住 (gdb) catch exec # 调用 exec 系统调用时停住 (gdb) catch fork # 调用 fork 时停住文档里写得清楚,exec、fork、vfork、load、unload 这些事件只在 HP-UX 平台下有效。也就是说,Linux 下 catch 实际可用的主要是 throw 和 catch。调试 C++ 异常导致的神秘崩溃,catch throw 比在可能抛异常的所有函数入口打断点高效得多——你直接停在了抛出点,调用栈完整保留。
信号处理用 handle,控制 GDB 收到信号时的行为:
(gdb) handle SIGPIPE stop print参数表如下:
| 参数 | 行为 |
|---|---|
| nostop | 信号到达时不停程序,只打消息 |
| stop | 信号到达时停住程序 |
| 信号到达时显示一条消息 | |
| noprint | 不显示信号消息 |
| pass | 把信号交给被调试程序处理 |
| nopass | GDB 拦截信号,不交给程序 |
调试网络服务时 SIGPIPE 非常烦人——客户端断开连接,程序收到 SIGPIPE 直接终止。用handle SIGPIPE nostop nopass让信号不要中断调试会话,能省不少事。
线程断点是服务端调试的刚需:
(gdb) info threads # 查看当前线程列表 (gdb) break tst.c:12 thread 3 # 只在 3 号线程命中 (gdb) break tst.c:12 thread 3 if count > 10 # 加条件多线程程序里,同一个断点可能被多个线程命中,如果只在某个特定线程上复现问题,不加 thread 限定就得反复 continue 跳过无关命中。thread 后面的编号来自 info threads 的第一列,不是操作系统的 TID,先查再用。
4. 单步执行与数据观察:next/step 的区别不止一个
4.1 单步执行三兄弟:next、step、continue 与 finish 的配合
单步调试的核心命令有三条,外加一个收尾命令:
| 命令 | 行为 | 适用场景 |
|---|---|---|
| next (n) | 执行一行,不进入函数内部 | 主流程排查 |
| step (s) | 执行一行,进入函数内部 | 跟踪被调函数逻辑 |
| continue (c) | 继续执行到下一个断点或结束 | 跳过确定无误的代码段 |
| finish | 跑完当前函数并返回 | 已经进了函数想退出来 |
next 和 step 的选择直接影响调试效率。next 在遇到函数调用时把它当成"一整行"执行完,step 则会钻进数内部一行行跑。在 main 函数里做整体流程梳理时用 next,想确认某个函数的内部实现细节时再用 step 进去。如果 step 进去了想退出来,finish 是后悔药——直接执行完当前函数,停在返回处。
下面用一个完整的调试会话演示这组命令的输出效果。源文件 tst.c 内容如下:
#include <stdio.h> int func(int n) { int sum = 0, i; for (i = 1; i <= n; i++) { sum += i; } return sum; } int main() { int i; long result = 0; for (i = 1; i <= 100; i++) { result += i; } printf("result[1-100] = %ld\n", result); result += func(100); printf("result[1-100] + func(100) = %ld\n", result); return 0; }编译启动:
gcc -g -O0 tst.c -o tst gdb tst(gdb) break 17 Breakpoint 1 at 0x4004d6: file tst.c, line 17. (gdb) run Starting program: /home/dev/tst Breakpoint 1, main () at tst.c:17 17 long result = 0; (gdb) next 18 for (i = 1; i <= 100; i++) { (gdb) next 20 result += i; (gdb) print i $1 = 1 (gdb) continue Continuing. result[1-100] = 5050 Breakpoint 2, func (n=100) at tst.c:5 5 int sum = 0, i;这个会话里做了三件事:断点设在 main 的第 17 行,run 运行后停住;next 逐行执行并配合 print 看 i 的值;continue 直接跑到下一个断点(func 函数入口)。注意 func 的断点是在会话前设置的,continue 才会停在函数入口——如果没设断点,程序会一路跑到底。
进入 func 之后的单步策略要变:
(gdb) next 6 for (i = 1; i <= n; i++) { (gdb) next 8 sum += i; (gdb) print i $2 = 1 (gdb) print sum $3 = 1 (gdb) finish Run till exit from #0 func (n=100) at tst.c:5 0x000000000040052e in main () at tst.c:24 Value returned is $4 = 5050在 func 内部继续用 next 逐行走,用 print 盯住 i 和 sum 的变化,确认求和逻辑无误后,finish 直接执行完函数回到 main,GDB 还会打印返回值。
4.2 数据与内存查看:print 的格式控制、examine 内存和调用栈回溯
print 是检查变量值的主力命令,但很多人只用默认格式,遇到指针和数组就抓瞎。print 支持格式修饰符:
(gdb) print /x i # 按十六进制显示 (gdb) print /d i # 按十进制显示 (gdb) print /t i # 按二进制显示 (gdb) print /c i # 按字符显示 (gdb) print /f i # 按浮点数显示排查二进制位相关的 Bug 时 /t 非常有用——比如检查某个标志位寄存器的第 3 位是否为 1,二进制格式一眼看清。查指针指向的一片内存,用数组语法:
(gdb) print *array@10 # 打印 array 指向的前 10 个元素 (gdb) print h@10 # 打印变量 h 后面的 10 个整数array@len 语法本质上是"人为数组",不检查越界。你要是写print *ptr@1000,而 ptr 实际只指向 10 个元素,GDB 会打印出一堆不可读的乱值,不会报错。所以用之前先确认分配长度。这里补充一个实用命令 whatis 和 ptype:
(gdb) whatis array type = int * (gdb) ptype struct_node type = struct { int value; struct node *next; }whatis 只看类型名,ptype 会把 struct 的完整定义展开。调试复杂链表结构时,ptype 比翻头文件快得多。
examine(简写 x)直接查看指定地址的内存内容:
(gdb) x/10cw pFilePath # 以 word(4字节)为单位,打印 10 个,按字符格式 (gdb) x/20bx buffer # 以单字节为单位,打印 20 个,十六进制格式x 命令的参数格式是x/[数量][格式][单位大小] 地址,单位大小 b 是单字节、h 双字节、w 四字节、g 八字节,默认四字节。检查缓冲区数据时x/64bx buf比 print 更直观——看到的就是内存里的原始字节序列。
函数调用栈用 backtrace:
(gdb) bt # 打印完整调用栈 (gdb) bt 5 # 只打印栈顶 5 层 (gdb) bt -5 # 只打印栈底 5 层bt 输出从当前执行点一路追溯到 main 甚至 libc 的入口。段错误时 bt 是救命命令——程序崩溃后先 bt,看最底层的用户函数是哪一个,崩在什么位置,十有八九问题就出在那附近。info 系列命令按需查信息:
(gdb) info locals # 当前栈帧的局部变量 (gdb) info registers # 所有整数寄存器 (gdb) info breakpoints # 断点状态 (gdb) info threads # 线程列表调试时随时 info locals,可以一次看到当前函数里所有变量的值,免去一个个 print。程序被优化过导致局部变量不可见时,info locals 会提示 optimized out,这时就得回编译环节加 -O0。
5. 常见问题排查:五个高频翻车现场
5.1 变量显示<optimized out>无法打印
现象:next 走到某一行,print 一个明明存在的局部变量,GDB 返回<optimized out>,或者显示的值和预期完全不符。
原因:可执行文件编译时开了优化(-O2 或 -O3)。编译器将变量直接优化进寄存器,或者把多行代码合并成一条指令,调试信息里的源码行号和变量的存储位置就不再与源码一一对应。
解决:本地调试统一使用gcc -g -O0编译。如果必须在 -O2 下调试(比如要复现线上特定表现),用print $eax这类寄存器查看方式绕过变量名,但定位成本会高很多。血泪经验:线上压测程序产生的 core 文件,加载后看到的变量状态与实际的差异非常大,优先用 -O0 -g 复现问题再分析。
5.2 断点在循环里不命中或命中时机不对
现象:设置break 8 if i==100,程序跑完循环也没停,或者停在 i=101 的位置。
原因:条件写错是最常见的原因——break if i=100是赋值不是比较,恒为真,第一次进入断点就停住。另一个原因是条件断点表达式里有函数调用,函数返回值被改变导致判断失真。
解决:条件断点只用==、!=、>、<这类纯比较表达式,严禁在条件里写赋值语句和函数调用。先普通断点看住循环,用 print 确认变量实际值,再上条件断点。我自己就犯过if i=100这种错误,断点直接变成每次循环都停,还以为是 GDB 的 bug。
5.3 断点命中了,但停在错误的位置
现象:断点设在第 10 行,程序停在第 12 行;或者 list 显示的源码和编辑器里看到的不一致。
原因:编译时开了优化导致指令重排,源码行号和机器指令的映射发生偏移。另一个常见原因是源码文件被修改过但可执行文件没有重新编译——GDB 加载的是旧调试信息,行号对不上新源码。
解决:确认 Makefile 里是 -g -O0 组合,确认每次改完代码都重新编译了目标文件。在 GDB 里用info source查看当前加载的源码路径,和编辑器里的文件做对比,防止因 vscode 工作区目录不同而加载了陈旧源码。
5.4 多线程程序断点总在奇怪的地方停住
现象:给线程 A 设了断点,结果在另一线程里也停了;用 continue 跳过时其他线程又可能先命中同一个断点,调试次序错乱。
原因:GDB 默认的 all-stop 模式是任何一个线程触发断点,所有线程全部暂停。如果不加 thread 限定,断点对任意线程都生效。
解决:断点时带上线程号——break tst.c:12 thread 3。配合info threads确认线程编号,再用thread 3切换当前线程上下文,观察该线程的局部变量。多线程调试的核心是"先指定线程,再指定断点"。
5.5 搜 "gdb" 找到了不相关的东西
现象:搜索 gdb 相关资料时,搜到了 qgis 打开 gdb 的教程、地理数据库文件格式之类的结果,跟 GNU 调试器完全无关。
原因:GDB 同时是 ESRI 地理数据库(Geodatabase)的文件格式后缀,GIS 领域提到 gdb 指的是矢量数据文件,和 GNU Debugger 撞了缩写。
解决:找调试器资料时把关键词加全,比如 "gdb 单步调试" "GNU gdb 命令",避免搜到 GIS 内容。反过来,GIS 从业者搜 gdb 时加 "GIS" 和 "地理数据库" 缩小范围。两个领域互不相关,纯属缩写撞车。
6. 进阶技巧:从命令行到脚本化的调试捷径
命令行逐条敲命令是基本功,但真正提效的是把重复操作固化成脚本。GDB 支持从文件批量执行命令,日常调试可以直接复用一套固定流程:
# debug.cmd 内容 break main run next print result continuegdb -batch -x debug.cmd ./tst-batch 模式跑完脚本自动退出,适合在自动化测试里输出崩溃点信息。更实用的做法是把 GDB 配置文件写到项目仓库里统一管理:
# .gdbinit set pagination off set print pretty on set print array-indexes on以后每次启动 gdb 自动加载这些设置,不用重复敲。我喜欢在临时调试会话里配合 vscode 使用——vscode 的 launch.json 里配置 "miDebuggerPath": "/usr/bin/gdb",再用 "stopAtEntry": true 让程序启动后先停住,效果等价于命令行 break 第一行:
{ "name": "Debug C++", "type": "cppdbg", "request": "launch", "program": "${workspaceFolder}/tst", "args": [], "stopAtEntry": true, "cwd": "${workspaceFolder}", "miDebuggerPath": "/usr/bin/gdb" }命令行调试的优势在脚本化,IDE 的优势在可视化观察变量和寄存器。各取所长,我一般用 vscode 写代码和设按钮,真正涉及条件断点和复杂 watch 时切回命令行。嵌入式场景里,vscode 配 qemu 的 gdb 远程调试是标准打法——qemu 启动带-s参数监听端口,本地 gdb 用 target remote 连上去,对内核或裸机程序做交叉调试:
qemu-system-arm -M vexpress-a9 -kernel kernel.bin -S -s(gdb) target remote :1234 (gdb) file kernel.elf (gdb) break start_kernel (gdb) continueGDB 13.2 版本对 C++20 协程的调试支持已经很完善,print 协程帧里的局部变量和标准库容器都不再是乱码;新版还改进了 pretty printer 的性能,打印大型 STL 容器时不再卡顿。如果你用的是老发行版自带的旧版本 gdb,建议自己编译一份新版——低版本 gdb 对 C++ 新特性的支持缺失,会让有些变量显示成不可读的原始内存。
最后分享一个习惯:从那以后,我每次调试前都强制走一遍固定流程——确认编译参数是 -g -O0、确认断点设到了正确的文件名和行号、确认 set args 带了完整参数、跑之前想清楚是 next 还是 step。这套检查只需要几十秒,但能避免大部分"GDB 怎么不听使唤"的玄学时刻。希望帮到你。
本文还有配套的精品资源,点击获取