iOS 启动优化(二)之二进制重排

iOS 启动优化(一)

前言

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

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

Page Fault

进程如果能直接访问物理内存无疑是很不安全的,所以操作系统在物理内存的上又建立了一层 虚拟内存。为了提高效率和方便管理,又对虚拟内存和物理内存又进行 分页(Page)。当进程 访问一个虚拟内存Page而对应的物理内存却不存在时,会触发一次 缺页中断(Page Fault),分配物理内存,有需要的话会从磁盘mmap读人数据。

通过App Store渠道分发的App,Page Fault还会进行签名验证,所以一次Page Fault的耗时比想象的要多:


image.png
如何查看app启动产生的Page Faults

检测app启动Page Faults次数

二进制重排

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

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

image.png

简化问题:假设我们只有两个page:page1/page2, 函数编译在 mach-o 中的位置是根据 ld ( Xcode 的链接器) 的编译顺序并非调用顺序来的 . 因此很可能这两个函数分布在不同的内存页上 .其中绿色的method1和method3启动时候需要调用,为了执行对应的代码,系统必须进行两个Page Fault。

但如果我们把method1和method3排布到一起,那么只需要一个Page Fault即可,这就是二进制文件重排的核心原理。

image.png

实际项目中的做法是将启动时需要调用的函数放到一起 ( 比如 前10页中 ) 以尽可能减少 page fault , 达到优化目的 . 而这个做法就叫做 : 二进制重排 .

二进制重排,主要是优化我们启动时需要的函数非常分散在各个页,启动时就会多次Page Fault造成时间的损耗

核心问题

为了完成重排,有以下几个问题要解决:

  • 重排效果怎么样 - 获取启动阶段的page fault次数
  • 重排成功了没 - 拿到当前二进制的函数布局
  • 如何重排 - 让链接器按照指定顺序生成Mach-O

重排的内容 - 获取启动时候用到的函数

  • hook objc_MsgSend ( 只能拿到 oc 以及 swift 加上 @objc dynamic 修饰后的方法 )
  • 静态扫描 macho 特定段和节里面所存储的符号以及函数数据 . (静态扫描 , 主要用来获取 load 方法 , c++ 构造(有关 c++ 构造 , 参考 从头梳理 dyld 加载流程 这篇文章有详细讲述和演示 ) .
  • clang 插桩 ( 完美版本 , 完全拿到 swift , oc , c , block 全部函数 )

前两种网上参考资料也较多 , 而且实现效果也并不是完美状态 , 本文我们来谈谈如何通过编译期插桩的方式来 hook 获取所有的函数符号 .

clang 插桩

其实 clang 插桩主要有两个实现思路

  1. 自己编写 clang 插件
  2. 利用 clang 本身已经提供的一个工具or 机制来实现我们获取所有符号的需求 . 本文我们就按照第二种思路来实际演练一下 .

原理探索
新建一个工程来测试和使用一下这个静态插桩代码覆盖工具的机制和原理 .

按照文档指示来走 .

  • 首先 , 添加编译设置 .
    直接搜索 Other C Flags 来到 Apple Clang - Custom Compiler Flags 中 , 添加
-fsanitize-coverage=trace-pc-guard
  • 添加 hook 代码 .
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);
  char PcDescr[1024];
  //__sanitizer_symbolize_pc(PC, "%p %F %L", PcDescr, sizeof(PcDescr));
  printf("guard: %p %x PC %s\n", guard, *guard, PcDescr);
}

可以写在空工程的 ViewController.m 里

  • 运行工程 , 查看打印
    image.png

    代码命名 INIT 后面打印的两个指针地址叫 start 和 stop . 那么我们通过 lldb 来查看下从 startstop 这个内存地址里面所存储的到底是啥 .
    image.png

发现存储的是从 1 到 14 这个序号 . 那么我们来添加一个 oc 方法 .

- (void)testOCFunc{
    NSLog(@"OC函数");
}

发现从 0e 变成了 0f . 也就是说存储的 114 这个序号变成了 115 .
那么我们再添加一个 c 函数 , 一个block , 和一个触摸屏幕方法来看下 .

void testCFunc(){
    LBBlock();
}
void(^LBBlock)(void) = ^(void){
    NSLog(@"block");
};

