OC

iOS应用启动优化之二进制重排

极致优化iOS启动速度

Posted by sunzhongliang on May 1, 2020

前言

如同Web页面一样,App的启动速度是给用户的第一印象,对用户的体验至关重要, 如果启动速度过慢很有可能就会造成用户流失。

传统的优化手段有减少objc的类、方法、分类(category)的数量、懒加载、划分任务优先级等等, 这些优化策略已经很普遍了,还有没有其他一些手段呢?这个时候就需要进行二进制重排

冷启动和热启动
有时候我们在打开一个APP时感觉到慢,但把这个APP的进程杀掉后,再次打开却发现很快就打开, 都是在”冷启动”状态下启动的,为什么杀掉进程再次打开就很快了呢? 这就和iOS应用的缓存有关系了
怎样才算真正的冷启动呢?我们可以在手机的APP进程杀死后,再启动多个其它的APP,这样其它APP的虚拟内存会覆盖掉当前应用,这样做才算是真正的冷启动; 所以这也就解释了为什么我们有时候打开一个APP感觉到启动速度很慢,而把它的进程杀掉后再次打开确又很快的原因

冷启动:后台任务没有该应用的进程,重新打开该应用的过程
热启动:后台任务该应用的进程,从后台回到前台的过程

原理

内存加载原理

假设一台计算机有64MB内存,程序A运行需要50MB,程序B运行需要10MB,如果同时运行这两个程序,比较直接的做法就是将0MB~50MB的空间地址分配给A50MB~60MB的空间地址分配给B
这样的分配策略会带来几个比较严重的问题:

  • 地址空间不隔离
    • 所有程序都直接访问物理地址,程序之间使用的地址空间共享物理内存,很容易发生恶意程序改写其他程序内存数据的情况;另外本身有bug的程序也有可能影响到其他程序的执行。这造成了程序运行不稳定的情况
  • 程序运行时地址不确定
    • 在程序装入运行时,需要分配一块足够大的空闲区域,而这个位置不确定,那么在程序编写时,指令的跳转需要你自己计算得出绝对地址,这是十分麻烦的
  • 内存使用效率低
    • 执行一个程序就将整个程序加载到内存,若需要继续同时执行另外的程序,则会出现内存不足,这时只能将内存中现有的数据换出到磁盘,磁盘、内存之间的大容量的换出换入必会导致效率低下

所以操作系统在物理内存的基础上又建立了一层虚拟内存(解决地址空间不隔离问题),为了提高效率和方便管理,又对虚拟内存和物理内存进行了分页(Page)(解决内存使用效率问题)。
当进程访问一个虚拟内存Page而对应的物理内存却不存在时,会触发一次缺页中断(Page Fault),然后去分配物理内存,有需要的话会从磁盘mmap读入数据。 软件被打开后,软件自己以为有一大片内存空间,但实际上是虚拟的,而虚拟内存和物理内存是通过一张表来关联的,我们可以看下下面这两张表: 进程1运行的时候会开辟一块内存空间,但访问到计算机的内存上的时候并不是这块内存空间,而且通过访问地址通过进程1的映射表映射到不同的物理内存空间,这个叫地址翻译,这个过程需要CPU和操作系统配合,因为这个映射表是操作系统来管理的

当我们调试时候发现访问数据的内存地址都是连续的,其实这是一个假象,在这个进程内部可以访问,是因为我们访问时候会通过该进程的内存映射表去拿到真正的物理内存地址,假如其他进程访问的话,其他进程没有相应的映射表,自然就访问不到真正的物理内存地址,这样就解决了内存安全问题

内存使用率问题:
内存分页管理,映射表不能以字节为单位,是以页为单位,Linux是以4K为一页,iOS是以16K为一页,但是mac系统是4K一页,我们可以在mac终端输入pageSize,发现返回的是4096

Page Fault
为啥分页后内存就够用呢,因为应用内存是虚拟的,所以当程序启动时候程序会认为自己有很多的内存,我们看看下图 在应用加载时候不会把所有数据放内存中,因为数据是懒加载,当进程访问虚拟地址时候,首先看页表,如果发现该页表数据为0,说明该页面数据没有在物理地址上,这个时候系统会阻塞该进程,这个行为就叫做缺页中断(Page Fault),也叫缺页异常

重排原理

编译器在生成二进制代码的时候,默认按照链接的Object file(.0)顺序写文件,按照Object File内部的函数顺序写函数。

静态库文件.a就是一组.o文件的ar包,可以用nm命令查看.a包含的所有.o

如下图:假设我们只有两个Page,Page1和Page2,其中绿色的method1method3在应用启动时候需要调用,为了执行对应的代码,系统必须进行两个Page Fault。 但如果我们把method1method3排布到一起,那么只需要一个Page Fault即可,这就是二进制文件重排的核心原理。

重排之前

但如果我们把method1method3排布到一起,那么只需要一个Page Fault即可。

重排之后

我们要做的事情就是在iOS应用的启动阶段,把需要调用的函数放到一起, 以尽可能减少Page Fault, 达到优化目的 . 而这个做法就叫做 : 二进制重排

实现

System Trace调试

  • 首先打开需要分析的项目,然后command+i打开instruments调试工具, 在打开System Trace
  • 在点击运行,注意需要在APP启动看到首页后点击停止
  • 运行结束后,即可看到整个分析图,在搜索框中输入main thread, 然后再到下面选择Main Thread --> Virtual Memory(虚拟内存)

