總結(jié)
本篇文章通過以實(shí)際探索過程為基準(zhǔn) , 一步一步實(shí)現(xiàn)clang靜態(tài)插樁達(dá)到二進(jìn)制重排優(yōu)化啟動(dòng)時(shí)間的完整流程 .
原理
- 利用編譯期
clang 靜態(tài)插樁的方式來 hook 獲取啟動(dòng)時(shí)期需要加載的所有函數(shù)/方法 ,block,swift 方法以及c++構(gòu)造方法的符號(hào) . - 通過
order file 機(jī)制實(shí)現(xiàn)二進(jìn)制重排 . - 二進(jìn)制重排利用符號(hào)順序 , 重新排列整個(gè)代碼在文件的偏移地址 ,
將啟動(dòng)需要加載的方法地址放到前面內(nèi)存頁(yè)中 , 以此達(dá)到減少 page fault 的次數(shù)從而實(shí)現(xiàn)時(shí)間上的優(yōu)化。
虛擬內(nèi)存和物理內(nèi)存的原理
利用虛擬內(nèi)存的分頁(yè)、懶加載、覆蓋機(jī)制以及ASLR技術(shù),解決內(nèi)存浪費(fèi)和效率以及安全問題。
- 引入虛擬內(nèi)存后 , cpu 在通過虛擬內(nèi)存地址訪問數(shù)據(jù)時(shí),
首先通過虛擬內(nèi)存地址 , 找到對(duì)應(yīng)進(jìn)程的映射表, 然后通過映射表找到其對(duì)應(yīng)的真實(shí)物理地址 , 進(jìn)而找到數(shù)據(jù)。
這個(gè)過程被稱為 地址翻譯 。 - 利用內(nèi)存分頁(yè)和覆蓋機(jī)制解決內(nèi)存浪費(fèi)和效率問題。
應(yīng)用是懶加載到內(nèi)存中的,并不會(huì)將整個(gè)應(yīng)用加載到內(nèi)存中 . 只會(huì)放用到的那一部分 。
當(dāng)應(yīng)用訪問到某個(gè)并沒有被加載到物理內(nèi)存中地址,(映射表中為 0)系統(tǒng)就會(huì)立刻阻塞整個(gè)進(jìn)程,觸發(fā)缺頁(yè)中斷 - Page Fault,
操作系統(tǒng)會(huì)從磁盤中重新讀取這頁(yè)數(shù)據(jù)到物理內(nèi)存上 , 然后將映射表中虛擬內(nèi)存指向?qū)?yīng)。
若當(dāng)前內(nèi)存已滿 , 操作系統(tǒng)會(huì)通過置換頁(yè)算法,找一頁(yè)數(shù)據(jù)進(jìn)行覆蓋。 - 利用ASLR技術(shù),每次 虛擬地址在映射真實(shí)地址之前 , 增加一個(gè)隨機(jī)偏移值。
二進(jìn)制重排原理
二進(jìn)制重排利用符號(hào)順序 , 重新排列整個(gè)代碼在文件的偏移地址 , 將啟動(dòng)需要加載的方法地址放到前面內(nèi)存頁(yè)中 , 以此達(dá)到減少 page fault 的次數(shù)從而實(shí)現(xiàn)時(shí)間上的優(yōu)化。
查看缺頁(yè)中斷( page fault)
通過Instruments, 選擇 System Trace(系統(tǒng)跟蹤) 可以查看缺頁(yè)中斷( page fault)配置Order File的文件路徑
通過配置Xcode的鏈接器ld的Order File的文件路徑,在Order File中,將需要的符號(hào)按順序?qū)懺诶锩妫?br> 當(dāng)工程 build 的時(shí)候 , Xcode 會(huì)讀取這個(gè)文件 , 打的二進(jìn)制包就會(huì)按照這個(gè)文件中的符號(hào)順序進(jìn)行生成對(duì)應(yīng)的 mach-O。查看工程的符號(hào)順序
通過Link Map查看工程的符號(hào)順序。
Link Map 是編譯期間產(chǎn)生的產(chǎn)物,記錄了二進(jìn)制文件的布局,通過設(shè)置 Write Link Map File 來設(shè)置輸出與否。獲取啟動(dòng)加載所有函數(shù)的符號(hào)
通過編譯期clang 靜態(tài)插樁的方式來 hook 獲取所有的函數(shù)符號(hào)。
Clang 靜態(tài)插樁原理
在編譯期就在每一個(gè)函數(shù)內(nèi)部二進(jìn)制源數(shù)據(jù)添加 hook 代碼 ( 我們添加的 __sanitizer_cov_trace_pc_guard 函數(shù) ) 來實(shí)現(xiàn)全局的方法 hook 的效果
- 編譯設(shè)置
在Other C Flags中添加-fsanitize-coverage=trace-pc-guard(trace-pc-guard:跟蹤pc保護(hù)) - 添加 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.
}
/**
在 __sanitizer_cov_trace_pc_guard 這個(gè)函數(shù)中 ,通過 __builtin_return_address 函數(shù)
拿到原函數(shù)調(diào)用 __sanitizer_cov_trace_pc_guard 這句匯編代碼的下一條指令的地址
*/
void __sanitizer_cov_trace_pc_guard(uint32_t *guard) {
if (!*guard) return; // Duplicate the guard check.
void *PC = __builtin_return_address(0); // 作用:讀取 x30 中所存儲(chǔ)的要返回時(shí)下一條指令的地址
Dl_info info;
dladdr(PC, &info); //這個(gè)函數(shù)能通過函數(shù)內(nèi)部地址找到函數(shù)符號(hào)
printf("fname所在文件=%s \nfbase文件地址=%p \nsname符號(hào)名稱=%s\nsaddr函數(shù)起始地址=%p \n",info.dli_fname,info.dli_fbase,info.dli_sname,info.dli_saddr);
char PcDescr[1024];
//__sanitizer_symbolize_pc(PC, "%p %F %L", PcDescr, sizeof(PcDescr));
printf("guard: %p %x PC %s\n", guard, *guard, PcDescr);
}
前言
了解二進(jìn)制重排之前 , 我們需要了解一些前導(dǎo)知識(shí) , 以及二進(jìn)制重排是為了解決什么問題 .
虛擬內(nèi)存與物理內(nèi)存
在早期的計(jì)算機(jī)中,并沒有虛擬內(nèi)存的概念,任何應(yīng)用被從磁盤中加載到運(yùn)行內(nèi)存中時(shí),都是完整加載和按序排列的。
[圖片上傳失敗...(image-462cdb-1672902867391)]
那么因此 , 就會(huì)出現(xiàn)兩個(gè)問題 :
使用物理內(nèi)存時(shí)遺留的問題
-
安全問題: 由于在內(nèi)存條中使用的都是真實(shí)物理地址 , 而且內(nèi)存條中各個(gè)應(yīng)用進(jìn)程都是按順序依次排列的。
那么在進(jìn)程1中通過地址偏移就可以訪問到其他進(jìn)程的內(nèi)存。 -
效率問題: 隨著軟件的發(fā)展 , 一個(gè)軟件運(yùn)行時(shí)需要占用的內(nèi)存越來越多 , 但往往用戶并不會(huì)用到這個(gè)應(yīng)用的所有功能,
造成很大的內(nèi)存浪費(fèi) , 而后面打開的進(jìn)程往往需要排隊(duì)等待。
為了解決上述兩個(gè)問題 , 虛擬內(nèi)存應(yīng)運(yùn)而生 .
虛擬內(nèi)存工作原理
引用了虛擬內(nèi)存后 , 在我們進(jìn)程中認(rèn)為自己有一大片連續(xù)的內(nèi)存空間實(shí)際上是虛擬的 , 也就是說從 0x000000 ~ 0xffffff 我們是都可以訪問的 .
但是實(shí)際上這個(gè)內(nèi)存地址只是一個(gè)虛擬地址 , 而這個(gè)虛擬地址通過一張映射表映射后才可以獲取到真實(shí)的物理地址 .
什么意思呢 ?
- 1、實(shí)際上我們可以理解為 , 系統(tǒng)對(duì)真實(shí)物理內(nèi)存訪問做了一層限制 , 只有被寫到映射表中的地址才是被認(rèn)可可以訪問的 .
- 2、例如 , 虛擬地址 0x000000 ~ 0xffffff 這個(gè)范圍內(nèi)的任意地址我們都可以訪問 ,
但是這個(gè)虛擬地址對(duì)應(yīng)的實(shí)際物理地址是計(jì)算機(jī)來隨機(jī)分配到內(nèi)存頁(yè)上的 .
這里提到了實(shí)際物理內(nèi)存分頁(yè)的概念 , 下面會(huì)詳細(xì)講述 .
(可能大家也有注意到 , 我們?cè)谝粋€(gè)工程中獲取的地址 , 同時(shí)在另一個(gè)工程中去訪問 , 并不能訪問到數(shù)據(jù) , 其原理就是虛擬內(nèi)存 .)
整個(gè)虛擬內(nèi)存的工作原理這里用一張圖來展示 :
[圖片上傳失敗...(image-dca342-1672902867391)]
虛擬內(nèi)存解決進(jìn)程間安全問題原理
顯然 , 引用虛擬內(nèi)存后就不存在通過偏移可以訪問到其他進(jìn)程的地址空間的問題了 .
因?yàn)槊總€(gè)進(jìn)程的映射表是單獨(dú)的 , 在你的進(jìn)程中隨便你怎么訪問 , 這些地址都是受映射表限制的 ,
其真實(shí)物理地址永遠(yuǎn)在規(guī)定范圍內(nèi) , 也就不存在通過偏移獲取到其他進(jìn)程的內(nèi)存空間的問題了 .
而且實(shí)際上 , 每次應(yīng)用被加載到內(nèi)存中 , 實(shí)際分配的物理內(nèi)存并不一定是固定或者連續(xù)的 ,
這是因?yàn)閮?nèi)存分頁(yè)以及懶加載以及 ASLR(地址空間布局隨機(jī)化) 所解決的安全問題 .
cpu 尋址過程
引入虛擬內(nèi)存后 , cpu 在通過虛擬內(nèi)存地址訪問數(shù)據(jù)的過程如下 :
- 1、通過虛擬內(nèi)存地址 , 找到對(duì)應(yīng)進(jìn)程的映射表 .
- 2、通過映射表找到其對(duì)應(yīng)的真實(shí)物理地址 , 進(jìn)而找到數(shù)據(jù) .
這個(gè)過程被稱為地址翻譯, 這個(gè)過程是由操作系統(tǒng)以及 cpu 上集成的一個(gè)硬件單元 MMU協(xié)同來完成的 .
那么安全問題解決了以后 , 效率問題如何解決呢 ?
虛擬內(nèi)存解決效率問題
剛剛提到虛擬內(nèi)存和物理內(nèi)存通過映射表進(jìn)行映射 , 但是這個(gè)映射并不可能是一一對(duì)應(yīng)的 , 那樣就太過浪費(fèi)內(nèi)存了 .
為了解決效率問題 , 實(shí)際上真實(shí)物理內(nèi)存是分頁(yè)的 . 而映射表同樣是以頁(yè)為單位的 .
換句話說 , 映射表只會(huì)映射到一頁(yè) , 并不會(huì)映射到具體每一個(gè)地址 .
在linux系統(tǒng)中 , 一頁(yè)內(nèi)存大小為4KB, 在不同平臺(tái)可能各有不同 .
Mac OS 系統(tǒng)中 , 一頁(yè)為4KB,
iOS 系統(tǒng)中 , 一頁(yè)為16KB.
我們可以使用pagesize命令直接查看 .
那么為什么說內(nèi)存分頁(yè)就可以解決內(nèi)存浪費(fèi)的效率問題呢 ?
內(nèi)存分頁(yè)原理
假設(shè)當(dāng)前有兩個(gè)進(jìn)程正在運(yùn)行 , 其狀態(tài)就如下圖所示 :
[圖片上傳失敗...(image-60849a-1672902867391)]
( 上圖中我們也看出 , 實(shí)際物理內(nèi)存并不是連續(xù)以及某個(gè)進(jìn)程完整的 ) .
映射表左側(cè)的0和1代表當(dāng)前地址有沒有在物理內(nèi)存中 . 為什么這么說呢 ?
- 當(dāng)應(yīng)用被加載到內(nèi)存中時(shí) , 并不會(huì)將整個(gè)應(yīng)用加載到內(nèi)存中 . 只會(huì)放用到的那一部分 . 也就是懶加載的概念 ,
換句話說就是應(yīng)用使用多少 , 實(shí)際物理內(nèi)存就實(shí)際存儲(chǔ)多少 . - 當(dāng)應(yīng)用訪問到某個(gè)地址 , 映射表中為0, 也就是說并沒有被加載到物理內(nèi)存中時(shí) ,
系統(tǒng)就會(huì)立刻阻塞整個(gè)進(jìn)程 , 觸發(fā)一個(gè)我們所熟知的缺頁(yè)中斷/中斷異常(Page Fault) - 當(dāng)一個(gè)缺頁(yè)中斷被觸發(fā) , 操作系統(tǒng)會(huì)從磁盤中重新讀取這頁(yè)數(shù)據(jù)到物理內(nèi)存上 , 然后將映射表中虛擬內(nèi)存指向?qū)?yīng)
(如果當(dāng)前內(nèi)存已滿 , 操作系統(tǒng)會(huì)通過置換頁(yè)算法
找一頁(yè)數(shù)據(jù)進(jìn)行覆蓋, 這也是為什么開再多的應(yīng)用也不會(huì)崩掉 ,
但是之前開的應(yīng)用再打開時(shí) , 就重新啟動(dòng)了的根本原因 ).
通過這種分頁(yè)和覆蓋機(jī)制 , 就完美的解決了內(nèi)存浪費(fèi)和效率問題 .
但是此時(shí) , 又出現(xiàn)了一個(gè)問題 .
問 : 當(dāng)應(yīng)用開發(fā)完成以后由于采用了虛擬內(nèi)存 , 那么其中一個(gè)函數(shù)無論如何運(yùn)行 , 運(yùn)行多少次 , 都會(huì)是虛擬內(nèi)存中的固定地址 .
什么意思呢 ?
假設(shè)應(yīng)用有一個(gè)函數(shù) , 基于首地址偏移量為0x00a000, 那么虛擬地址從0x000000 ~ 0xffffff, 基于這個(gè) ,
那么這個(gè)函數(shù)我無論如何只需要通過0x00a000這個(gè)虛擬地址就可以拿到其真實(shí)實(shí)現(xiàn)地址 .
而這種機(jī)制就給了很多黑客可操作性的空間 , 他們可以很輕易的提前寫好程序獲取固定函數(shù)的實(shí)現(xiàn)進(jìn)行修改hook操作 .
為了解決這個(gè)問題 , ASLR(地址空間布局隨機(jī)化)應(yīng)運(yùn)而生 .
其原理就是 每次 虛擬地址在映射真實(shí)地址之前 , 增加一個(gè)隨機(jī)偏移值 , 以此來解決我們剛剛所提到的這個(gè)問題 .
至此 , 有關(guān)物理內(nèi)存 , 虛擬內(nèi)存 , 內(nèi)存分頁(yè)的完整流程和原理 , 我們已經(jīng)講述完畢了 , 那么接下來來到重點(diǎn) , 二進(jìn)制重排 .
二進(jìn)制重排
概述
在了解了內(nèi)存分頁(yè)會(huì)觸發(fā)中斷異常Page Fault會(huì)阻塞進(jìn)程后 , 我們就知道了這個(gè)問題是會(huì)對(duì)性能產(chǎn)生影響的 .
實(shí)際上在 iOS 系統(tǒng)中 , 對(duì)于生產(chǎn)環(huán)境的應(yīng)用 , 當(dāng)產(chǎn)生缺頁(yè)中斷進(jìn)行重新加載時(shí) , iOS 系統(tǒng)還會(huì)對(duì)其做一次簽名驗(yàn)證 .
因此 iOS 生產(chǎn)環(huán)境的應(yīng)用Page Fault所產(chǎn)生的耗時(shí)要更多 .
當(dāng)用戶使用應(yīng)用時(shí) , 第一個(gè)直接印象就是啟動(dòng) app 耗時(shí) , 而恰巧由于啟動(dòng)時(shí)期有大量的類 , 分類 , 三方 等等需要加載和執(zhí)行 ,
多個(gè)Page Fault所產(chǎn)生的的耗時(shí)往往是不能小覷的 . 這也是二進(jìn)制重排進(jìn)行啟動(dòng)優(yōu)化的必要性
二進(jìn)制重排優(yōu)化原理
假設(shè)在啟動(dòng)時(shí)期我們需要調(diào)用兩個(gè)函數(shù)method1與method4.
函數(shù)編譯在mach-o中的位置是根據(jù)ld( Xcode 的鏈接器) 的編譯順序并非調(diào)用順序來的 .
因此很可能這兩個(gè)函數(shù)分布在不同的內(nèi)存頁(yè)上 .
[圖片上傳失敗...(image-914ae-1672902867391)]
那么啟動(dòng)時(shí) , page1與page2則都需要從無到有加載到物理內(nèi)存中 , 從而觸發(fā)兩次page fault.
而二進(jìn)制重排的做法就是將method1與method4放到一個(gè)內(nèi)存頁(yè)中 , 那么啟動(dòng)時(shí)則只需要加載page1即可 ,
也就是只觸發(fā)一次page fault, 達(dá)到優(yōu)化目的 .
實(shí)際項(xiàng)目中的做法是將啟動(dòng)時(shí)需要調(diào)用的函數(shù)放到一起 (比如 前10頁(yè)中 ) 以盡可能減少 page fault , 達(dá)到優(yōu)化目的 .
而這個(gè)做法就叫做 : 二進(jìn)制重排 .
講到這里相信很多同學(xué)已經(jīng)迫不及待的想要看看具體怎么二進(jìn)制重排了 . 其實(shí)操作很簡(jiǎn)單 , 但是在操作之前我們還需要知道這幾點(diǎn) :
- 如何檢測(cè)page fault: 首先我們要想看到優(yōu)化效果 , 就應(yīng)該知道如何查看page fault, 以此來幫助我們查看優(yōu)化前以及優(yōu)化后的效果
- 如何重排二進(jìn)制 .
- 如何查看自己重排成功了沒有 ?
- 如何檢測(cè)自己?jiǎn)?dòng)時(shí)刻需要調(diào)用的所有方法 .
1.hook objc_MsgSend( 只能拿到oc以及swift加上@objc dynamic修飾后的方法 ) .
2.靜態(tài)掃描macho特定段和節(jié)里面所存儲(chǔ)的符號(hào)以及函數(shù)數(shù)據(jù) . (靜態(tài)掃描 , 主要用來獲取load方法 ,c++構(gòu)造)
3.clang插樁( 完美版本 , 完全拿到swift,oc,c,block全部函數(shù) )
內(nèi)容很多 , 我們一項(xiàng)一項(xiàng)來 .
如何查看page fault
提示 :
如果想查看真實(shí)page fault次數(shù) , 應(yīng)該將應(yīng)用卸載 , 查看第一次應(yīng)用安裝后的效果 , 或者先打開很多個(gè)其他應(yīng)用 .
因?yàn)橹斑\(yùn)行過 app , 應(yīng)用其中一部分已經(jīng)被加載到物理內(nèi)存并做好映射表映射 , 這時(shí)再啟動(dòng)就會(huì)少觸發(fā)一部分缺頁(yè)中斷 , 并且殺掉應(yīng)用再打開也是如此 .
其實(shí)就是希望將物理內(nèi)存中之前加載的覆蓋/清理掉 , 減少誤差 .
- 1、 打開
Instruments, 選擇 System Trace(系統(tǒng)跟蹤) .
[圖片上傳失敗...(image-8e2c0b-1672902867391)]
- 2、
選擇真機(jī) , 選擇工程 , 點(diǎn)擊啟動(dòng) , 當(dāng)首個(gè)頁(yè)面加載出來點(diǎn)擊停止.
(這里注意 , 最好是將應(yīng)用殺掉重新安裝 , 因?yàn)?code>冷熱啟動(dòng)的界定其實(shí)由于進(jìn)程的原因并不一定后臺(tái)殺掉應(yīng)用重新打開就是冷啟動(dòng) .)
- 3、等待分析完成 , 查看缺頁(yè)次數(shù)
當(dāng)然 , 你可以通過添加 DYLD_PRINT_STATISTICS 來查看 pre-main 階段總耗時(shí)來做一個(gè)側(cè)面輔證 .
二進(jìn)制重排具體如何操作
說了這么多前導(dǎo)知識(shí) , 終于要開始做二進(jìn)制重排了 , 其實(shí)具體操作很簡(jiǎn)單 , Xcode 已經(jīng)提供好這個(gè)機(jī)制 ,
并且 libobjc 實(shí)際上也是用了二進(jìn)制重排進(jìn)行優(yōu)化 .
- 首先 , Xcode 是用的鏈接器叫做ld ,ld有一個(gè)參數(shù)叫
Order File, 我們可以通過這個(gè)參數(shù)配置一個(gè)order文件的路徑 .
如:$(SRCROOT)/lb.order - 在這個(gè) order 文件中 , 將你需要的符號(hào)按順序?qū)懺诶锩?.
- 當(dāng)工程
build的時(shí)候 , Xcode 會(huì)讀取這個(gè)文件 , 打的二進(jìn)制包就會(huì)按照這個(gè)文件中的符號(hào)順序進(jìn)行生成對(duì)應(yīng)的mach-O.
[圖片上傳失敗...(image-10a36c-1672902867391)]
二進(jìn)制重排疑問 - 題外話 :
- 1、
order文件里 符號(hào)寫錯(cuò)了或者這個(gè)符號(hào)不存在會(huì)不會(huì)有問題 ?- 答 : ld 會(huì)忽略這些符號(hào) , 實(shí)際上如果提供了 link 選項(xiàng)
-order_file_statistics,
會(huì)以 warning 的形式把這些沒找到的符號(hào)打印在日志里。
- 答 : ld 會(huì)忽略這些符號(hào) , 實(shí)際上如果提供了 link 選項(xiàng)
- 2、有部分同學(xué)可能會(huì)考慮這種方式會(huì)不會(huì)影響上架 ?
- 答 : 首先 , objc 源碼自己也在用這種方式 .
- 二進(jìn)制重排只是重新排列了所生成的 macho 中函數(shù)表與符號(hào)表的順序 .
如何查看自己工程的符號(hào)順序
重排前后我們需要查看自己的符號(hào)順序有沒有修改成功 , 這時(shí)候就用到了 Link Map .
Link Map 是編譯期間產(chǎn)生的產(chǎn)物 ,
( ld 的讀取二進(jìn)制文件順序默認(rèn)是按照 Compile Sources (編譯源代碼) - GUI 里的順序 ) ,
它記錄了二進(jìn)制文件的布局 . 通過設(shè)置 Write Link Map File 來設(shè)置輸出與否 , 默認(rèn)是 no .
[圖片上傳失敗...(image-c162e9-1672902867391)]
修改完畢后 clean 一下 , 運(yùn)行工程 , Products - show in finder, 找到 macho 的上上層目錄.
[圖片上傳失敗...(image-13e70f-1672902867391)]
按下圖依次找到最新的一個(gè) .txt 文件并打開.
[圖片上傳失敗...(image-3bd735-1672902867391)]
這個(gè)文件中就存儲(chǔ)了所有符號(hào)的順序 , 在 # Symbols: 部分
[圖片上傳失敗...(image-3d3173-1672902867391)]
可以看到 , 這個(gè)符號(hào)順序明顯是按照 Compile Sources 的文件順序來排列的 .
[圖片上傳失敗...(image-119ed4-1672902867391)]
提示:
上述文件中最左側(cè)地址就是 實(shí)際代碼地址而并非符號(hào)地址 , 因此我們二進(jìn)制重排并非只是修改符號(hào)地址 , 而是利用符號(hào)順序 , 重新排列整個(gè)代碼在文件的偏移地址 , 將啟動(dòng)需要加載的方法地址放到前面內(nèi)存頁(yè)中 , 以此達(dá)到減少 page fault 的次數(shù)從而實(shí)現(xiàn)時(shí)間上的優(yōu)化
, 一定要清楚這一點(diǎn) .
你可以利用 MachOView查看排列前后在 _text 段 ( 代碼段 ) 中的源碼順序來幫助理解 .
實(shí)戰(zhàn)演練
來到工程根目錄 , 新建一個(gè)文件 touch lb.order . 隨便挑選幾個(gè)啟動(dòng)時(shí)就需要加載的方法 , 例如我這里選了以下幾個(gè) .
-[LBOCTools lbCurrentPresentingVC]
+[LBOCTools lbGetCurrentTimes]
+[RSAEncryptor stripPublicKeyHeader:]
寫到該文件中 , 保存 , 配置文件路徑 .
[圖片上傳失敗...(image-e7ee2d-1672902867391)]
重新運(yùn)行 , 查看 .
[圖片上傳失敗...(image-9a223d-1672902867391)]
可以看到 , 我們所寫的這三個(gè)方法已經(jīng)被放到最前面了 , 至此 ,
生成的macho中距離首地址偏移量最小的代碼就是我們所寫的這三個(gè)方法 ,
假設(shè)這三個(gè)方法原本在不同的三頁(yè) , 那么我們就已經(jīng)優(yōu)化掉了兩個(gè)page fault.
獲取啟動(dòng)加載所有函數(shù)的符號(hào)
講到這 , 我們就只差一個(gè)問題了 , 那就是如何知道我的項(xiàng)目啟動(dòng)需要調(diào)用哪些方法 , 上述篇章中我們也有稍微提到一點(diǎn) .
-
hook objc_MsgSend( 只能拿到oc以及swift @objc dynamic后的方法 , 并且由于可變參數(shù)個(gè)數(shù) , 需要用匯編來獲取參數(shù) .) - 靜態(tài)掃描
macho特定段和節(jié)里面所存儲(chǔ)的符號(hào)以及函數(shù)數(shù)據(jù) . (靜態(tài)掃描 , 主要用來獲取load方法 ,c++構(gòu)造) . -
clang插樁( 完美版本 , 完全拿到swift , oc , c , block全部函數(shù) ) .
前兩種這里我們就不在贅述了 . 網(wǎng)上參考資料也較多 , 而且實(shí)現(xiàn)效果也并不是完美狀態(tài) ,
本文我們來談?wù)勅绾瓮ㄟ^編譯期插樁的方式來hook獲取所有的函數(shù)符號(hào) .
clang 插樁
關(guān)于 clang 的插樁覆蓋的官方文檔如下 : clang 自帶代碼覆蓋工具 文檔中有詳細(xì)概述 , 以及簡(jiǎn)短 Demo 演示 .
思考
其實(shí) clang 插樁主要有兩個(gè)實(shí)現(xiàn)思路 ,
一是自己編寫 clang 插件 ( 自定義 clang 插件在后續(xù)底層篇 llvm 中會(huì)帶著大家來手寫一個(gè)自己的插件 ) ,
另外一個(gè)就是利用 clang 本身已經(jīng)提供的一個(gè)工具 or 機(jī)制來實(shí)現(xiàn)我們獲取所有符號(hào)的需求 .
本文我們就按照第二種思路來實(shí)際演練一下 .
原理探索
新建一個(gè)工程來測(cè)試和使用一下這個(gè)靜態(tài)插樁代碼覆蓋工具的機(jī)制和原理 .
按照文檔指示來走
- 首先 , 添加編譯設(shè)置
直接搜索Other C Flags來到Apple Clang - Custom Compiler Flags中 , 添加-fsanitize-coverage=trace-pc-guard
[圖片上傳失敗...(image-37d285-1672902867391)]
- 添加 hook 代碼
#include <stdint.h>
#include <stdio.h>
#include <sanitizer/coverage_interface.h>
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里的.
運(yùn)行工程 , 查看打印
[圖片上傳失敗...(image-2b05b1-1672902867391)]
代碼命名INIT后面打印的兩個(gè)指針地址叫start和stop. 那么我們通過lldb來查看下從start到stop這個(gè)內(nèi)存地址里面所存儲(chǔ)的到底是啥 .
發(fā)現(xiàn)存儲(chǔ)的是從 1 到 14 這個(gè)序號(hào) . 那么我們來添加一個(gè)oc方法 .
- (void)testOCFunc{
}
再次運(yùn)行查看 .
[圖片上傳失敗...(image-493ce0-1672902867391)]
發(fā)現(xiàn)從0e變成了0f. 也就是說存儲(chǔ)的 1 到 14 這個(gè)序號(hào)變成了 1 到 15 .
那么我們?cè)偬砑右粋€(gè)c函數(shù) , 一個(gè)block, 和一個(gè)觸摸屏幕方法來看下 .
[圖片上傳失敗...(image-7c15e5-1672902867391)]
同樣發(fā)現(xiàn)序號(hào)依次增加到了18個(gè) , 那么我們得到一個(gè)猜想 , 這個(gè)內(nèi)存區(qū)間保存的就是工程所有符號(hào)的個(gè)數(shù) .
其次 , 我們?cè)谟|摸屏幕方法調(diào)用了c函數(shù) ,c函數(shù)中調(diào)用了block. 那么我們點(diǎn)擊屏幕 , 發(fā)現(xiàn)如下 :
[圖片上傳失敗...(image-f8fab9-1672902867391)]
發(fā)現(xiàn)我們實(shí)際調(diào)用幾個(gè)方法 , 就會(huì)打印幾次 guard : .(guard:警衛(wèi))
實(shí)際上就類似我們埋點(diǎn)統(tǒng)計(jì)所實(shí)現(xiàn)的效果 . 在觸摸方法添加一個(gè)斷點(diǎn)查看匯編 :
[圖片上傳失敗...(image-9d9310-1672902867391)]
[圖片上傳失敗...(image-ae67ec-1672902867391)]
通過匯編我們發(fā)現(xiàn) ,
在每個(gè)函數(shù)調(diào)用的第一句實(shí)際代碼 ( 棧平衡與寄存器數(shù)據(jù)準(zhǔn)備除外 ) , 被添加進(jìn)去了一個(gè) bl 調(diào)用到 __sanitizer_cov_trace_pc_guard這個(gè)函數(shù)中來。
bl匯編指令,跳轉(zhuǎn)
而實(shí)際上這也是靜態(tài)插樁的原理和名稱由來 .
靜態(tài)插樁總結(jié)
靜態(tài)插樁實(shí)際上是在編譯期就在每一個(gè)函數(shù)內(nèi)部二進(jìn)制源數(shù)據(jù)添加hook代碼 ( 我們添加的 __sanitizer_cov_trace_pc_guard 函數(shù) ) 來實(shí)現(xiàn)全局的方法hook的效果 .
疑問
可能有部分同學(xué)對(duì)我上述表述的原理總結(jié)有些疑問 .
究竟是直接修改二進(jìn)制在每個(gè)函數(shù)內(nèi)部都添加了調(diào)用 hook 函數(shù)這個(gè)匯編代碼 ,
還是只是類似于編譯器在所生成的二進(jìn)制文件添加了一個(gè)標(biāo)記 , 然后在運(yùn)行時(shí)如果有這個(gè)標(biāo)記就會(huì)自動(dòng)多做一步調(diào)用 hook 代碼呢 ?
筆者這里使用 hopper 來看下生成的mach-o二進(jìn)制文件 .
[圖片上傳失敗...(image-20af1c-1672902867391)]
上述二進(jìn)制源文件我們就發(fā)現(xiàn) , 的確是函數(shù)內(nèi)部 一開始就添加了調(diào)用額外方法的匯編代碼 . 這也是我們?yōu)槭裁捶Q其為 .
講到這里 , 原理我們大體上了解了 , 那么到底如何才能拿到函數(shù)的符號(hào)呢 ?
獲取所有函數(shù)符號(hào)
思路
我們現(xiàn)在知道了 , 所有函數(shù)內(nèi)部第一步都會(huì)去調(diào)用__sanitizer_cov_trace_pc_guard這個(gè)函數(shù) .
那么熟悉匯編的同學(xué)可能就有這么個(gè)想法 :
函數(shù)嵌套時(shí) , 在跳轉(zhuǎn)子函數(shù)時(shí)都會(huì)保存下一條指令的地址在
X30( 又叫 lr 寄存器) 里 .
例如 , A 函數(shù)中調(diào)用了 B 函數(shù) , 在 arm 匯編中即bl + 0x**** 指令 ,
該指令會(huì)首先將下一條匯編指令的地址保存在 x30 寄存器中 ,然后在跳轉(zhuǎn)到 bl 后面?zhèn)鬟f的指定地址去執(zhí)行 .
( 提示 : bl 能實(shí)現(xiàn)跳轉(zhuǎn)到某個(gè)地址的匯編指令 , 其原理就是修改 pc 寄存器的值來指向到要跳轉(zhuǎn)的地址 ,
而且實(shí)際上 B 函數(shù)中也會(huì)對(duì) x29 / x30 寄存器的值做保護(hù)防止子函數(shù)又跳轉(zhuǎn)其他函數(shù)會(huì)覆蓋掉 x30 的值 , 當(dāng)然 , 葉子函數(shù)除外 . ) .
當(dāng) B 函數(shù)執(zhí)行 ret 也就是返回指令時(shí) , 就會(huì)去讀取 x30 寄存器的地址 , 跳轉(zhuǎn)過去 , 因此也就回到了上一層函數(shù)的下一步 .
這種思路來實(shí)現(xiàn)實(shí)際上是可以的 . 我們所寫的 __sanitizer_cov_trace_pc_guard 函數(shù)中的這一句代碼 :
void *PC = __builtin_return_address(0);
它的作用其實(shí)就是去讀取x30中所存儲(chǔ)的要返回時(shí)下一條指令的地址 . 所以他名稱叫做 __builtin_return_address .
換句話說 , 這個(gè)地址就是我當(dāng)前這個(gè)函數(shù)執(zhí)行完畢后 , 要返回到哪里去 .
其實(shí) , bt 函數(shù)調(diào)用棧也是這種思路來實(shí)現(xiàn)的 .
也就是說 , 我們現(xiàn)在可以在 __sanitizer_cov_trace_pc_guard 這個(gè)函數(shù)中 , 通過 __builtin_return_address 函數(shù)拿到原函數(shù)調(diào)用 __sanitizer_cov_trace_pc_guard 這句匯編代碼的下一條指令的地址.
可能有點(diǎn)繞 , 畫個(gè)圖來梳理一下流程 .
[圖片上傳失敗...(image-31e37-1672902867391)]
根據(jù)內(nèi)存地址獲取函數(shù)名稱
拿到了函數(shù)內(nèi)部一行代碼的地址 , 如何獲取函數(shù)名稱呢 ? 這里筆者分享一下自己的思路 .
熟悉安全攻防 , 逆向的同學(xué)可能會(huì)清楚 . 我們?yōu)榱朔乐鼓承┨囟ǖ姆椒ū粍e人使用 fishhook hook 掉 ,
會(huì)利用 dlopen 打開動(dòng)態(tài)庫(kù) , 拿到一個(gè)句柄 , 進(jìn)而拿到函數(shù)的內(nèi)存地址直接調(diào)用 .
是不是跟我們這個(gè)流程有點(diǎn)相似 , 只是我們好像是反過來的 . 其實(shí)反過來也是可以的 .
與 dlopen 相同 , 在 dlfcn.h 中有一個(gè)方法如下 :
typedef struct dl_info {
const char *dli_fname; /* 所在文件 */
void *dli_fbase; /* 文件地址 */
const char *dli_sname; /* 符號(hào)名稱 */
void *dli_saddr; /* 函數(shù)起始地址 */
} Dl_info;
//這個(gè)函數(shù)能通過函數(shù)內(nèi)部地址找到函數(shù)符號(hào)
int dladdr(const void *, Dl_info *);
緊接著我們來實(shí)驗(yàn)一下 , 先導(dǎo)入頭文件#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);
}
查看打印結(jié)果 :
[圖片上傳失敗...(image-adb281-1672902867391)]
終于看到我們要找的符號(hào)了 .
收集符號(hào)
看到這里 , 很多同學(xué)可能想的是 , 那馬上到工程里去拿到我所有的符號(hào) , 寫到 order 文件里不就完事了嗎 ? 不能
clang靜態(tài)插樁 - 坑點(diǎn)1
- 多線程問題
這是一個(gè)多線程的問題 , 由于你的項(xiàng)目各個(gè)方法肯定有可能會(huì)在不同的函數(shù)執(zhí)行 ,
因此__sanitizer_cov_trace_pc_guard這個(gè)函數(shù)也有可能受多線程影響 , 所以你當(dāng)然不可能簡(jiǎn)簡(jiǎn)單單用一個(gè)數(shù)組來接收所有的符號(hào)就搞定了 .
那方法有很多 , 筆者在這里分享一下自己的做法 :
考慮到這個(gè)方法會(huì)來特別多次 , 使用鎖會(huì)影響性能 , 這里使用蘋果底層的原子隊(duì)列
( 底層實(shí)際上是個(gè)棧結(jié)構(gòu) , 利用隊(duì)列結(jié)構(gòu) + 原子性來保證順序 ) 來實(shí)現(xiàn) .
- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event{
//遍歷出隊(duì)
while (true) {
//offsetof 就是針對(duì)某個(gè)結(jié)構(gòu)體找到某個(gè)屬性相對(duì)這個(gè)結(jié)構(gòu)體的偏移量
SymbolNode * node = OSAtomicDequeue(&symboList, offsetof(SymbolNode, next));
if (node == NULL) break;
Dl_info info;
dladdr(node->pc, &info);
printf("%s \n",info.dli_sname);
}
}
//原子隊(duì)列
static OSQueueHead symboList = OS_ATOMIC_QUEUE_INIT;
//定義符號(hào)結(jié)構(gòu)體
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};
//入隊(duì)
// offsetof 用在這里是為了入隊(duì)添加下一個(gè)節(jié)點(diǎn)找到 前一個(gè)節(jié)點(diǎn)next指針的位置
OSAtomicEnqueue(&symboList, node, offsetof(SymbolNode, next));
}
當(dāng)你興致沖沖開始考慮好多線程的解決方法寫完之后 , 運(yùn)行發(fā)現(xiàn) :
死循環(huán)了 .
clang靜態(tài)插樁 - 坑點(diǎn)2
-
上述這種 clang 插樁的方式 , 會(huì)在循環(huán)中同樣插入 hook 代碼.
當(dāng)確定了我們隊(duì)列入隊(duì)和出隊(duì)都是沒問題的 , 你自己的寫法對(duì)應(yīng)的保存和讀取也是沒問題的 ,
我們發(fā)現(xiàn)了這個(gè)坑點(diǎn) , 這個(gè)會(huì)死循環(huán) , 為什么呢 ?
這里我就不帶著大家去分析匯編了 , 直接說結(jié)論 :
通過匯編會(huì)查看到 一個(gè)帶有 while 循環(huán)的方法 , 會(huì)被靜態(tài)加入多次 __sanitizer_cov_trace_pc_guard調(diào)用 , 導(dǎo)致死循環(huán).
解決方案
Other C Flags 修改為如下 :
-fsanitize-coverage=func,trace-pc-guard
代表僅針對(duì)func進(jìn)行hook. 再次運(yùn)行 .
[圖片上傳失敗...(image-d3f5a6-1672902867391)]
又以為完事了 ? 還沒有..
坑點(diǎn)3 : load 方法
-
load 方法時(shí) ,
__sanitizer_cov_trace_pc_guard函數(shù)的參數(shù) guard 是 0.
上述打印并沒有發(fā)現(xiàn)load.
解決 : 屏蔽掉 __sanitizer_cov_trace_pc_guard 函數(shù)中的
if (!*guard) return;
[圖片上傳失敗...(image-47b826-1672902867391)]
load方法就有了 .
這里也為我們提供了一點(diǎn)啟示:
如果我們希望從某個(gè)函數(shù)之后/之前開始優(yōu)化 , 通過一個(gè)全局靜態(tài)變量 , 在特定的時(shí)機(jī)修改其值 , 在__sanitizer_cov_trace_pc_guard這個(gè)函數(shù)中做好對(duì)應(yīng)的處理即可 .
剩余細(xì)化工作
- 如果你也是使用筆者這種多線程處理方式的話 , 由于用的先進(jìn)后出原因 , 我們要倒敘一下
- 還需要做去重 .
- order文件格式要求c函數(shù) ,block調(diào)用前面還需要加 _下劃線 .
- 寫入文件即可 .
筆者demo完整代碼如下 :
/**
啟動(dòng)優(yōu)化:
思路:clang靜態(tài)插樁達(dá)到二進(jìn)制重排優(yōu)化啟動(dòng)時(shí)間
利用編譯期`clang 靜態(tài)插樁`的方式來 hook 獲取啟動(dòng)時(shí)期需要加載的所有函數(shù)/方法 , 包含:`block` , `swift 方法`以及 `c++構(gòu)造方法`的符號(hào) .
通過 `order file 機(jī)制`實(shí)現(xiàn)二進(jìn)制重排 .
二進(jìn)制重排利用符號(hào)順序 , 重新排列整個(gè)代碼在文件的偏移地址 ,
將啟動(dòng)需要加載的方法地址放到前面內(nèi)存頁(yè)中 , 以此達(dá)到減少 page fault (缺頁(yè)中斷)的次數(shù)從而實(shí)現(xiàn)時(shí)間上的優(yōu)化。
*/
#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.
}
/**
考慮到這個(gè)方法會(huì)來特別多次 , 使用鎖會(huì)影響性能 , 這里使用蘋果底層的原子隊(duì)列
(底層實(shí)際上是個(gè)棧結(jié)構(gòu) , 利用隊(duì)列結(jié)構(gòu) + 原子性來保證順序) 來實(shí)現(xiàn) .
*/
- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event{
NSMutableArray<NSString *> * symbolNames = [NSMutableArray array];
while (true) {
//offsetof 就是針對(duì)某個(gè)結(jié)構(gòu)體找到某個(gè)屬性相對(duì)這個(gè)結(jié)構(gòu)體的偏移量
SymbolNode * node = OSAtomicDequeue(&symboList, offsetof(SymbolNode, next));
if (node == NULL) break;
Dl_info info;
dladdr(node->pc, &info); //這個(gè)函數(shù)能通過函數(shù)內(nèi)部地址找到函數(shù)符號(hào)
NSString * name = @(info.dli_sname); // 符號(hào)名稱
// 添加 _
BOOL isObjc = [name hasPrefix:@"+["] || [name hasPrefix:@"-["];
// block 調(diào)用前面還需要加_下劃線
NSString * symbolName = isObjc ? name : [@"_" stringByAppendingString:name];
//去重
if (![symbolNames containsObject:symbolName]) {
[symbolNames addObject:symbolName];
}
}
//取反
NSArray * symbolAry = [[symbolNames reverseObjectEnumerator] allObjects];
NSLog(@"%@",symbolAry);
/**
(
"+[ViewController load]",
"_main",
"-[AppDelegate application:didFinishLaunchingWithOptions:]",
"-[AppDelegate application:configurationForConnectingSceneSession:options:]",
"-[SceneDelegate setWindow:]",
"-[SceneDelegate scene:willConnectToSession:options:]",
"-[SceneDelegate window]",
"-[ViewController viewDidLoad]",
"_testCFunc",
"_LBBlock_block_invoke",
"-[ViewController testOCFunc]",
"-[SceneDelegate sceneWillEnterForeground:]",
"-[SceneDelegate sceneDidBecomeActive:]",
"-[ViewController touchesBegan:withEvent:]"
)
*/
//將結(jié)果寫入到文件
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);
// /Users/sunhui/Library/Developer/CoreSimulator/Devices/70CDD9F6-B254-4E29-9DAA-07DB4D9B86F8/data/Containers/Data/Application/FA706818-43A5-4D64-AEAC-97B4235013B1/tmp/lb.order
}else{
NSLog(@"文件寫入出錯(cuò)");
}
}
//原子隊(duì)列
static OSQueueHead symboList = OS_ATOMIC_QUEUE_INIT;
//定義符號(hào)結(jié)構(gòu)體
typedef struct{
void * pc;
void * next;
}SymbolNode;
/**
在 __sanitizer_cov_trace_pc_guard 這個(gè)函數(shù)中 ,通過 __builtin_return_address 函數(shù)
拿到原函數(shù)調(diào)用 __sanitizer_cov_trace_pc_guard 這句匯編代碼的下一條指令的地址
*/
void __sanitizer_cov_trace_pc_guard(uint32_t *guard) {
//if (!*guard) return; // 注釋掉的目的是,為了得到load方法
void *PC = __builtin_return_address(0); // 作用:讀取 x30 中所存儲(chǔ)的要返回時(shí)下一條指令的地址
SymbolNode * node = malloc(sizeof(SymbolNode));
*node = (SymbolNode){PC,NULL};
//入隊(duì)
// offsetof 用在這里是為了入隊(duì)添加下一個(gè)節(jié)點(diǎn)找到 前一個(gè)節(jié)點(diǎn)next指針的位置
OSAtomicEnqueue(&symboList, node, offsetof(SymbolNode, next));
}
搞定 , 小伙伴們就可以立馬去優(yōu)化自己的工程了 .
cocoapod 工程問題
對(duì)于 cocoapod 工程引入的庫(kù) , 由于針對(duì)不同的 target . 那么我們?cè)谥鞒绦蛑械?target 添加的編譯設(shè)置
Write Link Map File , -fsanitize-coverage=func,trace-pc-guard 以及 order file 等設(shè)置肯定是不會(huì)生效的 .
解決方法就是針對(duì)需要的 target 去做對(duì)應(yīng)的設(shè)置即可 .
對(duì)于直接手動(dòng)導(dǎo)入到工程里的 sdk , 不管是 靜態(tài)庫(kù) .a 還是 動(dòng)態(tài)庫(kù) , 默認(rèn)主工程的設(shè)置就可以了 , 是可以拿到符號(hào)的 .
最后提示一下 , 手動(dòng)導(dǎo)入的三方庫(kù)如果沒有導(dǎo)入并且使用的話 , 是不會(huì)加載的 . 添加了 load 方法也是如此 .