编程进阶网 编程进阶网
首页
  • 计算机原理
  • 操作系统
  • 网络协议
  • 数据库原理
  • 面向对象
  • 设计原则
  • 设计模式
  • 系统架构
  • 性能优化
  • 编程原理
  • 方案设计
  • 稳定可靠
  • 工程运维
  • 基础认知
  • 线性结构
  • 树与哈希
  • 工业级实现
  • 算法思想
  • 实战与综合
  • 算法题考核
  • C语言入门
  • C综合案例
  • C专栏博客
  • C标准集库
  • C++入门教程
  • C++综合案例
  • C++专栏博客
  • C++开发技巧
  • Java入门教程
  • Java综合案例
  • Java专栏博客
  • Go入门教程
  • Go综合案例
  • Go专栏博客
  • Go开发技巧
  • JavaScript入门
  • JavaScript高级
  • Android库解读
  • Android专栏
  • Android智能硬件
  • iOS ObjC入门
  • iOS Swift入门
  • iOS入门精通
  • Web之Html手册
  • Web之TypeScript
  • Web之Vue高级进阶
  • Linux之QML入门
  • Linux之QT核心库
  • Linux实践开发
  • Python教程
  • Shell&Bash教程
  • 工具脚本
  • 自动化脚本
  • 质量保障
  • 产品思考
  • 软实力
  • 开发流程
  • Git应用
  • 技术模版
  • 技术规范
  • Markdown
  • Mermaid
  • 开源协议
  • JSON工具
  • 文本工具
  • 图片处理
  • 文档转化
  • 代码压缩
  • 关于我
  • 自我精进
  • 职场管理
  • 职场面试
  • 心情杂货
  • 友情链接

杨充

