寫在前面
啟動是App給用戶的第一印象,對用戶體驗至關(guān)重要.試想一個App需要啟動5s以上,你還想用它么?
最初的工程肯定是沒有這些問題的,但隨著業(yè)務(wù)需求不斷豐富,代碼越來越多.如果放任不管的話,啟動時間會不斷上漲,最后讓人無法接受.
本文從優(yōu)化原理出發(fā),介紹了我是如何通過Clang插樁找到啟動所需符號,然后修改編譯參數(shù)完成二進制文件的重新排布提升應(yīng)用的啟動速度的.
一、基本概念(知識儲備)
①. 虛擬內(nèi)存 & 物理內(nèi)存
早期的數(shù)據(jù)訪問是直接通過物理地址訪問的,以這種方式訪問會存在以下兩個問題:
- 內(nèi)存不夠用
- 內(nèi)存數(shù)據(jù)的安全問題
①.1 內(nèi)存不夠用的解決方案:虛擬內(nèi)存
針對問題1,我們在進程和物理內(nèi)存之間增加一個中間層,這個中間層就是所謂的虛擬內(nèi)存,主要用于解決當多個進程同時存在時,對物理內(nèi)存的管理.提高了CPU的利用率,使多個進程可以同時、按需加載.所以虛擬內(nèi)存其本質(zhì)就是一張?zhí)摂M地址和物理地址對應(yīng)關(guān)系的映射表
每個進程都有一個獨立的虛擬內(nèi)存,其地址都是從0開始,大小是4G固定的,每個虛擬內(nèi)存又會劃分為一個一個的頁(頁的大小在iOS中是16KB,其他的是4KB),每次加載都是以頁為單位加載的,進程間是無法互相訪問的,保證了進程間數(shù)據(jù)的安全性.
一個進程中,只有部分功能是活躍的,所以只需要將進程中活躍的部分放入物理內(nèi)存,避免物理內(nèi)存的浪費
當CPU需要訪問數(shù)據(jù)時,首先是訪問虛擬內(nèi)存,然后通過虛擬內(nèi)存去尋址,即可以理解為在表中找對應(yīng)的物理地址,然后對相應(yīng)的物理地址進行訪問
如果在訪問時,虛擬地址的內(nèi)容未加載到物理內(nèi)存,會發(fā)生缺頁異常(pagefault),將當前進程阻塞掉,此時需要先將數(shù)據(jù)載入到物理內(nèi)存,然后再尋址,進行讀取.這樣就避免了內(nèi)存浪費
如下圖所示,虛擬內(nèi)存與物理內(nèi)存間的關(guān)系

①.2 內(nèi)存數(shù)據(jù)的安全問題:ASLR技術(shù)
在上面解釋的虛擬內(nèi)存中,我們提到了虛擬內(nèi)存的起始地址與大小都是固定的,這意味著,當我們訪問時,其數(shù)據(jù)的地址也是固定的,這會導(dǎo)致我們的數(shù)據(jù)非常容易被破解,為了解決這個問題,蘋果在iOS4.3開始引入了ASLR技術(shù).
ASLR的概念:(Address Space Layout Randomization ) 地址空間配置隨機加載,是一種針對緩沖區(qū)溢出的安全保護技術(shù),通過對堆、棧、共享庫映射等線性區(qū)布局的隨機化,通過增加攻擊者預(yù)測目的地址的難度,防止攻擊者直接定位攻擊代碼位置,達到阻止溢出攻擊的目的的一種技術(shù).
其目的是通過利用隨機方式配置數(shù)據(jù)地址空間,使某些敏感數(shù)據(jù)(例如APP登錄注冊、支付相關(guān)代碼)配置到一個惡意程序無法事先獲知的地址,令攻擊者難以進行攻擊.
由于ASLR的存在,導(dǎo)致可執(zhí)行文件和動態(tài)鏈接庫在虛擬內(nèi)存中的加載地址每次啟動都不固定,所以需要在編譯時來修復(fù)鏡像中的資源指針,來指向正確的地址。即正確的內(nèi)存地址 = ASLR地址 + 偏移值
②. 可執(zhí)行文件
不同的操作系統(tǒng),其可執(zhí)行文件的格式也不同.系統(tǒng)內(nèi)核將可執(zhí)行文件讀取到內(nèi)存,然后根據(jù)可執(zhí)行文件的頭簽名(magic魔數(shù))判斷二進制文件的格式

其中PE、ELF、Mach-O這三種可執(zhí)行文件格式都是COFF(Command file format)格式的變種,COFF的主要貢獻是目標文件里面引入了“段”的機制,不同的目標文件可以擁有不同數(shù)量和不同類型的“段”
③. 通用二進制文件
因為不同CPU平臺支持的指令不同,比如arm64和x86,蘋果中的通用二進制格式就是將多種架構(gòu)的Mach-O文件打包在一起,然后系統(tǒng)根據(jù)自己的CPU平臺,選擇合適的Mach-O,所以通用二進制格式也被稱為胖二進制格式,如下圖所示

