异常处理 - Native 层的崩溃捕获机制及实现 (一)

0 阅读21分钟

1. 深入理解 Native 层的崩溃处理机制

1.1. 前言

在 Android 开发中,Java 层的崩溃可以通过 Thread.setDefaultUncaughtExceptionHandler 轻松捕获

但 Native 层(C/C++)的崩溃——如段错误(Segmentation Fault)、进程终止(Abort)、非法指令(Illegal Instruction)等——完全依赖 Linux 内核的信号机制(Signal) 来处理。理解这一机制,是设计高质量 NDK 崩溃监控、分析 tombstone、甚至自研 Native Crash SDK 的必备基础。

1.2. 处理流程图

1.3. Native 层崩溃整体处理流程概览

Native 崩溃从"硬件异常"到"现场数据落地",完整经历以下五个阶段:

  1. 异常触发: 当进程发生硬件级异常(如非法内存访问、除零、非法指令)时,CPU 触发硬件中断。
  2. 内核响应: 内核捕获中断,根据异常类型向目标进程发送特定的 Signal(信号) 。
  3. 信号递送: 进程在“内核态返回用户态”或“从睡眠中被唤醒”的检查点检测到待处理信号。
  4. 捕获处理: 崩溃处理库(如 Bugly、Breakpad)通过预先注册的 sigaction 劫持该信号,替换系统的默认终止动作。
  5. 现场保全: 进程进入自定义 Handler 后,立即保存当前现场上下文(Context) ,并采取 Fork 子进程 的方式执行 ProcessDump,以规避原进程堆栈损坏带来的二次崩溃风险。

整体流程如下:

CPU 异常  →  内核异常中断  →  内核根据异常类型发送信号到用户进程  
    → 用户进程被打断 / 调度 → 信号检测  
    → [用户自定义 handler 捕获] or [默认终止进程 + core dump]

角色划分:

  • 内核是整个流程的发起者:它检测硬件异常、决定发送哪个信号、并在返回用户态前触发信号处理。
  • Native 层的崩溃处理库(Bugly、Breakpad、Firebase Crashlytics NDK)都是通过自定义 signal handler 来插入到流程中——它们不改变"信号产生"的事实,只改变"信号到达后做什么"。

1.4. 用户进程的信号检测时机

一个容易被忽视的关键点是:用户进程本身不会主动轮询信号,而是由操作系统在几个特定时机将信号“塞给”进程处理。

1.4.1. 时机一:从内核态返回用户态之前

当进程因系统调用、硬件中断等原因进入内核态,在即将返回用户态执行时,内核会检查该进程的待处理信号队列。

  • 若存在待处理信号,内核将立即安排处理:调用用户注册的 signal handler,或执行默认动作(如终止进程、生成 core dump)。
  • 这是绝大多数 Native Crash 的触发点——因为段错误、非法指令这类硬件异常,本身就是在 CPU 执行用户态指令时由硬件中断进入内核的,内核处理完异常后恰好处于"返回用户态"的检查点上。

1.4.2. 时机二:从可中断睡眠状态被唤醒时

当进程因为等待 I/O、锁、条件变量等进入睡眠状态(TASK_INTERRUPTIBLE),一旦收到信号,内核会将其唤醒。在返回用户态前,仍然会执行信号检测。

典型场景包括:

  • 线程在 epoll_wait() 中被信号唤醒;
  • 线程在 pthread_cond_timedwait() 睡眠中被唤醒。

这说明:sleep、wait、poll 等阻塞状态并不会阻止 Native 崩溃的触发与捕获,这也为监控线上阻塞态卡死问题提供了依据。

1.5. 系统处理的崩溃信号

Native 崩溃一般由以下情况触发:

信号值(signo)信号解释触发原因
4SIGILL非法指令进程执行了非法、格式错误、未知或特权指令。
5SIGTRAP断点或陷阱异常异常或trap指令发生。
6SIGABRT进程终止进程异常终止,通常为进程自身调用标准函数库的abort()函数。
7SIGBUS非法内存访问进程访问了未对齐或者不存在的物理地址。
8SIGFPE浮点异常进程执行了错误的算术运算,如除数为0、浮点溢出、整数溢出等。
11SIGSEGV无效内存访问进程访问了无效内存引用。
16SIGSTKFLT栈错误处理器执行了错误的栈操作,如栈空时弹出、栈满时压入。
31SIGSYS错误系统调用系统调用时使用了错误或非法参数。

