OC底层原理34:启动优化(三)二进制重排

张建 lol

前言

在之前的两篇文章中,大致介绍了一些基本概念以及启动优化的思路,下面来着重介绍一个 pre-main阶段 的优化方案,即 二进制重排,这个方案最开始是由抖音的这篇文章抖音研发实践:基于二进制文件重排的解决方案 APP启动速度提升超15% 提出来的。

二进制重排实现

  1. 在虚拟内存部分,我们知道,当进程访问一个虚拟内存page,而对应的物理内存不存在时,会触发 缺页中断(Page Fault),因此阻塞进程,此时就需要先加载数据到物理内存,然后继续访问,这个对性能有一定影响的。如果存在大量的 缺页中断,会非常的耗时。

  2. 基于 Page fault,我们思考,APP在冷却启动过程中,会有大量的类、分类、三方等需要加载和执行,此时产生的 Page Fault 所带来的的耗时是很大的,以 实际项目 为例,我们来看下,在启动阶段的 Page Fault 的次数

  • CMD + i 快捷键,选择 System Trace

  • 点击启动(启动前需要重启手机,清除缓存数据),第一个界面出来后,停掉,按照下图中操作

从图中可以看出 实际项目 发生的 Page Fault1101 次,可想而知,这个是非常影响性能的。

  1. 下面,我们通过 Demo 查看方法在编译时期的排列顺序,在 ViewController 中按下列顺序定义以下几个方法
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
void test1(){
printf("1");
}

void test2(){
printf("2");
}

- (void)viewDidLoad {
[super viewDidLoad];

test1();
}

+ (void)load{
printf("3");
test2();
}
  1. Build Setting -> write Link Map File 设置为 YES

  1. CMD + B 编译 Demo,查看 执行顺序 打印结果为 3 2 1,那么代码的 加载顺序 是什么?show in finder 在对应路径下查找 link map 文件,如下图所示:

  • 打开 .txt 文件,可以发现类中 函数的加载顺序是从上到下 的,而 文件 的顺序是根据 Buid Phases -> Compile Source 中的顺序加载的

  1. 从上面的 Page fault 的次数以及加载顺序,可以发现其实 导致 Page Fault 次数过多的根本原因是启动时刻需要调用的方法,处于不同的 Page Fault 导致的,因此我们的优化思路就是:将所有启动时刻需要调用的方法,排列在一起,即放在一个页中,这样就从多个 Page Fault变成了一个Page Fault,这就是 二进制重排 的核心原理,如下所示:

注意:在iOS生成环境中的APP,在发生 Page Fault 进行重新加载时,iOS系统还会对其做一次 签名验证,因此 iOS 生产环境的 Page Fault 比 Debug环境下所产生的耗时更多

二进制重排实践

下面,我们来进行具体的实践,首先先理解几个名词:

  1. Link Map

linkmap 是iOS编译过程的中间产物,记录了二进制文件的布局,需要在Xcode的 Build Settings 里面开启 Write Link Map FileLink Map 主要包含三个部分:

  • Object Files :生成二进制用到的link单元的路径和文件编号

  • Sections :记录 Mach-O 每个 Segment/Section 的地址范围

  • Symbols :按照顺序记录每个符号的地址范围

  1. ld

ld 是Xcode使用的链接器,有一个参数 order_file,我们可以通过在 Build Settings -> Order Files 配置一个后缀为 order 的文件路径,在这个 order 文件中,将所需要的符号按照顺序写在里面,在项目编译时,会找找这个文件的顺序进行加载,以此来达到我们的优化

所以说,二进制重排的本质就是对启动加载的符号进行重新排列

到目前为止,原理我们基本弄清楚了,如果项目比较小,完全可以自定义一个 order 文件,将方法的顺序手动添加,但是如果项目比较大,设计到的方法特别多,此时我们如何获取启动运行的函数呢?有以下几种思路:

  • hook objc_msgsSend:我们知道,函数的本质就是发送消息,在底层都会来到 objc_msgSend,但是由于 objc_msgSend 的参数是可变的,需要通过 汇编 获取,对开发人员要求较高,而且也只能拿到 ocswift@objc 后的方法

  • 静态扫描:扫描 mach-o 特定段和节里面所存储的符号一级函数数据

  • Clang插桩:即 批量hook,可以实现 100%符号覆盖,即完全获取 swift、oc、block函数

自定义 order 文件,改变执行顺序

  • cd 到 Demo 根目录,新建 order 文件
1
2
zhangjian@zhangjiandeMBP ~ % cd /Users/zhangjian/Desktop/Demo 
zhangjian@zhangjiandeMBP Demo % touch zj.order
  • order 文件中书写如下内容

  • build setting 搜索 order file 将根目录下的order文件路径填入:./zj.order

  • cmd + b 重新编译,没有报错,说明有一个符号没有找不到会自动或略掉,继续查看 link map 文件

