启动优化-二进制重排

·  阅读 1036
启动优化-二进制重排

App 中的业务模块越来越多、越来越复杂,集成了更多的三方库,App 启动也会越来越慢,因此我们希望能在业务扩张的同时,保持较优的启动速度,给用户带来良好的使用体验。一般启动分为两个阶段,main 函数之前与 main 函数之后。main 函数之前又叫做 pre-main

启动性能检测 Main 函数之前

iOS 也给我们提供了 main 函数之前的监测方法,该方法是由 dyld 提供的,下面我们就来介绍一下。

监测配置

image.png image.png image.png

如上图所示,我们创建一个空工程,在箭头位置添加 DYLD_PRINT_STATISTICS,添加完成之后运行工程就会打印相关内存,输出内容展示了系统调用 main() 函前主要进行的工作内容和时间花费。下面我们介绍下各阶段都做了哪些事情及如何优化。

dylib loading time:

动态库载入耗时,动态库只要载入到内存就肯定要消耗时间,而且每个动态库有自己的依赖关系,需要耗时去查找,读取等。但是系统提供的动态库会有点不一样,系统提供的动态库已经载入到了内存中,存在于共享缓存空间,而且系统做了很好的高速优化,但是我们自己的动态库并不具备这些,所以苹果给我们的建议动态库不要大于 6 个,这里我们做的优化就是可以对多个动态库进行合并。

rebase/binding time:

重绑定耗时,rebase 重定位,rebinding 重绑定。优化方法放到后面重点介绍。

ObjC setup time:

OC 类注册耗时,OC 是动态语言,OCruntime 需要维护一张映射表,当加载一个 macho 的时候这些类都需要注册到全局表中。包括 oc 类的属性,协议,分类信息的加载等,这些都需要造成时间的损耗,这里优化的话就是减少类的定义,但是这些一般很难减少,所以这里我们能做的微乎其微。但是我们维护一个老的项目的时候,老项目中会有很多已经废弃的类,已经不用的,但是依然被保留在项目中,这里我们可以借助一下脚本工具,找出这些类进行删除。

initializer time:

load 方法及构造方法耗时,所以在 loadinitalizer 方法中我们尽量不要做耗时的操作。把耗时的操作尽量放到子线程中去完成。

总结:这里的优化都是毫秒级别的,真正在启动优化时我们都是做业务逻辑的优化,一般用户在项目首屏界面渲染完成时就会认为项目启动完毕了,说明第一个界面出现的时间越早就越好。给用户的体验就越好,效果也是最好的。所以在这里我们可以做假的数据,及多开启子线程进行数据的处理,将 CPU 的性能发挥到极致。启动时候一般不用考虑线程个数的问题,在启动之后恢复正常就行。下面我们重点介绍 rebase/binding time 阶段的时间优化,但是在此之前会先介绍下虚拟内存的概念。

虚拟地址

在早期的计算机中,程序是直接运行在物理内存上的,也就是说,程序在运行时所访问的地址都是物理地址。当然,如果一个计算机同时只运行一个程序,那么只要程序要求的内存空间不要超过物理内存的大小,就不会有问题。但事实上为了更有效地利用硬件资源,我们必须同时运行多个程序,正如前面的多道程序、分时系统和多任务中一样,当我们能够同时运行多个程序时,CPU 的利用率将会比较高。那么很明显的一个问题是,如何将计算机上有限的物理内存分配给多个程序使用。

假设我们的计算机有 128 MB 内存,程序A运行需要 10 MB,程序 B 需要 100 MB,程序 C 需要 20 MB。如果我们需要同时运行程序 AB,那么比较直接的做法是将内存的前 10 MB 分配给程序 A10 MB~110 MB 分配给 B。这样就能够实现 AB 两个程序同时运行,但是这种简单的内存分配策略问题很多。例如地址空间不隔离,所有程序都直接访问物理地址,程序所使用的内存空间不是相互隔离的。恶意的程序可以很容易改写其他程序的内存数据,以达到破坏的目的。