- (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event {
    testCFunc();
}
image.png

同样发现序号依次增加到了 18 个 , 那么我们得到一个猜想 , 这个内存区间保存的就是工程所有符号的个数 .

其次 , 我们在触摸屏幕方法调用了 c 函数 , c 函数中调用了 block . 那么我们点击屏幕 , 发现如下 :

image.png

发现我们实际调用几个方法 , 就会打印几次 guard : .

实际上就类似我们埋点统计所实现的效果 . 在触摸方法添加一个断点查看汇编 :


image.png

通过汇编我们发现 , 在每个函数调用的第一句实际代码( 栈平衡与寄存器数据准备除外 ), 被添加进去了一个 bl 调用到__sanitizer_cov_trace_pc_guard 这个函数中来 .

而实际上这也是静态插桩的原理和名称由来 .

静态插桩总结

静态插桩实际上是在编译期就在每一个函数内部二进制源数据添加 hook 代码 ( 我们添加的__sanitizer_cov_trace_pc_guard 函数 ) 来实现全局的方法 hook 的效果 .

获取所有函数符号

先理一下思路 .
思路
我们现在知道了 , 所有函数内部第一步都会去调用 __sanitizer_cov_trace_pc_guard 这个函数. 那么熟悉汇编的同学可能就有这么个想法 :

函数嵌套时 , 在跳转子函数时都会保存下一条指令的地址在 X30 ( 又叫 lr 寄存器) 里 .

我们所写的 __sanitizer_cov_trace_pc_guard 函数中的这一句代码 :

void *PC = __builtin_return_address(0);

它的作用其实就是去读取 x30 中所存储的要返回时下一条指令的地址 . 所以他名称叫做 __builtin_return_address . 换句话说 , 这个地址就是我当前这个函数执行完毕后 , 要返回到哪里去 .

也就是说 , 我们现在可以在 __sanitizer_cov_trace_pc_guard 这个函数中 , 通过 __builtin_return_address 函数拿到原函数调用 __sanitizer_cov_trace_pc_guard 这句汇编代码的下一条指令的地址 .

image.png

根据内存地址获取函数名称

拿到了函数内部一行代码的地址 , 如何获取函数名称呢 ?

熟悉安全攻防 , 逆向的同学可能会清楚 . 我们为了防止某些特定的方法被别人使用 fishhook hook 掉 , 会利用 dlopen 打开动态库 , 拿到一个句柄 , 进而拿到函数的内存地址直接调用 .

是不是跟我们这个流程有点相似 , 只是我们好像是反过来的 . 其实反过来也是可以的 .

dlopen 相同 , 在 dlfcn.h 中有一个方法如下 :

typedef struct dl_info {
        const char      *dli_fname;     /* 所在文件 */
        void            *dli_fbase;     /* 文件地址 */
        const char      *dli_sname;     /* 符号名称 */
        void            *dli_saddr;     /* 函数起始地址 */
} Dl_info;
 
//这个函数能通过函数内部地址找到函数符号
int dladdr(const void *, Dl_info *);

紧接着我们来实验一下 , 先导入头文件#import , 然后修改代码如下 :

void __sanitizer_cov_trace_pc_guard(uint32_t *guard) {
    if (!*guard) return;  // Duplicate the guard check.
     
    void *PC = __builtin_return_address(0);
    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);
     
    char PcDescr[1024];
    printf("guard: %p %x PC %s\n", guard, *guard, PcDescr);
}

查看打印结果 :


image.png
收集符号

clang静态插桩 - 坑点1
→ : 多线程问题

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

考虑到这个方法会来特别多次 , 使用锁会影响性能 , 这里使用苹果底层的原子队列 ( 底层实际上是个栈结构 , 利用队列结构 + 原子性来保证顺序) 来实现 .

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

- (void)touchesBegan:(NSSet *)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);
    }
}

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

多线程的解决方法写完之后 , 运行发现 : 死循环了 .

clang静态插桩 - 坑点2

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

→ : 解决方案

Other C Flags 修改为如下 :

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

代表仅针对 func 进行 hook . 再次运行 .

image.png

clang静态插桩 - 坑点3
→ : load 方法时 , __sanitizer_cov_trace_pc_guard 函数的参数 guard 是 0.

上述打印并没有发现 load .

解决 : 屏蔽掉 __sanitizer_cov_trace_pc_guard 函数中的

if (!*guard) return;

image.png

load 方法就有了 .

这里也为我们提供了一点启示:
如果我们希望从某个函数之后/之前开始优化 , 通过一个全局静态变量 , 在特定的时机修改其值 , 在 __sanitizer_cov_trace_pc_guard 这个函数中做好对应的处理即可 .

