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_ADRALNBUS_ADRERR
SIGILL 4 非法指令、执行损坏代码、错误 ABI ILL_ILLOPCILL_ILLTRP
SIGFPE 8 整数除零、部分浮点异常 FPE_INTDIVFPE_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 内核强制,用户态无法捕获 无法接管

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

信号监听

注册致命信号回调,现代代码用 sigaction(),而不是老的 signal()

两者都能挂 handler,但能力差一截:

signal() sigaction()
回调形态 基本只有 void(int) 可设 SA_SIGINFO,拿到 siginfo_t*ucontext_t*
标志位 SA_ONSTACKSA_SIGINFOSA_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_addrsi_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_signosi_code;对 SIGSEGV / SIGBUSsi_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()coutfopen() 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_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 / 再次 raise */
}
  • pipe() / write() / read() / nanosleep() 都在白名单内;pthread_cond_signal()std::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() 兜底时,才重新受白名单约束。

获取 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>/memptrace() 负责把目标停在可读状态。

同 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) 看是 SIGSTOPSIGTRAP 还是致命信号本身;
  • 确认停下后再 PTRACE_GETREGSET、读栈、读 maps;做完 PTRACE_DETACHPTRACE_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_ACCESSEXC_BAD_INSTRUCTIONEXC_ARITHMETICEXC_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_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 后再次 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_addrCrash 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 &lt; 基址+大小」的模块"]
  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 首行:OSCPUbuild-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 相对偏移 + .symFUNC
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 的同一个 soaddr2line -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 文件侧约束——目录可写以便更新,文件本身只读。

步骤 做法
解压刚完成 对每个 .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、以及自定义字段(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 对齐后再替换

验收至少包括:

  • ldd minidump_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,通常就是出问题的那一个。