寫在前面
啟動是App給用戶的第一印象,對用戶體驗至關重要.試想一個App需要啟動5s以上,你還想用它么?
最初的工程肯定是沒有這些問題的,但隨著業務需求不斷豐富,代碼越來越多.如果放任不管的話,啟動時間會不斷上漲,最后讓人無法接受.
本文從優化原理出發,介紹了我是如何通過Clang插樁找到啟動所需符號,然后修改編譯參數完成二進制文件的重新排布提升應用的啟動速度的.
一、基本概念(知識儲備)
①. 虛擬內存 & 物理內存
早期的數據訪問是直接通過物理地址訪問的,以這種方式訪問會存在以下兩個問題:
- 內存不夠用
- 內存數據的安全問題
①.1 內存不夠用的解決方案:虛擬內存
針對問題1,我們在進程和物理內存之間增加一個中間層,這個中間層就是所謂的虛擬內存,主要用于解決當多個進程同時存在時,對物理內存的管理.提高了CPU的利用率,使多個進程可以同時、按需加載.所以虛擬內存其本質就是一張虛擬地址和物理地址對應關系的映射表
每個進程都有一個獨立的虛擬內存,其地址都是從0開始,大小是4G固定的,每個虛擬內存又會劃分為一個一個的頁(頁的大小在iOS中是16KB,其他的是4KB),每次加載都是以頁為單位加載的,進程間是無法互相訪問的,保證了進程間數據的安全性.
一個進程中,只有部分功能是活躍的,所以只需要將進程中活躍的部分放入物理內存,避免物理內存的浪費
當CPU需要訪問數據時,首先是訪問虛擬內存,然后通過虛擬內存去尋址,即可以理解為在表中找對應的物理地址,然后對相應的物理地址進行訪問
如果在訪問時,虛擬地址的內容未加載到物理內存,會發生缺頁異常(pagefault),將當前進程阻塞掉,此時需要先將數據載入到物理內存,然后再尋址,進行讀取.這樣就避免了內存浪費
如下圖所示,虛擬內存與物理內存間的關系
①.2 內存數據的安全問題:ASLR技術
在上面解釋的虛擬內存中,我們提到了虛擬內存的起始地址與大小都是固定的,這意味著,當我們訪問時,其數據的地址也是固定的,這會導致我們的數據非常容易被破解,為了解決這個問題,蘋果在iOS4.3開始引入了ASLR技術.
ASLR的概念:(Address Space Layout Randomization ) 地址空間配置隨機加載,是一種針對緩沖區溢出的安全保護技術,通過對堆、棧、共享庫映射等線性區布局的隨機化,通過增加攻擊者預測目的地址的難度,防止攻擊者直接定位攻擊代碼位置,達到阻止溢出攻擊的目的的一種技術.
其目的是通過利用隨機方式配置數據地址空間,使某些敏感數據(例如APP登錄注冊、支付相關代碼)配置到一個惡意程序無法事先獲知的地址,令攻擊者難以進行攻擊.
由于ASLR的存在,導致可執行文件和動態鏈接庫在虛擬內存中的加載地址每次啟動都不固定,所以需要在編譯時來修復鏡像中的資源指針,來指向正確的地址。即正確的內存地址 = ASLR地址 + 偏移值
②. 可執行文件
不同的操作系統,其可執行文件的格式也不同.系統內核將可執行文件讀取到內存,然后根據可執行文件的頭簽名(magic
魔數)判斷二進制文件的格式
其中PE、ELF、Mach-O這三種可執行文件格式都是COFF(Command file format)格式的變種,COFF的主要貢獻是目標文件里面引入了“段”的機制,不同的目標文件可以擁有不同數量和不同類型的“段”
③. 通用二進制文件
因為不同CPU平臺支持的指令不同,比如arm64
和x86
,蘋果中的通用二進制格式就是將多種架構的Mach-O文件打包在一起
,然后系統根據自己的CPU平臺,選擇合適的Mach-O,所以通用二進制格式
也被稱為胖二進制格式
,如下圖所示
通用二進制格式的定義在<mach-o/fat.h>
中,可以在下載xnu,然后根據 xnu -> EXTERNAL_HEADERS ->mach-o
中找到該文件.
通用二進制文件開始的Fat Header
是fat_header
結構體,而Fat Archs
是表示通用二進制文件中有多少個Mach-O,單個Mach-O的描述是通過fat_arch
結構體.兩個結構體的定義如下:
所以,綜上所述:
- 通用二進制文件是蘋果公司提出的一種新的二進制文件的存儲結構,可以同時存儲多種架構的二進制指令,使CPU在讀取該二進制文件時可以自動檢測并選用合適的架構,以最理想的方式進行讀取
- 由于通用二進制文件會同時存儲多種架構,所以比單一架構的二進制文件大很多,會占用大量的磁盤空間,但由于系統會自動選擇最合適的,不相關的架構代碼不會占用內存空間,且執行效率高了
- 還可以通過指令來進行Mach-O的合并與拆分
- 查看當前Mach-O的架構:
lipo -info MachO
文件 - 合并:
lipo -create MachO1 MachO2 -output
輸出文件路徑 - 拆分:
lipo MachO文件 –thin 架構 –output
輸出文件路徑
- 查看當前Mach-O的架構:
④. Mach-O文件
Mach-O
文件是Mach Object
文件格式的縮寫,它是用于可執行文件、動態庫、目標代碼的文件格式.作為a.out格式的替代,Mach-O格式提供了更強的擴展性,以及更快的符號表信息訪問速度
熟悉Mach-O文件格式,有助于更好的理解蘋果底層的運行機制,更好的掌握dyld加載Mach-O的步驟
④.1 Mach-O文件
如果想要查看具體的Mach-O文件信息,可以使用MachOView軟件查看:將Mach-O可執行文件拖動到MachOView
工具打開
④.2 Mach-O文件格式
對于OS X 和iOS來說,Mach-O是其可執行文件的格式,主要包括以下幾種文件類型
-
Executable
:可執行文件 -
Dylib
:動態鏈接庫 -
Bundle
:無法被鏈接的動態庫,只能在運行時使用dlopen加載 -
Image
:指的是Executable、Dylib和Bundle的一種 -
Framework
:包含Dylib、資源文件和頭文件的集合
以上是Mach-O文件的格式,一個完成的Mach-O
文件主要分為三大部分:
-
Header Mach-O頭部
:主要是Mach-O的cpu架構,文件類型以及加載命令等信息 -
Load Commands 加載命令
:描述了文件中數據的具體組織結構,不同的數據類型使用不同的加載命令表示 -
Data 數據
:數據中的每個段(segment)的數據都保存在這里,段的概念與ELF文件中段的概念類似.每個段都有一個或多個部分,它們放置了具體的數據與代碼,主要包含代碼,數據,例如符號表,動態符號表等等
Header
Mach-O的Header
包含了整個Mach-O文件的關鍵信息
,使得CPU能快速知道Mac-O的基本信息,其在MachO.h
文件中針對32
位和64
位架構的cpu,分別使用了mach_header
和mach_header_64
結構體來描述Mach-O頭部
.mach_header
是連接器加載時最先讀取的內容,決定了一些基礎架構、系統類型、指令條數等信息,這里查看64位架構的mach_header_64
結構體定義,相比于32
位架構的mach_header
,只是多了一個reserved
保留字段
其中filetype
主要記錄Mach-O的文件類型,常用的有以下幾種
#define MH_OBJECT 0x1 /* 目標文件*/
#define MH_EXECUTE 0x2 /* 可執行文件*/
#define MH_DYLIB 0x6 /* 動態庫*/
#define MH_DYLINKER 0x7 /* 動態鏈接器*/
#define MH_DSYM 0xa /* 存儲二進制文件符號信息,用于debug分析*/
相對應的,Header在MachOView
中的展示如下
Load Commands
在Mach-O文件中,Load Commands
主要是用于加載指令
,其大小和數目在Header中已經被提供,其在MachO.h
中的定義如下
我們在MachOView
中查看Load Commands
,其中記錄了很多信息,例如動態鏈接器的位置、程序的入口、依賴庫的信息、代碼的位置、符號表的位置
等等,如下所示
Data
Load Commands后就是Data
區域,這個區域存儲了具體的只讀、可讀寫代碼
,例如方法、符號表、字符表、代碼數據、連接器所需的數據(重定向、符號綁定等)。主要是存儲具體的數據。其中大多數的Mach-O文件均包含以下三個段:
-
__TEXT 代碼段
:只讀,包括函數,和只讀的字符串 -
__DATA 數據段
:讀寫,包括可讀寫的全局變量等 -
__LINKEDIT
: __LINKEDIT包含了方法和變量的元數據(位置,偏移量),以及代碼簽名等信息.
在Data
區中,Section
占了很大的比例,Section
在MachO.h
中是以結構體section_64
(在arm64架構下)表示,其定義如下
二、App啟動
進程如果能直接訪問物理內存無疑是很不安全的,所以操作系統在物理內存之上又建立了一層虛擬內存.蘋果在這個基礎上還有 ASLR(Address Space Layout Randomization) 技術的保護(前面概念有介紹).
iOS系統中虛擬內存到物理內存的映射都是以頁為最小單位的.當進程訪問一個虛擬內存Page而對應的物理內存卻不存在時,就會出現Page Fault缺頁中斷,然后加載這一頁.雖然本身這個處理速度是很快的,但是在一個App的啟動過程中可能出現上千(甚至更多)次Page Fault,這個時間積累起來會比較明顯了.
iOS系統中一頁是16KB.
我們常說的啟動是指點擊App到第一頁顯示為止,包含pre-main、main到didFinishLaunchingWithOptions結束的整個時間.
另外,還有兩個重要的概念:冷啟動、熱啟動.可能有些同學認為殺掉再重啟App就是冷啟動了,其實是不對的.
冷啟動
程序完全退出,之間加載的分頁數據被其他進程所使用覆蓋之后,或者重啟設備、第一次安裝,才算是冷啟動.熱啟動
程序殺掉之后,馬上又重新啟動.這個時候相應的物理內存中仍然保留之前加載過的分頁數據,可以進行重用,不需要全部重新加載.所以熱啟動的速度比較快.
而我們這里所說的啟動優化,一般是指冷啟動情況下的,這種情況下的啟動主要分為兩部分:
- T1 :pre-main階段,即main函數之前,操作系統加載App可執行文件到內存,執行一系列的加載&鏈接等工作,簡單來說,就是dyld加載過程
- T2:main函數之后,即從main函數開始,到 Appdelegate 的didFinishLaunching方法執行完成為止,主要是構建第一個界面,并完成渲染
所以,T1+T2 的過程就是從用戶點擊App圖標到用戶能看到app主界面的過程,即需要啟動優化的部分
①. pre-main階段的優化
pre-main階段的啟動時間其實就是dyld加載過程的時間
針對main函數之前的啟動時間,蘋果提供了內建的測量方法,在 Edit Scheme -> Run -> Arguments ->Environment Variables
點擊+添加環境變量 DYLD_PRINT_STATISTICS
設為 1
),然后運行,以下是iPhone6sp正常啟動的pre-main時間(以WeChat為例)
說明
pre-main階段總共用時1.1s
-
dylib loading time(動態庫耗時):主要是加載動態庫,用時297.53ms
- 動態加載程序查找并讀取應用程序使用的依賴動態庫.每個庫本身都可能有依賴項.雖然蘋果系統框架的加載是高度優化的,但加載嵌入式框架可能會很耗時.為了加快動態庫的加載速度,蘋果建議您使用更少的動態庫,或者考慮合并它們.
- 建議的目標是六個額外的(非系統)框架.
-
rebase/binding time(偏移修正/符號綁定耗時):耗時133.43ms
- 修正調整鏡像內的指針(重新調整)和設置指向鏡像外符號的指針(綁定).為了加快重新定位/綁定時間,我們需要更少的指針修復.
-
rebase(偏移修正):任何一個app生成的二進制文件,在二進制文件內部所有的方法、函數調用,都有一個地址,這個地址是在當前二進制文件中的偏移地址.一旦在運行時刻(即運行到內存中),每次系統都會隨機分配一個ASLR(Address Space Layout Randomization,地址空間布局隨機化)地址值(是一個安全機制,會分配一個隨機的數值,插入在二進制文件的開頭),例如:二進制文件中有一個
test
方法,偏移值是0x0001
,而隨機分配的ASLR
是0x1f00
,如果想訪問test
方法,其內存地址(即真實地址)變為 ASLR+偏移值 = 運行時確定的內存地址(即0x1f00
+0x0001
=0x1f01
) -
binding(綁定):例如
NSLog
方法,在編譯時期生成的mach-o
文件中,會創建一個符號!NSLog
(目前指向一個隨機的地址),然后在運行時(從磁盤加載到內存中,是一個鏡像文件),會將真正的地址給符號(即在內存中將地址與符號進行綁定,是dyld
做的,也稱為動態庫符號綁定),一句話概括:綁定就是給符號賦值的過程
-
ObjC setup time(OC類注冊的耗時):OC類越多,越耗時
- Objective-C運行時需要進行設置類、類別和選擇器注冊.我們對重新定位綁定時間所做的任何改進也將優化這個設置時間
- 如果有大量(大的是20000)Objective-C類、選擇器和類別的應用程序可以增加800ms的啟動時間.
- 如果應用程序使用C++代碼,那么使用更少的虛擬函數.
- 使用Swift結構體通常也更快
-
initializer time(執行load和構造函數的耗時)
- 運行初始化程序.如果使用了Objective-C的 +load 方法,請將其替換為 +initialize 方法.
②. main函數階段的優化
在main函數之后的 didFinishLaunching
方法中,主要是執行了各種業務,有很多并不是必須在這里立即執行的,這種業務我們可以采取延遲加載,防止影響啟動時間.
在 didFinishLaunching
中的業務主要分為三個類型
- 【第一類】初始化第三方sdk
- 【第二類】app運行環境配置
- 【第三類】自己工具類的初始化等
main函數階段的優化建議主要有以下幾點:
- 減少啟動初始化的流程,能懶加載的懶加載,能延遲的延遲,能放后臺初始化的放后臺,盡量不要占用主線程的啟動時間
- 優化代碼邏輯,去除非必須的代碼邏輯,減少每個流程的消耗時間
- 啟動階段能使用多線程來初始化的,就使用多線程
- 盡量使用純代碼來進行UI框架的搭建,尤其是主UI框架,例如
UITabBarController
.盡量避免使用Xib
或者SB
,相比純代碼而言,這種更耗時 - 刪除廢棄類、方法
三、二進制重排 —— 主要是針對如何減少Page Fault的優化
前面大致介紹了一些基本概念以及啟動優化的思路,下面來著重介紹一個pre-main階段的優化方案,即二進制重排
①. 二進制重排原理
在虛擬內存部分,我們知道,當進程訪問一個虛擬內存page,而對應的物理內存不存在時,會觸發缺頁中斷(Page Fault),因此阻塞進程.此時就需要先加載數據到物理內存,然后再繼續訪問.這個對性能是有一定影響的.
基于Page Fault
,我們思考,App
在冷啟動過程中,會有大量的類、分類、三方等需要加載和執行,此時產生的Page Fault
所帶來的耗時是很大的.以WeChat
為例,我們來看下,在啟動階段的Page Fault
的次數
-
CMD+i
快捷鍵,選擇System Trace
-
點擊啟動(啟動前需要重啟手機,清除緩存數據),第一個界面出來后,停掉,按照下圖中操作
從圖中可以看出WeChat
發生的PageFault
有2900+次,可想而知,這個是非常影響性能的.
- 然后我們再通過
Demo
查看方法在編譯時期的排列順序,在ViewController中按下列順序定義以下幾個方法
- 在
Build Settings -> Write Link Map File
設置為YES
-
CMD+B
編譯Demo
,然后在對應的路徑下查找link map
文件.右鍵Show In Finder
打開包文件夾:
* 在包文件的上兩層級,找到 `Intermediates.noindex`:
* 沿路徑找到并打開① - 啟動優化Demo-LinkMap-normal-arm64.txt文件:
- 函數順序(書寫順序),如下所示,可以發現 類中函數的加載順序是從上到下的,而
文件
的順序是根據Build Phases -> Compile Sources
中的順序加載的
總結
從上面的Page Fault
的次數以及加載順序,可以發現其實導致 Page Fault 次數過多的根本原因是啟動時刻需要調用的方法,處于不同的Page導致的.因此,我們的優化思路就是:**將所有啟動時刻需要調用的方法,排列在一起,即放在一個頁中,這樣就從多個 Page Fault 變成了一個 Page Fault **. 這就是二進制重排的 核心原理,如下所示
注意:在iOS生產環境的app,在發生Page Fault進行重新加載時,iOS系統還會對其做一次簽名驗證,因此 iOS 生產環境的 Page Fault 比Debug環境下所產生的耗時更多
②. 二進制重排實踐
下面,我們來進行具體的實踐,首先理解幾個名詞
②.1 Link Map
Link Map
是iOS編譯過程的中間產物,記錄了二進制文件的布局,需要在Xcode的Build Settings
里開啟Write Link Map File
,Link Map
主要包含三部分:
-
Object Files
生成二進制用到的link單元的路徑和文件編號 -
Sections
記錄Mach-O每個Segment/section的地址范圍 -
Symbols
按順序記錄每個符號的地址范圍
②.2 ld
ld
是Xcode使用的鏈接器,有一個參數order_file
,我們可以通過在Build Settings -> Order File
配置一個后綴為order的文件路徑.在這個order文件中,將所需要的符號按照順序寫在里面,在項目編譯時,會按照這個文件的順序進行加載,以此來達到我們的優化
所以二進制重排的本質就是對啟動加載的符號進行重新排列
到目前為止,原理我們基本弄清楚了,如果項目比較小,完全可以自定義一個order文件,將方法的順序手動添加,但是如果項目較大,涉及的方法特別多,此時我們如何獲取啟動運行的函數呢?有以下幾種思路:
-
hook objc_msgSend
:我們知道,函數的本質是發送消息,在底層都會來到objc_msgSend
,但是由于objc_msgSend
的參數是可變的,需要通過匯編獲取,對開發人員要求較高.而且也只能拿到OC
和swift
中@objc
后的方法 -
靜態掃描:掃描
Mach-O
特定段和節里面所存儲的符號以及函數數據 -
Clang插樁:即批量hook,可以實現100%符號覆蓋,即完全獲取
swift、OC、C、block
函數
②.3 二進制重排初體驗
二進制重排,關鍵是order文件
- 前面講
objc
源碼時,會在工程中看到order文件:
- 打開
.order文件
,可以看到內部都是排序好的函數符號
- 這是因為蘋果自己的庫,也都進行了二進制重排
新進一個Demo (② - 二進制重排初體驗) 玩玩
我們打開創建的Demo項目,我想把排序改成load->test1->test2->ViewDidAppear->main
-
在Demo項目根目錄創建一個
tcj.order
文件touch tcj.order
- 在
tcj.order
文件中手動順序寫入函數(還寫了個不存在的hello函數)
- 在
Build Settings
中搜索order file
,加入./tcj.order
-
Command + B
編譯后,再次去查看link map
文件:
* 發現`order`文件中`不存在的函數`(hello),編譯器會直接跳過
* 其他`函數符號`,完全按照我們`order`順序排列
* `order`中沒有的函數,按照默認順序接在`order`函數后面
- 那么問題來了.靠手寫一個個函數寫進
order
文件中.代碼寫了那么多,還有些代碼不是我寫的,我怎么知道哪個函數先,哪個函數后呢??- 我們要做到的目標: 拿到
啟動完成
后的某個時刻
,之前
的所有被調用
的函數
.勞煩你們自己
排隊進入我的order
文件中(Clang插樁來實現)
- 我們要做到的目標: 拿到
②.4 Clang插樁
要真正的實現二進制重排,我們需要拿到啟動的所有方法、函數等符號,并保存其順序,然后寫入order
文件,實現二進制重排.
抖音有一篇文章抖音研發實踐:基于二進制文件重排的解決方案 APP啟動速度提升超15%,但是文章中也提到了瓶頸:
基于靜態掃描+運行時trace的方案仍然存在少量瓶頸:
- initialize hook不到
- 部分block hook不到
- C++通過寄存器的間接函數調用靜態掃描不出來
目前的重排方案能夠覆蓋到80%~90%的符號,未來我們會嘗試編譯期插樁等方案來進行100%的符號覆蓋,讓重排達到最優效果。
同時也給出了解決方案編譯期插樁.
在說clang插樁之前,我們來說說什么是hook?
hook
是鉤子. -- 獲取原有函數符號
的內存地址
和實現
,勾住
它,做一些自己想做的事情
- 例如: 你遇到在公路上攔到一輛車.你可以跟他的車一起走(附加自己代碼),也可以直接搶了他的車自己開(重寫實現).
很明顯,我們此刻就是想勾住啟動結束前的所有函數
,附加一些代碼,把函數名
按順序存下來
,生成我們的order
文件
Q: 有沒有API,能讓我hook一切我想hook的東西?swift、oc、c函數我都要hook?
A: 有,clang插樁. 語法樹都是它生成的,順序它說了算.
Clang插樁
llvm
內置了一個簡單的代碼覆蓋率檢測(SanitizerCoverage
).它在函數級、基本塊級和邊緣級插入對用戶定義函數的調用.我們這里的批量hook,就需要借助于SanitizerCoverage
.
關于 clang
的插樁覆蓋的官方文檔如下 : clang 自帶代碼覆蓋工具 文檔中有詳細概述,以及簡短Demo
演示
我們創建TraceDemo
項目,按照官方給的示例,來嘗試開發
添加trace
-
按照官方描述,可以加入跟蹤代碼,并給出了回調函數.
打開我們的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
查看官網會需要我們添加兩個函數:
#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()
還會報錯,不重要先注釋了然后繼續.
函數 __sanitizer_cov_trace_pc_guard_init 統計了方法的個數.
運行后,我們可以看到讀取內存之后,我們可以看到一個類似計數器的東西.最后一個打印的是結束位置,按顯示是4位4位的,所以向前移動4位,打印出來的應該就是最后一位.
解釋兩個參數:
- 參數1
start
是一個指針,指向無符號int
類型,4個字節,相當于一個數組的起始位置,即符號的起始位置(是從高位往低位讀) - 參數2
stop
,由于數據的地址是往下讀的(即從高往低讀,所以此時獲取的地址并不是stop真正的地址,而是標記的最后的地址,讀取stop時,由于stop占4個字節,stop真實地址 = stop打印的地址-0x4
) -
start
和stop
表示當前文件的開始內存地址和結束內存地址。單位是int32 4字節 - 如果多加幾個函數,會發現stop地址值也會相應的增加。
- 此處是指從start到stop的前閉后開區間。[ , ),所以stop地址往前偏移4字節,才是最后一個函數符號的地址
根據小端模式,0e 00 00 00
對應的是00 00 00 0e
即14.
那么stop
內存地址中存儲的值表示什么?在增加一個方法/塊/c++/屬性的方法(多幾個),發現其值也會增加對應的數.
例如先在ViewController.m
增加一個touchesBegan
方法,運行:
根據小端模式,0f 00 00 00
對應的是00 00 00 0f
即15.
我們在增加一個函數test()
:運行:
根據小端模式,10 00 00 00
對應的是00 00 00 10
即16.
我們在增加一個block
:運行:
根據小端模式,11 00 00 00
對應的是00 00 00 11
即17.
到此時可以看到一共增加了3(block是匿名函數),計數器統計了函數/方法/塊的個數,這里添加了三個,索引增加了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
運行項目清空打印區:當我們再點擊一下屏幕的時候:我們在touchBegin
、test
、block
和__sanitizer_cov_trace_pc_guard
都加入斷點,運行代碼:
【驗證一】執行順序是:touchesBegan
-> __sanitizer_cov_trace_pc_guard
->
test
-> __sanitizer_cov_trace_pc_guard
->
block
-> __sanitizer_cov_trace_pc_guard
【驗證二】touchesBegan
時,進入匯編:
如果我們查看其他函數也會發現匯編代碼中有類似的顯示.那么每個函數在觸發時,都調用了__sanitizer_cov_trace_pc_guard
函數.
即:只要在Other C Flags
處加入標記,開啟了trace功能.LLVM
會在每個函數邊緣(開始位置),插入一行調用__sanitizer_cov_trace_pc_guard
的代碼.編譯期就插入了.所以可以100%覆蓋.(也就是說Clang插樁就是在匯編代碼中插入了 __sanitizer_cov_trace_pc_guard
函數的調用)
解釋一下__sanitizer_cov_trace_pc_guard
方法:主要是捕獲所有的啟動時刻的符號,將所有符號入隊.
拿到了全部的符號之后需要保存,但是不能用數組,因為有可能會有在子線程執行的,所以用數組會有線程問題 .這里我們使用原子隊列:
#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.只能保存結構體
static OSQueueHead symbolList = OS_ATOMIC_QUEUE_INIT;
//定義符號結構體鏈表
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個字節)。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方法、函數、以及block調用,用于捕捉符號,是在多線程進行的,這個方法中只存儲pc,以鏈表的形式
- guard 是一個哨兵,告訴我們是第幾個被調用的
*/
void __sanitizer_cov_trace_pc_guard(uint32_t *guard) {
// if (!*guard) return; // Duplicate the guard check. //將load方法過濾掉了,所以需要注釋掉
//獲取PC
/*
- PC 當前函數返回上一個調用的地址
- 0 當前這個函數地址,即當前函數的返回地址
- 1 當前函數調用者的地址,即上一個函數的返回地址
*/
void *PC = __builtin_return_address(0);
//創建結構體!
SymbolNode * node = malloc(sizeof(SymbolNode));
*node = (SymbolNode){PC, NULL};
//加入隊列
//符號的訪問不是通過下標訪問,是通過鏈表的next指針,所以需要借用offsetof(結構體類型,下一個的地址即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就是我們需要的符號名了.
下面我們通過點擊屏幕導出所需要的符號,需要注意的是C函數和Swift方法前面需要加下劃線.(這一點可以在前面提到的LinkMap文件中確認)
- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event{
NSMutableArray <NSString *>* symbolNames = [NSMutableArray array];
// 每次while循環,都會加入一次hook (__sanitizer_cov_trace_pc_guard) 只要是跳轉,就會被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};
// 取出節點的pc,賦值給info
dladdr(node->pc, &info);
// 釋放節點
free(node);
// 存名字
NSString * name = @(info.dli_sname);
BOOL isObjc = [name hasPrefix:@"+["] || [name hasPrefix:@"-["]; //OC方法不處理
NSString * symbolName = isObjc ? name : [@"_" stringByAppendingString:name]; //c函數、swift方法前面帶下劃線
[symbolNames addObject:symbolName];
printf("%s \n",info.dli_sname);
}
//取反(隊列的存儲是反序的)
NSEnumerator * emt = [symbolNames reverseObjectEnumerator];
//創建數組
NSMutableArray<NSString*>* funcs = [NSMutableArray arrayWithCapacity:symbolNames.count];
// 臨時變量
NSString * name;
// 遍歷集合,去重,添加到funcs中
while (name = [emt nextObject]) {
// 數組中去重添加
if (![funcs containsObject:name]) {
[funcs addObject:name];
}
}
// 刪掉當前方法,因為這個點擊方法不是啟動需要的
[funcs removeObject:[NSString stringWithFormat:@"%s",__FUNCTION__]];
// 文件路徑
NSString * filePath = [NSTemporaryDirectory() stringByAppendingPathComponent:@"tcj.order"];
// 數組轉字符串
NSString *funcStr = [funcs componentsJoinedByString:@"\n"];
// 文件內容
NSData * fileContents = [funcStr dataUsingEncoding:NSUTF8StringEncoding];
// 在路徑上創建文件
[[NSFileManager defaultManager] createFileAtPath:filePath contents:fileContents attributes:nil];
NSLog(@"%@",filePath);
}
這時如果你直接點擊屏幕,有個巨坑,會看到控制臺一直在輸出,出現了死循環:發現 __sanitizer_cov_trace_pc_guard居然有10個,這個地方會觸發 __sanitizer_cov_trace_pc_guard中的入隊,這里又進行出隊,最后就死循環了.
解決辦法:
Build Settings中Other C Flags添加func
配置,即-fsanitize-coverage=func,trace-pc-guard
.
官網對func的參數的解釋:只檢測每個函數的入口.
再次運行點擊屏幕就不會有問題了.
注意點:
- if(!guard) return;需要去掉,它會影響+load的寫入*
- while循環,也會觸發__sanitizer_cov_trace_pc_guard(trace的觸發,并不是根據函數來進行hook的,而是hook了每一個跳轉(bl).while也有跳轉,所以進入了死循環)
從真機上獲取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
方法中調用一下:
補充:
swift符號自帶名稱混淆
未改變代碼時,swift符號不會變
總之,order文件,請在代碼封版后,再生成
所有處理完之后,最后需要Write Link Map File改為NO
,把Other C Flags/Other Swift Flags的配置刪除掉
因為這個配置會在我們代碼中自動插入跳轉執行 __sanitizer_cov_trace_pc_guard
.重排完就不需要了,需要去除掉. 同時把ViewController
中的 __sanitizer_cov_trace_pc_guard
也要去除掉.
至此,Clang插樁和自動生成Order文件,都已完成.拿到order文件后,小伙伴們可以去自己的項目試試哦.
寫在后面
通過二進制重排,讓啟動需要的方法排列更緊湊,減少了Page Fault的次數.
獲取符號表時,采用Clang插樁可以直接hook到Objective-C方法、Swift方法、C函數、Block,可以不用區別對待.相比于抖音之前提出的方案確實簡單很多,門檻也要低一些.