线上一个 C++ 常驻服务在凌晨两点突然消失,日志停在半截,gdb 连不上现场,core dump 又因为容器里 ulimit 是 0、core_pattern 指向了别处而没留下来——这种局面我这几年遇到过不止一次。后来这套系统里所有核心模块都接了breakpad,再出崩溃,拿到的是一份几百 KB 的 minidump 文件,配上符号表,堆栈能还原到函数名加行号,定位时间从半天压到十几分钟。这篇就聊透Linux 系统下 breakpad 的安装与使用:它是什么、解决什么问题、从源码编译到接进业务代码、再到符号管理和堆栈还原的完整链路,中间哪些参数必须这么填、哪些坑我实际踩过。适合手上管着 C/C++ 服务、正在被线上崩溃折磨的后端和客户端同学,也适合刚接触崩溃分析、想搞明白 core dump 之外还有哪条路的人。
1. Linux 下程序崩溃定位,为什么最后都落到 Breakpad 上
1.1 core dump 在最需要的时候往往掉链子
先说清楚为什么不用 core dump 就完事了。core dump 本身没问题,问题是它的前提条件太多:进程的RLIMIT_CORE得放开,/proc/sys/kernel/core_pattern得指向一个可写路径,磁盘得留得下动辄几个 G 的完整内存镜像,而且容器化部署之后这几条经常同时不成立。更麻烦的是,core 文件天生和"现场机器"绑死,用户侧崩了、客户现场崩了,你根本拿不到那个文件。
我印象最深的一次是一个跑在边缘设备上的采集程序,内存占用 1.8G,core dump 打出来 1.7G,光是压缩上传就要十几分钟,设备的存储还是 eMMC,写这么一大坨直接把寿命干掉了。而真正需要的信息其实只有崩溃那一刻的寄存器状态、各个线程的调用栈、加载了哪些模块。Breakpad 的 minidump 格式恰好就是只抓这些,同一个进程打出来的 dmp 通常只有几百 KB 到 2MB,压缩之后几十 KB,邮件附件都能带走。
这个差异不是"优化",是定位思路的转变:core dump 是给调试器用的完整快照,minidump 是给"跨机器分析"用的精简证据包。前者适合本地复现,后者适合线上收集。
1.2 Breakpad 到底做了哪三件事
很多人第一次接触 breakpad 会被它的目录结构搞晕,其实它干的事情非常收敛,就三件:
第一件,在进程内装一个崩溃处理器。ExceptionHandler初始化时会把SIGSEGV、SIGABRT、SIGFPE、SIGILL、SIGBUS、SIGTRAP这些信号接管过来,同时装好一个进程内异常处理器,覆盖 C++ 抛出的未捕获异常。信号来了之后,它先把其他线程全部挂起,保证内存状态不再变化,然后开始写 minidump。
第二件,生成 minidump 文件。这个文件是微软定义的一套格式,里面记了系统信息、模块列表(每个模块的路径、大小、Build ID)、线程列表(每个线程的栈内存和寄存器上下文)、以及异常信息。注意关键点:它记的是"模块 ID + 栈上的绝对地址",不含任何符号。
第三件,离线把地址翻译成符号。这就是dump_syms和minidump_stackwalk两个工具的活儿。前者从带调试信息的二进制里抽出符号表,生成.sym文本文件;后者读 minidump,按模块 ID 找到对应的.sym,把栈上的地址逐个还原成函数名 + 文件:行号。
这个"生成"和"解析"分离的设计是 breakpad 最聪明的地方。被打包发出去的程序可以完全不带符号(体积小、不泄露内部结构),符号表留在构建服务器上,两边靠 Build ID 对上。没有这个设计,你不可能在用户设备上跑崩溃收集。
1.3 和 core dump、backtrace、Crashpad 的横向对比
光讲 breakpad 好不够,得知道什么时候不该用它。下面这张表是我自己选型时整理的,横着比完基本就有结论了。
| 方案 | 能拿到什么 | 体积 | 线上可用性 | 典型短板 |
|---|---|---|---|---|
| core dump | 完整内存镜像 | 几百 MB 到几 GB | 差,容器里常拿不到 | 体积大、依赖现场环境、跨机器符号对不上 |
backtrace()+addr2line | 当前线程栈地址 | 极小 | 仅本地调试 | 只覆盖当前线程,异步信号里不安全,优化后栈不可靠 |
| Breakpad | 全线程栈、寄存器、模块列表 | 几百 KB 到 2 MB | 好,跨机器可用 | 需要额外管理符号,不支持动态语言栈 |
| Crashpad | 同 Breakpad,格式兼容 | 类似 | 更好,独立进程 | 集成复杂度更高,构建链更重 |
backtrace()这条路我早期用过,问题在于它是在信号处理函数里跑的,而backtrace()内部可能调用动态内存分配,本身就是异步信号不安全的,线上偶发死锁。Breakpad 为了规避这类问题,写 dump 的动作做得非常克制,只做内存拷贝和write,这点在排查"崩溃处理器自己又崩了"的时候特别重要。
至于 Crashpad,它是 breakpad 的后继方案,核心改进是把写 dump 的动作放到一个独立的监控进程里,主进程只负责通过管道通知。健壮性确实更好,但代价是你得同时管理两个进程的生命周期,构建链也要多编一个可执行文件。我目前的取舍是:单进程的小工具、嵌入式场景继续用 breakpad,够用且构建简单;多进程的大型应用再考虑 Crashpad。这篇文章只讲 breakpad,把一条路走通比同时铺两条强。
2. 源码编译与环境准备:把 Breakpad 装干净
2.1 依赖清单与系统层面的准备
Breakpad 在 Linux 上的依赖其实很轻,但有几个是隐性的,缺了会在 configure 阶段报一些看不懂的错。基础工具链是gcc、g++、make,如果是直接从版本库拉源码而不是下 release 包,还需要autoconf、automake、libtool,因为要跑autogen.sh生成 configure 脚本。
Debian/Ubuntu 系一条命令能搞定:
sudo apt-get update sudo apt-get install -y build-essential autoconf automake libtool \ pkg-config zlib1g-devRHEL/CentOS 系对应的是:
sudo yum groupinstall -y "Development Tools" sudo yum install -y autoconf automake libtool zlib-develzlib是可选的,但minidump_stackwalk处理压缩过的 dump 时会用到,装上省事。另外我强烈建议不要用系统包管理器装 breakpad,绝大多数发行版的仓库里没有,个别有的版本也很老,ExceptionHandler的构造函数签名还停留在老接口上,网上抄的代码编译不过,白白浪费时间。统一走源码编译,版本自己控。
还有一个容易被忽略的系统层问题:编译产物如果想装到/usr/local之外的路径,--prefix要提前规划好。我习惯装到$HOME/breakpad或者项目的third_party/breakpad-install下,这样多版本共存、随时删掉重来都不影响系统环境,团队里每个人机器上的路径也一致。
2.2 从源码到 make install 的完整流程
拿到源码之后,整个流程分五步,我按顺序写:
# 1. 进源码目录,先看有没有 configure cd breakpad ls -l configure # 2. 没有 configure 就先跑 autogen.sh 生成 ./autogen.sh # 3. 配置安装路径,关掉不需要的组件 ./configure --prefix=$HOME/breakpad \ --disable-dependency-tracking \ --disable-silent-rules # 4. 编译,核数按机器来 make -j$(nproc) # 5. 安装 make install--disable-dependency-tracking这个参数看着无关紧要,但实际上能明显加快增量编译,尤其是你改了源码里某个头文件之后,不关掉会把整个依赖树重编一遍。--disable-silent-rules是为了让编译日志完整输出,出问题时能直接看到具体是哪条命令挂了,而不是只看到一个红叉。
make -j$(nproc)在内存 8G 以下的机器上建议改成-j4,breakpad 会编出很多中间产物,并行太高容易 OOM 被杀,而 make 被杀的时候报错信息很不友好,只显示Killed,新手容易误以为是代码问题。
装完之后验证一下:
ls $HOME/breakpad/bin # dump_syms minidump-2-core minidump_dump minidump_stackwalk core2md ls $HOME/breakpad/lib # libbreakpad_client.abin下应该有五个左右的工具,lib下有一个静态库libbreakpad_client.a。如果你只看到.la文件没看到.a,通常是libtool没装全,回去补上重来。
2.3 编译参数怎么定:-g、-O2、-fPIC 的取舍
这是我最想强调的一节,因为符号能不能还原,八成取决于这里的参数。
第一,业务代码必须带-g。没有调试信息,dump_syms抽出来的.sym里就只有函数名没有行号,堆栈只能看到一串函数,看不到具体是哪一行崩的。-g和优化并不冲突,-O2 -g是完全正常的组合,别被"开了优化就不能调试"这种说法吓到。真正需要注意的是优化会内联、会重排指令,导致某些栈帧看起来跳来跳去,这是正常的,不是符号错了。
第二,绝对不要在发布前strip掉原始文件。正确顺序是:编译出带-g的完整二进制 → 用dump_syms抽符号 → 再strip出发布版。顺序反了,符号就永久丢了,只能重新构建,而重新构建出来的 Build ID 又变了,对不上历史 dump。这个坑我见过有人踩,一个季度的崩溃数据全部作废。
第三,如果要链接进共享库,breakpad 本身需要用-fPIC编译。默认的./configure在 x86_64 上一般会自动加,但如果你的项目把它编进.so,链接时出现relocation R_X86_64_32S against ... can not be used when making a shared object这类错误,就回来加参数重编:
./configure --prefix=$HOME/breakpad CXXFLAGS="-fPIC -O2 -g"第四,Build ID 要确保生成。Breakpad 用 ELF 的 Build ID 作为模块标识,GCC 默认会生成,但如果你在链接脚本里加了-Wl,--build-id=none,或者某些嵌入式工具链默认关掉,minidump 里的模块 ID 就会退化成代码段的校验和,稳定性差很多。检查方式是:
readelf -n ./your_program | grep -A1 "Build ID"能打出一串 40 位十六进制就说明没问题。打不出来就得在链接参数里补-Wl,--build-id=sha1。
2.4 编译期常见报错与处理
有几个报错是高频出现的,我列出来省得你一个个搜。
报错一:configure: error: cannot find install-sh, install.sh, or install.sh.in
原因是从版本库拉的源码里没有预生成的辅助脚本。解决:先跑./autogen.sh,再./configure。如果autogen.sh本身报aclocal: command not found,说明automake没装。
报错二:undefined reference to 'pthread_create'
Breakpad 客户端库内部用了线程,链接顺序上-lpthread必须放在-lbreakpad_client后面。写 Makefile 的时候记住一条经验:被依赖的库永远放右边。
报错三:error: 'MinidumpDescriptor' has not been declared
这是典型的版本错配。老版本 breakpad 的ExceptionHandler构造函数直接收一个路径字符串,新版本收MinidumpDescriptor对象。网上大量教程是老版本的。判断办法是看头文件:
grep -n "ExceptionHandler(" $HOME/breakpad/include/breakpad/client/linux/handler/exception_handler.h看到签名里有MinidumpDescriptor就走新接口,代码按新接口写,别硬套老示例。
报错四:third_party/lss/linux_syscall_support.h里一堆汇编错误
这个头文件是 breakpad 用来直接发起系统调用的,在非常规架构(比如某些 MIPS 变种、RISC-V 早期工具链)上可能不支持。解决办法是在./configure时加--disable-lss,但这样会损失一部分崩溃安全性,属于下策。
3. 把 handler 接进业务代码:最小可用示例
3.1 初始化 ExceptionHandler 的正确姿势
先给一份能跑的最小完整代码,你复制过去改路径就能用:
// crash_demo.cc #include <cstdio> #include <sys/stat.h> #include "client/linux/handler/exception_handler.h" #include "client/linux/handler/minidump_descriptor.h" static bool DumpCallback(const google_breakpad::MinidumpDescriptor& descriptor, void* context, bool succeeded) { fprintf(stderr, "[breakpad] dump written: %s, ok=%d\n", descriptor.path(), succeeded); return succeeded; } int main(int argc, char** argv) { const char* dir = (argc > 1) ? argv[1] : "/tmp/crashdumps"; mkdir(dir, 0755); google_breakpad::MinidumpDescriptor descriptor(dir); google_breakpad::ExceptionHandler handler( descriptor, nullptr, // filter DumpCallback, // dump 回调 nullptr, // callback context true, // install_handler:是否接管信号 -1); // server_fd:-1 表示进程内写盘 fprintf(stderr, "[breakpad] handler installed, dir=%s\n", dir); volatile int* p = nullptr; *p = 42; return 0; }编译命令:
g++ -std=c++11 -O2 -g crash_demo.cc \ -I$HOME/breakpad/include/breakpad \ -L$HOME/breakpad/lib \ -lbreakpad_client -lpthread \ -o crash_demo跑起来会看到:
[breakpad] handler installed, dir=/tmp/crashdumps [...] [breakpad] dump written: /tmp/crashdumps/5f8c1a2b-1d3e-4c5a-9b7f-2e6d8a0c4f11.dmp, ok=1 Segmentation fault注意install_handler这个参数,传true表示接管信号,进程崩溃时会先打 dump 再退出;传false就只是把 handler 对象建起来,你可以手动调handler.WriteMinidump()主动写一份。后者在"我怀疑这里有问题,想留个现场"的场景下挺好用。
server_fd传-1是最省事的进程内模式,好处是配置简单。它有个理论上的风险:崩溃发生时内存可能已经被踩坏,写 dump 的过程本身可能再次出错。Breakpad 内部对此做了不少防护,实际生产中我用了几年没遇到过写不出来的情况,所以默认就选-1。真要做极致健壮,可以走 out-of-process 模式,用一个独立进程通过 socket 接收请求,但那样要处理进程守护、socket 传递,复杂度上一个台阶,一般项目没必要。
3.2 dump 回调里能干什么、不能干什么
DumpCallback是在 dump 写完之后被调用的,签名里带了文件路径和是否成功。新手最容易在这里犯错。
能做的:打印日志、记录路径、把文件路径写到一个固定的清单文件里、调用rename把它挪到指定目录。这些都只涉及简单的系统调用,相对安全。
不能做的:分配堆内存、加锁、调用 C++ 容器、调用任何可能阻塞的库函数。这会儿进程刚经历了一次致命崩溃,堆的状态大概率已经被破坏了,你再new一个std::string,很可能直接二次崩溃,而且这次连 dump 都留不下来,因为 breakpad 只会往同一个文件写一次。
我自己的做法是回调里只做一件事——把一个固定长度的路径字符串memcpy到一个事先预分配的全局缓冲区里,然后置一个标志位。真正的上报动作交给一个独立的上报进程或者下次启动时的守护逻辑。这样回调的代码路径极短,几乎没有出错空间。
还有一个细节:succeeded为false时不要忽略,这通常意味着目录不可写、磁盘满了,或者mkdir权限不对。上线前在目标环境里手动触发一次崩溃验证一遍,比出事了才发现强得多。
3.3 怎么把版本号、业务标识塞进 minidump
minidump 本身只记录系统级信息,但排查问题的时候你还需要知道"是哪个版本、哪个用户、走了哪条业务分支崩的"。Breakpad 在 Linux 上有两套办法。
办法一:RegisterAppMemory。较新版本的ExceptionHandler提供了这个接口,可以把一段应用内存挂进 minidump 的自定义流里:
static char g_build_info[256] = "version=1.4.2;channel=beta;"; // 初始化之后调用 handler.RegisterAppMemory(g_build_info, sizeof(g_build_info));用minidump_dump可以看到这段内存被记在了MD_LINUX_开头的流里,内容是十六进制加 ASCII,肉眼能读。
办法二:文件名编码。这是最土也最稳的办法。MinidumpDescriptor允许你指定完整文件名,直接把关键信息拼进去:
std::string name = std::string("v1.4.2_") + channel + "_" + userId + ".dmp"; google_breakpad::MinidumpDescriptor descriptor(dir, name);注意文件名里的字符要预先过滤,别把/之类的路径分隔符拼进去,会导致写文件失败。
办法三:旁挂日志。崩溃前把上下文写到一个固定路径的文本文件里,dump 和它配对上传。这个办法的好处是信息量不受限制,可以塞任意长的 JSON,坏处是两份文件要保证同步,分析时得多一步对账。
我的项目里用的是办法二加办法三:文件名带版本和渠道,方便批量归类;同时写一个xxx.dmp.meta的伴随文件存详细上下文。分析脚本按文件名前缀匹配,简单粗暴但很可靠。
3.4 Makefile / CMake 集成写法
真正接进项目的时候不可能手敲 g++,得写成构建脚本。CMake 版本大概长这样:
set(BREAKPAD_ROOT "$ENV{HOME}/breakpad") add_library(breakpad_client STATIC IMPORTED) set_target_properties(breakpad_client PROPERTIES IMPORTED_LOCATION "${BREAKPAD_ROOT}/lib/libbreakpad_client.a" INTERFACE_INCLUDE_DIRECTORIES "${BREAKPAD_ROOT}/include/breakpad") add_executable(your_app main.cc crash_handler.cc) target_link_libraries(your_app PRIVATE breakpad_client pthread) target_compile_options(your_app PRIVATE -g -O2)关键点是INTERFACE_INCLUDE_DIRECTORIES要指向include/breakpad这一层,因为源码里的#include路径是从client/linux/...开始的,多一层少一层都编译不过。
Makefile 版本就更直白:
BREAKPAD_ROOT = $(HOME)/breakpad CXXFLAGS += -g -O2 -I$(BREAKPAD_ROOT)/include/breakpad -std=c++11 LDFLAGS += -L$(BREAKPAD_ROOT)/lib LDLIBS += -lbreakpad_client -lpthread your_app: main.o crash_handler.o $(CXX) -o $@ $^ $(LDFLAGS) $(LDLIBS)注意LDLIBS里的顺序,-lbreakpad_client必须在-lpthread前面,前面说过这条规律。
4. 符号文件:90% 的分析失败都出在这一步
4.1 dump_syms 干了什么
很多人以为dump_syms是"把二进制转成符号表",更准确的说法是:它从带调试信息的 ELF 文件里,把函数符号、源文件路径、行号映射,整理成一个纯文本的.sym文件。这个文件的第一行是模块标识,后面的每一行是一个函数或者一条地址到行的映射。
运行方式:
$HOME/breakpad/bin/dump_syms ./crash_demo > crash_demo.sym head -3 crash_demo.sym输出类似:
MODULE Linux x86_64 4F2A8C1D9E3B7A506C4D2E8F1B0A9C7D crash_demo FILE 0 /home/dev/demo/crash_demo.cc FUNC 1000 40 0 main 1000 20 15 0第一行的四个字段分别是:格式标识、操作系统、CPU 架构、Build ID、模块名。这个 Build ID 就是后面匹配符号的唯一钥匙,它必须和 minidump 里记录的一模一样。
FUNC行的三个数字是:起始地址、大小、参数大小。下面的1000 20 15 0是行号记录,含义是"地址 0x1000 处,对应源文件编号 15 的第 20 行"。所以.sym文件其实是个紧凑的地址映射表。
一个常见的误区是以为.sym是二进制格式,其实它是纯文本,可以直接grep函数名,调试时挺好用:
grep "^FUNC.*CrashHandler" crash_demo.sym能快速确认某个函数有没有被抽进符号表,这一步在"分析时某个关键函数显示不出来"的场景下特别有用。
4.2 符号目录结构的硬性约定
minidump_stackwalk找符号的方式是写死的,目录结构必须是:
symbols/ crash_demo/ 4F2A8C1D9E3B7A506C4D2E8F1B0A9C7D/ crash_demo.sym也就是符号根目录 / 模块名 / BuildID / 模块名.sym。层级少一层、模块名写错一个字符、BuildID 大小写不对,都会导致找不到符号,而minidump_stackwalk的表现是静默降级——堆栈照样打出来,但只有地址没有函数名,不报错。新手经常以为是自己 dump 坏了,其实是符号没对上。
我写过一个小脚本来做这个事,省得每次手敲:
#!/bin/bash # make_symbols.sh BIN=$1 SYMROOT=$2 NAME=$(basename "$BIN") $HOME/breakpad/bin/dump_syms "$BIN" > /tmp/out.sym || exit 1 BUILD_ID=$(head -1 /tmp/out.sym | awk '{print $4}') DIR="$SYMROOT/$NAME/$BUILD_ID" mkdir -p "$DIR" mv /tmp/out.sym "$DIR/$NAME.sym" echo "symbols ready: $DIR/$NAME.sym"这个脚本从.sym首行反解 BuildID,比自己去readelf里抠更可靠,因为 breakpad 的 ID 生成规则和readelf显示的字节序在某些情况下不完全一致,用工具自己的输出最保险。
4.3 版本管理与符号归档的实操策略
符号文件的管理是长期工程,不是一个技术点,是一套流程。我踩过的坑值得说三条。
第一条,符号必须和发布包一起归档,不能只留在构建机上。构建机磁盘一满,运维第一件事就是清构建目录,一清就把半年的符号清了。建议在发布流水线里加一步:构建完成后立刻把.sym文件按模块名/BuildID/的结构传到对象存储或者内部文件服务器,路径带上版本号。存储成本极低,一个 50MB 的二进制抽出来的.sym通常只有 5-10MB,压缩后 1-2MB。
第二条,版本号别只看业务版本。同一个业务版本可能因为热修复重新构建多次,每次 BuildID 都不同。归档路径建议用业务版本_BuildID的组合,或者干脆只用 BuildID 做目录,另建一张映射表记业务版本。我吃过苦头:用业务版本做目录,热修复包覆盖了原目录,老 dump 再也解析不了。
第三条,长期保留策略按"用户版本分布"定,不按时间一刀切。简单地"保留最近三个月"会误伤——某个客户环境一直不升级,跑的是去年的版本,崩了你解析不了。更合理的做法是统计实际存量,只要还有设备在跑那个 BuildID,符号就得留着。实现上可以在上报服务里记一下每个 BuildID 最近一次收到 dump 的时间,超半年没动静的再清理。
5. 从 minidump 到可读堆栈:分析全流程
5.1 minidump_stackwalk 的输出怎么读
拿到.dmp和整理好的符号目录之后,一条命令出结果:
$HOME/breakpad/bin/minidump_stackwalk \ 5f8c1a2b-1d3e-4c5a-9b7f-2e6d8a0c4f11.dmp \ ./symbols/ \ > crash_report.txt 2>&1注意一定要把stderr也重定向进去 (2>&1),因为模块加载信息、警告、找不到符号的提示全在 stderr 里,只看 stdout 会漏掉关键线索。
输出文件大概长这样:
Operating system: Linux 0.0.0 Linux 5.15.0-91-generic #101-Ubuntu SMP ... x86_64 CPU: amd64 family 6 model 142 stepping 10 8 CPUs Crash reason: SIGSEGV Crash address: 0x0 Process uptime: not available Thread 0 (crashed) 0 crash_demo!main + 0x1c rax = 0x0000000000000000 rdx = 0x00007ffd9c3a1b40 ...几个关键位置:
Crash reason和Crash address第一眼看。SIGSEGV加地址0x0基本就是空指针解引用;SIGABRT通常是程序里主动abort或者断言失败;SIGFPE是除零。地址是个接近0xffff...的值,多半是野指针或者栈溢出。
Thread 0 (crashed)这一块是崩溃线程的栈,从0往下是调用链,0是最内层。每一行格式是帧号 模块名!函数名 + 偏移。有!说明符号解析成功了,没有!只有地址就是没找到符号。
紧接着的寄存器列表非常有价值。rip指示指令位置,rsp是栈顶,几个参数寄存器rdi、rsi、rdx往往是函数的入参,很多时候光看这几个值就能猜出发生了什么,比如rdi = 0x0而函数签名第一个参数是指针,那大概率是空指针传进去了。
后面的线程列表也不能忽略。如果是死锁或者竞态问题,崩溃线程往往是受害者,真凶在其他线程里。我会先扫一遍所有线程的栈顶函数,看有没有哪个线程卡在pthread_mutex_lock或者某个奇怪的系统调用上。
5.2 用 minidump-2-core 配合 gdb 看变量
minidump_stackwalk只给你堆栈和寄存器,看不了局部变量、看不了内存。想看变量怎么办?breakpad 提供了minidump-2-core,把 dmp 转成标准的 core 文件,然后交给 gdb:
$HOME/breakpad/bin/minidump-2-core \ 5f8c1a2b-1d3e-4c5a-9b7f-2e6d8a0c4f11.dmp > crash.core gdb ./crash_demo crash.core进去之后bt、frame N、info locals、p 变量名,和本地调试的体验基本一致。
这里有个必须知道的前提:gdb 加载 core 时用的是你这个本地二进制的符号,不是symbols/目录。所以本地这个./crash_demo的 BuildID 必须和产生 dump 的那个版本完全一致,否则 gdb 会提示符号不匹配,虽然有set sysroot之类的办法强行加载,但行号和变量位置大概率是错的,反而误导。
我的实际做法是:归档的时候把发布版二进制(strip 过的)和完整二进制(带-g的)都留着,分析时按 BuildID 找对应版本。存储多花不了多少,但省掉的是"看着错误行号排查半天"的时间。
minidump-2-core有个已知限制:转换出来的 core 里,线程栈是完整的,但堆内存只有 minidump 里记录的那部分,所以有些指针指向的堆数据读不出来,p *ptr会报Cannot access memory at address ...。这不是工具坏了,是 minidump 本来就只抓栈不抓堆。要抓特定堆内存,得用前面说的RegisterAppMemory提前登记。
5.3 符号对不上的几种典型表现
符号解析失败的表现有好几种,分清楚了排查起来快。
表现一:完全没符号,栈里全是0x00007f...这种地址,连模块名都没有。这是最严重的情况,说明 minidump 里的模块列表和你给的symbols/目录完全对不上。先看 stderr 里有没有类似Couldn't find symbol file的提示,再看 minidump 里记录的模块名——用minidump_dump工具能看到:
$HOME/breakpad/bin/minidump_dump xxx.dmp | grep -A3 "module"模块名可能是完整路径/usr/bin/your_app而不是your_app,这会直接导致目录匹配失败。
表现二:有模块名但没函数名,显示成your_app!后面跟一串地址。这说明目录对了但 BuildID 不对。对比一下.sym首行的 ID 和minidump_dump里记录的模块 ID,一个字符一个字符核对。常见原因是发布时重新链接过一次,或者你拿的是同版本的另一个构建产物。
表现三:函数名出来了但行号是?。这说明符号表在,但调试信息里的行号映射缺失。原因是编译时没加-g,或者加了-g之后又被strip -g砍掉了。这种情况只能重新构建,没法补救。
表现四:只有部分帧有符号,中间断了一截。这通常是调用经过了第三方.so,而那个.so的符号没放进symbols/目录。解决办法是把每个.so都单独跑一遍dump_syms,按同样的目录结构放进去。系统库(libc.so.6之类)的符号可以从发行版的 debug 包里拿,但一般不用,因为栈顶基本都在你自己的代码里。
6. 生产环境落地的几个硬问题
6.1 性能开销与线程暂停
先说性能。装 handler 本身的开销可以忽略,就是几次系统调用加一个信号栈的申请。真正的开销发生在崩溃那一刻:breakpad 要暂停其他所有线程,这个动作走的是 ptrace,线程多了会明显慢,几十个线程的情况下可能要几十毫秒到几百毫秒。但这件事只发生一次,而且进程本来就要死了,所以没有实际影响。
有一个场景要注意:如果程序里有逻辑在超时后主动终止某个卡住的线程,而那个线程正好在处理一个耗时操作,暂停动作可能会卡住。不过这只是理论上,我没遇到过。
另外,如果程序自己装了信号处理器(比如某些库会接管SIGSEGV做自己的事),breakpad 装的时候会覆盖掉。想共存的话,ExceptionHandler支持传入一个filter回调,在里面决定哪些崩溃交给 breakpad、哪些放行给原来的处理逻辑:
static bool FilterCallback(void* context) { if (is_recoverable_fault()) { return false; // 返回 false 表示不处理,交给原来的 handler } return true; }这个机制用得上的场景不多,但知道有它,遇到"装了 breakpad 之后某个第三方库行为变了"时能快速定位。
6.2 容器、权限与路径的坑
容器化部署之后,dump 路径的坑集中爆发。几个必查项:
第一,dump 目录必须可写。很多基础镜像是只读根文件系统,/tmp都不一定可写。我建议用环境变量指定,启动时检查一次,不可写就打个明显的警告日志,别等到崩了才发现。检查方法很简单,access(dir, W_OK)就行。
第二,/tmp可能被tmpfs挂载,容器重启就没了。如果你的上报逻辑是"下次启动时扫描/tmp上传",那就全丢了。dump 目录要么挂个持久卷,要么在 dump 回调里立刻同步上传(回调里做网络操作不推荐,容易卡死),折中方案是回调里只标记,由一个独立的轻量线程负责上传,这个线程在启动时就起来,不依赖崩溃现场。
第三,ulimit 和 breakpad 没关系,但很多人搞混。有人觉得用了 breakpad 还得开RLIMIT_CORE,其实不需要,breakpad 是主动open加write,不走内核的 core dump 机制。所以容器里ulimit -c 0完全不影响 breakpad 工作,这是个好消息。
第四,权限降级。如果程序启动后setuid到低权限用户,handler 必须在降权之前装好,否则写 dump 时没有权限。或者反过来,指定一个低权限用户也有写权限的目录,比如/var/log/yourapp/crash并提前chmod 1777。我倾向前者,权限模型更干净。
6.3 上报、去重与限流
崩溃上报如果不做限流,最容易出现的灾难是:某个版本上线后大面积崩溃,每台机器疯狂上报,把上报服务打挂,连带正常业务也受影响。这套东西得提前设计。
去重的维度一般是BuildID + 崩溃位置(最内层有符号的帧地址)+ 崩溃信号,算一个哈希作为指纹。同一个指纹在一个时间窗口内只上报一次,或者只上报 N 次并附带计数。这样即使十万台设备同时崩在同一个地方,服务端收到的也是十万条增量计数,不是十万个文件。
限流要分两层:客户端层做一个令牌桶,比如每小时最多上报 5 次,超过就丢弃并记本地计数;服务端层做全局配额,按 BuildID 或者按租户分配。两层都做是有必要的,客户端层的令牌桶状态可能因为进程重启而丢失,服务端层才是最后一道闸。
体积控制:上传前用 gzip 压一遍,minidump 压缩比通常能到 5:1 以上,几百 KB 压到几十 KB,带宽压力小很多。另外建议给单个 dump 设个上限,超过 5MB 的直接丢弃并记日志,防止某些极端情况(比如RegisterAppMemory登记了一大块内存)打出巨大的文件。
7. 高频问题速查与避坑清单
7.1 故障速查表
下面这张表是我这几年攒下来的,按"现象"查"原因"和"处理",遇到问题先对一遍。
| 现象 | 最可能的原因 | 处理动作 |
|---|---|---|
| 崩溃了但没生成 dmp 文件 | 目录不可写 / 磁盘满 / handler 装了但信号被别的库接管 | 检查目录权限,install_handler传 true,排查第三方信号处理 |
| dmp 文件生成了但是 0 字节 | 回调里二次崩溃,或者写盘时进程被强杀 | 简化回调逻辑,确认进程不是被SIGKILL干掉 |
| 堆栈里全是裸地址没有符号 | symbols/目录结构不对,或者模块名不匹配 | 用minidump_dump看模块名,比对目录层级 |
| 有模块名没函数名 | BuildID 对不上 | 核对.sym首行 ID 与 minidump 里记录的 ID |
| 有函数名没行号 | 编译没加-g或调试信息被 strip | 重新用-O2 -g构建,先 dump_syms 再 strip |
| 只有栈顶一帧有符号 | 调用链经过未提供符号的.so | 给每个自研.so都生成符号并按结构放置 |
minidump_stackwalk报no symbols但仍出结果 | 静默降级,正常的提示 | 看 stderr 完整输出,逐模块核对 |
| 转换出的 core 里看不了堆变量 | minidump 不含堆内存 | 用RegisterAppMemory提前登记关键内存 |
| 32 位程序解析异常 | dump_syms与目标架构不匹配 | 用对应架构的工具链编译 breakpad |
编译报pthread_create未定义 | 链接库顺序错 | -lbreakpad_client放-lpthread左边 |
7.2 几个不值得踩第二次的坑
除了表里的通用问题,还有几个偏门但很疼的坑,单独说。
坑一:多进程程序只给主进程装了 handler。有些架构是 fork 出多个 worker,主进程装了 handler,worker 崩了当然没 dump。要么每个 worker 启动时各自装一遍(注意 fork 之后不要继承父进程的 handler,会重复),要么用 out-of-process 模式统一收。我现在的做法是在一个公共的进程初始化函数里装,所有进程入口都调它,一处配置全局生效。
坑二:静态链接的程序符号抽不出来。静态链接时,dump_syms面对的是一个巨大的 ELF,抽取会很慢(几分钟),但能出结果。真正的问题是有些工具链在静态链接时关掉了调试信息,需要显式在链接参数里加-Wl,--build-id和确认-g传导到了链接阶段。判断办法还是readelf -n看有没有 Build ID。
坑三:升级编译器之后旧的 dump 符号对不上。这是 BuildID 之外的一层问题:不同编译器版本生成的 DWARF 格式可能不同,dump_syms有极小的概率解析出错。实际影响很小,但如果你发现某批 dump 的行号明显错乱,可以试试用产生该二进制时同版本的 breakpad 重新生成符号。
坑四:dump 文件名重复覆盖。Breakpad 默认用 UUID 做文件名,理论上不会重复,但如果你的MinidumpDescriptor传了自己拼的文件名,而拼接规则里有时间戳只精确到秒,高并发崩溃时就会互相覆盖。要么别自定义文件名,要么在名字里加上进程 PID 或者纳秒时间戳。
坑五:在信号处理函数里调用 breakpad 的 API。有人想在自定义的SIGSEGV处理器里先做点清理,再调handler.WriteMinidump()。这是危险操作,WriteMinidump本身不是异步信号安全的,只能在你确定安全的地方(比如主循环里)主动调用。崩溃路径就走 breakpad 自己的信号处理,别插手。
坑六:忽略dump_syms的退出码。这个工具在符号抽取失败时可能返回非零但仍有部分输出,如果你用dump_syms xxx > xxx.sym而不检查退出码,会得到一个残缺的.sym文件,分析时表现成"部分函数有符号、部分没有",查起来非常费劲。脚本里务必加|| exit 1或者显式检查$?。
我个人在实际项目里的体会是,breakpad 这套东西的技术门槛其实不高,编译、集成、解析三步走完,一天就能跑通。真正花时间的是流程建设——符号怎么归档、dump 怎么去重、版本怎么追溯。技术选型对了只解决一半问题,剩下那一半得靠上线前就把"崩溃之后我要用什么姿势查"想清楚,而不是等出事了现搭台子。我现在的习惯是,任何一个新的 C++ 模块接入 CI 的第一件事,就是跑一次人造崩溃,把从 dmp 到可读堆栈的链路完整走一遍,走通了才算这个模块的构建流程是合格的。