二十九、基于二進制文件重排的解決方案 APP啟動速度提升超15%

背景

啟動是App給用戶的第一印象刃榨,對用戶體驗至關(guān)重要弹砚。業(yè)務(wù)迭代迅速,如果放任不管枢希,啟動速度會一點點劣化迅栅。為此iOS客戶端團隊做了大量優(yōu)化工作,除了傳統(tǒng)的修改業(yè)務(wù)代碼方式晴玖,我們還做了些開拓性的探索读存,發(fā)現(xiàn)修改代碼在二進制文件的布局可以提高啟動性能,方案落地后在上啟動速度提高了約15%呕屎。

本文從原理出發(fā)让簿,介紹了我們是如何通過靜態(tài)掃描和運行時trace找到啟動時候調(diào)用的函數(shù),然后修改編譯參數(shù)完成二進制文件的重新排布秀睛。

原理

Page Fault

進程如果能直接訪問物理內(nèi)存無疑是很不安全的尔当,所以操作系統(tǒng)在物理內(nèi)存的上又建立了一層虛擬內(nèi)存。為了提高效率和方便管理蹂安,又對虛擬內(nèi)存和物理內(nèi)存又進行分頁(Page)椭迎。當(dāng)進程訪問一個虛擬內(nèi)存Page而對應(yīng)的物理內(nèi)存卻不存在時,會觸發(fā)一次缺頁中斷(Page Fault)田盈,分配物理內(nèi)存畜号,有需要的話會從磁盤mmap讀人數(shù)據(jù)。

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

image

Page Fault

重排

編譯器在生成二進制代碼的時候,默認(rèn)按照鏈接的Object File(.o)順序?qū)懳募鲈荩凑誒bject File內(nèi)部的函數(shù)順序?qū)懞瘮?shù)痹升。

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

image

默認(rèn)布局

簡化問題:假設(shè)我們只有兩個page:page1/page2疼蛾,其中綠色的method1和method3啟動時候需要調(diào)用,為了執(zhí)行對應(yīng)的代碼艺配,系統(tǒng)必須進行兩個Page Fault察郁。

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

image

重排之后

我們的經(jīng)驗是優(yōu)化一個Page Fault,啟動速度提升0.6~0.8ms酝掩。

核心問題

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

  • 重排效果怎么樣 - 獲取啟動階段的page fault次數(shù)

  • 重排成功了沒 - 拿到當(dāng)前二進制的函數(shù)布局

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

  • 重排的內(nèi)容 - 獲取啟動時候用到的函數(shù)

System Trace

日常開發(fā)中性能分析是用最多的工具無疑是Time Profiler,但Time Profiler是基于采樣的期虾,并且只能統(tǒng)計線程實際在運行的時間原朝,而發(fā)生Page Fault的時候線程是被blocked,所以我們需要用一個不常用但功能卻很強大的工具:System Trace镶苞。

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

image

System Trace

signpost

現(xiàn)在我們在Instrument中已經(jīng)能拿到某個時間段的Page In次數(shù)茂蚓,那么如何和啟動映射起來呢壕鹉?

我們的答案是: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//標(biāo)記時間段開始4os_signpost_interval_begin(logger, signPostId, "Launch","%{public}s", "");5//標(biāo)記結(jié)束6os_signpost_interval_end(logger, signPostId, "Launch");

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

image

啟動階段

有多少個Mach-O牍白,就會有多少個Load和C++靜態(tài)初始化階段脊凰,用signpost相關(guān)API對對應(yīng)階段打點,方便跟蹤每個階段的優(yōu)化效果茂腥。

Linkmap

Linkmap是iOS編譯過程的中間產(chǎn)物狸涌,記錄了二進制文件的布局,需要在Xcode的Build Settings里開啟Write Link Map File:

image

Build Settings

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

image

linkmap

linkmap主要包括三大部分:

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

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

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

ld

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

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中的符號會按照順序排列在對應(yīng)section的開始惶楼,完美的滿足了我們的需求。

Xcode的GUI也提供了order_file選項:

image

order_file

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

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

獲得符號

還剩下最后一個豹储,也是最核心的一個問題,獲取啟動時候用到的函數(shù)符號淘这。

我們首先排除了解析Instruments(Time Profiler/System Trace) trace文件方案剥扣,因為他們都是基于特定場景采樣的巩剖,大多數(shù)符號獲取不到。最后選擇了靜態(tài)掃描+運行時Trace結(jié)合的解決方案钠怯。

Load

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

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

C++靜態(tài)初始化

C++并不像Objective C方法那樣贤姆,大部分方法調(diào)用編譯后都是objc_msgSend,也就沒有一個入口函數(shù)去運行時hook稳衬。

但是可以用-finstrument-functions在編譯期插樁“hook”霞捡,但由于APP很多依賴由其他團隊提供靜態(tài)庫,這套方案需要修改依賴的構(gòu)建過程薄疚。二進制文件重排在沒有業(yè)界經(jīng)驗可供參考碧信,不確定收益的情況下,選擇了并不完美但成本最低的靜態(tài)掃描方案街夭。

