本文來自於騰訊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 官方操做系統,鵝廠的工程師都在使用,快來加入咱們吧!