通用二進制格式的定義在<mach-o/fat.h>中,可以在下載xnu,然后根據(jù) xnu -> EXTERNAL_HEADERS ->mach-o中找到該文件.

通用二進制文件開始的Fat Header是fat_header結(jié)構(gòu)體,而Fat Archs是表示通用二進制文件中有多少個Mach-O,單個Mach-O的描述是通過fat_arch結(jié)構(gòu)體.兩個結(jié)構(gòu)體的定義如下:

所以,綜上所述:
- 通用二進制文件是蘋果公司提出的一種新的二進制文件的存儲結(jié)構(gòu),可以同時存儲多種架構(gòu)的二進制指令,使CPU在讀取該二進制文件時可以自動檢測并選用合適的架構(gòu),以最理想的方式進行讀取
- 由于通用二進制文件會同時存儲多種架構(gòu),所以比單一架構(gòu)的二進制文件大很多,會占用大量的磁盤空間,但由于系統(tǒng)會自動選擇最合適的,不相關(guān)的架構(gòu)代碼不會占用內(nèi)存空間,且執(zhí)行效率高了
- 還可以通過指令來進行Mach-O的合并與拆分
- 查看當前Mach-O的架構(gòu):
lipo -info MachO文件 - 合并:
lipo -create MachO1 MachO2 -output輸出文件路徑 - 拆分:
lipo MachO文件 –thin 架構(gòu) –output輸出文件路徑
- 查看當前Mach-O的架構(gòu):
④. Mach-O文件
Mach-O文件是Mach Object文件格式的縮寫,它是用于可執(zhí)行文件、動態(tài)庫、目標代碼的文件格式.作為a.out格式的替代,Mach-O格式提供了更強的擴展性,以及更快的符號表信息訪問速度
熟悉Mach-O文件格式,有助于更好的理解蘋果底層的運行機制,更好的掌握dyld加載Mach-O的步驟
④.1 Mach-O文件
如果想要查看具體的Mach-O文件信息,可以使用MachOView軟件查看:將Mach-O可執(zhí)行文件拖動到MachOView工具打開

④.2 Mach-O文件格式
對于OS X 和iOS來說,Mach-O是其可執(zhí)行文件的格式,主要包括以下幾種文件類型
-
Executable:可執(zhí)行文件 -
Dylib:動態(tài)鏈接庫 -
Bundle:無法被鏈接的動態(tài)庫,只能在運行時使用dlopen加載 -
Image:指的是Executable、Dylib和Bundle的一種 -
Framework:包含Dylib、資源文件和頭文件的集合

以上是Mach-O文件的格式,一個完成的Mach-O文件主要分為三大部分:
-
Header Mach-O頭部:主要是Mach-O的cpu架構(gòu),文件類型以及加載命令等信息 -
Load Commands 加載命令:描述了文件中數(shù)據(jù)的具體組織結(jié)構(gòu),不同的數(shù)據(jù)類型使用不同的加載命令表示 -
Data 數(shù)據(jù):數(shù)據(jù)中的每個段(segment)的數(shù)據(jù)都保存在這里,段的概念與ELF文件中段的概念類似.每個段都有一個或多個部分,它們放置了具體的數(shù)據(jù)與代碼,主要包含代碼,數(shù)據(jù),例如符號表,動態(tài)符號表等等
Header
Mach-O的Header包含了整個Mach-O文件的關(guān)鍵信息,使得CPU能快速知道Mac-O的基本信息,其在MachO.h文件中針對32位和64位架構(gòu)的cpu,分別使用了mach_header和mach_header_64結(jié)構(gòu)體來描述Mach-O頭部.mach_header是連接器加載時最先讀取的內(nèi)容,決定了一些基礎(chǔ)架構(gòu)、系統(tǒng)類型、指令條數(shù)等信息,這里查看64位架構(gòu)的mach_header_64結(jié)構(gòu)體定義,相比于32位架構(gòu)的mach_header,只是多了一個reserved保留字段

其中filetype主要記錄Mach-O的文件類型,常用的有以下幾種
#define MH_OBJECT 0x1 /* 目標文件*/
#define MH_EXECUTE 0x2 /* 可執(zhí)行文件*/
#define MH_DYLIB 0x6 /* 動態(tài)庫*/
#define MH_DYLINKER 0x7 /* 動態(tài)鏈接器*/
#define MH_DSYM 0xa /* 存儲二進制文件符號信息,用于debug分析*/
相對應(yīng)的,Header在MachOView中的展示如下

Load Commands
在Mach-O文件中,Load Commands主要是用于加載指令,其大小和數(shù)目在Header中已經(jīng)被提供,其在MachO.h中的定義如下

我們在MachOView中查看Load Commands,其中記錄了很多信息,例如動態(tài)鏈接器的位置、程序的入口、依賴庫的信息、代碼的位置、符號表的位置等等,如下所示


