iOS 啓動優化之Clang插樁實現二進制重排 資料推薦

前言

原文作者:李斌同學
原文鏈接:https://juejin.im/post/6844904130406793224

  • 本篇文章首先講述下二進制重排的原理 , ( 因爲抖音團隊在上述文章中原理部分大多是點到即止 , 多數朋友看完並沒有什麼實際收穫 ) . 然後將結合 clang 插樁的方式 來實際講述和演練一下如何解決抖音團隊遺留下來的這一問題 :
`hook Objc_msgSend 無法解決的 純swift , block , c++ 方法` .



來達到完美的二進制重排方案 .

( 本篇文章由於會從原理角度講解 , 有些已經比較熟悉的同學可能會覺得節奏偏囉嗦 , 爲了照顧大部分同學 , 大家自行根據目錄跳過即可 . )

瞭解二進制重排之前 , 我們需要了解一些前導知識 , 以及二進制重排是爲了解決什麼問題 .

虛擬內存與物理內存

在本篇文章裏 , 筆者就不通過教科書或者大多數資料的方式來講述這個概念了 . 我們通過實際問題和其對應的解決方式來看這個技術 or 概念 .

在計算機領域 , 任何一個技術 or 概念 , 都是爲了解決實際的問題而誕生的 .

在早期的計算機中 , 並沒有虛擬內存的概念 , 任何應用被從磁盤中加載到運行內存中時 , 都是完整加載和按序排列的 .

那麼因此 , 就會出現兩個問題 :

使用物理內存時遺留的問題

  • 安全問題 : 由於在內存條中使用的都是真實物理地址 , 而且內存條中各個應用進程都是按順序依次排列的 . 那麼在 進程1 中通過地址偏移就可以訪問到 其他進程 的內存 .
  • 效率問題 : 隨着軟件的發展 , 一個軟件運行時需要佔用的內存越來越多 , 但往往用戶並不會用到這個應用的所有功能 , 造成很大的內存浪費 , 而後面打開的進程往往需要排隊等待 .

爲了解決上述兩個問題 , 虛擬內存應運而生 .

虛擬內存工作原理

引用了虛擬內存後 , 在我們進程中認爲自己有一大片連續的內存空間實際上是虛擬的 , 也就是說從 0x000000 ~ 0xffffff 我們是都可以訪問的 . 但是實際上這個內存地址只是一個虛擬地址 , 而這個虛擬地址通過一張映射表映射後纔可以獲取到真實的物理地址 .

什麼意思呢 ?

  • 實際上我們可以理解爲 , 系統對真實物理內存訪問做了一層限制 , 只有被寫到映射表中的地址纔是被認可可以訪問的 .
  • 例如 , 虛擬地址 0x000000 ~ 0xffffff 這個範圍內的任意地址我們都可以訪問 , 但是這個虛擬地址對應的實際物理地址是計算機來隨機分配到內存頁上的 .
  • 這裏提到了實際物理內存分頁的概念 , 下面會詳細講述 .

可能大家也有注意到 , 我們在一個工程中獲取的地址 , 同時在另一個工程中去訪問 , 並不能訪問到數據 , 其原理就是虛擬內存 .

整個虛擬內存的工作原理這裏用一張圖來展示 :

虛擬內存解決進程間安全問題原理

顯然 , 引用虛擬內存後就不存在通過偏移可以訪問到其他進程的地址空間的問題了 .

因爲每個進程的映射表是單獨的 , 在你的進程中隨便你怎麼訪問 , 這些地址都是受映射表限制的 , 其真實物理地址永遠在規定範圍內 , 也就不存在通過偏移獲取到其他進程的內存空間的問題了 .

而且實際上 , 每次應用被加載到內存中 , 實際分配的物理內存並不一定是固定或者連續的 , 這是因爲內存分頁以及懶加載以及 ASLR 所解決的安全問題 .

cpu 尋址過程

引入虛擬內存後 , cpu 在通過虛擬內存地址訪問數據的過程如下 :

  • 通過虛擬內存地址 , 找到對應進程的映射表 .
  • 通過映射表找到其對應的真實物理地址 , 進而找到數據 .

