ios启动优化:二进制重排

通过前面的探讨,我们知道内存分页触发中断异常 Page Fault 后,会阻塞进程,这个问题是会对性能产生影响。
实际上在 iOS 系统中,生产环境的应用,在发生缺页中断进行重新加载时 ,iOS 系统还会对其做一次签名验证,因此 iOS 生产环境的 Page Fault 所产生的耗时要更多。
对用户而言,使用App时第一个直接体验就是启动 App 时间,而启动时期会有大量的分类三方等等需要加载和执行,此时多个 Page Fault 所产生的的耗时往往是不能小觑的,下面我们就通过二进制重排来优化启动耗时。

抖音团队分享的一个 Page Fault,开销在 0.6 ~ 0.8ms。实际测试发现不同页会有所不同 , 也跟 cpu 负荷状态有关 , 在 0.1 ~ 1.0 ms 之间 。
二进制重排这个方案最早也是 抖音团队 分享的,不过他们的解决方案有瑕疵,下面我们会针对性的解决。

一、原理

假设在启动时期我们需要调用两个函数 method1method4,函数编译在 mach-O 中的位置是根据 ld ( Xcode 的链接器) 的编译顺序并非调用顺序来的,因此很可能这两个函数分布在不同的内存页上。

原理.png
如上图,那么启动时,page1page2 都需要从无到有加载到物理内存中,从而触发两次 Page Fault
二进制重排 的做法就是将 method1method4 放到一个内存页中,那么启动时则只需要加载一次 page 即可,也就是只触发一次 Page Fault
在实际项目中,我们可以将启动时需要调用的函数放到一起 ( 比如 前10页中 ) 以尽可能减少 Page Fault,进而减少启动耗时。

二、调试 Page Fault

最好是卸载App,重新安装,调试第一次启动的效果。

  1. 打开 Instruments,选择 System Trace
  2. 选择真机,选择工程,选择启动,当页面加载出来的时候,停止。
  3. 查看 Page Fault,如图标注。
    Page Fault.png

File Backed Page In:即为 Page Fault,对应的有count,一页Page Fault最大耗时,最小耗时等参数。

如果多次启动调试,你会发现count的波动范围很大。所以如果想获取准确的数据,最好重新安装App或者打开多个App之后,再来调试。
这是因为内存管理机制,杀掉进程时,他所占用的物理内存空间,如果没有被覆盖使用,那么这部分内存有很大可能一直存在。重新打开,内存就不需要全部初始化。所以 冷热启动的界定不能以是否后台杀死来简单判断

三、二进制重排

3.1 Order File

前面说了这么多,那么具体该怎么操作呢?苹果其实已经给我们提供了这个机制。

Order File.png

实际上 二进制重排就是对即将生成的可执行文件重新排列,即它发生在链接阶段
首先,Xcode 用的链接器叫做 ldld 有一个参数叫 Order File,我们可以通过这个参数配置一个 后缀名 为order的文件路径。在这个 order 文件中,将你需要的符号按顺序写在里面。当工程 build 的时候,Xcode 会读取这个文件,打的二进制包就会按照这个文件中的符号顺序进行生成对应的 mach-O
可以参考一下 libObjc 项目,它已经使用了二进制重排进行优化。
libobjc.order.png

是不是看到了ios应用启动加载过程中熟悉的方法。

1、order 文件里符号写错了或不存在会不会有问题:ld 会忽略这些符号,如果提供了 link 选项 -order_file_statistics,他们会以 warning 的形式把这些没找到的符号打印在日志里。

2、会不会影响上架:不会,order文件只是重新排列了所生成的 mach-O(可执行文件) 中函数表与符号表的顺序

3.2 如何查看项目符合顺序

  1. 可以设置 Write Link Map File 来设置是否输出,默认是 noLink Map 是编译期间产生的 ,( ld 的读取二进制文件顺序默认是按照 Compile Sources 里的顺序 ),它记录了二进制文件的布局。
  2. 修改 Write Link Map FileYES,然后clean项目并重新编译
  3. Products -> show in finder,上上层文件夹,然后找到一个xxxxx-LinkMap-normal-arm64.txt 的txt文件。
    Link map.png

    这个文件的# Symbols: 部分存储了所有符号的顺序,前面的 .o 等内容忽略 。
    Symbols.png

    我们发现符号顺序明显是按照 Compile Sources 的文件顺序来排列的。
    文件中最左侧地址就是 方法真实实现地址(实际代码地址)而并非符号地址 , 因此我们二进制重排并非只是修改符号地址 , 而是利用符号顺序 , 重新排列整个代码在文件的偏移地址 , 将启动需要加载的方法地址放到前面内存页中 , 以此达到减少 page fault 的次数从而实现时间上的优化。

终端查看符号表命令(不准确,仅供参考)。找到可执行文件:
nm (file):查看符号表
nm -p (file):按照orderfile顺序
nm -up (file): 只看系统
nm -Up (file):只看自定义

3.3实战

1、 新建一个项目,添加方法:

binary.png
2、修改配置,编译,找到xxx.txt文件
截图.png

