前言
之前我們分析過LLVM編譯流程,清楚了App的整個編譯過程,也分析過iOS應(yīng)用程序加載大致流程分析,清楚了dyld鏈接加載的整個過程,今天我們在這些基礎(chǔ)上,針對App的啟動做一些優(yōu)化的事情。
一、基礎(chǔ)概念
在做啟動優(yōu)化之前,我們需要弄清楚一些關(guān)于優(yōu)化的基礎(chǔ)概念。
1.1 物理內(nèi)存 vs 虛擬內(nèi)存
- 物理內(nèi)存:你可以這么理解,就是電腦插的
內(nèi)存條,容量就是真實的,是8G就8G,是16G就16G。 - 虛擬內(nèi)存:物理內(nèi)存的衍生物。
物理內(nèi)存很好理解,但虛擬內(nèi)存就有些難了,下面重點分析一下虛擬內(nèi)存的由來。
早期計算機中,沒有虛擬內(nèi)存的概念,只有物理內(nèi)存,每個應(yīng)用都全部寫在內(nèi)存條中,當(dāng)內(nèi)存條空間不夠時,就會內(nèi)存告警,這時我們必須手動關(guān)閉一些應(yīng)用,釋放內(nèi)存空間來讓當(dāng)前的應(yīng)用運行。如下圖

明顯,物理內(nèi)存這么使用,會有以下問題:
-
內(nèi)存不夠:每個應(yīng)用一打開,就把所有信息都加載進(jìn)入內(nèi)存,占用太多資源。如果是體積大的軟件,則直接無法加載。 -
不安全: 應(yīng)用一旦加載進(jìn)入內(nèi)存,其地址都是固定不變的,那么我們可以通過物理地址去篡改對應(yīng)的信息,很不安全。例如:早期的一些游戲外掛,就是通過物理內(nèi)存地址去篡改數(shù)據(jù)。
那么,針對上述兩大問題,前輩們經(jīng)過研究發(fā)現(xiàn),其實每個應(yīng)用在內(nèi)存中使用的部分,僅占該應(yīng)用的小部分(活躍部分),于是他們將內(nèi)存均勻分割成很多頁。應(yīng)用的運行也不用全部加載到內(nèi)存,而是分配一個虛擬的內(nèi)存,也跟物理內(nèi)存的一樣,被分割成很多頁,如下圖所示

