前言
之前我们分析过LLVM编译流程,清楚了App的整个编译过程,也分析过iOS应用程序加载大致流程分析,清楚了dyld链接加载的整个过程,今天我们在这些基础上,针对App的启动
做一些优化的事情。
一、基础概念
在做启动优化之前,我们需要弄清楚一些关于优化的基础概念。
1.1 物理内存 vs 虚拟内存
- 物理内存:你可以这么理解,就是电脑插的
内存条
,容量就是真实的,是8G就8G,是16G就16G。 - 虚拟内存:物理内存的衍生物。
物理内存很好理解,但虚拟内存就有些难了,下面重点分析一下虚拟内存的由来
。
早期计算机中,没有虚拟内存
的概念,只有物理内存
,每个应用都全部写在内存条中
,当内存条空间不够时,就会内存告警
,这时我们必须手动关闭
一些应用,释放内存空间
来让当前的应用运行。如下图
明显,物理内存这么使用,会有以下问题:
-
内存不够
:每个应用一打开,就把所有信息都加载进入内存,占用太多资源。如果是体积大的软件,则直接无法加载。 -
不安全
: 应用一旦加载进入内存,其地址都是固定不变的,那么我们可以通过物理地址去篡改对应的信息,很不安全。例如:早期的一些游戏外挂,就是通过物理内存地址去篡改数据。
那么,针对上述两大问题,前辈们经过研究发现,其实每个应用在内存中使用的部分,仅占该应用的小部分(活跃部分)
,于是他们将内存均匀分割成很多页
。应用的运行也不用全部加载到内存,而是分配一个虚拟的内存,也跟物理内存的一样,被分割成很多页
,如下图所示
内存页
内存页就是将内存分割成一小块,以页
的方式作为计量的单位。那一页的大小
是多少呢?Linux和MacOS系统:每页4K;iOS系统: 每页16K。
页表
页表就是应用的虚拟内存
与物理内存
的地址映射关系表
。
ASLR
在上面解释的虚拟内存中,我们提到了虚拟内存的起始地址与大小都是固定的,这意味着,当我们访问时,其数据的地址也是固定的,这会导致我们的数据非常容易被破解,为了解决这个问题,所以苹果为了解决这个问题,在iOS4.3开始引入了ASLR技术。
ASLR的概念:(Address Space Layout Randomization) 地址空间配置随机加载
,是一种针对缓冲区溢出
的安全保护技术,通过对堆、栈、共享库映射
等线性区布局的随机化
,通过增加攻击者预测目的地址的难度,防止攻击者直接定位攻击
代码位置,达到阻止溢出攻击的目的的一种技术。其目的的通过利用随机方式配置数据地址空间
,使某些敏感数据(例如APP登录注册、支付相关代码)
配置到一个恶意程序无法事先获知的地址,令攻击者难以进行攻击。
由于ASLR的存在,导致可执行文件和动态链接库在虚拟内存中的加载地址每次启动都不固定
,所以需要在编译时
来修复镜像中的资源指针
,来指向正确的地址。即正确的内存地址 = ASLR地址 + 偏移值
。
虚拟内存特点
那么,采用虚拟内存
去加载应用就具备了以下特点
- 每个应用(进程)默认可以分配
4G
大小。但它实际只是一张页表
,记录映射关系
就可以。 - 页表
存放
在操作系统的内存区域
。 - 应用用到的都是
虚拟内存
,实际占有物理内存大小
是应用运行时决定
的。
1.2 冷启动 vs 热启动
应用的启动大致分为3种情况:
- 首次启动
- kill应用后重新启动
- 应用置于后台,隔一段时间后再切回前台激活启动
这3种启动的情况,有的启动很快
,而有的启动又有些慢
,这就是冷启动
和 热启动
的区别
冷启动
内存中不包含APP的数据
,所有数据都需要载入内存中,提供给应用使用。因为从磁盘读取数据加载到内存中
,比较耗时
,所以速度慢
。
(ps: 内存中的数据是不会被删除的,但是存储空间可能被其他应用使用了,从而数据被覆盖。)
热启动
内存中仍然存在APP的数据
,数据不需要重新载入内存
,所以速度快
。
(ps: 当前应用所占的内存空间,未被其他应用覆盖。所以数据依旧可读取。)
那么,以上3种启动的场景,分别是那种启动呢?
- 首次启动: 一定是
冷启动
。(内存中无数据) - kill后启动:
冷启动
或热启动
。 (取决于内存中是否有数据) - 置于后台再回到前台启动:
冷启动
或热启动
。(取决于内存中是否有数据)
注意:
如果其他应用需要更多内存空间,系统可能自动覆盖
你的内存空间提供给其他应用使用,此时你的数据就被覆盖了,回到前台时,应用自动重启
。
1.3 启动性能检测和分析
在测试App应用启动之前,其实应该分为两大阶段,以main函数为边界
- main方法之前-->
dyld
负责的加载流程
系统处理,我们从dyld应用加载的流程来优化。(借助系统工具
分析耗时) - main方法之后--> 开发者自己的
业务代码
。
通过检测业务流程来优化(main函数打印个时间点、第一个页面渲染完成打印个时间点,这个时间差就是main之后到第一个页面显示出来的耗时
)
1.3.1 main函数前
我们都知道,main之前都是dyld负责的,说白了就是系统决定的东西,我们很难去修改其中的流程,那么,有其它的手段么? 当然有,针对ipa包砸壳
,这些不作为重点,知道即可
。
- 创建一个Demo工程,新增环境变量
DYLD_PRINT_STATISTICS
- 砸壳 (ps:此过程忽略,感兴趣的朋友可网上自行搜索。)
- 添加重签名脚本
appSign.sh
脚本代码如下
# ${SRCROOT} 它是工程文件所在的目录
TEMP_PATH="${SRCROOT}/Temp"
#资源文件夹,我们提前在工程目录下新建一个APP文件夹,里面放ipa包
ASSETS_PATH="${SRCROOT}/APP"
#目标ipa包路径
TARGET_IPA_PATH="${ASSETS_PATH}/*.ipa"
#清空Temp文件夹
rm -rf "${SRCROOT}/Temp"
mkdir -p "${SRCROOT}/Temp"
#----------------------------------------
# 1. 解压IPA到Temp下
unzip -oqq "$TARGET_IPA_PATH" -d "$TEMP_PATH"
# 拿到解压的临时的APP的路径
TEMP_APP_PATH=$(set -- "$TEMP_PATH/Payload/"*.app;echo "$1")
# echo "路径是:$TEMP_APP_PATH"
#----------------------------------------
# 2. 将解压出来的.app拷贝进入工程下
# BUILT_PRODUCTS_DIR 工程生成的APP包的路径
# TARGET_NAME target名称
TARGET_APP_PATH="$BUILT_PRODUCTS_DIR/$TARGET_NAME.app"
echo "app路径:$TARGET_APP_PATH"
rm -rf "$TARGET_APP_PATH"
mkdir -p "$TARGET_APP_PATH"
cp -rf "$TEMP_APP_PATH/" "$TARGET_APP_PATH"
#----------------------------------------
# 3. 删除extension和WatchAPP.个人证书没法签名Extention
rm -rf "$TARGET_APP_PATH/PlugIns"
rm -rf "$TARGET_APP_PATH/Watch"
#----------------------------------------
# 4. 更新info.plist文件 CFBundleIdentifier
# 设置:"Set : KEY Value" "目标文件路径"
/usr/libexec/PlistBuddy -c "Set :CFBundleIdentifier >$PRODUCT_BUNDLE_IDENTIFIER" "$TARGET_APP_PATH/Info.plist"
#----------------------------------------
# 5. 给MachO文件上执行权限
# 拿到MachO文件的路径
APP_BINARY=`plutil -convert xml1 -o - $TARGET_APP_PATH/Info.plist|grep -A1 Exec|tail -n1|cut -f2 -d\>|cut -f1 -d\<`
#上可执行权限
chmod +x "$TARGET_APP_PATH/$APP_BINARY"
#----------------------------------------
# 6. 重签名第三方 FrameWorks
TARGET_APP_FRAMEWORKS_PATH="$TARGET_APP_PATH/Frameworks"
if [ -d "$TARGET_APP_FRAMEWORKS_PATH" ];
then
for FRAMEWORK in "$TARGET_APP_FRAMEWORKS_PATH/"*
do
#签名
/usr/bin/codesign --force --sign "$EXPANDED_CODE_SIGN_IDENTITY" "$FRAMEWORK"
done
fi
#注入
#yololib "$TARGET_APP_PATH/$APP_BINARY" >"Frameworks/HankHook.framework/HankHook"
将砸壳后的ipa包和appSign.sh
脚本文件放入Demo工程目录中,如下图
配置脚本,如下图
- 运行项目
运行结果如下
Total pre-main time: 1.2 seconds (100.0%)
dylib loading time: 326.38 milliseconds (25.4%)
rebase/binding time: 146.54 milliseconds (11.4%)
ObjC setup time: 40.49 milliseconds (3.1%)
initializer time: 767.04 milliseconds (59.9%)
slowest intializers :
libSystem.B.dylib : 6.86 milliseconds (0.5%)
libMainThreadChecker.dylib : 38.26 milliseconds (2.9%)
libglInterpose.dylib : 447.73 milliseconds (34.9%)
marsbridgenetwork : 48.86 milliseconds (3.8%)
mars : 30.85 milliseconds (2.4%)
砸壳应用 : 212.00 milliseconds (16.5%)
1.3.2 分析dyld耗时
-
Total pre-main time --> main函数前的
总耗时
。- dylib loading time --> dylib库的加载耗时。(官方建议,
动态库不超过6个
) - rebase/binding time -->
重定向
(从磁盘的MachO中image镜像到内存中)和绑定
(MachO中每个文件使用其他库的符号时,绑定库名和地址)操作的耗时.
注意
:出于安全考虑,编译时和运行时地址不一样。使用了ASLR随机偏移值来进行内存读取,这就是要重定向和重新绑定的原因。 - ObjC setup time --> OC类的注册耗时。 (OC类越多,越耗时)
- initializer time --> 初始化耗时。(load非懒加载类和c++构造函数的耗时)
- dylib loading time --> dylib库的加载耗时。(官方建议,
-
slowest intializers --> 最慢的启动对象
- libSystem.B.dylib --> 系统库
- libMainThreadChecker.dylib --> 系统库
- libglInterpose.dylib --> 系统库(调试使用的,不影响)
- 砸壳应用 --> 自己的APP耗时
1.3.3 main函数后
接下来我们看看main函数后如何处理。
- 启动时用不到的类和页面,移到启动后创建
- 耗时操作使用多线程处理
- 启动页面,尽量不用XIB和StoryBoard
二、二进制重排
二进制重排
这个方案最开始是由于抖音的这篇文章抖音研发实践:基于二进制文件重排的解决方案 APP启动速度提升超15%火起来的,专门针对pre-main阶段
优化的一个方案。
2.1 重排的原理
应用启动前,页表
是空的,每一页都是PageFault(页缺省)
,启动时用到的每一页都需要cpu从硬盘
读取到物理内存
中,基于Page Fault
,App在冷启动
过程中,会有大量的类、分类、三方库等需要加载和执行,此时的产生的Page Fault
所带来的的耗时
是很大的。那么,我们得想办法,减少
在启动时需要加载的页数
。
iOS中每一页是16K
大小,但是16K
中,可能真正在启动时刻需要用到的,可能不到1K
。 但是启动需要访问到这1K
数据,不得不把整页都加载
。
我们的二进制重排
,就是为了把启动用到的这些数据,整合
到一起,然后再进行内存分页
。这样启动用到的数据都在前几页
中了。启动时,只需要加载几页数据
就可以了。
2.2 动手实践
2.2.1 方法顺序 & 文件加载顺序
- 创建个TestReArrange工程,加入测试代码:
#import "ViewController.h"
@interface ViewController ()
@end
@implementation ViewController
void test1() {
printf("1");
}
void test2() {
printf("2");
}
- (void)viewDidLoad {
[super viewDidLoad];
printf("viewDidLoad");
test1();
}
+(void)load {
printf("load");
test2();
}
@end
- 在Build Settings中搜索
link Map
,设置Write Link Map File
为YES
3.cmd+B
编译项目,找到product文件夹,右键Show In Finder
- 在包文件夹的上两层级,找到
Intermediates.noindex
- 打开,找到并打开
TestReArrange-LinkMap-normal-x86_64
文件
可以发现,我们在ViewController.m
中声明的函数的顺序,和在TestReArrange-LinkMap-normal-x86_64
文件中显示的顺序是一致的。
再接着看TestReArrange-LinkMap-normal-x86_64
文件,发现方法的加载顺序
和Build Phases里的文件的编译顺序
也是一致的
综上,二进制的排列顺序:先
文件按照加载顺序
排列,文件内部按照函数书写从上到下
的书序排列。
2.2.2 PageFault检测
找一个比较大的项目,按照下面的步骤检测
- 连接
真机
,运行项目,打开Instruments检测工具
- 选择
System Trace
- 选择
真机
,选择自己的项目
,点击第一个按钮运行,等APP启动后,点击第一个按钮停止。
- 选择
Process
,找到自己项目的BundleID
- 选中
主线程
,选择虚拟内存
,查看File Backed Page In
(就是PageFault缺省页)
可以看到,缺省页就2页
,耗时170.83微秒
,平均耗时85.19微秒
,这是热启动
的情况下。
我们再看看冷启动
(数据应该更大)
果然,缺省页4027页
,耗时865.25毫秒
,平均耗时214.86微秒
。
2.2.3 重排初体验
二进制重排,关键是.order文件
。我们之前用的objc源码,会在工程中看到.order
文件
打开.order文件
,可以看到内部都是排序好的函数符号
有很多系统的函数
,这是因为苹果自己的库
,也都进行了二进制重排
。
现在,我们在自己的TestReArrange项目里,试一下改变这个.order文件
的函数符号,看看有什么效果。
- 在TestReArrange项目根目录创建一个.order文件
- 打开创建的
TestReArrange.order
,手动动添加顺序load->test1->ViewDidLoad->main
- 配置order文件,在
Build Settings
中搜索order file
,加入./TestReArrange.order
- Command + B编译后,再次去查看
link map文件
可以发现:
- 发现order文件中不存在的函数(hello),编译器会直接跳过。
- 其他函数符号,完全按照我们order顺序排列。
- order中没有的函数,按照默认顺序接在order函数后面。
至此,我们验证发现了oder文件的重要性
,但是,靠手写
一个个函数到order文件中,如果项目代码量很大,我们怎么知道哪些方法必须调用
?况且一个大的项目,是多个人一起开发
,别人负责的模块根本不清楚,这时该怎么办?这时就要引入我们的重点模块--> clang插桩!
三、重点:clang插桩
试想上面的问题,一是我们想将手动
写函数改为自动
写函数到order文件,二是得想办法获取
到App启动时调用的所有方法的名称
。第一点实现起来很简单,一个简单的文件写操作即可,关键是第二点,如何获取你想要的方法名称
?很直观的,我们会想到hook
,通过方法hook
,先获取到方法名称,存起来再写入到order文件。
3.1 Hook方案
hook大致有以下几种方案:
hook
objc_msgSend
:我们知道,方法调用的本质是发送消息,在底层都会来到objc_msgSend
,但是由于objc_msgSend
的参数是可变的
,需要通过汇编获取
,对开发人员要求较高,而且也只能拿到OC
和swift中@objc
后的方法,对于c、c++函数则无法捕捉,pass!fishhook
:fishhook 是 FaceBook 开源的可以动态修改 MachO 符号表
的工具。fishhook 的强大之处在于它可以 HOOK 系统的静态 C 函数
。fishhook利用ios的动态库符号延迟绑定机制进行hook,但是这种延迟绑定机制仅有在可执行文件调用动态库或framework时
才会发生。而动态库
和framework
之间的相互调用
,在被加载时
就确定了所有符号的地址,调用时是直接跳到
相应的函数入口地址,所以fishhook
不能hook其它库例如第三方库
里的函数,pass!clang插桩:官方文档,文档中指出,llvm内置了一个简单的代码覆盖率检测(
SanitizerCoverage
)。它在函数级、基本块级和边缘级
插入对用户定义函数的调用。我们这里的批量hook,就需要借助于SanitizerCoverage
。
3.2 配置 & 使用 SanitizerCoverage
- 配置开启
SanitizerCoverage
,按照项目使用的语言区分:
OC项目,需要在:在
Build Settings
里的Other C Flags
中添加-fsanitize-coverage=trace-pc-guard
如果是Swift项目,还需要额外在
Other Swift Flags
中加入-sanitize-coverage=func
和-sanitize=undefined
如果集成了
cocoapods
管理项目,也可在podfile
中修改
post_install do |installer|
installer.pods_project.targets.each do |target|
target.build_configurations.each do |config|
config.build_settings['OTHER_CFLAGS'] = '-fsanitize-coverage=func,trace-pc-guard'
config.build_settings['OTHER_SWIFT_FLAGS'] = '-sanitize-coverage=func -sanitize=undefined'
end
end
end
- 使用
SanitizerCoverage
- 新建工程
TraceDemo
,按照步骤1里配置
- 去官方文档中,copy示例代码
我们可以在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;
}
void __sanitizer_cov_trace_pc_guard(uint32_t *guard) {
if (!*guard) return;
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);
}
再添加测试代码
void test(){
block1();
}
void(^block1)(void) = ^(void){
};
- (void)viewDidLoad {
[super viewDidLoad];
test();
}
- run,看看控制台的输出
注意:一定要
真机调试
!
发现报错
可注释改行代码
继续运行
上图发现,__sanitizer_cov_trace_pc_guard_init
函数的参数start
和 stop
的地址。我们先看看__sanitizer_cov_trace_pc_guard_init
的参数释义
__sanitizer_cov_trace_pc_guard_init函数
- uint32_t *start
是一个指针,指向无符号int类型
,4个字节
,相当于一个数组的起始位置,即符号的起始位置(是从高位往低位读)。我们看看打印出的地址具体信息
- uint32_t *stop
由于数据的地址是往下读的(即从高往低
读,所以此时获取的地址并不是stop真正的地址,而是标记的最后的地址,读取stop时,由于stop占4个字节
,stop真实地址 = stop打印的地址-0x4)。我们看看打印出的地址具体信息
那么stop中到底存储了什么信息呢?我们再增加一个方法/块/c++/属性的方法(多3个),发现其值也会增加对应的数,例如增加一个test1方法
void test1() {
block1();
}
- (void)viewDidLoad {
[super viewDidLoad];
test();
test1();
}
运行
我们发现,由之前1a 变成了 1b,增加了方法的调用,stop的地址也对应的增加。
__sanitizer_cov_trace_pc_guard函数
我们再来看看__sanitizer_cov_trace_pc_guard
函数的参数uint32_t *guard
- 参数guard是一个
哨兵
,告诉我们是第几个被调用的
。
示例:我们新增一个监听屏幕点击的方法,在里面调用test
- (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event {
test();
}
先run,再点击屏幕,看控制台输出
这3次分别是touchBegin
、test
、block
三个函数被触发时的打印!
验证:我们在touchesBegan
和__sanitizer_cov_trace_pc_guard
里分别加入断点,
运行,查看汇编
上图可知,确实在touchesBegan
的调用中先调用了__sanitizer_cov_trace_pc_guard
。
至此,我们得出结论
通过__sanitizer_cov_trace_pc_guard
这个函数,可以hook住所有的方法。
那么接下来,就是需要获取所有函数的名称(即函数符号
),然后存储并导出.order文件
。
3.3 获取函数符号
在__sanitizer_cov_trace_pc_guard
这个函数中,有一句代码void *PC = __builtin_return_address(0);
,这个__builtin_return_address
函数作用是什么?我们先验证一下
__builtin_return_address
我们可以通过Dl_info
接收PC信息 ,再打印查看
注意:需引入头文件
#import
typedef struct dl_info {
const char *dli_fname; /* 文件地址*/
void *dli_fbase; /* 起始地址(machO模块的虚拟地址)*/
const char *dli_sname; /* 符号名称 */
void *dli_saddr; /* 内存真实地址(偏移后的真实物理地址) */
} Dl_info;
修改代码
void __sanitizer_cov_trace_pc_guard(uint32_t *guard) {
if(!*guard) return;
void *PC = __builtin_return_address(0); //0 当前函数地址, 1 上一层级函数地址
Dl_info info; // 声明对象
dladdr(PC, &info); // 读取PC地址,赋值给info
printf("dli_fname:%s \n dli_fbase:%p \n dli_sname:%s \n dli_saddr:%p \n ", info.dli_fname, info.dli_fbase, info.dli_sname, info.dli_saddr);
}
run
这样,我们就拿到了函数符号
。接下来就是存储写入.order文件了。
3.2 .order文件写入
写入文件时,我们得考虑两个问题:
-
多线程
的情况 - 用什么数据结构来存储
函数符号
在之前的锁的原理中分析过,加锁可以应对多线程对资源的竞争,那么此时我们可使用OSAtomic原子锁
。然后采用链表
去存储函数符号
,因为链表的插入
和删除
比数组这样的有序表
速度更快,效率更高。
综上分析,我们可以采用系统提供的原子队列 OSQueue
防止多线程资源竞争,然后将函数符号存在链表
结构体当中。
原子队列的使用
- 引入头文件
#import
- 定义原子队列
static OSQueueHead symbolList = OS_ATOMIC_QUEUE_INIT;
- 定义符号结构体,用于接收
函数符号
信息,void * next表示是个链表
的结构。
typedef struct{
void *pc;
void *next;
} SYNode;
- 修改
__sanitizer_cov_trace_pc_guard
,代码如下
void __sanitizer_cov_trace_pc_guard(uint32_t *guard) {
if (!*guard) return;
void *PC = __builtin_return_address(0);
// 创建结构体!
SYNode * node = malloc(sizeof(SYNode));
*node = (SYNode){PC,NULL};
//加入结构!
OSAtomicEnqueue(&symbolList, node, offsetof(SYNode, next));
}
至此,函数的符号信息,被存储在了SYNode结构体链表
中,然后OSAtomicEnqueue
加入了原子队列防止多线程。
生成.order文件
我们可以在touchesBegan
中取出函数符号,存储文件,代码如下
-(void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event
{
// 创建可变数组
NSMutableArray * symbolNames = [NSMutableArray array];
// 每次while循环,都会加入一次hook (__sanitizer_cov_trace_pc_guard) 只要是跳转,就会被block
// 直接修改[other c clang]: -fsanitize-coverage=func,trace-pc-guard 指定只有func才加Hook
while (1) {
// 去除链表
SYNode * node = OSAtomicDequeue(&symbolList, offsetof(SYNode, next));
if(node ==NULL) break;
Dl_info info = {0};
// 取出节点的pc,赋值给info
dladdr(node->pc, &info);
// 释放节点
free(node);
// 存名字
NSString *name = @(info.dli_sname);
NSLog(@"方法名称:%@", name);
// 三目运算符 写法
BOOL isObjc = [name hasPrefix: @"+["] || [name hasPrefix: @"-["];
NSString * symbolName = isObjc ? name : [NSString stringWithFormat:@"_%@",name];
[symbolNames addObject:symbolName];
}
// 反向集合
NSEnumerator * enumerator = [symbolNames reverseObjectEnumerator];
// 创建数组
NSMutableArray * funcs = [NSMutableArray arrayWithCapacity:symbolNames.count];
// 临时变量
NSString * name;
// 遍历集合,去重,添加到funcs中
while (name = [enumerator nextObject]) {
// 数组中去重添加
if (![funcs containsObject:name]) {
[funcs addObject:name];
}
}
// 移除当前touchesBegan函数 (跟启动无关)
[funcs removeObject:[NSString stringWithFormat:@"%s",__FUNCTION__]];
// 数组转字符串
NSString * funcStr = [funcs componentsJoinedByString:@"\n"];
// 文件路径
NSString * filePath = [NSTemporaryDirectory() stringByAppendingPathComponent:@"ht.order"];
// 文件内容
NSData * fielContents = [funcStr dataUsingEncoding:NSUTF8StringEncoding];
// 创建文件
[[NSFileManager defaultManager] createFileAtPath:filePath contents:fielContents attributes:nil];
NSLog(@"%@",funcs);
NSLog(@"%@",filePath);
NSLog(@"%@",fielContents);
}
- 首先通过
while循环
遍历原子队列
,取出函数符号
信息,存储在数组 - 因为会存在重复调用的情况,所以
去重
- 还需要移除
touchesBegan
这一次的调用 - 文件写入
坑点
if(!*guard) return;需要去掉,会影响+load的写入
-
while循环,也会不停的触发
__sanitizer_cov_trace_pc_guard
,输出touchesBegan
通过看汇编,可以看到while也触发了__sanitizer_cov_trace_pc_guard的跳转。原因是trace的触发并不是根据函数
来进行hook的,而是hook了每一个跳转(bl)
。因为while也有跳转,所以进入了死循环
。
解决方法
Build Settings
的Other C Flags
配置,添加一个func
指定条件-fsanitize-coverage=func,trace-pc-guard
补充:swift版的插桩
总结
本篇文章篇幅很长,主要分析了App启动时虚拟内存的处理模式,再结合修改link符号文件对启动时调用函数顺序的变化,代码hook所有函数符号,并写入文件,实现了一个简单的clang插桩流程。
本篇文章参考
OC底层原理三十三:启动优化(二进制重排)