专注编程 · 终身学习者
首页
  • 计算机原理
  • 操作系统
  • 网络协议
  • 数据库原理
  • 面向对象
  • 设计原则
  • 设计模式
  • 系统架构
  • 性能优化
  • 编程原理
  • 方案设计
  • 稳定可靠
  • 工程运维
  • 基础认知
  • 线性结构
  • 树与哈希
  • 工业级实现
  • 算法思想
  • 实战与综合
  • 算法题考核
  • C语言入门
  • C综合案例
  • C专栏博客
  • C标准集库
  • C++入门教程
  • C++综合案例
  • C++专栏博客
  • C++开发技巧
  • Java入门教程
  • Java综合案例
  • Java专栏博客
  • Go入门教程
  • Go综合案例
  • Go专栏博客
  • Go开发技巧
  • JavaScript入门
  • JavaScript高级
  • Android库解读
  • Android专栏
  • Android智能硬件
  • iOS ObjC入门
  • iOS Swift入门
  • iOS入门精通
  • Web之Html手册
  • Web之TypeScript
  • Web之Vue高级进阶
  • Linux之QML入门
  • Linux之QT核心库
  • Linux实践开发
  • Python教程
  • Shell&Bash教程
  • 工具脚本
  • 自动化脚本
  • 质量保障
  • 产品思考
  • 软实力
  • 开发流程
  • Git应用
  • 技术模版
  • 技术规范
  • Markdown
  • Mermaid
  • 开源协议
  • JSON工具
  • 文本工具
  • 图片处理
  • 文档转化
  • 代码压缩
  • 关于我
  • 自我精进
  • 职场管理
  • 职场面试
  • 心情杂货
  • 友情链接
  • README
  • C语言入门精通

  • Cpp入门到精通

    • README
    • 入门教程

    • 综合案例

    • 专栏博客

    • 开发技巧

      • 信号崩溃快速排查
      • ASan内存三件套
      • GDB十命令速查
        • 1. 案例引入:两次 GDB 救命
          • 1.1 主线一:凌晨的 Hang
          • 1.2 主线二:变量值不对
          • 1.3 顺藤摸到根因
          • 1.4 本篇要回答什么
        • 2. GDB 在调试链中的位置
          • 2.1 GDB 是什么
          • 2.2 ptrace 系统调用
          • 2.3 与日志/Sanitizer 互补
          • 2.4 GDB vs LLDB 之分
          • 2.5 调试器全景图
        • 3. 启动调试的五种姿势
          • 3.1 直接启动 args
          • 3.2 attach 已运行进程
          • 3.3 加载 core dump
          • 3.4 远程调试 gdbserver
          • 3.5 batch 模式自动化
          • 3.6 编译选项必备
        • 4. 断点与观察点
          • 4.1 普通断点 b
          • 4.2 条件断点
          • 4.3 临时与一次性断点
          • 4.4 watchpoint 数据观察
          • 4.5 catchpoint 异常捕获
          • 4.6 断点命令脚本
        • 5. 单步与流程控制
          • 5.1 next 与 step
          • 5.2 continue 与 finish
          • 5.3 until 跳出循环
          • 5.4 stepi 指令级单步
          • 5.5 reverse 反向调试
        • 6. 现场观察核心命令
          • 6.1 bt 与 backtrace
          • 6.2 frame 切栈帧
          • 6.3 info locals/args
          • 6.4 print 与表达式
          • 6.5 display 持续观察
          • 6.6 x 看内存
        • 7. 高级排查武器
          • 7.1 多线程调试
          • 7.2 反汇编 disas
          • 7.3 调用函数与改值
          • 7.4 STL 容器美化
          • 7.5 Python 脚本扩展
          • 7.6 record 录制回放
        • 8. 优化代码的调试坑
          • 8.1 optimized out
          • 8.2 行号跳来跳去
          • 8.3 内联函数无栈帧
          • 8.4 -Og 折中方案
          • 8.5 split-dwarf 加速
        • 9. LLDB 命令对照
          • 9.1 命令一一对应
          • 9.2 LLDB 独有特性
          • 9.3 macOS 专属注意
          • 9.4 IDE 集成对比
        • 10. 调试方法论
          • 10.1 假设-验证循环
          • 10.2 二分法定位
          • 10.3 状态比对法
          • 10.4 静态阅读优先
          • 10.5 心法八条
        • 11. 典型场景速查
          • 11.1 空指针调成员
          • 11.2 死循环不退出
          • 11.3 多线程死锁
          • 11.4 内存内容被踩
          • 11.5 偶发崩溃排查
          • 11.6 第三方库内崩
          • 11.7 启动期就崩
        • 12. 自动化与工程化
          • 12.1 .gdbinit 配置
          • 12.2 自定义命令 define
          • 12.3 Python 脚本范式
          • 12.4 CI 中的 batch 调试
          • 12.5 与 IDE 协同
        • 13. 综合案例串讲
          • 13.1 案例真相揭晓
          • 13.2 一次调试的全景
          • 13.3 设计哲学回扣
          • 13.4 GDB 命令速查表
          • 13.5 思考题
      • CoreDump破案
      • perf火焰图实战
      • 迭代器失效陷阱
      • 智能指针选型
      • 异常安全RAII
      • 多线程锁选型
      • 编译期防御
  • Java入门精通

  • Go入门到精通

  • JavaScript入门

  • CodeX
  • Cpp入门到精通
  • 开发技巧
杨充
2026-06-15
目录

GDB十命令速查

# 第22章:GDB/LLDB 十命令速查

