iOS内功:如何排查无法复现的系统内部Crash?
转自:掘金 Blacktea
一、问题概述
苹果每年都会升级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 __NSFireTimer3 _CF_forwarding_prep_04 [NSInvocation _invocationWithMethodSignature:frame:]5 _NSIGetArgumentAtIndex6 _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日志中有价值的信息。 第二步,疑点分析。分析收集到的信息,找到可疑的地方。 第三步,模拟现场。这个阶段需要不断假设和验证,寻找突破口。 第四步,运行时分析。一步步调试,找到根本原因和修复的方案。