APM - iOS Crash监控 原理浅析

2,901 阅读11分钟

简介

一、什么是Crash?

对于用户来说直观的感觉是崩溃闪退。

Crash常见的原因有哪些?

  1. CPU无法执行的代码

  2. 语言层面抛出异常

  3. 被系统强杀

    1. 应用内存消耗过高OOM

    2. 主线程长时间无法响应ANR

    3. 资源异常

      1. 线程频繁唤醒
      2. 进程中的线程过多的占用了CPU,限制为50%,时间不超过180秒
      3. 线程短时间过多的磁盘写入
    4. 死锁

    5. 非法的应用签名

    6. 后台执行超时

    7. 设备总内存紧张

    8. 设备过热

  4. 开发者断言

如何分类?

一、系统层面

Mach异常

iOS基于ARM架构,分层如下: Darwin的内核是XNU,XNU是兼具宏内核和微内核特性的混合内核,架构分层如下:

Mach微内核负责进程和线程抽象、虚拟内存管理、任务管理以及进程间通信和消息传递机制。

Mach微内核的主要抽象

  • Tasks
  • Threads
  • Address space
  • Memory objects
  • IPC
  • Time

关于Mach port

Mach Port与UNIX单向管道类似,是由内核管理的消息队列。有多个发送方和一个接收方。

  • Port

  • Port权限,Task信息是系统资源的集合,也可以说是资源的所有权。这些Task允许您访问Port(发送,接收,发送一次),称为Port权限。(也就是说,Port权限是Mach的基本安全机制。)

    • 发送权限,不受限制地将数据插入到特定的消息队列中
    • 一次发送权限,将单个消息数据插入到特定的消息队列中
    • 接收权限,不受限制地从特定消息队列中提取数据
  • Port Set,一组有权限的端口,在接收来自某个成员的消息或事件时,可以将其视为单个单元。

  • Port Set权限

  • Message

中断

中断是一个硬件或者软件发出的请求,要求CPU暂停当前的工作去处理更加重要的事情。

比如你在输入的时候,不断地敲击键盘,CPU如何得知这一点的呢?

轮询(Poll)

CPU每隔一小段时间(几十或几百毫秒)去询问键盘是否有键按下,大部分轮询都会获得“没有键按下”的回应,这样操作就被浪费掉了。

中断(Interrupt)

还有一种方法是CPU不去理睬键盘,而当键盘上有键被按下时,键盘上的芯片发送一个信号给CPU,CPU收到信号之后,再去询问键盘被按下的是哪个键。

中断是重要的异步处理事件机制,否则对于外设需要通过轮询来确认外设的事件,造成CPU的浪费。中断的引入让外设能够“主动”通知操作系统,及打断操作系统和应用的正常执行,让操作系统完成外设的相关处理,然后再恢复操作系统和应用的正常执行,同时也是实现进程/线程抢占式调度的一个重要基石。(不管是用户态还是内核态)程序运行时,若中断发生就会打断现在的程序,进行上下文切换转入内核态并进入中断服务程序,中断服务程序执行完成后恢复被打断的程序继续执行。

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

  • 中断(Interrupt)
  • 陷阱(Trap)
  • 故障(Fault)
  • 终止(Abort)
类别原因异步/同步返回行为
中断来自I/O设备的信号异步总是返回到下一条指令
陷阱有意的异常同步总是返回到下一条指令
故障潜在可恢复的错误同步总是返回到下一条指令
终止不可恢复的错误同步不会返回

中断:是来自处理器外部的I/O异常信号导致的,不是由任何一条专门的指令造成的,从这个层面上来讲,它是异步的。硬件中断的处理程序通常被称为中断处理程序。比如:插拔U盘、键盘输入。


陷阱:是指有意的异常,是执行一条指令的结果。和中断一样,陷阱处理程序将控制返回到下一条指令。陷阱最终的用途,是在用户程序和内核之间提供一个像过程一样的接口,称为系统调用。比如用户程序经常需要向内核请求服务,比如读一个文件(read)、创建一个进程(fork)、加载一个新的程序(execve)或者终止当前进程(exit),这些操作都需要通过触发陷进异常,来执行系统内核的程序来实现。

