Android Native Crash 收集

本文开源实验室原创,转载请以连接形式注明地址: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 发生位置的函数调用栈;数据能回传到服务器。服务器

知道 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]);
    }
}
复制代码

捕获到 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 上的各类问题,真的是很复杂。

相关文章
相关标签/搜索