iOS启动优化-进阶篇

1,430 阅读6分钟

本篇主要根据抖音团队的博客基于二进制文件重排的解决方案 以及其他资料(clang 插桩hook 所有方法, block, 函数) 进行分析

零、相关概念解释

  1. 虚拟内存: 为了解决内存读取的效率和安全问题, 引进了虚拟内存, 每个进程都有自己的一个虚拟地址的映射表, 通过映射表, 找到真实的内存地址.
    • 安全问题: 每个进程的虚拟内存是单独的, 不能直接通过访问物理内存就访问到进程数据
    • 效率问题: 通过对虚拟内存的管理, cpu可以通过虚拟内存和物理内存的映射, 来管理进程数据
  2. ASLR(Address space layout randomization): 通过对虚拟内存头部添加随机的地址空间偏移, 可以让进程数据更加安全
  3. 内存分页: 通物理内存一样, 虚拟内存也是通过分页分段管理的, iOS中, 一页为16K
  4. Page Fault: 当CPU访问到某数据并没有加载到内存时, 操作系统就会阻塞当前线程, 新加载一页到物理内存, 并且将虚拟内存与之对应, 这个阻塞的过程就叫做 缺页中断(Page Fault)

一、如何使用二进制重排启动优化的原理

在App启动的过程中, 大量的数据加载到内存中, 这样就会有许多的 Page Fault出现, 大量的 Page Fault 就会让启动更加耗时, 如果我们将启动时需要的数据全都放在同一页进行加载, 那么就可以大量减少启动时间, 我们将启动相关符号放到一页的过程, 就叫做二进制重排

将启动符号放在一页中

二、 如何检测 Page Fault

我可以通过 Instruments 工具中的 App Launch 来查看Page Fault数量

查看App冷启动的Page Fault

三、 如何进行二进制重排以及如果查看重排效果

我们可以通过给Xcode 设置 Link Map = YES, 来让Xcode 在编译时生成链接文件

设置生成Link Map File

Clear一下工程, 在进行Build, 然后在 Path to Link Map File 对应的路径中找到 xxxx.txt, 就是对应的链接文件. 打开文件我们找到Symbols 位置, 就是当前的编译顺序

当前Symbols 的编译顺序

这个顺序其实就是 Build Phases 中, Compile Source 的文件顺序

我们把排在靠后的符号写在Order 文件中

编辑Order file

给Xcode 设置Order File 生效, 来进行二进制重排

配置Order File 生效

然后再次编译, 查看新的符号顺序

重排后的符号顺序

可以看到, 重排后, 符号顺序跟我们Order File 是一致的. 至此我们就已经实现了二进制的重排

有人会害怕使用Order文件会被苹果拒绝, 其实苹果是允许Order文件的使用的, 我们可以在objc的源码中看到, 苹果自己也使用了Order文件 libobjc.order

四、如何找到App启动时, 都加载了哪些符号?

我们知道了如何进行二进制重排, 那么如果我们找到了App启动时加载的所有符号, 将这些符号写在Order 文件中, 就可以实现我们第一节说的, 将启动符号放在一页中, 实现App启动的优化.下面我们就讨论如果找到App启动时加载的符号

  1. 字节的团队的文章中, 使用的是 fishhook objc_msgSend 来获取, 这样的缺点是, 这个方案只能hook OC的方法, 不能Hook C函数, Block等.
  2. 我们介绍一种通过Clang 插桩的方式, 可以实现全面覆盖. 而且字节团队在博客最后也提到了他们会尝试这种方案实现100%覆盖 通过Clang 官网的文档, 我们先来实现插桩 Clang插桩文档
  1. 在Clang 编译设置中 添加 -fsanitize-coverage=trace-pc-guard

配置Clang

  1. 这个时候, 编译工程会报错, 我们需要将官网提供的2个C函数复制到工程
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;  
  printf("guard: %p %x PC %s\n", guard, *guard, PcDescr);
}
  1. 运行程序以后,发现这2个函数被调用了, 并且输出了一些东西
  2. printf("INIT: %p %p\n", start, stop); 其实是输出了当前工程所有符号的数量, 这个数量包含了C函数, OC方法, Block, 大家可以通过 lldb 进行验证(最好新建一个空工程, 这样符号个数会比较少).
  3. 并且项目中有多少个符号, 就会调用多少次 printf("guard: %p %x PC %s\n", guard,*guard, PcDescr);
  1. 我们给main函数打上一个断点, 通过汇编,我们发现 main函数执行后, 会通过 bl 指令 跳转到 __sanitizer_cov_trace_pc_guard 函数.

main函数跳转到 __sanitizer_cov_trace_pc_guard函数

  1. __sanitizer_cov_trace_pc_guard 执行完成后又会通过 b指令 跳转回 main函数

__sanitizer_cov_trace_pc_guard函数 跳转回 main函数

  1. 其实, Clang 插桩其实是在编译时, 给每个符号都添加了一个执行__sanitizer_cov_trace_pc_guard的代码, 所以每个符号的执行, 都会调用__sanitizer_cov_trace_pc_guard函数
  2. 这个方法可以全面hook C函数, OC方法, Block,这里就不做试验了, 大家可以自己测试代码,通过断点查看
  1. 既然 __sanitizer_cov_trace_pc_guard 函数可以hook所有的符号, 那么我们就可以在这个方法中来获取所有的符号, 这里就借助了汇编知识和系统库来获取
  1. LR(x30)寄存器: LR寄存器中存储的是函数返回地址的值
  2. __builtin_return_address(0); 方法可以拿到当前函数的返回地址, 也就是LR寄存器的值
  3. A函数中调用 B函数, 那么对于B函数来说, __builtin_return_address(0);拿到的就是他的返回函数, 也就是A函数
  4. Clang 是在每个符号中都添加了 (__sanitizer_cov_trace_pc_guard)函数的跳转, 那么我们在 (__sanitizer_cov_trace_pc_guard)函数中拿到他的返回函数, 也就是所有的符号
  5. 系统提供了 dladdr(PC, &info); 方法, 可以将地址值找到函数符号,
  6. dl_info结构体如下:
    typedef struct dl_info {
    const char *dli_fname; // 符号的文件名字
    void *dli_fbase; // 符号的文件地址
    const char *dli_sname; // 当前符号名字
    void *dli_saddr; // 当前符号地址
    } Dl_info;
  1. 我们修改 __sanitizer_cov_trace_pc_guard 中的代码, 来输出所有的符号名字
void __sanitizer_cov_trace_pc_guard(uint32_t *guard) {
    // if (!*guard) return; // load 会在这里return, 先注释掉
    void *PC = __builtin_return_address(0);
    Dl_info info;
    dladdr(PC, &info);
    printf("guard sname=%s\n",info.dli_sname);
} 

打印出来的所有符号

五、将我们打印的符号写到Order文件

使用新的Order文件, 再次查看Page Fault, 如果Order 文件中写了错误的代码, 他会自动忽略掉, 我们不用在意

二进制重排以后的Page Fault

可以看到, 我们的Page Fault(204 -> 152) 明显减少了, 我的测试项目文件并不多, 所以优化效果一般, 字节团队提到的是优化15%以上.

六、总结

至此, 基于二进制重排的App启动优化就全部完成, 有哪里说的不对欢迎留言指正. 也欢迎分享你们优化后, 具体数据是多少

我的测试项目是纯OC的项目, 而OC,Swift 混编的项目还需要做额外的设置, 还需要继续研究

另附上基础篇链接: iOS启动优化-基础篇