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

通过前面的探讨,我们知道内存分页触发中断异常 Page Fault 后,会阻塞进程,这个问题是会对性能产生影响。
实际上在 iOS 系统中,生产环境的应用,在发生缺页中断进行重新加载时 ,iOS 系统还会对其做一次签名验证,因此 iOS 生产环境的 Page Fault 所产生的耗时要更多。
对用户而言,使用App时第一个直接体验就是启动 App 时间,而启动时期会有大量的分类三方等等需要加载和执行,此时多个 Page Fault 所产生的的耗时往往是不能小觑的,下面我们就通过二进制重排来优化启动耗时。

【ios启动优化(二进制重排)】抖音团队分享的一个 Page Fault,开销在 0.6 ~ 0.8ms。实际测试发现不同页会有所不同 , 也跟 cpu 负荷状态有关 , 在 0.1 ~ 1.0 ms 之间 。
二进制重排这个方案最早也是 抖音团队 分享的,不过他们的解决方案有瑕疵,下面我们会针对性的解决。
一、原理
假设在启动时期我们需要调用两个函数 method1method4,函数编译在 mach-O 中的位置是根据 ld ( Xcode 的链接器) 的编译顺序并非调用顺序来的,因此很可能这两个函数分布在不同的内存页上。
ios启动优化(二进制重排)
文章图片
原理.png 如上图,那么启动时, page1page2 都需要从无到有加载到物理内存中,从而触发两次 Page Fault
二进制重排 的做法就是将 method1method4 放到一个内存页中,那么启动时则只需要加载一次 page 即可,也就是只触发一次 Page Fault
在实际项目中,我们可以将启动时需要调用的函数放到一起 ( 比如 前10页中 ) 以尽可能减少 Page Fault,进而减少启动耗时。
二、调试 Page Fault
最好是卸载App,重新安装,调试第一次启动的效果。
  1. 打开 Instruments,选择 System Trace
  2. 选择真机,选择工程,选择启动,当页面加载出来的时候,停止。
  3. 查看 Page Fault,如图标注。
    ios启动优化(二进制重排)
    文章图片
    Page Fault.png
File Backed Page In:即为 Page Fault,对应的有count,一页Page Fault最大耗时,最小耗时等参数。
如果多次启动调试,你会发现count的波动范围很大。所以如果想获取准确的数据,最好重新安装App或者打开多个App之后,再来调试。
这是因为内存管理机制,杀掉进程时,他所占用的物理内存空间,如果没有被覆盖使用,那么这部分内存有很大可能一直存在。重新打开,内存就不需要全部初始化。所以 冷热启动的界定不能以是否后台杀死来简单判断。
三、二进制重排
3.1 Order File 前面说了这么多,那么具体该怎么操作呢?苹果其实已经给我们提供了这个机制。

ios启动优化(二进制重排)
文章图片
Order File.png
实际上 二进制重排就是对即将生成的可执行文件重新排列,即它发生在链接阶段。
首先,Xcode 用的链接器叫做 ldld 有一个参数叫 Order File,我们可以通过这个参数配置一个 后缀名 为order的文件路径。在这个 order 文件中,将你需要的符号按顺序写在里面。当工程 build 的时候,Xcode 会读取这个文件,打的二进制包就会按照这个文件中的符号顺序进行生成对应的 mach-O
可以参考一下 libObjc 项目,它已经使用了 二进制重排进行优化。 ios启动优化(二进制重排)
文章图片
libobjc.order.png
是不是看到了 ios应用启动加载过程中熟悉的方法。
1、order 文件里符号写错了或不存在会不会有问题:ld 会忽略这些符号,如果提供了 link 选项 -order_file_statistics,他们会以 warning 的形式把这些没找到的符号打印在日志里。
2、会不会影响上架:不会,order文件只是重新排列了所生成的 mach-O(可执行文件) 中函数表与符号表的顺序
3.2 如何查看项目符合顺序
  1. 可以设置 Write Link Map File 来设置是否输出,默认是 noLink Map 是编译期间产生的 ,( ld 的读取二进制文件顺序默认是按照 Compile Sources 里的顺序 ),它记录了二进制文件的布局。
  2. 修改 Write Link Map FileYES,然后clean项目并重新编译
  3. Products -> show in finder,上上层文件夹,然后找到一个xxxxx-LinkMap-normal-arm64.txt 的txt文件。
    ios启动优化(二进制重排)
    文章图片
    Link map.png
    这个文件的# Symbols: 部分存储了所有符号的顺序,前面的 .o 等内容忽略 。
    ios启动优化(二进制重排)
    文章图片
    Symbols.png
    我们发现符号顺序明显是按照 Compile Sources 的文件顺序来排列的。
    文件中最左侧地址就是 方法真实实现地址(实际代码地址)而并非符号地址 , 因此我们二进制重排并非只是修改符号地址 , 而是利用符号顺序 , 重新排列整个代码在文件的偏移地址 , 将启动需要加载的方法地址放到前面内存页中 , 以此达到减少 page fault 的次数从而实现时间上的优化。
