前言
前文iOS底层原理之启动优化(一):相关概念 & 优化方案简单介绍了启动相关的概念和一些优化的方案,本文将来介绍下pre-main阶段的优化方案,即二进制重排。
探索二进制重排之前,先扩展点其他方面的概念。
一: Link Map File
1.1: 什么是Link Map File
Link Map File中文直译为链接映射文件,它是在Xcode生成可执行文件的同时生成的链接信息文件,用于描述可执行文件的构造部分,包括了代码段和数据段的分布情况。Xcode在生成可执行文件的时候默认情况下不生成该文件,需要开发者手动设置Target -> Build Setting -> Write Link Map File为YES:
这里还可以设置Link Map File存放的位置:
// 默认的位置
$(TARGET_TEMP_DIR)/$(PRODUCT_NAME)-LinkMap-$(CURRENT_VARIANT)-$(CURRENT_ARCH).txt
// 例如:
/Users/baypac/Library/Developer/Xcode/DerivedData/LaunchTraceDemo-hbwolihehtukkzdwncleyihzvfrv/Build/Intermediates.noindex/LaunchTraceDemo.build/Debug-iphoneos/LaunchTraceDemo.build/LaunchTraceDemo-LinkMap-normal-arm64.txt
开发者也可以根据自己的需要自行设置该文件的位置。
1.2: Link Map File的组成
双击打开Link Map File,可以发现里面包含了如下几个部分:
1.2.1: Path
# Path: /Users/baypac/Library/Developer/Xcode/DerivedData/LaunchTraceDemo-hbwolihehtukkzdwncleyihzvfrv/Build/Products/Debug-iphoneos/LaunchTraceDemo.app/LaunchTraceDemo
生成的可执行文件的路径。
1.2.2: Arch
# Arch: arm64
生成的可执行文件的路径。
1.2.3: Object files
# Object files:
[ 0] linker synthesized
[ 1] /Users/baypac/Library/Developer/Xcode/DerivedData/LaunchTraceDemo-hbwolihehtukkzdwncleyihzvfrv/Build/Intermediates.noindex/LaunchTraceDemo.build/Debug-iphoneos/LaunchTraceDemo.build/Objects-normal/arm64/ViewController.o
[ 2] /Users/baypac/Library/Developer/Xcode/DerivedData/LaunchTraceDemo-hbwolihehtukkzdwncleyihzvfrv/Build/Intermediates.noindex/LaunchTraceDemo.build/Debug-iphoneos/LaunchTraceDemo.build/Objects-normal/arm64/AppDelegate.o
[ 3] /Users/baypac/Library/Developer/Xcode/DerivedData/LaunchTraceDemo-hbwolihehtukkzdwncleyihzvfrv/Build/Intermediates.noindex/LaunchTraceDemo.build/Debug-iphoneos/LaunchTraceDemo.build/Objects-normal/arm64/main.o
[ 4] /Users/baypac/Library/Developer/Xcode/DerivedData/LaunchTraceDemo-hbwolihehtukkzdwncleyihzvfrv/Build/Intermediates.noindex/LaunchTraceDemo.build/Debug-iphoneos/LaunchTraceDemo.build/Objects-normal/arm64/SceneDelegate.o
[ 5] /Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS14.5.sdk/System/Library/Frameworks//Foundation.framework/Foundation.tbd
[ 6] /Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS14.5.sdk/usr/lib/libobjc.tbd
[ 7] /Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS14.5.sdk/System/Library/Frameworks//UIKit.framework/UIKit.tbd
列举出了可执行文件里所有的目标文件和动态库.tbd。每行前面为文件编号。
1.2.4: Sections
# Sections:
# Address Size Segment Section
0x100005F10 0x00000588 __TEXT __text
0x100006498 0x00000090 __TEXT __stubs
0x100006528 0x000000A8 __TEXT __stub_helper
0x1000065D0 0x000000BC __TEXT __objc_methlist
0x10000668C 0x00000D8A __TEXT __objc_methname
0x100007416 0x00000070 __TEXT __objc_classname
0x100007486 0x00000B0F __TEXT __objc_methtype
0x100007F95 0x00000016 __TEXT __cstring
0x100007FAC 0x00000054 __TEXT __unwind_info
0x100008000 0x00000008 __DATA_CONST __got
0x100008008 0x00000020 __DATA_CONST __cfstring
0x100008028 0x00000018 __DATA_CONST __objc_classlist
0x100008040 0x00000020 __DATA_CONST __objc_protolist
0x100008060 0x00000008 __DATA_CONST __objc_imageinfo
0x10000C000 0x00000060 __DATA __la_symbol_ptr
0x10000C060 0x000011D8 __DATA __objc_const
0x10000D238 0x00000078 __DATA __objc_selrefs
0x10000D2B0 0x00000010 __DATA __objc_classrefs
0x10000D2C0 0x00000008 __DATA __objc_superrefs
0x10000D2C8 0x00000004 __DATA __objc_ivar
0x10000D2D0 0x000000F0 __DATA __objc_data
0x10000D3C0 0x00000188 __DATA __data
单从字面含义理解:每个Section包含了Address、Size、Segment以及Section。介绍之前,这里先简单介绍一下Mach-O文件。
上面第一部分的Path是可执行文件的路径,使用iTerm进去到该文件夹,然后使用file命令即可查看该文件的类型:
file LaunchTraceDemo
输出结果为:
LaunchTraceDemo: Mach-O 64-bit executable arm64
可以知道该文件是一个Mach-O格式的文件,它是iOS系统应用执行文件格式。Mach-O文件中的虚拟地址最终会被映射到物理地址上,这些地址会被分为不同的Segment(段)类型:__TEXT 、__DATA以及__LINKEDIT等。各个段的含义如下:
__TEXT包含了被执行的代码。这些代码是只读、可执行。__DATA包含了将会被更改的数据,例如全局变量、静态变量等,可读写,但是不可执行。__LINKEDIT包含了加载程序的元数据,比如函数名称和地址,只读。
Segment又被划分成了不同的Section,不同的Section存储了不同的信息,例如__objc_methname为方法的名称,__objc_classlist为类列表。
1.2.5: Symbols
# Symbols:
# Address Size File Name
0x100005F10 0x00000048 [ 1] -[ViewController viewDidLoad]
0x100005F58 0x0000007C [ 2] -[AppDelegate application:didFinishLaunchingWithOptions:]
0x100005FD4 0x00000100 [ 2] -[AppDelegate application:configurationForConnectingSceneSession:options:]
0x1000060D4 0x00000074 [ 2] -[AppDelegate application:didDiscardSceneSessions:]
0x100006148 0x0000009C [ 3] _main
0x1000061E4 0x0000009C [ 4] -[SceneDelegate scene:willConnectToSession:options:]
0x100006280 0x0000004C [ 4] -[SceneDelegate sceneDidDisconnect:]
0x1000062CC 0x0000004C [ 4] -[SceneDelegate sceneDidBecomeActive:]
0x100006318 0x0000004C [ 4] -[SceneDelegate sceneWillResignActive:]
0x100006364 0x0000004C [ 4] -[SceneDelegate sceneWillEnterForeground:]
0x1000063B0 0x0000004C [ 4] -[SceneDelegate sceneDidEnterBackground:]
0x1000063FC 0x00000024 [ 4] -[SceneDelegate window]
0x100006420 0x0000003C [ 4] -[SceneDelegate setWindow:]
0x10000645C 0x0000003C [ 4] -[SceneDelegate .cxx_destruct]
0x100006498 0x0000000C [ 5] _NSStringFromClass
0x1000064A4 0x0000000C [ 7] _UIApplicationMain
0x1000064B0 0x0000000C [ 6] _objc_alloc
0x1000064BC 0x0000000C [ 6] _objc_autoreleasePoolPop
0x1000064C8 0x0000000C [ 6] _objc_autoreleasePoolPush
0x1000064D4 0x0000000C [ 6] _objc_autoreleaseReturnValue
0x1000064E0 0x0000000C [ 6] _objc_msgSend
0x1000064EC 0x0000000C [ 6] _objc_msgSendSuper2
0x1000064F8 0x0000000C [ 6] _objc_opt_class
0x100006504 0x0000000C [ 6] _objc_release
...
根据Sections的起始地址,可以将Symbols分为Sections个数的组,例如0x100005F10到0x100006498之间,就是__text代码区。
Symbols包含的信息有:
Address:起始地址Size:所占内存大小,这里使用16进制表示。File:该Symbol所在的文件编号,也就是Object files部分的中括号的数字,例如-[ViewController viewDidLoad]对应的文件编号为1,根据Object files部分可以看到所属的文件为:ViewController.o。Name就是该Sybmol的名称。
1.2.6: Dead Stripped Symbols
# Dead Stripped Symbols:
# Size File Name
<<dead>> 0x00000005 [ 2] literal string: hash
<<dead>> 0x0000000B [ 2] literal string: superclass
<<dead>> 0x0000000C [ 2] literal string: description
<<dead>> 0x00000011 [ 2] literal string: debugDescription
<<dead>> 0x00000007 [ 2] literal string: window
<<dead>> 0x00000009 [ 4] literal string: NSObject
...
链接器认为无用的符号,链接的时候不会记入。
上面便是对Link Map File简单的介绍。
二: 二进制重排原理
经过前文介绍虚拟内存后,可以知道当访问一页虚拟内存,而此页虚拟内存与物理内存没有建立映射关系时,就会发生缺页异常(Page Fault,或缺页中断),从而阻塞进程。此时就需要先将数据加载到物理内存,建立映射关系,然后继续访问。Page Fault导致的进程阻塞虽然本身为毫秒级别,可以忽略不计,但是在冷启动时,有大量类、分类、三方库等需要加载,此时就会产生大量缺页异常,势必会影响启动速度。下面重签名微信,查看下微信启动时的Page Fault次数(重签名此处不作赘述)。
- 自建项目使用脚本重签名微信安装好之后,同时按住
Command + i,打开Instruments,双击打开System Trace。
- 点击
Record(冷启动需要重启手机,清除物理内存里的缓存数据),待第一个界面呈现之后,点击Stop,然后按图中流程搜索Main Thread查看虚拟内存的Page Fault次数。
从图中可知,此次启动,Page Fault次数高达4000+次(而且还是非冷启动,冷启动Page Fault次数会更高),耗时636.31ms,相当影响启动速度。
接下来我们再来看一下Link Map File(链接映射文件)中Symbols的信息。
- 修改
Build Setting -> Write Link Map File为YES。
Command + B编译项目,然后找到项目对应的.app应用包,Show in Finder,然后如图所示找到link map文件。
- 打开
link map文件,可以发现符号(OC方法,C/C++函数等)是按类文件Build Phases -> Compile Sources里的编译顺序和文件内部的书写顺序进行排列的。
- 将
AppDelegate.m文件的编译顺序移到第一位,并添加两个方法,来验证一下。
Command + B再次编译项目,查看link map文件,可以发现经过修改之后,符号顺序也随之改变了。
从上面Page Fault和符号排列顺序的案例,我们是否可以有一个猜想:微信启动时需要加载4000+ Page的内存数据,但可能并不是每页的所有数据都是启动时必须要用到的。
那么可以得出:启动时Page Fault次数过多的根本原因是启动时需要调用的符号(OC方法,C/C++函数等)处于不同Page导致的。因此,我们的优化思路是将启动时需要调用的符号集中排列到前面,组成启动所需的若干Page。这样就避免了启动时需要调用的符号过于分散导致大量Page Fault的问题,减少了大量Page Fault的次数,提升了启动速度。这就是二进制重排的核心原理。
三: 二进制重排实践
前面已经了解了二进制重排的相关原理,我们可以生成一个.order的文件,并将路径配置到Build Setting -> Order Files中,将所需要的符号按照顺序写入文件里面,编译时编译器会按照文件里的符号顺序对二进制进行重排,以此来达到优化的效果。所以,二进制重排的本质就是对启动加载的符号进行重新排列。
现在优化方案有了,如果项目较小,可以按照想要的启动流程将符号顺序添加到.order文件中(小项目不建议进行二进制重排,可能达不到启动优化的效果),但是如果项目较大,启动时涉及的符号特别多,此时我们如何获取启动时调用的所有符号呢?有以下几种思路:
-
hook objc_msgSend:OC方法的本质是发送消息,在底层都会调用objc_msgSend,但是objc_msgSend参数是可变的,需要通过汇编获取,对开发人员要求较高,而且也只能获取到OC和Swift中@objc后的方法。 -
静态扫描:扫描Mach-O特定段和节里面所存储的符号以及符号数据。 -
Clang插桩:可以实现100%符号覆盖,即完全获取C/C++、Swift、OC、block相关符号。
3.1: Clang插桩
llvm内置了一个简单的代码覆盖率检测(SanitizerCoverage)。它在函数级、基本块级和边缘级插入对用户定义函数的调用。我们这里的批量hook,就需要借助于SanitizerCoverage。
关于clang的插桩覆盖的官方文档如下 : clang 自带代码覆盖工具 文档中有详细概述,以及简短Example演示。
3.1.1: 第一步:配置
配置参数,开启SanitizerCoverage。
OC项目需要在Build Setting -> Other C Flags添加-fsanitize-coverage=func,trace-pc-guard。
Swift项目还需要额外在Build Setting -> Other Swift Flags中加入-sanitize-coverage=func和-sanitize=undefined。
- 如果想完全覆盖包括三方库的所有调用,则链接到
APP中的二进制都需要开启SanitizerCoverage。也可以通过podfile来配置参数。
post_install do |installer|
installer.pods_project.targets.each do |target|
target.build_configurations.each do |config|
config.build_settings['OTHER_CFLAGS'] = '-fsanitize-coverage=func,trace-pc-guard'
config.build_settings['OTHER_SWIFT_FLAGS'] = '-sanitize-coverage=func -sanitize=undefined'
end
end
end
3.1.2: 第二步:重写回调函数
新建一个OC文件XJOrderCallback,重写两个回调函数。
-
__sanitizer_cov_trace_pc_guard_init函数start和stop是指针,指向unsigned int类型,4个字节,对应的是可执行文件的begin和end(Example的for循环将符号的数量存在里面),stop - 0x4(stop本身为结束标记)获取最后一个数据,就是项目符号数量(不包括三方库等,此处三方库未开启SanitizerCoverage)。
- 添加两个符号(一个函数,一个
block),再次查看stop。
-
__sanitizer_cov_trace_pc_guard函数。- 只要开启了
SanitizerCoverage,编译器就会在所有符号的代码实现的“边缘”添加这个函数的调用。 - 此函数中可以获取所有启动时的符号地址,定义节点,借助链表存储起来。
-
通过
OSQueueHead创建原子队列,其目的是保证读写安全。 -
定义链表节点
XJNode。 -
通过
OSAtomicEnqueue方法将node入队,通过链表的next指针访问下一个node。
-
- 只要开启了
//原子队列,其目的是保证写入安全,线程安全
static OSQueueHead symbolList = OS_ATOMIC_QUEUE_INIT;
// 定义符号结构体,以链表的形式
typedef struct {
void *pc;
void *next;
}XJNode;
/*
`stop - 0x4`(`stop`本身为结束标记)获取最后一个数据,就是项目符号数量
*/
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;
}
}
/*
可以hook所有符号
*/
void __sanitizer_cov_trace_pc_guard(uint32_t *guard) {
// if (!*guard) return; // 将load方法过滤掉了,所以需要注释掉
// 获取PC
/*
- PC 当前函数返回上一个调用的地址
- 0 当前这个函数地址,即当前函数的返回地址
- 1 当前函数调用者的地址,即上一个函数的返回地址
*/
void *PC = __builtin_return_address(0);
// 创建node,并赋值
XJNode *node = malloc(sizeof(XJNode));
*node = (XJNode){PC, NULL};
// 入队
// 符号的访问不是通过下标访问,是通过链表的next指针,所以需要借用offsetof(结构体类型,下一个的地址即next)
OSAtomicEnqueue(&symbolList, node, offsetof(XJNode, next));
}
3.1.3: 第三步:获取启动时所有符号,并写入文件
-
while循环从队列中取出node,转换成符号,非OC方法的符号添加前缀,存入数组。 -
数组
取反,因为入队存储的顺序是反序的。 -
数组
去重,并移除当前函数的符号。 -
将数组转换成字符串并写入到
xj.order文件。
extern void getOrderFile(void(^completion)(NSString *orderFilePath)) {
__sync_synchronize();
// 当前函数的符号
NSString *functionExclude = [NSString stringWithFormat:@"_%s", __FUNCTION__];
// 创建符号数组
NSMutableArray<NSString *> *symbolNames = [NSMutableArray array];
// while循环取符号
while (YES) {
// 出队,出一个,少一个
XJNode *node = OSAtomicDequeue(&symbolList, offsetof(XJNode, next));
if (node == NULL) break;
// 取出地址PC,转换成Dl_info
Dl_info info;
dladdr(node->pc, &info);
// printf("%s \n", info.dli_sname);
if (info.dli_sname) {
// 判断是不是OC方法,如果不是,需要加下划线存储,反之,则直接存储
NSString *name = @(info.dli_sname);
BOOL isObjc = [name hasPrefix:@"+["] || [name hasPrefix:@"-["];
NSString *symbolName = isObjc ? name : [@"_" stringByAppendingString:name];
[symbolNames addObject:symbolName];
}
}
if (symbolNames.count == 0) {
if (completion) {
completion(nil);
}
return;
}
// 取反(队列的存储是反序的)
NSEnumerator *emt = [symbolNames reverseObjectEnumerator];
// 去重
NSMutableArray<NSString *> *funcs = [NSMutableArray arrayWithCapacity:symbolNames.count];
NSString *name;
while (name = [emt nextObject]) {
if (![funcs containsObject:name]) {
[funcs addObject:name];
}
}
// 去掉自己
[funcs removeObject:functionExclude];
// 将数组变成字符串
NSString *funcStr = [funcs componentsJoinedByString:@"\n"];
NSLog(@"Order:\n%@", funcStr);
// 字符串写入文件
NSString *filePath = [NSTemporaryDirectory() stringByAppendingPathComponent:@"XJ.order"];
NSData *fileContents = [funcStr dataUsingEncoding:NSUTF8StringEncoding];
BOOL success = [[NSFileManager defaultManager] createFileAtPath:filePath contents:fileContents attributes:nil];
if (completion) {
completion(success ? filePath : nil);
}
}
3.1.4: 第四步:启动完成之后调用
需要注意的是,调用位置是由你决定的。一般来说为首界面渲染完成之时。
注意:如果
Other C Flags配置的是-fsanitize-coverage=trace-pc-guard的话,会产生死循环,因为对while循环也进行了拦截,每次循环都会将while循环所在的符号入队一次,所以链表里面永远不为空,就造成了死循环。所以请务必使用-fsanitize-coverage=func,trace-pc-guard,表示只对func进行拦截。
此时xj.order中的符号列表为:
3.1.5: 第五步:拷贝文件,放入指定位置,并配置路径
- 本文生成
order文件指定的是真机的沙盒目录,所以Xcode -> Devices and Simulators选择运行的设备下载沙盒文件。
- 然后在工程目录下找到沙盒文件,右键选择显示包内容,然后根据路径找到
xj.order文件。
- 将
xj.order拷贝到工程根目录下。
-
在
Build Settings -> Order File中配置./xj.order。 -
下面是配置前后的
Link Map File对比(上边是配置前的符号顺序,下边是配置后的符号顺序)。
根据前后的Link Map File对比,可以发现可执行文件确实根据我们的配置进行了二进制重排。