1. 掃描linkmap的__DATA音婶,__mod_init_func,這個section存儲了包含C++靜態(tài)初始化方法的文件莱坎,獲得文件號[ 5]衣式。

1//__mod_init_func20x100008060    0x00000008  [  5] ltmp73//[  5]對應(yīng)的文件4[  5] .../Build/Products/Debug-iphoneos/libStaticLibrary.a(StaticLibrary.o)

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

1?  lipo libStaticLibrary.a -thin arm64 -output arm64.a2?  ar -x arm64.a StaticLibrary.o

3. 通過.o碴卧,獲得靜態(tài)初始化的符號名_demo_constructor

1?  objdump -r -section=__mod_init_func StaticLibrary.o23StaticLibrary.o:    file format Mach-O arm6445RELOCATION 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], #1610100004a48:    d7 06 00 14     b   #7004 

6. 通過掃描bl指令掃描子程序調(diào)用瓮具,子程序在二進制的開始地址為:100004a3c +1b68(對應(yīng)十進制的7016)荧飞。

1100004a3c:    da 06 00 94     bl  #7016 

7. 通過開始地址,可以找到符號名和結(jié)束地址名党,然后重復(fù)5~7叹阔,遞歸的找到所有的子程序調(diào)用的函數(shù)符號。

小坑

STL里會針對string生成初始化函數(shù)传睹,這樣會導(dǎo)致多個.o里存在同名的符號耳幢,例如:

1__ZNSt3__112basic_stringIcNS_11char_traitsIcEENS_9allocatorIcEEEC1IDnEEPKc

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

1//order_file.txt2libDemoLibrary.a(object.o):__GLOBAL__sub_I_demo_file.cpp

局限性

branch系列匯編指令除了bl/b,還有br/blr睛藻,即通過寄存器的間接子程序調(diào)用启上,靜態(tài)掃描無法覆蓋到這種情況。

Local符號

在做C++靜態(tài)初始化掃描的時候店印,發(fā)現(xiàn)掃描出了很多類似l002的符號冈在。經(jīng)過一番調(diào)研,發(fā)現(xiàn)是依賴方輸出靜態(tài)庫的時候裁剪了local符號按摘。導(dǎo)致__GLOBAL__sub_I_demo_file.cpp 變成了l002包券。

需要靜態(tài)庫出包的時候保留local符號,CI腳本不要執(zhí)行strip -x院峡,同時Xcode對應(yīng)target的Strip Style修改為Debugging symbol:

image

Strip Style

靜態(tài)庫保留的local符號會在宿主App生成IPA之前裁剪掉兴使,所以不會對最后的IPA包大小有影響系宜。宿主App的Strip Style要選擇All Symbols照激,宿主動態(tài)庫選擇Non-Global Symbols。

Objective C方法

絕大部分Objective C的方法在編譯后會走objc_msgSend盹牧,所以通過fishhook(https://github.com/facebook/fishhook) hook這一個C函數(shù)即可獲得Objective C符號俩垃。由于objc_msgSend是變長參數(shù),所以hook代碼需要用匯編來實現(xiàn):

 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}

子程序調(diào)用時候要保存和恢復(fù)參數(shù)寄存器汰寓,所以save和load分別對x0~x9, q0~q9入棧/出棧口柳。call則通過寄存器來間接調(diào)用函數(shù):

 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...1011#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恢復(fù)lr有滑。由于要生成trace文件跃闹,為了降低文件的大小,直接寫入的是函數(shù)地址毛好,且只有當(dāng)前可執(zhí)行文件的Mach-O(app和動態(tài)庫)代碼段才會寫入:

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;

獲取到函數(shù)地址后肌访,反查linkmap既可找到方法的符號名找默。

Block

block是一種特殊的單元,block在編譯后的函數(shù)體是一個C函數(shù)吼驶,在調(diào)用的時候直接通過指針調(diào)用惩激,并不走objc_msgSend,所以需要單獨hook蟹演。

通過Block的源碼可以看到block的內(nèi)存布局如下:

 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就是函數(shù)的指針风钻,hook思路是將invoke替換為自定義實現(xiàn),然后在reserved保存為原始實現(xiàn)酒请。

1//參考 https://github.com/youngsoft/YSBlockHook2if (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對應(yīng)的函數(shù)簽名不一樣魄咕,所以這里仍然采用匯編來實現(xiàn)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    //調(diào)用原始的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中獲得函數(shù)地址(同樣要減去slide)蚌父。

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

同樣哮兰,通過函數(shù)地址反查linkmap既可找到block符號毛萌。

瓶頸

基于靜態(tài)掃描+運行時trace的方案仍然存在少量瓶頸:

  • initialize hook不到

  • 部分block hook不到

  • C++通過寄存器的間接函數(shù)調(diào)用靜態(tài)掃描不出來

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

整體流程

image

