nativeCrash2Monitor_CollectStack

参考xcrashnativecrash源码分析

1 捕捉native crash的发生

信号

所有的信号量都定义在<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 // 非法的系统调用

致命的信号分为 2 类:

1、kernel 发出的:

SIGFPE: 除数为零。

SIGILL: 无法识别的 CPU 指令。

SIGSYS: 无法识别的系统调用(system call)。

SIGSEGV: 错误的虚拟内存地址访问。

SIGBUS: 错误的物理设备地址访问。

2、用户态进程发出的:

SIGABRT: 调用 abort() / kill() / tkill() / tgkill() 自杀,或被其他进程通过 kill() / tkill() / tgkill() 他杀。

image

Naive 崩溃捕获需要注册这些信号的处理函数(signal handler),然后在信号处理函数中收集数据。

因为信号是以“中断”的方式出现的,可能中断任何 CPU 指令序列的执行,所以在信号处理函数中,只能调用“异步信号安全(async-signal-safe)”的函数。例如malloc()、calloc()、free()、snprintf()、gettimeofday() 等等都是不能使用的,C++ STL / boost 也是不能使用的。

所以,在信号处理函数中我们只能不分配堆内存,需要使用堆内存只能在初始化时预分配。如果要使用不在异步信号安全白名单中的 libc / bionic 函数,只能直接调用 system call 或者自己实现

进程崩溃前的极端情况

当崩溃捕获逻辑开始运行时,会面对很多糟糕的情况,比如:栈溢出、堆内存不可用、虚拟内存地址耗尽、FD 耗尽、Flash 空间耗尽等。有时,这些极端情况的出现,本身就是导致进程崩溃的间接原因。

1、栈溢出

我们需要预先用 sigaltstack() 为 signal handler 分配专门的栈内存空间,否则当遇到栈溢出时,signal handler 将无法正常运行。

2、虚拟内存地址耗尽

内存泄露很容易导致虚拟内存地址耗尽,特别是在 32 位环境中。这意味着在 signal handler 中也不能使用类似 mmap() 的调用。

3、FD 耗尽

FD 泄露是常见的导致进程崩溃的间接原因。这意味着在 signal handler 中无法正常的使用依赖于 FD 的操作,比如无法 open() + read() 读取/proc 中的各种信息。为了不干扰 APP 的正常运行,我们仅仅预留了一个 FD,用于在崩溃时可靠的创建出“崩溃信息记录文件”。

4、Flash 空间耗尽

在 16G / 32G 存储空间的安卓设备中,这种情况经常发生。这意味着 signal handler 无法把崩溃信息记录到本地文件中。我们只能尝试在初始化时预先创建一些“占坑”文件,然后一直循环使用这些“占坑”文件来记录崩溃信息。如果“占坑”文件也创建失败,我们需要把最重要的一些崩溃信息(比如 backtrace)保存在内存中,然后立刻回调和发送这些信息。

信号处理函数与子进程

在信号处理函数(signal handler)代码执行的==开始阶段==,我们只能“忍辱偷生”:

1、遵守它的各种限制。

2、不使用堆内存。

3、自己实现需要的调用的“异步信号安全版本”,比如:snprintf()、gettimeofday()。

4、必要时直接调用 system call。

但这并非长久之计,我们要尽快在信号处理函数中执行“逃逸”,即使用clone() + execl() 创建新的子进程,然后在==子进程中继续收集崩溃信息==。这样做的目的是:

1、避开 async-signal-safe 的限制。

2、避开虚拟内存地址耗尽的问题。

3、避开 FD 耗尽的问题。

4、使用 ptrace() suspend 崩溃进程中所有的线程。与 iOS 不同,Linux / Android 不支持 suspend 本进程内的线程。(如果不做 suspend,则其他未崩溃的线程还在继续执行,还在继续写 logcat,当我们收集 logcat 时,崩溃时间点附近的 logcat 可能早已被淹没。类似的,其他的业务 log buffers 也存在被淹没的问题。)

5、除了崩溃线程本身的 registers、backtrace 等,还能用 ptrace()收集到进程中其他所有线程的 registers、backtrace 等信息,这对于某些崩溃问题的分析是有意义的。

6、更安全的读取内存数据。(ptrace 读数据失败会返回错误码,但是在崩溃线程内直接读内存数据,如果内存地址非法,会导致段错误)

由此可以看出“逃逸”是必然的选择,整个过程如下图所示:

Capture Native Crash

image

1. 注册信号处理函数

#include <signal.h> 
int sigaction(int signum,const struct sigaction *act,struct sigaction *oldact));