內(nèi)存頁
內(nèi)存頁就是將內(nèi)存分割成一小塊,以頁的方式作為計量的單位。那一頁的大小是多少呢?Linux和MacOS系統(tǒng):每頁4K;iOS系統(tǒng): 每頁16K。
頁表
頁表就是應(yīng)用的虛擬內(nèi)存與物理內(nèi)存的地址映射關(guān)系表。
ASLR
在上面解釋的虛擬內(nèi)存中,我們提到了虛擬內(nèi)存的起始地址與大小都是固定的,這意味著,當(dāng)我們訪問時,其數(shù)據(jù)的地址也是固定的,這會導(dǎo)致我們的數(shù)據(jù)非常容易被破解,為了解決這個問題,所以蘋果為了解決這個問題,在iOS4.3開始引入了ASLR技術(shù)。
ASLR的概念:(Address Space Layout Randomization) 地址空間配置隨機加載,是一種針對緩沖區(qū)溢出的安全保護(hù)技術(shù),通過對堆、棧、共享庫映射等線性區(qū)布局的隨機化,通過增加攻擊者預(yù)測目的地址的難度,防止攻擊者直接定位攻擊代碼位置,達(dá)到阻止溢出攻擊的目的的一種技術(shù)。其目的的通過利用隨機方式配置數(shù)據(jù)地址空間,使某些敏感數(shù)據(jù)(例如APP登錄注冊、支付相關(guān)代碼)配置到一個惡意程序無法事先獲知的地址,令攻擊者難以進(jìn)行攻擊。
由于ASLR的存在,導(dǎo)致可執(zhí)行文件和動態(tài)鏈接庫在虛擬內(nèi)存中的加載地址每次啟動都不固定,所以需要在編譯時來修復(fù)鏡像中的資源指針,來指向正確的地址。即正確的內(nèi)存地址 = ASLR地址 + 偏移值。
虛擬內(nèi)存特點
那么,采用虛擬內(nèi)存去加載應(yīng)用就具備了以下特點
- 每個應(yīng)用(進(jìn)程)默認(rèn)可以分配
4G大小。但它實際只是一張頁表,記錄映射關(guān)系就可以。 - 頁表
存放在操作系統(tǒng)的內(nèi)存區(qū)域。 - 應(yīng)用用到的都是
虛擬內(nèi)存,實際占有物理內(nèi)存大小是應(yīng)用運行時決定的。
1.2 冷啟動 vs 熱啟動
應(yīng)用的啟動大致分為3種情況:
- 首次啟動
- kill應(yīng)用后重新啟動
- 應(yīng)用置于后臺,隔一段時間后再切回前臺激活啟動
這3種啟動的情況,有的啟動很快,而有的啟動又有些慢,這就是冷啟動 和 熱啟動的區(qū)別
冷啟動
內(nèi)存中不包含APP的數(shù)據(jù),所有數(shù)據(jù)都需要載入內(nèi)存中,提供給應(yīng)用使用。因為從磁盤讀取數(shù)據(jù)加載到內(nèi)存中,比較耗時,所以速度慢。
(ps: 內(nèi)存中的數(shù)據(jù)是不會被刪除的,但是存儲空間可能被其他應(yīng)用使用了,從而數(shù)據(jù)被覆蓋。)
熱啟動
內(nèi)存中仍然存在APP的數(shù)據(jù),數(shù)據(jù)不需要重新載入內(nèi)存,所以速度快。
(ps: 當(dāng)前應(yīng)用所占的內(nèi)存空間,未被其他應(yīng)用覆蓋。所以數(shù)據(jù)依舊可讀取。)
那么,以上3種啟動的場景,分別是那種啟動呢?
- 首次啟動: 一定是
冷啟動。(內(nèi)存中無數(shù)據(jù)) - kill后啟動:
冷啟動或熱啟動。 (取決于內(nèi)存中是否有數(shù)據(jù)) - 置于后臺再回到前臺啟動:
冷啟動或熱啟動。(取決于內(nèi)存中是否有數(shù)據(jù))
注意:
如果其他應(yīng)用需要更多內(nèi)存空間,系統(tǒng)可能自動覆蓋你的內(nèi)存空間提供給其他應(yīng)用使用,此時你的數(shù)據(jù)就被覆蓋了,回到前臺時,應(yīng)用自動重啟。
1.3 啟動性能檢測和分析
在測試App應(yīng)用啟動之前,其實應(yīng)該分為兩大階段,以main函數(shù)為邊界
- main方法之前-->
dyld負(fù)責(zé)的加載流程
系統(tǒng)處理,我們從dyld應(yīng)用加載的流程來優(yōu)化。(借助系統(tǒng)工具分析耗時) - main方法之后--> 開發(fā)者自己的
業(yè)務(wù)代碼。
通過檢測業(yè)務(wù)流程來優(yōu)化(main函數(shù)打印個時間點、第一個頁面渲染完成打印個時間點,這個時間差就是main之后到第一個頁面顯示出來的耗時)
1.3.1 main函數(shù)前
我們都知道,main之前都是dyld負(fù)責(zé)的,說白了就是系統(tǒng)決定的東西,我們很難去修改其中的流程,那么,有其它的手段么? 當(dāng)然有,針對ipa包砸殼,這些不作為重點,知道即可。
- 創(chuàng)建一個Demo工程,新增環(huán)境變量
DYLD_PRINT_STATISTICS

