Native Crash 监控方案演进
Native crash 监控解决的不是「怎么打印一行堆栈」,而是一条端到端链路:
- 捕获:进程已经处于不确定状态,如何尽量不死二次、尽量写出完整现场。-
- 落盘:写出可跨平台解析的 minidump,并区分「写了一半」和「写完待传」。
- 符号化:把指令地址还原成函数名和源码行号。
- 上报:崩溃当次就把报告送出,而不是等用户下次打开 App。
- 聚类:海量事件归并成可跟进的 Issue。
本文按这条链路展开。Linux / Android 是主线,Apple 与 Windows 只作为对照;客户端采集以 Breakpad → Crashpad 的演进为主,服务端聚焦 stackwalk 工具链和聚类规则。
基本的 Native Crash 监控
POSIX 信号
在 Linux / Android 等 POSIX 系统上,内核用 信号(signal) 把异步事件通知给进程。对 native crash 来说,最常见的来源是:
- 非法访存、对齐错误、非法指令等硬件异常,由内核转换成信号;
abort()、assert失败、部分运行时自检,主动发送SIGABRT;- 调试器断点、错误系统调用等。
信号不是普通函数调用。它会打断当前线程,切到该线程上注册的处理函数。处理函数返回后,线程从被打断处继续;若处理函数选择结束进程,内核按默认动作终止。
这也是 crash 监控最难的地方:处理函数运行时,堆、锁、分配器、ART/JVM 都可能已经坏了。 能在 handler 里安全调用的接口,被 POSIX 收进 async-signal-safe 白名单——下一小节单独展开。
注册回调
现代代码用 sigaction,而不是老的 signal():
struct sigaction sa;
memset(&sa, 0, sizeof(sa));
sa.sa_sigaction = on_crash; /* 三参数回调,能拿到 siginfo */
sa.sa_flags = SA_SIGINFO | SA_ONSTACK;
sigemptyset(&sa.sa_mask);
sigaction(SIGSEGV, &sa, &old_sa);
要点:
| 项 | 说明 |
|---|---|
SA_SIGINFO |
回调签名变为 (int, siginfo_t*, void*),可读取 si_addr、si_code |
SA_ONSTACK |
使用预先准备的备用栈(sigaltstack),避免崩溃线程栈已耗尽时连 handler 都进不去 |
sa_mask |
handler 执行期间要屏蔽的信号集合,避免重入 |
保存 old_sa |
dump 完成后要把信号交还给系统默认动作,否则进程杀不掉,会变成「崩溃了但一直活着」 |
典型回调形态:
void on_crash(int signo, siginfo_t* info, void* ucontext) {
/* 只做 async-signal-safe 的事:通知另一条线程 / fork 子进程 */
/* 由那一侧写 dump */
/* 恢复旧 handler,重新 raise,让进程按默认动作退出 */
}
ucontext 里有崩溃瞬间的寄存器(PC、SP、通用寄存器),这是后续 unwind 的起点。siginfo_t.si_addr 对 SIGSEGV / SIGBUS 是出错地址。
常见致命信号
下表是 Android / Linux native crash 监控通常会接管的信号。默认动作几乎都是「终止进程,可能写 core」。
| 信号 | 编号(Linux) | 典型触发 | si_code 常见值 |
监控是否接管 |
|---|---|---|---|---|
SIGSEGV |
11 | 空指针、野指针、写只读页 | SEGV_MAPERR(未映射)、SEGV_ACCERR(权限) |
是,最常见 |
SIGABRT |
6 | abort()、assert、部分 libc / STL 检查 |
多为用户态 raise |
是 |
SIGBUS |
7 | 对齐错误、mmap 文件被截断后访问 |
BUS_ADRALN、BUS_ADRERR |
是 |
SIGILL |
4 | 非法指令、执行损坏代码、错误 ABI | ILL_ILLOPC、ILL_ILLTRP |
是 |
SIGFPE |
8 | 整数除零、部分浮点异常 | FPE_INTDIV、FPE_FLTDIV |
是 |
SIGTRAP |
5 | bkpt / int3、部分调试陷阱 |
TRAP_BRKPT |
视策略 |
SIGSYS |
31 | seccomp 拦截、错误 syscall 号 | SYS_SECCOMP |
视策略 |
SIGSTKFLT |
16 | 协处理器栈错误(少见) | — | 可选 |
SIGTERM / SIGINT |
15 / 2 | 外部请求退出,不是崩溃 | — | 通常不接管 |
SIGKILL / SIGSTOP |
9 / 19 | 内核强制,用户态无法捕获 | — | 无法接管 |
SIGKILL 和 SIGSTOP 不能被 sigaction 拦截。OOM Killer、am force-stop 走的是这条路,native crash SDK 对此无能为力。这也是「捕获率永远到不了 100%」的物理上限之一。
async-signal-safe
信号处理函数和普通回调有本质差别:它可能插在 任意 指令之间执行——包括 malloc 正在改空闲链表的半途、pthread_mutex_lock 已持锁未入临界区、printf 正往 FILE 缓冲里写。若 handler 再调这些函数,轻则死锁,重则堆元数据二次损坏,连 dump 都写不出来。
POSIX(IEEE Std 1003.1)因此定义了 async-signal-safe:在信号上下文中,只有列入标准白名单的接口才保证可重入、可安全调用。名单之外的,一律视为未定义行为——「碰巧能跑」不算合规。完整列表可参考 Linux 手册 signal-safety(7)。
常见可用(白名单摘录)
| 类别 | 示例 | 典型用途 |
|---|---|---|
| 退出 | _exit、_Exit |
dump 失败时立刻结束,不跑 atexit |
| 文件 | open、close、read、write、fsync |
写面包屑日志、唤醒 pipe |
| 进程 | fork、execve、waitpid、getpid、gettid(Linux) |
拉起写 dump 的子进程 |
| 信号 | sigaction、sigprocmask、raise |
恢复旧 handler 后 reraise |
| 时间 | nanosleep、clock_gettime(部分实现) |
带超时的短等待 |
| 内存映射 | 一般 不要 在 handler 里新 mmap 大块业务逻辑 |
预分配更稳 |
常见禁用(一踩就炸)
| 类别 | 示例 | 为什么危险 |
|---|---|---|
| 堆 | malloc / free / new / delete |
分配器全局锁 + 元数据,崩溃时多半不一致 |
| 标准 IO | printf、cout、fopen |
FILE* 带锁、会分配缓冲 |
| 线程 | pthread_mutex_lock、pthread_cond_signal、pthread_join、pthread_create |
可能与被打断线程抢同一把锁;pthread_create 死锁发生在返回前,外层超时救不了 |
| JNI / ART | AttachCurrentThread、任意 Java 调用 |
重入 JVM,二次崩溃则 native 栈也丢 |
| C++ 运行时 | 异常抛出/捕获、dynamic_cast、多数 STL 改容器 |
依赖堆与 RTTI,标准未给信号安全承诺 |
| C++20 同步 | std::condition_variable、semaphore、atomic::wait / atomic::notify |
标准 没有 async-signal-safe 保证,不能当 pipe 用 |
工程上的正确拆分是:
信号上下文(必须 async-signal-safe)
├─ 写 pipe / 设原子标志 唤醒已有线程
├─ fork +(子进程内)关 fd、exec 拉干净进程
├─ 恢复 old_sa,raise 原信号
└─ 其它:尽量没有
普通线程 / exec 后的 handler 进程
├─ malloc、写完整 minidump、JNI 抓 Java 栈
└─ HTTP 上传、数据库 rename、日志
典型做法是预创建工作线程,信号里只对 pipe 写端敲一个字节。示意如下(省略错误处理):
static int g_pipe[2] = {-1, -1}; /* [0]=读端 [1]=写端 */
static std::atomic<bool> g_done{false};
/* 初始化阶段:创建 pipe + 预创建工作线程(须在 sigaction 之前) */
void InitPipeWorker() {
pipe(g_pipe); /* 或 pipe2(..., O_CLOEXEC) */
std::thread([] {
char byte;
for (;;) {
/* 普通线程里阻塞读;被信号侧 write 唤醒 */
if (read(g_pipe[0], &byte, 1) != 1)
continue;
/* 此处可做非信号安全的事:JNI / 写文件 / 组包 … */
g_done.store(true, std::memory_order_release);
}
}).detach();
}
/* 信号上下文:只允许 async-signal-safe */
void NotifyPipeWorkerFromSignal() {
g_done.store(false, std::memory_order_release);
char one = 1;
write(g_pipe[1], &one, 1); /* 敲门;勿用 condvar / mutex */
/* 短轮询等待,带总超时;勿 pthread_join */
const int kMaxLoops = 500; /* 例如约 500ms */
for (int i = 0; i < kMaxLoops; ++i) {
if (g_done.load(std::memory_order_acquire))
return;
struct timespec ts = {0, 1 * 1000 * 1000}; /* 1ms */
nanosleep(&ts, nullptr);
}
/* 超时则放弃等待,继续后续 dump / reraise */
}
pipe/write/read/nanosleep都在白名单内;pthread_cond_signal、std::mutex、C++20atomic::wait不在。- 写端只写一个字节即可,语义是「有事件」,不是在信号里传大块数据。
- 超时必须包住整段等待:工作线程若挂住,handler 仍能返回。
几条实践原则:
- 初始化阶段付费,崩溃阶段省钱。 备用栈、
pipe、采栈线程、handler 路径字符串,全部在sigaction之前准备好;handler 里只做通知。 - 超时必须包住「非安全调用」本身。 若在 handler 里
pthread_create,死锁发生在pthread_create内部,外面的nanosleep轮询永远等不到。 - 日志用
write(fd, buf, n),不用 LOG 宏。 许多日志库内部有锁和堆。 - Windows / Mach 不自动继承这套税。 VEH、未处理异常过滤器、Mach 监听线程跑在普通上下文,可以分配内存;只有又落到 POSIX
sigaction兜底时,才重新受白名单约束。
后文 Breakpad 的 Java 栈问题、Crashpad 的 pipe 采栈与 fork + exec,全部建立在这一节之上。
信号链还有一个工程问题:后安装的 handler 会覆盖先安装的。WebView、厂商 ROM、其它 SDK 都可能抢 SIGSEGV。可靠的做法是:安装时保存旧动作,dump 完成后 reraise;运行期在前后台切换时检测是否被抢占,必要时重装。重装时必须保留「第一次安装拿到的旧动作」,否则 reraise 会链回自己,进程退不出去。
跨平台异常捕获
Linux 与 Android 的主路径是 POSIX 信号。另外两个主流桌面系统并不把「信号」当作一等公民。
Apple
Darwin 内核的异常模型是 Mach exception,不是 POSIX 信号。线程发生 EXC_BAD_ACCESS、EXC_BAD_INSTRUCTION、EXC_ARITHMETIC、EXC_BREAKPOINT 等时,内核把消息发到该任务的 exception port。监听方在独立线程上 mach_msg 收包,再决定是写 dump 还是交给下一层。
POSIX 信号在 Darwin 上是兼容层:Mach 异常可以被转换成 SIGSEGV 等。只挂 sigaction 在 macOS / iOS 上往往不够:
- 调试器、系统 crash reporter、其它 SDK 可能先占住 exception port;
- 部分异常在转成信号之前就已经被 port 消费。
所以 Apple 上的完整方案通常是:
硬件/内核异常
│
▼
Mach exception port ──────► 独立监听线程写 dump
│
▼
(可选)转换成 POSIX 信号
│
▼
`sigaction` 兜底 / 交给系统 Crash Reporter
代码框架(示意,省略错误处理与权限细节):
#include <mach/mach.h>
#include <pthread.h>
static mach_port_t g_exception_port;
static void* exception_server(void* arg) {
for (;;) {
/* 阻塞接收异常消息 */
mach_msg(/* ... EXC_MSG 缓冲区 ... */);
/* 从消息里取出异常线程、类型、代码、现场 */
/* EXC_BAD_ACCESS / EXC_BAD_INSTRUCTION / ... */
/* 写 dump(常在本线程或再派到专用线程) */
/* 决定后续:
KERN_SUCCESS — 已处理
或转交给原先的 exception port / 让进程崩溃 */
}
return NULL;
}
void install_mach_exception_handler(void) {
mach_port_allocate(mach_task_self(), MACH_PORT_RIGHT_RECEIVE,
&g_exception_port);
mach_port_insert_right(mach_task_self(), g_exception_port,
g_exception_port, MACH_MSG_TYPE_MAKE_SEND);
/* 把本 task 的异常端口指到 g_exception_port
mask 常见:EXC_MASK_BAD_ACCESS | EXC_MASK_BAD_INSTRUCTION |
EXC_MASK_ARITHMETIC | EXC_MASK_BREAKPOINT | ... */
task_set_exception_ports(
mach_task_self(),
EXC_MASK_BAD_ACCESS | EXC_MASK_BAD_INSTRUCTION |
EXC_MASK_ARITHMETIC | EXC_MASK_BREAKPOINT,
g_exception_port,
EXCEPTION_DEFAULT | MACH_EXCEPTION_CODES,
THREAD_STATE_NONE);
pthread_t t;
pthread_create(&t, NULL, exception_server, NULL);
pthread_detach(t);
}
/* 可选兜底:Mach 转成的 POSIX 信号 */
void install_posix_fallback(void) {
struct sigaction sa;
/* 同 Linux:sigaction(SIGSEGV/SIGBUS/SIGABRT/...) */
}
要点:异常在 独立监听线程 上处理,不是在崩溃线程的信号栈上;真正写 dump 前通常还要挂起异常线程、读寄存器与栈。iOS 还有额外限制:App 不能像桌面那样随便 spawn 常驻 helper,崩溃现场能做的事更少,很多能力要放到下一次冷启动补传。
Windows
Windows 没有 POSIX 信号模型。用户态 native 异常走 SEH(Structured Exception Handling):
| 机制 | API | 特点 |
|---|---|---|
| 线程/函数级 SEH | __try / __except |
编译器生成,作用域有限 |
| 进程级兜底 | SetUnhandledExceptionFilter |
未处理异常的最后一跳 |
| 向量化异常 | AddVectoredExceptionHandler |
比 SEH 更早,可观察所有异常 |
| 系统转储 | MiniDumpWriteDump(DbgHelp) |
写出 Windows minidump |
代码框架:
#include <windows.h>
#include <dbghelp.h>
/* ---------- 最早:VEH,几乎所有异常都会经过 ---------- */
LONG CALLBACK VectoredHandler(EXCEPTION_POINTERS* info) {
/* info->ExceptionRecord->ExceptionCode
如 EXCEPTION_ACCESS_VIOLATION / EXCEPTION_ILLEGAL_INSTRUCTION */
/* 此处可观察;一般不要在这里做重活,除非明确要拦截 */
return EXCEPTION_CONTINUE_SEARCH; /* 继续往后传 */
}
/* ---------- 进程兜底:没人处理时才会到这里 ---------- */
LONG WINAPI UnhandledFilter(EXCEPTION_POINTERS* info) {
HANDLE file = CreateFileW(L"crash.dmp", GENERIC_WRITE, 0, nullptr,
CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, nullptr);
MINIDUMP_EXCEPTION_INFORMATION mei = {};
mei.ThreadId = GetCurrentThreadId();
mei.ExceptionPointers = info;
mei.ClientPointers = FALSE;
MiniDumpWriteDump(
GetCurrentProcess(),
GetCurrentProcessId(),
file,
MiniDumpWithThreadInfo, /* 实际项目按需选 DumpType */
&mei,
nullptr,
nullptr);
CloseHandle(file);
return EXCEPTION_EXECUTE_HANDLER; /* 结束进程 */
}
/* ---------- 局部 SEH:只包住一段可疑代码 ---------- */
void maybe_risky() {
__try {
/* 业务逻辑 */
} __except (EXCEPTION_EXECUTE_HANDLER) {
/* 仅处理本作用域;不能替代进程级监控 */
}
}
void install_windows_crash_handler() {
AddVectoredExceptionHandler(/* First */ 1, VectoredHandler);
SetUnhandledExceptionFilter(UnhandledFilter);
}
调用顺序可以记成:
异常发生
→ VEH(AddVectoredExceptionHandler,可多个)
→ 各帧 SEH(`__try` / `__except`)
→ UnhandledExceptionFilter
→ 系统默认崩溃对话框 / 终止
Windows minidump 的磁盘格式,正是后来 Breakpad / Crashpad 跨平台 dump 的原型:一个带 MDMP 魔数的文件,内含多条 stream(线程、模块、异常、内存、系统信息)。格式定义见微软 MINIDUMP_HEADER / MINIDUMP_DIRECTORY。Linux / Android 上的 Breakpad 并不是「Linux core 换了个名字」,而是 用同一套 minidump 容器,把 POSIX 信号现场编码进去。
和信号模型的关键差异:VEH / 未处理异常过滤器运行在普通线程上下文,不是 async-signal-safe 约束。Windows 上可以较从容地分配内存、写文件、拉起外部进程。这也是后文「为什么 Java 栈采集在 Android 上必须绕开信号上下文」的对照:同一件事,Windows 简单,POSIX 极难。
Android 的额外挑战
Android 是 Linux,POSIX 信号能用;但它不是普通 Linux 进程模型。有三道门槛。
Zygote 与 ART
App 进程不是 exec 出来的干净映像,而是 Zygote fork 出来的:
zygote
├─ 已加载 framework / 已启动 ART
├─ fork() 出 App 进程 ← 子进程继承整份 Java VM
└─ 再按 ActivityThread 走组件生命周期
Zygote 能这么做,是因为 fork 前会走 Runtime::PreFork():停掉 ART 内部线程、做完 GC、释放锁、刷新 JIT。普通 App 在崩溃瞬间 不可能 再走一遍这套协议。因此:
- 崩溃时
fork()得到的子进程,表面上「有一份 Java VM 拷贝」,实际上 ART 线程没跟过来、锁可能悬空,不能再调 JNI; - 若既要独立进程写 dump,又想在子进程里跑 Java,必须
fork之后立刻exec一个全新程序(例如/system/bin/app_process64),让内核换掉整份内存映像。
开进程方式对比
「崩溃时再开一个进程写 dump」这句话,至少对应三种完全不同的系统调用组合。差别不在「有没有子进程」,而在子进程和崩溃进程还共享多少状态。
┌──────────── 是否共享地址空间 ────────────┐
│ │
CLONE_VM=1 CLONE_VM=0
(pthread 那种) (COW 拷贝)
│ │
│ ┌────── 拷贝之后是否 exec ──────┐
│ │ │
同进程线程 纯 clone / fork fork + exec
不能当 crash 子进程仍是崩溃 子进程换成
handler 用 进程的「脏克隆」 全新程序
| 方式 | 系统调用 | 子进程内存 | 子进程能否当普通程序用 | 崩溃场景下通常能做什么 |
|---|---|---|---|---|
| 同进程线程 | clone(CLONE_VM) / pthread_create |
与崩溃线程共享 | 共享堆/锁,极危险 | 只适合初始化阶段预创建,不适合崩溃时新建 |
无 CLONE_VM 的 clone |
例如 clone(CLONE_FS | CLONE_UNTRACED) |
父进程 COW 拷贝 | 几乎不能。堆、全局、ART 都可能是坏的 | 子进程只宜做 ptrace + 写文件,且须 async-signal-safe |
纯 fork |
fork() |
同样是 COW 拷贝 | 同左;Java VM 不可用 | 与上一行天花板相同,只是 POSIX 包装不同 |
fork + exec |
fork() 后立刻 execve |
exec 后全新映像 |
可以:独立堆、可跑完整 C++,甚至新的 Java Runtime | 关键差异在 exec,不在 fork |
示意(信号上下文里调用;省略错误处理与预分配栈细节):
/* A. 无 CLONE_VM 的 clone:脏克隆,子进程入口只能做有限的事 */
static int CloneDumpEntry(void* arg) {
/* ptrace 读父进程 + 写 minidump;勿 malloc / JNI */
DoPtraceAndWriteDump(arg);
_exit(0);
return 0;
}
void LaunchCloneDumper(void* stack_top, void* arg) {
/* 注意:没有 CLONE_VM → COW 拷贝,不是共享地址空间 */
pid_t child = clone(CloneDumpEntry,
stack_top,
CLONE_FS | CLONE_UNTRACED,
arg);
/* 父进程 waitpid 后 reraise */
}
/* B. 纯 fork:与上类似,仍是崩溃进程的脏克隆 */
void LaunchForkDumper() {
pid_t pid = fork();
if (pid == 0) {
DoPtraceAndWriteDump(/*...*/);
_exit(0);
}
/* 父进程 waitpid */
}
/* C. fork + exec:关键在 exec,换成干净映像 */
void LaunchExecHandler(char* const argv[], char* const envp[]) {
pid_t pid = fork();
if (pid == 0) {
/* exec 前盲关继承来的多余 fd(保留 0/1/2) */
struct rlimit lim;
getrlimit(RLIMIT_NOFILE, &lim);
for (int fd = STDERR_FILENO + 1; fd < (int)lim.rlim_cur; ++fd)
close(fd);
/* 复位致命信号为默认,再换程序 */
execve(argv[0], argv, envp); /* 例如 /system/bin/app_process64 */
_exit(127);
}
int status = 0;
waitpid(pid, &status, 0); /* 父进程等 handler 写完 dump */
}
补充两点,否则会踩坑:
exec换的是内存映像,不换进程身份。 PID、父子关系、打开的文件描述符默认保留。长存活业务进程可能挂着成百上千个 fd;若不在exec前关掉,新程序(例如app_process)可能在进入main之前就SIGSEGV。正确做法是子进程里盲关stderr之后的 fd,再execve。exec不会清掉LD_LIBRARY_PATH。 若把业务里的libcrypto路径塞进环境变量,系统二进制启动时可能去解析错的 OpenSSL,直接 link 失败。子进程侧加载业务 so 应用绝对路径dlopen/System.load,不要污染系统 linker。
多进程与文件锁
Android App 常见多进程:主进程、:push、WebView、业务子进程等。它们同 uid,共享 filesDir / 外部私有目录。crash 监控里的 dump 目录、pending 快照、配置与日志,很容易被多进程同时读写,必须用 跨进程 互斥,而不是进程内 mutex。
Linux / Android 上常见选择:
| API | 头文件 | 粒度 | 特点 |
|---|---|---|---|
flock |
<sys/file.h> |
整个文件 | 劝告锁;简单。部分文件系统 / NFS 上行为不一致 |
fcntl(F_SETLK / F_SETLKW) |
<fcntl.h> |
文件区间 | 劝告锁;struct flock 可指定偏移和长度。Java FileChannel.lock() 在 Linux 上就是它 |
lockf |
<unistd.h> |
从当前偏移起 | 实质是 fcntl 的封装,接口更窄 |
open(O_EXCL) / mkdir |
— | 路径 | 用「创建成功」当锁,无内核等待,要自己处理残留 |
应用私有目录一般是 ext4 / f2fs,推荐 fcntl 写锁(F_WRLCK):
- 和 Java NIO
FileChannel.lock()是同一把内核锁,JNI 与 Kotlin 能互斥; F_SETLKW阻塞等待,F_SETLK非阻塞;- 进程异常退出时,内核会释放该进程持有的
fcntl锁,比「lock 文件忘了删」更安全; - 锁的是某个约定好的
*.lock文件,真正的数据用「写 tmp +rename」保证原子性。
示意(省略错误处理;无头文件):
/* flock:整文件劝告锁,接口简单 */
void WithFlock(const char* lock_path, void (*work)()) {
int fd = open(lock_path, O_RDWR | O_CREAT, 0600);
flock(fd, LOCK_EX); /* 阻塞;LOCK_NB 为非阻塞 */
work();
flock(fd, LOCK_UN);
close(fd);
}
/* fcntl:与 Java FileChannel.lock() 同源,可指定区间 */
void WithFcntlWriteLock(const char* lock_path, void (*work)()) {
int fd = open(lock_path, O_RDWR | O_CREAT, 0600);
struct flock fl = {};
fl.l_type = F_WRLCK;
fl.l_whence = SEEK_SET;
fl.l_start = 0;
fl.l_len = 0; /* 0 = 整文件 */
fcntl(fd, F_SETLKW, &fl); /* 阻塞等到锁 */
work(); /* 临界区内写 tmp,再 rename */
fl.l_type = F_UNLCK;
fcntl(fd, F_SETLK, &fl);
close(fd);
}
/* 锁内原子发布:先写临时文件,再 rename 成正式路径 */
bool WriteTextAtomic(const char* path, const char* data, size_t len) {
char tmp[512];
snprintf(tmp, sizeof(tmp), "%s.tmp", path);
int fd = open(tmp, O_WRONLY | O_CREAT | O_TRUNC, 0600);
if (fd < 0)
return false;
/* write 循环写满 len;fsync(fd) 视可靠性要求 */
close(fd);
return rename(tmp, path) == 0; /* 同目录 rename 原子替换 */
}
lockf 是 fcntl 的窄封装,语义类似,跨语言协作时优先用 fcntl,避免和 Java 侧对不齐。
不要用:
pthread_mutex进程共享:要放在 shm 里,崩溃时可能留下 locked mutex;- 仅靠
rename当锁:它是原子发布,不是互斥。
配合方式:**fcntl 保护临界区,rename 发布完整内容。** 读侧对文本再加长度上限,防止损坏文件被一次性 mmap 爆内存。扫库、补传等逻辑也宜指定单一进程执行,避免多进程同时消费同一批 dump。
其它约束
| 约束 | 影响 |
|---|---|
extractNativeLibs=false(现行 AGP 默认) |
so 留在 APK zip 里,只有 Framework 配好的 linker namespace 才能直接加载;裸 app_process 进程没有这个 namespace,必须把 so 解压到可加载的目录 |
| Android 12+ 后台启动限制 | 崩溃后进程往往处于后台,startService / 甚至 startForegroundService 都可能被拒;「下次启动再上传」会大面积滞留 |
| 初始化盲区 | loadLibrary 完成、信号 handler 挂上之前的崩溃无法捕获;attachBaseContext 里加载的 so 正好落在这段窗口 |
| OEM 进程管理 | 厂商可能很快杀掉崩溃后拉起的短命进程,写 dump 的子进程必须尽快完成 |
到这里可以收束成一句:Android native crash 监控的本质,是在「已损坏的 Linux 进程 + 不可再安全 fork 的 ART」里,用尽量少的信号安全操作,把现场交给一个足够干净的进程。 脏克隆能做的事很少;只有 exec 换映像之后,才谈得上完整的写出、状态管理和当次上传。这些取舍会在后文 Breakpad / Crashpad 方案中落到具体实现上。
Breakpad 方案
Breakpad 是 Google 早期的跨平台 crash 采集套件,Chrome、Firefox 以及大量 Android SDK 都用过。它的历史贡献是两件事:统一的 minidump 容器,以及配套的 dump_syms / minidump_stackwalk 符号化工具链。客户端设计说明见官方 Client Design。
整体架构
+---------------------------+ +----------------------------+
| 业务进程 (in-process) | | 下次启动 / 独立 sender |
| | | |
| - 安装 ExceptionHandler | | - 扫描 dump 目录 |
| - 收到致命信号 | | - HTTP 上传 minidump |
| - clone 子进程 | | |
| ptrace 读父进程内存 | | |
| 写 *.dmp +--------->| |
+---------------------------+ +----------------------------+
|
v
+------------------+
| 服务端 processor |
| dump_syms |
| minidump_stackwalk
+------------------+
客户端按平台分成不同的 ExceptionHandler:
| 平台 | 捕获入口 | dump 写出 |
|---|---|---|
| Linux / Android | sigaction |
clone 子进程 + ptrace + minidump writer |
| Windows | SetUnhandledExceptionFilter / VEH |
常在独立线程调 MiniDumpWriteDump 或自研 writer |
| macOS | Mach exception port | 独立线程写 minidump |
设计原则(官方 client 文档的原意)在 Android 上被执行得非常彻底:
- 崩溃后禁止使用应用堆;
- 崩溃线程本身几乎什么都不做,尽快切到预分配栈 / 子进程;
- 不自己 walk 可能已经烂掉的栈,而是让 OS 协助转储寄存器和内存;
- dump 写完后用回调通知上层,真正的上传放到另一个还活着的进程。
Android 上一次典型崩溃:
致命信号到达崩溃线程
|
v
signal handler(备用栈,async-signal-safe)
|
+-- 可选:唤醒某条线程抓 Java 栈(这里已经开始不安全)
|
v
sys_clone(无 CLONE_VM) ──► 子进程 COW 拷贝
| |
| v
| ptrace 读父进程
| 写 minidump 到平面目录
| _exit
v
wait 子进程结束后 `reraise`,进程退出
dump 直接落到一个平面目录:文件在不在,就是「有没有待上报」。没有「写入中 / 写完 / 已消费」的区分。
minidump 格式
minidump 源自 Windows。文件头魔数为 MDMP(小端 PAMD 四字节),后面是目录,目录里每一项指向一条 stream。结构体层面以微软 MINIDUMP_HEADER 为准;Breakpad 在 Linux 上复用同一布局,使一套 processor 能吃掉全平台 dump。符号文件格式见 Breakpad Symbol Files。
+------------------+
| Header | Signature = 'MDMP' Version StreamCount
+------------------+
| Stream Directory | [Type, Size, RVA] × N
+------------------+
| Stream 0 | 例如 ThreadList
| Stream 1 | 例如 ModuleList
| Stream 2 | 例如 Exception
| ... |
+------------------+
常见 stream:
| Stream | 作用 |
|---|---|
| ThreadList | 每个线程的 ID、栈内存描述、上下文(寄存器) |
| ModuleList | 已加载 so / dll 的基址、大小、文件名、调试 ID |
| Exception | 异常线程、信号编号、si_addr、崩溃时上下文 |
| SystemInfo | CPU、OS 版本字符串(csd_version) |
| MemoryList / MemoryInfo | 栈内存切片、映射表 |
| MiscInfo / BreakpadInfo | 进程 ID 等补充 |
processor 并不需要完整 core dump。它用 Exception 里的 PC 当起点,按 ABI 往回 unwind;每帧的 PC 再去 ModuleList 里找落在哪个 so 的哪个相对偏移。相对偏移 才是后面符号化的钥匙。
自定义业务字段没有一等公民。很多 Android 集成会把版本、ABI、进程名、甚至 Java 栈 拼进 SystemInfo 的 OS 版本字符串。这个字段本来只该放 "#1 SMP PREEMPT aarch64" 这类内核描述,容量通常只有数 KB,还要先扣掉真正的 OS 文本。这是后文「字段格式限制」的根源。
符号化工具链
minidump 里没有函数名。函数名在构建产物的 DWARF / PDB 里。Breakpad 把这件事拆成两步。
dump_syms
带 DWARF 的 libfoo.so
|
v
dump_syms
|
v
libfoo.so.sym (文本,MODULE / FUNC / 行号 / STACK CFI)
.sym 是精简过的 ASCII。首行类似:
MODULE Linux arm64 <build-id> libfoo.so
build-id 必须和 minidump ModuleList 里的调试 ID 一致,否则 minidump_stackwalk 会报 WARNING: No symbols。APK 里随包发布的 so 通常是 strip 过的,不能当符号源;CI 必须把未 strip 的 debug so 单独归档。
minidump_stackwalk
crash.dmp + symbols/
|
v
minidump_stackwalk
|
v
可读栈:
Operating system: Android
Crash reason: SIGSEGV / SEGV_MAPERR
Crash address: 0x0
Thread 12 (crashed)
0 libfoo.so!Foo::bar(int) + 0x2c
src/foo.cpp:128
1 libfoo.so!Foo::entry() + 0x10
2 libart.so + 0x21eb28
与 NDK 工具对比
| 工具 | 输入 | 输出 | 适合 |
|---|---|---|---|
addr2line |
单个 so + 一个或多个地址 | 文件:行号 |
已经知道「哪个 so、哪个绝对/相对地址」 |
llvm-addr2line / llvm-symbolizer |
同上,DWARF 更完整 | 函数名、inlined 栈、行号 | 单模块精确定位 |
ndk-stack |
tombstone / logcat 文本 + 符号目录 | 把 pc 0000xxxx libfoo.so 换成函数 |
系统 tombstone、logcat,不是 minidump |
minidump_stackwalk |
minidump + .sym |
全线程栈、崩溃原因、模块列表 | 线上 minidump 主路径 |
ndk-stack 对 minidump |
不适用 | — | dump 不是文本 |
容易混淆的两点:
- tombstone ≠ minidump。 Debuggerd 写的 tombstone 是文本,
ndk-stack吃这个。Breakpad / Crashpad 写的是二进制 MDMP,必须走minidump_stackwalk。两者可以并存,但工具不能混用。 - 绝对地址不能直接扔给
addr2line。 ASLR 下每次加载基址不同。正确做法是相对偏移 = PC - module_base,再对 未 strip 的同一个 so 做addr2line -e libfoo.so -f -C 0x相对偏移。minidump_stackwalk已经替你完成「找模块 + 减基址 + 查 .sym」。
若只有相对偏移、没有 .sym,minidump_stackwalk 仍能输出 libfoo.so + 0xe9c18c。这对聚类有用,对人读不够。
方案局限
在 Android 上把 Breakpad 用上线之后,问题会集中暴露在四类。
Java 栈捕获
很多 native 崩溃的真正调用链在 Java:JNI 传入非法参数、Java 层驱动 native 组件。minidump 只有 native 帧,后台需要 Java 栈才能还原上下文。
常见做法是在 signal handler 路径里调 Thread.getAllStackTraces()。无论是 handler 里当场 pthread_create,还是预创建线程后用 pthread_cond_signal + pthread_join 唤醒,都踩在 POSIX 红线上:
| 操作 | async-signal-safe | 风险 |
|---|---|---|
| handler 内直接 JNI | 否 | 崩溃线程上重入 ART,二次崩溃则 native 栈也丢 |
pthread_create |
否 | 若崩溃时 libc 正持有线程链表锁,直接死锁,超时也救不了 |
pthread_mutex_lock / pthread_cond_signal |
否 | 与崩溃线程抢同一把锁则死锁 |
pthread_join 且无超时 |
否 | JNI 挂住则整个 dump 路径挂住 |
即便抓到了,还要塞进 OS 版本字符串那种共享 buffer。OS 文本占一两百字节,自定义字段头再占一些,留给 Java 栈往往只有约 3KB;一次主线程栈动辄 10~50KB。于是出现「Java 层以为给了 3KB,写进 dump 后只剩 2KB 出头」的多层静默截断。标准 minidump_stackwalk 也不会把这段当成结构化字段解析。
无法当次上传
clone 子进程不是一个正常进程:不能放心跑完整 HTTP 栈,不能复用 App 里现成的 curl / TLS。上传只能等:
- 用户下次打开 App,主进程扫 dump 目录;
- 或者拉起一个后台 Service。
办公类 App 可能数日才被打开一次;Android 12+ 对后台 startService 更苛刻。结果是:dump 在本地,后台不可见。Breakpad 的进程模型本身没有给「崩溃当次、在干净进程里走完整网络库」留位置。
字段格式限制
自定义数据走 OS 字符串,带来一串连带问题:
- 所有字段共享一个定长 buffer,互相踩;
- 用换行、
KEY: VALUE、竖线之类的自创分隔,值里一旦出现同样字符,后台解析就错; - 升级 Breakpad 源码时,这些侵入点分散在 writer / format / processor 多个文件,合并成本高。
拉进程天花板
clone 得到的是崩溃进程的脏克隆。子进程能稳定做的,上限就是 ptrace + 写文件。在这之上叠加「抓 Java 栈、写数据库状态、当次 HTTP 上传」,只会让信号安全问题越来越不可控。
平面目录还有状态盲区:clone 子进程写到一半被杀,半成品和完整 dump 混在一起,后台解析失败,也无法统计「因写入中断丢失」的数量。
这些限制不是调参能消掉的。Chromium 在 2019 年前后把 Android 采集切到 Crashpad,本质是换进程模型,而不是换一个更好的 minidump writer。
Crashpad 方案
Crashpad 是 Breakpad 的继任者,目标仍是写出同一套 minidump,但把「写 dump」从崩溃进程里搬到 独立 handler 进程。设计总览见 Overview Design。
整体架构
业务进程 (client) handler 进程 (out-of-process)
+---------------------------+ +----------------------------------+
| 初始化: | | exec 后的全新映像 |
| - 打开 crash database | | |
| - 安装信号 handler | socket | - ptrace 读业务进程 |
| - 预创建 Java 采栈线程 | / pipe | - 写 minidump 到 database/new/ |
| | | - rename 到 pending/ |
| 崩溃: | | - (可选)当次上传 |
| - 信号里只通知 handler +----------->| - 标记 completed / 退出 |
| - 不写 dump、不跑 HTTP | | |
+---------------------------+ +----------------------------------+
| |
| 失败兜底 | 成功则后台已可见
v v
下次冷启动扫 pending 上报服务端
和 Breakpad 的对照:
| 维度 | Breakpad | Crashpad |
|---|---|---|
| dump 写入点 | clone 子进程(脏克隆) |
fork + exec 后的干净 handler |
| 崩溃进程职责 | 几乎承担全部写出 | 只发通知,可选抓 Java 栈 |
| 自定义字段 | 侵入 writer,塞 OS 字符串 | Annotation:进程参数 + 共享内存 KV |
| 状态 | 平面目录 | new / pending / completed |
| 多进程 | 每进程自己 handler | 可共享同一 database 目录,文件锁协调 |
| 解析 | minidump_stackwalk |
同一套,minidump 二进制兼容 |
| 维护 | 基本停滞 | Chromium 持续维护 |
Crashpad 自带 HTTP 上传。多数团队不会直接用:已有 curl / TLS、已有鉴权与打包格式、已有自己的崩溃后台。正确切分是:只用 Crashpad 的捕获、minidump、database;上传换成自己的实现。因为 handler 已经是正常进程,这件事第一次变得可行。
改进点
拉进程方式
Android 上 Crashpad 并不常驻一个 handler 进程(常驻容易被 OEM 杀掉,也浪费内存),而是 崩溃时再拉:
| API | 机制 | 版本 |
|---|---|---|
StartHandlerAtCrash |
fork + exec handler 可执行文件 |
通用 Linux,需要磁盘上有二进制 |
StartJavaHandlerAtCrash |
fork + exec /system/bin/app_process,跑一个 Java 入口类 |
覆盖 minSdk 较低的 App |
StartHandlerWithLinkerAtCrash |
通过 linker 加载 handler so | API 29+ |
StartJavaHandlerAtCrash 的路径:
signal handler (业务进程)
|
v
fork()
|
+-- 子进程: 关闭继承来的多余 fd
| 复位致命信号为默认动作
| execve("/system/bin/app_process64", argv, envp)
| |
| v
| 全新 Java Runtime
| 加载 handler 入口类
| 绝对路径 load handler 所需 so
| ptrace 业务进程,写 dump
| (可选)dump 完成后再做内联上传
|
+-- 父进程: `waitpid`,然后 `reraise`
exec 之后,handler 拥有独立堆、独立 C++ 运行时,必要时还有独立 ART。它可以:
- 走完整的 minidump writer,而不必避开
malloc; - 维护三态数据库(目录
rename、锁、元数据); - 在 dump 写完之后 跑 HTTP——这就是内联上传的前提。
这不是 Android 四大组件的启动路径。app_process 裸启动 不会 自动带上 APK 的 ClassLoader 和 linker namespace,必须在 exec 前备好:
CLASSPATH=<apk 路径> # 否则找不到 handler Java 类
# 故意不设 LD_LIBRARY_PATH
# so 用绝对路径 dlopen / System.load
业务进程初始化时就可以把这些环境变量算好,崩溃时原样传给子进程。路径在解压完成前就是确定的,不必把解压放在信号路径里。
数据协议
Crashpad 为自定义元数据准备了正式通道,而不是继续滥用 OS 字符串。
业务进程 handler 进程
-------- ------------
静态字段 (ABI / 进程名 / 版本)
→ 命令行 process annotations ----► 直接读参数
动态字段 (崩溃瞬间 Java 栈)
→ StringAnnotation<N> 共享内存
地址固定、容量编译期确定 ----► ptrace 按布局读出
StringAnnotation<N> 的模板参数既是容量,也是跨进程内存布局。handler 和 client 必须对同一套 N 达成一致。
| OS 字符串(旧) | Crashpad Annotation(新) | |
|---|---|---|
| minidump 位置 | SystemInfo.csd_version | MD_CRASHPAD_INFO stream |
| 结构 | 单字符串拼接 | KV,字段隔离 |
| 容量 | 共享数 KB | 每字段独立,编译期指定 |
| 标准工具 | 当普通 OS 描述展示 | Crashpad 生态可解析;老 Breakpad processor 默认不认 |
迁移期可以双写:handler 仍把关键字段追加进 OS 字符串,同时客户端保留 Annotation。这样老后台零改动即可读到字段;等后台能解析 MD_CRASHPAD_INFO 后再收敛。长期应以 Annotation 为准,OS 字符串只放真正的内核版本。分阶段落地见「客户端部署 · 兼容迁移」。
Java 栈捕获
目标不变:崩溃瞬间拿到 Java 栈。约束也不变:信号上下文几乎什么都不能做。Crashpad 把线程创建提前到初始化,崩溃时只用 pipe 敲门(pipe 用法见前文 async-signal-safe)。
初始化
std::thread 预创建 capture 线程(阻塞在 pipe 读端)
JNI_OnLoad 缓存 jclass / jmethodID
(native 线程只有 System ClassLoader,必须在 Java 线程上 FindClass)
崩溃(信号上下文,仅 async-signal-safe)
write(pipe_fd) 唤醒
nanosleep 短轮询 带超时等待
写 crash_thread 标注 tid / 线程名 / 是否主线程
capture 线程(普通线程,可 JNI)
AttachCurrentThread
调用缓存好的 getAllStackTraces 一类方法
写入 managed_stack annotation
三种方案对比:
| A. handler 里直接 JNI | B. 崩溃时 pthread_create |
C. 预创建 + pipe |
|
|---|---|---|---|
| 信号安全 | 否 | pthread_create 否 |
write / nanosleep 是 |
| 最坏情况 | native + Java 全丢 | 同左(死锁发生在 pthread_create 内部,超时无效) |
Java 栈记 [capture_timeout],native dump 照写 |
| 常态开销 | 无 | 无 | 一条阻塞线程,栈很小,不占 CPU |
C++ 的 condition_variable、std::this_thread::sleep_for、C++20 semaphore / atomic::wait 都没有 async-signal-safe 承诺。Windows 不需要这套,因为异常过滤器不在信号上下文。这是 POSIX 特有的设计税。
数据侧,Java 栈应独占一个 annotation,例如 8KB,不要再和 OS 文本抢 buffer。兼容老后台时,OS 字符串里可以只放崩溃那一条 Java 线程的截断副本。
Database 状态管理
Crashpad 用目录当状态机,一次 rename 就是一次原子转移:
crashpadDb/
├── new/ handler 正在写,或写到一半被杀
├── pending/ 完整 dump,等待上传或入队
└── completed/ 已消费,避免重复
写出开始 ──► new/<uuid>.dmp
│
│ 写完并 fsync
v
pending/<uuid>.dmp ← 唯一「确定完整」的集合
│
├── 当次上传成功 ──► completed/
└── 失败 / 未直传 ──► 下次启动扫 pending,入队后再 completed/
和 Breakpad 平面目录比:
| 问题 | 平面目录 | 三态 DB |
|---|---|---|
| 半成品 | 无法区分 | 留在 new/,可当孤儿清理 |
| 是否已上报 | 文件还在?不可靠 | pending vs completed |
| 多进程竞争 | 容易双传、双删 | 文件锁 + UUID 一条报告 |
completed 的语义要定义清楚。常见两种,不要混用:
- 已入本地上传队列:拷贝到 App 自己的待传目录后即可 mark,真正 HTTP 由原有管线负责重试。
- 已达后台:仅当 HTTP 成功才 mark;失败必须留在
pending,供下次冷启动兜底。
内联上传走「已达后台」语义,Java 兜底走「已入队」语义。互斥规则是:内联上传进行中写一个短 TTL 的 guard 文件,扫库方看到 guard 就跳过,避免双传。
Database 还要有维护:清理 new/ 残留、过期 lock、按 TTL 删除过老的 pending / completed,避免频繁崩溃撑满磁盘。维护放在主进程扫库前,不要放进「纯查询 pending 列表」的路径,也不要和 handler 当次写盘抢同一把锁太久。
与 Breakpad 的兼容性
Crashpad 没有重新发明 dump 格式。这是能平滑替换采集端的前提。
| 层 | 兼容关系 |
|---|---|
| 磁盘文件 | 仍是 MDMP + 标准 stream(Thread / Module / Exception / Memory / SystemInfo) |
| 崩溃原因 | 同样编码为 POSIX 信号 + si_code |
| 模块 ID | 同样用 ELF build-id,对得上同一批 .sym |
| 扩展 stream | MD_CRASHPAD_INFO 是 附加 的;老 processor 忽略未知 stream 即可 |
| 符号化 | 直接复用 dump_syms + minidump_stackwalk,不必为 Crashpad 再做一套 |
| 聚类 | 输入仍是 minidump_stackwalk 文本,规则可以不变 |
也就是说:换成 Crashpad 采集后,服务端工具链和聚类输入可以不变;自定义字段从「塞进 OS 字符串」演进到 Annotation,是协议层的事。如何分阶段切过去,属于客户端 / 后台联调落地,见「客户端部署 · 兼容迁移」。
Crashpad 客户端部署
把 Crashpad 嵌进 Android App,工程问题不比采集协议少:构建系统、handler 的 so 从哪来、怎么和现有网络栈对接、怎么控制包体积和冷启动,以及如何在不打断后台的前提下从 Breakpad 迁过来。
编译集成
上游 Crashpad 跟 Chromium 一样用 GN + Ninja。对已经用 CMake / NDK 的 Android 工程,GN 是额外的世界:要拉 depot_tools、对齐一整套 Chromium 式依赖,和 AGP 的 externalNativeBuild 也不合拍。
社区有带 CMake 的 fork:backtrace-labs/crashpad(常用 backtrace 分支),可以把 Crashpad 当普通 add_subdirectory 依赖编进工程,产出 client / handler / minidump / snapshot / util 等静态库。相对 Chromium 原版,它主要多了两块:
| 相对上游 | 说明 |
|---|---|
| CMake 构建 | 真正有用的部分:免 GN,可直接挂 NDK / Gradle |
| 产品向扩展 | 官方 README / 仓库描述里还有 file attachment、面向 Backtrace 上报链路的改进等;Android 示例里甚至出现 HTTPS_TRANSPORT=OPENSSL 一类选项 |
接入时按需裁剪即可:用其 CMake 编进工程,去掉用不到的厂商扩展与上传相关目标,只保留自己需要的采集能力。
自定义内联上传
Crashpad 内置上传是简单的 multipart HTTP,认的是它自己的服务器协议。接入方通常要:
- 复用 App 里已经链接的 curl / ssl / crypto,不把第二份 TLS 打进 crash so;
- 走已有鉴权、打包、域名和后台入口;
- 崩溃当次送出 dump,失败再交给下次启动的 Java 管线。
handler 是 app_process 裸进程,没有 Framework linker namespace,也常常没有 nativeLibraryDir 里的实体 so(extractNativeLibs=false)。因此内联上传的前置条件是:把 handler 运行时用到的 so,从 APK 解到应用私有目录,再用绝对路径加载。
Handler so 加载
整体流程
主进程冷启动(后台线程,不挡 UI)
|
v
打开 APK(ZipFile)
读取 lib/<abi>/ 下各 so 的 ZipEntry.CRC
与本地 manifest 比较
|
+-- 一致:跳过解压(约数毫秒);仍检查 so 只读位,可写则补 setReadOnly
+-- 缺失或 CRC 变化:解到 staging,全成功后再 rename 到正式目录
写出后立刻去掉写权限
|
v
handler 进程(崩溃当次)
用绝对路径 System.load / dlopen 已解压的 so
(含 handler 自身及 curl / ssl / crypto 等依赖)
解压与 CRC 校验
为什么用 ZipEntry CRC-32 而不是对文件再算一遍摘要:
- 要回答的问题是「APK 里的 bundle 换没换」,不是密码学防篡改;
- CRC 写在 zip 中央目录,读元数据即可,冷启动不必扫数 MB 的 so;
- ELF 体量下碰撞可忽略;任一 so 单独升级也会改对应条目的 CRC。
manifest 应 逐个 so 一行。bundle 里任一依赖单独升级时,CRC 也会变,才能触发重解压。落盘用 staging + rename,避免半包被加载。
动态加载权限
从 APK 解压到 filesDir 的 so,目录本身往往仍可写,但 被 dlopen / System.load 映射的那份文件不能保持可写。较新的 Android(高 targetSdk / 平台侧的 Safer Dynamic Code Loading 一类策略)会拒绝加载「可写的原生代码文件」,典型表现是 load 失败或直接抛错,而不是静默降级。
原因很直接:可写 + 可执行 ≈ 运行时被篡改、注入的攻击面。系统要求 native 库文件在加载前满足更严的 W^X 文件侧约束——目录可写以便更新,文件本身只读。
| 步骤 | 做法 |
|---|---|
| 解压刚完成 | 对每个 .so 调 File.setReadOnly(),或 chmod 去掉 owner write;再 dlopen |
| 覆盖升级重解压 | 先 setWritable(或 chmod 加写)才能 truncate/覆盖,写完再改回只读 |
| CRC 命中未重解 | 仍扫描一遍:旧版本留下的可写 so 也要补成只读 |
| 原子发布 | 先写 *.tmp / staging,rename 成正式名后再 setReadOnly |
目录保持可写、文件只读,和「主进程解压、handler 绝对路径加载」是一套组合拳:缺任何一环,高版本机型上 handler 都可能起不来。
动态加载路径
app_process 裸进程没有 APK 的 linker namespace,也常常没有 nativeLibraryDir 里的实体文件。handler 侧加载 so 时,路径上要特别注意:
- 用绝对路径
dlopen/System.load,不要依赖LD_LIBRARY_PATH。把业务库目录写进环境变量,容易让系统二进制在启动阶段解析到错误的libcrypto等依赖(见「开进程方式对比」)。 - 路径在初始化时就算好,经环境变量或配置文件传给 handler;崩溃信号路径里不要再去拼 APK 路径或解压。
- 加载前确认目标文件只读(见上一节);路径对了但文件可写,高版本上同样会失败。
- 依赖库(如 curl / ssl / crypto)与 handler 主库放在同一解压目录时,同样用该目录下的绝对路径打开,保持查找顺序可控。
体积裁剪
Crash 采集 so 会打进所有 ABI。体积优化是接入评审里最容易被问到的一项。Crashpad 上游 GN 默认就是无 RTTI、无异常;CMake 接入要对齐,不要用 NDK 默认的 -funwind-tables 把表又加回来。
非 Debug:
-Oz
-ffunction-sections -fdata-sections -fvisibility=hidden
-Wl,--gc-sections
C++:
-fno-exceptions
-fno-rtti
-fno-asynchronous-unwind-tables
-fno-unwind-tables
说明:
- 关掉的是 本 so 自己的 异常与 RTTI。业务 so 的栈仍然靠各自的 DWARF / CFI 还原,不受影响。
-fno-unwind-tables会让本 so 自崩时更难看;采集库应用熔断而不是依赖自己的 C++ 异常。-Oz+ gc-sections 对模板和未引用 writer 很有效。
代码裁剪可以按「后台暂时不需要的 stream」来做:
| 可裁 | 条件 |
|---|---|
| Crashpad Info / Annotation writer | 仍在用 OS 字符串兼容 Breakpad 后台 |
| User extension stream | 业务没写自定义 stream |
| Handle stream | Linux/Android 本来就是空的 |
| UMA / histogram | 不接 Chromium 指标 |
| handler 内置 prune 线程 | 改由 Java 侧 TTL 清理 database |
不要裁:Thread / Module / Exception / MemoryInfo、以及为了读 Java 栈所必须的 Annotation 读取 路径。裁 writer 不等于裁 snapshot 读取。
启动性能
业务进程冷启动路径上,每一 KB 的 .so 映射和每一毫秒的 JNI_OnLoad 都会进启动曲线。若把 curl / TLS、组包上传等一并链进冷启动就要加载的 so,体积和初始化开销会明显抬高。
可选做法是:冷启动只加载采集必需部分;与上传相关、体积较大的逻辑放到独立 so,在 handler 写完 dump 之后再按绝对路径 dlopen。解压 bundle 放后台线程:CRC 命中时只有数毫秒,首次安装或覆盖升级才解数 MB,UI 线程不必等待。
信号 handler 的安装仍然要尽量靠前,否则 attachBaseContext 到 loadLibrary 之间的 JNI 崩溃是盲区。拆 so 解决的是 映射体积,不解决 注册时机。
兼容迁移
前文「与 Breakpad 的兼容性」说明了二进制与工具链可复用。真正上线时,自定义字段从 OS 字符串迁到 Annotation,需要客户端与后台对齐节奏,避免「采集换了、面板空了」。
实践上的迁移顺序:
- 兼容期:客户端改 Crashpad,dump 仍按 Breakpad 可读方式写 OS 字符串;后台零改动,stackwalk 二进制也不换。
- 双端升级:后台增加 Annotation stream 解析;客户端关掉「滥用 OS 字符串」,Java 栈用满独立容量。
- 收尾:删除兼容分支,采集与解析都走标准 Crashpad 字段。
兼容期可以和「体积裁剪」一起做:顺手裁掉「只为 Crashpad 扩展 stream 服务」的 writer(不写 MD_CRASHPAD_INFO),减小 so 体积。只要 ModuleList 和 Exception 还在,stackwalk 和聚类就不会断。双写期内 dump 里会有一份截断的 OS 字符串副本,这是兼容税,双端升级后再去掉。
服务端部署
客户端无论 Breakpad 还是 Crashpad,服务端看到的都是 minidump。服务端要做两件事:符号化,以及把海量事件收成 Issue。
minidump_stackwalk 部署
minidump_stackwalk 是本地可执行文件,动态链接 glibc。构建机和运行机的 glibc 必须兼容:在新发行版上编出来的二进制,拷到老清洗机上会报 GLIBC_2.xx not found。
原则:
- 在与线上相同或更老的 glibc 环境里编译,不要在开发者的 WSL / 新 Ubuntu 上直接出包。
- Crashpad 与 Breakpad 共用这一支工具,采集端升级 不要求 换
minidump_stackwalk;换的是「dump 从哪来」,不是「怎么 unwind」。 - 符号来自 CI 归档的 debug so →
dump_syms→.sym,按 build-id 索引。缺符号时minidump_stackwalk仍能给出so + 相对偏移,只是没有函数名。
用容器固定工具链是目前最省事的办法。Docker 与 Podman 命令几乎一样,Rootless Podman 更适合共享构建机。
# 示例:在老发行版容器里编,挂载源码目录
podman pull centos:7 # 或与线上一致的基础镜像
podman run --rm -it \
-v $PWD/breakpad:/src:Z \
centos:7 bash
# 容器内安装匹配的 gcc/cmake 后
cd /src && cmake -S . -B build && cmake --build build
# 产物拷到清洗机,与线上 glibc 对齐后再替换
验收至少包括:
lddminidump_stackwalk在目标机无not found;- 用一份已知 dump + 对应
.sym,输出中出现函数名而不是纯偏移; - 对无符号 dump,输出仍含
Thread N (crashed)和libxxx.so + 0x...,聚类兜底不至于全空。
聚类规则
符号化之后,聚类仍然建议 极度克制:只看崩溃线程的第一帧特征。后面的 ART / libc 尾帧噪声极大,编进 fingerprint 会把同一缺陷拆成无数 Issue。
优先级从高到低:
定位 "Thread xx (crashed)"
|
v
跳过 tid / 寄存器等非栈帧行
|
v
取「第一个 so / 模块帧」 ─────────────────────────┐
| │
+-- 能还原出函数名? │
| 是 → 用「模块!函数」 (只看这一行)│
| 否 → 用「so + 相对偏移」 │
| │
+-- 整段找不到任何 so 帧 ──► 用绝对 Crash address
| 优先级 | 特征 | 例子 | 稳定性 |
|---|---|---|---|
| 优先 | 还原后的符号,仅第一行 | libfoo.so!Foo::bar |
最好。函数名在版本间比指令偏移稳 |
| 次选 | 第一个 so 的相对地址 | libfoo.so + 0xe9c18c |
中。同一构建可合并;so 重编后会拆开 |
| 兜底 | 绝对崩溃地址 | Crash address: 0x0 |
最差。仅当栈完全没有模块信息 |
相对地址和绝对地址不是详略之别,是 是否扣掉加载基址:
PC(绝对) = 模块加载基址 + 相对偏移
(每次 ASLR 都变) (对本 so 文件是固定的)
例如:
libfoo.so 被 mmap 到 0x6f00000000
崩溃 PC = 0x6f000e9c18c
相对偏移 = 0xe9c18c ← dump_syms / addr2line / 聚类都用这个
另一次启动基址变成 0x7000000000
崩溃 PC = 0x700000e9c18c ← 绝对值不同,相对偏移仍是 0xe9c18c
因此:
- 绝对地址 把 ASLR 编进 IssueID,同一缺陷会被拆成碎片,只适合「栈上连 so 都没有」的绝境(例如 PC 落在已卸载内存,ModuleList 对不上)。
- 相对地址 对「同一构建、同一 so」稳定,跨版本会因编译器布局变化而拆 Issue——这是没有符号时的预期代价。
- 函数名 跨小版本更稳,但内联、模板实例化、strip 策略仍可能造成合并或拆分,所以仍然 只取第一行,不要把整栈 hash 进去。
系统库要不要跳过,是产品选择。跳过 libart.so / libc.so 能让业务缺陷更突出,但也更容易在「纯运行时崩溃、栈顶就是 art」时掉进绝对地址兜底。一种简单策略是:不跳过系统库,但仍然只看第一帧——实践中 (crashed) 后的第一个 so,通常就是出问题的那一个。
收束
POSIX 信号 / Mach exception / SEH
|
v
+---------------+----------------+
| Breakpad: clone 脏克隆写 dump |
| Crashpad: fork + exec 干净 handler|
+---------------+----------------+
|
v
minidump (MDMP)
new → pending → completed
|
+---------------+----------------+
| 当次:handler 内联上传(可选) |
| 兜底:下次启动 Java 管线 |
+---------------+----------------+
|
v
dump_syms + minidump_stackwalk
|
v
第一帧:符号 > so+相对偏移 > 绝对地址
|
v
Issue
可以记住三句话:
- 捕获 的上限由进程模型决定:脏克隆里做不了正常 C++,干净 handler 才能做数据库和当次上传。
- 符号化 的上限由构建决定:strip so 进包,debug so 进符号仓库,stackwalk 只是把两者对上。
- 聚类 的上限由第一帧决定:能还原就用函数名,否则用相对偏移,绝对地址只当最后兜底。
Breakpad 把 minidump 和工具链做成了行业事实标准;Crashpad 在不破坏这套标准的前提下,把 Android 上最难的那一段——崩溃瞬间的进程模型——换成了可以继续往上叠功能的底座。客户端部署的工作,则是把这个底座嵌进已有的构建、网络库和包体积预算里,而不是重新发明一种 dump。