本章提綱:
1笆豁、pre-Main階段的性能檢測
2郎汪、虛擬內(nèi)存
3、二進制重排
4闯狱、Clang插裝
1煞赢、pre-Main階段的性能檢測
應用的啟動過程一般以Main
函數(shù)為臨界點,分為Main
函數(shù)之前和Main
函數(shù)之后扩氢。
Main
函數(shù)之前我們稱為pre-Main
耕驰。
Xcode為檢測pre-Main
的耗時提供了環(huán)境變量,以便開發(fā)者了解pre-Main
的時間。
在Xcode中的Schemes->Run->Arguments
中添加DYLD_PRINT_STATISTICS
的環(huán)境變量為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
動態(tài)庫的載入耗時媒抠。系統(tǒng)的動態(tài)庫存在于共享緩存弟断,但是自定義的動態(tài)庫就要通過依賴關系一個一個的加載。
蘋果官方建議項目中不要超過6個自定義的動態(tài)庫趴生,超過的部分最好進行多個動態(tài)庫合并阀趴,以此來減少動態(tài)庫的加載時間。rebase/binding time
這是一個非常核心而且重要的概念苍匆。重定位/符號綁定耗時刘急。涉及到虛擬內(nèi)存
的相關技術,會在下面詳細介紹浸踩。
rebase(重定位)
:采用了ASLR技術叔汁,保證地址的隨機化,加強了內(nèi)存訪問的安全性检碗。
binding(符號綁定)
:使用外部符號据块,編譯時無法找到函數(shù)地址。在運行時折剃,dyld
加載共享緩存另假,加載鏈接動態(tài)庫之后,進行binding
操作怕犁,重新綁定外部符號边篮。ObjC setup time
注冊OC類的耗時。應用啟動時奏甫,系統(tǒng)會生成OC類和分類的兩張相關映射表苟耻,IMP到SEL的映射,分類的方法等合并到相關表中的等操作會造成一部分的耗時扶檐。
減少項目中類和分類的數(shù)量可以優(yōu)化這部分的時間。
減少類和分類中的Load
方法的使用胁艰,讓類以懶加載的方式加載款筑。initializer time
執(zhí)行load
以及C++
構(gòu)造函數(shù)的耗時slowest intializers
最耗時的幾個動態(tài)庫。
2腾么、虛擬內(nèi)存
聊到虛擬內(nèi)存
我們就要聊起早期的計算機結(jié)構(gòu)奈梳。早期的是馮·諾依曼計算機結(jié)構(gòu),在1945年就被提出了解虱,在當時是很新穎的結(jié)構(gòu)了攘须,它是第一次將存儲器和運算器分離,開啟了以存儲器為核心的現(xiàn)代計算機的篇章殴泰。
但是馮·諾依曼結(jié)構(gòu)有它自己的問題于宙,就是存儲器之間的讀取速度遠遠小于CPU的工作效率浮驳。讀取效率低,CPU的運算能力又太快捞魁,就造成了CPU性能的浪費至会。為了解決這個問題,現(xiàn)行的解決方式就是采用多級存儲谱俭,來平衡存儲器的讀寫速率奉件,容量,價格昆著。
該結(jié)構(gòu)下的CPU的尋址方式:內(nèi)存可以被看成一個數(shù)組县貌,數(shù)組元素是一個字節(jié)大小的空間,而數(shù)組索引則是所謂的物理地址凑懂。最簡單直接的方式就是CPU直接通過物理地址去訪問對應的內(nèi)存煤痕,也叫做物理尋址。
這種尋址方式有非常嚴重的安全問題征候。因為直接暴露的是物理地址杭攻,所以進程通過地址偏移可以訪問到任何屋里地址,用戶進程想干嘛就干嘛疤坝。這是非常不安全的兆解。
現(xiàn)代處理器使用的是虛擬尋址
的方式。CPU通過訪問虛擬地址跑揉,經(jīng)過翻譯獲得物理地址才能訪問內(nèi)存锅睛。這個翻譯過程由CPU中的內(nèi)存管理單元(Memory Management Unit,縮寫為MMU)完成历谍。
現(xiàn)代的操作系統(tǒng)都引入了虛擬內(nèi)存现拒。對于每個進程來說,操作系統(tǒng)可以為其提供一個獨立的私有的連續(xù)的地址空間望侈。對于進程來說印蔬,它的可見部分只有分配給它的虛擬內(nèi)存。而虛擬內(nèi)存實際可能映射到物理內(nèi)存以及硬盤的任何區(qū)域脱衙。由于硬盤的讀寫速度不如內(nèi)存快侥猬,所以操作系統(tǒng)會優(yōu)先使用物理內(nèi)存空間,但是當物理內(nèi)存空間不夠時捐韩,就會將部分內(nèi)存數(shù)據(jù)交換到硬盤上去存儲退唠,這也是所謂的Swap內(nèi)存交換機制。有了內(nèi)存交換機制以后荤胁,相比起物理尋址瞧预,虛擬內(nèi)存實際上利用了磁盤空間擴展了內(nèi)存空間。
虛擬內(nèi)存的優(yōu)勢同時也彰顯了出來:
1、保護了進程的地址空間垢油,將進程和物理地址完全阻隔開盆驹,無法跨進程訪問。
2秸苗、由于操作系統(tǒng)分配的虛擬內(nèi)存是連續(xù)的召娜,簡化了內(nèi)存管理。
3惊楼、利用硬盤空間拓展了內(nèi)存空間玖瘸。
4、可以按需加載內(nèi)容到內(nèi)存中檀咙,避免內(nèi)存浪費雅倒。
內(nèi)存分頁
虛擬內(nèi)存和物理內(nèi)存存在映射關系,為了方便映射和管理弧可,虛擬內(nèi)存和物理內(nèi)存都被分割成大小相同的單位蔑匣,物理內(nèi)存的最小單位稱為幀(Frame)
,而虛擬內(nèi)存的最小單位被稱為頁(Page)
棕诵。
在iOS中裁良,一頁的大小為16KB
,當進程被加載到內(nèi)存中是校套,虛擬內(nèi)存會給該進程開辟最大4個G
的虛擬內(nèi)存空間价脾。
內(nèi)存分頁的最大意義在于:
1、支持了物理內(nèi)存的離散使用笛匙;
2侨把、提高MMU
的翻譯效率,采用一些頁面調(diào)度(Paging)算法妹孙,利用翻譯過程中也存在局部性原理秋柄,將大概率被使用的幀地址加入到TLB
或者頁表之中,提高翻譯效率蠢正。
缺頁中斷
現(xiàn)代計算機都是分級緩存的骇笔,內(nèi)存命中的查找也是分級的。
- 首先會在
TLB(Translation Lookaside Buffer)
中進行查詢嚣崭,這個表位于CPU內(nèi)部蜘拉,查詢速度最快; - 如果沒有命中有鹿,那么接下來會在頁表(Page Table)中進行查詢,頁表位于物理內(nèi)存中谎脯,所以查詢速度較慢葱跋,如果發(fā)現(xiàn)目標不在物理內(nèi)存中,那么成為
缺頁
; - 如果物理內(nèi)存沒有命中查找娱俺,此時會去磁盤中查找稍味,如果還找不到就報錯了。
所以當發(fā)生缺頁時荠卷,操作系統(tǒng)會阻塞當前進程模庐,把需要的數(shù)據(jù)載入到物理內(nèi)存中,然后再尋址讀取油宜。當缺頁頻繁發(fā)生時掂碱,也是非常耗時的。
頁面置換
由于物理內(nèi)存是有限的慎冤,當物理內(nèi)存沒有空間時疼燥,操作系統(tǒng)會通過算法找到最不經(jīng)常使用的
物理頁驅(qū)逐回磁盤,為新的內(nèi)存頁讓出空間蚁堤。這個過程稱為頁面置換
醉者,也稱內(nèi)存交換
。
然而EG思础!iOS并不支持內(nèi)存交換機制3识印剥槐!
大多數(shù)移動設備都不支持內(nèi)存交換機制。移動設備上的大容量存儲器通常是閃存(Flash)掂咒,它的讀寫速度遠遠小于電腦所使用的的硬盤才沧,這就導致了在移動設備上,就算使用了內(nèi)存交換也不能提升性能绍刮。其次温圆,移動設備本身容量就經(jīng)常短缺,閃存的讀寫壽命也非常有限孩革,所以這種情況下還有進行內(nèi)存交換就非常不劃算了岁歉。
ASLR
程序的代碼在不修改的情況下,每次加載到虛擬內(nèi)存的地址是一樣的膝蜈,這樣的方式并不安全锅移,為了解決地址固定的問題,出現(xiàn)了ASLR
技術饱搏。
ASLR(Address space layout randomization)
:地址空間配置隨機加載非剃,是一種防范內(nèi)存損壞漏洞被利用的計算機安全技術。
地址空間配置隨機加載利用隨機方式配置數(shù)據(jù)地址空間推沸,使某些敏感數(shù)據(jù)配置到一個惡意程序無法事先獲知的地址备绽,令攻擊者難以進行攻擊券坞。
以上就簡單的介紹了下虛擬內(nèi)存的相關知識。接下來是二進制重排部分肺素。
3恨锚、二進制重排
3.1缺頁中斷時間消耗的檢測
前面我們已經(jīng)提到了缺頁中斷,接下來我們通過Profile
來檢測一下缺頁中斷的發(fā)生倍靡。
在Xcode
頂部菜單Product
->Profile
->Instruments
->System Trace
可以看到我們的項目冷啟動時猴伶,缺頁次數(shù)大概是1200多次,耗時130毫秒塌西,如果項目再大一些他挎,缺頁發(fā)生的更多那么也是一個不小的影響啟動時間的一個因素。
3.2二進制重排原理
創(chuàng)建測試項目雨让,查看代碼的順序雇盖,在Build Settings
->Write Link Map File
,設置為YES
栖忠,然后編譯項目崔挖,來到工程的Build
目錄下,找到LinkMap
文件
Build
目錄找不到的話從Xcode
->Preferences
->Locations
庵寞,可以看到Derived Data
的路徑狸相,可以直接跳轉(zhuǎn)過去。
具體看到LinkMap
文件保存了項目再編譯鏈接時的符號順序捐川,以方法/函數(shù)為單位排列脓鹃。
可以看到和編譯的文件順序是一樣的,目前
ViewController
中只有一個方法viewDidLoad
古沥,所以在這個文件下面ViewController只排列了這一個方法瘸右。
如果按照默認配置,在啟動時會加載大量的與啟動無關的代碼岩齿,導致缺頁
太颤。那么如果可以將啟動時需要的方法/函數(shù)排在最前面,就能降低缺頁
的發(fā)生盹沈,從而提高應用的啟動速度龄章,這就是二進制重排的核心原理。
3.2二進制重排準備
在工程目錄下創(chuàng)建一個.order
文件乞封,按照固定的格式做裙,將啟動時需要的方法/函數(shù)順序排列,然后再去把排列好的.order
文件放到Xcode中使用肃晚。在.order
中寫入測試順序
-[ViewController viewDidLoad]
_main
最后通過LinkMap文件查看來驗證.order
是否生效锚贱。
在Xcode中進行配置.order
文件,在Build Settings
->Order File
中配置
結(jié)果新的
LinkMap
中的前兩位的順序確實是我寫入Lucky.order
文件的順序关串。以上就完成了重排的準備工作拧廊,并且測試也生效了杂穷,接下來的難點就是,怎么能獲取到啟動時需要調(diào)用的所有方法和函數(shù)卦绣。
4、Clang插莊
如果只對于OC方法飞蚓,可以對objc_msgSend
方法進行Hook
滤港,但是系統(tǒng)調(diào)用的方法中會有一些c、c++
的方法函數(shù)趴拧,以及一些block
回調(diào)溅漾,這些通過objc_msgSend
是無法攔截到的。
而LLVM
內(nèi)置了一個簡單的代碼覆蓋率檢測的工具(SanitizerCoverage
)著榴。它在函數(shù)級添履、基本塊和邊緣級上插入了對用戶自定義函數(shù)的調(diào)用,通過方式脑又,可以順利對OC
方法暮胧、C
函數(shù)、Block
塊问麸、Swift
等函數(shù)進行更加全面的攔截往衷。
(官方文檔鏈接)https://clang.llvm.org/docs/SanitizerCoverage.html
4.1配置SanitizerCoverage
搭建測試項目,在Build Settings
->Other C Flags
中严卖,增加-fsanitize-coverage=trace-pc-guard
的配置席舍。
根據(jù)官方文檔的示例,在測試項目中添加以下代碼:
#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
函數(shù)__sanitizer_cov_trace_pc_guard_init
是回調(diào)函數(shù),start
和stop
表示一個section
的首地址和結(jié)束地址稠肘。這個方法能反應項目中的符號個數(shù)福铅。
// 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
而函數(shù)__sanitizer_cov_trace_pc_guard
則是可以監(jiān)聽到編譯器所有的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的測試
我們來測試一下是不是函數(shù)
启具,方法
本讥,block
都會被攔截,添加如下測試代碼:
- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event{
NSLog(@"touchesBegan方法執(zhí)行");
test();
}
void(^block)(void) = ^(void){
NSLog(@"Block執(zhí)行");
};
void test(){
NSLog(@"test函數(shù)執(zhí)行");
block();
}
可以看到這些方法確實都被函數(shù)
__sanitizer_cov_trace_pc_guard
能攔截到鲁冯。通過查看匯編指令:可以看到這幾個測試方法后邊都有
callq
指令拷沸,調(diào)用的都是__sanitizer_cov_trace_pc_guard
。
可以初步的了解到薯演,Clang
插裝的原理是撞芍,只要添加了插裝的標記,編譯器就會在當前項目中跨扮,在所有的方法序无、函數(shù)验毡、block的代碼實現(xiàn)的邊緣,插入一句__sanitizer_cov_trace_pc_guard
達到方法帝嗡、函數(shù)晶通、block的全覆蓋。
4.4獲取符號名稱
官方示例代碼中哟玷,用了__builtin_return_address
函數(shù)狮辽,該函數(shù)的作用會獲取到當前的返回地址,也就是函數(shù)的調(diào)用者巢寡。
通過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:調(diào)用的函數(shù)名稱
dli_saddr:函數(shù)地址
所以我們通過dli_sname
來拿到函數(shù)名稱喉脖。接下來的工作就是拿到這些名稱(去重),然后把名稱寫入到前面說的.order
文件中去抑月,也就完成了重排的工作树叽。
4.5實踐
- 存儲返回地址
為了保證線程安全,定義一個原子隊列谦絮,隊列中存儲帶有返回地址的結(jié)構(gòu)體题诵。
//定義原子隊列
static OSQueueHead symbolList = OS_ATOMIC_QUEUE_INIT;
//定義符號結(jié)構(gòu)體
typedef struct {
void * pc;
void * next;
} SYNode;
通過SYNode來存儲,方法__sanitizer_cov_trace_pc_guard
中通過函數(shù)__builtin_return_address
得到的pc
挨稿。
函數(shù)__sanitizer_cov_trace_pc_guard
的實現(xiàn)如下:
//HOOK一切的回調(diào)函數(shù)3鹎帷!
void __sanitizer_cov_trace_pc_guard(uint32_t *guard) {
void *PC = __builtin_return_address(0);
//創(chuàng)建結(jié)構(gòu)體
SYNode * node = malloc(sizeof(SYNode));
*node = (SYNode){PC,NULL};
//結(jié)構(gòu)體入棧
//offsetof:參數(shù)1傳入類型奶甘,將下一個節(jié)點的地址返回給參數(shù)2
OSAtomicEnqueue(&symbolList, node, offsetof(SYNode, next));
}
- 獲取函數(shù)符號并去重排序
獲取完畢返回的地址篷店,我們進行排序和去重處理
//定義數(shù)組
NSMutableArray<NSString *> * symbleNames = [NSMutableArray array];
while (YES) {
//循環(huán)體內(nèi)!進行了攔截3艏摇疲陕!
SYNode * node = OSAtomicDequeue(&symbolList, offsetof(SYNode,next));
if (node == NULL) {
break;
}
Dl_info info;
dladdr(node->pc, &info);
NSString * name = @(info.dli_sname);//獲取函數(shù)名稱,并轉(zhuǎn)字符串
//oc方法直接返回钉赁,其余的前面加"_"
BOOL isObjc = [name hasPrefix:@"+["] || [name hasPrefix:@"-["];
NSString * symbolName = isObjc ? name : [@"_" stringByAppendingString:name];
//符號加到符號數(shù)組里
[symbleNames addObject:symbolName];
}
//反向遍歷數(shù)組
NSEnumerator * em = [symbleNames reverseObjectEnumerator];
//去重
NSMutableArray * funcs = [NSMutableArray arrayWithCapacity:symbleNames.count];
NSString * name;
while (name = [em nextObject]) {
if (![funcs containsObject:name]) {//數(shù)組沒有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);
寫入完畢之后诅岩,我們根據(jù)前邊編譯.order
的經(jīng)驗來編譯,至此我們就完成了重排和插裝的過程带膜!可以對實際項目進行測試一下是不是有作用吩谦。
慢慢都堅持這么久了,繼續(xù)加油膝藕!