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

iOS 底層原理 文章匯總

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

二進制重排原理

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

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

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

    測Page Fault-1

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

    測Page Fault-2

    從圖中可以看出WeChat發生的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設置為YES

    設置

  • CMD+B編譯demo,然后在對應的路徑下查找 link map文件,如下所示,可以發現 類中函數的加載順序是從上到下的,而文件的順序是根據Build Phases -> Compile Sources中的順序加載的

    加載順序

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

二進制重排原理

注意:在iOS生產環境的app,在發生Page Fault進行重新加載時,iOS系統還會對其做一次簽名驗證,因此 iOS 生產環境的 Page Fault 比Debug環境下所產生的耗時更多。

二進制重排實踐

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

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

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

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

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

ld

ld是Xcode使用的鏈接器,有一個參數order_file,我們可以通過在Build Settings -> Order File配置一個后綴為order的文件路徑。在這個order文件中,將所需要的符號按照順序寫在里面,在項目編譯時,會按照這個文件的順序進行加載,以此來達到我們的優化

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

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

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

  • 2、靜態掃描:掃描 Mach-O 特定段和節里面所存儲的符號以及函數數據

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

Clang 插樁

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

關于 clang 的插樁覆蓋的官方文檔如下 : clang 自帶代碼覆蓋工具 文檔中有詳細概述,以及簡短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,這樣才能完全覆蓋到所有調用。

    • 也可以通過podfile來配置參數

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方法

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

        參數1-start

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

        參數2-stop

      • stop內存地址中存儲的值表示什么?在增加一個方法/塊/c++/屬性的方法(多3個),發現其值也會增加對應的數,例如增加一個test1方法


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

      • 參數guard是一個哨兵,告訴我們是第幾個被調用的

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

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

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

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

/*
 - start:起始位置
 - stop:并不是最后一個符號的地址,而是整個符號表的最后一個地址,最后一個符號的地址=stop-4(因為是從高地址往低地址讀取的,且stop是一個無符號int類型,占4個字節)。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方法、函數、以及block調用,用于捕捉符號,是在多線程進行的,這個方法中只存儲pc,以鏈表的形式
 
 - guard 是一個哨兵,告訴我們是第幾個被調用的
 */
void __sanitizer_cov_trace_pc_guard(uint32_t *guard) {
//    if (!*guard) return;//將load方法過濾掉了,所以需要注釋掉
    
    //獲取PC
    /*
     - PC 當前函數返回上一個調用的地址
     - 0 當前這個函數地址,即當前函數的返回地址
     - 1 當前函數調用者的地址,即上一個函數的返回地址
    */
    void *PC = __builtin_return_address(0);
    //創建node,并賦值
    CJLNode *node = malloc(sizeof(CJLNode));
    *node = (CJLNode){PC, NULL};
    
    //加入隊列
    //符號的訪問不是通過下標訪問,是通過鏈表的next指針,所以需要借用offsetof(結構體類型,下一個的地址即next)
    OSAtomicEnqueue(&queue, node, offsetof(CJLNode, next));
}
  • 【第三步:獲取所有符號并寫入文件】
    -while循環從隊列中取出符號,處理非OC方法的前綴,存到數組中
    • 數組取反,因為入隊存儲的順序是反序的
    • 數組去重,并移除本身方法的符號
    • 將數組中的符號轉成字符串并寫入到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), ^{
        //創建符號數組
        NSMutableArray<NSString *> *symbolNames = [NSMutableArray array];
        
        //while循環取符號
        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];
        
        //將數組變成字符串
        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方法最后調用】需要注意的是,這里的調用位置是由你決定的,一般來說,是第一個渲染的界面
- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions {
    
    [self test11];
    
    getOrderFile(^(NSString *orderFilePath) {
        NSLog(@"OrderFilePath:%@", orderFilePath);
    });
    
    return YES;
}

- (void)test11{
    
}

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

cjl.order文件

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

注意點:避免死循環

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

    避免死循環-1

  • 我們打開匯編調試,發現有3個__sanitizer_cov_trace_pc_guard的調用

    避免死循環-2

    • 第一次是bl 是 touchBegin
      避免死循環-3
    • 第三次 bl 是 printf
    • 第二次 bl 是因為while 循環。 即 只要是跳轉,就會被hook,即有 bl、b的指令,就會被hook
      避免死循環-4

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

參考鏈接

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容