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

2,341 阅读6分钟

背景

9月初iOS14正式发布,线上版本新冒出许多Crash。有一个Crash,UICollectionView刷新逻辑死循环,卡死了主线程。

阳差阳错,中美两个程序员的“误会”造成了这个Crash。

App有一个页面,自定义了一个XXCollectionView。XXCollectionView嵌套在Cell里,写代码的人偷懒,把delegate设置成自己。Apple工程师也不讲武德,把协议(UICollectionViewDelegate)没声明的方法转发给delegate执行,一点契约精神都没有。

Apple框架大多不开源,内部有奇怪的逻辑,我们只能通过读汇编指令去分析。刚开始分析源码时,奇怪的逻辑让我怀疑人生,最后通过调试汇编指令,才理清楚具体的原因。

这个案例挺经典的,分析过程中,用到了oc、lldb调试、arm等常用知识,也用到了归纳法、逻辑推理、逆向思维、抽象思维等思维方法。

我详细总结了分析过程,和大家交流和探讨。

iOS内功系列文章

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

【iOS内功】Crash分析模型

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

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

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

1.0、Crash Log特征分析

1.1、环境特征

crash都发生在iOS14,推断是iOS14系统库逻辑改动引发的Crash。

1.2、调用堆栈

堆栈里有UICollectionview和CPXXProxy,接下来可以找一下,哪些页面同时用到这两个类,

swipe点击事件
...
-[UICollectionView _diffableDataSourceImpl]
...
_CF_forwarding_prep_0
...
-[CPXXProxy forwardInvocation]
...
-[UICollectionView _diffableDataSourceImpl] (开始循环)
...
_CF_forwarding_prep_0
...
-[CPXXProxy forwardInvocation]

1.3、场景复现

符合条件的页面很多,试了几个都复现不了。后面发现有一个页面A,abort日志里有很多swipe事件,页面也用了UICollectionview。点击页面A的各种区域,最后在点击空白处卡死了,并且复现了同样的堆栈。

2.0、源码调试分析

为了简洁,下面的术语用缩略词表示
diffxx代表_diffableDataSourceImpl
CPXXProxy代表_CPCollectionViewFlowLayoutProxy

2.1、页面A和页面B有什么差别?

很多页面虽然也用了UICollectionView和CPXXProxy,但不会出现这个crash。于是我找了其中一个页面,下面称为页面B,从逻辑上推导,页面A和页面B逻辑上一定有一个差异,这个差异最终造成页面A的Crash。接下来,分析页面A和B的差异。

差异1:页面A里嵌套了UICollectionview

页面A和页面B有一个差异点,页面A的UICollectionView有多种cell,其中一个cell里又嵌套了UICollectionView。cell里的XXCollectionview继承自系统的UICollectionview。

差异2:页面B不会调用触发“-[CPXXProxy forwardInvocation]”

找到关键路径的3个方法,添加符号断点。模拟Crash的操作,分析页面A和页面B调用栈的差异.

-[UICollectionView _diffableDataSourceImpl]
_CF_forwarding_prep_0
-[CPXXProxy forwardInvocation]

调试发现,页面A会调用到“-[CPXXProxy forwardInvocation]”,而页面B并不会。页面A走到forwardInvocation,说明系统给CPXXProxy发送了未实现的方法,这个方法是“_diffableDataSourceImpl”。

2.2、_diffableDataSourceImpl方法是哪里定义的?

iOS13引入了DiffableDataSource,帮助UITableView和UICollection更方便地实现局部刷新。对外开发的类是NSDiffableDataSourceSnapshot,并没有diffxx方法。

使用runtime的接口,导出UICollectionView所有的方法,发现里面包括diffxx,diffxx方法并没有对外开放,是一个私有方法。

2.3、页面A为什么会调用到"-[CPXXProxy _diffableDataSourceImpl]"

diffxx是UICollectionView自己的方法,为什么转发给delegate,这是挺奇怪的逻辑。

CPXXProxy被设置为UICollectionView的delegate,它会接收到UICollectionViewDelegate协议声明的方法,但UICollectionViewDelegate里并没有diffxx方法,理论上不应该触发这个方法的调用。