Data
Load Commands后就是Data區(qū)域,這個區(qū)域存儲了具體的只讀、可讀寫代碼,例如方法、符號表、字符表、代碼數(shù)據(jù)、連接器所需的數(shù)據(jù)(重定向、符號綁定等)。主要是存儲具體的數(shù)據(jù)。其中大多數(shù)的Mach-O文件均包含以下三個段:
-
__TEXT 代碼段:只讀,包括函數(shù),和只讀的字符串 -
__DATA 數(shù)據(jù)段:讀寫,包括可讀寫的全局變量等 -
__LINKEDIT: __LINKEDIT包含了方法和變量的元數(shù)據(jù)(位置,偏移量),以及代碼簽名等信息.
在Data區(qū)中,Section占了很大的比例,Section在MachO.h中是以結(jié)構(gòu)體section_64(在arm64架構(gòu)下)表示,其定義如下





二、App啟動
進程如果能直接訪問物理內(nèi)存無疑是很不安全的,所以操作系統(tǒng)在物理內(nèi)存之上又建立了一層虛擬內(nèi)存.蘋果在這個基礎(chǔ)上還有 ASLR(Address Space Layout Randomization) 技術(shù)的保護(前面概念有介紹).
iOS系統(tǒng)中虛擬內(nèi)存到物理內(nèi)存的映射都是以頁為最小單位的.當進程訪問一個虛擬內(nèi)存Page而對應(yīng)的物理內(nèi)存卻不存在時,就會出現(xiàn)Page Fault缺頁中斷,然后加載這一頁.雖然本身這個處理速度是很快的,但是在一個App的啟動過程中可能出現(xiàn)上千(甚至更多)次Page Fault,這個時間積累起來會比較明顯了.
iOS系統(tǒng)中一頁是16KB.
我們常說的啟動是指點擊App到第一頁顯示為止,包含pre-main、main到didFinishLaunchingWithOptions結(jié)束的整個時間.
另外,還有兩個重要的概念:冷啟動、熱啟動.可能有些同學認為殺掉再重啟App就是冷啟動了,其實是不對的.
冷啟動
程序完全退出,之間加載的分頁數(shù)據(jù)被其他進程所使用覆蓋之后,或者重啟設(shè)備、第一次安裝,才算是冷啟動.熱啟動
程序殺掉之后,馬上又重新啟動.這個時候相應(yīng)的物理內(nèi)存中仍然保留之前加載過的分頁數(shù)據(jù),可以進行重用,不需要全部重新加載.所以熱啟動的速度比較快.
而我們這里所說的啟動優(yōu)化,一般是指冷啟動情況下的,這種情況下的啟動主要分為兩部分:
- T1 :pre-main階段,即main函數(shù)之前,操作系統(tǒng)加載App可執(zhí)行文件到內(nèi)存,執(zhí)行一系列的加載&鏈接等工作,簡單來說,就是dyld加載過程
- T2:main函數(shù)之后,即從main函數(shù)開始,到 Appdelegate 的didFinishLaunching方法執(zhí)行完成為止,主要是構(gòu)建第一個界面,并完成渲染
所以,T1+T2 的過程就是從用戶點擊App圖標到用戶能看到app主界面的過程,即需要啟動優(yōu)化的部分
①. pre-main階段的優(yōu)化
pre-main階段的啟動時間其實就是dyld加載過程的時間
針對main函數(shù)之前的啟動時間,蘋果提供了內(nèi)建的測量方法,在 Edit Scheme -> Run -> Arguments ->Environment Variables 點擊+添加環(huán)境變量 DYLD_PRINT_STATISTICS 設(shè)為 1),然后運行,以下是iPhone6sp正常啟動的pre-main時間(以WeChat為例)

