一文读懂崩溃原理

4,940 阅读13分钟

debug能力是程序员重要能力,也能真实反映程序员的水平,也是晋升重要的能力!尤其崩溃排查分析解决能力!这节课将透过现象看透崩溃的本质!

崩溃长什么样子

比如断点调试崩溃:

App Store上应用崩溃,可通过Xcode中的Oragnizer来获取,如下图:

或者直接连接崩溃的设备通过Xcode->Device->View Device Logs来获取,如下图:

对于Mac App可通过控制台中的”崩溃报告“来获取,如下图:

还可以通过第三方崩溃平台来获取,比如bugly、友盟、KSCrash等,如下图:

崩溃为什么会发生

那崩溃发生有哪些具体原因:

  • cpu无法执行的代码

    比如无效指令或操作、访问无效地址及不具有权限的内存地址、除以0等;

    有可能是苹果bug,会产生非法指令错误,如下图所示:

    僵尸对象,如下图:

    比如64位系统,访问0~4GB地址空间会报无效地址,就会报段错误Segmentation fault,如下图:

64位系统对应的__PAGEZERO段地址空间为0~4GB,在这个范围内所有访问权限-读、写和执行-都被撤销,因此若访问该地址就会引发MMU的硬件页错误,进而产生一个异常。

代码如下:

小技巧:那如何调试过程中直接让应用崩溃呢?是因为本身工程默认开启了Debug excutable选项,具体选项如图: 关闭该选项就可以直接让应用崩溃产生崩溃日志报告。

除以0,就会导致算术异常,导致EXC_ARITHMETIC错误,如下图:

  • 被系统强杀

    • 应用内存消耗过高OOM

    • 主线程长时间无法响应ANR

      为了防止一个应用占用过多的系统资源,开发iOS的苹果工程师门设计了一个“看门狗”Watchdog的机制。在不同的场景下,“看门狗”会监测应用的性能。如果超出了该场景所规定的运行时间,“看门狗”就会强制终结这个应用的进程。开发者们在crashlog里面,会看到诸如0x8badf00d这样的错误代码。

      Watchdog机制是iOS为了保持用户界面的响应引入的一种机制。如果我们的应用未能及时的响应一些用户界面事件,如启动、暂停、恢复和终止,Watchdog就会杀死程序并生成一个Watchdog超时崩溃报告。Watchdog超时时间并没有明文规定,但通常会少于网络超时。

      比如App应用启动时同步请求启动配置数据,如在网络差的情况下就会导致被Watchdog强杀,需要在真机上运行应用且不能xcode调试模式;

    • 资源异常

      • 线程频繁唤醒

        Wakeups是“资源异常”下的一个子类,指的是频繁唤醒线程,消耗cpu资源并增加功耗,在超过阈值并处于FATAL CONDITION的条件下触发崩溃;如果300秒内的总wakeup数超过45000(300 * 150)就会被判定为超出阈值。

      • 进程中的线程过多的占用了cpu,限制为 50%,时间不超过 180秒

      • 线程短时间过多的磁盘写入

    • 死锁

      比如互斥锁同一线程多次上锁就会导致死锁

      NSLock *_lock = [[NSLock alloc]init];
      [_lock lock];
      [_lock lock];//多次上锁
      
    • 非法的应用签名

    • 后台执行超时

      App退至后台后若执行时间过长就会导致被系统被杀,比如Backgroud Task方式可以在后台执行3min,若超过3min还未运行完成就会被系统强杀,实例代码:

      - (void)applicationDidEnterBackground:(UIApplication *)application {
          //通过backgroud task方法延长后台时间 这个方法必须与endBackgroundTask一一对象
          UIBackgroundTaskIdentifier _backgroundTaskIdentifier = [application beginBackgroundTaskWithExpirationHandler:^{
          }];
          
          //此处为执行任务代码 通常用来保存应用程序关键数据数据
          //执行关键任务
          
          //当任务执行完成时 调用endBackgroundTask方法 调用后就会将app挂起
          //如果在最后时间到了之前仍然没有调用endBackgroundTask方法 就会执行此回调
          //通常时间为3分钟,如果时间到期之前调用endBackgroundTask方法 就会强制杀掉进程,就会造成崩溃
          [application endBackgroundTask:_backgroundTaskIdentifier];
      }
      

    • 设备总内存紧张

      因为Mac平台存在内存交换机制,而iOS平台没有,就导致整个设备内存吃紧的时候,系统就会杀掉优先级不高且占用内存多大的应用;

    • 设备过热

      一般见于低端设备

  • 语言触发异常

    • OC语言抛出异常

      具体的异常如下:

      常见的如下:

      NSArray越界访问产生的异常NSRangeException,调试下会看到如下消息:

      *** Terminating app due to uncaught exception 'NSRangeException', reason: '*** -[__NSSingleObjectArrayI objectAtIndex:]: index 3 beyond bounds [0 .. 0]'
      

      未找到的方法或者NSDictionary添加nil对象等导致的非法参数异常NSInvalidArgumentException,如:

      *** Terminating app due to uncaught exception 'NSInvalidArgumentException', reason: '-[ViewController hello]: unrecognized selector sent to instance 0x7fc65b604e30'
      

      kvc未找到相对应的key抛出NSUnknownKeyException,结果如下:

      *** Terminating app due to uncaught exception 'NSUnknownKeyException', reason: '[<ViewController 0x7fd3f6806450> setValue:forUndefinedKey:]: this class is not key value coding-compliant for the key hello.'
      
    • C++抛出异常 语言异常抛出后最后都会调用到abort来终止应用,调用栈如下图所示:

  • 开发者触发 比如断言,NSAssert或者assert函数;

