上篇文章我们讲了虚拟内存。应用程序在运行的时候会有一个虚拟内存,虚拟内存是分页管理的,它通过页表映射到物理内存上面。分页管理有一个特点,当加载新的一块功能的时候,对应的某一页数据不在物理内存的时候,系统会缺页中断pageFault,而pageFault是需要时间的,用户在使用过程中,几毫秒实际上用户是感知不到的;但是在应用启动的时候,会有大量代码需要执行,此时会有数量众多的pageFault,这样一累计,用户就可以感知到了。
今天要研究的,就是通过一项技术来减少启动时的pageFault,进而缩减启动时间,这个技术就是二进制重排。
二进制重排这项技术为大众所熟知最初是源于抖音团队的这篇文章:
https://mp.weixin.qq.com/s/Drmmx5JtjG3UtTFksL6Q8Q
大家有兴趣的话可以去看一下。
想要优化,首先要学会调试。接下来我们就来看看如何去获取pageFault的次数。
测量PageFault次数
打开Instruments工具集,找到System Trace:
打开之后,依次选择好设备和要执行的APP,然后点击左上角的红点启动:
然后就开始分析,分析完成之后,找到对应APP下面的main thread,然后查看虚拟内存VertialMemary,File Backed Page In对应的Count就是启动期间的pageFault次数:
可以看到,第一次启动的时候的缺页中断次数是2433。
现在我在模拟器中杀掉TestApp,然后立马再执行SystemTrace,结果如下:
此时的缺页中断次数是49,跟第一次的2433相比,可谓是差了不止一个数量级。这是为什么呢?在我的印象中,App被杀死之后再启动就是冷启动了呀,同样是冷启动,为什么前后两次相差这么多呢?
实际上,当App被杀死之后,有可能它并不会立马从物理内存中被移除,这些都是由系统来做的,我只能说是不一定会立马被从物理内存中移除。要想完完整整地去测试冷启动的缺页中断次数的话,可以在杀死app之后再打开几个其他的APP,然后再过个一两分钟之后,再启动这个APP的话,就应该是冷启动了。
二进制重排步骤初体验
上面我们了解了如何去测量启动阶段pageFault的次数,接下来就来初步体验一下二进制重排。
实际上,二进制重排的步骤并不复杂,真正的难点在于如何按照函数的执行顺序去重新排列页表中的page。
Xcode使用的链接器是ld,ld中有一个参数是order file,order file是一个文件路径,它指向了order文件,order文件中写入的是符号的顺序,Xcode在编译打包的时候就会生成按照order文件中的符号顺序排列的可执行文件。
之前不是玩过objc源码么,在objc源码文件夹下有一个libobjc.order文件:
这个libobjc.order文件就是我上面说的记录符号加载顺序的order文件,这里面记录的全部都是函数或者方法的符号。
接下来使用Xcode打开苹果官方objc的Demo,然后在Build Settings中找到Order file:
这里的路径就是我上面说的libobjc.order文件的路径。一旦指定了这个路径,那么编译出来的二进制文件中的符号就是按照路径中order文件的符号顺序来进行排列的了。
这说明苹果官方本身就支持二进制重排这门技术,而且他们自己的开发者也在使用这门技术,只不过我们ios开发者平时不怎么使用这门技术而已。接下来我们就来看看如何使用。
查看可执行文件中的符号顺序
首先,我们来看一下如何查看二进制可执行文件中的符号顺序。
在machO可执行文件的代码段,各个函数依次排列在里面,那么这里面函数的排列顺序是如何查看呢?
如上图所示,我当前这个工程里面的所有的源文件都是记录在Compile Sources里面。每一个源文件在编译的时候都会生成一个目标文件(.o),然后将所有的.o以及静态库等链接成一个MachO,这个链接的顺序就是按照Compile Sources里面的顺序来的,而这里的顺序是可以手动拖动的。
所以说,文件的顺序就确定了。
那么如何查看整个项目中的符号顺序呢?
在Xcode中将Write Link Map File设置为YES,这表示要求给写一个链接符号表。
然后编译。
编译成功之后,对可执行文件show in finder:
然后鼠标点到红框内,按照如下顺序查找,就可以找到对应的LinkMap:
双击打开Test-LinkMap-normal-x86_64.txt:
首先会有一个Object files(红框内),这里面记录的是链接了哪些文件,这里面的文件顺序就是Compile Sources里面的顺序。
紧接着Object files下面是Sections:
Sections里面记录的是MachO二进制可执行文件里面段的一些数据,Segment这一列表示是哪一段。
Sections下面就是Symbols符号了:
可以看到,Symbles里面的数据有四列:Address、Size、File、Name。
自定义Order文件
接下来我们来玩一下,首先分别在ViewController和AppDelegate这两个文件中复写一下load方法,然后Clean一下工程再编译,然后查看LinkMap:
我将+[ViewController load]、+[AppDelegate load]和_main都画了红框,大家可以清晰地看到其在MachO中的排列顺序。
接下来我重排一下。
cd到工程目录下,终端执行如下指令,新建一个order文件:
touch norman.order
然后在工程目录下就会新增一个norman.order空文件:
打开该文件,我们写入各符号的排列顺序:
然后保存,并且设置工程的Order File的路径:
注意,./ 表示的是工程的根目录。
然后Clean并且重新编译,然后查看LinkMap:
此时,MachO文件中的方法或者函数的顺序,就是我在norman.order文件中设置的顺序!!!
也许你会问,万一norman.order文件中有的符号在MachO文件中没有怎么办?没关系,如果order文件中有的符号在MachO文件中没有,那么在编译的时候会直接忽略掉没有的符号,并且不会报错。
这就是二进制重排的基本步骤,是不是很简单!
实际上,二进制重排并不难,一个Order文件外加一个配置就搞定,真正的难点在于去找到启动时刻的符号,也就是说,你需要知道要将哪些符号排列到前面去。
Hook一切的终极武器——Clang插桩
上面说到,二进制重排最难最核心的一点就是如何去拿到启动阶段的各个符号。
现在大家考虑一个问题,如何去HookOC中所有方法的调用呢?
所有的OC方法最终都会调用objc_msgSend函数,所以我只要能够Hook住objc_msgSend函数,也就相当于Hook住了所有的OC方法。
我在fishhook详解中讲过,通过fishhook可以hook住所有的系统动态库中的函数,所以我们可以通过fishhook来hook住objc_msgSend函数。然后取出objc_msgSend函数中的第二个参数SEL并保存,也就拿到了所有的OC方法。
但是objc_msgSend函数的参数是可变参数,那么如何拿到第二个之后的参数呢?需要通过寄存器去拿,此时就需要写汇编代码。但是实际上,好多人对汇编是不了解的,所以通过fishhook来hook住objc_msgSend函数,进而Hook住所有的符号,这条路没有必要去死磕,因为它比较深。
那如果不死磕fishhook这条路,还有什么其他的路可以Hook住所有的符号呢?答案就是Clang插桩。
插桩的相关文档如下:
https://clang.llvm.org/docs/SanitizerCoverage.html
由此可见,插桩是Clang自带的工具,它可以实现所有符号的Hook。
接下来我们玩一下。
按照官方文档,首先配置一下编译器的参数-fsanitize-coverage=trace-pc-guard:
这里需要注意⚠️,不要完全按照官方文档来,配置信息要按照如下来配置:
-fsanitize-coverage=func,trace-pc-guard
也就是说,只hook func。不然的话,while循环的时候会有问题,因为每一次while循环也都会被监控到。而配置了coverage=func之后,就只会监控到func(方法、函数、block)了。
配置完成之后编译:
报错了!!报错信息是:
Undefined symbol: ___sanitizer_cov_trace_pc_guard_init
Undefined symbol: ___sanitizer_cov_trace_pc_guard
那么___sanitizer_cov_trace_pc_guard_init和___sanitizer_cov_trace_pc_guard是什么东西呢?我们接着看官方文档:
在官方文档的Example中轻而易举找到了___sanitizer_cov_trace_pc_guard_init和___sanitizer_cov_trace_pc_guard。
那么我就照葫芦画瓢,将这两个函数拷贝到我的工程中:
此时再编译就可以编译成功了。
编译成功之后我们就来研究下这两个函数,首先来看一下__sanitizer_cov_trace_pc_guard_init函数:
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.
}
可以看到该函数中有一个start和一个stop,它们分别是某一段内存的起始位置和结束位置。
我将结束位置stop往前挪4个字节就可以查看最后一块内存了:
可以看到,第一个字节记录的就是当前加载进内存的符号的个数。
接下来我在原工程中再增加几个符号:
我增加了两个方法一个block,最后打印符号个数的时候也正好多了3个,这说明,通过这种方式可以Hook住所有的符号。
接下来再来看一下__sanitizer_cov_trace_pc_guard函数:
void __sanitizer_cov_trace_pc_guard(uint32_t *guard) {
if (!*guard) return;
void *PC = __builtin_return_address(0);
char PcDescr[1024];
printf("guard: %p %x PC %s\n", guard, *guard, PcDescr);
}
实际上,没一个符号的调用都会走到__sanitizer_cov_trace_pc_guard函数里面来。
我在工程中写入下面代码:
先将断点断到touchBegin,然后查看汇编,如下:
然后断点往下走,走到test,查看汇编如下:
断点再往下走,走到normanBlock,查看汇编:
可以看到,无论是方法还是函数还是block,它们在调用的时候,首先都会调用__sanitizer_cov_trace_pc_guard函数。也就是说,当配置了Clang代码插入工具之后,编译器会在编译的时候在所有的方法、函数、block内部都加入了一条调用__sanitizer_cov_trace_pc_guard函数的汇编代码,这就是所谓的Clang静态插桩,Hook一切。
定位符号
现在我们已经Hook到了所有的方法和函数了,那么如何去定位对应的符号呢?如何获取当前Hook的符号的名称呢?
现在来到__sanitizer_cov_trace_pc_guard函数里面:
void __sanitizer_cov_trace_pc_guard(uint32_t *guard) {
if (!*guard) return;
void *PC = __builtin_return_address(0);
Dl_info info;
dladdr(PC, &info);
printf("dli_fname: %s \n dli_fbase: %p \n dli_sname: %s \n dli_saddr: %p \n", info.dli_fname, info.dli_fbase, info.dli_sname, info.dli_saddr);
}
(注意,使用dladdr需要#import <dlfcn.h>)
__sanitizer_cov_trace_pc_guard函数一定是被你所Hook的方法所调起的,在该函数内部,通过相关API可以获得符号的名称等相关信息,打印结果如下:
dli_fname: /Users/liwei/Library/Developer/CoreSimulator/Devices/F27DFCE8-E495-4713-9ED4-38BD4089D5DD/data/Containers/Bundle/Application/FB0EC220-9146-42F8-A9AB-357422BACBD7/Test.app/Test
dli_fbase: 0x10da32000
dli_sname: -[ViewController touchesBegan:withEvent:]
dli_saddr: 0x10da338d0
可以看到,dli_fname指的是文件路径,dli_fbase指的是文件地址,dli_sname指的是符号的名称,dli_saddr指的是函数的起始地址。
保存符号
现在我们已经拿到符号的名称了(即上面的dli_sname),接下来就看看如何保存这些个符号。
#import <dlfcn.h>
#import <libkern/OSAtomic.h>
// 声明一个原子队列,用于保存符号
static OSQueueHead symbleList = OS_ATOMIC_QUEUE_INIT;
// 定义符号结构体(符号是以该结构体的形态保存)
typedef struct {
void *pc;
void *next;
}SymbleNode;
void __sanitizer_cov_trace_pc_guard(uint32_t *guard) {
// if (!*guard) return; 注意,这里需要注释掉,因为如果是load方法,那么guard就是0.不注释的话就监控不到load方法了。
/* 精确定位 哪里开始 到哪里结束! 在这里面做判断写条件! */
void *PC = __builtin_return_address(0);
SymbleNode *node = malloc(sizeof(SymbleNode));
*node = (SymbleNode){PC, NULL};
// 入栈(保存)
OSAtomicEnqueue(&symbleList, node, offsetof(SymbleNode, next));
}
取出符号名称并生成Order文件
现在符号已经保存了,接下来就是将其取出来生成一个order文件:
#import <dlfcn.h>
#import <libkern/OSAtomic.h>
// 记录所有的符号名称
NSMutableArray <NSString *> * symbolNames = [NSMutableArray array];
// 遍历所有的符号节点
while (YES) {
SymbleNode *node = OSAtomicDequeue(&symbleList, offsetof(SymbleNode, next));
if (node == NULL) {
break;
}
Dl_info info;
dladdr(node->pc, &info);
NSString * name = @(info.dli_sname);
BOOL isObjc = [name hasPrefix:@"+["] || [name hasPrefix:@"-["]; // 是否是OC方法
// 函数前面加下划线(这里的函数包括C函数,也包括Swift函数)
NSString * symbolName = isObjc ? name : [@"_" stringByAppendingString:name];
[symbolNames addObject:symbolName];
}
// 顺序取反
NSEnumerator *emt = [symbolNames reverseObjectEnumerator];
// 元素去重
NSMutableArray<NSString *> *funcs = [NSMutableArray arrayWithCapacity:symbolNames.count];
NSString * name;
while (name = [emt nextObject]) {
if (![funcs containsObject:name]) {
[funcs addObject:name];
}
}
// 干掉自己!
[funcs removeObject:[NSString stringWithFormat:@"%s", __FUNCTION__]];
// 将数组变成字符串
NSString * funcStr = [funcs componentsJoinedByString:@"\n"];
// 写入order文件
NSString * filePath = [NSTemporaryDirectory() stringByAppendingPathComponent:@"norman.order"];
NSData * fileContents = [funcStr dataUsingEncoding:NSUTF8StringEncoding];
[[NSFileManager defaultManager] createFileAtPath:filePath contents:fileContents attributes:nil];
NSLog(@"%@",filePath); // 直接复制该路径取出里面的norman.order即可
在工程中执行完上述代码之后,就会在对应路径下生成一份order文件:
然后我将order文件拷贝出来,放入到工程的根目录下面:
并且设置工程的Order File的路径:
至此,所有的步骤就都搞完了。
咱先不着急编译工程,先来看一下目前的linkMap:
然后Clean并重新编译,再次查看Link Map:
可以看到,符号已经按照执行的顺序重新排列了。
混编工程配置
在混编工程中,由于有Swift代码,所以还需要对Swift编译器做如下配置:
-sanitize-coverage=func -trace-pc-guard
需要注意的是,在优化完毕之后,注意将符号的Hook、定位、保存以及生成Order文件的相关代码给去掉,只需要拿到对应的Order文件,然后放入工程根目录即可。
结语
至此,我们整个启动优化相关的内容就讲完了。
如果你的项目代码比较粗糙,那么严格按照我第一篇文章中的内容去做代码优化的话,启动时间应该能缩短很多。
如果你的项目代码已经十分优雅了,很难再在代码层面优化启动时间了,那么通过二进制重排,你大概还能优化10%左右。
我之前的项目,二进制重排之前大概是1300毫秒,之后是1150毫秒,大概提升了11%。
以上。