GDB十命令速查
# 第22章:GDB/LLDB 十命令速查
# 目录介绍
- 1. 案例引入:两次 GDB 救命
- 2. GDB 在调试链中的位置
- 3. 启动调试的五种姿势
- 4. 断点与观察点
- 5. 单步与流程控制
- 6. 现场观察核心命令
- 7. 高级排查武器
- 8. 优化代码的调试坑
- 9. LLDB 命令对照
- 10. 调试方法论
- 11. 典型场景速查
- 12. 自动化与工程化
- 13. 综合案例串讲
# 1. 案例引入:两次 GDB 救命
"学 GDB 不是学命令——是学怎么在不能加日志、不能改代码、不能复现的情况下,把 bug 钉在十字架上。"
很多人翻 GDB 教程,看到 b/r/n/c/p 几个命令就以为学完了,结果真到生产事故时连"看死锁卡在哪两把锁"都做不到。本篇用两条真实主线贯穿全文:一条是生产服务的悬挂死锁,一条是 30 行复现的"变量值莫名其妙不对",覆盖事中实时调试与事后 core 复盘两种最常见的诊断路径。
# 1.1 主线一:凌晨的 Hang
某交易系统的撮合引擎,连续两个礼拜都是凌晨 4 点 17 分前后整个进程"卡死"——CPU 使用率掉到 0%、连接堆积、日志停在最后一条。没崩,没 core,连 SIGTERM 都不响应。运维只能 kill -9 重启,然后第二天问题继续。
代码上是这样的"再普通不过"的两个函数:
// matcher.cpp —— 撮合主循环
struct OrderBook {
std::mutex mtx;
std::list<Order> bids;
std::list<Order> asks;
};
void on_new_order(OrderBook& book, const Order& o) {
std::lock_guard<std::mutex> g(book.mtx);
if (o.side == BUY) {
book.bids.push_back(o);
if (auto match = find_cross_book(book, o)) {
settle(book, *match); // ← 内部还会拿 book.mtx
}
}
// ...
}
void settle(OrderBook& book, Order& m) {
std::lock_guard<std::mutex> g(book.mtx); // ← 二次加锁
book.asks.remove_if(...);
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
现象:
- 测试环境(QPS 1000,单线程):100% 通过;
- 灰度环境(QPS 1 万,多线程,开启对手方撮合):偶发卡死;
- 生产环境(QPS 8 万,行情高峰):每天凌晨 4 点 17 分必复现。
直觉怀疑:是不是某个外部依赖响应慢?撤掉所有外部依赖后仍然卡死——问题就在进程内部。
没 core,怎么破?标准做法:attach 上去看现场。
$ ps -ef | grep matcher
trader 31874 ... matcher --config=prod.yaml
$ gdb -p 31874
(gdb) info threads
Id Target Id Frame
1 Thread 0x7f3a... (LWP ...) __lll_lock_wait () at lll_lock_wait.S:50
2 Thread 0x7f3b... (LWP ...) __lll_lock_wait () at lll_lock_wait.S:50
3 Thread 0x7f3c... (LWP ...) __lll_lock_wait () at lll_lock_wait.S:50
* 23 Thread 0x7f4d... (LWP ...) __lll_lock_wait () at lll_lock_wait.S:50
(gdb) thread apply all bt 8
Thread 23 (LWP ...):
#0 __lll_lock_wait ()
#1 __pthread_mutex_lock_full ()
#2 std::mutex::lock ()
#3 std::lock_guard<std::mutex>::lock_guard (...)
#4 settle (book=..., m=...) at matcher.cpp:38 ← 二次加锁
#5 on_new_order (book=..., o=...) at matcher.cpp:24
...
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
真相浮现:std::mutex 不是递归锁——同一线程连续两次 lock 自己,直接死锁。生产高 QPS + 撮合命中概率高,触发 settle 在持锁状态下被调用的概率也高。
不是"高峰期才崩"——是"高峰期才走到这条路径"。
# 1.2 主线二:变量值不对
另一位同学发来求助:
"我写了个简单的累加,循环里
sum应该是 1+2+3+...+10=55,但调试器里看到的是 0。改成 cout 又对了——是 GDB 出 bug 了?"
打开他的 30 行代码:
// strange.cpp
#include <iostream>
int compute(int n) {
int sum = 0;
for (int i = 1; i <= n; ++i) {
sum += i;
}
return sum;
}
int main() {
int r = compute(10);
std::cout << r << "\n";
return 0;
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
调试现场:
$ g++ -O2 strange.cpp -o strange
$ gdb ./strange
(gdb) b compute
(gdb) r
Breakpoint 1, compute (n=10) at strange.cpp:3
(gdb) n
(gdb) p sum
$1 = <optimized out>
(gdb) p i
$2 = <optimized out>
2
3
4
5
6
7
8
9
10
两个现象很扎眼:
- 程序输出明明是 55(正确);
- GDB 里
p sum看到的却是<optimized out>,单步n还会跳过几行直接到return。
这不是 GDB bug,是 -O2 优化把局部变量"消灭"了——编译器看到 sum 没人观察,整个循环被向量化甚至常量折叠成 return 55;,DWARF 里根本不存在 sum 这个变量的存储位置。GDB 老老实实告诉你"它已经不存在了"。
好的调试,第一步永远是搞清楚"调试器看到的状态" vs "程序实际行为"。优化下的"行号跳跃"、"变量消失"、"内联函数没栈帧"——不是工具坏了,是工具诚实地暴露了优化器的工作。
# 1.3 顺藤摸到根因
带着两条主线往下挖,至少藏着这些原理点:
① GDB 是怎么"控制"一个进程的? → 第 2 章
② attach 之后凭什么能看寄存器和内存? → 第 2.2 节
③ 多线程 hang 怎么一眼看穿是不是死锁? → 第 7.1 节
④ -O2 下变量为什么会消失? → 第 8.1 节
⑤ 复杂工程怎么用断点+条件高效缩小问题? → 第 4 章
⑥ Python/define 怎么把重复操作脚本化? → 第 12 章
⑦ macOS 用 lldb 命令对应关系是什么? → 第 9 章
⑧ 不能复现的偶发 bug 怎么实时抓? → 第 11.5 节
2
3
4
5
6
7
8
# 1.4 本篇要回答什么
| 层次 | 你将学到 |
|---|---|
| 原理层 | GDB 基于 ptrace 控制进程、DWARF 调试信息、断点 INT 3 替换 |
| 命令层 | 启动、断点、单步、栈帧、变量、反汇编、多线程八大类命令 |
| 工具层 | GDB / LLDB / gdbserver / .gdbinit / Python API 完整生态 |
| 方法层 | 假设-验证循环、二分定位、状态比对、静态阅读优先 |
| 工程层 | CI batch 调试、IDE 集成、生产环境 attach 取数 |
📌 本篇定位:上一篇
02.ASan内存诊断讲的是"让 bug 在第一现场暴露",本篇讲的是**"现场已经抓到了,怎么把它钉死"**。读完本篇,再看任何 C++ bug,都能立刻回答:"用哪几条 GDB 命令、走怎样的假设-验证路径、需要看哪几个寄存器和变量值"。
# 2. GDB 在调试链中的位置
学一个工具之前先问"它能做什么、不能做什么"——比死记命令重要十倍。
# 2.1 GDB 是什么
GDB(GNU Debugger)是一个符号级调试器,能做四件事:
- 启动 / attach 一个进程,把它置于"被控制"状态;
- 在指定位置暂停——按行号、函数名、地址、条件、内存变化都行;
- 检查暂停时的状态——寄存器、内存、栈、变量、表达式求值;
- 改变运行——单步、跳到任意行、调用函数、修改变量后继续。
它不是:
- ❌ 不是反编译器(不能从二进制还原源码——只能借助 DWARF 显示原码)
- ❌ 不是性能分析工具(能数次数但不擅长,那是 perf 的活)
- ❌ 不是内存检测工具(越界 / UAF 应该用 ASan,不是 GDB)
- ❌ 不能"修复" bug(只能定位,修复永远是改源码)
理解这四个边界,能让你不在错误问题上消耗 GDB——比如"用 GDB 找内存泄漏"是新手最常见的弯路,正确做法是开 ASan/LSan,而不是 print 一万次。
# 2.2 ptrace 系统调用
很多人疑惑:GDB 凭什么能任意暂停、读取一个用户进程的内存?这不是越权了吗?
答案是 Linux 提供了一个专门的内核接口:ptrace(2)。
long ptrace(enum __ptrace_request request, pid_t pid,
void *addr, void *data);
2
GDB 用到的几个 request:
| request | 作用 |
|---|---|
PTRACE_TRACEME | 子进程自我请求被父进程跟踪(gdb run 走这条) |
PTRACE_ATTACH | 父进程主动 attach 到指定 PID |
PTRACE_PEEKTEXT/DATA | 读取目标进程任意虚拟地址 |
PTRACE_POKETEXT/DATA | 写入目标进程任意虚拟地址 |
PTRACE_GETREGS/SETREGS | 读写所有寄存器 |
PTRACE_CONT | 让目标进程继续运行 |
PTRACE_SINGLESTEP | 单步执行一条指令 |
断点的实现机制——这是 GDB 最核心的"魔法":
1. GDB 在断点行的指令地址处,用 ptrace POKETEXT 写入 0xCC(x86 的 INT 3 指令)
原指令的第一字节被备份到 GDB 内部表
2. CPU 执行到 0xCC → 触发 SIGTRAP
3. 内核把进程置为 stopped,唤醒 GDB
4. GDB 从 ptrace 读寄存器 / 内存,把现场展示给用户
5. 用户敲 c:GDB 把原指令字节恢复,单步执行一次(再次插回断点),然后 PTRACE_CONT
2
3
4
5
6
条件断点的实现:每次 SIGTRAP 都唤醒 GDB → 表达式求值 → 不满足条件就立刻 PTRACE_CONT,对程序透明。
重要后果:
- 条件断点比普通断点慢得多——每次都要切到 GDB;
/proc/sys/kernel/yama/ptrace_scope限制 attach 权限(值=1 时只能 attach 子进程);- 一个进程同时只能被一个 ptrace 跟踪——已经被 strace 占着的进程,GDB attach 会失败。
# 2.3 与日志/Sanitizer 互补
很多人有一种认知误区:"会写日志了,GDB 没用"。或者反过来"会 GDB 了,不需要日志"。两者各有不可替代的位置:
| 维度 | 日志 | GDB | Sanitizer |
|---|---|---|---|
| 介入时机 | 编码时埋点 | 事后或实时 attach | 编译期插桩 |
| 信息粒度 | 你写了什么就有什么 | 任意时刻全状态 | 内存访问全覆盖 |
| 启动成本 | 0 | 重启或 attach | 重编 |
| 运行开销 | 1-5% | 0(不调试时) | 2-5x |
| 是否需要现场 | 不需要 | 需要 | 不需要 |
| 对偶发 bug | 命中即知 | 必须复现 | 命中即知 |
| 对生产环境 | ✓ 标配 | △ attach 风险 | ✗(开销) |
| 对运行中状态 | ✗ 离线分析 | ✓ 实时观察 | △ |
金字塔结构:
┌───────── GDB / LLDB ──────────┐
│ 实时介入、状态全可见、最强 │
│ 但需要现场,无法常驻 │
└────────────────────────────────┘
┌───────── Sanitizer ───────────────┐
│ 自动暴露内存类 bug,重编一次开过 │
│ 无需手动观察,CI 必跑 │
└─────────────────────────────────────┘
┌───────── 日志 / Trace / 监控 ─────────┐
│ 生产唯一全程可用的观测手段 │
│ 但只能看你预先埋的点 │
└─────────────────────────────────────────┘
2
3
4
5
6
7
8
9
10
11
12
实战搭配:开发期 GDB 解决"我手头这个 bug",CI 用 Sanitizer 兜底"未来的 bug",生产用日志 + 监控覆盖"线上不可控的 bug"。三层缺一不可。
# 2.4 GDB vs LLDB 之分
LLDB 是 LLVM 项目的调试器,与 GDB 在功能上 90% 重叠,但定位与生态不同:
| 维度 | GDB | LLDB |
|---|---|---|
| 项目所属 | GNU | LLVM |
| 默认平台 | Linux | macOS / iOS |
| 命令风格 | 简短缩写 (b/r/p) | 自然子命令 (breakpoint set) |
| C++ 支持 | 强(搭配 GCC) | 强(搭配 Clang) |
| 脚本扩展 | Python / Guile | Python(一等公民) |
| 可视化 | TUI 模式 | 内置漂亮的颜色 + 上下文 |
| Mach-O 支持 | 弱 | 强(macOS 必选) |
| ELF 支持 | 强 | 也强 |
| 性能 | 启动快 | 大型二进制启动慢 |
| Apple Silicon | 需特殊版本 | 原生 |
实战经验:
- Linux 服务端 → GDB 默认
- macOS / iOS / Apple Silicon → LLDB
- 嵌入式 → GDB + gdbserver
- 跨平台库 → 两个都能用,命令一对一翻译就行(详见第 9 章)
# 2.5 调试器全景图
把"程序员盯着代码 → 调试器在做什么"画成一张图:
┌────────────────┐ ┌──────────────────┐
│ 程序员 │ │ 目标进程 │
│ (你) │ │ (a.out / pid) │
└────────┬───────┘ └────────┬─────────┘
│ 命令 │
▼ │
┌────────────────┐ ptrace │
│ GDB │ ◄─────────────────────────────► │
│ │ (PEEK/POKE/REGS/CONT) │
│ 断点表 │ │
│ 符号表(DWARF) │ │
│ 栈展开器 │ │
└────────┬───────┘ │
│ │
▼ ▼
┌──────────────────────────────────────────────────────┐
│ 内核 (ptrace 实现 + signal 投递) │
│ 捕获 SIGTRAP/SIGSEGV → 暂停 → 唤醒 GDB │
└───────────────────────────────────────────────────────┘
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
理解这张图,下面所有命令的"为什么这么用"都能想通。GDB 自己不直接改你的进程——它请内核帮忙改。这就是为什么 attach 需要权限、为什么远程调试要 gdbserver(远端跑一个轻量进程代为执行 ptrace)。
# 3. 启动调试的五种姿势
不同的现场要求不同的进入方式。把 5 种姿势记牢,下次遇到任何场景都不会卡住。
# 3.1 直接启动 args
最常用——程序还没跑,从头开始调:
gdb --args ./a.out --config=test.yaml --port 8080
(gdb) b main
(gdb) r # 运行至断点
2
3
--args 是关键——把后面所有参数都传给 a.out,而不是当 GDB 自己的参数解析。新手最常踩的坑:
# ❌ 错误写法:--port 8080 被 GDB 自己吃掉了
gdb ./a.out --config=test.yaml --port 8080
# ✓ 正确写法
gdb --args ./a.out --config=test.yaml --port 8080
2
3
4
5
预设环境变量:
(gdb) set environment LD_LIBRARY_PATH=/opt/lib
(gdb) set environment ASAN_OPTIONS=detect_leaks=1
(gdb) r
2
3
重定向标准输入输出:
(gdb) run < input.txt > output.log 2> err.log
# 3.2 attach 已运行进程
服务已经在跑了——比如主线一的撮合进程卡死时——直接 attach 进去看现场:
$ ps -ef | grep matcher
$ gdb -p 31874
# 或者进 gdb 之后:
(gdb) attach 31874
2
3
4
5
attach 的关键事实:
- 目标进程被立刻暂停(所有线程一起停)
- GDB 加载二进制和共享库的符号——大型进程可能要 10-30 秒
- 你按
c让它继续,正常处理流量
生产 attach 的注意事项:
- 延迟敏感服务慎用——attach + 加载符号的时间内,所有线程冻结
- 必须有 ptrace 权限——
/proc/sys/kernel/yama/ptrace_scope决定能不能 attach 非子进程 - detach 前必须
c一次或显式detach——直接 quit 会让进程留在 stopped 状态
(gdb) detach # 干净地 detach,目标进程恢复
(gdb) quit # 没 detach 就 quit,目标进程仍是 stopped
2
临时取数后再 detach 是最常见的姿势:
gdb -p 31874 -batch -ex "thread apply all bt" -ex "detach"
一行命令拿到所有线程的栈,对生产影响只有几百毫秒。
# 3.3 加载 core dump
进程已经崩了,留下了 core 文件——经典的事后复盘:
gdb /path/to/binary /path/to/core
# 或
gdb -c core /path/to/binary
2
3
这个场景在 01.信号崩溃快速排查 第 6 章已经详细讲过:要点是二进制和 core 文件版本必须严格一致,否则栈帧地址全错。
加载后必查三件事:
(gdb) info files # 1. 二进制路径对不对
(gdb) info sharedlibrary # 2. 共享库符号是否对上
(gdb) bt # 3. 直接看崩溃栈
2
3
如果 info sharedlibrary 看到很多 Yes (*) 或 ???——符号文件不一致,所有结论都不可信。这时要先把对应版本的 .debug 文件找回来:
(gdb) set debug-file-directory /path/to/debug-symbols
(gdb) sharedlibrary
2
# 3.4 远程调试 gdbserver
嵌入式设备 / 容器 / 远程服务器上跑的程序,本地用 GDB 调试:
# 远端:用 gdbserver 启动目标
gdbserver :1234 ./a.out --config=...
# 本端:用 gdb 连过去
gdb ./a.out
(gdb) target remote 192.168.1.5:1234
(gdb) b main
(gdb) c
2
3
4
5
6
7
8
gdbserver 的角色:远端只跑 gdbserver(几 MB 的小程序),它代为执行 ptrace、读写内存。GDB 主体(含符号解析、命令解析)跑在本地——这意味着远端不需要装完整 GDB,也不需要源码或调试符号。
本地 远端 (容器/设备)
───── ─────────────
GDB gdbserver
(含符号 + DWARF) ◄────► (轻量代理)
│ │
│ ▼
│ ./a.out (被调试)
│
远程协议 (RSP, Remote Serial Protocol)
2
3
4
5
6
7
8
9
容器场景:
# Docker 容器里
docker exec -it mycontainer gdbserver :1234 --attach <PID>
# 主机
gdb ./a.out -ex "target remote 127.0.0.1:1234"
2
3
4
Kubernetes pod 场景:用 kubectl port-forward 把 1234 端口映射到本机,再 target remote 127.0.0.1:1234。
# 3.5 batch 模式自动化
需要"无人值守地从 core 里取数据"——比如 CI 跑测试崩了,自动出报告:
gdb ./a.out core \
-batch \
-ex "set print pretty on" \
-ex "thread apply all bt full" \
-ex "info registers" \
-ex "p $_siginfo" \
-ex "info sharedlibrary" \
> crash_report.txt 2>&1
2
3
4
5
6
7
8
-batch 关键作用:
- 跑完所有
-ex命令后自动退出,不进入交互模式 - 抑制启动 banner,输出更干净
- 适合 shell 脚本里调用
主线一的极简自动化——发现 hang 之后从 hang 进程取栈:
#!/bin/bash
# auto_dump_hang.sh —— 一行命令救命脚本
PID=$(pgrep -f matcher)
gdb -p $PID -batch \
-ex "info threads" \
-ex "thread apply all bt" \
-ex "detach" \
> hang_dump_$(date +%s).log
2
3
4
5
6
7
8
挂一个 cron 每分钟跑一次,hang 的瞬间自动留快照——对生产影响约 100ms。
# 3.6 编译选项必备
GDB 调试效果好不好,70% 取决于编译选项。最低三件套:
g++ -g -O0 -fno-omit-frame-pointer a.cpp -o a.out
| 选项 | 作用 | 不加的后果 |
|---|---|---|
-g | 生成 DWARF 调试信息 | GDB 里只有地址没有行号、变量名 |
-O0 | 关闭优化 | <optimized out> 满屏,单步乱跳 |
-fno-omit-frame-pointer | 保留 RBP 帧指针 | 栈回溯不可靠 |
调试三档 + Release:
# 档 1:本地开发(信息最全)
g++ -g3 -O0 -fno-omit-frame-pointer # -g3 包含宏定义
# 档 2:可调试的 Release(推荐)
g++ -g -Og -fno-omit-frame-pointer # -Og 是"为调试而优化"
# 档 3:生产 Release(保留分离的调试符号)
g++ -O2 -g -fno-omit-frame-pointer
objcopy --only-keep-debug a.out a.out.debug
strip --strip-debug a.out
objcopy --add-gnu-debuglink=a.out.debug a.out
# 部署 a.out,调试时让 GDB 找 a.out.debug
# 档 4:纯 Release(不可调试)
g++ -O3 -DNDEBUG # 仅性能基准
2
3
4
5
6
7
8
9
10
11
12
13
14
15
-Og 是调试黄金平衡点——保留大部分变量可观察,开少量低风险优化(去死代码、寄存器分配等)。生产服务最适合的档位。
-g 与 -g3 区别:
| 等级 | 含义 |
|---|---|
-g0 | 不生成 |
-g1 | 仅函数级符号(栈回溯) |
-g / -g2 | 默认,行号 + 局部变量 |
-g3 | 加宏定义(#define 也能看到) |
经验:本地开发 -g3 -O0,灰度 -g -Og,生产 -O2 -g 分离 debug 符号。
# 4. 断点与观察点
断点是调试器最核心的能力。会用断点的程度,直接决定调试效率——新手只会 b main,进阶者用条件断点 + 命令脚本能省 90% 单步时间。
# 4.1 普通断点 b
最常见的几种写法:
(gdb) b main # 函数名
(gdb) b matcher.cpp:24 # 文件:行号
(gdb) b OrderBook::insert # 类成员
(gdb) b 'OrderBook::insert(Order const&)' # 重载时指定签名
(gdb) b *0x401234 # 直接断在指定地址
(gdb) b *func+8 # 函数偏移
2
3
4
5
6
断点管理:
(gdb) info breakpoints # 列出所有断点
Num Type Disp Enb Address What
1 breakpoint keep y 0x0000000000401130 in main at a.cpp:5
2 breakpoint keep n 0x0000000000401200 in handle at a.cpp:18
^^^^ ^
状态 是否启用
(gdb) disable 1 # 禁用
(gdb) enable 1 # 启用
(gdb) delete 1 # 删除
(gdb) clear matcher.cpp:24 # 按位置删除
2
3
4
5
6
7
8
9
10
11
rbreak(正则批量打断点)——大型项目里特别有用:
(gdb) rbreak ^OrderBook:: # 给 OrderBook 类的所有成员打断点
(gdb) rbreak matcher\.cpp:.* # 给 matcher.cpp 所有函数打断点
2
# 4.2 条件断点
最被低估的功能。普通断点每次都中——条件断点只在表达式为真时中:
(gdb) b matcher.cpp:24 if order.price > 1000
(gdb) b on_event if s->handler == 0x4141414141414141
(gdb) b worker if i == 9999 # 最经典——循环 1 万次只想看最后一次
2
3
用条件断点排查"偶发"bug 是必杀技。比如主线一的死锁——给 settle 函数打条件断点,只在"已经持有锁"时中:
(gdb) b settle if book.mtx.__data.__owner == pthread_self()
给已有断点加条件:
(gdb) condition 1 i > 100 # 给 1 号断点加条件
(gdb) condition 1 # 不传表达式 = 取消条件
2
性能注意:条件断点 = 每次都 trap → GDB 求值 → 不满足就继续。在热点路径每秒触发 100 万次的位置加条件,可能让程序慢 1000 倍。热点位置改用 watchpoint 或 hardware breakpoint。
# 4.3 临时与一次性断点
tbreak 是"用一次就消失"的断点:
(gdb) tbreak main # 命中一次后自动删除
(gdb) tbreak handler.cpp:42 if cnt > 100
2
适合"我只想看 main 入口一次,之后别再烦我"。比每次手动 delete 优雅得多。
# 4.4 watchpoint 数据观察
watchpoint 不是"代码执行到某行",而是"内存某个地址被读写"——是排查"我的变量被谁悄悄改了"的唯一武器:
(gdb) watch counter # 写时中断
(gdb) rwatch counter # 读时中断(罕见)
(gdb) awatch counter # 读或写都中断
2
3
实战用例:
struct Config {
int max_conn = 1000; // 启动时是 1000
};
Config g_config;
int main() {
// ... 跑了几小时之后 g_config.max_conn 变成了 0,谁干的?
}
2
3
4
5
6
7
8
(gdb) watch g_config.max_conn
Hardware watchpoint 1: g_config.max_conn
(gdb) c
Hardware watchpoint 1: g_config.max_conn
Old value = 1000
New value = 0
0x4012ab in load_user_setting (...) at config.cpp:88
2
3
4
5
6
7
8
hardware watchpoint vs software:
- 硬件(默认):用 CPU 的 DR0-DR3 调试寄存器,速度极快(~0 开销)。但最多 4 个,且变量大小 ≤ 8 字节
- 软件(
watch -location):每条指令后查一次,慢 100-1000 倍——慎用
GDB 默认尝试硬件 watchpoint,超出 4 个或变量太大才退化为软件。退化时会有警告:
Hardware watchpoint 5: ...
Watchpoint 5 deleted because the program has left the block in which its expression is valid.
2
watchpoint 失效场景:变量出作用域、被优化掉、内存被 munmap——会自动删除。
# 4.5 catchpoint 异常捕获
专门捕获异常事件——不在代码行,而在事件发生时中:
(gdb) catch throw # C++ 抛异常时中
(gdb) catch catch # catch 捕获到时中
(gdb) catch rethrow # 重新抛出时中
(gdb) catch syscall execve # 调 execve 时中
(gdb) catch syscall openat write # 多个
(gdb) catch fork # fork
(gdb) catch exec # exec
(gdb) catch load libssl.so # 加载某个库时
2
3
4
5
6
7
8
9
10
典型用法:"程序里有个未捕获的异常导致 abort,但日志只看到 SIGABRT,不知道是哪条 throw":
(gdb) catch throw
(gdb) r
Catchpoint 1 (exception thrown), 0x... in __cxa_throw ()
(gdb) bt
#0 __cxa_throw
#1 Parser::parse at parser.cpp:88 ← 找到 throw 点
2
3
4
5
6
第一现场抓 throw——比 abort 之后的栈更早一步,更接近 bug 源头。
# 4.6 断点命令脚本
断点不仅能停下来——还能自动执行一组命令:
(gdb) b on_event
(gdb) commands
> silent
> printf "event: type=%d, sid=%d\n", ev.type, ev.session_id
> bt 3
> continue
> end
2
3
4
5
6
7
效果:每次命中断点,自动打印 + 看栈 3 层 + 继续运行——相当于动态加日志,不需要重编代码!
silent 关键作用:抑制 GDB 默认的"Breakpoint 1, ..."提示,输出更干净。
生产 attach 取数实战——用主线一的场景演示:
gdb -p 31874 -batch \
-ex 'b settle' \
-ex 'commands\n silent\n bt 5\n continue\n end' \
-ex 'c' &
# 后台跑 60 秒后 kill
sleep 60 && kill $!
2
3
4
5
6
7
每次 settle 被调用都自动记录栈——用断点做轻量 trace,不需要重编、不需要装 dtrace/bpf。
# 5. 单步与流程控制
会按断点之后,下一步是"如何在断下来的位置精确推进"。GDB 提供了 6 个单步命令,覆盖从"行级"到"指令级"再到"逆向"。
# 5.1 next 与 step
最常用的两个,区别要分清:
(gdb) n / next # 执行下一行(不进入函数调用)
(gdb) s / step # 进入函数调用一行(深入调用层)
2
int compute(int n) {
int sum = 0; // ← 当前停这
helper(n); // 行 A
sum += n; // 行 B
return sum;
}
2
3
4
5
6
n在行 A:直接跳到行 B(helper 一气呵成跑完)s在行 A:跳进 helper 函数内部第一行
新手坑:想看 helper 内部细节但按了 n——helper 已经跑完了。养成习惯:第一次进任何函数都用 s。
带次数:
(gdb) n 10 # 一口气走 10 行(中间有断点会停)
(gdb) s 5 # 一口气 step 5 次
2
step 进入了不想进的函数怎么办——比如不小心 s 进了 std::string 的构造:
(gdb) finish # 跑完当前函数,停在调用方下一行
# 5.2 continue 与 finish
(gdb) c / continue # 一直跑直到下一个断点 / 程序结束
(gdb) finish / fin # 跑完当前函数,停在调用方
2
finish 的关键价值——不小心 step 太深时,一键跳出:
void on_event(...) {
log_info(...); // 不小心 step 进了 log_info
}
2
3
(gdb) s
(gdb) finish # 立刻回到 on_event 调用 log_info 的下一行
2
finish 还会打印函数返回值——这是 n 没有的功能,特别有用:
(gdb) finish
Run till exit from #0 helper (n=10) at a.cpp:5
0x401234 in main () at a.cpp:12
Value returned is $1 = 55
2
3
4
# 5.3 until 跳出循环
循环里调试到一半想"跳出当前循环再说"——until 是为这个设计的:
(gdb) until # 跑到当前行号之后的第一行(即跳过当前循环)
(gdb) until 88 # 跑到 88 行(必须在当前栈帧里)
2
for (int i = 0; i < 10000; ++i) {
process(items[i]); // ← 你在这里被一个断点停下了
}
// (gdb) until ← 直接跳到这行
finish_processing();
2
3
4
5
比起手动 n 一万次,until 是循环调试救星。
# 5.4 stepi 指令级单步
s/n 是行级单步,对应汇编可能是 10 条指令。需要更细粒度的——si/ni:
(gdb) si / stepi # 单步一条机器指令,进入 call
(gdb) ni / nexti # 单步一条机器指令,跳过 call
2
啥时候用:
- 调汇编代码(手写 SIMD、性能关键 hot path)
- 怀疑编译器优化把代码改写了,想看真实在执行什么
- 函数没源码(只有二进制),只能看汇编
(gdb) layout asm # TUI 模式显示汇编
(gdb) display/i $pc # 每次停下来都显示当前指令
(gdb) si
0x4012a8 mov %rdi, %rax
(gdb) si
0x4012ab mov (%rax), %rdx ← 这条会崩
2
3
4
5
6
# 5.5 reverse 反向调试
GDB 的杀手特性:能"倒着"执行——回到上一条指令的状态。
(gdb) target record-full # 打开记录
(gdb) c
... 程序跑出 bug ...
(gdb) reverse-continue / rc # 反向跑到上一个断点
(gdb) reverse-step / rs # 反向单步
(gdb) reverse-next / rn # 反向 next
2
3
4
5
6
典型用法:变量被改坏了,但不知道在哪改的——
(gdb) watch counter
(gdb) c
... watchpoint 触发,counter = 0 ...
(gdb) reverse-continue # 回到上一次写 counter 的地方
2
3
4
代价:record 模式下程序速度慢 1000 倍——只在能复现的小用例上用。
rr(Mozilla 出品的录制工具)是更强的替代:
rr record ./a.out arg1
rr replay # 进入 GDB,可以反复回放、反向运行
2
rr 性能开销只有 1.5x(比 GDB 自己的 record 快 1000 倍),是反向调试的工业级工具。对偶发 bug 救命级有用——录一次现场,反复 replay。
# 6. 现场观察核心命令
断下来之后看什么?六类信息覆盖 99% 的调试需求。
# 6.1 bt 与 backtrace
(gdb) bt # 简洁栈
(gdb) bt full # 每帧+局部变量
(gdb) bt 5 # 只看前 5 帧
(gdb) bt -5 # 只看最后 5 帧(栈底)
2
3
4
bt 的输出怎么读:
#0 matcher_kernel at matcher.cpp:88 ← 当前栈顶(最深一层)
#1 on_new_order at matcher.cpp:24
#2 EventLoop::run at loop.cpp:115
#3 worker_thread at thread.cpp:50
#4 start_thread from libpthread.so ← 栈底(线程入口)
2
3
4
5
栈帧编号 0 = 当前执行点;编号越大 = 越早被调用的函数。
bt 的三大判断:
- 栈是不是被踩烂了:满屏
??或地址离谱 → 栈缓冲区溢出 - 死循环 / 递归过深:栈底反复出现同一个函数 → 递归没出口
- 多线程死锁:
thread apply all bt看是否多个线程都卡在同一把锁(主线一的诊断方式)
# 6.2 frame 切栈帧
栈是有的,但你想看调用链中间的某一层——比如想看 EventLoop::run 当时 i 是几:
(gdb) frame 2 # 切到 #2 帧
(gdb) f 2 # 缩写
(gdb) up # 上一帧(编号大)
(gdb) down # 下一帧(编号小)
(gdb) up 3 # 往上 3 帧
2
3
4
5
切到目标帧后,所有 print / info locals 都基于那一帧的上下文。这是排查"为什么进了这个分支"的标准动作:
(gdb) f 1
#1 on_new_order (book=..., o=...) at matcher.cpp:24
(gdb) p o
$1 = {side = BUY, price = 100, qty = 50, ...}
(gdb) p book.bids.size()
$2 = 12345
2
3
4
5
6
# 6.3 info locals/args
(gdb) info locals # 当前帧所有局部变量
(gdb) info args # 当前帧所有参数
2
info args 在 bt 已经显示参数的情况下看似多余——但它会展开结构体内容而不只是地址。
全局变量 / 静态变量:
(gdb) info variables # 全部全局/静态(可能很多,慎用)
(gdb) info variables ^g_ # 名字以 g_ 开头的
2
# 6.4 print 与表达式
GDB 的 print 是完整 C++ 表达式求值器——比想象的强大得多:
(gdb) p var # 打印变量
(gdb) p var.field # 成员
(gdb) p arr[5] # 数组下标
(gdb) p *ptr # 解引用
(gdb) p ptr->member # 指针成员
(gdb) p func(123) # 调用函数(!)
(gdb) p sizeof(MyClass) # sizeof
(gdb) p (int)var * 2 + 5 # 任意表达式
(gdb) p {a, b, c} # GDB 自带的"数组字面量"
2
3
4
5
6
7
8
9
格式控制:
(gdb) p/x var # 十六进制
(gdb) p/d var # 十进制
(gdb) p/o var # 八进制
(gdb) p/t var # 二进制
(gdb) p/c var # 字符
(gdb) p/a addr # 地址 + 函数符号
(gdb) p/f var # 浮点
2
3
4
5
6
7
长字符串截断问题——默认 GDB 只显示前 200 字符的字符串:
(gdb) set print elements 0 # 不截断(0 = 无限)
(gdb) set print elements 1000 # 最多 1000 元素
2
结构体格式化:
(gdb) set print pretty on # 多行美化打印
(gdb) p complex_struct
$1 = {
field1 = 10,
field2 = "abc",
inner = {
a = 1,
b = 2
}
}
2
3
4
5
6
7
8
9
10
数组打印特殊语法——指针打成 N 个元素的数组:
(gdb) p ptr # 只打第一个元素
(gdb) p *ptr@10 # 打 ptr 指向的连续 10 个元素
(gdb) p (int[10])*ptr # 同上的另一种写法
2
3
# 6.5 display 持续观察
print 是一次性的——单步之后想再看同一个变量,又得 p 一遍。display 解决这个问题:
(gdb) display sum # 加入"自动显示列表"
(gdb) display/x ptr # 带格式
(gdb) info display # 看列表
(gdb) undisplay 1 # 删除 1 号
2
3
4
每次程序停下来(断点、单步、watchpoint),列表里所有项目都自动打印一次:
(gdb) display sum
(gdb) display i
(gdb) n
1: sum = 0
2: i = 1
(gdb) n
1: sum = 1
2: i = 2
2
3
4
5
6
7
8
循环调试神器——一次设置,每步自动看变化。
# 6.6 x 看内存
p 是按变量类型解读,x 是直接看裸字节——调汇编、查内存损坏的关键命令:
(gdb) x/<count><format><size> address
| 参数 | 取值 | 含义 |
|---|---|---|
| count | 数字 | 显示多少个单元 |
| format | x/d/u/o/t/a/c/s/i | 16/10进制/无符号/8进/2进/地址/字符/字符串/指令 |
| size | b/h/w/g | 1/2/4/8 字节 |
实战例子:
(gdb) x/16xb 0x401000 # 16 个字节,十六进制
(gdb) x/4xw $rsp # 栈顶 4 个 4 字节,十六进制
(gdb) x/10i $rip # 当前指令往后 10 条
(gdb) x/s ptr # 当字符串
(gdb) x/g &counter # 8 字节看 counter
2
3
4
5
调试主线二被优化掉的变量——直接看寄存器:
(gdb) info registers rax rbx rcx
rax 0x37 55 ← compute 的返回值就藏在这
2
(gdb) p $rax # 用 GDB 的 $reg 语法直接当变量
$1 = 55
2
寄存器作为变量:所有寄存器都能用 $rax / $rip / $rsp 形式访问,可以参与表达式:
(gdb) p (char*)$rdi # 强转成字符串
(gdb) p *(MyStruct*)$rax # 强转成对象
2
$_siginfo、$_thread、$_exitcode 等是 GDB 维护的"约定俗成"变量,详情见后续章节和 01.信号崩溃快速排查 第 7.4 节。
# 7. 高级排查武器
掌握了基础八条命令之后,下一层武器库——这些命令很多人不知道,但遇到对的场景时一条顶一百条。
# 7.1 多线程调试
主线一的死锁,靠的就是多线程命令。核心五条:
(gdb) info threads # 列出所有线程
(gdb) thread 5 # 切到 5 号线程
(gdb) thread apply all bt # 所有线程的栈
(gdb) thread apply all bt 5 # 每个线程只要前 5 帧(更易读)
(gdb) thread apply 1-3 bt # 1-3 号线程
2
3
4
5
info threads 输出解读:
Id Target Id Frame
* 1 Thread 0x7fff... (LWP 12345) __lll_lock_wait () at lll_lock_wait.S:50
2 Thread 0x7fff... (LWP 12346) __lll_lock_wait () at ...
3 Thread 0x7fff... (LWP 12347) futex_wait () at futex.c:88
^^^^^^^^^^^^
卡在哪——一眼可见
2
3
4
5
6
* 标记当前线程。(LWP <num>) 是内核视角的线程 ID,可以与 top -H 等工具的 TID 对应。
死锁诊断套路:
(gdb) thread apply all bt
# 寻找特征:多个线程都卡在 __lll_lock_wait / futex_wait / pthread_mutex_lock
# 然后看每个等的是哪把锁、谁持有它
# GDB 高版本支持直接看 mutex 信息
(gdb) p mutex.__data.__owner # 持有线程的 LWP
$1 = 12347 # 哦,是 3 号线程持着,但 3 号又在等别的锁 → 死锁
2
3
4
5
6
7
只让某个线程跑:
(gdb) set scheduler-locking on # 单步时只跑当前线程
(gdb) set scheduler-locking off # 默认,单步时所有线程一起跑
(gdb) set scheduler-locking step # 仅 step/next 时锁,continue 时所有线程跑
2
3
调试 race 的标准用法:scheduler-locking on + 在可疑点单步——避免单步时其他线程"跑过头"。
# 7.2 反汇编 disas
disas 把当前函数(或指定地址范围)反汇编出来:
(gdb) disas # 当前函数全部
(gdb) disas main # 指定函数
(gdb) disas $rip-32, $rip+16 # 当前指令前 32 后 16 字节
(gdb) disas /m main # 混合源码 + 汇编
(gdb) disas /s main # 类似 /m 但更紧凑(GDB 7.10+)
2
3
4
5
实战:编译器优化后的代码不知道在执行什么——
(gdb) disas /m compute
3 int sum = 0;
0x4011a0 <+0>: mov $0x0,%eax ← sum 在 eax 里
4 for (int i = 1; i <= n; ++i) {
0x4011a5 <+5>: test %edi,%edi ← n 在 edi
0x4011a7 <+7>: jle 0x4011c0
5 sum += i;
0x4011a9 <+9>: lea 0x1(%rdi),%edx
...
2
3
4
5
6
7
8
9
判断"是不是真的还在执行某行"——优化下行号映射不可靠,但 disas /m 能告诉你这段汇编对应源码哪几行。
# 7.3 调用函数与改值
GDB 能在调试时主动调用进程内的函数:
(gdb) call print_state() # 调用进程内的函数
(gdb) call (void)dump_book(book) # 调试用的 dump 函数
(gdb) p strlen("hello") # 直接 print 也算 call
2
3
典型用法:
- 程序里有个
dump_state()调试函数,平时不调用——卡住时手动 call - 调用
malloc_stats()看堆状态 - 调用
pthread_kill(tid, SIGSEGV)强制某线程崩溃看现场
改变变量值:
(gdb) set var x = 42
(gdb) set var arr[5] = 100
(gdb) set var ptr = 0 # 改成 nullptr
(gdb) set $rax = 0 # 改寄存器
2
3
4
实战:复现"用户报告说传特殊参数会崩"——参数构造起来麻烦,直接断点拦截后改值跑过去:
(gdb) b on_request
(gdb) c
Breakpoint 1, on_request (req=0x...) at server.cpp:42
(gdb) set var req->size = -1 # 注入异常值
(gdb) c
... 看会不会崩
2
3
4
5
6
绕过分支——比如想跳过验证:
(gdb) jump line_88 # 强制跳到 88 行
(gdb) return 0 # 直接从当前函数返回 0
2
警告:这些操作会让程序状态偏离真实运行——只用于调试探索,不是修复手段。
# 7.4 STL 容器美化
默认 GDB 打印 STL 容器很难看——比如 std::vector 显示一堆 _M_start / _M_finish 内部成员。
解决方案 1:用 -g3 编译 + GDB 7.0+ 的内置 pretty-printer:
(gdb) p v
$1 = std::vector of length 3, capacity 4 = {1, 2, 3}
(gdb) p m
$2 = std::map with 2 elements = {[1] = "a", [2] = "b"}
(gdb) p s
$3 = "hello world"
2
3
4
5
6
7
8
如果你的 GDB 显示的还是内部结构——多半是 libstdc++ 的 pretty-printer 没装:
# Ubuntu / Debian
sudo apt install libstdc++6-<ver>-dbg
# 或者手动加载
(gdb) python
import sys
sys.path.insert(0, '/usr/share/gcc-<ver>/python')
from libstdcxx.v6.printers import register_libstdcxx_printers
register_libstdcxx_printers(None)
end
2
3
4
5
6
7
8
9
10
解决方案 2:GDB 命令展开
(gdb) p v._M_impl._M_start[0]@v.size()
$1 = {1, 2, 3}
2
但这种写法依赖 libstdc++ 的内部命名,跨版本不可靠——优先方案 1。
# 7.5 Python 脚本扩展
GDB 内嵌完整 CPython 解释器,能用 Python 写自定义命令、pretty-printer、扩展逻辑。
简单例子:写一个"打印当前线程持有的所有锁"的命令:
# my_gdb.py
import gdb
class PrintLocks(gdb.Command):
"""打印当前线程持有的 mutex 列表"""
def __init__(self):
super().__init__("print-locks", gdb.COMMAND_USER)
def invoke(self, arg, from_tty):
tid = gdb.selected_thread().ptid[1]
mutexes = gdb.parse_and_eval("g_all_mutexes")
for i in range(int(mutexes.size())):
m = mutexes[i]
if int(m['__data']['__owner']) == tid:
gdb.write(f"holding mutex at {m.address}\n")
PrintLocks()
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
(gdb) source my_gdb.py
(gdb) print-locks
holding mutex at 0x603000004018
holding mutex at 0x603000005020
2
3
4
实战价值:把"经常重复的 5 步调试动作"封装成一条命令,存进 .gdbinit 永久可用。详细范式见第 12.3 节。
# 7.6 record 录制回放
GDB 自带的 record 模式能反向调试(5.5 节已介绍),但开销极大。生产级替代是 rr:
# 录制:跑一次,记录所有非确定性事件(系统调用、信号、寄存器结果)
rr record ./a.out --port 8080
# 回放:可以反复 replay 同一次执行
rr replay
(rr) c
... 跑到崩溃 ...
(rr) reverse-continue # 反向跑
(rr) when # 显示当前在录制时间线的位置
(rr) when-ticks # 精确到 cpu cycles
2
3
4
5
6
7
8
9
10
rr 是偶发 bug 的核武器:录一次现场可以反复回放、反向单步、watchpoint 任意打——把"难以复现"的 bug 变成"想看几遍看几遍"。
rr 的限制:
- 仅支持 x86_64 Linux
- 需要 CPU 支持 PMU(性能计数器,VM 里很多不支持)
- 录制时单线程的程序快 1.5x;多线程程序会被串行化(变慢但仍可调试)
# 8. 优化代码的调试坑
"代码看着对,调试器看着不对——多半是 -O2 的锅。"
主线二的"sum 是 0"现象不是 GDB bug,而是优化器与调试信息的根本矛盾。本章把这类坑全部讲透。
# 8.1 optimized out
最常见的现象:
(gdb) p sum
$1 = <optimized out>
2
原因:-O2 下编译器会把变量"提升"到寄存器、消除冗余存储、甚至完全消灭——DWARF 里没有它的"位置",GDB 老老实实告诉你"找不到"。
完整状态机(每个变量在 DWARF 里的"位置表达式"):
| 状态 | DWARF 描述 | GDB 显示 |
|---|---|---|
| 在内存 | DW_OP_fbreg -8 | 正常值 |
| 在寄存器 | DW_OP_reg5 (= rdi) | 正常值(编译器假定的当前值) |
| 部分在 | 不同 PC 范围不同位置 | 正常值(取决于停在哪) |
| 已消亡 | (无) | <optimized out> |
| 常量折叠 | DW_OP_const4u 55 | 正常值(直接显示 55) |
应对策略:
# 方案 1:调试时改用 -Og 重编(推荐)
g++ -Og -g a.cpp
# 方案 2:在你想观察的变量前加 volatile
volatile int sum = 0; # 强制保留存储
# 方案 3:直接看寄存器(懂汇编时)
(gdb) info registers rax
(gdb) disas /m func # 找出变量在哪
2
3
4
5
6
7
8
9
不要在 -O2 下用 volatile 代替 -Og——volatile 会禁止合法优化,影响性能。它只适合临时调试。
# 8.2 行号跳来跳去
(gdb) n
44 if (cnt > 100) {
(gdb) n
51 return result; # 跳过了几行!
(gdb) n
46 log(...); # 又跳回来了!
2
3
4
5
6
原因:编译器指令调度——把不同源码行的指令交错排列以提升流水线效率。DWARF 的"行号表"如实记录"这条指令对应哪一行",但行号顺序就乱了。
应对:
-Og大幅缓解- 看清楚的逻辑用
disas /m func过一遍——汇编是真相
# 8.3 内联函数无栈帧
inline int helper(int x) { return x * 2; }
int main() {
int r = helper(10); // 内联展开后没有 helper 调用
}
2
3
4
(gdb) bt
#0 main () at a.cpp:5 # 没看到 helper!
2
原因:内联后 helper 的代码已并入 main,没有独立栈帧——但DWARF 仍然有 inline 信息。bt 默认不展开内联,加 inline-frames:
(gdb) set print frame-info source-and-location
(gdb) bt full
# 或者 -gpubnames -gdwarf-5 配合 GDB 9+ 显示 inlined
2
3
4
设断点照样能命中内联函数:
(gdb) b helper
Breakpoint 1 set at <multiple locations>
2
GDB 会把 helper 的所有内联展开点都设上断点——你以为没栈帧,实际上行号映射还在。
# 8.4 -Og 折中方案
-Og 是 GCC 4.8+ / Clang 4+ 引入的"为调试优化的优化级"——平衡点:
| 优化项 | -O0 | -Og | -O2 |
|---|---|---|---|
| 死代码消除 | ✗ | ✓ | ✓ |
| 寄存器分配 | ✗ | ✓ | ✓ |
| 常量传播 | ✗ | ✓ | ✓ |
| 内联 | ✗ | ✗ | ✓ |
| 指令调度 | ✗ | ✗ | ✓ |
| 循环展开 | ✗ | ✗ | ✓ |
| 自动向量化 | ✗ | ✗ | ✓ |
结果:变量基本都保留、行号映射准确、性能比 -O0 快 2-3x、调试体验接近 -O0。
生产可调试的最佳选项:
g++ -Og -g -fno-omit-frame-pointer
很多大型 C++ 项目(LLVM、Chromium 的 debug build)默认都用 -Og。
# 8.5 split-dwarf 加速
大型 C++ 项目的 -g 二进制可能 GB 级别,链接、加载、调试都慢。split-dwarf 是工业标配方案:
g++ -gsplit-dwarf -g -O2 a.cpp -c -o a.o
# 生成 a.o(小,含 .debug_info 占位)+ a.dwo(大,真正的调试信息)
g++ -Wl,--gdb-index a.o -o a.out
# 链接时生成 .gdb_index,让 GDB 启动加速 10x+
2
3
4
5
好处:
- 链接器只处理小的 .o,不操作 GB 级 DWARF——链接快 5-10x
- 二进制本身小(不含 DWARF)——分发 / 加载快
- DWARF 在
.dwo文件里,调试时按需加载——首次启动 GDB 快得多
注意:部署时要把 .dwo 文件一起打包(或单独存档),不然 GDB 找不到调试信息。
# 推荐工程姿势:编译时分离
cmake -DCMAKE_CXX_FLAGS="-gsplit-dwarf -g -O2"
cmake --build . -- -j
# 出来的就是最佳调试 + 性能 + 体积平衡
2
3
4
# 9. LLDB 命令对照
macOS / iOS / Apple Silicon 强制 LLDB——所以即使你是 Linux 开发者,至少要会查 LLDB 等价命令。
# 9.1 命令一一对应
完整对照表(按使用频率排序):
| 用途 | GDB | LLDB |
|---|---|---|
| 启动 + 参数 | gdb --args ./a.out arg1 | lldb -- ./a.out arg1 |
| attach | gdb -p <PID> | lldb -p <PID> |
| 加载 core | gdb ./a.out core | lldb ./a.out -c core |
| 运行 | r | r |
| 设断点 | b func / b file.cpp:42 | b func / b file.cpp:42 |
| 条件断点 | b 42 if x > 5 | b 42 然后 breakpoint modify -c "x > 5" 1 |
| 列断点 | info breakpoints | breakpoint list / br l |
| 删断点 | delete 1 | breakpoint delete 1 |
| watchpoint | watch var | watchpoint set variable var |
| catch throw | catch throw | breakpoint set -E c++ |
| 单步入 | s | s |
| 单步过 | n | n |
| 跳出 | finish | finish |
| 继续 | c | c |
| 反汇编 | disas | disassemble / di |
| 看栈 | bt | bt |
| 切栈帧 | frame 3 | frame select 3 / f 3 |
| 上一帧 | up | up |
| 局部变量 | info locals | frame variable / fr v |
| 参数 | info args | frame variable -a |
| 打印 | p var | p var 或 expression var |
| 表达式 | p func(1) | expr func(1) |
| 改值 | set var x=5 | expr x=5 |
| 看内存 | x/16x addr | memory read --format x --count 16 addr |
| 寄存器 | info registers | register read |
| 多线程 | info threads | thread list |
| 切线程 | thread 3 | thread select 3 |
| 全栈 | thread apply all bt | bt all |
LLDB 命令是子命令风格——长但层次清晰。可以用 command alias 把它做短:
(lldb) command alias bp breakpoint set --name
(lldb) bp main
2
LLDB 也提供大量 GDB 风格的别名(b、p、bt),所以日常用法基本能直接迁移。
# 9.2 LLDB 独有特性
LLDB 有几个 GDB 没有的小特性:
1) gui 模式——内置 TUI 比 GDB 的 layout 漂亮:
(lldb) gui
2) Python 脚本是一等公民:
(lldb) script
>>> lldb.frame.FindVariable("x").GetValueAsUnsigned()
42
2
3
3) 表达式语言更接近 C++:
(lldb) expr -- auto v = std::vector<int>{1,2,3}; v.size()
(unsigned long) $0 = 3
2
GDB 的 print 不支持声明 auto 变量,LLDB 可以——某些场景调试体验更顺。
4) 内置颜色 + 上下文——默认输出多行带颜色,比 GDB 清爽(GDB 需 TUI)。
# 9.3 macOS 专属注意
macOS 上调试 C++ 有几个特有坑:
1) 必须签名才能调试:
sudo /usr/sbin/DevToolsSecurity -enable
sudo dscl . append /Groups/_developer GroupMembership $USER
2
否则 lldb -p <PID> 会拒绝。
2) SIP(系统完整性保护)禁止 attach 系统进程:
csrutil status # 看 SIP 状态
# 调试系统进程要在 Recovery 模式下关 SIP,强烈不推荐
2
3) Apple Silicon 与 Rosetta:
- 原生 ARM64 二进制 → 用 ARM64 LLDB
- Rosetta 转译的 x86_64 二进制 → 在 ARM64 主机上调试要用
arch -x86_64 lldb
4) Mach-O 调试符号:
- macOS 的调试符号默认在
.dSYM包里(不是嵌入二进制) dsymutil a.out生成a.out.dSYM/,LLDB 自动找
# 编译后必跑
clang++ -g -O0 a.cpp -o a.out
dsymutil a.out # 否则 LLDB 看不到行号
2
3
# 9.4 IDE 集成对比
调试器越来越少人手敲——IDE 集成才是主流。但底层走的还是 gdb / lldb 协议:
| IDE | 协议 | 后端 |
|---|---|---|
| VSCode (C/C++ ext) | DAP (Debug Adapter Protocol) | gdb / lldb / cppvsdbg |
| CLion | MI / DAP | gdb / lldb / bundled |
| Visual Studio | VS native | vsdbg / gdb (linux remote) |
| Xcode | LLDB native | lldb |
| Eclipse CDT | MI | gdb |
所有 IDE 调试卡顿的解决方案:
- 编译加
-gsplit-dwarf -gdwarf-4(DAP 解析快) - 关掉"自动求值所有变量"——大型对象树会让 IDE 卡几秒
- 远程调试用
gdbserver而不是 SSHFS-mount 整个项目
实战经验:
- 本地小项目 → IDE 调试足够
- 远程 / 大项目 → 命令行 GDB + tmux 比 IDE 稳
- 崩溃事后 → 命令行 + batch 模式 + 自动化脚本
# 10. 调试方法论
会用命令 ≠ 会调试。调试是科学方法的具体应用——一组可重复、可验证的思维范式。
# 10.1 假设-验证循环
所有调试本质上都是假设-验证循环:
┌──────────────────┐
│ 现象 / Bug 报告 │
└────────┬─────────┘
│
▼
┌──────────────────┐
│ 建立假设 H1, H2... │ ← 不要无脑乱试,列清单
│ 按可能性排序 │
└────────┬─────────┘
│
▼
┌──────────────────┐
│ 设计验证实验 │ ← 用 GDB / 日志 / 单测
│ 成本最低的先做 │
└────────┬─────────┘
│
▼
┌──────────────────┐
│ 收集证据 │
└────────┬─────────┘
│
▼
┌──────────────────┐
│ 对比假设 │
└─┬─────────┬──────┘
│支持 │反对
▼ ▼
根因找到 剔除假设,回到第 2 步
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
反模式:没有假设就开调试器一通乱按。结果是花两小时单步两万行代码,一无所获。
主线一的假设清单:
| # | 假设 | 验证成本 | 结果 |
|---|---|---|---|
| 1 | 外部依赖响应慢 | 低(断外部,1 小时) | ❌ 仍卡死 |
| 2 | 死锁 | 中(attach + bt all,2 分钟) | ✓ 命中 |
| 3 | 无限循环 | 中(同上) | - 不需验证 |
| 4 | 内核 hang | 高(dmesg / kdump) | - 不需验证 |
先验证 #2 是因为成本最低且可能性最高——一行 thread apply all bt 就能区分死锁/无限循环/外部 hang。
# 10.2 二分法定位
bug 的位置在某个范围内——二分能让定位时间从 O(N) 降到 O(log N):
1) 时间二分——什么时候开始崩的?
git log --since="last week" # 查最近修改
git bisect start
git bisect bad # 当前版本崩
git bisect good v1.2 # 一周前的好版本
# git bisect 自动二分,每次 checkout 一个中间 commit
2
3
4
5
2) 代码二分——bug 在哪段代码?
把可疑函数分成两半,用断点 + print 看变量是否已损坏:
void process() {
step_1(); // 断点 + 打印 → 变量正常
step_2();
// 断点 + 打印 → 变量损坏 → bug 在 step_2 或之前
step_3();
}
2
3
4
5
6
3) 数据二分——什么样的输入触发?
# 复现要求:1000 个测试样本里某些会触发
./run_tests --input=samples/ | tee log
# 二分数据集
mkdir half1 half2
mv samples/{0001..0500}.txt half1/
mv samples/{0501..1000}.txt half2/
./run_tests --input=half1/ # 复现就在 half1
2
3
4
5
6
7
8
4) 配置二分——同样的代码,开发环境对、生产错——一定是配置/环境差异:
开发环境配置 ─┬──────────────┐
│ 逐项替换为 │
生产环境配置 ─┴──→ 哪一项导致 bug?
2
3
把 N 项差异二分替换:第一次替换前一半,能复现就在前一半。最多 log₂(N) 次实验定位。
# 10.3 状态比对法
主线二的"sum=0" 是状态比对的典型——程序行为(输出 55)与调试器观察(sum=0)不一致。
调试中常见的三种"不一致":
| 不一致 | 含义 | 解决 |
|---|---|---|
| 程序行为正常,GDB 显示异常 | 优化导致 | 8.1-8.4 节,重编 -Og |
| 程序行为异常,单步又对了 | Heisenbug:观察改变行为 | 加日志而非单步、用 rr |
| 两次运行行为不同 | 非确定性(race / 未初始化 / 时间相关) | 重复跑找规律、改用 TSan |
Heisenbug 的核心特征:调试时变好。最常见原因:
- 多线程时序:单步打破并发顺序
- 未初始化变量:栈上恰好不同的残留值
- 缓存效应:单步让缓存表现不同
- printf 副作用:加 cout 改变了内存布局
应对:放弃"单步看现场",改用"事后看 record"——rr / 详细日志 / coredump。
# 10.4 静态阅读优先
"看 30 分钟代码,胜过开 GDB 单步 3 小时。"
这个原则反直觉但极重要。理由:
- GDB 显示当前状态——但 bug 经常在"以前某个状态"埋下;
- 代码阅读看路径——所有可能的状态空间都能想到;
- GDB 的成本是"反复跑"——代码阅读是一次性的脑力投入。
工程师成熟度的关键指标:拿到 bug 报告——
- 新手:立刻打开 IDE,断点 + 单步
- 进阶:先读相关代码 30 分钟、列假设清单,再决定要不要 GDB
- 专家:80% 的 bug 读代码就找到根因,GDB 只用来验证最后一步
什么时候必须开 GDB:
- 不能复现的偶发 bug(必须实时抓)
- 第三方库内部崩溃(没源码,只能看汇编)
- 涉及多线程时序的(脑里建模不可靠)
- core dump 已经在手(直接看现场比读代码快)
什么时候不要急着开 GDB:
- 能复现 + 现象明确 + 范围在一两个文件——读代码
- 本质是设计问题(不是代码 bug)——画架构图
# 10.5 心法八条
提炼一份"调试心法",每一条都是用血泪换来的:
1. 先有假设,再开调试器
没有假设的单步是"在迷宫里随机走"。每次开 GDB 之前问自己一句:"我要验证什么?"
2. 工具选择先于命令组合
崩溃 → core + GDB;偶发 → rr 或 attach;内存 → ASan;race → TSan。选错工具,再多命令也白搭。
3. 看清楚再动手
bt 至少看 5 帧再下结论。栈顶不一定是 bug 现场——可能是被牵连的库函数。
4. 三角验证
GDB 显示一个值、日志显示另一个、ASan 又显示第三个——先怀疑自己的方法而不是工具坏了。
5. 一次只改一个变量
调试中同时改三处看效果——结果分不清是哪一处生效。严格控制变量法。
6. 把每次调试沉淀为单测
一次 bug 修完后,写一个单测覆盖这条路径——下次回归立刻发现。
7. 信任工具,但理解原理
p sum = <optimized out> 不是 GDB 错——是你不懂 DWARF。理解原理才能正确解读结果。
8. 调试是技能不是天分
资深工程师调试快不是因为"聪明"——是心法 + 工具 + 经验 三者复利。每次调试积累一点,三年后就是专家。
# 11. 典型场景速查
把方法论落到 7 个最高频的实战场景。
# 11.1 空指针调成员
struct Node { int v; Node* next; };
void process(Node* p) {
int x = p->v; // p 是 nullptr → SIGSEGV
}
2
3
4
特征:bt 看到 this = 0x0 或非常小的地址(如 0x10,对应 nullptr->member 偏移)。
第一刀:
(gdb) bt
(gdb) frame 1
(gdb) info args # 看 p 是否是 0
(gdb) up
(gdb) info locals # 看 p 在调用方是怎么得到的
2
3
4
5
修法:参考 01.信号崩溃快速排查 第 11.1 节。
# 11.2 死循环不退出
while (cond) {
if (some_check()) break;
}
// 跑半天没退出——cond 永真?
2
3
4
第一刀:直接 attach 看在哪:
gdb -p <PID>
(gdb) bt # 卡在哪个函数
(gdb) info locals # 看循环变量值
(gdb) c # 让它跑一会儿
^C # Ctrl-C 再次中断
(gdb) bt # 还在同一个地方?= 死循环
# 如果两次 bt 在同一个函数 + 行号
(gdb) p some_check() # 主动 call,看返回什么
$1 = false # 永远不会 break
2
3
4
5
6
7
8
9
10
经典场景:while (running) {} 但 running 是 bool(非原子)——其他线程的修改对当前线程不可见,编译器优化成 while (true)。改 std::atomic<bool>。
# 11.3 多线程死锁
主线一的标准排查路径:
gdb -p <PID>
(gdb) info threads
# 寻找特征:多个线程都卡在 lock_wait
(gdb) thread apply all bt 5
# 看每个 mutex 的持有者
(gdb) thread 1
(gdb) frame 4
(gdb) p mutex.__data.__owner # = 哪个 LWP
(gdb) thread 5
(gdb) frame 4
(gdb) p mutex.__data.__owner # = 哪个 LWP
# 把"谁等谁"画出来 → 死锁环
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
修法:
- 同一个 mutex 不要重入 → 改
std::recursive_mutex(或重构调用顺序) - 多个 mutex 按全局顺序加锁 →
std::scoped_lock(m1, m2)自动 deadlock-avoidance - 性能允许的话用
std::shared_mutex减少冲突
# 11.4 内存内容被踩
struct Config {
int max_conn = 1000;
};
Config g_config;
// 程序跑半天后 g_config.max_conn 莫名其妙变成 -1
2
3
4
5
6
第一刀:watchpoint:
(gdb) watch g_config.max_conn
(gdb) c
Hardware watchpoint 1: g_config.max_conn
Old value = 1000
New value = -1
0x4012ab in load_setting at config.cpp:88 ← 找到罪犯
2
3
4
5
6
关键:watchpoint 是排查"是谁改了它"的唯一武器——如果不知道是谁,就是它。
搭配:变量太大装不下硬件 watchpoint 时,先用 ASan 看是不是越界踩到了它。
# 11.5 偶发崩溃排查
崩 10% 概率——开 GDB 一次大概率不复现。三种武器:
1) 实时 attach + 等触发:
gdb --args ./a.out
(gdb) catch signal SIGSEGV # 设崩溃陷阱
(gdb) r
# ... 跑很多遍直到某次崩 ...
(gdb) bt
2
3
4
5
2) 自动 core:
ulimit -c unlimited
echo "/tmp/core-%e-%p-%t" > /proc/sys/kernel/core_pattern
./a.out # 跑到崩
gdb ./a.out /tmp/core-* # 事后分析
2
3
4
3) rr 录制:
rr record ./a.out # 跑很多次直到某次崩
ls ~/.local/share/rr/ # 找最新一次的 trace
rr replay <latest-trace> # 反复回放,可反向调试
2
3
对偶发 race:默认 GDB 抓不到,靠 TSan + rr 组合。
# 11.6 第三方库内崩
崩在 std::string::operator[] 内部 / boost::... 内部:
(gdb) bt
#0 std::__1::basic_string<char>::operator[] at string:1234
#1 boost::regex::compile at boost/...
#2 my_function at app.cpp:42 ← 自己的代码
2
3
4
第一原则:bug 不在标准库——是你传给标准库的参数不对。直接上找到 #2(自己的代码):
(gdb) frame 2
(gdb) info args # 看参数是什么
(gdb) info locals
(gdb) up
(gdb) info args # 一路往上找直到找到坏数据来源
2
3
4
5
99% 的库内崩,根因都在调用方的某个未初始化 / 越界 / nullptr。
# 11.7 启动期就崩
$ ./a.out
Segmentation fault
# 还没到 main——全局对象构造期
2
3
(gdb) b main # 不会触发——还没到 main
(gdb) r
Program received signal SIGSEGV
# bt 显示在某个全局对象的构造函数里
(gdb) bt
#0 GlobalLogger::GlobalLogger () at logger.cpp:12
#1 __static_initialization_and_destruction_0
#2 _GLOBAL__sub_I__ZN12GlobalLoggerC2Ev
2
3
4
5
6
7
8
9
特征:栈底是 __static_initialization_*、_GLOBAL__sub_I_*——明确是全局对象初始化阶段。
典型病因:
- 跨 TU 全局对象初始化顺序未定义(参考
02.ASan第 11.4 节) - 依赖某个尚未初始化的全局对象
- dynamic_cast 失败 / 抛异常未捕获
修法:用 Meyers Singleton(局部 static)替代全局对象。
# 12. 自动化与工程化
把 GDB 用到团队工程实践层面——下面这些是从"会用"到"用得好"的关键设施。
# 12.1 .gdbinit 配置
GDB 启动时自动读 ~/.gdbinit 和当前目录的 .gdbinit。把常用配置写进去:
# ~/.gdbinit
# 美化打印
set print pretty on
set print elements 0
set print array on
set print array-indexes on
# 多线程友好
set non-stop off # 默认 all-stop(一停全停)
set scheduler-locking step # 单步时只跑当前线程
# 崩溃时自动停下
catch signal SIGSEGV
catch signal SIGBUS
catch signal SIGABRT
# 历史记录
set history save on
set history size 10000
set history filename ~/.gdb_history
# 关掉烦人的提示
set confirm off # 不要每次问 "Quit anyway? (y or n)"
set verbose off
set print thread-events off
# 加载 STL pretty-printer(如果没自动加载)
python
import sys
sys.path.insert(0, '/usr/share/gcc-13/python')
from libstdcxx.v6.printers import register_libstdcxx_printers
register_libstdcxx_printers(None)
end
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
项目专用 .gdbinit:放在项目根目录,进项目目录起 GDB 自动加载(需要 set auto-load safe-path / 或手动允许)。
# 12.2 自定义命令 define
# ~/.gdbinit
define crash-summary
printf "===== Crash Summary =====\n"
printf "Signal: %d\n", $_siginfo.si_signo
printf "Code: %d\n", $_siginfo.si_code
printf "Addr: "
p/x $_siginfo._sifields._sigfault.si_addr
printf "RIP: "
p/x $rip
printf "RDI: "
p/x $rdi
printf "Stack:\n"
bt 8
end
document crash-summary
一键打印崩溃摘要:信号、地址、寄存器、调用栈
end
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
之后每次崩溃只要 crash-summary 一行命令——所有关键信息一次给齐。
给主线一加一个"找死锁"命令:
define find-deadlock
set $current_tid = (long)pthread_self()
printf "Current thread TID = %ld\n", $current_tid
printf "\nLooking for threads waiting on lock...\n"
thread apply all bt 1 | grep -A1 lock_wait
end
2
3
4
5
6
# 12.3 Python 脚本范式
复杂逻辑用 Python 比 GDB 命令脚本灵活得多。最常用的三种范式:
范式 1:自定义命令(7.5 节给过例子)。
范式 2:自动 pretty-printer——给自定义类型写美化打印:
# my_printers.py
class OrderPrinter:
def __init__(self, val):
self.val = val
def to_string(self):
side = self.val['side']
price = self.val['price']
qty = self.val['qty']
return f"Order[{side}, {price}@{qty}]"
def register():
pp = gdb.printing.RegexpCollectionPrettyPrinter("MyApp")
pp.add_printer('Order', '^Order$', OrderPrinter)
gdb.printing.register_pretty_printer(gdb.current_objfile(), pp)
register()
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
效果:
(gdb) p o
$1 = Order[BUY, 100@50] ← 自定义美化输出
2
范式 3:批量处理——遍历数据结构、自动统计:
# count_pending_orders.py
def count_pending():
book = gdb.parse_and_eval("g_book")
bids = book['bids']
n = 0
it = bids['_M_impl']['_M_node']['_M_next']
end = bids['_M_impl']['_M_node'].address
while it != end:
n += 1
it = it.dereference()['_M_next']
print(f"pending orders: {n}")
count_pending()
2
3
4
5
6
7
8
9
10
11
12
13
工程实践:把项目的所有 Python 脚本放在 scripts/gdb/,.gdbinit 自动 source 进来。
# 12.4 CI 中的 batch 调试
CI 跑测试崩了——自动留下完整诊断报告:
# .github/workflows/ci.yml
- name: Run tests
run: ./run_tests.sh
continue-on-error: true
id: tests
- name: Collect crash reports
if: steps.tests.outcome == 'failure'
run: |
for core in /tmp/cores/core.*; do
gdb ./build/test_bin "$core" -batch \
-ex "set print pretty on" \
-ex "thread apply all bt full" \
-ex "info registers" \
-ex "p \$_siginfo" \
> "$core.report.txt"
done
- name: Upload reports
if: failure()
uses: actions/upload-artifact@v3
with:
name: crash-reports
path: /tmp/cores/*.report.txt
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
效果:CI 每次失败自动把 core 转成可读的文本报告,工程师下载 artifact 直接看根因,不需要本地复现。
# 12.5 与 IDE 协同
终极姿势:命令行 GDB 和 IDE 调试不互斥——根据场景切换:
本地开发新代码 → IDE(VSCode / CLion)调试
回归测试 + 单步 → IDE
线上问题排查 → 命令行 GDB + tmux
事后 core 分析 → 命令行 batch 模式
集群里的偶发 bug → rr record + replay
2
3
4
5
VSCode launch.json 模板:
{
"type": "cppdbg",
"request": "launch",
"program": "${workspaceFolder}/build/a.out",
"args": ["--config=test.yaml"],
"MIMode": "gdb",
"miDebuggerPath": "/usr/bin/gdb",
"setupCommands": [
{"text": "set print pretty on"},
{"text": "source ${workspaceFolder}/scripts/gdb/init.gdb"}
]
}
2
3
4
5
6
7
8
9
10
11
12
关键:把 .gdbinit 的逻辑也通过 setupCommands 传给 IDE——IDE 调试和命令行调试的体验一致。
# 13. 综合案例串讲
# 13.1 案例真相揭晓
回到第 1 节的两条主线,所有疑问现在能逐条作答:
主线一(凌晨死锁)的诊断链:
| 步骤 | 工具/命令 | 证据 |
|---|---|---|
| 1. 现象 | 监控 + 运维报告 | 凌晨 4:17 进程不响应 |
| 2. 排除外部 | 切外部依赖测试 | 仍卡死 → 内部问题 |
| 3. attach | gdb -p <PID> | 进程 stopped 成功 |
| 4. 看线程 | info threads | 多线程卡在 lock_wait |
| 5. 看栈 | thread apply all bt | settle 在 on_new_order 内被调用 |
| 6. 根因 | 代码审查 | settle 二次加锁同一 mutex → 死锁 |
| 7. 修复 | 重构 | settle 改为不加锁版本(调用方已持锁) |
| 8. 回归 | 高 QPS 压测 | 跑一周不再卡死 |
最终一句话:
settle在已持有book.mtx的状态下被调用,又试图二次lock同一 mutex——std::mutex不可重入,触发死锁。修复方案:拆分为settle_locked与settle,调用方按持锁状态选用。
主线二(变量值看不到)的诊断链:
| 步骤 | 工具/命令 | 证据 |
|---|---|---|
| 1. 现象 | GDB | p sum 返回 <optimized out> |
| 2. 怀疑 | 经验 | -O2 优化导致变量被消除 |
| 3. 验证 | disas /m compute | 整个 sum 累加被折叠成常量 55 |
| 4. 重编 | g++ -Og -g | sum 重新可见 |
| 5. 学到 | - | 调试用 -Og,不是 -O2 |
最终一句话:
-O2 优化把
sum从局部变量优化为寄存器传递、循环常量折叠为返回55,DWARF 中没有 sum 的存储位置——GDB 如实报告<optimized out>。改用-Og编译即恢复。
# 13.2 一次调试的全景
把"一次 bug 从报告到沉淀"串成一棵知识树:
Bug 报告: "凌晨 4 点服务卡死"
│
├─ 假设阶段 (第 10.1 节)
│ ├─ H1: 外部依赖慢 → 验证:切依赖 → 否决
│ ├─ H2: 死锁 → 验证:attach → ✓ 命中
│ └─ H3: 死循环 → 不需验证
│
├─ 工具选择 (第 2.3 节)
│ ├─ 没崩 → 不能用 core + GDB
│ ├─ 实时 hang → attach 模式(第 3.2 节)
│ └─ 多线程 → thread apply all bt(第 7.1 节)
│
├─ 现场观察 (第 6 章)
│ ├─ info threads → 多线程都在 lock_wait
│ ├─ frame 4 → 看到 settle 函数
│ ├─ p mutex.__data.__owner → 找到持有者
│ └─ bt + 代码 → 同一 mutex 二次加锁
│
├─ 根因确认
│ └─ "std::mutex 不可重入 + 调用链中重复加锁"
│
├─ 修复方案
│ ├─ A. 改 recursive_mutex(最快但不优)
│ ├─ B. 拆函数为 _locked + 不加锁版本(推荐)
│ └─ C. 用 std::scoped_lock 重构(架构级)
│
└─ 回归与沉淀 (第 12 章)
├─ 写一个高并发 + 撮合命中的单测
├─ 把 attach 取栈做成 cron 自动化脚本
├─ 团队 .gdbinit 加 find-deadlock 命令
└─ Code Review checklist 加"持锁状态下调用其他锁相关函数"
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
# 13.3 设计哲学回扣
四条跨篇适用的设计哲学:
哲学 1:分层抽象——内核 / GDB / 你
GDB 不直接控制进程——它请内核帮忙(ptrace)。你不直接看内存——你让 GDB 帮忙读取并按 DWARF 解读。多层间接代理正是 Unix 一以贯之的设计哲学。理解这种分层后,远程调试、容器调试、嵌入式调试都不再神秘——都是同一架构的延伸。
哲学 2:诚实暴露——<optimized out> 不是 bug
调试器选择告诉你真相而不是给你假象。优化后变量真的不存在了——GDB 不会编一个值给你。这种"工程诚实"是高质量工具的共同特征——表面上让用户难受,本质上避免了更大的误判。不诚实的调试器("显示一个看起来对但其实错的值")才是灾难。
哲学 3:假设驱动——没有目标的调试是浪费
调试不是"用工具看代码"——是"用工具验证假设"。没有假设的单步,是在迷宫里随机走。把"列出 N 条假设并按成本排序" 作为每次开 GDB 之前的强制动作,调试效率立刻翻倍。
哲学 4:自动化复利——一次调试,永久受益
每次调试结束都问自己:
- 这条命令组合能不能写成
define? - 这个判断能不能用 Python 自动化?
- 这次的 bug 能不能加一个单测?
- 团队
.gdbinit能不能加一条?
调试技能是复利投资——三年后你的 .gdbinit 是一份私人核武器库,新 bug 排查时间从 2 小时降到 5 分钟。
# 13.4 GDB 命令速查表
最高频 12 命令(覆盖 80% 场景):
gdb --args ./a.out arg1 # 启动
gdb -p <PID> # attach
gdb ./a.out core # 看 core
(gdb) b func # 断点
(gdb) r # 运行
(gdb) c # 继续
(gdb) n # 下一行(不进入函数)
(gdb) s # 进入函数
(gdb) finish # 跳出函数
(gdb) bt # 看栈
(gdb) p var # 打印变量
(gdb) info locals # 看局部变量
2
3
4
5
6
7
8
9
10
11
12
13
进阶 8 命令(覆盖 95% 场景):
(gdb) b file:42 if x > 5 # 条件断点
(gdb) watch var # 数据观察点
(gdb) catch throw # 异常捕获
(gdb) frame 3 # 切栈帧
(gdb) thread apply all bt # 所有线程栈
(gdb) disas /m func # 反汇编 + 源码
(gdb) x/16x addr # 看内存
(gdb) display var # 持续观察
2
3
4
5
6
7
8
专家 5 命令(救命级):
(gdb) commands # 断点附带脚本
(gdb) python ... # Python 扩展
(gdb) target remote ... # 远程调试
(gdb) record-full # 反向调试
(gdb) source script.gdb # 加载脚本
2
3
4
5
60 秒诊断流程:
# 1. 程序还在跑?
ps -ef | grep myapp
# 2. 跑着但卡死?→ attach
gdb -p <PID> -batch \
-ex "info threads" \
-ex "thread apply all bt 8" \
-ex "detach"
# 3. 已经崩了?→ core
ls /tmp/core-* # 或 coredumpctl list
gdb ./myapp /tmp/core-myapp-12345 -batch \
-ex "set print pretty on" \
-ex "thread apply all bt full" \
-ex "info registers" \
-ex "p \$_siginfo"
# 4. 没 core?→ 重启 + 配置
ulimit -c unlimited
echo "/tmp/core-%e-%p" > /proc/sys/kernel/core_pattern
# 5. 偶发难复现?→ rr
rr record ./myapp
rr replay
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
命令速选:
| 想做什么 | 命令 |
|---|---|
| 我崩在哪 | bt |
| 这个变量值是多少 | p var |
| 这个变量是谁改的 | watch var + c |
| 哪个线程卡住 | info threads + thread apply all bt |
| 这条指令在干嘛 | disas /m |
| 看内存原始字节 | x/16x addr |
| 跳过这个循环 | until |
| 提早返回这个函数 | return val |
| 我装饰打印效果 | set print pretty on + set print elements 0 |
# 13.5 思考题
你给一个 30 万行的老 C++ 项目第一次 attach,
info sharedlibrary显示一堆库的符号是Yes (*),bt 全是地址没有函数名——是什么问题?怎么解决?主线一里
thread apply all bt看到很多线程都在__lll_lock_wait,但没有任何一个线程持有 mutex(__owner == 0)。这种情况是什么场景?一段代码在 -O0 跑没问题,-O2 偶发崩溃。GDB 加载 -O2 的 core 看到栈完全乱掉。怎么找根因?
你想监控
g_counter何时变成负数(不是被改写时,而是改完之后值为负)。GDB 怎么做?一个进程 attach 失败提示 "Operation not permitted"。可能的原因有哪些?怎么逐项排除?
想在某个高频函数里"每 1000 次命中才停一次"——怎么实现?提示用 GDB 的 ignore 命令。
core 文件 4GB,gdb 加载花 15 分钟。怎么提速?提示与 split-dwarf 和 .gdb_index 有关。
一个 inline 函数想打断点,
b inline_func报 "multiple locations"——什么意思?应该怎么选?在 macOS 用 LLDB 调试一个 -O2 编译的 C++ 程序,
p一个 std::string 显示乱码。问题在哪?假设你是某团队的调试基础设施负责人。你会建立哪些工程实践、工具脚本、CI 流程,让团队的调试能力从"每个人各凭经验"提升到"全员标准化高效"?
GDB 不是命令清单——是状态机的放大镜。 同样一行
bt,在新手手里看到的是地址,在专家手里看到的是设计缺陷。
下一篇:本篇讲了"现场抓住怎么钉死它",下一步进入 04.CoreDump破案实录——把"事后从一个崩溃文件还原全部现场"的实战流程讲透。配套阅读:01.信号崩溃快速排查(崩溃信号定位)、02.ASan内存诊断(让内存类 bug 在第一现场暴露)、01.进程地址空间布局(多线程栈与寄存器的内存模型基础)。