美文网首页
iOS-底层原理:启动优化(三)二进制重排

iOS-底层原理:启动优化(三)二进制重排

作者: 恍然如梦_b700 | 来源:发表于2021-01-18 18:09 被阅读0次

    前提,在之前的两篇文章中,大致介绍了一些基本概念以及启动优化的思路,下面来着重介绍一个pre-main阶段的优化方案,即二进制重排,这个方案最开始是由于抖音的这篇文章抖音研发实践:基于二进制文件重排的解决方案 APP启动速度提升超15%火起来的。

    二进制重排原理

    在虚拟内存部分,我们知道,当进程访问一个虚拟内存page,而对应的物理内存不存在时,会触发缺页中断(Page Fault),因此阻塞进程。此时就需要先加载数据到物理内存,然后再继续访问。这个对性能是有一定影响的。

    基于Page Fault,我们思考,App在冷启动过程中,会有大量的类、分类、三方等需要加载和执行,此时的产生的Page Fault所带来的的耗时是很大的。以WeChat为例,我们来看下,在启动阶段的Page Fault的次数

    • CMD+i快捷键,选择System Trace

      image
    • 点击启动(启动前需要重启手机,清除缓存数据),第一个界面出来后,停掉,按照下图中操作

      image

      从图中可以看出WeChat发生的PageFault有2800+次,可想而知,这个是非常影响性能的。

    • 然后我们再通过Demo查看方法在编译时期的排列顺序,在ViewController中按下列顺序定义以下几个方法

    @implementation ViewController
    
    void test1(){
        printf("1");
    }
    
    void test2(){
        printf("2");
    }
    
    - (void)viewDidLoad {
        [super viewDidLoad];
    
        test1();
    }
    
    +(void)load{
        printf("3");
        test2();
    }
    @end
    
    
    • Build Setting -> Write Link Map File设置为YES

      image
    • CMD+B编译demo,然后在对应的路径下查找 link map文件,如下所示,可以发现 类中函数的加载顺序是从上到下的,而文件的顺序是根据Build Phases -> Compile Sources中的顺序加载的

      image

    从上面的Page Fault的次数以及加载顺序,可以发现其实导致Page Fault次数过多的根本原因是启动时刻需要调用的方法,处于不同的Page导致的。因此,我们的优化思路就是:将所有启动时刻需要调用的方法,排列在一起,即放在一个页中,这样就从多个Page Fault变成了一个Page Fault。这就是二进制重排的核心原理,如下所示

    image

    注意:在iOS生产环境的app,在发生Page Fault进行重新加载时,iOS系统还会对其做一次签名验证,因此 iOS 生产环境的 Page Fault 比Debug环境下所产生的耗时更多。

    二进制重排实践

    下面,我们来进行具体的实践,首先理解几个名词

    Link Map
    Linkmap是iOS编译过程的中间产物,记录了二进制文件的布局,需要在Xcode的Build Settings里开启Write Link Map File,Link Map主要包含三部分:

    • Object Files 生成二进制用到的link单元的路径和文件编号

    • Sections 记录Mach-O每个Segment/section的地址范围

    • Symbols 按顺序记录每个符号的地址范围

    ld

    ld是Xcode使用的链接器,有一个参数order_file,我们可以通过在Build Settings -> Order File配置一个后缀为order的文件路径。在这个order文件中,将所需要的符号按照顺序写在里面,在项目编译时,会按照这个文件的顺序进行加载,以此来达到我们的优化

    所以二进制重排的本质就是对启动加载的符号进行重新排列

    到目前为止,原理我们基本弄清楚了,如果项目比较小,完全可以自定义一个order文件,将方法的顺序手动添加,但是如果项目较大,涉及的方法特别多,此时我们如何获取启动运行的函数呢?有以下几种思路

    • 1、hook objc_msgSend:我们知道,函数的本质是发送消息,在底层都会来到objc_msgSend,但是由于objc_msgSend的参数是可变的,需要通过汇编获取,对开发人员要求较高。而且也只能拿到OC 和 swift中@objc 后的方法

    • 2、静态扫描:扫描 Mach-O 特定段和节里面所存储的符号以及函数数据

    • 3、Clang插桩:即批量hook,可以实现100%符号覆盖,即完全获取swift、OC、C、block函数

    Clang 插桩

    llvm内置了一个简单的代码覆盖率检测(SanitizerCoverage)。它在函数级、基本块级和边缘级插入对用户定义函数的调用。我们这里的批量hook,就需要借助于SanitizerCoverage

    关于 clang 的插桩覆盖的官方文档如下 : clang 自带代码覆盖工具 文档中有详细概述,以及简短Demo演示。

    • 【第一步:配置】开启 SanitizerCoverage
      • OC项目,需要在:在 Build Settings 里的 “Other C Flags” 中添加 -fsanitize-coverage=func,trace-pc-guard

      • 如果是Swift项目,还需要额外在 “Other Swift Flags” 中加入-sanitize-coverage=func-sanitize=undefined

      • 所有链接到 App 中的二进制都需要开启 SanitizerCoverage,这样才能完全覆盖到所有调用。

      • 也可以通过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
    
    
    • 【第二步:重写方法】新建一个OC文件CJLOrderFile,重写两个方法
      • __sanitizer_cov_trace_pc_guard_init方法

        • 参数1 start 是一个指针,指向无符号int类型,4个字节,相当于一个数组的起始位置,即符号的起始位置(是从高位往低位读)

          image
        • 参数2 stop,由于数据的地址是往下读的(即从高往低读,所以此时获取的地址并不是stop真正的地址,而是标记的最后的地址,读取stop时,由于stop占4个字节,stop真实地址 = stop打印的地址-0x4

          image
        • stop内存地址中存储的值表示什么?在增加一个方法/块/c++/属性的方法(多3个),发现其值也会增加对应的数,例如增加一个test1方法

          image
      • __sanitizer_cov_trace_pc_guard方法 ,主要是捕获所有的启动时刻的符号,将所有符号入队

        • 参数guard是一个哨兵,告诉我们是第几个被调用的

        • 符号的存储需要借助于链表,所以需要定义链表节点CJLNode

        • 通过OSQueueHead创建原子队列,其目的是保证读写安全

        • 通过OSAtomicEnqueue方法将node入队,通过链表的next指针可以访问下一个符号

    //原子队列,其目的是保证写入安全,线程安全
    static  OSQueueHead queue = OS_ATOMIC_QUEUE_INIT;
    //定义符号结构体,以链表的形式
    typedef struct {
        void *pc;
        void *next;
    }CJLNode;
    
    /*
     - start:起始位置
     - stop:并不是最后一个符号的地址,而是整个符号表的最后一个地址,最后一个符号的地址=stop-4(因为是从高地址往低地址读取的,且stop是一个无符号int类型,占4个字节)。stop存储的值是符号的
     */
    void __sanitizer_cov_trace_pc_guard_init(uint32_t *start,
                                                        uint32_t *stop) {
        static uint64_t N;
        if (start == stop || *start) return;
        printf("INIT: %p - %p\n", start, stop);
        for (uint32_t *x = start; x < stop; x++) {
            *x = ++N;
        }
    
    }
    
    /*
     可以全面hook方法、函数、以及block调用,用于捕捉符号,是在多线程进行的,这个方法中只存储pc,以链表的形式
    
     - guard 是一个哨兵,告诉我们是第几个被调用的
     */
    void __sanitizer_cov_trace_pc_guard(uint32_t *guard) {
    //    if (!*guard) return;//将load方法过滤掉了,所以需要注释掉
    
        //获取PC
        /*
         - PC 当前函数返回上一个调用的地址
         - 0 当前这个函数地址,即当前函数的返回地址
         - 1 当前函数调用者的地址,即上一个函数的返回地址
        */
        void *PC = __builtin_return_address(0);
        //创建node,并赋值
        CJLNode *node = malloc(sizeof(CJLNode));
        *node = (CJLNode){PC, NULL};
    
        //加入队列
        //符号的访问不是通过下标访问,是通过链表的next指针,所以需要借用offsetof(结构体类型,下一个的地址即next)
        OSAtomicEnqueue(&queue, node, offsetof(CJLNode, next));
    }
    
    
    • 【第三步:获取所有符号并写入文件】
      -while循环从队列中取出符号,处理非OC方法的前缀,存到数组中
      • 数组取反,因为入队存储的顺序是反序的
      • 数组去重,并移除本身方法的符号
      • 将数组中的符号转成字符串并写入到cjl.order文件中
    extern void getOrderFile(void(^completion)(NSString *orderFilePath)){
    
        collectFinished = YES;
        __sync_synchronize();
        NSString *functionExclude = [NSString stringWithFormat:@"_%s", __FUNCTION__];
        dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.01 * NSEC_PER_SEC)), dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
            //创建符号数组
            NSMutableArray<NSString *> *symbolNames = [NSMutableArray array];
    
            //while循环取符号
            while (YES) {
                //出队
                CJLNode *node = OSAtomicDequeue(&queue, offsetof(CJLNode, next));
                if (node == NULL) break;
    
                //取出PC,存入info
                Dl_info info;
                dladdr(node->pc, &info);
    //            printf("%s \n", info.dli_sname);
    
                if (info.dli_sname) {
                    //判断是不是OC方法,如果不是,需要加下划线存储,反之,则直接存储
                    NSString *name = @(info.dli_sname);
                    BOOL isObjc = [name hasPrefix:@"+["] || [name hasPrefix:@"-["];
                    NSString *symbolName = isObjc ? name : [@"_" stringByAppendingString:name];
                    [symbolNames addObject:symbolName];
                }
    
            }
    
            if (symbolNames.count == 0) {
                if (completion) {
                    completion(nil);
                }
                return;
            }
    
            //取反(队列的存储是反序的)
            NSEnumerator *emt = [symbolNames reverseObjectEnumerator];
    
            //去重
            NSMutableArray<NSString *> *funcs = [NSMutableArray arrayWithCapacity:symbolNames.count];
            NSString *name;
            while (name = [emt nextObject]) {
                if (![funcs containsObject:name]) {
                    [funcs addObject:name];
                }
            }
    
            //去掉自己
            [funcs removeObject:functionExclude];
    
            //将数组变成字符串
            NSString *funcStr = [funcs componentsJoinedByString:@"\n"];
            NSLog(@"Order:\n%@", funcStr);
    
            //字符串写入文件
            NSString *filePath = [NSTemporaryDirectory() stringByAppendingPathComponent:@"cjl.order"];
            NSData *fileContents = [funcStr dataUsingEncoding:NSUTF8StringEncoding];
            BOOL success = [[NSFileManager defaultManager] createFileAtPath:filePath contents:fileContents attributes:nil];
            if (completion) {
                completion(success ? filePath : nil);
            }
        });
    }
    
    
    • 【第四步:在didFinishLaunchingWithOptions方法最后调用】需要注意的是,这里的调用位置是由你决定的,一般来说,是第一个渲染的界面
    - (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions {
    
        [self test11];
    
        getOrderFile(^(NSString *orderFilePath) {
            NSLog(@"OrderFilePath:%@", orderFilePath);
        });
    
        return YES;
    }
    
    - (void)test11{
    
    }
    
    

    此时的cjl.order中只有这三个方法

    image
    • 【第五步:拷贝文件,放入指定位置,并配置路径】一般将该文件放入主项目路径下,并在Build Settings -> Order File中配置./cjl.order,下面是配置前后的对比(上边是配置前的熟悉怒,下边是配置后符号顺序的)

      image

    注意点:避免死循环

    • Build Settings -> Other C Flags的如果配置的是-fsanitize-coverage=trace-pc-guard,在while循环部分会出现死循环(我们在touchBegin方法中调试)

      image
    • 我们打开汇编调试,发现有3个__sanitizer_cov_trace_pc_guard的调用

      image
      • 第一次是bl 是 touchBegin

        image
      • 第三次 bl 是 printf

      • 第二次 bl 是因为while 循环。 即 只要是跳转,就会被hook,即有 bl、b的指令,就会被hook

        image

    解决方式:将BuildSetting中的other C Flags的-fsanitize-coverage=trace-pc-guard ,改成-fsanitize-coverage=func,trace-pc-guard

    相关文章

      网友评论

          本文标题:iOS-底层原理:启动优化(三)二进制重排

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