CoreDump破案
# 第23章:Core Dump 破案
# 目录介绍
- 1. 案例引入:两条主线
- 2. core 在调试链中的位置
- 3. core 文件本质
- 4. 开启 core 的四关
- 5. 找到 core 文件
- 6. 加载 core 三步法
- 7. 现场分析六大命令
- 8. 高级排查武器
- 9. 符号与地址换算
- 10. 五步破案方法论
- 11. 典型场景速查
- 12. 工程化最佳实践
- 13. 综合案例串讲
# 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'.
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);
}
}
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!
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 ❌
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 ?? ()
...
(全是 ??)
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
2
3
4
5
6
7
把这些坑梳理清楚,下文就是一篇"故障 → 工具 → 方法论"的破案手册。
# 1.3 顺藤摸到根因
带着两条主线往下挖,至少藏着这些原理点:
① core 文件到底是什么? 内核怎么写出来的? → 第 3 章
② 没生成 core / 文件不全, 是哪一关没过? → 第 4 章
③ systemd 接管 / 容器里的 core 怎么找? → 第 5 章
④ gdb 加载完, 第一刀该砍在哪几个命令? → 第 6 ~ 7 章
⑤ 栈帧全是 ??, 怎么救? 怎么找回符号? → 第 9 章
⑥ this 指针离谱, 内存被踩, 怎么逆推谁干的? → 第 8 章
⑦ 大型 core / strip / 跨机调试, 工程上怎么搞? → 第 12 章
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 已经发生, 怎么破案?
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: 整个进程内存 + 所有寄存器 ← 被动全景
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', ...
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 出来的每一片
└────────────────────────────┘
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 ...
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 │ ← 当前线程栈
└──────────┘
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
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)
...
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
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 ...
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 上限
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
2
3
4
5
6
7
但是——ulimit 只对当前 shell 派生的进程有效。systemd 启动的服务 不继承 你 shell 的 ulimit,要在 unit 文件里单独配:
# /etc/systemd/system/pay-worker.service
[Service]
LimitCORE=infinity ← 关键
LimitNOFILE=65536
2
3
4
# 持久化所有用户
$ sudo tee /etc/security/limits.d/core.conf <<EOF
* soft core unlimited
* hard core unlimited
EOF
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"
# 多次崩溃会互相覆盖!
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 进程, 由它决定怎么存
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
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: 任何人可读 (危险, 不要在生产用)
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
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, 容器里也不生成
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
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
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
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
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 # 至少留这么多磁盘
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
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
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
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
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)
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
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"
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
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
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.
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}}}
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
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
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
# 啥都没 → 调试信息全去了
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
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
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
...
2
3
4
5
6
7
8
9
10
11
看栈五个判断:
- 栈是不是被踩烂:地址明显错(一堆
??或0x4141...) → 栈缓冲区溢出; - 死循环 / 递归过深:栈底反复出现同一个函数 → 递归没出口或栈溢出;
- 崩在系统库:
bt头几帧在libc.so/libstdc++.so→ 99% 是上游传给库的参数错; - 崩在自己代码:直接看那一行;
<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
...
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
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
...
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 这种地址会报错)
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
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
}
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 条指令
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 ← 出错地址
}
}
}
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=私有
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
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)
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!
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
...
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 = {...}}, ...}
^^^
看不出值
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"}
2
3
4
5
6
7
8
默认 GCC 自带的 pretty printer 通常已经装了,没装的话:
$ ls /usr/share/gcc-*/python/libstdcxx
$ ls ~/.gdbinit
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
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: ...
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))
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
之后可以在 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()
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
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'
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).
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
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...
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
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
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
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 {}
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
↑
看到内联展开的整条调用链
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 ?? () ← 内部函数, 完全丢
2
3
4
5
6
7
8
9
抢救方案:
- 从对应版本的 release 包提取调试包:CI 把每个 release 的
<bin>.debug归档; - eu-unstrip 合并:
$ eu-unstrip pay-worker pay-worker.debug -o pay-worker.full
# pay-worker.full 是合并后的, gdb 用它 + core 就能完整看
2
- 没有调试包:只能靠
.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 兜底 │
└─────────────────────────────────────────┘
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 ← 出错地址极小
2
3
立刻得到三个关键判断:
- SEGV_MAPERR → 不是写只读、不是 NX,是访问了根本没分配的页;
si_addr=0x18→ 一个绝对小、绝对落在 OS guard page 里的地址;- 不是 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 # 再上
2
3
4
5
6
每一帧问三个问题:
- 这一帧的入参是什么?是不是已经错了?
- 这一帧的局部变量呢?
- 上一帧调我时,传的值是从哪取的?
直到答案是"哦,这个值在 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 之后的状态
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
调试时怎么验证 T2 真的发生了?
- core 里搜
find看有多少OrderCtx::channel == 0x18—— 主线一搜出几百个,不是孤立现象 → 印证"批量损坏"; - 看
batch_reconcile帧的局部变量,确认是不是当时正在某个 hash 桶里取值; - 看其他线程都在干什么(
thread apply all bt),找到那个清理线程的栈,验证它确实在改channel_map_; - 看
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;
}
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;
}
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 由最后的引用者析构
}
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 快照 | 模式重写 | 根除"读中间态" | 长期 + 高性能 |
论证修复有效——必须做的三件事:
- 复现路径回归:能稳定触发原 bug 的测试用例(哪怕是 fuzz);
- TSan 跑一遍:data race 问题用 TSan 验证比 ASan 更直接;
- 加压回归:模拟原崩溃时的 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();
}
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 第一条指令
}
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 进来的
2
3
4
修法:函数入口判空 if (!r) return;,或返回类型用 gsl::not_null<T*> / 引用。
# 11.2 vtable 被踩
delete obj; // obj 已被释放
obj->virtual_func(); // vtable 已被填毒标记 / 被复用
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
# 不在 → 已被覆盖
2
3
4
5
6
修法:智能指针 + 显式所有权(01.信号崩溃快速排查 第 13.1 节)。
# 11.3 栈溢出递归
void walk(Tree* t) {
if (!t) return;
walk(t->left); // 树太深 / 没有终止条件
walk(t->right);
}
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
2
3
4
5
6
修法:递归改迭代 + 显式栈;或用 ulimit -s / pthread_attr_setstacksize 调大栈(治标)。
# 11.4 多线程死锁
// 线程 A: lock(m1) -> lock(m2)
// 线程 B: lock(m2) -> lock(m1)
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 # 看持有者卡在哪
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
2
3
特征:SIGABRT,stderr 上 glibc 输出 malloc(): memory corruption / free(): invalid next size。崩溃栈最顶是 malloc_consolidate 或 _int_free。
第一刀:
(gdb) bt
(gdb) frame N # 切到 abort 上层 (用户代码层)
2
真相——崩溃位置是"被踩的人发现自己被踩了",不是"踩别人的人"。找踩者只能靠 ASan 重跑:
# 不能直接调 core, 需要拿原代码重编 ASan 版
g++ -fsanitize=address -g -O1 ...
2
# 11.6 异常未捕获
void f() {
throw std::runtime_error("bad");
}
// 没人 catch → terminate → abort
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 关键字
2
3
4
5
6
修法:边界 try-catch,或 set_terminate 装一个打栈 handler 给后人留信息。
# 11.7 优化丢符号
(gdb) frame 1
(gdb) p ctx
$1 = <optimized out> ← 编译器把变量优化没了
2
3
特征:-O2 编译,gdb 看变量都是 <optimized out>。
第一刀:
(gdb) info registers # 看寄存器, 编译器把变量放寄存器了
(gdb) disas /m # 源码 + 汇编对照, 找到当前行用了哪几个寄存器
(gdb) p $rdi # 直接读寄存器
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... (索引主键)
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
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>
)
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 加载
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/
│ └── ...
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
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]}")
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-...
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 不包含:
- 整个堆 (避免泄露用户数据 + 减小体积)
- 共享库代码段
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);
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 份种子
└──────────────────────┘
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];
}
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);
}
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 节
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 ... # 自动化脚本
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 → 未对齐, 找指针强转
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
2
3
4
5
6
7
8
9
10
11
# 13.5 思考题
你拿到一个 8GB 的 core,gdb 加载花了 15 分钟,
bt全是??。手头有这台机器同时间打的 release 包。你会按什么顺序排查"为什么栈是??"?coredumpctl gdb <PID>失败,提示 "core file is not from the same machine"。原因可能有哪些?怎么绕过校验加载?一个进程崩溃发了 SIGSEGV,但你确认了它装了 SIGSEGV handler。core 仍然生成了——为什么?什么情况下 handler 装了反而 core 不生成?
主线一里
find命令搜出几百个0x18——但如果只搜出一个,意味着什么?方法论上要怎么调整?你的服务用
gold链接器编译,core 加载时 GDB 抱怨unsupported DW_FORM。可能是什么问题?一个崩溃栈最顶是
__cxa_throw,下面是terminate。这是什么场景?怎么找到原始抛异常的代码位置?你想把所有线上崩溃的栈做去重统计——
fingerprint应该用栈顶几帧?为什么不能用%rip直接做指纹?一份 core 在你机器上能 gdb 看,传给同事就不行——可能的原因有哪些?怎么"打包整个调试上下文"给同事?
info proc mappings显示[stack]段只有 8 KB(默认应该 8 MB)—— 进程崩溃时栈被压到这么小,可能是什么操作?假设你是某团队的 SRE 负责人,要把团队 core 调试能力从 Level 2(个人靠经验)提升到 Level 5(流水线驱动)。你会按什么 ROI 顺序投入:符号服务器、自动摘要、minidump、治理流水线、培训?为什么?
Core 文件不是终点,是侦查的起点。 调试器只是放大镜——真正的破案能力,是从一个信号、一个寄存器、一个栈帧出发,把"过去一秒发生了什么"还原回来的能力。
下一篇:本篇讲了"事后从一个崩溃文件还原全部现场",至此排查篇四件套(01.信号崩溃快速排查 → 02.ASan内存诊断 → 03.GDB命令速查表 → 04.CoreDump破案实录)已经形成完整闭环:事前阻断 → 现场调试 → 事后破案。配套阅读:01.进程地址空间布局(栈溢出、堆段、mmap 在 core 里的物理对照基础)。