一旦 CPU 检测到这些严重异常,会通过中断向内核报告。

每种信号还携带一个错误码(si_code),用于进一步细分崩溃原因。在分析 tombstone 或自定义捕获时,这些细分信息至关重要。

以上系统处理的崩溃信号,根据错误码(code)还有二级分类,二级分类如下:

1.5.1. SIGILL 二级分类

表示非法指令异常,通常由执行了非法 CPU 指令引起:

错误码(code)信号字符串解释触发原因
1ILL_ILLOPC非法操作码异常发生在执行不被CPU支持的指令或者无效指令时。
2ILL_ILLOPN非法操作数异常发生在指令使用了不正确的操作数,或者是操作数的类型不正确时。
3ILL_ILLADR非法地址异常发生在程序尝试访问无效的内存地址时,或者是在尝试执行未对齐的内存访问时。
4ILL_ILLTRP非法陷阱异常发生在程序尝试执行一个非法的陷阱指令时,或者是在尝试执行一个未定义的操作时。
5ILL_PRVOPC特权操作码异常发生在普通用户尝试执行特权指令时。
6ILL_PRVREG特权寄存器异常发生在普通用户尝试访问特权寄存器时。
7ILL_COPROC协处理器异常发生在程序尝试使用未定义的协处理器指令时。
8ILL_BADSTK无效的堆栈异常发生在程序尝试在无效的堆栈地址上执行操作时,或者是在堆栈溢出时。
0xacacILL_ILLPACCFI指针校验异常发生在程序校验指针失败时。

1.5.2. SIGTRAP 二级分类

SIGTRAP 信号通常用于调试和跟踪程序的执行。下面是SIGTRAP信号类别的问题场景介绍:

错误码(code)信号字符串解释触发原因
1TRAP_BRKPT软件断点由软件断点引起的,当程序执行到设置的断点时会触发该信号。软件断点通常用于调试程序,可以在程序的关键位置设置断点,以便在调试时暂停程序的执行并检查变量值等信息。
2TRAP_TRACE单步调试由单步执行引起的,当程序执行单个指令时会触发该信号。单步执行通常用于调试程序,可以逐步执行程序并检查每个指令的执行结果。
3TRAP_BRANCH分支跟踪由分支指令引起的,当程序执行分支指令时会触发该信号。分支指令通常用于控制程序的执行流程,例如if语句和循环语句等。
4TRAP_HWBKPT硬件断点由硬件断点引起的,当程序执行到设置的硬件断点时会触发该信号。硬件断点通常用于调试程序,可以在程序的关键位置设置断点,以便在调试时暂停程序的执行并检查变量值等信息。与软件断点不同的是,硬件断点是由CPU硬件实现的,因此可以在程序执行过程中实时检测断点是否被触发。

1.5.3. SIGBUS 二级分类

SIGBUS是一种由操作系统向进程发送的信号,通常表示内存访问错误。其中,不同的信号类别表示不同的错误场景:

错误码(code)信号字符串解释触发原因
1BUS_ADRALN内存地址对齐错误发生在尝试访问未对齐的内存地址时,例如尝试访问一个4字节整数的非偶数地址。
2BUS_ADRERR非法内存地址错误发生在尝试访问不属于进程地址空间的内存地址时,例如尝试访问一个空指针。
3BUS_OBJERR对象访问错误发生在尝试访问一个已经被删除或未初始化的对象时。
4BUS_MCEERR_AR需立即处理的硬件内存校验错误发生在访问内存时检测到需要立即处理的硬件内存错误。
5BUS_MCEERR_AO可等待或延迟处理的硬件内存校验错误发生在访问内存时检测到可等待或延迟处理的硬件内存错误。

1.5.4. SIGFPE 二级分类

SIGFPE是一个信号,它表示浮点异常或算术异常。下面是这些SIGFPE信号类别的问题场景:

错误码(code)信号字符串解释触发原因
1FPE_INTDIV整数除法错误表示整数除法中的除数为零的情况。当一个程序尝试进行整数除法,但除数为零时,会发出这个信号。
2FPE_INTOVF整数溢出错误表示整数溢出错误。当一个程序尝试进行整数运算,结果超出整数范围时,会发出这个信号。
3FPE_FLTDIV浮点除法错误表示浮点数除法中的除数为零的情况。当一个程序尝试进行浮点数除法,但除数为零时,会发出这个信号。
4FPE_FLTOVF浮点上溢错误表示浮点溢出错误。当一个程序尝试进行浮点数运算,结果超出浮点数上限范围时,会发出这个信号。
5FPE_FLTUND浮点下溢错误表示浮点下溢错误。当一个程序尝试进行浮点数运算,结果小于浮点数下限范围时,会发出这个信号。
6FPE_FLTRES浮点结果未定义错误表示浮点结果未定义错误。当一个程序尝试进行浮点数运算,结果未定义时,会发出这个信号。
7FPE_FLTINV无效浮点操作错误表示无效浮点操作错误。当一个程序尝试进行无效的浮点数运算时,会发出这个信号。
8FPE_FLTSUB浮点运算结果越界错误表示浮点运算结果越界错误。当一个程序尝试进行浮点数运算,浮点数结果越界,会发出这个信号。

1.5.5. SIGSEGV 二级分类

SIGSEGV是一种信号,它表示进程试图访问一个不属于它的内存地址,或者试图访问一个已被操作系统标记为不可访问的内存地址。SIGSEGV信号通常是由以下两种情况引起的:

错误码(code)信号字符串解释触发原因
1SEGV_MAPERR不存在的内存地址进程试图访问一个不存在的内存地址,或者试图访问一个没有映射到进程地址空间的内存地址。这种情况通常是由于程序中的指针错误或内存泄漏引起的。
2SEGV_ACCERR不可访问的内存地址进程试图访问一个已被操作系统标记为不可访问的内存地址,例如向只读内存写入数据或执行没有执行权限的内存。这种情况通常是由于程序中的缓冲区溢出或者试图修改只读内存等错误引起的。

1.6. 内核向用户进程发送 Signal

内核接收到中断后,根据异常类型决定发送的 SIGXXX,并将信号加入进程的 signal 队列。

  • 空指针访问 → SIGSEGV
  • 调用 abort() → SIGABRT
  • 非法指令 → SIGILL

信号被放入进程的 pending signal queue(待处理信号队列)中,由内核记录,等待下一次信号检测时机到来时处理。

1.7. 信号被检测到后:三条处理路径

信号一旦被检测到,用户进程可以选择处理这些信号(根据 sigaction 配置)。

处理信号的方式包括以下三种。

1.7.1. 忽略信号(SIG_IGN)

对于某些信号,进程可以选择忽略,不做任何处理,例如 SIGPIPE。

但致命信号(SIGSEGV、SIGABRT、SIGFPE 等)无法被忽略,内核始终会终止进程。

1.7.2. 自定义捕获信号(Signal Handler)

进程可以为特定的信号注册信号处理器(signal handler),在信号到达时执行指定的处理逻辑。

sigaction(SIGSEGV, &action, NULL);

这是 Native 崩溃捕获库的核心机制:

  • Bugly
  • Breakpad
  • Firebase Crashlytics NDK
  • 自研 NDK crash catcher

都是通过替换 SIGSEGV/SIGABRT/SIGFPE 等 handler 实现的。


自定义 handler 的作用:

  1. 收集寄存器信息(PC、LR、SP)
  2. 获取崩溃线程的 backtrace(libunwind / dladdr / unwindstack)
  3. 获取内存快照、map、so 信息
  4. 写入本地文件
  5. 上报到服务器

真正处理完成后:最终仍需要调用默认 handler,否则进程不会被真正杀死。

1.7.3. 默认行为:终止进程 + 生成 core dump

如果没有为信号设置处理器,内核会采取默认行为,内核执行默认动作:

kill进程  
根据 ulimit 决定是否生成 core dump
触发 tombstone (Android)

Android 会由 debuggerd 生成 tombstone 文件。

1.7.4. 三条路径的关系

信号被检测到
    ├─ SIG_IGN          → 忽略(仅对非致命信号有效)
    ├─ 自定义 handler    → 采集现场 → 恢复 SIG_DFL → raise(sig)
    └─ SIG_DFL          → 内核终止进程 → core dump / tombstone

