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

杨充

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

  • Cpp入门到精通

    • README
    • 入门教程

    • 综合案例

    • 专栏博客

    • 开发技巧

      • 信号崩溃快速排查
        • 1. 案例引入:两条主线
          • 1.1 主线一:dmesg 命案
          • 1.2 主线二:Bus error
          • 1.3 顺藤摸到根因
          • 1.4 本篇要回答什么
        • 2. 崩溃的九种原因
          • 2.1 内存访问违规
          • 2.2 栈溢出
          • 2.3 整数除零
          • 2.4 未处理异常
          • 2.5 断言失败
          • 2.6 系统资源耗尽
          • 2.7 多线程数据竞争
          • 2.8 非法指令
          • 2.9 主动终止
        • 3. 信号机制总图
          • 3.1 从硬件异常到信号
          • 3.2 信号是进程的"中断"
          • 3.3 同步信号与异步信号
          • 3.4 信号的生命周期
          • 3.5 崩溃流程全景图
        • 4. 五大崩溃信号详解
          • 4.1 SIGSEGV 段错误
          • 4.2 SIGBUS 总线错误
          • 4.3 SIGABRT 主动放弃
          • 4.4 SIGFPE 算术异常
          • 4.5 SIGILL 非法指令
          • 4.6 SEGV 与 BUS 之分
        • 5. si_code 解码
          • 5.1 siginfo_t 结构
          • 5.2 SEGV 的 si_code
          • 5.3 BUS 的 si_code
          • 5.4 FPE 的 si_code
          • 5.5 macOS 与 Linux 差异
        • 6. core dump 三步法
          • 6.1 打开 core dump
          • 6.2 找到 core 文件
          • 6.3 gdb 加载 core
          • 6.4 core 没生成的原因
        • 7. 现场分析关键命令
          • 7.1 bt full 调用栈
          • 7.2 info registers 寄存器
          • 7.3 disas 反汇编崩溃帧
          • 7.4 p $_siginfo 信号详情
          • 7.5 lldb 命令对照
          • 7.6 addr2line 与符号化
        • 8. 实时调试与捕获
          • 8.1 gdb --args 直接跑
          • 8.2 attach 已运行进程
          • 8.3 进程内 signal handler
          • 8.4 异步信号安全禁忌
          • 8.5 sigaltstack 备用栈
          • 8.6 DWARF 栈回溯
        • 9. Sanitizer 武器库
          • 9.1 ASan 内存红区
          • 9.2 shadow memory 原理
          • 9.3 UBSan 未定义行为
          • 9.4 TSan 数据竞争
          • 9.5 编译告警最便宜
        • 10. 五步排查方法论
          • 10.1 最小化复现 MCVE
          • 10.2 建立假设空间
          • 10.3 多路径证据交叉
          • 10.4 crash 不等于 bug
          • 10.5 修复论证与沉淀
        • 11. 典型场景速查
          • 11.1 空指针解引用
          • 11.2 栈溢出
          • 11.3 写只读段
          • 11.4 double-free / UAF
          • 11.5 整数除零
          • 11.6 mmap 文件被截断
          • 11.7 未对齐访问
        • 12. 进程终止与框架
          • 12.1 进程终止完整流程
          • 12.2 僵尸进程成因处理
          • 12.3 OOM 与崩溃区别
          • 12.4 生产级捕获框架
          • 12.5 崩溃处理成熟度
        • 13. 综合案例串讲
          • 13.1 案例真相揭晓
          • 13.2 一次崩溃的一生
          • 13.3 设计哲学回扣
          • 13.4 信号崩溃速查表
          • 13.5 思考题
      • ASan内存三件套
      • GDB十命令速查
      • CoreDump破案
      • perf火焰图实战
      • 迭代器失效陷阱
      • 智能指针选型
      • 异常安全RAII
      • 多线程锁选型
      • 编译期防御
  • Java入门精通

  • Go入门到精通

  • JavaScript入门

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

信号崩溃快速排查

# 第20章:信号崩溃快速排查

