iOS系统崩溃的捕获

相信大家在开发iOS程序的时候肯定写过各种Bug,而其中最为严重的Bug就是会导致崩溃的Bug(一般来说妥妥的P1级)。在应用软件大大小小的各种异常中,崩溃确实是最让人难以接受的行为。毕竟崩溃意味着用户将丢失应用程序运行中的所有上下文环境,丢失其所有未保存的数据,会带给用户最糟糕的使用体验。

所以在应用的开发阶段,我们一定要杜绝此类可能造成应用程序无法使用的崩溃。但是很多崩溃并不是自己在开发阶段就能预料到的,此时就需要一种能够线上获取崩溃日志并且上报的机制,这就是所谓的崩溃捕获和上报体系。

今天我们不研究SuperApp中的崩溃上报,主要研究一下崩溃捕获是如何实现的。

iOS系统中如何捕获崩溃

首先,iOS系统中,并没有通用的能够捕获所有崩溃的处理函数。捕获崩溃主要有以下三种方式:

  • NSSetUncaughtExceptionHandler
  • Unix Signal捕获函数
  • Mach(读音为[mʌk])异常捕获函数

关于如何用上述的方式捕获崩溃,不是本次分享的重点,大家可以自行查阅博客中的代码。我们主要需要理解的是这三者各自的原理和应用场景。

NSSetUncaughtExceptionHandler

首先我们写一个会导致崩溃的Objective-C代码片段:

NSDictionary *userinfo = @{
  @"username": @"TP-LINK",
  @"email": @"admin@tp-link.com.cn",
  @"tel": @"15015001500"
};
NSMutableArray<NSDictionary *> *memberarray = [NSMutableArray arrayWithArray:@[userinfo]];
for (NSDictionary *dic in memberarray) {
  if ([[dic valueForKey:@"username"] isEqualToString:@"TP-LINK"]) {
    [memberarray removeObject:dic];
  }
}

运行程序,不出意外的话,程序在执行到片段的时候就会立刻崩溃,然后我们会在控制台里面看到如下打印:

*** Terminating app due to uncaught exception 'NSGenericException', reason: '*** Collection <__NSArrayM: 0xb550c30> was mutated while being enumerated.'

相信不少同学都会这串提示很熟悉,从字面上来看,程序崩溃是因为有个异常没有被捕获到,异常的类型是NSGenericException,导致异常的原因则是因为在遍历集合的时候尝试去修改里面的元素。

NSGenericException是一个继承自NSException的类,表示触发的是一种通用的异常,除了这个类,还有很多其他的子类,像是NSRangExceptionNSInvalidArgumentException等,基本上只要看到名称就知道异常大致是什么原因导致的。

NSException是可以被我们手动捕获的,例如:

@try {
    for (NSDictionary *dic in memberarray) {
        if ([[dic valueForKey:@"username"] isEqualToString:@"TP-LINK"]) {
            [memberarray removeObject:dic];
        }
}
@catch (NSException *exception) {
    NSLog(@"Caught %@: %@", [exception name], [exception reason]);
}

但是在写实际项目的时候,我们通常不会手动写这类处理异常的代码,Objective-C也并没有强制要求我们写此类的异常处理程序。

其实,这主要是因为异常代表的通常是我们编写的程序存在逻辑错误,通常不可恢复,需要我们在发布给用户使用之前由开发者进行处理,所以NSException又被称为应用级异常。NSSetUncaughtExceptionHandler实际上是给我们提供了一个手段,对这些我们未捕获的异常进行一个最终的处理,但如果这些错误是在用户使用的时候发生的,我们也无法立刻进行处理。

或许也是因为这个原因,Swift语言抛弃了NSException,而只保留了Error。

由于NSSetUncaughtExceptionHandler不是万能的,比如我们写一段Swift的强制解包代码:

var userName= fetchUserName()
printUserName(userName!)

上述代码假设fetchUserName()函数返回nil,并且printUserName()函数只接受非空参数,那么在程序运行时,由于强制解包失败,应用程序会崩溃并且NSSetUncaughtExceptionHandler也无法捕获此类崩溃,这时候就需要其他的机制来捕获此类异常。

Mach异常

要了解Mach异常,首先要了解什么是Mach!首先上一张mac OS X的架构图:

mac OS X的核心操作系统被称为“Darwin”,其由系统组件和内核构成。其中内核被称为"xnu",他是一个混合型的内核,包括了Mach和BSD两个部分,其中BSD实现了文件系统、网络、NKE(Network Kernel Extension,实现注入通信加密、虚拟网络接口等网络方面的扩展功能)、POSIX接口等功能,而Mach则实现了I/O组件和驱动程序。xnu内核是开源的。

从图里面可以看到,内核的下面就是硬件,所以由Mach内核抛出的异常也被称为是最底层的异常,造成异常的原因通常是硬件导致的异常,比如:

  • 试图访问不存在的内存
  • 试图访问违反地址空间保护的内存
  • 由于非法或未定义的操作代码或操作数而无法执行指令
  • 产生算术错误,例如被零除、上溢、或者下溢
  • ……

关于Mach抛出异常的流程,我们可以结合以下图来理解:

如果出错的线程触发了一个硬件级别的错误,处于内核的陷阱处理程序就会调用exception_deliver()函数依次尝试将异常投递到thread、task和host。

这里插入一个小话题,在Mach内核中,为了和thread、task和host打交道,或者他们互相之间打交道,提供了一种基于端口的IPC手段,这个手段在Cocoa上层也有对应的抽象,就是NSMachPort。这个mach port大家可能听说过,不知大家是否有印象?

当异常发生的时候,一条包含异常的mach message,例如异常类型、发生异常的线程等等,都会被发送到一个异常端口。而thread、task、host都会维护一组异常端口,当Mach Exception机制传递异常消息的时候,它会按照thread → task → host 的顺序传递异常消息。这是通过上面的mach_exc_raise()类函数来实现的。

如果thread、task都没有处理异常,那么就会由host也就是操作系统内核来处理异常,操作系统处理异常的方式就是上图Exception Handler中的流程,可以看到,handler是一个循环处理消息的机制,mach_msg_receive()函数负责接受消息;mach_exc_server()函数内有catch_mach_exception_raise()函数,这个函数通过ux_exception()将mach异常转换为Unix的Signal,并通过threadsignal()将其发送到对应的线程上去。

这一系列过程中,我们可控的部分是thread,我们可以新建一条thread并且通过mach port监听异常端口来实现崩溃的捕获。

有时候,Debugger会在程序崩溃的时候,给出Mach异常的类型:

上述代码试图给一个assign类型的property赋值,由于引用计数为0,对象在赋值之后就被立刻释放了,所以这行代码就崩溃了

Debugger给出的标红信息,可以这么理解:

一些其他常见的Mach异常类型及其对应的原因如下表:

Exception Notes
EXC_BAD_ACCESS 访问了不该访问的内存
EXC_BAD_INSTRUCTION 线程执行非法指令
EXC_ARITHMETIC 算术异常
EXC_SOFTWARE 软件生成的异常
EXC_BREAKPOINT 跟踪或者断点

关于code大家可能会存在疑惑,它代表的其实是内核函数的返回值,其中,code=1代表的是地址不可用,其定义如下:

#define KERN_INVALID_ADDRESS            1

由于code的种类有很多,其他code对应的含义,可以翻阅kern_return.h头文件进行查阅

以下为苹果的崩溃日志,里面也包含类似信息:

Exception Type:  EXC_BAD_ACCESS (SIGSEGV)
Exception Subtype: KERN_INVALID_ADDRESS at 0x00000000000000b8

将两者进行结合,一般就可以判断崩溃的原因究竟是什么。了解以上知识相信会对大家日后解决Bug带来一定的帮助。

Unix Signal

Signal是Unix、类Unix以及其他POSIX兼容的操作系统中进程间通讯的一种有限制的方式。它是一种异步的通知机制,用来提醒进程一个事件已经发生。信号的作用有很多,比如可以用来进程间通信(IPC)、用于Debugger调试等,当然也可以用来报告异常。

既然Mach已经实现了硬件导致的异常,为什么还需要将其转化为Unix Signal,继续报告一次呢?

原因很简单,因为xnu包含了BSD和Mach,为了实现POSIX兼容,让用户可以使用BSD提供的POSIX API,就需要做这样一层转换。

Mach异常和Unix Signal两者的转换关系如下表:

Mach 异常 Unix Signal 原因
EXC_BAD_INSTRUCTION SIGILL 非法指令,比如数组越界,强制解包可选形等等
EXC_BAD_ACCESS SIGSEVG、SIGBUS SIGSEVG、SIGBUS两者都是错误内存访问,但是两者之间是有区别的:SIGBUS(总线错误)是内存映射有效,但是不允许被访问,比如访问一个结构体但是起始地址有误; SIGSEVG(段地址错误)是内存地址映射都失效,比如野指针
EXC_ARIHMETIC SIGFPE 运算错误,比如浮点数运算异常
EXC_BREAKPOINT SIGTRAP trace、breakpoint等等,比如说使用Xcode的断点
EXC_SOFTWARE SIGABRT、SIGPIPE、SIGSYS、SIGKILL 软件错误,其中SIGABRT最为常见。

问1:既然Mach异常可以转换为unix异常,而signal也是可以由我们自由处理的,那是否可以不处理Mach异常,只处理unix的signal就可以了?

答案是不行,因为某些异常,比如EXC_GUARD 异常(这是一种违反了受保护资源的防护而导致的异常,比如访问SQLite文件的时候关闭了它的文件描述符),是没有映射到Unix Signal的,这种异常就没法通过signal处理。

问2:那是不是处理了Mach异常,就不需要处理signal异常了呢?

答案是也不行,因为如果底层有些异常类型只能通过signal处理,比如直接调用了 __pthread_kill函数直接向某条线程发送了SIGABRT这个signal,这类异常不能被Mach所捕获

为什么没有通用的异常处理函数

现在我们可以回答这个问题了。总结一下,iOS系统中,崩溃有可能是以下两种方式产生的:

  • 应用级异常,比如NSException
  • 硬件级异常,比如野指针访问

对于前者,我们只能使用NSSetUncaughtExceptionHandler进行捕获,对于后者,我们需要使用以下机制:

  • Mach异常处理机制
  • Unix Signal异常处理机制

因为以上两者作用域也无法互相覆盖,所以以上两者也需要结合使用。

正是因为这三种处理机制覆盖了不同的领域,并且处理机制也不尽相同,因此iOS中没有通用的异常处理函数。

然而,事情没有那么简单

上述三个函数的功能十分强大,但是实际上设计一个崩溃捕获系统没有那么容易。一般来说,捕获系统除了捕获崩溃,还需要记录崩溃时的现场信息,比如崩溃时的iOS系统版本、应用版本、崩溃时间、异常信息、程序堆栈等等:

{"app_name":"TP-LINK物联","timestamp":"2023-02-16 15:40:40.00 +0800","app_version":"4.12.1","slice_uuid":"d146125f-f904-3e39-940a-0f7dd32d6071","adam_id":0,"build_version":"41201","platform":2,"bundleID":"net.tplink.surveillancesystem","share_with_app_devs":0,"is_first_party":0,"bug_type":"109","os_version":"iPhone OS 14.0.1 (18A393)","incident_id":"70E8ABFF-6F0F-4094-BF31-EE929EFA78DD","name":"TP-LINK物联"}
Incident Identifier: 70E8ABFF-6F0F-4094-BF31-EE929EFA78DD
CrashReporter Key:   8c905de38d4cd4ff6ad692cc4ca4f6b1f41a50af
Hardware Model:      iPhone12,8
Process:             TP-LINK物联 [2002]
Path:                /private/var/containers/Bundle/Application/F17C1188-8ED4-4C72-8E46-FE7ABE28DDA1/TP-LINK物联.app/TP-LINK物联
Identifier:          net.tplink.surveillancesystem
Version:             41201 (4.12.1)
Code Type:           ARM-64 (Native)
Role:                Foreground
Parent Process:      launchd [1]
Coalition:           net.tplink.surveillancesystem [564]


Date/Time:           2023-02-16 15:40:39.7011 +0800
Launch Time:         2023-02-16 15:37:15.6262 +0800
OS Version:          iPhone OS 14.0.1 (18A393)
Release Type:        User
Baseband Version:    2.00.01
Report Version:      104

Exception Type:  EXC_BAD_ACCESS (SIGSEGV)
Exception Subtype: KERN_INVALID_ADDRESS at 0x00000000000000b8
VM Region Info: 0xb8 is not in any region.  Bytes before following region: 4375183176
      REGION TYPE                      START - END             [ VSIZE] PRT/MAX SHRMOD  REGION DETAIL
      UNUSED SPACE AT START
--->  
      __TEXT                        104c80000-1077ac000        [ 43.2M] r-x/r-x SM=COW  ...app/TP-LINK物联

Termination Signal: Segmentation fault: 11
Termination Reason: Namespace SIGNAL, Code 0xb
Terminating Process: exc handler [2002]
Triggered by Thread:  15

Thread 0 name:  Dispatch queue: com.apple.main-thread
Thread 0:
0   libsystem_kernel.dylib        	0x00000001d0bbfdd0 0x1d0bbc000 + 15824
1   libsystem_kernel.dylib        	0x00000001d0bbf184 0x1d0bbc000 + 12676
2   CoreFoundation                	0x00000001a4bb6cf8 0x1a4b19000 + 646392
3   CoreFoundation                	0x00000001a4bb0ea8 0x1a4b19000 + 622248
4   CoreFoundation                	0x00000001a4bb04bc 0x1a4b19000 + 619708
5   GraphicsServices              	0x00000001bb635820 0x1bb632000 + 14368
6   UIKitCore                     	0x00000001a7554734 0x1a69d7000 + 12048180
7   UIKitCore                     	0x00000001a7559e10 0x1a69d7000 + 12070416
8   TP-LINK物联                     	0x0000000104c89ff0 0x104c80000 + 40944
9   libdyld.dylib                 	0x00000001a4877e60 0x1a4877000 + 3680
……

在iOS系统中,如果直接在上述的崩溃处理函数中进行这些信息的记录,并不安全,这主要是因为iOS中App被限制在一个进程中运行,如果应用崩溃,那崩溃的线程将会立刻暂停执行,那就会导致如下问题:

  • 内存可能被破坏(比如某些数值溢出导致的崩溃,内存会被溢出的数据覆盖)
  • 锁可能正在被暂停执行的线程持有着
  • 数据结构可能只更新一半

这样的不稳定环境,大部分函数都不能保证能够正确运行,导致崩溃处理程序能够调用的库函数非常有限,你将无法做到:

  • 通过malloc等函数分配堆内存
  • 通过backtrace函数获取调用栈信息

如果破解这些限制?我们不妨研究下SuperApp中集成的Breakpad是怎么操作的。

Breakpad的整体构成

如上图所示,Breakpad主要由三部分构成:

  • symbol dumper:符号提取器。应用程序在构建的时候会包含debug相关的信息,它能够提取这些信息并生成专属的符号文件。
  • client:客户端是一种包含在你应用程序里面的第三方库,它能够捕获当前各线程的状态以及当前加载的共享库等信息,将其写入minidump文件中。
  • processor:处理器主要用来读取minidump文件和符号文件,将其翻译为人类可读的格式

符号文件是程序编译的产物,里面会包含函数或数据的名称、地址、大小、类型等。由于Breakpad是一个跨平台的方案,因此没有采用XCode编译产生的符号表文件,而是使用了自定义的格式。minidump则是一种微软开发的文件格式,它被用在微软的崩溃上传体系中,包含了可执行文件和共享库的列表、进程中的各线程列表信息、调用栈信息等。

Breakpad如何解决上述问题

1.如何安全分配内存

以下为Breakpad启动代码:

ProtectedMemoryAllocator* gMasterAllocator = NULL;
ProtectedMemoryAllocator* gKeyValueAllocator = NULL;
ProtectedMemoryAllocator* gBreakpadAllocator = NULL;

BreakpadRef BreakpadCreate(NSDictionary* parameters) {
  try {
    gMasterAllocator =
        new ProtectedMemoryAllocator(sizeof(ProtectedMemoryAllocator) * 2);

    gKeyValueAllocator =
        new (gMasterAllocator->Allocate(sizeof(ProtectedMemoryAllocator)))
            ProtectedMemoryAllocator(sizeof(LongStringDictionary));
   
    int mutexResult = pthread_mutex_init(&gDictionaryMutex, NULL);
    if (mutexResult == 0) {
      int breakpad_pool_size = 4096;

      gBreakpadAllocator =
          new (gMasterAllocator->Allocate(sizeof(ProtectedMemoryAllocator)))
              ProtectedMemoryAllocator(breakpad_pool_size);

      NSAutoreleasePool* pool = [[NSAutoreleasePool alloc] init];
      Breakpad* breakpad = Breakpad::Create(parameters);

      if (breakpad) {
        gMasterAllocator->Protect();
        gKeyValueAllocator->Protect();
        gBreakpadAllocator->Protect();

        [pool release];
        return (BreakpadRef)breakpad;
      }

      [pool release];
    }
  } catch(...) { 
    fprintf(stderr, "BreakpadCreate() : error\n");
  }
  ...
}

上述代码片段已经包含了对内存分配问题的解决,其 核心思路是:既然崩溃时无法分配内存,那么只要在崩溃前提前分配好崩溃处理程序所需的内存并将其保护起来避免被崩溃所破坏即可

ProtectedMemoryAllocator这个类相当于一个内存池,它允许分配内存,但是分配内存无法被回收。此外,它还提供了一个Protect()方法用于将内存池设置为只读,这样一来这块内存就不会在崩溃发生的时候被各种原因覆盖。

通过源码,我们可以一窥其实现的原理,首先是构造函数:

ProtectedMemoryAllocator::ProtectedMemoryAllocator(vm_size_t pool_size) 
  : pool_size_(pool_size),
    next_alloc_offset_(0),
    valid_(false) {
  
  kern_return_t result = vm_allocate(mach_task_self(),
                                     &base_address_,
                                     pool_size,
                                     TRUE
                                     );
  
  valid_ = (result == KERN_SUCCESS);
  assert(valid_);
}

vm_allocate是一个内核函数,用于申请虚拟内存,由于Breakpad需要直接申请一块较大的内存,用于整个模块的内存使用,因此它直接使用了该函数,而不是malloc。该类申请的内存大小是由参数pool_size决定的,内存分配之后,base_address_指向内存池的起始地址。

再看看Protect()方法的实现:

kern_return_t  ProtectedMemoryAllocator::Protect() {
  kern_return_t result = vm_protect(mach_task_self(),
                                    base_address_,
                                    pool_size_,
                                    FALSE,
                                    VM_PROT_READ);
  
  return result;
}

其同样调用了内核函数vm_protect,将申请的虚拟内存设置为只读,这样就实现了内存的保护。

2.如何获取调用栈信息

Breakpad内部有一个MinidumpGenerator类专门用于写入minidump,其中包括了我们关心的线程调用栈信息。由于涉及到minidump格式问题,我们不深入分析这个类,只是简单介绍下原理。

首先,我们需要理解线程调用栈的结构:

线程的调用栈分为若干栈帧(stack frame),每个栈帧对应一个函数调用。上图包含了两个栈帧,DrawLine和DrawSquare。

栈帧主要由三部分组成:函数参数、返回地址、帧内的本地变量。上述DrawSquare函数调用DrawLine函数的时候,首先函数的参数入栈,然后把返回地址入栈,最后是函数内部本地变量。

这里要注意的是,有两个特殊的指针:Stack Pointer指向了调用栈的栈顶,Frame Pointer则指向了当前栈帧。

此外,我们还需要了解一下iOS系统中虚拟内存的相关知识:

我们知道,操作系统会对虚拟内存进行分页。在iOS系统中,为了更好的管理内存页,系统会将一组连续的内存页关联到一个VMObject上,也称为VM Region。我们可以通过XCode的Instruments工具,查看当前App的虚拟内存分配情况,其中就包含了VM Region的相关信息:

可以看到,VM Region被分为不同的Category,其中有一种Category叫做VM Stack,其包含的就是线程调用栈的信息。

为了获取VM Stack中的信息,Breakpad大致做了以下操作:

  1. 通过内核函数task_threads获取当前进程的所有线程

  2. 通过内核函数thread_get_state获取目标线程的thread_state_t,这个结构中包含了该线程调用栈的栈顶指针Stack Point

    _STRUCT_ARM_THREAD_STATE64
    {
    	__uint64_t __x[29]; /* General purpose registers x0-x28 */
    	__uint64_t __fp;    /* Frame pointer x29 */
    	__uint64_t __lr;    /* Link register x30 */
    	__uint64_t __sp;    /* Stack pointer x31 */
    	__uint64_t __pc;    /* Program counter */
    	__uint32_t __cpsr;  /* Current program status register */
    	__uint32_t __pad;   /* Same size for 32-bit or 64-bit clients */
    };
    
  3. 由Stack Pointer的地址作为起始地址,获取下一个VM Region,如果其Category为VM Stack,将此块内存的信息记录下来,写入minidump