- 砸殼 (ps:此過程忽略,感興趣的朋友可網(wǎng)上自行搜索。)
- 添加重簽名腳本
appSign.sh
腳本代碼如下
# ${SRCROOT} 它是工程文件所在的目錄
TEMP_PATH="${SRCROOT}/Temp"
#資源文件夾,我們提前在工程目錄下新建一個APP文件夾,里面放ipa包
ASSETS_PATH="${SRCROOT}/APP"
#目標(biāo)ipa包路徑
TARGET_IPA_PATH="${ASSETS_PATH}/*.ipa"
#清空Temp文件夾
rm -rf "${SRCROOT}/Temp"
mkdir -p "${SRCROOT}/Temp"
#----------------------------------------
# 1. 解壓IPA到Temp下
unzip -oqq "$TARGET_IPA_PATH" -d "$TEMP_PATH"
# 拿到解壓的臨時的APP的路徑
TEMP_APP_PATH=$(set -- "$TEMP_PATH/Payload/"*.app;echo "$1")
# echo "路徑是:$TEMP_APP_PATH"
#----------------------------------------
# 2. 將解壓出來的.app拷貝進(jìn)入工程下
# BUILT_PRODUCTS_DIR 工程生成的APP包的路徑
# TARGET_NAME target名稱
TARGET_APP_PATH="$BUILT_PRODUCTS_DIR/$TARGET_NAME.app"
echo "app路徑:$TARGET_APP_PATH"
rm -rf "$TARGET_APP_PATH"
mkdir -p "$TARGET_APP_PATH"
cp -rf "$TEMP_APP_PATH/" "$TARGET_APP_PATH"
#----------------------------------------
# 3. 刪除extension和WatchAPP.個人證書沒法簽名Extention
rm -rf "$TARGET_APP_PATH/PlugIns"
rm -rf "$TARGET_APP_PATH/Watch"
#----------------------------------------
# 4. 更新info.plist文件 CFBundleIdentifier
# 設(shè)置:"Set : KEY Value" "目標(biāo)文件路徑"
/usr/libexec/PlistBuddy -c "Set :CFBundleIdentifier >$PRODUCT_BUNDLE_IDENTIFIER" "$TARGET_APP_PATH/Info.plist"
#----------------------------------------
# 5. 給MachO文件上執(zhí)行權(quán)限
# 拿到MachO文件的路徑
APP_BINARY=`plutil -convert xml1 -o - $TARGET_APP_PATH/Info.plist|grep -A1 Exec|tail -n1|cut -f2 -d\>|cut -f1 -d\<`
#上可執(zhí)行權(quán)限
chmod +x "$TARGET_APP_PATH/$APP_BINARY"
#----------------------------------------
# 6. 重簽名第三方 FrameWorks
TARGET_APP_FRAMEWORKS_PATH="$TARGET_APP_PATH/Frameworks"
if [ -d "$TARGET_APP_FRAMEWORKS_PATH" ];
then
for FRAMEWORK in "$TARGET_APP_FRAMEWORKS_PATH/"*
do
#簽名
/usr/bin/codesign --force --sign "$EXPANDED_CODE_SIGN_IDENTITY" "$FRAMEWORK"
done
fi
#注入
#yololib "$TARGET_APP_PATH/$APP_BINARY" >"Frameworks/HankHook.framework/HankHook"
將砸殼后的ipa包和appSign.sh腳本文件放入Demo工程目錄中,如下圖

配置腳本,如下圖

