iOS启动优化:二进制重排

一. 启动分析:main阶段和pre-main阶段

1.1 概括

  • pre-main:启动之前,包括dylb加载,类的注册,符号绑定,load方法等
  • main之后:从main到第一个节面完全显示,减少同步耗时操作,把一些可以延迟加载的操作,做成异步操作(多线程初始化,这里需要对业务逻辑进行筛选,不是所有的业务都适;这里启动时所需要用到的节面等,不要使用xib,xib会解析成代码加载,多了一步操作)
  • 所以,今天分享的主要是针对pre-main阶段的启动优化

1.2 pre-main阶段时间分析

  1. 查看启动时间:Edit Scheme -Run - Arguments - Environment Variables 增加DYLD_PRINT_STATISTICS参数,值设置为1

  1. 各个部分阶段所花费的时间分析

dylib-loading:动态库加载时间:除系统库以外,建议动态库数量在6个以下
rebase/binding: 修正偏移指针ASLR(为了安全,随机一个偏移指针,访问内存使用 0xfff + ASLR),绑定外部符号
Objc settup:类注册时间:
    这里如果做优化,那么可以通过
        1. 减少OC的类来实现(20000个类,减少800ms),
        2. swift的效率要更高
        3. 要经常把弃用的类删除,例如业务实验应用以后,及时把不需要的无用类删除

initializer:load方法的时间,所以我们尽量避免在load中执行耗时操作
复制代码
  1. 优化建议,在以往来说,很少有对premain阶段进行优化,一是因为没有太多有效的技术能对这个阶段进行优化,其次就是premain阶段即使优化,也是毫秒级别的优化,相比较业务层优化来说,优化幅度肯定是要远远不如的,但是从去年起,这一块兴起一个比较火的技术,利用二进制重排来针对premain阶段进行有效的优化,这方面的优化属于纯技术优化,不会对业务产生任何影响

二. 二进制重排

2.1 什么叫做二进制重排

  • 在介绍二进制重排之前,需要先了解一下虚拟内存以及内存的分页管理

  • 2.1.1 虚拟内存
    • 在程序开发过程中,对开发者而言,内存是连续的,这一块连续的内存实际上就是虚拟内存
    • 在早期的计算机中,程序是直接运行在物理内存上的,程序运行时会把其全部加载到内存中,只要程序所需的内存不超过计算机剩余内存就不会出现问题。
    • 但由于程序是可以直接访问物理内存的,这也带来了内存数据的不安全性
    • 为了保证不同应用程序之间内存数据的安全以及互不影响,增加使用效率
    • 后面在物理内存之上增加了一个中间层,让程序通过虚拟内存去间接访问物理内存,这样对每个程序而言,内存都相当于被自己独占了,而且每个进程看到内存都是一致的
2.1.2 内存分页
  • 有了虚拟内存之后,每次程序的运行都会被全部加载到虚拟内存中,但实际上,每次运行并不需要用不到程序的全部逻辑,比如app启动时,实际上只需要掉用部分方法即可启动,其他不相干的逻辑并不需要
  • 为了提高内存使用率,才有了内存分页一说,把一段连续的虚拟内存地址组成的一页,映射到物理内存上对应的一页,这样只需要将少部分代码加载到虚拟内存中,并且映射到物理内存即可
  • 页面置换:当进程A,从虚拟内存页表中映射到物理内存当中,如果发现当前页没有在物理内存中找到,会发生缺页中断,操作系统阻塞当前进程,将磁盘中的进程A 对应的数据加载到内存中,并且将进程A的虚拟内存映射到物理内存;
  • 在加载进程A 对应的数据到内存中时:如果有物理内存中有空的,那么直接放 1 页 上去,如果内存已经满了,那么找一页去覆盖(找不活跃的进程去覆盖)
  • macos 4K = 1页, iOS=16K, 终端:PAGESIZE指令

2.1.3 二进制重排

举例:

假如我现在有一个书架,书架有很多层,每一层都放了很多本书,但是我想找一套书全集7本,碰巧这7本书由于某种原因放在不同的层,那么我们如果需要找到这一套书,是不是就需要从7层挨个全部找一下,最后才能得到一套;那么之后呢,我们肯定就想着把哈利波特一套书放到固定某一层去,如果我们经常要用到,我们也可能会把这7本书籍放到最显眼的一层,这样就可以一下子找到。

同理:

  • app在启动时就会从磁盘加载数据到内存中执行,
  • 如果启动时需要的方法分别在内存中不同的N页,那么就相当于需要把N页全部映射到物理内存上,而且iOS系统在加载分页时还会做签名校验,这样就会大大增加了缺页中断的时间(抖音给的数据:每一个缺页中断耗时0.6-0.8 ms)
  • 这些缺页中断在app运行时基本无法感知,但是当启动时,需要掉用大量方法的时候,随着缺页中断数量的大大增加,就会产生一定的影响
  • 这个时候,我们会想,那如果把启动时所需要用到的方法,全部放在前面 M 页, 那么就可以大大减少缺页中断的次数,提高启动速度,这就时今天所介绍的二进制重排技术
2.1.4 查看符号顺序的方法

1. iOS默认的分页顺序时按照编译顺序来的,也就是我们在compiler sources中的文件顺序

  1. 我们可以通过Build Settings - Write Link Map File - YES 打开设置
  2. 编译之后,通过文件路径找到 Demo1-LinkMap-normal-x86_64文件
  3. 可以发现,实际上的符号顺序是和编译时文件顺序符合的

2.2 二进制重排的目标

