iOS之武功秘籍⑱: 启动优化(重点是二进制重排)

1,448 阅读27分钟

iOS之武功秘籍 文章汇总

写在前面

启动是App给用户的第一印象,对用户体验至关重要.试想一个App需要启动5s以上,你还想用它么?

最初的工程肯定是没有这些问题的,但随着业务需求不断丰富,代码越来越多.如果放任不管的话,启动时间会不断上涨,最后让人无法接受.

本文从优化原理出发,介绍了我是如何通过Clang插桩找到启动所需符号,然后修改编译参数完成二进制文件的重新排布提升应用的启动速度的.

本节可能用到的秘籍Demo

一、基本概念(知识储备)

①. 虚拟内存 & 物理内存

早期的数据访问是直接通过物理地址访问的,以这种方式访问会存在以下两个问题:

  1. 内存不够用
  2. 内存数据的安全问题

①.1 内存不够用的解决方案:虚拟内存

针对问题1,我们在进程和物理内存之间增加一个中间层,这个中间层就是所谓的虚拟内存,主要用于解决当多个进程同时存在时,对物理内存的管理.提高了CPU的利用率,使多个进程可以同时、按需加载.所以虚拟内存其本质就是一张虚拟地址和物理地址对应关系的映射表

  • 每个进程都有一个独立的虚拟内存,其地址都是从0开始,大小是4G固定的,每个虚拟内存又会划分为一个一个的,每次加载都是以页为单位加载的,进程间是无法互相访问的,保证了进程间数据的安全性.

  • 物理内存和虚拟内存以页为单位映射,但是这个映射关系不是一一对应的: 一页物理内存可能对应多页虚拟内存; 一页虚拟内存也可能不占用物理内存(从iphone 6s开始, 物理内存的Page大小是16K, 6和之前的设备都是 4K, 这就是iPhone 6 相比6s启动速度断崖式下降的原因之一

  • 一个进程中,只有部分功能是活跃的,所以只需要将进程中活跃的部分放入物理内存,避免物理内存的浪费

  • 当CPU需要访问数据时,首先是访问虚拟内存,然后通过虚拟内存去寻址,即可以理解为在表中找对应的物理地址,然后对相应的物理地址进行访问

  • 如果在访问时,虚拟地址的内容未加载到物理内存,会发生缺页异常(pagefault),将当前进程阻塞掉,此时需要先将数据载入到物理内存,然后再寻址,进行读取.这样就避免了内存浪费

如下图所示,虚拟内存与物理内存间的关系

①.2 内存数据的安全问题:ASLR技术

在上面解释的虚拟内存中,我们提到了虚拟内存的起始地址与大小都是固定的,这意味着,当我们访问时,其数据的地址也是固定的,这会导致我们的数据非常容易被破解,为了解决这个问题,苹果在iOS4.3开始引入了ASLR技术.

ASLR的概念:(Address Space Layout Randomization ) 地址空间配置随机加载,是一种针对缓冲区溢出的安全保护技术,通过对堆、栈、共享库映射等线性区布局的随机化,通过增加攻击者预测目的地址的难度,防止攻击者直接定位攻击代码位置,达到阻止溢出攻击的目的的一种技术.

其目的是通过利用随机方式配置数据地址空间,使某些敏感数据(例如APP登录注册、支付相关代码)配置到一个恶意程序无法事先获知的地址,令攻击者难以进行攻击.

由于ASLR的存在,导致可执行文件和动态链接库在虚拟内存中的加载地址每次启动都不固定,所以需要在编译时来修复镜像中的资源指针,来指向正确的地址。即正确的内存地址 = ASLR地址 + 偏移值

②. 可执行文件

不同的操作系统,其可执行文件的格式也不同.系统内核将可执行文件读取到内存,然后根据可执行文件的头签名(magic魔数)判断二进制文件的格式

其中PE、ELF、Mach-O这三种可执行文件格式都是COFF(Command file format)格式的变种,COFF的主要贡献是目标文件里面引入了“段”的机制,不同的目标文件可以拥有不同数量和不同类型的“段”

③. 通用二进制文件

因为不同CPU平台支持的指令不同,比如arm64x86,苹果中的通用二进制格式就是将多种架构的Mach-O文件打包在一起,然后系统根据自己的CPU平台,选择合适的Mach-O,所以通用二进制格式也被称为胖二进制格式,如下图所示

通用二进制格式的定义在<mach-o/fat.h>中,可以在下载xnu,然后根据 xnu -> EXTERNAL_HEADERS ->mach-o中找到该文件. 通用二进制文件开始的Fat Headerfat_header结构体,而Fat Archs是表示通用二进制文件中有多少个Mach-O,单个Mach-O的描述是通过fat_arch结构体.两个结构体的定义如下:

所以,综上所述:

  1. 通用二进制文件是苹果公司提出的一种新的二进制文件的存储结构,可以同时存储多种架构的二进制指令,使CPU在读取该二进制文件时可以自动检测并选用合适的架构,以最理想的方式进行读取
  2. 由于通用二进制文件会同时存储多种架构,所以比单一架构的二进制文件大很多,会占用大量的磁盘空间,但由于系统会自动选择最合适的,不相关的架构代码不会占用内存空间,且执行效率高
  3. 还可以通过指令来进行Mach-O的合并与拆分
    1. 查看当前Mach-O的架构:lipo -info MachO文件
    2. 合并:lipo -create MachO1 MachO2 -output 输出文件路径
    3. 拆分:lipo MachO文件 –thin 架构 –output 输出文件路径

④. Mach-O文件

Mach-O文件是Mach Object文件格式的缩写,它是用于可执行文件、动态库、目标代码的文件格式.作为a.out格式的替代,Mach-O格式提供了更强的扩展性,以及更快的符号表信息访问速度

熟悉Mach-O文件格式,有助于更好的理解苹果底层的运行机制,更好的掌握dyld加载Mach-O的步骤

④.1 Mach-O文件

如果想要查看具体的Mach-O文件信息,可以使用MachOView软件查看:将Mach-O可执行文件拖动到MachOView工具打开

④.2 Mach-O文件格式

对于OS X 和iOS来说,Mach-O是其可执行文件的格式,主要包括以下几种文件类型

  • Executable:可执行文件
  • Dylib:动态链接库
  • Bundle:无法被链接的动态库,只能在运行时使用dlopen加载
  • Image:指的是Executable、Dylib和Bundle的一种
  • Framework:包含Dylib、资源文件和头文件的集合

下面图示是Mach-O 镜像文件格式

以上是Mach-O文件的格式,一个完成的Mach-O文件主要分为三大部分:

  • Header Mach-O头部:主要是Mach-O的cpu架构,文件类型以及加载命令等信息
  • Load Commands 加载命令:描述了文件中数据的具体组织结构,不同的数据类型使用不同的加载命令表示
  • Data 数据:数据中的每个段(segment)的数据都保存在这里,段的概念与ELF文件中段的概念类似.每个段都有一个或多个部分,它们放置了具体的数据与代码,主要包含代码,数据,例如符号表,动态符号表等等

Header Mach-O的Header包含了整个Mach-O文件的关键信息,使得CPU能快速知道Mac-O的基本信息,其在MachO.h文件中针对32位和64位架构的cpu,分别使用了mach_headermach_header_64结构体来描述Mach-O头部.mach_header是连接器加载时最先读取的内容,决定了一些基础架构、系统类型、指令条数等信息,这里查看64位架构的mach_header_64结构体定义,相比于32位架构的mach_header,只是多了一个reserved保留字段

其中filetype主要记录Mach-O的文件类型,常用的有以下几种

#define MH_OBJECT   0x1     /* 目标文件*/
#define MH_EXECUTE  0x2     /* 可执行文件*/
#define MH_DYLIB    0x6     /* 动态库*/
#define MH_DYLINKER 0x7     /* 动态链接器*/
#define MH_DSYM     0xa     /* 存储二进制文件符号信息,用于debug分析*/

相对应的,Header在MachOView中的展示如下

Load Commands 在Mach-O文件中,Load Commands主要是用于加载指令,其大小和数目在Header中已经被提供,其在MachO.h中的定义如下

我们在MachOView中查看Load Commands,其中记录了很多信息,例如动态链接器的位置、程序的入口、依赖库的信息、代码的位置、符号表的位置等等,如下所示

其中LC_SEGMENT_64的类型segment_command_64定义如下

Data Load Commands后就是Data区域,这个区域存储了具体的只读、可读写代码,例如方法、符号表、字符表、代码数据、连接器所需的数据(重定向、符号绑定等)。主要是存储具体的数据。其中大多数的Mach-O文件均包含以下三个段:

  • __TEXT 代码段:只读,包括函数,和只读的字符串
  • __DATA 数据段:读写,包括可读写的全局变量等
  • __LINKEDIT: __LINKEDIT包含了方法和变量的元数据(位置,偏移量),以及代码签名等信息.

Data区中,Section占了很大的比例,SectionMachO.h中是以结构体section_64(在arm64架构下)表示,其定义如下

Section在MachOView中可以看出,主要集中体现在TEXT和DATA两段里,如下所示

其中常见的section,主要有以下一些

所以,综上所述,Mach-O的格式图示,如下所示

二、App启动

进程如果能直接访问物理内存无疑是很不安全的,所以操作系统在物理内存之上又建立了一层虚拟内存.苹果在这个基础上还有 ASLR(Address Space Layout Randomization) 技术的保护(前面概念有介绍).

iOS系统中虚拟内存到物理内存的映射都是以页为最小单位的.当进程访问一个虚拟内存Page而对应的物理内存却不存在时,就会出现Page Fault缺页中断,然后加载这一页.虽然本身这个处理速度是很快的,但是在一个App的启动过程中可能出现上千(甚至更多)次Page Fault,这个时间积累起来会比较明显了.

iOS系统中一页是16KB.

我们常说的启动是指点击App到第一页显示为止,包含pre-mainmaindidFinishLaunchingWithOptions结束的整个时间.

另外,还有两个重要的概念:冷启动热启动.可能有些同学认为杀掉再重启App就是冷启动了,其实是不对的.

  • 冷启动 程序完全退出,之间加载的分页数据被其他进程所使用覆盖之后,或者重启设备、第一次安装,才算是冷启动.

  • 热启动 程序杀掉之后,马上又重新启动.这个时候相应的物理内存中仍然保留之前加载过的分页数据,可以进行重用,不需要全部重新加载.所以热启动的速度比较快.

而我们这里所说的启动优化,一般是指冷启动情况下的,这种情况下的启动主要分为两部分:

  • T1pre-main阶段,即main函数之前,操作系统加载App可执行文件到内存,执行一系列的加载&链接等工作,简单来说,就是dyld加载过程
  • T2:main函数之后,即从main函数开始,到 AppdelegatedidFinishLaunching方法执行完成为止,主要是构建第一个界面,并完成渲染

所以,T1+T2 的过程就是从用户点击App图标到用户能看到app主界面的过程,即需要启动优化的部分

①. pre-main阶段的优化

pre-main阶段的启动时间其实就是dyld加载过程的时间

针对main函数之前的启动时间,苹果提供了内建的测量方法,在 Edit Scheme -> Run -> Arguments ->Environment Variables 点击+添加环境变量 DYLD_PRINT_STATISTICS 设为 1),然后运行,以下是iPhone6sp正常启动的pre-main时间(以WeChat为例)