崩溃如何发生

说了这么多场景,那崩溃时如何发生的呢?其底层机制是如何运作的呢?只有你理解了崩溃的机制才能更有效的防护及排查!接下来给大家一一剖析!首先,要明白“中断”是什么?

中断

中断是重要的异步处理事件机制,否则对于外设需要通过轮询来确认外设的事件,太浪费cpu。中断的引入让外设能够“主动”通知操作系统,及打断操作系统和应用的正常执行,让操作系统完成外设的相关处理,然后在恢复操作系统和应用的正常执行,同时也是实现进程/线程抢占式调度的一个重要基石。

(不管是用户态还是内核态)程序运行时,若中断发生就会打断现在的程序,进行上下文切换转入内核态并进入中断服务程序,中断服务程序执行完成后恢复被打断的程序继续执行,如下图所示:

中断的类型及中断服务程序由“中断向量表”来决定,在系统启动时由内核负责加载这个向量。中断类型又分为:

  • 外部中断

由CPU外部设备引起的外部事件如I/O中断、时钟中断等是异步产生的(即产生的时刻不确定),与CPU的执行无关,我们称之为异步中断(asynchronous interrupt)也称外部中断,简称中断。(interrupt)

  • 异常

    此异常非语言中的异常,虽然语言异常会转化称为中断异常;

    把在CPU执行指令期间检测到不正常的或非法的条件(如除零错、地址访问越界)所引起的内部事件称作同步中断(synchronous interrupt),也称内部中断,简称异常(exception)

    Intel架构中,中断向量表中前20个单元定义为异常,具体如下:

    从表中可以看出,异常有以下类型:

    • 错误(fault)

      指令遇到一个可以纠正的异常,并且处理器可以重新启动这条出现异常的指令,这种异常称为错误,比如“页错误”。

    • 陷阱(trap)

      类似于错误,但是错误处理完成后返回发生陷阱指令之后的那条指令。

    • 中止(abort)

      不可重启指令,比如上图中的#8同一条指令发生两次错误。

  • 系统调用

    把在程序中使用请求系统服务的系统调用而引发的事件,称作陷入中断(trap interrupt),也称软中断(soft interrupt),**系统调用(system call)**简称trap。

    比如write函数;

XNU的中断通常为“陷阱”,这不同于异常中的“异常”;

自愿的内核转换,包括异常、系统调用,intel架构可以通过INT指令触发,在INT指令的参数传入异常的编号即可(64位架构使用SYSCALL指令),arm架构通过SVC/SWI指令来触发。比如INT 3指令(是一条单独的指令),可以来触发断点

一张图就可以了解整体过程,如下图所示:

摘自清华大学《操作系统学习》课程

那中断中的异常和崩溃有何关系?

程序的崩溃都会转换为异常被cpu通过中断向量表指定的异常类型捕获,进而触发异常处理程序处理,比如cpu无效指令、无效的地址或者无权限的访问,这些都是硬件产生的异常;被系统强杀的崩溃最终会调用到kill函数发送SIGKILL信号进而引发应用被强杀;语言及开发者触发崩溃最终会通过abort函数毅然会最终调用到kill函数发送SIGABRT信号引发应用被杀,这都是软件引发的异常

有哪些异常?

