注:本文為轉載拉鹃,原文地址在這里
前言
自從抖音團隊分享了這篇 抖音研發(fā)實踐:基于二進制文件重排的解決方案 APP啟動速度提升超15% 啟動優(yōu)化文章后 , 二進制重排優(yōu)化 pre-main 階段的啟動時間自此被大家廣為流傳 .
本篇文章首先講述下二進制重排的原理 , ( 因為抖音團隊在上述文章中原理部分大多是點到即止 , 多數(shù)朋友看完并沒有什么實際收獲 ) . 然后將結合 clang 插樁的方式 來實際講述和演練一下如何解決抖音團隊遺留下來的這一問題 :
hook Objc_msgSend 無法解決的 純swift , block , c++ 方法 .
來達到完美的二進制重排方案 .
( 本篇文章由于會從原理角度講解 , 有些已經(jīng)比較熟悉的同學可能會覺得節(jié)奏偏啰嗦 , 為了照顧大部分同學 , 大家自行根據(jù)目錄跳過即可 . )
了解二進制重排之前 , 我們需要了解一些前導知識 , 以及二進制重排是為了解決什么問題 .
虛擬內(nèi)存與物理內(nèi)存
在本篇文章里 , 筆者就不通過教科書或者大多數(shù)資料的方式來講述這個概念了 . 我們通過實際問題和其對應的解決方式來看這個技術 or 概念 .
在計算機領域 , 任何一個技術 or 概念 , 都是為了解決實際的問題而誕生的 .
在早期的計算機中 , 并沒有虛擬內(nèi)存的概念 , 任何應用被從磁盤中加載到運行內(nèi)存中時 , 都是完整加載和按序排列的 .
那么因此 , 就會出現(xiàn)兩個問題 :
使用物理內(nèi)存時遺留的問題
- 安全問題 : 由于在內(nèi)存條中使用的都是真實物理地址 , 而且內(nèi)存條中各個應用進程都是按順序依次排列的 . 那么在 進程1 中通過地址偏移就可以訪問到 其他進程 的內(nèi)存 .
- 效率問題 : 隨著軟件的發(fā)展 , 一個軟件運行時需要占用的內(nèi)存越來越多 , 但往往用戶并不會用到這個應用的所有功能 , 造成很大的內(nèi)存浪費 , 而后面打開的進程往往需要排隊等待 .
為了解決上述兩個問題 , 虛擬內(nèi)存應運而生 .
虛擬內(nèi)存工作原理
引用了虛擬內(nèi)存后 , 在我們進程中認為自己有一大片連續(xù)的內(nèi)存空間實際上是虛擬的 , 也就是說從 0x000000 ~ 0xffffff 我們是都可以訪問的 . 但是實際上這個內(nèi)存地址只是一個虛擬地址 , 而這個虛擬地址通過一張映射表映射后才可以獲取到真實的物理地址 .
什么意思呢 ?
- 實際上我們可以理解為 , 系統(tǒng)對真實物理內(nèi)存訪問做了一層限制 , 只有被寫到映射表中的地址才是被認可可以訪問的 .
- 例如 , 虛擬地址 0x000000 ~ 0xffffff 這個范圍內(nèi)的任意地址我們都可以訪問 , 但是這個虛擬地址對應的實際物理地址是計算機來隨機分配到內(nèi)存頁上的 .
這里提到了實際物理內(nèi)存分頁的概念 , 下面會詳細講述 .
(可能大家也有注意到 , 我們在一個工程中獲取的地址 , 同時在另一個工程中去訪問 , 并不能訪問到數(shù)據(jù) , 其原理就是虛擬內(nèi)存 .)
整個虛擬內(nèi)存的工作原理這里用一張圖來展示 :
虛擬內(nèi)存解決進程間安全問題原理
顯然 , 引用虛擬內(nèi)存后就不存在通過偏移可以訪問到其他進程的地址空間的問題了 .
因為每個進程的映射表是單獨的 , 在你的進程中隨便你怎么訪問 , 這些地址都是受映射表限制的 , 其真實物理地址永遠在規(guī)定范圍內(nèi) , 也就不存在通過偏移獲取到其他進程的內(nèi)存空間的問題了 .
而且實際上 , 每次應用被加載到內(nèi)存中 , 實際分配的物理內(nèi)存并不一定是固定或者連續(xù)的 , 這是因為內(nèi)存分頁以及懶加載以及 ASLR 所解決的安全問題 .
cpu 尋址過程
引入虛擬內(nèi)存后 , cpu 在通過虛擬內(nèi)存地址訪問數(shù)據(jù)的過程如下 :
- 通過虛擬內(nèi)存地址 , 找到對應進程的映射表 .
- 通過映射表找到其對應的真實物理地址 , 進而找到數(shù)據(jù) .
這個過程被稱為地址翻譯, 這個過程是由操作系統(tǒng)以及 cpu 上集成的一個硬件單元 MMU協(xié)同來完成的 .
那么安全問題解決了以后 , 效率問題如何解決呢 ?
虛擬內(nèi)存解決效率問題
剛剛提到虛擬內(nèi)存和物理內(nèi)存通過映射表進行映射 , 但是這個映射并不可能是一一對應的 , 那樣就太過浪費內(nèi)存了 . 為了解決效率問題 , 實際上真實物理內(nèi)存是分頁的 . 而映射表同樣是以頁為單位的 .
換句話說 , 映射表只會映射到一頁 , 并不會映射到具體每一個地址 .
在linux系統(tǒng)中 , 一頁內(nèi)存大小為4KB, 在不同平臺可能各有不同 .
Mac OS 系統(tǒng)中 , 一頁為4KB,
iOS 系統(tǒng)中 , 一頁為16KB.
我們可以使用pagesize命令直接查看 .
那么為什么說內(nèi)存分頁就可以解決內(nèi)存浪費的效率問題呢 ?
內(nèi)存分頁原理
假設當前有兩個進程正在運行 , 其狀態(tài)就如下圖所示 :
( 上圖中我們也看出 , 實際物理內(nèi)存并不是連續(xù)以及某個進程完整的 ) .
映射表左側的0和1代表當前地址有沒有在物理內(nèi)存中 . 為什么這么說呢 ?
- 當應用被加載到內(nèi)存中時 , 并不會將整個應用加載到內(nèi)存中 . 只會放用到的那一部分 . 也就是懶加載的概念 , 換句話說就是應用使用多少 , 實際物理內(nèi)存就實際存儲多少 .
- 當應用訪問到某個地址 , 映射表中為0, 也就是說并沒有被加載到物理內(nèi)存中時 , 系統(tǒng)就會立刻阻塞整個進程 , 觸發(fā)一個我們所熟知的 缺頁中斷 - Page Fault.
- 當一個缺頁中斷被觸發(fā) , 操作系統(tǒng)會從磁盤中重新讀取這頁數(shù)據(jù)到物理內(nèi)存上 , 然后將映射表中虛擬內(nèi)存指向對應 ( 如果當前內(nèi)存已滿 , 操作系統(tǒng)會通過置換頁算法 找一頁數(shù)據(jù)進行覆蓋 , 這也是為什么開再多的應用也不會崩掉 , 但是之前開的應用再打開時 , 就重新啟動了的根本原因 ).
通過這種分頁和覆蓋機制 , 就完美的解決了內(nèi)存浪費和效率問題 .
但是此時 , 又出現(xiàn)了一個問題 .
問 : 當應用開發(fā)完成以后由于采用了虛擬內(nèi)存 , 那么其中一個函數(shù)無論如何運行 , 運行多少次 , 都會是虛擬內(nèi)存中的固定地址 .
什么意思呢 ?
假設應用有一個函數(shù) , 基于首地址偏移量為0x00a000, 那么虛擬地址從0x000000 ~ 0xffffff, 基于這個 , 那么這個函數(shù)我無論如何只需要通過0x00a000這個虛擬地址就可以拿到其真實實現(xiàn)地址 .
而這種機制就給了很多黑客可操作性的空間 , 他們可以很輕易的提前寫好程序獲取固定函數(shù)的實現(xiàn)進行修改hook操作 .
為了解決這個問題 , ASLR 應運而生 . 其原理就是 每次 虛擬地址在映射真實地址之前 , 增加一個隨機偏移值 , 以此來解決我們剛剛所提到的這個問題 .
( Android 4.0 , Apple iOS4.3 , OS X Mountain Lion10.8 開始全民引入ASLR技術 , 而實際上自從引入ASLR后 , 黑客的門檻也自此被拉高 . 不再是人人都可做黑客的年代了 ) .
至此 , 有關物理內(nèi)存 , 虛擬內(nèi)存 , 內(nèi)存分頁的完整流程和原理 , 我們已經(jīng)講述完畢了 , 那么接下來來到重點 , 二進制重排 .
二進制重排
概述
在了解了內(nèi)存分頁會觸發(fā)中斷異常Page Fault會阻塞進程后 , 我們就知道了這個問題是會對性能產(chǎn)生影響的 .
實際上在 iOS 系統(tǒng)中 , 對于生產(chǎn)環(huán)境的應用 , 當產(chǎn)生缺頁中斷進行重新加載時 , iOS 系統(tǒng)還會對其做一次簽名驗證 . 因此 iOS 生產(chǎn)環(huán)境的應用page fault所產(chǎn)生的耗時要更多 .
抖音團隊分享的一個 Page Fault箫荡,開銷在0.6 ~ 0.8ms, 實際測試發(fā)現(xiàn)不同頁會有所不同 , 也跟 cpu 負荷狀態(tài)有關 , 在0.1 ~ 1.0 ms之間 梳庆。
當用戶使用應用時 , 第一個直接印象就是啟動 app 耗時 , 而恰巧由于啟動時期有大量的類 , 分類 , 三方 等等需要加載和執(zhí)行 , 多個page fault所產(chǎn)生的的耗時往往是不能小覷的 . 這也是二進制重排進行啟動優(yōu)化的必要性 .
二進制重排優(yōu)化原理
假設在啟動時期我們需要調(diào)用兩個函數(shù)method1與method4. 函數(shù)編譯在mach-o中的位置是根據(jù)ld( Xcode 的鏈接器) 的編譯順序并非調(diào)用順序來的 . 因此很可能這兩個函數(shù)分布在不同的內(nèi)存頁上 .
那么啟動時 , page1與page2則都需要從無到有加載到物理內(nèi)存中 , 從而觸發(fā)兩次page fault.
而二進制重排的做法就是將method1與method4放到一個內(nèi)存頁中 , 那么啟動時則只需要加載page1即可 , 也就是只觸發(fā)一次page fault, 達到優(yōu)化目的 .
實際項目中的做法是將啟動時需要調(diào)用的函數(shù)放到一起 ( 比如 前10頁中 ) 以盡可能減少 page fault , 達到優(yōu)化目的 . 而這個做法就叫做 : 二進制重排 .
講到這里相信很多同學已經(jīng)迫不及待的想要看看具體怎么二進制重排了 . 其實操作很簡單 , 但是在操作之前我們還需要知道這幾點 :
- 如何檢測page fault: 首先我們要想看到優(yōu)化效果 , 就應該知道如何查看page fault, 以此來幫助我們查看優(yōu)化前以及優(yōu)化后的效果 .
- 如何重排二進制 .
- 如何查看自己重排成功了沒有 ?
- 如何檢測自己啟動時刻需要調(diào)用的所有方法 .
1.hook objc_MsgSend ( 只能拿到oc以及swift加上@objc dynamic修飾后的方法 ) .
2.靜態(tài)掃描macho特定段和節(jié)里面所存儲的符號以及函數(shù)數(shù)據(jù) . (靜態(tài)掃描 , 主要用來獲取load方法 , c++構造(有關c++構造 , 參考 從頭梳理dyld加載流程 這篇文章有詳細講述和演示 ) .
3.clang插樁 ( 完美版本 , 完全拿到swift,oc,c,block全部函數(shù) )
內(nèi)容很多 , 我們一項一項來 .
如何查看page fault
提示 :
如果想查看真實page fault次數(shù) , 應該將應用卸載 , 查看第一次應用安裝后的效果 , 或者先打開很多個其他應用 .
因為之前運行過 app , 應用其中一部分已經(jīng)被加載到物理內(nèi)存并做好映射表映射 , 這時再啟動就會少觸發(fā)一部分缺頁中斷 , 并且殺掉應用再打開也是如此 .
其實就是希望將物理內(nèi)存中之前加載的覆蓋/清理掉 , 減少誤差 .
-
1?? : 打開 Instruments , 選擇 System Trace .
-
2?? : 選擇真機 , 選擇工程 , 點擊啟動 , 當首個頁面加載出來點擊停止 . (這里注意 , 最好是將應用殺掉重新安裝 , 因為冷熱啟動的界定其實由于進程的原因并不一定后臺殺掉應用重新打開就是冷啟動 .)
-
3?? : 等待分析完成 , 查看缺頁次數(shù)
后臺殺掉重啟應用
當然 , 你可以通過添加DYLD_PRINT_STATISTICS來查看pre-main階段總耗時來做一個側面輔證 .
大家可以分別測試以下幾種情況 , 來深度理解冷啟動 or 熱啟動以及物理內(nèi)存分頁覆蓋的實際情況 .
- 應用第一次安裝啟動
- 應用后臺沒有打開時啟動
- 殺掉后臺后重新啟動
- 不殺掉后臺重新啟動
- 殺掉后臺后多打開一些其他應用再次啟動
二進制重排具體如何操作
說了這么多前導知識 , 終于要開始做二進制重排了 , 其實具體操作很簡單 , Xcode 已經(jīng)提供好這個機制 , 并且libobjc實際上也是用了二進制重排進行優(yōu)化 .
- 首先 , Xcode 是用的鏈接器叫做ld ,ld有一個參數(shù)叫Order File , 我們可以通過這個參數(shù)配置一個order文件的路徑 .
- 在這個order文件中 , 將你需要的符號按順序寫在里面 .
- 當工程 build 的時候 , Xcode 會讀取這個文件 , 打的二進制包就會按照這個文件中的符號順序進行生成對應的mach-O.
二進制重排疑問 - 題外話 :
- : order 文件里 符號寫錯了或者這個符號不存在會不會有問題 ?
- 答 : ld 會忽略這些符號 , 實際上如果提供了 link 選項 -order_file_statistics,會以 warning 的形式把這些沒找到的符號打印在日志里卑惜。 .
- : 有部分同學可能會考慮這種方式會不會影響上架 ?
- 答 : 首先 , objc 源碼自己也在用這種方式 .
- 二進制重排只是重新排列了所生成的 macho 中函數(shù)表與符號表的順序 .
如何查看自己工程的符號順序
重排前后我們需要查看自己的符號順序有沒有修改成功 , 這時候就用到了Link Map.
Link Map是編譯期間產(chǎn)生的產(chǎn)物 , (ld的讀取二進制文件順序默認是按照Compile Sources - GUI 里的順序 ) , 它記錄了二進制文件的布局 . 通過設置Write Link Map File來設置輸出與否 , 默認是no.
上述文件中最左側地址就是實際代碼地址而并非符號地址 , 因此我們二進制重排并非只是修改符號地址 , 而是利用符號順序 , 重新排列整個代碼在文件的偏移地址 , 將啟動需要加載的方法地址放到前面內(nèi)存頁中 , 以此達到減少 page fault 的次數(shù)從而實現(xiàn)時間上的優(yōu)化, 一定要清楚這一點 .
你可以利用MachOView查看排列前后在_text 段( 代碼段 ) 中的源碼順序來幫助理解 .
實戰(zhàn)演練
來到工程根目錄 , 新建一個文件touch lb.order. 隨便挑選幾個啟動時就需要加載的方法 , 例如我這里選了以下幾個 .
-[LBOCTools lbCurrentPresentingVC]
+[LBOCTools lbGetCurrentTimes]
+[RSAEncryptor stripPublicKeyHeader:]
寫到該文件中 , 保存 , 配置文件路徑 .
錯誤提示
有部分同學可能配置完運行會發(fā)現(xiàn)報錯說can't open這個order file. 是因為文件格式的問題 . 不用使用 mac 自帶的文本編輯 . 使用命令工具touch創(chuàng)建即可 .
獲取啟動加載所有函數(shù)的符號
講到這 , 我們就只差一個問題了 , 那就是如何知道我的項目啟動需要調(diào)用哪些方法 , 上述篇章中我們也有稍微提到一點 .
- hook objc_MsgSend( 只能拿到oc以及swift @objc dynamic后的方法 , 并且由于可變參數(shù)個數(shù) , 需要用匯編來獲取參數(shù) .)
- 靜態(tài)掃描macho特定段和節(jié)里面所存儲的符號以及函數(shù)數(shù)據(jù) . (靜態(tài)掃描 , 主要用來獲取load方法 , c++構造(有關c++構造 , 參考 從頭梳理dyld加載流程 這篇文章有詳細講述和演示 ) .
- clang插樁 ( 完美版本 , 完全拿到swift , oc , c , block全部函數(shù) ) .
前兩種這里我們就不在贅述了 . 網(wǎng)上參考資料也較多 , 而且實現(xiàn)效果也并不是完美狀態(tài) , 本文我們來談談如何通過編譯期插樁的方式來hook獲取所有的函數(shù)符號 .
clang 插樁
關于clang的插樁覆蓋的官方文檔如下 :clang自帶代碼覆蓋工具 文檔中有詳細概述 , 以及簡短 Demo 演示 .
思考
其實clang插樁主要有兩個實現(xiàn)思路 , 一是自己編寫clang插件 ( 自定義clang插件在后續(xù)底層篇llvm中會帶著大家來手寫一個自己的插件 ) , 另外一個就是利用clang本身已經(jīng)提供的一個工具 or 機制來實現(xiàn)我們獲取所有符號的需求 . 本文我們就按照第二種思路來實際演練一下 .
原理探索
新建一個工程來測試和使用一下這個靜態(tài)插樁代碼覆蓋工具的機制和原理 . ( 不想看這個過程的自行跳到靜態(tài)插樁原理總結章節(jié) )
按照文檔指示來走 .
- 首先 , 添加編譯設置 .
直接搜索Other C Flags來到Apple Clang - Custom Compiler Flags中 , 添加
-fsanitize-coverage=trace-pc-guard
- 添加 hook 代碼 .
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);
}
筆者這里是寫在空工程的ViewController.m里的.
運行工程 , 查看打印
- (void)testOCFunc{
}
再次運行查看 .
那么我們再添加一個c函數(shù) , 一個block, 和一個觸摸屏幕方法來看下 .
其次 , 我們在觸摸屏幕方法調(diào)用了c函數(shù) ,c函數(shù)中調(diào)用了block. 那么我們點擊屏幕 , 發(fā)現(xiàn)如下 :
實際上就類似我們埋點統(tǒng)計所實現(xiàn)的效果 . 在觸摸方法添加一個斷點查看匯編 :
__sanitizer_cov_trace_pc_guard
這個函數(shù)中來 .而實際上這也是靜態(tài)插樁的原理和名稱由來 .
靜態(tài)插樁總結
靜態(tài)插樁實際上是在編譯期就在每一個函數(shù)內(nèi)部二進制源數(shù)據(jù)添加hook代碼 ( 我們添加的
__sanitizer_cov_trace_pc_guard
函數(shù) ) 來實現(xiàn)全局的方法hook的效果 .
疑問
可能有部分同學對我上述表述的原理總結有些疑問 .
究竟是直接修改二進制在每個函數(shù)內(nèi)部都添加了調(diào)用hook函數(shù)這個匯編代碼 , 還是只是類似于編譯器在所生成的二進制文件添加了一個標記 , 然后在運行時如果有這個標記就會自動多做一步調(diào)用hook 代碼呢 ?
筆者這里使用 hopper 來看下生成的mach-o二進制文件 .
上述二進制源文件我們就發(fā)現(xiàn) , 的確是函數(shù)內(nèi)部 一開始就添加了 調(diào)用額外方法的匯編代碼 . 這也是我們?yōu)槭裁捶Q其為 " 靜態(tài)插樁 " .
講到這里 , 原理我們大體上了解了 , 那么到底如何才能拿到函數(shù)的符號呢 ?
獲取所有函數(shù)符號
先理一下思路 .
思路
我們現(xiàn)在知道了 , 所有函數(shù)內(nèi)部第一步都會去調(diào)用 __sanitizer_cov_trace_pc_guard
這個函數(shù) . 那么熟悉匯編的同學可能就有這么個想法 :
函數(shù)嵌套時 , 在跳轉子函數(shù)時都會保存下一條指令的地址在 X30 ( 又叫 lr 寄存器) 里 .
例如 , A函數(shù)中調(diào)用了B函數(shù) , 在arm匯編中即(bl + 0x****) 指令 , 該指令會首先將下一條匯編指令的地址保存在x30寄存器中 ,
然后在跳轉到bl后面?zhèn)鬟f的指定地址去執(zhí)行 . ( 提示 : bl 能實現(xiàn)跳轉到某個地址的匯編指令 , 其原理就是修改pc寄存器的值來指向到要跳轉的地址 , 而且實際上B函數(shù)中也會對x29 / x30 寄存器的值做保護防止子函數(shù)又跳轉其他函數(shù)會覆蓋掉x30的值 , 當然 , 葉子函數(shù)除外 . ) .
當B函數(shù)執(zhí)行ret也就是返回指令時 , 就會去讀取x30寄存器的地址 , 跳轉過去 , 因此也就回到了上一層函數(shù)的下一步 .
這種思路來實現(xiàn)實際上是可以的 . 我們所寫的 __sanitizer_cov_trace_pc_guard
函數(shù)中的這一句代碼 :
void *PC = __builtin_return_address(0);
它的作用其實就是去讀取x30中所存儲的要返回時下一條指令的地址 . 所以他名稱叫做 __builtin_return_address
. 換句話說 , 這個地址就是我當前這個函數(shù)執(zhí)行完畢后 , 要返回到哪里去 .
其實 , bt 函數(shù)調(diào)用棧也是這種思路來實現(xiàn)的 .
也就是說 , 我們現(xiàn)在可以在 __sanitizer_cov_trace_pc_guard
這個函數(shù)中 , 通過 __builtin_return_address
數(shù)拿到原函數(shù)調(diào)用 __sanitizer_cov_trace_pc_guard
這句匯編代碼的下一條指令的地址.
可能有點繞 , 畫個圖來梳理一下流程 .
拿到了函數(shù)內(nèi)部一行代碼的地址 , 如何獲取函數(shù)名稱呢 ? 這里筆者分享一下自己的思路 .
熟悉安全攻防 , 逆向的同學可能會清楚 . 我們?yōu)榱朔乐鼓承┨囟ǖ姆椒ū粍e人使用fishhook hook掉 , 會利用dlopen打開動態(tài)庫 , 拿到一個句柄 , 進而拿到函數(shù)的內(nèi)存地址直接調(diào)用 .
是不是跟我們這個流程有點相似 , 只是我們好像是反過來的 . 其實反過來也是可以的 .
與dlopen相同 , 在dlfcn.h中有一個方法如下 :
typedef struct dl_info {
const char *dli_fname; /* 所在文件 */
void *dli_fbase; /* 文件地址 */
const char *dli_sname; /* 符號名稱 */
void *dli_saddr; /* 函數(shù)起始地址 */
} Dl_info;
//這個函數(shù)能通過函數(shù)內(nèi)部地址找到函數(shù)符號
int dladdr(const void *, Dl_info *);
緊接著我們來實驗一下 , 先導入頭文件#import <dlfcn.h>
, 然后修改代碼如下 :
void __sanitizer_cov_trace_pc_guard(uint32_t *guard) {
if (!*guard) return; // Duplicate the guard check.
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);
char PcDescr[1024];
printf("guard: %p %x PC %s\n", guard, *guard, PcDescr);
}
查看打印結果 :終于看到我們要找的符號了 .
收集符號
看到這里 , 很多同學可能想的是 , 那馬上到工程里去拿到我所有的符號 , 寫到 order
文件里不就完事了嗎 ?
為什么呢 ??
clang靜態(tài)插樁 - 坑點1
→ : 多線程問題
這是一個多線程的問題 , 由于你的項目各個方法肯定有可能會在不同的函數(shù)執(zhí)行 , 因此
__sanitizer_cov_trace_pc_guard
這個函數(shù)也有可能受多線程影響 , 所以你當然不可能簡簡單單用一個數(shù)組來接收所有的符號就搞定了 .
那方法有很多 , 筆者在這里分享一下自己的做法 :
考慮到這個方法會來特別多次 , 使用鎖會影響性能 , 這里使用蘋果底層的原子隊列 ( 底層實際上是個棧結構 , 利用隊列結構 + 原子性來保證順序 ) 來實現(xiàn) .
- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event{
//遍歷出隊
while (true) {
//offsetof 就是針對某個結構體找到某個屬性相對這個結構體的偏移量
SymbolNode * node = OSAtomicDequeue(&symboList, offsetof(SymbolNode, next));
if (node == NULL) break;
Dl_info info;
dladdr(node->pc, &info);
printf("%s \n",info.dli_sname);
}
}
//原子隊列
static OSQueueHead symboList = OS_ATOMIC_QUEUE_INIT;
//定義符號結構體
typedef struct{
void * pc;
void * next;
}SymbolNode;
void __sanitizer_cov_trace_pc_guard(uint32_t *guard) {
if (!*guard) return; // Duplicate the guard check.
void *PC = __builtin_return_address(0);
SymbolNode * node = malloc(sizeof(SymbolNode));
*node = (SymbolNode){PC,NULL};
//入隊
// offsetof 用在這里是為了入隊添加下一個節(jié)點找到 前一個節(jié)點next指針的位置
OSAtomicEnqueue(&symboList, node, offsetof(SymbolNode, next));
}
當你興致沖沖開始考慮好多線程的解決方法寫完之后 , 運行發(fā)現(xiàn) :
死循環(huán)了 .
clang靜態(tài)插樁 - 坑點2
→ : 上述這種 clang 插樁的方式 , 會在循環(huán)中同樣插入 hook 代碼 .
當確定了我們隊列入隊和出隊都是沒問題的 , 你自己的寫法對應的保存和讀取也是沒問題的 , 我們發(fā)現(xiàn)了這個坑點 , 這個會死循環(huán) , 為什么呢 ?
這里我就不帶著大家去分析匯編了 , 直接說結論 :
通過匯編會查看到 一個帶有 while 循環(huán)的方法 , 會被靜態(tài)加入多次
__sanitizer_cov_trace_pc_guard
調(diào)用 , 導致死循環(huán).
→ : 解決方案
Other C Flags
修改為如下 :
-fsanitize-coverage=func,trace-pc-guard
代表進針對func進行hook. 再次運行 .
坑點3 : load 方法
→ : load 方法時 , __sanitizer_cov_trace_pc_guard
函數(shù)的參數(shù) guard 是 0.
上述打印并沒有發(fā)現(xiàn)load.
解決 : 屏蔽掉 __sanitizer_cov_trace_pc_guard
函數(shù)中的
if (!*guard) return;
這里也為我們提供了一點啟示:
如果我們希望從某個函數(shù)之后/之前開始優(yōu)化 , 通過一個全局靜態(tài)變量 , 在特定的時機修改其值 , 在__sanitizer_cov_trace_pc_guard
這個函數(shù)中做好對應的處理即可 .
剩余細化工作
- 如果你也是使用筆者這種多線程處理方式的話 , 由于用的先進后出原因 , 我們要倒敘一下
- 還需要做去重 .
- order文件格式要求c函數(shù) ,block調(diào)用前面還需要加 _ , 下劃線 .
- 寫入文件即可 .
筆者demo完整代碼如下 :
#import "ViewController.h"
#import <dlfcn.h>
#import <libkern/OSAtomic.h>
@interface ViewController ()
@end
@implementation ViewController
+ (void)load{
}
- (void)viewDidLoad {
[super viewDidLoad];
testCFunc();
[self testOCFunc];
}
- (void)testOCFunc{
NSLog(@"oc函數(shù)");
}
void testCFunc(){
LBBlock();
}
void(^LBBlock)(void) = ^(void){
NSLog(@"block");
};
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)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event{
NSMutableArray<NSString *> * symbolNames = [NSMutableArray array];
while (true) {
//offsetof 就是針對某個結構體找到某個屬性相對這個結構體的偏移量
SymbolNode * node = OSAtomicDequeue(&symboList, offsetof(SymbolNode, 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];
//去重
if (![symbolNames containsObject:symbolName]) {
[symbolNames addObject:symbolName];
}
}
//取反
NSArray * symbolAry = [[symbolNames reverseObjectEnumerator] allObjects];
NSLog(@"%@",symbolAry);
//將結果寫入到文件
NSString * funcString = [symbolAry componentsJoinedByString:@"\n"];
NSString * filePath = [NSTemporaryDirectory() stringByAppendingPathComponent:@"lb.order"];
NSData * fileContents = [funcString dataUsingEncoding:NSUTF8StringEncoding];
BOOL result = [[NSFileManager defaultManager] createFileAtPath:filePath contents:fileContents attributes:nil];
if (result) {
NSLog(@"%@",filePath);
}else{
NSLog(@"文件寫入出錯");
}
}
//原子隊列
static OSQueueHead symboList = OS_ATOMIC_QUEUE_INIT;
//定義符號結構體
typedef struct{
void * pc;
void * next;
}SymbolNode;
void __sanitizer_cov_trace_pc_guard(uint32_t *guard) {
//if (!*guard) return; // Duplicate the guard check.
void *PC = __builtin_return_address(0);
SymbolNode * node = malloc(sizeof(SymbolNode));
*node = (SymbolNode){PC,NULL};
//入隊
// offsetof 用在這里是為了入隊添加下一個節(jié)點找到 前一個節(jié)點next指針的位置
OSAtomicEnqueue(&symboList, node, offsetof(SymbolNode, next));
}
@end
搞定 , 小伙伴們就可以立馬去優(yōu)化自己的工程了 .
swift 工程 / 混編工程問題
通過如上方式適合純OC工程獲取符號方式 .
由于swift的編譯器前端是自己的swift編譯前端程序 , 因此配置稍有不同 .
搜索Other Swift Flags
, 添加兩條配置即可 :
-sanitize-coverage=func
-sanitize=undefined
swift 類通過上述方法同樣可以獲取符號 .
優(yōu)化后效果監(jiān)測
在完全第一次安裝冷啟動 , 保證同樣的環(huán)境 , page fault 采樣同樣截取到第一個可交互界面 , 使用重排優(yōu)化前后效果如下 .
-
優(yōu)化前
- 優(yōu)化后
總結
本篇文章通過以實際碳素過程為基準 , 一步一步實現(xiàn)clang靜態(tài)插樁達到二進制重排優(yōu)化啟動時間的完整流程 .
具體實現(xiàn)步驟如下 :
- 利用 clang 插樁獲得啟動時期需要加載的所有 函數(shù)/方法 , block , swift 方法以及 c++構造方法的符號 .
- 通過 order file 機制實現(xiàn)二進制重排 .