iOS基於二進制重排的啓動優化

參考連接: 抖音研發實踐:基於二進制文件重排的解決方案 APP啓動速度提高超15%html

1、原理

一、虛擬內存和物理內存

早期計算機沒有虛擬地址,一旦加載都會所有加載到內存中,並且進程都是按順序排列的,這樣別的進程只須要把本身的地址加一些就能訪問到別的進程這樣就很不安全node

如今軟件發展的比硬件快,軟件佔用的內存愈來愈大,這就致使計算機的內存不夠用,當開啓多個軟件時候,若是內存不夠用就只能等待,只有等前面的軟件關掉後才能加載打開,這就是早期計算機有時候爲啥只有把前面的軟件關掉才能打開新軟件的緣由;算法

並且用戶使用軟件時候並非使用到所有內存,只會使用到一部分,若是軟件一打開就把軟件所有加載到內存中,這樣會很浪費內存空間數組

基於上面緣由虛擬內存技術出現了,軟件打開後,軟件本身覺得有一大片內存空間,但其實是虛擬的,而虛擬內存和物理內存是經過一張表來關聯的,咱們能夠看下下面兩張表緩存

進程1運行時候會開闢一塊內存空間,但訪問到內存條的時候並非這塊內存空間,並且經過訪問地址經過進程1的映射表映射到不一樣的物理內存空間,這個叫地址翻譯,這個過程須要CPU和操做系統配合,由於這個映射表是操做系統來管理的,安全

當咱們調試時候發現訪問數據的內存地址都是連續的,其實這是一個假象,在這個進程內部能夠訪問,是由於咱們訪問時候會經過該進程的內存映射表去拿到真正的物理內存地址,假如其餘進程訪問的話,其餘進程沒有相應的映射表,天然就訪問不到真正的物理內存地址,這樣就解決了內存安全問題bash

內存使用率問題:app

內存分頁管理,映射表不能以字節爲單位,是以頁爲單位,Linux是以4K爲一頁,iOS是以16K位一頁,可是mac系統是4K一頁,咱們能夠在mac終端輸入pageSize,發現返回的是4096iphone

爲啥分頁後內存就夠用呢,由於應用內存是虛擬的,因此當程序啓動時候程序會認爲本身有不少的內存,咱們看看下圖函數

在應用加載時候不會把全部數據放內存中,由於數據是懶加載,當進程訪問虛擬地址時候,首先看頁表,若是發現該頁表數據爲0,說明該頁面數據未在物理地址上,這個時候系統會阻塞該進程,這個行爲就叫作頁中斷(page Fault),也叫缺頁異常,而後將磁盤中對應頁面的數據加載到內存中,而後讓虛擬內存指向剛加載的物理內存,將數據加載到內存中時候,若是有空的內存空間,就放空的內存空間中,若是沒有的話,就會去覆蓋其餘進程的數據,具體怎麼覆蓋操做系統有一個算法,這樣永遠都會保證當前進程的使用,這就是靈活管理內存。

可是這時候有個問題,虛擬內存解決了安全和效率問題,可是出現了另個安全問題,由於虛擬內存在編譯連接時候就肯定了,那麼黑客很容易經過分析拿到對應的虛擬內存去操做 ,這樣就形成全部的代碼都很好hook,代碼注入,這個時候就出現了新技術ASLR(地址空間隨機化),就是進程每次加載的時候都會給一個隨機的偏移量,這樣就保證每次加載進程時候虛擬內存也在變化,iOS從iOS4就開始了,

二進制重拍:

由於虛擬內存中有個很大問題就是缺頁中斷,這個操做很耗時間,而且iOS不只僅是將數據加載到內存,還要對這頁作簽名認證,因此iOS耗時比較長,而且每頁耗時有很大差距,0.1ms到0.8毫秒,使用過程當中可能時間段感受不到,可是啓動時候會有不少數據要加載,這樣就會致使耗時很長,假如咱們啓動時候在不一樣頁面,由於代碼在machO的位置不是根據調用瞬間,而是經過文件編譯的位置來的,有可能啓動時候在運行時候會調用不少次page Fault,那麼若是咱們把全部啓動時候的代碼都放在一頁或者兩頁,這樣就很大程度上優化啓動速度,這種方法就叫作二進制重拍

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

經過App Store渠道分發的App,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。

2、實現

一、System Trace調試

