编程进阶网 编程进阶网
首页
  • 计算机原理
  • 操作系统
  • 网络协议
  • 数据库原理
  • 面向对象
  • 设计原则
  • 设计模式
  • 系统架构
  • 性能优化
  • 编程原理
  • 方案设计
  • 稳定可靠
  • 工程运维
  • 基础认知
  • 线性结构
  • 树与哈希
  • 工业级实现
  • 算法思想
  • 实战与综合
  • 算法题考核
  • 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十命令速查
      • CoreDump破案
        • 1. 案例引入:两条主线
          • 1.1 主线一:凌晨支付重启
          • 1.2 主线二:8GB core
          • 1.3 顺藤摸到根因
          • 1.4 本篇要回答什么
        • 2. core 在调试链中的位置
          • 2.1 调试三种时态
          • 2.2 与日志的互补
          • 2.3 与崩溃捕获互补
          • 2.4 适用与不适用
        • 3. core 文件本质
          • 3.1 它就是一个 ELF
          • 3.2 PT_LOAD 段映射
          • 3.3 PT_NOTE 段秘密
          • 3.4 内核如何写出
          • 3.5 大小估算
        • 4. 开启 core 的四关
          • 4.1 ulimit 软硬限制
          • 4.2 core_pattern 路径
          • 4.3 suid 与 dumpable
          • 4.4 容器场景特殊
          • 4.5 systemd-coredump
          • 4.6 macOS 与 BSD
        • 5. 找到 core 文件
          • 5.1 普通路径查找
          • 5.2 coredumpctl 系列
          • 5.3 容器内捞 core
          • 5.4 core 没生成
          • 5.5 core 文件压缩
        • 6. 加载 core 三步法
          • 6.1 第一步加载
          • 6.2 第二步检查
          • 6.3 第三步现场
          • 6.4 调试符号一致性
          • 6.5 sysroot 跨机调试
        • 7. 现场分析六大命令
          • 7.1 bt full 调用栈
          • 7.2 thread apply all
          • 7.3 info registers
          • 7.4 disas 反汇编
          • 7.5 p $_siginfo
          • 7.6 info proc mappings
        • 8. 高级排查武器
          • 8.1 反向定位 this
          • 8.2 STL 容器美化
          • 8.3 堆扫描追指针
          • 8.4 Python 自动化
          • 8.5 自定义 gdbinit
        • 9. 符号与地址换算
          • 9.1 build-id 校验
          • 9.2 分离调试符号
          • 9.3 共享库基址
          • 9.4 addr2line 实战
          • 9.5 strip 后的栈
        • 10. 五步破案方法论
          • 10.1 先看信号副标题
          • 10.2 看寄存器证据
          • 10.3 沿栈反向回溯
          • 10.4 状态对比时间线
          • 10.5 修复与回归沉淀
        • 11. 典型场景速查
          • 11.1 空指针 this
          • 11.2 vtable 被踩
          • 11.3 栈溢出递归
          • 11.4 多线程死锁
          • 11.5 堆内存损坏
          • 11.6 异常未捕获
          • 11.7 优化丢符号
        • 12. 工程化最佳实践
          • 12.1 构建期分离符号
          • 12.2 符号服务器搭建
          • 12.3 自动化分析脚本
          • 12.4 minidump 替代
          • 12.5 core 治理流水线
        • 13. 综合案例串讲
          • 13.1 案例真相揭晓
          • 13.2 一次破案的全景
          • 13.3 设计哲学回扣
          • 13.4 core 破案速查表
          • 13.5 思考题
      • perf火焰图实战
      • 迭代器失效陷阱
      • 智能指针选型
      • 异常安全RAII
      • 多线程锁选型
      • 编译期防御
  • Java入门精通

  • Go入门到精通

  • JavaScript入门

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

CoreDump破案

# 第23章:Core Dump 破案

# 目录介绍

  • 1. 案例引入:两条主线
    • 1.1 主线一:凌晨支付重启
    • 1.2 主线二:8GB core
    • 1.3 顺藤摸到根因
    • 1.4 本篇要回答什么
  • 2. core 在调试链中的位置
    • 2.1 调试三种时态
    • 2.2 与日志的互补
    • 2.3 与崩溃捕获互补
    • 2.4 适用与不适用
  • 3. core 文件本质
    • 3.1 它就是一个 ELF
    • 3.2 PT_LOAD 段映射
    • 3.3 PT_NOTE 段秘密
    • 3.4 内核如何写出
    • 3.5 大小估算
  • 4. 开启 core 的四关
    • 4.1 ulimit 软硬限制
    • 4.2 core_pattern 路径
    • 4.3 suid 与 dumpable
    • 4.4 容器场景特殊
    • 4.5 systemd-coredump
    • 4.6 macOS 与 BSD
  • 5. 找到 core 文件
    • 5.1 普通路径查找
    • 5.2 coredumpctl 系列
    • 5.3 容器内捞 core
    • 5.4 core 没生成
    • 5.5 core 文件压缩
  • 6. 加载 core 三步法
    • 6.1 第一步加载
    • 6.2 第二步检查
    • 6.3 第三步现场
    • 6.4 调试符号一致性
    • 6.5 sysroot 跨机调试
  • 7. 现场分析六大命令
    • 7.1 bt full 调用栈
    • 7.2 thread apply all
    • 7.3 info registers
    • 7.4 disas 反汇编
    • 7.5 p $_siginfo
    • 7.6 info proc mappings
  • 8. 高级排查武器
    • 8.1 反向定位 this
    • 8.2 STL 容器美化
    • 8.3 堆扫描追指针
    • 8.4 Python 自动化
    • 8.5 自定义 gdbinit
  • 9. 符号与地址换算
    • 9.1 build-id 校验
    • 9.2 分离调试符号
    • 9.3 共享库基址
    • 9.4 addr2line 实战
    • 9.5 strip 后的栈
  • 10. 五步破案方法论
    • 10.1 先看信号副标题
    • 10.2 看寄存器证据
    • 10.3 沿栈反向回溯
    • 10.4 状态对比时间线
    • 10.5 修复与回归沉淀
  • 11. 典型场景速查
    • 11.1 空指针 this
    • 11.2 vtable 被踩
    • 11.3 栈溢出递归
    • 11.4 多线程死锁
    • 11.5 堆内存损坏
    • 11.6 异常未捕获
    • 11.7 优化丢符号
  • 12. 工程化最佳实践
    • 12.1 构建期分离符号
    • 12.2 符号服务器搭建
    • 12.3 自动化分析脚本
    • 12.4 minidump 替代
    • 12.5 core 治理流水线
  • 13. 综合案例串讲
    • 13.1 案例真相揭晓
    • 13.2 一次破案的全景
    • 13.3 设计哲学回扣
    • 13.4 core 破案速查表
    • 13.5 思考题

# 1. 案例引入:两条主线

排查 core 文件,最忌讳"对着 bt 念栈帧"。本篇用两条真实主线贯穿全文:一条来自凌晨被电话叫醒的支付服务事故,一条来自 8GB 巨大 core 文件的"加载十五分钟还看不懂"经历。前者展示"如何从一个 core 文件 30 分钟内定位根因",后者展示"core 太大、符号对不上时怎么救"。

# 1.1 主线一:凌晨支付重启

某支付网关,凌晨 2:47 被告警拉起:服务在 5 分钟内崩溃 3 次,每次重启后撑不过两分钟。运维只留下一行系统日志:

Jan 14 02:47:13 pay-gw-07 kernel: pay_worker[24891]: segfault at 18 ip 00007fce4d2a3b4c sp 00007ffeac1f5c80 error 4 in libpay.so[7fce4d200000+340000]
Jan 14 02:47:13 pay-gw-07 systemd[1]: pay-worker.service: Main process exited, code=dumped, status=11/SEGV
Jan 14 02:47:13 pay-gw-07 systemd[1]: pay-worker.service: Failed with result 'core-dump'.
1
2
3

代码层面,是一个看起来"已经上线半年没动过"的对账回调:

// reconcile.cpp —— 对账聚合
struct OrderCtx {
    int64_t   order_id;
    Channel*  channel;        // ← 关键:渠道对象指针
    Trace*    trace;
};

void reconcile(OrderCtx* ctx) {
    auto* ch = ctx->channel;
    if (ch->status() == Channel::OK) {     // 看起来防御过了
        ch->confirm(ctx->order_id);
    }
}

// 上游:从批处理里取出待对账订单
void batch_reconcile(const std::vector<int64_t>& ids) {
    for (auto id : ids) {
        auto* ctx = ctx_pool_.acquire();
        ctx->order_id = id;
        ctx->channel  = channel_map_[id % 16];   // ← 按 hash 分桶
        ctx->trace    = trace_pool_.acquire();
        reconcile(ctx);
        ctx_pool_.release(ctx);
    }
}
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

现象:

  • 白天峰值 QPS 1.2 万:永远不崩;
  • 凌晨 2:30 启动月末批量对账,QPS 瞬间冲到 4 万 → 崩。

直觉怀疑:是不是 channel 是空?打开 coredumpctl gdb 24891:

(gdb) bt
#0  0x00007fce4d2a3b4c in Channel::status (this=0x18) at channel.cpp:88
#1  0x00007fce4d2a1255 in reconcile (ctx=0x7fce28a01040) at reconcile.cpp:14
#2  0x00007fce4d2a14ba in batch_reconcile (ids=...) at reconcile.cpp:36
...

(gdb) p ctx
$1 = (OrderCtx *) 0x7fce28a01040
(gdb) p ctx->channel
$2 = (Channel *) 0x18                    ← 不是 0,是 0x18!
1
2
3
4
5
6
7
8
9
10

channel 不是 nullptr,而是 0x18——一个奇怪到不可能合法的小整数。地址 0x18 落在用户态最低的几页(OS guard page 区域),访问必崩。

更关键的线索在 info registers:

(gdb) info registers rdi
rdi    0x18    24
(gdb) disas $rip-8,$rip+8
   0x7fce4d2a3b44 <Channel::status+0>:  push  %rbp
   0x7fce4d2a3b48 <Channel::status+4>:  mov   %rsp,%rbp
=> 0x7fce4d2a3b4c <Channel::status+8>:  mov   0x18(%rdi),%eax
                                              ^^^^^^^^^
                                              this+0x18 = 0x18+0x18 = 0x30 ❌
1
2
3
4
5
6
7
8

崩在 mov 0x18(%rdi), %eax——读 Channel 类的第 0x18 偏移成员。%rdi(也就是 this)等于 0x18,加上偏移就是 0x30,这块根本没映射。

为什么 channel 是 0x18? 后来用 p &channel_map_ 看 OrderCtx 的内存布局,恍然大悟:channel_map_[id % 16] 在多线程下被并发清理重建,指针表的某些槽被中间状态读到了一个"成员偏移量"被误当成指针——这是典型的"指针表与索引数组共用一个 mutex 但读路径漏锁"。

error 4 解读:bit 2 = user mode,bit 0 = 页不存在,bit 1 = 0(读),完整意思是用户态读未映射页——和 0x18 这个地址完全吻合。

# 1.2 主线二:8GB core

另一位同学发来求助:

"我有个 core,8GB,gdb 加载 15 分钟,bt 全是 ??。我们的 release 包没带 -g,根本看不出栈。怎么办?"

环境信息:

$ ls -lh core.pay-stream.18273
-rw------- 1 root root 8.1G Jan 14 03:12 core.pay-stream.18273

$ file core.pay-stream.18273
core.pay-stream.18273: ELF 64-bit LSB core file, x86-64, ...
                       SVR4-style, from 'pay-stream --config /etc/pay/...',
                       real uid: 1001, effective uid: 1001, ...

$ gdb ./pay-stream core.pay-stream.18273
... 等待 14 分钟 ...
(gdb) bt
#0  0x00007f3a8b478e2c in ?? ()
#1  0x00007f3a8b465311 in ?? ()
#2  0x00007f3a8b461a40 in ?? ()
...
(全是 ??)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

两个现象非常扎眼:

  • core 文件 8GB —— 进程虚拟内存大、又开了 hugepage,dump 整个进程镜像确实大;
  • bt 全是 ?? —— 二进制 strip 过、库版本对不上、调试信息 ELF 段缺失。

好的破案,第一步永远不是看 bt,而是看"我手里这副牌齐不齐"。8GB core 的"症状"很扎眼,但根因是调试链路不完整——这是生产环境最高频的 core 调试障碍。

现象      看到的栈帧                    根因
─────    ─────────────                ────────────────
?? ()    符号没了                      strip / 库版本错配
错的行号  地址翻译错位                  build-id 不一致
gdb 慢   .gdb_index 缺失                未启用 split-DWARF
optimized out  变量值看不到              -O2 + 没 -Og

1
2
3
4
5
6
7

把这些坑梳理清楚,下文就是一篇"故障 → 工具 → 方法论"的破案手册。

# 1.3 顺藤摸到根因

带着两条主线往下挖,至少藏着这些原理点:

