iOS启动优化(下)二进制重排与Clang插桩

1,459 阅读8分钟

前言

在上一篇 启动优化-概念篇 中讲解一些启动优化相关的知识,最后得到减少缺页中断(pageFault)可以达到启动优化的目的,本文将使用二进制重排与Clang插桩来实现优化的目的。

文件和方法的编译顺序

  • Build Setting中搜索Link Map,然后将Write Link Map File设置为YES

    截屏2021-09-05 14.40.49.png

    • 然后编译后在DerivedData找到对应的工程(本工程名为WeChatTest),然后Build -> Intermediates.noindex -> WeChatTest.build -> Debug-iphoneos -> WeChatTest.build -> WeChatTest-LinkMap-normal-arm64.txt

      截屏2021-09-05 14.48.25.png

    • 然后打开WeChatTest-LinkMap-normal-arm64.txt文件,可以发现代码实现的排列顺序:

      截屏2021-09-05 14.45.52.png

      • Address是代码的地址,它加上ASLR就是物理内存中的地址
      • Size是内存大小
      • File是文件编号
      • Name是方法
  • 其中代码文件的顺序是根据编译顺序确定的,文件内部的代码是按照书写顺序从上到下

    截屏2021-09-05 15.02.54.png

    • 可以通过修改文件的编译顺序和文件中代码来验证,先调整文件编译顺序:

    截屏2021-09-06 09.00.29.png

    • 然后在AppDelegate中增加方法

    截屏2021-09-06 09.02.24.png

    • 编译后查看.txt文件

    截屏2021-09-06 09.04.44.png

  • 根据调整后方法的打印,得以验证:

      1. 文件的顺序是根据编译顺序来确定
      1. 文件中代码的编译顺序是根据书写从上到下编译的顺序确定的

二进制重排

  • objc4-818.2源码中能看到一个libobjc.order文件:

    截屏2021-09-14 23.03.59.png

    • 文件里面方法的都是符号名称,这个是给编译器看的,编译器看到后就按照order中符号的顺序去对二进制进行排列。下面进行验证
  • 新建一个WushuangDemo1工程,然后在AppDelegateViewController中分别添加方法:

    // AppDelegate.m
    void wushuang() {
    }
    @implementation AppDelegate
    + (void)load {
    }
    
    // ViewController.m
    @implementation ViewController
    + (void)load {
      
    }
    
  • 然后在根目录先创建一个wushuang.order文件

    截屏2021-09-14 23.12.07.png

    • wushuang.order里写一些符号

      截屏2021-09-14 23.21.15.png

      • wushuang666wushuangSay方法不存在
  • 然后在Build Setting中搜索Order File,然后配置wushuang.order路径:

    截屏2021-09-14 23.24.38.png

  • 最后将Build SettingWrite Link Map File设置为YES,然后在真机上编译,之后打开生成的.txt文件:

    截屏2021-09-14 23.35.16.png

    • 原来的_main在第三个顺序现在在第一位,顺序已经换了,并且没有的符号也会忽略不计

虽然搞定了二进制重排,但我们的目标拿到启动相关的符号,启动涉及的东西很多,方法嵌套函数,函数里又有网络请求,然后又block等等,太多了眼睛都看不过来。那么怎么知道启动时调用了哪些方法,这个时候就需要用到Clang插桩,Clang会读所有的代码,可以无死角的覆盖所有的函数、block

Clang插桩

  • 进入 clang官方文档SanitizerCoverage处可以看到Tracing PCPCCPU读取代码的指针,Tracing PC是跟踪CPU执行到的代码。SanitizerCoverageLLVM内置的一个简单的代码覆盖率工具,它会在函数方法block处插入回调,并提供这些回调的默认实现,并实现简单的覆盖率和可视化。

    截屏2021-09-27 17.26.08.png

