iOS 优化篇 - 启动优化之Clang插桩实现二进制重排

前言

  • 自从抖音团队分享了这篇 抖音研发实践:基于二进制文件重排的解决方案 APP启动速度提高超15% 启动优化文章后 , 二进制重排优化 pre-main 阶段的启动时间自此被你们广为流传 .html

  • 本篇文章首先讲述下二进制重排的原理 , ( 由于抖音团队在上述文章中原理部分大可能是点到即止 , 多数朋友看完并无什么实际收获 ) . 而后将结合 clang 插桩的方式 来实际讲述和演练一下如何解决抖音团队遗留下来的这一问题 :前端

    hook Objc_msgSend 没法解决的 纯swift , block , c++ 方法 .node

    来达到完美的二进制重排方案 .linux

( 本篇文章因为会从原理角度讲解 , 有些已经比较熟悉的同窗可能会以为节奏偏啰嗦 , 为了照顾大部分同窗 , 你们自行根据目录跳过便可 . ) c++

了解二进制重排以前 , 咱们须要了解一些前导知识 , 以及二进制重排是为了解决什么问题 .算法

虚拟内存与物理内存

在本篇文章里 , 笔者就不经过教科书或者大多数资料的方式来说述这个概念了 . 咱们经过实际问题和其对应的解决方式来看这个技术 or 概念 .swift

在计算机领域 , 任何一个技术 or 概念 , 都是为了解决实际的问题而诞生的 .数组

在早期的计算机中 , 并无虚拟内存的概念 , 任何应用被从磁盘中加载到运行内存中时 , 都是完整加载和按序排列的 .安全

那么所以 , 就会出现两个问题 :多线程

使用物理内存时遗留的问题

  • 安全问题 : 因为在内存条中使用的都是真实物理地址 , 并且内存条中各个应用进程都是按顺序依次排列的 . 那么在 进程1 中经过地址偏移就能够访问到 其余进程 的内存 .
  • 效率问题 : 随着软件的发展 , 一个软件运行时须要占用的内存愈来愈多 , 但每每用户并不会用到这个应用的全部功能 , 形成很大的内存浪费 , 然后面打开的进程每每须要排队等待 .

为了解决上述两个问题 , 虚拟内存应运而生 .

虚拟内存工做原理

引用了虚拟内存后 , 在咱们进程中认为本身有一大片连续的内存空间其实是虚拟的 , 也就是说从 0x000000 ~ 0xffffff 咱们是均可以访问的 . 可是实际上这个内存地址只是一个虚拟地址 , 而这个虚拟地址经过一张映射表映射后才能够获取到真实的物理地址 .

什么意思呢 ?

  • 实际上咱们能够理解为 , 系统对真实物理内存访问作了一层限制 , 只有被写到映射表中的地址才是被承认能够访问的 .
  • 例如 , 虚拟地址 0x000000 ~ 0xffffff 这个范围内的任意地址咱们均可以访问 , 可是这个虚拟地址对应的实际物理地址是计算机来随机分配到内存页上的 .
  • 这里提到了实际物理内存分页的概念 , 下面会详细讲述 .

可能你们也有注意到 , 咱们在一个工程中获取的地址 , 同时在另外一个工程中去访问 , 并不能访问到数据 , 其原理就是虚拟内存 .

整个虚拟内存的工做原理这里用一张图来展现 :

虚拟内存解决进程间安全问题原理

显然 , 引用虚拟内存后就不存在经过偏移能够访问到其余进程的地址空间的问题了 .

由于每一个进程的映射表是单独的 , 在你的进程中随便你怎么访问 , 这些地址都是受映射表限制的 , 其真实物理地址永远在规定范围内 , 也就不存在经过偏移获取到其余进程的内存空间的问题了 .

并且实际上 , 每次应用被加载到内存中 , 实际分配的物理内存并不必定是固定或者连续的 , 这是由于内存分页以及懒加载以及 ASLR 所解决的安全问题 .

cpu 寻址过程

引入虚拟内存后 , cpu 在经过虚拟内存地址访问数据的过程以下 :

  • 经过虚拟内存地址 , 找到对应进程的映射表 .
  • 经过映射表找到其对应的真实物理地址 , 进而找到数据 .

这个过程被称为 地址翻译 , 这个过程是由操做系统以及 cpu 上集成的一个 硬件单元 MMU 协同来完成的 .

那么安全问题解决了之后 , 效率问题如何解决呢 ?