① core 文件到底是什么? 内核怎么写出来的?              → 第 3 章
② 没生成 core / 文件不全, 是哪一关没过?               → 第 4 章
③ systemd 接管 / 容器里的 core 怎么找?                → 第 5 章
④ gdb 加载完, 第一刀该砍在哪几个命令?                → 第 6 ~ 7 章
⑤ 栈帧全是 ??, 怎么救? 怎么找回符号?                 → 第 9 章
⑥ this 指针离谱, 内存被踩, 怎么逆推谁干的?            → 第 8 章
⑦ 大型 core / strip / 跨机调试, 工程上怎么搞?         → 第 12 章
1
2
3
4
5
6
7

# 1.4 本篇要回答什么

层次 你将学到
原因层 为什么 core 没生成、为什么栈是 ??、为什么变量是 optimized out
文件层 core 文件的 ELF 结构、PT_LOAD/PT_NOTE 段、内核 dump 流程
工具层 gdb/coredumpctl/addr2line/eu-unstrip/Python 扩展
方法层 信号副标题 → 寄存器 → 栈反向回溯 → 状态时间线 → 修复回归
工程层 build-id 校验、符号服务器、自动化分析、minidump 替代、core 治理流水线

📌 本篇定位:这是排查篇的第四篇。前三篇分别讲了"信号定位"(01.信号崩溃快速排查)、"内存类 bug 第一现场暴露"(02.ASan内存诊断)、"现场调试器钉死它"(03.GDB命令速查表)。本篇专攻的是最后一种调试时态:事后——程序已经死了,只剩一个文件,要把全部现场还原回来。读完本篇,再看任何 core,都能立刻回答:"符号齐不齐、信号是哪类、寄存器和栈讲了什么故事、根因在哪个时间点产生"。

# 2. core 在调试链中的位置

进入 core 内部之前,先把它在整个调试体系里的位置摆清楚——避免"什么 bug 都想用 core 治"。

# 2.1 调试三种时态

排查 C++ 崩溃,按"还能不能动"分三种时态:

事前 (preventive)         事中 (live)             事后 (postmortem)
──────────────         ───────────────         ───────────────
ASan / UBSan / TSan     gdb / lldb attach        core dump
编译告警                  printf / log           崩溃日志
静态分析                  rr record              minidump
                                                生产 stacktrace 上报
能否阻止 bug 出现?        能否抓到 bug 现场?       bug 已经发生, 怎么破案?
1
2
3
4
5
6
7

core 文件就是"事后调试"的核心载体——程序已经死了,没法 attach、没法重跑、运维醒不来,唯一的物证是这个文件。它的价值不是"代替前两类工具",而是当前两类工具用不上时的最后一条命。

时态 适合工具 信息丰富度 干预成本
事前 ASan/UBSan/CI 三栈齐发 重编译 + 测试运行
事中 gdb attach / rr 任意时刻可暂停查变量 轻度暂停目标进程
事后 core dump 一份内存快照 + 寄存器 0(崩溃时自动生成)

# 2.2 与日志的互补

很多团队"靠日志排查崩溃"——崩了就看 stderr,stderr 没东西就只能猜。日志有两个根本性局限:

  • 打哪些字段是事先决定的:日志里没打的字段,事后翻不出来;
  • 崩溃前一刻常常没来得及刷盘:stdio 缓冲区里的几 KB 输出永远看不到了。
   日志:           写了多少看到多少              ← 主动埋点
   core dump:      整个进程内存 + 所有寄存器       ← 被动全景
1
2

core 的价值就在于"被动全景"——不需要你提前预测会崩在哪、需要看哪些字段,所有线程的栈、所有局部变量、所有堆对象、所有寄存器都在里面。代价是要会用工具去问。

# 2.3 与崩溃捕获互补

崩溃捕获框架(01.信号崩溃快速排查 第 8 节、Breakpad、sentry-native)解决的是"崩溃自动上报"——它们生成的是 minidump:只保存关键线程栈 + 寄存器 + 模块列表,几百 KB。

维度 完整 core minidump
大小 几百 MB ~ 几 GB 几百 KB
内容 全部内存 + 所有线程 崩溃线程栈 + 寄存器 + 部分关键内存
上报成本 几乎不可能直传 网络可上传
调试粒度 任意堆对象都能看 栈和关键参数
适用环境 后端服务有大盘磁盘 客户端 / 移动端

两条路线不是替代关系:服务端崩溃 → 留 core + gdb;客户端崩溃 → 上 minidump + 符号服务器。本篇专讲第一条。

# 2.4 适用与不适用

场景 适合 core? 备注
服务端单次崩溃 极适合 生产标配
间歇性偶发崩溃 适合 多个 core 横向对比
多线程数据竞争 部分适合 core 看死锁可以;data race 不行(要 TSan)
客户端 / 移动端崩溃 不适合 应该走 minidump
内存泄漏 不适合 要 ASan / heap profiler
OOM 被 SIGKILL 不适合 OOM 不生成 core,要查 dmesg
崩溃在 loadable kernel module 不适合 要 kdump / vmcore

记住:core 只解决"用户态进程因同步信号挂掉"这一类问题。其他时态、其他来源的故障,要换工具。

# 3. core 文件本质

很多人用了十年 core,从没看过 core 文件本身是什么样。理解它的内部结构,能解释一大堆"为什么 gdb 这样反应"的怪现象。

# 3.1 它就是一个 ELF

file 命令揭示了第一条真相:

$ file core.pay-stream.18273
core.pay-stream.18273: ELF 64-bit LSB core file, x86-64, version 1 (SYSV),
                       SVR4-style, from 'pay-stream --config ...',
                       real uid: 1001, effective uid: 1001,
                       execfn: '/usr/bin/pay-stream', ...
1
2
3
4
5

core 是一个标准 ELF 文件——和 .so、可执行文件结构完全一样,只是 ELF Header 里 e_type = ET_CORE(值 4)。

ELF Header   (e_type = ET_CORE)
─────────────────────────────────
Program Header Table
  ┌────────────────────────────┐
  │  PT_NOTE (内核状态信息)     │  ← 信号、寄存器、auxv 都在这里
  │  PT_LOAD (内存段 1)         │  ← 进程的代码段镜像
  │  PT_LOAD (内存段 2)         │  ← 进程的数据段镜像
  │  PT_LOAD (内存段 3)         │  ← 堆
  │  PT_LOAD (内存段 4)         │  ← 栈
  │  PT_LOAD (内存段 N)         │  ← mmap 出来的每一片
  └────────────────────────────┘
1
2
3
4
5
6
7
8
9
10
11

用 readelf -l 直接看:

$ readelf -l core.pay-stream.18273 | head -20
Elf file type is CORE (Core file)
Entry point 0x0
There are 87 program headers, starting at offset 64

Program Headers:
  Type           Offset             VirtAddr           ...
  NOTE           0x0000000000000528 0x0000000000000000 ...
  LOAD           0x0000000000004000 0x00007fce4d200000 ...
  LOAD           0x0000000000344000 0x00007fce4d540000 ...
  LOAD           ...
1
2
3
4
5
6
7
8
9
10
11

87 个 program header——意味着进程崩溃时有 86 块独立内存映射,每一块在 core 里对应一个 PT_LOAD。

# 3.2 PT_LOAD 段映射

每个 PT_LOAD 段对应进程一个 VMA(virtual memory area):栈、堆、.text、.data、动态库的加载段、mmap 出来的文件……都被一段一段原样写进 core。

进程虚拟内存                       core 文件
──────────────                  ──────────
0x40_0000  .text (code)         ┌──────────┐
                                │ PT_LOAD  │ ← 复制 .text 内容
0x60_0000  .rodata              ├──────────┤
                                │ PT_LOAD  │ ← 复制 .rodata
0x80_0000  .data + .bss          ├──────────┤
                                │ PT_LOAD  │ ← .data + .bss 当前值
0x7fce_4d20_0000  libpay.so      ├──────────┤
                                │ PT_LOAD  │ ← libpay.so 的代码 + 数据
0x7fce_2800_0000  malloc 堆      ├──────────┤
                                │ PT_LOAD  │ ← 8 GB 的堆数据 ⚠️
0x7ffe_ac10_0000  栈             ├──────────┤
                                │ PT_LOAD  │ ← 当前线程栈
                                └──────────┘
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

这就解释了为什么主线二的 core 有 8GB:进程开了大堆(业务对账内存表),堆段被原样写进 core。

实际上内核不会写所有段——/proc/<pid>/coredump_filter(默认 0x33)控制哪些段进 core:

bit 0: anonymous private (匿名私有, 默认 ON)
bit 1: anonymous shared
bit 2: file-backed private
bit 3: file-backed shared
bit 4: ELF headers
bit 5: huge private
bit 6: huge shared
1
2
3
4
5
6
7

mmap 共享文件的段(共享库的代码段)默认不写进 core——因为可以从原文件恢复,写进去浪费空间。这一点很关键:core 里的 .so 代码段是空的,gdb 调试栈帧时必须从原 .so 文件读符号。版本不匹配就 ??。

# 3.3 PT_NOTE 段秘密

最有信息量的段是 PT_NOTE——它装着内核 dump 时记录的"元状态":

$ readelf -n core.pay-stream.18273 | head -40
Displaying notes found in: NOTE
  Owner                Data size       Description
  CORE                 0x000000a8      NT_PRSTATUS (prstatus structure)
  CORE                 0x00000088      NT_PRPSINFO (prpsinfo structure)
  CORE                 0x00000200      NT_FPREGSET (floating point registers)
  LINUX                0x00000340      NT_X86_XSTATE (x86 XSAVE extended state)
  CORE                 0x00000080      NT_SIGINFO (siginfo_t data)
  CORE                 0x00000158      NT_AUXV (auxiliary vector)
  CORE                 0x000007c0      NT_FILE (mapped files)
  ...
1
2
3
4
5
6
7
8
9
10
11

每个 NT_* 是一种"快照":

Note 类型 内容 gdb 命令对应
NT_PRSTATUS 通用寄存器 + 信号号 + pid info registers
NT_PRPSINFO 进程名、命令行、状态 info proc
NT_FPREGSET 浮点寄存器 info float
NT_X86_XSTATE AVX/AVX-512 扩展状态 info all-registers
NT_SIGINFO siginfo_t(信号 + 子码 + 地址) p $_siginfo
NT_AUXV ELF auxv(HWCAP/PAGESZ 等) info auxv
NT_FILE 进程加载的文件列表 info sharedlibrary

p $_siginfo 之所以能在 core 里用,就是因为 NT_SIGINFO 段被 dump 进来了——内核投递信号时填充的 siginfo_t 结构被原样保存到 core,gdb 加载后读出来。

# 3.4 内核如何写出

崩溃发生到 core 落盘的全过程:

1. 用户态指令访问非法地址        ← CPU 触发 #PF
2. 内核 do_page_fault()
   └─ 决定发 SIGSEGV
3. force_sig_info(SIGSEGV, ...)
   └─ 设置 task_struct->pending bit
4. 返回用户态前: get_signal()
   └─ 没装 handler → 默认动作 Core
5. do_coredump()
   ├─ 解析 /proc/sys/kernel/core_pattern
   ├─ 是普通路径 → vfs_write() 直写文件
   ├─ 是 "|prog" → fork + execve(prog), 把 core 通过 stdin 喂给它
   ├─ 检查 ulimit -c, 超过则截断
   ├─ 检查 fs.suid_dumpable, 不允许则跳过
   ├─ 遍历 mm->mmap, 决定哪些段写, 哪些跳过
   ├─ 拼好 ELF Header + Program Header Table
   ├─ 写 PT_NOTE: 寄存器、siginfo、auxv、文件列表
   └─ 按 PT_LOAD 顺序把每段虚拟内存内容写出去
6. 释放进程
   └─ 通知父进程 SIGCHLD
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

整个写出过程串行、阻塞。8GB core 大概要写 5 ~ 30 秒,期间进程已经"死了但还没消失"。这也意味着——几个进程同时崩,磁盘 IO 会被 core 写出占满,对在线服务是次生灾害。

# 3.5 大小估算

预估一个进程会生成多大 core:

# 进程当前 RSS(实际占用内存)
$ cat /proc/<pid>/status | grep -E "VmRSS|VmSize"
VmSize:  17280492 kB     ← 虚拟空间, core 上限
VmRSS:    8329488 kB     ← 实际占用, 接近 core 实际大小
VmPeak:  18204828 kB

# 更精细: 看 anonymous + file-backed
$ pmap -X <pid> | tail -1
Total:        17280492    8329488 ...
1
2
3
4
5
6
7
8
9

经验法则:core 大小 ≈ VmRSS。VmRSS 8GB 的服务,core 大概也是 8GB。如果不想 core 这么大:

# 排除文件映射段
echo 0x31 > /proc/<pid>/coredump_filter   # 关掉 file-backed shared

# 排除 hugepage
echo 0x21 > /proc/<pid>/coredump_filter   # 关掉 huge

# 直接限制 core 大小(截断后栈基本还在,但堆数据不全)
ulimit -c 1048576   # 1GB 上限
1
2
3
4
5
6
7
8

生产建议:默认 unlimited,但单独拉一块 SSD 给 /var/cores,避免和业务磁盘抢 IO。

# 4. 开启 core 的四关

崩溃总在凌晨 3 点发生而你在睡觉——所以现场必须自动留下来。但生产里 80% 的"我们没拿到 core"事件,都死在下面这四关之一。

# 4.1 ulimit 软硬限制