這個過程被稱爲 地址翻譯 , 這個過程是由操作系統以及 cpu 上集成的一個 硬件單元 MMU 協同來完成的 .

那麼安全問題解決了以後 , 效率問題如何解決呢 ?

虛擬內存解決效率問題

剛剛提到虛擬內存和物理內存通過映射表進行映射 , 但是這個映射並不可能是一一對應的 , 那樣就太過浪費內存了 . 爲了解決效率問題 , 實際上真實物理內存是分頁的 . 而映射表同樣是以頁爲單位的 .

換句話說 , 映射表只會映射到一頁 , 並不會映射到具體每一個地址 .

linux 系統中 , 一頁內存大小爲 4KB , 在不同平臺可能各有不同 .

  • Mac OS 系統中 , 一頁爲 4KB ,
  • iOS 系統中 , 一頁爲 16KB .

我們可以使用 pagesize 命令直接查看 .

那麼爲什麼說內存分頁就可以解決內存浪費的效率問題呢 ?

內存分頁原理

假設當前有兩個進程正在運行 , 其狀態就如下圖所示 :

( 上圖中我們也看出 , 實際物理內存並不是連續以及某個進程完整的 ) .

映射表左側的 01 代表當前地址有沒有在物理內存中 . 爲什麼這麼說呢 ?

  • 當應用被加載到內存中時 , 並不會將整個應用加載到內存中 . 只會放用到的那一部分 . 也就是懶加載的概念 , 換句話說就是應用使用多少 , 實際物理內存就實際存儲多少 .
  • 當應用訪問到某個地址 , 映射表中爲 0 , 也就是說並沒有被加載到物理內存中時 , 系統就會立刻阻塞整個進程 , 觸發一個我們所熟知的 缺頁中斷 - Page Fault .
  • 當一個缺頁中斷被觸發 , 操作系統會從磁盤中重新讀取這頁數據到物理內存上 , 然後將映射表中虛擬內存指向對應 ( 如果當前內存已滿 , 操作系統會通過置換頁算法 找一頁數據進行覆蓋 , 這也是爲什麼開再多的應用也不會崩掉 , 但是之前開的應用再打開時 , 就重新啓動了的根本原因 ).

通過這種分頁和覆蓋機制 , 就完美的解決了內存浪費和效率問題 .

但是此時 , 又出現了一個問題 .

問 : 當應用開發完成以後由於採用了虛擬內存 , 那麼其中一個函數無論如何運行 , 運行多少次 , 都會是虛擬內存中的固定地址 .

什麼意思呢 ?

假設應用有一個函數 , 基於首地址偏移量爲 0x00a000 , 那麼虛擬地址從 0x000000 ~ 0xffffff , 基於這個 , 那麼這個函數我無論如何只需要通過 0x00a000 這個虛擬地址就可以拿到其真實實現地址 .

而這種機制就給了很多黑客可操作性的空間 , 他們可以很輕易的提前寫好程序獲取固定函數的實現進行修改 hook 操作 .

爲了解決這個問題 , ASLR 應運而生 . 其原理就是 每次 虛擬地址在映射真實地址之前 , 增加一個隨機偏移值 , 以此來解決我們剛剛所提到的這個問題 .

( Android 4.0 , Apple iOS4.3 , OS X Mountain Lion10.8 開始全民引入 ASLR 技術 , 而實際上自從引入 ASLR 後 , 黑客的門檻也自此被拉高 . 不再是人人都可做黑客的年代了 ) .

至此 , 有關物理內存 , 虛擬內存 , 內存分頁的完整流程和原理 , 我們已經講述完畢了 , 那麼接下來來到重點 , 二進制重排 .

二進制重排

概述

在瞭解了內存分頁會觸發中斷異常 Page Fault 會阻塞進程後 , 我們就知道了這個問題是會對性能產生影響的 .

實際上在 iOS 系統中 , 對於生產環境的應用 , 當產生缺頁中斷進行重新加載時 , iOS 系統還會對其做一次簽名驗證 . 因此 iOS 生產環境的應用 page fault 所產生的耗時要更多 .