虚拟内存解决效率问题

刚刚提到虚拟内存和物理内存经过映射表进行映射 , 可是这个映射并不多是一一对应的 , 那样就太过浪费内存了 . 为了解决效率问题 , 实际上真实物理内存是分页的 . 而映射表一样是以页为单位的 .

换句话说 , 映射表只会映射到一页 , 并不会映射到具体每个地址 .

linux 系统中 , 一页内存大小为 4KB , 在不一样平台可能各有不一样 .

  • Mac OS 系统中 , 一页为 4KB ,
  • iOS 系统中 , 一页为 16KB .

咱们可使用 pagesize 命令直接查看 .

那么为何说内存分页就能够解决内存浪费的效率问题呢 ?

内存分页原理

假设当前有两个进程正在运行 , 其状态就以下图所示 :

( 上图中咱们也看出 , 实际物理内存并非连续以及某个进程完整的 ) .

映射表左侧的 01 表明当前地址有没有在物理内存中 . 为何这么说呢 ?

  • 当应用被加载到内存中时 , 并不会将整个应用加载到内存中 . 只会放用到的那一部分 . 也就是懒加载的概念 , 换句话说就是应用使用多少 , 实际物理内存就实际存储多少 .

  • 当应用访问到某个地址 , 映射表中为 0 , 也就是说并无被加载到物理内存中时 , 系统就会马上阻塞整个进程 , 触发一个咱们所熟知的 缺页中断 - Page Fault .

  • 当一个缺页中断被触发 , 操做系统会从磁盘中从新读取这页数据到物理内存上 , 而后将映射表中虚拟内存指向对应 ( 若是当前内存已满 , 操做系统会经过置换页算法 找一页数据进行覆盖 , 这也是为何开再多的应用也不会崩掉 , 可是以前开的应用再打开时 , 就从新启动了的根本缘由 ).

经过这种分页和覆盖机制 , 就完美的解决了内存浪费和效率问题 .

可是此时 , 又出现了一个问题 .

问 : 当应用开发完成之后因为采用了虚拟内存 , 那么其中一个函数不管如何运行 , 运行多少次 , 都会是虚拟内存中的固定地址 .

什么意思呢 ?

假设应用有一个函数 , 基于首地址偏移量为 0x00a000 , 那么虚拟地址从 0x000000 ~ 0xffffff , 基于这个 , 那么这个函数我不管如何只须要经过 0x00a000 这个虚拟地址就能够拿到其真实实现地址 .

而这种机制就给了不少黑客可操做性的空间 , 他们能够很轻易的提早写好程序获取固定函数的实现进行修改 hook 操做 .

为了解决这个问题 , ASLR 应运而生 . 其原理就是 每次 虚拟地址在映射真实地址以前 , 增长一个随机偏移值 , 以此来解决咱们刚刚所提到的这个问题 .

( Android 4.0 , Apple iOS4.3 , OS X Mountain Lion10.8 开始全民引入 ASLR 技术 , 而实际上自从引入 ASLR 后 , 黑客的门槛也自此被拉高 . 再也不是人人均可作黑客的年代了 ) .

至此 , 有关物理内存 , 虚拟内存 , 内存分页的完整流程和原理 , 咱们已经讲述完毕了 , 那么接下来来到重点 , 二进制重排 .

二进制重排

概述

在了解了内存分页会触发中断异常 Page Fault 会阻塞进程后 , 咱们就知道了这个问题是会对性能产生影响的 .

实际上在 iOS 系统中 , 对于生产环境的应用 , 当产生缺页中断进行从新加载时 , iOS 系统还会对其作一次签名验证 . 所以 iOS 生产环境的应用 page fault 所产生的耗时要更多 .

抖音团队分享的一个 Page Fault,开销在 0.6 ~ 0.8ms , 实际测试发现不一样页会有所不一样 , 也跟 cpu 负荷状态有关 , 在 0.1 ~ 1.0 ms 之间 。

当用户使用应用时 , 第一个直接印象就是启动 app 耗时 , 而恰巧因为启动时期有大量的类 , 分类 , 三方 等等须要加载和执行 , 多个 page fault 所产生的的耗时每每是不能小觑的 . 这也是二进制重排进行启动优化的必要性 .

二进制重排优化原理

假设在启动时期咱们须要调用两个函数 method1method4 . 函数编译在 mach-o 中的位置是根据 ld ( Xcode 的连接器) 的编译顺序并不是调用顺序来的 . 所以极可能这两个函数分布在不一样的内存页上 .

那么启动时 , page1page2 则都须要从无到有加载到物理内存中 , 从而触发两次 page fault .

而二进制重排的作法就是将 method1method4 放到一个内存页中 , 那么启动时则只须要加载 page1 便可 , 也就是只触发一次 page fault , 达到优化目的 .

实际项目中的作法是将启动时须要调用的函数放到一块儿 ( 好比 前10页中 ) 以尽量减小 page fault , 达到优化目的 . 而这个作法就叫作 : 二进制重排 .

讲到这里相信不少同窗已经火烧眉毛的想要看看具体怎么二进制重排了 . 其实操做很简单 , 可是在操做以前咱们还须要知道这几点 :

  • 如何检测 page fault : 首先咱们要想看到优化效果 , 就应该知道如何查看 page fault , 以此来帮助咱们查看优化前以及优化后的效果 .

  • 如何重排二进制 .

  • 如何查看本身重排成功了没有 ?

  • 如何检测本身启动时刻须要调用的全部方法 .

    • hook objc_MsgSend ( 只能拿到 oc 以及 swift 加上 @objc dynamic 修饰后的方法 ) .
    • 静态扫描 macho 特定段和节里面所存储的符号以及函数数据 . (静态扫描 , 主要用来获取 load 方法 , c++ 构造(有关 c++ 构造 , 参考 从头梳理 dyld 加载流程 这篇文章有详细讲述和演示 ) .
    • clang 插桩 ( 完美版本 , 彻底拿到 swift , oc , c , block 所有函数 )

内容不少 , 咱们一项一项来 .

如何查看 page fault

提示 :

若是想查看真实 page fault 次数 , 应该将应用卸载 , 查看第一次应用安装后的效果 , 或者先打开不少个其余应用 .

由于以前运行过 app , 应用其中一部分已经被加载到物理内存并作好映射表映射 , 这时再启动就会少触发一部分缺页中断 , 而且杀掉应用再打开也是如此 .

其实就是但愿将物理内存中以前加载的覆盖/清理掉 , 减小偏差 .

  • 1️⃣ : 打开 Instruments , 选择 System Trace .
  • 2️⃣ : 选择真机 , 选择工程 , 点击启动 , 当首个页面加载出来点击中止 . 这里注意 , 最好是将应用杀掉从新安装 , 由于冷热启动的界定其实因为进程的缘由并不必定后台杀掉应用从新打开就是冷启动 .
  • 3️⃣ : 等待分析完成 , 查看缺页次数
    • 后台杀掉重启应用
    • 第一次安装启动应用

固然 , 你能够经过添加 DYLD_PRINT_STATISTICS 来查看 pre-main 阶段总耗时来作一个侧面辅证 .

你们能够分别测试如下几种状况 , 来深度理解冷启动 or 热启动以及物理内存分页覆盖的实际状况 .

  • 应用第一次安装启动
  • 应用后台没有打开时启动
  • 杀掉后台后从新启动
  • 不杀掉后台从新启动
  • 杀掉后台后多打开一些其余应用再次启动

二进制重排具体如何操做

说了这么多前导知识 , 终于要开始作二进制重排了 , 其实具体操做很简单 , Xcode 已经提供好这个机制 , 而且 libobjc 实际上也是用了二进制重排进行优化 .

参考下图

  • 首先 , Xcode 是用的连接器叫作 ld , ld 有一个参数叫 Order File , 咱们能够经过这个参数配置一个 order 文件的路径 .
  • 在这个 order 文件中 , 将你须要的符号按顺序写在里面 .
  • 当工程 build 的时候 , Xcode 会读取这个文件 , 打的二进制包就会按照这个文件中的符号顺序进行生成对应的 mach-O .

二进制重排疑问 - 题外话 :

  • 1️⃣ : order 文件里 符号写错了或者这个符号不存在会不会有问题 ?

    • 答 : ld 会忽略这些符号 , 实际上若是提供了 link 选项 -order_file_statistics,会以 warning 的形式把这些没找到的符号打印在日志里。 .
  • 2️⃣ : 有部分同窗可能会考虑这种方式会不会影响上架 ?

    • 答 : 首先 , objc 源码本身也在用这种方式 .
    • 二进制重排只是从新排列了所生成的 macho 中函数表与符号表的顺序 .

如何查看本身工程的符号顺序

重排先后咱们须要查看本身的符号顺序有没有修改为功 , 这时候就用到了 Link Map .

Link Map 是编译期间产生的产物 , ( ld 的读取二进制文件顺序默认是按照 Compile Sources - GUI 里的顺序 ) , 它记录了二进制文件的布局 . 经过设置 Write Link Map File 来设置输出与否 , 默认是 no .

修改完毕后 clean 一下 , 运行工程 , Products - show in finder, 找到 macho 的上上层目录.

按下图依次找到最新的一个 .txt 文件并打开.

这个文件中就存储了全部符号的顺序 , 在 # Symbols: 部分 ( 前面的 .o 等内容忽略 , 这部分在笔者后续讲述 llvm 编译器篇章会详细讲解 ) .

能够看到 , 这个符号顺序明显是按照 Compile Sources 的文件顺序来排列的 .

提示 :

上述文件中最左侧地址就是 实际代码地址而并不是符号地址 , 所以咱们二进制重排并不是只是修改符号地址 , 而是利用符号顺序 , 从新排列整个代码在文件的偏移地址 , 将启动须要加载的方法地址放到前面内存页中 , 以此达到减小 page fault 的次数从而实现时间上的优化 , 必定要清楚这一点 .

你能够利用 MachOView 查看排列先后在 _text( 代码段 ) 中的源码顺序来帮助理解 .

实战演练

来到工程根目录 , 新建一个文件 touch lb.order . 随便挑选几个启动时就须要加载的方法 , 例如我这里选了如下几个 .

-[LBOCTools lbCurrentPresentingVC]
+[LBOCTools lbGetCurrentTimes]
+[RSAEncryptor stripPublicKeyHeader:]
复制代码

写到该文件中 , 保存 , 配置文件路径 .

从新运行 , 查看 .

能够看到 , 咱们所写的这三个方法已经被放到最前面了 , 至此 , 生成的 macho 中距离首地址偏移量最小的代码就是咱们所写的这三个方法 , 假设这三个方法本来在不一样的三页 , 那么咱们就已经优化掉了两个 page fault.

错误提示

有部分同窗可能配置完运行会发现报错说can't open 这个 order file . 是由于文件格式的问题 . 不用使用 mac 自带的文本编辑 . 使用命令工具 touch 建立便可 .

获取启动加载全部函数的符号

讲到这 , 咱们就只差一个问题了 , 那就是如何知道个人项目启动须要调用哪些方法 , 上述篇章中咱们也有稍微提到一点 .

  • hook objc_MsgSend ( 只能拿到 oc 以及 swift @objc dynamic 后的方法 , 而且因为可变参数个数 , 须要用汇编来获取参数 .)
  • 静态扫描 macho 特定段和节里面所存储的符号以及函数数据 . (静态扫描 , 主要用来获取 load 方法 , c++ 构造(有关 c++ 构造 , 参考 从头梳理 dyld 加载流程 这篇文章有详细讲述和演示 ) .
  • clang 插桩 ( 完美版本 , 彻底拿到 swift , oc , c , block 所有函数 ) .

前两种这里咱们就不在赘述了 . 网上参考资料也较多 , 并且实现效果也并非完美状态 , 本文咱们来谈谈如何经过编译期插桩的方式来 hook 获取全部的函数符号 .

clang 插桩

关于 clang 的插桩覆盖的官方文档以下 : clang 自带代码覆盖工具 文档中有详细概述 , 以及简短 Demo 演示 .

思考

其实 clang 插桩主要有两个实现思路 , 一是本身编写 clang 插件 ( 自定义 clang 插件在后续底层篇 llvm 中会带着你们来手写一个本身的插件 ) , 另一个就是利用 clang 自己已经提供的一个工具 or 机制来实现咱们获取全部符号的需求 . 本文咱们就按照第二种思路来实际演练一下 .

原理探索

新建一个工程来测试和使用一下这个静态插桩代码覆盖工具的机制和原理 . ( 不想看这个过程的自行跳到静态插桩原理总结章节 )

按照文档指示来走 .

  • 首先 , 添加编译设置 .

直接搜索 Other C Flags 来到 Apple Clang - Custom Compiler Flags 中 , 添加

-fsanitize-coverage=trace-pc-guard
复制代码
  • 添加 hook 代码 .
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);
}
复制代码

