涉及的基础知识点
虚拟内存和分页
我们知道,现代操作系统一般都采用虚拟内存管理机制,用分段(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方法检查。
原文