iOS原理 App的啟動(dòng)優(yōu)化2:二進(jìn)制重排

iOS原理 文章匯總

前言

iOS原理 App的啟動(dòng)優(yōu)化1:優(yōu)化建議一文中已經(jīng)介紹了啟動(dòng)優(yōu)化的相關(guān)概念,我們知道,通過(guò)二進(jìn)制重排可以減少App的啟動(dòng)時(shí)間,提高程序的啟動(dòng)性能。

二進(jìn)制重排原理

CPU訪(fǎng)問(wèn)進(jìn)程數(shù)據(jù)時(shí),先訪(fǎng)問(wèn)數(shù)據(jù)對(duì)應(yīng)的虛擬內(nèi)存page,通過(guò)虛擬內(nèi)存地址找到其對(duì)應(yīng)的物理內(nèi)存地址,再通過(guò)物理地址訪(fǎng)問(wèn)到物理內(nèi)存上的數(shù)據(jù)。如果對(duì)應(yīng)的物理內(nèi)存地址不存在,說(shuō)明這部分?jǐn)?shù)據(jù)沒(méi)有加載到物理內(nèi)存中,此時(shí)會(huì)觸發(fā)缺頁(yè)中斷(Page Fault)。

物理內(nèi)存和虛擬內(nèi)存的詳細(xì)介紹可閱讀iOS原理 物理內(nèi)存&虛擬內(nèi)存

  • Page Fault

Page Fault會(huì)中斷當(dāng)前進(jìn)程,需要先將訪(fǎng)問(wèn)的數(shù)據(jù)加載到物理內(nèi)存中,再讓CPU訪(fǎng)問(wèn)。而通過(guò)App Store渠道分發(fā)的App,Page Fault還會(huì)進(jìn)行簽名驗(yàn)證,所以每一個(gè)Page Fault都會(huì)帶來(lái)一定的耗時(shí)。

如果啟動(dòng)過(guò)程中觸發(fā)大量的Page Fault就會(huì)降低啟動(dòng)性能,延長(zhǎng)啟動(dòng)時(shí)間。通過(guò)System Trace可以查看App在啟動(dòng)過(guò)程中觸發(fā)的Page Fault次數(shù):

  • 找到Xcode -> Open Developer Tool ->Instruments,選擇并打開(kāi)System Trace。

  • 選擇設(shè)備和App,啟動(dòng)System Trace,在A(yíng)pp打開(kāi)第一個(gè)界面后停止System Trace。

  • 搜索Main Thread,選擇Virtual Memory,File Backed Page In即表示Page Fault。

可以看到,啟動(dòng)過(guò)程中觸發(fā)了60次Page Fault,總共耗時(shí)7.73ms。這個(gè)案例Demo只是新建的一個(gè)空項(xiàng)目,一般來(lái)說(shuō),工作項(xiàng)目會(huì)觸發(fā)上千次Page Fault,就拿微信來(lái)說(shuō),觸發(fā)次數(shù)達(dá)到2600多次,耗時(shí)接近700ms。因此,減少啟動(dòng)過(guò)程中Page Fault的觸發(fā)次數(shù),就能縮短啟動(dòng)時(shí)間,提高啟動(dòng)性能,而這就可以通過(guò)『二進(jìn)制重排』來(lái)實(shí)現(xiàn)。

二進(jìn)制重排實(shí)現(xiàn)方式

App啟動(dòng)過(guò)程中會(huì)調(diào)用一些方法和函數(shù),CPU需要訪(fǎng)問(wèn)相關(guān)數(shù)據(jù)。這時(shí),通過(guò)修改代碼在二進(jìn)制文件的布局,將啟動(dòng)時(shí)刻調(diào)用的方法和函數(shù)的二進(jìn)制符號(hào),排列在一起,確保在一個(gè)虛擬內(nèi)存page中,這樣就從多個(gè)Page Fault減少為一個(gè)Page Fault,這就是二進(jìn)制重排。

修改方法和函數(shù)二進(jìn)制符號(hào)的布局,需要通過(guò)Linkmap、ld以及Clang插樁來(lái)實(shí)現(xiàn)。

1. Linkmap

Linkmap是iOS編譯過(guò)程的中間產(chǎn)物,記錄了二進(jìn)制文件的布局,需要在Xcode中找到Target -> Build Settings -> Write Link Map File,并設(shè)置為Yes來(lái)開(kāi)啟。

Linkmap主要包括三大部分:

  • Object Files生成二進(jìn)制用到的link單元的路徑和文件編號(hào)
  • Sections記錄Mach-O每個(gè)Segment/section的地址范圍
  • Symbols按順序記錄每個(gè)符號(hào)的地址范圍