笔者这里是写在空工程的 ViewController.m 里的.

  • 运行工程 , 查看打印

代码命名 INIT 后面打印的两个指针地址叫 startstop . 那么咱们经过 lldb 来查看下从 startstop 这个内存地址里面所存储的究竟是啥 .

发现存储的是从 114 这个序号 . 那么咱们来添加一个 oc 方法 .

- (void)testOCFunc{
    
}
复制代码

再次运行查看 .

发现从 0e 变成了 0f . 也就是说存储的 114 这个序号变成了 115 .

那么咱们再添加一个 c 函数 , 一个 block , 和一个触摸屏幕方法来看下 .

一样发现序号依次增长到了 18 个 , 那么咱们获得一个猜测 , 这个内存区间保存的就是工程全部符号的个数 .

其次 , 咱们在触摸屏幕方法调用了 c 函数 , c 函数中调用了 block . 那么咱们点击屏幕 , 发现以下 :

发现咱们实际调用几个方法 , 就会打印几回 guard : .

实际上就相似咱们埋点统计所实现的效果 . 在触摸方法添加一个断点查看汇编 :

经过汇编咱们发现 , 在每一个函数调用的第一句实际代码 ( 栈平衡与寄存器数据准备除外 ) , 被添加进去了一个 bl 调用到 __sanitizer_cov_trace_pc_guard 这个函数中来 .

