Native Crash 监控方案演进
很多年前的文章已经介绍过 Android 捕获 Java 层的 crash,简单说就是系统提供了一个未捕获异常回调,其实 OC 也有类似的东西。
而我们这里要聊的 Native Crash 监控则要复杂得多:既得自己手动处理堆栈抓取、编码、还原,还得考虑各个系统的进程模型差异。
基本的 Native Crash 监控
POSIX 信号
信号(signal) 是一种源自 Unix 的古老 IPC 方式:
flowchart LR
Src["信号源<br/>用户 / 系统 / 其它进程"] --> Ker[内核信号管理]
Ker --> Proc[目标进程]
Proc --- Trap["已注册的 handler"]
虽然现在已很少用于用户侧进程通信场景,但仍用于系统对进程控制的场景。
对 native crash 来说,最常见的信号来源是:
- 非法访存、对齐错误、非法指令等硬件异常,由内核转换成信号;
abort()、assert()失败、部分运行时自检,主动发送SIGABRT;- 调试器断点、错误系统调用等。
不是普通函数调用:
- 它会打断当前线程,切到该线程上注册的处理函数;
- 处理函数返回后,线程从被打断处继续;
- 若处理函数选择结束进程,内核按默认动作终止。
不是普通多线程异步:
- 信号是对某条线程的抢占式打断:handler 跑在被打断的那条线程上,期间该线程原代码停住,二者不会在同一条线程上真并行;
- 多线程是多条执行流可同时推进,共享数据靠锁 / 原子 / 内存序。其它线程在 handler 期间仍可继续跑;
常见致命信号
下表是 Android / Linux native crash 监控通常会接管的信号。默认动作几乎都是:终止进程;
在 Linux 上若允许(如 ulimit -c / core_pattern 打开),内核还可能再写一份 core dump(进程崩溃瞬间的内存映像,供 gdb 事后调试)。
Android 上传统 core 常常关掉或很少用,更多走 debuggerd / tombstone;和本文的 minidump 不是同一条链路。
| 信号 | 编号(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 |
是 |
SIGQUIT |
3 | 终端 Ctrl+\;Android 上 ANR 常用此信号让进程出 Java/native 栈(其它系统用途因平台而异) | 多为用户态投递 | 视策略;贸然独占可能干扰系统 ANR dump |
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%」的物理上限之一。
信号监听
注册致命信号回调,现代代码用 sigaction(),而不是老的 signal()。
两者都能挂 handler,但能力差一截:
signal() |
sigaction() |
|
|---|---|---|
| 回调形态 | 基本只有 void(int) |
可设 SA_SIGINFO,拿到 siginfo_t* 与 ucontext_t* |
| 标志位 | 无 | SA_ONSTACK、SA_SIGINFO、SA_RESTART 等 |
| 执行期屏蔽 | 语义因平台而异 | sa_mask 明确可控 |
| 旧动作 | 难可靠保存 / 链式恢复 | 第三个参数原子写出旧 struct sigaction |
| 可移植性 | System V / BSD 历史行为不一致 | POSIX 明确,跨 Linux / Android 行为稳定 |
crash 监控几乎必定要 备用栈(SA_ONSTACK)和 崩溃上下文(SA_SIGINFO → PC / SP / si_addr),还要把旧 handler 存下来供再次 raise()。这些 signal() 做不到或做不稳,所以一律走 sigaction()。
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* uctx) {
/* 只做 async-signal-safe 的事:通知另一条线程 / fork 子进程 */
/* 由那一侧写 dump */
/* 恢复旧 handler,再次 raise,让进程按默认动作退出 */
}
sigaction() 本身:成功返回 0,失败返回 -1 并设置 errno。第三个参数 &old_sa 可为空;非空时写出被替换前的旧动作,供日后恢复 / 再次 raise()。
带 SA_SIGINFO 时,回调是三参数、返回类型为 void(没有「返回值传给内核」这一说;从 handler 返回 表示继续执行被打断处,crash 路径上通常不会这么做,而是恢复默认动作后再 raise() / _exit())。
| 参数 | 类型 | 含义 |
|---|---|---|
signo |
int |
当前信号编号,如 SIGSEGV |
info |
siginfo_t* |
信号详情。si_signo、si_code;对 SIGSEGV / SIGBUS,si_addr 多为出错虚址 |
uctx |
void* |
实际为 ucontext_t*。内核填好的 崩溃瞬间 CPU 上下文 |
ucontext_t 里与采栈直接相关的是 uc_mcontext:架构相关的寄存器快照。Linux / Android 上由此读取:
- PC(Program Counter):当前要执行 / 已停住的那条指令的地址,用来定位崩在哪个 so、哪个偏移;
- SP(Stack Pointer):当前栈顶;从这里往上读,才是该线程的栈内存;
- 以及 LR(Link Register)、FP(Frame Pointer)、通用寄存器等。
这是同线程、同进程下取线程上下文的主来源。把它当成只读快照用:不要在 handler 里依赖改完 ucontext 再返回来「修复现场」当常规方案;也不要假定此时还能安全 malloc() 或扫全进程 maps。完整字段见 sigaction() 对 SA_SIGINFO 回调的说明。
巡检与重装
后安装的 handler 会覆盖先安装的:WebView、厂商 ROM、其它 SDK 都可能抢 SIGSEGV。可靠的做法是:
- 安装时保存旧动作,dump 完成后再次
raise(),让信号继续走旧链; - 运行期在前后台切换等时机检测是否被抢占,必要时重装;
- 重装只覆盖自己,不更新第一次保存的旧动作——否则再次
raise()会链回自己,进程退不出去。
示意(省略错误处理,只写 SIGSEGV 一条):
static struct sigaction g_old_segv; /* 首次安装时保存的旧动作 */
static bool g_first_install = true;
/* 首次安装与重装共用:区别只在是否覆盖 g_old_segv */
void InstallCrashHandler(void (*on_crash)(int, siginfo_t*, void*)) {
struct sigaction sa = {};
sa.sa_sigaction = on_crash; /* SA_SIGINFO 形式 */
sa.sa_flags = SA_SIGINFO | SA_ONSTACK;
sigemptyset(&sa.sa_mask);
if (g_first_install) {
sigaction(SIGSEGV, &sa, &g_old_segv); /* 仅首次:旧动作落袋 */
g_first_install = false;
} else {
sigaction(SIGSEGV, &sa, nullptr); /* 重装:不再动旧动作 */
}
}
/* dump 完成后:恢复旧动作,让原信号走旧链(WebView / ROM / 默认动作) */
void RaiseAgain(int sig) {
sigaction(sig, &g_old_segv, nullptr);
raise(sig); /* 若旧动作是 SIG_IGN,raise 会返回 */
signal(sig, SIG_DFL); /* 兜底:退回默认动作,进程退出 */
raise(sig);
}
/* 前后台切换时巡检:当前动作还是我们的吗? */
void EnsureHandlerOwned(void (*on_crash)(int, siginfo_t*, void*)) {
struct sigaction cur;
sigaction(SIGSEGV, nullptr, &cur); /* 只查不装 */
if (cur.sa_sigaction != on_crash) /* 被其它库换掉了 */
InstallCrashHandler(on_crash); /* 重装;g_old_segv 仍是首装旧动作 */
}
async-signal-safe
前文已说过:信号处理既不是普通函数调用,也不是多线程那类异步。落到代码上,麻烦在于:
它可能插在 任意 指令之间——包括 malloc() 改空闲链表的半途、pthread_mutex_lock() 已持锁未入临界区、printf() 正写 FILE 缓冲。
若 handler 再调这些函数,轻则死锁,重则堆元数据二次损坏,连 dump 都写不出来。
POSIX(IEEE Std 1003.1)因此定义了 async-signal-safe:在信号上下文中,只有列入标准白名单的接口才保证可重入、可安全调用。名单之外的,一律视为未定义行为——「碰巧能跑」不算合规。完整列表可参考 Linux 手册 signal-safety。
与 volatile 的关系
之前这篇文章提过:C 的 volatile 面向的就是这类信号处理场景:但他主要为了防止编译器优化造成的内存可见性问题,而不保证操作的可重入和原子性。
常见可用
| 类别 | 示例 | 典型用途 |
|---|---|---|
| 退出 | _exit()、_Exit() |
dump 失败时立刻结束,不跑 atexit() |
| 文件 | open()、close()、read()、write()、fsync() |
写面包屑日志、唤醒 pipe() |
| 进程 | fork()、execve()、waitpid()、getpid()、gettid()(Linux) |
拉起写 dump 的子进程 |
| 信号 | sigaction()、sigprocmask()、raise() |
恢复旧 handler 后再次 raise() |
| 时间 | nanosleep()、clock_gettime()(部分实现) |
带超时的短等待 |
| 内存映射 | 一般 不要 在 handler 里新 mmap() 大块业务逻辑 |
预分配更稳 |
常见禁用
| 类别 | 示例 | 为什么危险 |
|---|---|---|
| 堆 | malloc() / free() / operator new / operator 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 / 再次 raise */
}
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()兜底时,才重新受白名单约束。
获取 native 堆栈
要把崩溃线程当时的调用现场读出来,最少拿到三样东西:线程上下文、栈内存、模块映射。
三要素从哪读
| 要素 | 最少要有 | 同进程从哪读 | 跨进程从哪读 |
|---|---|---|---|
| 线程上下文 | PC、SP;按需 LR / FP 等 | 信号回调的 ucontext |
ptrace() 取寄存器 |
| 栈内存 | 从 SP 起一段连续字节 | 按 SP 当指针直接读 | process_vm_readv() / ptrace() peek / /proc/<pid>/mem |
| 模块映射 | 各 so 基址、大小、路径;调试 ID | dl_iterate_phdr() |
/proc/<pid>/maps + so 内 build-id |
有了 PC 和模块基址,才能落到「哪个 so、相对偏移多少」;有了栈字节,才有还原调用链的输入。
ptrace()
跨进程读崩溃现场,几乎都绕不开 ptrace()。它让一个进程以「调试者」身份附着到另一个进程上,从而:
- 附着 / 停住目标:
PTRACE_ATTACH或子进程侧PTRACE_TRACEME,目标停住后寄存器与内存视图才稳定; - 读寄存器:
PTRACE_GETREGSET(推荐,按NT_PRSTATUS等取整组)或老接口PTRACE_GETREGS,拿到崩溃线程的 PC、SP 等; - 读内存:
PTRACE_PEEKDATA按字读取;大块栈更常用process_vm_readv()或/proc/<pid>/mem,ptrace()负责把目标停在可读状态。
同 uid 的 App 子进程读父进程,读完后要 PTRACE_DETACH,并避免在 attach 期间做与目标无关的重活——目标一直停着。
内核打上被 trace 标记,再借信号把目标停住;停住事件交给 tracer,由 waitpid() 收口。
flowchart TD
A["PTRACE_ATTACH / TRACEME"] --> B[内核给目标打上 traced 标记]
B --> C{怎样停住}
C -->|attach 常见| D[投递 SIGSTOP]
C -->|"断点 / 单步<br/>软件断点:arm brk / x86 int3"| E[CPU 异常或陷阱]
E --> F[内核转成 SIGTRAP 等]
D --> G[信号投递路径拦住目标]
F --> G
G --> H{目标正被 ptrace?}
H -->|是| I["停下事件交给 tracer<br/>不按默认杀/忽略"]
H -->|否| J[按普通信号处理]
I --> K["tracer:waitpid 看到 WIFSTOPPED"]
waitpid()
waitpid() 等待指定子进程(或同组进程)状态变化。和 ptrace() 搭配时,关心的是 停住,不只是进程退出:
- attach /
TRACEME之后调用waitpid(pid, &status, 0); - 用
WIFSTOPPED(status)判断目标因信号停下;WSTOPSIG(status)看是SIGSTOP、SIGTRAP还是致命信号本身; - 确认停下后再
PTRACE_GETREGSET、读栈、读 maps;做完PTRACE_DETACH或PTRACE_CONT。
没有这一步就去读寄存器,往往会读到仍在跑的线程,现场不一致。父进程等「写 dump 的子进程结束」时也会用 waitpid(),那是另一类用法:等的是 WIFEXITED / WIFSIGNALED,不是 stop。
process_vm_readv()
process_vm_readv() 按地址从另一进程批量读内存,比反复 PTRACE_PEEKDATA 更适合拉整段栈。调用方需有权限(通常同 uid,且目标已被 ptrace() 停下或配合)。跨进程路径里,寄存器用 ptrace() 取,栈字节优先用它来搬。
dl_iterate_phdr()
dl_iterate_phdr() 在本进程里遍历已加载 ELF:基址、dlpi_name、program headers,用来列模块。build-id 往往还要再读对应 so 的 .note.gnu.build-id。跨进程没有对等的一次调用,改解析对方的 /proc/<pid>/maps。
全流程与代码示意
两条读法:信号线程里直接读本进程,或另起进程 ptrace() 再读。后者是默认稳妥路径。
flowchart TD
Sig[致命信号] --> Choose{在哪读}
Choose -->|同进程| U["ucontext 取 PC / SP"]
U --> S1["按 SP 直接读栈"]
U --> M1["dl_iterate_phdr 列模块"]
M1 --> B1["按需读 so 取 build-id"]
S1 --> Done1[三要素在手]
B1 --> Done1
Choose -->|跨进程| N[信号里通知对端]
N --> Att["ptrace attach"]
Att --> W["waitpid:WIFSTOPPED"]
W --> Reg["PTRACE_GETREGSET 取寄存器"]
W --> Maps["解析 /proc/pid/maps"]
Reg --> S2["process_vm_readv 或 /proc/pid/mem 拉栈"]
Maps --> B2["打开 so 读 build-id"]
S2 --> Done2[三要素在手]
B2 --> Done2
Done2 --> Det[PTRACE_DETACH]
同进程示意(省略错误处理;寄存器布局依 ABI,以 arm64 为例):
void CaptureInProcess(ucontext_t* uc, void* stack_buf, size_t stack_cap) {
/* PC / SP:从 uc->uc_mcontext 按架构取出 */
uintptr_t pc = /* uc->uc_mcontext.pc */;
uintptr_t sp = /* uc->uc_mcontext.sp */;
size_t n = stack_cap; /* 实际上限还要受栈映射末端约束 */
memcpy(stack_buf, (void*)sp, n);
dl_iterate_phdr(OnPhdr, /*ctx*/ NULL); /* 回调里记录 dlpi_addr / dlpi_name */
/* build-id:打开 so,读 .note.gnu.build-id */
}
跨进程示意(子进程 attach 父进程;省略错误处理):
void CaptureCrossProcess(pid_t target) {
char path[64];
ptrace(PTRACE_ATTACH, target, NULL, NULL);
int status = 0;
waitpid(target, &status, 0); /* 等到 WIFSTOPPED */
struct iovec iov = { /* regs buf */, /* len */ };
ptrace(PTRACE_GETREGSET, target, (void*)(uintptr_t)NT_PRSTATUS, &iov);
/* 从 regs 取出 pc、sp */
/* 栈:二选一——syscall 批量拷,或读 /proc/<pid>/mem(都是跨进程读虚存,不是同一条底层路径) */
struct iovec local = { stack_buf, stack_cap };
struct iovec remote = { (void*)sp, stack_cap };
process_vm_readv(target, &local, 1, &remote, 1, 0);
/* 另一种常见写法:
snprintf(path, sizeof(path), "/proc/%d/mem", (int)target);
int memfd = open(path, O_RDONLY);
lseek(memfd, (off_t)sp, SEEK_SET);
read(memfd, stack_buf, stack_cap);
close(memfd);
*/
/* 模块:没有跨进程版 dl_iterate_phdr,一般直接解析 /proc/<pid>/maps */
snprintf(path, sizeof(path), "/proc/%d/maps", (int)target);
FILE* maps = fopen(path, "r");
/* 逐行:起止地址、权限、路径 → 再打开 so 读 build-id */
fclose(maps);
ptrace(PTRACE_DETACH, target, NULL, NULL);
}
信号侧通常只做通知(例如对 pipe() 写一字节,或 clone() / fork() 出读侧进程),由读侧执行上面的跨进程路径。
跨平台异常捕获
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 上的完整方案通常是:
flowchart TD
A[硬件/内核异常] --> B[Mach exception port]
B --> C[独立监听线程写 dump]
B --> D["(可选)转换成 POSIX 信号"]
D --> E["sigaction 兜底 / 交给系统 Crash Reporter"]
代码框架(示意,省略错误处理与权限细节):
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 |
代码框架(示意,省略错误处理):
/* ---------- 最早: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);
}
调用顺序可以记成:
flowchart TD
A[异常发生] --> B["VEH(AddVectoredExceptionHandler,可多个)"]
B --> C["各帧 SEH(__try / __except)"]
C --> D[UnhandledExceptionFilter]
D --> E[系统默认崩溃对话框 / 终止]
Windows minidump 的磁盘格式,后来被跨平台 crash 采集广泛沿用:一个带 MDMP 魔数的文件,内含多条 stream(线程、模块、异常、内存、系统信息)。格式定义见微软 MINIDUMP_HEADER / MINIDUMP_DIRECTORY。Linux / Android 上常见做法也不是另起一套「Linux core 换皮」,而是 用同一套 minidump 容器,把 POSIX 信号现场编码进去。
和信号模型的关键差异:VEH / 未处理异常过滤器运行在普通线程上下文,不是 async-signal-safe 约束。Windows 上可以较从容地分配内存、写文件、拉起外部进程。这也是后文「为什么 Java 栈采集在 Android 上必须绕开信号上下文」的对照:同一件事,Windows 简单,POSIX 极难。
Android 的额外挑战
Android 是 Linux,POSIX 信号能用;但它不是普通 Linux 进程模型。有三道门槛。
Zygote 与 ART
App 进程不是 exec() 出来的干净映像,而是 Zygote fork() 出来的:
flowchart TD
Z[zygote]
Z --> A[已加载 framework / 已启动 ART]
Z --> B["fork() 出 App 进程(子进程继承整份 Java VM)"]
Z --> C[再按 ActivityThread 走组件生命周期]
Zygote 能这么做,是因为 fork() 前会走 Runtime::PreFork():停掉 ART 内部线程、做完 GC、释放锁、刷新 JIT。普通 App 在崩溃瞬间 不可能 再走一遍这套协议。因此:
- 崩溃时
fork()得到的子进程,表面上「有一份 Java VM 拷贝」,实际上 ART 线程没跟过来、锁可能悬空,不能再调 JNI; - 若既要独立进程写 dump,又想在子进程里跑 Java,必须
fork()之后立刻exec()一个全新程序(例如/system/bin/app_process64),让内核换掉整份内存映像。
开进程方式对比
「崩溃时再开一个进程写 dump」这句话,至少对应三种完全不同的系统调用组合。差别不在「有没有子进程」,而在子进程和崩溃进程还共享多少状态。
flowchart TD
Q1{"是否共享地址空间?"}
Q1 -->|"CLONE_VM=1(pthread 那种)"| T["同进程线程<br/>不能当 crash handler 用"]
Q1 -->|"CLONE_VM=0(COW 拷贝)"| Q2{"拷贝之后是否 exec?"}
Q2 -->|否| Dirty["纯 clone / fork<br/>子进程仍是崩溃进程的脏克隆"]
Q2 -->|是| Clean["fork + exec<br/>子进程换成全新程序"]
| 方式 | 系统调用 | 子进程内存 | 子进程能否当普通程序用 | 崩溃场景下通常能做什么 |
|---|---|---|---|---|
| 同进程线程 | 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 后再次 raise */
}
/* 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。
Java 栈抓取
Android 上 native 代码几乎都不是入口,而是从 Java 层调进来的:某条 Java 线程执行到 JNI 方法,一路进 native,崩溃点往往是这段调用链的下半段。前文「获取 native 堆栈」的三要素还原的是 native 帧;要回答「哪个业务逻辑把代码调进 native」,还得先抓崩溃线程的 Java 调用栈——上半段。
抓 Java 栈这套 API 本身平淡无奇,进程健康时任意普通线程都能拿全量:
/* 基本版:普通线程抓全量 Java 栈,没有任何魔法 */
void CaptureAllJavaStacks(java.io.Writer out) throws java.io.IOException {
Map<Thread, StackTraceElement[]> all =
Thread.getAllStackTraces(); /* 全量快照:线程 -> 栈帧 */
for (Map.Entry<Thread, StackTraceElement[]> e : all.entrySet()) {
out.write("Thread \"" + e.getKey().getName() + "\" (id="
+ e.getKey().getId() + ")\n");
for (StackTraceElement f : e.getValue())
out.write(" at " + f + "\n"); /* 如 at com.foo.Bar.baz(Bar.java:12) */
}
}
只抓某一条线程用实例方法 Thread.getStackTrace()。调用成立的前提有三个:调用方是活着的普通线程(不在信号上下文)、ART 状态完好、没卡在 JNI 临界区里等锁。
崩溃瞬间这些前提一个都不保证:信号上下文碰 JNI 就踩 async-signal-safe 红线(见前文);进程本身又来自 Zygote fork(),崩溃时 ART 内部状态同样不可信(见「Zygote 与 ART」)。所以抓 Java 栈的两条候选路里,先排除带重大缺陷的进程路,再谈活着的线程路。
进程路
fork() + exec() 换干净程序——作为抓取路径它是断的;只在 handler 本体需要 Java 运行时(app_process 跑 Java 入口)时出现。它的账:
- 抓不到崩溃进程的 Java 栈:Java 栈是进程内状态(
Thread.getAllStackTraces()只能列本进程的线程),exec()换出的新 ART 里没有崩溃线程,上半段无从谈起; - 起步就是整套 ART:冷启动到 framework 可用以秒计,而崩溃现场要的是尽快处置、尽快再次
raise();内存与 JIT 开销全压在最不该有负担的时刻——成本构成见「开进程方式对比」; - 可靠性看系统脸色:
exec()出的 Java 环境依赖CLASSPATH、so 解压、linker namespace 逐环就位(见「其它约束」的extractNativeLibs=false),新进程在后台还可能被 OEM 快速回收(同节表)——这套账换来的只是「Java 形态的 handler」,不是崩溃进程的 Java 栈。
线程路
抓取仍在崩溃进程里跑,由一条崩溃前就准备好、能合法碰 JNI 的普通线程执行——本节对比集中在它。这条路的问题:
- 有准备窗口:线程与
jclass/jmethodID缓存要在初始化期备齐(完整形态见下方init子图);准备完成之前就崩溃,Java 栈无从谈起; - 抓取在濒死进程里进行:capture 线程与崩溃线程共享同一份进程状态,坏锁、坏堆不会因为它合规就放行——抓取质量的上限,由进程还剩多少健康度决定,兜底细节见下表 C 行;
- 只解决「抓到」,不解决「送到」:栈文本仍留在崩溃进程内存里,必须赶在再次
raise()前交给收集路径,否则随进程一起消失。
线程路在信号上下文里能做的文章只有「抓取的线程从哪来」,于是有三种姿势:
| A. handler 里直接 JNI | B. 崩溃时 pthread_create() |
C. 预创建 + pipe() |
|
|---|---|---|---|
| 线程从哪来 | 崩溃线程自己 | 崩溃时现建 | 初始化预创建,阻塞在 pipe() 读端 |
| 信号安全 | 否:崩溃线程上重入 ART,二次崩溃则 native 栈也丢 | 否:pthread_create() 内部要拿 libc 的线程链表锁,崩溃时多半已坏;死锁发生在返回前,外层超时救不了 |
是:信号里只 write() / nanosleep() |
| 最坏情况 | native + Java 全丢 | Java 栈永远拿不到,dump 路径跟着挂 | Java 栈记 [capture_timeout],native dump 照写 |
| 常态开销 | 无 | 无 | 一条阻塞线程,栈很小,不占 CPU |
唤醒与等待也得挑白名单原语:write(pipe) 敲门、nanosleep() 短轮询并带总超时;pthread_mutex_lock() / pthread_cond_signal() / pthread_join() 都可能与崩溃瞬间坏掉的锁互搏——提前建了线程也不改变这一点(禁用原因见 async-signal-safe)。合规的 C 的完整形态:
flowchart TD
subgraph init["初始化"]
i1["std::thread 预创建 capture 线程(阻塞在 pipe 读端)"]
i2["JNI_OnLoad 缓存 jclass / jmethodID<br/>native 线程只有 System ClassLoader,须在 Java 线程上 FindClass"]
end
subgraph crash["崩溃(信号上下文,仅 async-signal-safe)"]
c1["write(pipe_fd) 唤醒"]
c2["nanosleep 短轮询(带超时)"]
c3["写 crash_thread 标注"]
end
subgraph cap["capture 线程(普通线程,可 JNI)"]
a1[AttachCurrentThread]
a2["调用缓存好的 getAllStackTraces 一类方法"]
a3["格式化栈文本,交给收集路径"]
end
init --> crash
crash -->|pipe 唤醒| cap
C 是线程路唯一的合规解,机制与具体 SDK 无关:需要 Java 栈的采集方案,初始化阶段按这个形态备好线程与方法缓存即可。
多进程与文件锁
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()」保证原子性。
示意(省略错误处理)。open() 第三参是创建时的权限位(mode_t,八进制写法):
| 写法 | 含义 |
|---|---|
0600 |
rw-------:仅 owner 可读写(App 私有目录下 lock / 临时文件常用) |
0644 |
rw-r--r--:owner 读写,组与其它只读 |
0666 |
rw-rw-rw-:人人可读写;实际还会再被进程 umask() 掩掉若干位 |
锁文件、待 rename() 的临时文件一般用 0600,避免同机其它 uid 读到未发布内容。O_CREAT 时若文件已存在,第三参被忽略,不改已有 mode。
/* flock:整文件劝告锁,接口简单 */
void WithFlock(const char* lock_path, void (*work)()) {
int fd = open(lock_path, O_RDWR | O_CREAT, 0600); /* 0600 = 仅 owner rw */
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 方案
Breakpad 是 Google 早期的跨平台 crash 采集套件,Chrome、Firefox 以及大量 Android SDK 都用过。它的历史贡献是两件事:统一的 minidump 容器,以及配套的 dump_syms / minidump_stackwalk 符号化工具链。客户端设计说明见官方 Client Design。
整体架构
flowchart TD
subgraph client["业务进程 (in-process)"]
direction TB
c1[安装 ExceptionHandler]
c2[收到致命信号]
c3[clone 子进程]
c4[ptrace 读父进程内存]
c5["写 *.dmp 到本地"]
c1 --> c2 --> c3 --> c4 --> c5
end
subgraph server["服务端 processor"]
direction TB
p1[dump_syms]
p2[minidump_stackwalk]
p1 --> p2
end
client -->|minidump 文件| server
客户端按平台分成不同的 ExceptionHandler:
| 平台 | 捕获入口 | dump 写出 |
|---|---|---|
| Linux / Android | sigaction() |
clone() 子进程 + ptrace() + minidump writer |
| Windows | SetUnhandledExceptionFilter() / VEH |
常在独立线程调 MiniDumpWriteDump() 或自研 writer |
| macOS | Mach exception port | 独立线程写 minidump |
设计原则(官方 client 文档的原意)在 Android 上被执行得非常彻底:
- 崩溃后禁止使用应用堆;
- 崩溃线程本身几乎什么都不做,尽快切到预分配栈 / 子进程;
- 不自己 walk 可能已经烂掉的栈,而是让 OS 协助转储寄存器和内存;
- dump 写完后用回调通知上层;上传不在 Breakpad 采集核心路径里,由接入方自行决定何时、如何把文件送走。
Android 上一次典型崩溃:
flowchart TD
A[致命信号到达崩溃线程] --> B["signal handler(备用栈,async-signal-safe)"]
B --> D["sys_clone(无 CLONE_VM)"]
D --> E[子进程 COW 拷贝]
E --> F[ptrace 读父进程]
F --> G[写 minidump 到平面目录]
G --> H["_exit"]
D --> I["wait 子进程结束后再次 raise,进程退出"]
dump 直接落到一个平面目录:文件在不在,就是「有没有待上报」。没有「写入中 / 写完 / 已消费」的区分。
三要素抓取
前文「获取 native 堆栈」里的三要素——线程上下文、栈内存、模块映射——在 Breakpad 里没有换一套理论。抓什么不变;变的是谁在哪个进程里读。
flowchart TD
Sig[致命信号] --> Clone["clone 无 CLONE_VM<br/>脏克隆子进程"]
Clone --> Att["ptrace + waitpid"]
Att --> Three["取寄存器 · 拉栈 · 扫 maps<br/>三要素"]
Three --> Dump["写 *.dmp"]
Dump --> Stop["_exit<br/>不宜再跑 HTTP / ART"]
三要素怎么读,和前文跨进程示意相同。图末「不宜再跑 HTTP / ART」,是因为读侧仍是脏克隆(见「开进程方式对比」)。
minidump 格式
minidump 源自 Windows。Breakpad / Crashpad 在 Linux / Android 上 复用同一套磁盘布局,所以一套 processor 能吃全平台 dump。结构体以微软 MINIDUMP_HEADER / MINIDUMP_DIRECTORY 为准;符号侧见 Breakpad Symbol Files。
文件骨架
文件头魔数为 MDMP(磁盘上小端四字节常写作 PAMD)。Header 之后是 Stream Directory:每一项是 (StreamType, DataSize, RVA),RVA 是相对文件开头的偏移,指向一条 stream 的载荷。
+-----------------------------------+
| Header |
| Signature = "MDMP" |
| Version / NumberOfStreams |
| StreamDirectoryRva ------------>|
+-----------------------------------+
|
v
+-----------------------------------+
| Stream Directory |
| (StreamType, DataSize, RVA) |
| x NumberOfStreams |
| RVA of each entry ------------->|
+-----------------------------------+
|
v
+-----------------------------------+
| Stream payloads |
| ThreadList / ModuleList / |
| Exception / MemoryList / ... |
+-----------------------------------+
读 dump 时不要假设「Exception 一定紧挨着 Header」。正确做法是:扫 Directory → 按 Type 找到需要的 stream → 再按 RVA 跳转。
关键 stream
| Stream | 作用 | stackwalk 怎么用 |
|---|---|---|
| Exception | 崩溃线程 ID、异常记录、崩溃瞬间上下文(寄存器,含 PC/SP) | 取崩溃线程、起点 PC、Crash address |
| ThreadList | 每线程 ID、栈内存描述(RVA → MemoryList 切片)、上下文 | 展开全部线程;崩溃线程与 Exception 对齐 |
| ModuleList | 已加载 so/dll:基址、大小、路径、调试 ID | 相对偏移 = PC - 基址;用调试 ID 选 .sym |
| SystemInfo | CPU 架构、OS 版本字符串(csd_version) |
输出里的 Operating system / CPU |
| MemoryList | 若干内存切片(地址 + 字节) | unwind 时读栈上的返回地址 / 保存的 FP |
| MemoryInfoList | 映射表(权限、起止),偏诊断 | 可选;不是 unwind 必需 |
| MiscInfo / BreakpadInfo | 进程 ID、有用标志等 | 辅助字段 |
MD_CRASHPAD_INFO(Crashpad) |
Annotation 等扩展 | 附加 stream;老 processor 不认识即可跳过 |
Linux / Android 上 Exception 里编码的是 POSIX 现场,而不是 Win32 异常码:
- 信号编号 → 输出里的
Crash reason(如SIGSEGV); si_code→ 细分原因(如SEGV_MAPERR/SEGV_ACCERR);si_addr→Crash address(对SIGSEGV/SIGBUS通常是出错虚址);- 上下文里的 PC / SP / 通用寄存器 → unwind 起点。
ModuleList 的调试 ID 在 ELF 上对应 build-id(通常来自 .note.gnu.build-id)。dump_syms 写进 .sym 首行的那个 ID,必须和这里一致,否则会出现 WARNING: No symbols,只能退回 libfoo.so + 0x偏移。
MemoryList 不是完整 core:采集端一般只切各线程栈附近、以及 unwind 所需的少量区域。体积可控,也避免把整进程堆打进上报包。缺某一页时,栈可能在某一帧断掉,但前面已经还原的帧仍然有效。
processor 怎么读
flowchart TD
A["Exception.上下文.PC"] --> B["在 ModuleList 里找「基址 ≤ PC < 基址+大小」的模块"]
B --> C["相对偏移 = PC - 基址<br/>符号化 / 聚类的钥匙"]
C --> D["按 ABI + MemoryList 栈切片往回 unwind<br/>每帧:查模块 → 减基址 →(有符号则)查 .sym"]
自定义业务字段在经典 Breakpad 路径里没有一等公民。很多 Android 集成会把版本、ABI、进程名等元数据 拼进 SystemInfo 的 OS 版本字符串。这个字段本来只该放 "#1 SMP PREEMPT aarch64" 这类内核描述,容量通常只有数 KB,还要先扣掉真正的 OS 文本。这是后文「字段格式限制」的根源;Crashpad 则把这类数据放到 Annotation(MD_CRASHPAD_INFO),不再挤 OS 字符串。
符号化工具链
minidump 里没有函数名。函数名在构建产物的 DWARF / PDB 里。Breakpad 把这件事拆成两步:dump_syms 抽成精简文本符号;minidump_stackwalk 读 dump + 符号目录出可读栈。
dump_syms
一句话:带 DWARF 的 libfoo.so(未 strip)进去,同名 .sym 文本出来——内容无非 MODULE / FILE / FUNC / PUBLIC / STACK CFI 这些记录行。
.sym 是给 stackwalk 用的精简格式,不是把整份 DWARF 原样搬过来。常见记录:
| 行类 | 作用 |
|---|---|
MODULE |
首行:OS、CPU、build-id、模块文件名;和 ModuleList 调试 ID 对齐 |
FILE |
源文件编号表,供后面行号引用 |
FUNC / PUBLIC |
地址区间 → 函数名(相对模块基址) |
STACK CFI / STACK CFI INIT |
Call Frame Information:教 unwind 如何从当前帧找回返回地址与寄存器 |
此处 CFI = Call Frame Information(调用帧信息),不是安全领域的 Control Flow Integrity。完整规则原本在 DWARF 的 CIE/FDE 里;dump_syms 抽成 .sym 文本供 minidump_stackwalk 使用。有 FUNC 只能给已知 PC 起函数名;没有可用的 CFI(或平台等价 unwind 信息),就很难从顶帧继续往回走。
首行类似:
MODULE Linux arm64 <build-id> libfoo.so
要点:
- build-id 对不上就等于没符号。 APK 里随包 so 通常已 strip,不能当
dump_syms输入;CI 必须把未 strip 的 debug so 按同一 build-id 归档。 - CFI 决定「能不能走栈」。 有
FUNC只能把已知 PC 变成函数名;没有可用的 CFI(或等价 unwind 信息),arm64 上很容易只出第一帧,后面全丢。 - 同一 so 换编译器选项 / 重链之后 build-id 会变,旧
.sym不能复用。
minidump_stackwalk
输入是一份 .dmp + 符号目录(按 debug id 布局存放 .sym)。内部可以看成三步:
flowchart TD
A["读 Exception / ThreadList / ModuleList / MemoryList"]
A --> B["从崩溃线程上下文的 PC 起,按 ABI + CFI + 栈切片 unwind"]
B --> C["每一帧:PC → 模块 → 相对偏移 → 查 .sym(FUNC / 行号)"]
C --> D["文本报告(stdout / 文件)"]
输出字段和 stream 的对应关系:
| 输出 | 主要来源 |
|---|---|
Operating system / CPU |
SystemInfo |
Crash reason |
Exception:信号 + si_code |
Crash address |
Exception:si_addr 等 |
Thread N (crashed) |
Exception 标明的线程 + ThreadList |
libfoo.so!Foo::bar + 0x2c |
相对偏移 + .sym 的 FUNC |
src/foo.cpp:128 |
.sym 的行号信息 |
libart.so + 0x21eb28 |
有模块、无该 so 的 .sym(或查不到函数) |
样例输入输出:一份 crash.dmp + 按 debug-id 布局的符号目录进去,可读文本报告出来——典型调用:
minidump_stackwalk crash.dmp symbols/ > report.txt
输出大致长这样:
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
常见「残缺栈」并不都是采集失败:
- 完全无符号:仍有
Thread N (crashed)和so + 相对偏移,聚类可以靠相对偏移;人读费劲。 - 缺 CFI / 栈切片不完整:往往只剩顶帧,或中途
…断掉。 - PC 落在 JIT / 已卸载内存:ModuleList 对不上,可能只剩
Crash address。 - 叶子在系统库:
libart.so/libc.so在顶上是正常现象;聚类策略见后文,不要和「没采到业务帧」混为一谈。
与 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」。
原理与样例到这里为止。服务端章只补部署(glibc、容器、验收),不再重复 unwind 流程。
方案局限
在 Android 上把 Breakpad 用上线之后,问题会集中在三个地方。
无法当次上传
clone() 子进程不是一个正常进程:不能放心跑完整 HTTP 栈,不能复用 App 里现成的 curl / TLS。Breakpad 的模型止于把 minidump 写到本地;崩溃当次没有干净进程可承载可靠上传,送达完全依赖接入方事后把文件送走。办公类 App 可能数日才被打开一次,Android 12+ 对后台拉起也更苛刻——dump 容易长期留在本地,后台不可见。这和 Crashpad「干净 handler 里可以当次跑网络库」形成对照。
字段格式限制
自定义数据走 OS 字符串,带来一串连带问题:
- 所有字段共享一个定长 buffer,互相踩;
- 用换行、
KEY: VALUE、竖线之类的自创分隔,值里一旦出现同样字符,后台解析就错; - 升级 Breakpad 源码时,这些侵入点分散在 writer / format / processor 多个文件,合并成本高。
拉进程天花板
clone() 得到的是崩溃进程的脏克隆。子进程能稳定做的,上限就是 ptrace() + 写文件。在这之上叠加「写数据库状态、当次 HTTP 上传」,只会让信号安全问题越来越不可控。
平面目录还有状态盲区:clone() 子进程写到一半被杀,半成品和完整 dump 混在一起,后台解析失败,也无法统计「因写入中断丢失」的数量。
这些限制不是调参能消掉的。Chromium 在 2019 年前后把 Android 采集切到 Crashpad,本质是换进程模型,而不是换一个更好的 minidump writer。
Crashpad 方案
Crashpad 是 Breakpad 的继任者,目标仍是写出同一套 minidump,但把「写 dump」从崩溃进程里搬到 独立 handler 进程。设计总览见 Overview Design。
整体架构
flowchart TD
subgraph client["业务进程 (client)"]
direction TB
i1[打开 crash database]
i2[安装信号 handler]
x1[信号里只通知 handler]
x2["不写 dump、不跑 HTTP"]
i1 --> i2 --> x1 --> x2
end
subgraph handler["handler 进程 (out-of-process)"]
direction TB
h0["exec 后的全新映像"]
h1[ptrace 读业务进程]
h2["写 minidump 到 database/new/"]
h3["rename 到 pending/"]
h4["(可选)上传"]
h5[标记 completed / 退出]
h0 --> h1 --> h2 --> h3 --> h4 --> h5
end
client -->|"socket / pipe"| handler
handler -->|上传成功| up[上报服务端]
和 Breakpad 的对照:
| 维度 | Breakpad | Crashpad |
|---|---|---|
| dump 写入点 | clone() 子进程(脏克隆) |
fork() + exec() 后的干净 handler |
| 崩溃进程职责 | 几乎承担全部写出 | 只发通知,重活全外移 |
| 自定义字段 | 侵入 writer,塞 OS 字符串 | Annotation:进程参数 + 共享内存 KV |
| 状态 | 平面目录 | new / pending / completed |
| 多进程 | 每进程自己 handler | 可共享同一 database 目录,文件锁协调 |
| 解析 | minidump_stackwalk |
同一套,minidump 二进制兼容 |
| 维护 | 基本停滞 | Chromium 持续维护 |
Crashpad 自带 HTTP 上传。多数团队不会直接用:已有 curl / TLS、已有鉴权与打包格式、已有自己的崩溃后台。正确切分是:只用 Crashpad 的捕获、minidump、database;上传换成自己的实现。因为 handler 已经是正常进程,这件事第一次变得可行。
三要素抓取
Crashpad 要写进 minidump 的现场,仍然是同一套三要素:Exception 里的 PC / SP、ThreadList 的栈切片、ModuleList。抓什么不变;谁抓、抓完还能干什么变了。
flowchart TD
Sig[致命信号] --> N[业务进程只通知]
N --> Exec["fork + exec<br/>干净 handler"]
Exec --> Att["ptrace + waitpid"]
Att --> Three["取寄存器 · 拉栈 · 扫 maps<br/>三要素"]
Three --> Dump["写 minidump"]
Dump --> More["可叠 Annotation / Database<br/>甚至当次上传"]
和 Breakpad 比,中间抓三要素的那一段没变;跃迁在开进程换成干净 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 的路径:
flowchart TD
A["signal handler(业务进程)"] --> B["fork()"]
B --> C["子进程:关多余 fd → 复位致命信号 → execve app_process64"]
B --> D["父进程:waitpid,然后再次 raise"]
C --> E[全新 Java Runtime]
E --> F[加载 handler 入口类]
F --> G[绝对路径 load handler 所需 so]
G --> H["ptrace 业务进程,写 dump"]
H --> I["(可选)dump 完成后再做内联上传"]
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 字符串。
flowchart LR
subgraph biz["业务进程"]
s["静态字段 ABI / 进程名 / 版本<br/>→ 命令行 process annotations"]
d["动态字段 运行时随时可写<br/>→ StringAnnotation 共享内存<br/>地址固定、容量编译期确定"]
end
subgraph h["handler 进程"]
hs[直接读参数]
hd[ptrace 按布局读出]
end
s --> hs
d --> hd
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 字符串只放真正的内核版本。分阶段落地见「客户端部署 · 兼容迁移」。
Database 状态管理
Crashpad 用目录当状态机,一次 rename() 就是一次原子转移:
flowchart TD
DB[crashpadDb]
DB --> N["new/ — handler 正在写,或写到一半被杀"]
DB --> P["pending/ — 完整 dump,等待上传或入队"]
DB --> C["completed/ — 已消费,避免重复"]
flowchart TD
A["写出开始 → new/uuid.dmp"] -->|写完并 fsync| B["pending/uuid.dmp<br/>唯一「确定完整」的集合"]
B -->|当次上传成功| C["completed/"]
B -->|失败 / 未直传| D["下次启动扫 pending,入队后再 completed/"]
内联上传写在 handler 子进程里,但这个进程 撑不久:厂商 ROM 常扫杀短命后台进程,它不宜做很重的网络与磁盘工作,也难做多次重试,直传送达率无法保证。因此仍要在主进程 下次冷启动 时由 Java 侧扫描 pending/ 再上传,作为兜底。
和 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;
- 走已有鉴权、打包、域名和后台入口;
- 崩溃当次尽量直传;失败或未直传的,留给下次冷启动的 Java 扫描管线兜底(原因见「Database 状态管理」)。
handler 是 app_process 裸进程,没有 Framework linker namespace,也常常没有 nativeLibraryDir 里的实体 so(extractNativeLibs=false)。因此内联上传的前置条件是:把 handler 运行时用到的 so,从 APK 解到应用私有目录,再用绝对路径加载。
Handler so 加载
整体流程
flowchart TD
A["主进程冷启动(后台线程,不挡 UI)"] --> B["打开 APK(ZipFile)"]
B --> C["读取 lib/abi/ 下各 so 的 ZipEntry.CRC<br/>与本地 manifest 比较"]
C -->|一致| D["跳过解压(约数毫秒)<br/>仍检查 so 只读位,可写则补 setReadOnly"]
C -->|缺失或 CRC 变化| E["解到 staging,全成功后再 rename<br/>写出后立刻去掉写权限"]
D --> F["handler 进程(崩溃当次)"]
E --> F
F --> G["绝对路径 System.load / dlopen 已解压的 so<br/>含 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、以及自定义字段(OS 字符串 / 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 字符串」,字段各用各的独立容量。
- 收尾:删除兼容分支,采集与解析都走标准 Crashpad 字段。
兼容期可以和「体积裁剪」一起做:顺手裁掉「只为 Crashpad 扩展 stream 服务」的 writer(不写 MD_CRASHPAD_INFO),减小 so 体积。只要 ModuleList 和 Exception 还在,stackwalk 和聚类就不会断。双写期内 dump 里会有一份截断的 OS 字符串副本,这是兼容税,双端升级后再去掉。
服务端部署
客户端无论 Breakpad 还是 Crashpad,服务端看到的都是 minidump。服务端要做两件事:符号化,以及把海量事件收成 Issue。
minidump_stackwalk 部署
unwind 与输出字段含义见前文「符号化工具链」。这里只关心 怎么把二进制跑在清洗机上。
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...,聚类兜底不至于全空。
crash 聚类
绝对地址/相对地址
绝对地址靠不住的根因是 ASLR:出于安全考虑,内核每次加载都会随机化 so 的基址。副作用是同一行代码在不同启动、不同设备上的绝对 PC 都不同:要跨崩溃指认同一个位置,只能放弃「内存里的地址」,改用「文件里的位置」。于是有了相对偏移:
flowchart LR
PC["PC(绝对)<br/>每次 ASLR 都变"] --> sum["= 模块加载基址 + 相对偏移"]
sum --> rel["相对偏移<br/>对本 so 文件固定<br/>dump_syms / addr2line / 聚类都用这个"]
例如同一次崩溃点、两次启动:
libfoo.so 被 mmap 到 0x6f00000000
崩溃 PC = 0x6f000e9c18c → 相对偏移 = 0xe9c18c
另一次启动基址变成 0x7000000000
崩溃 PC = 0x700000e9c18c → 相对偏移仍是 0xe9c18c
因此:
- 绝对地址 把 ASLR 编进 IssueID,同一缺陷会被拆成碎片,只适合「栈上连 so 都没有」的绝境(例如 PC 落在已卸载内存,ModuleList 对不上)。
- 相对地址 对「同一构建、同一 so」稳定,跨版本会因编译器布局变化而拆 Issue——这是没有符号时的预期代价。
聚类策略
符号化之后,聚类建议只看崩溃线程的第一帧特征:后面的 ART / libc 尾帧噪声极大,编进 fingerprint 会把同一缺陷拆成无数 Issue。
优先级从高到低:
flowchart TD
A["定位 Thread xx (crashed)"] --> B[跳过 tid / 寄存器等非栈帧行]
B --> C["取第一个 so / 模块帧"]
C --> D{"能还原出函数名?"}
D -->|是| E["用「模块!函数」(只看这一行)"]
D -->|否| F["用「so + 相对偏移」"]
C --> G{"整段找不到任何 so 帧?"}
G -->|是| H[用绝对 Crash address]
| 优先级 | 特征 | 例子 | 稳定性 |
|---|---|---|---|
| 优先 | 还原后的符号,仅第一行 | libfoo.so!Foo::bar |
最好。函数名在版本间比指令偏移稳 |
| 次选 | 第一个 so 的相对地址 | libfoo.so + 0xe9c18c |
中。同一构建可合并;so 重编后会拆开 |
| 兜底 | 绝对崩溃地址 | Crash address: 0x0 |
最差。仅当栈完全没有模块信息 |
函数名 跨小版本更稳,但内联、模板实例化、strip 策略仍可能造成合并或拆分,所以仍然 只取第一行,不要把整栈 hash 进去。
系统库要不要跳过,是产品选择。跳过 libart.so / libc.so 能让业务缺陷更突出,但也更容易在「纯运行时崩溃、栈顶就是 art」时掉进绝对地址兜底。一种简单策略是:不跳过系统库,但仍然只看第一帧——实践中 (crashed) 后的第一个 so,通常就是出问题的那一个。