写代码这么久如果让我选一个最能提升效率的习惯调试器的使用绝对排在前三。很多朋友一把printf走天下觉得 gdb 是“大神专属”工具其实学会它不过就是两小时的事。gdb 全称 GNU Debugger是 Linux 生态里最经典的调试工具配合 cgdb 这个终端可视化前端连源代码窗口都省了直接治好了我“盲人摸象”式调试的毛病。这篇文章就把我从第一条断点到 multi-thread 排障的完整经验整理出来适合刚接触 Linux 开发的初学者也适合那些习惯printf、想换一种更高效排障方式的同学参考。1. 为什么非要用调试器printf 之外的另一层世界你可能会问我到处加printf不也能把问题找出来吗话是没错但printf调试有几个很要命的短板第一你得改源码重新编译一轮下来 30 秒起步第二程序崩了或者死循环了printf输出未必刷得出来就算刷出来也未必是问题现场第三多线程程序里你根本不知道打印出来的时点对应的是哪一帧状态。调试器解决问题的方式完全不同。它可以“暂停世界”让你看清每一行代码执行时变量到底长什么样、程序栈上挂着哪些函数调用、某个值是被谁改掉的。这其实不是在“捉虫”而是在还原犯罪现场。1.1 调试的基本思路单步还是断点调试策略通常分两派单步跟踪和断点停靠。单步适合理解代码逻辑按next一行行走但遇到大循环几千次迭代会按到怀疑人生。断点则是让程序跑起来只在可疑位置暂停效率高得多。我的习惯是先断点后单步再 watchpoint。逻辑不清晰时跑一遍单步理解全貌确认可疑函数后下条件断点需要盯变量变化时再上 watchpoint。三者组合没有解决不了的逻辑问题。1.2 符号表是什么-g选项的秘密gdb能认出行号和变量名靠的是编译期生成的调试符号。GCC 编译时加-g选项会生成 DWARF 格式的调试信息里面有源码路径、行号映射、参数和局部变量的名字与类型。没加-g编译的程序也能调试但只能看到汇编指令这在排查 C 模板展开或编译器优化导致的问题时简直灾难。还有一点调试编译时尽量加-O0。-O2优化会把变量优化掉、把函数内联掉你在 gdb 里 print 某个变量结果提示optimized out这就是优化器把变量直接融合进寄存器或常量了。友好建议Debug 构建一律-g -O0Release 构建才上优化。2. gdb 基础操作从编译到第一条断点先把工具装好。Ubuntu/Debian 系执行sudo apt install gdbCentOS/RHEL 系执行sudo yum install gdbmacOS 用户执行brew install gdb后还要额外配置代码签名过程稍麻烦。Windows 上如果你用的是 WSL直接和 Linux 一样安装即可。2.1 准备一个可调试的示例程序我不讲花哨的就用一个“链表反转”的经典例程来演示。先建一个reverse.c#include stdio.h #include stdlib.h typedef struct Node { int value; struct Node *next; } Node; Node* create_list(int n) { Node *head NULL; Node *prev NULL; for (int i 0; i n; i) { Node *node (Node*)malloc(sizeof(Node)); node-value i; node-next NULL; if (prev) { prev-next node; } else { head node; } prev node; } return head; } void reverse_list(Node **head) { Node *prev NULL; Node *curr *head; Node *next NULL; while (curr ! NULL) { next curr-next; curr-next prev; prev curr; curr next; } *head prev; } void print_list(Node *head) { Node *curr head; while (curr ! NULL) { printf(%d , curr-value); curr curr-next; } printf(\n); } int main() { Node *head create_list(5); print_list(head); reverse_list(head); print_list(head); return 0; }编译时务必带上调试选项gcc -g -O0 -o reverse reverse.c启动 gdb 有几种方式。通常我会先运行再查看崩溃状态也可以用gdb reverse直接开始或者在程序出问题后用gdb reverse core分析 core 文件。第三种方式如果碰到段错误可以直接分析程序坠毁前留下的“遗体”——core dump。2.2 下断点常用姿势与执行流的控制进入 gdb 后先看一眼源码再规划断点(gdb) list如果要带上下文可以list reverse.c:12, 25直接定位某个函数或某一行。下断点的命令是break或者简写b(gdb) break reverse_list (gdb) break 22 (gdb) break reverse.c:18第一条以函数名断下第二条直接断在文件行号 22第三条指定文件与行号适合多文件工程。运行程序用run或r程序会在第一个断点处停下。这时用info breakpoints能看到所有断点列表disable 1临时禁用不用的断点delete 1删掉它。单步命令两组要分清next和step。next执行当前行如果当前行是函数调用会整个调完再停到下一行相当于“跨过”。step则会跳进函数内部在函数第一行停住。配合finish执行完当前函数返回调用处再配合until 30直接往后跑到第 30 行——我常用until跳出大循环而不用傻傻按几十次next。下面是一个模拟的调试会话核心部分(gdb) break reverse_list Breakpoint 1 at 0x401156: file reverse.c, line 18. (gdb) run Breakpoint 1, reverse_list (head0x7ffc2e9d5f18) at reverse.c:18 18 Node *prev NULL; (gdb) next 19 Node *curr *head; (gdb) print curr $1 (Node *) 0x55c3f2d012a0 (gdb) print curr-value $2 0 (gdb) next 21 while (curr ! NULL) { (gdb) print *curr $3 {value 0, next 0x55c3f2d012c0}注意print *curr能一次性看整个结构体的内容这个操作比一个个字段打印省事太多。显示时给指针变量前面加*gdb 会解析出实际结构体内容非常直观。2.3 查看调用栈程序“从哪里来到哪里去”当程序停在某个断点你很可能想知道“谁调用了我”。backtrace或者bt显示从main到当前断点的所有栈帧(gdb) bt #0 reverse_list (head0x7ffc2e9d5f18) at reverse.c:18 #1 0x00000000004011c4 in main () at reverse.c:35frame 1切换到 main 的栈帧print head就能看 main 里head变量的值——因为head是局部变量不在当前栈帧里默认看不到切帧后没问题了。调试多模块程序时看调用关系是否有悖预期比如是不是调错了重载函数或者回调函数入口参数被写坏bt基本是第一步。2.4 程序崩溃瞬间看到死因的第一现场调试最刺激的其实是段错误。先让程序跑着等它崩溃(gdb) run Program received signal SIGSEGV, Segmentation fault. 0x000000000040118a in main () at reverse.c:36 36 print_list(head);这时别慌先执行bt看栈再用frame 0切进崩溃帧看看局部变量值。假如是空指针调用成员函数gdb 直接报错但报错的根源往往在前面几帧里。这个“顺着栈帧回溯”的习惯帮我解决了大概 80% 的空指针问题。3. gdb 进阶条件断点、watchpoint 与自动化脚本基础操作熟练后真正拉开差距的是进阶玩法。对付特定数据才出现的 bug条件断点比满世界设断点精准得多。3.1 条件断点等一个变量变成特定值比如想在第 21 行盯着curr-value 3的情况直接写(gdb) break 21 if curr-value 3 (gdb) run Breakpoint 1, reverse_list (head0x7ffc2e9d5f18) at reverse.c:21 21 while (curr ! NULL) { (gdb) print curr-value $1 3条件断点的内部实现是每次执行到 21 行gdb 都会把表达式的值给求出来不满足条件就继续。表达式里可以调用函数也可以访问全局变量和宏。但有两点注意条件表达式本身写错了程序不会立即发现只会打断点失败条件太复杂会让程序慢 10 倍以上尤其在大循环里。这种情况我会改用 watchpoint。3.2 watchpoint盯着一个变量改了立刻停watchpoint 就是“数据的断点”。它不关心代码执行到哪一行只关心某个变量是否被修改。硬件支持的情况下gdb 用 CPU 调试寄存器实现无侵入监控不支持时用软件模拟性能会差很多。设一个(gdb) watch curr-next当curr-next被篡改时程序会立刻停在修改它的那行还会标出旧值和新值。这是排查“谁动了我的指针”的神器多线程下配合硬件 watchpoint 尤其管用。我曾被一个诡异问题折磨两天某结构体里一个字段隔一段时间就变成 0xffffffff查来查去定位不到写入点。用 watchpoint 挂上去跑了十分钟程序就停在了一个彻底想不到的 write 系统调用附近的赋值语句。于是才知道是缓冲区溢出踩坏了结构体。那一刻我对 watchpoint 的信任直接拉满。3.3 gdb 命令脚本把重复操作打包每次调试都敲十几遍同一串命令实在太烦。gdb 支持把命令集放进脚本用-x加载。比如写一个debug.cmdset pagination off set print pretty on break reverse_list if curr-value 3 commands 1 silent printf Hit at value%d\n, curr-value bt 2 continue end run然后gdb -x debug.cmd reverse启动。所有的断点动作自动执行。排查“每次崩溃前 log 一下调用来源”这类场景脚本流能大幅提升效率。set print pretty on输出结构体会自动换行缩进多个嵌套结构时这体验完全离不开。set print elements 0让大数组全部显示出来不会被默认截断成...。调试大数组或链表时这两个设置组合直接爽到飞起。4. 多线程与 Core Dump还原现场的最后防线单线程调试只是基本功多线程排障才是硬仗。多线程里最烦的一个场景——不是程序不崩溃而是崩溃得像随机抽奖换个环境又不崩了。这种时候core dump 文件是唯一能还原现场的线索。4.1 多线程调试让世界停在下注的那一秒gdb 里多线程调试有三板斧查看线程列表、切换线程、锁住调度。info threads列出所有线程thread 3切换到编号 3 的线程thread apply all bt一次性看所有线程的调用栈查死锁最常用set scheduler-locking on让单步命令只作用于当前线程set scheduler-locking off允许其他线程继续跑默认行为有次我调一个资源竞争问题两线程同时操作一个全局链表。我分别在两个线程里下条件断点然后thread apply all bt一看——两个线程的栈帧都停在同一断点附近其中一个正在修改链表另一个同时也在修改谁都没拿锁。数据竞争立刻现形不需要再靠猜。死锁的定位方法也很直接看到多个线程都阻塞在pthread_mutex_lock之类的调用上基本锁粒度或者锁顺序出了问题。这里有个极易踩的坑在有锁的循环里使用next单步如果调度锁没开另一个线程可能会推进共享状态导致你 debug 出来的执行流和真实情况差别很大。排查多线程问题第一步永远是set scheduler-locking on。4.2 配置 core dump崩溃之后还有救core dump 默认经常关闭。先检查ulimit -c输出 0 表示关闭用ulimit -c unlimited放开。想让配置持久化写进~/.bashrc或/etc/security/limits.conf。临时路径下生成的 core 文件通常在程序工作目录里名字形如core或core.12345具体看/proc/sys/kernel/core_pattern配置。gdb reverse core进去之后bt看崩溃栈frame切到崩溃帧看变量再用list看崩溃位置的上下文源码。这个流程不需要复现 bug不需要跑几千次只要拿到现场问题就基本锁定了。个人强烈建议每次接手别人项目之前先确认 core dump 功能是开着的。不然遇到那种“用户机器必现开发环境死活不崩”的 bug你可就极被动了。core dump 还有一个延伸技巧coredumpctl。systemd 系统的发行版可以用coredumpctl gdb reverse自动调起 gdb 分析最近的 core省去找文件的功夫。5. cgdb给 gdb 装上一双可以看到代码的眼睛gdb 本身是纯命令行交互默认只能通过list命令一页页翻源码断点看不了源码上下文。时间久了眼睛确实累。cgdb 就是在 gdb 外面套一个终端文本界面上屏显示源码下屏保留 gdb 命令行这个设计简单直接但体验提升是质变的。5.1 安装 cgdb一条命令Debian/Ubuntusudo apt install cgdbCentOS/RHEL 需要先启用 EPELsudo yum install epel-release sudo yum install cgdbmacOSbrew install cgdb安装好以后没什么额外配置cgdb build/bin/你的程序直接启动。如果显示界面花屏或者配色奇怪基本是终端类型没设置好export TERMxterm-256color能解决绝大多数问题。5.2 上下分屏源码与命令行的无缝切换cgdb 启动后默认进入一个上下分割的界面上方是源码窗口下方是 gdb 命令窗口。焦点所在窗口高亮显示。在下方窗口输入 gdb 命令上方的源码窗口会实时更新当前执行位置和断点。分屏交互有几个快捷键必须记住Esc从命令窗口切到源码窗口i从源码窗口切回命令窗口源码窗口下按空格在当前行设置或取消断点F5发送runF8发送nextF7发送stepF9发送continue-和缩放源码窗口用几天后你基本就离不开空格下断点 F5 启动 F8 单步这套组合了全程不碰键盘敲命令。5.3 cgdb 调试实录体验一次完整流程实际的流程你感受下cgdb reverse启动后在命令窗口输入break reverse_list回车上方源码窗口第 18 行高亮变成红色代表有断点。按F5程序跑起来停在断点处。按一次F8代码高亮行移动到第 21 行。这时候使用i切到下窗口输入print curr-value数值立刻显示。处理完后再按Esc切回上窗口继续按F8。cgdb 真正让我心动的是看循环变量迭代过程单步时高亮行在while和循环体之间来回移动眼前看的是源码右手按的是 F8脑子想的是逻辑——零切换成本。原来在纯 gdb 里你要背下来“现在执行到第几行”现在直接看高亮这份“眼睛不累”的舒适感是最大的隐性收益。5.4 配置优化几个让 cgdb 更好用的设置默认设置的 cgdb 信息展示还不够丰富可以在~/.cgdb/cgdbrc里写上set cgdb/arrows1 set cgdb/showgdb1 set cgdb/helpline1 set cgdb/splitwinvertical这些配置开启方向键滚动、显示 gdb 子窗口、开启底部帮助以及把分屏从上下改成左右。左右分屏在宽屏终端下特别好用左边源码、右边命令各行其道。字体大小不用管继承终端设置。若用 iTerm2 或 Konsole把字号调大一点体验更舒服。6. 问题速查与经验总结最后整理一些我在实际使用中高频遇到的问题和对应的解法。这些不是教程手册里的标准答案全是从真实排障现场里挤出来的经验。6.1 常见问题速查表现象可能原因解决方式print变量提示optimized out编译使用了-O2等优化选项重新以-g -O0编译编译后行号乱跳优化器调度指令同上优化关掉bt全是问号没有调试符号或 strip 过确认是否-gstrip 后一般没法调断点打不上提示 pending 然后不停函数被内联或文件名路径不一致用list *函数名看汇编地址断在地址上程序一运行就段错误但退出码不是 139可能正常退出但被信号打断用catch signal SIGSEGV捕获后再btwatchpoint 不生效变量跨函数传递或被优化为寄存器对全局变量、堆上一级指针设 watchpoint多线程单步时无法预期其他线程一直在跑干扰状态set scheduler-locking oncore 文件没有生成ulimit 限制或内核配置关闭ulimit -c unlimited确认 core_patterncgdb 界面花屏终端 TERM 变量或配色冲突export TERMxterm-256color特别备注一下断点打不上的情况如果你在模板函数或内联函数里打断点gdb 会提示创建 pending breakpoint也就是“现在没有这个位置将来可能匹配”。这种断点有时候真的不生效所以函数被确认展开过再打断点更为靠谱。要用汇编断点可以info address 函数名拿到函数入口地址后break *地址硬断。6.2 调试器之外几个值得坚持的好习惯调试器和测试一样是效率工具。想真正玩好我最深的体会反而是调试之外的事第一程序最好保持“可调试”状态。Release 和 Debug 分开构建不要图省事一直编译优化版本。Debug 版本里关掉内联、关掉优化才能在 gdb 里看到真实逻辑而不是被优化器改写后的汇编。第二崩溃复现不是玄学。如果程序时好时坏优先怀疑未初始化内存、数组越界、use-after-free这三种 bug 用普通断点经常找不到watchpoint AddressSanitizer 才是正确的组合。第三学会看汇编不是浪费。gdb 里的layout asm或disassemble命令切换到汇编视图能直接看到编译器把 C 代码变成什么样。遇到栈溢出、函数调用规范差异、编译器 bug 时只有汇编能给你最终答案。虽然前期痛苦但长远收益非常可观。6.3 一套我日常最常用的调试开场白每次接手一个新的 bug我基本都按下面这套流程走一遍效率高出硬猜太多gdb 程序名 (gdb) break main (gdb) run (gdb) next 20 (gdb) bt先停在 main然后用next快速平移执行到可疑代码附近bt确认调用链。如果崩了走 core 路线。如果数据不对上条件断点。这套路径 95% 的问题都能在十分钟内锁死范围。我个人在实际操作中越来越深的体会是调试器的核心价值不在于“找 bug”而在于帮你理解一个程序真正的运行方式。很多人依赖printf其实等于一手遮着眼睛一手摸象。花两小时掌握 gdb 和 cgdb 的基本操作之后的每一次定位问题都是从“我可劲儿试”变成“我看一眼就知道大概在哪个位置”——这个转变值得每位开发者亲自体验一次。
阅读完成 · 觉得有帮助?