"-[CPXXProxy _diffableDataSourceImpl]"是UIKit内部逻辑触发的,而UIkit的源码没有开源,所以接下来只能调试Arm汇编继续分析。

3.0、汇编调试分析

我们应该从哪个方法入手?梳理一下思路。

“页面A为什么会出现异常”?触发异常逻辑肯定有一个源点,在这个源点之前页面A的逻辑也应该是正常的。

页面B作为正常的参照物,我们要找到它和页面A出现逻辑分叉的地方。对比页面A和页面B的调用栈,它们最后一个相同的方法是“-[UICollectionView _diffableDataSourceImpl]”,逻辑分叉就在这个方法里,因此我们就从这个方法入手分析。

3.1、“-[UICollectionView _diffableDataSourceImpl]”哪行指令出现逻辑分叉?

页面A指令

w0寄存器的值是1,没有命中tbz指令的跳转,按顺序继续执行下一行指令。一直执行到bl 0x196d04820指令,跳转到0x196d04820所在的函数。

注1:tbz指令

bl 0x196d04820里面经过几次跳转,最后执行objc_msgSend方法。根据寄存器的值,objc_msgSend里的target是“CPXXProxy”,selector就是是"diffxx"

CPXXProxy里并没有实现diffxx函数,进行消息转发_CF_forwarding_prep_0

页面B指令

w0寄存器的值是0,命中tbz指令的跳转,跳转到0x1991c12ac继续执行指令,后续也没有调用到CPXXProxy的方法。

结论

页面A和页面B的分叉点在tbz指令。tbz是一个条件跳转,页面A里tbz的测试值w0为1,页面B的测试值为0,最后走到了不同的逻辑。

3.2、w0的差异,是哪里造成的?

我们要找的关键指令就是“tbz w0 #0x0”,下面分析哪里将w0的值改为1。

执行"bl 0x197157750"指令前x0寄存器还是一个对象,执行后x0寄存器就成了1,说明这个方法调用的返回值就是1,也就是true。

进入"bl 0x197157750"调试,发现最终调用的方法是“CPXXXProxy respondsToSelector”,这个方法的返回值是true。也就是说,调用“-[CPXXXProxy respondsToSelector]”方法时,页面A和页面B结果不一样。

3.3、为什么“-[CPXXXProxy respondsToSelector]”的返回值不一样

CPXXXProxy有源码,直接分析源码的逻辑。

CPXXXProxy 简介

CPXXXProxy对象里有一个target属性,它是Cell里嵌套的XXCollectionView。

XXCollectionView的delegate和datasource设置为CPXXXProxy,collectionVie的回调方法先发给CPXXXProxy,CPXXXProxy接管了部分方法,自己不接管的回调方法,CPXXXProxy会再转发给target自行处理。

根据截图显示,调用了“-[_target respondsToSelector:aSelector]”,运行结果是true。说明页面A的_target实现了“_diffableDataSourceImpl”方法,而页面B的_target并没有实现。

在页面A,XXCollectionView嵌套在cell里,CPXXXProxy的target设置为XXCollectionView。而在页面B,XXCollectionView没有嵌套,CPXXXProxy的target设置为页面B的页面控制器,XXViewController。

“_diffableDataSourceImpl”本身就是UICollectionView的方法,而XXCollectionView继承于UICollectionView,结果当然是true。

4.0、总结

分析到这里,已经豁然开朗,下图是死循环的调用链路。

  • =>"-[XXCollectionView _diffableDataSourceImpl"]"
  • =>"-[CPXXXProxy respondsToSelector:@"_diffableDataSourceImpl"]"
    • => "-[XXCollectionView respondsToSelector:@"_diffableDataSourceImpl"]" 结果是True
  • =>"-[CPXXXProxy _diffableDataSourceImpl"]"
  • =>"-[CPXXXProxy forwardInvocation"]"
  • =>"-[XXCollectionView _diffableDataSourceImpl"]"
  • =>...死循环

参考

注1:tbz指令

测试位为0发生跳转,imm指定目的寄存器的某一个位,『b5:b40』组成,0-63或者0-31,有b5决定。哪个目的寄存器由Rt指定,label是偏移地址。

TBNZ介绍 www.cnblogs.com/rongmouzhan…