美文网首页
重学iOS系列之APP启动(五)二进制重排优化

重学iOS系列之APP启动(五)二进制重排优化

作者: 佛系编程 | 来源:发表于2021-12-01 01:45 被阅读0次

        前文我们已经了解了APP启动的整个过程,包括dyld加载mach-o,然后经过objc库通过runtime对类、category等的初始化。这一节笔者将通过实战带领大家对APP启动的深度优化-二进制重排。

        那么什么叫二进制重排?在回答问题之前,我们得先了解系统是怎么管理内存的。

    物理内存与虚拟内存

           在计算机早期是没有虚拟地址的概念的,所有的应用只要启动就会全部加载到物理内存中。如果不断启动应用,物理内存达到上限后,应用便会无法启动,必须先关闭前面的部分应用才能继续开启。而且应用在物理内存中的排序都是顺序排列的,这样进程只需要把自己的地址尾部往后偏移一点就能访问到别的进程中的内存地址,相当不安全。

            为了解决APP直接加载到物理内存,导致物理不够用且不安全的问题,虚拟内存便孕育而生。

            App 启动后会认为自己已经获取到整个 App 运行所需的内存空间,但实际上并没有在物理内存上为他申请那么大的空间,只是生成了一张 虚拟内存和物理内存关联的表 。通过这张表,内核会在物理内存与虚拟内存之间不断的更新,映射。cpu需要哪些数据便会映射虚拟内存中的那部分数据。并且所有的APP都不能直接访问物理内存,能访问的内存都只是由系统分配的那部分虚拟内存,一般来说是4G大小的虚拟内存。借助虚拟内存地址,系统可以保障APP空间的独立性。只要操作系统把两个APP的进程空间对应到不同的内存区域,就让两个APP空间成为“老死不相往来”的两个小王国。两个APP就不可能相互篡改对方的数据,并且也不会出现物理内存不够用的情况,完美的解决了上述物理内存问题。

    地址翻译

    当 App 需要使用某一块虚拟内存的地址时,会通过这张表查询该虚拟地址是否已经在物理内存中申请了空间。

    如果已经申请了则通过表的记录访问物理内存地址。

    如果没有申请则申请一块物理内存空间并记录在表中(Page Fault)。

    这个通过进程映射表映射到不同的物理内存空间的操作叫 地址翻译 ,这个过程需要 CPU 和操作系统配合。

    Page Fault

    当数据未在物理内存会进行下列操作

    1、系统阻塞该进程

    2、将磁盘中对应Page的数据加载到内存

    3、把虚拟内存指向物理内存

    上述3个行为就是Page Fault 

    内存分页

    虚拟内存地址和物理内存地址的分离,给进程带来便利性和安全性。但虚拟内存地址和物理内存地址的翻译,又会额外耗费计算机资源。在多任务的现代计算机中,虚拟内存地址已经成为必备的设计。那么,操作系统必须要考虑清楚,如何能高效地翻译虚拟内存地址。

    记录对应关系最简单的办法,就是把对应关系记录在一张表中。为了让翻译速度足够地快,这个表必须加载在内存中。不过,这种记录方式非常的浪费空间。如果1GB物理内存的每个字节都有一个对应记录的话,那么光是对应关系就要远远超过内存的空间。由于对应关系的条目众多,搜索到一个对应关系所需的时间也很长。这样的话,会让系统陷入瘫痪。

    因此,现在操作系统大部分都采用了分页(paging)的方式来记录对应关系。所谓的分页,就是以更大尺寸的单位页(page)来管理内存。在Linux和machOS中,通常每页大小为4KB。mac上可以在终端输入pagesize来查看具体大小值。

    Linux以4K为一页

    macOS以4K为一页

    iOS以16K一页

    好了,回到开篇的问题:什么是二进制重排?

    由于虚拟内存映射到物理内存有一个缺页中断(Page Fault)的过程,这个过程是相对来说比较耗时的,映射一页内存耗时在1微秒到0.8毫秒之间。不要觉得这个时间很短就忽略它,你要知道一页才4k的大小,1000页也才4M多,假设每页都耗时0.5毫秒(当然肯定不会这么背,没次都这么久),1000页的耗时就是5秒了,对于APP启动来说5秒是一个很可怕的数字。

    APP启动时,必然会产生Page Fault,而降低Page Fault 的数量就能降低APP启动的时间。二进制重排就是将mach-o二进制数据重新排列,达到降低Page Fault的数量的目的。

    将哪些数据重新排列呢?

    APP启动过程是一系列函数的调用过程,那么要排列的数据就是函数体了,只要将启动过程中调用的函数按照调用顺序重新排列,这样就能明星降低虚拟内存映射到物理内存的次数,从而达到降低Page Fault次数的目的。

    具体要怎么操作呢?

    1、获取启动过程的符号顺序

    2、将为排序的符号顺序修改为排序后的符号顺序,再设置到xcode环境中,进行编译期优化。

    我们先来看看原来的符号顺序,这需要用到 链接映射文件 Link Map File

    生成 Link Map File

    Xcode 在生成可执行文件的时候默认情况下不生成该文件。

    在Xcode的配置中 Target -> Build Setting -> Linking 将Write Link Map File设置为YES来生成Link Map File,运行代码即可生成Link Map File

    Link Map File主要分为3个部分

    1、路径部分,展示生成的相关文件路径

            Path是.app文件路径

            Object files是.o文件路径

    2、Section部分,展示相关地址段

            Mach-O 文件中的虚拟地址最终会映射到物理地址上。这些地址被分成不同的Segement: __TEXT段、__DATA段、__LINKEDIT,此段内容在上一篇重学iOS系列之APP启动(四)Mach-O中有详细讲解,不了解的读者可以直接跳转过去阅读。

    3、Symbols部分,方法符号段

            Address:方法代码的地址

            Size:方法占用的空间

            File:文件的编号

            Name:.o文件里面的方法符号

    Symbols 部分的 File 顺序是和 Target -> Build Phase -> Compile Sources 的文件顺序一致的。

    order_file

    Xcode提供了排列符号的设置给开发者,设置 order_file 即可。苹果也一直身体力行,objc源码就采用了二进制重排优化。

    设置order_file

    在根目录生成link.order文件,这里面就是方法符号的排序

    Target -> Build Setting -> Linking -> Order File 设置 order file 的路径

    自动生成order_file

    全手写一定是不可取的,想实现自动化就要解决下列问题:

    1、保证不遗漏方法

    2、保证方法符号正确

    3、保证方法符号顺序正确

    解决方案可见 《抖音研发实践:基于二进制文件重排的解决方案 APP启动速度提升超15%》

    准备工作都已经完成,正式进入实战过程!

    抖音团队使用的是 静态扫描+运行时trace的方案, 能够覆盖到80%~90%的符号。但是上述的方法也存在性能瓶颈

    initialize hook不到

    部分block hook不到

    C++通过寄存器的间接函数调用静态扫描不出来

    为了解决这个瓶颈,我打算尝试一下在文末提到的 编译期插桩(在代码编译期间修改已有的代码或者生成新代码)

    编译期插桩 && 获取方法符号

    我们要跟踪到 每个方法的执行,从而获取到启动时 方法执行的顺序,然后再按照这个顺序去编写order file。

    跟踪的具体实现会用到 clang 的 SanitizerCoverage

    先打开文档看一看 ~ clang文档

    简单翻译一下:

    使用-fsanize coverage=trace pc guard,编译器将在每条边界上插入以下代码:__sanitizer_cov_trace_pc_guard(&guard_variable)

    编译器还将插入对模块构造函数的调用:__sanitizer_cov_trace_pc_guard_init(uint32_t*start,uint32_t*stop);

    使用-fsanize coverage==trace-pc,indirect-calls,将在每个间接调用上插入间接调用:__sanitizer_cov_trace_pc_indirect(void *callee)

    我们可以自定义__sanitizer_cov_trace_pc_*这个函数。

    也就是说添加-fsanize coverage=trace pc guard这个环境变量后,编译器会在每个函数调用以及循环中插入一条代码__sanitizer_cov_trace_pc_guard(&guard_variable),我们可以从这句代码中获取到我们想要的符号信息。

    并且我们在文档下方找到demo

    具体实现

    添加设置 Target -> Build Setting -> Custom Complier Flags -> Other C Flags 添加 -fsanitize-coverage=trace-pc-guard

    然后在VC中添加下面2个方法。

    void __sanitizer_cov_trace_pc_guard_init(uint32_t *start, uint32_t*stop) {

          static uint64_tN;  // 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;

          void *PC = __builtin_return_address(0);

          charPcDescr[1024];

          printf("guard: %p %x PC %s\n", guard, *guard, PcDescr);    

    }

    OK,运行代码,看看会发生什么?

    很好,完全看不懂!

    我们在__sanitizer_cov_trace_pc_guard_init打个断点(注意断点的位置,不要打错位置了),看看内存中存储着什么东西。

    0x10534aa68是start的地址

    0x10534ca18是end的地址

    从start地址开始,每4字节存储的数据都加1,对应源码应该是++N的操作。由此可以猜想,从start到end这段内存中,每4字节存储的数据都是加1自增的。

    然后打印end的地址,后面全是0,既然是end,那么后面的数据就没有意义,要看也是看end前面的数据。将end往前移4个字节,得到ec 07 00 00 ,由于大小端的问题,该数据的实际值是0x07ec;为了验证从start到end每4字节数据自增,我们再将end往前移8个字节,得到的数据是 eb 07 00 00 ,对应的值是0x07eb,正好比0x07ec小1。由此确定start到end存储的数据为从1开始自增的一个序号。这个序号会不会是符号的序号呢?

    我们在VC页面再添加一个c函数testFunc(),看看end前4字节的值是否加1变为0x07ed。

    end的前一位数据确实是0x07ed,得到验证。并且笔者尝试了Block 、 C函数都会使序号值加1,但是swift方法由于混编的原因,系统会默认添加一些方法,导致序号值增加不止1。

    Swift 混编处理

    Target -> Build Setting -> Custom Complier Flags -> Other Swift Flags 添加

    -sanitize-coverage=func

    -sanitize=undefined

    由此可以得出__sanitizer_cov_trace_pc_guard_init 方法里面可以获取到所有方法的数量。那么我们大胆猜测__sanitizer_cov_trace_pc_guard函数里能获取到所有函数的符号。

    __sanitizer_cov_trace_pc_guard

    从上图可以看出该函数打印了一些地址,以及一些转义的字符串。打印的地址可以猜测为符号调用地址。那么怎么根据符号地址来获取对应的符号字符串呢?

    我们在viewDidLoad打个断点,看看函数调用情况:

    在红线的地方,系统插入了一个函数调用,该函数起始位置为VC.m 44行,从上文截图可以知道就是__sanitizer_cov_trace_pc_guard。说明__sanitizer_cov_trace_pc_guard确实可以拦截每一个函数的调用,并且函数内部调用了这么一个函数void *PC = __builtin_return_address(0);    

    __builtin_return_address从函数名称可以看出该函数是获取返回地址的,该函数其实是获取lr寄存器中的内容,这样就拿到符号的地址了。

    关于lr寄存器:也称为x30寄存器。

    汇编指令callq或者bl  是调用函数的意思,那么既然是函数调用,调用完之后肯定要返回原来的函数内继续执行后续的代码对吧,那么怎么跳转回来的呢?就是借用了lr寄存器存储当然返回的函数地址。

    callq和bl 指令会做2件事:

    1、将调用子函数的下一条指令地址存储到lr寄存器中

           假设A函数内部有一个子函数b,调用b函数的指令地址为0x520,那么lr寄存器存储的地址将会是0x524(call指令占用4个字节)

            关于子函数嵌套,即A() -> B() -> C() -> D() ,这种子函数调用子函数,lr寄存器存储的是哪个函数的地址问题。

            其实很好理解,每个函数系统都会开辟一个函数栈空间,然后将 lr 中存储的地址先保存在开辟的函数栈内存中,当需要调用子函数的时候,lr又会存储子函数调用的下一条指令的地址,然后在子函数栈内部空间开辟后,又将lr中的地址保存到子函数栈中,每次调用函数都会做这个操作。

            函数执行完成后,在执行ret指令之前,会将之前 lr 存储的地址从函数栈pop出来重新赋值给 lr 寄存器,lr寄存器中存储的值就变成了当前函数的父函数地址。也就是说lr寄存器保存的值永远都是当前执行函数的父函数地址。

    2、为子函数开辟一段内存地址,并且将pc寄存器指向该内存的起始位置

    3、跳转到pc寄存器指向的地址(跳转到子函数起始地址去执行汇编代码,我们常说的调用函数其实就是汇编指令的一个跳转,并没有什么高大上的高科技,是不是很失望)

    我们整理一下这个过程:在每个函数的内部实现中,将__sanitizer_cov_trace_pc_guard函数插入到具体的代码执行之前,寄存器初始化参数之后。这样就进入了函数内部就可以通过__builtin_return_address获取到lr寄存器的内容(当前函数调用的指令地址,注意,这个地址并非是函数起始地址,而是子函数调用的下一条指令的地址),然后我们就可以利用这个地址来反推该函数的符号了。

    那么怎么通过地址来反推函数符号呢?

    导入头文件

    #import <dlfcn.h>

    dlfcn.h 中有一个 dladdr() 方法,可以通过函数内部地址找到函数符号。

    该方法需要用到结构体Dl_info,结构体里面还包含有一些其他信息,dli_sname就是我们需要的符号字符串。

    我们调用dladdr来获取符号,并且打印出来验证一下。

    可以看到,我们从dli_sname中获取到需要的符号了,并且顺序就是调用函数的顺序。

    接下来只要我们将这些符号数据保存下来,然后生成一个order文件配置到xcode里,大功告成。

    那么很多读者肯定会想到用一个数组存储,然后将数组写入一个文件就行了。

    但是这种办法是不安全的,因为启动过程是一个多线程的环境,一些函数调用是在不同的线程,不同线程对同一个数组进行写入操作肯定是会出现顺序错乱问题的。解决方案:

    1、对数组加锁

    2、用原子队列(原子队列是栈结构,通过 队列结构 + 原子性 保证顺序)

    加锁实现比较简单,就不详细讲解了。

    接下来笔者带着大家了解下不常见的原子队列是怎么使用的。

    导入头文件

    #import <libkern/OSAtomic.h>

    然后添加下面的代码

    简单描述下过程:

    1、初始化一个静态原子队列list

    2、新增一个结构体Node用于保存函数符号,该结构体将会保存到list中

    3、在__sanitizer_cov_trace_pc_guard函数中调用OSAtomicEnqueue将node存储到队列list尾部,offsetof(Node,next)计算出队列尾部偏移量

        注意:如果需要捕获load方法,需要将    if (!*guard) return;    这句代码注释掉。(读者可以运行代码验证)

    4、创建一个getFuncNames函数,用于将队列中的元素取出来保存到数组中

    5、while循环调用OSAtomicDequeue取出队列中的元素。由于while循环会导致__sanitizer_cov_trace_pc_guard的调用,所以此处需要将other C flag 修改为-fsanitize-coverage=func,trace-pc-guard,修改为只捕获函数的调用

    6、由于OC以外的函数,block等符号都是以下划线开头的,所以需要单独处理。

    7、去除重复的符号

    8、将获取到的符号字符串插入到数组中的第一位。(由于入栈是顺序的,出栈是从尾部开始pop元素,所以先拿到的符号是尾部的符号,为了保证数组的顺序是按照app启动调用的顺序,所以每个取出的数据都是插入到数组的第0位)

    9、最后将数组转换成字符串保存到order文件中

    10、将order文件路径设置到xcode环境中

    运行工程,调用getFuncNames()函数得到order文件。

    由于笔者的demo原因,只能在tableView的didSelectRowAtIndexPath中调用。读者可以在任意非启动过程的函数中调用。

    得到的order文件内容如下

    删除最后2行调用的方法,然后将文件路径设置到Xcode的Order File路径里。

    重新运行工程,查看map文件中的Symbols段数据,验证是否成功。

    下图是笔者未进行二进制重排之前的Symbols顺序:

    然后这是进行了二进制重排后的Symbols顺序:

    可以发现一个有趣的现象,重排后面的符号依旧是以未排序前的顺序进行编译的。

    总结

    相关文章

      网友评论

          本文标题:重学iOS系列之APP启动(五)二进制重排优化

          本文链接:https://www.haomeiwen.com/subject/mbjkxrtx.html