Android Native Crash 收集
本篇核心講解了自己實現(xiàn)一個 Android Native Crash 收集的方案步驟,重點問題解決辦法。
對本文有任何問題,可加我的個人微信:kymjs123
在 Android 平臺上,Native Crash 一直是比較麻煩的問題,因為捕獲麻煩,獲取到了內(nèi)容又不全,內(nèi)容全了信息又不對,信息對了又不好處理。比 Java Crash 不知道麻煩多少倍。
今天跟大家講一下,我最近掉了幾百根頭發(fā)寫出來的一個 Native Crash 收集的功能(脫發(fā)已經(jīng)越來越嚴(yán)重了)。
一個 Native Crash 的 log 信息如下圖:
這張圖是我在網(wǎng)上找的(由于沒有寫 demo,項目中的截圖不方便直接拿出來,就偷了個懶)。
在上圖里,堆棧信息中 pc 后面跟的內(nèi)存地址,就是當(dāng)前函數(shù)的棧地址,我們可以通過命令行 arm-linux-androideabi-addr2line -e 內(nèi)存地址 得出出錯的代碼行數(shù)了。
要實現(xiàn) Native Crash 的收集,主要有四個重點:知道 Crash 的發(fā)生;捕獲到 Crash 的位置;獲取 Crash 發(fā)生位置的函數(shù)調(diào)用棧;數(shù)據(jù)能回傳到服務(wù)器。
知道 Crash 的發(fā)生與 Java 平臺不同,C/C++ 沒有一個通用的異常處理接口,在 C 層,CPU 通過異常中斷的方式,觸發(fā)異常處理流程。不同的處理器,有不同的異常中斷類型和中斷處理方式,linux 把這些中斷處理,統(tǒng)一為信號量,每一種異常都有一個對應(yīng)的信號,可以注冊回調(diào)函數(shù)進(jìn)行處理需要關(guān)注的信號量。
所有的信號量都定義在<signal.h>文件中,這里我將幾乎全部的信號量以及所代表的含義都標(biāo)注出來了:
#define SIGHUP 1 // 終端連接結(jié)束時發(fā)出(不管正常或非正常)#define SIGINT 2 // 程序終止(例如Ctrl-C)#define SIGQUIT 3 // 程序退出(Ctrl-)#define SIGILL 4 // 執(zhí)行了非法指令,或者試圖執(zhí)行數(shù)據(jù)段,堆棧溢出#define SIGTRAP 5 // 斷點時產(chǎn)生,由debugger使用#define SIGABRT 6 // 調(diào)用abort函數(shù)生成的信號,表示程序異常#define SIGIOT 6 // 同上,更全,IO異常也會發(fā)出#define SIGBUS 7 // 非法地址,包括內(nèi)存地址對齊出錯,比如訪問一個4字節(jié)的整數(shù), 但其地址不是4的倍數(shù)#define SIGFPE 8 // 計算錯誤,比如除0、溢出#define SIGKILL 9 // 強制結(jié)束程序,具有最高優(yōu)先級,本信號不能被阻塞、處理和忽略#define SIGUSR1 10 // 未使用,保留#define SIGSEGV 11 // 非法內(nèi)存操作,與SIGBUS不同,他是對合法地址的非法訪問,比如訪問沒有讀權(quán)限的內(nèi)存,向沒有寫權(quán)限的地址寫數(shù)據(jù)#define SIGUSR2 12 // 未使用,保留#define SIGPIPE 13 // 管道破裂,通常在進(jìn)程間通信產(chǎn)生#define SIGALRM 14 // 定時信號,#define SIGTERM 15 // 結(jié)束程序,類似溫和的SIGKILL,可被阻塞和處理。通常程序如果終止不了,才會嘗試SIGKILL#define SIGSTKFLT 16 // 協(xié)處理器堆棧錯誤#define SIGCHLD 17 // 子進(jìn)程結(jié)束時, 父進(jìn)程會收到這個信號。#define SIGCONT 18 // 讓一個停止的進(jìn)程繼續(xù)執(zhí)行#define SIGSTOP 19 // 停止進(jìn)程,本信號不能被阻塞,處理或忽略#define SIGTSTP 20 // 停止進(jìn)程,但該信號可以被處理和忽略#define SIGTTIN 21 // 當(dāng)后臺作業(yè)要從用戶終端讀數(shù)據(jù)時, 該作業(yè)中的所有進(jìn)程會收到SIGTTIN信號#define SIGTTOU 22 // 類似于SIGTTIN, 但在寫終端時收到#define SIGURG 23 // 有緊急數(shù)據(jù)或out-of-band數(shù)據(jù)到達(dá)socket時產(chǎn)生#define SIGXCPU 24 // 超過CPU時間資源限制時發(fā)出#define SIGXFSZ 25 // 當(dāng)進(jìn)程企圖擴大文件以至于超過文件大小資源限制#define SIGVTALRM 26 // 虛擬時鐘信號. 類似于SIGALRM, 但是計算的是該進(jìn)程占用的CPU時間.#define SIGPROF 27 // 類似于SIGALRM/SIGVTALRM, 但包括該進(jìn)程用的CPU時間以及系統(tǒng)調(diào)用的時間#define SIGWINCH 28 // 窗口大小改變時發(fā)出#define SIGIO 29 // 文件描述符準(zhǔn)備就緒, 可以開始進(jìn)行輸入/輸出操作#define SIGPOLL SIGIO // 同上,別稱#define SIGPWR 30 // 電源異常#define SIGSYS 31 // 非法的系統(tǒng)調(diào)用
通常我們在做 crash 收集的時候,主要關(guān)注這幾個信號量:
const int signal_array[] = {SIGILL, SIGABRT, SIGBUS, SIGFPE, SIGSEGV, SIGSTKFLT, SIGSYS};
對應(yīng)的含義可以參考上文,
extern int sigaction(int, const struct sigaction*, struct sigaction*);
第一個參數(shù) int 類型,表示需要關(guān)注的信號量
第二個參數(shù) sigaction 結(jié)構(gòu)體指針,用于聲明當(dāng)某個特定信號發(fā)生的時候,應(yīng)該如何處理。
第三個參數(shù)也是 sigaction 結(jié)構(gòu)體指針,他表示的是默認(rèn)處理方式,當(dāng)我們自定義了信號量處理的時候,用他存儲之前默認(rèn)的處理方式。
這也是指針與引用的區(qū)別,指針操作操作的都是變量本身,所以給新指針賦值了以后,需要另一個指針來記錄封裝了默認(rèn)處理方式的變量在內(nèi)存中的位置。
所以,要訂閱異常發(fā)生的信號,最簡單的做法就是直接用一個循環(huán)遍歷所有要訂閱的信號,對每個信號調(diào)用 sigaction()
void init() { struct sigaction handler; struct sigaction old_signal_handlers[SIGNALS_LEN]; for (int i = 0; i < SIGNALS_LEN; ++i) {sigaction(signal_array[i], &handler, & old_signal_handlers[i]); }} 捕獲到 Crash 的位置
sigaction 結(jié)構(gòu)體有一個 sa_sigaction 變量,他是個函數(shù)指針,原型為: void (*)(int siginfo_t *, void *) 因此,我們可以聲明一個函數(shù),直接將函數(shù)的地址賦值給 sa_sigaction
void signal_handle(int code, siginfo_t *si, void *context) {}void init() {struct sigaction old_signal_handlers[SIGNALS_LEN];struct sigaction handler;handler.sa_sigaction = signal_handle;handler.sa_flags = SA_SIGINFO;for (int i = 0; i < SIGNALS_LEN; ++i) { sigaction(signal_array[i], &handler, & old_signal_handlers[i]);}}
這樣當(dāng)發(fā)生 Crash 的時候就會回調(diào)我們傳入的 signal_handle() 函數(shù)了。在 signal_handle() 函數(shù)中,我們得要想辦法拿到當(dāng)前執(zhí)行的代碼信息。
設(shè)置緊急棧空間
如果當(dāng)前函數(shù)發(fā)生了無限遞歸造成堆棧溢出,在統(tǒng)計的時候需要考慮到這種情況而新開堆棧否則本來就滿了的堆棧又在當(dāng)前堆棧處理溢出信號,處理肯定是會失敗的。所以我們需要設(shè)置一個用于緊急處理的新棧,可以使用 sigaltstack() 在任意線程注冊一個可選的棧,保留一下在緊急情況下使用的空間。(系統(tǒng)會在危險情況下把棧指針指向這個地方,使得可以在一個新的棧上運行信號處理函數(shù))
void signal_handle(int sig) { write(2, 'stack overflown', 15); _exit(1);}unsigned infinite_recursion(unsigned x) { return infinite_recursion(x)+1;}int main() { static char stack[SIGSTKSZ]; stack_t ss = {.ss_size = SIGSTKSZ,.ss_sp = stack, }; struct sigaction sa = {.sa_handler = signal_handle,.sa_flags = SA_ONSTACK }; sigaltstack(&ss, 0); sigfillset(&sa.sa_mask); sigaction(SIGSEGV, &sa, 0); infinite_recursion(0);}
捕獲出問題的代碼
signal_handle() 函數(shù)中的第三個參數(shù) context 是 uc_mcontext 的結(jié)構(gòu)體指針,它封裝了 cpu 相關(guān)的上下文,包括當(dāng)前線程的寄存器信息和奔潰時的 pc 值,能夠知道崩潰時的pc,就能知道崩潰時執(zhí)行的是那條指令,同樣的,在本文頂部的那張圖中寄存器快照就可以用如下代碼獲得。
char *head_cpu = nullptr;asprintf(&head_cpu, 'r0 %08lx r1 %08lx r2 %08lx r3 %08lxn' 'r4 %08lx r5 %08lx r6 %08lx r7 %08lxn' 'r8 %08lx r9 %08lx sl %08lx fp %08lxn' 'ip %08lx sp %08lx lr %08lx pc %08lx cpsr %08lxn', t->uc_mcontext.arm_r0, t->uc_mcontext.arm_r1, t->uc_mcontext.arm_r2, t->uc_mcontext.arm_r3, t->uc_mcontext.arm_r4, t->uc_mcontext.arm_r5, t->uc_mcontext.arm_r6, t->uc_mcontext.arm_r7, t->uc_mcontext.arm_r8, t->uc_mcontext.arm_r9, t->uc_mcontext.arm_r10, t->uc_mcontext.arm_fp, t->uc_mcontext.arm_ip, t->uc_mcontext.arm_sp, t->uc_mcontext.arm_lr, t->uc_mcontext.arm_pc, t->uc_mcontext.arm_cpsr);
不過 uc_mcontext 結(jié)構(gòu)體的定義是平臺相關(guān)的,比如我們熟知的 arm 、 x86 這種都不是同一個結(jié)構(gòu)體定義,上面的代碼只列出了 arm 架構(gòu)的寄存器信息,要兼容其他架構(gòu)的 cpu 在處理的時候,就得要寄出宏編譯大法,不同的架構(gòu)使用不同的定義。
uintptr_t pc_from_ucontext(const ucontext_t *uc) {#if (defined(__arm__)) return uc->uc_mcontext.arm_pc;#elif defined(__aarch64__) return uc->uc_mcontext.pc;#elif (defined(__x86_64__)) return uc->uc_mcontext.gregs[REG_RIP];#elif (defined(__i386)) return uc->uc_mcontext.gregs[REG_EIP];#elif (defined (__ppc__)) || (defined (__powerpc__)) return uc->uc_mcontext.regs->nip;#elif (defined(__hppa__)) return uc->uc_mcontext.sc_iaoq[0] & ~0x3UL;#elif (defined(__sparc__) && defined (__arch64__)) return uc->uc_mcontext.mc_gregs[MC_PC];#elif (defined(__sparc__) && !defined (__arch64__)) return uc->uc_mcontext.gregs[REG_PC];#else#error 'Architecture is unknown, please report me!'#endif}
pc值轉(zhuǎn)內(nèi)存地址
pc值是程序加載到內(nèi)存中的絕對地址,絕對地址不能直接使用,因為每次程序運行創(chuàng)建的內(nèi)存肯定都不是固定區(qū)域的內(nèi)存,所以絕對地址肯定每次運行都不一致。我們需要拿到崩潰代碼相對于當(dāng)前庫的相對偏移地址,這樣才能使用 addr2line 分析出是哪一行代碼。通過 dladdr() 可以獲得共享庫加載到內(nèi)存的起始地址,和 pc 值相減就可以獲得相對偏移地址,并且可以獲得共享庫的名字。
Dl_info info; if (dladdr(addr, &info) && info.dli_fname) { void * const nearest = info.dli_saddr; uintptr_t addr_relative = addr - info.dli_fbase; } 獲取 Crash 發(fā)生時的函數(shù)調(diào)用棧
獲取函數(shù)調(diào)用棧是最麻煩的,至今沒有一個好用的,全都要做一些大改動。常見的做法有四種:
第一種:直接使用系統(tǒng)的 <unwind.h> 庫,可以獲取到出錯文件與函數(shù)名。只不過需要自己解析函數(shù)符號,同時經(jīng)常會捕獲到系統(tǒng)錯誤,需要手動過濾。 第二種:在 4.1.1 以上, 5.0 以下,使用系統(tǒng)自帶的 libcorkscrew.so ,5.0開始,系統(tǒng)中沒有了 libcorkscrew.so ,可以自己編譯系統(tǒng)源碼中的 libunwind 。 libunwind 是一個開源庫,事實上高版本的安卓源碼中就使用了他的優(yōu)化版替換 libcorkscrew 。 第三種:使用開源庫 coffeecatch ,但是這種方案也不能百分之百兼容所有機型。 第四種:使用 Google 的 breakpad ,這是所有 C/C++堆棧獲取的權(quán)威方案,基本上業(yè)界都是基于這個庫來做的。只不過這個庫是全平臺的 android、iOS、Windows、Linux、MacOS 全都有,所以非常大,在使用的時候得把無關(guān)的平臺剝離掉減小體積。下面以第一種為例講一下實現(xiàn):
核心方法是使用 <unwind.h> 庫提供的一個方法 _Unwind_Backtrace() 這個函數(shù)可以傳入一個函數(shù)指針作為回調(diào),指針指向的函數(shù)有一個重要的參數(shù)是 _Unwind_Context 類型的結(jié)構(gòu)體指針。
可以使用 _Unwind_GetIP() 函數(shù)將當(dāng)前函數(shù)調(diào)用棧中每個函數(shù)的絕對內(nèi)存地址(也就是上文中提到的 pc 值),寫入到 _Unwind_Context 結(jié)構(gòu)體中,最終返回的是當(dāng)前調(diào)用棧的全部函數(shù)地址了, _Unwind_Word 實際上就是一個 unsigned int 。
而 capture_backtrace() 返回的就是當(dāng)前我們獲取到調(diào)用棧中內(nèi)容的數(shù)量。
/** * callback used when using <unwind.h> to get the trace for the current context */_Unwind_Reason_Code unwind_callback(struct _Unwind_Context *context, void *arg) { backtrace_state_t *state = (backtrace_state_t *) arg; _Unwind_Word pc = _Unwind_GetIP(context); if (pc) {if (state->current == state->end) { return _URC_END_OF_STACK;} else { *state->current++ = (void *) pc;} } return _URC_NO_REASON;}/** * uses built in <unwind.h> to get the trace for the current context */size_t capture_backtrace(void **buffer, size_t max) { backtrace_state_t state = {buffer, buffer + max}; _Unwind_Backtrace(unwind_callback, &state); return state.current - buffer;}
當(dāng)所有的函數(shù)的絕對內(nèi)存地址(pc 值)都獲取到了,就可以用上文講的辦法將 pc 值轉(zhuǎn)換為相對偏移量,獲取到真正的函數(shù)信息和相對內(nèi)存地址了。
void *buffer[max_line];int frames_size = capture_backtrace(buffer, max_line);for (int i = 0; i < frames_size; i++) {Dl_info info; const void *addr = buffer[i];if (dladdr(addr, &info) && info.dli_fname) { void * const nearest = info.dli_saddr; uintptr_t addr_relative = addr - info.dli_fbase; }
Dl_info 是一個結(jié)構(gòu)體,內(nèi)部封裝了函數(shù)所在文件、函數(shù)名、當(dāng)前庫的基地址等信息
typedef struct { const char *dli_fname; /* Pathname of shared object that contains address */ void *dli_fbase; /* Address at which shared object is loaded */ const char *dli_sname; /* Name of nearest symbol with address lower than addr */ void *dli_saddr; /* Exact address of symbol named in dli_sname */} Dl_info;
有了這個對象,我們就能獲取到全部想要的信息了。雖然獲取到全部想要的信息,但 <unwind.h> 有個麻煩的就是不想要的信息也給你了,所以需要手動過濾掉各種系統(tǒng)錯誤,最終得到的數(shù)據(jù),就可以上報到自己的服務(wù)器了。
數(shù)據(jù)回傳到服務(wù)器數(shù)據(jù)回傳有兩種方式,一種是直接將信息寫入文件,下次啟動的時候直接由 Java 上報;另一種就是回調(diào) Java 代碼,讓 Java 去處理。用 Java 處理的好處是 Java 層可以繼續(xù)在當(dāng)前上下文上加上 Java 層的各種狀態(tài)信息,寫入到同一個文件中,使得開發(fā)在解決 bug 的時候能更方便。
這里就簡單將數(shù)據(jù)寫入文件了。
void save(const char *name, char *content) { FILE *file = fopen(name, 'w+'); fputs(content, file); fflush(file); fclose(file); //可以在寫入文件以后,再通知 Java 層,直接將文件名傳給 Java 層更簡單。 report();}
如果你按照本文講的,應(yīng)該是可以創(chuàng)建一個可以工作的 Native Crash 收集庫了,但是還有很多細(xì)節(jié)上的問題,比如數(shù)據(jù)的丟失問題,寫文件的時候使用 w+ 可能造成上次存儲的文件丟失;如果當(dāng)前函數(shù)發(fā)生了無限遞歸造成堆棧溢出,在統(tǒng)計的時候需要考慮到這種情況而新開堆棧否則本來就滿了的堆棧又在當(dāng)前堆棧處理溢出信號,處理肯定是會失敗的;再比方說多進(jìn)程多線程在 C 上的各種問題,真的是很復(fù)雜。
來自:https://www.kymjs.com/code/2018/08/22/01/
相關(guān)文章:
