参考链接: 抖音研发实践:基于二进制文件重排的解决方案 APP启动速度提升超15%
一、原理
1、虚拟内存和物理内存
早期计算机没有虚拟地址,一旦加载都会全部加载到内存中,而且进程都是按顺序排列的,这样别的进程只需要把自己的地址加一些就能访问到别的进程这样就很不安全。
现在软件发展的比硬件快,软件占用的内存越来越大,这就导致计算机的内存不够用,当开启多个软件时候,如果内存不够用就只能等待,只有等前面的软件关掉后才能加载打开,这就是早期计算机有时候为啥只有把前面的软件关掉才能打开新软件的原因。用户使用软件时候并不是使用到全部内存,只会使用到一部分,如果软件一打开就把软件全部加载到内存中,这样会很浪费内存空间。
基于上面原因虚拟内存技术出现了,软件打开后,软件自己以为有一大片内存空间,但实际上是虚拟的,而虚拟内存和物理内存是通过一张表来关联的,可以看下下面两张表:
image.png
进程1运行时候会开辟一块内存空间,但访问到内存条的时候并不是这块内存空间,而且通过访问地址通过进程1的映射表映射到不同的物理内存空间,这个叫地址翻译,这个过程需要CPU和操作系统配合,因为这个映射表是操作系统来管理的,当我们调试时候发现访问数据的内存地址都是连续的,其实这是一个假象,在这个进程内部可以访问,是因为访问时候会通过该进程的内存映射表去拿到真正的物理内存地址,假如其他进程访问的话,其他进程没有相应的映射表,自然就访问不到真正的物理内存地址,这样就解决了内存安全问题。
内存使用率问题:
内存分页管理,映射表不能以字节为单位,是以页为单位,Linux是以4K为一页,iOS是以16K位一页,但是mac系统是4K一页,可以在mac终端输入pageSize,发现返回的是4096。
为啥分页后内存就够用呢,因为应用内存是虚拟的,所以当程序启动时候程序会认为自己有很多的内存:
在应用加载时候不会把所有数据放内存中,因为数据是懒加载,当进程访问虚拟地址时候,首先看页表,如果发现该页表数据为0,说明该页面数据未在物理地址上,这个时候系统会阻塞该进程,这个行为就叫做页中断(page Fault),也叫缺页异常,然后将磁盘中对应页面的数据加载到内存中,然后让虚拟内存指向刚加载的物理内存,将数据加载到内存中时候,如果有空的内存空间,就放空的内存空间中,如果没有的话,就会去覆盖其他进程的数据,具体怎么覆盖操作系统有一个算法,这样永远都会保证当前进程的使用,这就是灵活管理内存。
但是这时候有个问题,虚拟内存解决了安全和效率问题,但是出现了另个安全问题,因为虚拟内存在编译链接时候就确定了,那么黑客很容易通过分析拿到对应的虚拟内存去操作 ,这样就造成所有的代码都很好hook,代码注入,这个时候就出现了新技术ASLR(Address space layout randomization 地址空间随机化),就是进程每次加载的时候都会给一个随机的偏移量,这样就保证每次加载进程时候虚拟内存也在变化,iOS从iOS4就开始了。
二进制重拍:
因为虚拟内存中有个很大问题就是缺页中断,这个操作很耗时间,并且iOS不仅仅是将数据加载到内存,还要对这页做签名认证,所以iOS耗时比较长,并且每页耗时有很大差距,0.1ms到0.8毫秒,使用过程中可能时间段感觉不到,但是启动时候会有很多数据要加载,这样就会导致耗时很长,假如我们启动时候在不同页面,因为代码在machO的位置不是根据调用瞬间,而是通过文件编译的位置来的,有可能启动时候在运行时候会调用很多次page Fault,那么如果把所有启动时候的代码都放在一页或者两页,这样就很大程度上优化启动速度,这种方法就叫做二进制重拍。
进程如果能直接访问物理内存无疑是很不安全的,所以操作系统在物理内存的上又建立了一层虚拟内存。为了提高效率和方便管理,又对虚拟内存和物理内存又进行分页(Page)。当进程访问一个虚拟内存Page而对应的物理内存却不存在时,会触发一次缺页中断(Page Fault),分配物理内存,有需要的话会从磁盘mmap读人数据。
通过App Store渠道分发的App,Page Fault还会进行签名验证,所以一次Page Fault的耗时比想象的要多!
编译器在生成二进制代码的时候,默认按照链接的Object File(.o)顺序写文件,按照Object File内部的函数顺序写函数。
静态库文件.a就是一组.o文件的ar包,可以用ar -t查看.a包含的所有.o。
默认布局:
简化问题:假设我们只有两个page:page1/page2,其中绿色的method1和method3启动时候需要调用,为了执行对应的代码,系统必须进行两个Page Fault。
但如果我们把method1和method3排布到一起,那么只需要一个Page Fault即可,这就是二进制文件重排的核心原理。
重排之后,我们的经验是优化一个Page Fault,启动速度提升0.6~0.8ms
。
二、实现
1、System Trace调试
首先优化,要先学会调试,只有调试才能发现需要优化的地方,我们知道内存分虚拟内存和物理内存,而内存是通过分页管理的,当我们启动的时候调用很多方法,假如这些方法不在同一个page上面,就会造成缺页中断(page fault),而这个操作是要消耗时间的,所以假如启动的方法都在一页上面,就会很大程度上减少启动时间的消耗,这个就需要用到二进制重拍来将启动时候调用的方法放在同一个page上
-
首先我们打开项目
image.pngcommand + i
打开Instruments调试工具
-
选择System Trace,这个软件可以看到我们项目中每个线程的数据:
image.png -
点击开始后这里我们搜索Main thread,选择我们的app,然后点击Main thread ,再到下面选择Main Thread --> Virtual Memory(虚拟内存)
image.png -
这里面File Backed Page In就是page fault的次数。
-
当我们把APP杀死后里面再启动,结果发现File Backed Page In这个值变得很小,说明APP就算杀死后,在启动不是冷启动,还是有一部数据在系统的缓存中。
-
要做到真正的冷启动,我们可以把APP杀掉后启动多个手机里面的APP,然后再启动APP,发现File Backed Page In又变得很大。
-
说明虚拟内存是在系统中的,当系统内存不够的时候,其他APP会覆盖老的APP的虚拟内存。
-
二进制重拍是在链接阶段生成的,重排之后生成可执行文件,所以我们只能在编译阶段来优化,而无法对已生成的ipa进行优化。
2、二进制重排
可以在XCode配置二进制重拍,首先要确定符号的顺序,才能知道怎么重拍。XCode使用的链接器叫做ld,ld有个参数叫order_file
,只要有这个文件,我们可以将文件的路径告诉XCode,在order_file
文件中把符号的顺序写进去,XCode编译的时候就会按照文件中的符号顺序打包成二进制可执行文件。
-
在苹果的objc4-750源码中找到这种文件
image.png -
打开后是下面这种格式:
image.png -
里面全是函数符号,打开项目,在build setting 里面搜索order file
image.png -
这里面指定了order的文件路径,因为一旦在这里指定了order file的路径,XCode就会在编译的时候按照文件里面写进去的顺序。
现在写一个Demo,然后编译,我们知道XCode编译的时候文件会有一个链接,链接是按照Build Phases的Compile SourceL里面的文件顺序将.m文件转换成.o文件,然后将这些.o文件链接在一起生成可执行文件:
image.png -
做一个实验,在ViewController和AppDelegate里面都写一个load方法,然后运行
+(void)load
{
NSLog(@"ViewController");
}
+(void)load
{
NSLog(@"AppDelegate");
}
-
Build Phases的Compile Source顺序:
image.png -
运行,看下打印:
image.png -
把Compile Source顺序改一下:
image.png -
运行后看打印结果:
image.png -
打印顺序跟Compile Source文件顺序一样,验证了上面的结论
-
如何查看整个项目的符号顺序呢,到Build Settings搜索
image.pnglink map
-
Link Map就是我们链接的符号表,我们把它改成
image.pngYES
,这样编译的时候就会把链接的符号表给我们写出来,command + R我们运行下,然后在Products里面的.app文件,在我们Intermediates.noindex-->项目名.build--->Debug-iphoneos-->项目名.build--->项目名-LinkMap-normal-arm64.txt,这个文件里面就有链接的符号顺序表
-
其中 Object files:就是链接了哪些.o文件
Sections:中
Address:
Size:
Segment:__TEXT
代码代码段,只可读;__DATA
是数据段,可读可写
Section:
再下面就是我们关心的符号:
Symbols:
Address:
方法代码的地址
Size:
方法占用的空间
File:
文件的编号
Name:
.o文件里面的方法符号
对于Address
,我们从.app中拿到项目的可执行文件,然后用MachOView打开,然后在Section中看下Assembly
-
符号表里的0x100004B70在MachOView对应的value是汇编代码,也就是我们写的代码转换成的汇编,所以这个地址就是代码地址,所以二进制重拍就是把所有的代码顺序重新排一下,把启动时候调用的代码排到前面去,减少启动时候加载page的数量(没一个page大小是16K)
-
添加order file,我们创建一个hank.order文件,在文件中写入:
image.png -
放到工程的根目录中,然后在Build setting里面搜下order file,然后在后面将该文件地址添加进去:
image.png -
Xcode在编译时候就会按照order文件中的符号顺序链接代码了,我们编译一下,再看一下LinkMap-normal-arm64.txt文件
image.png -
结果是按照order的符号顺序来的,而且如果order里面写了项目中不存在的方法符号,XCode会自动过滤掉,不存在影响。还有一种查看符号表的方法是在终端cd到项目可执行文件的目录,然后输入。
nm 可执行文件名
image.png
查看全部的符号,还有查看自定义方法的符号
nm -Up TraceDemo
image.png
查看系统的符号
nm -up TraceDemo
3、获取APP启动时候调用的所有方法
以上就是二进制重拍的步骤,但是如何知道APP启动时候的调用了哪些方法呢?
-
第一个方式:是用
fishHook
去hook 系统的objc_msgSend
这个函数,因为oc的方法都是通过发送消息的形式,但是这个函数参数是可变的参数,所以只能通过汇编形式hook,但是这种情况initialize和block以及直接调用函数方式hook不到。 -
第二种方式:clang插装形式: 官方文档:clang
OC方法、函数、block都能hook到!
1、首先在Build Setting里面搜索Other C Flags 在里面添加参数:-fsanitize-coverage=trace-pc-guard
-fsanitize-coverage=func,trace-pc-guard
2、然后编译,发现会报错:提示报错
Showing Recent Messages
Undefined symbol: ___sanitizer_cov_trace_pc_guard_init
image.png
提示找不到__sanitizer_cov_trace_pc_guard
和__sanitizer_cov_trace_pc_guard_init
方法。
看一下文档,发现有测试代码:
把这段代码copy到项目中,发现,错误没有了
__sanitizer_cov_trace_pc_guard_init
分析一下__sanitizer_cov_trace_pc_guard_init
函数,这里面有个start
和stop
,打个断点,看一下start
和stop
内存里面的值:
start里每4个字节里面都有一个数组,而且是按照1、2、3、4的顺序排列的,再看一下stop,按照start的规则,减4个字节看一下,发现是13,这里面存的是我们项目自定义文件中符号的数量,无论是方法、函数还是block,都会统计进来,我们可以多加几个方法或者函数、block试一下,就可以验证:
__sanitizer_cov_trace_pc_guard
我们再分析一下__sanitizer_cov_trace_pc_guard
我们运行时候发现打印了好多guard
实现个个手势
-(void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event{
}
点击一下屏幕,发现
image.png
点击一下打印一下,猜测每执行一个函数都会调用一次,说明该函数hook了所有的方法,为了进一步验证,定义一个函数和一个block,在点击屏幕时候调用一个函:
void(^block1)(void) = ^(void) {
};
void test(){
block1();
}
guard: 0x100d8381c a PC �
guard: 0x100d83814 8 PC �
guard: 0x100d83810 7 PC
发现点击一次,该函数调用了三次
通过汇编验证一下,在toubegain、函数、block出都加上断点,然后打开汇编,运行
bl指令代表调用一个方法或者一个函数 ,过掉这个断点
image.png
test也调用,再过一下
image.png
block也调用了,当我们配置了chang的代码覆盖工具,实现了上面两个函数,clang会以静态插装形式在所有方法、函数block内部插入一行代码,而且是在第一行一开始插入的,做到了全局的hook
我们再在分析下
__sanitizer_cov_trace_pc_guard
的作用,我们现在这个函数里面加一个断点image.png
再运行
image.png
在左边发现有个函数调用栈,并且在每次调用方法时候都会调起
__sanitizer_cov_trace_pc_guard
函数,而这个函数就是相应方法调起来的实例代码中有个PC,我们加个断点打印一下这个PC看看,先把启动时候的函数都过掉再打开断点,然后点击一下屏幕触发touchesBegan的方法进行拦截:
image.png
在控制栏中输入bt,查看一下函数调用栈
image.png
看一下0x0000000104349abc这个地址的信息
image.png
发现这个地址是在touchesBegan里面,但是不在touchesBegan开头,我们把它
减4个字节
image.png
第一个指令是bl,这时才是touchesBegan的开头
在touchesBegan方法里面加一个断点,然后跳到touchesBegan方法里面,再打开汇编:
image.png
bl是调用的意思,我们发现0x104349ab8是touchesBegan方法的开头,0x00000001000bdabc是调用下一个函数的指令的下一个地址,PC打印的就是0x104349abc
image.png
再来看一下函数调用栈
image.png
调用栈的左边是上一个函数的开始地址,最后面有个+64
,最后面那个数字是偏移量,也就是说函数的开始位置+偏移量
才是函数的真正的位置
,这个时候touchesBegan的偏移量是44
,我们测试一下:
这才是touchesBegan的真正实现,也就是汇编的这一段
image.png
说明在
__sanitizer_cov_trace_pc_guard
里面我们能拿到下一个函数调用的首地址:看一下
__sanitizer_cov_trace_pc_guard
的汇编调用image.png
最后面有个ret也就是return返回的意思,每个函数或者方法都有一个return, 在底层实现,每一个函数调用完成后都会返回下一个需要调用的函数的地址,也就是汇编中每次bl的时候会把下次要调用的指令的地址存在x30中,当函数执行时候遇到ret时候就会从x30中的值返回回去 ,例如我们点击屏幕时候在
__sanitizer_cov_trace_pc_guard
加个断点,然后读取x30数据,就得到了touchesBegan的地址image.png
所以__sanitizer_cov_trace_pc_guard中的
image.png
拿到的是下一个要调用的函数的地址,因为__sanitizer_cov_trace_pc_guard函数都是在hook函数前执行的,所以在这里面拿到的函数地址就是我们hook的函数地址
既然能拿到函数地址,我们可以通过这个函数去拿到函数名称。
#import <dlfcn.h>
dladdr(<#const void *#>, <#Dl_info *#>)
- 第一个参数是函数的地址。
第二个参数是一个结构体指针。
我们看看这个结构体格式
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;
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);
打印:
fname:/private/var/containers/Bundle/Application/38C6E838-7D51-4546-9882-BF5858D08C16/TraceDemo.app/TraceDemo
fbase:0x1000e0000
sname:-[ViewController touchesBegan:withEvent:]
saddr:0x1000e5a0c
- fname:文件路径
- fbase:文件地址
- sname:函数符号名称
- saddr:函数符号地址,也就是函数的起始地址
当我们能拿到项目所有调用函数的符号时候,我们就能通过这种方法来拿到APP启动时候调用的所有的函数、方法、block符号,然后创建order文件进行自动二进制重拍上代码:
//原子队列
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.
/* 精确定位 哪里开始 到哪里结束! 在这里面做判断写条件!*/
void *PC = __builtin_return_address(0);
SYNode *node = malloc(sizeof(SYNode));
*node = (SYNode){PC,NULL};
//进入,因为该函数可能在子线程中操作,所以用原子性操作,保证线程安全
OSAtomicEnqueue(&symbolList, node, offsetof(SYNode, next));
//
}
-(void)createOrderFile{
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];
[symbolNames addObject:symbolName];
}
//取反
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:@"hank.order"];
NSData * fileContents = [funcStr dataUsingEncoding:NSUTF8StringEncoding];
[[NSFileManager defaultManager] createFileAtPath:filePath contents:fileContents attributes:nil];
NSLog(@"%@",funcStr);
}
网友评论