前言
前文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
对比,可以发现可执行文件确实根据我们的配置进行了二进制重排。