本文開源實驗室原創,轉載請以連接形式註明地址:kymjs.com/code/2018/0…linux
本篇核心講解了本身實現一個 Android Native Crash 收集的方案步驟,重點問題的解決辦法。 在 Android 平臺上,Native Crash 一直是比較麻煩的問題,由於捕獲麻煩,獲取到了內容又不全,內容全了信息又不對,信息對了又很差處理。比 Java Crash 不知道麻煩多少倍。android
今天跟你們講一下,我最近掉了幾百根頭髮寫出來的一個 Native Crash 收集的功能(脫髮已經愈來愈嚴重了)。
一個 Native Crash 的 log 信息以下圖:
bash
這張圖是我在網上找的(因爲沒有寫 demo,項目中的截圖不方便直接拿出來,就偷了個懶)。
在上圖裏,堆棧信息中 pc 後面跟的內存地址,就是當前函數的棧地址,咱們能夠經過命令行arm-linux-androideabi-addr2line -e 內存地址
得出出錯的代碼行數了。
要實現 Native 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};
複製代碼
對應的含義能夠參考上文,socket
extern int sigaction(int, const struct sigaction*, struct sigaction*);
複製代碼
第一個參數 int 類型,表示須要關注的信號量
第二個參數 sigaction 結構體指針,用於聲明當某個特定信號發生的時候,應該如何處理。
第三個參數也是 sigaction 結構體指針,他表示的是默認處理方式,當咱們自定義了信號量處理的時候,用他存儲以前默認的處理方式。ide
這也是指針與引用的區別,指針操做操做的都是變量自己,因此給新指針賦值了之後,須要另外一個指針來記錄封裝了默認處理方式的變量在內存中的位置。函數
因此,要訂閱異常發生的信號,最簡單的作法就是直接用一個循環遍歷全部要訂閱的信號,對每一個信號調用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]);
}
}
複製代碼
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值是程序加載到內存中的絕對地址,絕對地址不能直接使用,由於每次程序運行建立的內存確定都不是固定區域的內存,因此絕對地址確定每次運行都不一致。咱們須要拿到崩潰代碼相對於當前庫的相對偏移地址,這樣才能使用 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;
}
複製代碼
獲取函數調用棧是最麻煩的,至今沒有一個好用的,全都要作一些大改動。常見的作法有四種:
<unwind.h>
庫,能夠獲取到出錯文件與函數名。只不過須要本身解析函數符號,同時常常會捕獲到系統錯誤,須要手動過濾。4.1.1
以上,5.0
如下,使用系統自帶的libcorkscrew.so
,5.0開始,系統中沒有了libcorkscrew.so
,能夠本身編譯系統源碼中的libunwind
。libunwind
是一個開源庫,事實上高版本的安卓源碼中就使用了他的優化版替換libcorkscrew
。coffeecatch
,可是這種方案也不能百分之百兼容全部機型。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 上的各類問題,真的是很複雜。