抖音團隊分享的一個 Page Fault,開銷在 0.6 ~ 0.8ms , 實際測試發現不同頁會有所不同 , 也跟 cpu 負荷狀態有關 , 在 0.1 ~ 1.0 ms 之間 。

當用戶使用應用時 , 第一個直接印象就是啓動 app 耗時 , 而恰巧由於啓動時期有大量的類 , 分類 , 三方 等等需要加載和執行 , 多個 page fault 所產生的的耗時往往是不能小覷的 . 這也是二進制重排進行啓動優化的必要性 .

二進制重排優化原理

假設在啓動時期我們需要調用兩個函數 method1method4 . 函數編譯在 mach-o 中的位置是根據 ld ( Xcode 的鏈接器) 的編譯順序並非調用順序來的 . 因此很可能這兩個函數分佈在不同的內存頁上 .

那麼啓動時 , page1page2 則都需要從無到有加載到物理內存中 , 從而觸發兩次 page fault .

而二進制重排的做法就是將 method1method4 放到一個內存頁中 , 那麼啓動時則只需要加載 page1 即可 , 也就是隻觸發一次 page fault , 達到優化目的 .

實際項目中的做法是將啓動時需要調用的函數放到一起 ( 比如 前10頁中 ) 以儘可能減少 page fault , 達到優化目的 . 而這個做法就叫做 : 二進制重排 .

講到這裏相信很多同學已經迫不及待的想要看看具體怎麼二進制重排了 . 其實操作很簡單 , 但是在操作之前我們還需要知道這幾點 :

  • 如何檢測 page fault : 首先我們要想看到優化效果 , 就應該知道如何查看 page fault , 以此來幫助我們查看優化前以及優化後的效果 .
  • 如何重排二進制 .
  • 如何查看自己重排成功了沒有 ?
  • 如何檢測自己啓動時刻需要調用的所有方法 .
