启动优化常规方案
之前已经写过一期关于iOS启动优化的总结。回顾一下那些是针对于APP的pre-main
加载过程的优化。
- xcode添加环境变量
DYLD_PRINT_STATISTICS
- 打印
pre-main
过程的各阶段耗时情况,针对不同阶段进行分析
1、动态库载入耗时
优化方案:动态库苹果官方推荐最多使用6
个,大于6
个我们可以通过动态库的合并进行优化
2、重绑定耗时
优化方案:虚拟内存空间与物理内存空间的重绑定过程,可以通过二进制数据重排进行优化
3、oc类注册耗时
优化方案:减少oc类的定义,只要类在工程,就会引起内存的消耗
4、执行load、构造函数耗时
优化方案:将启动耗时操作放在子线程
binding与link的区别
如果了解APP从代码到ipa包,再到程序启动整个过程,你会发现binding
发生在程序的运行时过程,而link
是发生工程在编译时期。
二进制重排
除了那些常规的操作,我们还能从哪些地方入手也可以达到优化启动的目的呢?我们可以在pre-main
的重绑定过程下手。
虚拟内存的出现前景
早期计算机的发展,当时还是物理内存的时代,当时一直是在物理内存上通过扩容解决内存相关的一些问题,但是当时这个物理内存两个明显的问题:
- 内存不够用
- 安全问题
随着计算机的进一步发展这两个问题都得到了解决,主要是人类发明了虚拟内存。内存的加载方式也改成懒加载的方式,内存用到哪才去加载哪,避免了内存的不必要的浪费。
在内存中数据是一页一页的排列的,内存分页提高了内存执行效率。在CPU
的组成器件里面有个MMU
的元件,它被称为内存管理单元,它的主要作用是翻译内存地址。在内存中虚拟内存
和物理内存
之间有一张映射表
,如下图所示。
例如外挂不管如何修改访问的虚拟内存地址,都跳不开系统分配的进程内存访问空间,做到进程与进程的安全隔离
,每一个进程都有一个映射表
,保证了内存的安全。物理内存
它是由操作系统来进行管理的。
段与页的关系
段
是Mach-O
文件的格式,与内存没有任何关系。
页
是内存里面的单位。
在iOS系统中内存一页是16k
的大小,Mac系统内存一页是4k
的大小。
缺页异常(PageDeault)
手机访问的内存地址都是访问的虚拟地址,当用户操作某一项功能,其对应的虚拟内存并没有加载到物理内存空间的时候,操作系统会缺页异常,缺页中断,它会将CPU
中当前进程当前代码中断并卡住,同时操作系统会把当前虚拟内存数据在物理内存中找到一块合适的位置放入。
页面置换
操作系统载入数据时会置换掉物理内存中不那么活跃的那片物理内存
地址。
手机的虚拟内存有8G的大小,每一个应用只有4G的内存空间。为什么不是8G而是4G呢,是为了隔离32位,兼容32位操作系统。进程之间的通讯,只能使用系统提供的接口,给接口发送信号。针对数据不安全问题Apple提出了ASLR
技术。ASLR
的概念:(Address Space Layout Randomization ) 地址空间配置随机加载
,是一种针对缓冲区溢出
的安全保护技术
,通过对堆、栈、共享库映射等线性区布局的随机化,通过增加攻击者预测目的地址的难度,防止攻击者直接定位攻击代码位置,达到阻止溢出攻击的目的的一种技术。其目的的通过利用随机方式配置数据地址空间
,使某些敏感数据配置到一个恶意程序无法事先获知的地址,令攻击者难以进行攻击。由于ASLR
的存在,导致可执行文件和动态链接库在虚拟内存中的加载地址每次启动都不固定
,所以需要在编译时来修复镜像中的资源指针,来指向正确的地址。即正确的内存地址 = ASLR地址 + 偏移值
。正确的内存地址就是重定向rebase。ASLR技术就是为了内存的安全,让应用每次启动时都从一个随机地址开始,给一个偏移值。Mach-O文件中代码块的地址其实是一个偏移地址offset
。
二进制重排
为什么二进制重排为什么可以优化启动速度?
PageDeault
缺页一般在毫秒级别,当同时有大量缺页时,用户就会感知,什么会有这种情况呢,答案是冷启动。那么我们是如何可以很直观的看到内存缺页的数量呢?怎样才可以减少内存缺页呢?
- APP启动时内存缺页数量可以通过Xcode自带
instruments
里面的System Trace
分析。
经过分析demo的Pagefault
数量为184个。当然这只是一个测试demo,如果换成我们开发的项目这个数量可能就很惊人。启动时候的方法分布在内存中的不同页,将启动时刻的方法全部排在内存的最前面,是可以减少Pagefault
的次数的。
下面我们以demo为例,通过配置项目的order file
可以达到修改二进制的目的。有研究过objc底层的知道,在objc源码里面就有一个.order
文件
打开.order
文件之后内容如下
小结:.order
文件就是符号文件,给编译器用,让编译器将二进制按符号文件排列。
其次我们应该了解Xcode通过配置LinkMap
可以查看工程代码的实现排列顺序。
按照上图配置完成后编译工程后,将products
里面的.app
文件Show In Finder
最终我们可以找到一个linkMap
文件,将其打开
下面我们在demo的工程主目录下创建一个demo.order
文件
下面我们在.order
文件里面编辑创建一些符号
Xcode配置order file
编译完成后重新打开LinkMap
文件
很明显通过LinkMap
可以发现代码的实现顺序按照我们配置的.order
文件发生了修改。这是不是就表示我们的项目工程也能按照这种方式达到修改二进制的目的呢。答案是不能,因为我们的项目工程启动时调用的方法很有可能嵌套了多层,具有很复杂的结构,很难分析清楚方法的实际调用顺序。
拓展
我们需要的是启动调用的方法顺序,哪些方式可以获取呢?直接观察肯定不现实。
C函数,block。
-
hook
objc_msgSend()
。 - 通过脚本,扫描代码。
- 100%
hook
到所有的方法 使用Clang插桩
。
Clang插桩
首先我们在研究Clang插桩
前,熟悉一下官方关于Clang插桩的文档
文档重点在于
Tracing PCs
,它的主要作用是跟踪CPU
执行的代码。
- 创建一个测试Demo,在
build setttin
g里面other c flag
s配置-fsanitize-coverage=trace-pc-guard
- 并将文档中的Example拷贝进工程,并buid看是否能成功
__sanitizer_cov_trace_pc_guard_init(uint32_t *start, uint32_t *stop)
函数会开辟一段内存空间会把起始和结束位置的内存地址给我们。通过stop
和start
我们可以确定符号的个数,也就是这个函数可以全局监控函数的个数。验证一下我们的猜想。首先拿到stop
的数据我们减去uint32_t
类型占用的空间4,就可以拿到最后的一个数据。打上断点,我们打印最后一个数据的内存地址如下
此时我们可以通过内存数据可以看到符号总个数是11,现在我们添加一个函数方法观察这个值是否变化呢?
添加函数方法后,符号的总个数变成了12,证明我们的猜想是正确的的。
我们目标是拿到所有的符号名称和顺序并生成.order
文件。__sanitizer_cov_trace_pc_guard(uint32_t *guard)
函数是可以hook到一切的函数,我们可以以它为出发点研究。
我们开辟一条子线程,并在函数中打印当前线程。
从打印结果可以看到函数__sanitizer_cov_trace_pc_guard(uint32_t *guard)
回调回调是多线程的,会存在多线程的访问,需要注意线程安全。其中回调返回结果里面void*PC = __builtin_return_address(0)
,PC
地址是上一个函数地址,有这个地址我们就有机会获取到函数符号名称。
我们将PC指向那个函数区域信息传递给DL_info
结构体,而函数调用栈就是通过return
的地址来显示出来的。结果我们发现控制台打印的结果死循环了。为啥呢?clang会hook
到while循环。经过摸索发现修改other c flags
为-fsanitize-coverage=func,trace-pc-guard
可以解决
修改配置后buid功能,点击屏幕发现控制台打印如下
貌似输出结果跟我们需要的符号名称很接近了,现在只需去重,将调用顺序倒过来就ok了。
经过去重和排序输出结果如下
我们可以通过沙盒拿到我们最终的.order
文件,打开文件如下
终于功夫不负有心人得到了我们的.order
文件了,那么我们可以按照本文开头的步骤陪LinkMap
及order file
文件了,配置完成后我们编译查看一下LinkMap
文件
根据LinkMap
的结果说明我们我们重排二进制成功了。下一步我们就可以根据demo的方法重排二进制数据,观察我们的内存Pagefault
数量是否有变化了。
OC与Swift混编项目二进制重排
关于OC
与Swift
混编二进制重排其实方法同上,只是buid setting
需要额外添加以下配置。other Swift flags
添加-sanitize-coverage=func
和-sanitize=undefined
。
总结
-
Clang插桩
只会hook带实现函数方法。 - 只要添加
Clang插桩
标记,那么编译器就会在所有的方法
、函数
、block
的代码边缘添加一句__sanitizer_cov_trace_pc_guard
代码,Clang插桩
是官方方案,常用于做代码的审查,一定程度上会损耗性能。所有我们在拿到.order
文件之后需要清除Clang插桩
标记。 - 在
Clang插桩
记录符号名称时需要将拿到的数据放在一个线程安全的原子队列里面去记录符号的执行顺序 - 项目开发过程不要造成资源浪费,更多的要在
业务逻辑
上优化。任何优化都是建立在浪费的基础之上,没有浪费就没有优化的空间。
iOS启动优化之二进制重排就分析到这,本文中阐述的观点纯属个人观点,如有问题欢迎评论指正。需要文中测试demo的请下方留言!