編譯器在生成二進(jìn)制代碼的時(shí)候,默認(rèn)按照鏈接的Object File(.o)順序?qū)懳募?,按照Object File內(nèi)部的函數(shù)順序?qū)懞瘮?shù),因此方法和函數(shù)編譯后的二進(jìn)制符號(hào),默認(rèn)先按照.o文件(Object File)的鏈接順序,再按照文件里的編寫(xiě)順序來(lái)排列

以案例Demo為例,查看編譯后方法和函數(shù)在Linkmap里面的排列順序。

  • ViewController里編寫(xiě)幾個(gè)方法和函數(shù)
#import "ViewController.h"

@interface ViewController ()

@end

@implementation ViewController

void test1(){
    
    printf("1");
}

void test2(){
    
    printf("2");
}

- (void)viewDidLoad {
    [super viewDidLoad];
    // Do any additional setup after loading the view.
    
    test1();
}

+(void)load{
    
    printf("3");
    test2();
}

@end
  • 查看.o文件(Object File)鏈接順序,順序可以任意改變。

  • Command + B編譯Demo,根據(jù)路徑找到Link Map File

    image.png
  • 打開(kāi)文件,查看方法和函數(shù)編譯后的二進(jìn)制符號(hào)在文件中的排列順序

Link Map File里的布局情況可以印證,方法和函數(shù)編譯后的二進(jìn)制符號(hào),是先按照.o文件(Object File)的鏈接順序,再按照文件里的編寫(xiě)順序來(lái)排列。由于啟動(dòng)過(guò)程中調(diào)用的方法和函數(shù)可能存在于不同的類(lèi)里,它們編譯后的符號(hào)默認(rèn)在二進(jìn)制文件里分散排列,調(diào)用時(shí)就會(huì)觸發(fā)大量的Page Fault。

2. ld

ld是Xcode使用的鏈接器,寫(xiě)入其參數(shù)order_file中的符號(hào),會(huì)按照寫(xiě)入順序排列在二進(jìn)制文件中符號(hào)區(qū)域的頂部。因此,在Xcode中,通過(guò)Target -> Build Settings -> Order File來(lái)配置一個(gè)后綴為.order的文件路徑,并在這個(gè)order文件中,將啟動(dòng)過(guò)程中調(diào)用的方法和函數(shù)以符號(hào)格式寫(xiě)在里面,在項(xiàng)目編譯后,這些符號(hào)就會(huì)按照文件里的順序排列在二進(jìn)制文件中。若order文件中的符號(hào)對(duì)應(yīng)的方法實(shí)際不存在,ld則會(huì)忽略這些符號(hào)。

3. Clang插樁

Clang插樁,即批量hook,借助SanitizerCoverage(llvm內(nèi)置的一個(gè)簡(jiǎn)單的代碼覆蓋率檢測(cè)),實(shí)現(xiàn)100%符號(hào)覆蓋,獲取到所有的swiftOC、Cblock函數(shù)。

Clang插樁覆蓋的官方文檔 : clang 自帶代碼覆蓋工具 。

實(shí)現(xiàn)步驟如下:

  • Step1:開(kāi)啟SanitizerCoverage

    • 方法一:找到Target -> Build Settings -> Other C Flags,添加-fsanitize-coverage=func,trace-pc-guard,如果是swift項(xiàng)目,還需在Other Swift Flags中加入-sanitize-coverage=func-sanitize=undefined

    • 方法二:通過(guò)podfile來(lái)配置參數(shù)

    post_install do |installer|
     installer.pods_project.targets.each do |target|
       target.build_configurations.each do |config|
         config.build_settings['OTHER_CFLAGS'] = '-fsanitize-coverage=func,trace-pc-guard'
         config.build_settings['OTHER_SWIFT_FLAGS'] = '-sanitize-coverage=func -sanitize=undefined'
       end
     end
    end
    
  • Step2:重寫(xiě)下面兩個(gè)函數(shù),捕獲所有調(diào)用的方法、函數(shù)以及block

    • __sanitizer_cov_trace_pc_guard_init函數(shù)
    /**
     *   start:是一個(gè)指針,指向無(wú)符號(hào)int類(lèi)型,4個(gè)字節(jié),相當(dāng)于一個(gè)數(shù)組的起始位置,即符號(hào)的起始位置。
     *   stop:標(biāo)記的最后的地址,通過(guò)stop的地址-4,獲取到最后一個(gè)符號(hào)的真實(shí)地址,真實(shí)地址里的值就代表這符號(hào)的總數(shù)。
     */
    void __sanitizer_cov_trace_pc_guard_init(uint32_t *start, uint32_t *stop) {}
    

    這個(gè)是初始化函數(shù),可以獲取到所有符號(hào)(方法、函數(shù)、block、屬性)的數(shù)量。在捕獲方法和函數(shù)時(shí),這個(gè)函數(shù)里面可以不做任何處理。

    • __sanitizer_cov_trace_pc_guard函數(shù)
    /**
     *  這個(gè)方法可以捕獲所有調(diào)用的方法、函數(shù)以及block
     *  guard:哨兵,告知是第幾次被調(diào)用
     */
    void __sanitizer_cov_trace_pc_guard(uint32_t *guard) {}
    

    這個(gè)函數(shù)可以捕獲所有調(diào)用的方法、函數(shù)以及block。每當(dāng)調(diào)用一個(gè)方法、函數(shù)或者block,都會(huì)執(zhí)行一次這個(gè)函數(shù),在這里面,通過(guò)__builtin_return_address(0)可以拿到當(dāng)前調(diào)用的方法(/函數(shù)/block)的地址,再通過(guò)Dl_info可以拿到方法地址和方法名。

