OC底层原理34:启动优化(三)二进制重排
前言
在之前的两篇文章中,大致介绍了一些基本概念以及启动优化的思路,下面来着重介绍一个 pre-main阶段
的优化方案,即 二进制重排
,这个方案最开始是由抖音的这篇文章抖音研发实践:基于二进制文件重排的解决方案 APP启动速度提升超15% 提出来的。
二进制重排实现
在虚拟内存部分,我们知道,当进程访问一个虚拟内存page,而对应的物理内存不存在时,会触发
缺页中断(Page Fault)
,因此阻塞进程,此时就需要先加载数据到物理内存,然后继续访问,这个对性能有一定影响的。如果存在大量的缺页中断
,会非常的耗时。基于
Page fault
,我们思考,APP在冷却启动过程中,会有大量的类、分类、三方等需要加载和执行,此时产生的Page Fault
所带来的的耗时是很大的,以实际项目
为例,我们来看下,在启动阶段的Page Fault
的次数
CMD + i
快捷键,选择System Trace
- 点击启动(启动前需要重启手机,清除缓存数据),第一个界面出来后,停掉,按照下图中操作
从图中可以看出 实际项目
发生的 Page Fault
有 1101
次,可想而知,这个是非常影响性能的。
- 下面,我们通过
Demo
查看方法在编译时期的排列顺序,在ViewController
中按下列顺序定义以下几个方法
1 | void test1(){ |
- 在
Build Setting
->write Link Map File
设置为YES
CMD + B
编译Demo
,查看执行顺序
打印结果为3 2 1
,那么代码的加载顺序
是什么?show in finder
在对应路径下查找link map
文件,如下图所示:
- 打开
.txt
文件,可以发现类中函数的加载顺序是从上到下
的,而文件
的顺序是根据Buid Phases
->Compile Source
中的顺序加载的
- 从上面的
Page fault
的次数以及加载顺序,可以发现其实导致 Page Fault 次数过多的根本原因是启动时刻需要调用的方法,处于不同的 Page Fault 导致的
,因此我们的优化思路就是:将所有启动时刻需要调用的方法,排列在一起,即放在一个页中,这样就从多个 Page Fault变成了一个Page Fault
,这就是二进制重排
的核心原理,如下所示:
注意:在iOS生成环境中的APP,在发生
Page Fault
进行重新加载时,iOS系统还会对其做一次 签名验证,因此 iOS 生产环境的Page Fault
比 Debug环境下所产生的耗时更多
二进制重排实践
下面,我们来进行具体的实践,首先先理解几个名词:
- Link Map
linkmap
是iOS编译过程的中间产物,记录了二进制文件的布局
,需要在Xcode的 Build Settings
里面开启 Write Link Map File
,Link Map
主要包含三个部分:
Object Files
:生成二进制用到的link单元的路径和文件编号Sections
:记录Mach-O
每个Segment/Section
的地址范围Symbols
:按照顺序记录每个符号的地址范围
- ld
ld
是Xcode使用的链接器,有一个参数 order_file
,我们可以通过在 Build Settings
-> Order Files
配置一个后缀为 order
的文件路径,在这个 order
文件中,将所需要的符号按照顺序写在里面,在项目编译时,会找找这个文件的顺序进行加载,以此来达到我们的优化
所以说,二进制重排的本质就是对启动加载的符号进行重新排列
到目前为止,原理我们基本弄清楚了,如果项目比较小,完全可以自定义一个 order
文件,将方法的顺序手动添加,但是如果项目比较大,设计到的方法特别多,此时我们如何获取启动运行的函数呢?有以下几种思路:
hook objc_msgsSend
:我们知道,函数的本质就是发送消息,在底层都会来到objc_msgSend
,但是由于objc_msgSend
的参数是可变的,需要通过汇编
获取,对开发人员要求较高,而且也只能拿到oc
和swift
中@objc
后的方法静态扫描
:扫描mach-o
特定段和节里面所存储的符号一级函数数据Clang插桩
:即批量hook
,可以实现100%符号覆盖
,即完全获取swift、oc、block
函数
自定义 order 文件,改变执行顺序
- cd 到
Demo
根目录,新建order
文件
1 | zhangjian@zhangjiandeMBP ~ % cd /Users/zhangjian/Desktop/Demo |
- order 文件中书写如下内容
- 在
build setting
搜索order file
将根目录下的order文件路径填入:./zj.order
cmd + b
重新编译,没有报错,说明有一个符号没有找不到会自动或略掉,继续查看link map
文件
由上图可知,main
变成第一位、viewDidLoad
变成第二位、test1
变成第三位,这就是 二进制重排
Clang 插桩
llvm
内置了一个简单的代码覆盖率检测(SanitizerCoverage
),它在函数级、基本块级和边缘级插入对用户定义函数的调用,我们这里的 批量hook
,就需要借助于 SanitizerCoverage
关于clang的插桩覆盖的官方文档如下:clang自带代码覆盖工具 文档中有详细概述,以及简短Demo演示
- 【第一步:配置】开启
SanitizerCoverage
- OC项目,需要在:
Build Settings
里面的Other C Flags
中添加-fsanitize-coverage=trace-pc-guard
- 此时
CMD + B
编译,会报两个符号错误,说明添加了上面就会调用下面的两个符号:__sanitizer_cov_trace_pc_guard_init
和__sanitizer_cov_trace_pc_guard
- 【第二步:重写方法】
- 将官方文档中的两个方法拷贝到
TraceDemo
项目中,如下图所示并运行,会报错找不 到__sanitizer_symbolize_pc
,我们将其注释,再次运行,成功
1 | #import "ViewController.h" |
- __sanitizer_cov_trace_pc_guard_init 方法
- 由上面运行结果可知,
start
=0x10204d3a0
,stop
=0x10204d3d8
,那么这个是什么意思呢?
- 参数1
start
是一个指针,指向无符号intl类型,4个字节,相当于一个数组的起始位置
,即符号的起始位置(是从高位往低位读)
- 参数2
stop
,由于数据的地址是往下读的(即 从高往低读,所以此时获取的地址并不是stop真正的地址,而是标记的最后的地址,读取stop时,由于stop占了4个字节,stop 真实地址 = stop打印的地址-0x4)
- stop 内存地址中存储的值表示什么?在增加一个
方法/block/属性
后,发现其值也会增加对应的数,例如增加一个 touchesBegan方法,运行并打印得到INIT: 0x104f0d3a8 0x104f0d3e4
,重新查看start
和stop
的值:
由此可见
增加一个方法
对应 stop值就增加一个
- 【第三步:通过返回地址拿到符号】
__sanitizer_cov_trace_pc_guard
方法,主要是捕获所有的启动时刻的符号,将所有符号入队参数
guard
是一个哨兵
,告诉我们是第几个被调用的
引入库
#import <dlfcn.h>
,并点击屏幕可以通过返回地址拿到符号
1 | void __sanitizer_cov_trace_pc_guard(uint32_t *guard) { |
查看打印结果:
1 | // 文件的内存地址 |
- 【第四步:创建队列保存符号】
引入头文件
#import <OSAtomic.h>
,符号的存储需要借助于链表,所以需要定义链表节点 ZJNode通过 OSQueueHead 创建原子队列,其目的是保证线程安全
通过 OSAtomicEnqueue 方法将 node 入队,通过链表 next 指针可以访问下一个符号
1 | // 原子队列-线程安全 |
- 【第五步:解决死循环问题】
- 当我们重新运行,点击屏幕,会发现一直重新打印,出现死循环
touchesBegan
- 我们打开汇编调式,发现有
3
个__sanitizer_cov_trace_pc_guard
调用
第一次 bl 是
touchBegin
;第二次是因为while
循环,即只要是跳转
,就会被hook
,即有bl/b
的指令,就会被hook
;第三次就printf
解决方式:将
Build Setting
中的Other C Flags
的-fsanitize-coverage=trace-pc-guard
改成-fsanitize-coverage=func,trace-pc-guard
,再次运行,看下面的结果显示已经没有死循环了
由上面的结果可知,还有几个问题需要解决:
重复、顺序是反的,并且没有load
- 【第六步:生成数组去重、取反】
- 添加
+ load
方法(需要注释掉if (!*guard) return;
这句代码)、调用test、block
方法,重新点击屏幕并查看结果
1 | + (void)load{ |
查看打印结果:
- 取反、去重
1 | - (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event{ |
重新运行,查看打印结果:
- 【第七步:生成order文件】
- 数组转成字符串
1 | // 数组转成字符串 |
- 字符串写入文件
1 | // 字符串写入文件 |
- 【第八步:找到order文件,并配置order文件】
- CMD + shift + 2 -> Devices -> TraceDemo -> Download Container 将文件下载到桌面 -> 显示包内容 -> tmp -> 找到 zj.order
- 配置
zj.order
到项目中,首先link map
设值为YES
,配置前:
- 将
zj.order
放在根目录,配置order file
为./zj.order
,配置后:
- 此时发现配置后的执行顺序和配置前的执行顺序有明显的区别,此时此时
二进制重排成功
。
补充
如果是Swift项目,还需要额外在
Other Swift Flags
中加入-sanitize-coverage=func
和-sanitize=undefined
OC 和 swift 混编
- OC 中引入头文件
- 按照上面的进行配置并查看
zj.order
- Post title:OC底层原理34:启动优化(三)二进制重排
- Post author:张建
- Create time:2021-05-17 14:44:54
- Post link:https://redefine.ohevan.com/2021/05/17/OC底层原理/OC底层原理34:启动优化(三)二进制重排/
- Copyright Notice:All articles in this blog are licensed under BY-NC-SA unless stating additionally.