终端查看符号表命令(不准确,仅供参考)。找到可执行文件:
nm (file):查看符号表
nm -p (file):按照orderfile顺序
nm -up (file): 只看系统
nm -Up (file):只看自定义
3.3实战 1、 新建一个项目,添加方法:
ios启动优化(二进制重排)
文章图片
binary.png 2、修改配置,编译,找到xxx.txt文件 ios启动优化(二进制重排)
文章图片
截图.png
3、新建一个order文件: touch binary.order,加入几个方法
-[ViewController test3] -[ViewController test2] -[ViewController test1]

4、修改Order File配置为:$(SRCROOT)/Binary/binary.order./Binary/binary.order
ios启动优化(二进制重排)
文章图片
order file.png
5、 clean编译,再次查看 xxx.txt文件。
ios启动优化(二进制重排)
文章图片
截图.png oh my god,我们所写的这三个方法已经被放到最前面了,也就是说,这三个方法被放到了距离 mach-O 中首地址偏移量最小位置。假设这三个方法原本在不同的三页,那么意味着我们已经优化掉了两个 Page Fault。
3.4 获取启动执行的函数 到这里,离启动优化就只差一步了,如何获取启动运行的函数?大致有三种方案,仅供参考:
  1. hook objc_MsgSend:只能拿到 oc 以及 swift @objc dynamic 后的方法,并且由于可变参数个数,需要用汇编来获取参数 。
  2. 静态扫描 machO 特定段和节里面所存储的符号以及函数数据。
  3. clang 插桩:完全拿到 swiftoccblock 全部函数。