说明 pre-main阶段总共用时1.1s

  • dylib loading time(动态库耗时):主要是加载动态库,用时297.53ms

    • 动态加载程序查找并读取应用程序使用的依赖动态库.每个库本身都可能有依赖项.虽然苹果系统框架的加载是高度优化的,但加载嵌入式框架可能会很耗时.为了加快动态库的加载速度,苹果建议您使用更少的动态库,或者考虑合并它们.
    • 建议的目标是六个额外的(非系统)框架.
  • rebase/binding time(偏移修正/符号绑定耗时):耗时133.43ms

    • 修正调整镜像内的指针(重新调整)和设置指向镜像外符号的指针(绑定).为了加快重新定位/绑定时间,我们需要更少的指针修复.
    • rebase(偏移修正):任何一个app生成的二进制文件,在二进制文件内部所有的方法、函数调用,都有一个地址,这个地址是在当前二进制文件中的偏移地址.一旦在运行时刻(即运行到内存中),每次系统都会随机分配一个ASLR(Address Space Layout Randomization,地址空间布局随机化)地址值(是一个安全机制,会分配一个随机的数值,插入在二进制文件的开头),例如:二进制文件中有一个 test 方法,偏移值是 0x0001,而随机分配的 ASLR0x1f00,如果想访问 test方法,其内存地址(即真实地址)变为 ASLR+偏移值 = 运行时确定的内存地址(即0x1f00 + 0x0001 = 0x1f01
    • binding(绑定):例如 NSLog 方法,在编译时期生成的mach-o文件中,会创建一个符号!NSLog(目前指向一个随机的地址),然后在运行时(从磁盘加载到内存中,是一个镜像文件),会将真正的地址给符号(即在内存中将地址与符号进行绑定,是dyld做的,也称为动态库符号绑定),一句话概括:绑定就是给符号赋值的过程
  • ObjC setup time(OC类注册的耗时):OC类越多,越耗时

    • Objective-C运行时需要进行设置类、类别和选择器注册.我们对重新定位绑定时间所做的任何改进也将优化这个设置时间
    • 如果有大量(大的是20000)Objective-C类、选择器和类别的应用程序可以增加800ms的启动时间.
    • 如果应用程序使用C++代码,那么使用更少的虚拟函数.
    • 使用Swift结构体通常也更快
  • initializer time(执行load和构造函数的耗时)

    • 运行初始化程序.如果使用了Objective-C的 +load 方法,请将其替换为 +initialize 方法.

②. main函数阶段的优化

在main函数之后的 didFinishLaunching 方法中,主要是执行了各种业务,有很多并不是必须在这里立即执行的,这种业务我们可以采取延迟加载,防止影响启动时间.

didFinishLaunching中的业务主要分为三个类型

  • 【第一类】初始化第三方sdk
  • 【第二类】app运行环境配置
  • 【第三类】自己工具类的初始化等

main函数阶段的优化建议主要有以下几点:

  • 减少启动初始化的流程,能懒加载的懒加载,能延迟的延迟,能放后台初始化的放后台,尽量不要占用主线程的启动时间
  • 优化代码逻辑,去除非必须的代码逻辑,减少每个流程的消耗时间
  • 启动阶段能使用多线程来初始化的,就使用多线程
  • 尽量使用纯代码来进行UI框架的搭建,尤其是主UI框架,例如 UITabBarController.尽量避免使用Xib或者SB,相比纯代码而言,这种更耗时
  • 删除废弃类、方法

三、二进制重排 —— 主要是针对如何减少Page Fault的优化

前面大致介绍了一些基本概念以及启动优化的思路,下面来着重介绍一个pre-main阶段的优化方案,即二进制重排

①. 二进制重排原理

在虚拟内存部分,我们知道,当进程访问一个虚拟内存page,而对应的物理内存不存在时,会触发缺页中断(Page Fault),因此阻塞进程.此时就需要先加载数据到物理内存,然后再继续访问.这个对性能是有一定影响的.

基于Page Fault,我们思考,App在冷启动过程中,会有大量的类、分类、三方等需要加载和执行,此时产生的Page Fault所带来的耗时是很大的.以WeChat为例,我们来看下,在启动阶段的Page Fault的次数

  • CMD+i快捷键,选择System Trace

  • 点击启动(启动前需要重启手机,清除缓存数据),第一个界面出来后,停掉,按照下图中操作

从图中可以看出WeChat发生的PageFault有2900+次,可想而知,这个是非常影响性能的.

  • 然后我们再通过Demo查看方法在编译时期的排列顺序,在ViewController中按下列顺序定义以下几个方法

  • Build Settings -> Write Link Map File 设置为 YES

  • CMD+B编译Demo,然后在对应的路径下查找 link map文件.右键 Show In Finder打开包文件夹:

    • 在包文件的上两层级,找到 Intermediates.noindex:

    • 沿路径找到并打开① - 启动优化Demo-LinkMap-normal-arm64.txt文件:

  • 函数顺序(书写顺序),如下所示,可以发现 类中函数的加载顺序是从上到下的,而文件的顺序是根据Build Phases -> Compile Sources中的顺序加载的

总结 从上面的Page Fault的次数以及加载顺序,可以发现其实导致 Page Fault 次数过多的根本原因是启动时刻需要调用的方法,处于不同的Page导致的.因此,我们的优化思路就是:将所有启动时刻需要调用的方法,排列在一起,即放在一个页中,这样就从多个 Page Fault 变成了一个 Page Fault. 这就是二进制重排的 核心原理,如下所示

注意:在iOS生产环境的app,在发生Page Fault进行重新加载时,iOS系统还会对其做一次签名验证,因此 iOS 生产环境的 Page Fault 比Debug环境下所产生的耗时更多

②. 二进制重排实践

下面,我们来进行具体的实践,首先理解几个名词

②.1 Link Map

Link Map 是iOS编译过程的中间产物,记录了二进制文件的布局,需要在Xcode的Build Settings 里开启Write Link Map File,Link Map主要包含三部分:

  • Object Files 生成二进制用到的link单元的路径和文件编号
  • Sections 记录Mach-O每个Segment/section的地址范围
  • Symbols 按顺序记录每个符号的地址范围

②.2 ld

ld是Xcode使用的链接器,有一个参数order_file,我们可以通过在Build Settings -> Order File配置一个后缀为order的文件路径.在这个order文件中,将所需要的符号按照顺序写在里面,在项目编译时,会按照这个文件的顺序进行加载,以此来达到我们的优化

所以二进制重排的本质就是对启动加载的符号进行重新排列

到目前为止,原理我们基本弄清楚了,如果项目比较小,完全可以自定义一个order文件,将方法的顺序手动添加,但是如果项目较大,涉及的方法特别多,此时我们如何获取启动运行的函数呢?有以下几种思路:

  1. hook objc_msgSend:我们知道,函数的本质是发送消息,在底层都会来到objc_msgSend,但是由于objc_msgSend的参数是可变的,需要通过汇编获取,对开发人员要求较高.而且也只能拿到 OCswift@objc 后的方法
  2. 静态扫描:扫描 Mach-O 特定段和节里面所存储的符号以及函数数据
  3. Clang插桩:即批量hook,可以实现100%符号覆盖,即完全获取swift、OC、C、block函数

②.3 二进制重排初体验

二进制重排,关键是order文件

  • 前面讲objc源码时,会在工程中看到order文件:

  • 打开.order文件,可以看到内部都是排序好的函数符号

  • 这是因为苹果自己的库,也都进行了二进制重排

新进一个Demo (② - 二进制重排初体验) 玩玩 我们打开创建的Demo项目,我想把排序改成load->test1->test2->ViewDidAppear->main

  • 在Demo项目根目录创建一个tcj.order文件

    touch tcj.order
    

  • tcj.order文件中手动顺序写入函数(还写了个不存在的hello函数)

  • Build Settings中搜索order file,加入./tcj.order

  • Command + B编译后,再次去查看link map文件:

    • 发现order文件中不存在的函数(hello),编译器会直接跳过
    • 其他函数符号,完全按照我们order顺序排列
    • order中没有的函数,按照默认顺序接在order函数后面
  • 那么问题来了.靠手写一个个函数写进order文件中.代码写了那么多,还有些代码不是我写的,我怎么知道哪个函数先,哪个函数后呢??

    • 我们要做到的目标: 拿到启动完成后的某个时刻之前的所有被调用函数.劳烦你们自己排队进入我的order文件中(Clang插桩来实现)

②.4 Clang插桩

要真正的实现二进制重排,我们需要拿到启动的所有方法、函数等符号,并保存其顺序,然后写入order文件,实现二进制重排.

抖音有一篇文章抖音研发实践:基于二进制文件重排的解决方案 APP启动速度提升超15%,但是文章中也提到了瓶颈:

基于静态扫描+运行时trace的方案仍然存在少量瓶颈:

  • initialize hook不到
  • 部分block hook不到
  • C++通过寄存器的间接函数调用静态扫描不出来

目前的重排方案能够覆盖到80%~90%的符号,未来我们会尝试编译期插桩等方案来进行100%的符号覆盖,让重排达到最优效果。

同时也给出了解决方案编译期插桩.

在说clang插桩之前,我们来说说什么是hook?

hook是钩子. -- 获取原有函数符号内存地址实现勾住它,做一些自己想做的事情

  • 例如: 你遇到在公路上拦到一辆车.你可以跟他的车一起走(附加自己代码),也可以直接抢了他的车自己开(重写实现).

很明显,我们此刻就是想勾住启动结束前的所有函数,附加一些代码,把函数名按顺序存下来,生成我们的order文件

Q: 有没有API,能让我hook一切我想hook的东西?swift、oc、c函数我都要hook? A: 有,clang插桩. 语法树都是它生成的,顺序它说了算.

Clang插桩 llvm内置了一个简单的代码覆盖率检测(SanitizerCoverage).它在函数级、基本块级和边缘级插入对用户定义函数的调用.我们这里的批量hook,就需要借助于SanitizerCoverage.

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

我们创建TraceDemo项目,按照官方给的示例,来尝试开发

添加trace

  • 按照官方描述,可以加入跟踪代码,并给出了回调函数.

打开我们的TranceDemo, 在Build Settings中搜索Other C,在 Other C Flags里加入-fsanitize-coverage=trace-pc-guard配置,编译的话会报错 objc Undefined symbol: ___sanitizer_cov_trace_pc_guard_init Undefined symbol: ___sanitizer_cov_trace_pc_guard

查看官网会需要我们添加两个函数:

#include <stdint.h>
#include <stdio.h>
#include <sanitizer/coverage_interface.h>

// This callback is inserted by the compiler as a module constructor
// into every DSO. 'start' and 'stop' correspond to the
// beginning and end of the section with the guards for the entire
// binary (executable or DSO). The callback will be called at least
// once per DSO and may be called multiple times with the same parameters.
extern "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.
}

