iOS启动优化之二进制重排

一、二进制重排介绍

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即可,这就是二进制文件重排的核心原理。

iOS启动优化之二进制重排_第1张图片
图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 
#include 
#include 

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 *)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 *)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 , 然后修改代码如下 :

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 *)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 
#import 
@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 *)touches withEvent:(UIEvent *)event{
    NSMutableArray * 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 路径下 , 运行 , 打开手机下载查看 :

iOS启动优化之二进制重排_第2张图片
图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的堆栈:

iOS启动优化之二进制重排_第3张图片
图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启动优化之二进制重排)