由上图可知,main 变成第一位、viewDidLoad 变成第二位、test1 变成第三位,这就是 二进制重排

Clang 插桩

llvm 内置了一个简单的代码覆盖率检测(SanitizerCoverage),它在函数级、基本块级和边缘级插入对用户定义函数的调用,我们这里的 批量hook,就需要借助于 SanitizerCoverage

关于clang的插桩覆盖的官方文档如下:clang自带代码覆盖工具 文档中有详细概述,以及简短Demo演示

  1. 【第一步:配置】开启 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

  1. 【第二步:重写方法】
  • 将官方文档中的两个方法拷贝到 TraceDemo 项目中,如下图所示并运行,会报错找不 到 __sanitizer_symbolize_pc,我们将其注释,再次运行,成功
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
#import "ViewController.h"
// trace-pc-guard-cb.cc
#include <stdint.h>
#include <stdio.h>
#include <sanitizer/coverage_interface.h>


@interface ViewController ()

@end

@implementation ViewController

- (void)viewDidLoad {
[super viewDidLoad];
// Do any additional setup after loading the view.
}

void test1(){

}

void (^block1)(void) = ^{

};


void __sanitizer_cov_trace_pc_guard_init(uint32_t *start,
uint32_t *stop) {
static uint64_t N; // Counter for the guards.
if (start == stop || *start) return; // Initialize only once.
printf("INIT: %p %p\n", start, stop);
for (uint32_t *x = start; x < stop; x++)
*x = ++N; // Guards should start from 1.
}

void __sanitizer_cov_trace_pc_guard(uint32_t *guard) {
// 将load方法过滤掉了,所以需要注释掉
if (!*guard) return;
// 获取PC
/*
- PC 当前函数返回上一个调用的地址
- 0 当前这个函数地址,即当前函数的返回地址
- 1 当前函数调用者的地址,即上一个函数的返回地址
*/
void *PC = __builtin_return_address(0);
char PcDescr[1024];
// 再次运行这个符号找不到,找不到我就不调用了吗,删掉
// __sanitizer_symbolize_pc(PC, "%p %F %L", PcDescr, sizeof(PcDescr));
printf("guard: %p %x PC %s\n", guard, *guard, PcDescr);
}

- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event{
test1();
block1();
}

  1. __sanitizer_cov_trace_pc_guard_init 方法
  • 由上面运行结果可知,start = 0x10204d3a0stop = 0x10204d3d8,那么这个是什么意思呢?

  • 参数1 start 是一个指针,指向无符号intl类型,4个字节,相当于一个数组的 起始位置,即符号的起始位置(是从高位往低位读)

  • 参数2 stop ,由于数据的地址是往下读的(即 从高往低读,所以此时获取的地址并不是stop真正的地址,而是标记的最后的地址,读取stop时,由于stop占了4个字节,stop 真实地址 = stop打印的地址-0x4)

  • stop 内存地址中存储的值表示什么?在增加一个 方法/block/属性 后,发现其值也会增加对应的数,例如增加一个 touchesBegan方法,运行并打印得到 INIT: 0x104f0d3a8 0x104f0d3e4,重新查看 startstop 的值:

由此可见 增加一个方法 对应 stop值就增加一个

  1. 【第三步:通过返回地址拿到符号】
  • __sanitizer_cov_trace_pc_guard 方法,主要是捕获所有的启动时刻的符号,将所有符号入队

  • 参数 guard 是一个 哨兵,告诉我们是 第几个被调用的

  • 引入库 #import <dlfcn.h> ,并点击屏幕可以通过返回地址拿到符号

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
void __sanitizer_cov_trace_pc_guard(uint32_t *guard) {
if (!*guard) return;
// 当前返回上一个调用的地址
void *PC = __builtin_return_address(0);
// 通过返回地址PC,获取符号
Dl_info info;
dladdr(PC, &info);
printf("frame:%s \nfbase:%p \nsname:%s \nsaddr:%p\n\n",info.dli_fname,info.dli_fbase,info.dli_sname,info.dli_saddr);


char PcDescr[1024];
// 再次运行这个符号找不到,找不到我就不调用了吗,删掉
// y__sanitizer_symbolize_pc(PC, "%p %F %L", PcDescr, sizeof(PcDescr));
// printf("guard: %p %x PC %s\n", guard, *guard, PcDescr);
}

查看打印结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 文件的内存地址
frame:/private/var/containers/Bundle/Application/A9B590E7-FC2A-4C54-85C1-FFFE55ACE448/TraceDemo.app/TraceDemo
// 文件内存地址
fbase:0x10251c000
// 符号名称
sname:-[ViewController touchesBegan:withEvent:]
// 起始位置地址
saddr:0x102521b78