而实际上这也是静态插桩的原理和名称由来 .

静态插桩总结

静态插桩其实是在编译期就在每个函数内部二进制源数据添加 hook 代码 ( 咱们添加的 __sanitizer_cov_trace_pc_guard 函数 ) 来实现全局的方法 hook 的效果 .

疑问

可能有部分同窗对我上述表述的原理总结有些疑问 .

到底是直接修改二进制在每一个函数内部都添加了调用 hook 函数这个汇编代码 , 仍是只是相似于编译器在所生成的二进制文件添加了一个标记 , 而后在运行时若是有这个标记就会自动多作一步调用 hook 代码呢 ?

笔者这里使用 hopper 来看下生成的 mach-o 二进制文件 .

上述二进制源文件咱们就发现 , 的确是函数内部 一开始就添加了 调用额外方法的汇编代码 . 这也是咱们为何称其为 " 静态插桩 " .

讲到这里 , 原理咱们大致上了解了 , 那么到底如何才能拿到函数的符号呢 ?

获取全部函数符号

先理一下思路 .

思路

咱们如今知道了 , 全部函数内部第一步都会去调用 __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 这句汇编代码的下一条指令的地址 .

可能有点绕 , 画个图来梳理一下流程 .

根据内存地址获取函数名称

拿到了函数内部一行代码的地址 , 如何获取函数名称呢 ? 这里笔者分享一下本身的思路 .

熟悉安全攻防 , 逆向的同窗可能会清楚 . 咱们为了防止某些特定的方法被别人使用 fishhook hook 掉 , 会利用 dlopen 打开动态库 , 拿到一个句柄 , 进而拿到函数的内存地址直接调用 .

是否是跟咱们这个流程有点类似 , 只是咱们好像是反过来的 . 其实反过来也是能够的 .

dlopen 相同 , 在 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 <dlfcn.h> , 而后修改代码以下 :

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("fname=%s \nfbase=%p \nsname=%s\nsaddr=%p \n",info.dli_fname,info.dli_fbase,info.dli_sname,info.dli_saddr);
    
    char PcDescr[1024];
    printf("guard: %p %x PC %s\n", guard, *guard, PcDescr);
}
复制代码

查看打印结果 :

终于看到咱们要找的符号了 .


收集符号

看到这里 , 不少同窗可能想的是 , 那立刻到工程里去拿到我全部的符号 , 写到 order 文件里不就完事了吗 ?

为何呢 ??

clang静态插桩 - 坑点1

→ : 多线程问题

这是一个多线程的问题 , 因为你的项目各个方法确定有可能会在不一样的函数执行 , 所以 __sanitizer_cov_trace_pc_guard 这个函数也有可能受多线程影响 , 因此你固然不可能简简单单用一个数组来接收全部的符号就搞定了 .

那方法有不少 , 笔者在这里分享一下本身的作法 :

考虑到这个方法会来特别屡次 , 使用锁会影响性能 , 这里使用苹果底层的原子队列 ( 底层其实是个栈结构 , 利用队列结构 + 原子性来保证顺序 ) 来实现 .

- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event{
    //遍历出队
    while (true) {
        //offsetof 就是针对某个结构体找到某个属性相对这个结构体的偏移量
        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));
}
复制代码

