Links

03.二进制重排实践

前言

近两年二进制重排在启动优化上还是经常被提到的,但自己没有尝试过。
继上一次「iOS官方瘦身方案ODR(二):换肤系统改造|践行 On-Demand Resources」后,再次拿自己个人项目小白鼠「梦见账本」来实践一下。

一、 为什么要二进制重排呢?

虽然听着很厉害的样子,但其实是个老概念了。

1.1 虚拟内存和分页

我们知道,现代操作系统一般都采用虚拟内存管理机制,用分段(segment)分页(page)管理虚拟内存。
分段即是区分数据段代码段堆内存栈内存等,不同的段数据的读写权限不一样。以 iOS 为例,代码段(_TEXT)是可读可执行但不能写的。
分页则是为了方便高效的进行内存管理。由于采用了虚拟内存管理机制,就要建立虚拟内存物理内存映射表,称为页表。如果在设计上将每一个字节的虚拟内存和物理内存一一对应,这样粒度足够细,虽然不会产生内存浪费(内存碎片),但需要维护巨大的页表;但如果一页数据过大,比如5M,那么存储1个字节就要分配一个5M的页面,是非常大的浪费。内存页过大或过小都有弊端,目前大多数系统的页大小都设置在了4096字节,通过页号和页内偏移进行寻址。可以使用pagesize命令查看当前系统的页大小。

1.2 Page Fault

使用虚拟内存的目的之一是解决物理内存资源紧张的问题。dyld 在加载二进制时,会使用 mmapMach-O 文件映射到虚拟内存地址空间中,此时并不会占用过多的物理内存。当读取一个虚拟内存地址时,如果该地址在物理内存中并不存在,会触发一次缺页中断(Page Fault),这个时候才将文件内容读取至物理内存中。
缺页中断发生时会执行下面的操作:
分配内存内存管理单元找到空闲内存并分配。
IO操作 从磁盘中读文件并写入内存中。
解密验签 如果是从 AppStore 上下载的 APPiOS 系统还有对每一页(仅针对 _TEXT 段的数据,_DATA 段数据不需要)进行解密和签名验证。
以上操作在每一次 Page Fault 时都会发生,如果在启动 APP 时,存在大量的 Page Fault 情况,势必影响启动速度。

二、 什么是二进制重排

频繁的发生 Page Fault 会影响启动速度,那么,是否可以干预 Mach-O_TEXT 段函数的映射顺序,将 APP 启动时需要用到的方法集中在一页或几页呢?答案是肯定的,二进制重排的原理就是字面上的理解,通过减少 Page Fault 发生次数,减少启动耗时。
理论上 Page Fault 确实会影响启动速度,但影响的大小要区分看待。一般来说,是要在常规的优化手段都做完之后,再考虑进行二进制重排。且对于小型APP来说,如果本身启动时执行的方法并不算多,那么二进制重排的意义就不是很大。
对于 iOS 13 系统来说,由于启用了 dyld3Page Fault发生时已经不需要执行解密验签(提前生成了 lauch closure 文件),对性能的影响就更小了。

三、 System Trace 查看耗时

建议重装应用
  • 选中指定的设备,选中安装的 App 点击 [*] 按钮,应用第一个页面(非启动页)显示后停止。
  • 找到自己的项目
  • 选中 Main Thread
  • 选中 Virtual Memory
01
02
File Backed Page In 次数就是 Page Fault 的次数。「梦见账本」耗时 341ms
3
点击这里的小箭头,可以看到调用堆栈
4
当然,我们不可能人工的来整理这些。那么有什么办法可以获取到所有调用呢?

四、 获取启动时调用的所有方法

现有方案对比
  • hook objc_msgSend
    • 只能捕获基于 objc 的方法调用
  • 静态扫描 MachO 文件里的符号和函数数据 + 解析 Trace 文件
    • 容易获取 +loadC++构造函数
    • initialize hook不到
    • 部分block hook不到
    • C++通过寄存器的间接函数调用静态扫描不出来
  • 编译器插桩 Clang
    • 可以拿到 OCSwiftCblock 全部调用

五、 Clang 插桩

5.1 基于 Clang SanitizerCoverage 的方案

SanitizerCoverageClang 内置的一个代码覆盖工具。它把一系列以 __sanitizer_cov_trace_pc_ 为前缀的函数调用插入到用户定义的函数里,借此实现了全局 AOP。其覆盖了/ Swift/Objective-C/C/C++ 等语言,Method/Function/Block 全支持。
开启 SanitizerCoverage 的方法是:
  • build settings 里的 Other C Flags 中添加 -fsanitize-coverage=func,trace-pc-guard
  • 如果含有 Swift 代码的话
    • 需要在 Other Swift Flags 中加入 -sanitize-coverage=func-sanitize=undefined
  • 所有链接到 App 中的二进制都需要开启 SanitizerCoverage,这样才能完全覆盖到所有调用
    • 例如Pod库,就要在Target里设置
SanitizerCoverage中可以看到 LLVM 官方对 SanitizerCoverage 的详细介绍,包含了示例代码。

5.2 获取 order 文件

这里直接使用了AppOrderFiles来进行获取。就不贴代码了,源码也不多,有兴趣可以自行查看。
AppDelegate 中调用:
// 我是放在 `func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool` 最后调用的
AppOrderFiles { path in
if let path = path { print("AppOrderFiles: \(path)") }
}
安装运行一次后,从 Xcode 获取应用的设备的 .xcappdata 文件中按照路径,取到 app.order 文件。
05

5.3 设置 order 文件路径

06
没必要加入 Bundle

5.4 验证顺序 LinkMap

开启 LinkMap 文件输出
07
编译获取 LinkMap
先在项目的 Product 文件夹中找到 .app 的目录
08
再按如图所示路径找到 linkmap 文件
09
对比 linkmaporder 文件
搜索 Address Size File Name
10
发现顺序是一样的了

5.5 System Trace 检查效果

11
只有 141ms 了,优化了一大半。具体效果根据不同项目会有所不同。

参考