本文来自于腾讯Bugly公众号(weixinBugly),未经做者赞成,请勿转载,原文地址:https://mp.weixin.qq.com/s/g-WzYF3wWAljok1XjPoo7wjava
在Android平台,native crash一直是crash里的大头。native crash具备上下文不全、出错信息模糊、难以捕捉等特色,比java crash更难修复。因此一个合格的异常捕获组件也要能达到如下目的:linux
方案 | 优势 | 缺点 |
---|---|---|
Google Breakpad | 权威,跨平台 | 代码体量较大 |
利用LogCat日志 | 利用安卓系统实现 | 须要在crash时启动新进程过滤logcat日志,不可靠 |
coffeecatch | 实现简洁,改动容易 | 存在兼容性问题 |
其实3个方案在Android平台的实现原理都是基本一致的,综合考虑,能够基于coffeecatch改进。android
在Unix-like系统中,全部的崩溃都是编程错误或者硬件错误相关的,系统遇到不可恢复的错误时会触发崩溃机制让程序退出,如除零、段地址错误等。web
异常发生时,CPU经过异常中断的方式,触发异常处理流程。不一样的处理器,有不一样的异常中断类型和中断处理方式。编程
linux把这些中断处理,统一为信号量,能够注册信号量向量进行处理。安全
信号机制是进程之间相互传递消息的一种方法,信号全称为软中断信号。微信
函数运行在用户态,当遇到系统调用、中断或是异常的状况时,程序会进入内核态。信号涉及到了这两种状态之间的转换。架构
接收信号的任务是由内核代理的,当内核接收到信号后,会将其放到对应进程的信号队列中,同时向进程发送一个中断,使其陷入内核态。注意,此时信号还只是在队列中,对进程来讲暂时是不知道有信号到来的。app
进程陷入内核态后,有两种场景会对信号进行检测:框架
当发现有新信号时,便会进入下一步,信号的处理。
信号处理函数是运行在用户态的,调用处理函数前,内核会将当前内核栈的内容备份拷贝到用户栈上,而且修改指令寄存器(eip)将其指向信号处理函数。
接下来进程返回到用户态中,执行相应的信号处理函数。
信号处理函数执行完成后,还须要返回内核态,检查是否还有其它信号未处理。若是全部信号都处理完成,就会将内核栈恢复(从用户栈的备份拷贝回来),同时恢复指令寄存器(eip)将其指向中断前的运行位置,最后回到用户态继续执行进程。
至此,一个完整的信号处理流程便结束了,若是同时有多个信号到达,上面的处理流程会在第2步和第3步骤间重复进行。
第一步就是要用信号处理函数捕获到native crash(SIGSEGV, SIGBUS等)。在posix系统,能够用sigaction():
#include <signal.h> int sigaction(int signum,const struct sigaction *act,struct sigaction *oldact));
signum:表明信号编码,能够是除SIGKILL及SIGSTOP外的任何一个特定有效的信号,若是为这两个信号定义本身的处理函数,将致使信号安装错误。
act:指向结构体sigaction的一个实例的指针,该实例指定了对特定信号的处理,若是设置为空,进程会执行默认处理。
oldact:和参数act相似,只不过保存的是原来对相应信号的处理,也可设置为NULL。
struct sigaction sa_old; memset(&sa, 0, sizeof(sa)); sigemptyset(&sa.sa_mask); sa.sa_sigaction = my_handler; sa.sa_flags = SA_SIGINFO; if (sigaction(sig, &sa, &sa_old) == 0) { ... }
#include <signal.h> int sigaltstack(const stack_t *ss, stack_t *oss);
SIGSEGV颇有多是栈溢出引发的,若是在默认的栈上运行颇有可能会破坏程序运行的现场,没法获取到正确的上下文。并且当栈满了(太屡次递归,栈上太多对象),系统会在同一个已经满了的栈上调用SIGSEGV的信号处理函数,又再一次引发一样的信号。
咱们应该开辟一块新的空间做为运行信号处理函数的栈。可使用sigaltstack在任意线程注册一个可选的栈,保留一下在紧急状况下使用的空间。(系统会在危险状况下把栈指针指向这个地方,使得能够在一个新的栈上运行信号处理函数)
stack_t stack; memset(&stack, 0, sizeof(stack)); /* Reserver the system default stack size. We don't need that much by the way. */ stack.ss_size = SIGSTKSZ; stack.ss_sp = malloc(stack.ss_size); stack.ss_flags = 0; /* Install alternate stack size. Be sure the memory region is valid until you revert it. */ if (stack.ss_sp != NULL && sigaltstack(&stack, NULL) == 0) { ... }
static void my_handler(const int code, siginfo_t *const si, void *const sc) { ... /* Call previous handler. */ old_handler.sa_sigaction(code, si, sc); }
某些信号可能在以前已经被安装过信号处理函数,而sigaction一个信号量只能注册一个处理函数,这意味着咱们的处理函数会覆盖其余人的处理信号
保存旧的处理函数,在处理完咱们的信号处理函数后,在从新运行老的处理函数就能完成兼容。
首先咱们要了解async-signal-safe和可重入函数概念:
- A signal handler function must be very careful, since processing elsewhere may be interrupted at some arbitrary point in the execution of the program.
回想下在“信号机制”一节中的图示,进程捕捉到信号并对其进行处理时,进程正在执行的正常指令序列就被信号处理程序临时中断,它首先执行该信号处理程序中的指令(相似发生硬件中断)。但在信号处理程序中,不能判断捕捉到信号时进程执行到何处。若是进程正在执行malloc,在其堆中分配另外的存储空间,而此时因为捕捉到信号而插入执行该信号处理程序,其中又调用malloc,这时会发生什么?这可能会对进程形成破坏,由于malloc一般为它所分配的存储区维护一个链表,而插入执行信号处理程序时,进程可能正在更改此链表。(参考《UNIX环境高级编程》)
Single UNIX Specification说明了在信号处理程序中保证调用安全的函数。这些函数是可重入的并被称为是异步信号安全(async-signal-safe)。除了可重入之外,在信号处理操做期间,它会阻塞任何会引发不一致的信号发送。下面是这些异步信号安全函数:
但即便咱们本身在信号处理程序中不使用不可重入的函数,也没法保证保存的旧的信号处理程序中不会有非异步信号安全的函数。因此要使用alarm保证信号处理程序不会陷入死锁或者死循环的状态。
static void signal_handler(const int code, siginfo_t *const si, void *const sc) { /* Ensure we do not deadlock. Default of ALRM is to die. * (signal() and alarm() are signal-safe) */ signal(code, SIG_DFL); signal(SIGALRM, SIG_DFL); /* Ensure we do not deadlock. Default of ALRM is to die. * (signal() and alarm() are signal-safe) */ (void) alarm(8); .... }
考虑到信号处理程序中的诸多限制,通常会clone一个新的进程,在其中完成解析堆栈等任务。
下面是Google Breakpad的流程图,在新的进程中DoDump,使用ptrace解析crash进程的堆栈,同时信号处理程序等待子进程完成任务后,再调用旧的信号处理函数。父子进程使用管道通讯。
在个人实验中,在子进程或者信号处理函数中,常常没法回调给java层。因而我选择了在初始化的时候就创建了子线程并一直等待,等到捕捉到crash信号时,唤醒这条线程dump出crash堆栈,并把crash堆栈回调给java。
static void nativeInit(JNIEnv* env, jclass javaClass, jstring packageNameStr, jstring tombstoneFilePathStr, jobject obj) { ... initCondition(); pthread_t thd; int ret = pthread_create(&thd, NULL, DumpThreadEntry, NULL); if(ret) { qmlog("%s", "pthread_create error"); } } void* DumpThreadEntry(void *argv) { JNIEnv* env = NULL; if((*g_jvm)->AttachCurrentThread(g_jvm, &env, NULL) != JNI_OK) { LOGE("AttachCurrentThread() failed"); estatus = 0; return &estatus; } while (true) { //等待信号处理函数唤醒 waitForSignal(); //回调native异常堆栈给java层 throw_exception(env); //告诉信号处理函数已经处理完了 notifyThrowException(); } if((*g_jvm)->DetachCurrentThread(g_jvm) != JNI_OK) { LOGE("DetachCurrentThread() failed"); estatus = 0; return &estatus; } return &estatus; }
信号处理函数的入参中有丰富的错误信息,下面咱们来一一分析。
/*信号处理函数*/ void (*sa_sigaction)(const int code, siginfo_t *const si, void * const sc) siginfo_t { int si_signo; /* Signal number 信号量 */ int si_errno; /* An errno value */ int si_code; /* Signal code 错误码 */ }
发生native crash以后,logcat中会打出以下一句信息:
signal 11 (SIGSEGV), code 0 (SI_USER), fault addr 0x0
根据code去查表,其实就能够知道发生native crash的大体缘由:
代码的一部分以下,其实就是根据不一样的code,输出不一样信息,这些都是固定的。
case SIGFPE: switch(code) { case FPE_INTDIV: return "Integer divide by zero"; case FPE_INTOVF: return "Integer overflow"; case FPE_FLTDIV: return "Floating-point divide by zero"; case FPE_FLTOVF: return "Floating-point overflow"; case FPE_FLTUND: return "Floating-point underflow"; case FPE_FLTRES: return "Floating-point inexact result"; case FPE_FLTINV: return "Invalid floating-point operation"; case FPE_FLTSUB: return "Subscript out of range"; default: return "Floating-point"; } break; case SIGSEGV: switch(code) { case SEGV_MAPERR: return "Address not mapped to object"; case SEGV_ACCERR: return "Invalid permissions for mapped object"; default: return "Segmentation violation"; } break;
信号处理函数中的第三个入参sc是uc_mcontext的结构体,是cpu相关的上下文,包括当前线程的寄存器信息和奔溃时的pc值。可以知道崩溃时的pc,就能知道崩溃时执行的是那条指令。
不过这个结构体的定义是平台相关,不一样平台、不一样cpu架构中的定义都不同:
pc值是程序加载到内存中的绝对地址,咱们须要拿到奔溃代码相对于共享库的相对偏移地址,才能使用addr2line分析出是哪一行代码。经过dladdr()能够得到共享库加载到内存的起始地址,和pc值相减就能够得到相对偏移地址,而且能够得到共享库的名字。
Dl_info info; if (dladdr(addr, &info) != 0 && info.dli_fname != NULL) { void * const nearest = info.dli_saddr; //相对偏移地址 const uintptr_t addr_relative = ((uintptr_t) addr - (uintptr_t) info.dli_fbase); ... }
做为有追求的咱们,确定不知足于仅仅经过一个函数就得到答案。咱们尝试下如何手工分析出相对地址。首先要了解下进程的地址空间布局。
任何一个程序一般都包括代码段和数据段,这些代码和数据自己都是静态的。程序要想运行,首先要由操做系统负责为其建立进程,并在进程的虚拟地址空间中为其代码段和数据段创建映射。光有代码段和数据段是不够的,进程在运行过程当中还要有其动态环境,其中最重要的就是堆栈。
上图中Random stack offset和Random mmap offset等随机值意在防止恶意程序。Linux经过对栈、内存映射段、堆的起始地址加上随机偏移量来打乱布局,以避免恶意程序经过计算访问栈、库函数等地址。
栈(stack),做为进程的临时数据区,增加方向是从高地址到低地址。
在Linux系统中,/proc/self/maps保存了各个程序段在内存中的加载地址范围,grep出共享库的名字,就能够知道共享库的加载基值是多少。
获得相对偏移地址以后,使用readelf查看共享库的符号表,就能够知道是哪一个函数crash了。
在前一步,咱们获取了奔溃时的pc值和各个寄存器的内容,经过SP和FP所限定的stack frame,就能够获得母函数的SP和FP,从而获得母函数的stack frame(PC,LR,SP,FP会在函数调用的第一时间压栈),以此追溯,便可获得全部函数的调用顺序。
#ifdef USE_UNWIND /* Frame buffer initial position. */ t->frames_size = 0; /* Skip us and the caller. */ t->frames_skip = 0; /* 使用libcorkscrew解堆栈 */ #ifdef USE_CORKSCREW t->frames_size = backtrace_signal(si, sc, t->frames, 0, BACKTRACE_FRAMES_MAX); #else /* Unwind frames (equivalent to backtrace()) */ _Unwind_Backtrace(coffeecatch_unwind_callback, t); #endif /* 若是没法加载libcorkscrew,则使用本身编译的libunwind解堆栈 */ #ifdef USE_LIBUNWIND if (t->frames_size == 0) { size_t i; t->frames_size = unwind_signal(si, sc, t->uframes, 0,BACKTRACE_FRAMES_MAX); for(i = 0 ; i < t->frames_size ; i++) { t->frames[i].absolute_pc = (uintptr_t) t->uframes[i]; t->frames[i].stack_top = 0; t->frames[i].stack_size = 0; __android_log_print(ANDROID_LOG_DEBUG, TAG, "absolute_pc:%x", t->frames[i].absolute_pc); } } #endif
libunwind是一个独立的开源库,高版本的安卓源码中也使用了libunwind做为解堆栈的工具,并针对安卓作了一些适配。下面是使用libunwind解堆栈的主循环,每次循环解一层堆栈。
static ALWAYS_INLINE int slow_backtrace (void **buffer, int size, unw_context_t *uc) { unw_cursor_t cursor; unw_word_t ip; int n = 0; if (unlikely (unw_init_local (&cursor, uc) < 0)) return 0; while (unw_step (&cursor) > 0) { if (n >= size) return n; if (unw_get_reg (&cursor, UNW_REG_IP, &ip) < 0) return n; buffer[n++] = (void *) (uintptr_t) ip; } return n; }
能够经过libcorkscrew中的get_backtrace_symbols函数得到函数符号。
/* * Describes the symbols associated with a backtrace frame. */ typedef struct { uintptr_t relative_pc; uintptr_t relative_symbol_addr; char* map_name; char* symbol_name; char* demangled_name; } backtrace_symbol_t; /* * Gets the symbols for each frame of a backtrace. * The symbols array must be big enough to hold one symbol record per frame. * The symbols must later be freed using free_backtrace_symbols. */ void get_backtrace_symbols(const backtrace_frame_t* backtrace, size_t frames, backtrace_symbol_t* backtrace_symbols);
更通用的方法是经过dladdr得到函数名字。
int dladdr(void *addr, Dl_info *info); typedef struct { const char *dli_fname; /* Pathname of shared object that contains address */ void *dli_fbase; /* Base address at which shared object is loaded */ const char *dli_sname; /* Name of symbol whose definition overlaps addr */ void *dli_saddr; /* Exact address of symbol named in dli_sname */ } Dl_info;
传入每一层堆栈的相对偏移地址,就能够从dli_fname中得到函数名字。
如何得到native crash所对应的java层堆栈,这个问题曾经困扰了我一段时间。这里有一个前提:咱们认为crash线程就是捕获到信号的线程,虽然这在SIGABRT下不必定可靠。有了这个认知,接下来就好办了。在信号处理函数中得到当前线程的名字,而后把crash线程的名字传给java层,在java里dump出这个线程的堆栈,就是crash所对应的java层堆栈了。
在c中得到线程名字:
char* getThreadName(pid_t tid) { if (tid <= 1) { return NULL; } char* path = (char *) calloc(1, 80); char* line = (char *) calloc(1, THREAD_NAME_LENGTH); snprintf(path, PATH_MAX, "proc/%d/comm", tid); FILE* commFile = NULL; if (commFile = fopen(path, "r")) { fgets(line, THREAD_NAME_LENGTH, commFile); fclose(commFile); } free(path); if (line) { int length = strlen(line); if (line[length - 1] == '\n') { line[length - 1] = '\0'; } } return line; }
而后传给java层:
/** * 根据线程名得到线程对象,native层会调用该方法,不能混淆 * @param threadName * @return */ @Keep public static Thread getThreadByName(String threadName) { if (TextUtils.isEmpty(threadName)) { return null; } Set<Thread> threadSet = Thread.getAllStackTraces().keySet(); Thread[] threadArray = threadSet.toArray(new Thread[threadSet.size()]); Thread theThread = null; for(Thread thread : threadArray) { if (thread.getName().equals(threadName)) { theThread = thread; } } Log.d(TAG, "threadName: " + threadName + ", thread: " + theThread); return theThread; }
通过诸多探索,终于获得了完美的堆栈:
java.lang.Error: signal 11 (Address not mapped to object) at address 0x0 at dalvik.system.NativeStart.run(Native Method) Caused by: java.lang.Error: signal 11 (Address not mapped to object) at address 0x0 at /data/app-lib/com.tencent.moai.crashcatcher.demo-1/libQMCrashGenerator.so.0xd8e(dangerousFunction:0x5:0) at /data/app-lib/com.tencent.moai.crashcatcher.demo-1/libQMCrashGenerator.so.0xd95(wrapDangerousFunction:0x2:0) at /data/app-lib/com.tencent.moai.crashcatcher.demo-1/libQMCrashGenerator.so.0xd9d(nativeInvalidAddressCrash:0x2:0) at /system/lib/libdvm.so.0x1ee8c(dvmPlatformInvoke:0x70:0) at /system/lib/libdvm.so.0x503b7(dvmCallJNIMethod(unsigned int const*, JValue*, Method const*, Thread*):0x1ee:0) at /system/lib/libdvm.so.0x28268(Native Method) at /system/lib/libdvm.so.0x2f738(dvmMterpStd(Thread*):0x44:0) at /system/lib/libdvm.so.0x2cda8(dvmInterpret(Thread*, Method const*, JValue*):0xb8:0) at /system/lib/libdvm.so.0x648e3(dvmInvokeMethod(Object*, Method const*, ArrayObject*, ArrayObject*, ClassObject*, bool):0x1aa:0) at /system/lib/libdvm.so.0x6cff9(Native Method) at /system/lib/libdvm.so.0x28268(Native Method) at /system/lib/libdvm.so.0x2f738(dvmMterpStd(Thread*):0x44:0) at /system/lib/libdvm.so.0x2cda8(dvmInterpret(Thread*, Method const*, JValue*):0xb8:0) at /system/lib/libdvm.so.0x643d9(dvmCallMethodV(Thread*, Method const*, Object*, bool, JValue*, std::__va_list):0x14c:0) at /system/lib/libdvm.so.0x4bca1(Native Method) at /system/lib/libandroid_runtime.so.0x50ac3(Native Method) at /system/lib/libandroid_runtime.so.0x518e7(android::AndroidRuntime::start(char const*, char const*):0x206:0) at /system/bin/app_process.0xf33(Native Method) at /system/lib/libc.so.0xf584(__libc_init:0x64:0) at /system/bin/app_process.0x107c(Native Method) Caused by: java.lang.Error: java stack at com.tencent.crashcatcher.CrashCatcher.nativeInvalidAddressCrash(Native Method) at com.tencent.crashcatcher.CrashCatcher.invalidAddressCrash(CrashCatcher.java:33) at com.tencent.moai.crashcatcher.demo.MainActivity$4.onClick(MainActivity.java:56) at android.view.View.performClick(View.java:4488) at android.view.View$PerformClick.run(View.java:18860) at android.os.Handler.handleCallback(Handler.java:808) at android.os.Handler.dispatchMessage(Handler.java:103) at android.os.Looper.loop(Looper.java:222) at android.app.ActivityThread.main(ActivityThread.java:5484) at java.lang.reflect.Method.invokeNative(Native Method) at java.lang.reflect.Method.invoke(Method.java:515) at com.android.internal.os.ZygoteInit$MethodAndArgsCaller.run(ZygoteInit.java:860) at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:676) at dalvik.system.NativeStart.main(Native Method)
在native层构造了一个Error传给java,因此在java层能够很轻松地根据堆栈进行业务上的处理。
public interface CrashHandleListener { @Keep void onCrash(int id, Error e); }
另外初始化时就创建等待回调线程的方式,提供了稳定的给java层的回调。在回调中咱们打印了app的状态信息,包括activity的堆栈、app是否在前台等,以及打印crash前的logcat日志和把应用日志flush进文件。针对某些具体的native crash还作了业务上的处理,例如遇到热补丁框架相关的crash时就回滚补丁。
在用户环境中的不少native crash单靠堆栈是解决不了的,logcat是很是重要的补充。好几例webview crash都是经过发生crash时的logcat定位的。好比咱们曾经遇到过的一个的webview crash:
#00 pc 00039874 /system/lib/libc.so (tgkill+12) #01 pc 00013b5d /system/lib/libc.so (pthread_kill+52) #02 pc 0001477b /system/lib/libc.so (raise+10) #03 pc 00010ff5 /system/lib/libc.so (__libc_android_abort+36) #04 pc 0000f554 /system/lib/libc.so (abort+4) #05 pc 00239885 /system/lib/libwebviewchromium.so #06 pc 00219da3 /system/lib/libwebviewchromium.so #07 pc 00206459 /system/lib/libwebviewchromium.so #08 pc 001fb6c7 /system/lib/libwebviewchromium.so #09 pc 001edc97 /system/lib/libwebviewchromium.so #10 pc 001ec5ad /system/lib/libwebviewchromium.so #11 pc 001ec617 /system/lib/libwebviewchromium.so #12 pc 001ec5e5 /system/lib/libwebviewchromium.so #13 pc 001ec5bf /system/lib/libwebviewchromium.so #14 pc 0022c941 /system/lib/libwebviewchromium.so #15 pc 0022c92b /system/lib/libwebviewchromium.so #16 pc 0022e6a1 /system/lib/libwebviewchromium.so #17 pc 0022ebcd /system/lib/libwebviewchromium.so #18 pc 0022ee1d /system/lib/libwebviewchromium.so #19 pc 0022c511 /system/lib/libwebviewchromium.so #20 pc 00013347 /system/lib/libc.so (_ZL15__pthread_startPv+30) #21 pc 0001135f /system/lib/libc.so (__start_thread+6)
单凭堆栈根本看不出来是什么问题,可是在logcat中却看到这样一个warning log:
05-21 15:09:28.423 W/System.err(16811): java.lang.NullPointerException: Attempt to get length of null array 05-21 15:09:28.424 W/System.err(16811): at java.io.ByteArrayInputStream.<init>(ByteArrayInputStream.java:60) 05-21 15:09:28.424 W/System.err(16811): at com.tencent.*.InlineImage.fetcher.HttpImageFetcher.fetchFromNetwork(HttpImageFetcher.java:86) 05-21 15:09:28.424 W/System.err(16811): at com.tencent.*.InlineImage.fetcher.BaseFetcher.fetch(BaseFetcher.java:24) 05-21 15:09:28.424 W/System.err(16811): at com.tencent.*.InlineImage.delaystream.DelayInputStream.read(DelayInputStream.java:36) 05-21 15:09:28.424 W/System.err(16811): at com.tencent.*.InlineImage.delaystream.DelayHttpInputStream.read(DelayHttpInputStream.java:12) 05-21 15:09:28.424 W/System.err(16811): at java.io.InputStream.read(InputStream.java:181) 05-21 15:09:28.424 W/System.err(16811): at org.chromium.android_webview.InputStreamUtil.read(InputStreamUtil.java:54)
查代码发现是咱们在WebViewClient的shouldInterceptRequest接口中的业务代码发生了NullPointerException, 传进去WebView内部变成了natvie crash,问题解决。
注:目前此组件还没有对外开放
更多精彩内容欢迎关注腾讯 Bugly的微信公众帐号:
腾讯 Bugly是一款专为移动开发者打造的质量监控工具,帮助开发者快速,便捷的定位线上应用崩溃的状况以及解决方案。智能合并功能帮助开发同窗把天天上报的数千条 Crash 根据根因合并分类,每日日报会列出影响用户数最多的崩溃,精准定位功能帮助开发同窗定位到出问题的代码行,实时上报能够在发布后快速的了解应用的质量状况,适配最新的 iOS, Android 官方操做系统,鹅厂的工程师都在使用,快来加入咱们吧!