故障:是由错误情况引起的,它可能被故障处理程序修正。当故障发生时,处理器将控制转移个故障处理程序。一般保护性故障,Unix不尝试修复,而是将这种保护性故障报告为段违规(Segmentation Violation)对应信号为SIGSEGV,然后终止程序。

终止:由不可恢复的致命错误造成的结果。如:奇偶校验错误。

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

中断分类

硬件异常

程序的崩溃都会转换为异常被CPU通过中断向量表指定的异常类型捕获,进而触发异常处理程序处理,比如CPU无效指令、无效的地址或者无权限的访问。被系统强杀的崩溃最终会调用到kill函数发送SIGKILL信号而引发应用被杀。

软件异常

语言及开发者触发崩溃最终会通过abort函数,依然会最终调用到kill函数发送SIGABRT信号,引发应用被杀。

整个异常机制是构建在Mach异常之上,所有的硬件/软件异常都会首先转换成Mach异常,进而转换为信号。

UNIX信号

Mach异常处理机制提供了底层的异常处理,为了兼容POSIX,Mach异常会被转换为UNIX信号。

  • SIGABRT,Signal Abort
  • SIGILL, Signal Illegal
  • SIGSEGV,Signal Segmentation Violation
  • SIGFPE,Signal Floating Point Exception
  • SIGINT,Signal Interrupt
  • SIGTERM,Signal Terminate

en.wikipedia.org/wiki/Signal…

Mach异常如何转换成UNIX信号

为什么捕捉Mach异常还需要捕捉UNIX信号

Mach异常和UNIX信号都可以被捕获,他们也几乎一一对应,但我们需要优先处理Mach异常,因为Mach异常更接近底层,而UNIX信号存在被Mach默认异常处理函数直接退出进程而无法生成的情况。

有一些异常如EXC_CRASH,在Mach异常的阶段没有捕捉,而是放到UNIX信号中捕捉,其中的解释在PLCrashReporter的注释中有详细的解释:

/* We still need to use signal handlers to catch SIGABRT in-process. The kernel sends an EXC_CRASH mach exception

* to denote SIGABRT termination. In that case, catching the Mach exception in-process leads to process deadlock

* in an uninterruptable wait. Thus, we fall back on BSD signal handlers for SIGABRT, and do not register for

* EXC_CRASH. */

二、语言层面

Objective-C语言抛出异常/NSException

Objective-C抛出的具体异常如下:

/***************        Generic Exception names                ***************/



FOUNDATION_EXPORT NSExceptionName const NSGenericException;

FOUNDATION_EXPORT NSExceptionName const NSRangeException;

FOUNDATION_EXPORT NSExceptionName const NSInvalidArgumentException;

FOUNDATION_EXPORT NSExceptionName const NSInternalInconsistencyException;



FOUNDATION_EXPORT NSExceptionName const NSMallocException;



FOUNDATION_EXPORT NSExceptionName const NSObjectInaccessibleException;

FOUNDATION_EXPORT NSExceptionName const NSObjectNotAvailableException;

FOUNDATION_EXPORT NSExceptionName const NSDestinationInvalidException;

   

FOUNDATION_EXPORT NSExceptionName const NSPortTimeoutException;

FOUNDATION_EXPORT NSExceptionName const NSInvalidSendPortException;

FOUNDATION_EXPORT NSExceptionName const NSInvalidReceivePortException;

FOUNDATION_EXPORT NSExceptionName const NSPortSendException;

FOUNDATION_EXPORT NSExceptionName const NSPortReceiveException;



FOUNDATION_EXPORT NSExceptionName const NSOldStyleException;



FOUNDATION_EXPORT NSExceptionName const NSInconsistentArchiveException;

其中NSRangeException,NSInvalidArgumentException等比较常见。

NSException/NSError的区别和Try Catch如何使用?

一般来说错误分为两种情况。

错误(error)

一种是可以预见的,可恢复。

异常(exception)

不可预见,不可恢复。

例如,文件无法打开是错误,数据访问越界是异常。

在Objective-C中,这两种错误分别以NSError和NSException表示。

Swift中把Objective-C中原本使用的NSError的代码标记成throws。原本会抛出异常的代码则使用fatalError直接崩溃。Swift中的try catch实际是使用Error protocol处理Error。

Objective-C中@try @catch是catch NSException的异常。不推荐这么做,因为Objective-C被设计为不能从异常中恢复,所以ARC默认不保证异常安全,抛出异常可能导致内存泄漏。务必只在必要的时候这样做。

C++抛出异常

iOS和OSX都会在CFRunloop中捕捉所有没有被捕捉的异常,包括C++异常。在OSX中,会通过对话框展示异常给用户,但是在iOS中,只是重新抛出异常。

为什么要捕捉C++异常?

系统在捕捉到C++异常后,如果能够将此C++异常转换成OC异常,则抛出OC异常处理机制。如果不能转换,则会立刻调用__cxa_throw重新抛出异常。

当系统在Runloop捕捉到C++异常时,此时的调用堆栈是异常发生时的堆栈,但当系统在不能转换为OC异常时调用__cxa_throw时,上层捕捉此再抛出的异常获取的调用堆栈是Runloop异常处理函数的堆栈,导致原始异常调用堆栈丢失。

Thread 0 Crashed:: Dispatch queue: com.apple.main-thread

0   libsystem_kernel.dylib          0x00007fff93ef8d46 __kill + 10

1   libsystem_c.dylib               0x00007fff89968df0 abort + 177

2   libc++abi.dylib                 0x00007fff8beb5a17 abort_message + 257

3   libc++abi.dylib                 0x00007fff8beb33c6 default_terminate() + 28

4   libobjc.A.dylib                 0x00007fff8a196887 _objc_terminate() + 111

5   libc++abi.dylib                 0x00007fff8beb33f5 safe_handler_caller(void (*)()) + 8

6   libc++abi.dylib                 0x00007fff8beb3450 std::terminate() + 16

7   libc++abi.dylib                 0x00007fff8beb45b7 __cxa_throw + 111

8   test                            0x0000000102999f3b main + 75

9   libdyld.dylib                   0x00007fff8e4ab7e1 start + 1

二、如何抓取Crash?

  1. NSException异常捕获

NSSetUncaughtExceptionHandler(&uncaughtExceptionHandler);



void uncaughtExceptionHandler(NSException *exception) {

  NSLog(@"uncaughtExceptionHandler %@", exception);

}

如何处理存在多个NSException异常捕获的问题?

先使用NSGetUncaughtExceptionHandler,保存Handler,之后在处理完成的时候,还原保存好的Handler句柄。

  1. C++异常捕获

为了获得C++异常的调用堆栈,我需要模拟抛出NSException的过程并在此过程中保存调用堆栈。

  • 设置异常处理函数
g_originalTerminateHandler = std::set_terminate(CPPExceptionTerminate);

调用std::set_terminate设置新的全局终止处理函数并保存旧的函数。

  • 重写__cxa_throw
void __cxa_throw(void* thrown_exception, std::type_info* tinfo, void (*dest)(void*))

在异常发生时,会先进入此重写函数,应该先获取调用堆栈并存储。再调用原始的__cxa_throw函数。

  • 异常处理函数

__cxa_throw往后执行,进入set_terminate设置的异常处理函数。判断如果检测是OC异常,则什么也不做,让OC异常机制处理。否则获取异常信息

  1. Unix异常信号捕获

1. signal

#include <signal.h>

signal(int, void (*)(int));

//第一个参数是我们要捕获的信号类型,第二个参数是我们要绑定的信号处理函数。

示例

  signal(SIGABRT, signalHandler);

  signal(SIGILL, signalHandler);

  signal(SIGSEGV, signalHandler);

  

  void signalHandler(int signal) {

  NSLog(@"signalHandler %d", signal);

}

2. sigaction

#include <signal.h>

int sigaction(int, const struct sigaction * __restrict, struct sigaction * __orestrict);

调试中让应用直接崩溃产生崩溃日志的话,可以在工程中Edit Scheme中,关闭Debug executable选项。

Xcode Debug模式运行App时,App进程signal被LLDB Debugger调试器捕获,需要使用LLDB调试命令将指定Signal信号抛到用户层处理。

查看全部信号传递配置:

// process handle缩写

pro hand

修改指定信号传递配置:

// option:

//   -P: PASS

//   -S: STOP

//   -N: NOTIFY

pro hand -option false 信号名



// 例:SIGABRT信号处理在LLDB不停止,可继续抛到用户层

pro hand -s false SIGABRT
  1. Mach异常信号捕获

mach message主要发送流程
mach_port_allocate();

//内核中创建一个消息队列,获取对应的port



mach_port_insert_right();

//授予Task对port的指定权限



mach_msg();

//通过设定MACH_RCV_MSG/MACH_SEND_MSG用于接收/发送mach message
Mach异常捕获主要流程

保存之前的异常端口

  kr = task_get_exception_ports(thisTask,

                 mask,

                 g_previousExceptionPorts.masks, &g_previousExceptionPorts.count, g_previousExceptionPorts.ports, g_previousExceptionPorts.behaviors, g_previousExceptionPorts.flavors);

创建mach_port

kr = mach_port_allocate(thisTask,

                MACH_PORT_RIGHT_RECEIVE,

                &g_exceptionPort);

赋予Task对port的指定权限

kr = mach_port_insert_right(thisTask,

                  g_exceptionPort,

                  g_exceptionPort,

                  MACH_MSG_TYPE_MAKE_SEND);

把port作为异常处理handler

kr = task_set_exception_ports(thisTask,

                 mask,

                 g_exceptionPort,

                 (int)(EXCEPTION_DEFAULT | MACH_EXCEPTION_CODES),

                 THREAD_STATE_NONE);

创建异常处理线程

error = pthread_create(&g_primaryPThread,

              &attr,

              &handleExceptions,

              (void*)kThreadPrimary);

停止异常监控的时候,还原为原先的exception ports

kr = task_set_exception_ports(thisTask,

                   g_previousExceptionPorts.masks[i],

                   g_previousExceptionPorts.ports[i],

                   g_previousExceptionPorts.behaviors[i],

                   g_previousExceptionPorts.flavors[i]);
  1. Runloop线程保活

- (void)handleException: (NSException *)exception {

  CFRunLoopRef runloop = CFRunLoopGetMain();

  CFArrayRef allModes = CFRunLoopCopyAllModes(runloop);

  bool runloopStop = false;

  while (!runloopStop) {

    for (NSString *mode in (__bridge NSArray *)allModes) {

      CFRunLoopRunInMode((CFStringRef)mode, 0.001, false);

    }

  }

   

  CFRelease(allModes);

  NSSetUncaughtExceptionHandler(NULL);

  signal(SIGABRT, SIG_DFL);

  signal(SIGILL, SIG_DFL);

  signal(SIGSEGV, SIG_DFL);

  signal(SIGFPE, SIG_DFL);

  signal(SIGBUS, SIG_DFL);

  signal(SIGPIPE, SIG_DFL);

}

三、流行方案是如何设计的?

PLCrashReporter

KSCrash

四、结合自身业务该如何设计或者选型?

Crash监控是从SDK捕获异常,获取堆栈信息,添加自定义业务信息,上报Crash信息。

从数据处理侧,需要设计数据存储,跟CICD结合上传符号表,对堆栈做符号化解析,有分钟级、小时级的数据处理支持。

从平台侧,需要分类和聚合Crash,需要有整体Crash率折线图,有Crash的分配、跟进、处理和衡量业务线指标,Crash告警等功能。

  1. Firebase、Bugly和友盟等一体化解决方案

优点:技术投入成本低,双端统一,数据不敏感时适合使用。

缺点:数据处理不灵活,跟业务结合程度较低。

  1. PLCrashReporter、KSCrash等第三方Crash收集方案

优点:客户端投入成本低,平台侧需要自己开发,可以为其他功能提供堆栈获取能力。

缺点:需要设计数据存储,符号化解析,平台侧展示以及数据聚合。

  1. 自己开发

优点:灵活程度高,可跟业务结合紧密。

缺点:客户端投入成本高,需要设计数据存储,符号化解析,平台侧展示以及数据聚合。