第一道关。ulimit -c 是 per-process 的 RLIMIT_CORE 限制,0 表示禁止 dump。大多数发行版默认就是 0:

$ ulimit -c
0                              ← 默认禁

$ ulimit -c unlimited           ← 当前 shell 解禁

$ cat /proc/<pid>/limits | grep -i core
Max core file size    unlimited   unlimited   bytes
1
2
3
4
5
6
7

但是——ulimit 只对当前 shell 派生的进程有效。systemd 启动的服务 不继承 你 shell 的 ulimit,要在 unit 文件里单独配:

# /etc/systemd/system/pay-worker.service
[Service]
LimitCORE=infinity              ← 关键
LimitNOFILE=65536
1
2
3
4
# 持久化所有用户
$ sudo tee /etc/security/limits.d/core.conf <<EOF
*  soft  core  unlimited
*  hard  core  unlimited
EOF
1
2
3
4
5

坑:很多公司的"生产基线镜像"把 hard limit 锁死在 0,soft 改不了。先 ulimit -Hc 看 hard limit。

# 4.2 core_pattern 路径

第二道关。/proc/sys/kernel/core_pattern 决定 core 写到哪、文件名叫什么。它有两种语法:

# 语法 A:普通路径模板
$ cat /proc/sys/kernel/core_pattern
core

# 此时 core 会写到 进程当前工作目录, 名字 "core"
# 多次崩溃会互相覆盖!
1
2
3
4
5
6
# 语法 B:管道(开头是 |)
$ cat /proc/sys/kernel/core_pattern
|/usr/lib/systemd/systemd-coredump %P %u %g %s %t %c %h

# core 通过 stdin 喂给 systemd-coredump 进程, 由它决定怎么存
1
2
3
4
5

生产推荐配置:

$ sudo mkdir -p /var/cores && sudo chmod 1777 /var/cores

# 写入持久化配置
$ sudo tee /etc/sysctl.d/99-core.conf <<EOF
kernel.core_pattern = /var/cores/core-%e-%p-%t-%s
kernel.core_uses_pid = 1
EOF
$ sudo sysctl --system
1
2
3
4
5
6
7
8

format 占位符:

% 占位 含义
%e 短可执行名(前 16 字节)
%E 完整可执行路径(/ 替换为 !)
%p 进程 PID
%t UNIX 时间戳
%s 信号编号
%c core 软限制(字节数)
%h 主机名
%u / %g uid / gid

强烈建议带 %t:避免同名 core 互相覆盖;带 %s:一眼看出是哪类信号(11=SIGSEGV、6=SIGABRT、7=SIGBUS)。

# 4.3 suid 与 dumpable

第三道关。setuid/setgid 程序默认禁止 dump,因为 dump 出来的内存可能包含敏感信息(root 切换前的 secret)。

$ cat /proc/sys/fs/suid_dumpable
0       ← 默认: setuid 进程禁 dump
        # 1: 允许, 但 owner 是 root, 普通用户读不了
        # 2: 任何人可读 (危险, 不要在生产用)
1
2
3
4

更精细的是 per-process 的 dumpable 标志(prctl(PR_SET_DUMPABLE, 1))。如果业务代码自己调用过 prctl(PR_SET_DUMPABLE, 0)(一些"安全加固"库会这么干),core 就不会生成。

# 看进程是否 dumpable
$ cat /proc/<pid>/status | grep -i dumpable
CoreDumping:    0
1
2
3

# 4.4 容器场景特殊

容器里的 core 有几个独特问题:

1. core_pattern 是 host 级别的, 不是 container 级别
   → 即使容器里改 /proc/sys/kernel/core_pattern 也写不进去 (read-only)
   → 容器内崩溃, core 写到 host 的 /var/cores/

2. 但 host 的 core_pattern 用容器内的进程名 (e.g. "pay-worker")
   → core 写到 host 时, 没人知道是哪个容器哪个 pod 的

3. Kubernetes 默认 ulimit -c = 0
   → 即使 host 配了 core_pattern, 容器里也不生成
1
2
3
4
5
6
7
8
9

正确姿势(K8s):

# Pod spec
securityContext:
  fsGroup: 0
  capabilities:
    add: ["SYS_PTRACE"]            # 允许 attach 调试
spec:
  containers:
  - name: pay-worker
    securityContext:
      privileged: false
    resources:
      limits:
        ephemeral-storage: 20Gi    # 留给 core 文件
    # 关键: 通过 init container 设 ulimit
    # 或在 entrypoint 里 ulimit -c unlimited && exec ./pay-worker
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

host 层 core_pattern 推荐写一个带 hostname/容器 ID 的脚本:

# /usr/local/bin/save-core.sh
#!/bin/bash
# stdin = core 文件内容
# 参数: %P %p %u %s %t %e
HOST_PID=$1
CONTAINER_ID=$(awk -F/ '/0::/{print $NF}' /proc/$HOST_PID/cgroup | cut -c1-12)
DEST=/var/cores/core-$CONTAINER_ID-$6-$2-$5-sig$4
cat > $DEST
chmod 0600 $DEST

# core_pattern:
# |/usr/local/bin/save-core.sh %P %p %u %s %t %e
1
2
3
4
5
6
7
8
9
10
11
12

# 4.5 systemd-coredump

现代 Linux 发行版(Ubuntu 18+ / RHEL 8+ / Debian 10+)默认用 systemd-coredump 接管:

$ cat /proc/sys/kernel/core_pattern
|/usr/lib/systemd/systemd-coredump %P %u %g %s %t %c %h
1
2

它会:

  • 把 core 压缩成 .zst 或 .xz,放在 /var/lib/systemd/coredump/;
  • 记录元数据到 systemd journal;
  • 默认保留 2 周、最多 10% 磁盘。

操作命令:

$ coredumpctl list                          # 列出所有 core
TIME            PID  UID  GID  SIG     EXE
2024-01-14 02:47 24891 1001 1001 SIGSEGV /usr/bin/pay-worker
...

$ coredumpctl info 24891                    # 看元信息
$ coredumpctl gdb 24891                     # 直接拉起 gdb (常用!)
$ coredumpctl dump 24891 -o /tmp/x.core     # 导出原始 core
$ coredumpctl debug --debugger=lldb 24891   # 用 lldb
1
2
3
4
5
6
7
8
9

配置文件 /etc/systemd/coredump.conf:

[Coredump]
Storage=external          # external=单独文件, journal=塞到日志, none=不存
Compress=yes
ProcessSizeMax=8G         # 单 core 最大 (超过截断)
ExternalSizeMax=10G       # 文件总大小上限
KeepFree=10G              # 至少留这么多磁盘
1
2
3
4
5
6

# 4.6 macOS 与 BSD

macOS 上 core 默认写 /cores/core.<pid>,需要先:

$ ulimit -c unlimited
$ sudo chmod 1777 /cores
$ ./crash
Bus error: 10 (core dumped)
$ ls /cores/
core.12345
1
2
3
4
5
6

macOS 的限制:

  • core 不能 dump 系统二进制(SIP 保护);
  • code signing 严格,自己编的二进制要 codesign -s - 否则 core 拒绝生成;
  • lldb 调试 core:lldb -c /cores/core.12345 ./crash。

FreeBSD:sysctl kern.coredump=1 + sysctl kern.corefile=/var/cores/%N.%P.core。

# 5. 找到 core 文件

ulimit 配好了、程序也确实崩了——但 find / -name "core*" 什么都没有?这一节专门讲"core 在哪"。

# 5.1 普通路径查找

最朴素的情况——core_pattern 是普通路径:

# 1. 看模板
$ cat /proc/sys/kernel/core_pattern
/var/cores/core-%e-%p-%t

# 2. 列出最近的
$ ls -lhrt /var/cores/ | tail -10
-rw------- 1 root root 8.1G Jan 14 02:47 core-pay-worker-24891-1705171633

# 3. 按时间过滤
$ find /var/cores -newer /tmp/last_check -type f
1
2
3
4
5
6
7
8
9
10

如果 core_pattern = "core"(相对路径),core 写在崩溃进程的当前工作目录。systemd 服务的 cwd 通常是 /:

$ ls -lh /core 2>/dev/null
$ ls -lh /core.* 2>/dev/null
1
2

# 5.2 coredumpctl 系列

systemd-coredump 接管时,普通文件系统找不到 core,要用 coredumpctl:

# 列表
$ coredumpctl list --since=today
TIME                    PID    UID  GID  SIG    EXE
Sun 2024-01-14 02:47:13  24891 1001 1001 SIGSEGV /usr/bin/pay-worker
Sun 2024-01-14 02:48:51  24902 1001 1001 SIGSEGV /usr/bin/pay-worker

# 按可执行名过滤
$ coredumpctl list /usr/bin/pay-worker

# 看元数据 (信号、栈基址、命令行)
$ coredumpctl info 24891
           PID: 24891 (pay-worker)
           UID: 1001 (pay)
           Signal: 11 (SEGV)
        Timestamp: Sun 2024-01-14 02:47:13
     Command Line: /usr/bin/pay-worker --config=/etc/pay/...
       Executable: /usr/bin/pay-worker
    Control Group: /system.slice/pay-worker.service
             Unit: pay-worker.service
             Slice: system.slice
        Boot ID: 1234abcd...
      Machine ID: 5678efgh...
        Hostname: pay-gw-07
        Storage: /var/lib/systemd/coredump/core.pay-worker.1001.xxx.zst (12.4M)

# 直接拉起 gdb (会自动解压 + 找符号)
$ coredumpctl gdb 24891

# 导出 core 原始文件
$ coredumpctl dump 24891 -o /tmp/x.core
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

生产小技巧:coredumpctl info 输出的 Storage: 行就是真实路径,需要 sudo 才能直接读。

# 5.3 容器内捞 core

容器内的 core 取决于 core_pattern 跑在 host 还是容器内:

# 情况 A: 容器内有自己的 core_pattern (privileged 容器)
$ docker exec -it <cid> cat /proc/sys/kernel/core_pattern
$ docker exec -it <cid> ls -lh /var/cores/
$ docker cp <cid>:/var/cores/core-xxx /tmp/

# 情况 B: 复用 host 的 core_pattern (常见)
$ ls -lh /var/cores/ | grep <container-name>

# 情况 C: pod 已被销毁, core 在重建后的 host 上
# 用 cgroup ID 反查 (前提是 core_pattern 包含 cgroup 信息)
$ ls /var/cores/ | grep $(docker inspect <cid> --format '{{.Id}}' | cut -c1-12)
1
2
3
4
5
6
7
8
9
10
11

Kubernetes 推荐做法:把 host 的 /var/cores mount 成 NFS,或定时 rsync 到对象存储,配 webhook 通知。

# 5.4 core 没生成

按优先级核对清单:

# 检查项 命令
1 ulimit 软限制非 0 cat /proc/<pid>/limits \| grep -i core
2 core_pattern 路径可写 cat /proc/sys/kernel/core_pattern + 测试写入
3 磁盘有空间 df -h /var/cores/
4 进程 dumpable=1 cat /proc/<pid>/status \| grep -i dumpable
5 suid 程序,suid_dumpable=1 cat /proc/sys/fs/suid_dumpable
6 程序自己装了 SIG_IGN/handler strings 二进制找 sigaction
7 OOM killer 杀的(SIGKILL 不生成 core) dmesg \| grep -i oom
8 容器 LimitCORE 没配 systemctl show <svc> \| grep -i core

特殊:如果是 SIGABRT 但没 core——可能是程序在 handler 里又调了 _exit(0) 或者重置 SIGABRT 又 raise。

# 5.5 core 文件压缩

8GB core 既占磁盘又难拷贝。常见压缩方案:

# 1. 落盘后压缩 (最简单)
$ zstd -19 core.pay-worker-24891
$ ls -lh
core.pay-worker-24891       8.1G
core.pay-worker-24891.zst   1.4G   ← 压缩 ~6 倍

# 2. 直接管道压缩 (省临时空间)
# core_pattern 改成:
echo "|/usr/bin/zstd -q -o /var/cores/core-%e-%p.zst" > /proc/sys/kernel/core_pattern
# 但要测试: zstd 不识别 stdin 的 ELF 文件就当二进制压

# 3. systemd-coredump 自动压缩
# /etc/systemd/coredump.conf: Compress=yes (默认就开)

# 4. gdb 直接读压缩 core (需要先解压)
$ zstd -d core.pay-worker-24891.zst
$ gdb ./pay-worker core.pay-worker-24891
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

实测压缩比:典型业务进程 core,zstd-19 能压到原大小的 1/5 ~ 1/10,因为堆里大量是字符串、JSON、零页。

# 6. 加载 core 三步法

文件拿到了,进入 gdb。看似一行 gdb ./bin core 的事——但90% 的"core 看不懂"都死在加载阶段。

# 6.1 第一步加载

$ gdb /path/to/binary /path/to/core
# 或
$ gdb -c /path/to/core /path/to/binary

# coredumpctl 一键
$ coredumpctl gdb <PID>

# 离线包静默加载
$ gdb ./pay-worker core -batch -ex "bt full"
1
2
3
4
5
6
7
8
9

两个参数顺序常被记反:gdb <executable> <core>,可执行文件在前。-c 是显式指定 core 的标志。

