启动优化(二)——二进制重排

上篇文章咱们讲了虚拟内存。应用程序在运行的时候会有一个虚拟内存,虚拟内存是分页管理的,它经过页表映射到物理内存上面。分页管理有一个特色,当加载新的一块功能的时候,对应的某一页数据不在物理内存的时候,系统会缺页中断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=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));}
  • 使用原子队列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文件:

#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%。


以上。

本文分享自微信公众号 - iOS小生活(iOSHappyLife)。
若有侵权,请联系 support@oschina.cn 删除。
本文参与“OSC源创计划”,欢迎正在阅读的你也加入,一块儿分享。

相关文章
相关标签/搜索