☰
从printf到gdb/cgdb:Linux C/C++调试实战手册
2026/10/21 16:15:20 网站建设 项目流程

1. 从 printf 调试到 gdb/cgdb:一次调试思维的转变

前阵子调试一个后台服务的内存越界问题,日志只留下一条 Segment Fault,复现一次还得等半小时。我最初还是老一套:在可疑位置加 printf,重新编译、部署、跑、看输出。结果加了十几行打印之后,程序反而不崩了——这大概就是典型的“海森 bug”,观察行为本身改变了崩溃路径。从那次之后,我开始认认真真用 gdb/cgdb 来调试,而不是靠猜。这篇东西就是我实际用下来的一套完整操作笔记,既有跑通第一个断点的基础步骤,也有条件断点、core 分析、cgdb 可视化调试、gdbserver 远程调试这些高级用法,适合所有 Linux 下写 C/C++、经常跟指针和段错误打交道的朋友。

1.1 为什么 printf 调试会失效

很多人觉得调试就是“猜哪里出问题,然后打日志确认”。在简单程序里这确实有效,一旦代码规模上来,printf 调试就有几个天然死穴:

第一,复现困难。在服务端程序里,问题可能只在特定输入、特定时序下出现。你随便加几行打印,编译优化、线程调度、IO 缓冲全部跟着变,崩溃可能就消失了。这不是玄学,而是真实存在的干扰效应。

第二,信息片面。printf 只能打印你预先想到要看的变量。但很多 bug 是“某个变量被莫名改掉”、“这个指针指向了不该指向的地方”,你根本不知道该在哪行加打印。就算加了,也只能看到当时的快照,看不到调用来源、函数栈、寄存器状态。

第三,生产环境不允许。你不可能在客户机器上重新编译,也不可能修改守护进程拉起的服务去加输出。这种时候,能够 attach 到一个正在运行的进程,或者事后分析 core 文件,才是正确解法。

1.2 gdb 和 cgdb 不是两个平行工具

gdb 是 Linux 下最常用的命令行动态调试器,它本身能力极强,但交互方式比较“原始”:代码、断点、输出全部混在同一个终端里,你敲一条命令,它吐一堆文本,阅读体验很差。

cgdb 是 gdb 的终端前端,它把屏幕分成上下两块:上面是带高亮的源码窗口,下面是 gdb 命令窗口。调试时你能同时看到“当前执行到哪一行”和“命令执行结果”。注意,cgdb 没有改变 gdb 的调试能力,只是把 gdb 的操作界面变得更友好。底层跑的仍然是同样的断点、单步、观察点、栈回溯机制。

用一句话概括:gdb 是引擎,cgdb 是方向盘和仪表盘。你完全可以从 gdb 学起,熟练后再切到 cgdb 提升日常操作效率。

1.3 用调试器之后,我的排查流程变了

以前我排查崩溃问题的路径是:加日志 → 复现失败 → 继续加日志 → 最终靠猜。现在用 gdb/cgdb,我的路径是:

  • 先看崩溃日志或 core 文件,拿到调用栈。
  • 在崩溃现场反向检查变量、内存、参数。
  • 如果复现路径明确,直接在可疑函数下断点单步跟踪。
  • 如果问题需要特定条件触发,上条件断点或观察点。

这个流程最大的好处是:让程序在关键时刻停下来,然后直接问它“你现在是什么状态”,而不是在代码里到处埋探针。省掉的不只是时间,还有大量的无效猜测。

2. 开始之前:编译参数、启动方式和第一个断点

2.1 -g 与优化等级:符号表是调试的前提

想把 gdb 用好,第一件事不是学命令,而是保证编译产物里有调试信息。在 gcc/clang 里,这句话就是加上-g参数:

gcc -g -O0 -o demo demo.c

-g生成调试符号,gdb 才能把机器码对应到源码行、拿到变量名和类型。没有-g,你也能进 gdb,但看到的是裸地址和汇编,难受程度直接翻倍。

同时我强烈建议在调试阶段使用-O0。-O2及以上等级会做常量折叠、变量优化、指令重排,断点打在源码第 10 行,实际停下来可能对应完全不同的逻辑,甚至变量被彻底优化掉,打印时只能看到<optimized out>。这会让新手误以为 gdb 坏了。

CMake 项目里可以这样指定:

set(CMAKE_BUILD_TYPE Debug) # 或者直接追加编译参数 set(CMAKE_C_FLAGS_DEBUG "-g -O0")

提示:如果是排查“只有 release 才出现的 bug”,在 release 构建里也可以临时加-g但不关闭优化,用-g -O2。此时符号有偏差,但总比完全没符号好。

2.2 三种启动 gdb 的方式

我平时启动 gdb 的方式有三种,分别对应不同场景:

第一种是直接调试一个新程序:

gdb ./demo

进入 gdb 后执行run启动,程序运行到断点或崩溃时停下。

第二种是分析已有的崩溃现场,也就是加载 core 文件:

gdb ./demo /path/to/core

这种方式不需要重新复现,直接就能看到崩溃时的调用栈和变量状态,极其适合“跑了一晚上才崩一次”的难缠问题。

第三种是 attach 到一个正在运行的进程:

gdb -p 12345

或者进入 gdb 后用attach 12345。这适合排查服务进程 CPU 飙高、卡死、死锁等问题,不用重启进程,调试完还能detach让它继续跑。

2.3 走一遍最小调试闭环

我建议每个入门者都手敲一次下面的最小示例:

#include <stdio.h> int add(int a, int b) { int sum = a + b; return sum; } int main(void) { int x = 1; int y = 2; int z = add(x, y); printf("z = %d\n", z); return 0; }

编译后启动:

gcc -g -O0 -o demo demo.c gdb ./demo

在 gdb 里依次执行:

list break main run next print x print y step print sum continue quit

过程解释:list查看源码;break main在 main 函数入口打断点;run启动后停在断点处;next不进入函数地执行下一行;print x查看变量 x 的值;step会进入 add 函数内部;print sum查看局部变量 sum;continue让程序继续跑到退出。

这里最关键的区别是next和step。新手经常混淆:next把函数调用当成一步跳过去,step则会钻进函数体内部。如果你用next想进入函数,永远看不到里面的局部变量;用step却很可能会一头扎进库函数源码。

2.4 高频命令速查表

命令常用简写作用
breakb设置断点,后接行号、函数名或文件:行号
runr启动程序
continuec继续执行到下一个断点
nextn单步跳过函数
steps单步进入函数
printp打印表达式或变量的值
info locals-查看当前栈帧的局部变量
backtracebt查看调用栈
framef切换到指定栈帧
watchwa设置观察点,变量变化时停下
xx按格式查看内存地址
listl打印源码
quitq退出 gdb

这张表不需要背下来,只要知道“断点、单步、打印、栈”四个核心能力,剩下的碰到时查一下即可。

3. 断点和调用栈的进阶玩法:条件断点、观察点与 core

3.1 条件断点:在循环里精准命中目标值

实际代码里最常遇到的情况是:循环跑了十万次,只在某个特定值时才出错。你不可能一行行 next,这时候条件断点就派上用场了。

假设有循环代码:

for (int i = 0; i < 100000; i++) { process(items[i]); }

想在 i 等于 8888 时停下来:

break demo.c:12 if i == 8888 run

程序运行到第 12 行且条件满足时才会停下,之前的九千多次都不会中断。条件里也能写复杂表达式,比如break xxx if ptr != NULL && ptr->len > 100。

这里要提醒一句:条件断点如果写错变量名,gdb 不会报“未定义”,而是会在每次触发时给出警告“无法解析条件”,然后忽略该断点继续执行。我碰到过一次,检查了半天才发现是变量名拼错。

3.2 watch 观察点:抓出“谁在半夜改我的变量”

有时候问题不是“值不对”,而是“值不知道什么时候被改了”。这种场景最适合用 watch 观察点。

watch target_value

只要 target_value 被写入,程序就会停下来,而且 gdb 会告诉你停在哪一行,当时的旧值和新值分别是什么。甚至还可以用rwatch监视“某个变量被读取”时停下,用awatch监视读写都停下。

用观察点处理内存越界写坏相邻变量的案例特别有效。比如你怀疑某个数组越界写坏了后面的结构体字段,可以在那个字段上设置 watch,再让程序跑一遍。它会在第一次篡改发生时当场抓住肇事代码,比你用 printf 满世界找快得多。

但要注意,CPU 的硬件 watchpoint 数量有限,gdb 可能会提示硬件资源不足,自动改用软件观察点。软件观察点很慢,程序跑得飞起时几乎没法用。我建议在小范围、可复现的场景中使用 watch,而不是在线上大流量环境硬凑。

3.3 core dump 分析:崩溃之后的案发现场

只要进程崩溃,Linux 可以生成一个 core 文件,记录崩溃瞬间的完整内存镜像和寄存器状态。拿到它能免去复现的痛苦。

先确保系统开启了 core 生成:

ulimit -c unlimited

ulimit -c的默认值往往是 0,也就是不生成 core。临时放开后,程序崩溃就会在当前目录或系统配置的目录里出现 core 文件。

分析时直接:

gdb ./demo /path/to/core

进入后第一件事永远先执行:

bt

bt 会打印从崩溃点一直回溯到 main 的调用栈。崩溃往往藏在栈的某一层,比如实际崩溃在 libc 的 memcpy 内部,但真正的问题是你的程序把非法指针传给了 memcpy。这种时候就需要切换到对应的栈帧去查。

