深入浅出扒一扒Android Crash的全过程

本文详细的描述了崩溃发生时android系统的处理过程和crash捕获、堆栈还原的基本原理。
| 导语 大家都知道C/C++程序出错会引起崩溃,但是知道崩溃发生时系统处理的整个过程的人并不多,深度了解crash捕获原理的人估计就更加少了。本文详细的描述了崩溃发生时android系统的处理过程和crash捕获、堆栈还原的基本原理。

1 哪些情况会让程序崩溃?

在Unix-like系统中,所有的崩溃都是编程错误或者硬件错误相关的,系统遇到不可恢复的错误时会触发崩溃机制让程序退出,如除零、段地址错误等。
异常发生时,CPU通过异常中断的方式,触发异常处理流程。不同的处理器,有不同的异常中断类型和中断处理方式。linux把这些中断处理,统一为信号量,可以注册信号量向量进行处理。

1.1 ARM处理器空指针

常见的空指针奔溃时,我们看看ARM平台下的linux是如何处理的。ARM支持7种异常。空指针属于数据中止异常,尝试读写一个非法内存地址时会发生。

step 1:中断向量调用do_kernel_fault
空指针在ARM平台上属于data abort异常。当中断发生时,会在中断模式下,保存现场信息,设置相关标志位。然后进入SVC模式,根据中断向量表跳转至指定的异常处理函数。内核处理数据中止的模块为
dabt_svc,调用do_DataAbort,do_DataAbort调用过程不细表。
Linux根据data abort最后调用的链为arch/arm/mm/fault.c中do_DataAbort -> do_translation_fault -> do_page_fault -> __do_kernel_fault

step 2:do_kernel_fault及其调用

arm/mm/fault.c中会尝试进行异常恢复,如果无法恢复show_pte打印page table相关的信息,然后会调用arch/arm/kernel/traps.c中的die()。如果不是用户模式下的,则报告内核的一个bug,然后调用die。__die会打印基本的错误信息,然后将信号量设置为SIGSEGV,调用kernel/notifier.c中的notify_die,调用die_chain上注册的回调函数。

1.2 软件异常中断

arm/mm/fault.c中的arm_syscall,如果传入的no未0,同样也会抛出SIGSEGV错误。