# 6.2 第二步检查

加载完成后立刻做三件事,别急着 bt:

(gdb) info files
(gdb) info sharedlibrary
(gdb) info threads
1
2
3

info files 看二进制和 core 是否对上:

Symbols from "/usr/bin/pay-worker".
Local core dump file:
        `/var/cores/core-pay-worker-24891-1705171633', file type elf64-x86-64.
        ...
        Entry point: 0x401050
1
2
3
4
5

info sharedlibrary 是关键中的关键——90% 的 ?? 来自这里:

(gdb) info sharedlibrary
From                To                  Syms Read   Shared Object Library
0x00007fce4d2a0000  0x00007fce4d540000  Yes (*)     /usr/lib/libpay.so.2
0x00007fce4cf80000  0x00007fce4d12a000  Yes         /lib/x86_64-linux-gnu/libc.so.6
0x00007fce4cd60000  0x00007fce4cd80000  No          /usr/lib/libcrypto.so.3
                                        ^^^
                                        没读到符号 → 这块的栈帧会是 ??
(*): Shared library is missing debugging information.
1
2
3
4
5
6
7
8
标志 含义 栈帧表现
Yes 符号 + 调试信息齐全 函数名 + 文件 + 行号
Yes (*) 仅符号,无 DWARF 函数名,但变量是 optimized out
No 完全没读到 ?? ()

# 6.3 第三步现场

确认了文件和符号都齐,再开始挖现场:

(gdb) bt
#0  0x00007fce4d2a3b4c in Channel::status (this=0x18) at channel.cpp:88
#1  0x00007fce4d2a1255 in reconcile (ctx=0x...) at reconcile.cpp:14
...

(gdb) info threads
  Id   Target Id                  Frame
* 1    LWP 24891 "pay-worker"     Channel::status (this=0x18)
  2    LWP 24893 "pay-worker"     futex_wait ()
  3    LWP 24894 "pay-worker"     epoll_wait ()
  ...

(gdb) p $_siginfo
$1 = {si_signo = 11, si_errno = 0, si_code = 1, ...
      _sifields = {_sigfault = {si_addr = 0x18}}}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

四个命令出来,信号、子码、出错地址、崩溃线程、调用栈全齐了——已经够 70% 的 case 定根因。

# 6.4 调试符号一致性

主线二的 ?? 就死在这里。三种典型故障:

故障 A:版本对不上

线上跑的是 libpay.so.2.1.5,本地放的是 libpay.so.2.1.3。地址全错位,看到的栈帧就算有函数名,行号也对不上。

# 看 core 里记录的库版本 (NT_FILE 段)
$ eu-readelf -n core.pay-worker-24891 | grep -A100 NT_FILE | head -20
                  Start                 End   Page Offset
  0x00007fce4d2a0000  0x00007fce4d540000  0x000000000  /usr/lib/libpay.so.2.1.5
                                                                            ^^^^^

# 看本地库版本
$ readelf -d /usr/lib/libpay.so.2 | grep SONAME
$ ls -l /usr/lib/libpay.so.2
1
2
3
4
5
6
7
8
9

不一致 → 必须从生产或制品库捞完全一样的版本。

故障 B:build-id 校验失败

ELF 文件每个都有一个 SHA1 fingerprint,叫 build-id:

$ readelf -n /usr/bin/pay-worker | grep -A2 'Build ID'
  Owner       Data size       Description
  GNU         0x00000014      NT_GNU_BUILD_ID
    Build ID: 7f3a8b478e2c8b478e2c8b478e2c8b478e2c8b47

# core 也记录了崩溃时各模块的 build-id
$ eu-readelf -n core.pay-worker-24891 | grep -i build
1
2
3
4
5
6
7

build-id 不一致 → 即使版本号一样,也是不同次编译的产物——所有地址映射都失效。

故障 C:strip 太狠

# release 包通常会 strip
$ file /usr/bin/pay-worker
ELF 64-bit ... stripped

# 用 strip 强度
$ readelf -S /usr/bin/pay-worker | grep -i debug
# 啥都没 → 调试信息全去了
1
2
3
4
5
6
7

正确做法是分离调试符号(objcopy --only-keep-debug),剥离时只剥 .debug_*、保留 .symtab——下文 9.2 节详细讲。

# 6.5 sysroot 跨机调试

线上崩了、core 拷到本地 → 但本地的 /lib/libc.so.6 跟线上不是同一个版本。gdb 默认从本机找共享库,导致栈再次错位。

正确姿势:把线上的 root 文件系统拷一份到本地,告诉 gdb 用它:

# 1. 在线上打包 (只要库文件)
$ cd / && tar czf /tmp/sysroot.tgz \
    lib/x86_64-linux-gnu/ \
    lib64/ \
    usr/lib/x86_64-linux-gnu/ \
    usr/lib/libpay.so.* \
    usr/lib/debug/

# 2. 本地解开
$ mkdir -p ~/sysroot/prod-pay-gw && cd ~/sysroot/prod-pay-gw
$ tar xzf /tmp/sysroot.tgz

# 3. gdb 启动
$ gdb ./pay-worker core
(gdb) set sysroot ~/sysroot/prod-pay-gw
(gdb) set solib-search-path ~/sysroot/prod-pay-gw/usr/lib
(gdb) file ~/sysroot/prod-pay-gw/usr/bin/pay-worker
(gdb) core ./core.pay-worker-24891
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

或者写到 ~/.gdbinit-prod-pay:

set sysroot /home/me/sysroot/prod-pay-gw
set solib-search-path /home/me/sysroot/prod-pay-gw/usr/lib
set debug-file-directory /home/me/sysroot/prod-pay-gw/usr/lib/debug
set print pretty on
1
2
3
4

启动时 gdb -x ~/.gdbinit-prod-pay。

# 7. 现场分析六大命令

加载干净后,按下面六条命令的顺序过一遍,80% 的崩溃原因当场出来。这六条不是随便排列——是有"侦查逻辑"的:先看大局(栈),再看线程间关系,再看寄存器/汇编锁定指令,最后看进程地图证明假设。

# 7.1 bt full 调用栈

(gdb) bt
#0  0x00007fce4d2a3b4c in Channel::status (this=0x18) at channel.cpp:88
#1  0x00007fce4d2a1255 in reconcile (ctx=0x7fce28a01040) at reconcile.cpp:14
#2  0x00007fce4d2a14ba in batch_reconcile (ids=...) at reconcile.cpp:36
#3  0x00007fce4d29fa11 in worker_loop () at worker.cpp:121
...

(gdb) bt full          ← 带每帧的局部变量
#1  0x00007fce4d2a1255 in reconcile (ctx=0x7fce28a01040) at reconcile.cpp:14
        ch = (Channel *) 0x18
        ...
1
2
3
4
5
6
7
8
9
10
11

看栈五个判断:

  1. 栈是不是被踩烂:地址明显错(一堆 ?? 或 0x4141...) → 栈缓冲区溢出;
  2. 死循环 / 递归过深:栈底反复出现同一个函数 → 递归没出口或栈溢出;
  3. 崩在系统库:bt 头几帧在 libc.so / libstdc++.so → 99% 是上游传给库的参数错;
  4. 崩在自己代码:直接看那一行;
  5. <signal handler called> 这一帧:内核在崩溃栈底插入的特殊帧,下面就是 ucontext,上面才是真正崩的代码。

关键动作:永远先 bt,再决定下一步用 frame N 切到哪一帧细查。

# 7.2 thread apply all

只看 bt 是单线程视角。多线程程序必跑:

(gdb) thread apply all bt

Thread 12 (LWP 24893):
#0  0x00007fce4cef85d0 in __lll_lock_wait () from /lib/.../libpthread.so.0
#1  0x00007fce4cef0d7c in __pthread_mutex_lock_full () from ...
#2  0x000000000040235a in BookOrder::insert (this=0x..., o=...) at book.cpp:88
...

Thread 11 (LWP 24894):
#0  0x00007fce4cef85d0 in __lll_lock_wait () from /lib/.../libpthread.so.0
...

Thread 1 (LWP 24891):
#0  0x00007fce4d2a3b4c in Channel::status (this=0x18) at channel.cpp:88
...
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

多线程崩溃的两类典型现象:

现象 含义
一个线程崩,其他都在 epoll_wait / futex_wait 普通的单点崩溃,看崩溃线程
多个线程都卡在 __lll_lock_wait 死锁,找到持锁者
某线程在 malloc_consolidate / _int_free 堆损坏,查这个线程之前在改什么
崩溃帧带 <signal handler called> 这个线程才是真正崩的,其他都是被信号打断

找到崩溃线程:

(gdb) info threads
  Id   Target Id                  Frame
* 1    LWP 24891 ...              Channel::status (...)
                ↑
                星号: 当前线程, 默认就是崩溃线程

(gdb) thread 1                    # 切过去
(gdb) bt
1
2
3
4
5
6
7
8

# 7.3 info registers

崩溃帧的寄存器是最直接的物证:

(gdb) info registers
rax    0x18                 24
rbx    0x7fce28a01040       140520...
rcx    0x4141414141414141   ← ⚠️ 毒标记!
rdx    0x0
rdi    0x18                 24    ← this 指针!
rsi    0x7ffeac1f5c80
rip    0x7fce4d2a3b4c       ← 当前指令
rsp    0x7ffeac1f5c70
rbp    0x7ffeac1f5c80
...
1
2
3
4
5
6
7
8
9
10
11

x86-64 函数调用约定(System V AMD64 ABI):

寄存器 用途
%rdi 第 1 个参数(成员函数里就是 this!)
%rsi 第 2 个参数
%rdx 第 3 个参数
%rcx 第 4 个参数
%r8 第 5 个参数
%r9 第 6 个参数
%rax 返回值
%rip 当前指令地址
%rsp 栈顶
%rbp 帧指针(如果保留)

主线一:%rdi = 0x18 立刻锁定"this == 0x18"——Channel* 指针被错误地写成了一个小整数。

两个高频技巧:

(gdb) p/x $rdi              # 16 进制看寄存器
(gdb) p (Channel*) $rdi     # 强转成对象类型, 然后能 p->字段
(gdb) p *((Channel*)$rdi)   # 解引用 (但 0x18 这种地址会报错)
1
2
3

# 7.4 disas 反汇编

info registers 给了值,disas 给了意图:

(gdb) disas $rip-16,$rip+16
   0x7fce4d2a3b3c <Channel::status+0>:    push   %rbp
   0x7fce4d2a3b3d <Channel::status+1>:    mov    %rsp,%rbp
   0x7fce4d2a3b40 <Channel::status+4>:    test   %rdi,%rdi
   0x7fce4d2a3b43 <Channel::status+7>:    je     0x7fce4d2a3b60
=> 0x7fce4d2a3b4c <Channel::status+12>:   mov    0x18(%rdi),%eax
   0x7fce4d2a3b4f <Channel::status+15>:   pop    %rbp
   0x7fce4d2a3b50 <Channel::status+16>:   retq
1
2
3
4
5
6
7
8

mov 0x18(%rdi), %eax —— 读 *(uint32_t*)(this + 0x18),也就是 Channel 结构里偏移 24 的那个 int 成员。%rdi = 0x18 → 实际访问 0x30。

和源码对照:

class Channel {
    int       status_;       // offset 0x00
    int64_t   conn_id_;      // offset 0x08
    string    name_;          // offset 0x10
    int       last_state_;   // offset 0x18  ← 就是这里!
    ...
};

int Channel::status() const {
    return last_state_;       // → mov 0x18(%rdi), %eax
}
1
2
3
4
5
6
7
8
9
10
11

反汇编三种姿势:

(gdb) disas                          # 当前函数全部
(gdb) disas /m                       # 源码 + 汇编混合
(gdb) disas $rip-32,$rip+16          # 崩溃点附近
(gdb) x/16i $rip                     # 当作内存读, 16 条指令
1
2
3
4

/m 是利器——直接看到"哪行 C++ → 哪几条汇编 → 哪条崩了"。

# 7.5 p $_siginfo

一行命令拿到信号、子码、地址三件套:

(gdb) p $_siginfo
$1 = {
  si_signo = 11,                 ← SIGSEGV
  si_errno = 0,
  si_code = 1,                   ← SEGV_MAPERR (页未映射)
  ...
  _sifields = {
    _sigfault = {
      si_addr = 0x18             ← 出错地址
    }
  }
}
1
2
3
4
5
6
7
8
9
10
11
12

读法对照(详见 01.信号崩溃快速排查 第 5 章):

signo + code 一句话病因 第一查
11 + 1 (SEGV_MAPERR) addr=0 空指针 哪个未判空
11 + 1 (SEGV_MAPERR) addr 接近栈底 栈溢出 递归 / 大局部数组
11 + 1 (SEGV_MAPERR) addr 是堆地址 UAF ASan 找释放点
11 + 2 (SEGV_ACCERR) addr 在 .rodata 写常量 字面量被改
7/10 + 2 (BUS_ADRERR) mmap 文件被截断 文件大小变化
6 (SIGABRT) 主动 abort 看 stderr glibc 输出
8 (SIGFPE) 整数除零 找除数

# 7.6 info proc mappings

最后一步:用进程地图证明你的假设。

(gdb) info proc mappings
Mapped address spaces:

      Start Addr           End Addr       Size     Offset  Perms  objfile
        0x400000           0x423000    0x23000          0  r-xp   /usr/bin/pay-worker
        0x423000           0x424000     0x1000    0x23000  r--p   /usr/bin/pay-worker
        0x424000           0x425000     0x1000    0x24000  rw-p   /usr/bin/pay-worker
   0x7fce28000000     0x7fce30000000  0x8000000          0  rw-p
                                                                  [heap]
   0x7fce4cd60000     0x7fce4cd80000    0x20000          0  r-xp   /usr/lib/libcrypto.so.3
   0x7fce4cf80000     0x7fce4d12a000   0x1aa000          0  r-xp   /lib/.../libc.so.6
   ...
   0x7ffeac0f6000     0x7ffeac1f7000   0x101000          0  rw-p   [stack]
                                                              ↑   ↑
                                                              │   权限位
                                                              │
                                                  权限: r=读 w=写 x=执行 p=私有
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

把 si_addr 对照映射表:

si_addr 落点 含义
不在任何 VMA 野指针 / 空指针(最常见)
在 r--p 段 试图写只读段
在 r-xp 段(代码段) 尝试写代码(可能 vtable 篡改)
在 [stack] 上方 50 KB 内 栈快溢出了
在 [stack] 下方守护页 栈已经溢出
在 [heap] 但接近边界 UAF / heap overflow

主线一证据链合龙:

si_addr = 0x18         → 不在任何 VMA → 不可能合法
%rdi = 0x18 (this)     → "channel" 字段值就是 0x18
disas: mov 0x18(%rdi)  → 读 this+0x18 → 0x30, 也不在 VMA
1
2
3

三层证据相互印证,根因锁定:"channel 不是 nullptr,而是被错误地写成了 0x18"——下文还要再追"为什么写成 0x18"。

# 8. 高级排查武器

基础六命令解决 80% 的 case。剩下 20% 的硬骨头——this 指针离谱、堆被踩、多线程死锁——要靠下面这些武器。

# 8.1 反向定位 this

主线一的一个关键问题:"channel 是 0x18,但谁把它写成 0x18 的?崩溃栈里看不到犯人"。

思路:找到这个 channel 字段所在的内存地址,再扫描整个进程内存,看哪些指令引用了它。但 core 是死的,不能跑——只能逆向推。

步骤一:定位字段地址。

(gdb) p &ctx->channel
$1 = (Channel **) 0x7fce28a01048
                   ^^^^^^^^^^^^^^
                   这个字段在堆上的地址

(gdb) p sizeof(*ctx)
$2 = 32
(gdb) p ctx
$3 = (OrderCtx *) 0x7fce28a01040
         + offset 0  : order_id (8 字节)
         + offset 8  : channel  (8 字节) ← 这里 = 0x18
         + offset 16 : trace
         + offset 24 : (padding)
1
2
3
4
5
6
7
8
9
10
11
12
13

步骤二:在 core 里搜这个地址附近的所有 0x18。find 命令可以扫描内存:

(gdb) find 0x7fce28000000, +0x8000000, 0x18
0x7fce28a01048             ← 我们的 ctx->channel
0x7fce28a01088             ← 隔壁的 ctx
0x7fce28a010c8             ← 再隔壁
...                        ← 所有 ctx 的 channel 都被写成了 0x18!
1
2
3
4
5

整个 OrderCtx 池里所有 channel 字段都被写成 0x18——不是个例,是批量损坏。这就指向了"批量写入逻辑里有 bug"。

步骤三:搜引用这块内存的指令。

# 在二进制里反汇编, 找写 ctx->channel (offset 8) 的指令
$ objdump -d /usr/bin/pay-worker | grep -B1 'mov.*0x8(%r' | head -20
   ...
   <batch_reconcile+0x4a>:
     mov   %rax,0x8(%rdi)        ← 把 rax 写到 ctx+0x8
   ...
1
2
3
4
5
6

回到源码看,channel_map_[id % 16] 在哪里赋值——主线一的根因是 channel_map_ 的某个元素值是个16 字节的 union/variant,被另一个并发线程"写到中间状态"时,被读到了"size_t 16 字节中的低 8 字节"——而这低 8 字节恰好是一个 discriminator,值是 0x18。

这个推理过程在生产 core 调试里很常见,方法论叫"值反向溯源":从一个错的值开始,找它的写入点。

# 8.2 STL 容器美化

GDB 默认打印 std::vector<X> 是这样:

(gdb) p vec
$1 = {<std::_Vector_base<int, std::allocator<int> >> = {
    _M_impl = {...}}, ...}
                ^^^
                看不出值
1
2
3
4
5

启用 pretty printer 后:

(gdb) p vec
$1 = std::vector of length 3, capacity 4 = {1, 2, 3}

(gdb) p str
$2 = "hello world"

(gdb) p mp
$3 = std::map with 2 elements = {[1] = "a", [2] = "b"}
1
2
3
4
5
6
7
8

默认 GCC 自带的 pretty printer 通常已经装了,没装的话:

$ ls /usr/share/gcc-*/python/libstdcxx
$ ls ~/.gdbinit
1
2

~/.gdbinit 加上:

python
import sys
sys.path.insert(0, '/usr/share/gcc-12/python')
from libstdcxx.v6.printers import register_libstdcxx_printers
register_libstdcxx_printers(None)
end

set print pretty on
set print elements 0       # 不省略长字符串/容器
set print object on        # 多态打印实际类型
set print frame-arguments all
1
2
3
4
5
6
7
8
9
10
11

boost / abseil / folly 的容器要自己装 pretty printer——它们各自的 GitHub 项目里都有。

# 8.3 堆扫描追指针

野指针 / UAF 调试时,常需要"反向找谁持有了这个地址"。

(gdb) p p
$1 = (Handler *) 0x7fce28b40300

# 在整个堆里搜引用这个地址的指针
(gdb) find /g 0x7fce28000000, +0x8000000, 0x7fce28b40300
0x7fce28a01050        ← 某个 OrderCtx
0x7fce28a01070        ← 另一个
...

# 看附近上下文 (前后 32 字节)
(gdb) x/32xb 0x7fce28a01040
0x7fce28a01040: 0x05 0x00 0x00 0x00 0x00 0x00 0x00 0x00   ← order_id
0x7fce28a01048: 0x00 0x03 0xb4 0x28 0xce 0x7f 0x00 0x00   ← handler (引用)
0x7fce28a01050: ...
1
2
3
4
5
6
7
8
9
10
11
12
13
14

注:/g 是按 8 字节单位搜,扫指针必须用它,否则会按字节匹配,命中海量噪声。

对 ASan 编译的 core——有更省事的方法:ASan 篇 提到的 __asan_describe_address 可以反向给出"这个地址当前是什么状态、之前是什么对象、谁分配的"。

# 8.4 Python 自动化

GDB 内嵌 Python,能写自动化脚本:

# ~/gdb_scripts/dump_threads.py
# 把所有线程栈转成 JSON, 方便上报
import gdb, json

result = []
for thread in gdb.selected_inferior().threads():
    thread.switch()
    frames = []
    frame = gdb.newest_frame()
    while frame:
        sal = frame.find_sal()
        frames.append({
            "func": frame.name() or "??",
            "file": sal.symtab.filename if sal.symtab else None,
            "line": sal.line,
            "pc":   "%#x" % frame.pc(),
        })
        frame = frame.older()
    result.append({
        "tid": thread.ptid[1],
        "name": thread.name,
        "frames": frames,
    })

print(json.dumps(result, indent=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

跑:

$ gdb ./pay-worker core -batch -x dump_threads.py > threads.json
1

之后可以在 ELK / Grafana 上做"哪条调用栈最常崩"的统计。

更复杂的可以写 GDB Command:

# ~/.gdbinit-py
class PrintCtx(gdb.Command):
    """print-ctx <addr>: 把 OrderCtx 美化打印"""
    def __init__(self):
        super().__init__("print-ctx", gdb.COMMAND_DATA)
    def invoke(self, arg, from_tty):
        addr = gdb.parse_and_eval(arg)
        ctx = addr.cast(gdb.lookup_type("OrderCtx").pointer())
        print(f"order_id = {ctx['order_id']}")
        print(f"channel  = {ctx['channel']}")
        # 检查有效性
        ch = int(ctx['channel'])
        if ch < 0x1000 or ch == 0x4141414141414141:
            print(f"  ⚠️ channel suspicious value: {ch:#x}")

PrintCtx()
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

后续 (gdb) print-ctx 0x7fce28a01040 直接调用。

# 8.5 自定义 gdbinit

把高频流程封装成命令。这是经验沉淀的核心:

# ~/.gdbinit (适用于 core 分析的版本)

# 一键概览
define overview
    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(this/arg1): "
    p/x $rdi
    printf "===== Top Stack =====\n"
    bt 8
    printf "===== Threads =====\n"
    info threads
end

# 看某线程的栈
define ts
    thread $arg0
    bt full
end

# 看堆地址附近
define near
    x/64xb $arg0-32
end

# 自动加载常用设置
set pagination off
set print pretty on
set print elements 0
set print frame-arguments all
set print object on
set logging file gdb.log
set logging on
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
35
36
37
38

每次进 gdb 直接 overview 就够了——5 秒钟出报告,比手敲 5 条命令强 10 倍。

# 9. 符号与地址换算

主线二全是 ?? 的 core,本质是这一节要讲的——符号没了 = core 没法读。

# 9.1 build-id 校验

每个编译产物都有 build-id(GCC -Wl,--build-id 默认开启),存在 .note.gnu.build-id 段:

$ readelf -n /usr/bin/pay-worker | grep 'Build ID'
    Build ID: 7f3a8b478e2c8b478e2c8b478e2c8b478e2c8b47

# core 也记录了崩溃时各模块的 build-id
$ eu-readelf -n core.pay-worker-24891 | grep 'Build ID'
1
2
3
4
5

GDB 加载时会自动校验 build-id,不一致直接拒绝:

warning: Mismatch between current exec-file /usr/bin/pay-worker
         and exec-file recorded in core file: /usr/bin/pay-worker
         (CRC mismatch).
1
2
3

生产做法:所有 release 都把 build-id 当主键存,对应一份完整 DWARF。崩溃后用 build-id 反查。

# 例: 把分离的调试包按 build-id 索引
$ ls /usr/lib/debug/.build-id/
00/  01/  ...  7f/
$ ls /usr/lib/debug/.build-id/7f/
3a8b478e2c....debug

# GDB 自动找:
# /usr/lib/debug/.build-id/<前2位>/<剩下字符>.debug
1
2
3
4
5
6
7
8

# 9.2 分离调试符号

发布的二进制不能带几百 MB 的 DWARF——但又要能调试 core。标准做法是分离:

# 1. 编译时正常带 -g
$ g++ -O2 -g -o pay-worker src/*.cpp

# 2. 把调试信息抽到 .debug 文件
$ objcopy --only-keep-debug pay-worker pay-worker.debug

# 3. 原文件 strip 掉调试段, 但保留链接关系
$ strip --strip-debug --strip-unneeded pay-worker

# 4. 在原文件里写一条 "我的调试信息在那个文件" 的链接
$ objcopy --add-gnu-debuglink=pay-worker.debug pay-worker

# 验证
$ readelf -p .gnu_debuglink pay-worker
String dump of section '.gnu_debuglink':
  [     0]  pay-worker.debug
  [    14]  ...crc32...
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

部署时:

  • 把 pay-worker 推到生产;
  • 把 pay-worker.debug 归档到符号服务器(按 build-id 索引);
  • 调试时 gdb 自动找:先看 --add-gnu-debuglink 名字,再在 debug-file-directory 找。
# gdb 找调试包的搜索路径 (按顺序)
1. /usr/lib/debug/<可执行文件路径>/<debuglink 名字>
2. <可执行文件目录>/<debuglink 名字>
3. <可执行文件目录>/.debug/<debuglink 名字>
4. ${debug-file-directory}/<可执行文件路径>
5. ${debug-file-directory}/.build-id/xx/yyyy.debug
1
2
3
4
5
6

set debug-file-directory 可以加多个路径。

# 9.3 共享库基址

core 里的栈帧地址通常是绝对地址(如 0x7fce4d2a3b4c)。要算出"在 libpay.so 里的偏移":

# 1. 看 core 里 libpay.so 加载基址 (info sharedlibrary 第一列)
0x00007fce4d2a0000  0x00007fce4d540000  ...  /usr/lib/libpay.so.2

# 2. 偏移 = 崩溃地址 - 基址
偏移 = 0x7fce4d2a3b4c - 0x7fce4d2a0000 = 0x3b4c
1
2
3
4
5

如果有了 .so 和它的 .debug 包,可以反查到源码行:

$ addr2line -e /usr/lib/libpay.so.2.debug -f -C 0x3b4c
Channel::status() const
/path/to/channel.cpp:88
1
2
3

PIE(Position Independent Executable)二进制本体也是这样——加载基址不固定,要算偏移。

# 9.4 addr2line 实战

栈打到日志里都是裸地址(如自定义 crash handler 输出 backtrace_symbols_fd)。在没有 gdb 的环境批量还原:

$ cat crash.log
crash at:
  /usr/bin/pay-worker(+0x4a25b)
  /usr/lib/libpay.so.2(+0x3b4c)
  /usr/lib/libpay.so.2(+0x125d)
  ...

# 单条查
$ addr2line -e /usr/bin/pay-worker -f -C +0x4a25b
worker_loop()
/path/to/worker.cpp:121

# 批量查 (从 stdin)
$ awk -F'[+()]' '{print $2}' crash.log | \
    xargs -I{} addr2line -e /usr/bin/pay-worker -f -C {}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

一个常被忽略的细节:+0x4a25b 里的偏移是相对该 ELF 起点的,addr2line 也按这个 ELF 解析。主二进制和 .so 要分开 addr2line,混着传只会得到错误结果。

eu-addr2line(来自 elfutils 包)更准——支持 split-DWARF、build-id 自动查、内联函数展开成多行:

$ eu-addr2line -e /usr/bin/pay-worker -f -i -C 0x4a25b
worker_loop_inline_helper inlined at /path/to/worker.cpp:117
worker_loop /path/to/worker.cpp:121
                ↑
                看到内联展开的整条调用链
1
2
3
4
5

# 9.5 strip 后的栈

如果连 .symtab 都被 strip 了(strip --strip-all),仍然有救——靠 .dynsym(动态符号表)和 .eh_frame(异常展开表):

$ strip --strip-all --keep-symbol main pay-worker
# .symtab 没了, 但 .dynsym 还在 (动态链接需要)
# .eh_frame 还在 (异常展开 + backtrace 需要)

$ gdb pay-worker core
(gdb) bt
#0  Channel::status (this=0x18) at ??:88     ← 函数名有, 行号没
#1  reconcile (...) at ??
#2  ?? ()                                    ← 内部函数, 完全丢
1
2
3
4
5
6
7
8
9

抢救方案:

  1. 从对应版本的 release 包提取调试包:CI 把每个 release 的 <bin>.debug 归档;
  2. eu-unstrip 合并:
$ eu-unstrip pay-worker pay-worker.debug -o pay-worker.full
# pay-worker.full 是合并后的, gdb 用它 + core 就能完整看
1
2
  1. 没有调试包:只能靠 .dynsym + .eh_frame 看到函数名和栈帧关系,行号注定丢。这是 strip-all 的代价。

铁律:strip --strip-debug 永远好过 strip --strip-all。前者只剥 DWARF(符号还在),后者把符号一起剥。生产二进制如果性能不敏感,永远不要 --strip-all。

# 10. 五步破案方法论

把主线一的破案过程抽象成可复用流程:

  ┌─────────────────────────────────────────┐
  │ 1. 看信号副标题 (signo + si_code)       │
  │    锁定大类: SEGV / BUS / ABRT / FPE    │
  └──────────────────┬──────────────────────┘
                     ↓
  ┌─────────────────────────────────────────┐
  │ 2. 看寄存器证据 (rdi/this 是否离谱)      │
  │    锁定"关键变量值是哪个"                │
  └──────────────────┬──────────────────────┘
                     ↓
  ┌─────────────────────────────────────────┐
  │ 3. 沿栈反向回溯 (frame N + info locals) │
  │    从 crash site 找到 bug site          │
  └──────────────────┬──────────────────────┘
                     ↓
  ┌─────────────────────────────────────────┐
  │ 4. 状态对比时间线                        │
  │    "正常时是什么样, 现在差在哪"           │
  └──────────────────┬──────────────────────┘
                     ↓
  ┌─────────────────────────────────────────┐
  │ 5. 修复 + 回归 + 沉淀                    │
  │    A/B/C 三级修复 + 测试 + CI 兜底       │
  └─────────────────────────────────────────┘
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24

# 10.1 先看信号副标题

没看 si_code 就开 gdb 翻栈,等于不看 X 光片就开刀。

主线一的 p $_siginfo:

si_signo = 11 (SIGSEGV)
si_code  = 1  (SEGV_MAPERR)   ← 页未映射 → 不是权限问题
si_addr  = 0x18                ← 出错地址极小
1
2
3

立刻得到三个关键判断:

  1. SEGV_MAPERR → 不是写只读、不是 NX,是访问了根本没分配的页;
  2. si_addr=0x18 → 一个绝对小、绝对落在 OS guard page 里的地址;
  3. 不是 0 → 不是常规的 nullptr 解引用,是某个指针被错误地写成了 0x18。

第三条特别重要——它把"空指针检查不到位"的怀疑直接排除:如果是 nullptr,if (s->channel) 那一行早就拦住了。问题一定出在"指针的值是个非空但无效的小数"。

# 10.2 看寄存器证据

info registers 是第二刀。

主线一里 %rdi = 0x18 —— 这是成员函数的 this 指针。**第一参数是 this**这条 ABI 规则,让我们立刻知道 "Channel::status 的 this 是 0x18"。

接着 disas $rip-8,$rip+8 看到 mov 0x18(%rdi), %eax——读 this+0x18 的成员。两条证据合龙:this 本身就是 0x18,加上偏移读 0x30,必崩。

寄存器证据三个关键观察:

观察 推论
%rdi / %rsi 等参数寄存器值离谱 调用者传错参数
%rsp 远低于栈底 栈溢出
%rip 不在任何代码段 函数指针被踩 / 跳到了堆栈
多个寄存器都是 0x4141... 缓冲区溢出覆盖了寄存器保存区
%rax 是返回值寄存器,但崩在 mov %rax, ... 上一个函数返回了坏值

# 10.3 沿栈反向回溯

The crash site is rarely the bug site——崩溃位置很少是 bug 位置。

主线一:

术语 位置
Crash site channel.cpp:88,Channel::status() 内的 mov 0x18(%rdi)
Bug site (近因) reconcile.cpp:14,ctx->channel 是 0x18
Bug site (远因) batch_reconcile,从 channel_map_ 读到了脏数据
Bug site (根因) channel_map_ 的并发写没加全锁

调试就是从 crash site 一帧一帧往上爬,直到找到自己代码的某个错误假设:

(gdb) frame 0     # crash site
(gdb) info locals
(gdb) info args
(gdb) frame 1     # 上一层, 看是谁传给我这个值的
(gdb) info args   # 看参数
(gdb) frame 2     # 再上
1
2
3
4
5
6

每一帧问三个问题:

  1. 这一帧的入参是什么?是不是已经错了?
  2. 这一帧的局部变量呢?
  3. 上一帧调我时,传的值是从哪取的?

直到答案是"哦,这个值在 batch_reconcile 里就已经错了"——那就是 bug site。

# 10.4 状态对比时间线

复杂 bug 不是一行能讲清的。把 core 当作"故障时刻的快照",对比"正常时刻的快照"——差异点就是 bug。

主线一时间线:

T0  服务正常运行
    channel_map_ = [c0, c1, c2, ..., c15]
    所有指针都是合法堆地址 (例如 0x7fce28b40300)

T1  对账批处理启动 (凌晨 2:30)
    QPS 从 1.2 万 → 4 万
    某个清理线程开始重建 channel_map_ 的部分槽

T2  (race window)
    线程 A: 写 channel_map_[3] 中, 写到一半 (写了 16 字节中前 8)
            前 8 字节 = 标志位 = 0x18
    线程 B: 此时读 channel_map_[3], 读到了未完成的状态
            认为 channel_map_[3] = 0x18

T3  reconcile(ctx) 被调用
    ctx->channel = 0x18   ← 这里被赋值
    Channel::status() → mov 0x18(%rdi), %eax → SIGSEGV

T4  core dump 落盘
    我们看到的就是 T3 之后的状态
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

调试时怎么验证 T2 真的发生了?

  1. core 里搜 find 看有多少 OrderCtx::channel == 0x18 —— 主线一搜出几百个,不是孤立现象 → 印证"批量损坏";
  2. 看 batch_reconcile 帧的局部变量,确认是不是当时正在某个 hash 桶里取值;
  3. 看其他线程都在干什么(thread apply all bt),找到那个清理线程的栈,验证它确实在改 channel_map_;
  4. 看 channel_map_ 的 mutex 持有者(pthread_mutex_t::__owner 字段),印证读路径漏了锁。

没有时间线对比,只看 core 一个截面,永远只能停在"crash site"。

# 10.5 修复与回归沉淀

三层修复方案(主线一):

修复 A:紧急止血 — 写路径加锁

void update_channel(int idx, Channel* ch) {
    std::lock_guard<std::mutex> g(channel_map_mtx_);
    channel_map_[idx] = ch;            // 整体替换, 在锁内
}

// 读路径其实已经"加了锁"——但只锁了 std::map::find, 没锁 value 的拷贝
Channel* lookup_channel(int idx) {
    std::lock_guard<std::mutex> g(channel_map_mtx_);
    auto it = channel_map_.find(idx);
    return it == channel_map_.end() ? nullptr : it->second;
}
1
2
3
4
5
6
7
8
9
10
11

适用:紧急 hotfix。局限:只修这一处,下一处类似的"双源真相 + 漏锁"还会崩。

修复 B:用并发安全容器

#include <tbb/concurrent_hash_map.h>     // 或 folly::ConcurrentHashMap

tbb::concurrent_hash_map<int, Channel*> channel_map_;

Channel* lookup_channel(int idx) {
    decltype(channel_map_)::const_accessor acc;
    return channel_map_.find(acc, idx) ? acc->second : nullptr;
}
1
2
3
4
5
6
7
8

适用:改动可控时。优点:编译期消除"忘加锁"。

修复 C:架构层 — 不可变快照 (RCU 风格)

struct ChannelMap {
    std::array<Channel*, 16> arr;
};

std::atomic<std::shared_ptr<const ChannelMap>> map_;

// 读: 永不阻塞
Channel* lookup_channel(int idx) {
    auto m = map_.load();              // 原子读 shared_ptr
    return m->arr[idx];
}

// 写: 拷贝 -> 改 -> 原子替换
void update_channel(int idx, Channel* ch) {
    auto old_m = map_.load();
    auto new_m = std::make_shared<ChannelMap>(*old_m);
    new_m->arr[idx] = ch;
    map_.store(std::move(new_m));      // 旧 map 由最后的引用者析构
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

优点:读路径无锁、永远看到一致快照、性能比加锁高 10x;根除"读到中间状态"这一类 bug。

修复 改动 效果 推荐场景
A:写加锁 1 个函数 只防当前 case 紧急发版
B:换容器 类型替换 防"忘加锁"一类 中期重构
C:RCU 快照 模式重写 根除"读中间态" 长期 + 高性能

论证修复有效——必须做的三件事:

  1. 复现路径回归:能稳定触发原 bug 的测试用例(哪怕是 fuzz);
  2. TSan 跑一遍:data race 问题用 TSan 验证比 ASan 更直接;
  3. 加压回归:模拟原崩溃时的 QPS 4 万 + 批量清理,跑 30 分钟不崩。

沉淀为 CI:

// test_concurrent_channel.cpp
TEST(ChannelMap, NoTornReadUnderRace) {
    ChannelMap m;
    std::atomic<bool> stop{false};
    std::thread writer([&] {
        for (int i = 0; !stop; ++i)
            m.update(i % 16, new Channel(i));
    });
    std::thread reader([&] {
        for (int i = 0; !stop; ++i) {
            auto* ch = m.lookup(i % 16);
            if (ch) ASSERT_NE((uintptr_t)ch, 0x18u);   // 不会读到撕裂
        }
    });
    std::this_thread::sleep_for(5s);
    stop = true;
    writer.join(); reader.join();
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

跑这个 test 时同时开 TSan,能在合并 PR 之前就抓出新引入的 race。

心法八条:

  • 看 si_code 比看栈早:信号副标题决定排查方向;
  • %rdi 是侦查线索之王:成员函数调用错位 90% 在它上面;
  • disas 给你"意图":源码看不到但汇编藏不住;
  • 永远 info threads:没有"当前线程崩了,其他线程不重要"这种事;
  • value reverse search:从一个错值反向找谁写的它;
  • 快照对比,不是单帧分析:要有"正常时长什么样"的基线;
  • 修一处不够,根除一类:A 修当下,C 防未来;
  • 沉淀进 CI:调试 1 天,测试 30 秒——值。

# 11. 典型场景速查

把第 1~10 章的方法论,落到 7 个最高频的 core 现场。

# 11.1 空指针 this

void do_work() {
    Resource* r = get_resource();   // 可能返回 nullptr
    r->process();                   // 崩在 r 第一条指令
}
1
2
3
4

特征:SIGSEGV + SEGV_MAPERR + si_addr 是一个很小的整数(0、0x8、0x10、0x20 等——对应成员偏移)。%rdi = 0。

第一刀:

(gdb) bt
(gdb) info registers rdi
(gdb) frame 1
(gdb) info args                # 看上一层是怎么传 nullptr 进来的
1
2
3
4

修法:函数入口判空 if (!r) return;,或返回类型用 gsl::not_null<T*> / 引用。

# 11.2 vtable 被踩

delete obj;             // obj 已被释放
obj->virtual_func();    // vtable 已被填毒标记 / 被复用
1
2

特征:崩溃帧是某个虚函数的第一条指令(mov (%rdi), %rax 读 vtable)。%rdi 是合法堆地址,但 *(%rdi) 是垃圾值(如 0x4141... 或一个非代码段地址)。

第一刀:

(gdb) p $rdi
(gdb) x/8gx $rdi          # 看对象前 64 字节, 第一个指针是 vtable
0x...: 0x4141414141414141   ← 毒标记!

(gdb) info proc mappings | grep -B1 "$rax"   # vtable 应该指向 .rodata
                                              # 不在 → 已被覆盖
1
2
3
4
5
6

修法:智能指针 + 显式所有权(01.信号崩溃快速排查 第 13.1 节)。

# 11.3 栈溢出递归

void walk(Tree* t) {
    if (!t) return;
    walk(t->left);          // 树太深 / 没有终止条件
    walk(t->right);
}
1
2
3
4
5

特征:bt 显示同一函数反复出现深度上千。%rsp 接近 stack guard。

第一刀:

(gdb) bt | wc -l           # 栈帧数, 上千就有问题
(gdb) p $rsp
(gdb) info proc mappings | grep stack
   0x7ffeac0f6000 - 0x7ffeac1f7000   [stack]
                ↑
                $rsp 离左边界很近 → 已经撞 guard page
1
2
3
4
5
6

修法:递归改迭代 + 显式栈;或用 ulimit -s / pthread_attr_setstacksize 调大栈(治标)。

# 11.4 多线程死锁

// 线程 A: lock(m1) -> lock(m2)
// 线程 B: lock(m2) -> lock(m1)
1
2

特征:进程没崩,是被外部 SIGABRT 杀的(看门狗超时)。thread apply all bt 看到多个线程在 __lll_lock_wait。

第一刀:

(gdb) thread apply all bt 5
... 找到所有 __lll_lock_wait 的线程

(gdb) thread N
(gdb) frame ?              # 找到 __pthread_mutex_lock 帧
(gdb) p *mutex
$1 = {__data = {__lock = 2, __count = 0,
                __owner = 24893,            ← 持有者 LWP!
                ...}}
(gdb) thread find LWP 24893
(gdb) thread <id>
(gdb) bt                    # 看持有者卡在哪
1
2
3
4
5
6
7
8
9
10
11
12

死锁链画清楚就找到环了。

修法:std::scoped_lock 一次性锁多个;或所有 mutex 按全局固定顺序加锁。

# 11.5 堆内存损坏

char* p = new char[16];
memcpy(p, src, 32);     // 越界写 16 字节, 踩了下一个 chunk 的 header
free(another);          // 触发 glibc 检测, abort
1
2
3

特征:SIGABRT,stderr 上 glibc 输出 malloc(): memory corruption / free(): invalid next size。崩溃栈最顶是 malloc_consolidate 或 _int_free。

第一刀:

(gdb) bt
(gdb) frame N            # 切到 abort 上层 (用户代码层)
1
2

真相——崩溃位置是"被踩的人发现自己被踩了",不是"踩别人的人"。找踩者只能靠 ASan 重跑:

# 不能直接调 core, 需要拿原代码重编 ASan 版
g++ -fsanitize=address -g -O1 ...
1
2

# 11.6 异常未捕获

void f() {
    throw std::runtime_error("bad");
}
// 没人 catch → terminate → abort
1
2
3
4

特征:SIGABRT,stderr 上 terminate called after throwing an instance of 'std::runtime_error'。bt 看到 __cxxabiv1::__terminate 或 std::terminate。

第一刀:

(gdb) bt
... 找到第一个用户代码帧

# 看异常对象 (libstdc++ ABI)
(gdb) p *(std::runtime_error*)((char*)__cxa_get_globals()->caughtExceptions+...)
# 太繁琐, 直接源码搜 throw 关键字
1
2
3
4
5
6

修法:边界 try-catch,或 set_terminate 装一个打栈 handler 给后人留信息。

# 11.7 优化丢符号

(gdb) frame 1
(gdb) p ctx
$1 = <optimized out>           ← 编译器把变量优化没了
1
2
3

特征:-O2 编译,gdb 看变量都是 <optimized out>。

第一刀:

(gdb) info registers       # 看寄存器, 编译器把变量放寄存器了
(gdb) disas /m             # 源码 + 汇编对照, 找到当前行用了哪几个寄存器
(gdb) p $rdi               # 直接读寄存器
1
2
3

修法:

  • 重编时用 -Og(专为调试优化的级别,性能损失 < 10% 但变量保留);
  • 关键产线服务保留 -O2 -g,不要用 -Og —— 性能差距不能忽略,但 split-DWARF 可以;
  • 接受现实:-O2 的 core 调试就是要会读汇编。

# 12. 工程化最佳实践

单次破案能力强 ≠ 团队整体调试效率高。这一节讲"怎么把 core 调试变成基础设施"。

# 12.1 构建期分离符号

CI 流水线应该自动产出三个产物:

release-pkg/
├── pay-worker              # 部署到生产 (strip 后, 几 MB)
├── pay-worker.debug        # 归档到符号服务器 (几百 MB)
└── pay-worker.buildid      # 文本: 7f3a8b478e2c... (索引主键)
1
2
3
4

Makefile / CMake 模板:

pay-worker: $(OBJS)
	$(CXX) -O2 -g -o $@ $^
	objcopy --only-keep-debug $@ $@.debug
	objcopy --strip-debug --strip-unneeded $@
	objcopy --add-gnu-debuglink=$@.debug $@
	# 输出 build-id 文件用于查找
	readelf -n $@ | grep 'Build ID' | awk '{print $$3}' > $@.buildid
1
2
3
4
5
6
7
# CMake
add_executable(pay-worker ${SRCS})
target_compile_options(pay-worker PRIVATE -O2 -g)

add_custom_command(TARGET pay-worker POST_BUILD
    COMMAND objcopy --only-keep-debug $<TARGET_FILE:pay-worker> $<TARGET_FILE:pay-worker>.debug
    COMMAND objcopy --strip-debug --strip-unneeded $<TARGET_FILE:pay-worker>
    COMMAND objcopy --add-gnu-debuglink=$<TARGET_FILE:pay-worker>.debug $<TARGET_FILE:pay-worker>
)
1
2
3
4
5
6
7
8
9

进阶:split-DWARF——更轻量的方案:

g++ -O2 -g -gsplit-dwarf -fdebug-types-section ...
# 生成 pay-worker (主二进制 + .debug_info 引用) + 多个 .dwo (DWARF 实体)
# 链接时配合 -Wl,--gdb-index 加速 GDB 加载
1
2
3

split-DWARF 让二进制和调试信息自然分离——不需要 strip + objcopy 两步。GDB 加载快 5~10 倍。

# 12.2 符号服务器搭建

简单可行的方案:对象存储 + 按 build-id 索引。

S3 / OSS bucket: my-symbols/
├── 7f/
│   └── 3a8b478e2c8b478e2c.../
│       ├── pay-worker.debug
│       └── manifest.json   { version: "2.1.5", git: "abc123", ... }
├── 8a/
│   └── ...
1
2
3
4
5
6
7

调试客户端封装:

# ~/bin/dgdb (debug-gdb)
#!/bin/bash
CORE=$1
BIN=$2

# 1. 提取 core 里的 build-id
BUILD_ID=$(eu-readelf -n $CORE | grep 'Build ID' | head -1 | awk '{print $3}')

# 2. 从符号服务器拉
PREFIX=${BUILD_ID:0:2}
SUFFIX=${BUILD_ID:2}
mkdir -p ~/.debug-cache/.build-id/$PREFIX
aws s3 cp s3://my-symbols/$PREFIX/$SUFFIX.debug \
       ~/.debug-cache/.build-id/$PREFIX/$SUFFIX.debug

# 3. 启动 gdb
gdb -ex "set debug-file-directory ~/.debug-cache" \
    -ex "core $CORE" \
    $BIN
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

之后调试体验就是:dgdb core.xxx ./pay-worker,几秒下载 + 自动加载完整符号。

# 12.3 自动化分析脚本

线上一台机器一晚上崩 50 次,没人能一个个 gdb 看。批量摘要脚本:

#!/usr/bin/env python3
# core_summary.py
import subprocess, json, sys, os

def gdb_query(binary, core):
    r = subprocess.run(
        ["gdb", "-batch", "-q",
         "-ex", "set print pretty on",
         "-ex", "info threads",
         "-ex", "thread apply all bt 5",
         "-ex", "p $_siginfo",
         "-ex", "info registers rip rdi rsi",
         binary, core],
        capture_output=True, timeout=300, text=True
    )
    return r.stdout

def fingerprint(stack_top):
    """同样的崩溃, fingerprint 一致 → 聚类"""
    return ":".join(stack_top[:3])  # 取前 3 帧函数名

cores = sys.argv[1:]
groups = {}
for c in cores:
    out = gdb_query("./pay-worker", c)
    top_funcs = extract_top_funcs(out)   # 自己实现解析
    fp = fingerprint(top_funcs)
    groups.setdefault(fp, []).append(c)

for fp, items in sorted(groups.items(), key=lambda x: -len(x[1])):
    print(f"[{len(items)}x]  {fp}")
    print(f"   sample: {items[0]}")
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

跑一遍:

$ ./core_summary.py /var/cores/core-pay-worker-*
[42x]  Channel::status:reconcile:batch_reconcile
   sample: /var/cores/core-pay-worker-24891-...
[5x]   __lll_lock_wait:BookOrder::insert:...
   sample: /var/cores/core-pay-worker-24905-...
1
2
3
4
5

50 个 core 立刻聚成 2 类——生产排障从"逐个看"变成"先排序,先打头部"。

# 12.4 minidump 替代

完整 core 不适合上报到云端(流量大、有内存安全风险)。Google Breakpad / Crashpad 提供的 minidump 是工程界主流方案:

minidump 内容:
├── 系统信息 (OS, CPU, 模块列表)
├── 异常信息 (signal, address, code)
├── 所有线程的栈 (栈数据 + 寄存器, 几 KB ~ 几百 KB)
└── 关键变量周围的内存 (栈帧 + TLS)

minidump 不包含:
- 整个堆 (避免泄露用户数据 + 减小体积)
- 共享库代码段
1
2
3
4
5
6
7
8
9

生成 + 上报流程:

#include "client/linux/handler/exception_handler.h"

bool CrashCallback(const google_breakpad::MinidumpDescriptor& d,
                   void* ctx, bool succeeded) {
    // 异步信号安全的写入
    fprintf(stderr, "Dump path: %s\n", d.path());
    return succeeded;
}

google_breakpad::MinidumpDescriptor desc("/tmp/dumps");
google_breakpad::ExceptionHandler eh(desc, nullptr, CrashCallback,
                                      nullptr, true, -1);
1
2
3
4
5
6
7
8
9
10
11
12

线上服务把 dump 上传到对象存储,符号服务器侧用 minidump_stackwalk + symbol_dir 反向打栈。

完整 core 与 minidump 对照:

维度 完整 core minidump
大小 几 GB 几百 KB
上报 几乎不可能 网络上传 OK
查堆任意对象 ✅ ❌
查所有线程栈 ✅ ✅
隐私风险 高 (堆里全是用户数据) 低
适合场景 服务端 客户端 / 移动端

# 12.5 core 治理流水线

成熟团队的 core 治理流水线:

线上崩溃
    │
    ▼
┌──────────────────────┐
│ ① 自动留 core         │  ← core_pattern + 单独 SSD 分区
│   (ulimit + path)    │
└─────────┬────────────┘
          ▼
┌──────────────────────┐
│ ② 元数据上报           │  ← post-core hook 脚本
│   (build-id, version,│     从 core_pattern 管道接收
│    timestamp, host)  │
└─────────┬────────────┘
          ▼
┌──────────────────────┐
│ ③ 自动摘要            │  ← Python 脚本批跑 gdb -batch
│   (fingerprint 聚类)  │     找符号服务器拉 .debug
└─────────┬────────────┘
          ▼
┌──────────────────────┐
│ ④ 告警                │  ← 同 fingerprint 出现 N 次, IM 通知
│   (按 fingerprint 去重)│
└─────────┬────────────┘
          ▼
┌──────────────────────┐
│ ⑤ 归档与清理           │  ← 7 天后压缩, 30 天后删除
│   (按 build-id 留种)  │     每个 fingerprint 至少留 1 份种子
└──────────────────────┘
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

生产建议:

  • 磁盘策略:/var/cores 单独分区,避免业务磁盘被占满;
  • 限速:单 host 同时落 core 数 ≤ 3(flock 实现),防止"集体崩溃 → 磁盘 IO 打满 → 服务雪崩";
  • 隐私合规:core 里有用户敏感数据时,落地必须加密 + 限制访问 + 短期清理;
  • 灰度协议:新版本上线 24 小时内崩溃必须留 core,N 天稳定后才能关;
  • Postmortem 模板:每次 fingerprint 引发 P0 / P1,必须出复盘文档(含 core 截图 + 修复 PR + CI 用例)。

core 调试成熟度模型:

阶段 能力
Level 1 知道 gdb ./bin core,能看 bt
Level 2 会用 info registers / disas / p $_siginfo 三件套
Level 3 能区分 crash site 与 bug site,会沿栈反推
Level 4 团队有符号服务器 + build-id 索引 + 自动化摘要
Level 5 core 治理流水线 + minidump 客户端方案 + Postmortem 闭环

绝大多数团队卡在 Level 2,少数到 Level 4。但 Level 5 才能在"凌晨 3 点服务崩溃 → 30 分钟定位 → 1 小时修复"的 SLA 下从容应对。

# 13. 综合案例串讲

# 13.1 案例真相揭晓

回到第 1.1 节的 reconcile.cpp,七个疑问现在能逐条作答:

疑问 答案
① core 文件到底是什么? 第 3:ELF 文件,内核 dump 时把进程的 VMA 写出,PT_NOTE 段保存寄存器 + signal 信息
② 没生成 core 是哪关没过? 第 4:ulimit / core_pattern / suid_dumpable / 容器 LimitCORE 四关
③ systemd / 容器里 core 怎么找? 第 5:coredumpctl gdb 一键拉起;容器跑 host 的 core_pattern 写到 host
④ 第一刀该砍在哪? 第 7:bt → info threads → info registers → disas → p $_siginfo → info proc mappings
⑤ 栈全是 ?? 怎么救? 第 9:build-id 校验 + 分离调试符号 + sysroot 跨机调试
⑥ this 离谱怎么逆推? 第 8.1:值反向溯源,find 命令搜内存找谁写的
⑦ 怎么工程化? 第 12:构建期分离符号 → 符号服务器 → 自动摘要 → minidump → 治理流水线

最终诊断:

channel_map_ 是个 16 元素的并发映射表,写路径加了锁,但读路径只锁了 find 没锁 value 的拷贝。当线程 A 在 update_channel(3, new_ch) 写到一半(new_ch 是 16 字节的复合类型,写了前 8 字节 0x18、后 8 字节还是旧的),线程 B 读 channel_map_[3] 取出了"前 8 字节是 0x18 + 后 8 字节是旧值"的撕裂状态——Channel* 本应是一个 8 字节指针,但读到的是 0x18。Channel::status() 第一条指令 mov 0x18(%rdi), %eax(rdi 即 this)→ 访问 0x30 → SIGSEGV (SEGV_MAPERR)。

修复路径(按优劣排序):

方案 A:读路径补锁(紧急)

Channel* lookup_channel(int idx) {
    std::lock_guard<std::mutex> g(channel_map_mtx_);
    return channel_map_[idx];
}
1
2
3
4

代价:所有读路径都要排队,QPS 4 万下竞争激烈。

方案 B:std::atomic<Channel>(推荐)*

struct ChannelMap {
    std::atomic<Channel*> arr[16];      // 单指针原子读写, 无撕裂
};

Channel* lookup(int idx) {
    return arr[idx].load(std::memory_order_acquire);
}
void update(int idx, Channel* p) {
    arr[idx].store(p, std::memory_order_release);
}
1
2
3
4
5
6
7
8
9
10

代价:要保证 Channel 自身不会被并发删除(用 shared_ptr 或 RCU)。

方案 C:RCU 快照(架构级)

详见第 10.5 节的修复 C——读永不阻塞,整张 map 原子替换。适合读多写少的场景。

生产建议:方案 B + Channel 用 shared_ptr 管理生命周期。

# 13.2 一次破案的全景

把"凌晨 2:47 段错误 → 早上 8:30 修复上线"的全过程串成一棵知识树:

凌晨 2:47 告警: pay-worker.service Failed (core-dump)
        │
        ├─ 现场留存
        │   ├─ ulimit -c unlimited (生产基线 ✓)              ─── 第 4.1 节
        │   ├─ core_pattern = /var/cores/...                ─── 第 4.2 节
        │   └─ 8GB core 落盘 /var/cores/core-pay-worker-... ─── 第 3 章
        │
        ├─ 文件准备
        │   ├─ build-id 提取 → 7f3a8b478e2c...              ─── 第 9.1 节
        │   ├─ 从符号服务器拉 pay-worker.debug              ─── 第 12.2 节
        │   └─ libpay.so 用 sysroot 同版本                  ─── 第 6.5 节
        │
        ├─ 加载 + 三步检查
        │   ├─ info files → 二进制 + core 对得上            ─── 第 6.2 节
        │   ├─ info sharedlibrary → 全 "Yes", 无 (*)        ─── 第 6.4 节
        │   └─ info threads → 16 个线程, 1 号崩, 余 epoll   ─── 第 7.2 节
        │
        ├─ 现场分析
        │   ├─ bt → Channel::status (this=0x18)             ─── 第 7.1 节
        │   ├─ info registers → rdi=0x18, rip=channel.cpp   ─── 第 7.3 节
        │   ├─ disas → mov 0x18(%rdi), %eax                 ─── 第 7.4 节
        │   ├─ p $_siginfo → SEGV_MAPERR, addr=0x18         ─── 第 7.5 节
        │   └─ info proc mappings → 0x18 不在任何 VMA       ─── 第 7.6 节
        │
        ├─ 反向溯源
        │   ├─ find 0x..., +0x..., 0x18 → 几百个 0x18       ─── 第 8.1 节
        │   ├─ 全是 OrderCtx::channel 字段 → 批量损坏        ─── 第 10.4 节
        │   └─ thread apply all bt → 找到 update 线程       ─── 第 7.2 节
        │
        ├─ 根因锁定
        │   └─ 读路径漏锁 + 16 字节复合写入的撕裂            ─── 第 13.1 节
        │
        └─ 修复
            ├─ 紧急: 读路径加锁 (8:00 上线)                  ─── 方案 A
            ├─ 中期: std::atomic<Channel*> (一周后)          ─── 方案 B
            └─ 沉淀: TSan 测试用例 + 加压回归                ─── 第 12.3 节
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
35
36

整个过程 5 小时——熟练后能压到 1 小时。关键是每一步都有对应章节的方法论支撑,没有任何一步靠"猜"。

# 13.3 设计哲学回扣

整理本篇的四条跨篇适用的设计哲学:

哲学 1:现场可重放——core 是排查的"时光机"

生产环境不能交互、不能挂 gdb、运维醒不来——所以现场必须自动留下来。ulimit -c + core_pattern + 调试符号分离 = 任何凌晨 3 点的崩溃,第二天上班都能回到那一刻。没有 core 的生产环境是裸奔。这条与 01.信号崩溃快速排查 第 13.3 节同源——本篇把它具体化为"工程上怎么留下、怎么取出、怎么读懂"。

哲学 2:结构 > 命令

很多工程师把 core 调试当成"记一堆 gdb 命令"——错。core 文件是 ELF,调用栈是 DWARF + .eh_frame,符号查找是 build-id 索引。理解结构,命令自然就会用;只记命令,遇到 strip-all 的二进制立刻歇菜。本篇第 3 章、第 9 章存在的意义就在这里。

哲学 3:crash site ≠ bug site,越往上回溯越接近真相

崩溃位置只是"第一个绷不住的地方"。Channel::status 是 crash site;reconcile 读到 0x18 是近因;channel_map_ 的并发写撕裂才是 bug site。调试就是从 crash 沿栈反向爬,每一帧都问"这一层的输入是不是已经错了"——直到找到自己代码里的某个错误假设。这条和 01.信号崩溃快速排查 第 10.4 节互为表里:信号篇讲"概念",core 篇讲"如何在 gdb 里一帧一帧实操"。

哲学 4:单点修复 → 类别消除

修复 A(紧急加锁)只防当前 case;修复 B(atomic)防"一类指针撕裂";修复 C(RCU)防"读到中间状态"这一类别。好的修复不是补一个洞,是关一扇门。配合 CI 沉淀,把"调试 1 天"的成果浓缩成"测试 30 秒"——这是核心团队和普通团队的根本差距。

# 13.4 core 破案速查表

一张图保存以备查:

现象 第一查 工具命令
没 core 文件 ulimit / core_pattern / dumpable cat /proc/<pid>/limits
栈全 ?? sharedlibrary 是 No / Yes (*) info sharedlibrary
行号错乱 build-id 不一致 eu-readelf -n core
变量 optimized out -O2 + 无 -Og + 无 split-DWARF disas /m 看汇编
this=0 空指针 info args 上层调用
this=小整数 (0x18) 指针被错写 find 内存搜值反推
this=0x4141... UAF + tcache 毒标记 ASan 重跑
多线程都 lock_wait 死锁 p mutex.__owner 找持有者
?? 加 <signal handler called> 信号在崩溃栈中间 跳过这帧看真正崩点
core 8GB 加载慢 堆太大 + 无 .gdb_index split-DWARF + gdb-index

60 秒诊断命令清单:

# 1. 确认 core 在哪
coredumpctl list --since=today
ls -lhrt /var/cores/ | tail

# 2. 拉一份 core, 提取 build-id
COREFILE=/var/cores/core-pay-worker-24891-...
BUILDID=$(eu-readelf -n $COREFILE | grep 'Build ID' | head -1 | awk '{print $3}')

# 3. 从符号服务器拉调试包 (假设已部署)
dgdb $COREFILE ./pay-worker
# 或手动:
# aws s3 cp s3://my-symbols/${BUILDID:0:2}/${BUILDID:2}.debug ~/.debug-cache/...

# 4. gdb 进入后, 一键摘要
(gdb) overview          # 来自 .gdbinit 的自定义命令
(gdb) thread apply all bt 5
(gdb) frame 0
(gdb) info locals
(gdb) p $_siginfo

# 5. 高级
(gdb) find 0x..., +0x..., 0x18         # 值反向溯源
(gdb) p *((MyClass*) $rdi)              # 强转 + 美化打印
(gdb) python ...                        # 自动化脚本
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24

信号 → 第一查询条目(与第 1 篇互补,本篇侧重"core 视角"):

SIGSEGV  → si_addr 是 0     → 空指针, info args 找谁传的 nullptr
        │
        ├─ si_addr 小整数   → 指针被错写, find 反向搜
        │
        ├─ si_addr 大整数   → 看是不是堆地址 → UAF (跑 ASan)
        │                                     → 不是堆 → 看是否栈底守护页 (栈溢出)
        │
        └─ si_addr 在 .rodata → 写常量, 检查字符串字面量

SIGABRT → 看 stderr 上 glibc 一行 → "memory corruption" → 跑 ASan
                                  → "Assertion failed"  → 看 assert
                                  → "terminate"         → 看 set_terminate handler

SIGBUS  → 看 si_code → ADRERR → mmap 文件被截断
                     → ADRALN → 未对齐, 找指针强转
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

生产 core 时间分布经验:

诊断阶段           平均耗时
────────────       ────
找 core 在哪        2 min
core 拉到本地       5 min  (8GB)
符号包拉到本地      2 min
gdb 加载            1-15 min  (大 core 可能更久)
现场六命令         3 min
反向溯源           5-30 min
锁定根因           因人而异
─────────────────────────
总计 (熟练)        20-60 min
1
2
3
4
5
6
7
8
9
10
11

# 13.5 思考题

  1. 你拿到一个 8GB 的 core,gdb 加载花了 15 分钟,bt 全是 ??。手头有这台机器同时间打的 release 包。你会按什么顺序排查"为什么栈是 ??"?

  2. coredumpctl gdb <PID> 失败,提示 "core file is not from the same machine"。原因可能有哪些?怎么绕过校验加载?

  3. 一个进程崩溃发了 SIGSEGV,但你确认了它装了 SIGSEGV handler。core 仍然生成了——为什么?什么情况下 handler 装了反而 core 不生成?

  4. 主线一里 find 命令搜出几百个 0x18——但如果只搜出一个,意味着什么?方法论上要怎么调整?

  5. 你的服务用 gold 链接器编译,core 加载时 GDB 抱怨 unsupported DW_FORM。可能是什么问题?

  6. 一个崩溃栈最顶是 __cxa_throw,下面是 terminate。这是什么场景?怎么找到原始抛异常的代码位置?

  7. 你想把所有线上崩溃的栈做去重统计——fingerprint 应该用栈顶几帧?为什么不能用 %rip 直接做指纹?

  8. 一份 core 在你机器上能 gdb 看,传给同事就不行——可能的原因有哪些?怎么"打包整个调试上下文"给同事?

  9. info proc mappings 显示 [stack] 段只有 8 KB(默认应该 8 MB)—— 进程崩溃时栈被压到这么小,可能是什么操作?

  10. 假设你是某团队的 SRE 负责人,要把团队 core 调试能力从 Level 2(个人靠经验)提升到 Level 5(流水线驱动)。你会按什么 ROI 顺序投入:符号服务器、自动摘要、minidump、治理流水线、培训?为什么?


Core 文件不是终点,是侦查的起点。 调试器只是放大镜——真正的破案能力,是从一个信号、一个寄存器、一个栈帧出发,把"过去一秒发生了什么"还原回来的能力。

下一篇:本篇讲了"事后从一个崩溃文件还原全部现场",至此排查篇四件套(01.信号崩溃快速排查 → 02.ASan内存诊断 → 03.GDB命令速查表 → 04.CoreDump破案实录)已经形成完整闭环:事前阻断 → 现场调试 → 事后破案。配套阅读:01.进程地址空间布局(栈溢出、堆段、mmap 在 core 里的物理对照基础)。

上次更新: 2026/06/16, 19:27:07
GDB十命令速查
perf火焰图实战

← GDB十命令速查 perf火焰图实战→

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