iOS 优化篇 - 启动优化二进制重排之Clang插桩(测试机型iphone7)

1,032 阅读13分钟

前言

  • 自从抖音团队分享了这篇 抖音研发实践:基于二进制文件重排的解决方案 APP启动速度提升超15% 启动优化文章后 , 二进制重排优化 pre-main 阶段的启动时间自此被大家广为流传 .

  • 本篇文章首先讲述下二进制重排的原理 , ( 因为抖音团队在上述文章中原理部分大多是点到即止 , 多数朋友看完并没有什么实际收获 ) . 然后将结合 clang 插桩的方式 来实际讲述和演练一下如何解决抖音团队遗留下来的这一问题 :

    hook Objc_msgSend 无法解决的 纯swift , block , c++ 方法 .

    来达到完美的二进制重排方案 .

( 本篇文章由于会从原理角度讲解 , 有些已经比较熟悉的同学可能会觉得节奏偏啰嗦 , 为了照顾大部分同学 , 大家自行根据目录跳过即可 . )

了解二进制重排之前 , 我们需要了解一些前导知识 , 以及二进制重排是为了解决什么问题 .

虚拟内存与物理内存

为了方便表述分为了三个名词

虚拟内存、物理内存、磁盘

1. 没有虚拟内存的时代

早期应用程序都是把整个数据从磁盘加载到物理内存中,例如物理内存120M,程序A运行需要10M,程序B运行需要100M,程序C运行需要30M。

当我们先运行程序A的时候,会把内存0M - 10M分配给程序A

当我们再运行程序B的时候,会把内存10M - 110M分配给程序B

此时内存占用110M还剩10M

当我们在运行程序C的时候,如果把A占用的区域A释放后,并不够使用,而且这个时候是整体加载,A占用的10M空间不够用,只能把B运行占用的区域,置换到硬盘中,在把内存10M-40M分配给程序C

出现的问题:
  1. 内存使用效率低, C置换B的时候,需要把整个B都置换到硬盘数据中,在把C放到内存中。有大量数据交换
  2. 安全问题:没有隔离各个程序,内存连续,可以从程序A 通过偏移量访问程序B
  3. 重定向问题:每次装载位置不同,需要重定向

为了解决安全问题,提出了虚拟内存

为了解决效率、重定向问题,提出了分页分段

2.虚拟内存

虚拟出一个虚拟地址空间,虚拟空间映射实际的物理空间。每个线程都有自己的独立的虚拟空间。控制线程只能访问自己的虚拟空间后,有效的对各个程序作出分离。

3.分段分页

  1. 分段(Segmentation)

    为了解决效率问题,首先想到的是分段操作,基本思路是把一段与程序所需要的内存空间大小的虚拟空间映射到某个物理地址空间。如下图所示,会吧虚拟空间中的每个字节对应物理空间的每个字节中,此时整个映射是由软件来设置,实际的地址转换是有硬件完成的。

    例如:当程序App1访问0x00000000的时候,访问的物理内存的实际地址为0x00C00000。

    image-20211112104112905

    虚拟内存+分段解决了安全问题和重定向的问题。但还没有解决内存使用效率低的问题,当程序来回切换的时候,会有大量的数据进行交换。即使我们只用到了程序中的一小部分数据。分段的方式也需要整个程序的内存到磁盘的读写操作。 所以,为了解决这个问题,想到了更小粒度的内存分割和映射方法,使得程序的局部性原理得到充分的利用,从而提高内存使用率。这个方法就是分页

  2. 分页(paging)

    “分页的基本方法是把地址空间人为地等分成固定大小的页,每一页的大小由硬件决定,或硬件支持多种大小的页,由操作系统选择决定页的大小。比如Intel Pentium系列处理器支持4KB或4MB的页大小,那么操作系统可以选择每页大小为4KB,也可以选择每页大小为4MB,但是在同一时刻只能选择一种大小,所以对整个系统来说,页就是固定大小的。目前几乎所有的PC上的操作系统都使用4KB大小的页。我们使用的PC机是32位的虚拟地址空间,也就是4GB,那么按4KB每页分的话,总共有1 048 576个页。物理空间也是同样的分法。”

    “下面我们来看一个简单的例子,如图下图所示,每个虚拟空间有8页,每页大小为1KB,那么虚拟地址空间就是8KB。我们假设该计算机有13条地址线,即拥有2^13的物理寻址能力,那么理论上物理空间可以多达8KB。但是出于种种原因,购买内存的资金不够,只买得起6KB的内存,所以物理空间其实真正有效的只是前6KB。”

    “那么,当我们把进程的虚拟地址空间按页分割,把常用的数据和代码页装载到内存中,把不常用的代码和数据保存在磁盘里,当需要用到的时候再把它从磁盘里取出来即可。以图1-6为例,我们假设有两个进程Process1和Process2,它们进程中的部分虚拟页面被映射到了物理页面,比如VP0、VP1和VP7映射到PP0、PP2和PP3;而有部分页面却在磁盘中,比如VP2和VP3位于磁盘的DP0和DP1中;另外还有一些页面如VP4、VP5和VP6可能尚未被用到或访问到,它们暂时处于未使用的状态。在这里,我们把虚拟空间的页就叫虚拟页(VP,Virtual Page),把物理内存中的页叫做物理页(PP,Physical Page),把磁盘中的页叫做磁盘页(DP,Disk Page)。“图中的线表示映射关系,我们可以看到虚拟空间的有些页被映射到同一个物理页,这样就可以实现内存共享。”

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

    image-20211112111800616

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

    image-20211112112029633

