iOS启动优化之二进制重排
一、二进制重排介绍
1、App启动
进程如果能直接访问物理内存无疑是很不安全的,所以操作系统在物理内存的上又建立了一层虚拟内存。苹果在这个基础上还有 ASLR(Address Space Layout Randomization) 技术的保护,不过不是这次的重点。
iOS系统中虚拟内存到物理内存的映射都是以页为最小单位的。当进程访问一个虚拟内存Page而对应的物理内存却不存在时,就会出现Page Fault缺页中断,然后加载这一页。虽然本身这个处理速度是很快的,但是在一个App的启动过程中可能出现上千(甚至更多)次Page Fault,这个时间积累起来会比较明显了
另外,还有两个重要的概念:冷启动、热启动。可能有些同学认为杀掉再重启App就是冷启动了,其实是不对的。
- 冷启动:程序完全退出,之间加载的分页数据被其他进程所使用覆盖之后,或者重启设备、第一次安装,才算是冷启动。
- 热启动:程序杀掉之后,马上又重新启动。这个时候相应的物理内存中仍然保留之前加载过的分页数据,可以进行重用,不需要全部重新加载。所以热启动的速度比较快。
2、二进制重排原理
编译器在生成二进制代码的时候,默认按照链接的Object File(.o)顺序写文件,按照Object File内部的函数顺序写函数。
静态库文件.a就是一组.o文件的ar包,可以用
ar -t
查看.a包含的所有.o。
简化问题:假设我们只有两个page:page1/page2,其中绿色的method1和method5启动时候需要调用,为了执行对应的代码,系统必须进行两个Page Fault。
但如果我们把method1和method5排布到一起,那么只需要一个Page Fault即可,这就是二进制文件重排的核心原理。
图1:二进制重排原理.png实际项目中的做法是将启动时需要调用的函数放到一起 ( 比如 前10页中 ) 以尽可能减少 page fault , 达到优化目的 . 而这个做法就叫做 : 二进制重排 。
注意:在iOS生产环境的app,在发生Page Fault进行重新加载时,iOS系统还会对其做一次签名验证,因此 iOS 生产环境的 Page Fault 比Debug环境下所产生的耗时更多
二、重排的order文件
1、文件顺序
Build Phases 中 Compile Sources 列表顺序决定了文件执行的顺序(可以调整)。如果不进行重排,文件的顺序决定了方法、函数的执行顺序。我们在 ViewController 和 AppDelegate 中加入以下代码:
+ (void)load {
NSLog(@"%s", __FUNCTION__);
}
我们调整 Compile Sources 中这两个类的顺序,然后分别执行对比。可以看到,随着 Compile Sources 中的文件顺序的修改,+load 方法的执行顺序也发生了改变。
图2:文件执行顺序.png2、符号表顺序
Link Map
是iOS编译过程的中间产物,记录了二进制文件的布局,需要在Xcode的Build Settings
里开启Write Link Map File
,Link Map
主要包含三部分:
-
Object Files
生成二进制用到的link单元的路径和文件编号 -
Sections
记录Mach-O每个Segment/section的地址范围 -
Symbols
按顺序记录每个符号的地址范围
1)Build Settings中修改Write Link Map File为YES
2)查找Link Map符号表txt
文件
编译后会生成一个Link Map符号表txt
文件,选择Product中的App,在Finder中打开,选择Intermediates.noindex文件夹,找到LinkMap文件,这里是ZJHBinaryLaunchDemo-LinkMap-normal-x86_64.txt。。详细路径请看下图。
3)查看Link Map符号表txt
文件
打开文件之后来到第一部分的最后。我们可以看到这个顺序和我们Compile Sources中的顺序是一致的。接下来的部分:
图4:查看符号表文件.png可以看到,整体的顺序和Compile Sources的中的顺序是一样的,并且方法是按照文件中方法的顺序进行链接的。ViewController中的方法添加完后,才是AppDelegate中的方法,以此类推。
-
Address
� 表示文件中方法的地址。 -
Size
表示方法的大小。 -
File
表示在第几个文件中。 -
Name
表示方法名。
3、导入order文件
ld
是Xcode使用的链接器,有一个参数order_file
,我们可以通过在Build Settings -> Order File
配置一个后缀为order的文件路径.在这个order文件中,将所需要的符号按照顺序写在里面,在项目编译时,会按照这个文件的顺序进行加载,以此来达到我们的优化
来到工程根目录 , 新建一个文件 touch ZJHBinaryLaunchDemo.order
. 随便挑选几个启动时就需要加载的方法 , 例如我这里选了以下几个 .
+[AppDelegate load]
+[ViewController load]
_main
-[ViewController someMethod]
然后在Build Settings中找到Order File,填入./ZJHBinaryLaunchDemo.order
。然后重新比纳音,再次查看生成符号表txt
文件。
可以看到Link Map中的最上面几个方法和我们在ZJHBinaryLaunchDemo.order文件中设置的方法顺序一致!
Xcode的连接器ld还忽略掉了不存在的方法 -[ViewController someMethod]。如果提供了link选项 -order_file_statistics,会以warning的形式把这些没找到的符号打印在日志里。
注意:有部分同学可能配置完运行会发现报错说
can't open
这个order file
. 是因为文件格式的问题 . 不用使用mac
自带的文本编辑 . 使用命令工具touch
创建即可 .
三、检测启动时方法
要真正的实现二进制重排,我们需要拿到启动时所有用到的方法、函数等符号,并保存其顺序,然后写入order
文件,实现二进制重排。这里我们使用Clang插桩
的方式
1、Clang插桩原理
其实就是一个代码覆盖工具,更多信息可以查看官网。
1)首先 , 添加编译设置
Build Settings中 Other C Flags添加配置
-fsanitize-coverage=trace-pc-guard
编译的话会报以下错误,意思是找不到这两个函数
Undefined symbol: ___sanitizer_cov_trace_pc_guard_init
Undefined symbol: ___sanitizer_cov_trace_pc_guard
2)添加 hook
代码
我们把面的代码,添加到 ViewController.m
中
#include <stdint.h>
#include <stdio.h>
#include <sanitizer/coverage_interface.h>
void __sanitizer_cov_trace_pc_guard_init(uint32_t *start, uint32_t *stop) {
static uint64_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) {
if (!*guard) return; // Duplicate the guard check.
// void *PC = __builtin_return_address(0);
char PcDescr[1024];
// __sanitizer_symbolize_pc(PC, "%p %F %L", PcDescr, sizeof(PcDescr));
printf("guard: %p %x PC %s\n", guard, *guard, PcDescr);
}
3)运行工程 , 查看打印
INIT: 0x1024153e0 0x102415420
guard: 0x1024153f8 7 PC
guard: 0x1024153ec 4 PC �
guard: 0x102415414 e PC
guard: 0x102415418 f PC �
guard: 0x102415414 e PC
guard: 0x102415414 e PC
guard: 0x1024153fc 8 PC $
guard: 0x102415414 e PC
guard: 0x102415414 e PC �
guard: 0x102415414 e PC \300\202\3605�
代码命名 INIT
后面打印的两个指针地址叫 start
和 stop
. 那么我们通过 lldb
来查看下从 start
到 stop
这个内存地址里面所存储的到底是啥 .
4)验证 start
到 stop
内存地址存储值
在viewDidLoad
方法中添加断点,执行项目。在lldb
分别输入 x start地址
和 x stop地址-0x4
,读取内存地址。 x stop地址-0x4
是结束位置,按显示是4位的,所以向前移动4位,打印出来的应该就是最后一位。
发现存储的是从 1 到 16(0x10) 这个序号 ,我们再新增两个方法,再次运行查看:
INIT: 0x102fb93f0 0x102fb9438
(lldb) x 0x102fb9438-0x4
0x102fb9434: 12 00 00 00 f0 92 0c 03 01 00 00 00 00 00 00 00 ................
0x102fb9444: 00 00 00 00 5f 33 fb 02 01 00 00 00 00 00 00 00 ...._3..........
(lldb)
发现从 0x10
变成了 0x12
. 也就是说存储的 1 到 16 这个序号变成了 1 到 18 。那么我们得到一个猜想 , 这个内存区间保存的就是工程所有符号的个数 。
5)验证guard
调用次数
在 touchesBegan
方法中,打印语句,点击屏幕。每点击一次就会调用一次 guard :
。而且 guard :
是在前面调用的。
- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
NSLog(@"touchesBegan");
}
// 控制台的输出
guard: 0x1006053fc 4 PC �
2021-04-21 13:29:51.936925+0800 ZJHBinaryLaunchDemo[13644:5077278] touchesBegan
在 touchesBegan
方法中,执行调用test1
方法,会发现 guard :
调用了两次
- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
NSLog(@"touchesBegan");
[self test1];
}
- (void)test1 {
NSLog(@"test1");
}
// 控制台的输出
guard: 0x102d893f8 3 PC �
2021-04-21 13:31:57.152720+0800 ZJHBinaryLaunchDemo[13646:5077923] touchesBegan
guard: 0x102d893fc 4 PC d�\330�\201\226P\314\370\223\330��
2021-04-21 13:31:57.152915+0800 ZJHBinaryLaunchDemo[13646:5077923] test1
由此,发现我们实际调用几个方法 , 就会打印几次 guard :
。
6)查看汇编代码验证
我们在 touchesBegan:touches withEvent:
开头设置一个断点,并开启汇编显示(菜单栏Debug → Debug Workflow → Always Show Disassembly
)。
通过汇编我们发现 , 在每个函数调用的第一句实际代码 ( 栈平衡与寄存器数据准备除外 ) , 被添加进去了一个 bl 调用到 __sanitizer_cov_trace_pc_guard
这个函数中来 。而实际上这也是静态插桩的原理和名称由来 。
静态插桩总结:静态插桩实际上是在编译期就在每一个函数内部二进制源数据添加
hook
代码 ( 我们添加的__sanitizer_cov_trace_pc_guard
函数 ) 来实现全局的方法hook
的效果 .
2、获取所有函数符号
1)获取原函数地址
我们在 __sanitizer_cov_trace_pc_guard
函数中的这一句代码 :
void *PC = __builtin_return_address(0);
它的作用其实就是去读取 x30
中所存储的要返回时下一条指令的地址 . 所以他名称叫做 __builtin_return_address
. 换句话说 , 这个地址就是我当前这个函数执行完毕后 , 要返回到哪里去 。也就是说 , 我们现在可以在 __sanitizer_cov_trace_pc_guard
这个函数中 , 通过 __builtin_return_address
数拿到原函数调用 __sanitizer_cov_trace_pc_guard
这句汇编代码的下一条指令的地址 。及上图中,拿到-[ViewController touchesBegan:withEvent:]
地址
2)根据内存地址获取函数名称
拿到了函数内部一行代码的地址 , 如何获取函数名称呢 ? 熟悉安全攻防 , 逆向的同学可能会清楚 . 我们为了防止某些特定的方法被别人使用 fishhook
hook
掉 , 会利用 dlopen
打开动态库 , 拿到一个句柄 , 进而拿到函数的内存地址直接调用 .是不是跟我们这个流程有点相似 , 只是我们好像是反过来的 . 其实反过来也是可以的 .
与 dlopen
相同 , 在 dlfcn.h
中有一个方法如下 :
typedef struct dl_info {
const char *dli_fname; /* 所在文件 */
void *dli_fbase; /* 文件地址 */
const char *dli_sname; /* 符号名称 */
void *dli_saddr; /* 函数起始地址 */
} Dl_info;
//这个函数能通过函数内部地址找到函数符号
int dladdr(const void *, Dl_info *);
紧接着我们来实验一下 , 先导入头文件#import <dlfcn.h>
, 然后修改代码如下 :
void __sanitizer_cov_trace_pc_guard(uint32_t *guard) {
if (!*guard) return; // Duplicate the guard check.
void *PC = __builtin_return_address(0);
Dl_info info;
dladdr(PC, &info);
printf("fname=%s \nfbase=%p \nsname=%s\nsaddr=%p \n",info.dli_fname,info.dli_fbase,info.dli_sname,info.dli_saddr);
char PcDescr[1024];
printf("guard: %p %x PC %s\n", guard, *guard, PcDescr);
}
查看打印结果,可以看到我们要找的符号了 :
图8:根据内存地址获取函数名称.png3、可能遇到的问题
1)多线程问题
项目各个方法肯定有可能会在不同的函数执行 , 因此 __sanitizer_cov_trace_pc_guard
这个函数也有可能受多线程影响 , 所以你当然不可能简简单单用一个数组来接收所有的符号就搞定了。考虑到这个方法会来特别多次 , 使用锁会影响性能 , 这里使用苹果底层的原子队列 ( 底层实际上是个栈结构 , 利用队列结构 + 原子性来保证顺序 ) 来实现 .
- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event{
//遍历出队
while (true) {
//offsetof 就是针对某个结构体找到某个属性相对这个结构体的偏移量
SymbolNode * node = OSAtomicDequeue(&symboList, offsetof(SymbolNode, next));
if (node == NULL) break;
Dl_info info;
dladdr(node->pc, &info);
printf("%s \n",info.dli_sname);
}
}
//原子队列
static OSQueueHead symboList = OS_ATOMIC_QUEUE_INIT;
//定义符号结构体
typedef struct{
void * pc;
void * next;
}SymbolNode;
void __sanitizer_cov_trace_pc_guard(uint32_t *guard) {
if (!*guard) return; // Duplicate the guard check.
void *PC = __builtin_return_address(0);
SymbolNode * node = malloc(sizeof(SymbolNode));
*node = (SymbolNode){PC,NULL};
//入队
// offsetof 用在这里是为了入队添加下一个节点找到 前一个节点next指针的位置
OSAtomicEnqueue(&symboList, node, offsetof(SymbolNode, next));
}
但此方法会导致死循环的问题
2)死循环问题
通过汇编会查看到 一个带有 while
循环的方法 , 会被静态加入多次 __sanitizer_cov_trace_pc_guard
调用 , 导致死循环.
Other C Flags
修改为如下 :
-fsanitize-coverage=func,trace-pc-guard
代表进针对 func
进行 hook
. 再次运行就可以了。
3) load 方法不打印问题
load
方法时 , __sanitizer_cov_trace_pc_guard
函数的参数 guard
是 0。上述打印并没有发现 load
.
解决 : 屏蔽掉 __sanitizer_cov_trace_pc_guard
函数中的
if (!*guard) return;
效果如下
void __sanitizer_cov_trace_pc_guard(uint32_t *guard) {
// if (!*guard) return; // Duplicate the guard check.
void *PC = __builtin_return_address(0);
SymbolNode * node = malloc(sizeof(SymbolNode));
*node = (SymbolNode){PC,NULL};
//入队
// offsetof 用在这里是为了入队添加下一个节点找到 前一个节点next指针的位置
OSAtomicEnqueue(&symboList, node, offsetof(SymbolNode, next));
}
// 打印结果
INIT: 0x104d8d400 0x104d8d444
-[ViewController touchesBegan:withEvent:]
-[SceneDelegate sceneDidBecomeActive:]
-[SceneDelegate sceneWillEnterForeground:]
-[ViewController viewDidLoad]
-[SceneDelegate window]
-[SceneDelegate window]
-[SceneDelegate window]
-[SceneDelegate scene:willConnectToSession:options:]
-[SceneDelegate window]
-[SceneDelegate window]
-[SceneDelegate setWindow:]
-[SceneDelegate window]
-[AppDelegate application:didFinishLaunchingWithOptions:]
main
+[AppDelegate load]
+[ViewController load]
这样的话,load
方法就有了。这里也为我们提供了一点启示:如果我们希望从某个函数之后/之前开始优化 , 通过一个全局静态变量 , 在特定的时机修改其值 , 在 __sanitizer_cov_trace_pc_guard
这个函数中做好对应的处理即可 .
4、符号信息写入文件
完整代码如下 :
#import "ViewController.h"
#import <dlfcn.h>
#import <libkern/OSAtomic.h>
@interface ViewController ()
@end
@implementation ViewController
+ (void)load{
}
- (void)viewDidLoad {
[super viewDidLoad];
testCFunc();
[self testOCFunc];
}
- (void)testOCFunc{
NSLog(@"oc函数");
}
void testCFunc(){
LBBlock();
}
void(^LBBlock)(void) = ^(void){
NSLog(@"block");
};
void __sanitizer_cov_trace_pc_guard_init(uint32_t *start,
uint32_t *stop) {
static uint64_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)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event{
NSMutableArray<NSString *> * symbolNames = [NSMutableArray array];
while (true) {
//offsetof 就是针对某个结构体找到某个属性相对这个结构体的偏移量
SymbolNode * node = OSAtomicDequeue(&symboList, offsetof(SymbolNode, next));
if (node == NULL) break;
Dl_info info;
dladdr(node->pc, &info);
NSString * name = @(info.dli_sname);
// 添加 _
BOOL isObjc = [name hasPrefix:@"+["] || [name hasPrefix:@"-["];
NSString * symbolName = isObjc ? name : [@"_" stringByAppendingString:name];
//去重
if (![symbolNames containsObject:symbolName]) {
[symbolNames addObject:symbolName];
}
}
//取反
NSArray * symbolAry = [[symbolNames reverseObjectEnumerator] allObjects];
NSLog(@"%@",symbolAry);
//将结果写入到文件
NSString * funcString = [symbolAry componentsJoinedByString:@"\n"];
NSString * filePath = [NSTemporaryDirectory() stringByAppendingPathComponent:@"lb.order"];
NSData * fileContents = [funcString dataUsingEncoding:NSUTF8StringEncoding];
BOOL result = [[NSFileManager defaultManager] createFileAtPath:filePath contents:fileContents attributes:nil];
if (result) {
NSLog(@"%@",filePath);
}else{
NSLog(@"文件写入出错");
}
}
//原子队列
static OSQueueHead symboList = OS_ATOMIC_QUEUE_INIT;
//定义符号结构体
typedef struct{
void * pc;
void * next;
}SymbolNode;
void __sanitizer_cov_trace_pc_guard(uint32_t *guard) {
//if (!*guard) return; // Duplicate the guard check.
void *PC = __builtin_return_address(0);
SymbolNode * node = malloc(sizeof(SymbolNode));
*node = (SymbolNode){PC,NULL};
//入队
// offsetof 用在这里是为了入队添加下一个节点找到 前一个节点next指针的位置
OSAtomicEnqueue(&symboList, node, offsetof(SymbolNode, next));
}
@end
文件写入到了 tmp
路径下 , 运行 , 打开手机下载查看 :
5、swift 工程 / 混编工程问题
通过如上方式适合纯 OC
工程获取符号方式 .由于 swift
的编译器前端是自己的 swift
编译前端程序 , 因此配置稍有不同 .
搜索 Other Swift Flags
, 添加两条配置即可 :
-sanitize-coverage=func
-sanitize=undefined
swift
类通过上述方法同样可以获取符号 .
四、验证
1、打印启动时间
在系统执行应用程序的main函数并调用应用程序委托函数(applicationWillFinishLaunching)之前,会发生很多事情。我们可以通过添加环境变量可以打印处APP的启动分析(Edit scheme -> Run -> Argument)
.
DYLD_PRINT_STATISTICS
设置为1
(dyld_print_statistics)。如果需要更详细的信息,那就将DYLD_PRINT_STATISTICS_DETAILS
设置为1
运行一下,对比控制台的输出(因笔者是在demo验证,时间优化的效果不明显,这里就不做对比展示了):
Total pre-main time: 97.73 milliseconds (100.0%)
dylib loading time: 28.02 milliseconds (28.6%)
rebase/binding time: 21.70 milliseconds (22.2%)
ObjC setup time: 5.16 milliseconds (5.2%)
initializer time: 42.85 milliseconds (43.8%)
slowest intializers :
libSystem.B.dylib : 6.26 milliseconds (6.4%)
libBacktraceRecording.dylib : 9.88 milliseconds (10.1%)
libMainThreadChecker.dylib : 22.10 milliseconds (22.6%)
ZJHBinaryLaunchDemo : 2.81 milliseconds (2.8%)
2、System Trace
日常开发中性能分析是用最多的工具无疑是Time Profiler,但Time Profiler是基于采样的,并且只能统计线程实际在运行的时间,而发生Page Fault的时候线程是被blocked,所以我们需要用一个不常用但功能却很强大的工具:System Trace。
选中主线程,在VM Activity中的File Backed Page In次数就是Page Fault次数,并且双击还能按时序看到引起Page Fault的堆栈:
图10:System Trace.jpeg五、CocoaPods相关
对于 cocoapod
工程引入的库 , 由于针对不同的 target
. 那么我们在主程序中的 target
添加的编译设置 Write Link Map File
, -fsanitize-coverage=func,trace-pc-guard
以及 order file
等设置肯定是不会生效的 . 解决方法就是针对需要的 target
去做对应的设置即可 .
对于直接手动导入到工程里的 sdk
, 不管是 静态库 .a
还是 动态库
, 默认主工程的设置就可以了 , 是可以拿到符号的 .
更多CocoaPods相关问题,可参考这篇文章:https://juejin.cn/post/6844904192193085448
参考链接:
iOS 启动优化之Clang插桩实现二进制重排
我是如何让微博绿洲的启动速度提升30%的
懒人版二进制重排
抖音研发实践:基于二进制文件重排的解决方案 APP启动速度提升超15%
懒人版二进制重排
手淘架构组最新实践 | iOS基于静态库插桩的⼆进制重排启动优化