//signum:代表信号编码,可以是除SIGKILL及SIGSTOP外的任何一个特定有效的信号,如果为这两个信号定义自己的处理函数,将导致信号安装错误。
//act:指向结构体sigaction的一个实例的指针,该实例指定了对特定信号的处理,如果设置为空,进程会执行默认处理。
//oldact:和参数act类似,只不过保存的是原来对相应信号的处理,也可设置为NULL。

struct sigaction sa_old;  
memset(&sa, 0, sizeof(sa));  
sigemptyset(&sa.sa_mask);  
sa.sa_sigaction = my_handler;  
sa.sa_flags = SA_SIGINFO;
if (sigaction(sig, &sa, &sa_old) == 0) {  
  ...  
}

2. 设置额外栈空间

#include <signal.h>
int sigaltstack(const stack_t *ss, stack_t *oss);
  • SIGSEGV很有可能是栈溢出引起的,如果在默认的栈上运行很有可能会破坏程序运行的现场,无法获取到正确的上下文。而且当栈满了(太多次递归,栈上太多对象),系统会在同一个已经满了的栈上调用SIGSEGV的信号处理函数,又再一次引起同样的信号。

  • 我们应该开辟一块新的空间作为运行信号处理函数的栈。可以使用sigaltstack在任意线程注册一个可选的栈,保留一下在紧急情况下使用的空间。(系统会在危险情况下把栈指针指向这个地方,使得可以在一个新的栈上运行信号处理函数)

stack_t stack;  
memset(&stack, 0, sizeof(stack));  
/* Reserver the system default stack size. We don't need that much by the way. */  
stack.ss_size = SIGSTKSZ;  
stack.ss_sp = malloc(stack.ss_size);  
stack.ss_flags = 0;  
/* Install alternate stack size. Be sure the memory region is valid until you revert it. */  
if (stack.ss_sp != NULL && sigaltstack(&stack, NULL) == 0) {  
  ...  
}

3.兼容其他signal处理

static void my_handler(const int code, siginfo_t *const si, void *const sc) {
...  
  /* Call previous handler. */  
  old_handler.sa_sigaction(code, si, sc);  
}
  • 某些信号可能在之前已经被安装过信号处理函数,而sigaction一个信号量只能注册一个处理函数,这意味着我们的处理函数会覆盖其他人的处理信号

  • 保存旧的处理函数,在处理完我们的信号处理函数后,在重新运行老的处理函数就能完成兼容。

2 收集crash原因和so中的相对地址

信号处理函数的入参中有丰富的错误信息,下面我们来一一分析。

/*信号处理函数*/
void (*sa_sigaction)(const int code, siginfo_t *const si, void * const sc) 
siginfo_t {
   int      si_signo;     /* Signal number 信号量 */
   int      si_errno;     /* An errno value */
   int      si_code;      /* Signal code 错误码 */
   }

1. code

发生native crash之后,logcat中会打出如下一句信息:

signal 11 (SIGSEGV), code 0 (SI_USER), fault addr 0x0

根据code去查表,其实就可以知道发生native crash的大致原因:

image

2. pc值

信号处理函数中的第三个入参sc是uc_mcontext的结构体,是cpu相关的上下文,包括当前线程的寄存器信息和奔溃时的pc值。能够知道崩溃时的pc,就能知道崩溃时执行的是那条指令。

不过这个结构体的定义是平台相关,不同平台、不同cpu架构中的定义都不一样:

  • x86-64架构:uc_mcontext.gregs[REG_RIP]

  • arm架构:uc_mcontext.arm_pc

3. 共享库名字和相对偏移地址

(1) dladdr()

pc值是程序加载到内存中的绝对地址,我们需要拿到奔溃代码相对于共享库的相对偏移地址,才能使用addr2line分析出是哪一行代码。通过dladdr()可以获得共享库加载到内存的起始地址,用pc值减去它就可以获得相对偏移地址,并且可以获得共享库的名字。

Dl_info info;  
if (dladdr(addr, &info) != 0 && info.dli_fname != NULL) {  
  void * const nearest = info.dli_saddr;  
  //相对偏移地址
  const uintptr_t addr_relative =  
    ((uintptr_t) addr - (uintptr_t) info.dli_fbase);  
  ...  
}

(2) Linux下进程的地址空间布局

image

==任何一个程序通常都包括代码段和数据段,这些代码和数据本身都是静态的。程序要想运行,首先要由操作系统负责为其创建进程,并在进程的虚拟地址空间中为其代码段和数据段建立映射。光有代码段和数据段是不够的,进程在运行过程中还要有其动态环境,其中最重要的就是堆栈==。

栈(stack),作为进程的临时数据区,增长方向是从高地址到低地址。

(3) /proc/self/maps:检查各个模块加载在内存的地址范围

在Linux系统中,/proc/self/maps保存了各个程序段在内存中的加载地址范围,grep出共享库的名字,就可以知道共享库的加载基值是多少。