自定义 handler 并不是"替代"默认行为,而是"插入到默认行为之前":先取证,再把控制权交还给内核,由内核完成最终终止与 tombstone 生成。

1.8. Native 崩溃捕获的难点

设计一个可靠的崩溃捕获库,最核心的挑战在于:如何在进程已经处于崩溃这种极端异常状态下,依然能够安全、完整地生成崩溃日志。以下三方面是最棘手却必须解决的核心问题。

1.8.1. 情况一:文件句柄耗尽——"预留 FD"策略

问题:

进程崩溃时,如果要创建 dump 文件,需要一个新的文件描述符(FD)。但如果进程此前已经泄漏了大量 FD(触及 RLIMIT_NOFILE 上限),open() 调用将直接失败,导致无法生成任何崩溃现场数据。


解决方案:启动初期预留一个 FD

在程序启动初期(资源充裕时),预先打开一个文件或占用一个句柄,存入全局变量。崩溃发生时,释放该预留句柄,为 dump 文件的创建腾出空间。

// 1. 在初始化阶段预留一个文件描述符,打开 /dev/null 占位(对系统几乎无影响)
int g_reserved_fd = -1;

void InitCrashReserve() {
    // 打开一个无害文件,仅用于占用 FD 编号
    g_reserved_fd = open("/dev/null", O_RDONLY);
    if (g_reserved_fd == -1) {
        // 极端情况:连一个 FD 都分配不到,应提前降级处理
        return;
    }
}

// 2. 崩溃回调函数中释放预留 FD,并重用它来创建 dump 文件
void CrashHandler(int sig, siginfo_t *info, void *context) {
    if (g_reserved_fd != -1) {
        close(g_reserved_fd);        // 释放该 FD,此时系统中空出一个配额
        g_reserved_fd = -1;
    }
    // 现在可以安全调用 open() 创建 dump 文件,不会因 FD 耗尽而失败
    int dump_fd = open("/data/data/com.example/dump.dmp",
                       O_WRONLY | O_CREAT | O_TRUNC, 0600);
    if (dump_fd != -1) {
        // 将崩溃信息写入 dump_fd ...
        close(dump_fd);
    }
    // 恢复默认处理,确保进程终止
    signal(sig, SIG_DFL);
    raise(sig);
}

设计考量:

  • 为什么选择 /dev/null?因为它是一个无副作用、始终可打开的字符设备,占用 FD 后对业务零干扰。
  • 此外,g_reserved_fd 必须设置为进程全局可见(静态或全局变量),并在初始化阶段尽早完成,避免多线程竞争(初始化时通常在 JNI_OnLoad 或 Application.onCreate 之后尽早调用)。

