启动优化 - 二进制重拍
iOS系统会对虚拟内存和物理内存进行分页,虚拟内存到物理内存的映射都是以页为最小单位的。
启动:
我们所说的启动时从点击icon 开始到 APP第一页显示。
冷启动:
程序完全退出(例杀掉进程后又开了许多其他的APP,这个时候程序所对应的物理内存已经被其他的程序占用,需要重新从虚拟内存加载到物理内存)或第一次安装启动(第一次安装 或 卸载后重装等)。
热启动:
程序杀掉之后,紧接着又启动,这个时候相应的物理内存中仍然存着我们的数据,不需要全部从虚拟内存重新加载。
程序启动,从虚拟内存加载到物理内存,是分页加载的。在虚拟内存中是一页一页的存储的数据(iOS 一页16KB)。如果启动程序的时候,物理内存中没有我们需要加载的内容,就会缺页中断从虚拟内存重新加载。如果少的话感知不出来,如果多的话就会影响到启动速度。
Instruments
xcode自带调试工具。打开xcode command + I 打开
调试工具有很多,这里使用 System Trace
直接双击打开,这里看到每个线程里的数据,这里需要查看的是线程的虚拟内存
重签名安装ipa 的时候先写一个demo 空工程,然后运行到手机上,然后修改将ipa安装上,目的是给手机添加描述文件安装这个ipa。已经安装过的描述文件已经存在手机上了,所以可以直接command + R
通过调试,第一次打开(冷启动)的时候加载了很多的文件,但是杀掉程序,直接再次启动(热启动)就会发现加载的少了很多。那么就可以知道虚拟内存加载到物理内存之后,再次启动的时候物理内存中有不需要再加载,启动就会很快。如果说杀掉程序,然后有打开了很多其他程序,再次打开我们的程序的时候,发现又是冷启动。可以分析之前加载的物理内存被其他应用程序占用掉了。
虚拟内存到物理内存,虚拟内存是连续的,通过连接器对应一片相应的物理内存,这个对应的物理内存不是固定的是根据情况找到空闲的内存开辟,或者没有空闲的时候去覆盖掉相应的一些内存。
File Backed Page In 加载的数据,并不是一个文件就占一页,一页是16KB,能放很多。
二进制重排
为什么要进行二进制重排,因程序是顺序执行的。
例如:
现在虚拟内存有6页, 1 2 3 4 5 6 ,而其中 2 4 6 是启动需要加载的,1 3 5 是启动不需要加载的。但是顺序执行就是将6页全部加载进去。二进制重排的目的就是将 2 4 6 页中需要启动加载的内容排到前面几页 其他再的顺序执行。
二进制重排难点是需要知道启动的符号顺序,然后将这些符号重新排列。
xcode 是可以配置的。 xcode使用连接器是 ld 他里面有一个 -order_file,只要写入进入,xcode 打包的时候就会根据这个文件里面的符号顺序进行打包。
如何看启动顺序
1、文件顺序
Build phases -> Compile Source 里面的顺序就是文件执行顺序(可调整)
如果不重排 修改的情况下 文件的顺便决定了方法、函数的顺序。里 A 页面在 B 页面之前,那么A 中的方法就在 B中方法之前执行。
2、符号表顺序。
Build Settings -> Write Link Map File -> YES 自动生成一个符号表
编译,编译完成之后就会把这个 link map 写进去。
然后打开查看
xcode Products -> filedome.app -> 右键Show In Finder -> 向上两个目录(与Products 同级)找到 Intermediates.noindex -> filedome.build -> Debug-iphoneos -> filedome.build -> filedome-LinkMap-normal-arm64.txt (找到这个以.text 结尾的文件)
4、Symbols
起始位置(代码地址) 占用多大空间(十六进制) 属于第几个.o文件 方法、函数等符号名称
# Address Size File Name
0x1000051C8 0x00000098 [ 1] +[LJLWeakProxy proxyWithTarget:]
0x100005260 0x0000006C [ 1] -[LJLWeakProxy methodSignatureForSelector:]
0x1000052CC 0x0000009C [ 1] -[LJLWeakProxy forwardInvocation:]
0x100005368 0x00000034 [ 1] -[LJLWeakProxy target]
0x10000539C 0x00000048 [ 1] -[LJLWeakProxy setTarget:]
0x1000053E4 0x0000003C [ 1] -[LJLWeakProxy .cxx_destruct]
0x100005420 0x000000E0 [ 2] -[LJLGCDTwoViewController viewDidLoad]
0x100005500 0x00000014 [ 2] -[LJLGCDTwoViewController createGroup]
0x100005514 0x000000C4 [ 2] -[LJLGCDTwoViewController groupRest1]
0x1000055D8 0x0000003C [ 2] ___37-[LJLGCDTwoViewController groupRest1]_block_invoke
0x100005614 0x0000003C [ 2] ___37-[LJLGCDTwoViewController groupRest1]_block_invoke_2
0x100005650 0x0000002C [ 2] ___37-[LJLGCDTwoViewController groupRest1]_block_invoke_3
0x10000567C 0x000001A0 [ 2] -[LJLGCDTwoViewController groupRest2]
0x10000581C 0x00000054 [ 2] ___37-[LJLGCDTwoViewController groupRest2]_block_invoke
开始二进制重排
1、创建 .order
终端 打开项目根目录, 输入:touch liujilou.order 回车。在根目录下就生成了一个 liujilou.order (根据自己的项目情况进行命名)文件。
2、xcode 配置
Build -> Settings -> Order File ./liujilou.order (在根目录配的是相对路径)
@implementation ViewController
//随便写一些代码
//函数
void test()
{
block1();
}
void(^block1)(void) = ^(void){
};
......
//例如 :编辑 liujilou.order
_main
-[LJLWeakProxy setTarget:]
-[LJLWeakProxy .cxx_destruct]
-[liujilou hello]
command + K 清一下缓存,然后 command + B 编译
# Symbols:
# Address Size File Name
0x1000051C8 0x000000A4 [ 11] _main
0x10000526C 0x00000048 [ 1] -[LJLWeakProxy setTarget:]
0x1000052B4 0x0000003C [ 1] -[LJLWeakProxy .cxx_destruct]
0x1000052F0 0x00000098 [ 1] +[LJLWeakProxy proxyWithTarget:]
0x100005388 0x0000006C [ 1] -[LJLWeakProxy methodSignatureForSelector:]
0x1000053F4 0x0000009C [ 1] -[LJLWeakProxy forwardInvocation:]
0x100005490 0x00000034 [ 1] -[LJLWeakProxy target]
0x1000054C4 0x000000E0 [ 2] -[LJLGCDTwoViewController viewDidLoad]
0x1000055A4 0x00000014 [ 2] -[LJLGCDTwoViewController createGroup]
0x1000055B8 0x000000C4 [ 2] -[LJLGCDTwoViewController groupRest1]
0x10000567C 0x0000003C [ 2] ___37-[LJLGCDTwoViewController
对比上面的结果,可以看到现在的启动顺序就是我们编辑的 .order 的顺序
_main
-[LJLWeakProxy setTarget:]
-[LJLWeakProxy .cxx_destruct]
其中我们的项目中并没有这个方法,即便是我们写入到了order 中,不会报错会直接忽略。所以不用担心会写错而出现问题的情况。
-[liujilou hello]
扩展:
查看符号的其他方法 nm(不严谨,顺序并不是完全按照地址排列的)
终端:
//打开自己APP 目录,并不是让输入 ../
cd ... /filedome.app
nm filedome
nm -p filedome 书序排列,但是也不是完全按照启动顺序排列的
nm -u filedome 只看系统的方法等
nm -U filedome 只看自定义的方法等
不准,所以还是以 link map 为准。
要真正的实现二进制重排,我们需要拿到启动的所有方法、函数等符号,并保存其顺序,然后写入 order 文件,实现二进制重排。抖音有一篇文章,但是并不能hook 到所有的,具体看文章吧。
抖音研发实践:基于二进制文件重排的解决方案 APP启动速度提升超15%
其思路:
所有的OC 方法都会走 objc_msgsend() 这个系统函数,那么就可以用 fishhook 去 hook objc_msgSend() 就可以拿到所有OC方法的调用,然后再 hook 的这里面拿出 objc_msgsend() 的第二个参数(SEL)就拿到了所有的符号。
但是这个样做是有坑的,因为objc_msgSend() 是可变参数,只能写汇编 通过寄存器 保持栈平衡。如果不懂iOS ram 汇编的这可以不用深入去探究了,学习一下fishhook 即可。
而且这样 initialize hook 不到
部分 block hook 不到
C++ 通过寄存器的间接函数调用静态扫描不出来。
能重排80% ~ 90%。需要100%的话不建议这样做。
想要完全hook 到所有的符号,我们使用
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
这是因为添加完 -fsanitize-coverage=trace-pc-guard ,系统会在所有的方法中默认添加 ___sanitizer_cov_trace_pc_guard。看clang 的文档。找到这两个函数加到项目中。
extern "C" 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.
}
extern "C" void __sanitizer_cov_trace_pc_guard(uint32_t *guard) {
if (!*guard) return; // Duplicate the guard check.
// If you set *guard to 0 this code will not be called again for this edge.
// Now you can get the PC and do whatever you want:
// store it somewhere or symbolize it and print right away.
// The values of `*guard` are as you set them in
// __sanitizer_cov_trace_pc_guard_init and so you can make them consecutive
// and use them to dereference an array or a bit vector.
void *PC = __builtin_return_address(0);
char PcDescr[1024];
// This function is a part of the sanitizer run-time.
// To use it, link with AddressSanitizer or other sanitizer.
__sanitizer_symbolize_pc(PC, "%p %F %L", PcDescr, sizeof(PcDescr));
printf("guard: %p %x PC %s\n", guard, *guard, PcDescr);
}
当然 __sanitizer_symbolize_pc() 还会报错,不重要先注释了然后继续。
现在启动项目,启动完成后暂停。然后打印一下
INIT: 0x1029135e8 0x102913e70
......
(lldb) x 0x1029135e8
0x1029135e8: 01 00 00 00 02 00 00 00 03 00 00 00 04 00 00 00 ................
0x1029135f8: 05 00 00 00 06 00 00 00 07 00 00 00 08 00 00 00 ................
(lldb) x 0x102913e70
0x102913e70: 98 cb 90 02 01 00 00 00 00 00 00 00 00 00 00 00 ................
0x102913e80: ca 98 90 02 01 00 00 00 00 00 00 00 00 00 00 00 ................
(lldb) x 0x102913e70-0x4
0x102913e6c: 22 02 00 00 98 cb 90 02 01 00 00 00 00 00 00 00 "...............
0x102913e7c: 00 00 00 00 ca 98 90 02 01 00 00 00 00 00 00 00 ................
最后一个打印的是结束位置,按显示是4位4位的,所以向前移动4位,打印出来的应该就是最后一位。
小端模式 22 02 00 00 就是十六进制的222.启动加载了十进制546个符号。
在viewController 加上 load 方法(不一定非得是load,load启动时加载,主要是要看看这个是不是我猜想的打印出来启动符号数量)
INIT: 0x10286b610 0x10286be9c
......
(lldb) x 0x10286b610
0x10286b610: 01 00 00 00 02 00 00 00 03 00 00 00 04 00 00 00 ................
0x10286b620: 05 00 00 00 06 00 00 00 07 00 00 00 08 00 00 00 ................
(lldb) x 0x10286be9c
0x10286be9c: 00 00 00 00 98 4b 86 02 01 00 00 00 00 00 00 00 .....K..........
0x10286beac: 00 00 00 00 c7 18 86 02 01 00 00 00 00 00 00 00 ................
(lldb) x 0x10286be9c-0x4
0x10286be98: 23 02 00 00 00 00 00 00 98 4b 86 02 01 00 00 00 #........K......
0x10286bea8: 00 00 00 00 00 00 00 00 c7 18 86 02 01 00 00 00 ................
发现变了,现在是23 02 00 00多了1个。
- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event
{
test();
}
+ (void)initialize
{
}
void(^block)(void) = ^(void) {
};
void test(){
block();
}
点一次屏幕就打印三次。
guard: 0x10de29ae0 f5 PC (\3041
guard: 0x10de29ae4 f6 PC \211\352:
guard: 0x10de29ae8 f7 PC 0/\337\341\376
使用initialize 、block 、 函数都会增加的,所以这个方法可以能hook 到所有的符号。
然后汇编查看
filedome`-[ViewController touchesBegan:withEvent:]:
.....
0x10214cca4 <+40>: bl 0x10214ce44 ; __sanitizer_cov_trace_pc_guard at ViewController.m:117
0x10214cca8 <+44>: ldr x8, [sp, #0x28]
bl 汇编中的意思是跳转指令,跳转到 地址 0x10214ce44
; 分号在汇编中是注释的意思 注释内容说明这个地址对的是 __sanitizer_cov_trace_pc_guard 在ViewController.m 中的第 117 行
filedome`test:
-> 0x100df0d2c <+0>: stp x29, x30, [sp, #-0x10]!
0x100df0d30 <+4>: mov x29, sp
0x100df0d34 <+8>: adrp x8, 23
0x100df0d38 <+12>: add x0, x8, #0x9d8 ; =0x9d8
0x100df0d3c <+16>: bl 0x100df0e44 ; __sanitizer_cov_trace_pc_guard at ViewController.m:117
filedome`block1_block_invoke:
0x102becd60 <+0>: sub sp, sp, #0x30 ; =0x30
0x102becd64 <+4>: stp x29, x30, [sp, #0x20]
0x102becd68 <+8>: add x29, sp, #0x20 ; =0x20
0x102becd6c <+12>: adrp x8, 23
0x102becd70 <+16>: add x8, x8, #0x9dc ; =0x9dc
0x102becd74 <+20>: str x0, [sp, #0x8]
0x102becd78 <+24>: mov x0, x8
0x102becd7c <+28>: bl 0x102bece44 ; __sanitizer_cov_trace_pc_guard at ViewController.m:117
在test block 中都打上断点,然后一个一个过掉,发现都跳转了 __sanitizer_cov_trace_pc_guard。
当我们配置了 clang 的这样一个代码插入覆盖中去。会进行静态插桩,在方法、函数、block 内部计入了上面这一行代码。在边缘插入也就是先插入这行代码,新进行hook。
汇编有确定给的地址,当一个函数执行完之后返回的时候会把下一个要执行的函数地址保存到 x30寄存器中。
#import "ViewController.h"
#import <dlfcn.h>
#import <libkern/OSAtomic.h>
#import "filedome-Swift.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.
}
拿到了全部的符号之后需要保存,但是不能用数组,因为有可能会有在子线程执行的,所以用数组会有问题
//原子队列 1、先进后出 2、线程安全 3、只能保存结构体
static OSQueueHead symbolList = OS_ATOMIC_QUEUE_INIT;
//定义符号结构体
typedef struct {
void *pc;
void *next;//下一个数据的结构体指针地址
}SYNode;
void __sanitizer_cov_trace_pc_guard(uint32_t *guard) {
// if (!*guard) return; // Duplicate the guard check.
// 汇编一个函数执行完返回会将下一个要执行的函数地址给保存到 x30 寄存器中,将返回值给下一个函数。
// 所以这个函数的命名就是 返回 内存地址。 这里PC 就是拿到的内存地址
void *PC = __builtin_return_address(0);
SYNode * node = malloc(sizeof(SYNode));
*node = (SYNode){PC,NULL};
// 进入 入栈
// 最后一个参数是下一个数据的位置,也就是往上边的*node 的第二位插入
// offsetof(SYNode, next) next 成员在 结构体 SYNode 中的偏移值
OSAtomicEnqueue(&symbolList, node, offsetof(SYNode, next));
// dlopen 通过动态库拿到句柄 通过句柄拿到函数的内存地址
// dladdr 通过函数内存地址拿到函数
// typedef struct dl_info {
// const char *dli_fname; /* Pathname of shared object 函数的路径 */
// void *dli_fbase; /* Base address of shared object 函数的地址 */
// const char *dli_sname; /* Name of nearest symbol 函数符号 */
// void *dli_saddr; /* Address of nearest symbol 函数起始地址 */
// } Dl_info;
// Dl_info info;
// dladdr(PC, &info);
// printf("fnam:%s \n fbase:%p \n sname:%s \n saddr:%p \n",
// info.dli_fname,
// info.dli_fbase,
// info.dli_sname,
// info.dli_saddr);
//fnam:/Users/liujilou/Library/Developer/CoreSimulator/Devices/F8857F80-4697-496A-8FE8-255DA2056C17/data/Containers/Bundle/Application/B57CF494-5D7D-4044-87B4-949E971BD39D/filedome.app/filedome
//fbase:0x108a1d000
//sname:-[LGPerson setNum:]
//saddr:0x108a27ff0
// 拿到了全部的符号之后需要保存,但是不能用数组,因为有可能会有在子线程执行的,所以用数组会有问题
// 用原子队列 #import <libkern/OSAtomic.h>
}
存储完之后需要遍历取出,然后编辑符号表 order 文件。
- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event
{
NSMutableArray <NSString *>* symbolNames = [NSMutableArray array];
while (YES) {
SYNode * node = OSAtomicDequeue(&symbolList, offsetof(SYNode, 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];//c函数前面带下划线
[symbolNames addObject:symbolName];
printf("%s \n",info.dli_sname);
}
// symbolNames = [[symbolNames reverseObjectEnumerator] allObjects];//取反
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:[NSString stringWithFormat:@"%s",__FUNCTION__]];
NSString * funcStr = [funcs componentsJoinedByString:@"\n"];
NSString * filePath = [NSTemporaryDirectory() stringByAppendingPathComponent:@"liujilou.order"];
NSData * fileContents = [funcStr dataUsingEncoding:NSUTF8StringEncoding];
// 在路径上创建文件
[[NSFileManager defaultManager] createFileAtPath:filePath contents:fileContents attributes:nil];
NSLog(@"%@",filePath);
// 1、压栈先进后出
// 2、获取的符号会有重复
// 3、load 的首位是0,所以要把if (!*guard) return; 注释掉。由此可以知道如果我们想过来某些函数,可以类似这样操作
}
这个地方有个坑
直接 while (YES) {} 会死循环,一直在打印
-[ViewController touchesBegan:withEvent:]
-[ViewController touchesBegan:withEvent:]
......
打断点 通过查看汇编 查看,会发现
-[ViewController touchesBegan:withEvent:]: 中调用了3次
__sanitizer_cov_trace_pc_guard
cbnz x8,
这个是我们的while 循环,触发了 __sanitizer_cov_trace_pc_guard。
如果有 break b 就会跳出,如果没有继续上边的代码。
循环进来
OSAtomicEnqueue(&symbolList, node, offsetof(SYNode, next));
加 1 个
SYNode * node = OSAtomicDequeue(&symbolList, offsetof(SYNode, next));
取出来 就会少一个,然后又进入加 1个,在取出少一个。一直这样循环。
解决方法 Build -> Settings -> Other C Flags
-fsanitize-coverage=trace-pc-guard 加一个func,
-fsanitize-coverage=func,trace-pc-guard
printf();调用是不会跳转到这的,这是因为printf() 不是我们内部的方法。只会在我们内部的方法里面加上
这样编译通过运行程序,然后点击屏幕就会在沙盒中生成一个 liujilou.order 的文件。现在导出这个文件,把之前项目工程中的liujilou.order文件替换了。
Xcode查看真机app沙盒内容
打开菜单Window-> Devices And Simulators (快捷键 shift + command + 2)
在DEVICES选择连接的真机
替换完成之后再 command + B 编译一下然后去看一下顺序
# Symbols:
# Address Size File Name
0x100005710 0x000000C8 [ 11] _main
0x1000057D8 0x00000058 [ 23] -[AppDelegate window]
0x100005830 0x00000070 [ 23] -[AppDelegate setWindow:]
0x1000058A0 0x000001A0 [ 23] -[AppDelegate application:didFinishLaunchingWithOptions:]
0x100005A40 0x00000090 [ 10] -[ViewController .cxx_destruct]
0x100005AD0 0x0000066C [ 10] -[ViewController viewDidLoad]
0x10000613C 0x00000070 [ 10] -[ViewController setDataArr:]
0x1000061AC 0x00000068 [ 12] -[LGPerson setNum:]
0x100006214 0x00000068 [ 12] -[LGPerson setHeight:]
0x10000627C 0x00000068 [ 12] -[LGPerson setAge:]
0x1000062E4 0x00000074 [ 23] -[AppDelegate applicationDidBecomeActive:]
已经重排完,是我们启动的顺序
swift 的配置稍微不一样
为了测试,dome 增加如下 swift 代码。
// SwiftTest.swift
// filedome
//import Foundation
import UIKit
//简单点用UIKit
class SwiftTest: NSObject {
@objc class public func swiftTestLoad(){
print("swiftTest");
}
}
ViewController 导入头文件 并且调用一下
#import "filedome-Swift.h"
- (void)viewDidLoad {
[SwiftTest swiftTestLoad];
}
Build Setting -> Other Swift Flags(需要项目工程中有swift代码,否者是搜不到的)
-sanitize-coverage=func
-sanitize=undefined
运行打印看一下 打印
-[ViewController touchesBegan:withEvent:]
-[AppDelegate applicationDidBecomeActive:]
-[AppDelegate window]
// 下面的这4个就是swift 的
$ss5print_9separator10terminatoryypd_S2StFfA1_
$ss5print_9separator10terminatoryypd_S2StFfA0_
$s8filedome9SwiftTestC05swiftC4LoadyyFZ
$s8filedome9SwiftTestC05swiftC4LoadyyFZTo
-[LGPerson setAge:]
-[LGPerson setHeight:]
最后重排完二进制,生成了order 文件,配置完成了link map。这个时候我们的二进制重排就算重排完成,这时候需要把 Other C Flags / Other Swift Flags 的配置删除掉,因为这个配置会在我们代码中自动插入跳转执行 __sanitizer_cov_trace_pc_guard。重排完就不需要了,需要去除掉。
同时把我们 ViewController 中的 __sanitizer_cov_trace_pc_guard 也要去除掉。
优化的结果:
并不是很明显,这个主要优化的目的是在项目已经优化的没有更多可优化的空间的时候,同时项目又比较大的时候非常有必要进行一下二进制重排。