iOS的崩溃捕获方案

Posted by 开不了口的猫 on November 23, 2017

前言

在日常的开发过程中,程序崩溃一直是一个比较敏感的话题。如何更好的防护崩溃和避免崩溃更是每一个程序猿必备的技能之一。

Q:那么我们的项目中是否需要一套无比安全的Crash防护机制呢?
当然需要,但是不能在研发阶段。我认为研发阶段如果注入了crash防护机制会造成依赖心理,降低对崩溃风险的敏感度,并且对于很多数据导致的崩溃,一旦带到线上环境,数据发生了异常,发生了重大经济损失,这将会让我们得不偿失。

Q:那么我们的项目是否需要一套崩溃日志上报的机制呢?
当然也是需要的,现在市面上的崩溃统计SDK繁多,友盟、Bugly、Fabric、PLCrashReporter等都是这一类的。他们通常将崩溃信息日志上传到对应的服务器上,然后做一些事后统计和分析工作。

Q:那么在前面两者机制下,是否还需要别的机制用以帮助我们更好的避免线上崩溃和优化产品迭代效率呢?
我觉得还需要一款用于研发和提测阶段,可以在崩溃现场或第一时间捕获崩溃信息,然后将崩溃堆栈信息及时反馈给开发者的App内置组件,这尤其会大大增加提测后测试和开发人员沟通以及定位崩溃的效率。

本文只探讨iOS平台的崩溃捕获方案与其原理。我实现的自动化崩溃捕获组件作为子工具组件已被集成到iOS真机桌面级调试工具中,可以直接clone或通过Cocoapods集成,欢迎Star🤓

Mach异常与Unix信号的捕获

先来谈一谈iOS发生许多崩溃的底层原理。如念茜姐的漫谈iOS Crash收集框架文中所述,crash分为mach exceptionsingalNSException三种类型,每一种类型的crash都处在不同的系统层级上,当然也有各自不同的捕获方式。

首先我们来看mach exception层级的crash。Mach是一个XNU的微内核核心,Mach异常是指最底层的内核级异常,被定义在 <mach/exception_types.h>下 。mach异常由处理器陷阱引发,在异常发生后会被异常处理程序转换成Mach消息,接着依次投递到threadtaskhost端口。如果没有一个端口处理这个异常并返回KERN_SUCCESS,那么应用将被终止。每个端口拥有一个异常端口数组,系统暴露了后缀为_set_exception_ports的多个API让我们注册对应的异常处理到端口中。 ctdpic 另外值得一提的是,所有Mach异常都在host层被ux_exception转换为相应的Unix信号,并通过threadsignal将信号投递到出错的线程。所以EXC_BAD_ACCESS(SIGSEGV)表示的意思是:Mach层的EXC_BAD_ACCESS异常,在host层被转换成SIGSEGV信号投递到出错的线程。
下面是mach_exception转换为signal的关系图。 ctdpic 因为上述关系,我们可以将如何捕获mach exception的注意力转移到如何捕获signal上了。

我们再来看signal层级的crash。Unix信号是一套基于POSIX标准开发的通信机制,在signal.h中声明了32种异常信号,以下六种为iOS常见的信号,它们均会导致程序崩溃。

信号 说明
SIGILL 执行了非法指令,一般是可执行文件出现了错误
SIGTRAP 断点指令或者其他trap指令产生
SIGABRT 调用abort产生
SIGBUS 非法地址。比如错误的内存类型访问、内存地址对齐等
SIGSEGV 非法地址。访问未分配内存、写入没有写权限的内存等
SIGFPE 致命的算术运算。比如数值溢出、NaN数值等

捕获signal只需要像这样通过注册signalHandler:

NSArray *machSignals = @[
    @(SIGABRT),
    @(SIGBUS),
    @(SIGFPE),
    @(SIGILL),
    @(SIGTRAP),
    @(SIGSEGV)
];
for (int index = 0; index < machSignals.count; index ++) {
    signal([machSignals[index] intValue], &machSignalExceptionHandler);
}

其中machSignalExceptionHandler为我们捕获后的回调函数。

应用级异常-NSException的捕获

对于iOS开发者来说,相比于mach exceptionsignalNSException真是再熟悉不过了。

NSException发生在CoreFoundation以及更高抽象层级,会通过__cxa_throw函数抛出异常。如果没有人为进行捕获或者在捕获回调函数中没有进行操作终止应用,那么最终会通过abort()函数来向进程抛出一个SIGABRT的信号。

另外,NSException可以直接通过iOS的@try-@catch机制轻松捕获,避免应用crash。但由于@try-@catch的性能开销比较大,所以在iOS开发中也并不是非常受到推崇。

我们还可以通过注册NSUncaughtExceptionHandler来捕获应用级异常:

NSSetUncaughtExceptionHandler(&ocExceptionHandler);

其中ocExceptionHandler为我们捕获后的回调函数。

NSSetUncaughtExceptionHandler的坑

如果你的公司App中存在引入多个crash日志收集平台的话,很可能会出现有的平台捕获到的日志另外几个平台并没有捕获到或者说干脆有些平台根本收集不到crash日志信息,这是为什么呢?

因为NSSetUncaughtExceptionHandler函数存在覆盖现象,后注册的总会顶替掉前面注册的,当crash发生时,永远只会触发最后注册传入的捕获回调函数。因此一种好的做法是,后注册者通过NSGetUncaughtExceptionHandler将先前别人注册的回调函数取出并备份,在自己回调函数处理完成之后自觉地把别人的handler注册回去,规规矩矩的传递。

// Avoid calling dead loops
if (self.originHandler != &ocExceptionHandler) {
    self.originHandler = NSGetUncaughtExceptionHandler();
}
NSSetUncaughtExceptionHandler(&ocExceptionHandler);

PS:经过我的一些试验发现,目前各大平台中,Bugly和Fabric确实是这么做的,但诸如友盟crash统计等少数SDK似乎并不会将上一个捕获回调保存并在完成后传递下去。若觉得我的结论有误,可以及时联系我🙄。

让程序继续存活

当了解完iOS的几种崩溃捕获机制后,“任性”的我又开始搞事情了。按照原先的初衷,我们需要在App崩溃的现场就可以通过一种友好的方式来通知开发者(提测阶段中可能是测试收到友好的提示,然后将消息转告给开发人员)。但如果在回调函数中什么都不做,那么程序在完成回调后立马会被杀死。苦恼的我在翻阅大量文献后,终于找到了一篇能为我解惑的文章。按照文中所述,我们可以像这样创建一个Runloop,将主线程的所有Runmode都拿过来跑,作为应用程序主Runloop的替代。

CFRunLoopRef runloop = CFRunLoopGetCurrent();
CFArrayRef allModesRef = CFRunLoopCopyAllModes(runloop);

while (captor.needKeepAlive) {
    for (NSString *mode in (__bridge NSArray *)allModesRef) {
        if ([mode isEqualToString:(NSString *)kCFRunLoopCommonModes]) {
            continue;
        }
        CFStringRef modeRef  = (__bridge CFStringRef)mode;
        CFRunLoopRunInMode(modeRef, keepAliveReloadRenderingInterval, false);
    }
}

然后在这个运行循环中获取对应的堆栈信息并弹出友好的提示页面。到此看起来似乎一切都水到渠成了。

安全模式与非安全模式

这篇文章中其实还提到

In order to continue the program without ever returning control to the calling function, we must return to the main thread (if we are not already there) and permanently block the old thread. On the main thread, we must start our own run loop and never return to the original run loop.

This will mean that the stack memory used by the thread that caused the exception will be permanently leaked. This is the price of this approach.

因为我们为了继续执行程序而没有将控制权返回给导致崩溃的调用函数,并且我们启动了自己的Runloop,所以永远不会返回到原始的Runloop中去了,这将意味着导致异常的线程使用的堆栈内存将永久泄漏。这是这种解决方案的代价。

另外还提到了

You cannot simply continue from all situations that trigger exceptions. If you're in the middle of a situation that must be completed in its entirety (a transaction on your document) then your application's document may now be invalid.

Alternately, the conditions which led to the exception or signal may have left your stack or heap in a state so corrupted that nothing is possible. In this type of situation, you're going to crash and there's little you can do.

简言之,就是如果我们是从一种不能中断的事务中发生崩溃 或 导致崩溃的条件可能会使当前堆栈处于损坏状态的,这种情况下,我们无能为力。

另提一句,PLCrashReporter实现源码中其实是使用了大量的c或c++的safe-api来确保程序不会因此受影响。

因此,为了解决这一矛盾,我们还可以为使用者提供另外一个备选选项 —- "崩溃捕获的安全模式"

其原理很简单,将提示使用者的友好页面的弹出时机推迟到下一次App启动后,通过缓存在沙盒中的标识来检测上一次App是否是因为崩溃而被迫关闭的。具体可以直接看源码实现,这里不再赘述。

最终效果

参考