# 目录介绍

  • 1. 案例引入:两条主线
    • 1.1 主线一:dmesg 命案
    • 1.2 主线二:Bus error
    • 1.3 顺藤摸到根因
    • 1.4 本篇要回答什么
  • 2. 崩溃的九种原因
    • 2.1 内存访问违规
    • 2.2 栈溢出
    • 2.3 整数除零
    • 2.4 未处理异常
    • 2.5 断言失败
    • 2.6 系统资源耗尽
    • 2.7 多线程数据竞争
    • 2.8 非法指令
    • 2.9 主动终止
  • 3. 信号机制总图
    • 3.1 从硬件异常到信号
    • 3.2 信号是进程的"中断"
    • 3.3 同步信号与异步信号
    • 3.4 信号的生命周期
    • 3.5 崩溃流程全景图
  • 4. 五大崩溃信号详解
    • 4.1 SIGSEGV 段错误
    • 4.2 SIGBUS 总线错误
    • 4.3 SIGABRT 主动放弃
    • 4.4 SIGFPE 算术异常
    • 4.5 SIGILL 非法指令
    • 4.6 SEGV 与 BUS 之分
  • 5. si_code 解码
    • 5.1 siginfo_t 结构
    • 5.2 SEGV 的 si_code
    • 5.3 BUS 的 si_code
    • 5.4 FPE 的 si_code
    • 5.5 macOS 与 Linux 差异
  • 6. core dump 三步法
    • 6.1 打开 core dump
    • 6.2 找到 core 文件
    • 6.3 gdb 加载 core
    • 6.4 core 没生成的原因
  • 7. 现场分析关键命令
    • 7.1 bt full 调用栈
    • 7.2 info registers 寄存器
    • 7.3 disas 反汇编崩溃帧
    • 7.4 p $_siginfo 信号详情
    • 7.5 lldb 命令对照
    • 7.6 addr2line 与符号化
  • 8. 实时调试与捕获
    • 8.1 gdb --args 直接跑
    • 8.2 attach 已运行进程
    • 8.3 进程内 signal handler
    • 8.4 异步信号安全禁忌
    • 8.5 sigaltstack 备用栈
    • 8.6 DWARF 栈回溯
  • 9. Sanitizer 武器库
    • 9.1 ASan 内存红区
    • 9.2 shadow memory 原理
    • 9.3 UBSan 未定义行为
    • 9.4 TSan 数据竞争
    • 9.5 编译告警最便宜
  • 10. 五步排查方法论
    • 10.1 最小化复现 MCVE
    • 10.2 建立假设空间
    • 10.3 多路径证据交叉
    • 10.4 crash 不等于 bug
    • 10.5 修复论证与沉淀
  • 11. 典型场景速查
    • 11.1 空指针解引用
    • 11.2 栈溢出
    • 11.3 写只读段
    • 11.4 double-free / UAF
    • 11.5 整数除零
    • 11.6 mmap 文件被截断
    • 11.7 未对齐访问
  • 12. 进程终止与框架
    • 12.1 进程终止完整流程
    • 12.2 僵尸进程成因处理
    • 12.3 OOM 与崩溃区别
    • 12.4 生产级捕获框架
    • 12.5 崩溃处理成熟度
  • 13. 综合案例串讲
    • 13.1 案例真相揭晓
    • 13.2 一次崩溃的一生
    • 13.3 设计哲学回扣
    • 13.4 信号崩溃速查表
    • 13.5 思考题

# 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]
1

代码本身看起来"无懈可击"——一个回调链路上的转发函数:

// 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
        }
    }
}
1
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
1
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
1
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;
}
1
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
1
2
3
4

两个现象非常扎眼:

  • 第一行成功打印 —— 说明 arr[0] 是合法的;
  • 第二次访问就崩 —— 信号是 SIGBUS (10)。

好的调试,第一步永远是把问题简化到 MCVE。复杂工程里的 bug 无非三种:

  1. 逻辑 bug —— 能在简化案例里稳定复现(本案例);
  2. 交互 bug —— 只在组件组合时出现,但仍能用 2~3 个文件复现;
  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 章
1
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
1
2