// This callback is inserted by the compiler on every edge in the
// control flow (some optimizations apply).
// Typically, the compiler will emit the code like this:
//    if(*guard)
//      __sanitizer_cov_trace_pc_guard(guard);
// But for large functions it will emit a simple call:
//    __sanitizer_cov_trace_pc_guard(guard);
extern "C" void __sanitizer_cov_trace_pc_guard(uint32_t *guard) {
  if (!*guard) return;  // Duplicate the guard check.
  // If you set *guard to 0 this code will not be called again for this edge.
  // Now you can get the PC and do whatever you want:
  //   store it somewhere or symbolize it and print right away.
  // The values of `*guard` are as you set them in
  // __sanitizer_cov_trace_pc_guard_init and so you can make them consecutive
  // and use them to dereference an array or a bit vector.
  void *PC = __builtin_return_address(0);
  char PcDescr[1024];
  // This function is a part of the sanitizer run-time.
  // To use it, link with AddressSanitizer or other sanitizer.
  __sanitizer_symbolize_pc(PC, "%p %F %L", PcDescr, sizeof(PcDescr));
  printf("guard: %p %x PC %s\n", guard, *guard, PcDescr);
}

我们把代码添加到ViewController.m中,我们不需要 extern "C" 所以可以删掉, __sanitizer_symbolize_pc() 还会报错,不重要先注释了然后继续.

