Android Native Crash 收集

在 Android 平臺上,Native Crash 一直是比較麻煩的問題,因為捕獲麻煩,獲取到了內容又不全,內容全了信息又不對,信息對了又不好處理。比 Java Crash 不知道麻煩多少倍。

今天跟大家講一下,我最近掉了幾百根頭髮寫出來的一個 Native Crash 收集的功能(脫髮已經越來越嚴重了)。

一個 Native Crash 的 log 信息如下圖:

Android Native Crash 收集


這張圖是我在網上找的(由於沒有寫 demo,項目中的截圖不方便直接拿出來,就偷了個懶)。

在上圖裡,堆棧信息中 pc 後面跟的內存地址,就是當前函數的棧地址,我們可以通過命令行arm-linux-androideabi-addr2line -e 內存地址得出出錯的代碼行數了。

要實現 Native Crash 的收集,主要有四個重點:知道 Crash 的發生;捕獲到 Crash 的位置;獲取 Crash 發生位置的函數調用棧;數據能回傳到服務器。

知道 Crash 的發生

與 Java 平臺不同,C/C++ 沒有一個通用的異常處理接口,在 C 層,CPU 通過異常中斷的方式,觸發異常處理流程。不同的處理器,有不同的異常中斷類型和中斷處理方式,linux 把這些中斷處理,統一為信號量,每一種異常都有一個對應的信號,可以註冊回調函數進行處理需要關注的信號量。

所有的信號量都定義在\文件中,這裡我將幾乎全部的信號量以及所代表的含義都標註出來了:

#define SIGHUP 1 // 終端連接結束時發出(不管正常或非正常) 

#define SIGINT 2 // 程序終止(例如Ctrl-C)
#define SIGQUIT 3 // 程序退出(Ctrl-\)
#define SIGILL 4 // 執行了非法指令,或者試圖執行數據段,堆棧溢出
#define SIGTRAP 5 // 斷點時產生,由debugger使用
#define SIGABRT 6 // 調用abort函數生成的信號,表示程序異常
#define SIGIOT 6 // 同上,更全,IO異常也會發出
#define SIGBUS 7 // 非法地址,包括內存地址對齊出錯,比如訪問一個4字節的整數, 但其地址不是4的倍數
#define SIGFPE 8 // 計算錯誤,比如除0、溢出
#define SIGKILL 9 // 強制結束程序,具有最高優先級,本信號不能被阻塞、處理和忽略
#define SIGUSR1 10 // 未使用,保留
#define SIGSEGV 11 // 非法內存操作,與SIGBUS不同,他是對合法地址的非法訪問,比如訪問沒有讀權限的內存,向沒有寫權限的地址寫數據
#define SIGUSR2 12 // 未使用,保留
#define SIGPIPE 13 // 管道破裂,通常在進程間通信產生
#define SIGALRM 14 // 定時信號,
#define SIGTERM 15 // 結束程序,類似溫和的SIGKILL,可被阻塞和處理。通常程序如果終止不了,才會嘗試SIGKILL
#define SIGSTKFLT 16 // 協處理器堆棧錯誤
#define SIGCHLD 17 // 子進程結束時, 父進程會收到這個信號。
#define SIGCONT 18 // 讓一個停止的進程繼續執行
#define SIGSTOP 19 // 停止進程,本信號不能被阻塞,處理或忽略