使用物理内存的方式就会造成内存不够用及安全问题。为了解决这个问题,设计者么就想到一种办法,增加中间层,即使用一种间接的地址访问方法。整个想法是这样的,我们把程序给出的地址看作是一种虚拟地址,然后通过某些映射方法,将这个虚拟地址转换成实际的物理地址。这样就可以保证任意一个程序所能够访问的物理内存区域跟另外一个程序相互不重叠,以达到地址空间隔离的效果。

iOS 系统为例,当一个 App 在运行时,“在某个时间段内,它只是频繁地用到了一小部分数据,也就是说,程序的很多数据其实在一个时间段内都是不会被用到的。人们很自然地想到了更小粒度的内存分割和映射的方法,使得程序的局部性原理得到充分的利用,大大提高了内存的使用率。这种方法就是分页(Paging)

分页的基本方法是把地址空间人为地等分成固定大小的页,每一页的大小由硬件决定,或硬件支持多种大小的页,由操作系统选择决定页的大小。操作系统可以选择每页大小为 4KB,也可以选择每页大小为 4MB,但是在同一时刻只能选择一种大小,所以对整个系统来说,页就是固定大小的。在 iOS 系统下 64 位之后一页是 16k,在此之前是 4k,在 mac 系统下一页是 4k。物理空间也是同样的分法。

下面我们来看一个简单的例子,如图所示,每个虚拟空间有 8 页,每页大小为 1KB,那么虚拟地址空间就是 8KB。我们假设该计算机有 13 条地址线,即拥有 2^13 的物理寻址能力,那么理论上物理空间可以多达 8KB。但是出于种种原因,购买内存的资金不够,只买得起 6KB 的内存,所以物理空间其实真正有效的只是前 6KB。那么,当我们把进程的虚拟地址空间按页分割,把常用的数据和代码页装载到内存中,把不常用的代码和数据保存在磁盘里,当需要用到的时候再把它从磁盘里取出来即可。以图为例,我们假设有两个进程 Process1Process2,它们进程中的部分虚拟页面被映射到了物理页面,比如 VP0VP1VP7 映射到 PP0PP2PP3;而有部分页面却在磁盘中,比如VP2VP3 位于磁盘的 DP0DP1 中;另外还有一些页面如 VP4VP5VP6 可能尚未被用到或访问到,它们暂时处于未使用的状态。在这里,我们把虚拟空间的页就叫虚拟页(VP,Virtual Page),把物理内存中的页叫做物理页(PP,Physical Page),把磁盘中的页叫做磁盘页(DP,Disk Page)。图中的线表示映射关系,我们可以看到虚拟空间的有些页被映射到同一个物理页,这样就可以实现内存共享。

图中 Process1VP2VP3 不在内存中,但是当进程需要用到这两个页的时候,硬件会捕获到这个消息,就是所谓的页错误(Page Fault),然后操作系统接管进程,负责将 VP2VP3 从磁盘中读出来并且装入内存,然后将内存中的这两个页与VP2VP3 之间建立映射关系。以页为单位来存取和交换这些数据非常方便,硬件本身就支持这种以页为单位的操作方式。

image.png

保护也是页映射的目的之一,简单地说就是每个页可以设置权限属性,谁可以修改,谁可以访问等,而只有操作系统有权限修改这些属性,那么操作系统就可以做到保护自己和保护进程。

虚拟存储的实现需要依靠硬件的支持,对于不同的 CPU 来说是不同的。但是几乎所有的硬件都采用一个叫 MMU(Memory Management Unit)的部件来进行页映射,如图所示。

image.png

在页映射模式下,CPU 发出的是 Virtual Address,即我们的程序看到的是虚拟地址。经过 MMU 转换以后就变成了 Physical Address。一般 MMU 都集成在 CPU 内部了,不会以独立的部件存在。

iOS 系统下每个 App 分配的虚拟内存是 4GB,只是 64 位系统下 0 号位置从 4GB 开始,4GB ~ 8GB,这是为了向下兼容 32 位系统。系统也专门开辟了相应的接口供进程与进程之间进行通信。

PageFault调试&二进制重排的原理

在上面 rebase/binding time: 这个阶段我们没有详细介绍,在这里我们重点来介绍一下。

rebase/binding