函数 __sanitizer_cov_trace_pc_guard_init 统计了方法的个数.

运行后,我们可以看到

读取内存之后,我们可以看到一个类似计数器的东西.最后一个打印的是结束位置,按显示是4位4位的,所以向前移动4位,打印出来的应该就是最后一位.

解释两个参数:

  • 参数1 start 是一个指针,指向无符号int类型,4个字节,相当于一个数组的起始位置,即符号的起始位置(是从高位往低位读)
  • 参数2 stop,由于数据的地址是往下读的(即从高往低读,所以此时获取的地址并不是stop真正的地址,而是标记的最后的地址,读取stop时,由于stop占4个字节,stop真实地址 = stop打印的地址-0x4
  • startstop表示当前文件的开始内存地址和结束内存地址。单位是int32 4字节
  • 如果多加几个函数,会发现stop地址值也会相应的增加。
  • 此处是指从start到stop的前闭后开区间。[ , ),所以stop地址往前偏移4字节,才是最后一个函数符号的地址

根据小端模式,0e 00 00 00对应的是00 00 00 0e即14.

那么stop内存地址中存储的值表示什么?在增加一个方法/块/c++/属性的方法(多几个),发现其值也会增加对应的数.

例如先在ViewController.m增加一个touchesBegan方法,运行:

根据小端模式,0f 00 00 00对应的是00 00 00 0f即15.

我们在增加一个函数test():运行: 根据小端模式,10 00 00 00对应的是00 00 00 10即16.

我们在增加一个block:运行: 根据小端模式,11 00 00 00对应的是00 00 00 11即17.

到此时可以看到一共增加了3(block是匿名函数),计数器统计了函数/方法/块的个数,这里添加了三个,索引增加了3

从新整理一下代码:

#import "ViewController.h"
#include <stdint.h>
#include <stdio.h>
#include <sanitizer/coverage_interface.h>

@interface ViewController ()

@end

@implementation ViewController

void test()
{
    block();
}

void(^block)(void) = ^(void){
    
};

- (void)viewDidLoad {
    [super viewDidLoad];
}

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);
}

- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event{
    
    test();
}

@end

运行项目清空打印区:当我们再点击一下屏幕的时候:

我们在touchBegintestblock__sanitizer_cov_trace_pc_guard都加入断点,运行代码:

【验证一】执行顺序是:touchesBegan -> __sanitizer_cov_trace_pc_guard -> test -> __sanitizer_cov_trace_pc_guard -> block -> __sanitizer_cov_trace_pc_guard

【验证二】touchesBegan时,进入汇编:

如果我们查看其他函数也会发现汇编代码中有类似的显示.那么每个函数在触发时,都调用了__sanitizer_cov_trace_pc_guard函数.

即:只要在Other C Flags处加入标记,开启了trace功能.LLVM会在每个函数边缘(开始位置),插入一行调用__sanitizer_cov_trace_pc_guard的代码.编译期就插入了.所以可以100%覆盖.(也就是说Clang插桩就是在汇编代码中插入了 __sanitizer_cov_trace_pc_guard函数的调用)

解释一下__sanitizer_cov_trace_pc_guard方法:主要是捕获所有的启动时刻的符号,将所有符号入队.

拿到了全部的符号之后需要保存,但是不能用数组,因为有可能会有在子线程执行的,所以用数组会有线程问题 .这里我们使用原子队列:

#import "ViewController.h"
#include <stdint.h>
#include <stdio.h>
#include <sanitizer/coverage_interface.h>
#import <libkern/OSAtomic.h>
#import <dlfcn.h>