二進(jìn)制重排的案例Demo

通過(guò)上面的學(xué)習(xí)可知,二進(jìn)制重排的實(shí)現(xiàn)步驟如下:

  • 通過(guò)Clang插樁獲取啟動(dòng)時(shí)刻調(diào)用的全部方法、函數(shù)、block。
  • 方法、函數(shù)、block以符號(hào)的格式寫(xiě)入Order文件
  • 配置Order文件。

接下來(lái)通過(guò)案例Demo來(lái)詳細(xì)講解實(shí)現(xiàn)步驟,在案例中把Clang插樁相關(guān)代碼封裝在一個(gè)文件中,方便后續(xù)使用。

  • Step1:新建一個(gè)OrderFileTool工具類(lèi),所有捕獲符號(hào)的相關(guān)代碼均在這里面實(shí)現(xiàn)。

    由于__sanitizer_cov_trace_pc_guard函數(shù)執(zhí)行太頻繁,所以在函數(shù)里面只保存調(diào)用函數(shù)的地址,后面再統(tǒng)一解析。因此,打算用單向鏈表來(lái)保存這些地址,考慮到線(xiàn)程安全,決定用原子隊(duì)列OSQueueHead來(lái)保存。捕獲的代碼邏輯如下:

    #import "OrderFileTool.h"
    #import <dlfcn.h>
    #import <libkern/OSAtomic.h>
    #include <stdlib.h>
    
    @implementation OrderFileTool
    
    //原子隊(duì)列,保證多線(xiàn)程下的寫(xiě)入安全
    static OSQueueHead symbolQueue = OS_ATOMIC_QUEUE_INIT;
    
    //定義符號(hào)結(jié)構(gòu)體,鏈表的節(jié)點(diǎn)
    typedef struct {
        void *pc;
        void *next;
    }SYNode;
    
    //初始化,里面不做任何處理
    void __sanitizer_cov_trace_pc_guard_init(uint32_t *start, uint32_t *stop) {}
    
    //捕獲方法、函數(shù)、block,這里只保存地址,不做解析處理
    void __sanitizer_cov_trace_pc_guard(uint32_t *guard) {
    
        //1.獲取方法、函數(shù)、block的地址
        /**
         *   __builtin_return_address:返回函數(shù)的地址
         *   0:表示返回當(dāng)前函數(shù)的地址
         *   1:表示返回當(dāng)前函數(shù)調(diào)用者的地址
         */
        void *PC = __builtin_return_address(0);
    
        //2.創(chuàng)建node
        //將地址賦值給node結(jié)構(gòu)體里的pc指針
        SYNode *node = malloc(sizeof(SYNode));
        *node = (SYNode){PC, NULL};
    
        //3.加入隊(duì)列
        //將node添加到隊(duì)列中,并將下一個(gè)node的地址賦值給當(dāng)前node結(jié)構(gòu)體里的next指針
        OSAtomicEnqueue(&symbolQueue, node, offsetof(SYNode, next));
    }
    
    +(void)writeOrderFile{
    
        //創(chuàng)建一個(gè)符號(hào)數(shù)組
        NSMutableArray *mArr = [NSMutableArray array];
    
        //while循環(huán)獲取所有符號(hào)
        while (YES) {
    
            //取出節(jié)點(diǎn)
            SYNode *node = OSAtomicDequeue(&symbolQueue, offsetof(SYNode, next));
            if(node==NULL){
    
                break;
            }
    
            //解析PC,獲取符號(hào)
            Dl_info info;
            dladdr(node->pc, &info);
            NSString *name = @(info.dli_sname);
            //如果不是OC方法,需要在前面加上_
            BOOL isObjC = [name hasPrefix:@"-["]||[name hasPrefix:@"+["];
            NSString *symbol = isObjC?name:[@"_" stringByAppendingString:name];
            //去重判斷,如果符號(hào)存在,就不添加
            if(![mArr containsObject:symbol]){
    
                //隊(duì)列的存儲(chǔ)是反序的,所以這里逆序保存在數(shù)組中,當(dāng)然,不逆序也不影響
                [mArr insertObject:symbol atIndex:0];
            }
        }
    
        //這里要去掉自己本身
        NSString *currentFunc = @"+[OrderFileTool writeOrderFile]";
        if([mArr containsObject:currentFunc]){
    
            [mArr removeObject:currentFunc];
        }
    
        //將數(shù)組轉(zhuǎn)換成字符串,并寫(xiě)入Order文件
        NSString *symbolStr = [mArr componentsJoinedByString:@"\n"];
        NSLog(@" ===== symbolStr = \n%@", symbolStr);
        //寫(xiě)入文件
        NSString *filePath = [NSTemporaryDirectory() stringByAppendingPathComponent:@"rewrite.order"];
        NSData *fileContents = [symbolStr dataUsingEncoding:NSUTF8StringEncoding];
        BOOL success = [[NSFileManager defaultManager] createFileAtPath:filePath contents:fileContents attributes:nil];
        if (success) {
    
            NSLog(@" ==== rewrite success:%@", filePath);
        }
    }
    
    @end
    

    這里就實(shí)現(xiàn)了整個(gè)邏輯,只需要在外部調(diào)用+(void)writeOrderFile方法就可完成所有符號(hào)的捕獲,并寫(xiě)入到order文件。

  • Step2:開(kāi)啟SanitizerCoverage,在程序啟動(dòng)結(jié)束后執(zhí)行捕獲方法

    一般來(lái)說(shuō),在首界面的ViewDidLoad方法里執(zhí)行就能捕獲到程序啟動(dòng)過(guò)程的所有符號(hào)。這里也是在案例Demo的ViewController.m文件里執(zhí)行。

    #import "ViewController.h"
    #import "OrderFileTool.h"
    
    @interface ViewController ()
    
    @end
    
    @implementation ViewController
    
    void test1(){
      
        printf("1");
    }
    
    void test2(){
      
        printf("2");
    }
    
    - (void)viewDidLoad {
        [super viewDidLoad];
        // Do any additional setup after loading the view.
      
        test1();
      
        //獲取啟動(dòng)過(guò)程所有方法和函數(shù)的符號(hào)
        [OrderFileTool writeOrderFile];
    }
    
    +(void)load{
      
        printf("3");
        test2();
    }
    
    @end
    

    通過(guò)輸出的Order文件路徑,找到文件并改成.txt后綴打開(kāi),可以看到符號(hào)的排列順序如下:

    可以看到,啟動(dòng)過(guò)程的最后一個(gè)函數(shù)test1的符號(hào)排在最后一個(gè),至此,完成了所有符號(hào)的捕獲。

  • Step3:配置Order文件,然后Command + B編譯。

    將生成的rewrite.order文件添加到項(xiàng)目中,再在Target -> Build Settings -> Order File中配置order文件的路徑,然后編譯。

    查看編譯后新生成的LinkMap File。

    可以看到,啟動(dòng)過(guò)程的方法和函數(shù)符號(hào),均按照order文件里的順序排列在一起,并置于二進(jìn)制文件前面。至此,整個(gè)二進(jìn)制重排的過(guò)程就完成了。

溫馨提示:獲取到啟動(dòng)過(guò)程的全部符號(hào)后,就關(guān)掉SanitizerCoverage,并刪除OrderFileTool工具類(lèi),若以后App啟動(dòng)相關(guān)業(yè)務(wù)發(fā)生變更后,再重新排列一次就可以了。

推薦閱讀

1. iOS原理 App的啟動(dòng)優(yōu)化1:優(yōu)化建議
2. 抖音研發(fā)實(shí)踐:基于二進(jìn)制文件重排的解決方案 APP啟動(dòng)速度提升超15%
3. Clang插樁覆蓋的官方文檔
4. iOS調(diào)優(yōu) | 深入理解Link Map File

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀(guān)點(diǎn),簡(jiǎn)書(shū)系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容