32.iOS底层学习之启动优化

本章提纲:
1、pre-Main阶段的性能检测
2、虚拟内存
3、二进制重排
4、Clang插装

1、pre-Main阶段的性能检测

应用的启动过程一般以Main函数为临界点,分为Main函数之前和Main函数之后。
Main函数之前我们称为pre-Main
Xcode为检测pre-Main的耗时提供了环境变量,以便开发者了解pre-Main的时间。
在Xcode中的Schemes->Run->Arguments中添加DYLD_PRINT_STATISTICS的环境变量为YES。然后运行程序,可以看到如下打印:

Total pre-main time: 540.09 milliseconds (100.0%)
dylib loading time: 159.35 milliseconds (29.5%)
rebase/binding time: 39.06 milliseconds (7.2%)
ObjC setup time: 28.37 milliseconds (5.2%)
initializer time: 313.30 milliseconds (58.0%)
slowest intializers :
libSystem.B.dylib : 7.52 milliseconds (1.3%)
libMainThreadChecker.dylib : 48.67 milliseconds (9.0%)
GPUToolsCore : 26.26 milliseconds (4.8%)
libglInterpose.dylib : 113.10 milliseconds (20.9%)
KSAdSDK : 105.15 milliseconds (19.4%)
xxxx : 80.49 milliseconds (14.9%)

  • dylib loading time
    动态库的载入耗时。系统的动态库存在于共享缓存,但是自定义的动态库就要通过依赖关系一个一个的加载。
    苹果官方建议项目中不要超过6个自定义的动态库,超过的部分最好进行多个动态库合并,以此来减少动态库的加载时间。

  • rebase/binding time
    这是一个非常核心而且重要的概念。重定位/符号绑定耗时。涉及到虚拟内存的相关技术,会在下面详细介绍。
    rebase(重定位):采用了ASLR技术,保证地址的随机化,加强了内存访问的安全性。
    binding(符号绑定):使用外部符号,编译时无法找到函数地址。在运行时,dyld加载共享缓存,加载链接动态库之后,进行binding操作,重新绑定外部符号。

  • ObjC setup time
    注册OC类的耗时。应用启动时,系统会生成OC类和分类的两张相关映射表,IMP到SEL的映射,分类的方法等合并到相关表中的等操作会造成一部分的耗时。
    减少项目中类和分类的数量可以优化这部分的时间。
    减少类和分类中的Load方法的使用,让类以懒加载的方式加载。

  • initializer time
    执行load以及C++构造函数的耗时

  • slowest intializers
    最耗时的几个动态库。

2、虚拟内存

聊到虚拟内存我们就要聊起早期的计算机结构。早期的是冯·诺依曼计算机结构,在1945年就被提出了,在当时是很新颖的结构了,它是第一次将存储器和运算器分离,开启了以存储器为核心的现代计算机的篇章。

冯·诺依曼计算机结构

但是冯·诺依曼结构有它自己的问题,就是存储器之间的读取速度远远小于CPU的工作效率。读取效率低,CPU的运算能力又太快,就造成了CPU性能的浪费。为了解决这个问题,现行的解决方式就是采用多级存储,来平衡存储器的读写速率,容量,价格。

该结构下的CPU的寻址方式:内存可以被看成一个数组,数组元素是一个字节大小的空间,而数组索引则是所谓的物理地址。最简单直接的方式就是CPU直接通过物理地址去访问对应的内存,也叫做物理寻址。

这种寻址方式有非常严重的安全问题。因为直接暴露的是物理地址,所以进程通过地址偏移可以访问到任何屋里地址,用户进程想干嘛就干嘛。这是非常不安全的。

现代处理器使用的是虚拟寻址的方式。CPU通过访问虚拟地址,经过翻译获得物理地址才能访问内存。这个翻译过程由CPU中的内存管理单元(Memory Management Unit,缩写为MMU)完成。

现代的操作系统都引入了虚拟内存。对于每个进程来说,操作系统可以为其提供一个独立的私有的连续的地址空间。对于进程来说,它的可见部分只有分配给它的虚拟内存。而虚拟内存实际可能映射到物理内存以及硬盘的任何区域。由于硬盘的读写速度不如内存快,所以操作系统会优先使用物理内存空间,但是当物理内存空间不够时,就会将部分内存数据交换到硬盘上去存储,这也是所谓的Swap内存交换机制。有了内存交换机制以后,相比起物理寻址,虚拟内存实际上利用了磁盘空间扩展了内存空间。

虚拟内存的优势同时也彰显了出来:
1、保护了进程的地址空间,将进程和物理地址完全阻隔开,无法跨进程访问。
2、由于操作系统分配的虚拟内存是连续的,简化了内存管理。
3、利用硬盘空间拓展了内存空间。
4、可以按需加载内容到内存中,避免内存浪费。

内存分页

虚拟内存和物理内存存在映射关系,为了方便映射和管理,虚拟内存和物理内存都被分割成大小相同的单位,物理内存的最小单位称为帧(Frame),而虚拟内存的最小单位被称为页(Page)

在iOS中,一页的大小为16KB,当进程被加载到内存中是,虚拟内存会给该进程开辟最大4个G的虚拟内存空间。

内存分页的最大意义在于:
1、支持了物理内存的离散使用;
2、提高MMU的翻译效率,采用一些页面调度(Paging)算法,利用翻译过程中也存在局部性原理,将大概率被使用的帧地址加入到TLB或者页表之中,提高翻译效率。

缺页中断

现代计算机都是分级缓存的,内存命中的查找也是分级的。

  • 首先会在TLB(Translation Lookaside Buffer)中进行查询,这个表位于CPU内部,查询速度最快;
  • 如果没有命中,那么接下来会在页表(Page Table)中进行查询,页表位于物理内存中,所以查询速度较慢,如果发现目标不在物理内存中,那么成为缺页
  • 如果物理内存没有命中查找,此时会去磁盘中查找,如果还找不到就报错了。

所以当发生缺页时,操作系统会阻塞当前进程,把需要的数据载入到物理内存中,然后再寻址读取。当缺页频繁发生时,也是非常耗时的。

页面置换

由于物理内存是有限的,当物理内存没有空间时,操作系统会通过算法找到最不经常使用的物理页驱逐回磁盘,为新的内存页让出空间。这个过程称为页面置换,也称内存交换

然而!!!iOS并不支持内存交换机制!!
大多数移动设备都不支持内存交换机制。移动设备上的大容量存储器通常是闪存(Flash),它的读写速度远远小于电脑所使用的的硬盘,这就导致了在移动设备上,就算使用了内存交换也不能提升性能。其次,移动设备本身容量就经常短缺,闪存的读写寿命也非常有限,所以这种情况下还有进行内存交换就非常不划算了。

ASLR

程序的代码在不修改的情况下,每次加载到虚拟内存的地址是一样的,这样的方式并不安全,为了解决地址固定的问题,出现了ASLR技术。
ASLR(Address space layout randomization):地址空间配置随机加载,是一种防范内存损坏漏洞被利用的计算机安全技术。

地址空间配置随机加载利用随机方式配置数据地址空间,使某些敏感数据配置到一个恶意程序无法事先获知的地址,令攻击者难以进行攻击。

以上就简单的介绍了下虚拟内存的相关知识。接下来是二进制重排部分。

3、二进制重排

3.1缺页中断时间消耗的检测

前面我们已经提到了缺页中断,接下来我们通过Profile来检测一下缺页中断的发生。
Xcode顶部菜单Product->Profile->Instruments->System Trace

image.png

可以看到我们的项目冷启动时,缺页次数大概是1200多次,耗时130毫秒,如果项目再大一些,缺页发生的更多那么也是一个不小的影响启动时间的一个因素。

3.2二进制重排原理

创建测试项目,查看代码的顺序,在Build Settings->Write Link Map File,设置为YES,然后编译项目,来到工程的Build目录下,找到LinkMap文件

image.png

Build目录找不到的话从Xcode->Preferences->Locations,可以看到Derived Data的路径,可以直接跳转过去。

具体看到LinkMap文件保存了项目再编译链接时的符号顺序,以方法/函数为单位排列。

image.png

可以看到和编译的文件顺序是一样的,目前ViewController中只有一个方法viewDidLoad,所以在这个文件下面ViewController只排列了这一个方法。

如果按照默认配置,在启动时会加载大量的与启动无关的代码,导致缺页。那么如果可以将启动时需要的方法/函数排在最前面,就能降低缺页的发生,从而提高应用的启动速度,这就是二进制重排的核心原理。

3.2二进制重排准备

在工程目录下创建一个.order文件,按照固定的格式,将启动时需要的方法/函数顺序排列,然后再去把排列好的.order文件放到Xcode中使用。在.order中写入测试顺序

-[ViewController viewDidLoad]
_main

最后通过LinkMap文件查看来验证.order是否生效。
在Xcode中进行配置.order文件,在Build Settings->Order File中配置

image.png

结果新的LinkMap中的前两位的顺序确实是我写入Lucky.order文件的顺序。
image.png

以上就完成了重排的准备工作,并且测试也生效了,接下来的难点就是,怎么能获取到启动时需要调用的所有方法和函数。

4、Clang插庄

如果只对于OC方法,可以对objc_msgSend方法进行Hook,但是系统调用的方法中会有一些c、c++的方法函数,以及一些block回调,这些通过objc_msgSend是无法拦截到的。

LLVM内置了一个简单的代码覆盖率检测的工具(SanitizerCoverage)。它在函数级、基本块和边缘级上插入了对用户自定义函数的调用,通过方式,可以顺利对OC方法、C函数、Block块、Swift等函数进行更加全面的拦截。
(官方文档链接)https://clang.llvm.org/docs/SanitizerCoverage.html

4.1配置SanitizerCoverage

搭建测试项目,在Build Settings->Other C Flags中,增加-fsanitize-coverage=trace-pc-guard的配置。
根据官方文档的示例,在测试项目中添加以下代码:

#import "ViewController.h"
#include 
#include 
#include 
@implementation ViewController

- (void)viewDidLoad {
    [super viewDidLoad];
    // Do any additional setup after loading the view.
}

void __sanitizer_cov_trace_pc_guard_init(uint32_t *start, uint32_t *stop) {
    static uint64_t N;
    if (start == stop || *start) return;
    printf("INIT: %p %p\n", start, stop);
    for (uint32_t *x = start; x < stop; x++)
        *x = ++N;
}

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);
}
@end

如果不添加__sanitizer_cov_trace_pc_guard_init方法和__sanitizer_cov_trace_pc_guard编译会报错。

image.png

添加完就可以正常编译运行了。
打印如下:
image.png

  • __sanitizer_cov_trace_pc_guard_init
    函数__sanitizer_cov_trace_pc_guard_init是回调函数,startstop表示一个section的首地址和结束地址。这个方法能反应项目中的符号个数。

// This callback is inserted by the compiler as a module constructor
// into every DSO. 'start' and 'stop' correspond to the
// beginning and end of the section with the guards for the entire
// binary (executable or DSO). The callback will be called at least
// once per DSO and may be called multiple times with the same parameters.

  • __sanitizer_cov_trace_pc_guard
    而函数__sanitizer_cov_trace_pc_guard则是可以监听到编译器所有的emit,例如官方给的注释中的例子:

/ This callback is inserted by the compiler on every edge in the
// control flow (some optimizations apply).
// Typically, the compiler will emit the code like this:
// if(*guard)
// __sanitizer_cov_trace_pc_guard(guard);
// But for large functions it will emit a simple call:
// __sanitizer_cov_trace_pc_guard(guard);

4.2 __sanitizer_cov_trace_pc_guard的测试

我们来测试一下是不是函数方法block都会被拦截,添加如下测试代码:

- (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event{
    NSLog(@"touchesBegan方法执行");
    test();
}

void(^block)(void) = ^(void){
    NSLog(@"Block执行");
};

void test(){
    NSLog(@"test函数执行");
    block();
}

image.png

可以看到这些方法确实都被函数__sanitizer_cov_trace_pc_guard能拦截到。通过查看汇编指令:
image.png

image.png

image.png

可以看到这几个测试方法后边都有callq指令,调用的都是__sanitizer_cov_trace_pc_guard

可以初步的了解到,Clang插装的原理是,只要添加了插装的标记,编译器就会在当前项目中,在所有的方法、函数、block的代码实现的边缘,插入一句__sanitizer_cov_trace_pc_guard达到方法、函数、block的全覆盖。

4.4获取符号名称

官方示例代码中,用了__builtin_return_address函数,该函数的作用会获取到当前的返回地址,也就是函数的调用者。
通过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;

dli_fname:当前的路径
dli_fbase:地址
dli_sname:调用的函数名称
dli_saddr:函数地址
所以我们通过dli_sname来拿到函数名称。接下来的工作就是拿到这些名称(去重),然后把名称写入到前面说的.order文件中去,也就完成了重排的工作。

4.5实践
  • 存储返回地址
    为了保证线程安全,定义一个原子队列,队列中存储带有返回地址的结构体。
//定义原子队列
static OSQueueHead symbolList = OS_ATOMIC_QUEUE_INIT;
//定义符号结构体
typedef struct {
    void * pc;
    void * next;
} SYNode;

通过SYNode来存储,方法__sanitizer_cov_trace_pc_guard中通过函数__builtin_return_address得到的pc
函数__sanitizer_cov_trace_pc_guard的实现如下:

//HOOK一切的回调函数!!
void __sanitizer_cov_trace_pc_guard(uint32_t *guard) {
    void *PC = __builtin_return_address(0);
    //创建结构体
    SYNode * node = malloc(sizeof(SYNode));
    *node = (SYNode){PC,NULL};
    //结构体入栈     
    //offsetof:参数1传入类型,将下一个节点的地址返回给参数2
    OSAtomicEnqueue(&symbolList, node, offsetof(SYNode, next));
}
  • 获取函数符号并去重排序
    获取完毕返回的地址,我们进行排序和去重处理
 //定义数组
    NSMutableArray * symbleNames = [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);//获取函数名称,并转字符串
        //oc方法直接返回,其余的前面加"_"
        BOOL isObjc = [name hasPrefix:@"+["] || [name hasPrefix:@"-["];
        NSString * symbolName = isObjc ? name : [@"_" stringByAppendingString:name];
        //符号加到符号数组里
        [symbleNames addObject:symbolName];
    }

    //反向遍历数组
    NSEnumerator * em = [symbleNames reverseObjectEnumerator];

//去重
    NSMutableArray * funcs = [NSMutableArray arrayWithCapacity:symbleNames.count];
    NSString * name;
    while (name = [em nextObject]) {
        if (![funcs containsObject:name]) {//数组没有name
            [funcs addObject:name];
        }
    }
    //去掉自己!
    [funcs removeObject:[NSString stringWithFormat:@"%s",__func__]];
  • 写入文件并配置
    处理完要进行重排的相关符号,下一步就是把这些写入.order文件中。
 //写入文件
    NSString * funcStr = [funcs componentsJoinedByString:@"\n"];
    NSString * filePath = [NSTemporaryDirectory() stringByAppendingPathComponent:@"Lucky.order"];
    NSData * file = [funcStr dataUsingEncoding:NSUTF8StringEncoding];
    [[NSFileManager defaultManager] createFileAtPath:filePath contents:file attributes:nil];
    NSLog(@"%@",funcStr);

写入完毕之后,我们根据前边编译.order的经验来编译,至此我们就完成了重排和插装的过程!可以对实际项目进行测试一下是不是有作用。


慢慢都坚持这么久了,继续加油!

你可能感兴趣的:(32.iOS底层学习之启动优化)