底层原理:虚拟地址空间的前几页(通常 0x0~0xFFF)被 OS 标记为不可访问(Guard Page)。MMU 在地址翻译时发现该页不在页表中,触发 Page Fault(#PF)。内核检测到非法访问,向进程发送 SIGSEGV。

2) 野指针解引用:访问已释放或未初始化的指针:

int* ptr = new int(42);
delete ptr;
*ptr = 100;                 // 崩溃:Use-After-Free
1
2
3

底层原理:内存释放后,OS 可能已将该区域标记为不可访问。但如果内存被复用给其他对象,可能不会立即崩溃,而是产生更隐蔽的数据损坏——这是 UAF 漏洞的危险所在(也是主线一的根因)。

3) 缓冲区溢出:数组越界、字符串操作超出分配空间:

char buffer[10];
strcpy(buffer, "This string is way too long for the buffer!");
// 崩溃:栈帧被破坏,返回地址被覆盖
1
2
3

底层原理:写入超出分配边界,破坏相邻栈帧(包括保存的寄存器值和返回地址)。函数返回时,CPU 跳转到被破坏的地址,执行非法指令或访问非法内存。

4) 双重释放(Double Free):

int* ptr = new int(42);
delete ptr;
delete ptr;                 // 崩溃:双重释放
1
2
3

底层原理:内存管理器(如 glibc 的 ptmalloc)维护空闲链表。第一次 free 将块标记为空闲并放入链表。第二次 free 时该块已在链表中,再次插入会破坏链表结构。现代分配器通常能检测到 double-free 并调用 abort()。

# 2.2 栈溢出

1) 无限递归或过深递归:

void recursiveFunction() {
    char buffer[1024];      // 每次递归占用 1KB+ 栈空间
    recursiveFunction();    // 无限递归
}
1
2
3
4

底层原理:线程栈空间有限(Linux 默认 8MB,可通过 ulimit -s 查看)。每次函数调用都在栈上分配新栈帧(返回地址、保存的寄存器、局部变量)。当栈指针超出栈的 Guard Page 时,触发 SIGSEGV。

高地址
┌─────────────┐
│   栈底       │
│   ...        │
│   递归帧 N   │ ← 最新的递归调用
│   递归帧 N-1 │
│   ...        │
│   main 帧    │
├─────────────┤
│  Guard Page  │ ← 碰到这里就触发 SIGSEGV
├─────────────┤
│   堆/其他    │
低地址
1
2
3
4
5
6
7
8
9
10
11
12
13

2) 栈上分配过大的局部变量:

void bigStackAllocation() {
    int huge_array[10000000];   // 约 40MB,远超栈大小
    huge_array[0] = 1;          // 崩溃
}
1
2
3
4

# 2.3 整数除零

int a = 10, b = 0;
int c = a / b;              // 崩溃:整数除零
1
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
1
2
3
4

# 2.4 未处理异常

void throwingFunction() {
    throw std::runtime_error("something went wrong");
}

int main() {
    throwingFunction();     // 未捕获 → std::terminate() → abort() → SIGABRT
}
1
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(),不展开
}
1
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");
}
1
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";
1
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++;   // 非原子的"读-改-写"
}
1
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 自动按一致顺序加锁
1
2
3
4

# 2.8 非法指令

int data = 0x12345678;
void (*func_ptr)() = reinterpret_cast<void(*)()>(&data);
func_ptr();                 // SIGILL:数据被当作指令执行
1
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 注册的函数
1
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)
1
2
3
4
5
6

异常触发时,CPU 会:

  1. 停止当前指令执行
  2. 保存当前状态(指令指针、标志寄存器等)到栈
  3. 通过 IDT(中断描述符表)跳转到内核异常处理程序

内核的异常处理程序识别异常类型,将其映射为 POSIX 信号:

硬件异常              →    POSIX 信号
─────────────────────────────────────
Page Fault (非法)     →    SIGSEGV (11)
Divide Error          →    SIGFPE  (8)
Invalid Opcode        →    SIGILL  (4)
Bus Error             →    SIGBUS  (7/10)
1
2
3
4
5
6

内核通过 force_sig_info() 向目标进程发送信号,附带 siginfo_t:

  • si_signo:信号编号
  • si_code:详细的错误原因(如 SEGV_MAPERR、SEGV_ACCERR)
  • si_addr:触发异常的内存地址