- 運行項目
運行結(jié)果如下
Total pre-main time: 1.2 seconds (100.0%)
dylib loading time: 326.38 milliseconds (25.4%)
rebase/binding time: 146.54 milliseconds (11.4%)
ObjC setup time: 40.49 milliseconds (3.1%)
initializer time: 767.04 milliseconds (59.9%)
slowest intializers :
libSystem.B.dylib : 6.86 milliseconds (0.5%)
libMainThreadChecker.dylib : 38.26 milliseconds (2.9%)
libglInterpose.dylib : 447.73 milliseconds (34.9%)
marsbridgenetwork : 48.86 milliseconds (3.8%)
mars : 30.85 milliseconds (2.4%)
砸殼應(yīng)用 : 212.00 milliseconds (16.5%)
1.3.2 分析dyld耗時
-
Total pre-main time --> main函數(shù)前的
總耗時。- dylib loading time --> dylib庫的加載耗時。(官方建議,
動態(tài)庫不超過6個) - rebase/binding time -->
重定向(從磁盤的MachO中image鏡像到內(nèi)存中)和綁定(MachO中每個文件使用其他庫的符號時,綁定庫名和地址)操作的耗時.
注意:出于安全考慮,編譯時和運行時地址不一樣。使用了ASLR隨機偏移值來進(jìn)行內(nèi)存讀取,這就是要重定向和重新綁定的原因。 - ObjC setup time --> OC類的注冊耗時。 (OC類越多,越耗時)
- initializer time --> 初始化耗時。(load非懶加載類和c++構(gòu)造函數(shù)的耗時)
- dylib loading time --> dylib庫的加載耗時。(官方建議,
-
slowest intializers --> 最慢的啟動對象
- libSystem.B.dylib --> 系統(tǒng)庫
- libMainThreadChecker.dylib --> 系統(tǒng)庫
- libglInterpose.dylib --> 系統(tǒng)庫(調(diào)試使用的,不影響)
- 砸殼應(yīng)用 --> 自己的APP耗時
1.3.3 main函數(shù)后
接下來我們看看main函數(shù)后如何處理。
- 啟動時用不到的類和頁面,移到啟動后創(chuàng)建
- 耗時操作使用多線程處理
- 啟動頁面,盡量不用XIB和StoryBoard
二、二進(jìn)制重排
二進(jìn)制重排這個方案最開始是由于抖音的這篇文章抖音研發(fā)實踐:基于二進(jìn)制文件重排的解決方案 APP啟動速度提升超15%火起來的,專門針對pre-main階段優(yōu)化的一個方案。
2.1 重排的原理
應(yīng)用啟動前,頁表是空的,每一頁都是PageFault(頁缺省),啟動時用到的每一頁都需要cpu從硬盤讀取到物理內(nèi)存中,基于Page Fault,App在冷啟動過程中,會有大量的類、分類、三方庫等需要加載和執(zhí)行,此時的產(chǎn)生的Page Fault所帶來的的耗時是很大的。那么,我們得想辦法,減少在啟動時需要加載的頁數(shù)。
iOS中每一頁是16K大小,但是16K中,可能真正在啟動時刻需要用到的,可能不到1K。 但是啟動需要訪問到這1K數(shù)據(jù),不得不把整頁都加載。
我們的二進(jìn)制重排,就是為了把啟動用到的這些數(shù)據(jù),整合到一起,然后再進(jìn)行內(nèi)存分頁。這樣啟動用到的數(shù)據(jù)都在前幾頁中了。啟動時,只需要加載幾頁數(shù)據(jù)就可以了。

2.2 動手實踐
2.2.1 方法順序 & 文件加載順序
- 創(chuàng)建個TestReArrange工程,加入測試代碼:
#import "ViewController.h"
@interface ViewController ()
@end
@implementation ViewController
void test1() {
printf("1");
}
void test2() {
printf("2");
}
- (void)viewDidLoad {
[super viewDidLoad];
printf("viewDidLoad");
test1();
}
+(void)load {
printf("load");
test2();
}
@end
- 在Build Settings中搜索
link Map,設(shè)置Write Link Map File為YES

3.cmd+B 編譯項目,找到product文件夾,右鍵Show In Finder

- 在包文件夾的上兩層級,找到
Intermediates.noindex

- 打開,找到并打開
TestReArrange-LinkMap-normal-x86_64文件


可以發(fā)現(xiàn),我們在ViewController.m中聲明的函數(shù)的順序,和在TestReArrange-LinkMap-normal-x86_64文件中顯示的順序是一致的。
再接著看TestReArrange-LinkMap-normal-x86_64文件,發(fā)現(xiàn)方法的加載順序和Build Phases里的文件的編譯順序也是一致的

綜上,二進(jìn)制的排列順序:先
文件按照加載順序排列,文件內(nèi)部按照函數(shù)書寫從上到下的書序排列。
2.2.2 PageFault檢測
找一個比較大的項目,按照下面的步驟檢測
- 連接
真機,運行項目,打開Instruments檢測工具

- 選擇
System Trace

- 選擇
真機,選擇自己的項目,點擊第一個按鈕運行,等APP啟動后,點擊第一個按鈕停止。


- 選擇
Process,找到自己項目的BundleID

- 選中
主線程,選擇虛擬內(nèi)存,查看File Backed Page In(就是PageFault缺省頁)


可以看到,缺省頁就2頁,耗時170.83微秒,平均耗時85.19微秒,這是熱啟動的情況下。
我們再看看冷啟動(數(shù)據(jù)應(yīng)該更大)

果然,缺省頁4027頁,耗時865.25毫秒,平均耗時214.86微秒。
2.2.3 重排初體驗
二進(jìn)制重排,關(guān)鍵是.order文件。我們之前用的objc源碼,會在工程中看到.order文件

打開.order文件,可以看到內(nèi)部都是排序好的函數(shù)符號

有很多系統(tǒng)的函數(shù),這是因為蘋果自己的庫,也都進(jìn)行了二進(jìn)制重排。
現(xiàn)在,我們在自己的TestReArrange項目里,試一下改變這個.order文件的函數(shù)符號,看看有什么效果。
- 在TestReArrange項目根目錄創(chuàng)建一個.order文件

- 打開創(chuàng)建的
TestReArrange.order,手動動添加順序load->test1->ViewDidLoad->main

- 配置order文件,在
Build Settings中搜索order file,加入./TestReArrange.order

- Command + B編譯后,再次去查看
link map文件

可以發(fā)現(xiàn):
- 發(fā)現(xiàn)order文件中不存在的函數(shù)(hello),編譯器會直接跳過。
- 其他函數(shù)符號,完全按照我們order順序排列。
- order中沒有的函數(shù),按照默認(rèn)順序接在order函數(shù)后面。
至此,我們驗證發(fā)現(xiàn)了oder文件的重要性,但是,靠手寫一個個函數(shù)到order文件中,如果項目代碼量很大,我們怎么知道哪些方法必須調(diào)用?況且一個大的項目,是多個人一起開發(fā),別人負(fù)責(zé)的模塊根本不清楚,這時該怎么辦?這時就要引入我們的重點模塊--> clang插樁!
三、重點:clang插樁
試想上面的問題,一是我們想將手動寫函數(shù)改為自動寫函數(shù)到order文件,二是得想辦法獲取到App啟動時調(diào)用的所有方法的名稱。第一點實現(xiàn)起來很簡單,一個簡單的文件寫操作即可,關(guān)鍵是第二點,如何獲取你想要的方法名稱?很直觀的,我們會想到hook,通過方法hook,先獲取到方法名稱,存起來再寫入到order文件。
3.1 Hook方案
hook大致有以下幾種方案:
hook
objc_msgSend:我們知道,方法調(diào)用的本質(zhì)是發(fā)送消息,在底層都會來到objc_msgSend,但是由于objc_msgSend的參數(shù)是可變的,需要通過匯編獲取,對開發(fā)人員要求較高,而且也只能拿到OC和swift中@objc后的方法,對于c、c++函數(shù)則無法捕捉,pass!fishhook:fishhook 是 FaceBook 開源的可以動態(tài)修改 MachO 符號表的工具。fishhook 的強大之處在于它可以 HOOK 系統(tǒng)的靜態(tài) C 函數(shù)。fishhook利用ios的動態(tài)庫符號延遲綁定機制進(jìn)行hook,但是這種延遲綁定機制僅有在可執(zhí)行文件調(diào)用動態(tài)庫或framework時才會發(fā)生。而動態(tài)庫和framework之間的相互調(diào)用,在被加載時就確定了所有符號的地址,調(diào)用時是直接跳到相應(yīng)的函數(shù)入口地址,所以fishhook不能hook其它庫例如第三方庫里的函數(shù),pass!clang插樁:官方文檔,文檔中指出,llvm內(nèi)置了一個簡單的代碼覆蓋率檢測(
SanitizerCoverage)。它在函數(shù)級、基本塊級和邊緣級插入對用戶定義函數(shù)的調(diào)用。我們這里的批量hook,就需要借助于SanitizerCoverage。

3.2 配置 & 使用 SanitizerCoverage
- 配置開啟
SanitizerCoverage,按照項目使用的語言區(qū)分:
OC項目,需要在:在
Build Settings里的Other C Flags中添加-fsanitize-coverage=trace-pc-guard如果是Swift項目,還需要額外在
Other Swift Flags中加入-sanitize-coverage=func和-sanitize=undefined如果集成了
cocoapods管理項目,也可在podfile中修改
post_install do |installer|
installer.pods_project.targets.each do |target|
target.build_configurations.each do |config|
config.build_settings['OTHER_CFLAGS'] = '-fsanitize-coverage=func,trace-pc-guard'
config.build_settings['OTHER_SWIFT_FLAGS'] = '-sanitize-coverage=func -sanitize=undefined'
end
end
end
- 使用
SanitizerCoverage
- 新建工程
TraceDemo,按照步驟1里配置

- 去官方文檔中,copy示例代碼

我們可以在ViewController.m中添加該代碼(多余的注釋可去掉)
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;
}
void __sanitizer_cov_trace_pc_guard(uint32_t *guard) {
if (!*guard) return;
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);
}
再添加測試代碼
void test(){
block1();
}
void(^block1)(void) = ^(void){
};
- (void)viewDidLoad {
[super viewDidLoad];
test();
}
- run,看看控制臺的輸出
注意:一定要
真機調(diào)試!
發(fā)現(xiàn)報錯

