Native Crash 基础:信号与核心转储

做 Android 底层开发,跟 Native Crash 打交道是家常便饭。说白了,它不是 Java 层那些空指针异常,而是直接跟操作系统内核打交道的崩溃。进程挂了,内核会告诉你原因——通过信号。

我第一次遇到那个场景是在一个线上视频播放器项目里。用户反馈播放几秒就闪退,Logcat 里只有一句 signal 11 (SIGSEGV)。当时我心里咯噔一下——这是段错误,指针乱飞了。

信号机制:操作系统在敲门

信号(Signal)是操作系统通知进程的方式。进程出错了,内核会发一个信号给你。Native Crash,本质上就是进程收到了一个非预期的信号,且没有处理它,于是默认动作来了——终止进程。

为什么要有信号?你想想看,进程在用户态跑,内核不能直接调用你的代码。它只能发个信号,让你自己收拾。你要是没接住,那就凉了。

常见的信号类型

我做崩溃分析时,有一张信号表是必须背下来的。下面这张表,我建议你存一份。

信号 编号 默认动作 典型原因
SIGSEGV 11 终止 + Core Dump 访问非法内存地址(空指针、野指针)
SIGABRT 6 终止 + Core Dump 调用 abort(),或 assert 失败
SIGFPE 8 终止 + Core Dump 除零错误、浮点运算异常
SIGBUS 7 终止 + Core Dump 未对齐的内存访问、硬件总线错误
SIGILL 4 终止 + Core Dump 非法指令、CPU 不支持的指令
SIGTRAP 5 终止 + Core Dump 断点触发、调试陷阱

我个人的习惯是:先把 SIGSEGV、SIGABRT、SIGFPE 这三个记牢。因为 90% 的 Native Crash 都是它们仨搞的鬼。

其中 SIGSEGV 是最常见的。我遇到过好几次,明明代码里检查了指针非空,结果还是崩了。后来一查,是内存被释放了,但指针没置空 —— 野指针比空指针更难防。

信号的默认行为

收到信号后,进程有三种命运:

  • 忽略:信号被无视,进程接着跑。但 SIGKILL 和 SIGSTOP 不能被忽略。
  • 终止:进程直接退出,连告别都不说。
  • 终止并生成 Core Dump:进程挂掉前,把内存现场完完整整地写下来。这就是我们分析问题的关键。

我刚才说了,对于 SIGSEGV 这类错误,默认动作是终止+Core Dump。但前提是 —— 你得把这玩意儿打开。

信号处理函数

我们可以拦截信号,自己做处理。比如注册一个 SIGSEGV 的 handler,让进程崩溃前先吐点日志。Android 的 libsigchain 库就是干这个的。

注册信号处理函数的代码很简单:

#include <signal.h>
#include <stdio.h>

void crash_handler(int sig) {
    // 注意!这里能做的事极其有限
    // 不要分配内存、不要调用非异步安全的函数
    write(STDERR_FILENO, "Crash caught!\n", 15);
    // 恢复默认处理,然后重新触发
    signal(sig, SIG_DFL);
    raise(sig);
}

int main() {
    signal(SIGSEGV, crash_handler);
    // 故意写个段错误
    int* p = NULL;
    *p = 42;
    return 0;
}

我曾经踩过一个坑:在信号处理函数里调用了 malloc 去记录日志。结果 malloc 自己也是可重入的,信号打断的时机不对,直接死锁了。记住,信号处理函数里只能干非常有限的事——write、sigprocmask、_exit 是安全的,其他尽量别碰。

信号掩码与信号栈

进程可以临时阻塞某些信号,这叫信号掩码。比如我们在写临界区代码时,不想被某个信号打断,可以用 sigprocmask 屏蔽它。

还有一个概念是信号栈,信号处理函数有自己的栈空间。这在栈溢出导致的 Native Crash 中特别有用。你想,主栈都炸了,总不能再在主栈上跑信号处理吧?

设置信号栈的代码示例:

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

static char alt_stack[SIGSTKSZ];