工程落地时的额外考量

  • 抢占竞态:close 预留 FD 与后续 open 之间并非原子操作。在崩溃的多线程进程中,若其他线程恰好也在申请 FD,可能出现"刚释放的配额被别的线程拿走"的情况。更稳妥的做法是预留一个真实的 dump 文件 FD(初始化时直接 open 好 dump 文件并长期持有),崩溃时直接 ftruncate + write,完全绕开 open 环节。
  • 路径可用性:Android 上应用私有目录(/data/data//、/data/user/0//files/)是可靠的落盘位置;O_CREAT | O_TRUNC 配合 0600 权限可避免多用户/多进程场景下的权限问题。
  • 空间检查:FD 不是唯一瓶颈,磁盘空间耗尽同样会导致写入失败。可以在预留阶段用 ftruncate / fallocate 预占一段空间。
  • RLIMIT_NOFILE 的确认:可通过 getrlimit(RLIMIT_NOFILE, &rl) 读取上限,在初始化阶段记录基线,便于事后判断本次崩溃是否与 FD 泄漏相关。

1.8.2. 情况二:栈溢出(Stack Overflow)—— sigaltstack 安全栈

问题

如果崩溃本身是由于栈溢出(Stack Overflow)引起的,那么当前线程的栈空间已耗尽,如果直接在原栈上执行崩溃 handler,极有可能再次触发栈访问异常,导致“二次崩溃”,最终连崩溃日志都无法生成。


解决方案:使用 sigaltstack 指定替代信号栈

通过 sigaltstack 系统调用,通知内核:当指定信号到达时,不要在当前(可能已损坏的)栈上运行 handler,而是切换到一块预先分配的内存区域(替代信号栈)上执行。

#include <signal.h>
#include <stdlib.h>

static char g_sig_stack[SIGSTKSZ]; // 静态分配替代栈,避免 crash 时无法 malloc

void SetupSigStack() {
    stack_t ss;
    // 使用预先分配的静态内存作为替代信号栈
    ss.ss_sp   = g_sig_stack;       // 替代栈基址
    ss.ss_size = SIGSTKSZ;          // 栈大小(系统建议值,通常 8192 以上)
    ss.ss_flags = 0;                // 0 表示使用 ss_sp 指定的新栈;或 SS_AUTOERASE 等

    if (sigaltstack(&ss, nullptr) == 0) {
        struct sigaction sa;
        memset(&sa, 0, sizeof(sa));
        sa.sa_sigaction = MyCrashHandler;   // 建议使用 sa_sigaction 以获取更多上下文
        sigemptyset(&sa.sa_mask);
        // 关键标志:指示内核在替代信号栈上运行 handler
        sa.sa_flags = SA_ONSTACK | SA_SIGINFO;

        // 对关键崩溃信号注册 handler
        sigaction(SIGSEGV, &sa, nullptr);
        sigaction(SIGBUS,  &sa, nullptr);
        sigaction(SIGFPE,  &sa, nullptr);
        sigaction(SIGABRT, &sa, nullptr);
    }
}

重要说明:

  • SIGSTKSZ 是系统定义的推荐大小,通常为 8192 或 16384 字节,足够 handler 使用。如果 handler 中需要较深的调用栈,可适当扩大。
  • 代码中使用静态数组 g_sig_stack 而非 malloc,是为了避免在崩溃时刻再次依赖可能已损坏的堆内存。
  • SA_ONSTACK 必须设置,否则 sigaltstack 不会生效,handler 仍会运行在原栈上。
  • sigaltstack 它作用于调用它的哪个线程, 而不是整个进程。因此替代栈只需为每个线程单独设置调用一次 SetupSigStack,但在崩溃监控初始化时通常为主线程设置即可;多线程场景下需考虑信号可能在其他线程触发,此时替代栈应为每个需监控的线程独立分配。
1.8.2.1. SA_SIGINFO
1.8.2.1.1. 作用
  1. 改变信号处理函数的调用签名:
    • 不设置 SA_SIGINFO:信号处理函数原型为
void handler(int sig);

只能拿到信号编号,无法获取额外上下文。

    • 设置 SA_SIGINFO:必须使用三参数版本信号处理器:
void handler(int sig, siginfo_t *info, void *ucontext);

2. 通过 siginfo_t *info 可以拿到丰富的附加信息:

    • 谁发送的信号:发送进程 PID、UID
    • 信号产生原因(比如崩溃类信号:段错误是非法地址、总线错误等)
    • 具体出错内存地址(si_addr,用来定位空指针、越界等崩溃)
    • 子进程退出状态、实时信号携带的数据等
  1. 没有该标志,siginfo_t、上下文 ucontext 都不可用。

典型场景:Native 崩溃捕获(SIGSEGV、SIGBUS、SIGFPE),需要拿到崩溃地址、调用栈上下文,必须开启 SA_SIGINFO。

1.8.2.2. SA_ONSTACK
1.8.2.2.1. 作用:在备用信号栈上执行信号处理函数

默认行为

进程每个线程只有常规栈,当信号发生时,信号处理器依然在当前线程原有用户栈执行。

极端场景:

  • 栈溢出(栈越界导致 SIGSEGV)
  • 栈已经耗尽、栈空间损坏

此时再在损坏的栈上执行信号处理函数,会直接二次崩溃,根本没机会执行崩溃收集、打印调用栈、保存日志逻辑。

1.8.2.2.2. SA_ONSTACK 机制
  1. 需要提前通过 sigaltstack() 预先分配一块独立的内存作为备用信号栈;
  2. 设置 SA_ONSTACK 后,当该信号触发时,内核会切换到这块独立备用栈运行信号处理函数;
  3. 哪怕原线程栈溢出、栈损坏,依然可以安全执行崩溃收集、dump 日志等逻辑。
1.8.2.2.3. 适用场景

Native 崩溃捕获、栈溢出类信号处理,Android / Linux 下捕获 SIGSEGV 必开该标志,防止信号处理时栈损坏导致直接闪退抓不到日志。

1.8.2.3. 两者组合使用场景(Android Native 崩溃捕获经典配置)
sa.sa_flags = SA_ONSTACK | SA_SIGINFO;
  1. SA_SIGINFO:拿到崩溃详细信息、出错地址、进程上下文;
  2. SA_ONSTACK:防止原线程栈损坏时信号处理函数栈溢出,保证崩溃收集代码能正常执行。
1.8.2.4. 补充常见配套知识点
  1. SA_RESTART:系统调用被信号打断后自动重启(比如 read/write);
  2. SA_NODEFER:信号处理期间不屏蔽当前信号(慎用,防止递归信号);
  3. SA_RESETHAND:信号处理执行一次后恢复默认处理方式。

1.8.3. 情况三:二次崩溃与进程镜像 —— fork + ptrace 机制

问题

即使解决了 FD 耗尽和栈溢出,父进程本身已处于崩溃状态:堆可能被破坏、锁可能未释放、数据结构可能不一致。直接在原进程中调用复杂的 dump 逻辑(例如 malloc、dladdr 等)极易引发二次崩溃,导致进程立即被内核杀死,dump 生成失败。

解决方案:fork 出子进程进行安全转储

核心思路是:崩溃线程先通过 fork() 创建一个子进程。子进程拥有父进程完整的地址空间快照(Copy-on-Write),但其地址空间是“干净”的——至少尚未由于崩溃逻辑进一步破坏。在子进程中,可以相对安全地读取父进程的内存,并写入 dump 文件。

void HandleCrash(int sig) {
    // 使用系统调用级别的 fork(避免 libc 可能的上层锁)
    pid_t pid = sys_fork();
    if (pid == 0) {
        // ---- 子进程 ----
        // 此时子进程拥有父进程地址空间的拷贝,且未受父进程崩溃逻辑污染。
        // 典型做法:
        // 1. 通过 ptrace 附加到父进程,读取其寄存器、内存映射等;
        // 2. 将崩溃信息序列化为 minidump 并写入文件;
        // 3. 完成后直接退出,不调用任何可能挂起的清理函数。

        // 示例:执行安全 dump 流程(抽象函数)
        WriteMinidumpInChild();

        sys_exit(0);      // 干净退出,不触发 atexit 或 C++ 静态析构
    } else if (pid > 0) {
        // ---- 父进程 ----
        // 等待子进程完成 dump,避免成为僵尸进程
        sys_waitpid(pid, nullptr, 0);
        // 最终恢复默认处理并终止,保证系统能够生成 tombstone
        signal(sig, SIG_DFL);
        raise(sig);
    } else {
        // fork 失败,回退到最简处理:直接终止
        signal(sig, SIG_DFL);
        raise(sig);
    }
}

设计要点解析:

  • 为什么用 sys_fork 而非 fork()?
    • fork 只会复制调用 fork 的那个线程,父进程其他线程在子进程中"消失",但它们持有的互斥锁状态会被一并复制。如果子进程后续调用了依赖同一把锁的 libc 函数(如 malloc 内部的 arena 锁),就会永久阻塞
    • 直接使用系统调用可以最大限度地避免这类不确定性。
    • 同理,用 sys_exit 而非 exit() 可以绕过 atexit 回调。
  • 子进程为何更安全?
    • fork 后,子进程获得父进程内存的完整快照,但它是全新的执行上下文,没有损坏的锁或栈。
    • 在子进程内读取父进程内存时,可进一步结合 ptrace 确保读取的原子性。
  • ptrace 的角色:
    • 崩溃捕获库(如 Breakpad)通常会在子进程中调用 ptrace(PTRACE_ATTACH, parent_pid, ...) 挂载到父进程,然后读取其寄存器、栈帧、内存映射等,生成 minidump。
    • 由于子进程是独立的,ptrace 读取不会受到父进程崩溃状态影响。
  • 超时与保障:
    • 生产代码中通常会为子进程的 dump 操作设置超时(如 alarm 信号),防止因极端情况子进程卡死导致父进程永远等待。