可注釋改行代碼

繼續(xù)運行

上圖發(fā)現(xiàn),__sanitizer_cov_trace_pc_guard_init函數(shù)的參數(shù)start 和 stop的地址。我們先看看__sanitizer_cov_trace_pc_guard_init的參數(shù)釋義
__sanitizer_cov_trace_pc_guard_init函數(shù)
- uint32_t *start
是一個指針,指向無符號int類型,4個字節(jié),相當(dāng)于一個數(shù)組的起始位置,即符號的起始位置(是從高位往低位讀)。我們看看打印出的地址具體信息

- uint32_t *stop
由于數(shù)據(jù)的地址是往下讀的(即從高往低讀,所以此時獲取的地址并不是stop真正的地址,而是標(biāo)記的最后的地址,讀取stop時,由于stop占4個字節(jié),stop真實地址 = stop打印的地址-0x4)。我們看看打印出的地址具體信息

那么stop中到底存儲了什么信息呢?我們再增加一個方法/塊/c++/屬性的方法(多3個),發(fā)現(xiàn)其值也會增加對應(yīng)的數(shù),例如增加一個test1方法
void test1() {
block1();
}
- (void)viewDidLoad {
[super viewDidLoad];
test();
test1();
}
運行

我們發(fā)現(xiàn),由之前1a 變成了 1b,增加了方法的調(diào)用,stop的地址也對應(yīng)的增加。
__sanitizer_cov_trace_pc_guard函數(shù)
我們再來看看__sanitizer_cov_trace_pc_guard函數(shù)的參數(shù)uint32_t *guard
- 參數(shù)guard是一個
哨兵,告訴我們是第幾個被調(diào)用的。
示例:我們新增一個監(jiān)聽屏幕點擊的方法,在里面調(diào)用test
- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
test();
}
先run,再點擊屏幕,看控制臺輸出