# 目录介绍

  • 1. 案例引入:两次 GDB 救命
    • 1.1 主线一:凌晨的 Hang
    • 1.2 主线二:变量值不对
    • 1.3 顺藤摸到根因
    • 1.4 本篇要回答什么
  • 2. GDB 在调试链中的位置
    • 2.1 GDB 是什么
    • 2.2 ptrace 系统调用
    • 2.3 与日志/Sanitizer 互补
    • 2.4 GDB vs LLDB 之分
    • 2.5 调试器全景图
  • 3. 启动调试的五种姿势
    • 3.1 直接启动 args
    • 3.2 attach 已运行进程
    • 3.3 加载 core dump
    • 3.4 远程调试 gdbserver
    • 3.5 batch 模式自动化
    • 3.6 编译选项必备
  • 4. 断点与观察点
    • 4.1 普通断点 b
    • 4.2 条件断点
    • 4.3 临时与一次性断点
    • 4.4 watchpoint 数据观察
    • 4.5 catchpoint 异常捕获
    • 4.6 断点命令脚本
  • 5. 单步与流程控制
    • 5.1 next 与 step
    • 5.2 continue 与 finish
    • 5.3 until 跳出循环
    • 5.4 stepi 指令级单步
    • 5.5 reverse 反向调试
  • 6. 现场观察核心命令
    • 6.1 bt 与 backtrace
    • 6.2 frame 切栈帧
    • 6.3 info locals/args
    • 6.4 print 与表达式
    • 6.5 display 持续观察
    • 6.6 x 看内存
  • 7. 高级排查武器
    • 7.1 多线程调试
    • 7.2 反汇编 disas
    • 7.3 调用函数与改值
    • 7.4 STL 容器美化
    • 7.5 Python 脚本扩展
    • 7.6 record 录制回放
  • 8. 优化代码的调试坑
    • 8.1 optimized out
    • 8.2 行号跳来跳去
    • 8.3 内联函数无栈帧
    • 8.4 -Og 折中方案
    • 8.5 split-dwarf 加速
  • 9. LLDB 命令对照
    • 9.1 命令一一对应
    • 9.2 LLDB 独有特性
    • 9.3 macOS 专属注意
    • 9.4 IDE 集成对比
  • 10. 调试方法论
    • 10.1 假设-验证循环
    • 10.2 二分法定位
    • 10.3 状态比对法
    • 10.4 静态阅读优先
    • 10.5 心法八条
  • 11. 典型场景速查
    • 11.1 空指针调成员
    • 11.2 死循环不退出
    • 11.3 多线程死锁
    • 11.4 内存内容被踩
    • 11.5 偶发崩溃排查
    • 11.6 第三方库内崩
    • 11.7 启动期就崩
  • 12. 自动化与工程化
    • 12.1 .gdbinit 配置
    • 12.2 自定义命令 define
    • 12.3 Python 脚本范式
    • 12.4 CI 中的 batch 调试
    • 12.5 与 IDE 协同
  • 13. 综合案例串讲
    • 13.1 案例真相揭晓
    • 13.2 一次调试的全景
    • 13.3 设计哲学回扣
    • 13.4 GDB 命令速查表
    • 13.5 思考题

# 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(...);
}
1
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
...
1
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;
}
1
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>
1
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 节
1
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)是一个符号级调试器,能做四件事:

  1. 启动 / attach 一个进程,把它置于"被控制"状态;
  2. 在指定位置暂停——按行号、函数名、地址、条件、内存变化都行;
  3. 检查暂停时的状态——寄存器、内存、栈、变量、表达式求值;
  4. 改变运行——单步、跳到任意行、调用函数、修改变量后继续。

它不是:

  • ❌ 不是反编译器(不能从二进制还原源码——只能借助 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);
1
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
1
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 / 监控 ─────────┐
            │  生产唯一全程可用的观测手段             │
            │  但只能看你预先埋的点                    │
            └─────────────────────────────────────────┘
1
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           │
   └───────────────────────────────────────────────────────┘
1
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                    # 运行至断点
1
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
1
2
3
4
5

预设环境变量:

(gdb) set environment LD_LIBRARY_PATH=/opt/lib
(gdb) set environment ASAN_OPTIONS=detect_leaks=1
(gdb) r
1
2
3

重定向标准输入输出:

(gdb) run < input.txt > output.log 2> err.log
1

# 3.2 attach 已运行进程

服务已经在跑了——比如主线一的撮合进程卡死时——直接 attach 进去看现场:

$ ps -ef | grep matcher
$ gdb -p 31874

# 或者进 gdb 之后:
(gdb) attach 31874
1
2
3
4
5

attach 的关键事实:

  1. 目标进程被立刻暂停(所有线程一起停)
  2. GDB 加载二进制和共享库的符号——大型进程可能要 10-30 秒
  3. 你按 c 让它继续,正常处理流量

生产 attach 的注意事项:

  • 延迟敏感服务慎用——attach + 加载符号的时间内,所有线程冻结
  • 必须有 ptrace 权限——/proc/sys/kernel/yama/ptrace_scope 决定能不能 attach 非子进程
  • detach 前必须 c 一次或显式 detach——直接 quit 会让进程留在 stopped 状态
(gdb) detach           # 干净地 detach,目标进程恢复
(gdb) quit             # 没 detach 就 quit,目标进程仍是 stopped
1
2

临时取数后再 detach 是最常见的姿势:

gdb -p 31874 -batch -ex "thread apply all bt" -ex "detach"
1

一行命令拿到所有线程的栈,对生产影响只有几百毫秒。

# 3.3 加载 core dump