@interface ViewController ()

@end

@implementation ViewController

//定义原子队列: 特点 1.先进后出 2.线程安全 3.只能保存结构体
static OSQueueHead symbolList = OS_ATOMIC_QUEUE_INIT;

//定义符号结构体链表
typedef struct{
    void *pc;
    void *next;
} SymbolNode;

void test()
{
    block();
}

void(^block)(void) = ^(void){
    
};

- (void)viewDidLoad {
    [super viewDidLoad];
}

/*
 - start:起始位置
 - stop:并不是最后一个符号的地址,而是整个符号表的最后一个地址,最后一个符号的地址=stop-4(因为是从高地址往低地址读取的,且stop是一个无符号int类型,占4个字节)。stop存储的值是符号的
 */
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.
}

/*
 可以全面hook方法、函数、以及block调用,用于捕捉符号,是在多线程进行的,这个方法中只存储pc,以链表的形式
 
 - guard 是一个哨兵,告诉我们是第几个被调用的
 */
void __sanitizer_cov_trace_pc_guard(uint32_t *guard) {
//  if (!*guard) return;  // Duplicate the guard check. //将load方法过滤掉了,所以需要注释掉
    
    //获取PC
    /*
     - PC 当前函数返回上一个调用的地址
     - 0 当前这个函数地址,即当前函数的返回地址
     - 1 当前函数调用者的地址,即上一个函数的返回地址
    */
  void *PC = __builtin_return_address(0);
    
    //创建结构体!
  SymbolNode * node = malloc(sizeof(SymbolNode));
    *node = (SymbolNode){PC, NULL};
    
    
    //加入队列
    //符号的访问不是通过下标访问,是通过链表的next指针,所以需要借用offsetof(结构体类型,下一个的地址即next)
    OSAtomicEnqueue(&symbolList, node, offsetof(SymbolNode, next));
    
    Dl_info info;// 声明对象
    dladdr(PC, &info);// 读取PC地址,赋值给info
    printf("fnam:%s \n fbase:%p \n sname:%s \n saddr:%p \n",
           info.dli_fname,
           info.dli_fbase,
           info.dli_sname,
           info.dli_saddr);

}

- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event{
    
    test();
}

@end

运行后这里我们可以看到很多打印,只取一条来说明,很明显其中sname就是我们需要的符号名了.

下面我们通过点击屏幕导出所需要的符号,需要注意的是C函数和Swift方法前面需要加下划线.(这一点可以在前面提到的LinkMap文件中确认)

- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event{
    
    NSMutableArray <NSString *>* symbolNames = [NSMutableArray array];
    
    // 每次while循环,都会加入一次hook (__sanitizer_cov_trace_pc_guard)   只要是跳转,就会被block
    // 直接修改[other c clang]: -fsanitize-coverage=func,trace-pc-guard 指定只有func才加Hook
    while (YES) {
        // 去除链表
        SymbolNode * node = OSAtomicDequeue(&symbolList, offsetof(SymbolNode, next));
        
        if (node == NULL) {
            break;
        }
        
        Dl_info info = {0};
        // 取出节点的pc,赋值给info
        dladdr(node->pc, &info);
        // 释放节点
        free(node);
        // 存名字
        NSString * name = @(info.dli_sname);
        
        BOOL isObjc = [name hasPrefix:@"+["] || [name hasPrefix:@"-["]; //OC方法不处理
        NSString * symbolName = isObjc ? name : [@"_" stringByAppendingString:name]; //c函数、swift方法前面带下划线
        [symbolNames addObject:symbolName];
        printf("%s \n",info.dli_sname);

    }
    
    //取反(队列的存储是反序的)
    NSEnumerator * emt = [symbolNames reverseObjectEnumerator];
    //创建数组
    NSMutableArray<NSString*>* funcs = [NSMutableArray arrayWithCapacity:symbolNames.count];
    // 临时变量
    NSString * name;
    // 遍历集合,去重,添加到funcs中
    while (name = [emt nextObject]) {
        // 数组中去重添加
        if (![funcs containsObject:name]) {
            [funcs addObject:name];
        }
    }
    // 删掉当前方法,因为这个点击方法不是启动需要的
    [funcs removeObject:[NSString stringWithFormat:@"%s",__FUNCTION__]];
    // 文件路径
    NSString * filePath = [NSTemporaryDirectory() stringByAppendingPathComponent:@"tcj.order"];
    // 数组转字符串
    NSString *funcStr = [funcs componentsJoinedByString:@"\n"];
    // 文件内容
    NSData * fileContents = [funcStr dataUsingEncoding:NSUTF8StringEncoding];
    // 在路径上创建文件
    [[NSFileManager defaultManager] createFileAtPath:filePath contents:fileContents attributes:nil];
    
    NSLog(@"%@",filePath);

}