這3次分別是touchBegin、test、block三個函數(shù)被觸發(fā)時的打印!
驗證:我們在touchesBegan 和__sanitizer_cov_trace_pc_guard里分別加入斷點,

運行,查看匯編

上圖可知,確實在touchesBegan的調(diào)用中先調(diào)用了__sanitizer_cov_trace_pc_guard。
至此,我們得出結(jié)論
通過__sanitizer_cov_trace_pc_guard這個函數(shù),可以hook住所有的方法。
那么接下來,就是需要獲取所有函數(shù)的名稱(即函數(shù)符號),然后存儲并導(dǎo)出.order文件。
3.3 獲取函數(shù)符號
在__sanitizer_cov_trace_pc_guard這個函數(shù)中,有一句代碼void *PC = __builtin_return_address(0);,這個__builtin_return_address函數(shù)作用是什么?我們先驗證一下
__builtin_return_address
我們可以通過Dl_info接收PC信息 ,再打印查看
注意:需引入頭文件
#import <dlfcn.h>
typedef struct dl_info {
const char *dli_fname; /* 文件地址*/
void *dli_fbase; /* 起始地址(machO模塊的虛擬地址)*/
const char *dli_sname; /* 符號名稱 */
void *dli_saddr; /* 內(nèi)存真實地址(偏移后的真實物理地址) */
} Dl_info;
修改代碼
void __sanitizer_cov_trace_pc_guard(uint32_t *guard) {
if(!*guard) return;
void *PC = __builtin_return_address(0); //0 當(dāng)前函數(shù)地址, 1 上一層級函數(shù)地址
Dl_info info; // 聲明對象
dladdr(PC, &info); // 讀取PC地址,賦值給info
printf("dli_fname:%s \n dli_fbase:%p \n dli_sname:%s \n dli_saddr:%p \n ", info.dli_fname, info.dli_fbase, info.dli_sname, info.dli_saddr);
}
run