流程

  1. 設(shè)置條件觸發(fā)流程

  2. 工程注入Trace動態(tài)庫,選擇release模式編譯出.app/linkmap/中間產(chǎn)物

  3. 運行一次App到啟動結(jié)束右遭,Trace動態(tài)庫會在沙盒生成Trace log

  4. 以Trace Log做盅,中間產(chǎn)物和linkmap作為輸入,運行腳本解析出order_file

總結(jié)

目前窘哈,在缺少業(yè)界經(jīng)驗參考的情況下吹榴,我們成功驗證了二進制文件重排方案在iOS APP開發(fā)中的可行性和穩(wěn)定性」鐾瘢基于二進制文件重排图筹,我們在針對iOS客戶端上的優(yōu)化工作中,獲得了約15%的啟動速度提升让腹。

抽象來看远剩,APP開發(fā)中大家會遇到這樣一個通用的問題,即在某些情況下骇窍,APP運行需要進行大量的Page Fault瓜晤,這會影響代碼執(zhí)行速度。而二進制文件重排方案腹纳,目前看來是解決這一通用問題比較好的方案痢掠。

轉(zhuǎn)載至: 基于二進制文件重排的解決方案 APP啟動速度提升超15%](https://mp.weixin.qq.com/s/Drmmx5JtjG3UtTFksL6Q8Q)

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市嘲恍,隨后出現(xiàn)的幾起案子足画,更是在濱河造成了極大的恐慌,老刑警劉巖蛔钙,帶你破解...
    沈念sama閱讀 222,464評論 6 517
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件锌云,死亡現(xiàn)場離奇詭異,居然都是意外死亡吁脱,警方通過查閱死者的電腦和手機桑涎,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 95,033評論 3 399
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來兼贡,“玉大人攻冷,你說我怎么就攤上這事”橄#” “怎么了等曼?”我有些...
    開封第一講書人閱讀 169,078評論 0 362
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經(jīng)常有香客問我禁谦,道長胁黑,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 59,979評論 1 299
  • 正文 為了忘掉前任州泊,我火速辦了婚禮丧蘸,結(jié)果婚禮上,老公的妹妹穿的比我還像新娘遥皂。我一直安慰自己力喷,他們只是感情好,可當(dāng)我...
    茶點故事閱讀 69,001評論 6 398
  • 文/花漫 我一把揭開白布演训。 她就那樣靜靜地躺著弟孟,像睡著了一般。 火紅的嫁衣襯著肌膚如雪样悟。 梳的紋絲不亂的頭發(fā)上拂募,一...
    開封第一講書人閱讀 52,584評論 1 312
  • 那天,我揣著相機與錄音乌奇,去河邊找鬼没讲。 笑死眯娱,一個胖子當(dāng)著我的面吹牛礁苗,可吹牛的內(nèi)容都是我干的。 我是一名探鬼主播徙缴,決...
    沈念sama閱讀 41,085評論 3 422
  • 文/蒼蘭香墨 我猛地睜開眼试伙,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了于样?” 一聲冷哼從身側(cè)響起疏叨,我...
    開封第一講書人閱讀 40,023評論 0 277
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎穿剖,沒想到半個月后蚤蔓,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體,經(jīng)...
    沈念sama閱讀 46,555評論 1 319
  • 正文 獨居荒郊野嶺守林人離奇死亡糊余,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點故事閱讀 38,626評論 3 342
  • 正文 我和宋清朗相戀三年秀又,在試婚紗的時候發(fā)現(xiàn)自己被綠了。 大學(xué)時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片贬芥。...
    茶點故事閱讀 40,769評論 1 353
  • 序言:一個原本活蹦亂跳的男人離奇死亡吐辙,死狀恐怖,靈堂內(nèi)的尸體忽然破棺而出蘸劈,到底是詐尸還是另有隱情昏苏,我是刑警寧澤,帶...
    沈念sama閱讀 36,439評論 5 351
  • 正文 年R本政府宣布,位于F島的核電站贤惯,受9級特大地震影響洼专,放射性物質(zhì)發(fā)生泄漏。R本人自食惡果不足惜孵构,卻給世界環(huán)境...
    茶點故事閱讀 42,115評論 3 335
  • 文/蒙蒙 一壶熏、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧浦译,春花似錦棒假、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 32,601評論 0 25
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至叹俏,卻和暖如春妻枕,著一層夾襖步出監(jiān)牢的瞬間,已是汗流浹背粘驰。 一陣腳步聲響...
    開封第一講書人閱讀 33,702評論 1 274
  • 我被黑心中介騙來泰國打工屡谐, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人蝌数。 一個月前我還...
    沈念sama閱讀 49,191評論 3 378
  • 正文 我出身青樓愕掏,卻偏偏與公主長得像,于是被迫代替她去往敵國和親顶伞。 傳聞我的和親對象是個殘疾皇子饵撑,可洞房花燭夜當(dāng)晚...
    茶點故事閱讀 45,781評論 2 361

推薦閱讀更多精彩內(nèi)容