Hook函数调用

  • 在文档的Tracing PCs with guards处,在编译器中加入-fsanitize-coverage=trace-pc-guard配置,系统会默认实现两个函数:

    __sanitizer_cov_trace_pc_guard(&guard_variable)
    __sanitizer_cov_trace_pc_guard_init(uint32_t *start, uint32_t *stop)
    

    下面来验证下:

    • 创建个新工程,在Build Setting中的Other C Flags中添加-fsanitize-coverage=trace-pc-guard

    截屏2021-09-27 17.47.30.png

    • 然后在真机环境下编译,会出现找不到这两个方法符号的错误

    截屏2021-09-27 17.56.38.png

    • 所以加上flag后,系统会调用这两个函数,需要手动去实现。根据文档中给的实现案例,复制到ViewController

    截屏2021-09-27 18.14.40.png

    • 此时编译就成功了
  • __sanitizer_cov_trace_pc_guard_init函数反映了项目中符号的个数

    • 其中参数startstop都是代表4字节的数据,for循环是遍历startstop的位置然后讲初始化的4字节变量自增然后放到遍历的相应位置
    • 运行后打印得到startstop的地址,使用x命令读取start内存可以得到存储的数据

    截屏2021-09-27 21.58.35.png

    • 那么最后一个数据就是stop内存减去4后得到第一个4字节数据

    截屏2021-09-27 21.59.59.png

    • 增加方法touchesBegan:方法,然后再运行查看最后一个数据,发现增加了1 截屏2021-09-27 22.02.16.png
    • 再添加一个wushuang函数,查看是否会再增加1

    截屏2021-09-27 22.04.39.png

    • 结果最后一个数据依然会+1,说明函数也能监控到,再定义一个block,然后再运行

    截屏2021-09-27 22.09.51.png

    • 还是会自增

结论:__sanitizer_cov_trace_pc_guard_init能够监控项目中所有方法,函数以及block,也就是反映了项目中符号的个数

  • __sanitizer_cov_trace_pc_guard方法是拦截项目中的方法,相当于Hook

    • touchesBegan:中调用wushaung函数,然后函数中调用block

    截屏2021-09-27 23.06.37.png

    • 然后运行起来后,在__sanitizer_cov_trace_pc_guard函数处打断点,再点击屏幕触发touchesBegan:

    截屏2021-09-27 23.08.48.png

    • 观察堆栈可知touchesBegan:会调用__sanitizer_cov_trace_pc_guard函数,过掉断点发现每走一个方法都会触发这个函数。
    • 然后在这个方法处打上断点重新启动项目,就监控到了启动方法的执行顺序

结论:__sanitizer_cov_trace_pc_guard是由项目内部方法,函数,block函数调用,也就是Hook了项目中一切的函数调用

原理

在项目运行起来后,然后在touchesBegan:函数处打断点,点击屏幕查看汇编

截屏2021-09-27 23.30.15.png

  • 通过观察发现在touchesBegan:的头部实现栈平衡相关代码后会插入__sanitizer_cov_trace_pc_guard函数,后面才是touchesBegan:的实现,然后在wushuang函数和block处都打断点发现都是一样的
  • 所以,项目中只要添加了Clang插桩的标记,编译器就会在所有方法、函数、block代码实现的 边缘 添加一句__sanitizer_cov_trace_pc_guard代码
  • 编译器在所有代码(自己写的代码)中添加这个函数,也就是修改了二进制文件,只有编译器能干这个事情
  • 那么可以在上线前代码审查时使用,上线时,将Other C Flag中的标记清除即可

获取启动符号

接下来需要获取启动符号,首先我们知道现在每个方法都会调用__sanitizer_cov_trace_pc_guard函数,用__builtin_return_address(0)函数就可以获取__sanitizer_cov_trace_pc_guard的上一个调用者地址。具体方法如下

void __sanitizer_cov_trace_pc_guard(uint32_t *guard) {
    void *PC = __builtin_return_address(0); // 获取上一个调用函数的地址
    Dl_info info; // 符号信息结构体
    dladdr(PC, &info); // 将从PC中获取的符号信息存入结构体中
    
    printf("Dl_info : \nfname: %s\nfbase: %p\nsname: %s\nsaddr: %p\n\n", info.dli_fname, info.dli_fbase, info.dli_sname, info.dli_saddr);
}
  • Dl_info是一个结构体,用来存储符号信息:

    • dli_fname: 当前项目路径
    • dli_fbase: 是MachO文件的起始位置
    • dli_sname: 符号名字
    • dli_saddr: 函数地址
  • dladdr作用是将获取函数的信息存入Dl_info结构体中

  • 验证结果如下:

    截屏2021-09-28 15.07.57.png

  • 于是可以用dli_sname直接输出符号名字,就得到了所有方法的调用顺序

    截屏2021-09-28 15.11.43.png

坑点分析及解决方案

上面的方式虽然获取到启动时调用的方法,但是有个坑点,就是会在多线程中访问数据 代码如下:

void __sanitizer_cov_trace_pc_guard(uint32_t *guard) {
    if (!*guard) return;
    void *PC = __builtin_return_address(0); 
    Dl_info info;
    dladdr(PC, &info);
    NSLog(@"%@", [NSThread currentThread]);
    printf("%s\n", info.dli_sname);
}

- (void)viewDidLoad {
    [super viewDidLoad];

    [self performSelectorInBackground:@selector(testWushuang) withObject:nil];
}

