ios启动优化:二进制重排

作者: 正_文 | 来源:发表于2020-07-24 16:24 被阅读0次

    通过前面的探讨,我们知道内存分页触发中断异常 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%

    相关文章

      网友评论

        本文标题:ios启动优化:二进制重排

        本文链接:https://www.haomeiwen.com/subject/mliikktx.html