3、新建一个order文件:touch binary.order,加入几个方法

-[ViewController test3]
-[ViewController test2]
-[ViewController test1]

4、修改Order File配置为:$(SRCROOT)/Binary/binary.order./Binary/binary.order

order file.png

5、clean编译,再次查看xxx.txt文件。
截图.png
oh my god,我们所写的这三个方法已经被放到最前面了,也就是说,这三个方法被放到了距离 mach-O 中首地址偏移量最小位置。假设这三个方法原本在不同的三页,那么意味着我们已经优化掉了两个 Page Fault。

3.4 获取启动执行的函数

到这里,离启动优化就只差一步了,如何获取启动运行的函数?大致有三种方案,仅供参考:

  1. hook objc_MsgSend:只能拿到 oc 以及 swift @objc dynamic 后的方法,并且由于可变参数个数,需要用汇编来获取参数 。
  2. 静态扫描 machO 特定段和节里面所存储的符号以及函数数据。
  3. clang 插桩:完全拿到 swiftoccblock 全部函数。

四、Clang插桩

关于 clang 的插桩覆盖的官方文档如下 : clang 自带代码覆盖工具 文档中有详细概述,以及简短Demo演示。
思路:一是自己编写 clang 插件,另外一个就是利用 clang 本身已经提供的一个工具来实现我们获取所有符号的需求。

4.1 静态插桩代码

下面我们来探索一下这个静态插桩代码覆盖工具的机制和原理。
1、添加编译设置:直接搜索 Other C Flags 来到 Apple Clang - Custom Compiler Flags 中 , 添加配置:-fsanitize-coverage=trace-pc-guard
2、在ViewController.m添加代码:

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

3、运行(最好是一个空工程,注释我们前面手动添加的方法),查看打印:

trace-pc-guard.png
通过打印startstop两个指针地址,会发现他存储的实际上是 1-15 几个序号。
4、添加一个oc方法,我们再次打印startstop指针,你会发现序号变为 1-16
继续添加一个c函数,一个block,一个touch函数,是不是惊喜的发现,序号增加到 19 了。
89dfb9d8a201.png

此时,我们是不是可以大胆的猜想:这个内存区间保存的就是工程所有符号的个数
5、继续,清空打印,点击屏幕。是不是发现有两次输出,看代码,此时有两次方法的调用。最终我们发现:调用几个方法,就会打印几次 guard:

此时查看汇编,你会发现:在每个函数调用的第一句实际代码,会被添加进去了一个 bl 指令, 调用到 __sanitizer_cov_trace_pc_guard 这个函数中来 。
bl,汇编跳转指令,即调用方法。bl之前是栈平衡与寄存器数据准备,不用关心。

这就是静态插桩:静态插桩实际上是在编译期,在每一个函数内部第一行代码处,添加 hook 代码 ( 即我们添加的 __sanitizer_cov_trace_pc_guard 函数 ) ,实现全局的方法 hook,即AOP效果

4.2 获取函数符号

通过上面的分析我们知道,所有函数的第一步都会调用__sanitizer_cov_trace_pc_guard,那我们是不是可以通过这个函数获取函数符号呢?
熟悉汇编的应该知道:函数嵌套时 , 在跳转子函数时,都会保存下一条指令的地址在 x30 ( 又叫 lr 寄存器) 里 。

例如 , A 函数中调用了 B 函数,在 arm 汇编中即 bl + 0x**** 指令,该指令会首先将下一条汇编指令的地址保存在 x30 寄存器中。然后在跳转到 bl 后面传递的指定地址去执行。
bl 能实现跳转到某个地址的汇编指令,其原理就是修改 pc 寄存器的值来指向到要跳转的地址,而且实际上 B 函数中也会对 x29 / x30 寄存器的值做保护,防止子函数又跳转其他函数会覆盖掉 x30 的值 , 当然叶子函数除外。
当 B 函数执行 ret 也就是返回指令时,就会去读取 x30 寄存器的地址,跳转过去,因此也就回到了上一层函数的下一步。
__sanitizer_cov_trace_pc_guard 函数中的这一句代码:

void *PC = __builtin_return_address(0); 

它的作用其实就是去读取 x30 中所存储的要返回时下一条指令的地址。所以他名称叫做 __builtin_return_address。换句话说,这个地址就是我当前这个函数执行完毕后,要返回到哪里去。
bt 函数调用栈也是这种思路来实现的。也就是说 , 我们可以在 __sanitizer_cov_trace_pc_guard 这个函数中 , 通过 __builtin_return_address 函数拿到原函数调用 __sanitizer_cov_trace_pc_guard 这句汇编代码的下一条指令的地址。

c5eaed5e0295.png

如图,PC的指向就是,当test1函数执行完__sanitizer_cov_trace_pc_guard后,下一行代码NSLog

那么问题又来了,如果通过函数内部内存地址,获取函数名称呢?

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