一系列操作完成后,可以看到分析的数据,这里的File Backed Page In就是page fault的次数,总共花费了132.83ms
当我们把手机的APP进程杀死后,再次重新启动分析可以看到File Backed Page In变的很小,说明就算APP进程被杀死后再次重新启动也并不是冷启动,还是有一部分数据在系统缓存里(可以看到Page Cache Hit的数量变的很大,意味着缓存命中了很多, 所以File Backed Page In变的很小)


二进制重排

要真正的实现二进制重排,我们需要拿到启动的所有方法、函数等符号,并保存其顺序,然后写入order文件,实现二进制重排。
有以下几个获取符号的方式比较常见:

  • fishHook
  • Clang插桩

fishHook
fishHookhttps://github.com/facebook/fishhook是Facebook开源的一个可以hook系统函数的一个工具, 我们可以hook到系统的objc_msgSend的方式,收集函数符号。但这种实现方式initializeblock以及直接调用方法hook不到
Clang插桩
OC方法、函数、block都能hook到.实际上是在编译期就在每一个函数内部二进制数据添加hook代码,来实现全局方法的hook效果

  1. 在项目Build Settings中添加-fsanitize-coverage=trace-pc-guard配置 Other C Flags中添加-fsanitize-coverage=trace-pc-guard配置

  2. 在ViewController中添加

     #include <stdint.h>
     #include <stdio.h>
     #include <sanitizer/coverage_interface.h>
    
     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.
     }
    
     #import <libkern/OSAtomic.h>
     #import <dlfcn.h>
    
     /*
     原子队列特点
     1、先进后出
     2、线程安全
     3、只能保存结构体
     */
     static OSQueueHead symbolList = 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};
         // 入队
         OSAtomicEnqueue(&symbolList, node, offsetof(SymbolNode, next));
            
         // 以下是一些打印,只是看一下,实际中可以注释
         // dlopen 通过动态库拿到句柄 通过句柄拿到函数的内存地址
         // dladdr 通过函数内存地址拿到函数
         typedef struct dl_info {
             const char      *dli_fname;     /* Pathname of shared object      函数的路径  */
             void            *dli_fbase;     /* Base address of shared object  函数的地址  */
             const char      *dli_sname;     /* Name of nearest symbol         函数符号    */
             void            *dli_saddr;     /* Address of nearest symbol      函数起始地址 */
         } Dl_info;
         Dl_info info;
         dladdr(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);
     }
    

运行项目, 直到出现首页后停止, 可以看到把首页的启动方法都输出出来了:

然后我们新建一个binary.order文件,将我们收集到的符号方法写在里面

然后再次打开System Trace调试(注意需要保持在冷启动下),然后可以观察到 缺页次数中断只有151次,耗时53.9ms,比之前132ms优化了80ms,性能提升明显

后记

Linkmap

Linkmap是iOS编译过程的中间产物,记录了二进制文件的布局,需要在Xcode的Build Settings里开启Write Link Map File
之后点击run就会生成一个Linkmap文件, 这个文件里面就有链接的符号顺序表:

Linkmap默认路径: /Users/admin/Library/Developer/Xcode/DerivedData/项目名-daesdalcakvokxfakgpljpmmzcay/Build/Intermediates.noindex/项目名.build/Debug-iphoneos/项目名.build/项目名-LinkMap-normal-arm64.txt

这个文件分为四个部分:

  1. # Path

    Path是生成.o目标文件的路径
    Arch是架构类型
    Object files列举了可执行文件里所有的obj以及tbd。每一行首的数字代表对文件的编号。

  2. # Section(Mach-O信息) Sections 第一列是起始位置,第二列是Section占用内存大小,第三列是Segment类型,第四列是Section类型,记录Mach-O每个Segment/Section的地址范围
    Segement划分成了不同的Section,不同的Section存储着不同的信息
    Mach-O文件中的虚拟地址最终会映射到物理地址上, 就可以知道代码和数据在内存中是如何存储的。这些地址被分成不同的Segement: __TEXT段、__DATA段 和 __LINKEDIT段。

    • __TEXT 包含 Mach header,被执行的代码和只读常量(如C 字符串),只读可执行(r-x)。
    • __DATA 包含全局变量,静态变量等,可读写(rw-)。
    • __LINKEDIT 包含了加载程序的『元数据』,比如函数的名称和地址,只读(r–)
  3. # Symbols(符号信息) 可以看到,整体顺序和APP的启动加载方法是一致的

    Address 表示文件中方法的地址。
    Size 表示方法所占内存的大小。
    File 表示所在的文件编号,与Object files部分的中括号的数字对应
    Name 表示方法名。

  4. # Dead Stripped Symbols 表示链接器认为无用的符号,链接的时候不会计入。

通过Linkmap文件的分析,也可以验证我们的二进制重排是否有效

二进制重排的疑问

order文件里如果写了项目中不存在的方法会不会有问题?
ld会忽略这些符号, 如果提供了link 选项-order_file_statistics,会以 warning 的形式把这些没找到的符号打印在日志里

本文首次发布于 孙忠良 Blog, 作者 [@sunzhongliang] , 转载请保留原文链接.