信号崩溃快速排查
# 第20章:信号崩溃快速排查
# 目录介绍
- 1. 案例引入:两条主线
- 2. 崩溃的九种原因
- 3. 信号机制总图
- 4. 五大崩溃信号详解
- 5. si_code 解码
- 6. core dump 三步法
- 7. 现场分析关键命令
- 8. 实时调试与捕获
- 9. Sanitizer 武器库
- 10. 五步排查方法论
- 11. 典型场景速查
- 12. 进程终止与框架
- 13. 综合案例串讲
# 1. 案例引入:两条主线
排查崩溃,最忌讳"光讲原理不讲案例"。本篇用两条真实主线贯穿全文:一条来自生产环境的事故,一条来自 22 行的最小可复现代码。前者展示"复杂系统下的崩溃排查全链路",后者展示"如何把任意 bug 简化到能讲清楚"。
# 1.1 主线一:dmesg 命案
某常驻服务,每周三凌晨 3 点准时崩溃一次,错误日志只留下一行 dmesg:
worker[18273]: segfault at 0 ip 00007f3a8b478e2c sp 00007ffd_a1a30000 error 4 in libworker.so[7f3a8b400000+200000]
代码本身看起来"无懈可击"——一个回调链路上的转发函数:
// dispatcher.cpp —— 事件分发器
struct Session {
Connection* conn;
Handler* handler; // ← 业务回调对象
};
void on_event(Session* s, const Event& ev) {
if (s->handler) { // 看起来防御过了
s->handler->process(ev);
}
}
void cleanup_idle_sessions() {
for (auto& s : sessions_) {
if (s.idle_too_long()) {
delete s.handler; // 释放回调对象
// ⚠️ 没有把 s.handler 置 nullptr
}
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
现象:
- 测试环境(流量低、连接少):100% 通过
- 生产环境(QPS 8 万、每周三凌晨触发批量空闲清理):SIGSEGV,core dump 显示
Handler::process内部某行
直觉怀疑:是不是 process 内部空指针?打开 core 看:
(gdb) bt
#0 0x00007f3a8b478e2c in Handler::process (this=0x4141414141414141, ev=...) at handler.cpp:42
#1 0x00007f3a8b465311 in on_event (s=0x12340800, ev=...) at dispatcher.cpp:18
...
(gdb) p s->handler
$1 = (Handler *) 0x4141414141414141
2
3
4
5
6
7
0x4141414141414141 这种"诡异得整齐"的地址——是 glibc tcache 释放后填充的毒标记,意味着这块内存早就被 delete 掉了。可代码明明 if (s->handler) 判过空——问题就出在:delete 不会把指针置空,悬挂指针不等于 nullptr。
更进一步看 dmesg 那行:
segfault at 0 ip 00007f3a8b478e2c sp ... error 4 ...
^^^^^ ^^^^^^^
访问地址 错误码 bit
2
3
at 0不是字面意思的 0,是内核打印regs->si_addr的低位error 4是 page-fault 错误码:bit 2 = user mode(用户态访问引发)
崩在 Handler::process 第一条指令——典型的"vtable 指针所在的内存已经被回收"。
# 1.2 主线二:Bus error
另一位同学发来求助:
"我就写了个遍历数组的函数,跑起来第一行是对的,第二行
Bus error: 10,完全看不懂。"
打开他的项目,几十个文件、多个类、读文件、继承多态——但触发崩溃的代码抽离出来其实只有 22 行。这就是最小可复现案例(MCVE, Minimal Complete Verifiable Example):
// crash.cpp —— 全文第二条主线,22 行
#include <iostream>
#include <string>
struct Employee {
int id;
std::string name;
};
int main() {
int n = 2; // ① 声明有 2 个员工
Employee** arr = new Employee*[n]; // ② 分配 2 个指针槽
arr[0] = new Employee{1, "Alice"}; // ③ 只填了第 0 个
for (int i = 0; i < n; ++i) { // ④ 遍历 2 个
std::cout << "id=" << arr[i]->id
<< ", name=" << arr[i]->name << "\n";
}
return 0;
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
编译运行:
$ g++ crash.cpp -o crash
$ ./crash
id=1, name=Alice
Bus error: 10
2
3
4
两个现象非常扎眼:
- 第一行成功打印 —— 说明
arr[0]是合法的; - 第二次访问就崩 —— 信号是
SIGBUS (10)。
好的调试,第一步永远是把问题简化到 MCVE。复杂工程里的 bug 无非三种:
- 逻辑 bug —— 能在简化案例里稳定复现(本案例);
- 交互 bug —— 只在组件组合时出现,但仍能用 2~3 个文件复现;
- 环境 bug —— 换台机器就好了(不在本文讨论范围)。
# 1.3 顺藤摸到根因
带着两条主线往下挖,至少藏着这些原理点:
① 信号是怎么来的? 谁发给谁? → 第 3 章
② SIGSEGV / SIGBUS / SIGABRT 怎么区分? → 第 4 章
③ si_code、error code 这些"副标题"怎么读? → 第 5 章
④ 为什么 macOS 给 SIGBUS,Linux 给 SIGSEGV? → 第 5.5 节
⑤ 现场没了怎么复盘? core dump 怎么开? → 第 6 章
⑥ gdb 进去之后看哪几个值才能定根因? → 第 7 章
⑦ 不能复现的 bug 怎么自己抓? → 第 8 章
⑧ 用 Sanitizer 把"释放后再用"在第一现场拍住 → 第 9 章
⑨ 复杂崩溃怎么用方法论一步步逼到根因? → 第 10 章
2
3
4
5
6
7
8
9
# 1.4 本篇要回答什么
| 层次 | 你将学到 |
|---|---|
| 原因层 | 9 种崩溃模式,从内存访问到主动终止 |
| 信号层 | SIGSEGV/SIGBUS/SIGABRT/SIGFPE/SIGILL 的本质、si_code、平台差异 |
| 工具层 | gdb/lldb/ASan/UBSan/编译告警五种排查路径的组合与互补 |
| 方法层 | 最小化复现、假设空间、证据链交叉、crash site vs bug site |
| 工程层 | core dump 配置、生产级 handler、Breakpad 架构、CI 防线 |
📌 本篇定位:这是排查篇的第一篇。无论后面要讲的 ASan、GDB、CoreDump、火焰图,本质都是"在某一类信号崩溃下,用什么工具切进去"。读完本篇,再看任何 C++ 崩溃,都能立刻回答:"它死于哪个信号、是哪个子类型、第一刀该砍在哪"。
# 2. 崩溃的九种原因
进入信号机制之前,先把"代码上能写出哪几类崩溃"列清楚。下面 9 种覆盖了 99% 的实战场景。
# 2.1 内存访问违规
1) 空指针解引用:CPU 尝试访问受保护的内存地址 0x0:
int* ptr = nullptr;
*ptr = 42; // 崩溃:访问地址 0x0
2
底层原理:虚拟地址空间的前几页(通常 0x0~0xFFF)被 OS 标记为不可访问(Guard Page)。MMU 在地址翻译时发现该页不在页表中,触发 Page Fault(#PF)。内核检测到非法访问,向进程发送 SIGSEGV。
2) 野指针解引用:访问已释放或未初始化的指针:
int* ptr = new int(42);
delete ptr;
*ptr = 100; // 崩溃:Use-After-Free
2
3
底层原理:内存释放后,OS 可能已将该区域标记为不可访问。但如果内存被复用给其他对象,可能不会立即崩溃,而是产生更隐蔽的数据损坏——这是 UAF 漏洞的危险所在(也是主线一的根因)。
3) 缓冲区溢出:数组越界、字符串操作超出分配空间:
char buffer[10];
strcpy(buffer, "This string is way too long for the buffer!");
// 崩溃:栈帧被破坏,返回地址被覆盖
2
3
底层原理:写入超出分配边界,破坏相邻栈帧(包括保存的寄存器值和返回地址)。函数返回时,CPU 跳转到被破坏的地址,执行非法指令或访问非法内存。
4) 双重释放(Double Free):
int* ptr = new int(42);
delete ptr;
delete ptr; // 崩溃:双重释放
2
3
底层原理:内存管理器(如 glibc 的 ptmalloc)维护空闲链表。第一次 free 将块标记为空闲并放入链表。第二次 free 时该块已在链表中,再次插入会破坏链表结构。现代分配器通常能检测到 double-free 并调用 abort()。
# 2.2 栈溢出
1) 无限递归或过深递归:
void recursiveFunction() {
char buffer[1024]; // 每次递归占用 1KB+ 栈空间
recursiveFunction(); // 无限递归
}
2
3
4
底层原理:线程栈空间有限(Linux 默认 8MB,可通过 ulimit -s 查看)。每次函数调用都在栈上分配新栈帧(返回地址、保存的寄存器、局部变量)。当栈指针超出栈的 Guard Page 时,触发 SIGSEGV。
高地址
┌─────────────┐
│ 栈底 │
│ ... │
│ 递归帧 N │ ← 最新的递归调用
│ 递归帧 N-1 │
│ ... │
│ main 帧 │
├─────────────┤
│ Guard Page │ ← 碰到这里就触发 SIGSEGV
├─────────────┤
│ 堆/其他 │
低地址
2
3
4
5
6
7
8
9
10
11
12
13
2) 栈上分配过大的局部变量:
void bigStackAllocation() {
int huge_array[10000000]; // 约 40MB,远超栈大小
huge_array[0] = 1; // 崩溃
}
2
3
4
# 2.3 整数除零
int a = 10, b = 0;
int c = a / b; // 崩溃:整数除零
2
底层原理:CPU 执行 DIV/IDIV 指令时除数为 0,触发 #DE(Divide Error)异常,内核转换为 SIGFPE。
注意:浮点除零不会崩溃,按 IEEE 754 产生特殊值:
double a = 1.0, b = 0.0;
double c = a / b; // c = +inf
double d = -a / b; // d = -inf
double e = b / b; // e = NaN
2
3
4
# 2.4 未处理异常
void throwingFunction() {
throw std::runtime_error("something went wrong");
}
int main() {
throwingFunction(); // 未捕获 → std::terminate() → abort() → SIGABRT
}
2
3
4
5
6
7
底层原理:异常在栈展开过程中没有找到匹配的 catch 块,展开到 main 仍未捕获,C++ 运行时调用 std::terminate(),默认行为是 abort(),生成 SIGABRT。
noexcept 违反也会触发 terminate:
void safe_function() noexcept {
throw std::runtime_error("oops"); // 直接 std::terminate(),不展开
}
2
3
# 2.5 断言失败
#include <cassert>
void process(int* data, size_t size) {
assert(data != nullptr && "data must not be null");
assert(size > 0 && "size must be positive");
}
2
3
4
5
底层原理:assert 宏在条件为 false 时调用 abort()。Release 模式(定义 NDEBUG)下被展开为空语句,不做检查。
# 2.6 系统资源耗尽
内存不足(OOM):
try {
char* huge = new char[100ULL * 1024 * 1024 * 1024]; // 100GB
} catch (const std::bad_alloc& e) {
std::cerr << "alloc failed: " << e.what() << std::endl;
}
// nothrow 版本不抛异常,返回 nullptr
char* ptr = new(std::nothrow) char[100ULL << 30];
if (!ptr) std::cerr << "alloc failed\n";
2
3
4
5
6
7
8
9
文件描述符耗尽(EMFILE)、线程数耗尽(EAGAIN)同理——ulimit -n / ulimit -u 查看上限。
# 2.7 多线程数据竞争
int counter = 0;
void increment() {
for (int i = 0; i < 1000000; i++) counter++; // 非原子的"读-改-写"
}
2
3
4
底层原理:counter++ 在汇编层是 load → add → store 三条指令。两线程可能同时读到相同值,各自加 1 后写回,更新丢失。对于复杂数据结构的并发修改,可能破坏内部一致性导致崩溃。
死锁:
std::mutex m1, m2;
void thread1() { std::lock_guard g1(m1); /* 拿 m2 */ std::lock_guard g2(m2); }
void thread2() { std::lock_guard g2(m2); /* 拿 m1 */ std::lock_guard g1(m1); }
// 解决:std::scoped_lock lock(m1, m2); // C++17 自动按一致顺序加锁
2
3
4
# 2.8 非法指令
int data = 0x12345678;
void (*func_ptr)() = reinterpret_cast<void(*)()>(&data);
func_ptr(); // SIGILL:数据被当作指令执行
2
3
底层原理:CPU 在解码阶段发现无法识别的操作码,触发 #UD(Undefined Opcode)异常,内核转换为 SIGILL。
# 2.9 主动终止
abort(); // 立即终止 + SIGABRT + core
exit(1); // 正常终止流程(atexit、刷 IO)
_exit(1); // 立即终止,不执行清理
std::terminate(); // C++ 运行时终止,默认调 abort
std::quick_exit(1); // 不调析构,仅 at_quick_exit 注册的函数
2
3
4
5
# 3. 信号机制总图
# 3.1 从硬件异常到信号
CPU 在执行指令过程中检测到非法操作,触发硬件中断:
x86 架构的常见异常向量:
#0 DE - 除零错误 (Divide Error)
#6 UD - 无效操作码 (Invalid Opcode)
#12 SS - 栈段错误 (Stack-Segment Fault)
#13 GP - 通用保护故障 (General Protection Fault)
#14 PF - 页面错误 (Page Fault)
2
3
4
5
6
异常触发时,CPU 会:
- 停止当前指令执行
- 保存当前状态(指令指针、标志寄存器等)到栈
- 通过 IDT(中断描述符表)跳转到内核异常处理程序
内核的异常处理程序识别异常类型,将其映射为 POSIX 信号:
硬件异常 → POSIX 信号
─────────────────────────────────────
Page Fault (非法) → SIGSEGV (11)
Divide Error → SIGFPE (8)
Invalid Opcode → SIGILL (4)
Bus Error → SIGBUS (7/10)
2
3
4
5
6
内核通过 force_sig_info() 向目标进程发送信号,附带 siginfo_t:
si_signo:信号编号si_code:详细的错误原因(如 SEGV_MAPERR、SEGV_ACCERR)si_addr:触发异常的内存地址
# 3.2 信号是进程的"中断"
疑惑:程序好端端在跑,操作系统怎么有权"打断"它?
论证:
- CPU 执行非法操作时触发硬件异常(trap),控制权立刻交给内核。
- 内核根据异常类型决定处理方式;对用户态进程,最常见的是——给该进程投递一个信号。
- 信号本质是"给进程的一字节通知":每个进程在内核中维护一张 64 位的
pending位图,每个 bit 代表一种信号。 - 进程下次回到用户态时(系统调用返回、中断返回、调度醒来),内核检查
pending,如果有未决信号,插入信号处理流程:要么走默认动作(多数是终止 + core),要么调用用户注册的sa_handler。
用户代码: mov [rax], 0 (rax 指向无效地址)
│
▼
CPU 触发 #PF 异常
│
▼
内核 do_page_fault()
│
├── 查 VMA:地址不在任何合法区域
▼
force_sig_fault(SIGSEGV, SEGV_MAPERR, addr)
│
▼
进程 task_struct->pending 里点亮 SIGSEGV bit
│
▼
下次返回用户态前 → get_signal() → handle_signal()
│
├── 用户没装 handler → 默认动作: 终止 + core dump
└── 用户装了 handler → 调用 handler,参数带 siginfo_t
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
结论:信号是内核与进程通信的最轻量级机制——一个 bit 位 + 一次中断检查,就让"硬件异常"变成"程序员能感知的事件"。这是 Unix 设计的精髓。
# 3.3 同步信号与异步信号
并不是所有信号都和"崩溃"有关。按来源可分两类:
| 类型 | 触发源 | 典型信号 | 是否可恢复 |
|---|---|---|---|
| 同步信号 | 当前指令执行触发硬件异常 | SIGSEGV / SIGBUS / SIGFPE / SIGILL / SIGTRAP | 极难,handler 返回后会重新执行同一条指令再次崩 |
| 异步信号 | 外部主动发送 | SIGINT / SIGTERM / SIGHUP / SIGUSR1 / SIGCHLD | 可以,handler 处理完正常返回 |
| 混合 | abort() 同步、kill -6 异步 | SIGABRT | 视情况 |
崩溃排查关心的几乎都是同步信号——它们由当前线程的指令引发,崩溃栈里能直接看到"凶器"。异步信号(如 kill -9 来的 SIGKILL,或 OOM Killer 的 SIGTERM)不会留下崩溃栈,要靠日志/dmesg 反查。
# 3.4 信号的生命周期
生成(generation) 投递(delivery) 处理(handling)
┌────────────────────┐ ┌──────────────────┐ ┌────────────────────┐
│ 硬件异常: do_page_ │ → │ 进入 task_struct │ → │ 默认动作 / handler │
│ fault → force_sig│ │ ->pending │ │ / 忽略 / 阻塞 │
│ 软件: kill(2) │ │ 位图 │ │ │
│ abort() / raise()│ │ │ │ │
└────────────────────┘ └──────────────────┘ └────────────────────┘
│
│ sigprocmask 阻塞 → 信号留在 pending
│ sigaction 注册 → 走自定义 handler
│ signal(SIG_IGN) → 直接丢弃
▼
┌──────────────────┐
│ 默认动作分类 │
├──────────────────┤
│ Term: 终止 │
│ Core: 终止+core │ ← SIGSEGV/BUS/ABRT/FPE/ILL
│ Ign : 忽略 │ ← SIGCHLD 默认
│ Stop: 暂停 │ ← SIGSTOP/SIGTSTP
│ Cont: 继续 │ ← SIGCONT
└──────────────────┘
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
几个坑要先记住:
- 同步信号不能阻塞:用
sigprocmask屏蔽 SIGSEGV 是无效的——内核检测到"这是同步致命信号且被屏蔽"会强制恢复默认动作并终止进程。否则就出现"指令重新执行、再次缺页、再次屏蔽"的死循环。 - handler 内只能调用 async-signal-safe 函数:详见第 8.4 节。
signal()不可移植:用sigaction(),signal()在不同 libc 行为不一样。
# 3.5 崩溃流程全景图
用户空间 内核空间 硬件
───────── ───────── ─────
CPU 执行指令
│
检测到异常 (如 #PF)
│
IDT 查表 ←─────────────────────┘
│
异常处理程序
│
映射为 POSIX 信号
│
发送信号给进程
│
用户信号处理 ←───────────────────────┘
│
├─ 有自定义 handler
│ ├─ 记录崩溃信息
│ ├─ 生成 minidump
│ └─ 调用 abort()
│
└─ 无自定义 handler(默认)
│
└─ 终止进程 ────────────────→ 生成 core dump
│
释放资源
│
通知父进程 (SIGCHLD)
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
# 4. 五大崩溃信号详解
排查代码崩溃,99% 的情况就是这 5 个信号。
# 4.1 SIGSEGV 段错误
Segmentation Violation,编号 11,是迄今为止最常见的崩溃信号。
触发条件:CPU 访问的虚拟地址,不符合页表中标记的权限:
访问类型 页表状态 结果
─────────────────────────────────────────────────────
读 页不存在(未映射) → SIGSEGV (SEGV_MAPERR)
读/写 页存在但权限不足 → SIGSEGV (SEGV_ACCERR)
写 页只读 (rodata / 共享库 .text) → SIGSEGV (SEGV_ACCERR)
执行 页 NX bit 置位 → SIGSEGV (SEGV_ACCERR)
2
3
4
5
6
最典型的五种来源:
// 1. 空指针解引用
int* p = nullptr;
*p = 1; // 写 0 地址 → 必崩
// 2. 野指针(已释放)
int* p = new int(1);
delete p;
*p = 2; // 可能崩,可能不崩(取决于 tcache 状态)
// 3. 越界
int a[10];
a[10000000] = 1; // 落到未映射页 → 崩
// 4. 写只读段
char* s = (char*)"hello";
s[0] = 'H'; // 写 .rodata → SEGV_ACCERR
// 5. 栈溢出
void f() { f(); } // 无限递归 → 撞守护页 → SEGV_MAPERR
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
dmesg 输出解读:
worker[18273]: segfault at 0000000000000000 ip 00007f3a8b478e2c sp 00007ffd_a1a30000 error 4 in libworker.so[7f3a8b400000+200000]
│ │ │ │ │
│ │ │ │ └─ 加载基址+大小
│ │ │ └─ error 错误码
│ │ └─ 崩溃时栈指针
│ └─ 崩溃指令地址(CPU 执行的那条)
└─ 访问的非法地址(si_addr)
2
3
4
5
6
7
error 错误码(x86-64)的 bit 含义:
| bit | 含义(置 1 时) |
|---|---|
| 0 | P:1=权限错误,0=页不存在 |
| 1 | W/R:1=写访问,0=读访问 |
| 2 | U/S:1=用户态,0=内核态 |
| 3 | RSVD:1=保留位被使用 |
| 4 | I/D:1=指令访问,0=数据访问 |
error 4 = 0b00100 = 用户态 + 读 + 页不存在 = 空指针读。
# 4.2 SIGBUS 总线错误
编号 10(macOS)/ 7(Linux)。比 SIGSEGV 罕见,但一旦遇到,往往是更"硬件级"的问题。
触发条件:地址在用户合法 VMA 内,但底层映射有问题:
// 1. mmap 文件后文件被截断(最常见的生产 SIGBUS 来源)
int fd = open("data.bin", O_RDONLY);
char* p = (char*)mmap(0, 1<<20, PROT_READ, MAP_SHARED, fd, 0);
ftruncate(fd, 0); // 别的线程把文件截断
char c = p[100]; // → SIGBUS (BUS_ADRERR),访问超出文件范围的页
// 2. 未对齐访问(在 ARM/SPARC 等严格架构上)
char buf[16];
int* p = (int*)(buf + 1); // 不是 4 字节对齐
*p = 1; // x86 OK, ARM 严格模式 → SIGBUS (BUS_ADRALN)
// 3. 物理内存故障(罕见)
// 内核检测到 ECC 错误页 → SIGBUS (BUS_MCEERR_AR)
// 4. macOS 上,写只读段、栈溢出也常报 SIGBUS(BSD 内核策略)
2
3
4
5
6
7
8
9
10
11
12
13
14
15
主线二的 Bus error: 10 就属于第 4 类——下文 5.5 节详细解释为什么 macOS 把空指针解引用翻译成 SIGBUS。
# 4.3 SIGABRT 主动放弃
编号 6。唯一一个"程序自己主动喊死"的信号。
触发来源:
// 1. assert 失败
assert(ptr != nullptr); // 失败 → __assert_fail → abort
// 2. 显式 abort()
if (config_invalid) abort();
// 3. C++ 异常未捕获
throw std::runtime_error(""); // 没人 catch → terminate() → abort()
// 4. glibc 检测到内存损坏(最常见!)
free(p);
free(p); // double free or corruption (fasttop)
// → glibc 主动 abort
2
3
4
5
6
7
8
9
10
11
12
13
glibc 的内存检测会输出非常显眼的诊断:
*** Error in `./a.out': double free or corruption (fasttop): 0x0000556c... ***
======= Backtrace: =========
/lib/x86_64-linux-gnu/libc.so.6(+0x777e5)[0x7f...]
...
======= Memory map: ========
00400000-00401000 r--p ...
...
Aborted (core dumped)
2
3
4
5
6
7
8
关键诊断词汇表:
| glibc 输出 | 含义 |
|---|---|
double free or corruption (fasttop) | 同一个 fastbin 块被 free 两次 |
double free or corruption (out) | 已 free 块的相邻边界被破坏 |
malloc(): memory corruption | 堆元数据(chunk header)被改 |
free(): invalid pointer | free 一个根本不是 malloc 出来的指针 |
free(): invalid next size | 块尾大小字段被覆盖 |
看到 SIGABRT,第一反应不是 gdb,而是看 stderr 上面那几行 glibc 诊断——它已经直接告诉你病因了。
# 4.4 SIGFPE 算术异常
编号 8。名字带 FP(floating point)但其实主要管整数。
int a = 10, b = 0;
int c = a / b; // → SIGFPE (FPE_INTDIV)
int d = a % b; // → SIGFPE (FPE_INTDIV)
// INT_MIN / -1 也会 SIGFPE(结果溢出 INT_MAX+1)
int x = INT_MIN;
int y = x / -1; // → SIGFPE (FPE_INTOVF)
// 浮点默认不崩(按 IEEE 754 给 inf/nan)
double f = 1.0 / 0.0; // f = inf,不报错
// 除非显式开 feenableexcept(FE_DIVBYZERO)
2
3
4
5
6
7
8
9
10
11
坑点:x / 0(x 是变量)会崩;但 x / 0(0 是字面量)会被编译器警告甚至优化掉。
# 4.5 SIGILL 非法指令
编号 4。CPU 解码到一条不认识的指令。
典型来源:
// 1. 二进制损坏:函数指针被踩,跳到一段乱码
void (*fp)() = (void(*)())0x4141414141414141;
fp(); // → 几乎一定 SIGILL 或 SIGSEGV
// 2. CPU 不支持的指令集
// 用 -mavx512f 编译,跑在没有 AVX-512 的老 CPU 上
__m512i v = _mm512_setzero_si512(); // → SIGILL (ILL_ILLOPC)
// 3. __builtin_trap() / __builtin_unreachable() 路径被走到
if (impossible_case) __builtin_trap(); // 故意 SIGILL
// 4. UBSan 在 -fsanitize-trap 模式下用 ud2 指令报错
2
3
4
5
6
7
8
9
10
11
12
-march=native 编译产物分发到异构集群,是 SIGILL 在生产里最常见的源头之一。容器化部署务必显式指定 -march=x86-64-v2 之类的最低基线。
# 4.6 SEGV 与 BUS 之分
这两个信号经常混用,但机制截然不同:
┌───────────────────────────────────────────┐
│ 进程访问虚拟地址 p │
└─────────────────┬─────────────────────────┘
↓
┌───────────────────────────────────────────┐
│ MMU 查页表,把虚拟地址翻译为物理地址 │
└─────────────────┬─────────────────────────┘
│
┌───────────┼───────────┐
↓ ↓ ↓
页表项缺失 权限不符 页表存在但
(unmapped) (write ro) 物理访问失败
↓ ↓ ↓
SIGSEGV SIGSEGV SIGBUS
(MAPERR) (ACCERR) (ADRERR/ADRALN)
2
3
4
5
6
7
8
9
10
11
12
13
14
15
| 信号 | 根源 | 经典场景 |
|---|---|---|
| SIGSEGV | MMU 级:页表缺失 / 权限不符 | 空指针解引用、野指针落到未映射区、写 const 段 |
| SIGBUS | 总线级:页存在但物理访问失败 | 未对齐原子操作、mmap 文件被截断后访问尾部、硬件 ECC 错 |
一句话总结:
SIGSEGV = "地址不该让你访问"(VMA 层面拒绝) SIGBUS = "地址你可以访问,但底子有问题"(VMA 通过,但物理映射坏了)
但在 x86 / x86_64 用户态代码里,二者经常互相冒充——一个"野指针落到某个奇怪地址"可能走进 MMU 也可能走进总线,分到哪种取决于操作系统的异常翻译策略。
# 5. si_code 解码
知道是哪个信号还不够——同一个 SIGSEGV 可能由 5 种原因引发。si_code 是信号的"副标题",把范围锁到具体子类型。
# 5.1 siginfo_t 结构
内核投递信号时会附带一个 siginfo_t 结构(POSIX 定义):
struct siginfo_t {
int si_signo; // 信号编号(SIGSEGV / SIGBUS / ...)
int si_errno; // 通常 0
int si_code; // ← 我们要的副标题
pid_t si_pid; // 发送方 PID(kill 来的)
uid_t si_uid;
void* si_addr; // ← 出错的访问地址(SIGSEGV/SIGBUS/SIGFPE 必填)
int si_status;
// ...
};
2
3
4
5
6
7
8
9
10
获取方式:
- handler 里:
sigaction用SA_SIGINFO标志注册三参数 handler,第二个参数就是siginfo_t* - gdb 里:
p $_siginfo(最近一次信号的 siginfo) - lldb 里:
thread info的stop reason行直接显示code - core dump 里:同上,
p $_siginfo仍可用
# 5.2 SEGV 的 si_code
SEGV_MAPERR = 1 // 访问的地址未映射(VMA 里没有这块)
SEGV_ACCERR = 2 // 地址映射了,但权限不足(读只读、执行 NX 等)
SEGV_BNDERR = 3 // 越界(Intel MPX,已废弃)
SEGV_PKUERR = 4 // Protection Key 错误(PKU 特性)
2
3
4
排查心法:
- MAPERR:地址要么是 0(NULL)、要么是野指针(被释放的堆地址通常已 munmap)、要么是栈溢出落到守护页
- ACCERR:地址有效,问题出在"做了不该做的操作"——典型是写
.rodata、执行.data、内核保护页
// 例子
int* p = nullptr;
*p = 1; // → SEGV_MAPERR, si_addr=0
char* s = (char*)"abc";
s[0] = 'A'; // → SEGV_ACCERR, si_addr 指向 .rodata
((void(*)())(uintptr_t)heap_buf)(); // 跳到堆上执行 → SEGV_ACCERR (NX)
2
3
4
5
6
7
8
# 5.3 BUS 的 si_code
BUS_ADRALN = 1 // 地址未对齐
BUS_ADRERR = 2 // 不存在的物理地址(mmap 文件被截断)
BUS_OBJERR = 3 // 硬件错误(ECC)
BUS_MCEERR_AR = 4 // 机器检查异常 - 已发生
BUS_MCEERR_AO = 5 // 机器检查异常 - 即将发生
2
3
4
5
生产里见到 BUS_ADRERR,几乎一定是 mmap 共享文件相关——99% 是文件被另一个进程截断/删除。
# 5.4 FPE 的 si_code
FPE_INTDIV = 1 // 整数除以 0
FPE_INTOVF = 2 // 整数溢出(INT_MIN / -1)
FPE_FLTDIV = 3 // 浮点除以 0
FPE_FLTOVF = 4 // 浮点上溢
FPE_FLTUND = 5 // 浮点下溢
FPE_FLTRES = 6 // 浮点结果不精确
FPE_FLTINV = 7 // 无效浮点操作
FPE_FLTSUB = 8 // 下标越界(极少见)
2
3
4
5
6
7
8
实战表:拿到信号 + si_code,立刻知道第一刀砍哪:
| 信号+si_code | 一句话病因 | 第一查 |
|---|---|---|
| SEGV_MAPERR + addr=0 | 空指针 | 看哪个指针没初始化/没判空 |
| SEGV_MAPERR + addr 接近栈底 | 栈溢出 | 看递归深度 / 大局部数组 |
| SEGV_MAPERR + addr 是堆地址 | UAF | 用 ASan 找释放点 |
| SEGV_ACCERR + addr 在 .rodata | 写常量 | 看是不是字符串字面量被改 |
| BUS_ADRERR | mmap 文件被截断 | 看文件大小变化日志 |
| BUS_ADRALN | 未对齐访问 | 检查指针强转 |
| ILL_ILLOPC | 指令集不匹配 | 检查 -march / 二进制损坏 |
| FPE_INTDIV | 除零 | 加除数判空 |
# 5.5 macOS 与 Linux 差异
回到主线二的 crash.cpp:
- 在 Linux 上通常报
Segmentation fault (core dumped); - 在 macOS 上报
Bus error: 10。
不是代码变了,是操作系统对同一个底层异常的翻译策略不同:
- Linux:对几乎所有 MMU 错误都发 SIGSEGV,SIGBUS 只在少数场景(mmap 截断、对齐、ECC)才发;
- macOS (Darwin/XNU):继承自 BSD,在 Mach VM 的某些中间状态下也把 MMU 错翻译成 SIGBUS。
这一段不是闲话——它直接影响排查思路:"换个平台信号就变了" ≠ "代码没问题"。在 macOS 上修好了,Linux 上依然可能有别的表现,反之亦然。所以 CI 要跨平台。
在 lldb 里看主线二的现场:
(lldb) thread info
stop reason = EXC_BAD_ACCESS (code=1, address=0x0)
2
code=1 就是 si_code = SEGV_MAPERR。address=0x0——说明解引用了一个近似于空的地址。这与 Linux 上 error 4(用户态读未映射页)的语义完全一致,只是包装层不同。
# 6. core dump 三步法
崩溃总是在凌晨 3 点发生而你在睡觉——所以现场必须自动留下来。
# 6.1 打开 core dump
四道关卡都要过,缺一不可:
# 关卡 1:进程级 ulimit
ulimit -c unlimited # 当前 shell
echo "* soft core unlimited" >> /etc/security/limits.conf # 持久化(root)
# 关卡 2:内核级 core_pattern(决定 core 写到哪、叫什么)
cat /proc/sys/kernel/core_pattern
# 常见两种:
# (a) "core" 或 "core.%p" → 写到当前工作目录
# (b) "|/usr/lib/systemd/systemd-coredump %P %u %g %s %t %c %h" → 走 systemd-coredump
# 关卡 3:suid 进程默认禁止 dump
echo 1 > /proc/sys/fs/suid_dumpable # 1=root only, 2=any(不安全,慎用)
# 关卡 4:磁盘空间足够(一个 8GB 进程的 core 就 8GB)
df -h .
2
3
4
5
6
7
8
9
10
11
12
13
14
15
core_pattern 常用模板:
# 推荐:固定目录 + 进程名 + PID + 时间
echo "/var/cores/core-%e-%p-%t" > /proc/sys/kernel/core_pattern
# %e=短可执行名, %E=完整路径, %p=PID, %t=时间戳
# %s=信号号, %h=主机名, %u=uid, %c=ulimit
2
3
4
5
macOS 下:core 默认写到 /cores/,文件名 core.<pid>。需要 ulimit -c unlimited 后才生成。
# 6.2 找到 core 文件
# 方式 A:core_pattern 是普通路径
ls -lt /var/cores/
# 方式 B:systemd-coredump 接管
coredumpctl list
coredumpctl info <PID>
coredumpctl gdb <PID> # 直接拉起 gdb
coredumpctl dump <PID> -o /tmp/x.core # 导出
# 方式 C:容器里跑的,core 默认写在容器内
docker exec <container> ls -lt /
# 或 docker cp 出来
# 方式 D:macOS
ls -lt /cores/
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# 6.3 gdb 加载 core
gdb /path/to/binary /path/to/core
# 或
gdb -c core /path/to/binary
2
3
加载时务必检查 3 件事:
(gdb) info files # 1. 二进制路径对不对
(gdb) info sharedlibrary # 2. .so 是否都对上了,有没有 "Yes (*)" 的不一致
(gdb) show debug-file-directory # 3. 调试符号去哪找
2
3
符号文件不一致是 90% 的"看不懂栈"的原因——版本对不上的话栈帧地址全错。生产构建建议:
# 编译时分离调试符号
g++ -O2 -g a.cpp -o app
objcopy --only-keep-debug app app.debug
strip --strip-debug app
objcopy --add-gnu-debuglink=app.debug app
# 部署 app,调试时 gdb 自动找 app.debug
2
3
4
5
6
7
# 6.4 core 没生成的原因
生产环境遇到"崩溃但没 core 文件"的排查清单(按优先级):
ulimit -c 0——用户栈的软限制未开(最常见)。core_pattern指向了|管道(比如 systemd-coredump、abrt),core 被转交给系统服务,要从那里拿。- 程序 setuid/setgid——出于安全考虑,默认禁止写 core(需要
fs.suid_dumpable=1)。 - core 文件路径所在文件系统空间不足或无写权限。
- 程序手动关闭了 core:
setrlimit(RLIMIT_CORE, 0)或prctl(PR_SET_DUMPABLE, 0)。 - 容器里的 rlimit 被 Kubernetes/Docker 默认设为 0——需要 securityContext 调整。
排查时按顺序验证这 6 点,通常能找到根因。
# 7. 现场分析关键命令
# 7.1 bt full 调用栈
(gdb) bt
#0 Handler::process (this=0x4141414141414141, ev=...) at handler.cpp:42
#1 on_event (s=0x12340800, ev=...) at dispatcher.cpp:18
#2 EventLoop::run (this=0x7fff...) at loop.cpp:115
...
(gdb) bt full # 带局部变量
#0 Handler::process (this=0x4141414141414141, ev=...) at handler.cpp:42
local_buf = "..."
n = 1024
...
(gdb) thread apply all bt # 多线程:所有线程的栈
2
3
4
5
6
7
8
9
10
11
12
13
看栈三个判断:
- 栈是不是被踩烂了:如果
bt显示一堆??或地址完全离谱(如0x4141...),说明返回地址被覆盖——栈缓冲区溢出。 - 死循环 / 递归:栈底反复出现同一个函数 → 递归没出口 / 栈溢出。
- 跨线程死锁:
thread apply all bt看是否多个线程都卡在同一把锁。
关键动作:永远先 bt,看崩溃是在"自己的代码里"还是在"库函数里"。
- 崩在库里(如
std::string内部)——99% 是上游传给库的参数就是坏的; - 崩在自己代码里——直接看那一行。
# 7.2 info registers 寄存器
(gdb) info registers
rax 0x4141414141414141 4702111234474983745
rdi 0x4141414141414141 4702111234474983745 ← this 指针
rsi 0x7fff... ← 第二参数
rip 0x7f3a8b478e2c ← 当前指令
rsp 0x7ffd_a1a30000 ← 栈顶
...
2
3
4
5
6
7
崩溃帧的寄存器是最直接的证据:
rdi(x86-64 第一个参数):成员函数里就是this。this = 0x4141...立刻锁定 UAF。rip:当前指令地址,配合disas看具体在执行什么。rsp离合法栈底很近:栈溢出。
# 7.3 disas 反汇编崩溃帧
(gdb) disas $rip-16,$rip+16
0x7f3a8b478e1c <process+0>: push %rbp
0x7f3a8b478e1d <process+1>: mov %rsp,%rbp
0x7f3a8b478e20 <process+4>: mov (%rdi),%rax ← 读 vtable 指针
=> 0x7f3a8b478e2c <process+12>: call *0x10(%rax) ← 调虚函数
2
3
4
5
mov (%rdi), %rax 读 this[0] —— 也就是 vtable 指针。%rdi = 0x4141...,这条指令就是崩溃现场——访问被毒标记的内存。
disas + info registers 的组合,能让你精确到一条指令、一个寄存器说出"为什么崩"。
# 7.4 p $_siginfo 信号详情
(gdb) p $_siginfo
$1 = {
si_signo = 11, ← SIGSEGV
si_errno = 0,
si_code = 1, ← SEGV_MAPERR(未映射)
...
_sifields = {
_sigfault = {
si_addr = 0x4141414141414141 ← 出错地址
}
}
}
2
3
4
5
6
7
8
9
10
11
12
一行命令拿到信号、子类型、地址三件套。配合第 5 章的对照表,根因方向当场出来。
进阶:自定义 gdb 函数把全过程串起来
# ~/.gdbinit
define crash-summary
printf "===== Crash Summary =====\n"
printf "Signal: %d\n", $_siginfo.si_signo
printf "Code: %d\n", $_siginfo.si_code
printf "Addr: "
p/x $_siginfo._sifields._sigfault.si_addr
printf "RIP: "
p/x $rip
printf "RDI(this/arg1): "
p/x $rdi
printf "Stack:\n"
bt 8
end
2
3
4
5
6
7
8
9
10
11
12
13
14
之后每次崩溃只要 crash-summary 一行命令。
# 7.5 lldb 命令对照
macOS 上的 lldb 命令几乎和 gdb 一一对应:
| gdb | lldb | 用途 |
|---|---|---|
run | run | 启动 |
bt / bt full | bt / bt all | 调用栈 |
info threads | thread list | 所有线程 |
thread 2 | thread select 2 | 切线程 |
frame 3 | frame select 3 | 切栈帧 |
info registers | register read | 看寄存器 |
info args / info locals | frame variable | 看参数/局部变量 |
print expr | expr expr 或 p expr | 求值 |
disas $rip-16,$rip+16 | disas -s $pc-16 -c 8 | 反汇编 |
x/10i $rip | memory read --format i --count 10 $pc | 看指令 |
info sharedlibrary | image list | 共享库 |
主线二在 lldb 里的完整现场:
$ lldb ./crash
(lldb) run
id=1, name=Alice
Process 12345 stopped
* thread #1, queue = 'com.apple.main-thread',
stop reason = EXC_BAD_ACCESS (code=1, address=0x0)
frame #0: 0x0000000100003a4c crash`main at crash.cpp:15:30
12 arr[0] = new Employee{1, "Alice"};
13
14 for (int i = 0; i < n; ++i) {
-> 15 std::cout << "id=" << arr[i]->id
16 << ", name=" << arr[i]->name << "\n";
(lldb) frame variable
(int) n = 2
(Employee **) arr = 0x0000600000004010
(int) i = 1
(lldb) p arr[0]
(Employee *) $0 = 0x0000600000008020
(lldb) p arr[1]
(Employee *) $1 = 0x0000000000000000 ← null!
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
证据链立现:arr[1] == 0x0,对它 ->id 就是访问地址 0,触发 EXC_BAD_ACCESS。
# 7.6 addr2line 与符号化
当只有地址信息(如生产环境的崩溃日志),用 addr2line 把地址翻成"文件:行号":
# 基本用法
addr2line -e ./myapp -f -C 0x401234
# 输出:
# processData(Data*)
# /home/user/myapp/main.cpp:42
# 批量符号化
addr2line -e ./myapp -f -C 0x401234 0x401345 0x401567
# 使用 eu-addr2line(更准确,支持 split DWARF)
eu-addr2line -e ./myapp -f -C 0x401234
# 共享库中的地址要减去加载基址
# 实际偏移 = 崩溃地址 - 库加载基址(从 /proc/pid/maps 获取)
2
3
4
5
6
7
8
9
10
11
12
13
14
backtrace_symbols_fd 输出的栈是被 mangle 过的,用 c++filt 解码:
g++ -rdynamic -g -O2 a.cpp # -rdynamic 让符号导出到动态符号表
./app 2>&1 | c++filt
# process()
# main+0x45
2
3
4
# 8. 实时调试与捕获
事后 core 不够,还有两类场景需要"实时抓现场":bug 能稳定复现 / 生产环境无法 dump。
# 8.1 gdb --args 直接跑
gdb --args ./app --config=prod.yaml --port 8080
(gdb) run # 跑到崩溃自动停
(gdb) bt
2
3
在 gdb 里设崩溃断点:
(gdb) catch signal SIGSEGV # 任何 SIGSEGV 都断下来
(gdb) catch throw # C++ 抛异常时断下(找未捕获异常根源)
(gdb) catch syscall execve # 跟踪某个系统调用
2
3
# 8.2 attach 已运行进程
gdb -p <PID>
# 进 gdb 后 c 继续运行;崩溃时自动停下
2
生产注意:attach 会暂停目标进程数毫秒到数秒(取决于线程数)——对延迟敏感服务先做好准备。
/proc/sys/kernel/yama/ptrace_scope 控制 attach 权限:
| 值 | 含义 |
|---|---|
| 0 | 任意进程可 attach 同 uid |
| 1 | 只能 attach 子进程(默认,需 root 或 CAP_SYS_PTRACE) |
| 2 | 只能 root |
| 3 | 完全禁止 |
# 8.3 进程内 signal handler
生产环境磁盘没空间、或者跑在 K8s 里 core dump 进不去——只能让进程自己写崩溃日志:
#include <csignal>
#include <execinfo.h>
#include <unistd.h>
#include <cstdio>
// async-signal-safe 的栈打印
static void crash_handler(int sig, siginfo_t* info, void* ucontext) {
// 1. 打印关键信息(用 write,不能用 printf!)
char buf[256];
int n = snprintf(buf, sizeof(buf),
"\n[CRASH] signal=%d code=%d addr=%p\n",
sig, info->si_code, info->si_addr);
write(STDERR_FILENO, buf, n);
// 2. 打调用栈(backtrace 在大多数 libc 上是 async-signal-safe)
void* frames[64];
int frame_count = backtrace(frames, 64);
backtrace_symbols_fd(frames, frame_count, STDERR_FILENO);
// 3. 恢复默认动作再 raise,让内核生成 core
signal(sig, SIG_DFL);
raise(sig);
}
void install_crash_handler() {
struct sigaction sa{};
sa.sa_sigaction = crash_handler;
sa.sa_flags = SA_SIGINFO | SA_RESETHAND | SA_ONSTACK;
sigemptyset(&sa.sa_mask);
for (int s : {SIGSEGV, SIGBUS, SIGABRT, SIGFPE, SIGILL}) {
sigaction(s, &sa, nullptr);
}
// 给 handler 单独的备用栈,防止"栈溢出导致的崩溃"在 handler 里再栈溢出
static char alt_stack[SIGSTKSZ];
stack_t ss{ alt_stack, 0, SIGSTKSZ };
sigaltstack(&ss, nullptr);
}
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
39
sigaction vs signal:
// signal() 的问题:
// 1. 行为在不同系统上不一致(System V vs BSD 语义)
// 2. 不能获取信号的附加信息(si_addr 等)
// 3. 在某些系统上,处理器执行后会被重置为默认
signal(SIGSEGV, handler); // 不推荐
// sigaction() 更可靠:
// 1. 行为一致
// 2. 可以通过 SA_SIGINFO 获取详细信息
// 3. 可以设置 SA_ONSTACK 使用备用栈
// 4. 可以设置信号掩码
sigaction(SIGSEGV, &sa, nullptr); // 推荐
2
3
4
5
6
7
8
9
10
11
12
C++ 异常的 terminate handler:
#include <exception>
#include <cxxabi.h>
void terminateHandler() {
std::exception_ptr eptr = std::current_exception();
if (eptr) {
try {
std::rethrow_exception(eptr);
} catch (const std::exception& e) {
int status;
const char* name = typeid(e).name();
char* demangled = abi::__cxa_demangle(name, nullptr, nullptr, &status);
std::cerr << "Unhandled exception: "
<< (demangled ? demangled : name)
<< "\nwhat(): " << e.what() << std::endl;
free(demangled);
}
}
abort();
}
int main() {
std::set_terminate(terminateHandler);
install_crash_handler();
// ...
}
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
# 8.4 异步信号安全禁忌
信号是异步中断:它可以在程序执行的任意指令之间插入。如果主程序正好在 malloc 内部(持有了 malloc 的全局锁),信号处理器又调用 malloc——死锁。如果主程序正在写 stdout 缓冲区中间,信号处理器又写 stdout——缓冲区损坏。
POSIX 规定了一份 async-signal-safe 函数清单(man 7 signal-safety),只有这些能在信号处理器里调用:
write/read/_exit(不是exit)signal/sigaction/kill/raisegetpid/getppidalarm/nanosleep- 绝大多数不加锁的系统调用
绝对禁止的:
printf/fprintf/std::cout(持有 stdio 锁)malloc/free/new/delete(持有 malloc 锁)pthread_mutex_lock(可能死锁)- C++ 异常(展开不可重入)
- 几乎所有 C++ 标准库函数
handler 里的 4 条铁律:
- 只调 async-signal-safe 函数:
write/read/_exit/raise/signal/sigaction是;malloc/printf/fopen/std::string不是。 - 用
SA_ONSTACK+sigaltstack:给 handler 单独的栈,否则栈溢出导致的崩溃会让 handler 也死掉。 SA_RESETHAND:handler 跑完恢复默认,避免无限递归崩溃。- 最后 raise 原信号:让内核生成 core,否则丢失现场。
反模式示例:
void handler(int sig) {
std::cout << "crashed!\n"; // ❌ stdout 可能被损坏
std::ofstream log("crash"); // ❌ 构造函数可能 malloc
log << get_stacktrace(); // ❌ 不可重入
std::exit(1); // ❌ exit 不是 async-safe,用 _exit
}
2
3
4
5
6
这段看似合理的代码在大概率情况下能工作——但一旦主程序崩溃时正在持有 malloc 锁,处理器就会死锁,导致进程挂起不退出,看起来像"卡死"而不是"崩溃"。这是生产环境调试最折磨人的问题之一。
# 8.5 sigaltstack 备用栈
一个棘手的场景:栈溢出导致 SIGSEGV。此时信号处理器会被调用——但处理器也要用栈!如果共用同一个栈,处理器自己也会立即栈溢出,死循环 → 进程被内核强杀。
sigaltstack 让你预先分配一块"备用栈",告诉内核:信号处理器用这块备用栈,不要动原栈:
stack_t ss;
ss.ss_sp = malloc(SIGSTKSZ);
ss.ss_size = SIGSTKSZ;
ss.ss_flags = 0;
sigaltstack(&ss, NULL);
struct sigaction sa;
sa.sa_flags = SA_ONSTACK; // 关键:告诉内核用备用栈
sa.sa_sigaction = handler;
sigaction(SIGSEGV, &sa, NULL);
2
3
4
5
6
7
8
9
10
有了 sigaltstack,即便原栈爆了,处理器仍能在备用栈上完成"写错误日志、触发 abort"的工作。这是所有生产级崩溃框架的标配。
# 8.6 DWARF 栈回溯
栈回溯是崩溃分析中最关键的信息。它的实现依赖于栈帧的组织方式。
方法 1:帧指针(Frame Pointer)回溯
高地址
┌─────────────────┐
│ 返回地址 N │ ← 通过 RBP 链找到每一帧的返回地址
│ 保存的 RBP(N) │ ← RBP[N] 指向上一帧的 RBP
│ 局部变量 │
├─────────────────┤
│ 返回地址 N-1 │
│ 保存的 RBP(N-1)│
│ 局部变量 │
├─────────────────┤
│ ... │
低地址
2
3
4
5
6
7
8
9
10
11
12
回溯算法:
current_rbp = register RBP;
while (current_rbp != 0) {
return_addr = *(current_rbp + 8); // 返回地址在 RBP 上方
print(return_addr);
current_rbp = *current_rbp; // 跟随 RBP 链
}
2
3
4
5
6
优点:简单快速。缺点:需要编译时保留帧指针(-fno-omit-frame-pointer)——但现代 C++ 程序 90% 都没有帧指针(-O2 默认 -fomit-frame-pointer)。
方法 2:DWARF 展开信息(.eh_frame 段)
异常展开需要能从任意代码位置往上回溯栈。但编译器优化后,"栈帧的结构"对每个函数的每一段汇编都不一样——.eh_frame 就是一张**"每个汇编地址对应的栈帧布局表"**。
.eh_frame 由两类条目组成:
- CIE(Common Information Entry):公共信息——ABI 约定的初始寄存器规则、指针编码格式、个性函数指针。
- FDE(Frame Description Entry):每个函数一个 FDE,描述"这段地址范围的栈帧变化"。
FDE 里存的不是完整布局,而是一串变化指令:比如"在 PC+4 处,sp 减了 32"、"在 PC+8 处,rbp 保存到了 CFA-8"。展开程序解析时用一个小虚拟机("DWARF CFI 虚拟机")依次执行这些指令,重建出每个地址的完整布局。
展开器(libunwind / libgcc_s 里的 _Unwind_* 函数)做这几件事:
- 从当前 PC 查找
.eh_frame_hdr(按地址排序的索引),二分找到对应的 FDE。 - 解析 FDE 的指令流,构建出当前 PC 的寄存器恢复规则。
- 按规则读取栈上保存的寄存器,特别是返回地址和上一帧的 CFA。
- 把寄存器值恢复到"上一帧的视角"(相当于"时光倒流一层")。
- 对新的 PC 重复步骤 1-4,直到栈底。
栈回溯每一帧大约 1-10μs。所以异常展开的成本主要来自这个(而不是匹配 catch 的时间)。
怎么保证 .eh_frame 存在:默认 GCC/Clang 都生成 .eh_frame(即使关闭异常也生成,因为它也用于 backtrace())。但以下场景会丢:
-fno-asynchronous-unwind-tables:禁用,二进制变小但无法回溯。strip --strip-all会移除.debug_info(DWARF),只保留.eh_frame——仍能栈回溯,但符号名变成地址。
生产环境推荐:保留 .eh_frame、strip 掉 .debug_*,把完整 DWARF 单独存档。崩溃时,先用 .eh_frame 得到地址栈,线下用归档的 DWARF 做 addr2line 解析。Google Breakpad、sentry-native 都是这个模式。
生产级的栈打印库推荐:libbacktrace、backward-cpp、google-breakpad——能打印源码行号、上下文、寄存器,比手写靠谱。
# 9. Sanitizer 武器库
事后 gdb 看的是"症状",Sanitizer 看的是"病灶"。让 bug 在第一现场暴露,是排查的圣杯。
# 9.1 ASan 内存红区
g++ -fsanitize=address -g -O1 -fno-omit-frame-pointer a.cpp
./a.out
2
注意三点:
-fsanitize=address必须同时在编译和链接阶段都传;-O1是为了保留一些内联信息但不过度优化;-fno-omit-frame-pointer保证 ASan 能可靠回溯栈。
ASan 能抓的崩溃种类:
| 种类 | 例子 |
|---|---|
| heap-buffer-overflow | int* p = new int[10]; p[10] = 0; |
| stack-buffer-overflow | int a[10]; a[10] = 0; |
| heap-use-after-free | delete p; *p = 0; ← 主线一案例 |
| stack-use-after-return | 返回局部变量地址 |
| double-free | delete p; delete p; |
| memory leak | 退出时未释放 |
ASan 报告示例(这正是主线一的 ASan 视角):
==12345==ERROR: AddressSanitizer: heap-use-after-free on address 0x603000000040
WRITE of size 8 at 0x603000000040 thread T0
#0 0x4012a0 in Handler::process(...) handler.cpp:42
#1 0x401311 in on_event(...) dispatcher.cpp:18
0x603000000040 is located 0 bytes inside of 32-byte region
freed by thread T0 here:
#0 0x7f... in operator delete(void*)
#1 0x401456 in cleanup_idle_sessions() dispatcher.cpp:35 ← 释放点
previously allocated by thread T0 here:
#0 0x7f... in operator new(unsigned long)
#1 0x401123 in Session::Session() session.cpp:10 ← 分配点
2
3
4
5
6
7
8
9
10
11
12
13
三个栈一次给齐:当前崩溃点、释放点、分配点——人类肉眼难以三处都看的关联,ASan 直接画好。
主线二(crash.cpp)的 ASan 视角(构造越界版本观察):
==45623==ERROR: AddressSanitizer: heap-buffer-overflow on address 0x60400000dff0
READ of size 8 at 0x60400000dff0 thread T0
#0 0x10... in main crash_v2.cpp:8
#1 0x7fff... in start
0x60400000dff0 is located 0 bytes to the right of 16-byte region
[0x60400000dfe0,0x60400000dff0) allocated by thread T0 here:
#0 0x10... in operator new[](unsigned long)
#1 0x10... in main crash_v2.cpp:4
SUMMARY: AddressSanitizer: heap-buffer-overflow crash_v2.cpp:8 in main
2
3
4
5
6
7
8
9
10
11
四块关键信息:
| 区块 | 告诉你什么 |
|---|---|
| 错误类型 | heap-buffer-overflow —— 不是 UAF、不是栈溢出,是堆越界读 |
| 错误位置 | crash_v2.cpp:8 —— 精确到代码行 |
| 分配位置 | crash_v2.cpp:4,大小 16 字节 —— 你只分配了 2 个指针槽(2×8=16),第 3 个槽不存在 |
| 访问方式 | READ of size 8 —— 读了 8 字节(即一个指针),落在了合法区右侧 0 字节处 |
这四块信息一结合,根因就是"你声称要分 3 个,其实只分了 2 个"——ASan 把"人要思考 30 分钟的推理"直接变成 5 秒钟的报告。
代价:内存 2~3x,速度 2~5x。测试/CI 必跑,生产慎用。
# 9.2 shadow memory 原理
ASan 不是黑魔法。理解它的原理能帮你判断 "ASan 为什么没抓到我这个 bug"。
核心思想:为每 8 字节真实内存分配 1 字节的"影子内存",记录这 8 字节的可访问性。
真实内存 shadow 内存
0x60400000dfe0 → shadow[X] = 0x00 (全 8 字节可访问)
0x60400000dfe8 → shadow[X+1] = 0x00 (全 8 字节可访问)
0x60400000dff0 → shadow[X+2] = 0xFA (redzone, 越界标记)
0x60400000dff8 → shadow[X+3] = 0xFA
2
3
4
5
每次内存访问插桩:编译器把 *p 改写成"先查 shadow、再真访问":
shadow_addr = (p >> 3) + SHADOW_OFFSET
shadow_value = *shadow_addr
if (shadow_value != 0 && offset_in_8bytes >= shadow_value)
report_error(...)
*p // 正常访问
2
3
4
5
真实内存: | malloc 块 (16B) | 红区 (16B) | malloc 块 (32B) | 红区...
影子内存: | 0 0 | 中毒 0xFA | 0 0 0 0 | 0xFA ...
↑
访问这里 → ASan 立刻报错并打栈
2
3
4
这也解释了几个常被误解的点:
| 现象 | 原理解释 |
|---|---|
| ASan 慢 2-5 倍 | 每次访存多了 5-10 条指令 + 查 shadow 的缓存抖动 |
| ASan 能检测 UAF | free 时不立刻归还,放入 quarantine,shadow 标记 freed |
| ASan 内存翻倍 | 1/8 shadow + quarantine 最多 256MB + 每块堆的 redzone |
| ASan 漏报语义错误 | 对齐合法的访问它说 OK,不管你意图是什么 |
| ASan 对 mmap 无效 | 不经过 malloc,shadow 默认全 0(可访问)—— 需要手动 poison |
# 9.3 UBSan 未定义行为
g++ -fsanitize=undefined -g a.cpp
抓的是"语言层"的 UB:
int x = INT_MAX;
x++; // signed overflow → UBSan 报警
int a[10];
int i = -1;
a[i] = 0; // 越界(编译期可推断的)
int* p = nullptr;
*p; // null deref,UBSan 比 SIGSEGV 报得更早
uint8_t b = 200;
int8_t s = (int8_t)b; // implicit-signed-integer-truncation
2
3
4
5
6
7
8
9
10
11
12
UBSan 开销很小(5~10%),生产可以开部分 check(如 signed-integer-overflow、null)。
# 9.4 TSan 数据竞争
g++ -fsanitize=thread -g a.cpp
抓的是多线程场景下的 data race:
int counter = 0;
void worker() { counter++; } // 多线程同时跑 → TSan 报 data race
2
TSan 与 ASan 不能同时开——shadow memory 模型冲突。
代价巨大:内存 5~10x,速度 5~15x。只在专门的 race 排查阶段用。
何时选哪个:
| 场景 | 选谁 |
|---|---|
| 崩溃在 malloc/free 路径 | ASan |
| 间歇性数据错乱 | UBSan + ASan 轮流 |
| 多线程偶现 | TSan |
| 性能敏感的生产 | UBSan 部分 check(不要 ASan) |
| 内存泄漏 | ASan(ASAN_OPTIONS=detect_leaks=1) |
| Fuzzing | ASan + UBSan 一起开 |
金律:只要 CI 跑得起来,所有单元测试都应该开 ASan + UBSan。绝大多数生产事故的根因,在 ASan 下都会立刻暴露。
ASan 和 lldb/gdb 的互补:
| 对比维度 | lldb/gdb | ASan |
|---|---|---|
| 什么时候用 | 崩溃现场、线上 core | 开发期、CI |
| 信息粒度 | 停在那一刻的状态 | 分配位置 + 访问位置 + 错误类型 |
| 能否找到非崩溃 bug | 不能 | 能(只要越界被触发) |
| 启动成本 | 零,随时可连 | 要重编 |
| 运行开销 | 0 | 2-5x 慢、2-3x 内存 |
实战组合:开发默认开 ASan,生产崩了用 lldb/gdb 分析 core。
# 9.5 编译告警最便宜
很多野指针/越界 bug,在 -Wall -Wextra -Wpedantic 下会被直接告警。比如主线二的 22 行案例,在 -O2 时 clang/gcc 就会提示:
warning: 'arr[1]' is used uninitialized [-Wuninitialized]
但大多数人习惯忽略 warning。treat warnings as errors(-Werror),是成本最低、收益最高的调试设置之一。
调试编译三件套:
g++ -g -O0 -Wall -Wextra crash.cpp -o crash
| 选项 | 作用 | 不加的后果 |
|---|---|---|
-g | 生成 DWARF 调试信息 | lldb 里看到的是裸地址而非行号 |
-O0 | 关闭优化 | 变量可能被优化掉,frame variable 显示 <optimized out> |
-Wall -Wextra | 打开常用告警 | 错过编译期能揪出的一半 bug |
经验:日常开发 -O0 -g;CI 上额外跑一轮 -O2 -g 以防优化引入的 UB 暴露;发布构建才是 -O2/-O3 不带 -g 或把 -g 分离成 .debug 包。
unitialized memory 的五种味道:
| 来源 | 代码 | 初值 | 结果 |
|---|---|---|---|
| 局部变量 | int x; use(x); | 未定义 | UB,开 -O2 后行为不确定 |
new T[n] | int* p = new int[3]; | 未定义 | 数组内容是垃圾 |
new T[n]() | int* p = new int[3](); | 0 | 值初始化 |
new T*[n] | T** p = new T*[3]; | 未定义 | 主线二的情况 |
make_unique<T[]> | auto p = std::make_unique<int[]>(3); | 0 | 安全 |
主线二的根本改法:new Employee*[n] → new Employee*[n](),所有槽会被 nullptr-init,越界会变成可预测的 null deref 而不是不可预测的野指针。
# 10. 五步排查方法论
把主线二的排查过程抽象成可复用的流程:
┌─────────────────────────────────────────┐
│ 1. 最小复现 (MCVE) │
│ 把 bug 剥离到 < 50 行代码 │
└──────────────────┬──────────────────────┘
↓
┌─────────────────────────────────────────┐
│ 2. 建立假设空间 │
│ 列出可能 3-5 种原因,按成本排序 │
└──────────────────┬──────────────────────┘
↓
┌─────────────────────────────────────────┐
│ 3. 多路径证据 │
│ lldb + ASan + 编译告警,三角验证 │
└──────────────────┬──────────────────────┘
↓
┌─────────────────────────────────────────┐
│ 4. 根因总结(crash site ≠ bug site) │
│ 写一句话的 bug 描述 │
└──────────────────┬──────────────────────┘
↓
┌─────────────────────────────────────────┐
│ 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 最小化复现 MCVE
主线二就是 MCVE 的典范——把"几十个文件的项目"剥离到 22 行。复杂工程里调试,第一步永远是简化。
剥离技巧:
- 二分法:把代码切两半,看哪半还崩,循环;
- 删依赖:把外部库调用换成空函数桩;
- 内联化:把多文件代码合到一个文件,便于版本控制对照。
# 10.2 建立假设空间
没有假设空间的调试,是在大海里捞针;有假设空间的调试,是在有限清单里逐项排除。
C++ 崩溃,99% 出自下面 8 种模式之一。按出现频率排序:
| # | 模式 | 典型写法 | 崩溃信号 |
|---|---|---|---|
| 1 | 空指针解引用 | p->f() 其中 p == nullptr | SIGSEGV |
| 2 | 野指针 / 未初始化指针 | T* p; p->f(); | SIGSEGV / SIGBUS |
| 3 | 数组越界 | arr[i] 其中 i >= size | SIGSEGV / SIGBUS / 静默错误 |
| 4 | Use-After-Free | delete p; p->f(); | SIGSEGV |
| 5 | 栈溢出 | 无限递归、巨型栈对象 | SIGSEGV / SIGBUS |
| 6 | Double-Free | delete p; delete p; | SIGABRT |
| 7 | 对齐访问错误 | *(int*)(buf+1) = 1; | SIGBUS |
| 8 | 写入只读段 | s[0] = 'H'; // s 是字面量 | SIGSEGV / SIGBUS |
回到主线二,观察到的事实:
id=1, name=Alice ← 成功打印
Bus error: 10 ← 然后崩了
2
三条事实:
- 事实 A:至少有一次
arr[i]->id和arr[i]->name访问是成功的 ⇒arr[0]指向了一个合法对象; - 事实 B:循环条件
i < n至少让循环进了第二次(i=1); - 事实 C:信号是 SIGBUS(address=0x0 或类似)⇒ 第二次访问的
arr[1]不是合法指针。
这三条事实把假设空间一下缩小到 "第二次循环时 arr[1] 不合法"。对比上面清单:
- 排除 #1(空指针):
arr[0]不是空;但arr[1]可能是; - 高度怀疑 #2(野指针):
new T*[n]只分配指针槽,不初始化内容; - 排除 #3(越界):本例
i < n是严格按n=2来的,不算越界——是arr[1]本身就没被赋值。
有了清单,调试就不再是"我不知道从哪下手",而是"先验证 H1,花 2 分钟"。
# 10.3 多路径证据交叉
三个排查方向都给出了证据。把它们交叉比对,才是"论证"这一步。
| 证据来源 | 给出的事实 |
|---|---|
| lldb | 崩在 crash.cpp:15,arr[1] == 0x0 |
| ASan(越界版) | arr[i] 访问了分配区右侧,分配点在第 4 行 |
| 编译告警 | arr[1] is used uninitialized |
三条证据相互印证、无矛盾,共同指向同一根因:
"第 11 行只给 arr[0] 赋了值,第 14 行循环却要访问 arr[0] 和 arr[1],arr[1] 的值未初始化。"
调试里最怕的是三条证据互相矛盾——那意味着你的心智模型出了问题,或者 bug 是多因复合的。本案例三路一致,确认可以进入修复阶段。
# 10.4 crash 不等于 bug
The crash site is rarely the bug site. 崩溃的位置,往往不是 bug 本身的位置。
| 术语 | 主线二 | 主线一 |
|---|---|---|
| Crash site | crash.cpp:15 arr[i]->id(症状) | handler.cpp:42 process 第一条指令 |
| Bug site | crash.cpp:11 漏掉了 arr[1] = ... | dispatcher.cpp:35 delete 后没置空 |
在真实大项目里,crash site 可能在别人写的某个库函数里,你要从那里一帧一帧往上回溯,直到找到自己代码的某个错误假设——那才是 bug site。这就是为什么 bt 比 frame #0 更重要。
ASan 的最大价值就是把"暴露位置"和"产生位置"绑在一起报告——三个栈一次给齐。手动 gdb 永远不如 ASan 三栈齐发。
调试结束时,给自己写一句不带技术名词的、能给产品经理看懂的根因描述:
主线二:"程序说要显示 2 个员工,但实际上只填了 1 个员工的信息进数组,显示第二个员工时就崩了。"
如果你能一句话讲清楚,说明你真的懂了。
# 10.5 修复论证与沉淀
三层修复方案(以主线二为例):
修复 A:最小改动(一行)
arr[0] = new Employee{1, "Alice"};
arr[1] = new Employee{2, "Bob"}; // ← 补上
2
适用:紧急 hotfix。局限:只修这一处,下一处类似的"漏赋值"还会崩。
修复 B:防御式编程
int n = 2;
Employee** arr = new Employee*[n](); // ① 所有槽先置 nullptr
arr[0] = new Employee{1, "Alice"};
// 忘了赋 arr[1] 也没关系
for (int i = 0; i < n; ++i) {
if (!arr[i]) continue; // ② 防御检查
std::cout << "id=" << arr[i]->id << "\n";
}
2
3
4
5
6
7
8
9
10
适用:代码多人维护、边界情况难以穷举。局限:仍然在用裸指针 + 双源真相(n 和 arr 独立维护)。
修复 C:根治式重构
#include <memory>
#include <vector>
std::vector<std::unique_ptr<Employee>> emps;
emps.emplace_back(std::make_unique<Employee>(1, "Alice"));
emps.emplace_back(std::make_unique<Employee>(2, "Bob"));
for (const auto& e : emps) {
std::cout << "id=" << e->id << ", name=" << e->name << "\n";
}
2
3
4
5
6
7
8
9
10
优点:
emps.size()永远等于实际元素数——"双源真相"消失;- 没有裸
new/delete——内存自动管理; - 遍历用 range-based for——没有越界可能;
unique_ptr保证每个对象唯一所有权,不会 double-free。
资深工程师的选择:A + C——紧急发一个 A 版止血,下一个迭代立刻做 C 版重构。把一次 bug 变成一次代码质量提升。
| 修复 | 改动量 | 能不能防未来类似 bug | 推荐场景 |
|---|---|---|---|
| A:补一行 | 1 行 | 不能 | 紧急发版 |
| B:零初始化 + null 检查 | 2-3 行 | 能防野指针,不防越界 | 过渡期 |
| C:vector + unique_ptr | 重写 5-10 行 | 能防 A/B 所有问题 | 长期代码 |
论证修复有效:跑一遍没崩 ≠ 修好了。至少需要:
- 路径覆盖:让原来的崩溃路径再走一遍;
- 边界覆盖:让周边路径(边界值)也走一遍;
- 机制覆盖:用工具(ASan)证明不存在同类 bug 的可能。
边界用例回归表:
| 用例 | 输入 | 期望 |
|---|---|---|
| n=0 | 空数组 | 不崩,正常输出(0 行) |
| n=1 | 1 个元素 | 正常显示 1 行 |
| n=2 | 2 个元素(原崩溃路径) | 正常显示 2 行 |
| n=100 | 100 个元素 | 正常显示 |
| 部分未填 | 声明 n=3 但只填 2 个 | 修复 B/C 下不崩,修复 A 下崩 |
最后一行特别重要——它反向证明了修复 A 的不完备性。
沉淀为 CI 用例:
// test_crash.cpp
#include <gtest/gtest.h>
TEST(EmployeeList, HandlesPartiallyFilled) {
auto emps = make_employees({{1, "Alice"}, {2, "Bob"}});
EXPECT_EQ(emps.size(), 2);
// 如果回归成旧代码,ASan 会抓
}
2
3
4
5
6
7
8
这条 test 的价值远大于"跑通一次":今后任何人重构这块代码,CI 立刻告警;整条调试经验被封存为可执行的文档;相当于给团队的代码基线加了一道免疫接种。
三层防御模型:
┌────────────────────────────────────────────┐
│ 编译期:-Wall -Wextra -Werror -Wpedantic │
│ clang-tidy、cppcheck │
├────────────────────────────────────────────┤
│ 运行期:-fsanitize=address,undefined │
│ valgrind、lldb/gdb │
├────────────────────────────────────────────┤
│ 回归期:单元测试、CI + ASan、Fuzz │
└────────────────────────────────────────────┘
2
3
4
5
6
7
8
9
任一层拦住,bug 就不会出现在用户那里。这三层都是成本固定、收益指数级增长的投资。
心法六条:
- 先读信号,再打工具:连错误信息都没看懂就开调试器,等于在黑屋子里打手电;
- 永远有假设空间:没有假设的调试是蒙,有假设才能用证据排除;
- 多路径交叉验证:单一证据不可信,三条一致的证据才叫根因;
- crash site ≠ bug site:被动看崩的位置只是起点;
- 验证要比修复更严格:修一处容易,证明"这一类都没了"难;
- 把每次调试沉淀成 CI:否则你每次都在重复同一类错误。
# 11. 典型场景速查
把第 1~10 章的方法论,落到 7 个最高频的崩溃场景。
# 11.1 空指针解引用
struct Node { Node* next; int v; };
void traverse(Node* p) {
while (p->next) p = p->next; // p 可能本身就是 null
}
2
3
4
特征:SIGSEGV + SEGV_MAPERR + si_addr=0(或非常小的偏移,如 0x10,对应 nullptr->member)。
第一刀:bt 找到崩溃帧,看 info args,哪个参数是 0。
修法:
- 函数入口
if (!p) return; - 用
gsl::not_null<T*>在类型层强制非空 - 用引用代替指针(引用不可能为空——除非用 UB 强转)
# 11.2 栈溢出
void walk(int depth) { char buf[8192]; walk(depth + 1); }
特征:SIGSEGV + SEGV_MAPERR + si_addr 在栈底守护页(看 pmap 找 ---p 段)。bt 显示同一函数反复出现,深度上千。
第一刀:
(gdb) bt | head -20
(gdb) p $rsp
(gdb) info proc mappings # 找 [stack] 与守护页
2
3
修法:
- 调大栈:
ulimit -s 65536(治标) - 改递归为迭代 + 显式栈(治本)
- 大局部数组改堆:
std::vector<char> buf(8192)
# 11.3 写只读段
char* s = (char*)"hello";
s[0] = 'H'; // .rodata 不可写
2
特征:SIGSEGV + SEGV_ACCERR + si_addr 落在 .rodata 区间(用 info proc mappings 看是否在 r--p 段)。
修法:用 char s[] = "hello";(栈数组拷贝)或 std::string,永远不要 (char*) 强转字面量。-Wwrite-strings 编译期就能告警。
# 11.4 double-free / UAF
delete p;
delete p;
2
特征:SIGABRT,stderr 上 glibc 输出 double free or corruption (fasttop)。
第一刀:
# 让 glibc 在第一次 free 时填毒,第二次更早暴露
MALLOC_CHECK_=3 ./a.out
# 终极武器:ASan
ASAN_OPTIONS=abort_on_error=1 ./a.out_asan
2
3
4
5
修法:
- 用
unique_ptr/shared_ptr,编译期消除手动 delete - 释放后立刻置空:
delete p; p = nullptr;(delete nullptr是合法 no-op)
# 11.5 整数除零
int avg = sum / count; // count 可能是 0
特征:SIGFPE + FPE_INTDIV。
修法:除法前判 0;或者用 if (count == 0) return 0; 明确兜底。不要靠 SIGFPE 当业务校验。
# 11.6 mmap 文件被截断
int fd = open("data.bin", O_RDONLY); // 假设文件只有 100 字节
char* p = (char*)mmap(nullptr, 4096, // 但 mmap 了 4KB
PROT_READ, MAP_PRIVATE, fd, 0);
char c = p[200]; // SIGBUS (BUS_ADRERR)
2
3
4
为什么? mmap 的页面必须能从文件对应位置读到;读超过文件实际长度那部分时,内核发 SIGBUS。
修法:
- 操作 mmap 区前
fstat确认文件大小 - 用
MAP_POPULATE让内核预读(部分缓解) - 上层加文件锁,防止 mmap 期间被截断
# 11.7 未对齐访问
char buffer[16];
int* p = reinterpret_cast<int*>(buffer + 1); // 偏移 1,非 4 对齐
__atomic_store_n(p, 1, __ATOMIC_SEQ_CST); // ARM 上 SIGBUS
2
3
x86/x64 对未对齐"容忍"(性能降但不崩),ARM/RISC-V 某些模式下直接发 SIGBUS(BUS_ADRALN)。
修法:
- 用
memcpy替代裸指针强转 - 用
alignas(8)显式对齐 - 用
std::atomic<T>而非__atomic_*内建函数(标准库会保证对齐)
# 12. 进程终止与框架
很多人把"进程崩溃"和"进程退出"当成一回事。实际上进程终止是一个多阶段过程,理解它能解决"崩溃后为什么没 coredump"、"为什么进程还在进程表里"这些生产问题。
# 12.1 进程终止完整流程
以 SIGSEGV 为例,从 CPU 异常到 task_struct 释放的全过程:
- CPU 触发异常 → 内核收到 #PF (page fault);
- 内核查页表,发现是非法访问 → 向进程发 SIGSEGV;
- 进程未注册 SIGSEGV 处理器 → 内核执行默认动作:terminate with core dump;
- 内核在
/proc/sys/kernel/core_pattern指定路径写 coredump(受ulimit -c限制); - 调用每个线程的 exit handler、释放 fd 和内存、关闭文件句柄;
- 把退出码、信号号、rusage 写入 task_struct;
- 向父进程发 SIGCHLD;
- 进程进入 zombie 状态——task_struct 还在,但资源已释放;
- 父进程
wait()/waitpid()→ 回收 task_struct,进程彻底消失。
父进程通过 wait 获取子进程的崩溃信息:
int status;
pid_t child = wait(&status);
if (WIFSIGNALED(status)) {
int sig = WTERMSIG(status);
printf("子进程被信号 %d (%s) 终止\n", sig, strsignal(sig));
if (WCOREDUMP(status)) {
printf("已生成核心转储文件\n");
}
}
2
3
4
5
6
7
8
9
# 12.2 僵尸进程成因处理
如果父进程不 wait,子进程 task_struct 一直留在系统里——这就是僵尸进程。它不占 CPU、不占内存(已释放),但占一个 PID 和一个 task_struct 槽。极端情况 PID 耗尽(默认 32768),系统无法再创建新进程。
两种标准解决方式:
- 父进程处理 SIGCHLD:
signal(SIGCHLD, SIG_IGN)让内核自动 reap(POSIX 2001 起支持);或注册 handler 在里面waitpid(-1, &status, WNOHANG)循环回收。 - 两次 fork:父 fork 出"中间进程",中间进程立即 fork 出"工作进程"后自己 exit——工作进程被 init(PID 1)接管,init 会自动 reap。daemon 程序的标准做法。
# 12.3 OOM 与崩溃区别
一种特殊的"崩溃":OOM killer。Linux 在物理内存不足时会选一个进程杀掉(SIGKILL)。被 SIGKILL 杀的进程:
- 没有信号处理机会(SIGKILL 不可捕获、不可忽略、不可阻塞);
- 没有 coredump;
- 没有析构函数调用——资源直接丢弃。
所以"进程在高内存场景下突然消失,没有任何日志"时,优先查 dmesg | grep -i "oom" 或 /var/log/kern.log。OOM 的选择算法基于 /proc/<pid>/oom_score——可以通过 oom_score_adj 告诉内核"优先别杀我"。
# 12.4 生产级捕获框架
生产级崩溃捕获框架(如 Google Breakpad/Crashpad)的设计原则:
核心设计原则:
1. 在崩溃进程外处理(用独立的子进程)
- 崩溃进程的内存可能已损坏
- 在损坏的进程中做复杂操作不可靠
2. 最小化崩溃处理器中的操作
- 信号处理函数中只做最基本的通知
- 复杂的 dump 生成由外部进程完成
3. 使用 minidump 而非完整 core dump
- minidump 只包含关键信息(线程栈、寄存器、模块列表)
- 大小通常只有几百 KB(vs core dump 可能几 GB)
架构:
┌──────────────┐ ┌──────────────┐
│ 应用进程 │ │ 监控进程 │
│ │ │ │
│ 信号处理: │ ──→ │ 生成 minidump │
│ 通知监控进程 │pipe │ 上传到服务器 │
│ │ │ 符号化处理 │
└──────────────┘ └──────────────┘
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
简化版实现:
class CrashHandler {
int pipe_fds[2]; // 管道,用于通知监控进程
pid_t monitor_pid;
public:
void install() {
pipe(pipe_fds);
// fork 出监控进程
monitor_pid = fork();
if (monitor_pid == 0) {
// 子进程:监控进程
close(pipe_fds[1]);
monitorLoop(pipe_fds[0]);
_exit(0);
}
close(pipe_fds[0]);
// 父进程:注册信号处理函数
setupSignalHandlers();
}
private:
void onCrash(int signal) {
// 信号处理函数中只做最小操作
// 写入信号编号到管道,通知监控进程
write(pipe_fds[1], &signal, sizeof(signal));
// 等待监控进程完成 dump
// 然后恢复默认信号处理,让进程正常终止
}
static void monitorLoop(int read_fd) {
int signal;
if (read(read_fd, &signal, sizeof(signal)) > 0) {
// 在独立进程中安全地执行:
// 1. 生成 minidump
// 2. 收集系统信息
// 3. 上传崩溃报告
generateMinidump(getppid());
uploadCrashReport();
}
}
};
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
39
40
41
42
43
44
45
# 12.5 崩溃处理成熟度
| 阶段 | 能力 | 典型技术栈 |
|---|---|---|
| Level 1 | 能看到崩溃消息 | stderr + 默认信号处理 |
| Level 2 | 能生成 coredump | ulimit -c、GDB 事后分析 |
| Level 3 | 运行时捕获栈回溯 | sigaction + backtrace |
| Level 4 | 异步信号安全的最小处理器 | sigaltstack + _exit + 固定字符串 |
| Level 5 | 生产级崩溃报告 | Breakpad / crashpad 级别(外部进程 minidump + 符号服务器 + 上报系统) |
Level 3 是大部分团队的能力;Level 4-5 需要专门的崩溃基础设施。选择哪个级别取决于产品形态:
- 命令行工具 → Level 3 够;
- 桌面软件 / 移动 App → 至少 Level 4;
- 大规模服务端 → 应该 Level 5。
与调试技巧一章的互补关系:
- 开发/测试阶段 → 调试器(GDB、LLDB)+ Sanitizer 尽早捕获;
- 预发/灰度 → Sanitizer + coredump 双保险;
- 生产环境 → 崩溃捕获框架 + 符号服务器 + 日志。
越往后阶段,介入的侵入性越低,分析的难度越大——这就是为什么"生产环境的崩溃调试"被认为是 C++ 工程师的高级能力。
# 13. 综合案例串讲
# 13.1 案例真相揭晓
回到第 1.1 节的 dispatcher.cpp,七个疑问现在能逐条作答:
| 疑问 | 答案 |
|---|---|
| ① 信号是怎么来的?谁发给谁? | 第 3.2:CPU 触发硬件异常 → 内核 do_page_fault → force_sig_fault → 投递到 task->pending |
| ② SIGSEGV / SIGBUS / SIGABRT 怎么区分? | 第 4:SEGV 看地址权限、BUS 看物理底子、ABRT 看是否主动 abort |
| ③ si_code、error code 怎么读? | 第 4.1 + 第 5:error 4 = 用户态读未映射页;si_code = SEGV_MAPERR |
| ④ 现场没了怎么复盘? | 第 6:ulimit -c unlimited + core_pattern |
| ⑤ gdb 看哪几个值? | 第 7:bt full + info registers(看 rdi/this)+ disas $rip + p $_siginfo |
| ⑥ 不能复现的怎么抓? | 第 8:进程内 sigaction + backtrace + SA_ONSTACK 备用栈 |
| ⑦ 怎么让 bug 在第一现场暴露? | 第 9:ASan 给"释放点 + 分配点 + 当前点"三栈,根因当场锁定 |
最终诊断:
cleanup_idle_sessions在delete s.handler后没有把指针置空;on_event的if (s->handler)防御对悬挂指针无效——Handler::process第一条指令就是mov (%rdi), %rax读 vtable,访问已被 glibc tcache 填毒(0x4141...)的内存,触发SEGV_MAPERR。
修复方案(按优劣排序):
方案 A:立即修复——置空 + 防御
void cleanup_idle_sessions() {
for (auto& s : sessions_) {
if (s.idle_too_long()) {
delete s.handler;
s.handler = nullptr; // ← 关键
}
}
}
2
3
4
5
6
7
8
代价:所有 delete 都要配对置空,靠人肉记忆,下次还会再犯。
方案 B:智能指针(推荐)
struct Session {
Connection* conn;
std::unique_ptr<Handler> handler; // ← 自管理
};
void cleanup_idle_sessions() {
for (auto& s : sessions_) {
if (s.idle_too_long()) {
s.handler.reset(); // 自动 delete + 自动置空
}
}
}
void on_event(Session* s, const Event& ev) {
if (s->handler) s->handler->process(ev); // bool 转换会查 nullptr
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
代价:要改改类型;收益是编译期消灭一整类 UAF,不依赖人肉。
方案 C:架构级——所有权显式化
如果 Handler 可能被多处持有,用 shared_ptr + weak_ptr:Session 持 weak_ptr,调用前 lock() 升级,对象已死自然拿到空。这是异步框架常用的"weak 回调"模式。
生产建议:方案 B 永远是首选。手动 delete 的代码 = 未来的 UAF 候选。
# 13.2 一次崩溃的一生
把 dmesg: segfault at 0... 这一行的全过程串成一棵知识树:
程序: s->handler->process(ev) ← Handler 已被 delete
│
├─ 编译期
│ └─ 生成 mov (%rdi), %rax / call *0x10(%rax)
│
├─ 运行期 - 触发崩溃
│ ├─ %rdi = 0x4141...(glibc tcache 毒标记) ─── 第 1.1 节
│ ├─ MMU 查页表 → 该 VA 未映射 ─── 第 4.1 节
│ └─ CPU 触发 #PF 异常
│
├─ 内核处理
│ ├─ do_page_fault 检查 VMA → 不在合法区域 ─── 第 3.2 节
│ ├─ force_sig_fault(SIGSEGV, SEGV_MAPERR, 0x4141...)
│ ├─ 写 dmesg: "segfault at... ip... error 4" ─── 第 4.1 节
│ └─ 设置 task->pending bit 11
│
├─ 返回用户态前
│ ├─ get_signal() 检查 pending
│ ├─ 用户没装 handler → 默认动作 Core
│ └─ do_coredump → 按 core_pattern 写 core 文件 ─── 第 6 章
│
└─ 事后排查
├─ gdb ./app core ─── 第 6.3 节
├─ bt → 看到 process at 0x... ─── 第 7.1 节
├─ info registers → rdi=0x4141... ─── 第 7.2 节
├─ p $_siginfo → si_code=1 (MAPERR) ─── 第 7.4 节
└─ ASan 重跑 → 锁定 free 点 / alloc 点 ─── 第 9.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
理解一次崩溃从 CPU 异常到 gdb 现场的全链路,就是理解所有 C++ 崩溃排查的总骨架。
# 13.3 设计哲学回扣
整理本篇的四条跨篇适用的设计哲学:
哲学 1:分层定位——信号 → si_code → 寄存器 → 源码行
排查不是"一眼看穿",而是沿着分层证据链一步步缩小范围。信号告诉你"死于什么",si_code 告诉你"哪一类",寄存器告诉你"哪个值有问题",源码行告诉你"哪一行写错了"。跳层猜测就是浪费时间。
哲学 2:故障即时——守护页、canary、ASan 红区
Linux 选择"立刻崩"而不是"静默继续"——守护页是 OS 层的承诺、stack canary 是编译器层的承诺、ASan 红区是 Sanitizer 层的承诺。晚一秒崩,多一千倍调试成本。这是和 C++ "fail fast" 一脉相承的工程信念。
哲学 3:现场可重放——core dump 是排查的"时光机"
生产环境不能交互、不能挂 gdb、运维醒不来——所以现场必须自动留下来。ulimit -c + core_pattern + 调试符号分离 = 任何凌晨 3 点的崩溃,第二天上班都能回到那一刻。没有 core 的生产环境是裸奔。
哲学 4:第一现场原则——bug 暴露的位置 ≠ bug 产生的位置
UAF 崩在 process 里,但 bug 在 cleanup_idle_sessions;栈溢出崩在最深一层递归,但 bug 在没控制递归深度。Sanitizer 的最大价值,是把"暴露位置"和"产生位置"绑在一起报告。手动 gdb 永远不如 ASan 三栈齐发。
# 13.4 信号崩溃速查表
一张图保存以备查:
| 信号 | 编号 | 默认 | 典型病因 | si_code 关键值 | 第一刀 |
|---|---|---|---|---|---|
| SIGSEGV | 11 | Core | 空指针 / 野指针 / 越界 / 写只读 | MAPERR(1) / ACCERR(2) | bt + p $_siginfo |
| SIGBUS | 7/10 | Core | mmap 文件被截断 / 未对齐 / ECC | ADRERR(2) / ADRALN(1) | 看 mmap 文件状态 |
| SIGABRT | 6 | Core | assert / 异常未捕获 / 内存损坏 | - | 看 stderr glibc 输出 |
| SIGFPE | 8 | Core | 整数除 0 / INT_MIN/-1 | INTDIV(1) / INTOVF(2) | 检查除数 |
| SIGILL | 4 | Core | 二进制损坏 / 指令集不匹配 | ILLOPC(1) / ILLOPN(2) | objdump -d |
60 秒诊断命令清单:
# 1. 看 dmesg 拿信号 + 错误码 + 地址
dmesg | tail -20
journalctl -k --since "10 minutes ago" | grep -i segfault
# 2. 让现场留下来
ulimit -c unlimited
echo "/var/cores/core-%e-%p-%t" > /proc/sys/kernel/core_pattern
# 3. core 拿到后
gdb ./app /var/cores/core-app-12345-...
(gdb) bt full
(gdb) info registers
(gdb) disas $rip-32,$rip+16
(gdb) p $_siginfo
# 4. 不能复现的,进程自捕获
# 在 main 入口加 install_crash_handler() ← 第 8.3 节
# 5. 有 ASan 测试环境的,第一时间跑
g++ -fsanitize=address,undefined -g -O1 -fno-omit-frame-pointer
./a.out_asan
# 6. systemd-coredump 接管的环境
coredumpctl list
coredumpctl gdb <PID>
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
信号 → 第一查询条目:
SIGSEGV → si_addr 是不是 0 → 是 → 空指针,看哪个未判空
│ → 否 → 是不是栈底 → 是 → 栈溢出
│ → 否 → 是不是堆地址 → 是 → UAF (跑 ASan)
│ → 否 → 写只读 (查 mappings)
SIGBUS → si_code = ADRERR → 看 mmap 文件大小变化日志
→ si_code = ADRALN → 找最近的指针强转
SIGABRT → 看 stderr 上 glibc 那行 → "double free / corruption" → 跑 ASan
→ "Assertion failed" → 看 assert 表达式
SIGFPE → 找 / 或 % 操作符 → 加除数判 0
SIGILL → 看 -march / 二进制是否匹配机型 → 重编 baseline
2
3
4
5
6
7
8
9
10
信号编号备忘(无需查 man):
SIGHUP=1 SIGINT=2 SIGQUIT=3 SIGILL=4 SIGTRAP=5
SIGABRT=6 SIGBUS=7|10 SIGFPE=8 SIGKILL=9 SIGUSR1=10
SIGSEGV=11 SIGUSR2=12 SIGPIPE=13 SIGALRM=14 SIGTERM=15
2
3
(SIGBUS 在 Linux 是 7,在 macOS/BSD 是 10——遇到陌生平台先 kill -l 确认。)
# 13.5 思考题
程序启动时在多线程环境里递归调用
std::string构造,偶发崩溃在 libc 内部。coredump 里栈回溯看到的是malloc_consolidate。这是什么问题?如何 localize?你的服务收到 SIGTERM 时想做"优雅退出"——关闭连接、刷新日志、等待任务完成。为什么你的 handler 不能直接写
logger.info("shutdown")?正确做法是什么?一个进程 coredump 总是生成在
/var/lib/systemd/coredump/。怎么把它拿出来用 GDB 分析?coredumpctl怎么用?在 Docker 容器里跑的 C++ 服务崩溃了,但
docker logs里没任何输出,kubectl exec也没 core 文件。你怎么定位?你要给一个闭源的 C++ SDK 加上崩溃捕获(你不能改它的源代码)。有哪几种侵入方式?各自的权衡是什么?
如果主线二的案例在 Linux 上崩成 SIGSEGV 而不是 SIGBUS,你的排查路径会变吗?哪些步骤会变,哪些不变?
假如崩溃是 10% 概率偶发的,前述哪些工具仍然适用?哪些失效?怎么补?
如果把
new Employee*[n]改成new Employee*[n](),你能用 ASan 抓到同样的 bug 吗?为什么?假设你是 CI 负责人,要把本案例的教训系统化嵌入团队流程,你会加哪些检查?
有没有一类崩溃是本文方法论解决不了的?(提示:数据竞争、内存损坏在别处发生而在当前线程暴露)
调试不是寻找答案,而是不断缩小不确定性。 信号是第一手证据,工具是放大镜,方法论是地图,经验是直觉。四者缺一不可。
下一篇:本篇讲了"怎么从信号定位到行号",下一步进入 02.ASan内存诊断——把"内存类崩溃在第一现场暴露"这把武器讲透到 shadow memory 字节级。配套阅读:01.进程地址空间布局(栈溢出场景的内存模型基础)。