iOS性能---启动优化(2)二进制重排

启动优化(1)汇总篇
启动优化(2)二进制重排

背景

抖音团队发现修改代码在二进制文件的布局可以提高启动性能,方案落地后在抖音上启动速度提高了约15%。抖音团队分享的一个 Page Fault,开销在 0.6 ~ 0.8ms,实际测试发现不同页会有所不同 , 也跟 cpu 负荷状态有关 , 在 0.1 ~ 1.0 ms 之间)。

内存分页、虚拟内存、物理内存

  • iOS一页为16KBlinux一页为4KB, Mac OS一页为4KB.
  • 当应用被加载到内存中时,并不会将整个应用加载到内存中,只会放用到的那一部分,也就是懒加载的概念,换句话说就是应用使用多少,实际物理内存就实际存储多少 .
  • 当应用访问到某个地址,映射表中为0,也就是说并没有被加载到物理内存中时,系统就会立刻阻塞整个进程,触发一个我们所熟知的 缺页中断 - Page Fault .
  • 当一个缺页中断被触发 , 操作系统会从磁盘中重新读取这页数据到物理内存上 , 然后将映射表中虚拟内存指向对应 ( 如果当前内存已满 , 操作系统会通过置换页算法 找一页数据进行覆盖 , 这也是为什么开再多的应用也不会崩掉 , 但是之前开的应用再打开时 , 就重新启动了的根本原因 ).
  • 虚拟地址从 0x000000 ~ 0xffffff , 基于这个 , 那么这个函数我无论如何只需要通过 0x00a000 这个虚拟地址就可以拿到其真实实现地址,给了很多黑客可操作性的空间,ASLR 应运而生 . 其原理就是每次 虚拟地址在映射真实地址之前, 增加一个随机偏移值 , 以此来解决这个问题.

二进制重排优化原理

  • page fault会将没有加载到物理内存的数据加载到物理内存,这是一个耗时操作
  • 实际项目中,mach-o中的方法是按照编译顺序(Build Phases-> Compile Sources)排列,我们做法是将启动时需要调用的函数放到一起以尽可能减少page fault, 达到优化目的 . 而这个做法就叫做 : 二进制重排。

如何查看 page fault

  1. 打开 Instruments , 选择 System Trace.
  2. 选择真机 , 选择工程 , 点击启动 , 当首个页面加载出来点击停止 . 这里注意 , 最好是将应用杀掉重新安装 , 因为冷热启动的界定其实由于进程的原因并不一定后台杀掉应用重新打开就是冷启动 .
  3. 等待分析完成 , 查看缺页次数

二进制重排实现

  • 生成一个.order文件,放在工程中,在xcode的build settings里搜order file配置好
  • 在这个 order 文件中 , 将你需要的符号按顺序写在里面 .
  • 当工程 build 的时候 , Xcode 会读取这个文件 , 打的二进制包就会按照这个文件中的符号顺序进行生成对应的 mach-O

order 文件里 符号写错了或者这个符号不存在会不会有问题 ?

  • 答 : ld 会忽略这些符号 , 实际上如果提供了 link 选项 -order_file_statistics,会以 warning 的形式把这些没找到的符号打印在日志里。
    有部分同学可能会考虑这种方式会不会影响上架 ?
  • 答 : 首先 , objc 源码自己也在用这种方式,二进制重排只是重新排列了所生成的 macho 中函数表与符号表的顺序 .

如何查看自己工程的符号顺序

  • 在xcode的build settings里搜Link Map配置好,设置Write Link Map File为YES
  • 修改完毕后 clean 一下 , 运行工程 , Products - show in finder, 找到 mach-o的上上层目录,按下图依次找到最新的一个 .txt 文件并打开

clang插桩生成.order文件

  • 在xcode的build settings里分别搜Other C FlagsOther Swift Flags配置好,设置-fsanitize-coverage=func,trace-pc-guard
  • 代码实现
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    #import "ViewController.h"
    #import <dlfcn.h>
    #import <libkern/OSAtomic.h>
    @interface ViewController ()
    @end

    @implementation ViewController
    //可以得到函数个数
    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.
    }
    //原子队列
    static OSQueueHead symboList = OS_ATOMIC_QUEUE_INIT;
    //定义符号结构体,链表的节点
    typedef struct{
    void * pc;
    void * next;
    }SymbolNode;

    //所有函数调用会走此方法,获得函数名
    void __sanitizer_cov_trace_pc_guard(uint32_t *guard) {
    //if (!*guard) return; 这个方法会屏蔽系统load方法所以注掉
    //获取插桩函数的返回地址
    void *PC = __builtin_return_address(0);
    //根据PC可以获得函数的具体信息
    SymbolNode * node = malloc(sizeof(SymbolNode));
    *node = (SymbolNode){PC,NULL};
    //入队
    // offsetof 用在这里是为了入队添加下一个节点找到 前一个节点next指针的位置
    OSAtomicEnqueue(&symboList, node, offsetof(SymbolNode, next));
    }
    //点击生成文件
    - (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event{
    NSMutableArray<NSString *> * symbolNames = [NSMutableArray array];
    while (true) {
    //offsetof 就是针对某个结构体找到某个属性相对这个结构体的偏移量
    SymbolNode * node = OSAtomicDequeue(&symboList, offsetof(SymbolNode, next));
    if (node == NULL) break;
    Dl_info info;
    dladdr(node->pc, &info);
    NSString * name = @(info.dli_sname);
    // 添加 _
    BOOL isObjc = [name hasPrefix:@"+["] || [name hasPrefix:@"-["];
    NSString * symbolName = isObjc ? name : [@"_" stringByAppendingString:name];
    //去重
    if (![symbolNames containsObject:symbolName]) {
    [symbolNames addObject:symbolName];
    }
    }
    //取反
    NSArray * symbolAry = [[symbolNames reverseObjectEnumerator] allObjects];
    NSLog(@"%@",symbolAry);
    //将结果写入到文件
    NSString * funcString = [symbolAry componentsJoinedByString:@"\n"];
    NSString * filePath = [NSTemporaryDirectory() stringByAppendingPathComponent:@"lb.order"];
    NSData * fileContents = [funcString dataUsingEncoding:NSUTF8StringEncoding];
    BOOL result = [[NSFileManager defaultManager] createFileAtPath:filePath contents:fileContents attributes:nil];
    if (result) {
    NSLog(@"%@",filePath);
    }else{
    NSLog(@"文件写入出错");
    }
    }
    @end
  • 注意点
    1. 通过汇编会查看到 一个带有 while 循环的方法 , 会被静态加入多次 __sanitizer_cov_trace_pc_guard调用 , 导致死循环.

      解决: 将Other C Flags ->-fsanitize-coverage=trace-pc-guard改为-fsanitize-coverage=func,trace-pc-guard

    2. 多线程问题,项目各个方法肯定有可能会在不同的函数执行 , 因此 __sanitizer_cov_trace_pc_guard 这个函数也有可能受多线程影响 , 所以你当然不可能简简单单用一个数组来接收所有的符号就搞定了 .

      解决: 这里使用苹果底层的原子队列:static OSQueueHead symboList = OS_ATOMIC_QUEUE_INIT;

    3. load方法时 , __sanitizer_cov_trace_pc_guard 函数的参数 guard 是 0.

      解决: 注销 // if (!*guard) return;

  • 插桩原理
    通过汇编可知,在clang编译过程,所以方法内插入一个__sanitizer_cov_trace_pc_guard方法,每次执行方法前,先执行__sanitizer_cov_trace_pc_guard方法

其他hook方法不能用的原因

  1. Instruments(Time Profiler/System Trace) trace文件方案,因为他们都是基于特定场景采样的,大多数符号获取不到。

参考文章

iOS 优化篇 - 启动优化之Clang插桩实现二进制重排
抖音研发实践:基于二进制文件重排的解决方案 APP启动速度提升超15%