4. Crash与Native Crash:Java层与Native层崩溃的捕获与分析
说到Crash,做Android开发的兄弟应该都不陌生。Java层的空指针、数组越界,Native层的段错误、非法指令……这些崩溃就像悬在头上的达摩克利斯之剑。我这些年排查过的崩溃案例少说也有上百个,说实话,Java层的还好办,Native层的崩溃才是真正让人头疼的。
今天我们就来聊聊,这两类崩溃到底怎么捕获,怎么分析。
4.1 Java层Crash:捕获其实很简单
Java层的崩溃,说白了就是未捕获的异常。系统会帮你打印堆栈,然后杀掉进程。但问题是——堆栈打印完了,进程也死了,你拿不到现场数据。
所以我们需要一个全局的异常捕获器。
核心思路:实现Thread.UncaughtExceptionHandler接口,在应用启动时设置为主线程的默认处理器。
public class GlobalCrashHandler implements Thread.UncaughtExceptionHandler {
private static final String TAG = "CrashHandler";
private Context mContext;
@Override
public void uncaughtException(Thread thread, Throwable ex) {
// 1. 保存崩溃信息到本地文件
saveCrashInfo(ex);
// 2. 上传到服务器(可选)
uploadCrashReport(ex);
// 3. 延迟2秒后杀死进程
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
android.os.Process.killProcess(android.os.Process.myPid());
}
private void saveCrashInfo(Throwable ex) {
StringWriter sw = new StringWriter();
PrintWriter pw = new PrintWriter(sw);
ex.printStackTrace(pw);
String crashInfo = sw.toString();
// 写入文件,带上时间戳
String fileName = "crash_" + System.currentTimeMillis() + ".log";
// ... 文件写入逻辑
}
}
嗯,这里要注意一点:不要在uncaughtException里做耗时操作。系统给你处理异常的时间窗口很短,大概只有几秒钟。我见过有人在这个回调里做网络上传,结果文件还没写完进程就被杀了。
我的建议:崩溃信息先写本地,下次启动时再上传。这样既不会丢数据,也不会影响崩溃处理的时效性。
4.2 Native Crash:这才是硬骨头
Java层的崩溃好歹有堆栈,Native层的崩溃呢?你看到的往往是这样的信息:
pid: 12345, tid: 12346, name: Binder:12345_1
signal 11 (SIGSEGV), code 1 (SEGV_MAPERR), fault addr 0xdeadbeef
r0 00000000 r1 00000001 r2 00000002 r3 deadbeef
r4 00000004 r5 00000005 r6 00000006 r7 00000007
...
#00 pc 00012345 /system/lib/libc.so (__memcpy+32)
#01 pc 00023456 /system/lib/libutils.so (String8::setTo+12)
#02 pc 00034567 /data/app/com.example/lib/arm/libnative.so (Java_com_example_nativeMethod+88)
看到这堆寄存器值和地址,你是不是有点懵?我第一次看到的时候也是。但别怕,这东西有规律可循。
4.3 如何捕获Native Crash
Native层的崩溃信号主要有这么几种:
| 信号 | 含义 | 常见原因 |
|---|---|---|
| SIGSEGV (11) | 段错误 | 空指针解引用、访问非法内存 |
| SIGABRT (6) | 主动中止 | assert失败、abort()调用 |
| SIGFPE (8) | 浮点异常 | 除零操作、浮点溢出 |
| SIGBUS (7) | 总线错误 | 未对齐的内存访问 |
要捕获这些信号,我们需要注册信号处理器。Android提供了crash_dump机制,但如果你想自己捕获,可以用sigaction()系统调用。
#include <signal.h>
#include <unwind.h>
#include <dlfcn.h>
void crash_handler(int sig, siginfo_t* info, void* context) {
// 1. 获取崩溃时的寄存器信息
ucontext_t* uc = (ucontext_t*)context;
// 2. 回溯堆栈
_Unwind_Backtrace(trace_func, &state);
// 3. 保存到文件
// 注意:这里不能做malloc等不安全操作
// 4. 恢复默认信号处理并重新发送信号
signal(sig, SIG_DFL);
raise(sig);
}
void register_crash_handler() {
struct sigaction sa;
sa.sa_sigaction = crash_handler;
sa.sa_flags = SA_SIGINFO | SA_ONSTACK;
sigemptyset(&sa.sa_mask);
sigaction(SIGSEGV, &sa, NULL);
sigaction(SIGABRT, &sa, NULL);
sigaction(SIGFPE, &sa, NULL);
}
警告:信号处理函数里能做的事情非常有限。你不能调用malloc、printf、fopen等非重入函数。我建议的做法是:把原始数据写到一块预分配的内存缓冲区里,等下次启动时再解析。
4.4 分析Native Crash:从堆栈到源码
拿到Native的堆栈后,怎么定位到代码行?你需要做两件事:
- 地址符号化:把pc地址转换成函数名和偏移量
- 行号映射:从函数名和偏移量找到具体的代码行
Android提供了addr2line工具来做这件事:
# 假设你的so文件在out/target/product/xxx/symbols/system/lib/
# 注意:一定要用带symbols的so,strip过的so没有符号信息
arm-linux-androideabi-addr2line -f -e libnative.so 0x00034567
# 输出:
# Java_com_example_nativeMethod
# /home/user/project/jni/native.cpp:128
你看,这样就定位到了native.cpp的第128行。我在项目里遇到过一个问题,堆栈显示崩溃在memcpy里,但实际原因是上层传过来的buffer长度不对。所以分析的时候,别只看崩溃点,要往上多看几层。
4.5 实战技巧:如何让崩溃信息更有价值
光有堆栈有时候还不够。我总结了几点经验:
- 带上进程状态:崩溃时记录一下内存使用、线程数、运行时长
- 保留logcat:崩溃前几秒的logcat往往能提供关键线索
- 区分首次崩溃和复现崩溃:首次崩溃可能是环境问题,复现崩溃才是代码bug
我曾经遇到一个诡异的Native崩溃,只在用户手机上出现,我们怎么也复现不了。后来我在崩溃信息里加上了android.os.Build的完整信息,才发现是某个特定厂商的ROM改了libc的行为。你看,有时候崩溃的原因不在你的代码里。
4.6 总结一下
Java层的崩溃,用Thread.UncaughtExceptionHandler就能搞定。Native层的崩溃,需要注册信号处理器,然后用addr2line做符号化。说白了,捕获不难,难的是分析。
我个人习惯的做法是:先看堆栈,再看寄存器,最后看logcat。三者结合起来,大部分崩溃都能找到根因。如果还找不到……嗯,那就加日志再跑一遍吧。
下一章我们会聊聊ANR的分析与定位,那个比Crash更隐蔽,也更难搞。到时候再细说。