Native Crash 监控方案演进

Native crash 监控解决的不是「怎么打印一行堆栈」,而是一条端到端链路:

  • 捕获:进程已经处于不确定状态,如何尽量不死二次、尽量写出完整现场。-
  • 落盘:写出可跨平台解析的 minidump,并区分「写了一半」和「写完待传」。
  • 符号化:把指令地址还原成函数名和源码行号。
  • 上报:崩溃当次就把报告送出,而不是等用户下次打开 App。
  • 聚类:海量事件归并成可跟进的 Issue。

本文按这条链路展开。Linux / Android 是主线,Apple 与 Windows 只作为对照;客户端采集以 Breakpad → Crashpad 的演进为主,服务端聚焦 stackwalk 工具链和聚类规则。

基本的 Native Crash 监控

POSIX 信号

在 Linux / Android 等 POSIX 系统上,内核用 信号(signal) 把异步事件通知给进程。对 native crash 来说,最常见的来源是:

  • 非法访存、对齐错误、非法指令等硬件异常,由内核转换成信号;
  • abort()assert 失败、部分运行时自检,主动发送 SIGABRT
  • 调试器断点、错误系统调用等。

信号不是普通函数调用。它会打断当前线程,切到该线程上注册的处理函数。处理函数返回后,线程从被打断处继续;若处理函数选择结束进程,内核按默认动作终止。

这也是 crash 监控最难的地方:处理函数运行时,堆、锁、分配器、ART/JVM 都可能已经坏了。 能在 handler 里安全调用的接口,被 POSIX 收进 async-signal-safe 白名单——下一小节单独展开。

注册回调

现代代码用 sigaction,而不是老的 signal()

struct sigaction sa;
memset(&sa, 0, sizeof(sa));
sa.sa_sigaction = on_crash;   /* 三参数回调,能拿到 siginfo */
sa.sa_flags = SA_SIGINFO | SA_ONSTACK;
sigemptyset(&sa.sa_mask);
sigaction(SIGSEGV, &sa, &old_sa);

要点:

说明
SA_SIGINFO 回调签名变为 (int, siginfo_t*, void*),可读取 si_addrsi_code
SA_ONSTACK 使用预先准备的备用栈(sigaltstack),避免崩溃线程栈已耗尽时连 handler 都进不去
sa_mask handler 执行期间要屏蔽的信号集合,避免重入
保存 old_sa dump 完成后要把信号交还给系统默认动作,否则进程杀不掉,会变成「崩溃了但一直活着」

典型回调形态:

void on_crash(int signo, siginfo_t* info, void* ucontext) {
    /* 只做 async-signal-safe 的事:通知另一条线程 / fork 子进程 */
    /* 由那一侧写 dump */
    /* 恢复旧 handler,重新 raise,让进程按默认动作退出 */
}

ucontext 里有崩溃瞬间的寄存器(PC、SP、通用寄存器),这是后续 unwind 的起点。siginfo_t.si_addrSIGSEGV / SIGBUS 是出错地址。

常见致命信号

下表是 Android / Linux native crash 监控通常会接管的信号。默认动作几乎都是「终止进程,可能写 core」。

信号 编号(Linux) 典型触发 si_code 常见值 监控是否接管
SIGSEGV 11 空指针、野指针、写只读页 SEGV_MAPERR(未映射)、SEGV_ACCERR(权限) 是,最常见
SIGABRT 6 abort()assert、部分 libc / STL 检查 多为用户态 raise
SIGBUS 7 对齐错误、mmap 文件被截断后访问 BUS_ADRALNBUS_ADRERR
SIGILL 4 非法指令、执行损坏代码、错误 ABI ILL_ILLOPCILL_ILLTRP
SIGFPE 8 整数除零、部分浮点异常 FPE_INTDIVFPE_FLTDIV
SIGTRAP 5 bkpt / int3、部分调试陷阱 TRAP_BRKPT 视策略
SIGSYS 31 seccomp 拦截、错误 syscall 号 SYS_SECCOMP 视策略
SIGSTKFLT 16 协处理器栈错误(少见) 可选
SIGTERM / SIGINT 15 / 2 外部请求退出,不是崩溃 通常不接管
SIGKILL / SIGSTOP 9 / 19 内核强制,用户态无法捕获 无法接管

SIGKILLSIGSTOP 不能被 sigaction 拦截。OOM Killer、am force-stop 走的是这条路,native crash SDK 对此无能为力。这也是「捕获率永远到不了 100%」的物理上限之一。

async-signal-safe

信号处理函数和普通回调有本质差别:它可能插在 任意 指令之间执行——包括 malloc 正在改空闲链表的半途、pthread_mutex_lock 已持锁未入临界区、printf 正往 FILE 缓冲里写。若 handler 再调这些函数,轻则死锁,重则堆元数据二次损坏,连 dump 都写不出来。

POSIX(IEEE Std 1003.1)因此定义了 async-signal-safe:在信号上下文中,只有列入标准白名单的接口才保证可重入、可安全调用。名单之外的,一律视为未定义行为——「碰巧能跑」不算合规。完整列表可参考 Linux 手册 signal-safety(7)

常见可用(白名单摘录)

类别 示例 典型用途
退出 _exit_Exit dump 失败时立刻结束,不跑 atexit
文件 openclosereadwritefsync 写面包屑日志、唤醒 pipe
进程 forkexecvewaitpidgetpidgettid(Linux) 拉起写 dump 的子进程
信号 sigactionsigprocmaskraise 恢复旧 handler 后 reraise
时间 nanosleepclock_gettime(部分实现) 带超时的短等待
内存映射 一般 不要 在 handler 里新 mmap 大块业务逻辑 预分配更稳

常见禁用(一踩就炸)

类别 示例 为什么危险
malloc / free / new / delete 分配器全局锁 + 元数据,崩溃时多半不一致
标准 IO printfcoutfopen FILE* 带锁、会分配缓冲
线程 pthread_mutex_lockpthread_cond_signalpthread_joinpthread_create 可能与被打断线程抢同一把锁;pthread_create 死锁发生在返回前,外层超时救不了
JNI / ART AttachCurrentThread、任意 Java 调用 重入 JVM,二次崩溃则 native 栈也丢
C++ 运行时 异常抛出/捕获、dynamic_cast、多数 STL 改容器 依赖堆与 RTTI,标准未给信号安全承诺
C++20 同步 std::condition_variablesemaphoreatomic::wait / atomic::notify 标准 没有 async-signal-safe 保证,不能当 pipe

工程上的正确拆分是:

信号上下文(必须 async-signal-safe)
  ├─ 写 pipe / 设原子标志          唤醒已有线程
  ├─ fork +(子进程内)关 fd、exec 拉干净进程
  ├─ 恢复 old_sa,raise 原信号
  └─ 其它:尽量没有

普通线程 / exec 后的 handler 进程
  ├─ malloc、写完整 minidump、JNI 抓 Java 栈
  └─ HTTP 上传、数据库 rename、日志

典型做法是预创建工作线程,信号里只对 pipe 写端敲一个字节。示意如下(省略错误处理):

static int g_pipe[2] = {-1, -1};          /* [0]=读端 [1]=写端 */
static std::atomic<bool> g_done{false};

