本章提纲:
1、pre-Main阶段的性能检测
2、虚拟内存
3、二进制重排
4、Clang插装
1、pre-Main阶段的性能检测
应用的启动过程一般以Main
函数为临界点,分为Main
函数之前和Main
函数之后。
Main
函数之前我们称为pre-Main
。
Xcode为检测pre-Main
的耗时提供了环境变量,以便开发者了解pre-Main
的时间。
在Xcode中的Schemes->Run->Arguments
中添加DYLD_PRINT_STATISTICS
的环境变量为YES
。然后运行程序,可以看到如下打印:
Total pre-main time: 540.09 milliseconds (100.0%)
dylib loading time: 159.35 milliseconds (29.5%)
rebase/binding time: 39.06 milliseconds (7.2%)
ObjC setup time: 28.37 milliseconds (5.2%)
initializer time: 313.30 milliseconds (58.0%)
slowest intializers :
libSystem.B.dylib : 7.52 milliseconds (1.3%)
libMainThreadChecker.dylib : 48.67 milliseconds (9.0%)
GPUToolsCore : 26.26 milliseconds (4.8%)
libglInterpose.dylib : 113.10 milliseconds (20.9%)
KSAdSDK : 105.15 milliseconds (19.4%)
xxxx : 80.49 milliseconds (14.9%)
dylib loading time
动态库的载入耗时。系统的动态库存在于共享缓存,但是自定义的动态库就要通过依赖关系一个一个的加载。
苹果官方建议项目中不要超过6个自定义的动态库,超过的部分最好进行多个动态库合并,以此来减少动态库的加载时间。rebase/binding time
这是一个非常核心而且重要的概念。重定位/符号绑定耗时。涉及到虚拟内存
的相关技术,会在下面详细介绍。
rebase(重定位)
:采用了ASLR技术,保证地址的随机化,加强了内存访问的安全性。
binding(符号绑定)
:使用外部符号,编译时无法找到函数地址。在运行时,dyld
加载共享缓存,加载链接动态库之后,进行binding
操作,重新绑定外部符号。ObjC setup time
注册OC类的耗时。应用启动时,系统会生成OC类和分类的两张相关映射表,IMP到SEL的映射,分类的方法等合并到相关表中的等操作会造成一部分的耗时。
减少项目中类和分类的数量可以优化这部分的时间。
减少类和分类中的Load
方法的使用,让类以懒加载的方式加载。initializer time
执行load
以及C++
构造函数的耗时slowest intializers
最耗时的几个动态库。
2、虚拟内存
聊到虚拟内存
我们就要聊起早期的计算机结构。早期的是冯·诺依曼计算机结构,在1945年就被提出了,在当时是很新颖的结构了,它是第一次将存储器和运算器分离,开启了以存储器为核心的现代计算机的篇章。
但是冯·诺依曼结构有它自己的问题,就是存储器之间的读取速度远远小于CPU的工作效率。读取效率低,CPU的运算能力又太快,就造成了CPU性能的浪费。为了解决这个问题,现行的解决方式就是采用多级存储,来平衡存储器的读写速率,容量,价格。
该结构下的CPU的寻址方式:内存可以被看成一个数组,数组元素是一个字节大小的空间,而数组索引则是所谓的物理地址。最简单直接的方式就是CPU直接通过物理地址去访问对应的内存,也叫做物理寻址。
这种寻址方式有非常严重的安全问题。因为直接暴露的是物理地址,所以进程通过地址偏移可以访问到任何屋里地址,用户进程想干嘛就干嘛。这是非常不安全的。
现代处理器使用的是虚拟寻址
的方式。CPU通过访问虚拟地址,经过翻译获得物理地址才能访问内存。这个翻译过程由CPU中的内存管理单元(Memory Management Unit,缩写为MMU)完成。
现代的操作系统都引入了虚拟内存。对于每个进程来说,操作系统可以为其提供一个独立的私有的连续的地址空间。对于进程来说,它的可见部分只有分配给它的虚拟内存。而虚拟内存实际可能映射到物理内存以及硬盘的任何区域。由于硬盘的读写速度不如内存快,所以操作系统会优先使用物理内存空间,但是当物理内存空间不够时,就会将部分内存数据交换到硬盘上去存储,这也是所谓的Swap内存交换机制。有了内存交换机制以后,相比起物理寻址,虚拟内存实际上利用了磁盘空间扩展了内存空间。
虚拟内存的优势同时也彰显了出来:
1、保护了进程的地址空间,将进程和物理地址完全阻隔开,无法跨进程访问。
2、由于操作系统分配的虚拟内存是连续的,简化了内存管理。
3、利用硬盘空间拓展了内存空间。
4、可以按需加载内容到内存中,避免内存浪费。
内存分页
虚拟内存和物理内存存在映射关系,为了方便映射和管理,虚拟内存和物理内存都被分割成大小相同的单位,物理内存的最小单位称为帧(Frame)
,而虚拟内存的最小单位被称为页(Page)
。
在iOS中,一页的大小为16KB
,当进程被加载到内存中是,虚拟内存会给该进程开辟最大4个G
的虚拟内存空间。
内存分页的最大意义在于:
1、支持了物理内存的离散使用;
2、提高MMU
的翻译效率,采用一些页面调度(Paging)算法,利用翻译过程中也存在局部性原理,将大概率被使用的帧地址加入到TLB
或者页表之中,提高翻译效率。
缺页中断
现代计算机都是分级缓存的,内存命中的查找也是分级的。
- 首先会在
TLB(Translation Lookaside Buffer)
中进行查询,这个表位于CPU内部,查询速度最快; - 如果没有命中,那么接下来会在页表(Page Table)中进行查询,页表位于物理内存中,所以查询速度较慢,如果发现目标不在物理内存中,那么成为
缺页
; - 如果物理内存没有命中查找,此时会去磁盘中查找,如果还找不到就报错了。
所以当发生缺页时,操作系统会阻塞当前进程,把需要的数据载入到物理内存中,然后再寻址读取。当缺页频繁发生时,也是非常耗时的。
页面置换
由于物理内存是有限的,当物理内存没有空间时,操作系统会通过算法找到最不经常使用的
物理页驱逐回磁盘,为新的内存页让出空间。这个过程称为页面置换
,也称内存交换
。
然而!!!iOS并不支持内存交换机制!!
大多数移动设备都不支持内存交换机制。移动设备上的大容量存储器通常是闪存(Flash),它的读写速度远远小于电脑所使用的的硬盘,这就导致了在移动设备上,就算使用了内存交换也不能提升性能。其次,移动设备本身容量就经常短缺,闪存的读写寿命也非常有限,所以这种情况下还有进行内存交换就非常不划算了。
ASLR
程序的代码在不修改的情况下,每次加载到虚拟内存的地址是一样的,这样的方式并不安全,为了解决地址固定的问题,出现了ASLR
技术。
ASLR(Address space layout randomization)
:地址空间配置随机加载,是一种防范内存损坏漏洞被利用的计算机安全技术。
地址空间配置随机加载利用随机方式配置数据地址空间,使某些敏感数据配置到一个恶意程序无法事先获知的地址,令攻击者难以进行攻击。
以上就简单的介绍了下虚拟内存的相关知识。接下来是二进制重排部分。
3、二进制重排
3.1缺页中断时间消耗的检测
前面我们已经提到了缺页中断,接下来我们通过Profile
来检测一下缺页中断的发生。
在Xcode
顶部菜单Product
->Profile
->Instruments
->System Trace
可以看到我们的项目冷启动时,缺页次数大概是1200多次,耗时130毫秒,如果项目再大一些,缺页发生的更多那么也是一个不小的影响启动时间的一个因素。
3.2二进制重排原理
创建测试项目,查看代码的顺序,在Build Settings
->Write Link Map File
,设置为YES
,然后编译项目,来到工程的Build
目录下,找到LinkMap
文件
Build
目录找不到的话从Xcode
->Preferences
->Locations
,可以看到Derived Data
的路径,可以直接跳转过去。
具体看到LinkMap
文件保存了项目再编译链接时的符号顺序,以方法/函数为单位排列。
可以看到和编译的文件顺序是一样的,目前
ViewController
中只有一个方法viewDidLoad
,所以在这个文件下面ViewController只排列了这一个方法。
如果按照默认配置,在启动时会加载大量的与启动无关的代码,导致缺页
。那么如果可以将启动时需要的方法/函数排在最前面,就能降低缺页
的发生,从而提高应用的启动速度,这就是二进制重排的核心原理。
3.2二进制重排准备
在工程目录下创建一个.order
文件,按照固定的格式,将启动时需要的方法/函数顺序排列,然后再去把排列好的.order
文件放到Xcode中使用。在.order
中写入测试顺序
-[ViewController viewDidLoad]
_main
最后通过LinkMap文件查看来验证.order
是否生效。
在Xcode中进行配置.order
文件,在Build Settings
->Order File
中配置
结果新的
LinkMap
中的前两位的顺序确实是我写入Lucky.order
文件的顺序。以上就完成了重排的准备工作,并且测试也生效了,接下来的难点就是,怎么能获取到启动时需要调用的所有方法和函数。
4、Clang插庄
如果只对于OC方法,可以对objc_msgSend
方法进行Hook
,但是系统调用的方法中会有一些c、c++
的方法函数,以及一些block
回调,这些通过objc_msgSend
是无法拦截到的。
而LLVM
内置了一个简单的代码覆盖率检测的工具(SanitizerCoverage
)。它在函数级、基本块和边缘级上插入了对用户自定义函数的调用,通过方式,可以顺利对OC
方法、C
函数、Block
块、Swift
等函数进行更加全面的拦截。
(官方文档链接)https://clang.llvm.org/docs/SanitizerCoverage.html
4.1配置SanitizerCoverage
搭建测试项目,在Build Settings
->Other C Flags
中,增加-fsanitize-coverage=trace-pc-guard
的配置。
根据官方文档的示例,在测试项目中添加以下代码:
#import "ViewController.h"
#include <stdint.h>
#include <stdio.h>
#include <sanitizer/coverage_interface.h>
@implementation ViewController
- (void)viewDidLoad {
[super viewDidLoad];
// Do any additional setup after loading the view.
}
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;
}
void __sanitizer_cov_trace_pc_guard(uint32_t *guard) {
if (!*guard) return;
void *PC = __builtin_return_address(0);
char PcDescr[1024];
// printf("guard: %p %x PC %s\n", guard, *guard, PcDescr);
}
@end
如果不添加__sanitizer_cov_trace_pc_guard_init
方法和__sanitizer_cov_trace_pc_guard
编译会报错。
添加完就可以正常编译运行了。
打印如下:
- __sanitizer_cov_trace_pc_guard_init
函数__sanitizer_cov_trace_pc_guard_init
是回调函数,start
和stop
表示一个section
的首地址和结束地址。这个方法能反应项目中的符号个数。
// This callback is inserted by the compiler as a module constructor
// into every DSO. 'start' and 'stop' correspond to the
// beginning and end of the section with the guards for the entire
// binary (executable or DSO). The callback will be called at least
// once per DSO and may be called multiple times with the same parameters.
- __sanitizer_cov_trace_pc_guard
而函数__sanitizer_cov_trace_pc_guard
则是可以监听到编译器所有的emit
,例如官方给的注释中的例子:
/ This callback is inserted by the compiler on every edge in the
// control flow (some optimizations apply).
// Typically, the compiler will emit the code like this:
// if(*guard)
// __sanitizer_cov_trace_pc_guard(guard);
// But for large functions it will emit a simple call:
// __sanitizer_cov_trace_pc_guard(guard);
4.2 __sanitizer_cov_trace_pc_guard的测试
我们来测试一下是不是函数
,方法
,block
都会被拦截,添加如下测试代码:
- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event{
NSLog(@"touchesBegan方法执行");
test();
}
void(^block)(void) = ^(void){
NSLog(@"Block执行");
};
void test(){
NSLog(@"test函数执行");
block();
}
可以看到这些方法确实都被函数
__sanitizer_cov_trace_pc_guard
能拦截到。通过查看汇编指令:可以看到这几个测试方法后边都有
callq
指令,调用的都是__sanitizer_cov_trace_pc_guard
。
可以初步的了解到,Clang
插装的原理是,只要添加了插装的标记,编译器就会在当前项目中,在所有的方法、函数、block的代码实现的边缘,插入一句__sanitizer_cov_trace_pc_guard
达到方法、函数、block的全覆盖。
4.4获取符号名称
官方示例代码中,用了__builtin_return_address
函数,该函数的作用会获取到当前的返回地址,也就是函数的调用者。
通过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;
dli_fname:当前的路径
dli_fbase:地址
dli_sname:调用的函数名称
dli_saddr:函数地址
所以我们通过dli_sname
来拿到函数名称。接下来的工作就是拿到这些名称(去重),然后把名称写入到前面说的.order
文件中去,也就完成了重排的工作。
4.5实践
- 存储返回地址
为了保证线程安全,定义一个原子队列,队列中存储带有返回地址的结构体。
//定义原子队列
static OSQueueHead symbolList = OS_ATOMIC_QUEUE_INIT;
//定义符号结构体
typedef struct {
void * pc;
void * next;
} SYNode;
通过SYNode来存储,方法__sanitizer_cov_trace_pc_guard
中通过函数__builtin_return_address
得到的pc
。
函数__sanitizer_cov_trace_pc_guard
的实现如下:
//HOOK一切的回调函数!!
void __sanitizer_cov_trace_pc_guard(uint32_t *guard) {
void *PC = __builtin_return_address(0);
//创建结构体
SYNode * node = malloc(sizeof(SYNode));
*node = (SYNode){PC,NULL};
//结构体入栈
//offsetof:参数1传入类型,将下一个节点的地址返回给参数2
OSAtomicEnqueue(&symbolList, node, offsetof(SYNode, next));
}
- 获取函数符号并去重排序
获取完毕返回的地址,我们进行排序和去重处理
//定义数组
NSMutableArray<NSString *> * symbleNames = [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);//获取函数名称,并转字符串
//oc方法直接返回,其余的前面加"_"
BOOL isObjc = [name hasPrefix:@"+["] || [name hasPrefix:@"-["];
NSString * symbolName = isObjc ? name : [@"_" stringByAppendingString:name];
//符号加到符号数组里
[symbleNames addObject:symbolName];
}
//反向遍历数组
NSEnumerator * em = [symbleNames reverseObjectEnumerator];
//去重
NSMutableArray * funcs = [NSMutableArray arrayWithCapacity:symbleNames.count];
NSString * name;
while (name = [em nextObject]) {
if (![funcs containsObject:name]) {//数组没有name
[funcs addObject:name];
}
}
//去掉自己!
[funcs removeObject:[NSString stringWithFormat:@"%s",__func__]];
- 写入文件并配置
处理完要进行重排的相关符号,下一步就是把这些写入.order
文件中。
//写入文件
NSString * funcStr = [funcs componentsJoinedByString:@"\n"];
NSString * filePath = [NSTemporaryDirectory() stringByAppendingPathComponent:@"Lucky.order"];
NSData * file = [funcStr dataUsingEncoding:NSUTF8StringEncoding];
[[NSFileManager defaultManager] createFileAtPath:filePath contents:file attributes:nil];
NSLog(@"%@",funcStr);
写入完毕之后,我们根据前边编译.order
的经验来编译,至此我们就完成了重排和插装的过程!可以对实际项目进行测试一下是不是有作用。
慢慢都坚持这么久了,继续加油!