进程已经崩了,留下了 core 文件——经典的事后复盘:

gdb /path/to/binary /path/to/core
# 或
gdb -c core /path/to/binary
1
2
3

这个场景在 01.信号崩溃快速排查 第 6 章已经详细讲过:要点是二进制和 core 文件版本必须严格一致,否则栈帧地址全错。

加载后必查三件事:

(gdb) info files                       # 1. 二进制路径对不对
(gdb) info sharedlibrary               # 2. 共享库符号是否对上
(gdb) bt                               # 3. 直接看崩溃栈
1
2
3

如果 info sharedlibrary 看到很多 Yes (*) 或 ???——符号文件不一致,所有结论都不可信。这时要先把对应版本的 .debug 文件找回来:

(gdb) set debug-file-directory /path/to/debug-symbols
(gdb) sharedlibrary
1
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
1
2
3
4
5
6
7
8

gdbserver 的角色:远端只跑 gdbserver(几 MB 的小程序),它代为执行 ptrace、读写内存。GDB 主体(含符号解析、命令解析)跑在本地——这意味着远端不需要装完整 GDB,也不需要源码或调试符号。

本地                                远端 (容器/设备)
─────                              ─────────────
   GDB                            gdbserver
   (含符号 + DWARF)        ◄────►  (轻量代理)
        │                              │
        │                              ▼
        │                        ./a.out (被调试)
        │
   远程协议 (RSP, Remote Serial Protocol)
1
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"
1
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
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
1
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
1
选项 作用 不加的后果
-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                          # 仅性能基准
1
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                 # 函数偏移
1
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      # 按位置删除
1
2
3
4
5
6
7
8
9
10
11

rbreak(正则批量打断点)——大型项目里特别有用:

(gdb) rbreak ^OrderBook::       # 给 OrderBook 类的所有成员打断点
(gdb) rbreak matcher\.cpp:.*    # 给 matcher.cpp 所有函数打断点
1
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 万次只想看最后一次
1
2
3

用条件断点排查"偶发"bug 是必杀技。比如主线一的死锁——给 settle 函数打条件断点,只在"已经持有锁"时中:

(gdb) b settle if book.mtx.__data.__owner == pthread_self()
1

给已有断点加条件:

(gdb) condition 1 i > 100        # 给 1 号断点加条件
(gdb) condition 1                # 不传表达式 = 取消条件
1
2

性能注意:条件断点 = 每次都 trap → GDB 求值 → 不满足就继续。在热点路径每秒触发 100 万次的位置加条件,可能让程序慢 1000 倍。热点位置改用 watchpoint 或 hardware breakpoint。

# 4.3 临时与一次性断点

tbreak 是"用一次就消失"的断点:

(gdb) tbreak main             # 命中一次后自动删除
(gdb) tbreak handler.cpp:42 if cnt > 100
1
2

适合"我只想看 main 入口一次,之后别再烦我"。比每次手动 delete 优雅得多。

# 4.4 watchpoint 数据观察

watchpoint 不是"代码执行到某行",而是"内存某个地址被读写"——是排查"我的变量被谁悄悄改了"的唯一武器:

(gdb) watch counter            # 写时中断
(gdb) rwatch counter           # 读时中断(罕见)
(gdb) awatch counter           # 读或写都中断
1
2
3

实战用例:

struct Config {
    int max_conn = 1000;        // 启动时是 1000
};
Config g_config;

int main() {
    // ... 跑了几小时之后 g_config.max_conn 变成了 0,谁干的?
}
1
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
1
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.
1
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           # 加载某个库时
1
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 点
1
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
1
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 $!
1
2
3
4
5
6
7

每次 settle 被调用都自动记录栈——用断点做轻量 trace,不需要重编、不需要装 dtrace/bpf。

# 5. 单步与流程控制

会按断点之后,下一步是"如何在断下来的位置精确推进"。GDB 提供了 6 个单步命令,覆盖从"行级"到"指令级"再到"逆向"。

# 5.1 next 与 step

最常用的两个,区别要分清:

(gdb) n / next       # 执行下一行(不进入函数调用)
(gdb) s / step       # 进入函数调用一行(深入调用层)
1
2
int compute(int n) {
    int sum = 0;          // ← 当前停这
    helper(n);            // 行 A
    sum += n;             // 行 B
    return sum;
}
1
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 次
1
2

step 进入了不想进的函数怎么办——比如不小心 s 进了 std::string 的构造:

(gdb) finish         # 跑完当前函数,停在调用方下一行
1

# 5.2 continue 与 finish

(gdb) c / continue   # 一直跑直到下一个断点 / 程序结束
(gdb) finish / fin   # 跑完当前函数,停在调用方
1
2

finish 的关键价值——不小心 step 太深时,一键跳出:

void on_event(...) {
    log_info(...);              // 不小心 step 进了 log_info
}
1
2
3
(gdb) s
(gdb) finish        # 立刻回到 on_event 调用 log_info 的下一行
1
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
1
2
3
4

# 5.3 until 跳出循环

循环里调试到一半想"跳出当前循环再说"——until 是为这个设计的:

(gdb) until           # 跑到当前行号之后的第一行(即跳过当前循环)
(gdb) until 88        # 跑到 88 行(必须在当前栈帧里)
1
2
for (int i = 0; i < 10000; ++i) {
    process(items[i]);    // ← 你在这里被一个断点停下了
}
// (gdb) until ← 直接跳到这行
finish_processing();
1
2
3
4
5

比起手动 n 一万次,until 是循环调试救星。

# 5.4 stepi 指令级单步

s/n 是行级单步,对应汇编可能是 10 条指令。需要更细粒度的——si/ni:

(gdb) si / stepi        # 单步一条机器指令,进入 call
(gdb) ni / nexti        # 单步一条机器指令,跳过 call
1
2

啥时候用:

  • 调汇编代码(手写 SIMD、性能关键 hot path)
  • 怀疑编译器优化把代码改写了,想看真实在执行什么
  • 函数没源码(只有二进制),只能看汇编
(gdb) layout asm        # TUI 模式显示汇编
(gdb) display/i $pc     # 每次停下来都显示当前指令
(gdb) si
0x4012a8  mov   %rdi, %rax
(gdb) si
0x4012ab  mov   (%rax), %rdx     ← 这条会崩
1
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
1
2
3
4
5
6

典型用法:变量被改坏了,但不知道在哪改的——

(gdb) watch counter
(gdb) c
... watchpoint 触发,counter = 0 ...
(gdb) reverse-continue        # 回到上一次写 counter 的地方
1
2
3
4

代价:record 模式下程序速度慢 1000 倍——只在能复现的小用例上用。

rr(Mozilla 出品的录制工具)是更强的替代:

rr record ./a.out arg1
rr replay                     # 进入 GDB,可以反复回放、反向运行
1
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 帧(栈底)
1
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         ← 栈底(线程入口)
1
2
3
4
5

栈帧编号 0 = 当前执行点;编号越大 = 越早被调用的函数。

bt 的三大判断:

  1. 栈是不是被踩烂了:满屏 ?? 或地址离谱 → 栈缓冲区溢出
  2. 死循环 / 递归过深:栈底反复出现同一个函数 → 递归没出口
  3. 多线程死锁: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 帧
1
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
1
2
3
4
5
6

# 6.3 info locals/args

(gdb) info locals          # 当前帧所有局部变量
(gdb) info args            # 当前帧所有参数
1
2

info args 在 bt 已经显示参数的情况下看似多余——但它会展开结构体内容而不只是地址。

全局变量 / 静态变量:

(gdb) info variables       # 全部全局/静态(可能很多,慎用)
(gdb) info variables ^g_   # 名字以 g_ 开头的
1
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 自带的"数组字面量"
1
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          # 浮点
1
2
3
4
5
6
7

长字符串截断问题——默认 GDB 只显示前 200 字符的字符串:

(gdb) set print elements 0           # 不截断(0 = 无限)
(gdb) set print elements 1000        # 最多 1000 元素
1
2

结构体格式化:

(gdb) set print pretty on            # 多行美化打印
(gdb) p complex_struct
$1 = {
  field1 = 10,
  field2 = "abc",
  inner = {
    a = 1,
    b = 2
  }
}
1
2
3
4
5
6
7
8
9
10

数组打印特殊语法——指针打成 N 个元素的数组:

(gdb) p ptr               # 只打第一个元素
(gdb) p *ptr@10           # 打 ptr 指向的连续 10 个元素
(gdb) p (int[10])*ptr     # 同上的另一种写法
1
2
3

