iOS启动优化之二进制重排

作者: 张聪_2048 | 来源:发表于2021-04-23 08:59 被阅读0次

    一、二进制重排介绍

    1、App启动

    进程如果能直接访问物理内存无疑是很不安全的,所以操作系统在物理内存的上又建立了一层虚拟内存。苹果在这个基础上还有 ASLR(Address Space Layout Randomization) 技术的保护,不过不是这次的重点。

    iOS系统中虚拟内存到物理内存的映射都是以页为最小单位的。当进程访问一个虚拟内存Page而对应的物理内存却不存在时,就会出现Page Fault缺页中断,然后加载这一页。虽然本身这个处理速度是很快的,但是在一个App的启动过程中可能出现上千(甚至更多)次Page Fault,这个时间积累起来会比较明显了

    另外,还有两个重要的概念:冷启动热启动。可能有些同学认为杀掉再重启App就是冷启动了,其实是不对的。

    • 冷启动:程序完全退出,之间加载的分页数据被其他进程所使用覆盖之后,或者重启设备、第一次安装,才算是冷启动。
    • 热启动:程序杀掉之后,马上又重新启动。这个时候相应的物理内存中仍然保留之前加载过的分页数据,可以进行重用,不需要全部重新加载。所以热启动的速度比较快。

    2、二进制重排原理

    编译器在生成二进制代码的时候,默认按照链接的Object File(.o)顺序写文件,按照Object File内部的函数顺序写函数。

    静态库文件.a就是一组.o文件的ar包,可以用ar -t查看.a包含的所有.o。

    简化问题:假设我们只有两个page:page1/page2,其中绿色的method1和method5启动时候需要调用,为了执行对应的代码,系统必须进行两个Page Fault。

    但如果我们把method1和method5排布到一起,那么只需要一个Page Fault即可,这就是二进制文件重排的核心原理。

    图1:二进制重排原理.png

    实际项目中的做法是将启动时需要调用的函数放到一起 ( 比如 前10页中 ) 以尽可能减少 page fault , 达到优化目的 . 而这个做法就叫做 : 二进制重排 。

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

    二、重排的order文件

    1、文件顺序

    Build PhasesCompile Sources 列表顺序决定了文件执行的顺序(可以调整)。如果不进行重排,文件的顺序决定了方法、函数的执行顺序。我们在 ViewControllerAppDelegate 中加入以下代码:

    + (void)load {
        NSLog(@"%s", __FUNCTION__);
    }
    

    我们调整 Compile Sources 中这两个类的顺序,然后分别执行对比。可以看到,随着 Compile Sources 中的文件顺序的修改,+load 方法的执行顺序也发生了改变。

    图2:文件执行顺序.png

    2、符号表顺序

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

    • Object Files 生成二进制用到的link单元的路径和文件编号
    • Sections 记录Mach-O每个Segment/section的地址范围
    • Symbols 按顺序记录每个符号的地址范围
    1)Build Settings中修改Write Link Map FileYES
    2)查找Link Map符号表txt文件

    编译后会生成一个Link Map符号表txt文件,选择Product中的App,在Finder中打开,选择Intermediates.noindex文件夹,找到LinkMap文件,这里是ZJHBinaryLaunchDemo-LinkMap-normal-x86_64.txt。。详细路径请看下图。

    图3:查找符号表文件.png
    3)查看Link Map符号表txt文件

    打开文件之后来到第一部分的最后。我们可以看到这个顺序和我们Compile Sources中的顺序是一致的。接下来的部分:

    图4:查看符号表文件.png

    可以看到,整体的顺序和Compile Sources的中的顺序是一样的,并且方法是按照文件中方法的顺序进行链接的。ViewController中的方法添加完后,才是AppDelegate中的方法,以此类推。

    • Address� 表示文件中方法的地址。
    • Size 表示方法的大小。
    • File 表示在第几个文件中。
    • Name 表示方法名。

    3、导入order文件

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

    来到工程根目录 , 新建一个文件 touch ZJHBinaryLaunchDemo.order . 随便挑选几个启动时就需要加载的方法 , 例如我这里选了以下几个 .

    +[AppDelegate load]
    +[ViewController load]
    _main
    -[ViewController someMethod]
    

    然后在Build Settings中找到Order File,填入./ZJHBinaryLaunchDemo.order。然后重新比纳音,再次查看生成符号表txt文件。

    图5:导入order文件.png

    可以看到Link Map中的最上面几个方法和我们在ZJHBinaryLaunchDemo.order文件中设置的方法顺序一致!

    Xcode的连接器ld还忽略掉了不存在的方法 -[ViewController someMethod]。如果提供了link选项 -order_file_statistics,会以warning的形式把这些没找到的符号打印在日志里。

    注意:有部分同学可能配置完运行会发现报错说can't open 这个 order file . 是因为文件格式的问题 . 不用使用 mac 自带的文本编辑 . 使用命令工具 touch 创建即可 .

    三、检测启动时方法

    要真正的实现二进制重排,我们需要拿到启动时所有用到的方法、函数等符号,并保存其顺序,然后写入order文件,实现二进制重排。这里我们使用Clang插桩的方式

    1、Clang插桩原理

    其实就是一个代码覆盖工具,更多信息可以查看官网

    1)首先 , 添加编译设置

    Build SettingsOther C Flags添加配置

    -fsanitize-coverage=trace-pc-guard
    

    编译的话会报以下错误,意思是找不到这两个函数

    Undefined symbol: ___sanitizer_cov_trace_pc_guard_init
    Undefined symbol: ___sanitizer_cov_trace_pc_guard
    
    2)添加 hook 代码

    我们把面的代码,添加到 ViewController.m

    #include <stdint.h>
    #include <stdio.h>
    #include <sanitizer/coverage_interface.h>
    
    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.
    }
    
    void __sanitizer_cov_trace_pc_guard(uint32_t *guard) {
      if (!*guard) return;  // Duplicate the guard check.
    //  void *PC = __builtin_return_address(0);
      char PcDescr[1024];
    //  __sanitizer_symbolize_pc(PC, "%p %F %L", PcDescr, sizeof(PcDescr));
      printf("guard: %p %x PC %s\n", guard, *guard, PcDescr);
    }
    
    
    3)运行工程 , 查看打印
    INIT: 0x1024153e0 0x102415420
    guard: 0x1024153f8 7 PC 
    guard: 0x1024153ec 4 PC �
    guard: 0x102415414 e PC 
    guard: 0x102415418 f PC �
    guard: 0x102415414 e PC 
    guard: 0x102415414 e PC 
    guard: 0x1024153fc 8 PC $
    guard: 0x102415414 e PC 
    guard: 0x102415414 e PC �
    guard: 0x102415414 e PC \300\202\3605�
    

    代码命名 INIT 后面打印的两个指针地址叫 startstop . 那么我们通过 lldb 来查看下从 startstop 这个内存地址里面所存储的到底是啥 .

    4)验证 startstop 内存地址存储值

    viewDidLoad方法中添加断点,执行项目。在lldb分别输入 x start地址x stop地址-0x4,读取内存地址。 x stop地址-0x4 是结束位置,按显示是4位的,所以向前移动4位,打印出来的应该就是最后一位。

    图6:验证start到stop内存地址存储值.png

    发现存储的是从 116(0x10) 这个序号 ,我们再新增两个方法,再次运行查看:

    INIT: 0x102fb93f0 0x102fb9438
    
    (lldb) x 0x102fb9438-0x4
    0x102fb9434: 12 00 00 00 f0 92 0c 03 01 00 00 00 00 00 00 00  ................
    0x102fb9444: 00 00 00 00 5f 33 fb 02 01 00 00 00 00 00 00 00  ...._3..........
    (lldb) 
    

    发现从 0x10 变成了 0x12 . 也就是说存储的 116 这个序号变成了 118 。那么我们得到一个猜想 , 这个内存区间保存的就是工程所有符号的个数 。

    5)验证guard调用次数

    touchesBegan 方法中,打印语句,点击屏幕。每点击一次就会调用一次 guard :。而且 guard :是在前面调用的。

    - (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
        NSLog(@"touchesBegan");
    }
    
    // 控制台的输出
    guard: 0x1006053fc 4 PC �
    2021-04-21 13:29:51.936925+0800 ZJHBinaryLaunchDemo[13644:5077278] touchesBegan
    
    

    touchesBegan 方法中,执行调用test1方法,会发现 guard :调用了两次

    - (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
        NSLog(@"touchesBegan");
        [self test1];
    }
    
    - (void)test1 {
        NSLog(@"test1");
    }
    
    // 控制台的输出
    guard: 0x102d893f8 3 PC �
    2021-04-21 13:31:57.152720+0800 ZJHBinaryLaunchDemo[13646:5077923] touchesBegan
    guard: 0x102d893fc 4 PC d�\330�\201\226P\314\370\223\330��
    2021-04-21 13:31:57.152915+0800 ZJHBinaryLaunchDemo[13646:5077923] test1
    

    由此,发现我们实际调用几个方法 , 就会打印几次 guard :

    6)查看汇编代码验证

    我们在 touchesBegan:touches withEvent: 开头设置一个断点,并开启汇编显示(菜单栏Debug → Debug Workflow → Always Show Disassembly)。

    图7:查看汇编代码验证.png

    通过汇编我们发现 , 在每个函数调用的第一句实际代码 ( 栈平衡与寄存器数据准备除外 ) , 被添加进去了一个 bl 调用到 __sanitizer_cov_trace_pc_guard 这个函数中来 。而实际上这也是静态插桩的原理和名称由来 。

    静态插桩总结:静态插桩实际上是在编译期就在每一个函数内部二进制源数据添加 hook 代码 ( 我们添加的 __sanitizer_cov_trace_pc_guard 函数 ) 来实现全局的方法 hook 的效果 .

    2、获取所有函数符号

    1)获取原函数地址

    我们在 __sanitizer_cov_trace_pc_guard 函数中的这一句代码 :

    void *PC = __builtin_return_address(0); 
    

    它的作用其实就是去读取 x30 中所存储的要返回时下一条指令的地址 . 所以他名称叫做 __builtin_return_address . 换句话说 , 这个地址就是我当前这个函数执行完毕后 , 要返回到哪里去 。也就是说 , 我们现在可以在 __sanitizer_cov_trace_pc_guard 这个函数中 , 通过 __builtin_return_address 数拿到原函数调用 __sanitizer_cov_trace_pc_guard 这句汇编代码的下一条指令的地址 。及上图中,拿到-[ViewController touchesBegan:withEvent:] 地址

    2)根据内存地址获取函数名称

    拿到了函数内部一行代码的地址 , 如何获取函数名称呢 ? 熟悉安全攻防 , 逆向的同学可能会清楚 . 我们为了防止某些特定的方法被别人使用 fishhook hook 掉 , 会利用 dlopen 打开动态库 , 拿到一个句柄 , 进而拿到函数的内存地址直接调用 .是不是跟我们这个流程有点相似 , 只是我们好像是反过来的 . 其实反过来也是可以的 .

    dlopen 相同 , 在 dlfcn.h 中有一个方法如下 :

    typedef struct dl_info {
            const char      *dli_fname;     /* 所在文件 */
            void            *dli_fbase;     /* 文件地址 */
            const char      *dli_sname;     /* 符号名称 */
            void            *dli_saddr;     /* 函数起始地址 */
    } Dl_info;
    
    //这个函数能通过函数内部地址找到函数符号
    int dladdr(const void *, Dl_info *);
    

    紧接着我们来实验一下 , 先导入头文件#import <dlfcn.h> , 然后修改代码如下 :

    void __sanitizer_cov_trace_pc_guard(uint32_t *guard) {
        if (!*guard) return;  // Duplicate the guard check.
    
        void *PC = __builtin_return_address(0);
        Dl_info info;
        dladdr(PC, &info);
    
        printf("fname=%s \nfbase=%p \nsname=%s\nsaddr=%p \n",info.dli_fname,info.dli_fbase,info.dli_sname,info.dli_saddr);
    
        char PcDescr[1024];
        printf("guard: %p %x PC %s\n", guard, *guard, PcDescr);
    }
    

    查看打印结果,可以看到我们要找的符号了 :

    图8:根据内存地址获取函数名称.png

    3、可能遇到的问题

    1)多线程问题

    项目各个方法肯定有可能会在不同的函数执行 , 因此 __sanitizer_cov_trace_pc_guard 这个函数也有可能受多线程影响 , 所以你当然不可能简简单单用一个数组来接收所有的符号就搞定了。考虑到这个方法会来特别多次 , 使用锁会影响性能 , 这里使用苹果底层的原子队列 ( 底层实际上是个栈结构 , 利用队列结构 + 原子性来保证顺序 ) 来实现 .

    - (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event{
        //遍历出队
        while (true) {
            //offsetof 就是针对某个结构体找到某个属性相对这个结构体的偏移量
            SymbolNode * node = OSAtomicDequeue(&symboList, offsetof(SymbolNode, next));
            if (node == NULL) break;
            Dl_info info;
            dladdr(node->pc, &info);
    
            printf("%s \n",info.dli_sname);
        }
    }
    //原子队列
    static OSQueueHead symboList = OS_ATOMIC_QUEUE_INIT;
    //定义符号结构体
    typedef struct{
        void * pc;
        void * next;
    }SymbolNode;
    
    void __sanitizer_cov_trace_pc_guard(uint32_t *guard) {
        if (!*guard) return;  // Duplicate the guard check.
        void *PC = __builtin_return_address(0);
        SymbolNode * node = malloc(sizeof(SymbolNode));
        *node = (SymbolNode){PC,NULL};
    
        //入队
        // offsetof 用在这里是为了入队添加下一个节点找到 前一个节点next指针的位置
        OSAtomicEnqueue(&symboList, node, offsetof(SymbolNode, next));
    }
    

    但此方法会导致死循环的问题

    2)死循环问题

    通过汇编会查看到 一个带有 while 循环的方法 , 会被静态加入多次 __sanitizer_cov_trace_pc_guard 调用 , 导致死循环.

    Other C Flags 修改为如下 :

    -fsanitize-coverage=func,trace-pc-guard
    

    代表进针对 func 进行 hook . 再次运行就可以了。

    3) load 方法不打印问题

    load 方法时 , __sanitizer_cov_trace_pc_guard 函数的参数 guard 是 0。上述打印并没有发现 load .

    解决 : 屏蔽掉 __sanitizer_cov_trace_pc_guard 函数中的

    if (!*guard) return;
    

    效果如下

    void __sanitizer_cov_trace_pc_guard(uint32_t *guard) {
    //    if (!*guard) return;  // Duplicate the guard check.
        void *PC = __builtin_return_address(0);
        SymbolNode * node = malloc(sizeof(SymbolNode));
        *node = (SymbolNode){PC,NULL};
    
        //入队
        // offsetof 用在这里是为了入队添加下一个节点找到 前一个节点next指针的位置
        OSAtomicEnqueue(&symboList, node, offsetof(SymbolNode, next));
    }
    
    // 打印结果
    INIT: 0x104d8d400 0x104d8d444
    -[ViewController touchesBegan:withEvent:] 
    -[SceneDelegate sceneDidBecomeActive:] 
    -[SceneDelegate sceneWillEnterForeground:] 
    -[ViewController viewDidLoad] 
    -[SceneDelegate window] 
    -[SceneDelegate window] 
    -[SceneDelegate window] 
    -[SceneDelegate scene:willConnectToSession:options:] 
    -[SceneDelegate window] 
    -[SceneDelegate window] 
    -[SceneDelegate setWindow:] 
    -[SceneDelegate window] 
    -[AppDelegate application:didFinishLaunchingWithOptions:] 
    main 
    +[AppDelegate load] 
    +[ViewController load] 
    

    这样的话,load 方法就有了。这里也为我们提供了一点启示:如果我们希望从某个函数之后/之前开始优化 , 通过一个全局静态变量 , 在特定的时机修改其值 , 在 __sanitizer_cov_trace_pc_guard 这个函数中做好对应的处理即可 .

    4、符号信息写入文件

    完整代码如下 :

    #import "ViewController.h"
    #import <dlfcn.h>
    #import <libkern/OSAtomic.h>
    @interface ViewController ()
    @end
    
    @implementation ViewController
    + (void)load{
    
    }
    - (void)viewDidLoad {
        [super viewDidLoad];
        testCFunc();
        [self testOCFunc];
    }
    - (void)testOCFunc{
        NSLog(@"oc函数");
    }
    void testCFunc(){
        LBBlock();
    }
    void(^LBBlock)(void) = ^(void){
        NSLog(@"block");
    };
    
    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.
    }
    - (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event{
        NSMutableArray<NSString *> * symbolNames = [NSMutableArray array];
        while (true) {
            //offsetof 就是针对某个结构体找到某个属性相对这个结构体的偏移量
            SymbolNode * node = OSAtomicDequeue(&symboList, offsetof(SymbolNode, next));
            if (node == NULL) break;
            Dl_info info;
            dladdr(node->pc, &info);
    
            NSString * name = @(info.dli_sname);
    
            // 添加 _
            BOOL isObjc = [name hasPrefix:@"+["] || [name hasPrefix:@"-["];
            NSString * symbolName = isObjc ? name : [@"_" stringByAppendingString:name];
    
            //去重
            if (![symbolNames containsObject:symbolName]) {
                [symbolNames addObject:symbolName];
            }
        }
    
        //取反
        NSArray * symbolAry = [[symbolNames reverseObjectEnumerator] allObjects];
        NSLog(@"%@",symbolAry);
    
        //将结果写入到文件
        NSString * funcString = [symbolAry componentsJoinedByString:@"\n"];
        NSString * filePath = [NSTemporaryDirectory() stringByAppendingPathComponent:@"lb.order"];
        NSData * fileContents = [funcString dataUsingEncoding:NSUTF8StringEncoding];
        BOOL result = [[NSFileManager defaultManager] createFileAtPath:filePath contents:fileContents attributes:nil];
        if (result) {
            NSLog(@"%@",filePath);
        }else{
            NSLog(@"文件写入出错");
        }
    
    }
    //原子队列
    static OSQueueHead symboList = OS_ATOMIC_QUEUE_INIT;
    //定义符号结构体
    typedef struct{
        void * pc;
        void * next;
    }SymbolNode;
    
    void __sanitizer_cov_trace_pc_guard(uint32_t *guard) {
        //if (!*guard) return;  // Duplicate the guard check.
    
        void *PC = __builtin_return_address(0);
    
        SymbolNode * node = malloc(sizeof(SymbolNode));
        *node = (SymbolNode){PC,NULL};
    
        //入队
        // offsetof 用在这里是为了入队添加下一个节点找到 前一个节点next指针的位置
        OSAtomicEnqueue(&symboList, node, offsetof(SymbolNode, next));
    }
    @end
    

    文件写入到了 tmp 路径下 , 运行 , 打开手机下载查看 :

    图9:符号信息写入文件.png

    5、swift 工程 / 混编工程问题

    通过如上方式适合纯 OC 工程获取符号方式 .由于 swift 的编译器前端是自己的 swift 编译前端程序 , 因此配置稍有不同 .

    搜索 Other Swift Flags , 添加两条配置即可 :

    • -sanitize-coverage=func
    • -sanitize=undefined

    swift 类通过上述方法同样可以获取符号 .

    四、验证

    1、打印启动时间

    在系统执行应用程序的main函数并调用应用程序委托函数(applicationWillFinishLaunching)之前,会发生很多事情。我们可以通过添加环境变量可以打印处APP的启动分析(Edit scheme -> Run -> Argument).
    DYLD_PRINT_STATISTICS设置为1(dyld_print_statistics)。如果需要更详细的信息,那就将DYLD_PRINT_STATISTICS_DETAILS设置为1

    运行一下,对比控制台的输出(因笔者是在demo验证,时间优化的效果不明显,这里就不做对比展示了):

    Total pre-main time:  97.73 milliseconds (100.0%)
             dylib loading time:  28.02 milliseconds (28.6%)
            rebase/binding time:  21.70 milliseconds (22.2%)
                ObjC setup time:   5.16 milliseconds (5.2%)
               initializer time:  42.85 milliseconds (43.8%)
               slowest intializers :
                 libSystem.B.dylib :   6.26 milliseconds (6.4%)
       libBacktraceRecording.dylib :   9.88 milliseconds (10.1%)
        libMainThreadChecker.dylib :  22.10 milliseconds (22.6%)
               ZJHBinaryLaunchDemo :   2.81 milliseconds (2.8%)
    

    2、System Trace

    日常开发中性能分析是用最多的工具无疑是Time Profiler,但Time Profiler是基于采样的,并且只能统计线程实际在运行的时间,而发生Page Fault的时候线程是被blocked,所以我们需要用一个不常用但功能却很强大的工具:System Trace。

    选中主线程,在VM Activity中的File Backed Page In次数就是Page Fault次数,并且双击还能按时序看到引起Page Fault的堆栈:

    图10:System Trace.jpeg

    五、CocoaPods相关

    对于 cocoapod 工程引入的库 , 由于针对不同的 target . 那么我们在主程序中的 target 添加的编译设置 Write Link Map File , -fsanitize-coverage=func,trace-pc-guard 以及 order file 等设置肯定是不会生效的 . 解决方法就是针对需要的 target 去做对应的设置即可 .

    对于直接手动导入到工程里的 sdk , 不管是 静态库 .a 还是 动态库 , 默认主工程的设置就可以了 , 是可以拿到符号的 .

    更多CocoaPods相关问题,可参考这篇文章:https://juejin.cn/post/6844904192193085448

    参考链接:

    iOS 启动优化之Clang插桩实现二进制重排
    我是如何让微博绿洲的启动速度提升30%的
    懒人版二进制重排
    抖音研发实践:基于二进制文件重排的解决方案 APP启动速度提升超15%
    懒人版二进制重排
    手淘架构组最新实践 | iOS基于静态库插桩的⼆进制重排启动优化

    相关文章

      网友评论

        本文标题:iOS启动优化之二进制重排

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