李峰峰博客

APP 启动优化 3-二进制重排

2021-05-11

一、二进制重排原理

1、Page In 与 二进制重排

在前面总结的冷启动流程中已经提到了 Page In,这里再简单补充下,在 iOS 中,内存管理主要由操作系统、CPU 协同完成:

  • 操作系统负责分配物理内存,并更新存放物理内存、虚拟内存映射关系的页表。
  • 位于 CPU 上的 MMU(内存管理单元,是个硬件)负责虚拟内存、物理内存间的转换。
  • mmap 是一个系统调用,可以将文件映射到虚拟地址空间,操作系统也会同步更新页表,创建对应映射条目。但 mmap 只能将文件映射到虚拟地址空间,不会将文件加载进物理内存,所以操作系统在页表中会将其映射条目标记为未加载的状态。

我们启动 APP 时,进程加载 Mach-O 实际上是通过 mmap 将 Mach-O 文件映射到 APP 进程的虚拟内存里的,这时候只分配了虚拟内存,并没有分配物理内存,也没有加载进物理内存。

如果访问一个虚拟内存地址,而 MMU 检测到对应物理内存中不存在的时候,就会触发 MMU 的 Page Fault(缺页错误、缺页异常、缺页中断),此时操作系统会暂停当前线程,停止后续代码逻辑的执行。接着触发一个 Page In:分配物理内存,并把文件中的内容拷贝到物理内存里。需要的数据读取到物理内存后,将会恢复线程,继续代码逻辑的执行。比如,我们在启动时通过虚拟内存调用一个方法,第一次调用这个方法时如果物理内存中还没将该方法加载进来,就会触发 Page In。

大致流程如下:

而 Page In 是相当耗时的,每次 Page In 都需要先查找空闲的物理内存页,然后触发磁盘 IO 将数据加载进物理内存。对于 iOS 13 以下系统,如果加载进物理内存的是 TEXT 段内容,还需要进行解密,最后还要对解密后的页进行签名验证。而一个中大型 APP 在一次启动流程中触发 Page In 次数少则几百,多则上千,所以减少 Page In 次数可以在一定程度上加快启动时间。

但是在一次 APP 启动过程中,只有少部分函数被调用,这些函数在二进制文件中的分布是零散的,所以 Page In 读入的数据利用率并不高。如果我们可以把启动用到的函数排列到二进制的连续区间,那么就可以减少 Page In 的次数,从而优化启动时间。

以下图为例,方法 1 和方法 3 是启动的时候用到的,为了执行对应的代码,就需要两次 Page In。假如我们把方法 1 和 3 排列到一起,那么只需要一次 Page In,从而提升启动速度。

2、二进制重排配置

Xcode 早就提供了支持二进制重排,并且苹果也对 objc 的源码就采用了二进制重排方案进行优化,如下图:

在源码文件夹中可以找到该文件:

内容格式如下:

可以看到,该 order 格式的文件内部都是函数符号,在 Xcode 中指定了 order file 的路径后,Xcode 在编译的时候按照文件中函数符号的顺序来排列二进制代码段。而我们要做的就是获取到 APP 启动过程中执行的所有函数符号,写入到 order 格式的文件中,并在 Xcode 的 Build Settings -> Linking -> Order File 选项中指定该文件的路径,这样就达到了把启动用到的函数排列到二进制的连续区间的目的,减少了启动时发生 Page In 的次数。

3、查看 Page In 次数

这块前面文章也有提到,可以借助 Instrument 中的 System Trace 工具,System Trace 调试结束会获取到启动过程中的分析数据,在结果页面选中主线程,结果中的 File Backed Page In 次数就是 Page In 次数(也称为 Page Fault 次数):

二、二进制重排实现方案

根据前面内容可以知道实现二进制重排的关键点是如何获取到启动过程中执行的所有函数符号。

获取启动过程中执行的函数符号目前主要有两种主流实现方式:

  • 静态扫描 + 运行时 Trace(使用 fishhook 进行 hook 获取被调用的函数)
  • Clang 插桩

1、静态扫描 + 运行时 Trace

大致原理是通过扫描 linkmap 文件获得所有函数符号,然后借助 fishhook hook objc_msgSend 函数,由于 objc_msgSend 是变长参数,hook 相关代码是使用汇编来实现的。具体可查看《抖音研发实践:基于二进制文件重排的解决方案 APP启动速度提升超15%》

2、Clang 插桩

基于 Clang 插桩获取符号有两种实现方式:

  • 一种是自己编写一个 Clang 插件,在 Clang 插件中我们去分析抽象语法树不同的节点,在相应的节点中插入自定义的代码用于符号收集,这种自定义 Clang 插件的方式优点是可根据自己需求进行灵活处理,缺点是通用性较差;

  • 一种是利用 SanitizerCoverage 工具进行符号收集。

