美文网首页iOS性能优化专题
iOS原理 App的启动优化2:二进制重排

iOS原理 App的启动优化2:二进制重排

作者: 东篱采桑人 | 来源:发表于2020-12-05 18:57 被阅读0次

    iOS原理 文章汇总

    前言

    iOS原理 App的启动优化1:优化建议一文中已经介绍了启动优化的相关概念,我们知道,通过二进制重排可以减少App的启动时间,提高程序的启动性能。

    二进制重排原理

    CPU访问进程数据时,先访问数据对应的虚拟内存page,通过虚拟内存地址找到其对应的物理内存地址,再通过物理地址访问到物理内存上的数据。如果对应的物理内存地址不存在,说明这部分数据没有加载到物理内存中,此时会触发缺页中断(Page Fault)

    物理内存和虚拟内存的详细介绍可阅读iOS原理 物理内存&虚拟内存

    • Page Fault

    Page Fault会中断当前进程,需要先将访问的数据加载到物理内存中,再让CPU访问。而通过App Store渠道分发的App,Page Fault还会进行签名验证,所以每一个Page Fault都会带来一定的耗时。

    如果启动过程中触发大量的Page Fault就会降低启动性能,延长启动时间。通过System Trace可以查看App在启动过程中触发的Page Fault次数:

    • 找到Xcode -> Open Developer Tool ->Instruments,选择并打开System Trace

    • 选择设备和App,启动System Trace,在App打开第一个界面后停止System Trace

    • 搜索Main Thread,选择Virtual MemoryFile Backed Page In即表示Page Fault

    可以看到,启动过程中触发了60次Page Fault,总共耗时7.73ms。这个案例Demo只是新建的一个空项目,一般来说,工作项目会触发上千次Page Fault,就拿微信来说,触发次数达到2600多次,耗时接近700ms。因此,减少启动过程中Page Fault的触发次数,就能缩短启动时间,提高启动性能,而这就可以通过『二进制重排』来实现

    二进制重排实现方式

    App启动过程中会调用一些方法和函数,CPU需要访问相关数据。这时,通过修改代码在二进制文件的布局,将启动时刻调用的方法和函数的二进制符号,排列在一起,确保在一个虚拟内存page中,这样就从多个Page Fault减少为一个Page Fault,这就是二进制重排

    修改方法和函数二进制符号的布局,需要通过Linkmapld以及Clang插桩来实现。

    1. Linkmap

    Linkmap是iOS编译过程的中间产物,记录了二进制文件的布局,需要在Xcode中找到Target -> Build Settings -> Write Link Map File,并设置为Yes来开启。

    Linkmap主要包括三大部分:

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

    编译器在生成二进制代码的时候,默认按照链接的Object File(.o)顺序写文件,按照Object File内部的函数顺序写函数,因此方法和函数编译后的二进制符号,默认先按照.o文件(Object File)的链接顺序,再按照文件里的编写顺序来排列

    以案例Demo为例,查看编译后方法和函数在Linkmap里面的排列顺序。

    • ViewController里编写几个方法和函数
    #import "ViewController.h"
    
    @interface ViewController ()
    
    @end
    
    @implementation ViewController
    
    void test1(){
        
        printf("1");
    }
    
    void test2(){
        
        printf("2");
    }
    
    - (void)viewDidLoad {
        [super viewDidLoad];
        // Do any additional setup after loading the view.
        
        test1();
    }
    
    +(void)load{
        
        printf("3");
        test2();
    }
    
    @end
    
    • 查看.o文件(Object File)链接顺序,顺序可以任意改变。

    • Command + B编译Demo,根据路径找到Link Map File

      image.png
    • 打开文件,查看方法和函数编译后的二进制符号在文件中的排列顺序

    Link Map File里的布局情况可以印证,方法和函数编译后的二进制符号,是先按照.o文件(Object File)的链接顺序,再按照文件里的编写顺序来排列。由于启动过程中调用的方法和函数可能存在于不同的类里,它们编译后的符号默认在二进制文件里分散排列,调用时就会触发大量的Page Fault

    2. ld

    ld是Xcode使用的链接器,写入其参数order_file中的符号,会按照写入顺序排列在二进制文件中符号区域的顶部。因此,在Xcode中,通过Target -> Build Settings -> Order File来配置一个后缀为.order的文件路径,并在这个order文件中,将启动过程中调用的方法和函数以符号格式写在里面,在项目编译后,这些符号就会按照文件里的顺序排列在二进制文件中。若order文件中的符号对应的方法实际不存在,ld则会忽略这些符号。

    3. Clang插桩

    Clang插桩,即批量hook,借助SanitizerCoverage(llvm内置的一个简单的代码覆盖率检测),实现100%符号覆盖,获取到所有的swiftOCCblock函数

    Clang插桩覆盖的官方文档 : clang 自带代码覆盖工具

    实现步骤如下:

    • Step1:开启SanitizerCoverage

      • 方法一:找到Target -> Build Settings -> Other C Flags,添加-fsanitize-coverage=func,trace-pc-guard,如果是swift项目,还需在Other Swift Flags中加入-sanitize-coverage=func-sanitize=undefined

      • 方法二:通过podfile来配置参数

      post_install do |installer|
       installer.pods_project.targets.each do |target|
         target.build_configurations.each do |config|
           config.build_settings['OTHER_CFLAGS'] = '-fsanitize-coverage=func,trace-pc-guard'
           config.build_settings['OTHER_SWIFT_FLAGS'] = '-sanitize-coverage=func -sanitize=undefined'
         end
       end
      end
      
    • Step2:重写下面两个函数,捕获所有调用的方法、函数以及block

      • __sanitizer_cov_trace_pc_guard_init函数
      /**
       *   start:是一个指针,指向无符号int类型,4个字节,相当于一个数组的起始位置,即符号的起始位置。
       *   stop:标记的最后的地址,通过stop的地址-4,获取到最后一个符号的真实地址,真实地址里的值就代表这符号的总数。
       */
      void __sanitizer_cov_trace_pc_guard_init(uint32_t *start, uint32_t *stop) {}
      

      这个是初始化函数,可以获取到所有符号(方法、函数、block、属性)的数量。在捕获方法和函数时,这个函数里面可以不做任何处理。

      • __sanitizer_cov_trace_pc_guard函数
      /**
       *  这个方法可以捕获所有调用的方法、函数以及block
       *  guard:哨兵,告知是第几次被调用
       */
      void __sanitizer_cov_trace_pc_guard(uint32_t *guard) {}
      

      这个函数可以捕获所有调用的方法、函数以及block。每当调用一个方法、函数或者block,都会执行一次这个函数,在这里面,通过__builtin_return_address(0)可以拿到当前调用的方法(/函数/block)的地址,再通过Dl_info可以拿到方法地址和方法名。

    二进制重排的案例Demo

    通过上面的学习可知,二进制重排的实现步骤如下:

    • 通过Clang插桩获取启动时刻调用的全部方法、函数、block
    • 方法、函数、block以符号的格式写入Order文件
    • 配置Order文件。

    接下来通过案例Demo来详细讲解实现步骤,在案例中把Clang插桩相关代码封装在一个文件中,方便后续使用。

    • Step1:新建一个OrderFileTool工具类,所有捕获符号的相关代码均在这里面实现

      由于__sanitizer_cov_trace_pc_guard函数执行太频繁,所以在函数里面只保存调用函数的地址,后面再统一解析。因此,打算用单向链表来保存这些地址,考虑到线程安全,决定用原子队列OSQueueHead来保存。捕获的代码逻辑如下:

      #import "OrderFileTool.h"
      #import <dlfcn.h>
      #import <libkern/OSAtomic.h>
      #include <stdlib.h>
      
      @implementation OrderFileTool
      
      //原子队列,保证多线程下的写入安全
      static OSQueueHead symbolQueue = OS_ATOMIC_QUEUE_INIT;
      
      //定义符号结构体,链表的节点
      typedef struct {
          void *pc;
          void *next;
      }SYNode;
      
      //初始化,里面不做任何处理
      void __sanitizer_cov_trace_pc_guard_init(uint32_t *start, uint32_t *stop) {}
      
      //捕获方法、函数、block,这里只保存地址,不做解析处理
      void __sanitizer_cov_trace_pc_guard(uint32_t *guard) {
      
          //1.获取方法、函数、block的地址
          /**
           *   __builtin_return_address:返回函数的地址
           *   0:表示返回当前函数的地址
           *   1:表示返回当前函数调用者的地址
           */
          void *PC = __builtin_return_address(0);
      
          //2.创建node
          //将地址赋值给node结构体里的pc指针
          SYNode *node = malloc(sizeof(SYNode));
          *node = (SYNode){PC, NULL};
      
          //3.加入队列
          //将node添加到队列中,并将下一个node的地址赋值给当前node结构体里的next指针
          OSAtomicEnqueue(&symbolQueue, node, offsetof(SYNode, next));
      }
      
      +(void)writeOrderFile{
      
          //创建一个符号数组
          NSMutableArray *mArr = [NSMutableArray array];
      
          //while循环获取所有符号
          while (YES) {
      
              //取出节点
              SYNode *node = OSAtomicDequeue(&symbolQueue, offsetof(SYNode, next));
              if(node==NULL){
      
                  break;
              }
      
              //解析PC,获取符号
              Dl_info info;
              dladdr(node->pc, &info);
              NSString *name = @(info.dli_sname);
              //如果不是OC方法,需要在前面加上_
              BOOL isObjC = [name hasPrefix:@"-["]||[name hasPrefix:@"+["];
              NSString *symbol = isObjC?name:[@"_" stringByAppendingString:name];
              //去重判断,如果符号存在,就不添加
              if(![mArr containsObject:symbol]){
      
                  //队列的存储是反序的,所以这里逆序保存在数组中,当然,不逆序也不影响
                  [mArr insertObject:symbol atIndex:0];
              }
          }
      
          //这里要去掉自己本身
          NSString *currentFunc = @"+[OrderFileTool writeOrderFile]";
          if([mArr containsObject:currentFunc]){
      
              [mArr removeObject:currentFunc];
          }
      
          //将数组转换成字符串,并写入Order文件
          NSString *symbolStr = [mArr componentsJoinedByString:@"\n"];
          NSLog(@" ===== symbolStr = \n%@", symbolStr);
          //写入文件
          NSString *filePath = [NSTemporaryDirectory() stringByAppendingPathComponent:@"rewrite.order"];
          NSData *fileContents = [symbolStr dataUsingEncoding:NSUTF8StringEncoding];
          BOOL success = [[NSFileManager defaultManager] createFileAtPath:filePath contents:fileContents attributes:nil];
          if (success) {
      
              NSLog(@" ==== rewrite success:%@", filePath);
          }
      }
      
      @end
      

      这里就实现了整个逻辑,只需要在外部调用+(void)writeOrderFile方法就可完成所有符号的捕获,并写入到order文件。

    • Step2:开启SanitizerCoverage,在程序启动结束后执行捕获方法

      一般来说,在首界面的ViewDidLoad方法里执行就能捕获到程序启动过程的所有符号。这里也是在案例Demo的ViewController.m文件里执行。

      #import "ViewController.h"
      #import "OrderFileTool.h"
      
      @interface ViewController ()
      
      @end
      
      @implementation ViewController
      
      void test1(){
        
          printf("1");
      }
      
      void test2(){
        
          printf("2");
      }
      
      - (void)viewDidLoad {
          [super viewDidLoad];
          // Do any additional setup after loading the view.
        
          test1();
        
          //获取启动过程所有方法和函数的符号
          [OrderFileTool writeOrderFile];
      }
      
      +(void)load{
        
          printf("3");
          test2();
      }
      
      @end
      

      通过输出的Order文件路径,找到文件并改成.txt后缀打开,可以看到符号的排列顺序如下:

      可以看到,启动过程的最后一个函数test1的符号排在最后一个,至此,完成了所有符号的捕获。

    • Step3:配置Order文件,然后Command + B编译

      将生成的rewrite.order文件添加到项目中,再在Target -> Build Settings -> Order File中配置order文件的路径,然后编译。

      查看编译后新生成的LinkMap File

      可以看到,启动过程的方法和函数符号,均按照order文件里的顺序排列在一起,并置于二进制文件前面。至此,整个二进制重排的过程就完成了。

    温馨提示:获取到启动过程的全部符号后,就关掉SanitizerCoverage,并删除OrderFileTool工具类,若以后App启动相关业务发生变更后,再重新排列一次就可以了。

    推荐阅读

    1. iOS原理 App的启动优化1:优化建议
    2. 抖音研发实践:基于二进制文件重排的解决方案 APP启动速度提升超15%
    3. Clang插桩覆盖的官方文档
    4. iOS调优 | 深入理解Link Map File

    相关文章

      网友评论

        本文标题:iOS原理 App的启动优化2:二进制重排

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