程序崩了不可怕,可怕的是崩了之后什么都没留下。

线上环境排查崩溃,核心就一个问题:崩溃现场能不能保住? 这篇文章聊两种主流方案 —— Google Breakpad 和 Kernel Coredump,它们各自怎么干活、什么时候会掉链子、该怎么选。


Breakpad:在进程咽气前抢救现场

Breakpad 的思路很直接:程序崩溃时内核会发信号(SIGSEGV、SIGABRT 等),Breakpad 提前注册了信号处理函数,在进程真正死掉之前,抓紧把调用栈、寄存器、关键内存区域写进一个 minidump 文件。

整个过程大致是这样的:

程序启动 → 注册信号处理函数
    ↓
程序崩溃 → 内核发送信号(如 SIGSEGV)
    ↓
Breakpad 信号处理函数接管
    ├─ 进程暂停但尚未终止,内存状态还在
    ├─ 遍历调用栈、收集寄存器状态
    └─ 写入 .dmp 文件(通常只有几 MB)
    ↓
恢复默认信号处理,重新 raise 信号 → 进程退出

一个最小的接入示例:

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

bool DumpCallback(const google_breakpad::MinidumpDescriptor& descriptor,
                  void* context, bool succeeded) {
    // 注意:这里只能用 async-signal-safe 的函数
    const char msg[] = "Crash dump saved\n";
    write(STDERR_FILENO, msg, sizeof(msg) - 1);
    return succeeded;
}

int main() {
    google_breakpad::MinidumpDescriptor descriptor("/var/crash");
    google_breakpad::ExceptionHandler eh(descriptor, NULL,
                                          DumpCallback, NULL,
                                          true, -1);
    // 你的业务代码
    run_application();
    return 0;
}

Breakpad 的优点很明显:dump 文件小(几 MB vs. 动辄 GB 的 core 文件)、跨平台、可以过滤敏感数据、方便离线分析。但它有一个本质弱点 —— 它是在”患者”体内做手术


Breakpad 靠不住的时候

既然 Breakpad 跑在用户态、依赖进程自身的环境,那进程状态一旦烂透了,它也就无能为力了。常见的翻车场景:

栈溢出 — 信号处理函数也需要栈空间来执行。栈都爆了,处理函数根本跑不起来。可以通过 sigaltstack 设置备用栈来缓解:

stack_t ss;
ss.ss_sp = malloc(SIGSTKSZ);
ss.ss_size = SIGSTKSZ;
ss.ss_flags = 0;
sigaltstack(&ss, NULL);

回调中二次崩溃 — 如果 DumpCallback 里不小心触发了新的段错误,系统不允许嵌套处理同一信号,进程直接死亡,dump 文件可能写到一半就废了。所以回调里绝对不能用 mallocprintfstd::string 这些东西,只能用 writeopenclose_exit 这类 async-signal-safe 的函数。

SIGKILL / OOM Killerkill -9 和 OOM Killer 发出的是 SIGKILL,压根不经过信号处理函数,Breakpad 连执行的机会都没有。

内存严重损坏 — double free 破坏了堆的元数据、buffer overflow 覆盖了栈帧链表… 这些情况下 Breakpad 遍历调用栈时拿到的数据本身就是错的,生成的 dump 要么不完整,要么有误导性。

多线程竞态 — 多个线程同时崩溃,竞争写 minidump,可能产出损坏的文件。

还有一些边缘情况:dump 目录没权限、磁盘满了、容器沙箱禁用了某些系统调用… 总之,Breakpad 需要一个”还算健康”的进程环境,才能完成它的工作。


Kernel Coredump:从外面给进程拍遗照

Kernel Coredump 的思路完全不同:内核捕获到异常后,自己从外部读取进程的整个内存空间,写成 core 文件。进程的代码完全不参与这个过程。

用前面的比喻来说:

Breakpad 是让溺水者自己写遗书 —— 如果他已经失去意识,就没戏了。

Kernel Coredump 是法医做尸检 —— 不管遗体什么状态,都能检查。

这解释了为什么 Kernel Coredump 在很多极端场景下更可靠:

  • 栈溢出? 内核跑在自己的内核栈上,用户栈爆了不影响它。
  • 内存损坏? 内核只是遍历进程的虚拟内存区域(VMA)逐页 dump,不关心堆的元数据是不是完好。遇到损坏的页面,copy_from_user 返回错误,跳过继续。
  • 二次崩溃? 不存在的。内核代码跑在保护模式下,和用户进程的状态完全隔离。
  • 权限? 内核以最高权限运行,不受 ulimit、文件权限的限制。