这里主要介绍使用 SanitizerCoverage 进行插桩的方式,Clang SanitizerCoverage 是 LLVM 提供的代码覆盖工具,在编译时,它能够根据我们的编译配置,将一系列以 __sanitizer_cov_trace_pc_ 为前缀的函数插入到我们自定义的函数内。

当我们在 Clang 的自定义配置 Other C Flags 中新增:
-fsanitize-coverage=trace-pc-guard
标志时,编译器将会为每个自定义的函数中插入 __sanitizer_cov_trace_pc_guard
回调函数。

所以我们在 APP 中实现该回调函数,就可以在该回调函数内部收集原函数符号,例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 插桩的初始化方法
void __sanitizer_cov_trace_pc_guard_init(uint32_t *start, uint32_t *stop) {
static uint64_t N;
if (start == stop || *start) return;
for (uint32_t *x = start; x < stop; x++)
*x = ++N;
}

// 回调函数
void __sanitizer_cov_trace_pc_guard(uint32_t *guard) {
if (!*guard) return;

// 获取函数符号
}

函数 __sanitizer_cov_trace_pc_guard 是在编译期由 Clang 插入到原函数内部的,因此__sanitizer_cov_trace_pc_guard 函数算是原函数内部的一个嵌套子函数,而操作系统在执行 bl 跳转指令的时候,会先保存下一条指令地址到 lr 寄存器中,当__sanitizer_cov_trace_pc_guard 函数执行完即执行 ret 指令后,需要继续回到原函数中继续执行,操作系统会去读取 LR 寄存器中的值拿到原函数的下一条待执行指令地址,这个地址可以通过下面代码来获取:

1
void *PC = __builtin_return_address(0);

也就是说,在 __sanitizer_cov_trace_pc_guard 函数中我们可以通过 __builtin_return_address(0) 拿到原函数某条指令的地址,那我们只要再通过 dladdr() 函数就可以获取到原函数的信息,从而拿到该函数符号:

1
2
3
4
5
6
7
8
9
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("%s\n",info.dli_sname);
}


在实际的使用过程中,需要解决以下几个主要问题:
(1) 多线程问题,由于 __sanitizer_cov_trace_pc_guard 函数是各个方法内插入的回调函数,而原函数可能处于不同的线程中,从而造成 __sanitizer_cov_trace_pc_guard 函数调用的多线程问题,解决这个问题可以使用原子队列 OSAtomicEnqueue 来处理,使用原子队列之后需要在 Other C Flags 配置中修改原来的配置为如下形式:
-fsanitize-coverage=func,trace-pc-guard

(2) 如果要支持 Swift 符号收集,由于 Swift 的编译前端与 OC 不同,需要在编译配置的Other Swift Flags下,新增下面配置:

1
2
-sanitize-coverage=func
-sanitize=undefined

(3) 使用 Cocoapods 管理的项目,存在多 target 的情况下,需要在每个 target 下都要进行上面的 Other C Flags 配置。

收集到启动过程中的函数符号之后,将这些符号写入到 order 文件中,并将该 order 文件的地址在 Xcode 的 Order File参数下进行配置即可。另外,《App 二进制文件重排已经被玩坏了》文章中详细介绍了此方式,并且作者基于 Clang SanitizerCoverage 写了个工具 AppOrderFiles,可一行调用生成 Order File,已经开源:AppOrderFiles

使用该工具也一样需要先配置 Other C Flags 和 Other Swift Flags 参数:

  • 在 build settings 里的 “Other C Flags” 中添加 -fsanitize-coverage=func,trace-pc-guard。
  • 如果含有 Swift 代码的话,还需要在 “Other Swift Flags” 中加入 -sanitize-coverage=func 和 -sanitize=undefined。
  • 所有链接到 App 中的二进制都需要开启 SanitizerCoverage,这样才能完全覆盖到所有调用。

使用:

1
2
3
AppOrderFiles(^(NSString *orderFilePath) {
NSLog(@"OrderFilePath:%@", orderFilePath);
});

不过,这两种方式也都有一个缺点,如果项目依赖了静态库,由于静态库是编译后的二进制文件,所以这两种方式无法获取到其中的函数符号。为了解决该问题,手淘采用了汇编插桩的实现方式,具体可查看相关文章

参考:
58 同城 App 性能治理实践-iOS 启动时间优化
抖音研发实践:基于二进制文件重排的解决方案 APP启动速度提升超15%
iOS基于静态库插桩的⼆进制重排启动优化

Tags: 优化