Native Crash 监控方案演进
很多年前的文章已经介绍过 Android 捕获 Java 层的 crash,简单说就是系统提供了一个未捕获异常回调,其实 OC 也有类似的东西。
而我们这里要聊的 Native Crash 监控则要复杂得多:既得自己手动处理堆栈抓取、编码、还原,还得考虑各个系统的进程模型差异。
基本的 Native Crash 监控
POSIX 信号
信号是什么
信号(signal) 是一种源自 Unix 的古老 IPC 方式:
flowchart LR
Src["信号源:用户 / 系统 / 其它进程"] --> Ker[内核信号管理]
Ker --> Proc[目标进程]
Proc --- Trap["已注册的 handler"]
虽然现在已很少用于用户侧进程通信场景,但仍用于系统对进程控制的场景。
对 native crash 来说,最常见的信号来源是:
- 非法访存、对齐错误、非法指令等硬件异常,由内核转换成信号;
abort()、assert()失败、部分运行时自检,主动发送SIGABRT;- 调试器断点、错误系统调用等。
不是普通函数调用:
- 它会打断当前线程,切到该线程上注册的处理函数;
- 处理函数返回后,线程从被打断处继续;
- 若处理函数选择结束进程,内核按默认动作终止。
不是另一个线程,而是同一条线程被异步劫持:
- handler 跑在被打断的那条线程上,期间该线程原代码停住;多线程不一样——其它线程在 handler 期间照常跑,共享数据靠锁 / 原子 / 内存序协调;
- handler 与被打断的代码共享同一个栈、同一个 TLS(含
errno)、同一个堆与 stdio 状态,它不是一个独立执行环境; - 内核投递时不与用户态协调,也没有调度间隙:它不知道用户态正改到第几步,更不会「等当前操作做完再投递」;
- 所以锁在这里不是失效而是自死锁:普通线程抢不到锁可以等,而 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 上下文 |
uctx 是这个崩溃现场的主来源——它从哪来、里面能读什么,见后文「信号的投递与恢复」。
巡检与重装
后安装的 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 仍是首装旧动作 */
}
信号的投递与恢复
内核把前面那条性质实现成下面这条流程——上下文由内核在用户栈上伪造,也由内核恢复:
flowchart TD
subgraph U["用户态"]
U0["线程正常执行(可能正停在 malloc 改堆元数据的中间)"]
UH["handler 执行:只能调 async-signal-safe 接口"]
TR["signal trampoline(vDSO 或 libc)"]
UR["从被打断的那条指令继续"]
end
subgraph K["内核态"]
G["信号产生:硬件异常 / kill / 定时器<br/>先在线程上挂 pending 标记(TIF_SIGPENDING)"]
K2{"即将返回用户态:<br/>有未屏蔽的待处理信号?"}
K3["选出接收线程(多线程时投给一条未屏蔽它的线程)"]
K4["在该线程的用户栈上构造 signal frame(SA_ONSTACK 时改用备用栈)<br/>保存处理器状态字 / 寄存器 / 浮点状态 / 信号掩码 / 备用栈设置"]
K5["改 RIP = handler、RSP = frame<br/>并屏蔽当前信号(除非 SA_NODEFER)"]
K6["rt_sigreturn:按 frame 恢复寄存器、信号掩码、备用栈设置<br/>该调用永不返回"]
end
U0 -->|"陷入内核 / 外部投递"| G
G -->|"下一次返回用户态时兑现"| K2
K2 -->|"没有"| UR
K2 -->|"有"| K3 --> K4 --> K5
K5 -->|"返回用户态"| UH
UH --> TR
TR -->|"发起 rt_sigreturn"| K6
K6 --> UR
图里三个名词需要交代:
- signal frame:内核在用户栈上压的那块内存,handler 的第三参
ucontext_t就是它;与采栈相关的是uc_mcontext——PC 定位崩在哪个 so、哪个偏移,SP 往上是该线程的栈内存,另有 LR(Link Register)、FP(Frame Pointer)与通用寄存器,字段全貌见sigaction()对SA_SIGINFO的说明; - trampoline:handler
return后先进的那小段代码,在 vDSO 或 libc 里(若在 libc,glibc 的sigaction()包装会把地址写进sa_restorer并置SA_RESTORER,这两个字段不供应用使用)(sigreturn(2)); - 恢复边界:内核只保证寄存器、信号掩码与备用栈设置能还原;栈内存、堆、stdio 缓冲、TLS(含
errno)、锁状态、全局变量都在用户态内存里,内核不做快照也不负责还原——这正是下一节存在的原因。
两点提醒:
ucontext当只读快照用:不要依赖「改完再返回」来修现场,也别假定此时还能安全malloc()或扫全进程 maps;- 信号既不是硬件中断,也不是内核里的 softirq(网络收包、定时器那套下半部机制),它只是待处理位图上的标记。
async-signal-safe
前文已说过:信号处理既不是普通函数调用,也不是多线程那类异步。落到代码上,麻烦在于:
它可能插在 任意 指令之间——包括 malloc() 改空闲链表的半途、pthread_mutex_lock() 已持锁未入临界区、printf() 正写 FILE 缓冲。
若 handler 再调这些函数,轻则死锁,重则堆元数据二次损坏,连 dump 都写不出来。
POSIX(IEEE Std 1003.1-2024)因此定义了 async-signal-safe:在信号上下文中,只有列入标准白名单的接口才保证可重入、可安全调用。名单之外的,一律视为未定义行为——「碰巧能跑」不算合规。完整列表见标准 2.4.3「Signal Actions」,Linux 手册另有一份整理版 signal-safety。
为什么会有这套规则
前提在开头已经讲过:handler 与被打断的代码是同一条执行流,共享栈、TLS 与堆,中间没有调度间隙。由此推出两条判定标准和几条边界:
- 半修改状态:
printf的静态缓冲区与计数、malloc的空闲链表可能正改到一半,handler 再进一次就读到不一致的内部结构(signal-safety(7)); - 栈是借来的:handler 压在被中断线程的栈上,栈快溢出时连 handler 都进不去——这才需要
sigaltstack()配SA_ONSTACK另备一块; errno也是同一线程共享:- TLS 只隔离线程,隔不了同一线程内的 handler——一次失败的系统调用就会覆盖主流程的
errno; - 但这条只对会返回、程序接着跑的 handler 有意义(
SIGALRM、SIGCHLD之类,按 signal-safety(7) 的口径要在进入时保存、返回前恢复); - 崩溃 handler 恢复默认动作后重新
raise()/_exit(),不会回到被打断处,errno也就没人再读;
- TLS 只隔离线程,隔不了同一线程内的 handler——一次失败的系统调用就会覆盖主流程的
- 白名单的判定标准:要么可重入(只用栈上局部变量),要么相对信号原子(系统调用)——
write()属于后者(signal-safety(7)); - 层级关系:可重入 ⊂ async-signal-safe,而后者严格来说小于线程安全——线程安全可以用锁、TLS、原子变量,这些在 handler 里都用不了;
- POSIX 为什么不改造内核:要求「等当前系统调用完成再投递」或「为所有用户态状态做快照」,会让内核复杂度爆炸、破坏信号的异步语义,而且解决不了用户态库函数内部的中间态——只能反过来约束调用方。
与 volatile 的关系
之前这篇文章提过:C 的 volatile 面向的就是这类信号处理场景:但他主要为了防止编译器优化造成的内存可见性问题,而不保证操作的可重入和原子性。
和主流程通信时,可移植的做法是 volatile sig_atomic_t 标志位:它保证这一处读写不被优化掉、且是原子的。但它的能力仅止于此——链表、引用计数、容器内部状态都保护不了,handler 里能安全地置一个标志,不能安全地改一个 std::vector。
常见可用
| 类别 | 示例 | 典型用途 |
|---|---|---|
| 退出 | _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() 用 |
write() 为什么能进白名单
- 用户态几乎没有中间态:它只做三件事——参数进寄存器、执行
syscall、返回值写回;没有缓冲区、没有链表、没有锁,可变状态(VFS、页缓存、文件偏移)都在内核里由内核自己保护; - 但它不等于「永不中断、永远原子」:可能返回
EINTR、可能只写一部分、写管道超过PIPE_BUF仍可能与其他写入交错; - 安全的理由只有一条:handler 能观察到的只是返回值与
errno,主流程不会在一份被腐蚀的用户态结构上继续。
longjmp 也在白名单里,但有代价
- 它会跳过
sigreturn(机制见前文「信号的投递与恢复」),信号掩码与备用栈只能靠sigsetjmp/siglongjmp自己恢复; - handler 若从被中断的不安全函数里跳走,之后再调用这些不安全函数就是未定义行为(signal-safety(7))。
工程上的正确拆分是:
- 信号上下文(只能做 async-signal-safe 的事)
- 写
pipe()/ 设原子标志,唤醒初始化阶段预创建的线程; fork(),子进程内关多余 fd、exec()拉一个干净进程(多线程进程fork出的子进程只带当前线程,父进程其它线程持有的锁与缓冲会被原样冻进子进程镜像,在execve()之前只能调 async-signal-safe 函数(fork(2)));- 恢复
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 堆栈
要把崩溃现场读出来,最少拿到四样东西:进程信息、线程上下文、栈内存、模块映射。
C / C++ 的 stacktrace API
拿调用栈的现成 API 分三层,共同的适用场景是活着的进程里主动抓一次栈:调试、断言、日志、异常上下文。
| 层次 | 例子 | 给什么 |
|---|---|---|
| 编译器内建 | __builtin_frame_address(level) / __builtin_return_address(level) |
裸帧指针 / 返回地址;没有函数名、文件、行号,AArch64 上还要过 __builtin_extract_return_addr() 解掉返回地址签名 |
| 平台 API | backtrace() / backtrace_symbols()(glibc)、CaptureStackBackTrace()(Windows)、libunwind |
一组地址;要拿到函数名还得链接时加 -rdynamic,且 static 函数不会出现(见 backtrace(3)) |
| 标准 / 库封装 | std::stacktrace(C++23,即 std::basic_stacktrace 的别名)、Boost.Stacktrace |
类型安全的栈帧对象:current() 抓快照,stacktrace_entry 提供 description() / source_file() / source_line() |
三层的调用长这样(示例按 #include <execinfo.h> / <stacktrace> 展开):
// 第一层:编译器内建,只有裸地址
void* fp = __builtin_frame_address(0);
void* ra = __builtin_return_address(0); // 非零 level,官方不建议
// 第二层:平台 API(glibc),一组地址
void* buffer[64];
int n = backtrace(buffer, 64);
char** syms = backtrace_symbols(buffer, n); // 这一行就是 malloc,非 async-signal-safe
free(syms); // 手册页明确要求调用方 free
// 第三层:C++23 标准库,类型安全的栈帧对象
auto st = std::stacktrace::current();
for (const auto& e : st)
std::cout << e.description() << " at " << e.source_file() << ":" << e.source_line() << '\n';
越往下封得越好,依赖也越多:source_file() / source_line() 依赖调试信息,剥过符号的产物只会打印空描述(实测输出就是 at :0)。
放进 native crash 监控为什么不行,原因分三层:
- 信号上下文只认白名单(见前文 async-signal-safe),三层都过不了:
backtrace_symbols()会malloc();backtrace_symbols_fd()不显式分配,但它属于libgcc,首次调用会触发动态加载、通常连带一次malloc()——手册页给的解法是”提前把libgcc加载好”;std::stacktrace::current()与Boost.Stacktrace也不是为信号上下文设计的。
- 崩溃时进程状态本身不可信:堆、锁、动态链接器都可能已经损坏。就算某个接口”理论上安全”,它依赖的运行时未必还活着——这条比白名单更根本。
__builtin_frame_address连”能跑”都不保证:它依赖帧指针链,优化、内联、尾调用都会破坏这套假设(backtrace()踩的是同一套假设)。GCC 文档对非零level的原话是 “can have unpredictable effects, including crashing the calling program”,-Wframe-address会把这类调用诊断为不安全,只建议调试用。
这些 API 的正当用途都在非崩溃路径:
- 断言失败现场、
try/catch的异常上下文; - 测试 / CI / 调试日志里的”谁调用了这里”;
- 性能采样:只存地址,事后统一符号化。
Boost.Stacktrace 的 safe_dump_to 是少数面向信号场景的接口,官方标注是 “Theoretically async signal safe”——目标仍是把原始帧数据安全 dump 出去、留待离线解析,不是当场符号化。
Rust 的 stacktrace API
C++ 那套现成 API 在 Rust 里几乎都有对应物,但稳定版能直接用的更少:
| C++ / 平台 | Rust 等价物 | 定位 |
|---|---|---|
__builtin_frame_address / __builtin_return_address |
没有稳定的等价物;nightly 只有 core::intrinsics::return_address |
该模块文档写明”是 core 的实现细节,不应在标准库外使用”,返回值也”no hard guarantees… non-deterministically returns an arbitrary pointer without provenance” |
backtrace() / backtrace_symbols() |
backtrace crate |
可编程捕获、遍历帧、解析符号(trace() + resolve_frame()) |
std::stacktrace |
std::backtrace::Backtrace |
1.65 起稳定:capture() / force_capture() / status() 都是稳定 API,只有逐帧遍历 frames() 仍属 nightly(backtrace_frames) |
| no_std / 嵌入式 | mini-backtrace |
无分配回溯:Backtrace::<16>::capture() 返回 ArrayVec 里的 IP 列表 |
最常用的是 std::backtrace,代价是”要不要真抓”由环境变量决定:
// capture():只有 RUST_BACKTRACE / RUST_LIB_BACKTRACE 被打开时才真抓
let bt = std::backtrace::Backtrace::capture();
if bt.status() == std::backtrace::BacktraceStatus::Captured {
eprintln!("{bt}"); // Display / Debug 都稳定
}
// force_capture():无视环境变量总是抓——慢,别放热路径
放进 crash 路径同样不行,理由与上一节(C / C++)一致:
- 都会分配:
Backtrace要收集帧,eprintln!/format!要分配,backtracecrate 的符号解析更重——都不是 async-signal-safe(见前文#### async-signal-safe); - panic hook 管不到信号:
std::panic::set_hook只在 panic 时触发,而SIGSEGV这类致命信号根本不会产生 panic; - 裸地址那条路更窄:Rust 没有稳定的
__builtin_frame_address对应物,nightly 的return_address()官方自己都不给硬保证。
no_std 或嵌入式场景可以看 mini-backtrace:
- 它把 LLVM libunwind 编成无 OS 依赖、无分配,回溯结果是
ArrayVec里的 IP 列表; - 要配
-C force-unwind-tables加eh_frame.ld链接脚本片段,否则定位不到展开表; - 信号上下文里用
capture_from_context传入ucontext_t的寄存器状态,该路径目前只支持 AArch64 与 RISC-V。
四要素从哪读
| 要素 | 最少要有 | 同进程从哪读 | 跨进程从哪读 |
|---|---|---|---|
| 进程信息 | pid、进程名 | getpid() + /proc/self/cmdline |
pid 已知(通知 / 参数带上);进程名读 /proc/<pid>/cmdline |
| 线程上下文 | 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、相对偏移多少」;有了栈字节,才有还原调用链的输入。上表是 Linux / Android 路径;Apple、Windows 见本章后文各自的「四要素抓取」。
进程信息排第一:它回答的是「哪个进程崩了」。
dump 收集后,归档、过滤、告警路由首先依据进程名;具体读法见后文 /proc/<pid>/cmdline 小节。
/proc/<pid>/cmdline
pid 由 getpid() 获得;进程名在 Linux / Android / OHOS 上直接读 /proc/<pid>/cmdline(本进程用 /proc/self/cmdline),按普通文件读取即可,内容为 argv,\0 分隔,取第一段。
实践要点:
- 尽早读取并缓存。 进程名在启动期即确定(Android 上 zygote 起子进程时写好 argv0),初始化时读取一次并缓存即可;
open()/read()虽在 async-signal-safe 白名单内,也没有必要把这份稳定数据留到信号路径里读取。 - dump 侧读取不需要停住目标。
/proc/<pid>/cmdline是内核里的字符串,不是内存快照;同 uid 即可读,崩溃进程与 dump 进程即属此类(Android 上跨 uid 会被权限 / SELinux 拦住)。 comm只当兜底。 15 字节上限会把长包名连:后缀一起截掉,主进程与子进程可能同名;仅在拿不到 cmdline 时使用。
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 -->|"断点 / 单步"| E["软件断点:arm brk / x86 int3"]
E --> E1[CPU 异常或陷阱]
E1 --> F[内核转成 SIGTRAP 等]
D --> G[信号投递路径拦住目标]
F --> G
G --> H{目标正被 ptrace?}
H -->|是| I["停下事件交给 tracer,不按默认杀 / 忽略"]
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。
同源的「按地址反查模块」接口 dladdr()、以及模块基址(load_bias)的来历,见现代 C++ 跨平台开发 - 二进制篇的「符号查找与反查」与「加载器实现细节」。
全流程与代码示意
两条读法:信号线程里直接读本进程,或另起进程 ptrace() 再读。后者是默认稳妥路径。
flowchart TD
Sig[致命信号] --> Choose{在哪读}
Choose -->|同进程| U["ucontext 取 PC / SP"]
U --> P1["进程信息:getpid + /proc/self/cmdline"]
U --> S1["按 SP 直接读栈"]
U --> M1["dl_iterate_phdr 列模块"]
M1 --> B1["按需读 so 取 build-id"]
P1 --> Done1[四要素在手]
S1 --> Done1
B1 --> Done1
Choose -->|跨进程| N[信号里通知对端]
N --> Att["ptrace attach"]
Att --> W["waitpid:WIFSTOPPED"]
W --> Reg["PTRACE_GETREGSET 取寄存器"]
W --> Maps["解析 /proc/pid/maps"]
W --> P2["进程信息:pid + /proc/pid/cmdline"]
Reg --> S2["process_vm_readv 或 /proc/pid/mem 拉栈"]
Maps --> B2["打开 so 读 build-id"]
P2 --> Done2
S2 --> Done2[四要素在手]
B2 --> Done2
Done2 --> Det[PTRACE_DETACH]
同进程示意(省略错误处理;寄存器布局依 ABI,以 arm64 为例):
void CaptureInProcess(ucontext_t* uc, void* stack_buf, size_t stack_cap) {
/* 进程信息:pid 已知;进程名读 /proc/self/cmdline 取 argv 首段 */
/* 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 */
/* 进程信息:pid 已知;进程名读 /proc/<pid>/cmdline 取 argv 首段 */
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() 出读侧进程),由读侧执行上面的跨进程路径。
Android 的特殊挑战
Android 是 Linux,POSIX 信号能用;但它不是普通 Linux 进程模型。有三道门槛。
Zygote 与 ART
App 进程不是 exec() 出来的干净映像,而是 Zygote fork() 出来的:
flowchart TD
Z[zygote]
Z --> A[已加载 framework / 已启动 ART]
Z --> B["fork() 出 App 进程"]
B --> B1[子进程继承整份 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[同进程线程]
T --> T1[不能当 crash handler 用]
Q1 -->|"CLONE_VM=0(COW 拷贝)"| Q2{"拷贝之后是否 exec?"}
Q2 -->|否| Dirty["纯 clone / fork"]
Dirty --> Dirty1[子进程仍是崩溃进程的脏克隆]
Q2 -->|是| Clean["fork + exec"]
Clean --> Clean1[子进程换成全新程序]
| 方式 | 系统调用 | 子进程内存 | 子进程能否当普通程序用 | 崩溃场景下通常能做什么 |
|---|---|---|---|---|
| 同进程线程 | 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); /* 自研 crash_handler,或 app_process64 */
_exit(127);
}
int status = 0;
waitpid(pid, &status, 0); /* 父进程等 handler 写完 dump */
}
/* C 的另一半:exec 之后的新进程入口(独立编成可执行文件;省略参数解析) */
int main(int argc, char** argv) {
pid_t target = /* 从 argv 取出崩溃进程 pid */;
/* 此处已是干净映像:可 malloc、写文件、跑完整 C++ */
CaptureCrossProcess(target); /* 前文:ptrace / waitpid / 读栈 / maps */
WriteMinidump(/* 四要素 → *.dmp */);
return 0;
}
补充两点,否则会踩坑:
exec()换的是内存映像,不换进程身份。 PID、父子关系、打开的文件描述符默认保留。长存活业务进程可能挂着成百上千个 fd;若不在exec()前关掉,新程序(例如app_process)可能在进入main之前就SIGSEGV。正确做法是子进程里盲关stderr之后的 fd,再execve()。exec()不会清掉LD_LIBRARY_PATH。 若把业务里的libcrypto路径塞进环境变量,系统二进制启动时可能去解析错的 OpenSSL,直接 link 失败。子进程侧加载业务 so 应用绝对路径dlopen()/System.load(),不要污染系统 linker。各平台的隔离强度对比见现代 C++ 跨平台开发 - 二进制篇的「加载隔离」。
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"]
i2 --> i3[native 线程只有 System ClassLoader]
i3 --> i4[须在 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。
进程信息的 Java 侧读法
四要素里的进程信息,Java 层也有对应封装:
- pid:
Process.myPid()与 nativegetpid()是同一个值——实现上就是对getpid()的 JNI 包装,Java 侧埋点与 native dump 可以直接对齐。差别在可用性:getpid()是 async-signal-safe,信号上下文里也可调用;Process.myPid()要求 ART 健康,只能用于初始化 / 普通线程。 - 进程名:API 28+ 用
Application.getProcessName();更低版本可反射ActivityThread.currentProcessName(),但隐藏 API 在新系统上不保证可用。它和/proc/<pid>/cmdline读到的是同一个名字;采集侧仍以/proc为准,Java 侧结果用于对齐 / 校验。
其它约束
| 约束 | 影响 |
|---|---|
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() 换映像之后,才谈得上完整的写出、状态管理和当次上传。这些取舍会在后文具体方案中落到实现上。
OHOS 的特殊挑战
鸿蒙基于 Linux,基本的 POSIX 信号监听以及四要素抓取方式可复用 Android。
虽然 Ability / Stage 进程模型不同于 Zygote,但脏 fork 后同样不能用运行时,所以拉进程方式也类似 Android:脏克隆只做有限转储,干净能力靠 exec。
只不过,exec 的目标不是 app_process,而是系统 ProcessDump,或 HAP 里自带的 native 可执行文件:
/* A. 系统路径同构:fork + exec ProcessDump(官方 DFX 即此类形状) */
void LaunchProcessDump(pid_t crashed_pid) {
pid_t pid = fork();
if (pid == 0) {
char pid_str[16];
snprintf(pid_str, sizeof(pid_str), "%d", (int)crashed_pid);
/* 路径随镜像而定,这里只示意形态;参数常见为 -p <pid> [-t <tid>] */
char* argv[] = { "/system/bin/processdump", "-p", pid_str, NULL };
char* envp[] = { NULL }; /* 勿继承业务 LD_LIBRARY_PATH */
execve(argv[0], argv, envp);
_exit(127);
}
int status = 0;
waitpid(pid, &status, 0);
}
/* B. 第三方自建:同样 fork + exec,但换成 HAP 内解出的 handler 二进制 */
void LaunchOwnOhosHandler(const char* handler_abs_path, pid_t crashed_pid) {
pid_t pid = fork();
if (pid == 0) {
char pid_str[16];
snprintf(pid_str, sizeof(pid_str), "%d", (int)crashed_pid);
/* handler_abs_path 例:应用私有目录下解压出的 crash_handler,
不是 /system/bin/app_process*,也不经 Ability/Stage 冷启动 */
char* argv[] = { (char*)handler_abs_path, "--pid", pid_str, NULL };
char* envp[] = { NULL };
execve(argv[0], argv, envp);
_exit(127);
}
int status = 0;
waitpid(pid, &status, 0);
}
/* B 的另一半:自研 handler 的 main(ProcessDump 是系统二进制,入口同构但非应用代码) */
int main(int argc, char** argv) {
pid_t target = /* 解析 --pid */;
/* 干净映像:四要素读法与 Android/Linux 相同 */
CaptureCrossProcess(target);
WriteMinidump(/* ... */);
return 0;
}
注意:系统已为致命信号挂了 DFX handler;应用再抢同一批信号可能影响官方 NativeCrash / HiAppEvent。自建采集要么与系统链共存并按时再次 raise(),要么以订阅系统事件为主、自己只做补充。
ArkTS 侧也有对应封装:@ohos.process 的 process.pid 与 native getpid() 同值(底层同源),初始化时记录即可与 native 侧对齐;进程名用 appManager.getRunningProcessInformation() 按 pid 匹配 ProcessInformation.processName,与 /proc/<pid>/cmdline 互相校验。与 Java 层一样,信号路径上无法调用 ArkTS;采集仍以 /proc 为准。
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,崩溃现场能做的事更少,很多能力要放到下一次冷启动补传。
四要素抓取
要写进 dump 的仍是同一套四要素:进程信息、线程上下文、栈内存、模块映射。Mach 路径换的是读法,不是换目标。
| 要素 | Apple 上常见读法 |
|---|---|
| 进程信息 | pid:getpid();进程名:getprogname()(iOS / macOS 通用);macOS 上跨进程可走 libproc 的 proc_name() |
| 线程上下文 | 异常消息里带故障线程;监听线程再 thread_get_state() / thread_get_exception_ports 相关接口取 PC、SP 等 |
| 栈内存 | 按 SP 对 task 做 mach_vm_read() / vm_read()(同进程监听线程即可;也可交给持有 task port 的外部 handler) |
| 模块映射 | dyld 镜像列表(如 _dyld_image_count / task_info 的 dyld infos);符号化用的是 Mach-O UUID,不是 ELF build-id |
flowchart TD
Exc[Mach 异常消息] --> State["thread_get_state 取 PC / SP"]
State --> Stack["mach_vm_read 拉栈"]
State --> Images["枚举 dyld 镜像 + UUID"]
Stack --> Dump[写 dump]
Images --> Dump
示意(监听线程上;省略错误处理;寄存器布局以 arm64 为例):
void CaptureMach(thread_t fault_thread, void* stack_buf, size_t stack_cap) {
/* 1) 线程上下文:从故障线程取出 PC / SP(布局依 ABI) */
arm_thread_state64_t st;
mach_msg_type_number_t count = ARM_THREAD_STATE64_COUNT;
thread_get_state(fault_thread, ARM_THREAD_STATE64,
(thread_state_t)&st, &count);
uintptr_t pc = /* st.__pc */;
uintptr_t sp = /* st.__sp */;
/* 2) 栈内存:按 SP 从本 task 虚存拷一段出来 */
mach_vm_size_t got = 0;
mach_vm_read_overwrite(mach_task_self(), (mach_vm_address_t)sp,
stack_cap, (mach_vm_address_t)stack_buf, &got);
/* 3) 模块映射:枚举 dyld 已加载镜像,再取 Mach-O UUID */
uint32_t n = _dyld_image_count();
for (uint32_t i = 0; i < n; ++i) {
const struct mach_header* hdr = _dyld_get_image_header(i);
const char* name = _dyld_get_image_name(i);
/* UUID:遍历 load commands,找 LC_UUID */
}
}
和 Linux 对照:没有 /proc/<pid>/maps 与 dl_iterate_phdr() 那套;也很少把 ptrace() 当主路径。监听线程跑在普通上下文,不受 async-signal-safe 白名单束缚,但仍要尽快挂起故障线程,避免现场被继续跑糊。iOS 上外部 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 极难。
四要素抓取
同样是四要素;Windows 上现场多半已经装在 EXCEPTION_POINTERS 里,或由 MiniDumpWriteDump() 代劳写出。
| 要素 | Windows 上常见读法 |
|---|---|
| 进程信息 | pid:GetCurrentProcessId();进程名:本进程 GetModuleFileNameW(NULL, ...) 取路径后留文件名,跨进程 QueryFullProcessImageNameW() |
| 线程上下文 | EXCEPTION_POINTERS->ContextRecord(x64 上 Rip / Rsp 等);跨进程则用 GetThreadContext() |
| 栈内存 | 同进程按 SP 直接读,或 ReadProcessMemory();MiniDumpWriteDump() 按 DumpType 自动切栈等区域 |
| 模块映射 | EnumProcessModules() / Toolhelp 快照 / PEB 的 LDR 链表;调试 ID 来自 PE 的 CodeView / PDB 信息,不是 ELF build-id |
flowchart TD
EP["EXCEPTION_POINTERS"] --> Ctx["ContextRecord:PC / SP"]
Ctx --> Stack["读栈或交给 MiniDumpWriteDump"]
Ctx --> Mods["枚举模块 + PDB/CodeView ID"]
Stack --> Dump["写出 MDMP"]
Mods --> Dump
同进程示意(省略错误处理;以 x64 为例):
void CaptureFromException(EXCEPTION_POINTERS* info,
void* stack_buf, size_t stack_cap) {
/* 1) 线程上下文:从 ContextRecord 取 PC / SP(布局依 ABI) */
CONTEXT* ctx = info->ContextRecord;
uintptr_t pc = ctx->Rip;
uintptr_t sp = ctx->Rsp;
/* 2) 栈内存:同进程按 SP 直接读一段 */
size_t n = stack_cap; /* 实际上限还要受栈提交范围约束 */
memcpy(stack_buf, (void*)sp, n);
/* 3) 模块映射:枚举已加载模块,再取 PDB / CodeView ID */
HMODULE mods[256];
DWORD needed = 0;
EnumProcessModules(GetCurrentProcess(), mods, sizeof(mods), &needed);
for (DWORD i = 0; i < needed / sizeof(HMODULE); ++i) {
/* GetModuleFileNameEx → 路径;再读 PE debug directory 取 PDB/ID */
}
}
工程上更常见是直接交给 DbgHelp(前文过滤器里已示意):
/* MiniDumpWriteDump 内部会按 DumpType 收集上下文、栈切片、模块列表 */
MiniDumpWriteDump(GetCurrentProcess(), GetCurrentProcessId(), file,
MiniDumpWithThreadInfo, &mei, nullptr, nullptr);
和 Linux 对照:主路径通常是 同进程 过滤器 / VEH 里调 DbgHelp,而不是先 ptrace();需要隔离时再拉外部进程对崩溃进程 OpenProcess() + MiniDumpWriteDump()。跑在普通线程上下文,可以分配内存、写文件——这是和 POSIX 信号路径最大的体验差。minidump 容器本身就从这里长出来,后文 Breakpad / Crashpad 在 Linux 上复用的是同一套磁盘布局。
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"]
Clone --> Dirty[脏克隆子进程]
Dirty --> Att["ptrace + waitpid"]
Att --> Four[取四要素]
Four --> Four1[进程信息]
Four --> Four2[寄存器]
Four --> Four3[栈切片]
Four --> Four4[扫 maps 得模块映射]
Four --> Dump["写 *.dmp"]
Dump --> Stop[_exit]
Stop --> Stop1[不宜再跑 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 里定位所属模块]
B --> B1["基址 ≤ PC < 基址 + 大小"]
B1 --> C[算出相对偏移 = PC - 基址]
C --> C1[符号化与聚类的钥匙]
C1 --> D[按 ABI + MemoryList 栈切片往回 unwind]
D --> D1["每帧:查模块、减基址"]
D1 --> D2[有符号则查 .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"] --> B[从崩溃线程的 PC 起 unwind]
B --> B1[依据 ABI + CFI + 栈切片]
B1 --> C[逐帧解析]
C --> C1[定位所属模块]
C1 --> C2[减去基址得相对偏移]
C2 --> C3[查 .sym 的 FUNC 与行号]
C3 --> D[输出文本报告]
输出字段和 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"]
Exec --> Exec1[干净 handler]
Exec1 --> Att["ptrace + waitpid"]
Att --> Four[取四要素]
Four --> Four1[进程信息]
Four --> Four2[寄存器]
Four --> Four3[栈切片]
Four --> Four4[扫 maps 得模块映射]
Four --> Dump[写 minidump]
Dump --> More[叠 Annotation / Database]
More --> More1[甚至当次上传]
和 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[子进程]
C --> C1[关掉多余 fd]
C1 --> C2[复位致命信号]
C2 --> C3[execve app_process64]
B --> D[父进程 waitpid 后再次 raise]
C3 --> 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 / 进程名 / 版本"]
s --> s1[写进命令行]
d["动态字段:运行时随时可写"]
d --> d1[写 StringAnnotation 共享内存]
d1 --> d2["地址固定、容量编译期确定"]
end
subgraph h["handler 进程"]
hs[直接读启动参数]
hd[ptrace 按布局读出]
end
s1 --> hs
d2 --> 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
A["写出开始"] --> A1["new/uuid.dmp<br/>handler 正在写,或写到一半被杀"]
A1 -->|"写完并 fsync"| B["pending/uuid.dmp<br/>唯一「确定完整」的集合"]
B -->|"当次上传成功"| C["completed/<br/>已消费,避免重复"]
B -->|"失败 / 未直传"| D["下次冷启动扫 pending"]
D --> D1["入队后再 rename 到 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 so 加载
为什么自己解压
动态库加载的基本 API 见现代 C++ 跨平台开发 - 二进制篇的「动态库加载 API」;本场景特殊之处只有一点:so 得先从 APK 解出来。
原因有两条:handler 是 fork() + exec() 出来的 app_process 裸进程,没有 Framework 给 APK 配好的 linker namespace,按库名搜不到;而 extractNativeLibs=false(现行 AGP 默认)时,APK 里的 so 也不会落到 nativeLibraryDir,磁盘上没有现成路径可指。于是自然只剩一条路:由主进程在冷启动阶段把 so 从 APK 解到应用私有目录,handler 启动后再按绝对路径加载。
整体流程
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(),避免半包被加载。
加载约束
解出来的 so 有两条硬约束:文件必须只读、必须用绝对路径加载。两条的机制与通用做法见现代 C++ 跨平台开发 - 二进制篇的「加载隔离」与「文件权限(Android)」,handler 侧特有的部分只剩:
- 权限跟随解压流程:解压完成立刻去写权限,覆盖升级重解压时先加回写、写完再改回只读,CRC 命中未重解的也要补扫一遍(旧版本可能留下可写 so);
- 路径在主进程初始化时算好,经环境变量或配置文件传给 handler;崩溃信号路径里不再拼 APK 路径、不再解压;
- handler 自身与 curl / ssl / crypto 等依赖放在同一解压目录,全部用该目录下的绝对路径打开。
体积裁剪
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——这是没有符号时的预期代价。
「基址 + 相对偏移」这套模型、以及 ASLR 下 load_bias 的算法,见现代 C++ 跨平台开发 - 二进制篇的「加载器实现细节」。
聚类策略
符号化之后,聚类建议只看崩溃线程的第一帧特征:后面的 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,通常就是出问题的那一个。