那崩溃具体有哪些异常?下面带大家来一一探索,如下:

  • Mach异常

    这个后面跟大家详解,暂且不用管

  • BSD Signal信号

    信号是什么?信号是一种异步处理的软中断,内核会发送给进程某些异步事件,这些异步事件可能来自硬件,比如除0或者访问了非法地址;也可能来自其他进程或用户输入,比如ctrl+c,就会产生SIGINT信号由内核发送至当前终端执行进程,若进程未处理该信号,就会导致进程退出;ctrl+\就会产生SIGQUIT退出信号来终止进程执行,并且会产生崩溃日志报告,如下图所示:

    具体的demo如下:

    #include <stdio.h>
    #include <signal.h>
    #include <errno.h>
    #include <string.h>
    
    #define BUF_LEN 1024
    //信号处理函数
    static void sig_int(int signo) {
        printf("handler signo:%d \n", signo);
    }
    
    int main(int argc, char **argv) {
        char buf[BUF_LEN] = {0};
        //处理信号,SIGINT默认终止进程
        if (signal(SIGINT, sig_int) == SIG_ERR) {
            printf("signal error:%s \n", strerror(errno));
        }
        
        //接收终端输入并打印输出
        while (fgets(buf, sizeof(buf), stdin) != NULL) {
            if (fputs(buf, stdout) == EOF) {
                printf("fputs error:%s \n", strerror(errno));
                return 1;
            }
        }
        
        return 0;
    }
    
  • 语言异常,OC/C++

    前面已提到语言异常最终会转化为信号SIGABRT进而引发应用终止;

  • 用户抛出的异常

    比如前面提到的断言也会调用abort函数转化为SIGABRT异常终止信号,该信号默认是终止进程并生成崩溃日志报告,如下图:

那最终不管是硬件异常还是语言异常及用户抛出异常都会产生信号,那信号与Mach异常有何关系?首先来带大家了解下什么是Mach异常。

Mach异常是什么?

从名字来看就包含了Mach和“异常”,那Mach是什么?大家有没有这样的疑问,我这里给大家讲解一下iOS/Mac操作系统框架,如下图所示:

整个操作系统核心包括了Mach微内核、BSD层(大家熟悉的POSIX接口就在这一层)、I/O Kit设备驱动框架以及核心库,其中Mach微内核负责进程和线程抽象、虚拟内存管理、任务管理以及进程间通信和消息传递机制,所以Mach微内核就是整个操作系统的核心。

鸿蒙系统也是基于微内核

与大家熟知的Runloop中的Mach port消息就是基于Mach微内核的消息机制的体现,那Mach异常是什么呢?如下图所示:

RunLoop 的核心就是一个 mach_msg() ,RunLoop 调用这个函数去接收消息,如果没有别人发送 port 消息过来,内核会将线程置于等待状态。例如你在模拟器里跑起一个 iOS 的 App,然后在 App 静止时点击暂停,你会看到主线程调用栈是停留在 mach_msg_trap() 这个地方。

Mach异常是在已有的消息传递架构上实现的一种独有的异常处理方法,是一种轻量级的架构

听起来很抽象,其实很简单,一句话概括就是整个的异常机制是构建在Mach异常之上的,所有的硬件/软件异常都会首先转换为Mach异常,进而转换为信号。

我们看到的崩溃日志报告中的异常错误码是不是包含了EXC_xxx,这里对应的就是Mach异常,如下图:

Mach异常是如何转化为BSD信号的呢?

Mach异常与信号转换机制

一张图概述:

流程主要分三个步骤:

  • 异常封装、转换、发送

    • 异常(比如硬件错误、软件异常)发生时,就会由内核异常处理程序同步接收;
    • 内核异常处理程序不会针对不同异常单独处理,而是统一通过exception_triage来处理,该函数会将异常信息(比如所在任务、线程、异常类型等)转化为Mach消息;
    • 再调用exception_deliver函数封装封装异常端口(包括线程、任务异常端口)、异常、异常错误码、线程寄存器状态等信息;
    • 再调用mach_exception_raise函数,该函数会通过mach_msg发送mach异常消息到异常端口;
  • 异常消息接收处理

    • 内核启动创建第一个用户态进程launchd的同时,会将进程的Mach异常消息重定向到异常端口

      因用户态进程都是通过launchd进程fork克隆出来,同时也随之继承了异常端口;

    • 调用ux_handler_init来创建一个内核线程开启ux_hanlder异常处理函数;

    • ux_handler函数会开启消息循环来接收异常线程的Mach异常消息;

  • 异常消息转换为BSD信号

    • 内核线程循环接收异常消息,当接收到异常消息后,会调用mach_exc_server函数;
    • 该函数会调用catch_mach_exception_raise函数来捕获异常消息,同时会调用ux_exception函数将异常消息转换为BSD信号;
    • 通过threadsignal函数来抛出信号;

其中关键点就是Mach异常消息通过消息发送的形式,发送到指定的异常端口,而该异常端口被内核线程持有,进而接收异常消息并转换为BSD信号。

信号处理

那最终信号如何处理呢?

上面我们讲到用户态进程若指定了信号处理函数(比如SIGINT)则可以自己来处理,若未指定呢?比较有意思的地方开始了,内核发现信号未存在异常处理函数,就会将其抛给崩溃报告守护进程ReportCrash,这里可以查看Mac的进程就会发现该进程,如下: 该进程负责来获取异常消息及信号信息来生成崩溃日志报告。那问题来了,既然异常消息是通过Mach消息的形式发送出去的,那我是不是可以截获这个消息呢? 答案是肯定的!具体就是来注册自己的异常端口来截获异常消息,具体见demo