# 6.5 display 持续观察

print 是一次性的——单步之后想再看同一个变量,又得 p 一遍。display 解决这个问题:

(gdb) display sum                # 加入"自动显示列表"
(gdb) display/x ptr              # 带格式
(gdb) info display               # 看列表
(gdb) undisplay 1                # 删除 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
1
2
3
4
5
6
7
8

循环调试神器——一次设置,每步自动看变化。

# 6.6 x 看内存

p 是按变量类型解读,x 是直接看裸字节——调汇编、查内存损坏的关键命令:

(gdb) x/<count><format><size> address
1
参数 取值 含义
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
1
2
3
4
5

调试主线二被优化掉的变量——直接看寄存器:

(gdb) info registers rax rbx rcx
rax            0x37 55              ← compute 的返回值就藏在这
1
2
(gdb) p $rax       # 用 GDB 的 $reg 语法直接当变量
$1 = 55
1
2

寄存器作为变量:所有寄存器都能用 $rax / $rip / $rsp 形式访问,可以参与表达式:

(gdb) p (char*)$rdi               # 强转成字符串
(gdb) p *(MyStruct*)$rax          # 强转成对象
1
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 号线程
1
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
                                     ^^^^^^^^^^^^
                                     卡在哪——一眼可见
1
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 号又在等别的锁 → 死锁
1
2
3
4
5
6
7

只让某个线程跑:

(gdb) set scheduler-locking on        # 单步时只跑当前线程
(gdb) set scheduler-locking off       # 默认,单步时所有线程一起跑
(gdb) set scheduler-locking step      # 仅 step/next 时锁,continue 时所有线程跑
1
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+)
1
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
   ...
1
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
1
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                   # 改寄存器
1
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
... 看会不会崩
1
2
3
4
5
6

绕过分支——比如想跳过验证:

(gdb) jump line_88                   # 强制跳到 88 行
(gdb) return 0                       # 直接从当前函数返回 0
1
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"
1
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
1
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}
1
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()
1
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
1
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
1
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>
1
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              # 找出变量在哪
1
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(...);           # 又跳回来了!
1
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 调用
}
1
2
3
4
(gdb) bt
#0  main () at a.cpp:5             # 没看到 helper!
1
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
1
2
3
4

设断点照样能命中内联函数:

(gdb) b helper
Breakpoint 1 set at <multiple locations>
1
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
1

很多大型 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+
1
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
# 出来的就是最佳调试 + 性能 + 体积平衡
1
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
1
2

LLDB 也提供大量 GDB 风格的别名(b、p、bt),所以日常用法基本能直接迁移。

# 9.2 LLDB 独有特性

LLDB 有几个 GDB 没有的小特性:

1) gui 模式——内置 TUI 比 GDB 的 layout 漂亮:

(lldb) gui
1

2) Python 脚本是一等公民:

(lldb) script
>>> lldb.frame.FindVariable("x").GetValueAsUnsigned()
42
1
2
3

3) 表达式语言更接近 C++:

(lldb) expr -- auto v = std::vector<int>{1,2,3}; v.size()
(unsigned long) $0 = 3
1
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
1
2

否则 lldb -p <PID> 会拒绝。

2) SIP(系统完整性保护)禁止 attach 系统进程:

csrutil status                # 看 SIP 状态
# 调试系统进程要在 Recovery 模式下关 SIP,强烈不推荐
1
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 看不到行号
1
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 步
1
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
1
2
3
4
5

2) 代码二分——bug 在哪段代码?

把可疑函数分成两半,用断点 + print 看变量是否已损坏:

void process() {
    step_1();      // 断点 + 打印 → 变量正常
    step_2();
    // 断点 + 打印 → 变量损坏 → bug 在 step_2 或之前
    step_3();
}
1
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
1
2
3
4
5
6
7
8

4) 配置二分——同样的代码,开发环境对、生产错——一定是配置/环境差异:

开发环境配置 ─┬──────────────┐
              │  逐项替换为     │
生产环境配置 ─┴──→ 哪一项导致 bug?
1
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
}
1
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 在调用方是怎么得到的
1
2
3
4
5

修法:参考 01.信号崩溃快速排查 第 11.1 节。

# 11.2 死循环不退出