這樣,我們就拿到了函數(shù)符號。接下來就是存儲寫入.order文件了。
3.2 .order文件寫入
寫入文件時,我們得考慮兩個問題:
-
多線程的情況 - 用什么數(shù)據(jù)結(jié)構(gòu)來存儲
函數(shù)符號
在之前的鎖的原理中分析過,加鎖可以應(yīng)對多線程對資源的競爭,那么此時我們可使用OSAtomic原子鎖。然后采用鏈表去存儲函數(shù)符號,因為鏈表的插入和刪除比數(shù)組這樣的有序表速度更快,效率更高。
綜上分析,我們可以采用系統(tǒng)提供的原子隊列 OSQueue防止多線程資源競爭,然后將函數(shù)符號存在鏈表結(jié)構(gòu)體當(dāng)中。
原子隊列的使用
- 引入頭文件
#import <libkern/OSAtomic.h> - 定義原子隊列
static OSQueueHead symbolList = OS_ATOMIC_QUEUE_INIT; - 定義符號結(jié)構(gòu)體,用于接收
函數(shù)符號信息,void * next表示是個鏈表的結(jié)構(gòu)。
typedef struct{
void *pc;
void *next;
} SYNode;
- 修改
__sanitizer_cov_trace_pc_guard,代碼如下
void __sanitizer_cov_trace_pc_guard(uint32_t *guard) {
if (!*guard) return;
void *PC = __builtin_return_address(0);
// 創(chuàng)建結(jié)構(gòu)體!
SYNode * node = malloc(sizeof(SYNode));
*node = (SYNode){PC,NULL};
//加入結(jié)構(gòu)!
OSAtomicEnqueue(&symbolList, node, offsetof(SYNode, next));
}
至此,函數(shù)的符號信息,被存儲在了SYNode結(jié)構(gòu)體鏈表中,然后OSAtomicEnqueue加入了原子隊列防止多線程。
生成.order文件
我們可以在touchesBegan中取出函數(shù)符號,存儲文件,代碼如下
-(void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event
{
// 創(chuàng)建可變數(shù)組
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 (1) {
// 去除鏈表
SYNode * node = OSAtomicDequeue(&symbolList, offsetof(SYNode, next));
if(node ==NULL) break;
Dl_info info = {0};
// 取出節(jié)點的pc,賦值給info
dladdr(node->pc, &info);
// 釋放節(jié)點
free(node);
// 存名字
NSString *name = @(info.dli_sname);
NSLog(@"方法名稱:%@", name);
// 三目運算符 寫法
BOOL isObjc = [name hasPrefix: @"+["] || [name hasPrefix: @"-["];
NSString * symbolName = isObjc ? name : [NSString stringWithFormat:@"_%@",name];
[symbolNames addObject:symbolName];
}
// 反向集合
NSEnumerator * enumerator = [symbolNames reverseObjectEnumerator];
// 創(chuàng)建數(shù)組
NSMutableArray * funcs = [NSMutableArray arrayWithCapacity:symbolNames.count];
// 臨時變量
NSString * name;
// 遍歷集合,去重,添加到funcs中
while (name = [enumerator nextObject]) {
// 數(shù)組中去重添加
if (![funcs containsObject:name]) {
[funcs addObject:name];
}
}
// 移除當(dāng)前touchesBegan函數(shù) (跟啟動無關(guān))
[funcs removeObject:[NSString stringWithFormat:@"%s",__FUNCTION__]];
// 數(shù)組轉(zhuǎn)字符串
NSString * funcStr = [funcs componentsJoinedByString:@"\n"];
// 文件路徑
NSString * filePath = [NSTemporaryDirectory() stringByAppendingPathComponent:@"ht.order"];
// 文件內(nèi)容
NSData * fielContents = [funcStr dataUsingEncoding:NSUTF8StringEncoding];
// 創(chuàng)建文件
[[NSFileManager defaultManager] createFileAtPath:filePath contents:fielContents attributes:nil];
NSLog(@"%@",funcs);
NSLog(@"%@",filePath);
NSLog(@"%@",fielContents);
}
- 首先通過
while循環(huán)遍歷原子隊列,取出函數(shù)符號信息,存儲在數(shù)組 - 因為會存在重復(fù)調(diào)用的情況,所以
去重 - 還需要移除
touchesBegan這一次的調(diào)用 - 文件寫入
坑點
if(!*guard) return;需要去掉,會影響+load的寫入
-
while循環(huán),也會不停的觸發(fā)
__sanitizer_cov_trace_pc_guard,輸出touchesBegan
通過看匯編,可以看到while也觸發(fā)了__sanitizer_cov_trace_pc_guard的跳轉(zhuǎn)。原因是trace的觸發(fā)并不是根據(jù)函數(shù)來進(jìn)行hook的,而是hook了每一個跳轉(zhuǎn)(bl)。因為while也有跳轉(zhuǎn),所以進(jìn)入了死循環(huán)。
解決方法
Build Settings的Other C Flags配置,添加一個func指定條件-fsanitize-coverage=func,trace-pc-guard
補充:swift版的插樁
總結(jié)
本篇文章篇幅很長,主要分析了App啟動時虛擬內(nèi)存的處理模式,再結(jié)合修改link符號文件對啟動時調(diào)用函數(shù)順序的變化,代碼hook所有函數(shù)符號,并寫入文件,實現(xiàn)了一個簡單的clang插樁流程。