#define SIGTSTP 20 // 停止進程,但該信號可以被處理和忽略
#define SIGTTIN 21 // 當後臺作業要從用戶終端讀數據時, 該作業中的所有進程會收到SIGTTIN信號
#define SIGTTOU 22 // 類似於SIGTTIN, 但在寫終端時收到
#define SIGURG 23 // 有緊急數據或out-of-band數據到達socket時產生
#define SIGXCPU 24 // 超過CPU時間資源限制時發出
#define SIGXFSZ 25 // 當進程企圖擴大文件以至於超過文件大小資源限制
#define SIGVTALRM 26 // 虛擬時鐘信號. 類似於SIGALRM, 但是計算的是該進程佔用的CPU時間.
#define SIGPROF 27 // 類似於SIGALRM/SIGVTALRM, 但包括該進程用的CPU時間以及系統調用的時間
#define SIGWINCH 28 // 窗口大小改變時發出
#define SIGIO 29 // 文件描述符準備就緒, 可以開始進行輸入/輸出操作
#define SIGPOLL SIGIO // 同上,別稱
#define SIGPWR 30 // 電源異常
#define SIGSYS 31 // 非法的系統調用

通常我們在做 crash 收集的時候,主要關注這幾個信號量:

const int signal_array[] = {SIGILL, SIGABRT, SIGBUS, SIGFPE, SIGSEGV, SIGSTKFLT, SIGSYS};

對應的含義可以參考上文,

extern int sigaction(int, const struct sigaction*, struct sigaction*);

第一個參數 int 類型,表示需要關注的信號量

第二個參數 sigaction 結構體指針,用於聲明當某個特定信號發生的時候,應該如何處理。

第三個參數也是 sigaction 結構體指針,他表示的是默認處理方式,當我們自定義了信號量處理的時候,用他存儲之前默認的處理方式。

這也是指針與引用的區別,指針操作操作的都是變量本身,所以給新指針賦值了以後,需要另一個指針來記錄封裝了默認處理方式的變量在內存中的位置。

所以,要訂閱異常發生的信號,最簡單的做法就是直接用一個循環遍歷所有要訂閱的信號,對每個信號調用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 結構體有一個 sa_sigaction變量,他是個函數指針,原型為:void (*)(int siginfo_t *, void *)

因此,我們可以聲明一個函數,直接將函數的地址賦值給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]);
}
}

這樣當發生 Crash 的時候就會回調我們傳入的signal_handle()函數了。在signal_handle()函數中,我們得要想辦法拿到當前執行的代碼信息。

設置緊急棧空間

如果當前函數發生了無限遞歸造成堆棧溢出,在統計的時候需要考慮到這種情況而新開堆棧否則本來就滿了的堆棧又在當前堆棧處理溢出信號,處理肯定是會失敗的。所以我們需要設置一個用於緊急處理的新棧,可以使用sigaltstack()在任意線程註冊一個可選的棧,保留一下在緊急情況下使用的空間。(系統會在危險情況下把棧指針指向這個地方,使得可以在一個新的棧上運行信號處理函數)

