美文网首页iOS 高级储备Ios
32.iOS底层学习之启动优化

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

作者: 牛牛大王奥利给 | 来源:发表于2022-02-17 16:24 被阅读0次

    本章提纲:
    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 <stdint.h>
    #include <stdio.h>
    #include <sanitizer/coverage_interface.h>
    @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<UITouch *> *)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<NSString *> * 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底层学习之启动优化

        本文链接:https://www.haomeiwen.com/subject/sehygltx.html