启动优化之二进制重排

涉及的基础知识点


虚拟内存和分页

我们知道,现代操作系统一般都采用虚拟内存管理机制,用分段(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方法检查。


原文

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

推荐阅读更多精彩内容