二进制重排优化原理

image-20211112112335171

从上面的介绍可以知道,每次Page为没加载的时候都会产生页错误(Page Fault),每产生一个page页,系统就会发生一次阻塞。如果我们调用方法的循序是Method1 > Method4 > Method2 这种顺序。当调用Method1的时候因为内存中没有加载Page1所以就会触发一次PageFault, 在调用Method4,因为Method4在Page2中,所以我们就需要加载Page2,在产生一次PageFault。 此时产生了2个PageFault。

那我们可以把Method1和Method4放到一个Page中,这样系统调用method1后在调用method4,因为Page1在加载Method1的时候已经加载到内存中,所以当调用Method4的时候就不会在产生PageFault。一次达到我们优化的目的。

在实际的项目中,我们通过把启动的时候锁需要调用的函数,尽可量的放到一起。这样就减少PageFault的产生,从而达到优化的目的。这种做法就佳叫做二进制重排。

如何查看PageFault

  1. 进入Instruments,选择System Trace
  2. 点击左上角开始录制,直到首页页面出现后点击停止,选中项目即可查,如下图所示

image-20210811170105330

二进制重排如何操作

首先 , Xcode 是用的链接器叫做 ld , ld 有一个参数叫 Order File , 我们可以通过这个参数配置一个 order 文件的路径 .

在这个 order 文件中 , 将你需要的符号按顺序写在里面 .

当工程 build 的时候 , Xcode 会读取这个文件 , 打的二进制包就会按照这个文件中的符号顺序进行生成对应的 mach-O .

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

  • 答 : ld 会忽略这些符号 , 实际上如果提供了 link 选项 -order_file_statistics,会以 warning 的形式把这些没找到的符号打印在日志里。 .

2️⃣ : 有部分同学可能会考虑这种方式会不会影响上架 ?

  • 答 : 首先 , objc 源码自己也在用这种方式 .
  • 二进制重排只是重新排列了所生成的 macho 中函数表与符号表的顺序 .

Clang插桩

官方文档

配置项

  • 搭建测试项目,在Build Setting -> Other C Flags中,增加-fsanitize-coverage=trace-pc-guard的配置

  • 在代码中实现如下两个方法

    void __sanitizer_cov_trace_pc_guard_init(uint32_t *start, uint32_t *stop)
    void __sanitizer_cov_trace_pc_guard(uint32_t *guard) 
    
官方示例:

image-20211105143158599

函数描述

// trace-pc-guard-cb.cc
#include <stdint.h>
#include <stdio.h>
#include <sanitizer/coverage_interface.h>
// 编译器将此回调作为模块构造插入到每一个DSO,start和stop相当于整个每整个二进制文件的开始和结束。
// 回调至少调用一次在DSO前,也可以使用相同的参数调用多次。

// This callback is inserted by the compiler as a module constructor
// into every DSO. 'start' and 'stop' correspond to the
// beginning and end of the section with the guards for the entire
// binary (executable or DSO). The callback will be called at least
// once per DSO and may be called multiple times with the same parameters.
// uint32_t *start 无符号32位 占4个字节 符号的起始位置
// uint32_t *stop 符号的结束位置
extern "C" 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.
}

// This callback is inserted by the compiler on every edge in the
// control flow (some optimizations apply).
// Typically, the compiler will emit the code like this:
//    if(*guard)
//      __sanitizer_cov_trace_pc_guard(guard);
// But for large functions it will emit a simple call:
//    __sanitizer_cov_trace_pc_guard(guard);
// 每次插桩的回调函数
extern "C" void __sanitizer_cov_trace_pc_guard(uint32_t *guard) {
  if (!*guard) return;  // Duplicate the guard check.
  // If you set *guard to 0 this code will not be called again for this edge.
  // Now you can get the PC and do whatever you want:
  //   store it somewhere or symbolize it and print right away.
  // The values of `*guard` are as you set them in
  // __sanitizer_cov_trace_pc_guard_init and so you can make them consecutive
  // and use them to dereference an array or a bit vector.
  void *PC = __builtin_return_address(0);
  char PcDescr[1024];
  // This function is a part of the sanitizer run-time.
  // To use it, link with AddressSanitizer or other sanitizer.
  __sanitizer_symbolize_pc(PC, "%p %F %L", PcDescr, sizeof(PcDescr));
  printf("guard: %p %x PC %s\n", guard, *guard, PcDescr);
}
可能出现的问题

