前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >启动优化(二)——二进制重排

启动优化(二)——二进制重排

作者头像
拉维
发布2021-04-16 16:42:05
1.6K0
发布2021-04-16 16:42:05
举报
文章被收录于专栏:iOS小生活iOS小生活

上篇文章我们讲了虚拟内存。应用程序在运行的时候会有一个虚拟内存,虚拟内存是分页管理的,它通过页表映射到物理内存上面。分页管理有一个特点,当加载新的一块功能的时候,对应的某一页数据不在物理内存的时候,系统会缺页中断pageFault,而pageFault是需要时间的,用户在使用过程中,几毫秒实际上用户是感知不到的;但是在应用启动的时候,会有大量代码需要执行,此时会有数量众多的pageFault,这样一累计,用户就可以感知到了。

今天要研究的,就是通过一项技术来减少启动时的pageFault,进而缩减启动时间,这个技术就是二进制重排。

二进制重排这项技术为大众所熟知最初是源于抖音团队的这篇文章:

代码语言:javascript
复制
https://mp.weixin.qq.com/s/Drmmx5JtjG3UtTFksL6Q8Q

大家有兴趣的话可以去看一下。

想要优化,首先要学会调试。接下来我们就来看看如何去获取pageFault的次数。

测量PageFault次数

打开Instruments工具集,找到System Trace:

打开之后,依次选择好设备和要执行的APP,然后点击左上角的红点启动:

然后就开始分析,分析完成之后,找到对应APP下面的main thread,然后查看虚拟内存VertialMemary,File Backed Page In对应的Count就是启动期间的pageFault次数:

可以看到,第一次启动的时候的缺页中断次数是2433。

现在我在模拟器中杀掉TestApp,然后立马再执行SystemTrace,结果如下:

此时的缺页中断次数是49,跟第一次的2433相比,可谓是差了不止一个数量级。这是为什么呢?在我的印象中,App被杀死之后再启动就是冷启动了呀,同样是冷启动,为什么前后两次相差这么多呢?

实际上,当App被杀死之后,有可能它并不会立马从物理内存中被移除,这些都是由系统来做的,我只能说是不一定会立马被从物理内存中移除。要想完完整整地去测试冷启动的缺页中断次数的话,可以在杀死app之后再打开几个其他的APP,然后再过个一两分钟之后,再启动这个APP的话,就应该是冷启动了。

二进制重排步骤初体验

上面我们了解了如何去测量启动阶段pageFault的次数,接下来就来初步体验一下二进制重排。

实际上,二进制重排的步骤并不复杂,真正的难点在于如何按照函数的执行顺序去重新排列页表中的page。

Xcode使用的链接器是ld,ld中有一个参数是order file,order file是一个文件路径,它指向了order文件,order文件中写入的是符号的顺序,Xcode在编译打包的时候就会生成按照order文件中的符号顺序排列的可执行文件。

之前不是玩过objc源码么,在objc源码文件夹下有一个libobjc.order文件:

这个libobjc.order文件就是我上面说的记录符号加载顺序的order文件,这里面记录的全部都是函数或者方法的符号。

接下来使用Xcode打开苹果官方objc的Demo,然后在Build Settings中找到Order file:

这里的路径就是我上面说的libobjc.order文件的路径。一旦指定了这个路径,那么编译出来的二进制文件中的符号就是按照路径中order文件的符号顺序来进行排列的了。

这说明苹果官方本身就支持二进制重排这门技术,而且他们自己的开发者也在使用这门技术,只不过我们ios开发者平时不怎么使用这门技术而已。接下来我们就来看看如何使用。

查看可执行文件中的符号顺序

首先,我们来看一下如何查看二进制可执行文件中的符号顺序。

在machO可执行文件的代码段,各个函数依次排列在里面,那么这里面函数的排列顺序是如何查看呢?

如上图所示,我当前这个工程里面的所有的源文件都是记录在Compile Sources里面。每一个源文件在编译的时候都会生成一个目标文件(.o),然后将所有的.o以及静态库等链接成一个MachO,这个链接的顺序就是按照Compile Sources里面的顺序来的,而这里的顺序是可以手动拖动的。

所以说,文件的顺序就确定了

那么如何查看整个项目中的符号顺序呢?

在Xcode中将Write Link Map File设置为YES,这表示要求给写一个链接符号表。

然后编译。

编译成功之后,对可执行文件show in finder:

然后鼠标点到红框内,按照如下顺序查找,就可以找到对应的LinkMap:

双击打开Test-LinkMap-normal-x86_64.txt:

首先会有一个Object files(红框内),这里面记录的是链接了哪些文件,这里面的文件顺序就是Compile Sources里面的顺序。

紧接着Object files下面是Sections:

Sections里面记录的是MachO二进制可执行文件里面段的一些数据,Segment这一列表示是哪一段。

Sections下面就是Symbols符号了:

可以看到,Symbles里面的数据有四列:Address、Size、File、Name。

  • Name指的是方法名或者函数名
  • File指的是在哪一个文件当中,这里面的数字给最上方Object files里面的数字是对应的
  • Size指的是这个方法或者函数占用的空间大小,函数里面的内容多少不一样,其Size也是不一样的,100行代码的方法肯定比1行代码的方法的Size要大。
  • Address指的是这个方法或者函数的真实的地址,不是这个方法对应的符号地址(符号地址就是存储在MachO文件的Data段中的符号)。我们做二进制重排,实际上就是将相关代码的所有内容放到前面去,而不仅仅是简简单单将符号放到前面。

自定义Order文件

接下来我们来玩一下,首先分别在ViewController和AppDelegate这两个文件中复写一下load方法,然后Clean一下工程再编译,然后查看LinkMap:

我将+[ViewController load]、+[AppDelegate load]和_main都画了红框,大家可以清晰地看到其在MachO中的排列顺序。

接下来我重排一下。

cd到工程目录下,终端执行如下指令,新建一个order文件:

代码语言:javascript
复制
touch norman.order

然后在工程目录下就会新增一个norman.order空文件:

打开该文件,我们写入各符号的排列顺序:

然后保存,并且设置工程的Order File的路径:

注意,./ 表示的是工程的根目录。

然后Clean并且重新编译,然后查看LinkMap:

此时,MachO文件中的方法或者函数的顺序,就是我在norman.order文件中设置的顺序!!!

也许你会问,万一norman.order文件中有的符号在MachO文件中没有怎么办?没关系,如果order文件中有的符号在MachO文件中没有,那么在编译的时候会直接忽略掉没有的符号,并且不会报错

这就是二进制重排的基本步骤,是不是很简单!

实际上,二进制重排并不难,一个Order文件外加一个配置就搞定,真正的难点在于去找到启动时刻的符号,也就是说,你需要知道要将哪些符号排列到前面去。

Hook一切的终极武器——Clang插桩

上面说到,二进制重排最难最核心的一点就是如何去拿到启动阶段的各个符号。

现在大家考虑一个问题,如何去HookOC中所有方法的调用呢?

所有的OC方法最终都会调用objc_msgSend函数,所以我只要能够Hook住objc_msgSend函数,也就相当于Hook住了所有的OC方法。

我在fishhook详解中讲过,通过fishhook可以hook住所有的系统动态库中的函数,所以我们可以通过fishhook来hook住objc_msgSend函数。然后取出objc_msgSend函数中的第二个参数SEL并保存,也就拿到了所有的OC方法。

但是objc_msgSend函数的参数是可变参数,那么如何拿到第二个之后的参数呢?需要通过寄存器去拿,此时就需要写汇编代码。但是实际上,好多人对汇编是不了解的,所以通过fishhook来hook住objc_msgSend函数,进而Hook住所有的符号,这条路没有必要去死磕,因为它比较深。

那如果不死磕fishhook这条路,还有什么其他的路可以Hook住所有的符号呢?答案就是Clang插桩。

插桩的相关文档如下:

代码语言:javascript
复制
https://clang.llvm.org/docs/SanitizerCoverage.html

由此可见,插桩是Clang自带的工具,它可以实现所有符号的Hook。

接下来我们玩一下。

按照官方文档,首先配置一下编译器的参数-fsanitize-coverage=trace-pc-guard:

这里需要注意⚠️,不要完全按照官方文档来,配置信息要按照如下来配置

代码语言:javascript
复制
-fsanitize-coverage=func,trace-pc-guard

也就是说,只hook func。不然的话,while循环的时候会有问题,因为每一次while循环也都会被监控到。而配置了coverage=func之后,就只会监控到func(方法、函数、block)了

配置完成之后编译:

报错了!!报错信息是:

Undefined symbol: ___sanitizer_cov_trace_pc_guard_init

Undefined symbol: ___sanitizer_cov_trace_pc_guard

那么___sanitizer_cov_trace_pc_guard_init和___sanitizer_cov_trace_pc_guard是什么东西呢?我们接着看官方文档:

在官方文档的Example中轻而易举找到了___sanitizer_cov_trace_pc_guard_init和___sanitizer_cov_trace_pc_guard。

那么我就照葫芦画瓢,将这两个函数拷贝到我的工程中:

此时再编译就可以编译成功了。

编译成功之后我们就来研究下这两个函数,首先来看一下__sanitizer_cov_trace_pc_guard_init函数:

代码语言:javascript
复制
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.
}

可以看到该函数中有一个start和一个stop,它们分别是某一段内存的起始位置和结束位置。

我将结束位置stop往前挪4个字节就可以查看最后一块内存了:

可以看到,第一个字节记录的就是当前加载进内存的符号的个数

接下来我在原工程中再增加几个符号:

我增加了两个方法一个block,最后打印符号个数的时候也正好多了3个,这说明,通过这种方式可以Hook住所有的符号

接下来再来看一下__sanitizer_cov_trace_pc_guard函数:

代码语言:javascript
复制
void __sanitizer_cov_trace_pc_guard(uint32_t *guard) {
  if (!*guard) return;
    
  void *PC = __builtin_return_address(0);
  char PcDescr[1024];
    
  printf("guard: %p %x PC %s\n", guard, *guard, PcDescr);
}

实际上,没一个符号的调用都会走到__sanitizer_cov_trace_pc_guard函数里面来。

我在工程中写入下面代码:

先将断点断到touchBegin,然后查看汇编,如下:

然后断点往下走,走到test,查看汇编如下:

断点再往下走,走到normanBlock,查看汇编:

可以看到,无论是方法还是函数还是block,它们在调用的时候,首先都会调用__sanitizer_cov_trace_pc_guard函数。也就是说,当配置了Clang代码插入工具之后,编译器会在编译的时候在所有的方法、函数、block内部都加入了一条调用__sanitizer_cov_trace_pc_guard函数的汇编代码,这就是所谓的Clang静态插桩,Hook一切

定位符号

现在我们已经Hook到了所有的方法和函数了,那么如何去定位对应的符号呢?如何获取当前Hook的符号的名称呢?

现在来到__sanitizer_cov_trace_pc_guard函数里面:

代码语言:javascript
复制
void __sanitizer_cov_trace_pc_guard(uint32_t *guard) {
  if (!*guard) return;
    
  void *PC = __builtin_return_address(0);
  Dl_info info;
  dladdr(PC, &info);
    
  printf("dli_fname: %s \n dli_fbase: %p \n dli_sname: %s \n dli_saddr: %p \n", info.dli_fname, info.dli_fbase, info.dli_sname, info.dli_saddr);
}

(注意,使用dladdr需要#import <dlfcn.h>)

__sanitizer_cov_trace_pc_guard函数一定是被你所Hook的方法所调起的,在该函数内部,通过相关API可以获得符号的名称等相关信息,打印结果如下:

代码语言:javascript
复制
 dli_fname: /Users/liwei/Library/Developer/CoreSimulator/Devices/F27DFCE8-E495-4713-9ED4-38BD4089D5DD/data/Containers/Bundle/Application/FB0EC220-9146-42F8-A9AB-357422BACBD7/Test.app/Test 
 dli_fbase: 0x10da32000 
 dli_sname: -[ViewController touchesBegan:withEvent:] 
 dli_saddr: 0x10da338d0 

可以看到,dli_fname指的是文件路径,dli_fbase指的是文件地址,dli_sname指的是符号的名称,dli_saddr指的是函数的起始地址

保存符号

现在我们已经拿到符号的名称了(即上面的dli_sname),接下来就看看如何保存这些个符号。

代码语言:javascript
复制
#import <dlfcn.h>
#import <libkern/OSAtomic.h>

// 声明一个原子队列,用于保存符号
static OSQueueHead symbleList = OS_ATOMIC_QUEUE_INIT;

// 定义符号结构体(符号是以该结构体的形态保存)
typedef struct {
    void *pc;
    void *next;
}SymbleNode;

void __sanitizer_cov_trace_pc_guard(uint32_t *guard) {
    // if (!*guard) return; 注意,这里需要注释掉,因为如果是load方法,那么guard就是0.不注释的话就监控不到load方法了。
    /*  精确定位 哪里开始 到哪里结束!  在这里面做判断写条件! */
    
    void *PC = __builtin_return_address(0);
    SymbleNode *node = malloc(sizeof(SymbleNode));
    *node = (SymbleNode){PC, NULL};
    // 入栈(保存)
    OSAtomicEnqueue(&symbleList, node, offsetof(SymbleNode, next));
}
  • 使用原子队列OSQueueHead作为容器来保存符号
  • 自定义一个SymbleNode结构体,符号是以该结构体的形态进行保存的
  • __sanitizer_cov_trace_pc_guard函数中,当该函数是由load方法调起的时候,*guard是0,此时就会直接return。所以为了能够hook住load方法,需要将if (!*guard) return;这行代码给注释掉
  • 通过上面说的这一点,我也有所启发。我可以定义一个全局静态变量来记录是否入栈,在起点函数的时候给该变量设置为YES,在终点函数的时候给该变量设置为NO,然后在__sanitizer_cov_trace_pc_guard函数一开始根据该变量值来决定是否返回,这样的话我就可以进行监控起点和终点的精确定位了

取出符号名称并生成Order文件

现在符号已经保存了,接下来就是将其取出来生成一个order文件:

代码语言:javascript
复制
#import <dlfcn.h>
#import <libkern/OSAtomic.h>
    
    // 记录所有的符号名称
    NSMutableArray <NSString *> * symbolNames = [NSMutableArray array];
    
    // 遍历所有的符号节点
    while (YES) {
        SymbleNode *node = OSAtomicDequeue(&symbleList, offsetof(SymbleNode, next));
        if (node == NULL) {
            break;
        }
        Dl_info info;
        dladdr(node->pc, &info);
        NSString * name = @(info.dli_sname);
        BOOL  isObjc = [name hasPrefix:@"+["] || [name hasPrefix:@"-["]; // 是否是OC方法
        // 函数前面加下划线(这里的函数包括C函数,也包括Swift函数)
        NSString * symbolName = isObjc ? name : [@"_" stringByAppendingString:name];
        [symbolNames addObject:symbolName];
    }
    // 顺序取反
    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:[NSString stringWithFormat:@"%s", __FUNCTION__]];
    // 将数组变成字符串
    NSString * funcStr = [funcs  componentsJoinedByString:@"\n"];
    
    // 写入order文件
    NSString * filePath = [NSTemporaryDirectory() stringByAppendingPathComponent:@"norman.order"];
    NSData * fileContents = [funcStr dataUsingEncoding:NSUTF8StringEncoding];
    [[NSFileManager defaultManager] createFileAtPath:filePath contents:fileContents attributes:nil];
    NSLog(@"%@",filePath); // 直接复制该路径取出里面的norman.order即可

在工程中执行完上述代码之后,就会在对应路径下生成一份order文件:

然后我将order文件拷贝出来,放入到工程的根目录下面:

并且设置工程的Order File的路径:

至此,所有的步骤就都搞完了。

咱先不着急编译工程,先来看一下目前的linkMap:

然后Clean并重新编译,再次查看Link Map:

可以看到,符号已经按照执行的顺序重新排列了。

混编工程配置

在混编工程中,由于有Swift代码,所以还需要对Swift编译器做如下配置

代码语言:javascript
复制
-sanitize-coverage=func -trace-pc-guard

需要注意的是,在优化完毕之后,注意将符号的Hook、定位、保存以及生成Order文件的相关代码给去掉,只需要拿到对应的Order文件,然后放入工程根目录即可。

结语

至此,我们整个启动优化相关的内容就讲完了。

如果你的项目代码比较粗糙,那么严格按照我第一篇文章中的内容去做代码优化的话,启动时间应该能缩短很多。

如果你的项目代码已经十分优雅了,很难再在代码层面优化启动时间了,那么通过二进制重排,你大概还能优化10%左右。

我之前的项目,二进制重排之前大概是1300毫秒,之后是1150毫秒,大概提升了11%。

以上。

本文参与 腾讯云自媒体分享计划,分享自微信公众号。
原始发表:2021-04-02,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 iOS小生活 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体分享计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
相关产品与服务
容器服务
腾讯云容器服务(Tencent Kubernetes Engine, TKE)基于原生 kubernetes 提供以容器为核心的、高度可扩展的高性能容器管理服务,覆盖 Serverless、边缘计算、分布式云等多种业务部署场景,业内首创单个集群兼容多种计算节点的容器资源管理模式。同时产品作为云原生 Finops 领先布道者,主导开源项目Crane,全面助力客户实现资源优化、成本控制。
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档