剩余细化工作

  • 如果你也是使用这种多线程处理方式的话 , 由于用的先进后出原因 , 我们要倒叙一下
  • 还需要做去重 .
  • order 文件格式要求c 函数 , block 调用前面还需要加 _ , 下划线 .
  • 写入文件即可 .
完整Demo:
#import "ViewController.h"
#import 
#import 

@interface ViewController ()

@end

@implementation ViewController

+ (void)load{
     
}

- (void)viewDidLoad {
    [super viewDidLoad];
    // Do any additional setup after loading the view.
}

- (void)testOCFunc{
    NSLog(@"OC函数");
}
void testCFunc(){
    LBBlock();
}
void(^LBBlock)(void) = ^(void){
    NSLog(@"block");
};

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

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)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event {
   NSMutableArray * 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(@"文件写入出错");
   }
}
     
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));
}

@end

文件写入到了 tmp 路径下 , 运行 , 打开手机下载查看 :


image.png
image.png
swift 工程 / 混编工程问题

通过如上方式适合纯 OC 工程获取符号方式 .
由于 swift 的编译器前端是自己的 swift 编译前端程序 , 因此配置稍有不同 .
搜索 Other Swift Flags , 添加两条配置即可 :

-sanitize-coverage=func
-sanitize=undefined
swift 类通过上述方法同样可以获取符号 .

如何重排 - 让链接器按照指定顺序生成Mach-O

1. 先查看自己工程的符号顺序

linkmap主要包括三大部分:

Link Map File中文直译为链接映射文件,编译期间产生的产物,( ld 的读取二进制文件顺序默认是按照 Compile Sources - GUI 里的顺序 )它是在Xcode生成可执行文件的同时生成的链接信息文件,用于描述可执行文件的构造部分,包括了代码段和数据段的分布情况。我们可以在Xcode的配置中将 Write Link Map File 设置为 YES 来生成 Map File

  • Object Files 生成二进制用到的link单元的路径和文件编号
  • Sections 记录Mach-O每个Segment/section的地址范围
  • Symbols 按顺序记录每个符号的地址范围
image.png

Run下一app,查看Map File

Products -> ClangDemo.app -> show in finder, 找到 macho 的上上层目录.

image.png

按下图依次找到最新的一个.txt文件并打开.
image.png

这个文件中就存储了所有符号的顺序 , 在 # Symbols:部分

image.png

提示 :

上述文件中最左侧地址就是 实际代码地址而并非符号地址 , 因此我们二进制重排并非只是修改符号地址 , 而是利用符号顺序 , 重新排列整个代码在文件的偏移地址 , 将启动需要加载的方法地址放到前面内存页中 , 以此达到减少 page fault 的次数从而实现时间上的优化 , 一定要清楚这一点 .

2. Xcode配置Order

那么我们需要将启动时候调用的函数进行重排,让它们尽可能的分配在同一个页;比如load方法我们就将其找出来,放到一起;LLVM支持我们通过设置order来达到这个效果

  • 首先 , Xcode 是用的链接器叫做ld, ld 有一个参数叫 Order File , 我们可以通过这个参数配置一个 order 文件的路径 .
  • 在这个 order 文件中 , 将你需要的符号按顺序写在里面 .
  • 当工程 build 的时候 ,Xcode 会读取这个文件 , 打的二进制包就会按照这个文件中的符号顺序进行生成对应的 mach-O .
image.png

来到工程根目录 , 新建一个文件 touch lb.order . 挑选几个启动时就需要加载的方法

重新运行 , 查看 Link Map File

二进制重排疑问

Q : order 文件里 符号写错了或者这个符号不存在会不会有问题 ?
答 : ld 会忽略这些符号 , 实际上如果提供了link 选项 -order_file_statistics,会以 warning 的形式把这些没找到的符号打印在日志里。

Q : 有部分同学可能会考虑这种方式会不会影响上架 ?
答 : 首先 , objc 源码自己也在用这种方式 .
二进制重排只是重新排列了所生成的 macho 中函数表与符号表的顺序 .

image.png

总结

本篇文章 一步一步实现 clang 静态插桩达到二进制重排优化启动时间的完整流程 .
具体实现步骤如下 :

  1. 利用 clang 插桩获得启动时期需要加载的所有 函数/方法 , block , swift 方法以及 c++构造方法的符号 .
  2. 通过 order file 机制实现二进制重排 .

参考文章

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

你可能感兴趣的:(iOS 启动优化(二)之二进制重排)