iOS基于二进制重排的启动优化

参考连接: 抖音研发实践:基于二进制文件重排的解决方案 APP启动速度提高超15%html

1、原理

一、虚拟内存和物理内存

早期计算机没有虚拟地址,一旦加载都会所有加载到内存中,并且进程都是按顺序排列的,这样别的进程只须要把本身的地址加一些就能访问到别的进程这样就很不安全node

如今软件发展的比硬件快,软件占用的内存愈来愈大,这就致使计算机的内存不够用,当开启多个软件时候,若是内存不够用就只能等待,只有等前面的软件关掉后才能加载打开,这就是早期计算机有时候为啥只有把前面的软件关掉才能打开新软件的缘由;算法

并且用户使用软件时候并非使用到所有内存,只会使用到一部分,若是软件一打开就把软件所有加载到内存中,这样会很浪费内存空间数组

基于上面缘由虚拟内存技术出现了,软件打开后,软件本身觉得有一大片内存空间,但其实是虚拟的,而虚拟内存和物理内存是经过一张表来关联的,咱们能够看下下面两张表缓存

进程1运行时候会开辟一块内存空间,但访问到内存条的时候并非这块内存空间,并且经过访问地址经过进程1的映射表映射到不一样的物理内存空间,这个叫地址翻译,这个过程须要CPU和操做系统配合,由于这个映射表是操做系统来管理的,安全

当咱们调试时候发现访问数据的内存地址都是连续的,其实这是一个假象,在这个进程内部能够访问,是由于咱们访问时候会经过该进程的内存映射表去拿到真正的物理内存地址,假如其余进程访问的话,其余进程没有相应的映射表,天然就访问不到真正的物理内存地址,这样就解决了内存安全问题bash

内存使用率问题:app

内存分页管理,映射表不能以字节为单位,是以页为单位,Linux是以4K为一页,iOS是以16K位一页,可是mac系统是4K一页,咱们能够在mac终端输入pageSize,发现返回的是4096iphone

为啥分页后内存就够用呢,由于应用内存是虚拟的,因此当程序启动时候程序会认为本身有不少的内存,咱们看看下图函数

在应用加载时候不会把全部数据放内存中,由于数据是懒加载,当进程访问虚拟地址时候,首先看页表,若是发现该页表数据为0,说明该页面数据未在物理地址上,这个时候系统会阻塞该进程,这个行为就叫作页中断(page Fault),也叫缺页异常,而后将磁盘中对应页面的数据加载到内存中,而后让虚拟内存指向刚加载的物理内存,将数据加载到内存中时候,若是有空的内存空间,就放空的内存空间中,若是没有的话,就会去覆盖其余进程的数据,具体怎么覆盖操做系统有一个算法,这样永远都会保证当前进程的使用,这就是灵活管理内存。

可是这时候有个问题,虚拟内存解决了安全和效率问题,可是出现了另个安全问题,由于虚拟内存在编译连接时候就肯定了,那么黑客很容易经过分析拿到对应的虚拟内存去操做 ,这样就形成全部的代码都很好hook,代码注入,这个时候就出现了新技术ASLR(地址空间随机化),就是进程每次加载的时候都会给一个随机的偏移量,这样就保证每次加载进程时候虚拟内存也在变化,iOS从iOS4就开始了,

二进制重拍:

由于虚拟内存中有个很大问题就是缺页中断,这个操做很耗时间,而且iOS不只仅是将数据加载到内存,还要对这页作签名认证,因此iOS耗时比较长,而且每页耗时有很大差距,0.1ms到0.8毫秒,使用过程当中可能时间段感受不到,可是启动时候会有不少数据要加载,这样就会致使耗时很长,假如咱们启动时候在不一样页面,由于代码在machO的位置不是根据调用瞬间,而是经过文件编译的位置来的,有可能启动时候在运行时候会调用不少次page Fault,那么若是咱们把全部启动时候的代码都放在一页或者两页,这样就很大程度上优化启动速度,这种方法就叫作二进制重拍

进程若是能直接访问物理内存无疑是很不安全的,因此操做系统在物理内存的上又创建了一层虚拟内存。为了提升效率和方便管理,又对虚拟内存和物理内存又进行分页(Page)。当进程访问一个虚拟内存Page而对应的物理内存却不存在时,会触发一次缺页中断(Page Fault),分配物理内存,有须要的话会从磁盘mmap读人数据。

经过App Store渠道分发的App,Page Fault还会进行签名验证,因此一次Page Fault的耗时比想象的要多:

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

静态库文件.a就是一组.o文件的ar包,能够用ar -t查看.a包含的全部.o。

默认布局:

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

但若是咱们把method1和method3排布到一块儿,那么只须要一个Page Fault便可,这就是二进制文件重排的核心原理。

重排以后,咱们的经验是优化一个Page Fault,启动速度提高0.6~0.8ms。

2、实现

一、System Trace调试

首先优化,咱们要先学会调试,只有调试才能发现须要优化的地方,咱们知道内存分虚拟内存和物理内存,而内存是经过分页管理的,当咱们启动的时候调用不少方法,假如这些方法不在同一个page上面,就会形成缺页中断(page fault),而这个操做是要消耗时间的,因此假如启动的方法都在一页上面,就会很大程度上减小启动时间的消耗,这个就须要用到二进制重拍来将启动时候调用的方法放在同一个page上

  • 首先咱们打开项目command + i打开Instruments调试工具

  • 选择System Trace,这个软件能够看到咱们项目中每一个线程的数据,

  • 点击开始后这里咱们搜索Main thread,选择咱们的app,而后点击Main thread ,再到下面选择Main Thread --> Virtual Memory(虚拟内存)

  • 这里面File Backed Page In就是page fault的次数
  • 当咱们把APP杀死后里面再启动,结果发现File Backed Page In这个值变得很小,说明APP就算杀死后,在启动不是冷启动,仍是有一部数据在系统的缓存中
  • 如何才是真正的冷启动呢,咱们能够把APP杀掉后启动多个手机里面的APP,而后再启动APP,发现File Backed Page In又变得很大
  • 说明虚拟内存是在系统中的,当系统内存不够的时候,其余APP会覆盖老的APP的虚拟内存
  • 二进制重拍是在连接阶段生成的,重排以后生成可执行文件,因此咱们只能在编译阶段来优化,而没法对已生成的ipa进行优化

二、二进制重排

咱们能够在XCode配置二进制重拍,首先咱们要肯定符号的顺序,才能知道怎么重拍,XCode使用的连接器叫作ld,ld有个参数叫order_file,只要有这个文件,咱们能够将文件的路径告诉XCode,在order_file文件中把符号的顺序写进去,XCode编译的时候就会按照文件中的符号顺序打包成二进制可执行文件。

咱们能够在苹果的objc4-750源码中找到这种文件

打开后是下面这种格式:

里面全是函数符号,咱们打开项目,在build setting 里面搜索order file

发现这里面指定了order的文件路径,由于一旦在这里指定了order file的路径,XCode就会在编译的时候按照文件里面写进去的顺序

咱们如今写一个Demo,而后编译,咱们知道XCode编译的时候文件会有一个连接,连接是按照Build Phases的Compile SourceL里面的文件顺序将.m文件转换成.o文件,而后将这些.o文件连接在一块儿生成可执行文件,

咱们能够作一个实验,在ViewController和AppDelegate里面都写一个load方法,而后运行

+(void)load
{
   NSLog(@"ViewController");
}
+(void)load
{
   NSLog(@"AppDelegate");
}
复制代码

而且Build Phases的Compile Source顺序:

运行,看下打印:

咱们再把Compile Source顺序改一下

运行,打印:

咱们发现打印顺序跟Compile Source文件顺序同样,验证了上面的结论

如何查看整个项目的符号顺序呢,咱们到Build Settings搜索link map

Link Map就是咱们连接的符号表,咱们把它改为YES,这样编译的时候就会把连接的符号表给咱们写出来,command + R咱们运行下,而后在Products里面的.app文件,在咱们Intermediates.noindex-->项目名.build--->Debug-iphoneos-->项目名.build--->项目名-LinkMap-normal-arm64.txt,这个文件里面就有连接的符号顺序表

其中 Object files:就是连接了哪些.o文件

Sections:中

  • Address:

  • Size:

  • Segment:__TEXT代码代码段,只可读;__DATA是数据段,可读可写

  • Section:

再下面就是咱们关心的符号:

Symbols:

  • Address:方法代码的地址

  • Size:方法占用的空间

  • File:文件的编号

  • Name:.o文件里面的方法符号

对于Address,咱们从.app中拿到项目的可执行文件,而后用MachOView打开,而后在Section中看下Assembly

咱们发现符号表里的0x100004B70在MachOView对应的value是汇编代码,也就是咱们写的代码转换成的汇编,因此这个地址就是代码地址,因此二进制重拍就是把全部的代码顺序从新排一下,把启动时候调用的代码排到前面去,减小启动时候加载page的数量(没一个page大小是16K)

添加order file,咱们建立一个hank.order文件,在文件中写入

而后放到工程的根目录中,而后在Build setting里面搜下order file,而后在后面将该文件地址添加进去

这样Xcode在编译时候就会按照order文件中的符号顺序连接代码了,咱们编译一下,再看一下LinkMap-normal-arm64.txt文件

咱们发现是按照order的符号顺序来的,并且若是order里面写了项目中不存在的方法符号,XCode会自动过滤掉,不存在影响

还有一种查看符号表的方法是在终端cd到项目可执行文件的目录,而后输入

nm 可执行文件名
复制代码

这是查看所有的符号,还有查看自定义方法的符号

nm -Up TraceDemo
复制代码

查看系统的符号

nm -up TraceDemo
复制代码

三、获取APP启动时候调用的全部方法

这就是二进制重拍的步骤,可是咱们怎么知道APP启动时候的调用了哪些方法呢?

咱们之前拿到调用方法都是经过hook的形式,可是咱们须要hook项目中全部方法,

第一个方式:是用fishHook去hook 系统的 objc_msgSend这个函数,由于oc的方法都是经过发送消息的形式,可是这个函数参数是可变的参数,因此只能经过汇编形式hook,可是这种状况initialize和block以及直接调用函数方式hook不到

第二种方式:clang插装形式: 官方文档:clang

OC方法、函数、block都能hook到

一、首先在Build Setting里面搜索Other C Flags 在里面添加参数:-fsanitize-coverage=trace-pc-guard

-fsanitize-coverage=func,trace-pc-guard
复制代码

二、而后编译,咱们发现会报错,提示报错

Showing Recent Messages
Undefined symbol: ___sanitizer_cov_trace_pc_guard_init
复制代码

提示找不到__sanitizer_cov_trace_pc_guard和__sanitizer_cov_trace_pc_guard_init方法,咱们看一下文档,发现又测试代码:

咱们把这段代码copy到项目中,发现,错误没有了

__sanitizer_cov_trace_pc_guard_init

而后咱们先分析一下__sanitizer_cov_trace_pc_guard_init函数,这里面有个start和stop,打个断点,咱们看一下start和stop内存里面的值,

发现start里每4个字节里面都有一个数组,并且是按照一、二、三、4的顺序排列的,再看一下stop,由于stop字面意思是结尾,按照start的规则,咱们减4个字节看一下,发现是13,这是由于这里面存的是咱们项目自定义文件中符号的数量,不管是方法、函数仍是block,都会统计进来,咱们能够多加几个方法或者函数、block试一下,就能够验证

__sanitizer_cov_trace_pc_guard

咱们再分析一下__sanitizer_cov_trace_pc_guard

咱们运行时候发现打印了好多guard

而后咱们实现个个手势

-(void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event{

}
复制代码

点击一下屏幕,发现

点击一下打印一下,咱们猜想每执行一个函数都会调用一次,说明该函数hook了全部的方法,为了验证一下,咱们定义一个函数和一个block,在点击屏幕时候调用一个函数,再看一下

void(^block1)(void) = ^(void) {

};

void test(){
    block1();

}

guard: 0x100d8381c a PC 
guard: 0x100d83814 8 PC 
guard: 0x100d83810 7 PC 

复制代码

咱们发现点击一次,该函数调用了三次,证实了一下

咱们再经过汇编验证一下,在toubegain、函数、block出都加上断点,而后打开汇编,运行

bl指令表明调用一个方法或者一个函数 ,过掉这个断点

test也调用了,再过一下

block也调用了,这是由于当咱们配置了chang的代码覆盖工具,而后实现了上面两个函数,clang会以静态插装形式在全部方法、函数block内部插入一行代码,并且是在第一行一开始插入的,作到了全局的hook

咱们再在分析下__sanitizer_cov_trace_pc_guard的做用,咱们如今这个函数里面加一个断点

而后运行

在左边咱们发现有个函数调用栈,而且在每次调用方法时候都会调起__sanitizer_cov_trace_pc_guard函数,而这个函数就是相应方法调起来的

咱们发现实例代码中有个PC,咱们加一个断点打印一下这个PC看看,咱们先把启动时候的函数都过掉再打开断点,而后点击一下屏幕触发touchesBegan的方法进行拦截

而后在控制栏中输入bt,查看一下函数调用栈

咱们看一下0x0000000104349abc这个地址的信息

咱们发现这个地址是在touchesBegan里面,可是不在touchesBegan开头,咱们把它减4个字节

第一个指令是bl,这时才是touchesBegan的开头

咱们在touchesBegan方法里面加一个断点,而后跳到touchesBegan方法里面,再打开汇编看看

由于bl是调用的意思,咱们发现0x104349ab8是touchesBegan方法的开头,bl是调用的意思,也就是说0x00000001000bdabc是调用下一个函数的指令的下一个地址,而且咱们发现PC打印的就是0x104349abc

咱们再来看一下函数调用栈

调用栈的左边是上一个函数的开始地址,最后面有个+64,最后面那个数字是偏移量,也就是说函数的开始位置+偏移量才是函数的真正的位置,这个时候touchesBegan的偏移量是44,咱们测试一下:

这才是touchesBegan的真正实现,也就是汇编的这一段

这说明在__sanitizer_cov_trace_pc_guard里面咱们能拿到下一个函数调用的首地址,这时为啥呢

咱们看一下__sanitizer_cov_trace_pc_guard的汇编调用

最后面有个ret也就是return返回的意思,由于每一个函数或者方法都有一个return, 在底层实现,每个函数调用完成后都会返回下一个须要调用的函数的地址,也就是汇编中每次bl的时候会把下次要调用的指令的地址存在x30中,当函数执行时候遇到ret时候就会从x30中的值返回回去 ,例如咱们点击屏幕时候在__sanitizer_cov_trace_pc_guard加个断点,而后读取x30数据,就获得了touchesBegan的地址

因此__sanitizer_cov_trace_pc_guard中的

拿到的是下一个要调用的函数的地址,由于__sanitizer_cov_trace_pc_guard函数都是在hook函数前执行的,因此在这里面拿到的函数地址就是咱们hook的函数地址

既然能拿到函数地址,咱们能够经过这个函数去拿到函数名称

#import <dlfcn.h>
dladdr(<#const void *#>, <#Dl_info *#>)
复制代码

第一个参数是函数的地址,第二个参数是一个结构体指针,咱们看看这个结构体格式

typedef struct dl_info {
        const char      *dli_fname;     /* Pathname of shared object  */
        void            *dli_fbase;     /* Base address of shared object  */
        const char      *dli_sname;     /* Name of nearest symbol  */
        void            *dli_saddr;     /* Address of nearest symbol  */
} Dl_info;
复制代码

咱们打印一下:

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);

打印:

fname:/private/var/containers/Bundle/Application/38C6E838-7D51-4546-9882-BF5858D08C16/TraceDemo.app/TraceDemo 
fbase:0x1000e0000 
sname:-[ViewController touchesBegan:withEvent:] 
saddr:0x1000e5a0c

复制代码

因此咱们知道:

  • fname:文件路径
  • fbase:文件地址
  • sname:函数符号名称
  • saddr:函数符号地址,也就是函数的起始地址

当咱们能拿到项目全部调用函数的符号时候,咱们就能经过这种方法来拿到APP启动时候调用的全部的函数、方法、block符号,而后建立order文件进行自动二进制重拍上代码:

//原子队列
static  OSQueueHead symbolList = OS_ATOMIC_QUEUE_INIT;
//定义符号结构体
typedef struct {
    void *pc;
    void *next;
}SYNode;

void __sanitizer_cov_trace_pc_guard(uint32_t *guard) {
//    if (!*guard) return;  // Duplicate the guard check.
    /*  精肯定位 哪里开始 到哪里结束!  在这里面作判断写条件!*/
    void *PC = __builtin_return_address(0);
    SYNode *node = malloc(sizeof(SYNode));
    *node = (SYNode){PC,NULL};
    //进入,由于该函数可能在子线程中操做,因此用原子性操做,保证线程安全
    OSAtomicEnqueue(&symbolList, node, offsetof(SYNode, next));
//
}

-(void)createOrderFile{

    NSMutableArray <NSString *> * symbolNames = [NSMutableArray array];

    while (YES) {
        SYNode * node = OSAtomicDequeue(&symbolList, offsetof(SYNode, 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];
        [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"];

    NSString * filePath = [NSTemporaryDirectory() stringByAppendingPathComponent:@"hank.order"];
    NSData * fileContents = [funcStr dataUsingEncoding:NSUTF8StringEncoding];
    [[NSFileManager defaultManager] createFileAtPath:filePath contents:fileContents attributes:nil];
    NSLog(@"%@",funcStr);
}
复制代码
相关文章
相关标签/搜索