iOS 優化篇 - 啓動優化之Clang插樁實現二進制重排

前言

  • 自從抖音團隊分享了這篇 抖音研發實踐:基於二進制文件重排的解決方案 APP啓動速度提高超15% 啓動優化文章後 , 二進制重排優化 pre-main 階段的啓動時間自此被你們廣爲流傳 .html

  • 本篇文章首先講述下二進制重排的原理 , ( 由於抖音團隊在上述文章中原理部分大可能是點到即止 , 多數朋友看完並無什麼實際收穫 ) . 而後將結合 clang 插樁的方式 來實際講述和演練一下如何解決抖音團隊遺留下來的這一問題 :前端

    hook Objc_msgSend 沒法解決的 純swift , block , c++ 方法 .node

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

( 本篇文章因爲會從原理角度講解 , 有些已經比較熟悉的同窗可能會以爲節奏偏囉嗦 , 爲了照顧大部分同窗 , 你們自行根據目錄跳過便可 . ) c++

瞭解二進制重排以前 , 咱們須要瞭解一些前導知識 , 以及二進制重排是爲了解決什麼問題 .算法

虛擬內存與物理內存

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

在計算機領域 , 任何一個技術 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 加載流程 這篇文章有詳細講述和演示 ) .
    • 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 類經過上述方法一樣能夠獲取符號 .

優化後效果監測

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

  • 優化前
  • 優化後

實際上 , 在生產環境中 , 因爲 page fault 還須要簽名驗證 , 所以在分發環境下 , 優化效果其實更多 .

總結

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

具體實現步驟以下 :

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

若有疑問或者不一樣見解 , 歡迎留言交流 .

相關文章
相關標籤/搜索