iOS二進位文件重排,啟動速度提升超15%

来源:https://www.cnblogs.com/chengxyyh/archive/2020/06/12/13099407.html
-Advertisement-
Play Games

背景 啟動是App給用戶的第一印象,對用戶體驗至關重要。抖音的業務迭代迅速,如果放任不管,啟動速度會一點點劣化。為此抖音iOS客戶端團隊做了大量優化工作,除了傳統的修改業務代碼方式,我們還做了些開拓性的探索,發現修改代碼在二進位文件的佈局可以提高啟動性能,方案落地後在抖音上啟動速度提高了約15%。 ...


背景

啟動是App給用戶的第一印象,對用戶體驗至關重要。抖音的業務迭代迅速,如果放任不管,啟動速度會一點點劣化。為此抖音iOS客戶端團隊做了大量優化工作,除了傳統的修改業務代碼方式,我們還做了些開拓性的探索,發現修改代碼在二進位文件的佈局可以提高啟動性能,方案落地後在抖音上啟動速度提高了約15%。

本文從原理出發,介紹了我們是如何通過靜態掃描和運行時trace找到啟動時候調用的函數,然後修改編譯參數完成二進位文件的重新排布。

原理

Page Fault

進程如果能直接訪問物理記憶體無疑是很不安全的,所以操作系統在物理記憶體的上又建立了一層虛擬記憶體。為了提高效率和方便管理,又對虛擬記憶體和物理記憶體又進行分頁(Page)。當進程訪問一個虛擬記憶體Page而對應的物理記憶體卻不存在時,會觸發一次缺頁中斷(Page Fault),分配物理記憶體,有需要的話會從磁碟mmap讀人數據。

通過App Store渠道分發的App,Page Fault還會進行簽名驗證,所以一次Page Fault的耗時比想象的要多:

Page Fault

重排

編譯器在生成二進位代碼的時候,預設按照鏈接的Object File(.o)順序寫文件,按照Object File內部的函數順序寫函數。

靜態庫文件.a就是一組.o文件的ar包,可以用ar -t查看.a包含的所有.o。

預設佈局

簡化問題:假設我們只有兩個page:page1/page2,其中綠色的method1和method3啟動時候需要調用,為了執行對應的代碼,系統必須進行兩個Page Fault。

但如果我們把method1和method3排布到一起,那麼只需要一個Page Fault即可,這就是二進位文件重排的核心原理。

重排之後

我們的經驗是優化一個Page Fault,啟動速度提升0.6~0.8ms。

核心問題

為了完成重排,有以下幾個問題要解決:

  • 重排效果怎麼樣 - 獲取啟動階段的page fault次數

  • 重排成功了沒 - 拿到當前二進位的函數佈局

  • 如何重排 - 讓鏈接器按照指定順序生成Mach-O

  • 重排的內容 - 獲取啟動時候用到的函數

作為一個開發者,有一個學習的氛圍跟一個交流圈子特別重要,這是一個我的iOS交流群:519832104 不管你是小白還是大牛歡迎入駐,分享經驗,討論技術,大家一起交流學習成長!

另附上一份各好友收集的大廠面試題,需要iOS開發學習資料、面試真題,可以添加iOS開發進階交流群,進群可自行下載!

System Trace

日常開發中性能分析是用最多的工具無疑是Time Profiler,但Time Profiler是基於採樣的,並且只能統計線程實際在運行的時間,而發生Page Fault的時候線程是被blocked,所以我們需要用一個不常用但功能卻很強大的工具:System Trace。

選中主線程,在VM Activity中的File Backed Page In次數就是Page Fault次數,並且雙擊還能按時序看到引起Page Fault的堆棧:

System Trace

signpost

現在我們在Instrument中已經能拿到某個時間段的Page In次數,那麼如何和啟動映射起來呢?

我們的答案是:os_signpost

os_signpost是iOS 12開始引入的一組API,可以在Instruments繪製一個時間段,代碼也很簡單:

1os_log_t logger = os_log_create("com.bytedance.tiktok", "performance");
2os_signpost_id_t signPostId = os_signpost_id_make_with_pointer(logger,sign);
3//標記時間段開始
4os_signpost_interval_begin(logger, signPostId, "Launch","%{public}s", "");
5//標記結束
6os_signpost_interval_end(logger, signPostId, "Launch");

通常可以把啟動分為四個階段處理:

啟動階段

有多少個Mach-O,就會有多少個Load和C++靜態初始化階段,用signpost相關API對對應階段打點,方便跟蹤每個階段的優化效果。

Linkmap

Linkmap是iOS編譯過程的中間產物,記錄了二進位文件的佈局,需要在Xcode的Build Settings里開啟Write Link Map File:

Build Settings

比如以下是一個單頁面Demo項目的linkmap。

linkmap

linkmap主要包括三大部分:

  • Object Files 生成二進位用到的link單元的路徑和文件編號

  • Sections 記錄Mach-O每個Segment/section的地址範圍

  • Symbols 按順序記錄每個符號的地址範圍

ld

Xcode使用的鏈接器件是ld,ld有一個不常用的參數-order_file,通過man ld可以看到詳細文檔:

Alters the order in which functions and data are laid out. For each section in the output file, any symbol in that section that are specified in the order file file is moved to the start of its section and laid out in the same order as in the order file file.

可以看到,order_file中的符號會按照順序排列在對應section的開始,完美的滿足了我們的需求。

Xcode的GUI也提供了order_file選項:

order_file

如果order_file中的符號實際不存在會怎麼樣呢?

ld會忽略這些符號,如果提供了link選項-order_file_statistics,會以warning的形式把這些沒找到的符號列印在日誌里。

獲得符號

還剩下最後一個,也是最核心的一個問題,獲取啟動時候用到的函數符號。

我們首先排除瞭解析Instruments(Time Profiler/System Trace) trace文件方案,因為他們都是基於特定場景採樣的,大多數符號獲取不到。最後選擇了靜態掃描+運行時Trace結合的解決方案。

Load

Objective C的符號名是+-[Class_name(category_name) method:name:],其中+表示類方法,-表示實例方法。

剛剛提到linkmap里記錄了所有的符號名,所以只要掃一遍linkmap的__TEXT,__text,正則匹配("^\+\[.*\ load\]$")既可以拿到所有的load方法符號。

C++靜態初始化

C++並不像Objective C方法那樣,大部分方法調用編譯後都是objc_msgSend,也就沒有一個入口函數去運行時hook。

但是可以用-finstrument-functions在編譯期插樁“hook”,但由於抖音的很多依賴由其他團隊提供靜態庫,這套方案需要修改依賴的構建過程。二進位文件重排在沒有業界經驗可供參考,不確定收益的情況下,選擇了並不完美但成本最低的靜態掃描方案。

1//__mod_init_func
20x100008060    0x00000008  [  5] ltmp7
3//[  5]對應的文件
4[  5] .../Build/Products/Debug-iphoneos/libStaticLibrary.a(StaticLibrary.o)

2. 通過文件號,解壓出.o。

1➜  lipo libStaticLibrary.a -thin arm64 -output arm64.a
2➜  ar -x arm64.a StaticLibrary.o

3. 通過.o,獲得靜態初始化的符號名_demo_constructor

1➜  objdump -r -section=__mod_init_func StaticLibrary.o
2
3StaticLibrary.o:    file format Mach-O arm64
4
5RELOCATION RECORDS FOR [__mod_init_func]:
60000000000000000 ARM64_RELOC_UNSIGNED _demo_constructor

4. 通過符號名,文件號,在linkmap中找到符號在二進位中的範圍:

10x100004A30    0x0000001C  [  5] _demo_constructor

5. 通過起始地址,對代碼進行反彙編:

1➜  objdump -d --start-address=0x100004A30 --stop-address=0x100004A4B demo_arm64 
2
3_demo_constructor:
4100004a30:    fd 7b bf a9     stp x29, x30, [sp, #-16]!
5100004a34:    fd 03 00 91     mov x29, sp
6100004a38:    20 0c 80 52     mov w0, #97
7100004a3c:    da 06 00 94     bl  #7016 
8100004a40:    40 0c 80 52     mov w0, #98
9100004a44:    fd 7b c1 a8     ldp x29, x30, [sp], #16
10100004a48:    d7 06 00 14     b   #7004

6. 通過掃描bl指令掃描子程式調用,子程式在二進位的開始地址為:100004a3c +1b68(對應十進位的7016)。

1100004a3c:    da 06 00 94     bl  #7016

7. 通過開始地址,可以找到符號名和結束地址,然後重覆5~7,遞歸的找到所有的子程式調用的函數符號。

小坑

STL里會針對string生成初始化函數,這樣會導致多個.o里存在同名的符號,例如:

1__ZNSt3__112basic_stringIcNS_11char_traitsIcEENS_9allocatorIcEEEC1IDnEEPKc

類似這樣的重覆符號的情況在C++里有很多,所以C/C++符號在order_file里要帶著所在的.o信息:

1//order_file.txt
2libDemoLibrary.a(object.o):__GLOBAL__sub_I_demo_file.cpp

局限性

branch系列彙編指令除了bl/b,還有br/blr,即通過寄存器的間接子程式調用,靜態掃描無法覆蓋到這種情況。

Local符號

在做C++靜態初始化掃描的時候,發現掃描出了很多類似l002的符號。經過一番調研,發現是依賴方輸出靜態庫的時候裁剪了local符號。導致__GLOBAL__sub_I_demo_file.cpp 變成了l002。

需要靜態庫出包的時候保留local符號,CI腳本不要執行strip -x,同時Xcode對應target的Strip Style修改為Debugging symbol:

Strip Style

靜態庫保留的local符號會在宿主App生成IPA之前裁剪掉,所以不會對最後的IPA包大小有影響。宿主App的Strip Style要選擇All Symbols,宿主動態庫選擇Non-Global Symbols。

Objective C方法

絕大部分Objective C的方法在編譯後會走objc_msgSend,所以通過fishhook(https://github.com/facebook/fishhook) hook這一個C函數即可獲得Objective C符號。由於objc_msgSend是變長參數,所以hook代碼需要用彙編來實現:

1//代碼參考InspectiveC
2__attribute__((naked))
3static void hook_Objc_msgSend() {
4    save()
5    __asm volatile ("mov x2, lr\n");
6    __asm volatile ("mov x3, x4\n");
7    call(blr, &before_objc_msgSend)
8    load()
9    call(blr, orig_objc_msgSend)
10    save()
11    call(blr, &after_objc_msgSend)
12    __asm volatile ("mov lr, x0\n");
13    load()
14    ret()
15}

子程式調用時候要保存和恢復參數寄存器,所以save和load分別對x0~x9, q0~q9入棧/出棧。call則通過寄存器來間接調用函數:

1#define save() 
2__asm volatile ( 
3"stp q6, q7, [sp, #-32]!\n"
4...
5
6#define load() 
7__asm volatile ( 
8"ldp x0, x1, [sp], #16\n" 
9...
10
11#define call(b, value) 
12__asm volatile ("stp x8, x9, [sp, #-16]!\n"); 
13__asm volatile ("mov x12, %0\n" :: "r"(value)); 
14__asm volatile ("ldp x8, x9, [sp], #16\n"); 
15__asm volatile (#b " x12\n");

before_objc_msgSend中用棧保存lr,在after_objc_msgSend恢復lr。由於要生成trace文件,為了降低文件的大小,直接寫入的是函數地址,且只有當前可執行文件的Mach-O(app和動態庫)代碼段才會寫入:

iOS中,由於ALSR(https://en.wikipedia.org/wiki/Address_space_layout_randomization)的存在,在寫入之前需要先減去偏移量slide:

1IMP imp = (IMP)class_getMethodImplementation(object_getClass(self), _cmd);
2unsigned long imppos = (unsigned long)imp;
3unsigned long addr = immpos - macho_slide

獲取一個二進位的__text段地址範圍:

1unsigned long size = 0;
2unsigned long start = (unsigned long)getsectiondata(mhp,  "__TEXT", "__text", &size);
3unsigned long end = start + size;

獲取到函數地址後,反查linkmap既可找到方法的符號名。

Block

block是一種特殊的單元,block在編譯後的函數體是一個C函數,在調用的時候直接通過指針調用,並不走objc_msgSend,所以需要單獨hook。

通過Block的源碼可以看到block的記憶體佈局如下:

1struct Block_layout {
2    void *isa;
3    int32_t flags; // contains ref count
4    int32_t reserved;
5    void  *invoke;
6    struct Block_descriptor1 *descriptor;
7};
8struct Block_descriptor1 {
9    uintptr_t reserved;
10    uintptr_t size;
11};

其中invoke就是函數的指針,hook思路是將invoke替換為自定義實現,然後在reserved保存為原始實現。

1//參考 https://github.com/youngsoft/YSBlockHook
2if (layout->descriptor != NULL && layout->descriptor->reserved == NULL)
3{
4    if (layout->invoke != (void *)hook_block_envoke)
5    {
6        layout->descriptor->reserved = layout->invoke;
7        layout->invoke = (void *)hook_block_envoke;
8    }
9}

由於block對應的函數簽名不一樣,所以這裡仍然採用彙編來實現hook_block_envoke

1__attribute__((naked))
2static void hook_block_envoke() {
3    save()
4    __asm volatile ("mov x1, lr\n");
5    call(blr, &before_block_hook);
6    __asm volatile ("mov lr, x0\n");
7    load()
8    //調用原始的invoke,即resvered存儲的地址
9    __asm volatile ("ldr x12, [x0, #24]\n");
10    __asm volatile ("ldr x12, [x12]\n");
11    __asm volatile ("br x12\n");
12}

before_block_hook中獲得函數地址(同樣要減去slide)。

1intptr_t before_block_hook(id block,intptr_t lr)
2{
3    Block_layout * layout = (Block_layout *)block;
4    //layout->descriptor->reserved即block的函數地址
5    return lr;
6}

同樣,通過函數地址反查linkmap既可找到block符號。

瓶頸

基於靜態掃描+運行時trace的方案仍然存在少量瓶頸:

  • initialize hook不到

  • 部分block hook不到

  • C++通過寄存器的間接函數調用靜態掃描不出來

目前的重排方案能夠覆蓋到80%~90%的符號,未來我們會嘗試編譯期插樁等方案來進行100%的符號覆蓋,讓重排達到最優效果。

整體流程

流程

  1. 設置條件觸發流程

  2. 工程註入Trace動態庫,選擇release模式編譯出.app/linkmap/中間產物

  3. 運行一次App到啟動結束,Trace動態庫會在沙盒生成Trace log

  4. 以Trace Log,中間產物和linkmap作為輸入,運行腳本解析出order_file

總結

目前,在缺少業界經驗參考的情況下,我們成功驗證了二進位文件重排方案在iOS APP開發中的可行性和穩定性。基於二進位文件重排,我們在針對抖音的iOS客戶端上的優化工作中,獲得了約15%的啟動速度提升。

抽象來看,APP開發中大家會遇到這樣一個通用的問題,即在某些情況下,APP運行需要進行大量的Page Fault,這會影響代碼執行速度。而二進位文件重排方案,目前看來是解決這一通用問題比較好的方案。

未來我們會進行更多的嘗試,讓二進位文件重排在更多的業務場景落地。

點擊此處,立即與iOS大牛交流學習


您的分享是我們最大的動力!

-Advertisement-
Play Games
更多相關文章
  • 1.重置密碼的第一步就是跳過MySQL的密碼認證過程,方法如下: #vim /etc/my.cnf(註:windows下修改的是my.ini) 很多老鐵,在開始時設置了 MySQL 的密碼,後來一段時間沒有用 MySQL之後,密碼忘了~ QAQ,請別急,現在有以下方法解決密碼忘了的情況。 1.首先我 ...
  • 最近一個電子看板小項目上線,由於資料庫非常小,而且數據也不太重要。因此未選擇XtraBackup備份,打算用AutoMySQLBackup來備份,結果部署後測試發現,有一些小問題是之前解決過的。有一些是MySQL 5.7版本才有的。下麵記錄一下解決過程。關於AutoMySQLBackup的基礎知識,... ...
  • 42.統計APP應用的DB連接及IP情況 select b.hostname ,a.client_net_address, b.program_name ,count(1) as Qtyfrom sys.dm_exec_connections a(nolock) inner join sys.sys ...
  • 在開發過程中,埋點可以解決兩大類問題:一是瞭解用戶使用 App 的行為,二是降低分析線上問題的難度。目前,iOS 開發中常見的埋點方式,主要包括: 代碼埋點 可視化埋點 無埋點 代碼埋點 代碼埋點主要就是通過手寫代碼的方式來埋點,能很精確的在需要埋點的代碼處加上埋點的代碼,可以很方便地記錄當前環境的 ...
  • 分享近期 GitHub 上比較流行的 22 個和 iOS 開發相關的開源項目。 包括開發輔助工具,非同步編程庫,JSON 解析,移動端資料庫,圖像視頻處理,網路請求,UI 框架、組件,演算法、數據結構等內容。 Accio 使用 Swift 編寫的 iOS/tvOS/watchOS/macOS 依賴管理工 ...
  • 背景 過完年來北京之後,有準備看看機會,也是想瞭解下市場行情。簡歷沒有投太多,只定向投了頭條教育部門、抖音、快手、阿裡,這些公司。 頭條和阿裡的簡歷都沒過,肯定是亮點太少吧。只有快手簡歷過了,快手是三輪技術面+一輪HR面,前兩輪技術都比較順利,到第三輪卻栽了,很痛心o(╥﹏╥)o。目前就不考慮換工作 ...
  • 在移動開發中,App 的閃退率是工程師十分關註且又頭疼的事情。去年,網易杭州研究院曾經針對 crash 的防護有提出『大白健康系統--iOS APP 運行時 Crash 自動修複系統』方案,使得 crash 防護這個想法真正被落實,但至今該方案的具體實現並沒有被開源。經過一年的時間,圈子裡也有一些開 ...
  • 將Android Studio 升級到4.0然後創建一個新項目,編譯出現“ gradle 前言中不允許有內容” 的錯誤,在網上找了很多資料,眾說紛紜,但都沒有解決我的問題,最後反覆摸索把問題解決了。 1.清除gradle的所有緩存。 2.修改gradle 的版本,4.0支持的最的gradle 版本是 ...
一周排行
    -Advertisement-
    Play Games
  • 移動開發(一):使用.NET MAUI開發第一個安卓APP 對於工作多年的C#程式員來說,近來想嘗試開發一款安卓APP,考慮了很久最終選擇使用.NET MAUI這個微軟官方的框架來嘗試體驗開發安卓APP,畢竟是使用Visual Studio開發工具,使用起來也比較的順手,結合微軟官方的教程進行了安卓 ...
  • 前言 QuestPDF 是一個開源 .NET 庫,用於生成 PDF 文檔。使用了C# Fluent API方式可簡化開發、減少錯誤並提高工作效率。利用它可以輕鬆生成 PDF 報告、發票、導出文件等。 項目介紹 QuestPDF 是一個革命性的開源 .NET 庫,它徹底改變了我們生成 PDF 文檔的方 ...
  • 項目地址 項目後端地址: https://github.com/ZyPLJ/ZYTteeHole 項目前端頁面地址: ZyPLJ/TreeHoleVue (github.com) https://github.com/ZyPLJ/TreeHoleVue 目前項目測試訪問地址: http://tree ...
  • 話不多說,直接開乾 一.下載 1.官方鏈接下載: https://www.microsoft.com/zh-cn/sql-server/sql-server-downloads 2.在下載目錄中找到下麵這個小的安裝包 SQL2022-SSEI-Dev.exe,運行開始下載SQL server; 二. ...
  • 前言 隨著物聯網(IoT)技術的迅猛發展,MQTT(消息隊列遙測傳輸)協議憑藉其輕量級和高效性,已成為眾多物聯網應用的首選通信標準。 MQTTnet 作為一個高性能的 .NET 開源庫,為 .NET 平臺上的 MQTT 客戶端與伺服器開發提供了強大的支持。 本文將全面介紹 MQTTnet 的核心功能 ...
  • Serilog支持多種接收器用於日誌存儲,增強器用於添加屬性,LogContext管理動態屬性,支持多種輸出格式包括純文本、JSON及ExpressionTemplate。還提供了自定義格式化選項,適用於不同需求。 ...
  • 目錄簡介獲取 HTML 文檔解析 HTML 文檔測試參考文章 簡介 動態內容網站使用 JavaScript 腳本動態檢索和渲染數據,爬取信息時需要模擬瀏覽器行為,否則獲取到的源碼基本是空的。 本文使用的爬取步驟如下: 使用 Selenium 獲取渲染後的 HTML 文檔 使用 HtmlAgility ...
  • 1.前言 什麼是熱更新 游戲或者軟體更新時,無需重新下載客戶端進行安裝,而是在應用程式啟動的情況下,在內部進行資源或者代碼更新 Unity目前常用熱更新解決方案 HybridCLR,Xlua,ILRuntime等 Unity目前常用資源管理解決方案 AssetBundles,Addressable, ...
  • 本文章主要是在C# ASP.NET Core Web API框架實現向手機發送驗證碼簡訊功能。這裡我選擇是一個互億無線簡訊驗證碼平臺,其實像阿裡雲,騰訊雲上面也可以。 首先我們先去 互億無線 https://www.ihuyi.com/api/sms.html 去註冊一個賬號 註冊完成賬號後,它會送 ...
  • 通過以下方式可以高效,並保證數據同步的可靠性 1.API設計 使用RESTful設計,確保API端點明確,並使用適當的HTTP方法(如POST用於創建,PUT用於更新)。 設計清晰的請求和響應模型,以確保客戶端能夠理解預期格式。 2.數據驗證 在伺服器端進行嚴格的數據驗證,確保接收到的數據符合預期格 ...