image

得到相对偏移地址之后,使用readelf查看共享库的符号表,就可以知道是哪个函数crash了。

image

3 获取Crash发生时的函数调用栈

获取 backtrace

xCrash 的 backtrace 实现

xCrash 参考了一部分 AOSP 和 BreakPad 的实现思路,在不需要 root 权限和兼容 Android 4.0 - 9.0 的前提下,自己实现了 unwind 逻辑。这样做的好处是 unwind 过程不再是一个黑盒,细节完全可控,遇到问题完全可调试。

Backtrace unwind 依赖于三部分数据:寄存器、栈内存、各 ELF 中的 unwind table。xCrash 目前能处理 Android 4.0 - 9.0 中可能出现的所有格式的 unwind table,它们来自于 ELF 中的以下 section:

(1).ARM.exidx(只存在于 32 位 ARM 架构)

(2).eh_frame 和 .eh_frame_hdr

(3).debug_frame

(4).gnu_debugdata(LZMA 压缩的 mini debug info,其中可能包含其他的 unwind table,比如:.debug_frame)

1. 原理

在前一步,我们获取了奔溃时的pc值和各个寄存器的内容,通过SP(stack pointer)和FP(frame pointer)所限定的stack frame,就可以得到母函数的SP和FP,从而得到母函数的stack frame(PC,LR,SP,FP会在函数调用的第一时间压栈),以此追溯,即可得到所有函数的调用顺序。

image

2. 实现

获取函数调用栈是最麻烦的,至今没有一个好用的,全都要做一些大改动。常见的做法有四种:

  • 第一种:直接使用系统的<unwind.h>库,可以获取到出错文件与函数名。只不过需要自己解析函数符号,同时经常会捕获到系统错误,需要手动过滤。

  • 第二种:在4.1.1以上,5.0以下,使用系统自带的libcorkscrew.so,5.0开始,系统中没有了libcorkscrew.so,可以自己编译系统源码中的libunwind。libunwind是一个开源库,事实上高版本的安卓源码中就使用了优化版libunwind作为解堆栈的工具来替代libcorkscrew。

  • 第三种:使用开源库coffeecatch,但是这种方案也不能百分之百兼容所有机型。

  • 第四种:使用 Google 的breakpad,这是所有 C/C++堆栈获取的权威方案,基本上业界都是基于这个库来做的。只不过这个库是全平台的 android、iOS、Windows、Linux、MacOS 全都有,所以非常大,在使用的时候得把无关的平台剥离掉减小体积。

  • 第五种: libbacktrace,其实也是用 libunwind 实现的,源码在 system/core 下面.使用 libbacktrace 一共就 3 步:

  1. 使用 Backtrace::Create 创建一个 Backtrace 实例
  2. 调用 Unwind 函数 unwind 一下 stack
  3. FormatFrameData 输出每个栈帧的文本信息(也可以自己根据 frame 自己打印)

获取函数符号

(1) libcorkscrew/libunwind

可以通过libcorkscrew中的get_backtrace_symbols函数获得函数符号。

(2) dladdr

更通用的方法是通过dladdr获得函数名字。

int dladdr(void *addr, Dl_info *info);

typedef struct {
   const char *dli_fname;  /* Pathname of shared object that
                              contains address */
   void       *dli_fbase;  /* Base address at which shared
                              object is loaded */
   const char *dli_sname;  /* Name of symbol whose definition
                              overlaps addr */
   void       *dli_saddr;  /* Exact address of symbol named
                              in dli_sname */
} Dl_info;

传入每一层堆栈的相对偏移地址,就可以从dli_fname中获得函数名字。

获得java堆栈

如何获得native crash所对应的java层堆栈,这个问题曾经困扰了我一段时间。这里有一个前提:我们认为crash线程就是捕获到信号的线程,虽然这在SIGABRT下不一定可靠。有了这个认知,接下来就好办了。在信号处理函数中获得当前线程的名字,然后把crash线程的名字传给java层,在java里dump出这个线程的堆栈,就是crash所对应的java层堆栈了。

  • 在c中获得线程名字:

  • 然后传给java层:

结果展示

经过诸多探索,终于得到了完美的堆栈:

java.lang.Error: signal 11 (Address not mapped to object) at address 0x0
  at dalvik.system.NativeStart.run(Native Method)
