iOS-底層原理:啟動優(yōu)化(三)二進制重排

前提,在之前的兩篇文章中,大致介紹了一些基本概念以及啟動優(yōu)化的思路,下面來著重介紹一個pre-main階段的優(yōu)化方案,即二進制重排,這個方案最開始是由于抖音的這篇文章抖音研發(fā)實踐:基于二進制文件重排的解決方案 APP啟動速度提升超15%火起來的。

二進制重排原理

在虛擬內(nèi)存部分,我們知道,當(dāng)進程訪問一個虛擬內(nèi)存page,而對應(yīng)的物理內(nèi)存不存在時,會觸發(fā)缺頁中斷(Page Fault),因此阻塞進程。此時就需要先加載數(shù)據(jù)到物理內(nèi)存,然后再繼續(xù)訪問。這個對性能是有一定影響的。

基于Page Fault,我們思考,App在冷啟動過程中,會有大量的類、分類、三方等需要加載和執(zhí)行,此時的產(chǎn)生的Page Fault所帶來的的耗時是很大的。以WeChat為例,我們來看下,在啟動階段的Page Fault的次數(shù)

  • CMD+i快捷鍵,選擇System Trace

    image
  • 點擊啟動(啟動前需要重啟手機,清除緩存數(shù)據(jù)),第一個界面出來后,停掉,按照下圖中操作

    image

    從圖中可以看出WeChat發(fā)生的PageFault有2800+次,可想而知,這個是非常影響性能的。

  • 然后我們再通過Demo查看方法在編譯時期的排列順序,在ViewController中按下列順序定義以下幾個方法

@implementation ViewController

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

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

- (void)viewDidLoad {
    [super viewDidLoad];

    test1();
}

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

  • Build Setting -> Write Link Map File設(shè)置為YES

    image
  • CMD+B編譯demo,然后在對應(yīng)的路徑下查找 link map文件,如下所示,可以發(fā)現(xiàn) 類中函數(shù)的加載順序是從上到下的,而文件的順序是根據(jù)Build Phases -> Compile Sources中的順序加載的

    image

從上面的Page Fault的次數(shù)以及加載順序,可以發(fā)現(xiàn)其實導(dǎo)致Page Fault次數(shù)過多的根本原因是啟動時刻需要調(diào)用的方法,處于不同的Page導(dǎo)致的。因此,我們的優(yōu)化思路就是:將所有啟動時刻需要調(diào)用的方法,排列在一起,即放在一個頁中,這樣就從多個Page Fault變成了一個Page Fault。這就是二進制重排的核心原理,如下所示

image

注意:在iOS生產(chǎn)環(huán)境的app,在發(fā)生Page Fault進行重新加載時,iOS系統(tǒng)還會對其做一次簽名驗證,因此 iOS 生產(chǎn)環(huán)境的 Page Fault 比Debug環(huán)境下所產(chǎn)生的耗時更多。

二進制重排實踐

下面,我們來進行具體的實踐,首先理解幾個名詞

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

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

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

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

ld

ld是Xcode使用的鏈接器,有一個參數(shù)order_file,我們可以通過在Build Settings -> Order File配置一個后綴為order的文件路徑。在這個order文件中,將所需要的符號按照順序?qū)懺诶锩?,在項目編譯時,會按照這個文件的順序進行加載,以此來達(dá)到我們的優(yōu)化

所以二進制重排的本質(zhì)就是對啟動加載的符號進行重新排列。

到目前為止,原理我們基本弄清楚了,如果項目比較小,完全可以自定義一個order文件,將方法的順序手動添加,但是如果項目較大,涉及的方法特別多,此時我們?nèi)绾潍@取啟動運行的函數(shù)呢?有以下幾種思路

  • 1、hook objc_msgSend:我們知道,函數(shù)的本質(zhì)是發(fā)送消息,在底層都會來到objc_msgSend,但是由于objc_msgSend的參數(shù)是可變的,需要通過匯編獲取,對開發(fā)人員要求較高。而且也只能拿到OC 和 swift中@objc 后的方法

  • 2、靜態(tài)掃描:掃描 Mach-O 特定段和節(jié)里面所存儲的符號以及函數(shù)數(shù)據(jù)

  • 3、Clang插樁:即批量hook,可以實現(xiàn)100%符號覆蓋,即完全獲取swift、OC、C、block函數(shù)