dlopen.h 相同 , 在 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 <dlfcn.h>,然后修改代码如下 :

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("\nfname:%s \nfbase:%p \nsname:%s\nsaddr:%p \n",info.dli_fname,info.dli_fbase,info.dli_sname,info.dli_saddr);

    char PcDescr[1024];
    //__sanitizer_symbolize_pc(PC, "%p %F %L", PcDescr, sizeof(PcDescr));
    printf("guard: %p %x PC %s\n", guard, *guard, PcDescr);
}

打印结果:

fname:/Users/00393998/Library/Developer/CoreSimulator/Devices/23342248-4844-41AB-9851-2023D815FAA2/data/Containers/Bundle/Application/9A86A08B-5411-4909-B62B-B27097CA2EC9/Binary.app/Binary 
fbase:0x10beee000 
sname:-[ViewController touchesBegan:withEvent:]
saddr:0x10beef9d0 
guard: 0x10bef468c 6 PC �

fname:/Users/00393998/Library/Developer/CoreSimulator/Devices/23342248-4844-41AB-9851-2023D815FAA2/data/Containers/Bundle/Application/9A86A08B-5411-4909-B62B-B27097CA2EC9/Binary.app/Binary 
fbase:0x10beee000 
sname:testFunc
saddr:0x10beef9b0 
guard: 0x10bef4688 5 PC \367\371\356��

4.3 写入order文件

写入文件时有许多需要注意的地方,即坑点

1、多线程

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

- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event{
    //遍历出队
    while (true) {
        //offset 通过next指针在结构体的偏移量,进而知道next的指向
        //offsetof 就是针对某个结构体找到某个属性相对这个结构体的偏移量
       // offsetof(SymbolNode, next) 可以替换为 8
        SymbolNode * node = OSAtomicDequeue(&symboList, offsetof(SymbolNode, next));
        if (node == NULL) break;
        Dl_info info;
        dladdr(node->pc, &info);
        
        printf("%s \n",info.dli_sname);
    }
}
//原子队列
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));
}
2、死循环

上述这种 clang 插桩的方式,会在while循环中同样插入 hook 代码。
通过汇编会查看到 while 循环,会被多次静态加入 __sanitizer_cov_trace_pc_guard 调用,导致死循环。
解决方式:Other C Flags 修改为如下:-fsanitize-coverage=func,trace-pc-guardfunc:表示仅 hook函数时调用

cbnz:汇编执行,while循环。

3、load方法

load 方法时,__sanitizer_cov_trace_pc_guard 函数的参数 guard0,所以打印并没有发现 load。屏蔽掉 __sanitizer_cov_trace_pc_guard 函数中的:if (!*guard) return;

拓展:如果我们希望从某个函数之后/之前开始优化,那么我们可以通过一个全局静态变量,在特定的时机修改其值,在 __sanitizer_cov_trace_pc_guard 这个函数中做好对应的处理即可。

4、其他处理
  1. 由于用的先进后出原因 , 我们要 倒叙 一下
  2. 去重
  3. order 文件格式要求:c函数block 前面还需要加 _下划线。
    核心代码(不要忘记编译配置哦):
//引入头文件
#import <dlfcn.h>
#import <libkern/OSAtomic.h>


//核心代码
#pragma mark - 获取order文件

- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event{
    NSMutableArray<NSString *> * symbolNames = [NSMutableArray array];
    while (YES) {
        //offsetof 就是针对某个结构体找到某个属性相对这个结构体的偏移量
        SymbolNode * node = OSAtomicDequeue(&symbolList, 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:@"binary.order"];
    NSData * fileContents = [funcString dataUsingEncoding:NSUTF8StringEncoding];
    BOOL result = [[NSFileManager defaultManager] createFileAtPath:filePath contents:fileContents attributes:nil];
    if (result) {
        NSLog(@"%@",filePath);
    }else{
        NSLog(@"文件写入出错");
    }
    
}
//原子队列
static OSQueueHead symbolList = OS_ATOMIC_QUEUE_INIT;
//定义符号结构体
typedef struct{
    void * pc;
    void * next;
}SymbolNode;


#pragma mark - 静态插桩代码

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);
    
    SymbolNode * node = malloc(sizeof(SymbolNode));
    *node = (SymbolNode){PC,NULL};
    
    //入队
    // offsetof 用在这里是为了入队添加下一个节点找到 前一个节点next指针的位置
    OSAtomicEnqueue(&symbolList, node, offsetof(SymbolNode, next));
}

最后运行,下载.order文件到本地,就可以愉快的玩耍了。

五、补充

5.1 swift / OC 混编工程问题

通过如上方式适合纯 OC 工程获取符号。由于 swift 的编译器前端是自己的 swift 编译前端程序,因此配置稍有不同。搜索 Other Swift Flags,添加两条配置即可:-sanitize-coverage=func、 -sanitize=undefinedswift类同样可以通过这个方式获取。

5.2 cocoapod 工程问题

cocoapod 工程引入的库,会产生多 target,我们在主target添加的配置是不会生效的,我们需要针对需要的target做对应的设置。
对于直接手动导入到工程里的 sdk,不管是 静态库 .a 还是 动态库,会默认使用主工程的设置,也就是可以拿到符号的。

参考:
抖音研发实践:基于二进制文件重排的解决方案 APP启动速度提升超15%