在上一篇 启动优化(上) 中讲解一些启动优化相关的知识,最后得到减少缺页中断(pageFault)可以达到启动优化的目的,本文将使用二进制重排优化的目的。
1. 文件和方法的编译顺序
-
在
Build Setting中搜索Link Map,然后将Write Link Map File设置为YES-
编译项目,然后在
Users/xxx/Library/Developer/Xcode/DerivedData/ProjectName(这里是LaunchOptimization)/Build/Intermediates.noindex/LaunchOptimization.build/Debug-iphoneos/LaunchOptimization.build路径下找到LaunchOptimization-LinkMap-normal-arm64.txt,如下: -
打开
LaunchOptimization-LinkMap-normal-arm64.txt,如下
Address是地址偏移量,它加上ASLR就是虚拟内存中的地址Size是占用内存大小File是文件编号Name是方法/函数名
-
-
这里代码文件的顺序是按
编译顺序确定的,文件内部的代码是按照书写顺序从上到下的-
可以通过修改文件的编译顺序和文件中代码来验证,先调整文件编译顺序:
-
在
AppDelegate.m中增加方法: -
重新编译 生成
.txt文件
-
-
根据调整后方法的打印,得以验证:
- 文件的顺序是根据编译顺序来确定
- 文件中代码的编译顺序是根据书写从上到下编译的顺序确定的
2. 二进制重排
-
在
objc4源码根目录中能看到一个libobjc.order文件:-
打开
libobjc.order文件: -
文件里面方法的都是符号名称,这个是给编译器看的,编译器看到后就按照
order中符号的顺序去对二进制进行排列。下面进行验证
-
-
还用上面
LaunchOptimization工程,在根目录下创建LaunchOpt.order文件-
在
LaunchOpt.order里写一些符号: -
然后在
Build Setting中搜索Order File,然后配置LaunchOpt.order路径:
-
-
Write Link Map File设置为YES,再次运行项目看.txt文件- 原来的
_main在第三个顺序现在在第一位,顺序已经换了,没有的符号也会自动忽略
- 原来的
搞定了二进制重排,但我们的目标拿到启动相关的符号,启动涉及的东西很多,方法嵌套函数,函数里又有网络请求,然后又block等等,太多了眼睛都看不过来。那么怎么知道启动时调用了哪些方法,这个时候就需要用到Clang插桩,Clang会读所有的代码,可以无死角的覆盖所有的函数、block等
3. Clang插桩
clang官文 的SanitizerCoverage处可以看到Tracing PC,PC是CPU读取代码的指针,Tracing PC是跟踪CPU执行到的代码。SanitizerCoverage是LLVM内置的一个简单的代码覆盖率工具,它会在函数,方法和block处插入回调,并提供这些回调的默认实现,并实现简单的覆盖率和可视化。
Hook函数调用
在文档的Tracing PCs with guards处,在编译器中加入-fsanitize-coverage=trace-pc-guard配置,系统会默认实现两个函数:
__sanitizer_cov_trace_pc_guard(&guard_variable)
__sanitizer_cov_trace_pc_guard_init(uint32_t *start, uint32_t *stop)
在 LaunchOptimization 工程中来验证。在Build Setting中的Other C Flags中添加-fsanitize-coverage=trace-pc-guard
然后在真机环境下编译,会出现找不到这两个方法符号的错误
所以加上flag后,系统会调用这两个函数,需要手动去实现。根据文档中给的实现案例,复制到ViewController中:
#import "ViewController.h"
#include <stdint.h>
#include <stdio.h>
#include <sanitizer/coverage_interface.h>
@implementation ViewController
- (void)viewDidLoad {
[super viewDidLoad];
}
// 里面反应了项目中符号个数
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.
}
// HOOK 一切的回调函数!!
void __sanitizer_cov_trace_pc_guard(uint32_t *guard) {
}
@end
如此这般。。。 编译就成功了
__sanitizer_cov_trace_pc_guard_init
__sanitizer_cov_trace_pc_guard_init函数反映了项目中符号的个数。其中参数start和stop都是uint32_t *类型 即4字节的数据,这样for循环每4字节为一个单位从start开始 到stop-1(4字节)的结束,将++N存储到对应的位置。如此最后一个++N的位置即是 stop-1(4字节)。
运行后打印得到start和stop的地址,使用x命令读取start内存可以得到存储的数据,stop-4即最后一个数据:
增加方法touchesBegan:方法,然后再运行查看最后一个数据,发现增加了1
增加 C 函数 testCFunc(),然后再运行查看最后一个数据,又增加了1
增加 block,然后再运行查看最后一个数据,又增加了1
结论:__sanitizer_cov_trace_pc_guard_init能够监控项目中所有OC方法、C函数 和 block,也就是反映了项目中符号的个数
__sanitizer_cov_trace_pc_guard
__sanitizer_cov_trace_pc_guard方法是拦截项目中的方法,相当于Hook
在touchesBegan:中调用testCFunc()函数,然后函数中调用block
运行项目,在__sanitizer_cov_trace_pc_guard函数处打断点,
点击屏幕触发touchesBegan:时堆栈信息:
调用 testCFunc() 函数时堆栈信息:
调用 block 时堆栈信息:
过掉断点发现每执行一个 OC方法/C函数/block都会触发这个函数。
结论:__sanitizer_cov_trace_pc_guard是由项目内部方法,函数,block函数调用,也就是Hook了项目中一切的函数调用
原理
在项目运行起来后,然后在touchesBegan:函数处打断点,点击屏幕查看汇编
-
通过观察发现在
touchesBegan:的汇编实现中先调用了__sanitizer_cov_trace_pc_guard函数,然后在testCFunc函数和block处都打断点发现都是一样的 -
所以,项目中只要添加了
Clang插桩的标记,编译器就会在所有方法、函数、block代码实现的 边缘 添加一句__sanitizer_cov_trace_pc_guard代码 -
编译器在所有代码
(自己写的代码)中添加这个函数,也就是修改了二进制文件,只有编译器能干这个事情 -
那么可以在上线前
代码审查时使用,上线时,将Other C Flag中的标记清除即可
获取启动符号
接下来需要获取启动符号,首先我们知道现在每个方法都会调用__sanitizer_cov_trace_pc_guard函数,用__builtin_return_address(0)函数就可以获取__sanitizer_cov_trace_pc_guard的调用者函数地址。具体方法如下
void __sanitizer_cov_trace_pc_guard(uint32_t *guard) {
// 获取调用者函数的地址
void *PC = __builtin_return_address(0);
Dl_info info; // 符号信息结构体
dladdr(PC, &info); // 将从PC中获取的函数信息存入结构体中
printf("Dl_info : \nfname: %s\nfbase: %p\nsname: %s\nsaddr: %p\n\n", info.dli_fname, info.dli_fbase, info.dli_sname, info.dli_saddr);
}
-
Dl_info是一个结构体,用来存储函数相关信息:typedef struct dl_info { const char *dli_fname; // 当前项目路径 void *dli_fbase; // 是`MachO`文件的起始位置 const char *dli_sname; // 符号名字(方法名) void *dli_saddr; // 函数地址 } Dl_info; -
dladdr作用是将获取函数的信息存入Dl_info结构体中 -
验证结果如下:
-
如此这般 便可以用
dli_sname直接输出符号名字,就得到了所有方法的调用顺序
坑点分析及解决方案
上面的方式虽然获取到启动时调用的方法,但是有个坑点,就是会在多线程中访问数据 代码如下:
可以看出testMethod方法的调用是在子线程
在子线程调用的方法,__sanitizer_cov_trace_pc_guard也在子线程,这时就产生了多线程访问,可能会出现获取 方法信息 顺序错乱的问题。这个可以使用OSAtomicEnqueue()和 OSAtomicDequeue()来线程安全,使获取方法信息顺序不出错
-
OSAtomicEnqueue()和OSAtomicDequeue()使用- 先导入头文件
#import <libkern/OSAtomic.h>, - 再将
Other C Flag标识符改成:-fsanitize-coverage=func,trace-pc-guard,里面添加了func是仅限函数使用!!! - 因为
-fsanitize-coverage=trace-pc-guard标识,默认循环也会调用__sanitizer_cov_trace_pc_guard,核心代码如下:
// 定义一个原子队列 static OSQueueHead symbolList = OS_ATOMIC_QUEUE_INIT; // 链表结构体 typedef struct { void *pc; // 存获取到的PC(方法地址) void *next; // 指向下一个节点 (下一个方法地址) } Node; void __sanitizer_cov_trace_pc_guard(uint32_t *guard) { void *PC = __builtin_return_address(0); // 给node节点分配内存 Node *node = malloc(sizeof(Node)); // 将PC赋值给node的pc *node = (Node){PC, NULL}; // 在队列中将新的node节点存入上个节点的next位置 OSAtomicEnqueue(&symbolList, node, offsetof(Node, next)); } - (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event { // 上面 `Other C Flag`标识符改成:`-fsanitize-coverage=func,trace-pc-guard` // 就是为了避免这里循环,一直调用 __sanitizer_cov_trace_pc_guard while (YES) { Node *node = OSAtomicDequeue(&symbolList, offsetof(Node, next)); // 在队列上个节点的next位置获取node if (node == NULL) { break; } Dl_info info; dladdr(node->pc, &info); //将pc信息存入info printf("sname: %s\n", info.dli_sname); } } - 先导入头文件
-
此时就可以保证线程安全,按真实的顺序取到启动符号了
生成Order文件并配置
将打印出来的启动方法放到launchopt.order文件,然后取出来进行二进制重排,代码如下:
- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
// 上面 `Other C Flag`标识符改成:`-fsanitize-coverage=func,trace-pc-guard`
// 就是为了避免这里循环,一直调用 __sanitizer_cov_trace_pc_guard
NSMutableArray<NSString *> *symbolNameArray = [NSMutableArray array];
while (YES) {
Node *node = OSAtomicDequeue(&symbolList, offsetof(Node, next));
if (node == NULL) { break; }
Dl_info info;
dladdr(node->pc, &info);
printf("sname: %s\n", info.dli_sname);
NSString *funcName = @(info.dli_sname); // 转成OC字符串
// 是否是 OC 方法
BOOL isOcFunc = [funcName hasPrefix:@"-["] || [funcName hasPrefix:@"+["];
// 生成方法名字
funcName = isOcFunc ? funcName : [@"_" stringByAppendingString:funcName];
// 去重
if (![symbolNameArray containsObject:funcName]) {
[symbolNameArray insertObject:funcName atIndex:0];
}
}
// 去掉自己
[symbolNameArray removeObject:[NSString stringWithFormat:@"%s", __func__]];
// 写入文件
NSString *funcString = [symbolNameArray componentsJoinedByString:@"\n"];
NSLog(@"\nfuncString: \n%@", funcString);
NSString *filePath = [NSTemporaryDirectory() stringByAppendingPathComponent:@"launchopt.order"];
NSData *fileData = [funcString dataUsingEncoding:NSUTF8StringEncoding];
BOOL isSuccess = [[NSFileManager defaultManager] createFileAtPath:filePath contents:fileData attributes:nil];
if (isSuccess) {
NSLog(@"写入成功 ✅");
} else {
NSLog(@"写入失败 ❌");
}
}
-
运行后得到的方法顺序为
-
在
Window->Devices and Simulators选中自己的手机,然后选择当前的LaunchOptimization,再Download Container下载到桌面- 在右键显示包内容,在
AppData/tmp里找到launchopt.order文件,将文件放到工程根目录 - 然后在
Build Setting的Order File配置好路径, - 然后将
Write Link Map File设置为YES,最后删除Other C Flag内容,运行后找到txt文件并打开
- 在右键显示包内容,在
此时得到的顺序和打印的一致!
Swift符号获取
首先添加 Swift 代码如下:
class SwiftTest: NSObject {
@objc class public func swiftTest(){
print("Swift Test ...")
}
}
在 viewDidLoad 中调用:
- (void)viewDidLoad {
[super viewDidLoad];
[SwiftTest swiftTest];
}
获取Swift的符号需要在Build Setting->Other Swift Flag处配置-sanitize-coverage=func,与-sanitize=undefined,如下图:
运行项目,输出: