程序崩溃(segfault)、逻辑错、死锁,是每个 C++ 开发者的日常。靠 printf 打日志排查又慢又容易改错代码,专业的做法是上调试器。gdb(GNU Debugger)是 Linux 上最主流的调试器,这篇把它的核心命令和实战套路系统讲一遍。
关于 gdb 本身,先诚实交代:这篇环境里没有 gdb(find 探测 /opt、/usr 均无,只有同名的 gdbus 不是它),所以下面的 gdb 命令都是用法示例,标注出来是为了不假装跑过。但gdb 要解决的那些问题:「崩溃在哪一行」「哪个变量越界」「谁在反复调用」,这篇全部用可实测的工具补上了(ASan/UBSan 的崩溃栈、gprof 的调用图,见第 6.5 节)。作为调试对象的 C++ 程序本身也都真实编译运行(在线 gcc 13.2 验证)。
1. 引子:程序崩了,第一件事是什么
假设你的程序跑着跑着 Segmentation fault (core dumped)。你要问的第一个问题不是「哪里写错了」,而是「崩溃那一刻的调用栈是什么样」。gdb 的价值就是把「崩溃现场」摊开给你看:哪个函数、哪一行、变量当时是什么值。
先用一个能跑的目标程序当调试对象:
#include <cstdio>
#include <vector>
// 供 gdb 断点调试的目标程序:累加一个数组
int sum(const std::vector<int>& v) {
int total = 0;
for (int x : v) {
total += x; // 可以在这里打断点,观察 total 的变化
}
return total;
}
int main() {
std::vector<int> data{1, 2, 3, 4, 5};
int result = sum(data);
std::printf("sum = %d\n", result);
return 0;
}
sum = 15
官方文档:std::vector —— 上面程序的容器类型,调试时
print data能看到它的内容
2. 核心命令:断点、单步、看变量
gdb 最核心的几个命令,背下来就够应付 80% 的场景:
| 命令 | 作用 | 场景 |
|---|---|---|
break 函数名 / break 文件:行号 | 设断点 | 停在想看的代码处 |
run | 开始运行 | 启动程序(可带命令行参数) |
next / step | 单步执行 | next 不进入函数,step 进入 |
continue | 继续到下一个断点 | 跳过不关心的部分 |
print 表达式 | 打印变量/表达式 | 看变量当前值 |
backtrace(缩写 bt) | 打印调用栈 | 看「谁调用了谁」 |
watch 变量 | 观察点(watchpoint) | 变量被修改时自动停下 |
用法示例(标注:gdb 命令,环境无 gdb,未实测):
g++ -g -O0 demo.cpp -o demo # -g 生成调试信息,-O0 避免优化掉变量
gdb ./demo
# (gdb) break sum # 在 sum 函数入口打断点
# (gdb) run # 运行,停在 sum
# (gdb) next # 单步,不进入函数
# (gdb) print total # 看 total 的值
# (gdb) backtrace # 看调用栈:sum <- main
# (gdb) continue # 跑到结束
# (gdb) quit
调试必须 -g 加调试信息、-O0 关优化,否则断点可能对不上行号,变量也可能被优化掉显示 optimized out。
3. 条件断点与 frame 切换
断点打在循环里会停很多次,条件断点能只在满足条件时停:
# (gdb) break demo.cpp:7 if total > 10 # total 超过 10 才停
# (gdb) condition 1 total == 15 # 给 1 号断点加条件
崩溃时 backtrace 会打印多层调用栈,用 frame 在栈帧间切换、查看每一层的局部变量:
# (gdb) backtrace
# #0 sum(...) at demo.cpp:7
# #1 main() at demo.cpp:13
# (gdb) frame 1 # 切到 main 那一帧
# (gdb) print data # 看 main 里的 data
# (gdb) frame 0 # 切回 sum
一个典型的「变量被观察」场景,作为 watch 的演示对象:
#include <cstdio>
// 供 watchpoint 观察的目标:循环里变量逐步变化
int main() {
int budget = 100;
int step = 10;
for (int i = 0; i < 5; ++i) {
budget -= step; // 设 watch budget,观察每次变化
std::printf("第 %d 轮后 budget = %d\n", i + 1, budget);
}
std::printf("最终 budget = %d\n", budget);
return 0;
}
第 1 轮后 budget = 90
第 2 轮后 budget = 80
第 3 轮后 budget = 70
第 4 轮后 budget = 60
第 5 轮后 budget = 50
最终 budget = 50
官方文档:未定义行为(UB) —— 很多难查的崩溃根源是 UB,gdb 是定位它的第一现场工具
对上面的程序 watch budget 后,每次 budget -= step 执行完,gdb 都会自动停下来并打印旧值和新值,比手写 printf 省事得多。
4. core dump:崩溃现场的「快照」
程序崩溃时,操作系统可以把那一刻的内存镜像存成一个 core 文件,事后用 gdb 打开它,不需要重新运行就能看崩溃现场。这是排查「线上偶发崩溃」的关键手段。
ulimit -c unlimited # 允许生成 core 文件(默认可能被限制为 0)
g++ -g -O0 crash.cpp -o crash
./crash 1 # 触发崩溃,生成 core(文件名可能是 core 或 core.进程号)
gdb ./crash core # 打开 core 文件,直接看崩溃现场
# (gdb) backtrace # 崩溃那一刻的调用栈
# (gdb) print 变量 # 崩溃时变量的值
core dump 的完整回放流程:
core dump 的产生与回放流程
程序运行 ──崩溃(SIGSEGV)──► 内核把内存镜像写进 core 文件
│
▼
gdb ./程序 core 文件
│
▼
backtrace 看崩溃那一刻的调用栈
│
▼
frame 逐帧切换 + print 看各层变量
下面是会触发崩溃的演示程序。它的崩溃路径需要传命令行参数,沙箱无参数运行时走正常路径:
#include <cstdio>
// 反例,不要这么写:解引用空指针,触发 SIGSEGV
// 真实代码应遵循 Core Guidelines 用智能指针 / 引用,避免裸指针
void crash_by_null_deref() {
int* p = nullptr;
std::printf("%d\n", *p); // 反例:解引用空指针,这里会崩溃
}
int main(int argc, char**) {
if (argc > 1) {
crash_by_null_deref(); // 命令行传任意参数 → 触发崩溃,产生 core
} else {
std::printf("正常路径:没有崩溃\n");
}
return 0;
}
正常路径:没有崩溃
官方文档:std::signal —— SIGSEGV 这类信号是崩溃/被中断的底层机制
ulimit -c 要设成 unlimited 才能生成 core 文件;0 表示不生成。core 文件名和存放位置可用 /proc/sys/kernel/core_pattern 查看。拿到 core 文件后 gdb ./crash core 一行就能回到崩溃现场,不用重现 bug。这对「只在线上偶发、本地复现不出来」的崩溃尤其重要。
5. 多线程调试:thread apply all bt
多线程程序卡死、死锁时,光看主线程没用,要看所有线程各自的调用栈。先给一个多线程目标程序:
#include <cstdio>
#include <thread>
#include <atomic>
// 供 gdb 多线程调试的目标程序:两个线程各自累加
void worker(std::atomic<int>& counter) {
for (int i = 0; i < 1000; ++i) {
counter.fetch_add(1, std::memory_order_relaxed);
}
}
int main() {
std::atomic<int> counter{0};
std::thread t1(worker, std::ref(counter));
std::thread t2(worker, std::ref(counter));
t1.join();
t2.join();
std::printf("最终计数 = %d(期望 2000)\n", counter.load());
return 0;
}
最终计数 = 2000(期望 2000)
官方文档:std::thread 与 std::atomic —— 多线程调试的两个核心对象
多线程调试的关键命令:
g++ -g -O0 -pthread thread.cpp -o thread # 多线程要加 -pthread
gdb ./thread
# (gdb) run
# (gdb) info threads # 列出所有线程
# (gdb) thread 2 # 切到 2 号线程
# (gdb) backtrace # 看这个线程的栈
# (gdb) thread apply all bt # 一次性看所有线程的调用栈(排查死锁利器)
死锁时用 thread apply all bt 能看到:线程 A 停在 lock() 等 B 释放锁,线程 B 也停在 lock() 等 A。两个栈一对就看出死锁环了。
6. 完整示例:一段「看着对但结果错」的代码
把断点、watchpoint、frame 的排查思路串起来。下面这段代码的结果不符合直觉,正是调试器的用武之地:
#include <cstdio>
#include <vector>
// 目标:累加 1..10,但结果却不对 —— 供 gdb 排查的完整示例
int main() {
std::vector<int> data;
for (int i = 1; i <= 10; ++i) {
data.push_back(i);
}
int total = 0;
for (std::size_t i = 0; i < data.size(); ++i) {
total += data[i];
}
std::printf("累加 1..10 的结果 = %d\n", total);
return 0;
}
累加 1..10 的结果 = 55
这段代码本身是对的(结果 55)。真正排查时,你会在 total += data[i] 那行打断点、watch total 观察累加过程、用 print i 和 print data[i] 看循环走到了哪一步。这就是 gdb 排查「结果不对」的标准姿势:先定位到错误的那一次循环,再往前推是哪个输入导致的。
6.5 受限环境下的替代:用 ASan/UBSan 拿到「崩溃栈」
gdb 最有价值的一招是崩溃后的 backtrace:告诉你崩在哪个函数、哪一行。这个能力在没有 gdb 的受限环境里,可以用 AddressSanitizer / UndefinedBehaviorSanitizer 顶上:编译期插桩,崩溃时直接打印精确的调用栈和出错行号。
下面是一个越界读(vector 只有 3 个元素却读第 4 个),ASan 的崩溃报告(真实输出,关键行):
==2==ERROR: AddressSanitizer: heap-buffer-overflow on address 0x60200000001c ...
READ of size 4 at 0x60200000001c thread T0
#0 0x4014cd in main /app/example.cpp:6
#1 0x72f42e62a1c9 (/lib/x86_64-linux-gnu/libc.so.6+0x2a1c9)
#2 0x72f42e62a28a in __libc_start_main ...
#3 0x401154 in _start ...
...
SUMMARY: AddressSanitizer: heap-buffer-overflow /app/example.cpp:6 in main
看 #0 ... in main /app/example.cpp:6 和 SUMMARY: ... /app/example.cpp:6。它直接告诉你越界发生在第 6 行,作用等价于 gdb 的 backtrace:崩溃现场自己带上了调用栈,不用重现。编译方式:
g++ -O1 -fsanitize=address -g demo.cpp -o demo # ASan + 调试信息
./demo # 越界时直接打印上面的崩溃栈
UBSan(-fsanitize=undefined)同理,能定位「有符号整数溢出」「无效类型转换」这类 UB 的确切行号。所以「沙箱没 gdb」不代表「没法做崩溃定位」:sanitizer 编译跑一遍,崩溃栈比 gdb 还省事(不用手动 break + run)。
补一句:性能侧的「谁在反复调用」用 gprof(真实调用图见 perf 篇第 2.5 节),系统调用侧的「程序卡在哪」用 strace 跟踪。这几样凑一起,就是受限环境里不靠 gdb 的一套调试工具箱。
7. 延伸阅读
- GDB 官方手册(sourceware.org):所有命令的权威说明,
break/watch/thread apply都在里面 - C++ Core Guidelines — 调试:调试与错误排查的工程基调
- Compiler Explorer(godbolt):在线看编译结果与汇编,调试「编译器生成什么」时配合用
本知识库内的相关篇目:
- 《perf 与火焰图:从热点到定位(接 Perfetto)》 —— 程序慢在哪?这篇讲 Linux perf 采样型 profiler 的原理(周期采样 + 栈回溯)、pe
- 《clang-tidy 与 cppcheck:静态分析落地》 —— 编译器 -Wall 能抓到的是语法和浅层语义问题
- 《二进制分析:nm、objdump、readelf 看懂符号表与段》 —— 链接报 multiple definition、undefined reference
8. 一句话总结
gdb 的核心就五件事:断点(break)、单步(next/step)、看值(print)、看栈(backtrace)、观察变化(watch);崩溃先看 core dump 的 backtrace,多线程卡死用 thread apply all bt。记住「编译加 -g -O0」这个前提。
407



被折叠的 条评论
为什么被折叠?