四、Clang插桩
关于 clang 的插桩覆盖的官方文档如下 : clang 自带代码覆盖工具 文档中有详细概述,以及简短Demo演示。
思路:一是自己编写 clang 插件,另外一个就是利用 clang 本身已经提供的一个工具来实现我们获取所有符号的需求。
4.1 静态插桩代码 下面我们来探索一下这个静态插桩代码覆盖工具的机制和原理。
1、添加编译设置:直接搜索 Other C Flags 来到 Apple Clang - Custom Compiler Flags 中 , 添加配置:-fsanitize-coverage=trace-pc-guard
2、在ViewController.m添加代码:
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) { if (!*guard) return; // Duplicate the guard check.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); }

3、运行(最好是一个空工程,注释我们前面手动添加的方法),查看打印:
ios启动优化(二进制重排)
文章图片
trace-pc-guard.png 通过打印 startstop两个指针地址,会发现他存储的实际上是 1-15 几个序号。
4、添加一个 oc方法,我们再次打印 startstop指针,你会发现序号变为 1-16
继续添加一个 c函数,一个 block,一个 touch函数,是不是惊喜的发现,序号增加到 19 了。 ios启动优化(二进制重排)
文章图片
89dfb9d8a201.png
此时,我们是不是可以大胆的猜想: 这个内存区间保存的就是工程所有符号的个数。
5、继续,清空打印,点击屏幕。是不是发现有两次输出,看代码,此时有两次方法的调用。最终我们发现: 调用几个方法,就会打印几次 guard:。
此时查看汇编,你会发现:在每个函数调用的第一句实际代码,会被添加进去了一个 bl 指令, 调用到 __sanitizer_cov_trace_pc_guard 这个函数中来 。
bl,汇编跳转指令,即调用方法。bl之前是栈平衡与寄存器数据准备,不用关心。
这就是静态插桩:静态插桩实际上是在编译期,在每一个函数内部第一行代码处,添加 hook 代码 ( 即我们添加的 __sanitizer_cov_trace_pc_guard 函数 ) ,实现全局的方法 hook,即AOP效果。
4.2 获取函数符号 通过上面的分析我们知道,所有函数的第一步都会调用__sanitizer_cov_trace_pc_guard,那我们是不是可以通过这个函数获取函数符号呢?
熟悉汇编的应该知道:函数嵌套时 , 在跳转子函数时,都会保存下一条指令的地址在 x30 ( 又叫 lr 寄存器) 里 。
例如 , A 函数中调用了 B 函数,在 arm 汇编中即 bl + 0x**** 指令,该指令会首先将下一条汇编指令的地址保存在 x30 寄存器中。然后在跳转到 bl 后面传递的指定地址去执行。
bl 能实现跳转到某个地址的汇编指令,其原理就是修改 pc 寄存器的值来指向到要跳转的地址,而且实际上 B 函数中也会对 x29 / x30 寄存器的值做保护,防止子函数又跳转其他函数会覆盖掉 x30 的值 , 当然叶子函数除外。
当 B 函数执行 ret 也就是返回指令时,就会去读取 x30 寄存器的地址,跳转过去,因此也就回到了上一层函数的下一步。
__sanitizer_cov_trace_pc_guard 函数中的这一句代码:
void *PC = __builtin_return_address(0);

它的作用其实就是去读取 x30 中所存储的要返回时下一条指令的地址。所以他名称叫做 __builtin_return_address。换句话说,这个地址就是我当前这个函数执行完毕后,要返回到哪里去。
bt 函数调用栈也是这种思路来实现的。也就是说 , 我们可以在 __sanitizer_cov_trace_pc_guard 这个函数中 , 通过 __builtin_return_address 函数拿到原函数调用 __sanitizer_cov_trace_pc_guard 这句汇编代码的下一条指令的地址。

ios启动优化(二进制重排)
文章图片
c5eaed5e0295.png
如图, PC的指向就是,当 test1函数执行完 __sanitizer_cov_trace_pc_guard后,下一行代码 NSLog
那么问题又来了,如果通过函数内部内存地址,获取函数名称呢?
熟悉安全攻防,逆向的同学可能会清楚。我们为了防止某些特定的方法被别人使用 fishhook hook 掉,会利用 dlopen 打开动态库,拿到一个句柄,进而拿到函数的内存地址直接调用。那我们可以反过来使用。
dlopen.h 相同 , 在 dlfcn.h 中有一个方法如下 :
typedef struct dl_info { const char*dli_fname; /* 所在文件 */ void*dli_fbase; /* 文件地址 */ const char*dli_sname; /* 符号名称 */ void*dli_saddr; /* 函数起始地址 */ } Dl_info; //这个函数能通过函数内部地址找到函数符号 int dladdr(const void *, Dl_info *);

我们在项目中实践一下,先导入头文件 #import ,然后修改代码如下 :
void __sanitizer_cov_trace_pc_guard(uint32_t *guard) { if (!*guard) return; // Duplicate the guard check.void *PC = __builtin_return_address(0); Dl_info info; dladdr(PC, &info); printf("\nfname:%s \nfbase:%p \nsname:%s\nsaddr:%p \n",info.dli_fname,info.dli_fbase,info.dli_sname,info.dli_saddr); char PcDescr[1024]; //__sanitizer_symbolize_pc(PC, "%p %F %L", PcDescr, sizeof(PcDescr)); printf("guard: %p %x PC %s\n", guard, *guard, PcDescr); }

打印结果:
fname:/Users/00393998/Library/Developer/CoreSimulator/Devices/23342248-4844-41AB-9851-2023D815FAA2/data/Containers/Bundle/Application/9A86A08B-5411-4909-B62B-B27097CA2EC9/Binary.app/Binary fbase:0x10beee000 sname:-[ViewController touchesBegan:withEvent:] saddr:0x10beef9d0 guard: 0x10bef468c 6 PC ?fname:/Users/00393998/Library/Developer/CoreSimulator/Devices/23342248-4844-41AB-9851-2023D815FAA2/data/Containers/Bundle/Application/9A86A08B-5411-4909-B62B-B27097CA2EC9/Binary.app/Binary fbase:0x10beee000 sname:testFunc saddr:0x10beef9b0 guard: 0x10bef4688 5 PC \367\371\356??

4.3 写入order文件 写入文件时有许多需要注意的地方,即坑点
1、多线程 考虑到这个方法会来特别多次,使用锁会影响性能,这里使用苹果底层的原子队列 ( 底层实际上是个栈结构,利用队列结构 + 原子性来保证顺序 ) 来实现。
- (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event{ //遍历出队 while (true) { //offset 通过next指针在结构体的偏移量,进而知道next的指向 //offsetof 就是针对某个结构体找到某个属性相对这个结构体的偏移量 // offsetof(SymbolNode, next) 可以替换为 8 SymbolNode * node = OSAtomicDequeue(&symboList, offsetof(SymbolNode, next)); if (node == NULL) break; Dl_info info; dladdr(node->pc, &info); printf("%s \n",info.dli_sname); } } //原子队列 static OSQueueHead symboList = OS_ATOMIC_QUEUE_INIT; //定义符号结构体 typedef struct{ void * pc; void * next; }SymbolNode; void __sanitizer_cov_trace_pc_guard(uint32_t *guard) { if (!*guard) return; // Duplicate the guard check. void *PC = __builtin_return_address(0); SymbolNode * node = malloc(sizeof(SymbolNode)); *node = (SymbolNode){PC,NULL}; //入队 // offsetof 用在这里是为了入队添加下一个节点找到 前一个节点next指针的位置 OSAtomicEnqueue(&symboList, node, offsetof(SymbolNode, next)); }

2、死循环 上述这种 clang 插桩的方式,会在while循环中同样插入 hook 代码。
通过汇编会查看到 while 循环,会被多次静态加入 __sanitizer_cov_trace_pc_guard 调用,导致死循环。
解决方式:Other C Flags 修改为如下:-fsanitize-coverage=func,trace-pc-guardfunc:表示仅 hook函数时调用
cbnz:汇编执行,while循环。
3、load方法 有load 方法时,__sanitizer_cov_trace_pc_guard 函数的参数 guard0,所以打印并没有发现 load。屏蔽掉 __sanitizer_cov_trace_pc_guard 函数中的:if (!*guard) return;
拓展:如果我们希望从某个函数之后/之前开始优化,那么我们可以通过一个全局静态变量,在特定的时机修改其值,在 __sanitizer_cov_trace_pc_guard 这个函数中做好对应的处理即可。
4、其他处理
  1. 由于用的先进后出原因 , 我们要 倒叙 一下
  2. 去重
  3. order 文件格式要求:c函数block 前面还需要加 _下划线。
    核心代码(不要忘记编译配置哦):
//引入头文件 #import #import //核心代码 #pragma mark - 获取order文件- (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event{ NSMutableArray * symbolNames = [NSMutableArray array]; while (YES) { //offsetof 就是针对某个结构体找到某个属性相对这个结构体的偏移量 SymbolNode * node = OSAtomicDequeue(&symbolList, offsetof(SymbolNode, next)); if (node == NULL) break; Dl_info info; dladdr(node->pc, &info); NSString * name = @(info.dli_sname); // 添加 _ BOOL isObjc = [name hasPrefix:@"+["] || [name hasPrefix:@"-["]; NSString * symbolName = isObjc ? name : [@"_" stringByAppendingString:name]; //去重 if (![symbolNames containsObject:symbolName]) { [symbolNames addObject:symbolName]; } }//取反 NSArray * symbolAry = [[symbolNames reverseObjectEnumerator] allObjects]; NSLog(@"%@",symbolAry); //将结果写入到文件 NSString * funcString = [symbolAry componentsJoinedByString:@"\n"]; NSString * filePath = [NSTemporaryDirectory() stringByAppendingPathComponent:@"binary.order"]; NSData * fileContents = [funcString dataUsingEncoding:NSUTF8StringEncoding]; BOOL result = [[NSFileManager defaultManager] createFileAtPath:filePath contents:fileContents attributes:nil]; if (result) { NSLog(@"%@",filePath); }else{ NSLog(@"文件写入出错"); }} //原子队列 static OSQueueHead symbolList = OS_ATOMIC_QUEUE_INIT; //定义符号结构体 typedef struct{ void * pc; void * next; }SymbolNode; #pragma mark - 静态插桩代码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) { //if (!*guard) return; // Duplicate the guard check.void *PC = __builtin_return_address(0); SymbolNode * node = malloc(sizeof(SymbolNode)); *node = (SymbolNode){PC,NULL}; //入队 // offsetof 用在这里是为了入队添加下一个节点找到 前一个节点next指针的位置 OSAtomicEnqueue(&symbolList, node, offsetof(SymbolNode, next)); }

最后运行,下载.order文件到本地,就可以愉快的玩耍了。
五、补充
5.1 swift / OC 混编工程问题 通过如上方式适合纯 OC 工程获取符号。由于 swift 的编译器前端是自己的 swift 编译前端程序,因此配置稍有不同。搜索 Other Swift Flags,添加两条配置即可:-sanitize-coverage=func、 -sanitize=undefinedswift类同样可以通过这个方式获取。
5.2 cocoapod 工程问题 cocoapod 工程引入的库,会产生多 target,我们在主target添加的配置是不会生效的,我们需要针对需要的target做对应的设置。
对于直接手动导入到工程里的 sdk,不管是 静态库 .a 还是 动态库,会默认使用主工程的设置,也就是可以拿到符号的。
参考:
抖音研发实践:基于二进制文件重排的解决方案 APP启动速度提升超15%

    推荐阅读