ios启动优化:二进制重排
通过前面的探讨,我们知道内存分页触发中断异常 Page Fault 后,会阻塞进程,这个问题是会对性能产生影响。
实际上在 iOS 系统中,生产环境的应用,在发生缺页中断进行重新加载时 ,iOS 系统还会对其做一次签名验证,因此 iOS 生产环境的 Page Fault
所产生的耗时要更多。
对用户而言,使用App时第一个直接体验就是启动 App 时间,而启动时期会有大量的类
、分类
、三方
等等需要加载和执行,此时多个 Page Fault
所产生的的耗时往往是不能小觑的,下面我们就通过二进制重排
来优化启动耗时。
抖音团队分享的一个
Page Fault
,开销在0.6 ~ 0.8ms
。实际测试发现不同页会有所不同 , 也跟 cpu 负荷状态有关 , 在0.1 ~ 1.0 ms
之间 。
二进制重排
这个方案最早也是 抖音团队 分享的,不过他们的解决方案有瑕疵,下面我们会针对性的解决。
一、原理
假设在启动时期我们需要调用两个函数 method1
与 method4
,函数编译在 mach-O
中的位置是根据 ld
( Xcode 的链接器) 的编译顺序
并非调用顺序来的,因此很可能这两个函数分布在不同的内存页上。
page1
与 page2
都需要从无到有加载到物理内存中,从而触发两次 Page Fault
。二进制重排 的做法就是将
method1
与 method4
放到一个内存页中,那么启动时则只需要加载一次 page 即可,也就是只触发一次 Page Fault
。在实际项目中,我们可以将启动时需要调用的函数放到一起 ( 比如 前10页中 ) 以尽可能减少
Page Fault
,进而减少启动耗时。
二、调试 Page Fault
最好是卸载App,重新安装,调试第一次启动的效果。
- 打开
Instruments
,选择System Trace
。 - 选择真机,选择工程,选择启动,当页面加载出来的时候,停止。
- 查看
Page Fault
,如图标注。
Page Fault.png
File Backed Page In
:即为 Page Fault,对应的有count,一页Page Fault最大耗时,最小耗时等参数。
如果多次启动调试,你会发现
count
的波动范围很大。所以如果想获取准确的数据,最好重新安装App或者打开多个App之后,再来调试。
这是因为内存管理机制,杀掉进程时,他所占用的物理内存空间,如果没有被覆盖使用,那么这部分内存有很大可能一直存在。重新打开,内存就不需要全部初始化。所以 冷热启动的界定不能以是否后台杀死来简单判断。
三、二进制重排
3.1 Order File
前面说了这么多,那么具体该怎么操作呢?苹果其实已经给我们提供了这个机制。
实际上 二进制重排就是对即将生成的可执行文件重新排列,即它发生在链接阶段。
首先,Xcode 用的链接器叫做
ld
,ld
有一个参数叫 Order File
,我们可以通过这个参数配置一个 后缀名
为order的文件路径。在这个 order 文件
中,将你需要的符号按顺序写在里面。当工程 build 的时候,Xcode 会读取这个文件,打的二进制包就会按照这个文件中的符号顺序进行生成对应的 mach-O
。可以参考一下
libObjc
项目,它已经使用了二进制重排
进行优化。
libobjc.order.png
是不是看到了ios应用启动加载过程中熟悉的方法。
1、order 文件里符号写错了或不存在会不会有问题:ld 会忽略这些符号,如果提供了 link 选项
-order_file_statistics
,他们会以 warning 的形式把这些没找到的符号打印在日志里。
2、会不会影响上架:不会,order文件只是重新排列了所生成的 mach-O
(可执行文件) 中函数表与符号表的顺序
。
3.2 如何查看项目符合顺序
- 可以设置
Write Link Map File
来设置是否输出,默认是no
。Link Map
是编译期间产生的 ,( ld 的读取二进制文件顺序默认是按照Compile Sources
里的顺序 ),它记录了二进制文件的布局。 - 修改
Write Link Map File
为YES
,然后clean项目并重新编译 -
Products -> show in finder
,上上层文件夹,然后找到一个xxxxx-LinkMap-normal-arm64.txt
的txt文件。
Link map.png
这个文件的# Symbols:
部分存储了所有符号的顺序,前面的.o
等内容忽略 。
Symbols.png
我们发现符号顺序明显是按照Compile Sources
的文件顺序来排列的。
文件中最左侧地址就是 方法真实实现地址(实际代码地址)而并非符号地址 , 因此我们二进制重排并非只是修改符号地址 , 而是利用符号顺序 , 重新排列整个代码在文件的偏移地址 , 将启动需要加载的方法地址放到前面内存页中 , 以此达到减少 page fault 的次数从而实现时间上的优化。
终端查看符号表命令(不准确,仅供参考)。找到可执行文件:
nm (file)
:查看符号表
nm -p (file)
:按照orderfile顺序
nm -up (file)
: 只看系统
nm -Up (file)
:只看自定义
3.3实战
1、 新建一个项目,添加方法:
binary.png 2、修改配置,编译,找到xxx.txt文件 截图.png3、新建一个order文件:
touch binary.order
,加入几个方法
-[ViewController test3]
-[ViewController test2]
-[ViewController test1]
4、修改Order File配置为:$(SRCROOT)/Binary/binary.order
或 ./Binary/binary.order
。
5、
clean
,编译
,再次查看xxx.txt
文件。截图.png oh my god,我们所写的这三个方法已经被放到最前面了,也就是说,这三个方法被放到了距离
mach-O
中首地址偏移量最小位置。假设这三个方法原本在不同的三页,那么意味着我们已经优化掉了两个 Page Fault。
3.4 获取启动执行的函数
到这里,离启动优化就只差一步了,如何获取启动运行的函数?大致有三种方案,仅供参考:
-
hook
objc_MsgSend
:只能拿到oc
以及swift @objc dynamic
后的方法,并且由于可变参数个数,需要用汇编来获取参数 。 - 静态扫描
machO
特定段和节里面所存储的符号以及函数数据。 -
clang 插桩:完全拿到
swift
、oc
、c
、block
全部函数。
四、Clang插桩
关于 clang
的插桩覆盖的官方文档如下 : clang 自带代码覆盖工具 文档中有详细概述,以及简短Demo演示。
思路:一是自己编写 clang 插件,另外一个就是利用 clang 本身已经提供的一个工具来实现我们获取所有符号的需求。
4.1 静态插桩代码
下面我们来探索一下这个静态插桩代码覆盖工具的机制和原理。
1、添加编译设置:直接搜索 Other C Flags
来到 Apple Clang - Custom Compiler Flags
中 , 添加配置:-fsanitize-coverage=trace-pc-guard
。
2、在ViewController.m添加代码:
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、运行(最好是一个空工程,注释我们前面手动添加的方法),查看打印:
trace-pc-guard.png 通过打印start
和stop
两个指针地址,会发现他存储的实际上是 1-15
几个序号。4、添加一个
oc
方法,我们再次打印start
和stop
指针,你会发现序号变为 1-16
。继续添加一个
c函数
,一个block
,一个touch函数
,是不是惊喜的发现,序号增加到 19
了。
89dfb9d8a201.png
此时,我们是不是可以大胆的猜想:这个内存区间保存的就是工程所有符号的个数。
5、继续,清空打印,点击屏幕。是不是发现有两次输出,看代码,此时有两次方法的调用。最终我们发现:调用几个方法,就会打印几次 guard:。
此时查看汇编,你会发现:在每个函数调用的第一句实际代码,会被添加进去了一个
bl
指令, 调用到__sanitizer_cov_trace_pc_guard
这个函数中来 。
bl
,汇编跳转指令,即调用方法。bl之前是栈平衡与寄存器数据准备,不用关心。
这就是静态插桩:静态插桩实际上是在编译期,在每一个函数内部第一行代码处,添加 hook 代码 ( 即我们添加的 __sanitizer_cov_trace_pc_guard 函数 ) ,实现全局的方法 hook,即AOP效果。
4.2 获取函数符号
通过上面的分析我们知道,所有函数的第一步都会调用__sanitizer_cov_trace_pc_guard,那我们是不是可以通过这个函数获取函数符号呢?
熟悉汇编的应该知道:函数嵌套时 , 在跳转子函数时,都会保存下一条指令的地址在 x30 ( 又叫 lr 寄存器) 里 。
例如 , A 函数中调用了 B 函数,在 arm 汇编中即 bl + 0x****
指令,该指令会首先将下一条汇编指令的地址保存在 x30
寄存器中。然后在跳转到 bl
后面传递的指定地址去执行。
bl
能实现跳转到某个地址的汇编指令,其原理就是修改 pc 寄存器的值来指向到要跳转的地址,而且实际上 B 函数中也会对 x29 / x30
寄存器的值做保护,防止子函数又跳转其他函数会覆盖掉 x30
的值 , 当然叶子函数除外。
当 B 函数执行 ret
也就是返回指令时,就会去读取 x30
寄存器的地址,跳转过去,因此也就回到了上一层函数的下一步。
在 __sanitizer_cov_trace_pc_guard
函数中的这一句代码:
void *PC = __builtin_return_address(0);
它的作用其实就是去读取 x30
中所存储的要返回时下一条指令的地址。所以他名称叫做 __builtin_return_address
。换句话说,这个地址就是我当前这个函数执行完毕后,要返回到哪里去。
bt
函数调用栈也是这种思路来实现的。也就是说 , 我们可以在 __sanitizer_cov_trace_pc_guard
这个函数中 , 通过 __builtin_return_address
函数拿到原函数调用 __sanitizer_cov_trace_pc_guard
这句汇编代码的下一条指令的地址。
如图,
PC
的指向就是,当test1
函数执行完__sanitizer_cov_trace_pc_guard
后,下一行代码NSLog
。
那么问题又来了,如果通过函数内部内存地址,获取函数名称呢?
熟悉安全攻防,逆向的同学可能会清楚。我们为了防止某些特定的方法被别人使用
fishhook hook
掉,会利用dlopen
打开动态库,拿到一个句柄,进而拿到函数的内存地址
直接调用。那我们可以反过来
使用。
与 dlopen.h
相同 , 在 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("\nfname:%s \nfbase:%p \nsname:%s\nsaddr:%p \n",info.dli_fname,info.dli_fbase,info.dli_sname,info.dli_saddr);
char PcDescr[1024];
//__sanitizer_symbolize_pc(PC, "%p %F %L", PcDescr, sizeof(PcDescr));
printf("guard: %p %x PC %s\n", guard, *guard, PcDescr);
}
打印结果:
fname:/Users/00393998/Library/Developer/CoreSimulator/Devices/23342248-4844-41AB-9851-2023D815FAA2/data/Containers/Bundle/Application/9A86A08B-5411-4909-B62B-B27097CA2EC9/Binary.app/Binary
fbase:0x10beee000
sname:-[ViewController touchesBegan:withEvent:]
saddr:0x10beef9d0
guard: 0x10bef468c 6 PC �
fname:/Users/00393998/Library/Developer/CoreSimulator/Devices/23342248-4844-41AB-9851-2023D815FAA2/data/Containers/Bundle/Application/9A86A08B-5411-4909-B62B-B27097CA2EC9/Binary.app/Binary
fbase:0x10beee000
sname:testFunc
saddr:0x10beef9b0
guard: 0x10bef4688 5 PC \367\371\356��
4.3 写入order文件
写入文件时有许多需要注意的地方,即坑点
1、多线程
考虑到这个方法会来特别多次,使用锁会影响性能,这里使用苹果底层的原子队列
( 底层实际上是个栈结构,利用队列结构 + 原子性
来保证顺序 ) 来实现。
- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event{
//遍历出队
while (true) {
//offset 通过next指针在结构体的偏移量,进而知道next的指向
//offsetof 就是针对某个结构体找到某个属性相对这个结构体的偏移量
// offsetof(SymbolNode, next) 可以替换为 8
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、死循环
上述这种 clang 插桩的方式,会在while循环中同样插入 hook 代码。
通过汇编会查看到 while 循环
,会被多次静态加入 __sanitizer_cov_trace_pc_guard
调用,导致死循环。
解决方式:Other C Flags
修改为如下:-fsanitize-coverage=func,trace-pc-guard
。func:表示仅 hook函数
时调用。
cbnz
:汇编执行,while循环。
3、load方法
有load
方法时,__sanitizer_cov_trace_pc_guard
函数的参数 guard
是 0
,所以打印并没有发现 load
。屏蔽掉 __sanitizer_cov_trace_pc_guard
函数中的:if (!*guard) return;
拓展:如果我们希望从某个函数之后/之前开始优化,那么我们可以通过一个全局静态变量,在特定的时机修改其值,在
__sanitizer_cov_trace_pc_guard
这个函数中做好对应的处理即可。
4、其他处理
- 由于用的先进后出原因 , 我们要
倒叙
一下 去重
-
order
文件格式要求:c函数
、block
前面还需要加_
下划线。
核心代码(不要忘记编译配置哦):
//引入头文件
#import <dlfcn.h>
#import <libkern/OSAtomic.h>
//核心代码
#pragma mark - 获取order文件
- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event{
NSMutableArray<NSString *> * symbolNames = [NSMutableArray array];
while (YES) {
//offsetof 就是针对某个结构体找到某个属性相对这个结构体的偏移量
SymbolNode * node = OSAtomicDequeue(&symbolList, 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:@"binary.order"];
NSData * fileContents = [funcString dataUsingEncoding:NSUTF8StringEncoding];
BOOL result = [[NSFileManager defaultManager] createFileAtPath:filePath contents:fileContents attributes:nil];
if (result) {
NSLog(@"%@",filePath);
}else{
NSLog(@"文件写入出错");
}
}
//原子队列
static OSQueueHead symbolList = OS_ATOMIC_QUEUE_INIT;
//定义符号结构体
typedef struct{
void * pc;
void * next;
}SymbolNode;
#pragma mark - 静态插桩代码
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);
SymbolNode * node = malloc(sizeof(SymbolNode));
*node = (SymbolNode){PC,NULL};
//入队
// offsetof 用在这里是为了入队添加下一个节点找到 前一个节点next指针的位置
OSAtomicEnqueue(&symbolList, node, offsetof(SymbolNode, next));
}
最后运行,下载.order
文件到本地,就可以愉快的玩耍了。
五、补充
5.1 swift / OC 混编工程问题
通过如上方式适合纯 OC
工程获取符号。由于 swift
的编译器前端是自己的 swift
编译前端程序,因此配置稍有不同。搜索 Other Swift Flags
,添加两条配置即可:-sanitize-coverage=func、 -sanitize=undefined
。swift类
同样可以通过这个方式获取。
5.2 cocoapod 工程问题
cocoapod
工程引入的库,会产生多 target
,我们在主target
添加的配置是不会生效的,我们需要针对需要的target
做对应的设置。
对于直接手动导入到工程里的 sdk
,不管是 静态库 .a
还是 动态库
,会默认使用主工程的设置,也就是可以拿到符号的。