*   `hook objc_MsgSend` ( 只能拿到 `oc` 以及 `swift` 加上 `@objc dynamic` 修飾後的方法 ) .
*   靜態掃描 `macho` 特定段和節裏面所存儲的符號以及函數數據 . (靜態掃描 , 主要用來獲取 `load` 方法 , `c++ 構造`(有關 c++ 構造 , 參考 [從頭梳理 dyld 加載流程](https://juejin.im/post/6844904040149729294) 這篇文章有詳細講述和演示 ) .
*   `clang` 插樁 ( 完美版本 , 完全拿到 `swift` , `oc` , `c` , `block` 全部函數 )

內容很多 , 我們一項一項來 .

如何查看 page fault

提示 :
如果想查看真實 page fault 次數 , 應該將應用卸載 , 查看第一次應用安裝後的效果 , 或者先打開很多個其他應用 .

因爲之前運行過 app , 應用其中一部分已經被加載到物理內存並做好映射表映射 , 這時再啓動就會少觸發一部分缺頁中斷 , 並且殺掉應用再打開也是如此 .

其實就是希望將物理內存中之前加載的覆蓋/清理掉 , 減少誤差 .

  • 1️⃣ : 打開 Instruments , 選擇 System Trace .
  • 2️⃣ : 選擇真機 , 選擇工程 , 點擊啓動 , 當首個頁面加載出來點擊停止 . 這裏注意 , 最好是將應用殺掉重新安裝 , 因爲冷熱啓動的界定其實由於進程的原因並不一定後臺殺掉應用重新打開就是冷啓動 .
  • 3️⃣ : 等待分析完成 , 查看缺頁次數
    • 後臺殺掉重啓應用
*   第一次安裝啓動應用

當然 , 你可以通過添加 DYLD_PRINT_STATISTICS 來查看 pre-main 階段總耗時來做一個側面輔證 .

大家可以分別測試以下幾種情況 , 來深度理解冷啓動 or 熱啓動以及物理內存分頁覆蓋的實際情況 .

  • 應用第一次安裝啓動
  • 應用後臺沒有打開時啓動
  • 殺掉後臺後重新啓動
  • 不殺掉後臺重新啓動
  • 殺掉後臺後多打開一些其他應用再次啓動

二進制重排具體如何操作

說了這麼多前導知識 , 終於要開始做二進制重排了 , 其實具體操作很簡單 , Xcode 已經提供好這個機制 , 並且 libobjc 實際上也是用了二進制重排進行優化 .

參考下圖


  • 首先 , Xcode 是用的鏈接器叫做 ld , ld 有一個參數叫 Order File , 我們可以通過這個參數配置一個 order 文件的路徑 .
  • 在這個 order 文件中 , 將你需要的符號按順序寫在裏面 .
  • 當工程 build 的時候 , Xcode 會讀取這個文件 , 打的二進制包就會按照這個文件中的符號順序進行生成對應的 mach-O .

二進制重排疑問 - 題外話 :

  • 1️⃣ : order 文件裏 符號寫錯了或者這個符號不存在會不會有問題 ?
*   答 : `ld` 會忽略這些符號 , 實際上如果提供了 `link` 選項 `-order_file_statistics`,會以 `warning` 的形式把這些沒找到的符號打印在日誌裏。 .
  • 2️⃣ : 有部分同學可能會考慮這種方式會不會影響上架 ?
*   答 : 首先 , `objc` 源碼自己也在用這種方式 .
*   二進制重排只是重新排列了所生成的 `macho` 中函數表與符號表的順序 .

如何查看自己工程的符號順序

重排前後我們需要查看自己的符號順序有沒有修改成功 , 這時候就用到了 Link Map .

Link Map 是編譯期間產生的產物 , ( ld 的讀取二進制文件順序默認是按照 Compile Sources - GUI 裏的順序 ) , 它記錄了二進制文件的佈局 . 通過設置 Write Link Map File 來設置輸出與否 , 默認是 no .

修改完畢後 clean 一下 , 運行工程 , Products - show in finder, 找到 macho 的上上層目錄.

按下圖依次找到最新的一個 .txt 文件並打開.

這個文件中就存儲了所有符號的順序 , 在 # Symbols: 部分 ( 前面的 .o 等內容忽略 , 這部分在筆者後續講述 llvm 編譯器篇章會詳細講解 ) .

可以看到 , 這個符號順序明顯是按照 Compile Sources 的文件順序來排列的 .

提示 :

上述文件中最左側地址就是 實際代碼地址而並非符號地址 , 因此我們二進制重排並非只是修改符號地址 , 而是利用符號順序 , 重新排列整個代碼在文件的偏移地址 , 將啓動需要加載的方法地址放到前面內存頁中 , 以此達到減少 page fault 的次數從而實現時間上的優化 , 一定要清楚這一點 .

你可以利用 MachOView 查看排列前後在 _text 段 ( 代碼段 ) 中的源碼順序來幫助理解 .

實戰演練

來到工程根目錄 , 新建一個文件 touch lb.order . 隨便挑選幾個啓動時就需要加載的方法 , 例如我這裏選了以下幾個 .

-[LBOCTools lbCurrentPresentingVC]
+[LBOCTools lbGetCurrentTimes]
+[RSAEncryptor stripPublicKeyHeader:]

寫到該文件中 , 保存 , 配置文件路徑 .


重新運行 , 查看 .

可以看到 , 我們所寫的這三個方法已經被放到最前面了 , 至此 , 生成的 macho 中距離首地址偏移量最小的代碼就是我們所寫的這三個方法 , 假設這三個方法原本在不同的三頁 , 那麼我們就已經優化掉了兩個 page fault.

錯誤提示

有部分同學可能配置完運行會發現報錯說can't open 這個 order file . 是因爲文件格式的問題 . 不用使用 mac 自帶的文本編輯 . 使用命令工具 touch 創建即可 .

獲取啓動加載所有函數的符號

講到這 , 我們就只差一個問題了 , 那就是如何知道我的項目啓動需要調用哪些方法 , 上述篇章中我們也有稍微提到一點 .

  • hook objc_MsgSend ( 只能拿到 oc 以及 swift @objc dynamic 後的方法 , 並且由於可變參數個數 , 需要用匯編來獲取參數 .)
  • 靜態掃描 macho 特定段和節裏面所存儲的符號以及函數數據 . (靜態掃描 , 主要用來獲取 load 方法 , c++ 構造(有關 c++ 構造 , 參考 從頭梳理 dyld 加載流程 這篇文章有詳細講述和演示 ) .
  • clang 插樁 ( 完美版本 , 完全拿到 swift , oc , c , block 全部函數 ) .

前兩種這裏我們就不在贅述了 . 網上參考資料也較多 , 而且實現效果也並不是完美狀態 , 本文我們來談談如何通過編譯期插樁的方式來 hook 獲取所有的函數符號 .

clang 插樁

關於 clang 的插樁覆蓋的官方文檔如下 : clang 自帶代碼覆蓋工具 文檔中有詳細概述 , 以及簡短 Demo 演示 .

思考

其實 clang 插樁主要有兩個實現思路 , 一是自己編寫 clang 插件 ( 自定義 clang 插件在後續底層篇 llvm 中會帶着大家來手寫一個自己的插件 ) , 另外一個就是利用 clang 本身已經提供的一個工具 or 機制來實現我們獲取所有符號的需求 . 本文我們就按照第二種思路來實際演練一下 .

原理探索

新建一個工程來測試和使用一下這個靜態插樁代碼覆蓋工具的機制和原理 . ( 不想看這個過程的自行跳到靜態插樁原理總結章節 )

按照文檔指示來走 .

  • 首先 , 添加編譯設置 .

直接搜索 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 裏的.

  • 運行工程 , 查看打印


代碼命名 INIT 後面打印的兩個指針地址叫 startstop . 那麼我們通過 lldb 來查看下從 startstop 這個內存地址裏面所存儲的到底是啥 .

發現存儲的是從 114 這個序號 . 那麼我們來添加一個 oc 方法 .

- (void)testOCFunc{

}

再次運行查看 .

發現從 0e 變成了 0f . 也就是說存儲的 114 這個序號變成了 115 .

那麼我們再添加一個 c 函數 , 一個 block , 和一個觸摸屏幕方法來看下 .

同樣發現序號依次增加到了 18 個 , 那麼我們得到一個猜想 , 這個內存區間保存的就是工程所有符號的個數 .

其次 , 我們在觸摸屏幕方法調用了 c 函數 , c 函數中調用了 block . 那麼我們點擊屏幕 , 發現如下 :

發現我們實際調用幾個方法 , 就會打印幾次 guard : .

實際上就類似我們埋點統計所實現的效果 . 在觸摸方法添加一個斷點查看彙編 :

通過彙編我們發現 , 在每個函數調用的第一句實際代碼 ( 棧平衡與寄存器數據準備除外 ) , 被添加進去了一個 bl 調用到 __sanitizer_cov_trace_pc_guard 這個函數中來 .

而實際上這也是靜態插樁的原理和名稱由來 .

靜態插樁總結

靜態插樁實際上是在編譯期就在每一個函數內部二進制源數據添加 hook 代碼 ( 我們添加的 __sanitizer_cov_trace_pc_guard 函數 ) 來實現全局的方法 hook 的效果 .

疑問

可能有部分同學對我上述表述的原理總結有些疑問 .

究竟是直接修改二進制在每個函數內部都添加了調用 hook 函數這個彙編代碼 , 還是隻是類似於編譯器在所生成的二進制文件添加了一個標記 , 然後在運行時如果有這個標記就會自動多做一步調用 hook 代碼呢 ?

筆者這裏使用 hopper 來看下生成的 mach-o 二進制文件 .

上述二進制源文件我們就發現 , 的確是函數內部 一開始就添加了 調用額外方法的彙編代碼 . 這也是我們爲什麼稱其爲 " 靜態插樁 " .

講到這裏 , 原理我們大體上了解了 , 那麼到底如何才能拿到函數的符號呢 ?

獲取所有函數符號

先理一下思路 .

思路

我們現在知道了 , 所有函數內部第一步都會去調用 __sanitizer_cov_trace_pc_guard 這個函數 . 那麼熟悉彙編的同學可能就有這麼個想法 :

函數嵌套時 , 在跳轉子函數時都會保存下一條指令的地址在 X30 ( 又叫 lr 寄存器) 裏 .

例如 , A 函數中調用了 B 函數 , 在 arm 彙編中即 bl + 0x**** 指令 , 該指令會首先將下一條彙編指令的地址保存在 x30 寄存器中 ,
然後在跳轉到 bl 後面傳遞的指定地址去執行 . ( 提示 : bl 能實現跳轉到某個地址的彙編指令 , 其原理就是修改 pc 寄存器的值來指向到要跳轉的地址 , 而且實際上 B 函數中也會對 x29 / x30 寄存器的值做保護防止子函數又跳轉其他函數會覆蓋掉 x30 的值 , 當然 , 葉子函數除外 . ) .

B 函數執行 ret 也就是返回指令時 , 就會去讀取 x30 寄存器的地址 , 跳轉過去 , 因此也就回到了上一層函數的下一步 .

這種思路來實現實際上是可以的 . 我們所寫的 __sanitizer_cov_trace_pc_guard 函數中的這一句代碼 :

void *PC = __builtin_return_address(0); 

它的作用其實就是去讀取 x30 中所存儲的要返回時下一條指令的地址 . 所以他名稱叫做 __builtin_return_address . 換句話說 , 這個地址就是我當前這個函數執行完畢後 , 要返回到哪裏去 .

其實 , bt 函數調用棧也是這種思路來實現的 .

也就是說 , 我們現在可以在 __sanitizer_cov_trace_pc_guard 這個函數中 , 通過 __builtin_return_address 數拿到原函數調用 __sanitizer_cov_trace_pc_guard 這句彙編代碼的下一條指令的地址 .

可能有點繞 , 畫個圖來梳理一下流程 .

根據內存地址獲取函數名稱

拿到了函數內部一行代碼的地址 , 如何獲取函數名稱呢 ? 這裏筆者分享一下自己的思路 .

熟悉安全攻防 , 逆向的同學可能會清楚 . 我們爲了防止某些特定的方法被別人使用 fishhook hook 掉 , 會利用 dlopen 打開動態庫 , 拿到一個句柄 , 進而拿到函數的內存地址直接調用 .

是不是跟我們這個流程有點相似 , 只是我們好像是反過來的 . 其實反過來也是可以的 .

dlopen 相同 , 在 dlfcn.h 中有一個方法如下 :

typedef struct dl_info {
        const char      *dli_fname;     /* 所在文件 */
        void            *dli_fbase;     /* 文件地址 */
        const char      *dli_sname;     /* 符號名稱 */
        void            *dli_saddr;     /* 函數起始地址 */
} Dl_info;

//這個函數能通過函數內部地址找到函數符號
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靜態插樁 - 坑點1

→ : 多線程問題

這是一個多線程的問題 , 由於你的項目各個方法肯定有可能會在不同的函數執行 , 因此 __sanitizer_cov_trace_pc_guard 這個函數也有可能受多線程影響 , 所以你當然不可能簡簡單單用一個數組來接收所有的符號就搞定了 .

那方法有很多 , 筆者在這裏分享一下自己的做法 :

考慮到這個方法會來特別多次 , 使用鎖會影響性能 , 這裏使用蘋果底層的原子隊列 ( 底層實際上是個棧結構 , 利用隊列結構 + 原子性來保證順序 ) 來實現 .

- (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 用在這裏是爲了入隊添加下一個節點找到 前一個節點next指針的位置
    OSAtomicEnqueue(&symboList, node, offsetof(SymbolNode, next));
}

當你興致沖沖開始考慮好多線程的解決方法寫完之後 , 運行發現 :

死循環了 .

clang靜態插樁 - 坑點2

→ : 上述這種 clang 插樁的方式 , 會在循環中同樣插入 hook 代碼 .

當確定了我們隊列入隊和出隊都是沒問題的 , 你自己的寫法對應的保存和讀取也是沒問題的 , 我們發現了這個坑點 , 這個會死循環 , 爲什麼呢 ?

這裏我就不帶着大家去分析彙編了 , 直接說結論 :

通過彙編會查看到 一個帶有 while 循環的方法 , 會被靜態加入多次 __sanitizer_cov_trace_pc_guard 調用 , 導致死循環.

→ : 解決方案

Other C Flags 修改爲如下 :

-fsanitize-coverage=func,trace-pc-guard

代表進針對 func 進行 hook . 再次運行 .

又以爲完事了 ? 還沒有..

坑點3 : load 方法

→ : load 方法時 , __sanitizer_cov_trace_pc_guard 函數的參數 guard 是 0.

上述打印並沒有發現 load .

解決 : 屏蔽掉 __sanitizer_cov_trace_pc_guard 函數中的

if (!*guard) return;

load 方法就有了 .

這裏也爲我們提供了一點啓示:

如果我們希望從某個函數之後/之前開始優化 , 通過一個全局靜態變量 , 在特定的時機修改其值 , 在 __sanitizer_cov_trace_pc_guard 這個函數中做好對應的處理即可 .

剩餘細化工作

  • 如果你也是使用筆者這種多線程處理方式的話 , 由於用的先進後出原因 , 我們要倒敘一下
  • 還需要做去重 .
  • order 文件格式要求c 函數 , block 調用前面還需要加 _ , 下劃線 .
  • 寫入文件即可 .

筆者 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函數");
}
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 用在這裏是爲了入隊添加下一個節點找到 前一個節點next指針的位置
    OSAtomicEnqueue(&symboList, node, offsetof(SymbolNode, next));
}
@end

文件寫入到了 tmp 路徑下 , 運行 , 打開手機下載查看 :

搞定 , 小夥伴們就可以立馬去優化自己的工程了 .

swift 工程 / 混編工程問題

通過如上方式適合純 OC 工程獲取符號方式 .

由於 swift 的編譯器前端是自己的 swift 編譯前端程序 , 因此配置稍有不同 .

搜索 Other Swift Flags , 添加兩條配置即可 :

  • -sanitize-coverage=func
  • -sanitize=undefined

swift 類通過上述方法同樣可以獲取符號 .

cocoapod 工程問題

對於 cocoapod 工程引入的庫 , 由於針對不同的 target . 那麼我們在主程序中的 target 添加的編譯設置 Write Link Map File , -fsanitize-coverage=func,trace-pc-guard 以及 order file 等設置肯定是不會生效的 . 解決方法就是針對需要的 target 去做對應的設置即可 .

對於直接手動導入到工程裏的 sdk , 不管是 靜態庫 .a 還是 動態庫 , 默認主工程的設置就可以了 , 是可以拿到符號的 .

最後提示一下 , 手動導入的三方庫如果沒有導入並且使用的話 , 是不會加載的 . 添加了 load 方法也是如此 .

部分同學反應的添加設置後報錯問題

部分同學反應添加了 -fsanitize-coverage=func,trace-pc-guardOther C Flags 時 , 直接編譯工程會報錯 .

這個是由於沒有跟着文章所講完成導致的 . 添加了這個 flag 之後 , 需要實現 hook 代碼 , 否則編譯會提示找不到這個符號 .

根據本文 "clang 插樁 -> 原理探索" 部分添加 hook 函數實現即可 . 或者可以直接使用 筆者 demo 完整代碼 .

優化後效果監測

在完全第一次安裝冷啓動 , 保證同樣的環境 , page fault 採樣同樣截取到第一個可交互界面 , 使用重排優化前後效果如下 .

  • 優化前
  • 優化後

實際上 , 在生產環境中 , 由於 page fault 還需要簽名驗證 , 因此在分發環境下 , 優化效果其實更多 .

總結

本篇文章通過以實際碳素過程爲基準 , 一步一步實現 clang 靜態插樁達到二進制重排優化啓動時間的完整流程 .

具體實現步驟如下 :

  • 1️⃣ : 利用 clang 插樁獲得啓動時期需要加載的所有 函數/方法 , block , swift 方法以及 c++構造方法的符號 .
  • 2️⃣ : 通過 order file 機制實現二進制重排 .

如有疑問或者不同看法 , 歡迎留言交流 .

資料推薦

如果你正在跳槽或者正準備跳槽不妨動動小手,添加一下咱們的交流羣1012951431來獲取一份詳細的大廠面試資料爲你的跳槽多添一份保障。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章