寫在前面
啟動是App給用戶的第一印象,對用戶體驗至關(guān)重要.試想一個App需要啟動5s以上,你還想用它么疏咐?
最初的工程肯定是沒有這些問題的,但隨著業(yè)務(wù)需求不斷豐富,代碼越來越多.如果放任不管的話税肪,啟動時間會不斷上漲,最后讓人無法接受.
本文從優(yōu)化原理出發(fā)榜田,介紹了我是如何通過Clang插樁找到啟動所需符號益兄,然后修改編譯參數(shù)完成二進(jìn)制文件的重新排布提升應(yīng)用的啟動速度的.
一、基本概念(知識儲備)
①. 虛擬內(nèi)存 & 物理內(nèi)存
早期的數(shù)據(jù)訪問是直接通過物理地址訪問的箭券,以這種方式訪問會存在以下兩個問題:
- 內(nèi)存不夠用
- 內(nèi)存數(shù)據(jù)的安全問題
①.1 內(nèi)存不夠用的解決方案:虛擬內(nèi)存
針對問題1净捅,我們在進(jìn)程和物理內(nèi)存之間增加一個中間層,這個中間層就是所謂的虛擬內(nèi)存辩块,主要用于解決當(dāng)多個進(jìn)程同時存在時灸叼,對物理內(nèi)存的管理.提高了CPU的利用率,使多個進(jìn)程可以同時庆捺、按需加載.所以虛擬內(nèi)存其本質(zhì)就是一張?zhí)摂M地址和物理地址對應(yīng)關(guān)系的映射表
每個進(jìn)程都有一個獨(dú)立的虛擬內(nèi)存古今,其地址都是從0開始,大小是4G固定的滔以,每個虛擬內(nèi)存又會劃分為一個一個的頁(頁的大小在iOS中是16KB捉腥,其他的是4KB),每次加載都是以頁為單位加載的你画,進(jìn)程間是無法互相訪問的抵碟,保證了進(jìn)程間數(shù)據(jù)的安全性.
一個進(jìn)程中,只有部分功能是活躍的坏匪,所以只需要將進(jìn)程中活躍的部分放入物理內(nèi)存拟逮,避免物理內(nèi)存的浪費(fèi)
當(dāng)CPU需要訪問數(shù)據(jù)時,首先是訪問虛擬內(nèi)存适滓,然后通過虛擬內(nèi)存去尋址敦迄,即可以理解為在表中找對應(yīng)的物理地址,然后對相應(yīng)的物理地址進(jìn)行訪問
如果在訪問時凭迹,虛擬地址的內(nèi)容未加載到物理內(nèi)存罚屋,會發(fā)生缺頁異常(pagefault),將當(dāng)前進(jìn)程阻塞掉嗅绸,此時需要先將數(shù)據(jù)載入到物理內(nèi)存脾猛,然后再尋址,進(jìn)行讀取.這樣就避免了內(nèi)存浪費(fèi)
如下圖所示鱼鸠,虛擬內(nèi)存與物理內(nèi)存間的關(guān)系
①.2 內(nèi)存數(shù)據(jù)的安全問題:ASLR技術(shù)
在上面解釋的虛擬內(nèi)存中猛拴,我們提到了虛擬內(nèi)存的起始地址與大小都是固定的羹铅,這意味著,當(dāng)我們訪問時愉昆,其數(shù)據(jù)的地址也是固定的职员,這會導(dǎo)致我們的數(shù)據(jù)非常容易被破解,為了解決這個問題撼唾,蘋果在iOS4.3開始引入了ASLR技術(shù).
ASLR的概念:(Address Space Layout Randomization ) 地址空間配置隨機(jī)加載,是一種針對緩沖區(qū)溢出的安全保護(hù)技術(shù)哥蔚,通過對堆倒谷、棧、共享庫映射等線性區(qū)布局的隨機(jī)化糙箍,通過增加攻擊者預(yù)測目的地址的難度渤愁,防止攻擊者直接定位攻擊代碼位置,達(dá)到阻止溢出攻擊的目的的一種技術(shù).
其目的是通過利用隨機(jī)方式配置數(shù)據(jù)地址空間深夯,使某些敏感數(shù)據(jù)(例如APP登錄注冊抖格、支付相關(guān)代碼)配置到一個惡意程序無法事先獲知的地址,令攻擊者難以進(jì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ù))判斷二進(jìn)制文件的格式
其中PE质蕉、ELF势篡、Mach-O這三種可執(zhí)行文件格式都是COFF(Command file format)格式的變種,COFF的主要貢獻(xiàn)是目標(biāo)文件里面引入了“段”的機(jī)制模暗,不同的目標(biāo)文件可以擁有不同數(shù)量和不同類型的“段”
③. 通用二進(jìn)制文件
因為不同CPU平臺支持的指令不同禁悠,比如arm64
和x86
,蘋果中的通用二進(jìn)制格式就是將多種架構(gòu)的Mach-O文件打包在一起
兑宇,然后系統(tǒng)根據(jù)自己的CPU平臺碍侦,選擇合適的Mach-O,所以通用二進(jìn)制格式
也被稱為胖二進(jìn)制格式
隶糕,如下圖所示
通用二進(jìn)制格式的定義在<mach-o/fat.h>
中祝钢,可以在下載xnu,然后根據(jù) xnu -> EXTERNAL_HEADERS ->mach-o
中找到該文件.
通用二進(jìn)制文件開始的Fat Header
是fat_header
結(jié)構(gòu)體若厚,而Fat Archs
是表示通用二進(jìn)制文件中有多少個Mach-O拦英,單個Mach-O的描述是通過fat_arch
結(jié)構(gòu)體.兩個結(jié)構(gòu)體的定義如下:
所以,綜上所述:
- 通用二進(jìn)制文件是蘋果公司提出的一種新的二進(jìn)制文件的存儲結(jié)構(gòu)测秸,可以同時存儲多種架構(gòu)的二進(jìn)制指令疤估,使CPU在讀取該二進(jìn)制文件時可以自動檢測并選用合適的架構(gòu)灾常,以最理想的方式進(jìn)行讀取
- 由于通用二進(jìn)制文件會同時存儲多種架構(gòu),所以比單一架構(gòu)的二進(jìn)制文件大很多铃拇,會占用大量的磁盤空間钞瀑,但由于系統(tǒng)會自動選擇最合適的,不相關(guān)的架構(gòu)代碼不會占用內(nèi)存空間慷荔,且執(zhí)行效率高了
- 還可以通過指令來進(jìn)行Mach-O的合并與拆分
- 查看當(dāng)前Mach-O的架構(gòu):
lipo -info MachO
文件 - 合并:
lipo -create MachO1 MachO2 -output
輸出文件路徑 - 拆分:
lipo MachO文件 –thin 架構(gòu) –output
輸出文件路徑
- 查看當(dāng)前Mach-O的架構(gòu):
④. Mach-O文件
Mach-O
文件是Mach Object
文件格式的縮寫雕什,它是用于可執(zhí)行文件、動態(tài)庫显晶、目標(biāo)代碼的文件格式.作為a.out格式的替代贷岸,Mach-O格式提供了更強(qiáng)的擴(kuò)展性,以及更快的符號表信息訪問速度
熟悉Mach-O文件格式磷雇,有助于更好的理解蘋果底層的運(yùn)行機(jī)制偿警,更好的掌握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)庫螟蒸,只能在運(yùn)行時使用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 /* 目標(biāo)文件*/
#define MH_EXECUTE 0x2 /* 可執(zhí)行文件*/
#define MH_DYLIB 0x6 /* 動態(tài)庫*/
#define MH_DYLINKER 0x7 /* 動態(tài)鏈接器*/
#define MH_DSYM 0xa /* 存儲二進(jìn)制文件符號信息,用于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啟動
進(jìn)程如果能直接訪問物理內(nèi)存無疑是很不安全的,所以操作系統(tǒng)在物理內(nèi)存之上又建立了一層虛擬內(nèi)存.蘋果在這個基礎(chǔ)上還有 ASLR(Address Space Layout Randomization) 技術(shù)的保護(hù)(前面概念有介紹).
iOS系統(tǒng)中虛擬內(nèi)存到物理內(nèi)存的映射都是以頁為最小單位的.當(dāng)進(jìn)程訪問一個虛擬內(nèi)存Page而對應(yīng)的物理內(nèi)存卻不存在時足删,就會出現(xiàn)Page Fault缺頁中斷僻焚,然后加載這一頁.雖然本身這個處理速度是很快的,但是在一個App的啟動過程中可能出現(xiàn)上千(甚至更多)次Page Fault谬运,這個時間積累起來會比較明顯了.
iOS系統(tǒng)中一頁是16KB.
我們常說的啟動是指點(diǎn)擊App到第一頁顯示為止隙赁,包含pre-main、main到didFinishLaunchingWithOptions結(jié)束的整個時間.
另外梆暖,還有兩個重要的概念:冷啟動伞访、熱啟動.可能有些同學(xué)認(rèn)為殺掉再重啟App就是冷啟動了,其實是不對的.
冷啟動
程序完全退出轰驳,之間加載的分頁數(shù)據(jù)被其他進(jìn)程所使用覆蓋之后厚掷,或者重啟設(shè)備、第一次安裝级解,才算是冷啟動.熱啟動
程序殺掉之后冒黑,馬上又重新啟動.這個時候相應(yīng)的物理內(nèi)存中仍然保留之前加載過的分頁數(shù)據(jù),可以進(jìn)行重用勤哗,不需要全部重新加載.所以熱啟動的速度比較快.
而我們這里所說的啟動優(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 的過程就是從用戶點(diǎn)擊App圖標(biāo)到用戶能看到app主界面的過程吆你,即需要啟動優(yōu)化的部分
①. pre-main階段的優(yōu)化
pre-main階段的啟動時間其實就是dyld加載過程的時間
針對main函數(shù)之前的啟動時間,蘋果提供了內(nèi)建的測量方法俊犯,在 Edit Scheme -> Run -> Arguments ->Environment Variables
點(diǎn)擊+添加環(huán)境變量 DYLD_PRINT_STATISTICS
設(shè)為 1
)妇多,然后運(yùn)行,以下是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)庫贬循,或者考慮合并它們.
- 建議的目標(biāo)是六個額外的(非系統(tǒng))框架.
-
rebase/binding time(偏移修正/符號綁定耗時):耗時133.43ms
- 修正調(diào)整鏡像內(nèi)的指針(重新調(diào)整)和設(shè)置指向鏡像外符號的指針(綁定).為了加快重新定位/綁定時間咸包,我們需要更少的指針修復(fù).
-
rebase(偏移修正):任何一個app生成的二進(jìn)制文件,在二進(jìn)制文件內(nèi)部所有的方法杖虾、函數(shù)調(diào)用烂瘫,都有一個地址,這個地址是在當(dāng)前二進(jìn)制文件中的偏移地址.一旦在運(yùn)行時刻(即運(yùn)行到內(nèi)存中),每次系統(tǒng)都會隨機(jī)分配一個ASLR(Address Space Layout Randomization坟比,地址空間布局隨機(jī)化)地址值(是一個安全機(jī)制芦鳍,會分配一個隨機(jī)的數(shù)值,插入在二進(jìn)制文件的開頭)葛账,例如:二進(jìn)制文件中有一個
test
方法柠衅,偏移值是0x0001
,而隨機(jī)分配的ASLR
是0x1f00
籍琳,如果想訪問test
方法菲宴,其內(nèi)存地址(即真實地址)變?yōu)?ASLR+偏移值 = 運(yùn)行時確定的內(nèi)存地址(即0x1f00
+0x0001
=0x1f01
) -
binding(綁定):例如
NSLog
方法,在編譯時期生成的mach-o
文件中趋急,會創(chuàng)建一個符號喝峦!NSLog
(目前指向一個隨機(jī)的地址),然后在運(yùn)行時(從磁盤加載到內(nèi)存中呜达,是一個鏡像文件)谣蠢,會將真正的地址給符號(即在內(nèi)存中將地址與符號進(jìn)行綁定,是dyld
做的查近,也稱為動態(tài)庫符號綁定)眉踱,一句話概括:綁定就是給符號賦值的過程
-
ObjC setup time(OC類注冊的耗時):OC類越多,越耗時
- Objective-C運(yùn)行時需要進(jìn)行設(shè)置類嗦嗡、類別和選擇器注冊.我們對重新定位綁定時間所做的任何改進(jìn)也將優(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ù)的耗時)
- 運(yùn)行初始化程序.如果使用了Objective-C的 +load 方法侥祭,請將其替換為 +initialize 方法.
②. main函數(shù)階段的優(yōu)化
在main函數(shù)之后的 didFinishLaunching
方法中,主要是執(zhí)行了各種業(yè)務(wù)茄厘,有很多并不是必須在這里立即執(zhí)行的矮冬,這種業(yè)務(wù)我們可以采取延遲加載,防止影響啟動時間.
在 didFinishLaunching
中的業(yè)務(wù)主要分為三個類型
- 【第一類】初始化第三方sdk
- 【第二類】app運(yùn)行環(huán)境配置
- 【第三類】自己工具類的初始化等
main函數(shù)階段的優(yōu)化建議主要有以下幾點(diǎn):
- 減少啟動初始化的流程次哈,能懶加載的懶加載胎署,能延遲的延遲,能放后臺初始化的放后臺窑滞,盡量不要占用主線程的啟動時間
- 優(yōu)化代碼邏輯琼牧,去除非必須的代碼邏輯,減少每個流程的消耗時間
- 啟動階段能使用多線程來初始化的哀卫,就使用多線程
- 盡量使用純代碼來進(jìn)行UI框架的搭建巨坊,尤其是主UI框架,例如
UITabBarController
.盡量避免使用Xib
或者SB
此改,相比純代碼而言趾撵,這種更耗時 - 刪除廢棄類、方法
三共啃、二進(jìn)制重排 —— 主要是針對如何減少Page Fault的優(yōu)化
前面大致介紹了一些基本概念以及啟動優(yōu)化的思路占调,下面來著重介紹一個pre-main階段的優(yōu)化方案暂题,即二進(jìn)制重排
①. 二進(jìn)制重排原理
在虛擬內(nèi)存部分,我們知道究珊,當(dāng)進(jìn)程訪問一個虛擬內(nèi)存page薪者,而對應(yīng)的物理內(nèi)存不存在時,會觸發(fā)缺頁中斷(Page Fault)剿涮,因此阻塞進(jìn)程.此時就需要先加載數(shù)據(jù)到物理內(nèi)存啸胧,然后再繼續(xù)訪問.這個對性能是有一定影響的.
基于Page Fault
,我們思考幔虏,App
在冷啟動過程中纺念,會有大量的類、分類想括、三方等需要加載和執(zhí)行陷谱,此時產(chǎn)生的Page Fault
所帶來的耗時是很大的.以WeChat
為例,我們來看下瑟蜈,在啟動階段的Page Fault
的次數(shù)
-
CMD+i
快捷鍵烟逊,選擇System Trace
-
點(diǎn)擊啟動(啟動前需要重啟手機(jī),清除緩存數(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`:![](https://upload-images.jianshu.io/upload_images/2340353-4dc3261150c3766c.jpg?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240)
* 沿路徑找到并打開① - 啟動優(yōu)化Demo-LinkMap-normal-arm64.txt文件:![](https://upload-images.jianshu.io/upload_images/2340353-8c6a7b7a7e2be50d.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240)
- 函數(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 **. 這就是二進(jìn)制重排的 核心原理,如下所示
注意:在iOS生產(chǎn)環(huán)境的app柒竞,在發(fā)生Page Fault進(jìn)行重新加載時政供,iOS系統(tǒng)還會對其做一次簽名驗證,因此 iOS 生產(chǎn)環(huán)境的 Page Fault 比Debug環(huán)境下所產(chǎn)生的耗時更多
②. 二進(jìn)制重排實踐
下面,我們來進(jìn)行具體的實踐布隔,首先理解幾個名詞
②.1 Link Map
Link Map
是iOS編譯過程的中間產(chǎn)物离陶,記錄了二進(jìn)制文件的布局,需要在Xcode的Build Settings
里開啟Write Link Map File
,Link Map
主要包含三部分:
-
Object Files
生成二進(jìn)制用到的link單元的路徑和文件編號 -
Sections
記錄Mach-O每個Segment/section的地址范圍 -
Symbols
按順序記錄每個符號的地址范圍
②.2 ld
ld
是Xcode使用的鏈接器衅檀,有一個參數(shù)order_file
招刨,我們可以通過在Build Settings -> Order File
配置一個后綴為order的文件路徑.在這個order文件中,將所需要的符號按照順序?qū)懺诶锩姘Ь陧椖烤幾g時沉眶,會按照這個文件的順序進(jìn)行加載,以此來達(dá)到我們的優(yōu)化
所以二進(jìn)制重排的本質(zhì)就是對啟動加載的符號進(jìn)行重新排列
到目前為止杉适,原理我們基本弄清楚了谎倔,如果項目比較小,完全可以自定義一個order文件猿推,將方法的順序手動添加片习,但是如果項目較大,涉及的方法特別多蹬叭,此時我們?nèi)绾潍@取啟動運(yùn)行的函數(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 二進(jìn)制重排初體驗
二進(jìn)制重排坯沪,關(guān)鍵是order文件
- 前面講
objc
源碼時,會在工程中看到order文件:
- 打開
.order文件
擒滑,可以看到內(nèi)部都是排序好的函數(shù)符號
- 這是因為蘋果自己的庫腐晾,也都進(jìn)行了二進(jìn)制重排
新進(jìn)一個Demo (② - 二進(jìn)制重排初體驗) 玩玩
我們打開創(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ù)库车,按照默認(rèn)順序接在`order`函數(shù)后面
- 那么問題來了.靠手寫一個個函數(shù)寫進(jìn)
order
文件中.代碼寫了那么多巨柒,還有些代碼不是我寫的,我怎么知道哪個函數(shù)先,哪個函數(shù)后呢洋满?晶乔?- 我們要做到的目標(biāo): 拿到
啟動完成
后的某個時刻
,之前
的所有被調(diào)用
的函數(shù)
.勞煩你們自己
排隊進(jìn)入我的order
文件中(Clang插樁來實現(xiàn))
- 我們要做到的目標(biāo): 拿到
②.4 Clang插樁
要真正的實現(xiàn)二進(jìn)制重排牺勾,我們需要拿到啟動的所有方法正罢、函數(shù)等符號,并保存其順序驻民,然后寫入order
文件翻具,實現(xiàn)二進(jìn)制重排.
抖音有一篇文章抖音研發(fā)實踐:基于二進(jìn)制文件重排的解決方案 APP啟動速度提升超15%,但是文章中也提到了瓶頸:
基于靜態(tài)掃描+運(yùn)行時trace的方案仍然存在少量瓶頸:
- initialize hook不到
- 部分block hook不到
- C++通過寄存器的間接函數(shù)調(diào)用靜態(tài)掃描不出來
目前的重排方案能夠覆蓋到80%~90%的符號回还,未來我們會嘗試編譯期插樁等方案來進(jìn)行100%的符號覆蓋裆泳,讓重排達(dá)到最優(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 自帶代碼覆蓋工具 文檔中有詳細(xì)概述憎夷,以及簡短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ù).
運(yùn)行后,我們可以看到讀取內(nèi)存之后额衙,我們可以看到一個類似計數(shù)器的東西.最后一個打印的是結(jié)束位置饮焦,按顯示是4位4位的怕吴,所以向前移動4位,打印出來的應(yīng)該就是最后一位.
解釋兩個參數(shù):
- 參數(shù)1
start
是一個指針县踢,指向無符號int
類型械哟,4個字節(jié),相當(dāng)于一個數(shù)組的起始位置殿雪,即符號的起始位置(是從高位往低位讀) - 參數(shù)2
stop
暇咆,由于數(shù)據(jù)的地址是往下讀的(即從高往低讀,所以此時獲取的地址并不是stop真正的地址丙曙,而是標(biāo)記的最后的地址爸业,讀取stop時,由于stop占4個字節(jié)亏镰,stop真實地址 = stop打印的地址-0x4
) -
start
和stop
表示當(dāng)前文件的開始內(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
方法,運(yùn)行:
根據(jù)小端模式大刊,0f 00 00 00
對應(yīng)的是00 00 00 0f
即15.
我們在增加一個函數(shù)test()
:運(yùn)行:
根據(jù)小端模式,10 00 00 00
對應(yīng)的是00 00 00 10
即16.
我們在增加一個block
:運(yùn)行:
根據(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
運(yùn)行項目清空打印區(qū):當(dāng)我們再點(diǎn)擊一下屏幕的時候:我們在touchBegin
伴郁、test
、block
和__sanitizer_cov_trace_pc_guard
都加入斷點(diǎn)蛋叼,運(yùn)行代碼:
【驗證一】執(zhí)行順序是:touchesBegan
-> __sanitizer_cov_trace_pc_guard
->
test
-> __sanitizer_cov_trace_pc_guard
->
block
-> __sanitizer_cov_trace_pc_guard
【驗證二】touchesBegan
時焊傅,進(jìn)入?yún)R編:
如果我們查看其他函數(shù)也會發(fā)現(xiàn)匯編代碼中有類似的顯示.那么每個函數(shù)在觸發(fā)時,都調(diào)用了__sanitizer_cov_trace_pc_guard
函數(shù).
即:只要在Other C Flags
處加入標(biāo)記鸦列,開啟了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
//定義原子隊列: 特點(diǎn) 1.先進(jìn)后出 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)用同规,用于捕捉符號,是在多線程進(jìn)行的窟社,這個方法中只存儲pc券勺,以鏈表的形式
- guard 是一個哨兵,告訴我們是第幾個被調(diào)用的
*/
void __sanitizer_cov_trace_pc_guard(uint32_t *guard) {
// if (!*guard) return; // Duplicate the guard check. //將load方法過濾掉了灿里,所以需要注釋掉
//獲取PC
/*
- PC 當(dāng)前函數(shù)返回上一個調(diào)用的地址
- 0 當(dāng)前這個函數(shù)地址关炼,即當(dāng)前函數(shù)的返回地址
- 1 當(dāng)前函數(shù)調(diào)用者的地址,即上一個函數(shù)的返回地址
*/
void *PC = __builtin_return_address(0);
//創(chuàng)建結(jié)構(gòu)體!
SymbolNode * node = malloc(sizeof(SymbolNode));
*node = (SymbolNode){PC, NULL};
//加入隊列
//符號的訪問不是通過下標(biāo)訪問匣吊,是通過鏈表的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
運(yùn)行后這里我們可以看到很多打印社痛,只取一條來說明,很明顯其中sname就是我們需要的符號名了.
下面我們通過點(diǎn)擊屏幕導(dǎo)出所需要的符號命雀,需要注意的是C函數(shù)和Swift方法前面需要加下劃線.(這一點(diǎn)可以在前面提到的LinkMap文件中確認(rèn))
- (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é)點(diǎn)的pc,賦值給info
dladdr(node->pc, &info);
// 釋放節(jié)點(diǎn)
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];
}
}
// 刪掉當(dāng)前方法赊抖,因為這個點(diǎn)擊方法不是啟動需要的
[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);
}
這時如果你直接點(diǎn)擊屏幕统倒,有個巨坑,會看到控制臺一直在輸出氛雪,出現(xiàn)了死循環(huán):發(fā)現(xiàn) __sanitizer_cov_trace_pc_guard居然有10個房匆,這個地方會觸發(fā) __sanitizer_cov_trace_pc_guard中的入隊,這里又進(jìn)行出隊报亩,最后就死循環(huán)了.
解決辦法:
Build Settings中Other C Flags添加func
配置浴鸿,即-fsanitize-coverage=func,trace-pc-guard
.
官網(wǎng)對func的參數(shù)的解釋:只檢測每個函數(shù)的入口.
再次運(yùn)行點(diǎn)擊屏幕就不會有問題了.
注意點(diǎn):
- if(!guard) return;需要去掉,它會影響+load的寫入*
- while循環(huán)弦追,也會觸發(fā)__sanitizer_cov_trace_pc_guard(trace的觸發(fā)岳链,并不是根據(jù)函數(shù)來進(jìn)行hook的,而是hook了每一個跳轉(zhuǎn)(bl).while也有跳轉(zhuǎn)劲件,所以進(jìn)入了死循環(huán))
從真機(jī)上獲取order文件
我們把order
文件存在了真機(jī)上的tmp
文件夾中掸哑,要怎么拿到呢约急?
在Window→Devices And Simulators(快捷鍵?+?+2)中:
Swift二進(jìn)制重排
Swift也可以重排么?當(dāng)然可以苗分!
Swift 二進(jìn)制重排
厌蔽,與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)用一下:
補(bǔ)充:
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文件后,小伙伴們可以去自己的項目試試哦.
寫在后面
通過二進(jìn)制重排攒磨,讓啟動需要的方法排列更緊湊泳桦,減少了Page Fault的次數(shù).
獲取符號表時,采用Clang插樁可以直接hook到Objective-C方法娩缰、Swift方法灸撰、C函數(shù)、Block拼坎,可以不用區(qū)別對待.相比于抖音之前提出的方案確實簡單很多浮毯,門檻也要低一些.