# 3.2 信号是进程的"中断"

疑惑:程序好端端在跑,操作系统怎么有权"打断"它?

论证:

  1. CPU 执行非法操作时触发硬件异常(trap),控制权立刻交给内核。
  2. 内核根据异常类型决定处理方式;对用户态进程,最常见的是——给该进程投递一个信号。
  3. 信号本质是"给进程的一字节通知":每个进程在内核中维护一张 64 位的 pending 位图,每个 bit 代表一种信号。
  4. 进程下次回到用户态时(系统调用返回、中断返回、调度醒来),内核检查 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
1
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
                              └──────────────────┘
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21

几个坑要先记住:

  1. 同步信号不能阻塞:用 sigprocmask 屏蔽 SIGSEGV 是无效的——内核检测到"这是同步致命信号且被屏蔽"会强制恢复默认动作并终止进程。否则就出现"指令重新执行、再次缺页、再次屏蔽"的死循环。
  2. handler 内只能调用 async-signal-safe 函数:详见第 8.4 节。
  3. signal() 不可移植:用 sigaction(),signal() 在不同 libc 行为不一样。

# 3.5 崩溃流程全景图

用户空间                           内核空间                     硬件
─────────                        ─────────                    ─────
                                                              CPU 执行指令
                                                                 │
                                                              检测到异常 (如 #PF)
                                                                 │
                                  IDT 查表 ←─────────────────────┘
                                     │
                                  异常处理程序
                                     │
                                  映射为 POSIX 信号
                                     │
                                  发送信号给进程
                                     │
用户信号处理 ←───────────────────────┘
   │
   ├─ 有自定义 handler
   │  ├─ 记录崩溃信息
   │  ├─ 生成 minidump
   │  └─ 调用 abort()
   │
   └─ 无自定义 handler(默认)
      │
      └─ 终止进程 ────────────────→ 生成 core dump
                                        │
                                     释放资源
                                        │
                                   通知父进程 (SIGCHLD)
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

# 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)
1
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
1
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)
1
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 内核策略)
1
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
1
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)
1
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)
1
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 指令报错
1
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)
1
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;
    // ...
};
1
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 特性)
1
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)
1
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   // 机器检查异常 - 即将发生
1
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   // 下标越界(极少见)
1
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)
1
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 .
1
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
1
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/
1
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
1
2
3

加载时务必检查 3 件事:

(gdb) info files                       # 1. 二进制路径对不对
(gdb) info sharedlibrary               # 2. .so 是否都对上了,有没有 "Yes (*)" 的不一致
(gdb) show debug-file-directory        # 3. 调试符号去哪找
1
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
1
2
3
4
5
6
7

# 6.4 core 没生成的原因

生产环境遇到"崩溃但没 core 文件"的排查清单(按优先级):

  1. ulimit -c 0——用户栈的软限制未开(最常见)。
  2. core_pattern 指向了 | 管道(比如 systemd-coredump、abrt),core 被转交给系统服务,要从那里拿。
  3. 程序 setuid/setgid——出于安全考虑,默认禁止写 core(需要 fs.suid_dumpable=1)。
  4. core 文件路径所在文件系统空间不足或无写权限。
  5. 程序手动关闭了 core:setrlimit(RLIMIT_CORE, 0) 或 prctl(PR_SET_DUMPABLE, 0)。
  6. 容器里的 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    # 多线程:所有线程的栈
1
2
3
4
5
6
7
8
9
10
11
12
13

看栈三个判断:

  1. 栈是不是被踩烂了:如果 bt 显示一堆 ?? 或地址完全离谱(如 0x4141...),说明返回地址被覆盖——栈缓冲区溢出。
  2. 死循环 / 递归:栈底反复出现同一个函数 → 递归没出口 / 栈溢出。
  3. 跨线程死锁: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     ← 栈顶
...
1
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)       ← 调虚函数
1
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    ← 出错地址
    }
  }
}
1
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
1
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!
1
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 获取)
1
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
1
2
3
4

# 8. 实时调试与捕获

