Native Crash捕获原理与实践(一)

在Android平台,native crash一直是crash里的大头。native crash具有上下文不全、出错信息模糊、难以捕捉等特点,比java crash更难修复。

一、为什么会产生Native Crash?

常见导致Native Crash的原因有以下几种:

1. jni内部数组越界、缓冲区溢出、空指针、野指针等;

2. jni中多线程出现竞争,比如一个线程调用jni接口释放了内部一个指针,另一个线程调用另外一个jni接口还在使用这个指针;

3. Android ART发现或出现异常;

4. 其他framework、Kernel或硬件bug;

二、现有的方案

方案

优点

缺点

Google Breakpad权威,跨平台代码体量大
利用Logcat日志利用Android系统实现需要在crash时启动新进程过滤logcat日志,不可靠
coffeecatch实现简介,改动容易存在兼容性问题

Google breakpad是一个跨平台的崩溃转储和分析框架和工具集合,具有权威、跨平台等优点,其实现原理如下图所示。

Breakpad由三个主要组件:

  • client:以library的形式内置在你的应用中,当崩溃发生时写 minidump文件
  • symbol dumper:读取由编译器生成的调试信息(debugging information),并生成 symbol file
  • processor:读取 minidump文件 和 symbol file ,生成可读的c/c++ Stack trace.

由于google-breakpad是夸平台开源工具,体量较大,在其基础上生成的通用so和dmp日志也都较大,对于sdk大小有严格要求的APP,可能不是很方便。Logcat日志虽然是利用Android系统实现,但是需要在crash时启动新进程过滤logcat日志,而且不可靠。因此,下面介绍一种体量较小的基于c/c++异常信号处理的NativeCrash日志收集方法,其实现原理如下图所示。

三、信号机制

1、用户态和内核态

介绍信号机制之前,我们先来了解两个概念,即用户态和内核态。

用户态(User Mode):运行用户程序。

内核态(Kernel Mode):运行操作系统程序。

用户态和内核态是操作系统的两种CPU状态。

CPU状态之间的转换如下图所示。

2、信号机制

信号机制是进程之间相互传递消息的一种方法,信号全称为软中断信号。应用程序运行在用户态,当遇到系统调用、中断或异常的情况时,程序会进入内核态。信号涉及到了这两种状态之间的转换,其转换过程如下图所示。

(1) 信号的接收

接收信号的任务是由内核代理的,当内核接收到信号后,会将其放到对应进程的信号队列中,同时向进程发送一个中断,使其陷入内核态。注意,此时信号还只是在队列中,对进程来说暂时是不知道有信号到来的。

(2) 信号的检测

进程陷入内核态后,有两种场景会对信号进行检测:

  • 进程从内核态返回到用户态前进行信号检测

  • 进程在内核态中,从睡眠状态被唤醒的时候进行信号检测

当发现有新信号时,便会进入下一步,信号的处理。

(3) 信号的处理

信号处理函数是运行在用户态的,调用处理函数前,内核会将当前内核栈的内容备份拷贝到用户栈上,并且修改指令寄存器(eip)将其指向信号处理函数。

接下来进程返回到用户态中,执行相应的信号处理函数。

信号处理函数执行完成后,还需要返回内核态,检查是否还有其它信号未处理。如果所有信号都处理完成,就会将内核栈恢复(从用户栈的备份拷贝回来),同时恢复指令寄存器(eip)将其指向中断前的运行位置,最后回到用户态继续执行进程。

至此,一个完整的信号处理流程便结束了,如果同时有多个信号到达,上面的处理流程会在第2步和第3步骤间重复进行。

(4) 常见信号量类型

linux把这些中断处理,统一为信号量,可以注册信号量向量进行处理,常见的信号量类型如下所示。

信号量

描述

例子

SIGABRT6进程发现错误或者调用了abort()很多C语言的库函数中,如果发现异常会调用abort();如strlen
SIGBUS10,7不存在的物理地址,硬件有误更多的是因为硬件或者系统引起的
SIGFPE8浮点数运算错误如除0操作,余0,整型溢出
SIGILL4非法指令损坏的可执行文件或者代码区损坏
SIGSEGV11段地址错误空指针,访问不存在的地址空间,访问内核区,写只读空间,栈溢出,数组越界,野指针
SIGSTKFLT16//未使用
SIGPIPE13管道错误,往没有reader的管道中写Linux中的socket,如断掉了继续写。Signal(SIGPIPE,SIG_IGN)