Clang 插樁

llvm內(nèi)置了一個簡單的代碼覆蓋率檢測(SanitizerCoverage)。它在函數(shù)級、基本塊級和邊緣級插入對用戶定義函數(shù)的調(diào)用。我們這里的批量hook,就需要借助于SanitizerCoverage

關(guān)于 clang 的插樁覆蓋的官方文檔如下 : clang 自帶代碼覆蓋工具 文檔中有詳細(xì)概述,以及簡短Demo演示。

  • 【第一步:配置】開啟 SanitizerCoverage
    • OC項目,需要在:在 Build Settings 里的 “Other C Flags” 中添加 -fsanitize-coverage=func,trace-pc-guard

    • 如果是Swift項目,還需要額外在 “Other Swift Flags” 中加入-sanitize-coverage=func-sanitize=undefined

    • 所有鏈接到 App 中的二進制都需要開啟 SanitizerCoverage,這樣才能完全覆蓋到所有調(diào)用。

    • 也可以通過podfile來配置參數(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

  • 【第二步:重寫方法】新建一個OC文件CJLOrderFile,重寫兩個方法
    • __sanitizer_cov_trace_pc_guard_init方法

      • 參數(shù)1 start 是一個指針,指向無符號int類型,4個字節(jié),相當(dāng)于一個數(shù)組的起始位置,即符號的起始位置(是從高位往低位讀)

        image
      • 參數(shù)2 stop,由于數(shù)據(jù)的地址是往下讀的(即從高往低讀,所以此時獲取的地址并不是stop真正的地址,而是標(biāo)記的最后的地址,讀取stop時,由于stop占4個字節(jié),stop真實地址 = stop打印的地址-0x4

        image
      • stop內(nèi)存地址中存儲的值表示什么?在增加一個方法/塊/c++/屬性的方法(多3個),發(fā)現(xiàn)其值也會增加對應(yīng)的數(shù),例如增加一個test1方法

        image
    • __sanitizer_cov_trace_pc_guard方法 ,主要是捕獲所有的啟動時刻的符號,將所有符號入隊

      • 參數(shù)guard是一個哨兵,告訴我們是第幾個被調(diào)用的

      • 符號的存儲需要借助于鏈表,所以需要定義鏈表節(jié)點CJLNode,

      • 通過OSQueueHead創(chuàng)建原子隊列,其目的是保證讀寫安全

      • 通過OSAtomicEnqueue方法將node入隊,通過鏈表的next指針可以訪問下一個符號

//原子隊列,其目的是保證寫入安全,線程安全
static  OSQueueHead queue = OS_ATOMIC_QUEUE_INIT;
//定義符號結(jié)構(gòu)體,以鏈表的形式
typedef struct {
    void *pc;
    void *next;
}CJLNode;

/*
 - start:起始位置
 - stop:并不是最后一個符號的地址,而是整個符號表的最后一個地址,最后一個符號的地址=stop-4(因為是從高地址往低地址讀取的,且stop是一個無符號int類型,占4個字節(jié))。stop存儲的值是符號的
 */
void __sanitizer_cov_trace_pc_guard_init(uint32_t *start,
                                                    uint32_t *stop) {
    static uint64_t N;
    if (start == stop || *start) return;
    printf("INIT: %p - %p\n", start, stop);
    for (uint32_t *x = start; x < stop; x++) {
        *x = ++N;
    }

}

/*
 可以全面hook方法、函數(shù)、以及block調(diào)用,用于捕捉符號,是在多線程進行的,這個方法中只存儲pc,以鏈表的形式

 - guard 是一個哨兵,告訴我們是第幾個被調(diào)用的
 */
void __sanitizer_cov_trace_pc_guard(uint32_t *guard) {
//    if (!*guard) return;//將load方法過濾掉了,所以需要注釋掉

    //獲取PC
    /*
     - PC 當(dāng)前函數(shù)返回上一個調(diào)用的地址
     - 0 當(dāng)前這個函數(shù)地址,即當(dāng)前函數(shù)的返回地址
     - 1 當(dāng)前函數(shù)調(diào)用者的地址,即上一個函數(shù)的返回地址
    */
    void *PC = __builtin_return_address(0);
    //創(chuàng)建node,并賦值
    CJLNode *node = malloc(sizeof(CJLNode));
    *node = (CJLNode){PC, NULL};

    //加入隊列
    //符號的訪問不是通過下標(biāo)訪問,是通過鏈表的next指針,所以需要借用offsetof(結(jié)構(gòu)體類型,下一個的地址即next)
    OSAtomicEnqueue(&queue, node, offsetof(CJLNode, next));
}

  • 【第三步:獲取所有符號并寫入文件】
    -while循環(huán)從隊列中取出符號,處理非OC方法的前綴,存到數(shù)組中
    • 數(shù)組取反,因為入隊存儲的順序是反序的
    • 數(shù)組去重,并移除本身方法的符號
    • 將數(shù)組中的符號轉(zhuǎn)成字符串并寫入到cjl.order文件中
extern void getOrderFile(void(^completion)(NSString *orderFilePath)){

    collectFinished = YES;
    __sync_synchronize();
    NSString *functionExclude = [NSString stringWithFormat:@"_%s", __FUNCTION__];
    dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.01 * NSEC_PER_SEC)), dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
        //創(chuàng)建符號數(shù)組
        NSMutableArray<NSString *> *symbolNames = [NSMutableArray array];

        //while循環(huán)取符號
        while (YES) {
            //出隊
            CJLNode *node = OSAtomicDequeue(&queue, offsetof(CJLNode, next));
            if (node == NULL) break;

            //取出PC,存入info
            Dl_info info;
            dladdr(node->pc, &info);
//            printf("%s \n", info.dli_sname);

            if (info.dli_sname) {
                //判斷是不是OC方法,如果不是,需要加下劃線存儲,反之,則直接存儲
                NSString *name = @(info.dli_sname);
                BOOL isObjc = [name hasPrefix:@"+["] || [name hasPrefix:@"-["];
                NSString *symbolName = isObjc ? name : [@"_" stringByAppendingString:name];
                [symbolNames addObject:symbolName];
            }

        }

        if (symbolNames.count == 0) {
            if (completion) {
                completion(nil);
            }
            return;
        }

        //取反(隊列的存儲是反序的)
        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:functionExclude];

        //將數(shù)組變成字符串
        NSString *funcStr = [funcs componentsJoinedByString:@"\n"];
        NSLog(@"Order:\n%@", funcStr);

        //字符串寫入文件
        NSString *filePath = [NSTemporaryDirectory() stringByAppendingPathComponent:@"cjl.order"];
        NSData *fileContents = [funcStr dataUsingEncoding:NSUTF8StringEncoding];
        BOOL success = [[NSFileManager defaultManager] createFileAtPath:filePath contents:fileContents attributes:nil];
        if (completion) {
            completion(success ? filePath : nil);
        }
    });
}

  • 【第四步:在didFinishLaunchingWithOptions方法最后調(diào)用】需要注意的是,這里的調(diào)用位置是由你決定的,一般來說,是第一個渲染的界面
- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions {

    [self test11];

    getOrderFile(^(NSString *orderFilePath) {
        NSLog(@"OrderFilePath:%@", orderFilePath);
    });

    return YES;
}

- (void)test11{

}

此時的cjl.order中只有這三個方法

image
  • 【第五步:拷貝文件,放入指定位置,并配置路徑】一般將該文件放入主項目路徑下,并在Build Settings -> Order File中配置./cjl.order,下面是配置前后的對比(上邊是配置前的熟悉怒,下邊是配置后符號順序的)

    image

注意點:避免死循環(huán)

  • Build Settings -> Other C Flags的如果配置的是-fsanitize-coverage=trace-pc-guard,在while循環(huán)部分會出現(xiàn)死循環(huán)(我們在touchBegin方法中調(diào)試)

    image
  • 我們打開匯編調(diào)試,發(fā)現(xiàn)有3個__sanitizer_cov_trace_pc_guard的調(diào)用

    image
    • 第一次是bl 是 touchBegin

      image
    • 第三次 bl 是 printf

    • 第二次 bl 是因為while 循環(huán)。 即 只要是跳轉(zhuǎn),就會被hook,即有 bl、b的指令,就會被hook

      image

解決方式:將BuildSetting中的other C Flags的-fsanitize-coverage=trace-pc-guard ,改成-fsanitize-coverage=func,trace-pc-guard

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

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

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