iOS启动优化之二进制重排

很早之前就阅读过那篇关于抖音基于二进制文件重排解决方案的文章,那应该是我首次了解「二进制重排」的相关技术。最近,我系统地梳理了这部分知识点,做下笔记。

涉及的基础知识点

虚拟内存和分页

我们知道,现代操作系统一般都采用虚拟内存管理机制,用分段(segment)和分页(page)管理虚拟内存。

分段即是区分数据段、代码段、堆内存、栈内存等,不同的段数据的读写权限不一样。以iOS为例,代码段(_TEXT)是可读可执行但不能写的。

分页则是为了方便高效的进行内存管理。由于采用了虚拟内存管理机制,就要建立虚拟内存到物理内存的映射表,称为页表。如果在设计上将每一个字节的虚拟内存和物理内存一一对应,这样粒度足够细,虽然不会产生内存浪费(内存碎片),但需要维护巨大的页表;但如果一页数据过大,比如5M,那么存储1个字节就要分配一个5M的页面,是非常大的浪费。内存页过大或过小都有弊端,目前大多数系统的页大小都设置在了4096字节,通过页号和页内偏移进行寻址。可以使用pagesize命令查看当前系统的页大小。

Page Fault

使用虚拟内存的目的之一是解决物理内存资源紧张的问题。dyld在加载二进制时,会使用mmap将Mach-O文件映射到虚拟内存地址空间中,此时并不会占用过多的物理内存。当读取一个虚拟内存地址时,如果该地址在物理内存中并不存在,会触发一次缺页中断(Page Fault),这个时候才将文件内容读取至物理内存中。

缺页中断发生时会执行下面的操作:

  • 分配内存

由MMU内存管理单元找到空闲内存并分配。

  • IO操作

从磁盘中读文件并写入内存中。

  • 解密验签

如果是从AppStore上下载的APP,iOS系统还有对每一页(仅针对_TEXT段的数据,_DATA端数据不需要)进行解密和签名验证。

以上操作在每一次Page Fault时都会发生,如果在启动APP时,存在大量的Page Fault情况,势必影响启动速度。

什么是二进制重排

频繁的发生Page Fault会影响启动速度,那么,是否可以干预Mach-O的_TEXT段函数的映射顺序,将APP启动时需要用到的方法集中在一页或几页呢?答案是肯定的,二进制重排的原理就是字面上的理解,通过减少Page Fault发生次数,减少启动耗时。

这里插一句:理论上Page Fault确实会影响启动速度,但影响的大小要区分看待。一般来说,是要在常规的优化手段都做完之后,再考虑进行二进制重排。且对于小型APP来说,如果本身启动时执行的方法并不算多,那么二进制重排的意义就不是很大。对于iOS 13系统来说,由于启用了 dyld3,Page Fault发生时已经不需要执行解密验签(提前生成了lauch closure文件),对性能的影响就更小了。

Link Map和Order File

Link Map是记录链接信息的文本文件,文件中的分段和方法的先后顺序是和实际dyld进行虚拟内存映射时一致的。进一步细究会发现,默认情况下,方法的符号地址顺序和Xcode->Build Phases->Compile Sources中文件添加的顺序有关:

  • Compile Sources越靠前文件的方法在符号表中越靠前;
  • 同一个文件中的方法实现越靠前,则在符号表中越靠前;

在序号表中离得越近的方法mmap到一页的可能性越大。那么,有没有一种方法可以干预符号表的排列顺序,将启动需要执行的方法集中在一页或几页呢?

Xcode提供了Order File的设置选项:Xcode->Build Settings->Linking->Order File。在Order File配置文件路径,则clang在调用ld链接器链接时,会多一个-order_file参数,由-order_file参数指定哪些方法的符号表最先链接。

order_file文件有自己的格式,内容示例如下:

_main
-[AppDelegate window]
-[AppDelegate setWindow:]
-[AppDelegate application:didFinishLaunchingWithOptions:]
_$s19AppOrderFilesSample0A9SwiftTestC3fooyyFZTo
  • 每一行是一个符号;
  • 注释以#开头;
  • 当存在符号冲突歧义的问题时,可以指定object file区分。

符号名称命名规则:

  • C函数前面统一加下划线;
  • Objective-C实例方法前面加-,类方法前面加+;
  • Swift方法则是项目名+类名+方法名的形式。

如果order_file中的符号实际并不存在,则ld会自动忽略。也可以使用-order_file_statistics选项,会以warning的形式将未找到的符号打印出来。

LLVM静态插桩

下面的问题就是如何找到APP启动时执行了哪些方法。有文章介绍使用fishhook hook掉_\objc_msgSend方法,这种方案可以达到部分目的,但有缺陷:无法记录block函数、load方法、C/C++ initialize方法。更好的方案是采取LLVM静态插桩,能保证block函数、load方法、C/C++ initialize方法都覆盖到。静态插桩就是在编译阶段,在每一个函数内部执行本函数代码之前,添加HOOK方法。

Clang提供了内置的代码覆盖率检测工具SanitizerCoverage,它允许开发者在function-level(函数), basic-block-level(基本块)和edge-level(边界)插入一个回调函数。

对于二进制重排场景来说,使用function-level的桩就可以了。配置方法:Target->Build Setting->Custom Complier Flags->Other C Flags添加:

-fsanitize-coverage=trace-pc-guard

并实现__sanitizer_cov_trace_pc_guard_init和__sanitizer_cov_trace_pc_guard两个C方法。

Github上有一份实现AppOrderFiles可以参考。核心逻辑在AppOrderFiles.m中

static OSQueueHead queue = OS_ATOMIC_QUEUE_INIT;

typedef struct {
    void *pc;
    void *next;
} PCNode;

//start和stop内存区间保存了工程中的符号个数
void __sanitizer_cov_trace_pc_guard_init(uint32_t *start,
                                         uint32_t *stop) {
    static uint32_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.
}

void __sanitizer_cov_trace_pc_guard(uint32_t *guard) {
    *guard = 0;
    //读取 x30 中所存储的要返回时下一条指令的地址
    void *PC = __builtin_return_address(0);
    PCNode *node = malloc(sizeof(PCNode));
    *node = (PCNode){PC, NULL};
    OSAtomicEnqueue(&queue, node, offsetof(PCNode, next));
}

每一次方法调用时(包括block调用)都会执行__sanitizer_cov_trace_pc_guard方法。调用是在多线程环境中,需要用原子队列存储每一次调用获取的原方法地址。启动完成后,读取原子队列中的每一个地址,通过dladdr查询改地址所在的符号信息,去重后就是APP启动时所有调用的方法列表了。

CNode *node = OSAtomicDequeue(&queue, offsetof(PCNode, next));
if (node == NULL) {
    break;
}
Dl_info info = {0};
dladdr(node->pc, &info);

注意:AppOrderFiles的实现中判断了!*guard就return了,*guard为0的情况可能是load方法,可以去掉这个判断以支持load方法检查。

其他文章

llvm 编译器高级用法:第三方库插桩
SanitizerCoverage
抖音:基于二进制文件重排的解决方案
ios启动优化:二进制重排
iOS APP灰度发布方案
iOS URLSession Authentication Challenge及SSL Pinning