while (cond) {
    if (some_check()) break;
}
// 跑半天没退出——cond 永真?
1
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
1
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

# 把"谁等谁"画出来 → 死锁环
1
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
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   ← 找到罪犯
1
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
1
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-*      # 事后分析
1
2
3
4

3) rr 录制:

rr record ./a.out            # 跑很多次直到某次崩
ls ~/.local/share/rr/        # 找最新一次的 trace
rr replay <latest-trace>     # 反复回放,可反向调试
1
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                 ← 自己的代码
1
2
3
4

第一原则:bug 不在标准库——是你传给标准库的参数不对。直接上找到 #2(自己的代码):

(gdb) frame 2
(gdb) info args              # 看参数是什么
(gdb) info locals
(gdb) up
(gdb) info args              # 一路往上找直到找到坏数据来源
1
2
3
4
5

99% 的库内崩,根因都在调用方的某个未初始化 / 越界 / nullptr。

# 11.7 启动期就崩

$ ./a.out
Segmentation fault
# 还没到 main——全局对象构造期
1
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
1
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
1
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
1
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
1
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()
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

效果:

(gdb) p o
$1 = Order[BUY, 100@50]                    ← 自定义美化输出
1
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()
1
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
1
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
1
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"}
  ]
}
1
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 加"持锁状态下调用其他锁相关函数"
1
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           # 看局部变量
1
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           # 持续观察
1
2
3
4
5
6
7
8

专家 5 命令(救命级):

(gdb) commands              # 断点附带脚本
(gdb) python ...            # Python 扩展
(gdb) target remote ...     # 远程调试
(gdb) record-full           # 反向调试
(gdb) source script.gdb     # 加载脚本
1
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
1
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 思考题

  1. 你给一个 30 万行的老 C++ 项目第一次 attach,info sharedlibrary 显示一堆库的符号是 Yes (*),bt 全是地址没有函数名——是什么问题?怎么解决?

  2. 主线一里 thread apply all bt 看到很多线程都在 __lll_lock_wait,但没有任何一个线程持有 mutex(__owner == 0)。这种情况是什么场景?

  3. 一段代码在 -O0 跑没问题,-O2 偶发崩溃。GDB 加载 -O2 的 core 看到栈完全乱掉。怎么找根因?

  4. 你想监控 g_counter 何时变成负数(不是被改写时,而是改完之后值为负)。GDB 怎么做?

  5. 一个进程 attach 失败提示 "Operation not permitted"。可能的原因有哪些?怎么逐项排除?

  6. 想在某个高频函数里"每 1000 次命中才停一次"——怎么实现?提示用 GDB 的 ignore 命令。

  7. core 文件 4GB,gdb 加载花 15 分钟。怎么提速?提示与 split-dwarf 和 .gdb_index 有关。

  8. 一个 inline 函数想打断点,b inline_func 报 "multiple locations"——什么意思?应该怎么选?

  9. 在 macOS 用 LLDB 调试一个 -O2 编译的 C++ 程序,p 一个 std::string 显示乱码。问题在哪?

  10. 假设你是某团队的调试基础设施负责人。你会建立哪些工程实践、工具脚本、CI 流程,让团队的调试能力从"每个人各凭经验"提升到"全员标准化高效"?


GDB 不是命令清单——是状态机的放大镜。 同样一行 bt,在新手手里看到的是地址,在专家手里看到的是设计缺陷。

下一篇:本篇讲了"现场抓住怎么钉死它",下一步进入 04.CoreDump破案实录——把"事后从一个崩溃文件还原全部现场"的实战流程讲透。配套阅读:01.信号崩溃快速排查(崩溃信号定位)、02.ASan内存诊断(让内存类 bug 在第一现场暴露)、01.进程地址空间布局(多线程栈与寄存器的内存模型基础)。

上次更新: 2026/06/16, 18:05:07
ASan内存三件套
CoreDump破案

← ASan内存三件套 CoreDump破案→

最近更新
01
信号崩溃快速排查
06-15
02
CoreDump破案
06-15
03
perf火焰图实战
06-15
更多文章>
Theme by Vdoing | Copyright © 2019-2026 杨充 | MIT License | 桂ICP备2024034950号 | 桂公网安备45142202000030
  • 跟随系统
  • 浅色模式
  • 深色模式
  • 阅读模式