說明
pre-main階段總共用時1.1s
-
dylib loading time(動態(tài)庫耗時):主要是加載動態(tài)庫,用時297.53ms
- 動態(tài)加載程序查找并讀取應(yīng)用程序使用的依賴動態(tài)庫.每個庫本身都可能有依賴項.雖然蘋果系統(tǒng)框架的加載是高度優(yōu)化的,但加載嵌入式框架可能會很耗時.為了加快動態(tài)庫的加載速度,蘋果建議您使用更少的動態(tài)庫,或者考慮合并它們.
- 建議的目標是六個額外的(非系統(tǒng))框架.
-
rebase/binding time(偏移修正/符號綁定耗時):耗時133.43ms
- 修正調(diào)整鏡像內(nèi)的指針(重新調(diào)整)和設(shè)置指向鏡像外符號的指針(綁定).為了加快重新定位/綁定時間,我們需要更少的指針修復(fù).
-
rebase(偏移修正):任何一個app生成的二進制文件,在二進制文件內(nèi)部所有的方法、函數(shù)調(diào)用,都有一個地址,這個地址是在當前二進制文件中的偏移地址.一旦在運行時刻(即運行到內(nèi)存中),每次系統(tǒng)都會隨機分配一個ASLR(Address Space Layout Randomization,地址空間布局隨機化)地址值(是一個安全機制,會分配一個隨機的數(shù)值,插入在二進制文件的開頭),例如:二進制文件中有一個
test方法,偏移值是0x0001,而隨機分配的ASLR是0x1f00,如果想訪問test方法,其內(nèi)存地址(即真實地址)變?yōu)?ASLR+偏移值 = 運行時確定的內(nèi)存地址(即0x1f00+0x0001=0x1f01) -
binding(綁定):例如
NSLog方法,在編譯時期生成的mach-o文件中,會創(chuàng)建一個符號!NSLog(目前指向一個隨機的地址),然后在運行時(從磁盤加載到內(nèi)存中,是一個鏡像文件),會將真正的地址給符號(即在內(nèi)存中將地址與符號進行綁定,是dyld做的,也稱為動態(tài)庫符號綁定),一句話概括:綁定就是給符號賦值的過程
-
ObjC setup time(OC類注冊的耗時):OC類越多,越耗時
- Objective-C運行時需要進行設(shè)置類、類別和選擇器注冊.我們對重新定位綁定時間所做的任何改進也將優(yōu)化這個設(shè)置時間
- 如果有大量(大的是20000)Objective-C類、選擇器和類別的應(yīng)用程序可以增加800ms的啟動時間.
- 如果應(yīng)用程序使用C++代碼,那么使用更少的虛擬函數(shù).
- 使用Swift結(jié)構(gòu)體通常也更快
-
initializer time(執(zhí)行l(wèi)oad和構(gòu)造函數(shù)的耗時)
- 運行初始化程序.如果使用了Objective-C的 +load 方法,請將其替換為 +initialize 方法.
②. main函數(shù)階段的優(yōu)化
在main函數(shù)之后的 didFinishLaunching 方法中,主要是執(zhí)行了各種業(yè)務(wù),有很多并不是必須在這里立即執(zhí)行的,這種業(yè)務(wù)我們可以采取延遲加載,防止影響啟動時間.
在 didFinishLaunching中的業(yè)務(wù)主要分為三個類型
- 【第一類】初始化第三方sdk
- 【第二類】app運行環(huán)境配置
- 【第三類】自己工具類的初始化等
main函數(shù)階段的優(yōu)化建議主要有以下幾點:
- 減少啟動初始化的流程,能懶加載的懶加載,能延遲的延遲,能放后臺初始化的放后臺,盡量不要占用主線程的啟動時間
- 優(yōu)化代碼邏輯,去除非必須的代碼邏輯,減少每個流程的消耗時間
- 啟動階段能使用多線程來初始化的,就使用多線程
- 盡量使用純代碼來進行UI框架的搭建,尤其是主UI框架,例如
UITabBarController.盡量避免使用Xib或者SB,相比純代碼而言,這種更耗時 - 刪除廢棄類、方法
三、二進制重排 —— 主要是針對如何減少Page Fault的優(yōu)化
前面大致介紹了一些基本概念以及啟動優(yōu)化的思路,下面來著重介紹一個pre-main階段的優(yōu)化方案,即二進制重排
①. 二進制重排原理
在虛擬內(nèi)存部分,我們知道,當進程訪問一個虛擬內(nèi)存page,而對應(yīng)的物理內(nèi)存不存在時,會觸發(fā)缺頁中斷(Page Fault),因此阻塞進程.此時就需要先加載數(shù)據(jù)到物理內(nèi)存,然后再繼續(xù)訪問.這個對性能是有一定影響的.
基于Page Fault,我們思考,App在冷啟動過程中,會有大量的類、分類、三方等需要加載和執(zhí)行,此時產(chǎn)生的Page Fault所帶來的耗時是很大的.以WeChat為例,我們來看下,在啟動階段的Page Fault的次數(shù)
-
CMD+i快捷鍵,選擇System Trace
-
點擊啟動(啟動前需要重啟手機,清除緩存數(shù)據(jù)),第一個界面出來后,停掉,按照下圖中操作
從圖中可以看出WeChat發(fā)生的PageFault有2900+次,可想而知,這個是非常影響性能的.
- 然后我們再通過
Demo查看方法在編譯時期的排列順序,在ViewController中按下列順序定義以下幾個方法
- 在
Build Settings -> Write Link Map File設(shè)置為YES
-
CMD+B編譯Demo,然后在對應(yīng)的路徑下查找link map文件.右鍵Show In Finder打開包文件夾:
* 在包文件的上兩層級,找到 `Intermediates.noindex`:
* 沿路徑找到并打開① - 啟動優(yōu)化Demo-LinkMap-normal-arm64.txt文件:
- 函數(shù)順序(書寫順序),如下所示,可以發(fā)現(xiàn) 類中函數(shù)的加載順序是從上到下的,而
文件的順序是根據(jù)Build Phases -> Compile Sources中的順序加載的

總結(jié)
從上面的Page Fault的次數(shù)以及加載順序,可以發(fā)現(xiàn)其實導(dǎo)致 Page Fault 次數(shù)過多的根本原因是啟動時刻需要調(diào)用的方法,處于不同的Page導(dǎo)致的.因此,我們的優(yōu)化思路就是:**將所有啟動時刻需要調(diào)用的方法,排列在一起,即放在一個頁中,這樣就從多個 Page Fault 變成了一個 Page Fault **. 這就是二進制重排的 核心原理,如下所示