四、Native Crash捕获与处理关键函数

1、信号处理函数

在Android平台,比较常用Native Crash信号处理函数是sa_sigaction,当然也可以使用默认信号处理函数sa_handler,这个根据自己的需求确定,sa_sigaction函数的代码结构如下所示。

void (*sa_sigaction)(const int code, siginfo_t *const si, void const sc)

sa_sigaction是个函数指针,具体还要自己实现,该函数有三个参数。

code:是指错误码,根据错误码去查表,就可以知道发生native crash的大致原因,code与native crash原因对应的表如下所示。

si:是指信号信息,该参数是一个结构体,其具体代码实现如下所示。

siginfo_t {
   int      si_signo;     /* Signal number 信号量 */
   int      si_errno;     /* An errno value */
   int      si_code;      /* Signal code 错误码 */
   }

sc:是uc_mcontext的结构体,是cpu相关的上下文,包括当前线程的寄存器信息和奔溃时的pc值。能够知道崩溃时的pc,就能知道崩溃时执行的是那条指令。不过这个结构体的定义是平台相关,不同平台、不同cpu架构中的定义都不一样:

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

2、信号处理函数注册函数

信号处理函数实现后,需要通过sigaction函数进行注册,sigaction函数代码结构如下所示。

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

该函数的作用是查询或设置信号处理方式,该函数有三个参数。

signum:信号编码,即要捕获的信号类型,可以是除SIGKILL及SIGSTOP外的任何一个特定有效的信号,如果为这两个信号定义自己的处理函数,将导致信号安装错误。

act:新的信号处理方式,该参数指向结构体sigaction的一个实例的指针,该实例指定了对特定信号的处理,如果设置为空,进程会执行默认处理,即使用sa_handler作为信号处理函数。

oldact:与act类似,如果不为NULL,则会输出先前信号的处理方式。

我们再来说说sigaction这个结构体,其代码如下。

struct sigaction {
    void (*sa_handler)(int);
    void (*sa_sigaction)(int, siginfo_t *, void *);
    sigset_t sa_mask;
    int sa_flags;
    void (*sa_restorer)(void);
}

sa_handler:代表新的信号处理函数。
sa_mask :用来设置在处理该信号时暂时将sa_mask 指定的信号集搁置。
sa_restorer :此参数没有使用。
sa_flags :用于指定信号处理的行为,它可以是一下值的“按位或”组合,如代码中的SA_SIGINFO标记是指系统将使用 sa_sigaction 函数作为信号处理函数,如果不指定该标记则使用 sa_handler 作为信号处理。此外,除了可以设置SA_SIGINFO标记之外,还可以设置其他标记,如:

SA_RESETHAND:当调用信号处理函数时,将信号的处理函数重置为缺省值SIG_DFL。
SA_RESTART:如果信号中断了进程的某个系统调用,则系统自动启动该系统调用。
SA_NODEFER :一般情况下, 当信号处理函数运行时,内核将阻塞该给定信号。但是如果设置了 SA_NODEFER标记, 那么在该信号处理函数运行时,内核将不会阻塞该信号。

3、处理栈溢出函数

Native crash 中有一种是堆栈溢出错误。调用函数时会将被调用函数入栈,并保存该函数中的局部变量等信息。当栈满了(太多次递归,栈上太多对象)时,系统会在同一个已经满了的栈上调用SIGSEGV的信号处理函数,又再一次引起同样的信号。

sigaltstack函数允许进程创建一个备用的栈,供信号处理函数使用,代码结构如下所示。

int sigaltstack(const stack_t *ss, stack_t *oss);

该函数两个个参数为均为stack_t类型的结构体,其代码如下。

typedef struct sigaltstack {
 void __user *ss_sp;
 int ss_flags;
 size_t ss_size;
} stack_t;

要想创建一个新的可替换信号栈,ss_flags必须设置为0,ss_sp和ss_size分别指明可替换信号栈的起始地址和栈大小。

sigaltstack第一个参数为创建的新的可替换信号栈,第二个参数可以设置为NULL,如果不为NULL的话,将会将旧的可替换信号栈的信息保存在里面。函数成功返回0,失败返回-1.

使用可替换信号栈的步骤如下:

  • 在内存中分配一块区域作为可替换信号栈
  • 使用sinalstack()函数通知系统 存在一个可以替换的信号栈。
  • 使用sigaction()函数建立信号处理函数的时候,通过将sa_flags设置为SA_ONSTACK来告诉系统信号处理函数将在可替换信号栈上面运行。

4、获取Native Crash堆栈的原理与函数

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

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

上图中Random stack offset和Random mmap offset等随机值意在防止恶意程序。Linux通过对栈、内存映射段、堆的起始地址加上随机偏移量来打乱布局,以免恶意程序通过计算访问栈、库函数等地址。

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

(2) 获取Native Crash堆栈的原理

我们获取了奔溃时的pc值和各个寄存器的内容,通过SP和FP所限定的stack frame,就可以得到母函数的SP和FP,从而得到母函数的stack frame(PC,LR,SP,FP会在函数调用的第一时间压栈),以此追溯,即可得到所有函数的调用顺序。x86_64和arm64的栈帧结构与堆栈回溯原理如下图所示。

  • x86_64的frame point模式

  • arm64的frame point模式

    (3) 获取Native Crash堆栈的函数

  • 在4.1.1以上,5.0以下:使用安卓系统自带的libcorkscrew.so,其主要获取堆栈函数为unwind_backtrace_signal_arch函数,其代码结构如下所示:
  • ssize_t (*t_unwind_backtrace_signal_arch)(siginfo_t* si, void* sc, const map_info_t* lst, backtrace_frame_t* bt, size_t ignore_depth, size_t max_depth);
    

    由于现在很少有5.0以下的手机,因此,此处不再详细介绍该函数。

  • 5.0以上:可以使用自己编译的libunwind,也可以使用系统提供的_Unwind_Backtrace函数,该函数代码结构如下所示。
  • #include <unwind.h>
    _Unwind_Reason_Code _Unwind_Backtrace(_Unwind_Trace_Fn, void *);

    该函数是Android系统函数,那么我们怎么使用它呢?我们知道,要想了解和使用一个系统函数,最好的方法就是阅读它的源码,该函数的实现源码如下所示。

  • PROTECTED _Unwind_Reason_Code _Unwind_Backtrace (_Unwind_Trace_Fn trace, void *trace_parameter) {
     
      struct _Unwind_Context context;
     
      unw_context_t uc;
     
      int ret;
     
     
      if (_Unwind_InitContext (&context, &uc) < 0)
     
        return _URC_FATAL_PHASE1_ERROR;
     
     
      /* Phase 1 (search phase) */
     
     
      while (1)
     
        {
     
          //逐步解栈帧
          if ((ret = unw_step (&context.cursor)) <= 0)
     
        {
     
          if (ret == 0)
     
            return _URC_END_OF_STACK;
     
          else
     
            return _URC_FATAL_PHASE1_ERROR;
     
        }
     
          //调用回调函数
          if ((*trace) (&context, trace_parameter) != _URC_NO_REASON)
     
          return _URC_FATAL_PHASE1_ERROR;
     
        }
    }

    从源码中我们可以看出,该函数有两个参数,第一个是回调函数,需要自己定义。系统每解一个栈帧信息,就会回调一次该函数,因此,我们可以在该回调函数中获取我们需要的Native Crash信息。第二个参数是传到回调函数中的参数,也需要自己定义。

    5、获取共享库加载信息函数。

    dladdr函数可以获取共享库加载到内存中的信息,其代码结构如下所示。

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

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

    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;

    这个结构体对我们获取native崩溃信息非常重要,下面具体说一下每个元素代表的意思。

    dli_fname:是指共享库加载到内存的路径。
    dli_fbase:是指共享库加载到内存的起始位置,其与相对偏移地址和pc值的对应关系为:offset = pc - dli_fbase。
    dli_sname:是指离崩溃地址最近的符号名。我们通过该函数可以获取到离native崩溃最近的函数名。
    dli_saddr:是指在dli_sname中获取符号名的其他信息。

    为了方便大家理解,下面我们通过实际获取的崩溃栈信息解释一下上述元素的意义。

五、总结

本文主要介绍了Native Crash的原因、获取Native Crash的现有方案、信号机制以及需要用到的主要函数。当然,捕获Native Crash的原理非常复杂,上述只是简单的介绍了一些内容,后续还会不断进行补充和完善。下一篇文章将会对Native Crash捕获代码的具体实现进行详细的阐述,敬请期待。

参考文档:

  1. https://rtoax.blog.csdn.net/article/details/110846509
  2. https://mp.weixin.qq.com/s/7pJCplBgxf9H5SvjbBpgKA
  3. https://mp.weixin.qq.com/s/g-WzYF3wWAljok1XjPoo7w?
  • 0
    点赞
  • 13
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值