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

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

一、原理

1、虚拟内存和物理内存

早期计算机没有虚拟地址,一旦加载都会全部加载到内存中,而且进程都是按顺序排列的,这样别的进程只需要把自己的地址加一些就能访问到别的进程这样就很不安全。

现在软件发展的比硬件快,软件占用的内存越来越大,这就导致计算机的内存不够用,当开启多个软件时候,如果内存不够用就只能等待,只有等前面的软件关掉后才能加载打开,这就是早期计算机有时候为啥只有把前面的软件关掉才能打开新软件的原因。用户使用软件时候并不是使用到全部内存,只会使用到一部分,如果软件一打开就把软件全部加载到内存中,这样会很浪费内存空间。

基于上面原因虚拟内存技术出现了,软件打开后,软件自己以为有一大片内存空间,但实际上是虚拟的,而虚拟内存和物理内存是通过一张表来关联的,可以看下下面两张表:


image.png

进程1运行时候会开辟一块内存空间,但访问到内存条的时候并不是这块内存空间,而且通过访问地址通过进程1的映射表映射到不同的物理内存空间,这个叫地址翻译,这个过程需要CPU和操作系统配合,因为这个映射表是操作系统来管理的,当我们调试时候发现访问数据的内存地址都是连续的,其实这是一个假象,在这个进程内部可以访问,是因为访问时候会通过该进程的内存映射表去拿到真正的物理内存地址,假如其他进程访问的话,其他进程没有相应的映射表,自然就访问不到真正的物理内存地址,这样就解决了内存安全问题。

内存使用率问题:
内存分页管理,映射表不能以字节为单位,是以页为单位,Linux是以4K为一页,iOS是以16K位一页,但是mac系统是4K一页,可以在mac终端输入pageSize,发现返回的是4096。
为啥分页后内存就够用呢,因为应用内存是虚拟的,所以当程序启动时候程序会认为自己有很多的内存:

image.png

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

但是这时候有个问题,虚拟内存解决了安全和效率问题,但是出现了另个安全问题,因为虚拟内存在编译链接时候就确定了,那么黑客很容易通过分析拿到对应的虚拟内存去操作 ,这样就造成所有的代码都很好hook,代码注入,这个时候就出现了新技术ASLR(Address space layout randomization 地址空间随机化),就是进程每次加载的时候都会给一个随机的偏移量,这样就保证每次加载进程时候虚拟内存也在变化,iOS从iOS4就开始了。

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

进程如果能直接访问物理内存无疑是很不安全的,所以操作系统在物理内存的上又建立了一层虚拟内存。为了提高效率和方便管理,又对虚拟内存和物理内存又进行分页(Page)。当进程访问一个虚拟内存Page而对应的物理内存却不存在时,会触发一次缺页中断(Page Fault),分配物理内存,有需要的话会从磁盘mmap读人数据。
通过App Store渠道分发的App,Page Fault还会进行签名验证,所以一次Page Fault的耗时比想象的要多!

image.png

编译器在生成二进制代码的时候,默认按照链接的Object File(.o)顺序写文件,按照Object File内部的函数顺序写函数。
静态库文件.a就是一组.o文件的ar包,可以用ar -t查看.a包含的所有.o。

默认布局:

image.png

简化问题:假设我们只有两个page:page1/page2,其中绿色的method1和method3启动时候需要调用,为了执行对应的代码,系统必须进行两个Page Fault。
但如果我们把method1和method3排布到一起,那么只需要一个Page Fault即可,这就是二进制文件重排的核心原理。

image.png

重排之后,我们的经验是优化一个Page Fault,启动速度提升0.6~0.8ms

二、实现

1、System Trace调试

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

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

    image.png

  • 选择System Trace,这个软件可以看到我们项目中每个线程的数据:


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


    image.png
  • 这里面File Backed Page In就是page fault的次数。

  • 当我们把APP杀死后里面再启动,结果发现File Backed Page In这个值变得很小,说明APP就算杀死后,在启动不是冷启动,还是有一部数据在系统的缓存中。

  • 要做到真正的冷启动,我们可以把APP杀掉后启动多个手机里面的APP,然后再启动APP,发现File Backed Page In又变得很大。

  • 说明虚拟内存是在系统中的,当系统内存不够的时候,其他APP会覆盖老的APP的虚拟内存。

  • 二进制重拍是在链接阶段生成的,重排之后生成可执行文件,所以我们只能在编译阶段来优化,而无法对已生成的ipa进行优化。

2、二进制重排

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

  • 在苹果的objc4-750源码中找到这种文件


    image.png
  • 打开后是下面这种格式:


    image.png
  • 里面全是函数符号,打开项目,在build setting 里面搜索order file


    image.png
  • 这里面指定了order的文件路径,因为一旦在这里指定了order file的路径,XCode就会在编译的时候按照文件里面写进去的顺序。
    现在写一个Demo,然后编译,我们知道XCode编译的时候文件会有一个链接,链接是按照Build Phases的Compile SourceL里面的文件顺序将.m文件转换成.o文件,然后将这些.o文件链接在一起生成可执行文件:


    image.png
  • 做一个实验,在ViewController和AppDelegate里面都写一个load方法,然后运行

+(void)load
{
   NSLog(@"ViewController");
}
+(void)load
{
   NSLog(@"AppDelegate");
}
  • Build Phases的Compile Source顺序:


    image.png
  • 运行,看下打印:


    image.png
  • 把Compile Source顺序改一下:


    image.png
  • 运行后看打印结果:


    image.png
  • 打印顺序跟Compile Source文件顺序一样,验证了上面的结论

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

    image.png

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

    image.png

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

Sections:中
Address:
Size:
Segment:__TEXT代码代码段,只可读;__DATA是数据段,可读可写
Section:

再下面就是我们关心的符号:
Symbols:
Address:方法代码的地址
Size:方法占用的空间
File:文件的编号
Name:.o文件里面的方法符号
对于Address,我们从.app中拿到项目的可执行文件,然后用MachOView打开,然后在Section中看下Assembly

image.png

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

  • 添加order file,我们创建一个hank.order文件,在文件中写入:


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


    image.png
  • Xcode在编译时候就会按照order文件中的符号顺序链接代码了,我们编译一下,再看一下LinkMap-normal-arm64.txt文件


    image.png
  • 结果是按照order的符号顺序来的,而且如果order里面写了项目中不存在的方法符号,XCode会自动过滤掉,不存在影响。还有一种查看符号表的方法是在终端cd到项目可执行文件的目录,然后输入。

nm 可执行文件名
image.png

查看全部的符号,还有查看自定义方法的符号

nm -Up TraceDemo
image.png

查看系统的符号

nm -up TraceDemo

3、获取APP启动时候调用的所有方法

以上就是二进制重拍的步骤,但是如何知道APP启动时候的调用了哪些方法呢?

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

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

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

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

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

2、然后编译,发现会报错:提示报错

Showing Recent Messages
Undefined symbol: ___sanitizer_cov_trace_pc_guard_init
image.png

提示找不到__sanitizer_cov_trace_pc_guard__sanitizer_cov_trace_pc_guard_init方法。
看一下文档,发现有测试代码:

image.png

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

分析一下__sanitizer_cov_trace_pc_guard_init函数,这里面有个startstop,打个断点,看一下startstop内存里面的值:

image.png

start里每4个字节里面都有一个数组,而且是按照1、2、3、4的顺序排列的,再看一下stop,按照start的规则,减4个字节看一下,发现是13,这里面存的是我们项目自定义文件中符号的数量,无论是方法、函数还是block,都会统计进来,我们可以多加几个方法或者函数、block试一下,就可以验证:
__sanitizer_cov_trace_pc_guard
我们再分析一下__sanitizer_cov_trace_pc_guard
我们运行时候发现打印了好多guard

image.png

实现个个手势

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

点击一下屏幕,发现


image.png

点击一下打印一下,猜测每执行一个函数都会调用一次,说明该函数hook了所有的方法,为了进一步验证,定义一个函数和一个block,在点击屏幕时候调用一个函:

void(^block1)(void) = ^(void) {
 
};
 
void test(){
    block1();
 
}
 
guard: 0x100d8381c a PC �
guard: 0x100d83814 8 PC �
guard: 0x100d83810 7 PC

发现点击一次,该函数调用了三次

通过汇编验证一下,在toubegain、函数、block出都加上断点,然后打开汇编,运行

image.png

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

test也调用,再过一下
image.png

block也调用了,当我们配置了chang的代码覆盖工具,实现了上面两个函数,clang会以静态插装形式在所有方法、函数block内部插入一行代码,而且是在第一行一开始插入的,做到了全局的hook
我们再在分析下__sanitizer_cov_trace_pc_guard的作用,我们现在这个函数里面加一个断点
image.png

再运行
image.png

在左边发现有个函数调用栈,并且在每次调用方法时候都会调起__sanitizer_cov_trace_pc_guard函数,而这个函数就是相应方法调起来的
实例代码中有个PC,我们加个断点打印一下这个PC看看,先把启动时候的函数都过掉再打开断点,然后点击一下屏幕触发touchesBegan的方法进行拦截:
image.png

在控制栏中输入bt,查看一下函数调用栈
image.png

看一下0x0000000104349abc这个地址的信息
image.png

发现这个地址是在touchesBegan里面,但是不在touchesBegan开头,我们把它减4个字节
image.png

第一个指令是bl,这时才是touchesBegan的开头
在touchesBegan方法里面加一个断点,然后跳到touchesBegan方法里面,再打开汇编:
image.png

bl是调用的意思,我们发现0x104349ab8是touchesBegan方法的开头,0x00000001000bdabc是调用下一个函数的指令的下一个地址,PC打印的就是0x104349abc
image.png

再来看一下函数调用栈
image.png

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

image.png

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

说明在__sanitizer_cov_trace_pc_guard里面我们能拿到下一个函数调用的首地址:
看一下__sanitizer_cov_trace_pc_guard的汇编调用
image.png

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

所以__sanitizer_cov_trace_pc_guard中的
image.png

拿到的是下一个要调用的函数的地址,因为__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);
}
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 194,390评论 5 459
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 81,821评论 2 371
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 141,632评论 0 319
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 52,170评论 1 263
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 61,033评论 4 355
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 46,098评论 1 272
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 36,511评论 3 381
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 35,204评论 0 253
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 39,479评论 1 290
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 34,572评论 2 309
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 36,341评论 1 326
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,213评论 3 312
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 37,576评论 3 298
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 28,893评论 0 17
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,171评论 1 250
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 41,486评论 2 341
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 40,676评论 2 335