首先優化,咱們要先學會調試,只有調試才能發現須要優化的地方,咱們知道內存分虛擬內存和物理內存,而內存是經過分頁管理的,當咱們啓動的時候調用不少方法,假如這些方法不在同一個page上面,就會形成缺頁中斷(page fault),而這個操做是要消耗時間的,因此假如啓動的方法都在一頁上面,就會很大程度上減小啓動時間的消耗,這個就須要用到二進制重拍來將啓動時候調用的方法放在同一個page上

  • 首先咱們打開項目command + i打開Instruments調試工具

  • 選擇System Trace,這個軟件能夠看到咱們項目中每一個線程的數據,

  • 點擊開始後這裏咱們搜索Main thread,選擇咱們的app,而後點擊Main thread ,再到下面選擇Main Thread --> Virtual Memory(虛擬內存)

  • 這裏面File Backed Page In就是page fault的次數
  • 當咱們把APP殺死後裏面再啓動,結果發現File Backed Page In這個值變得很小,說明APP就算殺死後,在啓動不是冷啓動,仍是有一部數據在系統的緩存中
  • 如何纔是真正的冷啓動呢,咱們能夠把APP殺掉後啓動多個手機裏面的APP,而後再啓動APP,發現File Backed Page In又變得很大
  • 說明虛擬內存是在系統中的,當系統內存不夠的時候,其餘APP會覆蓋老的APP的虛擬內存
  • 二進制重拍是在連接階段生成的,重排以後生成可執行文件,因此咱們只能在編譯階段來優化,而沒法對已生成的ipa進行優化

二、二進制重排

咱們能夠在XCode配置二進制重拍,首先咱們要肯定符號的順序,才能知道怎麼重拍,XCode使用的連接器叫作ld,ld有個參數叫order_file,只要有這個文件,咱們能夠將文件的路徑告訴XCode,在order_file文件中把符號的順序寫進去,XCode編譯的時候就會按照文件中的符號順序打包成二進制可執行文件。

咱們能夠在蘋果的objc4-750源碼中找到這種文件

打開後是下面這種格式:

裏面全是函數符號,咱們打開項目,在build setting 裏面搜索order file

發現這裏面指定了order的文件路徑,由於一旦在這裏指定了order file的路徑,XCode就會在編譯的時候按照文件裏面寫進去的順序

咱們如今寫一個Demo,而後編譯,咱們知道XCode編譯的時候文件會有一個連接,連接是按照Build Phases的Compile SourceL裏面的文件順序將.m文件轉換成.o文件,而後將這些.o文件連接在一塊兒生成可執行文件,

咱們能夠作一個實驗,在ViewController和AppDelegate裏面都寫一個load方法,而後運行

+(void)load
{
   NSLog(@"ViewController");
}
+(void)load
{
   NSLog(@"AppDelegate");
}
複製代碼

而且Build Phases的Compile Source順序:

運行,看下打印:

咱們再把Compile Source順序改一下

運行,打印:

咱們發現打印順序跟Compile Source文件順序同樣,驗證了上面的結論

如何查看整個項目的符號順序呢,咱們到Build Settings搜索link map

Link Map就是咱們連接的符號表,咱們把它改爲YES,這樣編譯的時候就會把連接的符號表給咱們寫出來,command + R咱們運行下,而後在Products裏面的.app文件,在咱們Intermediates.noindex-->項目名.build--->Debug-iphoneos-->項目名.build--->項目名-LinkMap-normal-arm64.txt,這個文件裏面就有連接的符號順序表

其中 Object files:就是連接了哪些.o文件

Sections:中

  • Address:

  • Size:

  • Segment:__TEXT代碼代碼段,只可讀;__DATA是數據段,可讀可寫

  • Section:

再下面就是咱們關心的符號:

Symbols:

  • Address:方法代碼的地址

  • Size:方法佔用的空間

  • File:文件的編號

  • Name:.o文件裏面的方法符號

對於Address,咱們從.app中拿到項目的可執行文件,而後用MachOView打開,而後在Section中看下Assembly

咱們發現符號表裏的0x100004B70在MachOView對應的value是彙編代碼,也就是咱們寫的代碼轉換成的彙編,因此這個地址就是代碼地址,因此二進制重拍就是把全部的代碼順序從新排一下,把啓動時候調用的代碼排到前面去,減小啓動時候加載page的數量(沒一個page大小是16K)

添加order file,咱們建立一個hank.order文件,在文件中寫入

而後放到工程的根目錄中,而後在Build setting裏面搜下order file,而後在後面將該文件地址添加進去

這樣Xcode在編譯時候就會按照order文件中的符號順序連接代碼了,咱們編譯一下,再看一下LinkMap-normal-arm64.txt文件

咱們發現是按照order的符號順序來的,並且若是order裏面寫了項目中不存在的方法符號,XCode會自動過濾掉,不存在影響

