本章提綱:
1、pre-Main階段的性能檢測
2、虛擬內存
3、二進制重排
4、Clang插裝
1、pre-Main階段的性能檢測
應用的啟動過程一般以Main
函數為臨界點,分為Main
函數之前和Main
函數之后。
Main
函數之前我們稱為pre-Main
。
Xcode為檢測pre-Main
的耗時提供了環境變量,以便開發者了解pre-Main
的時間。
在Xcode中的Schemes->Run->Arguments
中添加DYLD_PRINT_STATISTICS
的環境變量為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
動態庫的載入耗時。系統的動態庫存在于共享緩存,但是自定義的動態庫就要通過依賴關系一個一個的加載。
蘋果官方建議項目中不要超過6個自定義的動態庫,超過的部分最好進行多個動態庫合并,以此來減少動態庫的加載時間。rebase/binding time
這是一個非常核心而且重要的概念。重定位/符號綁定耗時。涉及到虛擬內存
的相關技術,會在下面詳細介紹。
rebase(重定位)
:采用了ASLR技術,保證地址的隨機化,加強了內存訪問的安全性。
binding(符號綁定)
:使用外部符號,編譯時無法找到函數地址。在運行時,dyld
加載共享緩存,加載鏈接動態庫之后,進行binding
操作,重新綁定外部符號。ObjC setup time
注冊OC類的耗時。應用啟動時,系統會生成OC類和分類的兩張相關映射表,IMP到SEL的映射,分類的方法等合并到相關表中的等操作會造成一部分的耗時。
減少項目中類和分類的數量可以優化這部分的時間。
減少類和分類中的Load
方法的使用,讓類以懶加載的方式加載。initializer time
執行load
以及C++
構造函數的耗時slowest intializers
最耗時的幾個動態庫。
2、虛擬內存
聊到虛擬內存
我們就要聊起早期的計算機結構。早期的是馮·諾依曼計算機結構,在1945年就被提出了,在當時是很新穎的結構了,它是第一次將存儲器和運算器分離,開啟了以存儲器為核心的現代計算機的篇章。
但是馮·諾依曼結構有它自己的問題,就是存儲器之間的讀取速度遠遠小于CPU的工作效率。讀取效率低,CPU的運算能力又太快,就造成了CPU性能的浪費。為了解決這個問題,現行的解決方式就是采用多級存儲,來平衡存儲器的讀寫速率,容量,價格。
該結構下的CPU的尋址方式:內存可以被看成一個數組,數組元素是一個字節大小的空間,而數組索引則是所謂的物理地址。最簡單直接的方式就是CPU直接通過物理地址去訪問對應的內存,也叫做物理尋址。
這種尋址方式有非常嚴重的安全問題。因為直接暴露的是物理地址,所以進程通過地址偏移可以訪問到任何屋里地址,用戶進程想干嘛就干嘛。這是非常不安全的。
現代處理器使用的是虛擬尋址
的方式。CPU通過訪問虛擬地址,經過翻譯獲得物理地址才能訪問內存。這個翻譯過程由CPU中的內存管理單元(Memory Management Unit,縮寫為MMU)完成。
現代的操作系統都引入了虛擬內存。對于每個進程來說,操作系統可以為其提供一個獨立的私有的連續的地址空間。對于進程來說,它的可見部分只有分配給它的虛擬內存。而虛擬內存實際可能映射到物理內存以及硬盤的任何區域。由于硬盤的讀寫速度不如內存快,所以操作系統會優先使用物理內存空間,但是當物理內存空間不夠時,就會將部分內存數據交換到硬盤上去存儲,這也是所謂的Swap內存交換機制。有了內存交換機制以后,相比起物理尋址,虛擬內存實際上利用了磁盤空間擴展了內存空間。
虛擬內存的優勢同時也彰顯了出來:
1、保護了進程的地址空間,將進程和物理地址完全阻隔開,無法跨進程訪問。
2、由于操作系統分配的虛擬內存是連續的,簡化了內存管理。
3、利用硬盤空間拓展了內存空間。
4、可以按需加載內容到內存中,避免內存浪費。
內存分頁
虛擬內存和物理內存存在映射關系,為了方便映射和管理,虛擬內存和物理內存都被分割成大小相同的單位,物理內存的最小單位稱為幀(Frame)
,而虛擬內存的最小單位被稱為頁(Page)
。
在iOS中,一頁的大小為16KB
,當進程被加載到內存中是,虛擬內存會給該進程開辟最大4個G
的虛擬內存空間。
內存分頁的最大意義在于:
1、支持了物理內存的離散使用;
2、提高MMU
的翻譯效率,采用一些頁面調度(Paging)算法,利用翻譯過程中也存在局部性原理,將大概率被使用的幀地址加入到TLB
或者頁表之中,提高翻譯效率。
缺頁中斷
現代計算機都是分級緩存的,內存命中的查找也是分級的。
- 首先會在
TLB(Translation Lookaside Buffer)
中進行查詢,這個表位于CPU內部,查詢速度最快; - 如果沒有命中,那么接下來會在頁表(Page Table)中進行查詢,頁表位于物理內存中,所以查詢速度較慢,如果發現目標不在物理內存中,那么成為
缺頁
; - 如果物理內存沒有命中查找,此時會去磁盤中查找,如果還找不到就報錯了。
所以當發生缺頁時,操作系統會阻塞當前進程,把需要的數據載入到物理內存中,然后再尋址讀取。當缺頁頻繁發生時,也是非常耗時的。
頁面置換
由于物理內存是有限的,當物理內存沒有空間時,操作系統會通過算法找到最不經常使用的
物理頁驅逐回磁盤,為新的內存頁讓出空間。這個過程稱為頁面置換
,也稱內存交換
。
然而!!!iOS并不支持內存交換機制!!
大多數移動設備都不支持內存交換機制。移動設備上的大容量存儲器通常是閃存(Flash),它的讀寫速度遠遠小于電腦所使用的的硬盤,這就導致了在移動設備上,就算使用了內存交換也不能提升性能。其次,移動設備本身容量就經常短缺,閃存的讀寫壽命也非常有限,所以這種情況下還有進行內存交換就非常不劃算了。
ASLR
程序的代碼在不修改的情況下,每次加載到虛擬內存的地址是一樣的,這樣的方式并不安全,為了解決地址固定的問題,出現了ASLR
技術。
ASLR(Address space layout randomization)
:地址空間配置隨機加載,是一種防范內存損壞漏洞被利用的計算機安全技術。
地址空間配置隨機加載利用隨機方式配置數據地址空間,使某些敏感數據配置到一個惡意程序無法事先獲知的地址,令攻擊者難以進行攻擊。
以上就簡單的介紹了下虛擬內存的相關知識。接下來是二進制重排部分。
3、二進制重排
3.1缺頁中斷時間消耗的檢測
前面我們已經提到了缺頁中斷,接下來我們通過Profile
來檢測一下缺頁中斷的發生。
在Xcode
頂部菜單Product
->Profile
->Instruments
->System Trace
可以看到我們的項目冷啟動時,缺頁次數大概是1200多次,耗時130毫秒,如果項目再大一些,缺頁發生的更多那么也是一個不小的影響啟動時間的一個因素。
3.2二進制重排原理
創建測試項目,查看代碼的順序,在Build Settings
->Write Link Map File
,設置為YES
,然后編譯項目,來到工程的Build
目錄下,找到LinkMap
文件
Build
目錄找不到的話從Xcode
->Preferences
->Locations
,可以看到Derived Data
的路徑,可以直接跳轉過去。
具體看到LinkMap
文件保存了項目再編譯鏈接時的符號順序,以方法/函數為單位排列。
可以看到和編譯的文件順序是一樣的,目前
ViewController
中只有一個方法viewDidLoad
,所以在這個文件下面ViewController只排列了這一個方法。
如果按照默認配置,在啟動時會加載大量的與啟動無關的代碼,導致缺頁
。那么如果可以將啟動時需要的方法/函數排在最前面,就能降低缺頁
的發生,從而提高應用的啟動速度,這就是二進制重排的核心原理。
3.2二進制重排準備
在工程目錄下創建一個.order
文件,按照固定的格式,將啟動時需要的方法/函數順序排列,然后再去把排列好的.order
文件放到Xcode中使用。在.order
中寫入測試順序
-[ViewController viewDidLoad]
_main
最后通過LinkMap文件查看來驗證.order
是否生效。
在Xcode中進行配置.order
文件,在Build Settings
->Order File
中配置
結果新的
LinkMap
中的前兩位的順序確實是我寫入Lucky.order
文件的順序。以上就完成了重排的準備工作,并且測試也生效了,接下來的難點就是,怎么能獲取到啟動時需要調用的所有方法和函數。
4、Clang插莊
如果只對于OC方法,可以對objc_msgSend
方法進行Hook
,但是系統調用的方法中會有一些c、c++
的方法函數,以及一些block
回調,這些通過objc_msgSend
是無法攔截到的。
而LLVM
內置了一個簡單的代碼覆蓋率檢測的工具(SanitizerCoverage
)。它在函數級、基本塊和邊緣級上插入了對用戶自定義函數的調用,通過方式,可以順利對OC
方法、C
函數、Block
塊、Swift
等函數進行更加全面的攔截。
(官方文檔鏈接)https://clang.llvm.org/docs/SanitizerCoverage.html
4.1配置SanitizerCoverage
搭建測試項目,在Build Settings
->Other C Flags
中,增加-fsanitize-coverage=trace-pc-guard
的配置。
根據官方文檔的示例,在測試項目中添加以下代碼:
#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
函數__sanitizer_cov_trace_pc_guard_init
是回調函數,start
和stop
表示一個section
的首地址和結束地址。這個方法能反應項目中的符號個數。
// 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
而函數__sanitizer_cov_trace_pc_guard
則是可以監聽到編譯器所有的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的測試
我們來測試一下是不是函數
,方法
,block
都會被攔截,添加如下測試代碼:
- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event{
NSLog(@"touchesBegan方法執行");
test();
}
void(^block)(void) = ^(void){
NSLog(@"Block執行");
};
void test(){
NSLog(@"test函數執行");
block();
}
可以看到這些方法確實都被函數
__sanitizer_cov_trace_pc_guard
能攔截到。通過查看匯編指令:可以看到這幾個測試方法后邊都有
callq
指令,調用的都是__sanitizer_cov_trace_pc_guard
。
可以初步的了解到,Clang
插裝的原理是,只要添加了插裝的標記,編譯器就會在當前項目中,在所有的方法、函數、block的代碼實現的邊緣,插入一句__sanitizer_cov_trace_pc_guard
達到方法、函數、block的全覆蓋。
4.4獲取符號名稱
官方示例代碼中,用了__builtin_return_address
函數,該函數的作用會獲取到當前的返回地址,也就是函數的調用者。
通過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:調用的函數名稱
dli_saddr:函數地址
所以我們通過dli_sname
來拿到函數名稱。接下來的工作就是拿到這些名稱(去重),然后把名稱寫入到前面說的.order
文件中去,也就完成了重排的工作。
4.5實踐
- 存儲返回地址
為了保證線程安全,定義一個原子隊列,隊列中存儲帶有返回地址的結構體。
//定義原子隊列
static OSQueueHead symbolList = OS_ATOMIC_QUEUE_INIT;
//定義符號結構體
typedef struct {
void * pc;
void * next;
} SYNode;
通過SYNode來存儲,方法__sanitizer_cov_trace_pc_guard
中通過函數__builtin_return_address
得到的pc
。
函數__sanitizer_cov_trace_pc_guard
的實現如下:
//HOOK一切的回調函數!!
void __sanitizer_cov_trace_pc_guard(uint32_t *guard) {
void *PC = __builtin_return_address(0);
//創建結構體
SYNode * node = malloc(sizeof(SYNode));
*node = (SYNode){PC,NULL};
//結構體入棧
//offsetof:參數1傳入類型,將下一個節點的地址返回給參數2
OSAtomicEnqueue(&symbolList, node, offsetof(SYNode, next));
}
- 獲取函數符號并去重排序
獲取完畢返回的地址,我們進行排序和去重處理
//定義數組
NSMutableArray<NSString *> * symbleNames = [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);//獲取函數名稱,并轉字符串
//oc方法直接返回,其余的前面加"_"
BOOL isObjc = [name hasPrefix:@"+["] || [name hasPrefix:@"-["];
NSString * symbolName = isObjc ? name : [@"_" stringByAppendingString:name];
//符號加到符號數組里
[symbleNames addObject:symbolName];
}
//反向遍歷數組
NSEnumerator * em = [symbleNames reverseObjectEnumerator];
//去重
NSMutableArray * funcs = [NSMutableArray arrayWithCapacity:symbleNames.count];
NSString * name;
while (name = [em nextObject]) {
if (![funcs containsObject:name]) {//數組沒有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);
寫入完畢之后,我們根據前邊編譯.order
的經驗來編譯,至此我們就完成了重排和插裝的過程!可以對實際項目進行測試一下是不是有作用。
慢慢都堅持這么久了,繼續加油!