Caused by: java.lang.Error: signal 11 (Address not mapped to object) at address 0x0
  at /data/app-lib/com.tencent.moai.crashcatcher.demo-1/libQMCrashGenerator.so.0xd8e(dangerousFunction:0x5:0)
  at /data/app-lib/com.tencent.moai.crashcatcher.demo-1/libQMCrashGenerator.so.0xd95(wrapDangerousFunction:0x2:0)
  at /data/app-lib/com.tencent.moai.crashcatcher.demo-1/libQMCrashGenerator.so.0xd9d(nativeInvalidAddressCrash:0x2:0)
  at /system/lib/libdvm.so.0x1ee8c(dvmPlatformInvoke:0x70:0)
  at /system/lib/libdvm.so.0x503b7(dvmCallJNIMethod(unsigned int const*, JValue*, Method const*, Thread*):0x1ee:0)
  at /system/lib/libdvm.so.0x28268(Native Method)
  at /system/lib/libdvm.so.0x2f738(dvmMterpStd(Thread*):0x44:0)
  at /system/lib/libdvm.so.0x2cda8(dvmInterpret(Thread*, Method const*, JValue*):0xb8:0)
  at /system/lib/libdvm.so.0x648e3(dvmInvokeMethod(Object*, Method const*, ArrayObject*, ArrayObject*, ClassObject*, bool):0x1aa:0)
  at /system/lib/libdvm.so.0x6cff9(Native Method)
  at /system/lib/libdvm.so.0x28268(Native Method)
  at /system/lib/libdvm.so.0x2f738(dvmMterpStd(Thread*):0x44:0)
  at /system/lib/libdvm.so.0x2cda8(dvmInterpret(Thread*, Method const*, JValue*):0xb8:0)
  at /system/lib/libdvm.so.0x643d9(dvmCallMethodV(Thread*, Method const*, Object*, bool, JValue*, std::__va_list):0x14c:0)
  at /system/lib/libdvm.so.0x4bca1(Native Method)
  at /system/lib/libandroid_runtime.so.0x50ac3(Native Method)
  at /system/lib/libandroid_runtime.so.0x518e7(android::AndroidRuntime::start(char const*, char const*):0x206:0)
  at /system/bin/app_process.0xf33(Native Method)
  at /system/lib/libc.so.0xf584(__libc_init:0x64:0)
  at /system/bin/app_process.0x107c(Native Method)
Caused by: java.lang.Error: java stack
  at com.tencent.crashcatcher.CrashCatcher.nativeInvalidAddressCrash(Native Method)
  at com.tencent.crashcatcher.CrashCatcher.invalidAddressCrash(CrashCatcher.java:33)
  at com.tencent.moai.crashcatcher.demo.MainActivity$4.onClick(MainActivity.java:56)
  at android.view.View.performClick(View.java:4488)
  at android.view.View$PerformClick.run(View.java:18860)
  at android.os.Handler.handleCallback(Handler.java:808)
  at android.os.Handler.dispatchMessage(Handler.java:103)
  at android.os.Looper.loop(Looper.java:222)
  at android.app.ActivityThread.main(ActivityThread.java:5484)
  at java.lang.reflect.Method.invokeNative(Native Method)
  at java.lang.reflect.Method.invoke(Method.java:515)
  at com.android.internal.os.ZygoteInit$MethodAndArgsCaller.run(ZygoteInit.java:860)
  at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:676)
  at dalvik.system.NativeStart.main(Native Method)

在native层构造了一个Error传给java,所以在java层可以很轻松地根据堆栈进行业务上的处理。

public interface CrashHandleListener {
    @Keep
    void onCrash(int id, Error e);
}

参考

Linux虚拟地址空间布局

Android 平台 Native 代码的崩溃捕获机制及实现

Android Native Crash 收集

Android native 崩溃信息捕获实践

Android APP native 崩溃分析之令人困惑的 backtrace

https://linux.die.net/man/1/addr2line

介绍一种性能较好的 Android native unwind 技术

汇编基础知识

  • 伪处理程序中的堆栈从高地址增长到低地址。因此,push会导致堆栈指针的递减。pop会导致堆栈指针的增量。
  • 寄存器 sp(stack pointer) 用于指向堆栈。
  • 寄存器 fp(frame pointer) 用作帧指针。帧指针充当被调用函数和调用函数之间的锚。
  • 当调用一个函数时,该函数首先将 fp 的当前值保存在堆栈上。然后,它将 sp 寄存器的值保存在 fp 寄存器中。然后递减 sp 寄存器来为本地变量分配空间。
  • fp 寄存器用于访问本地变量和参数,局部变量位于帧指针的负偏移量处,传递给函数的参数位于帧指针的正偏移量。
  • 当函数返回时, fp 寄存器被复制到 sp 寄存器中,这将释放用于局部变量的堆栈,函数调用者的 fp 寄存器的值由pop从堆栈中恢复。
  1. pc and lr are related code registers. One is “Where you are”, the other is “Where you were”.
  2. sp and fp are related local data registers. One is “Where local data is”, the other is “Where the last local data is”.