iOS 底层探索 文章汇总
目录
- 一、查看APP启动耗时
- 二、虚拟内存和物理内存
- 三、二进制重排原理
- 四、实现二进制重排
- 五、Clang插桩
- 六、其他问题
一、查看APP启动耗时
main
函数之前的处理为pre-mian
阶段,这篇文章主要分析这个阶段。
添加DYLD_PRINT_STATISTICS
参数打印出pre-mian
阶段的耗时情况:
各时段处理耗时分析:
-
Total pre-main time
: 总耗时 -
dylib loading time
: 动态库载入耗时 -
rebase/binding time
:rebase
表示地址偏移修正(ASLR
),binding
表示符号绑定 -
ObjC setup time
: OC类注册耗时 -
initializer time
: 执行load
和构造函数
的耗时
slowest intializers
: -
libSystem.B.dylib
: 系统的 -
libMainThreadChecker.dylib
: -
XXXXX
: 项目主程序耗时
pre-main优化方向:
- 官方建议非系统动态库的加载个数不超过6个,多于6个就要考虑动态库的合并;
- 减少
OC
类,减少C++
虚函数- 减少
load
方法和构造函数
main方法之后优化方向:
- 延迟初始化、懒加载
- 删除不使用类、方法、图片资源
- 尽量不用
XIB
和Storyboard
,特别是首屏界面
参考:
iOS 脚本查看项目中未使用的类、iOS 脚本查看项目未使用到的方法、iOS 脚本查找项目中无用资源脚本原理
二、虚拟内存和物理内存
1、虚拟内存和物理内存的区别
当我们向系统申请内存时,系统并不会给你返回物理内存的地址,而是给你一个虚拟内存地址。CPU
读取数据时也是通过内存管理单元MMU
将虚拟地址映射到物理内存地址。每个进程都拥有相同大小的虚拟地址空间,对于32位
的进程,可以拥有4GB
的虚拟内存,64位
进程则更多,可达18EB
。只有我们开始使用申请到的虚拟内存时,系统才会将虚拟地址映射到物理地址上,从而让程序使用真实的物理内存。
2、内存分页
系统会对虚拟内存和物理内存进行分页,虚拟内存到物理内存的映射都是以页为最小粒度的。在OSX
和早期的iOS
系统中,物理和虚拟内存都按照4KB
的大小进行分页。iOS
近期的系统中,基于A7
和A8
处理器的系统,物理内存按照4KB
分页,虚拟内存按照16KB
分页。基于A9
处理器的系统,物理和虚拟内存都是以16KB
进行分页。(终端输入PAGESIZE
可以查看到macOS的分页大小)。
系统将内存页分为三种状态。
- 活跃内存页
(active pages)
- 这种内存页已经被映射到物理内存中,而且近期被访问过,处于活跃状态。 - 非活跃内存页
(inactive pages)
- 这种内存页已经被映射到物理内存中,但是近期没有被访问过。 - 可用的内存页
(free pages)
- 没有关联到虚拟内存页的物理内存页集合。
当可用的内存页降低到一定的阀值时,系统就会采取低内存应对措施,在OSX
中,系统会将非活跃内存页交换到硬盘上,而在iOS
中,则会触发Memory Warning
,如果你的App
没有处理低内存警告并且还在后台占用太多内存,则有可能被杀掉。
3、如何解决内存浪费的?
应用程序加载到内存中时,并不会全部加载到物理内存中,属于懒加载,用哪一部分就加载那一部分。当访问进程的内存地址时,首先看页表,查看所要访问的对应页表是否已经加载到内存中。如果这一页没有在物理内存中时,操作系统会阻塞当前进程,发出一个缺页异常/缺页中断(pagefault)
,让后将磁盘中对应页的数据加载到内存中,完成虚拟内存和物理内存的映射。
当前进程的页表数据加载到物理内存中时,不一定是连续的,也有可能会覆盖其他进程的不活跃页,这样的按需分配,极大提高内存的使用效率。
4、虚拟内存的安全问题
虚拟内存通过页表映射到物理内存上,因此直接访问物理地址并不能实际正确的拿到进程的数据,但是进程的虚拟内存地址相对于自己来说也是绝对的,不管程序运行多少次,如果访问同一个函数,它在虚拟内存中的地址都是一样的这样也存在安全问题(比如直接静态注入)。
这样也出现了新的技术--ASLR(Address Space Layout Randomization)
。
每次虚拟内存在加载之前,都加一个随机偏移值。
三、二进制重排原理
1、什么是二进制重排
缺页中断/缺页异常:内存分页管理,每一页加载的时候都会发生。
在iOS中,在加载缺页内存的时候,不仅发生缺页阻塞从磁盘中加载数据,还要对加载的这页做签名验证。
在App
使用中不会发生大量的pagefault
,我们一般感受不到这个过程。但是在启动时,程序有大量的代码需要加载、执行,那么这个缺页中断有可能就很明显了。
如何优化?
假如我的App
只有10页
数据,但是启动的时候需要加载的代码分散放在1、3、5
页。因为代码在Mach-o
文件中的位置是根据文件加载生成的顺序来决定。那么这时候App
启动需要运行的代码放在3
个虚拟内存页中就会出现3
次pagefault
。
如果我们将需要启动用的代码全部放在第1
页中,那么App
启动时便只会触发一次pagefault
,App
启动加载的数据也会变少,这样极大减少进程的阻塞。这就是二进制重排的原理。
2、查看pagefault
Xcode
提供相关的调试工具,打开Instruments-System Trace
,选中手机中的App
,点击System Trace
左上角开始记录后会自动打开手机中的App
,进入首屏后点击System Trace
左上角停止。查看Main Thread
中虚拟内存的File Backed Page In
项目,它代表着启动时产生的pagefault
次数。
查看
pagefault
次数时受App
冷启动热启动影响很大,可以先开启几个其他App
然后等一段时间再点击System Trace
左上角开启记录。
二进制重排的优化是发生在编译链接阶段,对即将生成的二进制可执行文件进行重排。
Xcode
使用的连接器叫ld
它可以指向一个order_file
文件,在这个文件中指定排列符号,那么Xcode
在编译时会按照指定的排列编译出可执行的文件,苹果objc
源码项目中的libobjc.order
文件就是实现二进制重排功能的。
四、实现二进制重排
1、查看方法排列顺序
新建测试项目Test_TracingPCs
在项目的build settings
中搜索link map
开启这个文件的输出
重新编译后就可以在工程的build
目录里面找到一份link map
文件
路径如下:
Xcode -> DerivedData-> 项目名-> Build-> Intermediates.noindex-> 项目名.build-> Debug-iphoneos-> 项目名.build-> 项目名-LinkMap-normal-arm64.txt
这个文件里面就记录一些链接.o
的文件、Mach-o
文件里的一些信息、符号信息symbols
等等…
注意,这个symbols
就是关注的要点:默认情况下它是按照Build Phases-Compile Sources
中编译文件从上至下排序以及类中方法从上至下排序。
2、通过order
文件重新排列加载顺序:
在项目根目录创建lcj.order
文件,在工程配置中添加.order
文件的路径./lcj.order
后,让编译器按照指定的顺序重新排列二进制文件,把最需要加载的代码段放在内存页靠前的位置。
这里只是演示了让viewcontroller
中的几个自定义方法优先靠排列在内存分页中,实际中一个App
启动时的page fault
可能多达几千次,那么需要重排的函数远不止这一点。
五、Clang插桩
1、引入Clang插桩
由于项目中存在大量的函数方法调用,此外还有Block、Swift、C、C++函数
,因此仅仅HOOK msgSend
方法不可行。因为Clang
会读取所有代码,分析AST
中所有节点,所有通过Clang插桩可以实现100%的符号覆盖。
抖音研发实践:基于二进制文件重排的解决方案
官方插桩工具-Tracing PCs
Clang Documentation
Tracing PCs
2、使用Tracing PCs
根据官方文档添加-fsanitize-coverage=trace-pc-guard
标记
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;
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);
}
-[ViewController viewDidLoad]
前添加断点,运行项目,到断点后打开汇编断点(菜单栏Debug->Debug Workflow->Always Show Disassembly)
结合汇编中插入的__sanitizer_cov_trace_pc_guard
代码和控制台打印的信息分析可知:添加-fsanitize-coverage=trace-pc-guard
标记后Clang
会在中间代码IR
中的每个方法、Block等调用边缘插入__sanitizer_cov_trace_pc_guard
方法的调用。
所以Clang
插桩插入的就是__sanitizer_cov_trace_pc_guard
方法调用。
3、修改__sanitizer_cov_trace_pc_guard方法,获取函数的调用方法名
#import
void __sanitizer_cov_trace_pc_guard(uint32_t *guard) {
//排除load方法
//if (!*guard) return;
//当前函数返回到上一个方法继续执行的地址
void *PC = __builtin_return_address(0);
Dl_info info;
dladdr(PC, &info);
printf("fname:%s \nfbase:%p \nsname:%s \nsaddr:%p\n\n\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);
}
- (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event {
}
点击屏幕输出:
fname:/private/var/containers/Bundle/Application/BAE470B2.../Test_TracingPCs.app/Test_TracingPCs
fbase:0x10236c000
sname:-[ViewController touchesBegan:withEvent:]
saddr:0x102371ad4
4、将获取到的符号写入到. order
文件中
#import //用于定义原子队列
//定义原子队列
static OSQueueHead symbolList = OS_ATOMIC_QUEUE_INIT;
//定义符号结构体
typedef struct{
void *pc;
void *next;
} SYNode;
+ (void)load {
}
- (void)viewDidLoad {
[super viewDidLoad];
testCFunc();
[self testOCFunc];
}
- (void)testOCFunc {
NSLog(@"OC函数");
}
void testCFunc() {
CJBlock();
}
void(^CJBlock)(void) = ^(void) {
NSLog(@"Block");
};
void __sanitizer_cov_trace_pc_guard(uint32_t *guard) {
//排除load方法
//if (!*guard) return;
//当前函数返回到上一个方法继续执行的地址
void *PC = __builtin_return_address(0);
//创建结构体!
SYNode * node = malloc(sizeof(SYNode));
*node = (SYNode){PC,NULL};
//该方法在子线程中调用,因此需要使用线程安全的Atomic原子队列
//加入结构
OSAtomicEnqueue(&symbolList, node, offsetof(SYNode, next));
}
- (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event {
//定义数组
NSMutableArray *symbolNames = [NSMutableArray array];
while (YES) {//一次循环!也会被HOOK一次!!(Tracing PCs只要有跳转(汇编中b/bl指令)就会被HOOK)
SYNode *node = OSAtomicDequeue(&symbolList, offsetof(SYNode, next));
if (node == NULL) {
break;
}
Dl_info info = {0};
dladdr(node->pc, &info);
printf("%s \n",info.dli_sname);
NSString *name = @(info.dli_sname);
free(node);
//C函数前需加 _
BOOL isObjc = [name hasPrefix:@"+["]||[name hasPrefix:@"-["];
NSString *symbolName = isObjc ? name : [@"_" stringByAppendingString:name];
if (![symbolNames containsObject:symbolName]) {
[symbolNames addObject:symbolName];
}
}
//反向数组
symbolNames = (NSMutableArray*)[[symbolNames reverseObjectEnumerator] allObjects];
//去掉当前方法
[symbolNames removeObject:[NSString stringWithFormat:@"%s",__FUNCTION__]];
//数组转成字符串
NSString *funcStr = [symbolNames componentsJoinedByString:@"\n"];
//字符串写入文件
NSString *filePath = [NSTemporaryDirectory() stringByAppendingPathComponent:@"lcj.order"];
//文件内容
NSData *fileContents = [funcStr dataUsingEncoding:NSUTF8StringEncoding];
[[NSFileManager defaultManager] createFileAtPath:filePath contents:fileContents attributes:nil];
}
由于Tracing PCs只要有跳转(汇编中b/bl指令)就会被HOOK,因此while也会被HOOK,为了避免循环调用需要修改
Other C Flags
为:-fsanitize-coverage=func,trace-pc-guard
运行后点击屏幕拿到.order文件
六、其他问题
1、Swift 工程 / 混编工程问题
通过上面的方法可以拿到OC
项目中的符号,想要拿到Swift
中的符号还需要做以下配置:
-sanitize-coverage=func
-sanitize=undefined
2、cocoapod 工程问题
对于cocoapod
工程引入的库 , 由于针对不同的target
。那么我们在主程序中的target
添加的编译设置Write Link Map File , -fsanitize-coverage=func,trace-pc-guard
以及order file
等设置肯定是不会生效的。解决方法就是针对需要的target
去做对应的设置即可(配置target
自己的Order File
)。
对于直接手动导入到工程里的SDK
, 不管是静态库.a
还是动态库
, 默认在主工程设置就可以可以拿到符号的。
手动导入的三方库如果没有导入使用的话 , 是不会加载的,添加了
load
方法也是如此。
参考
iOS 优化篇 - 启动优化之Clang插桩实现二进制重排