// 内核 do_coredump 的核心逻辑(极度简化)
do_coredump() {
    // 跑在内核栈上,不依赖用户栈
    struct mm_struct *mm = current->mm;

    // 遍历所有虚拟内存区域,逐页 dump
    for (vma = mm->mmap; vma; vma = vma->vm_next) {
        dump_range(vma->vm_start, vma->vm_end);
    }
    // 就是给整个内存空间"拍照"
}

但 Kernel Coredump 也有自己的硬伤:

问题 说明
文件巨大 进程用了 10GB 内存,core 文件就是 10GB
生成慢 写一个大 core 文件可能要几十秒
隐私风险 完整内存 dump,strings core \| grep password 就能翻出敏感数据
分析不便 需要原始可执行文件 + 调试符号,还得在同架构机器上用 gdb

而 SIGKILL 这种信号,Kernel Coredump 同样无法处理 —— 这是设计如此,SIGKILL 的语义就是”立即终止,不留后路”。


两者对比一览

维度 Breakpad Kernel Coredump
执行环境 用户态,进程内 内核态,进程外
栈溢出 通常失败(除非配了备用栈) 正常工作
内存损坏 可能失败或产出错误数据 不受影响
二次崩溃 致命 不存在此问题
SIGKILL 无法捕获 同样无法处理
文件大小 几 MB 可达 GB 级别
生成速度
跨平台 支持 平台相关
隐私控制 可过滤敏感数据 全量 dump
离线分析 方便,有专用工具链 需要匹配环境

退出码:读懂进程的死因

当进程被信号终止时,退出码 = 128 + 信号编号。这个规则很实用,直接从退出码就能判断死因:

退出码 信号 含义 Breakpad 能捕获?
132 SIGILL 非法指令
133 SIGTRAP 断点陷阱
134 SIGABRT 主动 abort
135 SIGBUS 总线错误
136 SIGFPE 浮点异常
137 SIGKILL 强制终止 不能
139 SIGSEGV 段错误

Breakpad 在生成 minidump 后,会恢复默认信号处理再 raise(sig),让进程以原始信号退出——所以外部看到的退出码和没装 Breakpad 时是一样的,监控脚本不用做特殊适配。

一个简单的监控示例:

#!/bin/bash
./my_program
EXIT_CODE=$?

case $EXIT_CODE in
    0)   echo "正常退出" ;;
    134) echo "SIGABRT - 检查 assert 或主动 abort" ;;
    137) echo "SIGKILL - 被强杀,无 minidump" ;;
    139) echo "SIGSEGV - 段错误,查看 minidump" ;;
    *)
        if [ $EXIT_CODE -ge 128 ]; then
            echo "被信号 $((EXIT_CODE - 128)) 终止"
        else
            echo "异常退出,退出码:$EXIT_CODE"
        fi ;;
esac

生产环境怎么选?

大多数情况下,Breakpad 是首选。 文件小、传输快、隐私可控,适合线上大规模部署。

但最稳妥的做法是两者都开:Breakpad 作为主力生成小巧的 minidump 用于快速分析,Kernel Coredump 作为兜底——Breakpad 翻车的时候,至少还有 core 文件可以捞。

int main() {
    // 兜底:启用 kernel coredump
    struct rlimit rl = { RLIM_INFINITY, RLIM_INFINITY };
    setrlimit(RLIMIT_CORE, &rl);

    // 主力:初始化 Breakpad
    setup_alternate_stack();  // 别忘了备用栈
    google_breakpad::MinidumpDescriptor descriptor("/var/crash");
    google_breakpad::ExceptionHandler eh(descriptor, NULL,
                                          DumpCallback, NULL,
                                          true, -1);

    return run_application();
}

配套的运维工作也别忘了:

# dump 目录权限
mkdir -p /var/crash && chown myapp:myapp /var/crash

# kernel coredump 路径配置
echo "/var/crash/core.%e.%p.%t" | sudo tee /proc/sys/kernel/core_pattern

# 自动清理(只保留 7 天)
find /var/crash -name "*.dmp" -mtime +7 -delete

# 构建时生成符号文件,供离线分析
dump_syms ./myapp > myapp.sym

最后一条建议:定期测试 dump 功能。 不要等到线上真崩了才发现 dump 目录没权限、磁盘满了、符号文件没更新。用 eh.WriteMinidump() 主动触发一次,确认整条链路是通的。


References