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、SIGABRT、SIGFPE、SIGBUS 等。
- 信号处理函数可以拦截信号,但只能做非常有限的操作。
- 核心转储(Core Dump)是进程崩溃前的快照,需要手动开启。
ulimit -c unlimited是开启 Core Dump 的基本命令。- Core Dump 文件可以用 GDB 的
bt和info threads分析。