參考鏈接: 抖音研發實踐:基于二進制文件重排的解決方案 APP啟動速度提升超15%
一、原理
1、虛擬內存和物理內存
早期計算機沒有虛擬地址,一旦加載都會全部加載到內存中,而且進程都是按順序排列的,這樣別的進程只需要把自己的地址加一些就能訪問到別的進程這樣就很不安全。
現在軟件發展的比硬件快,軟件占用的內存越來越大,這就導致計算機的內存不夠用,當開啟多個軟件時候,如果內存不夠用就只能等待,只有等前面的軟件關掉后才能加載打開,這就是早期計算機有時候為啥只有把前面的軟件關掉才能打開新軟件的原因。用戶使用軟件時候并不是使用到全部內存,只會使用到一部分,如果軟件一打開就把軟件全部加載到內存中,這樣會很浪費內存空間。
基于上面原因虛擬內存技術出現了,軟件打開后,軟件自己以為有一大片內存空間,但實際上是虛擬的,而虛擬內存和物理內存是通過一張表來關聯的,可以看下下面兩張表:
進程1運行時候會開辟一塊內存空間,但訪問到內存條的時候并不是這塊內存空間,而且通過訪問地址通過進程1的映射表映射到不同的物理內存空間,這個叫地址翻譯,這個過程需要CPU和操作系統配合,因為這個映射表是操作系統來管理的,當我們調試時候發現訪問數據的內存地址都是連續的,其實這是一個假象,在這個進程內部可以訪問,是因為訪問時候會通過該進程的內存映射表去拿到真正的物理內存地址,假如其他進程訪問的話,其他進程沒有相應的映射表,自然就訪問不到真正的物理內存地址,這樣就解決了內存安全問題。
內存使用率問題:
內存分頁管理,映射表不能以字節為單位,是以頁為單位,Linux是以4K為一頁,iOS是以16K位一頁,但是mac系統是4K一頁,可以在mac終端輸入pageSize,發現返回的是4096。
為啥分頁后內存就夠用呢,因為應用內存是虛擬的,所以當程序啟動時候程序會認為自己有很多的內存:
在應用加載時候不會把所有數據放內存中,因為數據是懶加載,當進程訪問虛擬地址時候,首先看頁表,如果發現該頁表數據為0,說明該頁面數據未在物理地址上,這個時候系統會阻塞該進程,這個行為就叫做頁中斷(page Fault),也叫缺頁異常,然后將磁盤中對應頁面的數據加載到內存中,然后讓虛擬內存指向剛加載的物理內存,將數據加載到內存中時候,如果有空的內存空間,就放空的內存空間中,如果沒有的話,就會去覆蓋其他進程的數據,具體怎么覆蓋操作系統有一個算法,這樣永遠都會保證當前進程的使用,這就是靈活管理內存。
但是這時候有個問題,虛擬內存解決了安全和效率問題,但是出現了另個安全問題,因為虛擬內存在編譯鏈接時候就確定了,那么黑客很容易通過分析拿到對應的虛擬內存去操作 ,這樣就造成所有的代碼都很好hook,代碼注入,這個時候就出現了新技術ASLR(Address space layout randomization 地址空間隨機化),就是進程每次加載的時候都會給一個隨機的偏移量,這樣就保證每次加載進程時候虛擬內存也在變化,iOS從iOS4就開始了。
二進制重拍:
因為虛擬內存中有個很大問題就是缺頁中斷,這個操作很耗時間,并且iOS不僅僅是將數據加載到內存,還要對這頁做簽名認證,所以iOS耗時比較長,并且每頁耗時有很大差距,0.1ms到0.8毫秒,使用過程中可能時間段感覺不到,但是啟動時候會有很多數據要加載,這樣就會導致耗時很長,假如我們啟動時候在不同頁面,因為代碼在machO的位置不是根據調用瞬間,而是通過文件編譯的位置來的,有可能啟動時候在運行時候會調用很多次page Fault,那么如果把所有啟動時候的代碼都放在一頁或者兩頁,這樣就很大程度上優化啟動速度,這種方法就叫做二進制重拍。
進程如果能直接訪問物理內存無疑是很不安全的,所以操作系統在物理內存的上又建立了一層虛擬內存。為了提高效率和方便管理,又對虛擬內存和物理內存又進行分頁(Page)。當進程訪問一個虛擬內存Page而對應的物理內存卻不存在時,會觸發一次缺頁中斷(Page Fault),分配物理內存,有需要的話會從磁盤mmap讀人數據。
通過App Store渠道分發的App,Page Fault還會進行簽名驗證,所以一次Page Fault的耗時比想象的要多!
編譯器在生成二進制代碼的時候,默認按照鏈接的Object File(.o)順序寫文件,按照Object File內部的函數順序寫函數。
靜態庫文件.a就是一組.o文件的ar包,可以用ar -t查看.a包含的所有.o。
默認布局:
簡化問題:假設我們只有兩個page:page1/page2,其中綠色的method1和method3啟動時候需要調用,為了執行對應的代碼,系統必須進行兩個Page Fault。
但如果我們把method1和method3排布到一起,那么只需要一個Page Fault即可,這就是二進制文件重排的核心原理。
重排之后,我們的經驗是優化一個Page Fault,啟動速度提升0.6~0.8ms
。
二、實現
1、System Trace調試
首先優化,要先學會調試,只有調試才能發現需要優化的地方,我們知道內存分虛擬內存和物理內存,而內存是通過分頁管理的,當我們啟動的時候調用很多方法,假如這些方法不在同一個page上面,就會造成缺頁中斷(page fault),而這個操作是要消耗時間的,所以假如啟動的方法都在一頁上面,就會很大程度上減少啟動時間的消耗,這個就需要用到二進制重拍來將啟動時候調用的方法放在同一個page上
-
首先我們打開項目
command + i
打開Instruments調試工具
image.png -
選擇System Trace,這個軟件可以看到我們項目中每個線程的數據:
image.png -
點擊開始后這里我們搜索Main thread,選擇我們的app,然后點擊Main thread ,再到下面選擇Main Thread --> Virtual Memory(虛擬內存)
image.png 這里面File Backed Page In就是page fault的次數。
當我們把APP殺死后里面再啟動,結果發現File Backed Page In這個值變得很小,說明APP就算殺死后,在啟動不是冷啟動,還是有一部數據在系統的緩存中。
要做到真正的冷啟動,我們可以把APP殺掉后啟動多個手機里面的APP,然后再啟動APP,發現File Backed Page In又變得很大。
說明虛擬內存是在系統中的,當系統內存不夠的時候,其他APP會覆蓋老的APP的虛擬內存。
二進制重拍是在鏈接階段生成的,重排之后生成可執行文件,所以我們只能在編譯階段來優化,而無法對已生成的ipa進行優化。
2、二進制重排
可以在XCode配置二進制重拍,首先要確定符號的順序,才能知道怎么重拍。XCode使用的鏈接器叫做ld,ld有個參數叫order_file
,只要有這個文件,我們可以將文件的路徑告訴XCode,在order_file
文件中把符號的順序寫進去,XCode編譯的時候就會按照文件中的符號順序打包成二進制可執行文件。
-
在蘋果的objc4-750源碼中找到這種文件
image.png -
打開后是下面這種格式:
image.png -
里面全是函數符號,打開項目,在build setting 里面搜索order file
image.png -
這里面指定了order的文件路徑,因為一旦在這里指定了order file的路徑,XCode就會在編譯的時候按照文件里面寫進去的順序。
現在寫一個Demo,然后編譯,我們知道XCode編譯的時候文件會有一個鏈接,鏈接是按照Build Phases的Compile SourceL里面的文件順序將.m文件轉換成.o文件,然后將這些.o文件鏈接在一起生成可執行文件:
image.png 做一個實驗,在ViewController和AppDelegate里面都寫一個load方法,然后運行
+(void)load
{
NSLog(@"ViewController");
}
+(void)load
{
NSLog(@"AppDelegate");
}
-
Build Phases的Compile Source順序:
image.png -
運行,看下打印:
image.png -
把Compile Source順序改一下:
image.png -
運行后看打印結果:
image.png 打印順序跟Compile Source文件順序一樣,驗證了上面的結論
-
如何查看整個項目的符號順序呢,到Build Settings搜索
link map
image.png -
Link Map就是我們鏈接的符號表,我們把它改成
YES
,這樣編譯的時候就會把鏈接的符號表給我們寫出來,command + R我們運行下,然后在Products里面的.app文件,在我們Intermediates.noindex-->項目名.build--->Debug-iphoneos-->項目名.build--->項目名-LinkMap-normal-arm64.txt,這個文件里面就有鏈接的符號順序表
image.png 其中 Object files:就是鏈接了哪些.o文件
Sections:中
Address:
Size:
Segment:__TEXT
代碼代碼段,只可讀;__DATA
是數據段,可讀可寫
Section:
再下面就是我們關心的符號:
Symbols:
Address:
方法代碼的地址
Size:
方法占用的空間
File:
文件的編號
Name:
.o文件里面的方法符號
對于Address
,我們從.app中拿到項目的可執行文件,然后用MachOView打開,然后在Section中看下Assembly
符號表里的0x100004B70在MachOView對應的value是匯編代碼,也就是我們寫的代碼轉換成的匯編,所以這個地址就是代碼地址,所以二進制重拍就是把所有的代碼順序重新排一下,把啟動時候調用的代碼排到前面去,減少啟動時候加載page的數量(沒一個page大小是16K)
-
添加order file,我們創建一個hank.order文件,在文件中寫入:
image.png -
放到工程的根目錄中,然后在Build setting里面搜下order file,然后在后面將該文件地址添加進去:
image.png -
Xcode在編譯時候就會按照order文件中的符號順序鏈接代碼了,我們編譯一下,再看一下LinkMap-normal-arm64.txt文件
image.png 結果是按照order的符號順序來的,而且如果order里面寫了項目中不存在的方法符號,XCode會自動過濾掉,不存在影響。還有一種查看符號表的方法是在終端cd到項目可執行文件的目錄,然后輸入。
nm 可執行文件名
查看全部的符號,還有查看自定義方法的符號
nm -Up TraceDemo
查看系統的符號
nm -up TraceDemo
3、獲取APP啟動時候調用的所有方法
以上就是二進制重拍的步驟,但是如何知道APP啟動時候的調用了哪些方法呢?
第一個方式:是用
fishHook
去hook 系統的objc_msgSend
這個函數,因為oc的方法都是通過發送消息的形式,但是這個函數參數是可變的參數,所以只能通過匯編形式hook,但是這種情況initialize和block以及直接調用函數方式hook不到。第二種方式:clang插裝形式: 官方文檔:clang
OC方法、函數、block都能hook到!
1、首先在Build Setting里面搜索Other C Flags 在里面添加參數:-fsanitize-coverage=trace-pc-guard
-fsanitize-coverage=func,trace-pc-guard
2、然后編譯,發現會報錯:提示報錯
Showing Recent Messages
Undefined symbol: ___sanitizer_cov_trace_pc_guard_init
提示找不到__sanitizer_cov_trace_pc_guard
和__sanitizer_cov_trace_pc_guard_init
方法。
看一下文檔,發現有測試代碼:
把這段代碼copy到項目中,發現,錯誤沒有了
__sanitizer_cov_trace_pc_guard_init
分析一下__sanitizer_cov_trace_pc_guard_init
函數,這里面有個start
和stop
,打個斷點,看一下start
和stop
內存里面的值:
start里每4個字節里面都有一個數組,而且是按照1、2、3、4的順序排列的,再看一下stop,按照start的規則,減4個字節看一下,發現是13,這里面存的是我們項目自定義文件中符號的數量,無論是方法、函數還是block,都會統計進來,我們可以多加幾個方法或者函數、block試一下,就可以驗證:
__sanitizer_cov_trace_pc_guard
我們再分析一下__sanitizer_cov_trace_pc_guard
我們運行時候發現打印了好多guard
實現個個手勢
-(void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event{
}
點擊一下屏幕,發現
點擊一下打印一下,猜測每執行一個函數都會調用一次,說明該函數hook了所有的方法,為了進一步驗證,定義一個函數和一個block,在點擊屏幕時候調用一個函:
void(^block1)(void) = ^(void) {
};
void test(){
block1();
}
guard: 0x100d8381c a PC ?
guard: 0x100d83814 8 PC ?
guard: 0x100d83810 7 PC
發現點擊一次,該函數調用了三次
通過匯編驗證一下,在toubegain、函數、block出都加上斷點,然后打開匯編,運行
bl指令代表調用一個方法或者一個函數 ,過掉這個斷點
test也調用,再過一下
block也調用了,當我們配置了chang的代碼覆蓋工具,實現了上面兩個函數,clang會以靜態插裝形式在所有方法、函數block內部插入一行代碼,而且是在第一行一開始插入的,做到了全局的hook
我們再在分析下
__sanitizer_cov_trace_pc_guard
的作用,我們現在這個函數里面加一個斷點再運行
在左邊發現有個函數調用棧,并且在每次調用方法時候都會調起
__sanitizer_cov_trace_pc_guard
函數,而這個函數就是相應方法調起來的實例代碼中有個PC,我們加個斷點打印一下這個PC看看,先把啟動時候的函數都過掉再打開斷點,然后點擊一下屏幕觸發touchesBegan的方法進行攔截:
在控制欄中輸入bt,查看一下函數調用棧
看一下0x0000000104349abc這個地址的信息
發現這個地址是在touchesBegan里面,但是不在touchesBegan開頭,我們把它
減4個字節
第一個指令是bl,這時才是touchesBegan的開頭
在touchesBegan方法里面加一個斷點,然后跳到touchesBegan方法里面,再打開匯編:
bl是調用的意思,我們發現0x104349ab8是touchesBegan方法的開頭,0x00000001000bdabc是調用下一個函數的指令的下一個地址,PC打印的就是0x104349abc
再來看一下函數調用棧
調用棧的左邊是上一個函數的開始地址,最后面有個+64
,最后面那個數字是偏移量,也就是說函數的開始位置+偏移量
才是函數的真正的位置
,這個時候touchesBegan的偏移量是44
,我們測試一下:
這才是touchesBegan的真正實現,也就是匯編的這一段
說明在
__sanitizer_cov_trace_pc_guard
里面我們能拿到下一個函數調用的首地址:看一下
__sanitizer_cov_trace_pc_guard
的匯編調用最后面有個ret也就是return返回的意思,每個函數或者方法都有一個return, 在底層實現,每一個函數調用完成后都會返回下一個需要調用的函數的地址,也就是匯編中每次bl的時候會把下次要調用的指令的地址存在x30中,當函數執行時候遇到ret時候就會從x30中的值返回回去 ,例如我們點擊屏幕時候在
__sanitizer_cov_trace_pc_guard
加個斷點,然后讀取x30數據,就得到了touchesBegan的地址所以__sanitizer_cov_trace_pc_guard中的
拿到的是下一個要調用的函數的地址,因為__sanitizer_cov_trace_pc_guard函數都是在hook函數前執行的,所以在這里面拿到的函數地址就是我們hook的函數地址
既然能拿到函數地址,我們可以通過這個函數去拿到函數名稱。
#import <dlfcn.h>
dladdr(<#const void *#>, <#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;
void *PC = __builtin_return_address(0);
Dl_info info;
dladdr(PC, &info);
printf("fname:%s \nfbase:%p \nsname:%s \nsaddr:%p\n",
info.dli_fname,
info.dli_fbase,
info.dli_sname,
info.dli_saddr);
打印:
fname:/private/var/containers/Bundle/Application/38C6E838-7D51-4546-9882-BF5858D08C16/TraceDemo.app/TraceDemo
fbase:0x1000e0000
sname:-[ViewController touchesBegan:withEvent:]
saddr:0x1000e5a0c
- fname:文件路徑
- fbase:文件地址
- sname:函數符號名稱
- saddr:函數符號地址,也就是函數的起始地址
當我們能拿到項目所有調用函數的符號時候,我們就能通過這種方法來拿到APP啟動時候調用的所有的函數、方法、block符號,然后創建order文件進行自動二進制重拍上代碼:
//原子隊列
static OSQueueHead symbolList = OS_ATOMIC_QUEUE_INIT;
//定義符號結構體
typedef struct {
void *pc;
void *next;
}SYNode;
void __sanitizer_cov_trace_pc_guard(uint32_t *guard) {
// if (!*guard) return; // Duplicate the guard check.
/* 精確定位 哪里開始 到哪里結束! 在這里面做判斷寫條件!*/
void *PC = __builtin_return_address(0);
SYNode *node = malloc(sizeof(SYNode));
*node = (SYNode){PC,NULL};
//進入,因為該函數可能在子線程中操作,所以用原子性操作,保證線程安全
OSAtomicEnqueue(&symbolList, node, offsetof(SYNode, next));
//
}
-(void)createOrderFile{
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:@"hank.order"];
NSData * fileContents = [funcStr dataUsingEncoding:NSUTF8StringEncoding];
[[NSFileManager defaultManager] createFileAtPath:filePath contents:fileContents attributes:nil];
NSLog(@"%@",funcStr);
}