事后 core 不够,还有两类场景需要"实时抓现场":bug 能稳定复现 / 生产环境无法 dump。

# 8.1 gdb --args 直接跑

gdb --args ./app --config=prod.yaml --port 8080
(gdb) run                    # 跑到崩溃自动停
(gdb) bt
1
2
3

在 gdb 里设崩溃断点:

(gdb) catch signal SIGSEGV   # 任何 SIGSEGV 都断下来
(gdb) catch throw            # C++ 抛异常时断下(找未捕获异常根源)
(gdb) catch syscall execve   # 跟踪某个系统调用
1
2
3

# 8.2 attach 已运行进程

gdb -p <PID>
# 进 gdb 后 c 继续运行;崩溃时自动停下
1
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);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
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);   // 推荐
1
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();
    // ...
}
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

# 8.4 异步信号安全禁忌

信号是异步中断:它可以在程序执行的任意指令之间插入。如果主程序正好在 malloc 内部(持有了 malloc 的全局锁),信号处理器又调用 malloc——死锁。如果主程序正在写 stdout 缓冲区中间,信号处理器又写 stdout——缓冲区损坏。

POSIX 规定了一份 async-signal-safe 函数清单(man 7 signal-safety),只有这些能在信号处理器里调用:

  • write / read / _exit(不是 exit)
  • signal / sigaction / kill / raise
  • getpid / getppid
  • alarm / nanosleep
  • 绝大多数不加锁的系统调用

绝对禁止的:

  • printf / fprintf / std::cout(持有 stdio 锁)
  • malloc / free / new / delete(持有 malloc 锁)
  • pthread_mutex_lock(可能死锁)
  • C++ 异常(展开不可重入)
  • 几乎所有 C++ 标准库函数

handler 里的 4 条铁律:

  1. 只调 async-signal-safe 函数:write/read/_exit/raise/signal/sigaction 是;malloc/printf/fopen/std::string 不是。
  2. 用 SA_ONSTACK + sigaltstack:给 handler 单独的栈,否则栈溢出导致的崩溃会让 handler 也死掉。
  3. SA_RESETHAND:handler 跑完恢复默认,避免无限递归崩溃。
  4. 最后 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
}
1
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);
1
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)│
│   局部变量       │
├─────────────────┤
│   ...           │
低地址
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 链
}
1
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_* 函数)做这几件事:

  1. 从当前 PC 查找 .eh_frame_hdr(按地址排序的索引),二分找到对应的 FDE。
  2. 解析 FDE 的指令流,构建出当前 PC 的寄存器恢复规则。
  3. 按规则读取栈上保存的寄存器,特别是返回地址和上一帧的 CFA。
  4. 把寄存器值恢复到"上一帧的视角"(相当于"时光倒流一层")。
  5. 对新的 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
1
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           ← 分配点
1
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
1
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
1
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   // 正常访问
1
2
3
4
5
真实内存:    | malloc 块 (16B) | 红区 (16B) | malloc 块 (32B) | 红区...
影子内存:    | 0  0           | 中毒 0xFA   | 0  0  0  0      | 0xFA   ...
                                  ↑
                          访问这里 → ASan 立刻报错并打栈
1
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
1

抓的是"语言层"的 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
1
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
1

抓的是多线程场景下的 data race:

int counter = 0;
void worker() { counter++; }   // 多线程同时跑 → TSan 报 data race
1
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]
1

但大多数人习惯忽略 warning。treat warnings as errors(-Werror),是成本最低、收益最高的调试设置之一。

调试编译三件套:

g++ -g -O0 -Wall -Wextra crash.cpp -o crash
1
选项 作用 不加的后果
-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 用例        │
  └─────────────────────────────────────────┘
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24

# 10.1 最小化复现 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          ← 然后崩了
1
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"};        // ← 补上
1
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";
}
1
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";
}
1
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 所有问题 长期代码

论证修复有效:跑一遍没崩 ≠ 修好了。至少需要:

  1. 路径覆盖:让原来的崩溃路径再走一遍;
  2. 边界覆盖:让周边路径(边界值)也走一遍;
  3. 机制覆盖:用工具(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 会抓
}
1
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          │
└────────────────────────────────────────────┘
1
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
}
1
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); }
1

特征:SIGSEGV + SEGV_MAPERR + si_addr 在栈底守护页(看 pmap 找 ---p 段)。bt 显示同一函数反复出现,深度上千。

第一刀:

(gdb) bt | head -20
(gdb) p $rsp
(gdb) info proc mappings    # 找 [stack] 与守护页
1
2
3

修法:

  • 调大栈:ulimit -s 65536(治标)
  • 改递归为迭代 + 显式栈(治本)
  • 大局部数组改堆:std::vector<char> buf(8192)

# 11.3 写只读段

char* s = (char*)"hello";
s[0] = 'H';                   // .rodata 不可写
1
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;
1
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
1
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
1

特征: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)
1
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
1
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 释放的全过程:

  1. CPU 触发异常 → 内核收到 #PF (page fault);
  2. 内核查页表,发现是非法访问 → 向进程发 SIGSEGV;
  3. 进程未注册 SIGSEGV 处理器 → 内核执行默认动作:terminate with core dump;
  4. 内核在 /proc/sys/kernel/core_pattern 指定路径写 coredump(受 ulimit -c 限制);
  5. 调用每个线程的 exit handler、释放 fd 和内存、关闭文件句柄;
  6. 把退出码、信号号、rusage 写入 task_struct;
  7. 向父进程发 SIGCHLD;
  8. 进程进入 zombie 状态——task_struct 还在,但资源已释放;
  9. 父进程 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");
    }
}
1
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 │  上传到服务器   │
│              │     │  符号化处理     │
└──────────────┘     └──────────────┘
1
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();
        }
    }
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
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;     // ← 关键
        }
    }
}
1
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
}
1
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 节
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>
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

信号 → 第一查询条目:

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
1
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
1
2
3

(SIGBUS 在 Linux 是 7,在 macOS/BSD 是 10——遇到陌生平台先 kill -l 确认。)

# 13.5 思考题

  1. 程序启动时在多线程环境里递归调用 std::string 构造,偶发崩溃在 libc 内部。coredump 里栈回溯看到的是 malloc_consolidate。这是什么问题?如何 localize?

  2. 你的服务收到 SIGTERM 时想做"优雅退出"——关闭连接、刷新日志、等待任务完成。为什么你的 handler 不能直接写 logger.info("shutdown")?正确做法是什么?

  3. 一个进程 coredump 总是生成在 /var/lib/systemd/coredump/。怎么把它拿出来用 GDB 分析?coredumpctl 怎么用?

  4. 在 Docker 容器里跑的 C++ 服务崩溃了,但 docker logs 里没任何输出,kubectl exec 也没 core 文件。你怎么定位?

  5. 你要给一个闭源的 C++ SDK 加上崩溃捕获(你不能改它的源代码)。有哪几种侵入方式?各自的权衡是什么?

  6. 如果主线二的案例在 Linux 上崩成 SIGSEGV 而不是 SIGBUS,你的排查路径会变吗?哪些步骤会变,哪些不变?

  7. 假如崩溃是 10% 概率偶发的,前述哪些工具仍然适用?哪些失效?怎么补?

  8. 如果把 new Employee*[n] 改成 new Employee*[n](),你能用 ASan 抓到同样的 bug 吗?为什么?

  9. 假设你是 CI 负责人,要把本案例的教训系统化嵌入团队流程,你会加哪些检查?

  10. 有没有一类崩溃是本文方法论解决不了的?(提示:数据竞争、内存损坏在别处发生而在当前线程暴露)


调试不是寻找答案,而是不断缩小不确定性。 信号是第一手证据,工具是放大镜,方法论是地图,经验是直觉。四者缺一不可。

下一篇:本篇讲了"怎么从信号定位到行号",下一步进入 02.ASan内存诊断——把"内存类崩溃在第一现场暴露"这把武器讲透到 shadow memory 字节级。配套阅读:01.进程地址空间布局(栈溢出场景的内存模型基础)。

上次更新: 2026/06/16, 18:05:07
写作模板
ASan内存三件套

← 写作模板 ASan内存三件套→

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