binding:当内部文件要访问外部函数的时候就需要通过内符号绑定之后访问外部。如果想减少这一块的耗时就是减少外部函数的使用,这里的绑定是懒加载的形式,只有用到的时候才会绑定。

rebase:在早期物理内存的时候,一个应用载入到内存中的地址都是随机的,所以如果黑客想 hook 的话就不能单纯通过地址去 hook,因为每次地址都随机的。但是采用虚拟内存之后这种方式反而不安全了,因为每次应用启动都是从 0 号位置开始,只要计算好函数在地址中的偏移值就可以每次都通过地址去访问,所以不安全。为了解决这个问题,操作系统就加入了一种 ASLR 的技术。采用的方式就是让应用每次启动的时候虚拟页表都不是从 0 开始,而是生成一个随机值。现在各大操作系统都支持这种技术。采用这种方式之后,每次再访问代码的时候就需要用 ASLR + offest 才能找到代码的位置。ASLR + offest 这个步骤就叫作 rebase,重定位。

PageFault

当代码访问了一段没有载入到内存中的数据的时候就会触发缺页异常或者缺页中断(PAGEFAULT),这一过程消耗的时间是毫秒级别的,我们可以忽略不计,因为系统对此已经做了非常高效的优化,MMU 在做地址翻译的时候速度是非常快的,用户基本是感知不到的。所以人们一般不会在这方面进行优化。但是当有大量缺页异常的话,几百页或者几千页的情况就会消耗 几百毫秒或者几千毫秒。极少成多,一般出现大量缺页异常发生在冷启动的时候,就是 App 重新打开的时候。一般在这一过程中我们采用的优化手段为二进制重排,下面会详细介绍。App 退出后台重新打开或者刚杀死进程就打开这个时候属于热启动,有好多页是已经载入过内存的。

二进制重排体验

image.png image.png image.png

首先我创建一个测试工程,在 Bulid Settings 搜索 link map,将 Write Link Map File 设置为 YES。然后编译工程,最后 show in finder 打开 testDemo.app,在 Intermediates.noindex 文件下往下找可以看到 testDemo-LinkMap-normal-arm64.txtLinkMap 文件。现在我们打开文件来看一下。

image.png image.png

LinkMap 文件中我们可以看到这里依次排列的都是方法名称,在方法名称之前是 addresssize。而且排列规则是先按文件顺序,然后按文件中方法的排列顺序。这个其实就是文件编译的顺序。这里其实会有一些问题,在启动加载的数据页中会有跟启动不相关的数据也会被加载,这就增加了数据中断的次数,就会增加启动时间。这里我们有个猜想,我们把跟启动时要用到的方法都尽可能的排列到最前面,是不是就可以提高启动的速度呢?下面我们来试一下。

image.png image.png

objc 源码中我们可以看到一个 libobjc.order 文件,打开文件我们可以看到,文件中排列的都是函数名称。这是这个 order 文件就控制着 LinkMap 文件中方法的编译顺序。我们也可以在我们的项目中添加 order 文件。

image.png image.png

我们 cd 到工程目录下,新建一个 chenxi.order 文件,并在文件中把 main, [AppDelegate application:didFinishLaunchingWithOptions:] 及一个不存在的方法 test111 排到最前面。

Bulid Settings 搜索 order file,并把参数设置为 chenxi.order,这时候再编译一次。

image.png

这时候我们再打开 LinkMap 文件,可以看到编译顺序确实发生了变化,而且 test111 这个不存在的方法也不会被编译。

总结:至此二进制重排就完成了,大家会不会觉得很简单,但是这里不能高兴的太早,这里我们会遇到一个问题,怎么确定启动完成前应该调用哪些方法, 函数, block 呢?这确实是一个很棘手的问题,在早期人们尝试过 hook objc_msgSend(),加上使用脚本静态扫描代码找到所有方法, c 函数, block, c++ 构造方法等。但是都不够好,其实最好的方式就是通过 clang 插桩来解决这个问题, clang 插桩可以做到百分之百覆盖,这里我们放到下个篇章来介绍。

分类:
iOS
收藏成功!
已添加到「」, 点击更改