image-20211104101625998

出现这种问题是没有实现上述的两个方法

仔细描述函数方法

首先我们先看一下void __sanitizer_cov_trace_pc_guard_init(uint32_t *start, uint32_t *stop) 这个函数。 从打印信息可以看出。这个方法是生成guard的,函数会给出开始指针和结束指针。然后遍历指针,给每个指针地址赋值(此处对应的是void __sanitizer_cov_trace_pc_guard(uint32_t *guard) 中的蓝线部分,也就是空判断)。

image-20211115111944998

这个时候我们就在好奇这个数字代表着什么。

我们添加一个-(void) test方法试一下,发现打印出来的增加了一个。由此我们可以看出。这个连续的空间代表这所有符号的个数。

image-20211115112255978

从汇编上看,调用函数后,会在调用函数前方插入void __sanitizer_cov_trace_pc_guard(uint32_t *guard) 方法。执行后在调用实际调用的方法。我们可以在此方法中调用__builtin_return_address(0);方法即可获取到开始执行方法的地址,如下图所示: 在函数中获取的PC,返回的地址就是即将执行方法的地址。

image-20211115142131605

我们可以通过下面的函数,使用函数调用的地址,获取到函数的符号名

#include <dlfcn.h>
/*
 * Structure filled in by 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;
extern int dladdr(const void *, Dl_info *);


Dl_info info;
dladdr(PC, &info);

首先导入 <dlfcn.h>库,然后定义Dl_info结构体变量,最后通过dladdr来获取到具体的内容。dli_sname为最近的符号名称。

这样我们就可以根据PC来获取到相对应的符号名称,因为__sanitizer_cov_trace_pc_guard函数调用是在调用每个方法前调用,我们就可以通过记录PC,从而获取到对应的符号名称。从而拿到App启动到第一个页面的所有符号名。

坑点:

→ :多线程问题

由于方法肯定是在不同的线程调用,所以在收集PC的时候不能用一个简单的数组,可以用锁,也可以用下面的方式

//原子队列
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;  // Duplicate the guard check.
    void *PC = __builtin_return_address(0);
    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{
    //遍历出队
    while (true) {
        //offsetof 就是针对某个结构体找到某个属性相对这个结构体的偏移量
        SymbolNode * node = OSAtomicDequeue(&symboList, offsetof(SymbolNode, next));
        if (node == NULL) break;
        Dl_info info;
        dladdr(node->pc, &info);
        
        printf("%s \n",info.dli_sname);
    }
}
→ :死循环问题

→ : 上述这种 clang 插桩的方式 , 会在循环中同样插入 hook 代码 .

当确定了我们队列入队和出队都是没问题的 , 你自己的写法对应的保存和读取也是没问题的 , 我们发现了这个坑点 , 这个会死循环 , 为什么呢 ?

这里我就不带着大家去分析汇编了 , 直接说结论 :

通过汇编会查看到 一个带有 while 循环的方法 , 会被静态加入多次 __sanitizer_cov_trace_pc_guard调用 , 导致死循环.

→ : 解决方案

Other C Flags 修改为如下 :

-fsanitize-coverage=func,trace-pc-guard

→ : 另一种死循环问题

如果在while中,调用OC方法,就会在调用方法前再次调用__sanitizer_cov_trace_pc_guard,再次 OSAtomicEnqueue(&symboList, node, offsetof(SymbolNode, next));,从而导致死循环。

- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event{
    //遍历出队
    while (true) {
       SymbolNode * node = OSAtomicDequeue(&symboList, offsetof(SymbolNode, next));
	     if (node == NULL) break;
  	   ...
    	 [self test];
       ...
    }
}
→ :Load方法

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

上述打印并没有发现 load .

解决 : 屏蔽掉 __sanitizer_cov_trace_pc_guard 函数中的

if (!*guard) return;

剩余细化工作

  • 如果你也是使用笔者这种多线程处理方式的话 , 由于使用的是先进后出, 我们要倒序一下,

  • 还需要做去重操作.

  • order 文件格式要求c 函数 , block 调用前面还需要加 _ , 下划线 .

  • 写入文件即可 .

  • 针对Cocoapods里的方式引入的库,而且公司项目用use_modular_headers修饰为静态库。所以可以直接开启这个方法即可,如果为动态库需要重新实现上文提供的两个方法。

    config.build_settings['LD_GENERATE_MAP_FILE'] == 'YES'
    config.build_settings['OTHER_CFLAGS'] = '$(inherited) -fsanitize-coverage=func,trace-pc-guard'
    config.build_settings['OTHER_SWIFT_FLAGS'] = '$(inherited) -sanitize-coverage=func -sanitize=undefined'
    config.build_settings['GCC_PREPROCESSOR_DEFINITIONS'] = '$(inherited) OrderFileDebug=1'
    
  • Swift工程

    搜索 Other Swift Flags , 添加两条配置即可 :
    -sanitize-coverage=func
    -sanitize=undefined
    

完整的方法如下:

#import <libkern/OSAtomic.h>
#include <dlfcn.h>
#import <os/signpost.h>

void __sanitizer_cov_trace_pc_guard_init(uint32_t *start, uint32_t *stop) {
    static uint64_t N;
    if (start == stop || *start) return;
    printf("INIT: %p %p\n", start, stop);
    for (uint32_t *x = start; x < stop; x++)
        *x = ++N;
}


//定义原子队列
OSQueueHead symbolList = OS_ATOMIC_QUEUE_INIT;

//定义结构体
typedef struct {
    void *pc;
    void *next;
} SYNode;

static BOOL isFinding = false;

void __sanitizer_cov_trace_pc_guard(uint32_t *guard)  {
    if (isFinding) {
        return;
    }
    void *PC = __builtin_return_address(0);
    //创建结构体
    SYNode *node = malloc(sizeof(SYNode));
    *node = (SYNode){PC, NULL};
    //结构体入栈
    //offsetof:参数1传入类型,将下一个节点的地址返回给参数
    OSAtomicEnqueue(&symbolList, node, offsetof(SYNode, next));
}

@implementation LGClangTools
/// 遍历所有的
+ (void)symbolNames {
#if OrderFileDebug == 1
    //     定义数组
    isFinding = true;
    NSMutableArray<NSString *> *symbolNames = [NSMutableArray array];
    while (YES) {
        SYNode *node = OSAtomicDequeue(&symbolList, offsetof(SYNode, next));
        if(node == NULL){
            break;
        }
        Dl_info info;
        dladdr(node->pc, &info);
        printf("%s",info.dli_sname);
        // 转字符串
        NSString *name = @(info.dli_sname);
        // 给OC函数名称添加_
        BOOL isObjc = [name hasPrefix:@"+["] || [name hasPrefix:@"-["];
        NSString * symbolName = isObjc ? name : [@"_" stringByAppendingString:name];
        [symbolNames addObject:symbolName];
    }
    isFinding = false;
    // 反向遍历数组
    NSEnumerator * em = [symbolNames reverseObjectEnumerator];
    NSMutableArray * funcs = [NSMutableArray arrayWithCapacity:symbolNames.count];
    NSString * name;
    while (name = [em nextObject]) {
        // 如果符号名称不在数组中,添加到数组
        if (![funcs containsObject:name]) {//去重:数组没有name
            [funcs addObject:name];
        }
    }
    //去掉当前函数名称
    [funcs removeObject:[NSString stringWithFormat:@"%s",__func__]];
    
    //写入文件
    //1.编程字符串
    NSString * funcStr = [funcs componentsJoinedByString:@"\n"];
    NSString * filePath = [NSTemporaryDirectory() stringByAppendingPathComponent:@"hank.order"];
    NSData * file = [funcStr dataUsingEncoding:NSUTF8StringEncoding];
    [[NSFileManager defaultManager] createFileAtPath:filePath contents:file attributes:nil];
    
    NSLog(@"%@",funcStr);
#else

#endif
}


优化前

image-20211104153515073

优化后

image-20211104154448904

优化前

image-20211108105129407

优化后

image-20211108104742086

开始测量

结果测量: 启动顺序 优化后,优化前,优化后 为准确测量优化前后,首次启动为优化后的app 在启动之前打开手机内20个app左右,并且相机连拍100张,确保数据的准确性

冷启动

优化后

image-20211115155048881

image-20211115155112163

优化前

image-20211115155521989

image-20211115155533349

二次测量优化后冷启动

image-20211115160835360

image-20211115160853808

测量结果总结

优化后总共耗时 01.644.771s (0.1.615.618s+ 29.09ms)

优化前总共耗时 03.339.297s (0.3.287.467s + 52.23ms)

优化后总共耗时 01.891.344s (0.1.864.704 + 26.64ms)

优化后page fault 646ms

优化前page fault 1.20s

优化后page fault 592ms

热启动

优化后

image-20211115161636717

image-20211115161654387

优化前

image-20211115161821674

image-20211115161836431

总结

热启动优化前后差距不大,总体大概差0.15s左右,考虑误差大概0.1s左右 冷启动优化前后差距较大,大约提升40%左右

参考文章

juejin.cn/post/684490…

参考书籍

《程序员的自我修养》