【iOS内功】如何排查无法复现的系统内部Crash?

6,141 阅读5分钟

iOS内功系列文章

Crash分析的基础知识了解不多的,可以参考原来写的一些文章

【iOS内功】Crash分析模型

【iOS内功】深入解析Crash调用栈的内存布局

【iOS内功】ARM黑魔法—栈桢的入栈和出栈

【iOS内功】使用Hopper定位疑难问题

【iOS内功】ARM汇编实战,解析iOS14 UICollectionView死循环问题

一、问题概述

苹果每年都会升级iOS系统,可能会对系统库进行逻辑改动。我们自己工程里有些代码你可能几年没动过,但系统一升级就会出现奇怪的Crash。今天介绍一个案例,iOS13.3升级后,导致工程里某个方法签名会引发NSInvocation内部的数组越界。因为一直无法完美复现,最后经过多次假设和实验才修复。

这类问题有两个难点。第一个难点,它们是由系统唤起的任务,很难直接复现,我们不能通过运行时去观察上下文的信息。第二难点是,Crash的堆栈都是在系统的方法,我们不能直接看到系统方法逻辑,推导过程会有盲区。

二、基本面分析

特征分析

今年9月份开始,线上出现NSInvocation越界的Crash。这个Crash只在iOS13.3系统以上设备才会出现。

Crash调用栈分析

Crash异常描述:

'NSInvalidArgumentException', reason: '-[NSInvocation getArgument:atIndex:]: index (0) out of bounds [-1, -1]'

Crash调用栈可以简化为6个重要的方法:

1 _GSEventRunModal(主线程runloop唤醒)
2 __NSFireTimer
3 _CF_forwarding_prep_0
4 +[NSInvocation _invocationWithMethodSignature:frame:]
5 _NSIGetArgumentAtIndex
6 _objc_exception_throw

三、 疑点分析

  • 疑点1:为什么会走到消息转发?

第一种是方法未实现。排查了工程里所有定时器的代码,发现不存在这样的情况。第二种,新系统存在某个逻辑,直接调用了__NSFireTimer里target的消息转发函数。很可能性是第二种。 ”_CF_forwarding_prep_0“rep_0“u。

  • 疑点2: invocation为什么会越界?

根据异常日志,invocation在参数列表里取第0个参数时,数组越界了。 NSInvocation里有一个数组arguments,里面存储了方法所有的入参。根据异常描述,”methodSignatureForSelector“方法执行后会调用到_NSIGetArgumentAtIndex,然后取出arguments的第一个参数.arguments第一个参数是self,取参数时直接越界了。

福尔摩斯在查案时会观察不寻常的细节。疑点分析就是要发现和凶手留下的痕迹,哪些细节和平时不一样。

四、提出假设,模拟现场

上面分析疑点1时,结论是有两种情况会导致消息转发。 具体是哪个原因并不重要,重要的是我们要尽可能还原现场,让定时器执行任务后,走到消息转发。 因此我们可以主动触发定时器target的消息转发机制模拟Crash现场。

Demo模拟

为了快速验证,我创建了Demo工程,开一个定时器,调用一个未实现的方法。无论是模拟器还是真机都无法复现。

源项目模拟

可能是环境不一样,于是在源工程模拟测试。模拟器运行不能复现。根据Crash特征,找了一台iPhone Xʀ iOS14.2,终于复现了!!!

因此,结论是工程环境存在关键逻辑,这个逻辑直接触发Crash。

模拟现场要突破思维局限,就像踢足球时我们不一定要从后场一步一步往前传球。如果前锋有明显空挡,一个长传直接找他更高效。

五、运行时分析

寻找根因

成功复现后,就可以沿着调用栈进行运行时分析。 添加一个符号断点”methodSignatureForSelector“,断点断在一个NSTimer的Category里,这个category实现了”methodSignatureForSelector“方法,代码如下。

// 返回方法签名
- (NSMethodSignature *)methodSignatureForSelector:(SEL)selector {
    NSMethodSignature *signature = [self.target methodSignatureForSelector:selector];
    if (!signature) {
        signature = [NSMethodSignature signatureWithObjCTypes:"v"];
    }
    return signature;
}

这里实现逻辑分为两步,第一步,先去target里是的方法签名,如果存在直接返回。第二步,如果不存在,说明target没有实现这个方法,返回一个空的方法签名。

在我们模拟的场景里,timer调用的是一个未被实现的方法,所以会走到第二步,返回空的方法签名。

返回的空方法签名有很大嫌疑!!!

修复问题

修改方法签名为”v@“,再重新运行。见证奇迹的时刻到了,运行结果不再出现Crash。

[NSMethodSignature signatureWithObjCTypes:"v@:"];

我们回顾一下OC的基础知识。OC消息发送是有两个固定参数self和selector,方法签名里self用符号’@’,selector用’:’来表示。NSInvocation的参数里,前面两个参数就是self和selector。

objc_msgSend(id _Nullable self, SEL _Nonnull op, ...)

而空方法签名"v"并没有描述这两个参数,一个最简单的OC方法至少应该写成”v@:”。 因此,我们得到结论。因为方法签名里如果缺少参数符号,而invocation取参数时是会越界了。这个结论不好理解,

调试分析时,逻辑的严谨很重要。有时候因果关系很难理解,但只要推导过程逻辑严谨,它就是真理。

六、总结

总结一下排查的思路:

  • 第一步,基础面分析。收集Crash日志中有价值的信息。
  • 第二步,疑点分析。分析收集到的信息,找到可疑的地方。
  • 第三步,模拟现场。这个阶段需要不断假设和验证,寻找突破口。
  • 第四步,运行时分析。一步步调试,找到根本原因和修复的方案。