假设我们N个文件当中,每一个文件都有一个func方法会在启动时进行掉用,假定这个方法名为func3,那么下图就是原始顺序和目标顺序,我们把所有启动需要用到的符号方法全部放到前面几页,来避免大量的缺页中断
例如现在启动时需要掉用20个方法,但是这20个方法分别在不同的分页,那么启动时就需要pagefault 20次,但实际上这20页中除了20个方法外的其他方法启动时都不需要,这时我们把所有启动需要加载的方法,放在前面1或2或N页,这样加载的时候,只需要从前面2页加载,可以大大减少pagefault次数,优化启动时间
复制代码

查看缺页中断的次数
查看 pagefault:instrument -> system trace -> 输入 Main Thread过滤 ->summary virtual memory -> page in 
复制代码

2.3 二进制重排的方法

  • 苹果官方提供了一个可以修改符号顺序的方法,创建一个demo.order文件,在buildsetting - order file中配置order文件的路径,这样在打包时,会按照order file文件中的符号顺序进行重排
  • 这个技术在oc750的源码中也能看到

  • 我们可以手动创建一个order文件,然后写入自定义的符号顺序,编译看一下结果
  • ps:order文件中即使编写的符号不存在,也不会报错的,可以放心写

address:代码的地址
size:代码大小,随着代码变化会变动
file:代表在第几个文件
name:符号名称
复制代码

2.4 问题

  • 二进制重排技术主要就这么多,但这里有一个问题,我们不可能去一行行去把所有启动时候用到的方法挨个敲一遍,我们也没法知道启动时到底调用了哪些方法,所以这里我们就需要一个自动化的能够批量获取启动时调用的所有符号以及符号调用顺序

三. 解决方法:自动化获取符号以及顺序

3.1 先驱方案:

  • 利用fishhook去动态hook所有的方法,hook系统方法objc_msgsend 拿到第二个参数selector
  • 但时这种方法存在一定的问题,例如initiallize hook不到, 部分block hook不到,C++和swift也hook不到

3.2 终极方案:利用Clang进行编译器静态插桩

3.2.1 新增配置
  • other C flags : -fsanitize-coverage=func,trace-pc-guard
  • other swift flags: -sanitize-coverage=func
    • -sanitize=undefined
3.2.2 增加全局相应方法
  • __sanitizer_cov_trace_pc_guard_init

  • __sanitizer_cov_trace_pc_guard

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

    /// 进行符号收集
    SYNode *node = malloc(sizeof(SYNode));
    *node = (SYNode){PC,NULL};
    //进入
    OSAtomicEnqueue(&symbolList, node, offsetof(SYNode, next));
    
    /// 日志打印
    // 利用Dl_info,获取PC的相关信息
    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);
    复制代码

    }

3.2.3 针对方法是否能够hook全部方法进行验证
  • 首先通过在__sanitizer_cov_trace_pc_guard方法中,增加log,判断出,改方法的确hook了项目中的所有方法
  • 增加initialize方法
  • 增加block等
  • 下图是:原始项目,增加了一个initialize方法,增加了block后的三次启动log对比
  • 说明,改方法的确可以hook到所有的方法

3.2.4 方法中通过对__builtin_return_address进行处理,拿到被hook的方法的相关信息
  • 通过 Dl_info结构体拿到所有的函数信息和符号名称

    void *PC = __builtin_return_address(0);
    Dl_info info;
    dladdr(PC, &info);
    printf("\n-----------------------");
    printf("fname:%s \nfbase:%p \nsname:%s \nsaddr:%p\n",
           info.dli_fname,
           info.dli_fbase,
           info.dli_sname,
           info.dli_saddr);
    复制代码

ps:汇编当中方法A通过bl进行跳转方法B的时候,会把执行完之后下次执行的地址放到x30寄存器中,方法B中遇到ret指令时,会去x30寻找并且执行

  •   -(void)demo_func {
          // 执行clang静态注入的方法
          //__sanitizer_cov_trace_pc_guard
          // 执行完之后,回到demo_func继续改方法的后续执行
      }
      
    复制代码

    所以我们可以相信__builtin_return_address方法返回的值,就是被hook的方法地址

  • 增加log后,我们可以看到打印日志

sname:

+[Test2

load]

sname:``+[Test1 load]

sname:main

3.2.5 到这里为止,我们已经拿到了启动时所需要的所有方法,接下来只需要对这些信息进行过滤写入文件即可
  • 这里需要注意的是,由于这个hook方法是有可能在子线程执行的,所以我们做方法收集的时候需要考虑到线程安全,

  • 这里使用 OSQueueHead 搭配 OSAtomicEnqueue进行信息的收集,当然也可以用其他方法

    //原子队列 static OSQueueHead symbolList = OS_ATOMIC_QUEUE_INIT; //定义符号结构体 typedef struct { void *pc; void *next; }SYNode;

    /// 进行符号收集 SYNode *node = malloc(sizeof(SYNode)); *node = (SYNode){PC,NULL}; //进入 OSAtomicEnqueue(&symbolList, node, offsetof(SYNode, next));

  • 最后我们可以在app的第一个界面进行viewdidappear的时候,执行一下具体解析的方法即可,拿到demo1.order文件

  • 之后可以删除相关的插桩配置,打开order文件读取即可

四. 总结:

  • 之所以使用二进制重排对项目进行启动优化,主要还是在于对于业务的无侵入

  • 就小说项目来说,优化幅度大概在100-200ms左右,具体看项目实际情况

  • 该方法可以适用于OC以及Swift项目均可以,其中如果需要对swift进行插桩,那么需要配置swift flags即可

    other swift flags: -sanitize-coverage=func -sanitize=undefined

猜你喜欢

转载自juejin.im/post/7158707366509150215