iOS二進制文件重排,啓動速度提高超15%

背景

啓動是App給用戶的第一印象,對用戶體驗相當重要。抖音的業務迭代迅速,若是聽任無論,啓動速度會一點點劣化。爲此抖音iOS客戶端團隊作了大量優化工做,除了傳統的修改業務代碼方式,咱們還作了些開拓性的探索,發現修改代碼在二進制文件的佈局能夠提升啓動性能,方案落地後在抖音上啓動速度提升了約15%。git

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

原理

Page Fault

進程若是能直接訪問物理內存無疑是很不安全的,因此操做系統在物理內存的上又創建了一層虛擬內存。爲了提升效率和方便管理,又對虛擬內存和物理內存又進行分頁(Page)。當進程訪問一個虛擬內存Page而對應的物理內存卻不存在時,會觸發一次缺頁中斷(Page Fault),分配物理內存,有須要的話會從磁盤mmap讀人數據。面試

經過App Store渠道分發的App,Page Fault還會進行簽名驗證,因此一次Page Fault的耗時比想象的要多:objective-c

Page Fault安全

重排

編譯器在生成二進制代碼的時候,默認按照連接的Object File(.o)順序寫文件,按照Object File內部的函數順序寫函數。app

靜態庫文件.a就是一組.o文件的ar包,能夠用ar -t查看.a包含的全部.o。dom

默認佈局iphone

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

但若是咱們把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大牛交流學習

相關文章
相關標籤/搜索