上篇文章咱们讲了虚拟内存。应用程序在运行的时候会有一个虚拟内存,虚拟内存是分页管理的,它经过页表映射到物理内存上面。分页管理有一个特色,当加载新的一块功能的时候,对应的某一页数据不在物理内存的时候,系统会缺页中断pageFault,而pageFault是须要时间的,用户在使用过程当中,几毫秒实际上用户是感知不到的;可是在应用启动的时候,会有大量代码须要执行,此时会有数量众多的pageFault,这样一累计,用户就能够感知到了。javascript
今天要研究的,就是经过一项技术来减小启动时的pageFault,进而缩减启动时间,这个技术就是二进制重排。css
二进制重排这项技术为大众所熟知最初是源于抖音团队的这篇文章:
html
https://mp.weixin.qq.com/s/Drmmx5JtjG3UtTFksL6Q8Q
你们有兴趣的话能够去看一下。
java
想要优化,首先要学会调试。接下来咱们就来看看如何去获取pageFault的次数。node
测量PageFault次数
ios
打开Instruments工具集,找到System Trace:
数组
打开以后,依次选择好设备和要执行的APP,而后点击左上角的红点启动:
微信
而后就开始分析,分析完成以后,找到对应APP下面的main thread,而后查看虚拟内存VertialMemary,File Backed Page In对应的Count就是启动期间的pageFault次数:
app
能够看到,第一次启动的时候的缺页中断次数是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。
Name指的是方法名或者函数名
File指的是在哪个文件当中,这里面的数字给最上方Object files里面的数字是对应的
Size指的是这个方法或者函数占用的空间大小,函数里面的内容多少不同,其Size也是不同的,100行代码的方法确定比1行代码的方法的Size要大。
Address指的是这个方法或者函数的真实的地址,不是这个方法对应的符号地址(符号地址就是存储在MachO文件的Data段中的符号)。咱们作二进制重排,实际上就是将相关代码的全部内容放到前面去,而不只仅是简简单单将符号放到前面。
自定义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=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:] 0x10da338d0 :
能够看到,dli_fname指的是文件路径,dli_fbase指的是文件地址,dli_sname指的是符号的名称,dli_saddr指的是函数的起始地址。
保存符号
如今咱们已经拿到符号的名称了(即上面的dli_sname),接下来就看看如何保存这些个符号。
// 声明一个原子队列,用于保存符号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));}
使用原子队列OSQueueHead做为容器来保存符号
自定义一个SymbleNode结构体,符号是以该结构体的形态进行保存的
__sanitizer_cov_trace_pc_guard函数中,当该函数是由load方法调起的时候,*guard是0,此时就会直接return。因此为了可以hook住load方法,须要将if (!*guard) return;这行代码给注释掉
经过上面说的这一点,我也有所启发。我能够定义一个全局静态变量来记录是否入栈,在起点函数的时候给该变量设置为YES,在终点函数的时候给该变量设置为NO,而后在__sanitizer_cov_trace_pc_guard函数一开始根据该变量值来决定是否返回,这样的话我就能够进行监控起点和终点的精肯定位了。
取出符号名称并生成Order文件
如今符号已经保存了,接下来就是将其取出来生成一个order文件:
// 记录全部的符号名称 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%。
以上。
本文分享自微信公众号 - iOS小生活(iOSHappyLife)。
若有侵权,请联系 support@oschina.cn 删除。
本文参与“OSC源创计划”,欢迎正在阅读的你也加入,一块儿分享。