Android Native Crash 收集

kymjs張濤發表於2018-08-23

本文開源實驗室原創,轉載請以連結形式註明地址:kymjs.com/code/2018/0…

本篇核心講解了自己實現一個 Android Native Crash 收集的方案步驟,重點問題的解決辦法。 在 Android 平臺上,Native Crash 一直是比較麻煩的問題,因為捕獲麻煩,獲取到了內容又不全,內容全了資訊又不對,資訊對了又不好處理。比 Java Crash 不知道麻煩多少倍。

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

開源實驗室

這張圖是我在網上找的(由於沒有寫 demo,專案中的截圖不方便直接拿出來,就偷了個懶)。
在上圖裡,堆疊資訊中 pc 後面跟的記憶體地址,就是當前函式的棧地址,我們可以通過命令列arm-linux-androideabi-addr2line -e 記憶體地址得出出錯的程式碼行數了。
要實現 Native Crash 的收集,主要有四個重點:知道 Crash 的發生;捕獲到 Crash 的位置;獲取 Crash 發生位置的函式呼叫棧;資料能回傳到伺服器。

知道 Crash 的發生

與 Java 平臺不同,C/C++ 沒有一個通用的異常處理介面,在 C 層,CPU 通過異常中斷的方式,觸發異常處理流程。不同的處理器,有不同的異常中斷型別和中斷處理方式,linux 把這些中斷處理,統一為訊號量,每一種異常都有一個對應的訊號,可以註冊回撥函式進行處理需要關注的訊號量。
所有的訊號量都定義在<signal.h>檔案中,這裡我將幾乎全部的訊號量以及所代表的含義都標註出來了:

#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() 函式中的第三個引數 contextuc_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結構體的定義是平臺相關的,比如我們熟知的armx86這種都不是同一個結構體定義,上面的程式碼只列出了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 發生時的函式呼叫棧

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

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

下面以第一種為例講一下實現:
核心方法是使用<unwind.h>庫提供的一個方法_Unwind_Backtrace()這個函式可以傳入一個函式指標作為回撥,指標指向的函式有一個重要的引數是_Unwind_Context型別的結構體指標。
可以使用_Unwind_GetIP()函式將當前函式呼叫棧中每個函式的絕對記憶體地址(也就是上文中提到的 pc 值),寫入到_Unwind_Context結構體中,最終返回的是當前呼叫棧的全部函式地址了,_Unwind_Word實際上就是一個unsigned int
capture_backtrace()返回的就是當前我們獲取到呼叫棧中內容的數量。


/**
 * 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;
}
複製程式碼

當所有的函式的絕對記憶體地址(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;
複製程式碼

有了這個物件,我們就能獲取到全部想要的資訊了。雖然獲取到全部想要的資訊,但<unwind.h>有個麻煩的就是不想要的資訊也給你了,所以需要手動過濾掉各種系統錯誤,最終得到的資料,就可以上報到自己的伺服器了。

資料回傳到伺服器

資料回傳有兩種方式,一種是直接將資訊寫入檔案,下次啟動的時候直接由 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 上的各種問題,真的是很複雜。

相關文章