注意:在iOS生產(chǎn)環(huán)境的app,在發(fā)生Page Fault進行重新加載時,iOS系統(tǒng)還會對其做一次簽名驗證,因此 iOS 生產(chǎn)環(huán)境的 Page Fault 比Debug環(huán)境下所產(chǎn)生的耗時更多
②. 二進制重排實踐
下面,我們來進行具體的實踐,首先理解幾個名詞
②.1 Link Map
Link Map 是iOS編譯過程的中間產(chǎn)物,記錄了二進制文件的布局,需要在Xcode的Build Settings 里開啟Write Link Map File,Link Map主要包含三部分:
-
Object Files生成二進制用到的link單元的路徑和文件編號 -
Sections記錄Mach-O每個Segment/section的地址范圍 -
Symbols按順序記錄每個符號的地址范圍
②.2 ld
ld是Xcode使用的鏈接器,有一個參數(shù)order_file,我們可以通過在Build Settings -> Order File配置一個后綴為order的文件路徑.在這個order文件中,將所需要的符號按照順序?qū)懺诶锩妫陧椖烤幾g時,會按照這個文件的順序進行加載,以此來達到我們的優(yōu)化
所以二進制重排的本質(zhì)就是對啟動加載的符號進行重新排列
到目前為止,原理我們基本弄清楚了,如果項目比較小,完全可以自定義一個order文件,將方法的順序手動添加,但是如果項目較大,涉及的方法特別多,此時我們?nèi)绾潍@取啟動運行的函數(shù)呢?有以下幾種思路:
-
hook objc_msgSend:我們知道,函數(shù)的本質(zhì)是發(fā)送消息,在底層都會來到objc_msgSend,但是由于objc_msgSend的參數(shù)是可變的,需要通過匯編獲取,對開發(fā)人員要求較高.而且也只能拿到OC和swift中@objc后的方法 -
靜態(tài)掃描:掃描
Mach-O特定段和節(jié)里面所存儲的符號以及函數(shù)數(shù)據(jù) -
Clang插樁:即批量hook,可以實現(xiàn)100%符號覆蓋,即完全獲取
swift、OC、C、block函數(shù)
②.3 二進制重排初體驗
二進制重排,關(guān)鍵是order文件
- 前面講
objc源碼時,會在工程中看到order文件:
- 打開
.order文件,可以看到內(nèi)部都是排序好的函數(shù)符號
- 這是因為蘋果自己的庫,也都進行了二進制重排
新進一個Demo (② - 二進制重排初體驗) 玩玩
我們打開創(chuàng)建的Demo項目,我想把排序改成load->test1->test2->ViewDidAppear->main
-
在Demo項目根目錄創(chuàng)建一個
tcj.order文件touch tcj.order

- 在
tcj.order文件中手動順序?qū)懭牒瘮?shù)(還寫了個不存在的hello函數(shù))
- 在
Build Settings中搜索order file,加入./tcj.order
-
Command + B編譯后,再次去查看link map文件:
* 發(fā)現(xiàn)`order`文件中`不存在的函數(shù)`(hello),編譯器會直接跳過
* 其他`函數(shù)符號`,完全按照我們`order`順序排列
* `order`中沒有的函數(shù),按照默認順序接在`order`函數(shù)后面
- 那么問題來了.靠手寫一個個函數(shù)寫進
order文件中.代碼寫了那么多,還有些代碼不是我寫的,我怎么知道哪個函數(shù)先,哪個函數(shù)后呢??- 我們要做到的目標: 拿到
啟動完成后的某個時刻,之前的所有被調(diào)用的函數(shù).勞煩你們自己排隊進入我的order文件中(Clang插樁來實現(xiàn))
- 我們要做到的目標: 拿到
②.4 Clang插樁
要真正的實現(xiàn)二進制重排,我們需要拿到啟動的所有方法、函數(shù)等符號,并保存其順序,然后寫入order文件,實現(xiàn)二進制重排.
抖音有一篇文章抖音研發(fā)實踐:基于二進制文件重排的解決方案 APP啟動速度提升超15%,但是文章中也提到了瓶頸:
基于靜態(tài)掃描+運行時trace的方案仍然存在少量瓶頸:
- initialize hook不到
- 部分block hook不到
- C++通過寄存器的間接函數(shù)調(diào)用靜態(tài)掃描不出來
目前的重排方案能夠覆蓋到80%~90%的符號,未來我們會嘗試編譯期插樁等方案來進行100%的符號覆蓋,讓重排達到最優(yōu)效果。
同時也給出了解決方案編譯期插樁.
在說clang插樁之前,我們來說說什么是hook?
hook是鉤子. -- 獲取原有函數(shù)符號的內(nèi)存地址和實現(xiàn),勾住它,做一些自己想做的事情
- 例如: 你遇到在公路上攔到一輛車.你可以跟他的車一起走(附加自己代碼),也可以直接搶了他的車自己開(重寫實現(xiàn)).
很明顯,我們此刻就是想勾住啟動結(jié)束前的所有函數(shù),附加一些代碼,把函數(shù)名按順序存下來,生成我們的order文件
Q: 有沒有API,能讓我hook一切我想hook的東西?swift、oc、c函數(shù)我都要hook?
A: 有,clang插樁. 語法樹都是它生成的,順序它說了算.
Clang插樁
llvm內(nèi)置了一個簡單的代碼覆蓋率檢測(SanitizerCoverage).它在函數(shù)級、基本塊級和邊緣級插入對用戶定義函數(shù)的調(diào)用.我們這里的批量hook,就需要借助于SanitizerCoverage.
關(guān)于 clang 的插樁覆蓋的官方文檔如下 : clang 自帶代碼覆蓋工具 文檔中有詳細概述,以及簡短Demo演示
我們創(chuàng)建TraceDemo項目,按照官方給的示例,來嘗試開發(fā)
添加trace
-
按照官方描述,可以加入跟蹤代碼,并給出了回調(diào)函數(shù).
打開我們的TranceDemo, 在Build Settings中搜索Other C,在 Other C Flags里加入-fsanitize-coverage=trace-pc-guard配置,編譯的話會報錯
objc Undefined symbol: ___sanitizer_cov_trace_pc_guard_init Undefined symbol: ___sanitizer_cov_trace_pc_guard
查看官網(wǎng)會需要我們添加兩個函數(shù):
#include <stdint.h>
#include <stdio.h>
#include <sanitizer/coverage_interface.h>
// 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.
extern "C" 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.
}
// 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);
extern "C" void __sanitizer_cov_trace_pc_guard(uint32_t *guard) {
if (!*guard) return; // Duplicate the guard check.
// If you set *guard to 0 this code will not be called again for this edge.
// Now you can get the PC and do whatever you want:
// store it somewhere or symbolize it and print right away.
// The values of `*guard` are as you set them in
// __sanitizer_cov_trace_pc_guard_init and so you can make them consecutive
// and use them to dereference an array or a bit vector.
void *PC = __builtin_return_address(0);
char PcDescr[1024];
// This function is a part of the sanitizer run-time.
// To use it, link with AddressSanitizer or other sanitizer.
__sanitizer_symbolize_pc(PC, "%p %F %L", PcDescr, sizeof(PcDescr));
printf("guard: %p %x PC %s\n", guard, *guard, PcDescr);
}
我們把代碼添加到ViewController.m中,我們不需要 extern "C" 所以可以刪掉, __sanitizer_symbolize_pc() 還會報錯,不重要先注釋了然后繼續(xù).

函數(shù) __sanitizer_cov_trace_pc_guard_init 統(tǒng)計了方法的個數(shù).
運行后,我們可以看到

讀取內(nèi)存之后,我們可以看到一個類似計數(shù)器的東西.最后一個打印的是結(jié)束位置,按顯示是4位4位的,所以向前移動4位,打印出來的應(yīng)該就是最后一位.
解釋兩個參數(shù):
- 參數(shù)1
start是一個指針,指向無符號int類型,4個字節(jié),相當于一個數(shù)組的起始位置,即符號的起始位置(是從高位往低位讀) - 參數(shù)2
stop,由于數(shù)據(jù)的地址是往下讀的(即從高往低讀,所以此時獲取的地址并不是stop真正的地址,而是標記的最后的地址,讀取stop時,由于stop占4個字節(jié),stop真實地址 = stop打印的地址-0x4) -
start和stop表示當前文件的開始內(nèi)存地址和結(jié)束內(nèi)存地址。單位是int32 4字節(jié) - 如果多加幾個函數(shù),會發(fā)現(xiàn)stop地址值也會相應(yīng)的增加。
- 此處是指從start到stop的前閉后開區(qū)間。[ , ),所以stop地址往前偏移4字節(jié),才是最后一個函數(shù)符號的地址

根據(jù)小端模式,0e 00 00 00對應(yīng)的是00 00 00 0e即14.
那么stop內(nèi)存地址中存儲的值表示什么?在增加一個方法/塊/c++/屬性的方法(多幾個),發(fā)現(xiàn)其值也會增加對應(yīng)的數(shù).
例如先在ViewController.m增加一個touchesBegan方法,運行:

根據(jù)小端模式,0f 00 00 00對應(yīng)的是00 00 00 0f即15.
我們在增加一個函數(shù)test():運行:

根據(jù)小端模式,10 00 00 00對應(yīng)的是00 00 00 10即16.
我們在增加一個block:運行:

根據(jù)小端模式,11 00 00 00對應(yīng)的是00 00 00 11即17.
到此時可以看到一共增加了3(block是匿名函數(shù)),計數(shù)器統(tǒng)計了函數(shù)/方法/塊的個數(shù),這里添加了三個,索引增加了3
從新整理一下代碼:
#import "ViewController.h"
#include <stdint.h>
#include <stdio.h>
#include <sanitizer/coverage_interface.h>
@interface ViewController ()
@end
@implementation ViewController
void test()
{
block();
}
void(^block)(void) = ^(void){
};
- (void)viewDidLoad {
[super viewDidLoad];
}
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.
}
void __sanitizer_cov_trace_pc_guard(uint32_t *guard) {
if (!*guard) return; // Duplicate the guard check.
// void *PC = __builtin_return_address(0);
char PcDescr[1024];
// __sanitizer_symbolize_pc(PC, "%p %F %L", PcDescr, sizeof(PcDescr));
printf("guard: %p %x PC %s\n", guard, *guard, PcDescr);
}
- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event{
test();
}
@end
運行項目清空打印區(qū):當我們再點擊一下屏幕的時候:
我們在touchBegin、test、block和__sanitizer_cov_trace_pc_guard都加入斷點,運行代碼:

【驗證一】執(zhí)行順序是:touchesBegan -> __sanitizer_cov_trace_pc_guard ->
test -> __sanitizer_cov_trace_pc_guard ->
block -> __sanitizer_cov_trace_pc_guard
【驗證二】touchesBegan時,進入?yún)R編:

如果我們查看其他函數(shù)也會發(fā)現(xiàn)匯編代碼中有類似的顯示.那么每個函數(shù)在觸發(fā)時,都調(diào)用了__sanitizer_cov_trace_pc_guard函數(shù).
即:只要在Other C Flags處加入標記,開啟了trace功能.LLVM會在每個函數(shù)邊緣(開始位置),插入一行調(diào)用__sanitizer_cov_trace_pc_guard的代碼.編譯期就插入了.所以可以100%覆蓋.(也就是說Clang插樁就是在匯編代碼中插入了 __sanitizer_cov_trace_pc_guard函數(shù)的調(diào)用)
解釋一下__sanitizer_cov_trace_pc_guard方法:主要是捕獲所有的啟動時刻的符號,將所有符號入隊.
拿到了全部的符號之后需要保存,但是不能用數(shù)組,因為有可能會有在子線程執(zhí)行的,所以用數(shù)組會有線程問題 .這里我們使用原子隊列:
#import "ViewController.h"
#include <stdint.h>
#include <stdio.h>
#include <sanitizer/coverage_interface.h>
#import <libkern/OSAtomic.h>
#import <dlfcn.h>
@interface ViewController ()
@end
@implementation ViewController
//定義原子隊列: 特點 1.先進后出 2.線程安全 3.只能保存結(jié)構(gòu)體
static OSQueueHead symbolList = OS_ATOMIC_QUEUE_INIT;
//定義符號結(jié)構(gòu)體鏈表
typedef struct{
void *pc;
void *next;
} SymbolNode;
void test()
{
block();
}
void(^block)(void) = ^(void){
};
- (void)viewDidLoad {
[super viewDidLoad];
}
/*
- start:起始位置
- stop:并不是最后一個符號的地址,而是整個符號表的最后一個地址,最后一個符號的地址=stop-4(因為是從高地址往低地址讀取的,且stop是一個無符號int類型,占4個字節(jié))。stop存儲的值是符號的
*/
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.
}
/*
可以全面hook方法、函數(shù)、以及block調(diào)用,用于捕捉符號,是在多線程進行的,這個方法中只存儲pc,以鏈表的形式
- guard 是一個哨兵,告訴我們是第幾個被調(diào)用的
*/
void __sanitizer_cov_trace_pc_guard(uint32_t *guard) {
// if (!*guard) return; // Duplicate the guard check. //將load方法過濾掉了,所以需要注釋掉
//獲取PC
/*
- PC 當前函數(shù)返回上一個調(diào)用的地址
- 0 當前這個函數(shù)地址,即當前函數(shù)的返回地址
- 1 當前函數(shù)調(diào)用者的地址,即上一個函數(shù)的返回地址
*/
void *PC = __builtin_return_address(0);
//創(chuàng)建結(jié)構(gòu)體!
SymbolNode * node = malloc(sizeof(SymbolNode));
*node = (SymbolNode){PC, NULL};
//加入隊列
//符號的訪問不是通過下標訪問,是通過鏈表的next指針,所以需要借用offsetof(結(jié)構(gòu)體類型,下一個的地址即next)
OSAtomicEnqueue(&symbolList, node, offsetof(SymbolNode, next));
Dl_info info;// 聲明對象
dladdr(PC, &info);// 讀取PC地址,賦值給info
printf("fnam:%s \n fbase:%p \n sname:%s \n saddr:%p \n",
info.dli_fname,
info.dli_fbase,
info.dli_sname,
info.dli_saddr);
}
- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event{
test();
}
@end
運行后這里我們可以看到很多打印,只取一條來說明,很明顯其中sname就是我們需要的符號名了.