asmlinkage int arm_syscall(int no, struct pt_regs *regs)
{
    struct thread_info *thread = current_thread_info();
    siginfo_t info;
    if ((no >> 16) != (__ARM_NR_BASE>> 16))
        return bad_syscall(no, regs);
    switch (no & 0xffff) {
    case 0: /* branch through 0 */
        info.si_signo = SIGSEGV;
        info.si_errno = 0;
        info.si_code  = SEGV_MAPERR;
        info.si_addr  = NULL;
        arm_notify_die("branch through zero", regs, &info, 0, 0);
        return 0;

        ......
}

void arm_notify_die(const char *str, struct pt_regs *regs,
        struct siginfo *info, unsigned long err, unsigned long trap)
{
    if (user_mode(regs)) {
        current->thread.error_code = err;
        current->thread.trap_no = trap;
        force_sig_info(info->si_signo, info, current);
    } else {
        die(str, regs, err);
    }
}

1.3 哪些信号量会让程序crash

debuggerd.c是Android自带的的程序异常退出诊断的程序,在侦测到程序奔溃时将进程状态输出,供开发人员使用。Android的连接程序linker会通过debugger_init()注册信号处理函数。

void debugger_init()
{
    struct sigaction act;
    memset(&act, 0, sizeof(act));
    act.sa_sigaction = debugger_signal_handler;
    act.sa_flags = SA_RESTART | SA_SIGINFO;
    sigemptyset(&act.sa_mask);
    sigaction(SIGILL, &act, NULL);
    sigaction(SIGABRT, &act, NULL);
    sigaction(SIGBUS, &act, NULL);
    sigaction(SIGFPE, &act, NULL);
    sigaction(SIGSEGV, &act, NULL);
#if defined(SIGSTKFLT)
    sigaction(SIGSTKFLT, &act, NULL);
#endif
    sigaction(SIGPIPE, &act, NULL);
}

Google的Breakpad认为,不认为只有Action为Core(终结程序并输出dump)的是崩溃的信号量,SIGPIPE是Term(只终结程序),所以不认为是crash信号。其实对用户来说,这个就是crash,应该捕获。

2 如何捕获异常

2.1 sigaction信号注册函数

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

signum:可以是除了SIGKILL和SIGSTOP外的所有信号量
act:act如果不是null,则设置当前为新的信号处理函数。如果oldact是非Null则保存老的处理函数到该指针。

struct sigaction {
    //sa_handler和sa_sigaction只能存一,sa_flags设置为SA_SIGINFO则为后者
        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_handler可以注册默认的SIG_DFL或者SIG_IGN忽略,也可以注册只有一个signum为参数的处理函数。
sa_mask:设置屏蔽信号量,该信号处理函数时,其他信号量应该被屏蔽。如果SA_NODEFER被设置则无效。
sa_flags:修改信号量的处理行为。
SA_ONSTACK:在sigalstack(2)提供的栈上面运行。如果被选的栈不可用,则在默认的栈上运行。该参数只有在建立信号量处理函数时有用。
SA_SIGINFO:使用sa_sigaction而不是,sa_handler。该函数可以设置和查询指定信号量的处理函数。sigaction(SIGSEGV, NULL, &older_handler_tmp)可以获取到老的信号处理函数,sigaction(SIGSEGV, &new_handler, NULL)注册new_handler为SIGSEGV函数。

2.2 如何注册信号处理函数

2.2.1 设置额外栈空间

我们在前面提到SIGSEGV很有可能是栈溢出引起的,如果在默认的栈上运行很有可能会破坏程序运行的现场,无法获取到正确的上下文。因此,我们应该开辟一块新的空间作为运行信号处理函数的栈。只要在注册时,将sa_flags设置为SA_ONSTACK即可。
创建时,可以先查看是否已经有创建sigal栈空间。如果没有,或者创建的大小太小,则需要创建一块足够大的栈空间。

stack_t old_stack,new_stack;
if (sigaltstack(NULL, &old_stack) == -1 || !old_stack.ss_sp ||
      old_stack.ss_size < SIGSTKSZ) {
    new_stack.ss_sp = calloc(1, SIGSTKSZ);
    new_stack.ss_size = SIGSTKSZ;

    if (sys_sigaltstack(&new_stack, NULL) == -1) {
      free(new_stack.ss_sp);
      return;
    }
  }

2.2.2 注册信号处理函数

void SignalHandler(int sig, siginfo_t* info, void* uc){
    ......
}

struct sigaction sa;
memset(&sa, 0, sizeof(sa));
sigemptyset(&sa.sa_mask);
sigaddset(&sa.sa_mask,[signal_value]);//将所有注册的信号均加入信号集,不然sigaction()中的参数内容可能是未定义的。
sa.sa_sigaction = SignalHandler;
sa.sa_flags = SA_ONSTACK | SA_SIGINFO;//使用sa_sigaction和其他栈上运行
sigaction([signal_value], &sa, NULL);

2.2.2.1 兼容其他signal处理

sigaction一个信号量只能注册一个处理函数,这意味着我们的处理函数会覆盖其他人的处理信号。集成我们的异常检测程序的同时,程序也可能会集成其他应用的异常检测程序。
在前面,我们提到sigaction既可以注册也可以获取到原有的处理函数。如果,我们保存老的处理函数,在处理完我们的信号处理函数后,在重新运行老的处理函数就能完成兼容。
因为,我们的处理函数可能会被覆盖掉。所以,我们需要允许我们的程序多次注册而不会出错。其实,检测到老的处理函数就是自己注册的处理函数时,返回不做处理即可。

struct sigaction older_handler_tmp;
if (sigaction([signal_value], NULL, &older_handler_tmp) == -1){
    return false;
}

//现在处理程序为自己定义的函数,则无需进一步处理

if(older_handler_tmp.sa_sigaction==SignalHandler){
    LOG_DEBUG("Wetest CrashMonitor has registered");
    return false;
} else{
    sigaction([signal_value], NULL, &old_handlers);
}

//注册自己的处理函数
在我们自己的信号处理函数中,调用玩处理后,我们需要重新注册回老的处理函数。信号量将会重新触发老的处理函数。
if (sigaction([signal_value], &old_handlers, NULL) == -1) {
    //注册出错,则尝试注册系统默认的处理函数。
}

这样,我们就完成了插入,我们自己的crash又不影响到其他crash检测模块的运行。这里有一个巨大的风险,就是当捕获到crash的时候,你的crash捕获模块不会退出,是否退出完全交给上一个注册的该信号量的函数。

3 如何还原崩溃现场

在崩溃的瞬间,如果能够提供崩溃的原因、崩溃点(崩溃的代码所在行)、调用栈及崩溃点附近的变量这是最理想的,这样开发人员就能在最短的时间内定位到问题。在这方面Java、C#等语言,相对处理的比较好,但是C和C++在这方面表现较差。

3.1 获取崩溃原因

崩溃时注册的信号量处理函数sa_sigaction会被调用,void (sa_sigaction)(int, siginfo_t , void *)。这个函数调用中分别提供信号量、siginfo_t结构体和一个指针。

siginfo_tsiginfo_t {
  int      si_signo;     /* Signal number */
  int      si_errno;    /* An errno value,Linux中通常无用*/
  int      si_code;      /* Signal code */ 文档查看具体原因,如SEVG_MAPPERR.如果是<0的表示内核,
……
}

不同的信号量,对应不同的错误码,每个错误码均有自己的含义。

根据这些信息,大致能够提供崩溃的错误原因。Fatal signal 11 (SIGSEGV) code 1 (SEGV_MAPERR)这种类型的日志,也是我们最为常见的。

3.2 获取崩溃所在行

如果,我们能够知道崩溃时的pc,就能知道崩溃时执行的是那条指令。sa_sigaction在调用时,还会提供第三个指针参数,ucontext_t。在官方文档中,说这是一个很少使用到的参数,这里却恰巧提供了我们所需的内容。
在<ucontext.h>中定义了两个类型mcontext_t和ucontext_t。mcontext_t是与机器相关的,并且不透明的。ucontext_t类型至少包含以下信息。

typedef struct ucontext {
               struct ucontext *uc_link;
               sigset_t         uc_sigmask;
               stack_t          uc_stack;
               mcontext_t       uc_mcontext;
               ...
} ucontext_t;

sigset_t和stack_t定义在<signal.h>。uc_link指向另一个ucontext_t,当前上下文被毁时,可以恢复。uc_sigmask是信号量屏蔽的集合。uc_stack是当前使用的栈地址。uc_mcontext是cpu相关的上下文,包括当前线程的寄存器信息。uc_context如果是信号量获取到的,获取后程序是处于中断状态,还是继续执行并没有详细描述(因此,crash进程的现场可能是会被破坏掉的)。

3.2.1 ARM寄存器

如果是手机上市ARM的CPU,那么uc_context保存即为ARM相关的内容。
ARM处理器包含31个通用寄存器,包括程序计数器pc。ARM处理器在7中模式(用户模式,系统模式,特权模式,中止模式,未定义指令,外部中断模式,快速中断模式)。
未备份(R0-R7):所有模式公用。异常中断切换时,现场会被破坏。
备份(R8-R14):两个寄存器,FIQ使用独有寄存器。(FIQ,快速中断,比如说数据读取)。R9,称之为SB栈底指针;R10,称之为SL栈限制;R11,称之为FP栈帧指针;R12,称之为IP内部过程调用寄存器;R13,通常称之为堆栈指针(SP),会指向当前模式独有的堆栈。异常模式时,可以把通用寄存器压入堆栈,返回时出栈,保证完成性。R14,称之为连接寄存器,保存子程序返回地址。异常模式时,可以保存异常返回地址,如处理嵌套中断。
程序计数器pc:PC寄存器,总是存储下一条指定的地址。
程序状态寄存器(CPSR):保存当前程序状态寄存器。最后5位,状态。

3.2.2 进程内存获取代码奔溃处

通过寄存器中的PC程序计数器我们就可以知道,程序崩溃时执行的指令地址。不过,这个地址并不是我们能够阅读的。我们需要通过进程内存解析出,我们能够阅读的信息。
任何一个程序通常都包括代码段和数据段,这些代码和数据本身都是静态的。程序要想运行,首先要由操作系统负责为其创建进程,并在进程的虚拟地址空间中为其代码段和数据段建立映射。光有代码段和数据段是不够的,进程在运行过程中还要有其动态环境,其中最重要的就是堆栈。图3所示为Linux下进程的地址空间布局:

其中,用户地址空间中的蓝色条带对应于映射到物理内存的不同内存段,灰白区域表示未映射的部分。这些段只是简单的内存地址范围,与处理器的段没有关系。
上图中Random stack offset和Random mmap offset等随机值意在防止恶意程序。Linux通过对栈、内存映射段、堆的起始地址加上随机偏移量来打乱布局,以免恶意程序通过计算访问栈、库函数等地址。execve(2)负责为进程代码段和数据段建立映射,真正将代码段和数据段的内容读入内存是由系统的缺页异常处理程序按需完成的。另外,execve(2)还会将BSS段清零。
用户进程部分分段存储内容如下表所示(按地址递减顺序):
在将应用程序加载到内存空间执行时,操作系统负责代码段、数据段和BSS段的加载,并在内存中为这些段分配空间。栈也由操作系统分配和管理;堆由程序员自己管理,即显式地申请和释放空间。
BSS段、数据段和代码段是可执行程序编译时的分段,运行时还需要栈和堆。
PC寄存器的内容必然是指向代码段(libc.so在映射区)的,获取进程的虚拟地址空间后,我们就能够解析出代码段所对应的库或者是程序本身(比如,libc.so)。
Linux程序会把对应进程的虚拟地址空间结构以文件的方式保存在/proc/[pid]/maps下面。
该文件有6列,分别为
地址:库在进程里地址范围
权限:虚拟内存的权限,r=读,w=写,x=,s=共享,p=私有;
偏移量:库在.so文件里的地址范围
设备:映像文件的主设备号和次设备号;
节点:映像文件的节点号;
路径: 映像文件的路径(如lib/libc.so)。
各共享库的代码段,存放着二进制可执行的机器指令,是由kernel把该库ELF文件的代码段map到虚存空间;
各共享库的数据段,存放着程序执行所需的全局变量,是由kernel把ELF文件的数据段map到虚存空间;
用户代码段,存放着二进制形式的可执行的机器指令,是由kernel把ELF文件的代码段map到虚存空间;
用户数据段之上是代码段,存放着程序执行所需的全局变量,是由kernel把ELF文件的数据段map到虚存空间;
用户数据段之下是堆(heap),当且仅当malloc调用时存在,是由kernel把匿名内存map到虚存空间,堆则在程序中没有调用malloc的情况下不存在;
用户数据段之下是栈(stack),作为进程的临时数据区,是由kernel把匿名内存map到虚存空间,栈空间的增长方向是从高地址到低地址。
自己写了一个,会产生nullptr的代码,然后运行

char* str=0;
str[0]='1';

3.3 调用栈获取

通过解析lib*.so的异常处理机制,来完成堆栈的还原。

4 子进程输出堆栈

在crash的进程中输出堆栈信息,存在两个问题。1、crash进程的关键数据或堆栈可能已经遭到破坏,在当前线程中输出堆栈信息存在风险;2、signalaction函数里面使用的函数有非常大的限制。
Crash进程仅仅简单的创建子进程,子进程能够共享crash进程的地址空间、文件资源、信号处理函数等。通过ptrace获取crash进程信息。

4.1 clone

#include <sched.h>

 int clone(int (*fn)(void *), void *child_stack,int flags, void *arg, ...
/* pid_t *ptid, struct user_desc *tls, pid_t *ctid */ );

以类似于fork的方式创建一个新的进程。不同于fork,clone允许子进程共享父进程的部分上下文,如内存、文件描述符表、信号量处理表等。
其中一个应用是,多个线程跑在一个内存空间里面。
fn:子线程创建完成后,会执行fn函数。fn是在子进程执行前执行。当fn返回时,子进程将会终结。fn返回的int,是子进程的退出码。子进程也可以显式的调用exit或者处理错误信号量来退出。
child_stack:该参数用于设定子进程的堆栈。虽然,子进程可以与父进程共享内存,但是在共享同一个栈是不允许的。所以,必须要指定该栈区。
flags:中的低字节保存子进程结束码。如果信号不是SIGCHLD,那么父进程必须指定_WALL或者__WCLONE选项。如果,没有选项设置,则子进程退出时,父进程不会有任何信号量。
CLONE_FILFS:父子进程共享文件描述符列表。父进程或者子进程创建的文件描述符,两边都能使用。如果进程调用execve,描述符表将不再共享。
CLONE_FS:父子进程共享文件系统信息。共享的文件系统包括,现在的工作目录,权限。任何改变都会影响到其他进程。
CLONE_UNTRACED:如果该参数被指定,将不能再为CLONE_PTRACE。如果是CLONE_PTRACE,则父进程将被追踪,子进程也是。

4.2 子进程创建过程

创建子进程之后,需要在crash进程设置为允许ptrace,通过管道的方式模拟信号。创建子进程的时候必须要设置为CLONE_FILFS、CLONE_FS能够共享父进程的文件目录,这样才能获取到对应的.so共享文件。

5 参考

https://code.google.com/p/google-breakpad/wiki/ClientDesign
http://blog.csdn.net/zangdongming/article/details/38543059
http://read.pudn.com/downloads153/doc/672477/%E6%9D%A8%E5%AE%97%E5%BE%B7_%E5%B5%8C%E5%85%A5%E5%BC%8FARM%E7%B3%BB%E7%BB%9F%E5%8E%9F%E7%90%86%E4%B8%8E%E5%AE%9E%E4%BE%8B%E5%BC%80%E5%8F%91/CH06.pdf
https://android.googlesource.com/kernel/msm/+/3ab322a9e0a419e7f378770c9edebca17821bf6e/arch/arm/mm/fault.c
@126/blog/static/236614472009112693539253/"">http://blog.163.com/lhmood@126/blog/static/236614472009112693539253/
http://infocenter.arm.com/help/topic/com.arm.doc.ihi0042e/IHI0042E_aapcs.pdf
http://blog.csdn.net/skyflying2012/article/details/37510171

最新文章
1WeTest携PC&主机游戏质量保障服务和性能测试平台PerfDog亮相Gamescom 2024 以全场景游戏质量保障服务及性能测试解决方案,助力全球游戏行业的创新与发展
2一张图带你了解小程序隐私合规检测 快速了解小程序隐私合规检测如何防范黑灰产风险,守护用户数据安全
3防范小程序隐私合规风险,筑牢用户信任防线 了解隐私合规检测如何帮助小程序规避数据安全风险
4WeTest 海外测试需求有奖问卷活动中奖名单公布 近日,WeTest 海外测试需求有奖问卷活动圆满结束,经过紧张的统计与筛选,以下朋友们中奖,成功获得了我们的门票礼品。
5海外本地化测试的全生命周期服务 第三期 支付测试 海外支付风控升级,非本地测试封号现象频发,真金测试推进困难?来看WeTest的本地化支付测试方案
购买
客服
反馈