iOS启动优化(下)

393 阅读5分钟

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

1. 文件和方法的编译顺序

  • Build Setting中搜索Link Map,然后将Write Link Map File设置为YES Xnip2022-08-01_22-08-46.png

    • 编译项目,然后在Users/xxx/Library/Developer/Xcode/DerivedData/ProjectName(这里是LaunchOptimization)/Build/Intermediates.noindex/LaunchOptimization.build/Debug-iphoneos/LaunchOptimization.build 路径下找到 LaunchOptimization-LinkMap-normal-arm64.txt,如下: Xnip2022-08-01_22-26-09.png

    • 打开LaunchOptimization-LinkMap-normal-arm64.txt,如下
      Xnip2022-08-01_22-31-48.png

      • Address 是地址偏移量,它加上ASLR就是虚拟内存中的地址
      • Size 是占用内存大小
      • File 是文件编号
      • Name 是方法/函数名
  • 这里代码文件的顺序是按 编译顺序 确定的,文件内部的代码是按照书写顺序从上到下

    Xnip2022-08-01_22-36-50.png

    • 可以通过修改文件的编译顺序和文件中代码来验证,先调整文件编译顺序:
      Xnip2022-08-01_22-47-29.png

    • AppDelegate.m 中增加方法: Xnip2022-08-01_22-49-24.png

    • 重新编译 生成 .txt 文件 Xnip2022-08-01_22-52-48.png

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

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

2. 二进制重排

  • objc4源码根目录中能看到一个libobjc.order文件: Xnip2022-08-01_23-01-58.png

    • 打开 libobjc.order文件: Xnip2022-08-01_23-03-50.png

    • 文件里面方法的都是符号名称,这个是给编译器看的,编译器看到后就按照order中符号的顺序去对二进制进行排列。下面进行验证

  • 还用上面 LaunchOptimization工程,在根目录下创建 LaunchOpt.order文件 Xnip2022-08-01_23-09-49.png

    • LaunchOpt.order里写一些符号: Xnip2022-08-01_23-13-07.png

    • 然后在Build Setting中搜索Order File,然后配置LaunchOpt.order路径: Xnip2022-08-01_23-17-31.png

  • Write Link Map File设置为YES,再次运行项目看 .txt 文件 Xnip2022-08-01_23-20-05.png

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

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

3. 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)

LaunchOptimization 工程中来验证。在Build Setting中的Other C Flags中添加-fsanitize-coverage=trace-pc-guard

Xnip2022-08-02_10-17-17.png

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

Xnip2022-08-02_10-20-34.png

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

#import "ViewController.h"
#include <stdint.h>
#include <stdio.h>
#include <sanitizer/coverage_interface.h>

@implementation ViewController
- (void)viewDidLoad {
    [super viewDidLoad];
}
// 里面反应了项目中符号个数
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.
}
// HOOK 一切的回调函数!!
void __sanitizer_cov_trace_pc_guard(uint32_t *guard) { 
    
}
@end

如此这般。。。 编译就成功了

__sanitizer_cov_trace_pc_guard_init

__sanitizer_cov_trace_pc_guard_init函数反映了项目中符号的个数。其中参数startstop都是uint32_t *类型 即4字节的数据,这样for循环每4字节为一个单位从start开始 到stop-1(4字节)的结束,将++N存储到对应的位置。如此最后一个++N的位置即是 stop-1(4字节)

运行后打印得到startstop的地址,使用x命令读取start内存可以得到存储的数据,stop-4即最后一个数据:

Xnip2022-08-02_13-48-59.png

增加方法touchesBegan:方法,然后再运行查看最后一个数据,发现增加了1

Xnip2022-08-02_13-52-18.png

增加 C 函数 testCFunc(),然后再运行查看最后一个数据,又增加了1

Xnip2022-08-02_13-55-10.png

增加 block,然后再运行查看最后一个数据,又增加了1

Xnip2022-08-02_13-58-45.png

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

__sanitizer_cov_trace_pc_guard

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

touchesBegan:中调用testCFunc()函数,然后函数中调用block

Xnip2022-08-02_14-15-44.png

运行项目,在__sanitizer_cov_trace_pc_guard函数处打断点,

点击屏幕触发touchesBegan:时堆栈信息: Xnip2022-08-02_14-16-59.png

调用 testCFunc() 函数时堆栈信息: Xnip2022-08-02_14-19-59.png

调用 block 时堆栈信息: Xnip2022-08-02_14-21-03.png

过掉断点发现每执行一个 OC方法/C函数/block都会触发这个函数。

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

原理

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

Xnip2022-08-02_14-27-08.png

  • 通过观察发现在touchesBegan:的汇编实现中先调用了__sanitizer_cov_trace_pc_guard函数,然后在testCFunc函数和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是一个结构体,用来存储函数相关信息:

    typedef struct dl_info {
        const char      *dli_fname;  // 当前项目路径
        void            *dli_fbase;  // 是`MachO`文件的起始位置
        const char      *dli_sname;  // 符号名字(方法名)
        void            *dli_saddr;  // 函数地址
    } Dl_info;
    
  • dladdr作用是将获取函数的信息存入Dl_info结构体中

  • 验证结果如下: Xnip2022-08-02_15-01-10.png

  • 如此这般 便可以用dli_sname直接输出符号名字,就得到了所有方法的调用顺序 Xnip2022-08-02_15-11-07.png

坑点分析及解决方案

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

Xnip2022-08-02_15-40-34.png

可以看出testMethod方法的调用是在子线程

在子线程调用的方法,__sanitizer_cov_trace_pc_guard也在子线程,这时就产生了多线程访问,可能会出现获取 方法信息 顺序错乱的问题。这个可以使用OSAtomicEnqueue() OSAtomicDequeue()来线程安全,使获取方法信息顺序不出错

  • OSAtomicEnqueue()OSAtomicDequeue() 使用

    • 先导入头文件#import <libkern/OSAtomic.h>
    • 再将Other C Flag标识符改成:-fsanitize-coverage=func,trace-pc-guard,里面添加了func是仅限函数使用!!!
    • 因为-fsanitize-coverage=trace-pc-guard 标识,默认循环也会调用 __sanitizer_cov_trace_pc_guard,核心代码如下:
    // 定义一个原子队列
    static OSQueueHead symbolList = 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 *node = malloc(sizeof(Node)); 
        // 将PC赋值给node的pc
        *node = (Node){PC, NULL};  
        // 在队列中将新的node节点存入上个节点的next位置
        OSAtomicEnqueue(&symbolList, node, offsetof(Node, next)); 
    }
    
    - (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
        // 上面 `Other C Flag`标识符改成:`-fsanitize-coverage=func,trace-pc-guard` 
        // 就是为了避免这里循环,一直调用 __sanitizer_cov_trace_pc_guard
        while (YES) {
            Node *node = OSAtomicDequeue(&symbolList, 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);
        }
    }
    
  • 此时就可以保证线程安全,按真实的顺序取到启动符号了 Xnip2022-08-02_16-26-13.png

生成Order文件并配置

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

- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
    // 上面 `Other C Flag`标识符改成:`-fsanitize-coverage=func,trace-pc-guard` 
    // 就是为了避免这里循环,一直调用 __sanitizer_cov_trace_pc_guard 
    
    NSMutableArray<NSString *> *symbolNameArray = [NSMutableArray array];
    while (YES) {
        Node *node = OSAtomicDequeue(&symbolList, 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字符串
        // 是否是 OC 方法
        BOOL isOcFunc = [funcName hasPrefix:@"-["] || [funcName hasPrefix:@"+["];
        // 生成方法名字
        funcName = isOcFunc ? funcName : [@"_" stringByAppendingString:funcName];
        // 去重
        if (![symbolNameArray containsObject:funcName]) {
            [symbolNameArray insertObject:funcName atIndex:0];
        }
    }

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

    // 写入文件
    NSString *funcString = [symbolNameArray componentsJoinedByString:@"\n"];
    NSLog(@"\nfuncString: \n%@", funcString);
    NSString *filePath = [NSTemporaryDirectory() stringByAppendingPathComponent:@"launchopt.order"];
    NSData *fileData = [funcString dataUsingEncoding:NSUTF8StringEncoding];
    BOOL isSuccess = [[NSFileManager defaultManager] createFileAtPath:filePath contents:fileData attributes:nil];
    if (isSuccess) {
        NSLog(@"写入成功  ✅");
    } else {
        NSLog(@"写入失败 ❌");
    }
}
  • 运行后得到的方法顺序为 Xnip2022-08-02_17-00-56.png

  • Window->Devices and Simulators选中自己的手机,然后选择当前的LaunchOptimization,再Download Container下载到桌面 Xnip2022-08-02_17-05-31.png

    • 在右键显示包内容,在AppData/tmp 里找到launchopt.order文件,将文件放到工程根目录
    • 然后在Build SettingOrder File配置好路径,
    • 然后将Write Link Map File设置为YES,最后删除Other C Flag内容,运行后找到txt文件并打开 Xnip2022-08-02_17-20-17.png

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

Swift符号获取

首先添加 Swift 代码如下:

class SwiftTest: NSObject {
    @objc class public func swiftTest(){
        print("Swift Test ...")
    }
}

viewDidLoad 中调用:

- (void)viewDidLoad {
    [super viewDidLoad];
    [SwiftTest swiftTest];
}

获取Swift的符号需要在Build Setting->Other Swift Flag处配置-sanitize-coverage=func,与-sanitize=undefined,如下图:

Xnip2022-08-02_20-46-16.png

运行项目,输出:

Xnip2022-08-02_20-58-52.png