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

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

    iOS原理 App的启动优化2:二进制重排_第1张图片
  • 选择设备和App,启动System Trace,在App打开第一个界面后停止System Trace

    iOS原理 App的启动优化2:二进制重排_第2张图片
  • 搜索Main Thread,选择Virtual MemoryFile Backed Page In即表示Page Fault

    iOS原理 App的启动优化2:二进制重排_第3张图片

可以看到,启动过程中触发了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来开启。

iOS原理 App的启动优化2:二进制重排_第4张图片

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)链接顺序,顺序可以任意改变。

    iOS原理 App的启动优化2:二进制重排_第5张图片
  • Command + B编译Demo,根据路径找到Link Map File

    iOS原理 App的启动优化2:二进制重排_第6张图片
    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则会忽略这些符号。

iOS原理 App的启动优化2:二进制重排_第7张图片
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 
    #import 
    #include 
    
    @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后缀打开,可以看到符号的排列顺序如下:

    iOS原理 App的启动优化2:二进制重排_第8张图片

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

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

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

    iOS原理 App的启动优化2:二进制重排_第9张图片

    查看编译后新生成的LinkMap File

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

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

推荐阅读

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

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