使用二进制重排 & Clang插桩技术点来进行iOS冷启动进行优化
1.冷启动
1.1 什么是冷启动?
冷启动是指内存中不包含该应用程序相关的数据,必须要从磁盘载入到内存中的启动过程。
注意:重新打开 APP, 不一定就是冷启动。
- 当内存不足,APP被系统自动杀死后,再启动就是冷启动。
- 如果在重新打开 APP 之前,APP 的相关数据还存储在内存中,这时再打开 APP,就是热启动
- 冷启动与热启动是由系统决定的,我们无法决定。
- 当然设备重启以后,第一次打开 APP 的过程,一定是冷启动。
1.2 如何统计冷启动耗时?
一般来讲,统计 APP 启动时长,以 main 函数为节点 ,分两个大阶段:
- main 函数之后的代码,是我们自己写的,我们可以自行统计进入 main 函数到第一个界面显示的耗时。
- 在 main 函数里打印一下当前的时间,
- 在第一个要显示的控制器的 viewDidLoad 方法中打印一下当前时间
- 两个时间的差值,即为main函数后的加载时长。
- main 函数之前,为 pre-main 阶段,由于是系统在做事情,这段时间的 耗时,我们没办法直接统计,需要查 看系统给我们的反馈。
1.2.1 pre-main阶段都做了什么?
接下来看一下项目中的 pre-main 阶段的耗时。
- 查看系统给的反馈需要 增加一个环境变量,
- 增加路径:在 Xcode -> Edit Scheme -> Run -> Arguments -> Environment Variables 中,
- 增加一个环境变量 DYLD_PRINT_STATISTICS:1。
下图是我项目的加载耗时:
耗时过程分为以下4部分:
- dylib loading time : 是指动态库加载的耗时,系统的动态库做过优化,耗时较少。 苹果官方推荐最多不要超过6个外部动态库,多余6个,需要考虑合并动态库,合并动态库对于启动时期的优化,非常有效。 像微信的动态库早期有八九个,现在也优化成6个了。
- rebase/binding
- rebase:是指地址的 偏移修正耗时。
- 在编译生成二进制文件的时候,每个函数都有一个地址,这个地址是相对于二进制文件的偏移地址。
- 在启动时,也就是在二进制文件在加载到虚拟内存的时候,为了安全起见,苹果有个安全机制(ASLR),会在整个二进制文件的最前面,随机加一个偏移值。
- 比如 A 函数,相对于二进制文件的偏移值是 0x003。 启动时,整个二进制文件被分配了一个随机值0x100。 那么 A 函数在内存中的实际地址是 0x003 + 0x100 = 0x103。
- 偏移修正指的就是计算方法在虚拟内存中的地址的过程!
- binding: 动态库的方法绑定,是指将方法名字与方法的实现进行绑定过程的耗时。
- 比如 NSLog 方法,在加载的时候需要先找到Foundation库,再找到库里的NSLog的方法的实现,将方法名字和方法实现绑定在一起。
- Objc setup time: 注册所有 OC类 耗时, 类越多耗时越多,有人统计过2万个自定义的OC的类,大概耗时800毫秒。删除不用的类,可以减少耗时。
- initializer time: load方法 和 C++构造函数的耗时. 减少重写load方法,尽量将事情延迟到 main 方法以后,可以减少耗时。
- slowest intializers : 指出了最耗时的几个库是下面的6个库(最后一个是我的项目)。
1.2.2 pre-main阶段耗时优化方法总结:
- 减少外部动态库的数量
- 不用的类和方法,删除
- 类尽量使用懒加载,也就是尽量不要重写load方法。
- 启动时加载的数据使用多线程
- 使用纯代码。不用xib storyboard(要额外进行代码解析转换和页面的渲染)
以上方法,都是和自己的项目代码息息相关的优化方案。不同项目具体是实施动作不一样。
还有一个优化方法,不管是什么项目,实施动作都一样 ,对什么项目都有效,那就是二进制重排!
2. 二进制重排
学习二进制重排,首先要知道数据是如何加载到内存中的 。
☞内存加载机制:数据是如何加载到内存中的
我们已经知道数据加载到内存的过程,当虚拟内存页还没有对应的物理内存页时,会出现缺页异常(PageFault)。
当冷启动时,物理内存中是没有数据的,这时会出现大量的缺页异常,在iOS生产环境的app,在发生Page Fault进行重新加载时,iOS系统还会对其做一次签名验证,因此 iOS 生产环境的 Page Fault 比Debug环境下所产生的耗时更多
这里有没有优化空间呢?接下来就是优化方案:二进制重排!
在了解二进制重排之前,再了解下在项目编译生成二进制文件的时候,类及其内部方法实现的排列顺序是什么样的呢?
2.2.1 二进制文件中方法实现排序是什么样的?
- 在 viewController 中,先随便写几个方法。
- 再看下源文件的编译顺序
接下来查看 Link Map文件查看符号顺序, 查看方式:
- 打开link map
****
- 编译生成link map 文件
- 找到link map 文件
- 项目目录中,生成的 app 右键,show in Finder
- 找到 app 的上上级目录
- 进入Intermediates.noindex -> TraceDemo.build -> Debug-iphonesimulator -> TraceDemo.build -> TraceDemo-LinkMap-normal-x86_64.txt
- 打开link map 文件,找到自己的类及方法的名字
5.我们可以直观的看出 link map中符号的顺序,类是以源文件的编译顺序,从上到下按序排列。方法名是以类中方法的书写顺序,由上到下排序。
2.2.2 为什么需要二进制重排?
从源码的执行顺序上看,应该是 load -> test2 -> viewDidLoad -> test1.
但是二进制文件中符号的顺序是方法从上到下的书写顺序,没有按照调用顺序去排列。
在冷启动分页加载二进制文件时,发现很多页中都有启动时需要用到的方法,那么即使页里面也存在启动时不需要的方法,但是由于内存是分页管理的,要加载就要整页加载。这样就导致了大量不需要在 pre-main 阶段执行的方法,也会被加载到内存中,增加了启动的耗时。
\
例如,启动需要加载100个页,每个页可以包含20个方法。但是每个页里只有2个方法是启动时后用到的。这样实际上启动时必须要的方法是2 * 100 = 200个,如果将这200个方法紧挨着放在一起,那么只需要2页。比100个页,减少了98页。这样耗时就会大大降低。
2.2.3 如何进行二进制重排?
1. 二进制重排的方法
在项目编译生成二进制文件的时候,找到启动时需要的方法,并且将它们放在一起 重新排序,这就是二进制重排。
两个关键点: 找到启动时需要方法 & 方法 的重排序
2.方法的重排序:
重排序其实很简单。xcode已经为我们提供了这个机制,它使用的链接器叫做 ld, ld有一个参数叫做Order File, 我们可以通过配置order文件,来使编译时生成的二进制的文件的Link Map种的符号顺序,按照我们指定的顺序排列生成。而且 libobjc 实际上也做了二进制重排 。
【第一步】在项目根目录下建一个xxx.order的文件,里面写上按照自己想排列的顺序,写上方法或者函数的名字。(如果写了一个不存在的符号,也不会报错,会被自动过滤掉~)
【第二步】在 Build Settings 搜索order file 的文件。将项目根目录创建的文件,设置上去。
【第三步】重新编译,查看 Link Map 文件的顺序,果然,按照我们指定的顺序排列啦!
3. 静态插桩 - 找到冷启动时的所有方法
接下来,需要做的就是写入 order 文件里的符号了,我们不可能手写上所有的启动时需要的执行的符号,这里的所有符号包括,调用的方法、函数、C++构造方法、swift方法、block。
这里使用 LLVM 内置的简单代码覆盖率检测工具(SanitizerCoverage)。它在边缘、 函数、基本块 级别上插入对用户定义函数的调用。
edge(默认):检测边缘(所有的指令跳转都会被插入对用户定义函数的调用, 如循环、分支判断、方法函数等)。bb:检测基本块。func:仅将检测每个 功能的输入块(这个就是我们要重排序的符号)。
按照文档,
- 【第1步】搜索并设置 Other C Flags/ Other C++ Flags 为 -fsanitize-coverage=func,trace-pc-guard (这里要用func, 不能用默认的edge, 不然会造成死循环)。
- 如果有swift ,需要设置 Other Swift Flags 设置为 **** -sanitize-coverage=func -sanitize=undefined
- 【第2步】编译器将插入对模块构造函数的调用,所以我们要实现这个方法:
__sanitizer_cov_trace_pc_guard_init(uint32_t *start, uint32_t *stop);
通过打印start, stop 地址的内容,从 start 地址开始,到 stop 地址的前4位,存储的是 uint32 的 1-19的数字。
我们可以从这个函数中知道, 当前项目中自定义的功能输入块的数量。
- 【第3步】编译器会在生成二进制文件的时候,在每个func调用之初,插入以下代码:
__sanitizer_cov_trace_pc_guard(&guard_variable)
也就是说,每个方法在执行的时候,都会调用上面这个方法。 接下来:
- 我们要实现这个方法,并在这个方法里,获取到本方法结束后要返回的地址
// 获取到本方法结束后,要返回的地址去,这个地址包含在被hook的方法内部,但不是被hook 的方法的首地址
void *PC = __builtin_return_address(0);
- 并将地址保存一个系统的原子队列( ( 底层实际上是个栈结构 , 利用队列结构 + 原子性来保证顺序 ))中,使用原子队列,是为了防止多线程资源抢夺。原子队列的存值方法如下:
// 将结构体存入到原子队列中。
// offsetof(type,member) 返回结构体中成员的偏移值,由于指针PC是8字节,所以这里返回8字节。
// 详见下图
OSAtomicEnqueue(&symbolList, node, offsetof(SYNode, next));
每个 SYNode 首地址都距离上一个偏移 PC 所占的字节数。这样做的妙处就是,每个 SYNode 的 next 的地址,恰巧就是下一个结构体的地址。这样方便获取队列里面的所有数据。
- 【第4步】我们在点击屏幕的事件中
- 把存储到原子队列中的地址遍历出来,
- 根据地址获取当前地址所在的方法的名称并存入数组中,
typedef struct dl_info {
const char *dli_fname; /* 所在文件 */
void *dli_fbase; /* 文件地址 */
const char *dli_sname; /* 符号名称 */
void *dli_saddr; /* 函数起始地址 */
} Dl_info;
//这个函数能通过函数内部地址找到函数符号
int dladdr(const void *, Dl_info *);
- 由于原子队列是栈结构,先进后出,所以我们需要将数组倒序排列
- 由于方法可能会被多次调用,我们需要去重
- 再将最后我们当前点击屏幕的方法删除掉
- 将方法名字的数组,转成字符串,写到沙盒文件中
完整代码如下:
//
// ViewController.m
// TraceDemo
//
// Created by hank on 2020/3/16.
// Copyright 2020 hank. All rights reserved.
//
#import "ViewController.h"
#import <dlfcn.h>
#import <libkern/OSAtomic.h>
#import "TraceDemo-Swift.h"
@interface ViewController ()
@end
@implementation ViewController
+(void)initialize
{
}
void(^block1)(void) = ^(void) {
};
void test(){
block1();
}
+(void)load
{
}
- (void)viewDidLoad {
[super viewDidLoad];
[SwiftTest swiftTestLoad];
test();
}
-(void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event
{
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:@"demo.order"];
NSData * fileContents = [funcStr dataUsingEncoding:NSUTF8StringEncoding];
[[NSFileManager defaultManager] createFileAtPath:filePath contents:fileContents attributes:nil];
NSLog(@"%@",funcStr);
}
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.
}
//原子队列
static OSQueueHead symbolList = OS_ATOMIC_QUEUE_INIT;
//定义符号结构体
typedef struct {
void *pc;
void *next;
}SYNode;
void __sanitizer_cov_trace_pc_guard(uint32_t *guard) {
// 会导致load 方法被return
// if (!*guard) return;
// 获取到本方法结束后,要返回的地址去,这个地址包含在被hook的方法内部,但不是被hook 的方法的首地址
void *PC = __builtin_return_address(0);
SYNode *node = malloc(sizeof(SYNode));
*node = (SYNode){PC,NULL};
//进入
OSAtomicEnqueue(&symbolList, node, offsetof(SYNode, next));
}
@end
2.2.4 如何验证二进制重排的效果?
1.查看缺页异常数量Page Fualt:
- 查看一下项目的缺页异常数量。注意需要卸载 APP 或者重启手机,来保证这个APP完全没有被加载到内存中,因为如果物理内存中有该APP的数据,
- 打开 Instrument -> System Trace
3.选择真机、项目、点击启动,当第一个页面显示出来后,点击停止。
- xcode 12搜索main thread, 选择Virtual Memory,File Backed Page in 就是缺页异常的数量
优化前:项目的缺页遗产数量是427
优化后:
优化前:项目的缺页遗产数量是286
减少了启动时大概40%的缺页异常~
3.自动更新order 文件
随着代码迭代,order文件需要更新,每次手动更新很麻烦,所以需要自动更新。
brew install ios-deploy
APP_ORDER_DIR=appOrderDir
APP_ORDER=./$APP_ORDER_DIR/Documents/app.order
mkdir $APP_ORDER_DIR
ios-deploy --download=/Documents --bundle_id $PRODUCT_BUNDLE_IDENTIFIER --to ./$APP_ORDER_DIR
if [ -e $APP_ORDER ] ;then
cp -f $APP_ORDER ./Resource/app.order
fi
rm -r $APP_ORDER_DIR
【补充xcode13】查看缺页异常的方式
选择真机、项目、点击启动,当第一个页面显示出来后,点击停止。
青山不改,绿水常流。谢谢大家!
使用二进制重排 & Clang插桩技术点来进行iOS冷启动进行优化的更多相关文章
- 方案设计:基于IDEA插件开发和字节码插桩技术,实现研发交付质量自动分析
作者:小傅哥 博客:https://bugstack.cn 沉淀.分享.成长,让自己和他人都能有所收获! 一.前言 如何保证代码质量? 业务提需求,产品定方案,研发做实现,测试验流程.四种角色的相互配 ...
- Java Instrumentation插桩技术学习
Instrumentation基础 openrasp中用到了Instrumentation技术,它的最大作用,就是类的动态改变和操作. 使用Instrumentation实际上也可以可以开发一个代理来 ...
- 插桩 inline hook 动态二进制插桩的原理和基本实现过程
插桩测试 https://source.android.google.cn/compatibility/tests/development/instrumentation https://zhuanl ...
- APK修改神器:插桩工具 DexInjector
本文介绍了一个针对Dex进行插桩的工具,讲解了一下直接修改Dalvik字节码和Dex文件时遇到的问题和解决方法 作者:字节跳动终端技术-- 李言 背景 线下场景中,我们经常需要在APK中插入一些检测代 ...
- 开发 IDEA Plugin 引入探针,基于字节码插桩获取执行SQL
作者:小傅哥 博客:https://bugstack.cn 沉淀.分享.成长,让自己和他人都能有所收获! 一.前言 片面了! 一月三舟,托尔斯泰说:"多么伟大的作家,也不过就是在书写自己的片 ...
- 手淘架构组最新实践 | iOS基于静态库插桩的⼆进制重排启动优化 抖音研发实践:基于二进制文件重排的解决方案 APP启动速度提升超15% 编译期插桩
抖音研发实践:基于二进制文件重排的解决方案 APP启动速度提升超15% 原创 Leo 字节跳动技术团队 2019-08-09 https://mp.weixin.qq.com/s/Drmmx5JtjG ...
- intel Pin:动态二进制插桩的安装和使用,以及如何开发一个自己的Pintool
先贴几个你可能用得上的链接 intel Pin的官方介绍Pin: Pin 3.21 User Guide (intel.com) intel Pin的API文档Pin: API Reference ( ...
- ICSFUZZ:操纵I/O、二进制代码重用以及插桩,来Fuzzing工业控制应用程序
本文系原创,转载请说明出处 Please Subscribe Wechat Official Account:信安科研人,获取更多的原创安全资讯 源码:GitHub - momalab/ICSFu ...
- Flymeos插桩适配教程
插桩适配前提,安装Ubuntu或者其他linux系统. 安装JDK7 sudo apt--jdk Ubuntu 16.04与基于它的版本,需要添加源 sudo add-apt-repository p ...
- zorka源码解读之通过beanshell进行插桩的流程
zorka中插桩流程概述 1.在SpyDefinition中配置插桩属性,将SpyDefinition实例提交给插桩引擎.2.SpyDefinition实例中包含了插桩探针probes,probe插入 ...
随机推荐
- 初识VUE响应式原理
作者:京东零售 吴静 自从Vue发布以来,就受到了广大开发人员的青睐,提到Vue,我们首先想到的就是Vue的响应式系统,那响应式系统到底是怎么回事呢?接下来我就给大家简单介绍一下Vue中的响应式原理. ...
- 【代码分享】使用 terraform, 在 ZeroSSL 上申请托管在 cloudflare 上的域名对应的证书
作者:张富春(ahfuzhang),转载时请注明作者和引用链接,谢谢! cnblogs博客 zhihu Github 公众号:一本正经的瞎扯 接上一篇:<使用 terraform, 在 Let' ...
- plcTIA Portal V16找不到许可证
首先快捷键win+s唤出搜索,搜:服务 其次搜索这个服务Automation License Manager Service 右击-启动服务,然后重新启动plc即可选择CPU型号了
- DevelopTool
目录 01-PostMan常用玩法详解
- LyScript 从文本中读写ShellCode
LyScript 插件通过配合内存读写,可实现对特定位置的ShellCode代码的导出,或者将一段存储在文本中的ShellCode代码插入到程序堆中,此功能可用于快速将自己编写的ShellCode注入 ...
- VB6的OfficeMenu控件 - 开源研究系列文章
这次将原来VB6中喜欢和使用到的OfficeMenu的控件做一个使用介绍. 上次介绍了VB6中的控件引擎,但是那个只针对基本的控件,这个OfficeMenu控件在当时是收费的,笔者找度娘好不容易才下载 ...
- iPhone 15 Pro Max的Type-C接口有多牛?实测USB3比USB2快11倍
苹果最新的iPhone 15系列和iPhone 15 Pro系列新机,尽管两者都是Type-C接口,但速度相差20倍. 据了解,iPhone 15 Pro/Max搭载的苹果A17 Pro芯片内含专门的 ...
- STL源码剖析 | priority_queue优先队列底层模拟实现
今天博主继续带来STL源码剖析专栏的第四篇博客了! 今天带来优先队列priority_queue的模拟实现!话不多说,直接进入我们今天的内容! 前言 那么这里博主先安利一下一些干货满满的专栏啦! 手撕 ...
- 【算法】【回溯】两道经典排列问题OJ详解【力扣46 力扣47】【超详细的回溯算法教程】让我们牢牢把握回溯的精髓
[算法][回溯]两道经典排列问题OJ详解[力扣46 力扣47][超详细的回溯算法教程]让我们牢牢把握回溯的精髓 作者: @小小Programmer 这是我的主页:@小小Programmer 在食用这篇 ...
- (Python)每日代码||2024.1.17||函数中给列表形参默认值时,该默认列表在函数中的改变会保留下来
def f(x,li=[1]): print(id(li)) li.append(x) print(li) f('a')#第一次调用函数 print() f('b')#第二次调用函数 print() ...