/* 初始化阶段:创建 pipe + 预创建工作线程(须在 sigaction 之前) */
void InitPipeWorker() {
    pipe(g_pipe);                         /* 或 pipe2(..., O_CLOEXEC) */
    std::thread([] {
        char byte;
        for (;;) {
            /* 普通线程里阻塞读;被信号侧 write 唤醒 */
            if (read(g_pipe[0], &byte, 1) != 1)
                continue;

            /* 此处可做非信号安全的事:JNI / 写文件 / 组包 … */
            g_done.store(true, std::memory_order_release);
        }
    }).detach();
}

/* 信号上下文:只允许 async-signal-safe */
void NotifyPipeWorkerFromSignal() {
    g_done.store(false, std::memory_order_release);

    char one = 1;
    write(g_pipe[1], &one, 1);            /* 敲门;勿用 condvar / mutex */

    /* 短轮询等待,带总超时;勿 pthread_join */
    const int kMaxLoops = 500;            /* 例如约 500ms */
    for (int i = 0; i < kMaxLoops; ++i) {
        if (g_done.load(std::memory_order_acquire))
            return;
        struct timespec ts = {0, 1 * 1000 * 1000};  /* 1ms */
        nanosleep(&ts, nullptr);
    }
    /* 超时则放弃等待,继续后续 dump / reraise */
}
  • pipe / write / read / nanosleep 都在白名单内;pthread_cond_signalstd::mutex、C++20 atomic::wait 不在。
  • 写端只写一个字节即可,语义是「有事件」,不是在信号里传大块数据。
  • 超时必须包住整段等待:工作线程若挂住,handler 仍能返回。

几条实践原则:

  • 初始化阶段付费,崩溃阶段省钱。 备用栈、pipe、采栈线程、handler 路径字符串,全部在 sigaction 之前准备好;handler 里只做通知。
  • 超时必须包住「非安全调用」本身。 若在 handler 里 pthread_create,死锁发生在 pthread_create 内部,外面的 nanosleep 轮询永远等不到。
  • 日志用 write(fd, buf, n),不用 LOG 宏。 许多日志库内部有锁和堆。
  • Windows / Mach 不自动继承这套税。 VEH、未处理异常过滤器、Mach 监听线程跑在普通上下文,可以分配内存;只有又落到 POSIX sigaction 兜底时,才重新受白名单约束。

后文 Breakpad 的 Java 栈问题、Crashpad 的 pipe 采栈与 fork + exec,全部建立在这一节之上。

信号链还有一个工程问题:后安装的 handler 会覆盖先安装的。WebView、厂商 ROM、其它 SDK 都可能抢 SIGSEGV。可靠的做法是:安装时保存旧动作,dump 完成后 reraise;运行期在前后台切换时检测是否被抢占,必要时重装。重装时必须保留「第一次安装拿到的旧动作」,否则 reraise 会链回自己,进程退不出去。

跨平台异常捕获

Linux 与 Android 的主路径是 POSIX 信号。另外两个主流桌面系统并不把「信号」当作一等公民。

Apple

Darwin 内核的异常模型是 Mach exception,不是 POSIX 信号。线程发生 EXC_BAD_ACCESSEXC_BAD_INSTRUCTIONEXC_ARITHMETICEXC_BREAKPOINT 等时,内核把消息发到该任务的 exception port。监听方在独立线程上 mach_msg 收包,再决定是写 dump 还是交给下一层。

POSIX 信号在 Darwin 上是兼容层:Mach 异常可以被转换成 SIGSEGV 等。只挂 sigaction 在 macOS / iOS 上往往不够:

  • 调试器、系统 crash reporter、其它 SDK 可能先占住 exception port;
  • 部分异常在转成信号之前就已经被 port 消费。

所以 Apple 上的完整方案通常是:

硬件/内核异常
    │
    ▼
Mach exception port  ──────► 独立监听线程写 dump
    │
    ▼
(可选)转换成 POSIX 信号
    │
    ▼
`sigaction` 兜底 / 交给系统 Crash Reporter

代码框架(示意,省略错误处理与权限细节):

#include <mach/mach.h>
#include <pthread.h>

static mach_port_t g_exception_port;

static void* exception_server(void* arg) {
    for (;;) {
        /* 阻塞接收异常消息 */
        mach_msg(/* ... EXC_MSG 缓冲区 ... */);

        /* 从消息里取出异常线程、类型、代码、现场 */
        /*    EXC_BAD_ACCESS / EXC_BAD_INSTRUCTION / ... */

        /* 写 dump(常在本线程或再派到专用线程) */

        /* 决定后续:
              KERN_SUCCESS  — 已处理
              或转交给原先的 exception port / 让进程崩溃 */
    }
    return NULL;
}

void install_mach_exception_handler(void) {
    mach_port_allocate(mach_task_self(), MACH_PORT_RIGHT_RECEIVE,
                       &g_exception_port);
    mach_port_insert_right(mach_task_self(), g_exception_port,
                           g_exception_port, MACH_MSG_TYPE_MAKE_SEND);

    /* 把本 task 的异常端口指到 g_exception_port
       mask 常见:EXC_MASK_BAD_ACCESS | EXC_MASK_BAD_INSTRUCTION |
                  EXC_MASK_ARITHMETIC | EXC_MASK_BREAKPOINT | ... */
    task_set_exception_ports(
        mach_task_self(),
        EXC_MASK_BAD_ACCESS | EXC_MASK_BAD_INSTRUCTION |
            EXC_MASK_ARITHMETIC | EXC_MASK_BREAKPOINT,
        g_exception_port,
        EXCEPTION_DEFAULT | MACH_EXCEPTION_CODES,
        THREAD_STATE_NONE);

    pthread_t t;
    pthread_create(&t, NULL, exception_server, NULL);
    pthread_detach(t);
}

/* 可选兜底:Mach 转成的 POSIX 信号 */
void install_posix_fallback(void) {
    struct sigaction sa;
    /* 同 Linux:sigaction(SIGSEGV/SIGBUS/SIGABRT/...) */
}

要点:异常在 独立监听线程 上处理,不是在崩溃线程的信号栈上;真正写 dump 前通常还要挂起异常线程、读寄存器与栈。iOS 还有额外限制:App 不能像桌面那样随便 spawn 常驻 helper,崩溃现场能做的事更少,很多能力要放到下一次冷启动补传。

Windows

Windows 没有 POSIX 信号模型。用户态 native 异常走 SEH(Structured Exception Handling)

机制 API 特点
线程/函数级 SEH __try / __except 编译器生成,作用域有限
进程级兜底 SetUnhandledExceptionFilter 未处理异常的最后一跳
向量化异常 AddVectoredExceptionHandler 比 SEH 更早,可观察所有异常
系统转储 MiniDumpWriteDump(DbgHelp) 写出 Windows minidump

代码框架:

#include <windows.h>
#include <dbghelp.h>

/* ---------- 最早:VEH,几乎所有异常都会经过 ---------- */
LONG CALLBACK VectoredHandler(EXCEPTION_POINTERS* info) {
    /* info->ExceptionRecord->ExceptionCode
       如 EXCEPTION_ACCESS_VIOLATION / EXCEPTION_ILLEGAL_INSTRUCTION */
    /* 此处可观察;一般不要在这里做重活,除非明确要拦截 */
    return EXCEPTION_CONTINUE_SEARCH;  /* 继续往后传 */
}

/* ---------- 进程兜底:没人处理时才会到这里 ---------- */
LONG WINAPI UnhandledFilter(EXCEPTION_POINTERS* info) {
    HANDLE file = CreateFileW(L"crash.dmp", GENERIC_WRITE, 0, nullptr,
                              CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, nullptr);

    MINIDUMP_EXCEPTION_INFORMATION mei = {};
    mei.ThreadId = GetCurrentThreadId();
    mei.ExceptionPointers = info;
    mei.ClientPointers = FALSE;

    MiniDumpWriteDump(
        GetCurrentProcess(),
        GetCurrentProcessId(),
        file,
        MiniDumpWithThreadInfo,  /* 实际项目按需选 DumpType */
        &mei,
        nullptr,
        nullptr);

    CloseHandle(file);
    return EXCEPTION_EXECUTE_HANDLER;  /* 结束进程 */
}

/* ---------- 局部 SEH:只包住一段可疑代码 ---------- */
void maybe_risky() {
    __try {
        /* 业务逻辑 */
    } __except (EXCEPTION_EXECUTE_HANDLER) {
        /* 仅处理本作用域;不能替代进程级监控 */
    }
}

void install_windows_crash_handler() {
    AddVectoredExceptionHandler(/* First */ 1, VectoredHandler);
    SetUnhandledExceptionFilter(UnhandledFilter);
}

调用顺序可以记成:

异常发生
  → VEH(AddVectoredExceptionHandler,可多个)
  → 各帧 SEH(`__try` / `__except`)
  → UnhandledExceptionFilter
  → 系统默认崩溃对话框 / 终止

Windows minidump 的磁盘格式,正是后来 Breakpad / Crashpad 跨平台 dump 的原型:一个带 MDMP 魔数的文件,内含多条 stream(线程、模块、异常、内存、系统信息)。格式定义见微软 MINIDUMP_HEADER / MINIDUMP_DIRECTORY。Linux / Android 上的 Breakpad 并不是「Linux core 换了个名字」,而是 用同一套 minidump 容器,把 POSIX 信号现场编码进去

和信号模型的关键差异:VEH / 未处理异常过滤器运行在普通线程上下文,不是 async-signal-safe 约束。Windows 上可以较从容地分配内存、写文件、拉起外部进程。这也是后文「为什么 Java 栈采集在 Android 上必须绕开信号上下文」的对照:同一件事,Windows 简单,POSIX 极难。

Android 的额外挑战

Android 是 Linux,POSIX 信号能用;但它不是普通 Linux 进程模型。有三道门槛。

Zygote 与 ART

App 进程不是 exec 出来的干净映像,而是 Zygote fork 出来的:

zygote
  ├─ 已加载 framework / 已启动 ART
  ├─ fork() 出 App 进程          ← 子进程继承整份 Java VM
  └─ 再按 ActivityThread 走组件生命周期

Zygote 能这么做,是因为 fork 前会走 Runtime::PreFork():停掉 ART 内部线程、做完 GC、释放锁、刷新 JIT。普通 App 在崩溃瞬间 不可能 再走一遍这套协议。因此:

  • 崩溃时 fork() 得到的子进程,表面上「有一份 Java VM 拷贝」,实际上 ART 线程没跟过来、锁可能悬空,不能再调 JNI
  • 若既要独立进程写 dump,又想在子进程里跑 Java,必须 fork 之后立刻 exec 一个全新程序(例如 /system/bin/app_process64),让内核换掉整份内存映像。

开进程方式对比

「崩溃时再开一个进程写 dump」这句话,至少对应三种完全不同的系统调用组合。差别不在「有没有子进程」,而在子进程和崩溃进程还共享多少状态。

      ┌──────────── 是否共享地址空间 ────────────┐
      │                                          │
CLONE_VM=1                                 CLONE_VM=0
(pthread 那种)                             (COW 拷贝)
      │                                          │
      │                     ┌────── 拷贝之后是否 exec ──────┐
      │                     │                              │
   同进程线程            纯 clone / fork               fork + exec
   不能当 crash          子进程仍是崩溃                  子进程换成
   handler 用            进程的「脏克隆」                全新程序
方式 系统调用 子进程内存 子进程能否当普通程序用 崩溃场景下通常能做什么
同进程线程 clone(CLONE_VM) / pthread_create 与崩溃线程共享 共享堆/锁,极危险 只适合初始化阶段预创建,不适合崩溃时新建
CLONE_VMclone 例如 clone(CLONE_FS | CLONE_UNTRACED) 父进程 COW 拷贝 几乎不能。堆、全局、ART 都可能是坏的 子进程只宜做 ptrace + 写文件,且须 async-signal-safe
fork fork() 同样是 COW 拷贝 同左;Java VM 不可用 与上一行天花板相同,只是 POSIX 包装不同
fork + exec fork() 后立刻 execve exec 后全新映像 可以:独立堆、可跑完整 C++,甚至新的 Java Runtime 关键差异在 exec,不在 fork

示意(信号上下文里调用;省略错误处理与预分配栈细节):

/* A. 无 CLONE_VM 的 clone:脏克隆,子进程入口只能做有限的事 */
static int CloneDumpEntry(void* arg) {
    /* ptrace 读父进程 + 写 minidump;勿 malloc / JNI */
    DoPtraceAndWriteDump(arg);
    _exit(0);
    return 0;
}

void LaunchCloneDumper(void* stack_top, void* arg) {
    /* 注意:没有 CLONE_VM → COW 拷贝,不是共享地址空间 */
    pid_t child = clone(CloneDumpEntry,
                        stack_top,
                        CLONE_FS | CLONE_UNTRACED,
                        arg);
    /* 父进程 waitpid 后 reraise */
}

/* B. 纯 fork:与上类似,仍是崩溃进程的脏克隆 */
void LaunchForkDumper() {
    pid_t pid = fork();
    if (pid == 0) {
        DoPtraceAndWriteDump(/*...*/);
        _exit(0);
    }
    /* 父进程 waitpid */
}

/* C. fork + exec:关键在 exec,换成干净映像 */
void LaunchExecHandler(char* const argv[], char* const envp[]) {
    pid_t pid = fork();
    if (pid == 0) {
        /* exec 前盲关继承来的多余 fd(保留 0/1/2) */
        struct rlimit lim;
        getrlimit(RLIMIT_NOFILE, &lim);
        for (int fd = STDERR_FILENO + 1; fd < (int)lim.rlim_cur; ++fd)
            close(fd);

        /* 复位致命信号为默认,再换程序 */
        execve(argv[0], argv, envp);      /* 例如 /system/bin/app_process64 */
        _exit(127);
    }
    int status = 0;
    waitpid(pid, &status, 0);             /* 父进程等 handler 写完 dump */
}

补充两点,否则会踩坑:

  • exec 换的是内存映像,不换进程身份。 PID、父子关系、打开的文件描述符默认保留。长存活业务进程可能挂着成百上千个 fd;若不在 exec 前关掉,新程序(例如 app_process)可能在进入 main 之前就 SIGSEGV。正确做法是子进程里盲关 stderr 之后的 fd,再 execve
  • exec 不会清掉 LD_LIBRARY_PATH 若把业务里的 libcrypto 路径塞进环境变量,系统二进制启动时可能去解析错的 OpenSSL,直接 link 失败。子进程侧加载业务 so 应用绝对路径 dlopen / System.load,不要污染系统 linker。

多进程与文件锁

Android App 常见多进程:主进程、:push、WebView、业务子进程等。它们同 uid,共享 filesDir / 外部私有目录。crash 监控里的 dump 目录、pending 快照、配置与日志,很容易被多进程同时读写,必须用 跨进程 互斥,而不是进程内 mutex

Linux / Android 上常见选择:

API 头文件 粒度 特点
flock <sys/file.h> 整个文件 劝告锁;简单。部分文件系统 / NFS 上行为不一致
fcntlF_SETLK / F_SETLKW <fcntl.h> 文件区间 劝告锁;struct flock 可指定偏移和长度。Java FileChannel.lock() 在 Linux 上就是它
lockf <unistd.h> 从当前偏移起 实质是 fcntl 的封装,接口更窄
open(O_EXCL) / mkdir 路径 用「创建成功」当锁,无内核等待,要自己处理残留

应用私有目录一般是 ext4 / f2fs,推荐 fcntl 写锁(F_WRLCK

  • 和 Java NIO FileChannel.lock() 是同一把内核锁,JNI 与 Kotlin 能互斥;
  • F_SETLKW 阻塞等待,F_SETLK 非阻塞;
  • 进程异常退出时,内核会释放该进程持有的 fcntl 锁,比「lock 文件忘了删」更安全;
  • 锁的是某个约定好的 *.lock 文件,真正的数据用「写 tmp + rename」保证原子性。

示意(省略错误处理;无头文件):

/* flock:整文件劝告锁,接口简单 */
void WithFlock(const char* lock_path, void (*work)()) {
    int fd = open(lock_path, O_RDWR | O_CREAT, 0600);
    flock(fd, LOCK_EX);                   /* 阻塞;LOCK_NB 为非阻塞 */
    work();
    flock(fd, LOCK_UN);
    close(fd);
}

/* fcntl:与 Java FileChannel.lock() 同源,可指定区间 */
void WithFcntlWriteLock(const char* lock_path, void (*work)()) {
    int fd = open(lock_path, O_RDWR | O_CREAT, 0600);
    struct flock fl = {};
    fl.l_type = F_WRLCK;
    fl.l_whence = SEEK_SET;
    fl.l_start = 0;
    fl.l_len = 0;                         /* 0 = 整文件 */
    fcntl(fd, F_SETLKW, &fl);             /* 阻塞等到锁 */

    work();                               /* 临界区内写 tmp,再 rename */

    fl.l_type = F_UNLCK;
    fcntl(fd, F_SETLK, &fl);
    close(fd);
}

/* 锁内原子发布:先写临时文件,再 rename 成正式路径 */
bool WriteTextAtomic(const char* path, const char* data, size_t len) {
    char tmp[512];
    snprintf(tmp, sizeof(tmp), "%s.tmp", path);
    int fd = open(tmp, O_WRONLY | O_CREAT | O_TRUNC, 0600);
    if (fd < 0)
        return false;
    /* write 循环写满 len;fsync(fd) 视可靠性要求 */
    close(fd);
    return rename(tmp, path) == 0;        /* 同目录 rename 原子替换 */
}

lockffcntl 的窄封装,语义类似,跨语言协作时优先用 fcntl,避免和 Java 侧对不齐。

不要用:

  • pthread_mutex 进程共享:要放在 shm 里,崩溃时可能留下 locked mutex;
  • 仅靠 rename 当锁:它是原子发布,不是互斥。

配合方式:**fcntl 保护临界区,rename 发布完整内容。** 读侧对文本再加长度上限,防止损坏文件被一次性 mmap 爆内存。扫库、补传等逻辑也宜指定单一进程执行,避免多进程同时消费同一批 dump。

其它约束

约束 影响
extractNativeLibs=false(现行 AGP 默认) so 留在 APK zip 里,只有 Framework 配好的 linker namespace 才能直接加载;裸 app_process 进程没有这个 namespace,必须把 so 解压到可加载的目录
Android 12+ 后台启动限制 崩溃后进程往往处于后台,startService / 甚至 startForegroundService 都可能被拒;「下次启动再上传」会大面积滞留
初始化盲区 loadLibrary 完成、信号 handler 挂上之前的崩溃无法捕获;attachBaseContext 里加载的 so 正好落在这段窗口
OEM 进程管理 厂商可能很快杀掉崩溃后拉起的短命进程,写 dump 的子进程必须尽快完成

到这里可以收束成一句:Android native crash 监控的本质,是在「已损坏的 Linux 进程 + 不可再安全 fork 的 ART」里,用尽量少的信号安全操作,把现场交给一个足够干净的进程。 脏克隆能做的事很少;只有 exec 换映像之后,才谈得上完整的写出、状态管理和当次上传。这些取舍会在后文 Breakpad / Crashpad 方案中落到具体实现上。


Breakpad 方案

Breakpad 是 Google 早期的跨平台 crash 采集套件,Chrome、Firefox 以及大量 Android SDK 都用过。它的历史贡献是两件事:统一的 minidump 容器,以及配套的 dump_syms / minidump_stackwalk 符号化工具链。客户端设计说明见官方 Client Design

整体架构

+---------------------------+          +----------------------------+
|     业务进程 (in-process)  |          |     下次启动 / 独立 sender  |
|                           |          |                            |
|  - 安装 ExceptionHandler  |          |  - 扫描 dump 目录           |
|  - 收到致命信号           |          |  - HTTP 上传 minidump       |
|  - clone 子进程           |          |                            |
|    ptrace 读父进程内存    |          |                            |
|    写 *.dmp               +--------->|                            |
+---------------------------+          +----------------------------+
                                                |
                                                v
                                       +------------------+
                                       |  服务端 processor |
                                       |  dump_syms        |
                                       |  minidump_stackwalk
                                       +------------------+

客户端按平台分成不同的 ExceptionHandler:

平台 捕获入口 dump 写出
Linux / Android sigaction clone 子进程 + ptrace + minidump writer
Windows SetUnhandledExceptionFilter / VEH 常在独立线程调 MiniDumpWriteDump 或自研 writer
macOS Mach exception port 独立线程写 minidump

设计原则(官方 client 文档的原意)在 Android 上被执行得非常彻底:

  • 崩溃后禁止使用应用堆;
  • 崩溃线程本身几乎什么都不做,尽快切到预分配栈 / 子进程;
  • 不自己 walk 可能已经烂掉的栈,而是让 OS 协助转储寄存器和内存;
  • dump 写完后用回调通知上层,真正的上传放到另一个还活着的进程。

Android 上一次典型崩溃:

致命信号到达崩溃线程
        |
        v
signal handler(备用栈,async-signal-safe)
        |
        +-- 可选:唤醒某条线程抓 Java 栈(这里已经开始不安全)
        |
        v
sys_clone(无 CLONE_VM) ──► 子进程 COW 拷贝
        |                       |
        |                       v
        |                 ptrace 读父进程
        |                 写 minidump 到平面目录
        |                 _exit
        v
wait 子进程结束后 `reraise`,进程退出

dump 直接落到一个平面目录:文件在不在,就是「有没有待上报」。没有「写入中 / 写完 / 已消费」的区分。

minidump 格式

minidump 源自 Windows。文件头魔数为 MDMP(小端 PAMD 四字节),后面是目录,目录里每一项指向一条 stream。结构体层面以微软 MINIDUMP_HEADER 为准;Breakpad 在 Linux 上复用同一布局,使一套 processor 能吃掉全平台 dump。符号文件格式见 Breakpad Symbol Files

+------------------+
| Header           |  Signature = 'MDMP'  Version  StreamCount
+------------------+
| Stream Directory |  [Type, Size, RVA] × N
+------------------+
| Stream 0         |  例如 ThreadList
| Stream 1         |  例如 ModuleList
| Stream 2         |  例如 Exception
| ...              |
+------------------+

常见 stream:

Stream 作用
ThreadList 每个线程的 ID、栈内存描述、上下文(寄存器)
ModuleList 已加载 so / dll 的基址、大小、文件名、调试 ID
Exception 异常线程、信号编号、si_addr、崩溃时上下文
SystemInfo CPU、OS 版本字符串(csd_version
MemoryList / MemoryInfo 栈内存切片、映射表
MiscInfo / BreakpadInfo 进程 ID 等补充

processor 并不需要完整 core dump。它用 Exception 里的 PC 当起点,按 ABI 往回 unwind;每帧的 PC 再去 ModuleList 里找落在哪个 so 的哪个相对偏移。相对偏移 才是后面符号化的钥匙。

自定义业务字段没有一等公民。很多 Android 集成会把版本、ABI、进程名、甚至 Java 栈 拼进 SystemInfo 的 OS 版本字符串。这个字段本来只该放 "#1 SMP PREEMPT aarch64" 这类内核描述,容量通常只有数 KB,还要先扣掉真正的 OS 文本。这是后文「字段格式限制」的根源。

符号化工具链

minidump 里没有函数名。函数名在构建产物的 DWARF / PDB 里。Breakpad 把这件事拆成两步。

dump_syms

带 DWARF 的 libfoo.so
        |
        v
    dump_syms
        |
        v
libfoo.so.sym     (文本,MODULE / FUNC / 行号 / STACK CFI)

.sym 是精简过的 ASCII。首行类似:

MODULE Linux arm64 <build-id> libfoo.so

build-id 必须和 minidump ModuleList 里的调试 ID 一致,否则 minidump_stackwalk 会报 WARNING: No symbols。APK 里随包发布的 so 通常是 strip 过的,不能当符号源;CI 必须把未 strip 的 debug so 单独归档。

minidump_stackwalk

crash.dmp  +  symbols/
        |
        v
minidump_stackwalk
        |
        v
可读栈:

Operating system: Android
Crash reason:  SIGSEGV / SEGV_MAPERR
Crash address: 0x0

Thread 12 (crashed)
 0  libfoo.so!Foo::bar(int) + 0x2c
    src/foo.cpp:128
 1  libfoo.so!Foo::entry() + 0x10
 2  libart.so + 0x21eb28

与 NDK 工具对比

工具 输入 输出 适合
addr2line 单个 so + 一个或多个地址 文件:行号 已经知道「哪个 so、哪个绝对/相对地址」
llvm-addr2line / llvm-symbolizer 同上,DWARF 更完整 函数名、inlined 栈、行号 单模块精确定位
ndk-stack tombstone / logcat 文本 + 符号目录 pc 0000xxxx libfoo.so 换成函数 系统 tombstone、logcat,不是 minidump
minidump_stackwalk minidump + .sym 全线程栈、崩溃原因、模块列表 线上 minidump 主路径
ndk-stack 对 minidump 不适用 dump 不是文本

容易混淆的两点:

  • tombstone ≠ minidump。 Debuggerd 写的 tombstone 是文本,ndk-stack 吃这个。Breakpad / Crashpad 写的是二进制 MDMP,必须走 minidump_stackwalk。两者可以并存,但工具不能混用。
  • 绝对地址不能直接扔给 addr2line ASLR 下每次加载基址不同。正确做法是 相对偏移 = PC - module_base,再对 未 strip 的同一个 soaddr2line -e libfoo.so -f -C 0x相对偏移minidump_stackwalk 已经替你完成「找模块 + 减基址 + 查 .sym」。

若只有相对偏移、没有 .symminidump_stackwalk 仍能输出 libfoo.so + 0xe9c18c。这对聚类有用,对人读不够。

方案局限

在 Android 上把 Breakpad 用上线之后,问题会集中暴露在四类。

Java 栈捕获

很多 native 崩溃的真正调用链在 Java:JNI 传入非法参数、Java 层驱动 native 组件。minidump 只有 native 帧,后台需要 Java 栈才能还原上下文。

常见做法是在 signal handler 路径里调 Thread.getAllStackTraces()。无论是 handler 里当场 pthread_create,还是预创建线程后用 pthread_cond_signal + pthread_join 唤醒,都踩在 POSIX 红线上:

操作 async-signal-safe 风险
handler 内直接 JNI 崩溃线程上重入 ART,二次崩溃则 native 栈也丢
pthread_create 若崩溃时 libc 正持有线程链表锁,直接死锁,超时也救不了
pthread_mutex_lock / pthread_cond_signal 与崩溃线程抢同一把锁则死锁
pthread_join 且无超时 JNI 挂住则整个 dump 路径挂住

即便抓到了,还要塞进 OS 版本字符串那种共享 buffer。OS 文本占一两百字节,自定义字段头再占一些,留给 Java 栈往往只有约 3KB;一次主线程栈动辄 10~50KB。于是出现「Java 层以为给了 3KB,写进 dump 后只剩 2KB 出头」的多层静默截断。标准 minidump_stackwalk 也不会把这段当成结构化字段解析。

无法当次上传

clone 子进程不是一个正常进程:不能放心跑完整 HTTP 栈,不能复用 App 里现成的 curl / TLS。上传只能等:

  • 用户下次打开 App,主进程扫 dump 目录;
  • 或者拉起一个后台 Service。

办公类 App 可能数日才被打开一次;Android 12+ 对后台 startService 更苛刻。结果是:dump 在本地,后台不可见。Breakpad 的进程模型本身没有给「崩溃当次、在干净进程里走完整网络库」留位置。

字段格式限制

自定义数据走 OS 字符串,带来一串连带问题:

  • 所有字段共享一个定长 buffer,互相踩;
  • 用换行、KEY: VALUE、竖线之类的自创分隔,值里一旦出现同样字符,后台解析就错;
  • 升级 Breakpad 源码时,这些侵入点分散在 writer / format / processor 多个文件,合并成本高。

拉进程天花板

clone 得到的是崩溃进程的脏克隆。子进程能稳定做的,上限就是 ptrace + 写文件。在这之上叠加「抓 Java 栈、写数据库状态、当次 HTTP 上传」,只会让信号安全问题越来越不可控。

平面目录还有状态盲区:clone 子进程写到一半被杀,半成品和完整 dump 混在一起,后台解析失败,也无法统计「因写入中断丢失」的数量。

这些限制不是调参能消掉的。Chromium 在 2019 年前后把 Android 采集切到 Crashpad,本质是换进程模型,而不是换一个更好的 minidump writer。


Crashpad 方案

Crashpad 是 Breakpad 的继任者,目标仍是写出同一套 minidump,但把「写 dump」从崩溃进程里搬到 独立 handler 进程。设计总览见 Overview Design

整体架构

业务进程 (client)                         handler 进程 (out-of-process)
+---------------------------+            +----------------------------------+
| 初始化:                   |            | exec 后的全新映像                 |
|  - 打开 crash database    |            |                                  |
|  - 安装信号 handler       |   socket   |  - ptrace 读业务进程              |
|  - 预创建 Java 采栈线程   |   / pipe   |  - 写 minidump 到 database/new/  |
|                           |            |  - rename 到 pending/             |
| 崩溃:                     |            |  - (可选)当次上传               |
|  - 信号里只通知 handler   +----------->|  - 标记 completed / 退出          |
|  - 不写 dump、不跑 HTTP   |            |                                  |
+---------------------------+            +----------------------------------+
         |                                            |
         | 失败兜底                                    | 成功则后台已可见
         v                                            v
   下次冷启动扫 pending                    上报服务端

和 Breakpad 的对照:

维度 Breakpad Crashpad
dump 写入点 clone 子进程(脏克隆) fork + exec 后的干净 handler
崩溃进程职责 几乎承担全部写出 只发通知,可选抓 Java 栈
自定义字段 侵入 writer,塞 OS 字符串 Annotation:进程参数 + 共享内存 KV
状态 平面目录 new / pending / completed
多进程 每进程自己 handler 可共享同一 database 目录,文件锁协调
解析 minidump_stackwalk 同一套,minidump 二进制兼容
维护 基本停滞 Chromium 持续维护

Crashpad 自带 HTTP 上传。多数团队不会直接用:已有 curl / TLS、已有鉴权与打包格式、已有自己的崩溃后台。正确切分是:只用 Crashpad 的捕获、minidump、database;上传换成自己的实现。因为 handler 已经是正常进程,这件事第一次变得可行。

改进点

拉进程方式

Android 上 Crashpad 并不常驻一个 handler 进程(常驻容易被 OEM 杀掉,也浪费内存),而是 崩溃时再拉

API 机制 版本
StartHandlerAtCrash fork + exec handler 可执行文件 通用 Linux,需要磁盘上有二进制
StartJavaHandlerAtCrash fork + exec /system/bin/app_process,跑一个 Java 入口类 覆盖 minSdk 较低的 App
StartHandlerWithLinkerAtCrash 通过 linker 加载 handler so API 29+

StartJavaHandlerAtCrash 的路径:

signal handler (业务进程)
    |
    v
fork()
    |
    +-- 子进程: 关闭继承来的多余 fd
    |           复位致命信号为默认动作
    |           execve("/system/bin/app_process64", argv, envp)
    |               |
    |               v
    |           全新 Java Runtime
    |           加载 handler 入口类
    |           绝对路径 load handler 所需 so
    |           ptrace 业务进程,写 dump
    |           (可选)dump 完成后再做内联上传
    |
    +-- 父进程: `waitpid`,然后 `reraise`

exec 之后,handler 拥有独立堆、独立 C++ 运行时,必要时还有独立 ART。它可以:

  • 走完整的 minidump writer,而不必避开 malloc
  • 维护三态数据库(目录 rename、锁、元数据);
  • 在 dump 写完之后 跑 HTTP——这就是内联上传的前提。

这不是 Android 四大组件的启动路径。app_process 裸启动 不会 自动带上 APK 的 ClassLoader 和 linker namespace,必须在 exec 前备好:

CLASSPATH=<apk 路径>          # 否则找不到 handler Java 类
# 故意不设 LD_LIBRARY_PATH
# so 用绝对路径 dlopen / System.load

业务进程初始化时就可以把这些环境变量算好,崩溃时原样传给子进程。路径在解压完成前就是确定的,不必把解压放在信号路径里。

数据协议

Crashpad 为自定义元数据准备了正式通道,而不是继续滥用 OS 字符串。

业务进程                              handler 进程
--------                              ------------
静态字段 (ABI / 进程名 / 版本)
  → 命令行 process annotations  ----►  直接读参数

动态字段 (崩溃瞬间 Java 栈)
  → StringAnnotation<N> 共享内存
     地址固定、容量编译期确定    ----►  ptrace 按布局读出

StringAnnotation<N> 的模板参数既是容量,也是跨进程内存布局。handler 和 client 必须对同一套 N 达成一致。

OS 字符串(旧) Crashpad Annotation(新)
minidump 位置 SystemInfo.csd_version MD_CRASHPAD_INFO stream
结构 单字符串拼接 KV,字段隔离
容量 共享数 KB 每字段独立,编译期指定
标准工具 当普通 OS 描述展示 Crashpad 生态可解析;老 Breakpad processor 默认不认

迁移期可以双写:handler 仍把关键字段追加进 OS 字符串,同时客户端保留 Annotation。这样老后台零改动即可读到字段;等后台能解析 MD_CRASHPAD_INFO 后再收敛。长期应以 Annotation 为准,OS 字符串只放真正的内核版本。分阶段落地见「客户端部署 · 兼容迁移」。

Java 栈捕获

目标不变:崩溃瞬间拿到 Java 栈。约束也不变:信号上下文几乎什么都不能做。Crashpad 把线程创建提前到初始化,崩溃时只用 pipe 敲门(pipe 用法见前文 async-signal-safe)。

初始化
  std::thread 预创建 capture 线程(阻塞在 pipe 读端)
  JNI_OnLoad 缓存 jclass / jmethodID
        (native 线程只有 System ClassLoader,必须在 Java 线程上 FindClass)

崩溃(信号上下文,仅 async-signal-safe)
  write(pipe_fd)          唤醒
  nanosleep 短轮询        带超时等待
  写 crash_thread 标注    tid / 线程名 / 是否主线程

capture 线程(普通线程,可 JNI)
  AttachCurrentThread
  调用缓存好的 getAllStackTraces 一类方法
  写入 managed_stack annotation

三种方案对比:

A. handler 里直接 JNI B. 崩溃时 pthread_create C. 预创建 + pipe
信号安全 pthread_create write / nanosleep
最坏情况 native + Java 全丢 同左(死锁发生在 pthread_create 内部,超时无效) Java 栈记 [capture_timeout],native dump 照写
常态开销 一条阻塞线程,栈很小,不占 CPU

C++ 的 condition_variablestd::this_thread::sleep_for、C++20 semaphore / atomic::wait 都没有 async-signal-safe 承诺。Windows 不需要这套,因为异常过滤器不在信号上下文。这是 POSIX 特有的设计税。

数据侧,Java 栈应独占一个 annotation,例如 8KB,不要再和 OS 文本抢 buffer。兼容老后台时,OS 字符串里可以只放崩溃那一条 Java 线程的截断副本。

Database 状态管理

Crashpad 用目录当状态机,一次 rename 就是一次原子转移:

crashpadDb/
  ├── new/          handler 正在写,或写到一半被杀
  ├── pending/      完整 dump,等待上传或入队
  └── completed/    已消费,避免重复
写出开始 ──► new/<uuid>.dmp
               │
               │ 写完并 fsync
               v
            pending/<uuid>.dmp     ← 唯一「确定完整」的集合
               │
               ├── 当次上传成功 ──► completed/
               └── 失败 / 未直传 ──► 下次启动扫 pending,入队后再 completed/

和 Breakpad 平面目录比:

问题 平面目录 三态 DB
半成品 无法区分 留在 new/,可当孤儿清理
是否已上报 文件还在?不可靠 pending vs completed
多进程竞争 容易双传、双删 文件锁 + UUID 一条报告

completed 的语义要定义清楚。常见两种,不要混用:

  • 已入本地上传队列:拷贝到 App 自己的待传目录后即可 mark,真正 HTTP 由原有管线负责重试。
  • 已达后台:仅当 HTTP 成功才 mark;失败必须留在 pending,供下次冷启动兜底。

内联上传走「已达后台」语义,Java 兜底走「已入队」语义。互斥规则是:内联上传进行中写一个短 TTL 的 guard 文件,扫库方看到 guard 就跳过,避免双传。

Database 还要有维护:清理 new/ 残留、过期 lock、按 TTL 删除过老的 pending / completed,避免频繁崩溃撑满磁盘。维护放在主进程扫库前,不要放进「纯查询 pending 列表」的路径,也不要和 handler 当次写盘抢同一把锁太久。

与 Breakpad 的兼容性

Crashpad 没有重新发明 dump 格式。这是能平滑替换采集端的前提。

兼容关系
磁盘文件 仍是 MDMP + 标准 stream(Thread / Module / Exception / Memory / SystemInfo)
崩溃原因 同样编码为 POSIX 信号 + si_code
模块 ID 同样用 ELF build-id,对得上同一批 .sym
扩展 stream MD_CRASHPAD_INFO附加 的;老 processor 忽略未知 stream 即可
符号化 直接复用 dump_syms + minidump_stackwalk,不必为 Crashpad 再做一套
聚类 输入仍是 minidump_stackwalk 文本,规则可以不变

也就是说:换成 Crashpad 采集后,服务端工具链和聚类输入可以不变;自定义字段从「塞进 OS 字符串」演进到 Annotation,是协议层的事。如何分阶段切过去,属于客户端 / 后台联调落地,见「客户端部署 · 兼容迁移」。


Crashpad 客户端部署

把 Crashpad 嵌进 Android App,工程问题不比采集协议少:构建系统、handler 的 so 从哪来、怎么和现有网络栈对接、怎么控制包体积和冷启动,以及如何在不打断后台的前提下从 Breakpad 迁过来。

编译集成

上游 Crashpad 跟 Chromium 一样用 GN + Ninja。对已经用 CMake / NDK 的 Android 工程,GN 是额外的世界:要拉 depot_tools、对齐一整套 Chromium 式依赖,和 AGP 的 externalNativeBuild 也不合拍。

社区有带 CMake 的 fork:backtrace-labs/crashpad(常用 backtrace 分支),可以把 Crashpad 当普通 add_subdirectory 依赖编进工程,产出 client / handler / minidump / snapshot / util 等静态库。相对 Chromium 原版,它主要多了两块:

相对上游 说明
CMake 构建 真正有用的部分:免 GN,可直接挂 NDK / Gradle
产品向扩展 官方 README / 仓库描述里还有 file attachment、面向 Backtrace 上报链路的改进等;Android 示例里甚至出现 HTTPS_TRANSPORT=OPENSSL 一类选项

接入时按需裁剪即可:用其 CMake 编进工程,去掉用不到的厂商扩展与上传相关目标,只保留自己需要的采集能力。

自定义内联上传

Crashpad 内置上传是简单的 multipart HTTP,认的是它自己的服务器协议。接入方通常要:

  • 复用 App 里已经链接的 curl / ssl / crypto,不把第二份 TLS 打进 crash so;
  • 走已有鉴权、打包、域名和后台入口;
  • 崩溃当次送出 dump,失败再交给下次启动的 Java 管线。

handler 是 app_process 裸进程,没有 Framework linker namespace,也常常没有 nativeLibraryDir 里的实体 so(extractNativeLibs=false)。因此内联上传的前置条件是:把 handler 运行时用到的 so,从 APK 解到应用私有目录,再用绝对路径加载。

Handler so 加载

整体流程
主进程冷启动(后台线程,不挡 UI)
    |
    v
打开 APK(ZipFile)
    读取 lib/<abi>/ 下各 so 的 ZipEntry.CRC
    与本地 manifest 比较
    |
    +-- 一致:跳过解压(约数毫秒);仍检查 so 只读位,可写则补 setReadOnly
    +-- 缺失或 CRC 变化:解到 staging,全成功后再 rename 到正式目录
            写出后立刻去掉写权限
    |
    v
handler 进程(崩溃当次)
    用绝对路径 System.load / dlopen 已解压的 so
    (含 handler 自身及 curl / ssl / crypto 等依赖)
解压与 CRC 校验

为什么用 ZipEntry CRC-32 而不是对文件再算一遍摘要:

  • 要回答的问题是「APK 里的 bundle 换没换」,不是密码学防篡改;
  • CRC 写在 zip 中央目录,读元数据即可,冷启动不必扫数 MB 的 so;
  • ELF 体量下碰撞可忽略;任一 so 单独升级也会改对应条目的 CRC。

manifest 应 逐个 so 一行。bundle 里任一依赖单独升级时,CRC 也会变,才能触发重解压。落盘用 staging + rename,避免半包被加载。

动态加载权限

从 APK 解压到 filesDir 的 so,目录本身往往仍可写,但 dlopen / System.load 映射的那份文件不能保持可写。较新的 Android(高 targetSdk / 平台侧的 Safer Dynamic Code Loading 一类策略)会拒绝加载「可写的原生代码文件」,典型表现是 load 失败或直接抛错,而不是静默降级。

原因很直接:可写 + 可执行 ≈ 运行时被篡改、注入的攻击面。系统要求 native 库文件在加载前满足更严的 W^X 文件侧约束——目录可写以便更新,文件本身只读。

步骤 做法
解压刚完成 对每个 .soFile.setReadOnly(),或 chmod 去掉 owner write;再 dlopen
覆盖升级重解压 setWritable(或 chmod 加写)才能 truncate/覆盖,写完再改回只读
CRC 命中未重解 仍扫描一遍:旧版本留下的可写 so 也要补成只读
原子发布 先写 *.tmp / staging,rename 成正式名后再 setReadOnly

目录保持可写、文件只读,和「主进程解压、handler 绝对路径加载」是一套组合拳:缺任何一环,高版本机型上 handler 都可能起不来。

动态加载路径

app_process 裸进程没有 APK 的 linker namespace,也常常没有 nativeLibraryDir 里的实体文件。handler 侧加载 so 时,路径上要特别注意:

  • 用绝对路径 dlopen / System.load,不要依赖 LD_LIBRARY_PATH。把业务库目录写进环境变量,容易让系统二进制在启动阶段解析到错误的 libcrypto 等依赖(见「开进程方式对比」)。
  • 路径在初始化时就算好,经环境变量或配置文件传给 handler;崩溃信号路径里不要再去拼 APK 路径或解压。
  • 加载前确认目标文件只读(见上一节);路径对了但文件可写,高版本上同样会失败。
  • 依赖库(如 curl / ssl / crypto)与 handler 主库放在同一解压目录时,同样用该目录下的绝对路径打开,保持查找顺序可控。

体积裁剪

Crash 采集 so 会打进所有 ABI。体积优化是接入评审里最容易被问到的一项。Crashpad 上游 GN 默认就是无 RTTI、无异常;CMake 接入要对齐,不要用 NDK 默认的 -funwind-tables 把表又加回来。

非 Debug:
  -Oz
  -ffunction-sections -fdata-sections -fvisibility=hidden
  -Wl,--gc-sections

C++:
  -fno-exceptions
  -fno-rtti
  -fno-asynchronous-unwind-tables
  -fno-unwind-tables

说明:

  • 关掉的是 本 so 自己的 异常与 RTTI。业务 so 的栈仍然靠各自的 DWARF / CFI 还原,不受影响。
  • -fno-unwind-tables 会让本 so 自崩时更难看;采集库应用熔断而不是依赖自己的 C++ 异常。
  • -Oz + gc-sections 对模板和未引用 writer 很有效。

代码裁剪可以按「后台暂时不需要的 stream」来做:

可裁 条件
Crashpad Info / Annotation writer 仍在用 OS 字符串兼容 Breakpad 后台
User extension stream 业务没写自定义 stream
Handle stream Linux/Android 本来就是空的
UMA / histogram 不接 Chromium 指标
handler 内置 prune 线程 改由 Java 侧 TTL 清理 database

不要裁:Thread / Module / Exception / MemoryInfo、以及为了读 Java 栈所必须的 Annotation 读取 路径。裁 writer 不等于裁 snapshot 读取。

启动性能

业务进程冷启动路径上,每一 KB 的 .so 映射和每一毫秒的 JNI_OnLoad 都会进启动曲线。若把 curl / TLS、组包上传等一并链进冷启动就要加载的 so,体积和初始化开销会明显抬高。

可选做法是:冷启动只加载采集必需部分;与上传相关、体积较大的逻辑放到独立 so,在 handler 写完 dump 之后再按绝对路径 dlopen。解压 bundle 放后台线程:CRC 命中时只有数毫秒,首次安装或覆盖升级才解数 MB,UI 线程不必等待。

信号 handler 的安装仍然要尽量靠前,否则 attachBaseContextloadLibrary 之间的 JNI 崩溃是盲区。拆 so 解决的是 映射体积,不解决 注册时机

兼容迁移

前文「与 Breakpad 的兼容性」说明了二进制与工具链可复用。真正上线时,自定义字段从 OS 字符串迁到 Annotation,需要客户端与后台对齐节奏,避免「采集换了、面板空了」。

实践上的迁移顺序:

  • 兼容期:客户端改 Crashpad,dump 仍按 Breakpad 可读方式写 OS 字符串;后台零改动,stackwalk 二进制也不换。
  • 双端升级:后台增加 Annotation stream 解析;客户端关掉「滥用 OS 字符串」,Java 栈用满独立容量。
  • 收尾:删除兼容分支,采集与解析都走标准 Crashpad 字段。

兼容期可以和「体积裁剪」一起做:顺手裁掉「只为 Crashpad 扩展 stream 服务」的 writer(不写 MD_CRASHPAD_INFO),减小 so 体积。只要 ModuleList 和 Exception 还在,stackwalk 和聚类就不会断。双写期内 dump 里会有一份截断的 OS 字符串副本,这是兼容税,双端升级后再去掉。


服务端部署

客户端无论 Breakpad 还是 Crashpad,服务端看到的都是 minidump。服务端要做两件事:符号化,以及把海量事件收成 Issue。

minidump_stackwalk 部署

minidump_stackwalk 是本地可执行文件,动态链接 glibc。构建机和运行机的 glibc 必须兼容:在新发行版上编出来的二进制,拷到老清洗机上会报 GLIBC_2.xx not found

原则:

  • 在与线上相同或更老的 glibc 环境里编译,不要在开发者的 WSL / 新 Ubuntu 上直接出包。
  • Crashpad 与 Breakpad 共用这一支工具,采集端升级 不要求minidump_stackwalk;换的是「dump 从哪来」,不是「怎么 unwind」。
  • 符号来自 CI 归档的 debug so → dump_syms.sym,按 build-id 索引。缺符号时 minidump_stackwalk 仍能给出 so + 相对偏移,只是没有函数名。

用容器固定工具链是目前最省事的办法。Docker 与 Podman 命令几乎一样,Rootless Podman 更适合共享构建机。

# 示例:在老发行版容器里编,挂载源码目录
podman pull centos:7          # 或与线上一致的基础镜像
podman run --rm -it \
    -v $PWD/breakpad:/src:Z \
    centos:7 bash

# 容器内安装匹配的 gcc/cmake 后
cd /src && cmake -S . -B build && cmake --build build
# 产物拷到清洗机,与线上 glibc 对齐后再替换

验收至少包括:

  • ldd minidump_stackwalk 在目标机无 not found
  • 用一份已知 dump + 对应 .sym,输出中出现函数名而不是纯偏移;
  • 对无符号 dump,输出仍含 Thread N (crashed)libxxx.so + 0x...,聚类兜底不至于全空。

聚类规则

符号化之后,聚类仍然建议 极度克制:只看崩溃线程的第一帧特征。后面的 ART / libc 尾帧噪声极大,编进 fingerprint 会把同一缺陷拆成无数 Issue。

优先级从高到低:

定位 "Thread xx (crashed)"
        |
        v
跳过 tid / 寄存器等非栈帧行
        |
        v
取「第一个 so / 模块帧」 ─────────────────────────┐
        |                                          │
        +-- 能还原出函数名?                       │
        |       是 → 用「模块!函数」  (只看这一行)│
        |       否 → 用「so + 相对偏移」            │
        |                                          │
        +-- 整段找不到任何 so 帧 ──► 用绝对 Crash address
优先级 特征 例子 稳定性
优先 还原后的符号,仅第一行 libfoo.so!Foo::bar 最好。函数名在版本间比指令偏移稳
次选 第一个 so 的相对地址 libfoo.so + 0xe9c18c 中。同一构建可合并;so 重编后会拆开
兜底 绝对崩溃地址 Crash address: 0x0 最差。仅当栈完全没有模块信息

相对地址和绝对地址不是详略之别,是 是否扣掉加载基址

PC(绝对)     =  模块加载基址  +  相对偏移
                 (每次 ASLR 都变)   (对本 so 文件是固定的)

例如:
  libfoo.so 被 mmap 到 0x6f00000000
  崩溃 PC = 0x6f000e9c18c
  相对偏移 = 0xe9c18c          ← dump_syms / addr2line / 聚类都用这个

  另一次启动基址变成 0x7000000000
  崩溃 PC = 0x700000e9c18c     ← 绝对值不同,相对偏移仍是 0xe9c18c

因此:

  • 绝对地址 把 ASLR 编进 IssueID,同一缺陷会被拆成碎片,只适合「栈上连 so 都没有」的绝境(例如 PC 落在已卸载内存,ModuleList 对不上)。
  • 相对地址 对「同一构建、同一 so」稳定,跨版本会因编译器布局变化而拆 Issue——这是没有符号时的预期代价。
  • 函数名 跨小版本更稳,但内联、模板实例化、strip 策略仍可能造成合并或拆分,所以仍然 只取第一行,不要把整栈 hash 进去。

系统库要不要跳过,是产品选择。跳过 libart.so / libc.so 能让业务缺陷更突出,但也更容易在「纯运行时崩溃、栈顶就是 art」时掉进绝对地址兜底。一种简单策略是:不跳过系统库,但仍然只看第一帧——实践中 (crashed) 后的第一个 so,通常就是出问题的那一个。


收束

POSIX 信号 / Mach exception / SEH
                |
                v
+---------------+----------------+
|  Breakpad: clone 脏克隆写 dump  |
|  Crashpad: fork + exec 干净 handler|
+---------------+----------------+
                |
                v
        minidump (MDMP)
     new → pending → completed
                |
+---------------+----------------+
| 当次:handler 内联上传(可选)   |
| 兜底:下次启动 Java 管线        |
+---------------+----------------+
                |
                v
dump_syms + minidump_stackwalk
                |
                v
第一帧:符号 > so+相对偏移 > 绝对地址
                |
                v
             Issue

可以记住三句话:

  • 捕获 的上限由进程模型决定:脏克隆里做不了正常 C++,干净 handler 才能做数据库和当次上传。
  • 符号化 的上限由构建决定:strip so 进包,debug so 进符号仓库,stackwalk 只是把两者对上。
  • 聚类 的上限由第一帧决定:能还原就用函数名,否则用相对偏移,绝对地址只当最后兜底。

Breakpad 把 minidump 和工具链做成了行业事实标准;Crashpad 在不破坏这套标准的前提下,把 Android 上最难的那一段——崩溃瞬间的进程模型——换成了可以继续往上叠功能的底座。客户端部署的工作,则是把这个底座嵌进已有的构建、网络库和包体积预算里,而不是重新发明一种 dump。