void setup_alt_stack() {
    stack_t ss;
    ss.ss_sp = alt_stack;
    ss.ss_flags = 0;
    ss.ss_size = SIGSTKSZ;
    sigaltstack(&ss, NULL);

    struct sigaction sa;
    sa.sa_handler = crash_handler;
    sa.sa_flags = SA_ONSTACK; // 使用备选栈
    sigemptyset(&sa.sa_mask);
    sigaction(SIGSEGV, &sa, NULL);
}

我个人习惯在线上环境一定要配好信号栈。因为很多 Native Crash 本身就源于栈问题,信号栈能保证至少我们可以捕获到异常。

核心转储(Core Dump)

核心转储,说白了就是进程死前的「尸体」。内核把内存映象、寄存器、栈、堆、线程信息一股脑儿写到一个文件里。我们拿到这个文件,用 GDB 或者 LLDB 就能做尸检。

但 Android 设备上很少直接生成 Core Dump,因为:

  • 文件太大,动辄几百兆,手机上扛不住
  • 涉及用户隐私数据,不能写
  • Google 自己有 debuggerd 替代方案

不过,在 Linux 服务器上,Core Dump 仍然是我们分析问题的杀手锏。

开启 Core Dump

先检查当前限制:

# 检查 core 文件大小限制
ulimit -c

如果输出是 0,说明限制未开启。可以用这条命令打开:

# 设为无限制
ulimit -c unlimited

# 或者指定大小,单位是块(通常 1 块 = 512 字节)
ulimit -c 1000000

注意:ulimit 命令只在当前 shell 和其子进程生效。如果你想让所有进程都生成 Core Dump,需要修改 /etc/security/limits.conf 文件或系统配置。我曾经因为忘了这个,在容器里折腾了半天。

核心转储文件的路径与命名

Core Dump 默认生成在工作目录,名字通常叫 core 或 core.PID。你也可以通过 /proc/sys/kernel/core_pattern 控制它的路径和名字。

# 查看当前配置
cat /proc/sys/kernel/core_pattern

# 例如:配置成 /var/crash/core-%e-%p-%t
# %e = 可执行文件名
# %p = 进程 PID
# %t = 时间戳
echo "/var/crash/core-%e-%p-%t" > /proc/sys/kernel/core_pattern

我喜欢用 %e-%p-%t 这个模式,一眼就能看出是哪个进程在什么时候崩的。

用 GDB 分析 Core Dump

拿到核心转储文件后,分析过程其实很简单:

# 启动 GDB,加载可执行文件和 Core Dump
gdb ./my_app /var/crash/core-my_app-1234-1678901234

# 查看崩溃时的调用栈
(gdb) bt
# 查看当前线程
(gdb) info threads
# 切换线程
(gdb) thread 2
# 查看具体帧的变量
(gdb) frame 3
(gdb) info locals

我记得有一次,线上一个服务频繁崩溃,没有日志,只有 Core Dump。我加载后 bt 一看,调用栈停在一个 string 拷贝函数里。检查后发现是内存池被其他线程写坏了。如果没有 Core Dump,这个线索根本找不到。

信号与核心转储整体流程

这张图展示了一个典型的 Native Crash 从信号产生到核心转储再到调试的完整过程。

Native Crash 信号与核心转储流程 进程运行出错(非法内存访问) 内核检测到异常,发送信号 是否注册了信号处理函数? (SIGSEGV handler 是否存在?) 是 否 执行信号处理 (有限操作) 恢复默认处理 再次触发信号 检查 ulimit -c Core 文件限制 Core 是否开启? 是 生成 Core Dump 文件 GDB / LLDB 分析堆栈 信号处理函数不宜复杂 仅异步安全函数可用

核心要点:

  • Native Crash 的本质是进程收到了一个致命信号。
  • 常见信号有 SIGSEGV、SIGABRT、SIGFPE、SIGBUS 等。
  • 信号处理函数可以拦截信号,但只能做非常有限的操作。
  • 核心转储(Core Dump)是进程崩溃前的快照,需要手动开启。
  • ulimit -c unlimited 是开启 Core Dump 的基本命令。
  • Core Dump 文件可以用 GDB 的 bt 和 info threads 分析。