上篇文章咱們講了虛擬內存。應用程序在運行的時候會有一個虛擬內存,虛擬內存是分頁管理的,它經過頁表映射到物理內存上面。分頁管理有一個特色,當加載新的一塊功能的時候,對應的某一頁數據不在物理內存的時候,系統會缺頁中斷pageFault,而pageFault是須要時間的,用戶在使用過程當中,幾毫秒實際上用戶是感知不到的;可是在應用啓動的時候,會有大量代碼須要執行,此時會有數量衆多的pageFault,這樣一累計,用戶就能夠感知到了。javascript
今天要研究的,就是經過一項技術來減小啓動時的pageFault,進而縮減啓動時間,這個技術就是二進制重排。css
二進制重排這項技術爲大衆所熟知最初是源於抖音團隊的這篇文章:
html
https://mp.weixin.qq.com/s/Drmmx5JtjG3UtTFksL6Q8Q
你們有興趣的話能夠去看一下。
java
想要優化,首先要學會調試。接下來咱們就來看看如何去獲取pageFault的次數。node
測量PageFault次數
ios
打開Instruments工具集,找到System Trace:
數組
打開以後,依次選擇好設備和要執行的APP,而後點擊左上角的紅點啓動:
微信
而後就開始分析,分析完成以後,找到對應APP下面的main thread,而後查看虛擬內存VertialMemary,File Backed Page In對應的Count就是啓動期間的pageFault次數:
app
能夠看到,第一次啓動的時候的缺頁中斷次數是2433。
函數
如今我在模擬器中殺掉TestApp,而後立馬再執行SystemTrace,結果以下:
此時的缺頁中斷次數是49,跟第一次的2433相比,可謂是差了不止一個數量級。這是爲何呢?在個人印象中,App被殺死以後再啓動就是冷啓動了呀,一樣是冷啓動,爲何先後兩次相差這麼多呢?
實際上,當App被殺死以後,有可能它並不會立馬從物理內存中被移除,這些都是由系統來作的,我只能說是不必定會立馬被從物理內存中移除。要想完完整整地去測試冷啓動的缺頁中斷次數的話,能夠在殺死app以後再打開幾個其餘的APP,而後再過個一兩分鐘以後,再啓動這個APP的話,就應該是冷啓動了。
二進制重排步驟初體驗
上面咱們瞭解瞭如何去測量啓動階段pageFault的次數,接下來就來初步體驗一下二進制重排。
實際上,二進制重排的步驟並不複雜,真正的難點在於如何按照函數的執行順序去從新排列頁表中的page。
Xcode使用的連接器是ld,ld中有一個參數是order file,order file是一個文件路徑,它指向了order文件,order文件中寫入的是符號的順序,Xcode在編譯打包的時候就會生成按照order文件中的符號順序排列的可執行文件。
以前不是玩過objc源碼麼,在objc源碼文件夾下有一個libobjc.order文件:
這個libobjc.order文件就是我上面說的記錄符號加載順序的order文件,這裏面記錄的所有都是函數或者方法的符號。
接下來使用Xcode打開蘋果官方objc的Demo,而後在Build Settings中找到Order file:
這裏的路徑就是我上面說的libobjc.order文件的路徑。一旦指定了這個路徑,那麼編譯出來的二進制文件中的符號就是按照路徑中order文件的符號順序來進行排列的了。
這說明蘋果官方自己就支持二進制重排這門技術,並且他們本身的開發者也在使用這門技術,只不過咱們ios開發者平時不怎麼使用這門技術而已。接下來咱們就來看看如何使用。
查看可執行文件中的符號順序
首先,咱們來看一下如何查看二進制可執行文件中的符號順序。
在machO可執行文件的代碼段,各個函數依次排列在裏面,那麼這裏面函數的排列順序是如何查看呢?
如上圖所示,我當前這個工程裏面的全部的源文件都是記錄在Compile Sources裏面。每個源文件在編譯的時候都會生成一個目標文件(.o),而後將全部的.o以及靜態庫等連接成一個MachO,這個連接的順序就是按照Compile Sources裏面的順序來的,而這裏的順序是能夠手動拖動的。
因此說,文件的順序就肯定了。
那麼如何查看整個項目中的符號順序呢?
在Xcode中將Write Link Map File設置爲YES,這表示要求給寫一個連接符號表。
而後編譯。
編譯成功以後,對可執行文件show in finder:
而後鼠標點到紅框內,按照以下順序查找,就能夠找到對應的LinkMap:
雙擊打開Test-LinkMap-normal-x86_64.txt:
首先會有一個Object files(紅框內),這裏面記錄的是連接了哪些文件,這裏面的文件順序就是Compile Sources裏面的順序。
緊接着Object files下面是Sections:
Sections裏面記錄的是MachO二進制可執行文件裏面段的一些數據,Segment這一列表示是哪一段。
Sections下面就是Symbols符號了:
能夠看到,Symbles裏面的數據有四列:Address、Size、File、Name。
Name指的是方法名或者函數名
File指的是在哪個文件當中,這裏面的數字給最上方Object files裏面的數字是對應的
Size指的是這個方法或者函數佔用的空間大小,函數裏面的內容多少不同,其Size也是不同的,100行代碼的方法確定比1行代碼的方法的Size要大。
Address指的是這個方法或者函數的真實的地址,不是這個方法對應的符號地址(符號地址就是存儲在MachO文件的Data段中的符號)。咱們作二進制重排,實際上就是將相關代碼的全部內容放到前面去,而不只僅是簡簡單單將符號放到前面。
自定義Order文件
接下來咱們來玩一下,首先分別在ViewController和AppDelegate這兩個文件中複寫一下load方法,而後Clean一下工程再編譯,而後查看LinkMap:
我將+[ViewController load]、+[AppDelegate load]和_main都畫了紅框,你們能夠清晰地看到其在MachO中的排列順序。
接下來我重排一下。
cd到工程目錄下,終端執行以下指令,新建一個order文件:
touch norman.order
而後在工程目錄下就會新增一個norman.order空文件:
打開該文件,咱們寫入各符號的排列順序:
而後保存,而且設置工程的Order File的路徑:
注意,./ 表示的是工程的根目錄。
而後Clean而且從新編譯,而後查看LinkMap:
此時,MachO文件中的方法或者函數的順序,就是我在norman.order文件中設置的順序!!!
也許你會問,萬一norman.order文件中有的符號在MachO文件中沒有怎麼辦?不要緊,若是order文件中有的符號在MachO文件中沒有,那麼在編譯的時候會直接忽略掉沒有的符號,而且不會報錯。
這就是二進制重排的基本步驟,是否是很簡單!
實際上,二進制重排並不難,一個Order文件外加一個配置就搞定,真正的難點在於去找到啓動時刻的符號,也就是說,你須要知道要將哪些符號排列到前面去。
Hook一切的終極武器——Clang插樁
上面說到,二進制重排最難最核心的一點就是如何去拿到啓動階段的各個符號。
如今你們考慮一個問題,如何去HookOC中全部方法的調用呢?
全部的OC方法最終都會調用objc_msgSend函數,因此我只要可以Hook住objc_msgSend函數,也就至關於Hook住了全部的OC方法。
我在fishhook詳解中講過,經過fishhook能夠hook住全部的系統動態庫中的函數,因此咱們能夠經過fishhook來hook住objc_msgSend函數。而後取出objc_msgSend函數中的第二個參數SEL並保存,也就拿到了全部的OC方法。
可是objc_msgSend函數的參數是可變參數,那麼如何拿到第二個以後的參數呢?須要經過寄存器去拿,此時就須要寫彙編代碼。可是實際上,好多人對彙編是不瞭解的,因此經過fishhook來hook住objc_msgSend函數,進而Hook住全部的符號,這條路沒有必要去死磕,由於它比較深。
那若是不死磕fishhook這條路,還有什麼其餘的路能夠Hook住全部的符號呢?答案就是Clang插樁。
插樁的相關文檔以下:
https://clang.llvm.org/docs/SanitizerCoverage.html
因而可知,插樁是Clang自帶的工具,它能夠實現全部符號的Hook。
接下來咱們玩一下。
這裏須要注意⚠️,不要徹底按照官方文檔來,配置信息要按照以下來配置:
-fsanitize-coverage=func,trace-pc-guard
也就是說,只hook func。否則的話,while循環的時候會有問題,由於每一次while循環也都會被監控到。而配置了coverage=func以後,就只會監控到func(方法、函數、block)了。
配置完成以後編譯:
報錯了!!報錯信息是:
Undefined symbol: ___sanitizer_cov_trace_pc_guard_init
Undefined symbol: ___sanitizer_cov_trace_pc_guard
那麼___sanitizer_cov_trace_pc_guard_init和___sanitizer_cov_trace_pc_guard是什麼東西呢?咱們接着看官方文檔:
在官方文檔的Example中垂手可得找到了___sanitizer_cov_trace_pc_guard_init和___sanitizer_cov_trace_pc_guard。
那麼我就照葫蘆畫瓢,將這兩個函數拷貝到個人工程中:
此時再編譯就能夠編譯成功了。
編譯成功以後咱們就來研究下這兩個函數,首先來看一下__sanitizer_cov_trace_pc_guard_init函數:
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.}
能夠看到該函數中有一個start和一個stop,它們分別是某一段內存的起始位置和結束位置。
我將結束位置stop往前挪4個字節就能夠查看最後一塊內存了:
能夠看到,第一個字節記錄的就是當前加載進內存的符號的個數。
接下來我在原工程中再增長几個符號:
我增長了兩個方法一個block,最後打印符號個數的時候也正好多了3個,這說明,經過這種方式能夠Hook住全部的符號。
接下來再來看一下__sanitizer_cov_trace_pc_guard函數:
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);}
實際上,沒一個符號的調用都會走到__sanitizer_cov_trace_pc_guard函數裏面來。
我在工程中寫入下面代碼:
先將斷點斷到touchBegin,而後查看彙編,以下:
而後斷點往下走,走到test,查看彙編以下:
斷點再往下走,走到normanBlock,查看彙編:
能夠看到,不管是方法仍是函數仍是block,它們在調用的時候,首先都會調用__sanitizer_cov_trace_pc_guard函數。也就是說,當配置了Clang代碼插入工具以後,編譯器會在編譯的時候在全部的方法、函數、block內部都加入了一條調用__sanitizer_cov_trace_pc_guard函數的彙編代碼,這就是所謂的Clang靜態插樁,Hook一切。
定位符號
如今咱們已經Hook到了全部的方法和函數了,那麼如何去定位對應的符號呢?如何獲取當前Hook的符號的名稱呢?
如今來到__sanitizer_cov_trace_pc_guard函數裏面:
void __sanitizer_cov_trace_pc_guard(uint32_t *guard) { if (!*guard) return; void *PC = __builtin_return_address(0); Dl_info info; dladdr(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);}
(注意,使用dladdr須要#import <dlfcn.h>)
__sanitizer_cov_trace_pc_guard函數必定是被你所Hook的方法所調起的,在該函數內部,經過相關API能夠得到符號的名稱等相關信息,打印結果以下:
dli_fname: /Users/liwei/Library/Developer/CoreSimulator/Devices/F27DFCE8-E495-4713-9ED4-38BD4089D5DD/data/Containers/Bundle/Application/FB0EC220-9146-42F8-A9AB-357422BACBD7/Test.app/Test dli_fbase: 0x10da32000 dli_sname: -[ViewController touchesBegan:withEvent:] 0x10da338d0 :
能夠看到,dli_fname指的是文件路徑,dli_fbase指的是文件地址,dli_sname指的是符號的名稱,dli_saddr指的是函數的起始地址。
保存符號
如今咱們已經拿到符號的名稱了(即上面的dli_sname),接下來就看看如何保存這些個符號。
// 聲明一個原子隊列,用於保存符號static OSQueueHead symbleList = OS_ATOMIC_QUEUE_INIT;
// 定義符號結構體(符號是以該結構體的形態保存)typedef struct { void *pc; void *next;}SymbleNode;
void __sanitizer_cov_trace_pc_guard(uint32_t *guard) { // if (!*guard) return; 注意,這裏須要註釋掉,由於若是是load方法,那麼guard就是0.不註釋的話就監控不到load方法了。 /* 精肯定位 哪裏開始 到哪裏結束! 在這裏面作判斷寫條件! */ void *PC = __builtin_return_address(0); SymbleNode *node = malloc(sizeof(SymbleNode)); *node = (SymbleNode){PC, NULL}; // 入棧(保存) OSAtomicEnqueue(&symbleList, node, offsetof(SymbleNode, next));}
使用原子隊列OSQueueHead做爲容器來保存符號
自定義一個SymbleNode結構體,符號是以該結構體的形態進行保存的
__sanitizer_cov_trace_pc_guard函數中,當該函數是由load方法調起的時候,*guard是0,此時就會直接return。因此爲了可以hook住load方法,須要將if (!*guard) return;這行代碼給註釋掉
經過上面說的這一點,我也有所啓發。我能夠定義一個全局靜態變量來記錄是否入棧,在起點函數的時候給該變量設置爲YES,在終點函數的時候給該變量設置爲NO,而後在__sanitizer_cov_trace_pc_guard函數一開始根據該變量值來決定是否返回,這樣的話我就能夠進行監控起點和終點的精肯定位了。
取出符號名稱並生成Order文件
如今符號已經保存了,接下來就是將其取出來生成一個order文件:
// 記錄全部的符號名稱 NSMutableArray <NSString *> * symbolNames = [NSMutableArray array]; // 遍歷全部的符號節點 while (YES) { SymbleNode *node = OSAtomicDequeue(&symbleList, offsetof(SymbleNode, next)); if (node == NULL) { break; } Dl_info info; dladdr(node->pc, &info); NSString * name = @(info.dli_sname); BOOL isObjc = [name hasPrefix:@"+["] || [name hasPrefix:@"-["]; // 是不是OC方法 // 函數前面加下劃線(這裏的函數包括C函數,也包括Swift函數) 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"]; // 寫入order文件 NSString * filePath = [NSTemporaryDirectory() stringByAppendingPathComponent:@"norman.order"]; NSData * fileContents = [funcStr dataUsingEncoding:NSUTF8StringEncoding]; [[NSFileManager defaultManager] createFileAtPath:filePath contents:fileContents attributes:nil]; NSLog(@"%@",filePath); // 直接複製該路徑取出裏面的norman.order便可
在工程中執行完上述代碼以後,就會在對應路徑下生成一份order文件:
而後我將order文件拷貝出來,放入到工程的根目錄下面:
而且設置工程的Order File的路徑:
至此,全部的步驟就都搞完了。
咱先不着急編譯工程,先來看一下目前的linkMap:
而後Clean並從新編譯,再次查看Link Map:
能夠看到,符號已經按照執行的順序從新排列了。
混編工程配置
在混編工程中,因爲有Swift代碼,因此還須要對Swift編譯器作以下配置:
-sanitize-coverage=func -trace-pc-guard
須要注意的是,在優化完畢以後,注意將符號的Hook、定位、保存以及生成Order文件的相關代碼給去掉,只須要拿到對應的Order文件,而後放入工程根目錄便可。
結語
至此,咱們整個啓動優化相關的內容就講完了。
若是你的項目代碼比較粗糙,那麼嚴格按照我第一篇文章中的內容去作代碼優化的話,啓動時間應該能縮短不少。
若是你的項目代碼已經十分優雅了,很難再在代碼層面優化啓動時間了,那麼經過二進制重排,你大概還能優化10%左右。
我以前的項目,二進制重排以前大概是1300毫秒,以後是1150毫秒,大概提高了11%。
以上。
本文分享自微信公衆號 - iOS小生活(iOSHappyLife)。
若有侵權,請聯繫 support@oschina.cn 刪除。
本文參與「OSC源創計劃」,歡迎正在閱讀的你也加入,一塊兒分享。