void signal_handle(int sig) {
write(2, "stack overflow\n", 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() 函數中的第三個參數 context 是uc_mcontext的結構體指針,它封裝了 cpu 相關的上下文,包括當前線程的寄存器信息和奔潰時的 pc 值,能夠知道崩潰時的pc,就能知道崩潰時執行的是那條指令,同樣的,在本文頂部的那張圖中寄存器快照就可以用如下代碼獲得。

char *head_cpu = nullptr;
asprintf(&head_cpu, "r0 %08lx r1 %08lx r2 %08lx r3 %08lx\n"
"r4 %08lx r5 %08lx r6 %08lx r7 %08lx\n"
"r8 %08lx r9 %08lx sl %08lx fp %08lx\n"
"ip %08lx sp %08lx lr %08lx pc %08lx cpsr %08lx\n",
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結構體的定義是平臺相關的,比如我們熟知的arm、x86這種都不是同一個結構體定義,上面的代碼只列出了arm架構的寄存器信息,要兼容其他架構的 cpu 在處理的時候,就得要寄出宏編譯大法,不同的架構使用不同的定義。

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值轉內存地址

pc值是程序加載到內存中的絕對地址,絕對地址不能直接使用,因為每次程序運行創建的內存肯定都不是固定區域的內存,所以絕對地址肯定每次運行都不一致。我們需要拿到崩潰代碼相對於當前庫的相對偏移地址,這樣才能使用 addr2line 分析出是哪一行代碼。通過dladdr()可以獲得共享庫加載到內存的起始地址,和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 發生時的函數調用棧

獲取函數調用棧是最麻煩的,至今沒有一個好用的,全都要做一些大改動。常見的做法有四種:

  • 第一種:直接使用系統的庫,可以獲取到出錯文件與函數名。只不過需要自己解析函數符號,同時經常會捕獲到系統錯誤,需要手動過濾。
  • 第二種:在4.1.1以上,5.0以下,使用系統自帶的libcorkscrew.so,5.0開始,系統中沒有了libcorkscrew.so,可以自己編譯系統源碼中的libunwind。libunwind是一個開源庫,事實上高版本的安卓源碼中就使用了他的優化版替換libcorkscrew。
  • 第三種:使用開源庫coffeecatch,但是這種方案也不能百分之百兼容所有機型。
  • 第四種:使用 Google 的breakpad,這是所有 C/C++堆棧獲取的權威方案,基本上業界都是基於這個庫來做的。只不過這個庫是全平臺的 android、iOS、Windows、Linux、MacOS 全都有,所以非常大,在使用的時候得把無關的平臺剝離掉減小體積。

下面以第一種為例講一下實現:

核心方法是使用庫提供的一個方法_Unwind_Backtrace()這個函數可以傳入一個函數指針作為回調,指針指向的函數有一個重要的參數是_Unwind_Context類型的結構體指針。

可以使用_Unwind_GetIP()函數將當前函數調用棧中每個函數的絕對內存地址(也就是上文中提到的 pc 值),寫入到_Unwind_Context結構體中,最終返回的是當前調用棧的全部函數地址了,_Unwind_Word實際上就是一個unsigned int。

而capture_backtrace()返回的就是當前我們獲取到調用棧中內容的數量。

/**
* callback used when using 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 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;
}

當所有的函數的絕對內存地址(pc 值)都獲取到了,就可以用上文講的辦法將 pc 值轉換為相對偏移量,獲取到真正的函數信息和相對內存地址了。

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是一個結構體,內部封裝了函數所在文件、函數名、當前庫的基地址等信息

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;

有了這個對象,我們就能獲取到全部想要的信息了。雖然獲取到全部想要的信息,但有個麻煩的就是不想要的信息也給你了,所以需要手動過濾掉各種系統錯誤,最終得到的數據,就可以上報到自己的服務器了。

數據回傳到服務器

數據回傳有兩種方式,一種是直接將信息寫入文件,下次啟動的時候直接由 Java 上報;另一種就是回調 Java 代碼,讓 Java 去處理。用 Java 處理的好處是 Java 層可以繼續在當前上下文上加上 Java 層的各種狀態信息,寫入到同一個文件中,使得開發在解決 bug 的時候能更方便。

這裡就簡單將數據寫入文件了。

void save(const char *name, char *content) {
FILE *file = fopen(name, "w+");
fputs(content, file);
fflush(file);
fclose(file);
//可以在寫入文件以後,再通知 Java 層,直接將文件名傳給 Java 層更簡單。
report();
}

如果你按照本文講的,應該是可以創建一個可以工作的 Native Crash 收集庫了,但是還有很多細節上的問題,比如數據的丟失問題,寫文件的時候使用w+可能造成上次存儲的文件丟失;如果當前函數發生了無限遞歸造成堆棧溢出,在統計的時候需要考慮到這種情況而新開堆棧否則本來就滿了的堆棧又在當前堆棧處理溢出信號,處理肯定是會失敗的;再比方說多進程多線程在 C 上的各種問題,真的是很複雜。

更多Android進階技術,職業生涯規劃,產品,思維,行業觀察,談天說地。可以關注後私信我關鍵詞;Android進階 前往獲取。


分享到:


相關文章: