iOS Crash 防护你看这个就够了 - 下篇

2022-11-03 09:11:26 浏览数 (1)

# 0x1: Previously

上篇 中讲到了 Crash 处理流程分为四个环节,也分析了 Crash 防护的方法,本章来讲下其余三个环节。

# 0x2: Crash 的拦截

所有的未被防护住的 Crash 最终会走到这里,在这里我们必须要保证拦截的 全面性稳定性 尽可能多的拦截到所有类型的异常,同时拦截逻辑本身不能产生异常。那么我们需要通过以下几个方面去考虑。

# I: Crash 类型

和多数操作系统一样,iOS 的异常也基本分为 用户层 系统底层 信号 这三个类别,接下来我们看下每种异常都做了哪些事情

  • Mach Exception <br> Mach 异常,分为两种情况,第一种是本身就是硬件层面或者系统层面的异常,这个大家比较好理解,因为 mach 是微内核,所以底层的内核级别的包括硬件的异常都是 mach 异常。另一种是 iOS 系统独有的逻辑或者说是苹果独有的。就是用户层面的异常也都首先下沉到 mach 层再发出来,也等于是另一种意义上的 mach 异常。苹果官方文档上写的是为了统一机制才做了这样的处理,但是没有说具体原因。他的触发流程大概为下图

然后我去看 Runtime 的源码进一步证明了这个说法,runloop 中大量使用这种方式监听 mach 异常消息,一旦 Crash 随时准备打破循环,因为系统也需要监听 crash,统一出口将对监听来说对系统将变得非常方便。

根据代码上下文可以判断出,苹果会监听统一的异常端口,在出现异常后进行相应的操作,也印证了我当时的推断。

  • Exception <br> 很常见的异常,触发流程大概为
  • signal <br> signal 的产生流程大概分为几种情况
    • 由于 MachExcption 转换而成的 signal
    • 由于 Exception 而发出的 abort 信号
    • 用户自定义的信号

    但是需要注意一点:<font color=red size=3 face="黑体"> 收到 signal 不一定会 Crash,但是 Crash 一定会有 Signal 发出 </font>

# II: Crash 传递流程

上面分析了每种 Crash 的类型,那么这三种类型的 Crash 是如何在 App 生命周期中传递的呢?他们又是如何相互转化以及相互之间有什么关系呢?

帮大家提取下上图中的几个关键信息

  • 1:Exception 最终会转化为 Mach Exception
  • 2:通过 Mach 端口拦截的较为全面
  • 3:如果发生了 exception 那么就不会抛出对应的 signal 只能抛出 abort ();
  • 4:通过捕获 signal 是无法拦截到 exception。

# III: 拦截的选择

通过上面的分析大家一定会说通过 Mach 端口的拦截更加全面,毕竟苹果自己也在用。但是在实际使用中有一个问题,mach 会拦截所有的异常以及信号量,也就是随便一个操作(比如发一个自定义 signal 等)可能都被 mach 捕获,那么如果在其捕获回调中再进行捕获就会很容易发生死锁,而且容易和系统的处理产生冲突。当时看了 PLCrash 的文档,也看到了开发者写的一句话:

这样说明了大家确实被坑过。

那接下来只剩 signal 和 exception,其实细心的同学早已发现这两个的优缺点是一个互补的状态

  • singal 能捕获除 Exception 之外的所有异常。
  • exception 只能获取应用层的异常而对信号量无法处理

那么最终的方式采用 singnal exception 的方式进行捕获,最终的流程为:

# IV: 坑点

上面的流程图可以看出在每一个 CustomHandle 之前都会有一个 PreviousHandle,其实是因为在 iOS 系统中只能存在一个 customHandel,如果你的项目中接入了或者准备接入多个 Crash 防护相关的 SDK(虽然不建议这么做),那么多个 Handle 之间一定会产生冲突,导致堆栈不明确,或者丢失。所以在注册我们的 handle 前先将之前的 handle 指针保存下来,等我们的 handle 处理完后在通过函数指针调用回去,这样就能保证每一个 handle 都能被正常调用。

  • exception:通过 NSGetUncaughtExceptionHandler 获得之前 handle 指针,之后再通过 NSSetUncaughtExceptionHandler(oldHandler); 调用回去。
  • signal: 使用 sigaction 函数获得之前的 handle 指针。

# 0x3: 堆栈获取

因为苹果使用了 (Address Space Layout Randomization) 地址空间配置随机加载技术,所以线上堆栈必须要通过符号表堆栈还原进行解读,不然的话就是内存地址。所以当我们使用 NSThread 的相关函数在 Debug 下虽然能看到可读性行的堆栈,但是在线上包上并不可取,那我们要怎么获取堆栈呢?先来看下符号表的构造:

之前拿到这样的符号表,我们通常手动还原,找一个相同系统的真机,找到对应库的基地址按照符号表上函数的偏移量进行计算(通过 LLDB 的相关函数)

通过看 Mach-o 相关接口可以找到相关函数进行端内符号表还原,大致流程为:

  • 获取函数地址:
    • 遍历 Mach-o 中的所有 image
    • 获取每个 image 的基地址
    • 通过堆栈偏移地址获取栈帧函数地址
  • 将函数地址翻译成函数名
    • 找到对应 Image 的 symple table 段的 nlist_64 结构体
    • 通过 nlist_64.n_un.n_strx 获取函数对应的字符串

最终的效果:

# 0x4: Crash 后续

通常在 AppCrash 后会在 handle 中做些上报操作.

但是这样做有两个问题:

  • 苹果不推荐在 Handle 中做太多操作,而且数据上报等网络请求属于耗时操作,有可能没有完成 App 就被杀死。
  • App 直接闪退,体验不好

通过查看 runloop 源码可以看出,在 Crash 发生后当前 runloop 中断

<font color='red'> 注意:runloop 本次循环还在继续,但是循环已经被打破,本次循环结束后 app 才退出 </font> 既下图的 retVal 被置为 NO

iOS Crash 发生后 runloop 中的 do-while 循环的条件会被置为 NO,然后 Handler 函数走完之后当前循环后直接结束,不会在进行下一次循环了,此时我们只需要再 handler 中再重启 runloop,便可以继续执行代码,通过观察 runloop 源码可以看出 这样的操作是在之前已经中断但是还没结束的 runloop 中开启一个新的 runloop,他依然可以接受各种事件,比如交互事件等,前提是每个 model 都要开启,因为不同操作是发生在不同阶段的。 但是之前 runloop 中的内容处于不可控状态,且之前的东西被永远的留在内存中,不可恢复,所以在做完相关操作后要立即结束 App,避免其他异常情况,这种做法类似于一种安全模式,在安全模式中处理相关的东西。

函数调用:

代码语言:javascript复制
void continueAfterCrash()
{
    CFRunLoopRef runLoop = CFRunLoopGetCurrent();
    CFArrayRef allModes = CFRunLoopCopyAllModes(runLoop);

    for (NSString *mode in (__bridge NSArray *)allModes)
    {
        CFRunLoopRunInMode((CFStringRef)mode, 1.0e10, false);
    }
}

在新的 runloop 中我们做一些操作后再调用 abort 退出 App,比如弹出友好提示之类的操作,告知用户 app 即将退出,但是该操作存在风险,需要注意以下情况

  • 新开 runloop 后之前的 runloop 内容便会永远的留在内存中变成不可控的状态如果一旦被访问可能会有异常,所以在做完我们必要的操作后要及时结束 App。
  • 安全模式必须保证稳定,在新 runloop 中执行的上报、弹窗或者其他逻辑必须要使用系统原生的 API,不能依赖任何第三方。
  • 尽量不要做太多的操作,及时结束。

# 0x5: 参考资料

  • Apple iOS Api
  • iOS Open Sourcre
  • CFRunloop
  • XNU 3248.60.10 源码
  • Understanding Crash Reports on iPhone OS
  • 《深入解析 MAC OS X & IOS 操作系统》

# 0x6: 最后

大概这就是所有 Crash 防护的流程,通过两篇文章讲解,希望大家对 iOS 系统的 Crash 流程能有些许的了解,并没有贴太多的源码,其实还是解耦度不够,思路有了代码就很简单了。

0 人点赞