当你兴致冲冲开始考虑好多线程的解决方法写完以后 , 运行发现 :

死循环了 .

clang静态插桩 - 坑点2

→ : 上述这种 clang 插桩的方式 , 会在循环中一样插入 hook 代码 .

当肯定了咱们队列入队和出队都是没问题的 , 你本身的写法对应的保存和读取也是没问题的 , 咱们发现了这个坑点 , 这个会死循环 , 为何呢 ?

这里我就不带着你们去分析汇编了 , 直接说结论 :

经过汇编会查看到 一个带有 while 循环的方法 , 会被静态加入屡次 __sanitizer_cov_trace_pc_guard 调用 , 致使死循环.

→ : 解决方案

Other C Flags 修改成以下 :

-fsanitize-coverage=func,trace-pc-guard
复制代码

表明进针对 func 进行 hook . 再次运行 .

又觉得完事了 ? 尚未..

坑点3 : load 方法

→ : load 方法时 , __sanitizer_cov_trace_pc_guard 函数的参数 guard 是 0.

上述打印并无发现 load .

解决 : 屏蔽掉 __sanitizer_cov_trace_pc_guard 函数中的

if (!*guard) return;
复制代码

load 方法就有了 .

这里也为咱们提供了一点启示:

若是咱们但愿从某个函数以后/以前开始优化 , 经过一个全局静态变量 , 在特定的时机修改其值 , 在 `__sanitizer_cov_trace_pc_guard` 这个函数中作好对应的处理便可 .

剩余细化工做

  • 若是你也是使用笔者这种多线程处理方式的话 , 因为用的先进后出缘由 , 咱们要倒叙一下
  • 还须要作去重 .
  • order 文件格式要求c 函数 , block 调用前面还须要加 _ , 下划线 .
  • 写入文件便可 .

笔者 demo 完整代码以下 :

#import "ViewController.h"
#import <dlfcn.h>
#import <libkern/OSAtomic.h>
@interface ViewController ()
@end

@implementation ViewController
+ (void)load{
    
}
- (void)viewDidLoad {
    [super viewDidLoad];
    testCFunc();
    [self testOCFunc];
}
- (void)testOCFunc{
    NSLog(@"oc函数");
}
void testCFunc(){
    LBBlock();
}
void(^LBBlock)(void) = ^(void){
    NSLog(@"block");
};

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)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event{
    NSMutableArray<NSString *> * symbolNames = [NSMutableArray array];
    while (true) {
        //offsetof 就是针对某个结构体找到某个属性相对这个结构体的偏移量
        SymbolNode * node = OSAtomicDequeue(&symboList, 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:@"lb.order"];
    NSData * fileContents = [funcString dataUsingEncoding:NSUTF8StringEncoding];
    BOOL result = [[NSFileManager defaultManager] createFileAtPath:filePath contents:fileContents attributes:nil];
    if (result) {
        NSLog(@"%@",filePath);
    }else{
        NSLog(@"文件写入出错");
    }
    
}
//原子队列
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));
}
@end
复制代码

文件写入到了 tmp 路径下 , 运行 , 打开手机下载查看 :

搞定 , 小伙伴们就能够立马去优化本身的工程了 .

swift 工程 / 混编工程问题

经过如上方式适合纯 OC 工程获取符号方式 .

因为 swift 的编译器前端是本身的 swift 编译前端程序 , 所以配置稍有不一样 .

搜索 Other Swift Flags , 添加两条配置便可 :

  • -sanitize-coverage=func
  • -sanitize=undefined

swift 类经过上述方法一样能够获取符号 .

优化后效果监测

在彻底第一次安装冷启动 , 保证一样的环境 , page fault 采样一样截取到第一个可交互界面 , 使用重排优化先后效果以下 .

  • 优化前
  • 优化后

实际上 , 在生产环境中 , 因为 page fault 还须要签名验证 , 所以在分发环境下 , 优化效果其实更多 .

总结

本篇文章经过以实际碳素过程为基准 , 一步一步实现 clang 静态插桩达到二进制重排优化启动时间的完整流程 .

具体实现步骤以下 :

  • 1️⃣ : 利用 clang 插桩得到启动时期须要加载的全部 函数/方法 , block , swift 方法以及 c++构造方法的符号 .
  • 2️⃣ : 经过 order file 机制实现二进制重排 .

若有疑问或者不一样见解 , 欢迎留言交流 .

相关文章
相关标签/搜索