这时如果你直接点击屏幕,有个巨坑,会看到控制台一直在输出,出现了死循环:

我们在while里面设置一个断点: 然后查看汇编:

发现 __sanitizer_cov_trace_pc_guard居然有10个,这个地方会触发 __sanitizer_cov_trace_pc_guard中的入队,这里又进行出队,最后就死循环了.

解决办法: Build SettingsOther C Flags添加func配置,即-fsanitize-coverage=func,trace-pc-guard.

官网对func的参数的解释:只检测每个函数的入口.

再次运行点击屏幕就不会有问题了.

注意点:

  1. if(!*guard) return;需要去掉,它会影响+load的写入
  2. while循环,也会触发__sanitizer_cov_trace_pc_guard(trace的触发,并不是根据函数来进行hook的,而是hook了每一个跳转(bl).while也有跳转,所以进入了死循环)

从真机上获取order文件 我们把order文件存在了真机上的tmp文件夹中,要怎么拿到呢?

Window→Devices And Simulators(快捷键⇧+⌘+2)中:

下载到指定位置,显示包内容,在tmp文件夹内可找到order文件.

Swift二进制重排 Swift也可以重排么?当然可以!

Swift 二进制重排,与OC一样.只是LLVM前端不同.

  • OC的前端编译器是Clang,所以在other c flags处添加-fsanitize-coverage=func,trace-pc-guard
  • Swift的前端编译器是Swift,所以在other Swift Flags处添加-sanitize=undefined-sanitize-coverage=func

我们在项目中添加一个Swift类,然后在ViewControllerload方法中调用一下:

Build Setting中Other Swift Flags设置:

运行后点击一下屏幕,查看控制台:

补充: swift符号自带名称混淆 未改变代码时,swift符号不会变 总之,order文件,请在代码封版后,再生成

所有处理完之后,最后需要Write Link Map File改为NO,把Other C Flags/Other Swift Flags的配置删除掉

因为这个配置会在我们代码中自动插入跳转执行 __sanitizer_cov_trace_pc_guard.重排完就不需要了,需要去除掉. 同时把ViewController中的 __sanitizer_cov_trace_pc_guard也要去除掉.

至此,Clang插桩和自动生成Order文件,都已完成.拿到order文件后,小伙伴们可以去自己的项目试试哦.

写在后面

通过二进制重排,让启动需要的方法排列更紧凑,减少了Page Fault的次数. 获取符号表时,采用Clang插桩可以直接hook到Objective-C方法、Swift方法、C函数、Block,可以不用区别对待.相比于抖音之前提出的方案确实简单很多,门槛也要低一些.