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的堆栈后,怎么定位到代码行?你需要做两件事:

  1. 地址符号化:把pc地址转换成函数名和偏移量
  2. 行号映射:从函数名和偏移量找到具体的代码行

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更隐蔽,也更难搞。到时候再细说。