啟動優(yōu)化解析

前言

之前我們分析過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)存這么使用,會有以下問題:

  1. 內(nèi)存不夠:每個應(yīng)用一打開,就把所有信息都加載進(jìn)入內(nèi)存,占用太多資源。如果是體積大的軟件,則直接無法加載。
  2. 不安全: 應(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)用就具備了以下特點

  1. 每個應(yīng)用(進(jìn)程)默認(rèn)可以分配4G大小。但它實際只是一張頁表,記錄映射關(guān)系就可以。
  2. 頁表存放在操作系統(tǒng)的內(nèi)存區(qū)域。
  3. 應(yīng)用用到的都是虛擬內(nèi)存,實際占有物理內(nèi)存大小是應(yīng)用運行時決定的。

1.2 冷啟動 vs 熱啟動

應(yīng)用的啟動大致分為3種情況:

  1. 首次啟動
  2. kill應(yīng)用后重新啟動
  3. 應(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種啟動的場景,分別是那種啟動呢?

  1. 首次啟動: 一定是冷啟動。(內(nèi)存中無數(shù)據(jù))
  2. kill后啟動:冷啟動熱啟動。 (取決于內(nèi)存中是否有數(shù)據(jù))
  3. 置于后臺再回到前臺啟動: 冷啟動熱啟動。(取決于內(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包砸殼,這些不作為重點,知道即可。

  1. 創(chuàng)建一個Demo工程,新增環(huán)境變量 DYLD_PRINT_STATISTICS
  1. 砸殼 (ps:此過程忽略,感興趣的朋友可網(wǎng)上自行搜索。)
  2. 添加重簽名腳本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工程目錄中,如下圖

配置腳本,如下圖

  1. 運行項目
    運行結(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耗時
  1. 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ù)的耗時)
  2. 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 方法順序 & 文件加載順序
  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
  1. 在Build Settings中搜索link Map,設(shè)置Write Link Map FileYES

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

  1. 在包文件夾的上兩層級,找到Intermediates.noindex
  1. 打開,找到并打開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檢測

找一個比較大的項目,按照下面的步驟檢測

  1. 連接真機,運行項目,打開Instruments檢測工具
  1. 選擇System Trace
  1. 選擇真機,選擇自己的項目,點擊第一個按鈕運行,等APP啟動后,點擊第一個按鈕停止。
  1. 選擇Process,找到自己項目的BundleID
  1. 選中主線程,選擇虛擬內(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ù)符號,看看有什么效果。

  1. 在TestReArrange項目根目錄創(chuàng)建一個.order文件
  1. 打開創(chuàng)建的TestReArrange.order,手動動添加順序load->test1->ViewDidLoad->main
  1. 配置order文件,在Build Settings中搜索order file,加入./TestReArrange.order
  1. Command + B編譯后,再次去查看link map文件

可以發(fā)現(xiàn):

  1. 發(fā)現(xiàn)order文件中不存在的函數(shù)(hello),編譯器會直接跳過。
  2. 其他函數(shù)符號,完全按照我們order順序排列。
  3. 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大致有以下幾種方案:

  1. hook objc_msgSend:我們知道,方法調(diào)用的本質(zhì)是發(fā)送消息,在底層都會來到objc_msgSend,但是由于objc_msgSend參數(shù)是可變的,需要通過匯編獲取,對開發(fā)人員要求較高,而且也只能拿到OCswift中@objc后的方法,對于c、c++函數(shù)則無法捕捉,pass!

  2. fishhookfishhook 是 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!

  3. clang插樁:官方文檔,文檔中指出,llvm內(nèi)置了一個簡單的代碼覆蓋率檢測(SanitizerCoverage)。它在函數(shù)級、基本塊級和邊緣級插入對用戶定義函數(shù)的調(diào)用。我們這里的批量hook,就需要借助于SanitizerCoverage。

3.2 配置 & 使用 SanitizerCoverage

  1. 配置開啟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
  1. 使用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();
}
  1. run,看看控制臺的輸出

注意:一定要真機調(diào)試!

發(fā)現(xiàn)報錯


可注釋改行代碼

繼續(xù)運行

上圖發(fā)現(xiàn),__sanitizer_cov_trace_pc_guard_init函數(shù)的參數(shù)startstop的地址。我們先看看__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文件寫入

寫入文件時,我們得考慮兩個問題:

  1. 多線程的情況
  2. 用什么數(shù)據(jù)結(jié)構(gòu)來存儲函數(shù)符號

在之前的鎖的原理中分析過,加鎖可以應(yīng)對多線程對資源的競爭,那么此時我們可使用OSAtomic原子鎖。然后采用鏈表去存儲函數(shù)符號,因為鏈表的插入刪除數(shù)組這樣的有序表速度更快,效率更高。
綜上分析,我們可以采用系統(tǒng)提供的原子隊列 OSQueue防止多線程資源競爭,然后將函數(shù)符號存在鏈表結(jié)構(gòu)體當(dāng)中。

原子隊列的使用
  1. 引入頭文件 #import <libkern/OSAtomic.h>
  2. 定義原子隊列static OSQueueHead symbolList = OS_ATOMIC_QUEUE_INIT;
  3. 定義符號結(jié)構(gòu)體,用于接收函數(shù)符號信息,void * next表示是個鏈表的結(jié)構(gòu)。
typedef struct{
    void *pc;
    void *next;
} SYNode;
  1. 修改__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);
}
  1. 首先通過while循環(huán)遍歷原子隊列,取出函數(shù)符號信息,存儲在數(shù)組
  2. 因為會存在重復(fù)調(diào)用的情況,所以去重
  3. 還需要移除touchesBegan這一次的調(diào)用
  4. 文件寫入
坑點
  1. if(!*guard) return;需要去掉,會影響+load的寫入

  2. 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 SettingsOther C Flags 配置,添加一個func指定條件 -fsanitize-coverage=func,trace-pc-guard

補充:swift版的插樁

總結(jié)

本篇文章篇幅很長,主要分析了App啟動時虛擬內(nèi)存的處理模式,再結(jié)合修改link符號文件對啟動時調(diào)用函數(shù)順序的變化,代碼hook所有函數(shù)符號,并寫入文件,實現(xiàn)了一個簡單的clang插樁流程。

本篇文章參考
OC底層原理三十三:啟動優(yōu)化(二進(jìn)制重排)

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

  • 一 、了解自己項目冷啟動階段耗時。 1、配置 環(huán)境變量 獲取 dyld 反饋。DYLD_PRINT_STATIST...
    猿人閱讀 941評論 0 6
  • 測試啟動時間 下面我們通過重簽名微信的IPA包來測試一下微信的啟動耗時。創(chuàng)建一個項目,然后將微信的IPA包以及重簽...
    半邊楓葉閱讀 1,136評論 0 3
  • 參考鏈接: 抖音研發(fā)實踐:基于二進(jìn)制文件重排的解決方案 APP啟動速度提升超15% 一、原理 1、虛擬內(nèi)存和物理內(nèi)...
    Foxhoundsun閱讀 1,300評論 0 3
  • 原理: 1.Page Fault 進(jìn)程直接訪問物理內(nèi)存是不安全的,所以操作系統(tǒng)在物流內(nèi)存上又建立了一層虛擬內(nèi)存,虛...
    crazyfox閱讀 782評論 0 3
  • 漸變的面目拼圖要我怎么拼? 我是疲乏了還是投降了? 不是不允許自己墜落, 我沒有滴水不進(jìn)的保護(hù)膜。 就是害怕變得面...
    悶熱當(dāng)乘涼閱讀 4,502評論 0 13

友情鏈接更多精彩內(nèi)容