- (void)testWushuang {
    sleep(3);
}
  • 很明显testWushuang方法的调用是在子线程,打印__sanitizer_cov_trace_pc_guard的线程得到如下结果:

    截屏2021-09-29 10.27.53.png

    • 在子线程调用的方法,__sanitizer_cov_trace_pc_guard也在子线程,这时就产生了多线程访问,会出现数据错乱的问题。这个时候就需要使用OSAtomicEnqueue() OSAtomicDequeue()来将启动的方法进行安全存取。
    • 先导入头文件#import <libkern/OSAtomic.h>,再将Other C Flag标识符改成:-fsanitize-coverage=func,trace-pc-guard,里面添加了func是仅限方法使用,因为方法里的其他函数也会调用__sanitizer_cov_trace_pc_guard,为了排除其他的调用就需要添加这个flag。其他核心代码如下:
    // 定义一个原子队列
    static OSQueueHead list = OS_ATOMIC_QUEUE_INIT;
    
    // 链表结构体
    typedef struct {
        void *pc;   //存获取到的PC
        void *next; //指向下一个节点
    } Node;
    
    void __sanitizer_cov_trace_pc_guard(uint32_t *guard) {
        void *PC = __builtin_return_address(0); 
    
        Node *node = malloc(sizeof(Node)); // 给node节点分配内存
        *node = (Node){PC, NULL};  // 将PC赋值给node的pc
    
        OSAtomicEnqueue(&list, node, offsetof(Node, next)); // 在队列中将新的node节点存入上个节点的next位置
    }
    
    - (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
        while (YES) {
            Node *node = OSAtomicDequeue(&list, offsetof(Node, next)); // 在队列上个节点的next位置获取node
            if (node == NULL) {
                break;
            }
            Dl_info info;
            dladdr(node->pc, &info); //将pc信息存入info
            printf("sname: %s\n", info.dli_sname);
        }
    }
    
    • 此时就可以安全的取到启动符号了

    截屏2021-09-29 11.32.22.png

生成Order文件并配置

将打印出来的启动方法放到wushuang.order文件,然后取出来进行二进制重排,代码如下:

- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
    NSMutableArray<NSString *> *nameArray = [NSMutableArray array];
    while (YES) {
        Node *node = OSAtomicDequeue(&list, offsetof(Node, next));
        if (node == NULL) {
            break;
        }
        Dl_info info;
        dladdr(node->pc, &info);

        printf("sname: %s\n", info.dli_sname);
        NSString *funcName = @(info.dli_sname); // 转成OC字符串

        BOOL isOCFunc = NO;
        if ([funcName hasPrefix:@"-["] || [funcName hasPrefix:@"+["]) {
            isOCFunc = YES;
        }
        // 生成方法名字
        funcName = isOCFunc ? funcName : [@"_" stringByAppendingString:funcName];
        // 去重
        if (![nameArray containsObject:funcName]) {
            [nameArray insertObject:funcName atIndex:0];
        }
    }

    // 去掉自己
    [nameArray removeObject:[NSString stringWithFormat:@"%s", __func__]];

    // 写入文件
    NSString *funcString = [nameArray componentsJoinedByString:@"\n"];
    NSLog(@"\nfuncString: \n%@", funcString);
    NSString *filePath = [NSTemporaryDirectory() stringByAppendingPathComponent:@"wushuang.order"];
    NSData *fileData = [funcString dataUsingEncoding:NSUTF8StringEncoding];
    BOOL isSuccess = [[NSFileManager defaultManager] createFileAtPath:filePath contents:fileData attributes:nil];
    if (isSuccess) {
        NSLog(@"写入成功  🎉");
    } else {
        NSLog(@"写入失败 😭");
    }
}
  • 运行后得到的方法顺序为

    截屏2021-09-29 14.24.15.png

  • Window->Devices and Simulators选中自己的手机,然后选择当前的Demo,再Download Container下载到桌面

    截屏2021-09-29 14.07.53.png

    • 在右键显示包内容,在temp里找到wushuang.order文件,将文件放到工程根目录,然后在Build SettingOrder File配置好路径,然后将Write Link Map File设置为YES,最后删除Other C Flag内容,运行后找到txt文件并打开

    截屏2021-09-29 14.34.08.png

    此时得到的顺序和打印的一致!

Swift符号获取

获取Swift的符号需要在Other Swift Flag处配置-sanitize-coverage=func,与-sanitize=undefined,核心代码如下:

class Wushuang: NSObject {
    @objc class func say() {
        print(" wushuang say NB ");
    }
}

// ViewController.h
+ (void)load {
    [Wushuang say];
    
    block();
}

于是就得到了swift符号

截屏2021-09-29 14.58.23.png