還有一種查看符號表的方法是在終端cd到項目可執行文件的目錄,而後輸入

nm 可執行文件名
複製代碼

這是查看所有的符號,還有查看自定義方法的符號

nm -Up TraceDemo
複製代碼

查看系統的符號

nm -up TraceDemo
複製代碼

三、獲取APP啓動時候調用的全部方法

這就是二進制重拍的步驟,可是咱們怎麼知道APP啓動時候的調用了哪些方法呢?

咱們之前拿到調用方法都是經過hook的形式,可是咱們須要hook項目中全部方法,

第一個方式:是用fishHook去hook 系統的 objc_msgSend這個函數,由於oc的方法都是經過發送消息的形式,可是這個函數參數是可變的參數,因此只能經過彙編形式hook,可是這種狀況initialize和block以及直接調用函數方式hook不到

第二種方式:clang插裝形式: 官方文檔:clang

OC方法、函數、block都能hook到

一、首先在Build Setting裏面搜索Other C Flags 在裏面添加參數:-fsanitize-coverage=trace-pc-guard

-fsanitize-coverage=func,trace-pc-guard
複製代碼

二、而後編譯,咱們發現會報錯,提示報錯

Showing Recent Messages
Undefined symbol: ___sanitizer_cov_trace_pc_guard_init
複製代碼

提示找不到__sanitizer_cov_trace_pc_guard和__sanitizer_cov_trace_pc_guard_init方法,咱們看一下文檔,發現又測試代碼:

咱們把這段代碼copy到項目中,發現,錯誤沒有了

__sanitizer_cov_trace_pc_guard_init

而後咱們先分析一下__sanitizer_cov_trace_pc_guard_init函數,這裏面有個start和stop,打個斷點,咱們看一下start和stop內存裏面的值,

發現start裏每4個字節裏面都有一個數組,並且是按照一、二、三、4的順序排列的,再看一下stop,由於stop字面意思是結尾,按照start的規則,咱們減4個字節看一下,發現是13,這是由於這裏面存的是咱們項目自定義文件中符號的數量,不管是方法、函數仍是block,都會統計進來,咱們能夠多加幾個方法或者函數、block試一下,就能夠驗證

__sanitizer_cov_trace_pc_guard

咱們再分析一下__sanitizer_cov_trace_pc_guard

咱們運行時候發現打印了好多guard

而後咱們實現個個手勢

-(void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event{

}
複製代碼

點擊一下屏幕,發現

點擊一下打印一下,咱們猜想每執行一個函數都會調用一次,說明該函數hook了全部的方法,爲了驗證一下,咱們定義一個函數和一個block,在點擊屏幕時候調用一個函數,再看一下

void(^block1)(void) = ^(void) {

};

void test(){
    block1();

}

guard: 0x100d8381c a PC 
guard: 0x100d83814 8 PC 
guard: 0x100d83810 7 PC 

複製代碼

咱們發現點擊一次,該函數調用了三次,證實了一下

咱們再經過彙編驗證一下,在toubegain、函數、block出都加上斷點,而後打開彙編,運行

bl指令表明調用一個方法或者一個函數 ,過掉這個斷點

test也調用了,再過一下

block也調用了,這是由於當咱們配置了chang的代碼覆蓋工具,而後實現了上面兩個函數,clang會以靜態插裝形式在全部方法、函數block內部插入一行代碼,並且是在第一行一開始插入的,作到了全局的hook

咱們再在分析下__sanitizer_cov_trace_pc_guard的做用,咱們如今這個函數裏面加一個斷點

而後運行

在左邊咱們發現有個函數調用棧,而且在每次調用方法時候都會調起__sanitizer_cov_trace_pc_guard函數,而這個函數就是相應方法調起來的

咱們發現實例代碼中有個PC,咱們加一個斷點打印一下這個PC看看,咱們先把啓動時候的函數都過掉再打開斷點,而後點擊一下屏幕觸發touchesBegan的方法進行攔截

而後在控制欄中輸入bt,查看一下函數調用棧

咱們看一下0x0000000104349abc這個地址的信息

咱們發現這個地址是在touchesBegan裏面,可是不在touchesBegan開頭,咱們把它減4個字節

第一個指令是bl,這時纔是touchesBegan的開頭

咱們在touchesBegan方法裏面加一個斷點,而後跳到touchesBegan方法裏面,再打開彙編看看

由於bl是調用的意思,咱們發現0x104349ab8是touchesBegan方法的開頭,bl是調用的意思,也就是說0x00000001000bdabc是調用下一個函數的指令的下一個地址,而且咱們發現PC打印的就是0x104349abc

咱們再來看一下函數調用棧

調用棧的左邊是上一個函數的開始地址,最後面有個+64,最後面那個數字是偏移量,也就是說函數的開始位置+偏移量纔是函數的真正的位置,這個時候touchesBegan的偏移量是44,咱們測試一下:

這纔是touchesBegan的真正實現,也就是彙編的這一段

這說明在__sanitizer_cov_trace_pc_guard裏面咱們能拿到下一個函數調用的首地址,這時爲啥呢

咱們看一下__sanitizer_cov_trace_pc_guard的彙編調用

最後面有個ret也就是return返回的意思,由於每一個函數或者方法都有一個return, 在底層實現,每個函數調用完成後都會返回下一個須要調用的函數的地址,也就是彙編中每次bl的時候會把下次要調用的指令的地址存在x30中,當函數執行時候遇到ret時候就會從x30中的值返回回去 ,例如咱們點擊屏幕時候在__sanitizer_cov_trace_pc_guard加個斷點,而後讀取x30數據,就獲得了touchesBegan的地址

因此__sanitizer_cov_trace_pc_guard中的

拿到的是下一個要調用的函數的地址,由於__sanitizer_cov_trace_pc_guard函數都是在hook函數前執行的,因此在這裏面拿到的函數地址就是咱們hook的函數地址

既然能拿到函數地址,咱們能夠經過這個函數去拿到函數名稱

#import <dlfcn.h>
dladdr(<#const void *#>, <#Dl_info *#>)
複製代碼

第一個參數是函數的地址,第二個參數是一個結構體指針,咱們看看這個結構體格式

typedef struct dl_info {
        const char      *dli_fname;     /* Pathname of shared object  */
        void            *dli_fbase;     /* Base address of shared object  */
        const char      *dli_sname;     /* Name of nearest symbol  */
        void            *dli_saddr;     /* Address of nearest symbol  */
} Dl_info;
複製代碼

咱們打印一下:

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);

打印:

fname:/private/var/containers/Bundle/Application/38C6E838-7D51-4546-9882-BF5858D08C16/TraceDemo.app/TraceDemo 
fbase:0x1000e0000 
sname:-[ViewController touchesBegan:withEvent:] 
saddr:0x1000e5a0c

複製代碼

因此咱們知道:

  • fname:文件路徑
  • fbase:文件地址
  • sname:函數符號名稱
  • saddr:函數符號地址,也就是函數的起始地址

當咱們能拿到項目全部調用函數的符號時候,咱們就能經過這種方法來拿到APP啓動時候調用的全部的函數、方法、block符號,而後建立order文件進行自動二進制重拍上代碼:

//原子隊列
static  OSQueueHead symbolList = OS_ATOMIC_QUEUE_INIT;
//定義符號結構體
typedef struct {
    void *pc;
    void *next;
}SYNode;

void __sanitizer_cov_trace_pc_guard(uint32_t *guard) {
//    if (!*guard) return;  // Duplicate the guard check.
    /*  精肯定位 哪裏開始 到哪裏結束!  在這裏面作判斷寫條件!*/
    void *PC = __builtin_return_address(0);
    SYNode *node = malloc(sizeof(SYNode));
    *node = (SYNode){PC,NULL};
    //進入,由於該函數可能在子線程中操做,因此用原子性操做,保證線程安全
    OSAtomicEnqueue(&symbolList, node, offsetof(SYNode, next));
//
}

-(void)createOrderFile{

    NSMutableArray <NSString *> * symbolNames = [NSMutableArray array];

    while (YES) {
        SYNode * node = OSAtomicDequeue(&symbolList, offsetof(SYNode, 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];
        [symbolNames addObject:symbolName];
    }
    //取反
    NSEnumerator * emt = [symbolNames reverseObjectEnumerator];
    //去重
    NSMutableArray<NSString *> *funcs = [NSMutableArray arrayWithCapacity:symbolNames.count];
    NSString * name;
    while (name = [emt nextObject]) {
        if (![funcs containsObject:name]) {
            [funcs addObject:name];
        }
    }
    //幹掉本身!
    [funcs removeObject:[NSString stringWithFormat:@"%s",__FUNCTION__]];
    //將數組變成字符串
    NSString * funcStr = [funcs  componentsJoinedByString:@"\n"];

    NSString * filePath = [NSTemporaryDirectory() stringByAppendingPathComponent:@"hank.order"];
    NSData * fileContents = [funcStr dataUsingEncoding:NSUTF8StringEncoding];
    [[NSFileManager defaultManager] createFileAtPath:filePath contents:fileContents attributes:nil];
    NSLog(@"%@",funcStr);
}
複製代碼
相關文章
相關標籤/搜索