frame:/private/var/containers/Bundle/Application/A9B590E7-FC2A-4C54-85C1-FFFE55ACE448/TraceDemo.app/TraceDemo
fbase:0x10251c000
sname:test1
saddr:0x1025219e0

frame:/private/var/containers/Bundle/Application/A9B590E7-FC2A-4C54-85C1-FFFE55ACE448/TraceDemo.app/TraceDemo
fbase:0x10251c000
sname:block1_block_invoke
saddr:0x1025219fc
  1. 【第四步:创建队列保存符号】
  • 引入头文件 #import <OSAtomic.h>,符号的存储需要借助于链表,所以需要定义链表节点 ZJNode

  • 通过 OSQueueHead 创建原子队列,其目的是保证线程安全

  • 通过 OSAtomicEnqueue 方法将 node 入队,通过链表 next 指针可以访问下一个符号

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
// 原子队列-线程安全
static OSQueueHead symbolList = OS_ATOMIC_QUEUE_INIT;

// 定义符号结构体,以链表的形式
typedef struct{
// 指针8个字节
void *pc;
void *next;
}ZJNode;

void __sanitizer_cov_trace_pc_guard(uint32_t *guard) {
// 将load方法过滤掉了,所以需要注释掉
if (!*guard) return;
// 当前返回上一个调用的地址
void *PC = __builtin_return_address(0);

// 创建结构体
ZJNode * node = malloc(sizeof(ZJNode));
*node = (ZJNode){PC,NULL};

// 加入结构体
// 符号的访问不是通过下标访问,是通过链表的next指针,所以需要借用offsetof(结构体类型,下一个的地址即next)
OSAtomicEnqueue(&symbolList, node, offsetof(ZJNode, next));

// 通过返回地址PC,获取符号
Dl_info info;
dladdr(PC, &info);
// printf("frame:%s \nfbase:%p \nsname:%s \nsaddr:%p\n\n",info.dli_fname,info.dli_fbase,info.dli_sname,info.dli_saddr);


char PcDescr[1024];
// 再次运行这个符号找不到,找不到我就不调用了吗,删掉
// y__sanitizer_symbolize_pc(PC, "%p %F %L", PcDescr, sizeof(PcDescr));
// printf("guard: %p %x PC %s\n", guard, *guard, PcDescr);
}

- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event{
while (YES) { // 一次循环!也会被HOOK一次
ZJNode * node = OSAtomicDequeue(&symbolList, offsetof(ZJNode, next));
if (node == NULL) {
break;
}
Dl_info info = {0};
dladdr(node->pc, &info);
printf("%s \n",info.dli_sname);
}
}
  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

  1. 【第六步:生成数组去重、取反】
  • 添加 + load 方法(需要注释掉if (!*guard) return;这句代码)、调用test、block方法,重新点击屏幕并查看结果
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
+ (void)load{

}

- (void)viewDidLoad {
[super viewDidLoad];

test();
}

void test(){
block();
}

void (^block)(void) = ^{

};

查看打印结果:

  • 取反、去重
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event{
// 定义数组
NSMutableArray<NSString *> * symbolNames = [NSMutableArray array];

while (YES) {
ZJNode * node = OSAtomicDequeue(&symbolList, offsetof(ZJNode, next));
if (node == NULL) {
break;
}
Dl_info info = {0};
dladdr(node->pc, &info);
// printf("%s \n",info.dli_sname);
// 转成字符串
NSString * name = @(info.dli_sname);

if ([name hasPrefix:@"+["] ||
[name hasPrefix:@"-["]) {
// 如果是oc方法名称直接存
[symbolNames addObject:name];
continue;
}
// 如果不是oc直接加个_存
[symbolNames addObject:[@"_" stringByAppendingString:name]];
}
// 反向遍历数组
symbolNames = (NSMutableArray<NSString *> *)[[symbolNames reverseObjectEnumerator] allObjects];
// 去重
NSEnumerator * enumerator = [symbolNames reverseObjectEnumerator];
NSMutableArray * funcs = [NSMutableArray arrayWithCapacity:symbolNames.count];
NSString * name;
while (name = [enumerator nextObject]){
// 数组中不包含name
if (![funcs containsObject:name]) {
[funcs addObject:name];
}
}
NSLog(@"%@",funcs);
}

重新运行,查看打印结果:

  1. 【第七步:生成order文件】
  • 数组转成字符串
1
2
// 数组转成字符串
NSString * funcStr = [symbolNames componentsJoinedByString:@"\n"];
  • 字符串写入文件
1
2
3
4
5
6
// 字符串写入文件
// 文件路径
NSString * filePath = [NSTemporaryDirectory() stringByAppendingPathComponent:@"zj.order"];
// 文件内容
NSData * fileContents = [funcStr dataUsingEncoding:NSUTF8StringEncoding];
[[NSFileManager defaultManager] createFileAtPath:filePath contents:fileContents attributes:nil];
  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.