下面我們通過點擊屏幕導(dǎo)出所需要的符號,需要注意的是C函數(shù)和Swift方法前面需要加下劃線.(這一點可以在前面提到的LinkMap文件中確認)
- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event{
NSMutableArray <NSString *>* symbolNames = [NSMutableArray array];
// 每次while循環(huán),都會加入一次hook (__sanitizer_cov_trace_pc_guard) 只要是跳轉(zhuǎn),就會被block
// 直接修改[other c clang]: -fsanitize-coverage=func,trace-pc-guard 指定只有func才加Hook
while (YES) {
// 去除鏈表
SymbolNode * node = OSAtomicDequeue(&symbolList, offsetof(SymbolNode, next));
if (node == NULL) {
break;
}
Dl_info info = {0};
// 取出節(jié)點的pc,賦值給info
dladdr(node->pc, &info);
// 釋放節(jié)點
free(node);
// 存名字
NSString * name = @(info.dli_sname);
BOOL isObjc = [name hasPrefix:@"+["] || [name hasPrefix:@"-["]; //OC方法不處理
NSString * symbolName = isObjc ? name : [@"_" stringByAppendingString:name]; //c函數(shù)、swift方法前面帶下劃線
[symbolNames addObject:symbolName];
printf("%s \n",info.dli_sname);
}
//取反(隊列的存儲是反序的)
NSEnumerator * emt = [symbolNames reverseObjectEnumerator];
//創(chuàng)建數(shù)組
NSMutableArray<NSString*>* funcs = [NSMutableArray arrayWithCapacity:symbolNames.count];
// 臨時變量
NSString * name;
// 遍歷集合,去重,添加到funcs中
while (name = [emt nextObject]) {
// 數(shù)組中去重添加
if (![funcs containsObject:name]) {
[funcs addObject:name];
}
}
// 刪掉當前方法,因為這個點擊方法不是啟動需要的
[funcs removeObject:[NSString stringWithFormat:@"%s",__FUNCTION__]];
// 文件路徑
NSString * filePath = [NSTemporaryDirectory() stringByAppendingPathComponent:@"tcj.order"];
// 數(shù)組轉(zhuǎn)字符串
NSString *funcStr = [funcs componentsJoinedByString:@"\n"];
// 文件內(nèi)容
NSData * fileContents = [funcStr dataUsingEncoding:NSUTF8StringEncoding];
// 在路徑上創(chuàng)建文件
[[NSFileManager defaultManager] createFileAtPath:filePath contents:fileContents attributes:nil];
NSLog(@"%@",filePath);
}
這時如果你直接點擊屏幕,有個巨坑,會看到控制臺一直在輸出,出現(xiàn)了死循環(huán):


發(fā)現(xiàn) __sanitizer_cov_trace_pc_guard居然有10個,這個地方會觸發(fā) __sanitizer_cov_trace_pc_guard中的入隊,這里又進行出隊,最后就死循環(huán)了.
解決辦法:
Build Settings中Other C Flags添加func配置,即-fsanitize-coverage=func,trace-pc-guard.
官網(wǎng)對func的參數(shù)的解釋:只檢測每個函數(shù)的入口.
再次運行點擊屏幕就不會有問題了.
注意點:
- if(!guard) return;需要去掉,它會影響+load的寫入*
- while循環(huán),也會觸發(fā)__sanitizer_cov_trace_pc_guard(trace的觸發(fā),并不是根據(jù)函數(shù)來進行hook的,而是hook了每一個跳轉(zhuǎn)(bl).while也有跳轉(zhuǎn),所以進入了死循環(huán))
從真機上獲取order文件
我們把order文件存在了真機上的tmp文件夾中,要怎么拿到呢?
在Window→Devices And Simulators(快捷鍵?+?+2)中:


Swift二進制重排
Swift也可以重排么?當然可以!
Swift 二進制重排,與OC一樣.只是LLVM前端不同.
-
OC的前端編譯器是Clang,所以在other c flags處添加-fsanitize-coverage=func,trace-pc-guard -
Swift的前端編譯器是Swift,所以在other Swift Flags處添加-sanitize=undefined和-sanitize-coverage=func
我們在項目中添加一個Swift類,然后在ViewController的load方法中調(diào)用一下:




補充:
swift符號自帶名稱混淆
未改變代碼時,swift符號不會變
總之,order文件,請在代碼封版后,再生成
所有處理完之后,最后需要Write Link Map File改為NO,把Other C Flags/Other Swift Flags的配置刪除掉
因為這個配置會在我們代碼中自動插入跳轉(zhuǎn)執(zhí)行 __sanitizer_cov_trace_pc_guard.重排完就不需要了,需要去除掉. 同時把ViewController中的 __sanitizer_cov_trace_pc_guard也要去除掉.
至此,Clang插樁和自動生成Order文件,都已完成.拿到order文件后,小伙伴們可以去自己的項目試試哦.
寫在后面
通過二進制重排,讓啟動需要的方法排列更緊湊,減少了Page Fault的次數(shù).
獲取符號表時,采用Clang插樁可以直接hook到Objective-C方法、Swift方法、C函數(shù)、Block,可以不用區(qū)別對待.相比于抖音之前提出的方案確實簡單很多,門檻也要低一些.