3.4 bt full 与 frame 切换:读懂栈里的每一层

bt只显示函数名和调用关系,bt full则会把每个栈帧里的局部变量、函数参数一起列出来。崩溃后我基本都直接执行bt full。

如果看到崩溃点在第 3 层,想查看那一层的细节,可以:

frame 3 info locals info args

frame 3把上下文切换到第 3 层栈帧,之后用print、info locals、info args就能看到这一层函数的变量和参数。你会经常发现:栈顶崩溃的原因其实是栈底的某个函数传入了野指针。不切换栈帧,光看着崩溃那一行是永远查不出根因的。

4. 深入内存和远程进程:x 命令与 gdbserver 组合调试

4.1 x 命令,把内存当十六进制字典来翻

print变量能帮我们理解逻辑,但很多危机时刻要看的是“这块内存里到底是什么”。x 命令就是干这个的。

基本格式是:

x/数量+格式+单位 地址

几个我高频使用的等价写法:

x/4wd ptr # 以十进制显示 ptr 指向的 4 个 int x/4wx ptr # 以十六进制显示 ptr 指向的 4 个字 x/10xb ptr # 以十六进制显示 ptr 指向的 10 个字节 x/s str # 把 str 当字符串显示

有一次我怀疑结构体被越界写坏,就用x/20bx把结构体前后的内存都打出来,再和正常字节比对,很快就发现多了一段陌生数据。这种“按字节查看原始内存”的能力是 printf 给不了的,也是排查指针问题的必备武器。

4.2 gdbserver 接力调试:服务端进程不再难缠

有些程序跑在远程机器上,或者由守护进程拉起,你不能直接在前台运行它。这时候用 gdbserver 做远程调试是常规操作。

在目标机器上启动调试服务:

gdbserver :2345 ./app

这会让 app 停在启动处,监听 2345 端口等待调试器接入。在你自己的机器上:

gdb ./app target remote <目标机器IP>:2345

之后的操作和本地调试完全一样,断点、单步、打印、观察点都在本地终端操作。唯一要注意的是:本地机器的二进制文件必须和远程机器一致,且含调试符号。否则地址和符号对不上,调试体验会变得很糟糕。

我记得第一次用 gdbserver 时没注意符号同步,远程跑的是最新代码,本地还是旧二进制,结果print出来的变量全是乱的。折腾了半天才想起来重新拉取镜像里的二进制文件,血泪教训。

4.3 attach 到“假死”进程,用线程栈定位死锁

服务进程 CPU 跑到 100%,或者干脆“假死”,最有效的定位方式就是 attach。

gdb -p 12345

进来以后不要直接 continue,先看线程状态:

info threads thread apply all bt

thread apply all bt会把所有线程的调用栈都打印出来。如果多个线程都卡在锁相关函数上,且互相等待对方持有的锁,死锁原因基本一眼就能确认。定位完执行detach,进程会继续按原样运行,不会因为这次调试被干掉。

有个常见误区是:attach 之后如果直接quit,gdb 会先detach再退出,默认是安全的。但如果你做了修改操作,还是小心为妙。我习惯调完就detach,避免影响线上进程。

5. cgdb 可视化操作:从代码窗口到快捷键配置

5.1 cgdb 的界面结构和工作逻辑

第一次启动 cgdb 的人会看到上下两个窗口:上面是代码窗口,当前执行行高亮显示;下面是 gdb 命令窗口,所有 gdb 交互都在这进行。

cgdb ./demo

启动后你能在代码窗口看到源码,断点所在行会被标出来。底部仍然可以直接输入 gdb 命令。这意味着你既能享受代码高亮、断点可视化的便利,又不需要改变原来的 gdb 使用习惯。

cgdb 并不是图形 IDE,它仍然跑在终端里。一个屏幕就能放下源码和命令,对远程 SSH 调试尤其友好,不需要额外安装桌面环境,也没有笨重的图形渲染开销。

5.2 高频快捷键与断点管理

在 cgdb 里,键盘操作主要围绕两个区域切换:

  • 按Esc,进入代码窗口导航模式,方向键可以翻动代码,上下搜索。
  • 按i或Enter,回到 gdb 命令窗口,继续输入调试命令。

代码窗口里有一个我几乎离不开的快捷键:空格键。在光标所在行按空格,可以直接切换断点。不用再敲break 文件名:行号这种长命令,鼠标都不用,非常顺手。

查看源码时我还常用/做关键字搜索,快速定位函数定义。如果你在调试过程中想给某个函数打断点,直接在底部窗口输入break 函数名也能立刻在代码窗口看到断点标记。这是 cgdb 比纯 gdb 最明显的体验提升。

提示:如果你在代码窗口里按Enter回到了命令窗口,但接下来想继续翻代码,再按一次Esc即可。刚开始容易忘记当前焦点在哪,看的时候留意光标形态就行。

5.3 把 gdb 配置和 cgdb 配置分开管理

cgdb 本身有一些界面层配置,比如代码高亮、搜索高亮、窗口分割比例等,可以放在 cgdb 自己的配置文件里,通常在用户主目录下的.cgdb/cgdbrc。

我个人的习惯是:凡涉及 gdb 行为的配置,全部放到 .gdbinit,比如自动加载符号、初始化 handle 信号、设置断点命令脚本;凡涉及屏幕操作的配置,才放到 cgdbrc。这样从 gdb 切到 cgdb,甚至切回纯命令行模式,调试行为不会变,变的只是显示层。

如果你还不知从哪里下手,可以在 cgdb 里先不急着配,直接用默认设置跑一周。等意识到“这个默认操作不太顺手”时,再去查对应配置项改掉,比一上来抄一堆别人的配置更有效。

5.4 cgdb 的限制和我自己的使用建议

cgdb 也不是万能的。它没有鼠标点击交互,无法像图形 IDE 那样悬停显示变量值。如果你对 IDE 调试模式更熟悉,可能需要一两天才能适应“在命令窗口里敲 print”的节奏。

我的建议是:先把 gdb 的基础命令练熟,再用 cgdb 提效。如果你连 next、step、print、bt 都要想半天,直接上 cgdb 只会多一个窗口焦虑。反过来,一旦 gdb 命令熟练,cgdb 就能让你远离“背源码行号”的痛苦,调试一个上千行的源文件时,代码窗口实时滚动带来的舒适感非常明显。

6. 进阶技巧与常见坑:这几招让我少熬几次夜

6.1 反向调试:让程序倒带

gdb 原生支持记录执行轨迹,然后像看回放一样反向单步。启动录制后,程序可以倒着走,回到任意一条语句。

record continue

程序崩溃后,你就能用:

reverse-step reverse-next reverse-continue

倒回到变量还是正常状态的时刻,观察它究竟在哪一步被改坏。理论上这非常适合难复现的内存问题,但代价是性能开销巨大,运行时间会明显变长。我只在几十秒能跑完的小型复现用例里用过,效果确实惊艳,但不适合长时间运行的进程。

6.2 断点命令脚本与批量自动化

有时候不想每次命中断点都手动敲一组操作。gdb 允许给断点挂一串自动命令:

break main.c:42 commands silent printf "x=%d, y=%d\n", x, y continue end

silent不打印断点命中的提示,直接执行printf然后continue。这样断点就像一个自动探针,不打日志、不重新编译就能观察程序行为。编写临时调试脚本时非常顺手。

更彻底的自动化是用 gdb 的批处理模式:

gdb -batch -x debug_script.gdb ./demo

这条命令会加载脚本里的所有 gdb 命令并自动执行,适合在回归测试里跑一轮“只要崩溃就停住”的场景。

6.3 多进程、多线程调试的基本动作

调试 fork 出来的子进程时,gdb 默认跟随父进程。想切换到子进程,用:

set follow-fork-mode child

如果 fork 之后父进程和子进程都想调试,可以:

set detach-on-fork off

然后通过info inferiors查看进程列表,用inferior 2切换过去。

多线程程序里,thread apply all bt用来打印所有线程栈已经说过,还常用:

thread 3

切换到 3 号线程。死锁排查时,我一般先把所有线程栈拉出来,找到互相等待的锁,再用frame和info locals查看持锁现场。

6.4 最常被忽略的几个 GDB 坑

排掉这几个坑,你至少能少折腾一个下午。

第一个坑是变量被优化掉。-O2下局部变量被优化没,print显示<optimized out>。解决办法是先换-O0复现,或者接受汇编级调试。不要在一个优化构建里花太多时间质疑 gdb。

第二个坑是断点不生效。常见原因是动态库还没加载,或者代码路径根本没执行到。可以先用info sharedlibrary看共享库是否加载,必要时执行sharedlibrary重新加载符号。

第三个坑是 SIGPIPE 信号导致程序没崩溃却频繁停止。网络程序里很常见。可以在 gdb 里忽略它:

handle SIGPIPE nostop noprint pass

第四个坑是观察点失效。硬件 watchpoint 数目有限,被占用或数量超限时 gdb 会退化成软件观察点,慢得让人怀疑人生。大范围内存监视宁可拆成断点加条件判断。

至于我个人,现在接到“项目又崩了”的消息,第一步永远是:先确认有没有 core,有就直接分析;没有就开 gdb 或者 gdbserver,让程序在断点处停下来,慢慢问它。用 printf 当主力调试手段的日子,我是不想再回去了。最后再分享一个小技巧:调试时顺手把set print pretty打开,打印结构体时会按字段换行缩进,看复杂嵌套结构会舒服很多。

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

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

立即咨询