通過 Mach-O 檔案動態分析進行 iOS load 方法耗時檢測

CoderLin發表於2019-03-04

背景

目前部分產品反饋啟動時間還是較慢。但目前啟動時間耗時統計方案無法統計到 main 方法之前的 load 方法耗時,無法定位耗時長的元件程式碼。

第三方方案:Hook所有+load方法(包括Category)

該方案通過 Hook 所有 Class 中的 load 方法的方式實現了 load 方法的替換。在替換的方法前後加入耗時統計函式,從而實現 load 方法耗時統計。

但是該方案遍歷 load 方法的過程是通過查詢所有的映象,然後通過 const char * _Nonnull * _Nullable objc_copyClassNamesForImage(const char * _Nonnull image, unsigned int * _Nullable outCount) 方法找到所有的類,再遍歷所有類中的所有方法,從而撈出了所有的 +load 方法。

優點

這個方案中有一個非常值得借鑑的點:將 Hook 方法寫在動態庫中,若讓主工程包只依賴該動態庫。使得該動態庫一定可以最先被載入。在該動態庫中唯一一個 +load 方法中去檢測整個 App 中所有的類,確保可以在其他任何類載入前對其進行檢測,和方法替換。

缺點

以目前一箇中等大小的應用工廠組裝產品為例,需要耗時大約150ms,以99u這樣的平臺型 App 而言,耗時至少會增加一倍以上。而這一切都是在工程啟動的時候做的,若在每次啟動時都開始 load 耗時檢測,那這個 Hook 過程的耗時肯定不能接受。哪怕是選擇啟用,這樣的耗時也十分影響體驗。所以本篇文章將說明如何在這個方案的基礎上進行改進。

load 耗時檢測的思路:

思路一

通過在最早 load 的方法中加入一個獲取到所有需要被執行load方法的類及分類,並對其進行 method swizzling 替換。

思路二

對執行 load 方法的程式呼叫棧上的關鍵函式進行 fishhook ,從而實現獲取到 load 方法關鍵資訊,並對關資訊(如 IMP、SEL 、Class 等)做處理。

探究 + load 方法呼叫過程

dyld 和 objc 庫靜態分析

從 App 啟動到載入到這個動態庫第一個 load 方法過程中經歷了哪些過程呢?

我們可以通過打斷點的方式檢視這個堆疊。

load_progress

這裡可以看到程式入口是:

_dyld_start ,這是一個彙編的入口,其目的是載入、啟動 dyld 庫解析 App 的動態庫依賴,然後在 objc 庫中進行image 的載入。這兩個庫在 /usr/lib 路徑下,我們可以通過 Mach-O 檔案看到其中的函式名。由於它是開源的。我們可以在 dyld 原始碼(線上版) 線上閱讀它,也可以通過 dyld原始碼(下載版) 下載到本地閱讀。

我們下載 dyld-551.4 、objc4-723 這兩個庫到本地進行靜態分析。

堆疊中可以看到,在棧底處 12 dyld::_main 中正式地開始載入程式,進行動態庫以來解析等。在 3 dyld::notifySingle 中呼叫 objc 中的 load_images 進行映象的載入,在載入過程中進行了 load 的初始化。

知道了 +load 初始化的大致過程後,我們可以深入程式碼細節進行分析。

我們從棧頂開始反向看 +load 方法被呼叫過程。在 objc 庫中的 objc-loadmethod.m 檔案找到 call_class_loads 方法:

static void call_class_loads(void){
    int i;
    // Detach current loadable list.
    //這是所有符合條件可被執行load的類
    struct loadable_class *classes = loadable_classes;
    int used = loadable_classes_used;
    loadable_classes = nil;
    loadable_classes_allocated = 0;
    loadable_classes_used = 0;
    // Call all +loads for the detached list.
    for (i = 0; i < used; i++) {
        Class cls = classes[i].cls;
        //此處取到 load 方法的 IMP
        load_method_t load_method = (load_method_t)classes[i].method;
        if (!cls) continue; 
        if (PrintLoading) {
            _objc_inform("LOAD: +[%s load]
", cls->nameForLogging());
        }
        //此次進行load初始化
        (*load_method)(cls, SEL_load);
    }
    // Destroy the detached list.
    if (classes) free(classes);
}
複製程式碼

這一步中有兩個地方值得注意:

  • 所有需要被執行 load 方法的類已經被放到 loadable_classes 連結串列中了
  • load 的 IMP 是存在結構體中的。這個關鍵資訊在本次開發中雖然沒有用上,但在後續的思考和改進中存在一定的利用空間。

該方法被 call_load_methods 方法呼叫,call_class_loads 方法實現如下:

void call_load_methods(void) {
    static bool loading = NO;
    bool more_categories;
    loadMethodLock.assertLocked();
    // Re-entrant calls do nothing; the outermost call will finish the job.
    if (loading) return;
    loading = YES;
    void *pool = objc_autoreleasePoolPush();
    do {
        // 1. Repeatedly call class +loads until there aren`t any more
        while (loadable_classes_used > 0) {
            //載入類 load
            call_class_loads();
        }
        // 2. Call category +loads ONCE
        //載入分類 load
        more_categories = call_category_loads();
        // 3. Run more +loads if there are classes OR more untried categories
    } while (loadable_classes_used > 0  ||  more_categories);
    objc_autoreleasePoolPop(pool);
    loading = NO;
}
複製程式碼

該方法就是先載入所有類的 load,再載入所有分類的 load。該方法被 load_images 呼叫

void load_images(const char *path __unused, const struct mach_header *mh) {
    // Return without taking locks if there are no +load methods here.
    if (!hasLoadMethods((const headerType *)mh)) return;
    recursive_mutex_locker_t lock(loadMethodLock);
    // Discover load methods {
        rwlock_writer_t lock2(runtimeLock);
        prepare_load_methods((const headerType *)mh);
    }
    // Call +load methods (without runtimeLock - re-entrant)
    call_load_methods();
}
複製程式碼

上文說到:進行 load_method 呼叫的時候,所有需要被呼叫的 load 的方法已經被加入到連結串列中了,那麼它們是怎麼被加入到連結串列中、何時被加入到連結串列中呢?

答案是:在 load_images 中的 prepare_load_methods((const headerType *)mh);進行映象檔案預載入/解析的時候生成了 loadable_classes 連結串列。

prepare_load_methods 方法實現如下:

void prepare_load_methods(const headerType *mhdr){
    size_t count, i;
    runtimeLock.assertWriting();
    //此處獲取到所有需要被執行load方法的類
    classref_t *classlist = 
        _getObjc2NonlazyClassList(mhdr, &count);
    for (i = 0; i < count; i++) {
        //此處對類進行remap
        schedule_class_load(remapClass(classlist[i]));
    }
    //此處獲取到所有需要被執行load方法的分類
    category_t **categorylist = _getObjc2NonlazyCategoryList(mhdr, &count);
    for (i = 0; i < count; i++) {
        category_t *cat = categorylist[i];
        //此處膚對分類進行remap
        Class cls = remapClass(cat->cls);
        if (!cls) continue;  // category for ignored weak-linked class
        realizeClass(cls);
        assert(cls->ISA()->isRealized());
        add_category_to_loadable_list(cat);
    }
}
複製程式碼

通過 _getObjc2NonlazyClassList_getObjc2NonlazyCategoryList 分別獲取到需要被執行 load 方法的類和分類的連結串列。這兩個方法內部實現如下:

typedef struct classref * classref_t;

struct category_t {
    const char *name;
    classref_t cls;
    struct method_list_t *instanceMethods;
    struct method_list_t *classMethods;
    struct protocol_list_t *protocols;
    struct property_list_t *instanceProperties;
    // Fields below this point are not always present on disk.
    struct property_list_t *_classProperties;

    method_list_t *methodsForMeta(bool isMeta) {
        if (isMeta) return classMethods;
        else return instanceMethods;
    }

    property_list_t *propertiesForMeta(bool isMeta, struct header_info *hi);
};

#define GETSECT(name, type, sectname)                                   
    type *name(const headerType *mhdr, size_t *outCount) {              
        return getDataSection<type>(mhdr, sectname, nil, outCount);     
    }                                                                   
    type *name(const header_info *hi, size_t *outCount) {               
        return getDataSection<type>(hi->mhdr(), sectname, nil, outCount); 
    }

GETSECT(_getObjc2NonlazyClassList,    classref_t,      "__objc_nlclslist");
GETSECT(_getObjc2NonlazyCategoryList, category_t *,    "__objc_nlcatlist");

template <typename T>
T* getDataSection(const headerType *mhdr, const char *sectname, size_t *outBytes, size_t *outCount) {
    unsigned long byteCount = 0;
    T* data = (T*)getsectiondata(mhdr, "__DATA", sectname, &byteCount);
    if (!data) {
        data = (T*)getsectiondata(mhdr, "__DATA_CONST", sectname, &byteCount);
    }
    if (!data) {
        data = (T*)getsectiondata(mhdr, "__DATA_DIRTY", sectname, &byteCount);
    }
    if (outBytes) *outBytes = byteCount;
    if (outCount) *outCount = byteCount / sizeof(T);
    return data;
}
複製程式碼

此處用的C++ 的模板方法從 Mach-O 檔案的 __DATA 章節中的 __objc_nlclslist__objc_nlcatlist 段中分別獲取到指向類描述結構體、分類描述結構體地址的指標。然後通過 remap 的方式拿到類物件、分類物件的指標,加入連結串列。

逆向 Mach-O 檔案進行驗證。

為驗證我們的解析結果,我們取一個現有 App 中的 Mach-O 檔案進行檢驗:

用 MachOView 工具開啟 Mach-O 檔案,確實在其中看到 __objc_nlclslist__objc_nlcatlist 等段。

通過 Mach-O 檔案動態分析進行 iOS load 方法耗時檢測

NonlazyClass的命名上可以推斷出:含有 load 方法的類屬於非懶載入類

同理,從從NonlazyCategory的命名上可以推斷出:含有 load 方法的類屬於非懶載入分類

非懶載入類儲存方式

__objc_nlclslist段部分資料展示如下:

通過 Mach-O 檔案動態分析進行 iOS load 方法耗時檢測

裡面的資料如 68 67 fc 00 01 00 00 00 ,此處儲存的是大端序的資料,將其轉化為小端序後即: 00 00 00 01 00 fc 67 68

找到 00 00 00 01 00 fc 67 68 地址上的資料,確實是儲存類描述結構體(即struct classref)資料的地址。經驗證該類確實實現了 load 方法。所以大致驗證我們的猜測正確。

通過 Mach-O 檔案動態分析進行 iOS load 方法耗時檢測

objc 庫通過讀取 Mach-O 檔案中非懶載入類表和非懶載入分類表的方式實現 + load 方法載入的方案確實優於第三方提供的遍歷所有類然後篩選出實現了 + load 方法的類列表的方案。

非懶載入分類儲存方式

同理,我們可以找到 __objc_nlcatlist 段部分資料,如下所示:

通過 Mach-O 檔案動態分析進行 iOS load 方法耗時檢測

裡面的資料如 40 44 D9 00 01 00 00 00 ,將其轉化為小端序後即: 00 00 00 01 00 D9 44 40

找到 00 00 00 01 00 D9 44 40 地址上的資料,確實是儲存分類描述結構體(即 struct category_t )資料的地址。

通過 Mach-O 檔案動態分析進行 iOS load 方法耗時檢測

方案實現

基於思路一實現:

弄懂 load 函式遍歷、呼叫過程,但是可以看到的是以上涉及的方法都是 objc 的內部方法,外部無法進行直接呼叫。所以就得精簡程式碼後,進行整合、使用。

首先:從動態庫載入的時候,遍歷需要載入的映象列表,找到我們需要解析的映象:

/**
 獲取主工程 Mach-O 檔案入口指標

 @return Mach-O 檔案入口指標
 */
const struct mach_header *get_target_image_header() {
    if (target_image_header == NULL) {
        for (int i = 0; i < _dyld_image_count(); i++) {
            const char *image_name = _dyld_get_image_name(i);
             const char *target_image_name = ((NSString *)[[[NSBundle mainBundle] infoDictionary] objectForKey:@"CFBundleName"]).UTF8String;
            if (strstr(image_name, target_image_name) != NULL) {
                target_image_header = _dyld_get_image_header(i);
                break;
            }
        }
    }
    return target_image_header;
}
複製程式碼

然後從映象檔案中撈出我們想要的非懶載入類和分類連結串列:其中 _getObjc2NonlazyCategoryList_getObjc2NonlazyClassList 可以基本照搬 objc 庫中實現。

category_t **get_non_lazy_categary_list(size_t *count) {
    category_t **nlcatlist = NULL;
    nlcatlist = _getObjc2NonlazyCategoryList((headerType *)get_target_image_header(), count);
    return nlcatlist;
}

classref_t *get_non_lazy_class_list(size_t *count) {
    classref_t *nlclslist = NULL;
    nlclslist = _getObjc2NonlazyClassList((headerType *)get_target_image_header(), count);
    return nlclslist;
}

複製程式碼

所以整個遍歷非懶載入類及分類並通過 method swizzling 替換的過程如下:

+ (void)load {
    NSString *path = NSSearchPathForDirectoriesInDomains(NSDocumentDirectory, NSUserDomainMask, YES).firstObject;
    NSFileManager *fileManager = [NSFileManager defaultManager];
    NSString *apfDocPath = [NSString stringWithFormat:@"這段路徑不重要,隱藏", path];
    if(![fileManager fileExistsAtPath:apfDocPath]){
        return;
    }
    size_t count = 0;
    classref_t *nlclslist = get_non_lazy_class_list(&count);
    //最後一位指向的結構體中isa變數指向0x00000000的指標,故排除
    for (int i = 0; i < count - 1; i++) {
        Class cls = (Class)CFBridgingRelease(nlclslist[i]);
        cls = object_getClass(cls);
        swizzeLoadMethodInClass(cls, NO);
    }
    
    nlcategarylist = get_non_lazy_categary_list(&categaryCount);
    for (int i = 0; i < categaryCount; i++) {
        Class cls = (Class)CFBridgingRelease(nlcategarylist[i]->cls);
        cls = object_getClass(cls);
        swizzeLoadMethodInClass(cls, YES);
    }
}
複製程式碼

其他要點

值得注意的是:

classref_t 型別

get_non_lazy_class_list 返回型別是 classref_t

typedef struct classref * classref_t; 得知:這個型別是 struct classref * 。那麼 struct classref 是什麼型別呢?在 Mach-O檔案解析中,我們看到其型別是 struct objc_class 。所以: struct classref 的型別就是 struct objc_class

category_t * 型別

可以看到 struct category_t 的型別定義如下:

struct category_t {
    const char *name;
    classref_t cls;
    struct method_list_t *instanceMethods;
    struct method_list_t *classMethods;
    struct protocol_list_t *protocols;
    struct property_list_t *instanceProperties;
    // Fields below this point are not always present on disk.
    struct property_list_t *_classProperties;
    method_list_t *methodsForMeta(bool isMeta) {
        if (isMeta) return classMethods;
        else return instanceMethods;
    }
    property_list_t *propertiesForMeta(bool isMeta, struct header_info *hi);
};
複製程式碼

這裡,引入了 struct method_list_t * , struct protocol_list_t * 等,我們此次功能開發中不用的型別。所以在進行 struct category_t 型別引入的時候,做了個精簡,能夠通過編譯即可。

struct category_t {
    const char *name;
    classref_t cls;
    void *instanceMethods;
    void *classMethods;
    void *protocols;
    void *instanceProperties;
    void *_classProperties;
    void *methodsForMeta(bool isMeta) {
        if (isMeta) return classMethods;
        else return instanceMethods;
    }
    void *propertiesForMeta(bool isMeta, struct header_info *hi);
};
複製程式碼

結果

原本採用第三方的方案,做完一箇中等大小 App 的 load 方法 hook 大概需要150 ms ,採用改進後的方案,可以控制在 10ms 以內。雖然這樣的效果還達不到讓人無感知的程度,所以在生產環境下,目前只是在開發者工具中進行選擇啟用。

這是最終效果圖:

通過 Mach-O 檔案動態分析進行 iOS load 方法耗時檢測

關於思路二的思考

因為思路一的實現方案,雖然是比第三方的實現方案快了十倍以上,但是我覺得還沒有到我很滿意的程度。所以我這裡也做了一些關於思路二可行性的思考,做一個簡單的記錄。

上文提到:在 objc 庫中的 objc-loadmethod.m 檔案找到 call_class_loads 方法,call_class_loads方法中有 load_method_t load_method = (load_method_t)classes[i].method; 這個 load_method ,就是 +load 方法的 IMP,如果可能拿到這個 IMP,並指向一個 HOOK 後的 IMP,在hook方法之中,呼叫源 IMP ,其實也是一個非常不錯的方案。

那麼有辦法更加高效地拿到這個 IMP 嗎?

我覺得可能有。

這個 IMP 在何時何處被賦值呢?

在執行 prepare_load_methods 時被賦值,在 objc-loadmethod.m 被呼叫。

void add_class_to_loadable_list(Class cls){
    IMP method;
    loadMethodLock.assertLocked();
    method = cls->getLoadMethod();
    if (!method) return;  // Don`t bother if cls has no +load method
    if (PrintLoading) {
        _objc_inform("LOAD: class `%s` scheduled for +load", cls->nameForLogging());
    }
    if (loadable_classes_used == loadable_classes_allocated) {
        loadable_classes_allocated = loadable_classes_allocated*2 + 16;
        loadable_classes = (struct loadable_class *)
            realloc(loadable_classes, loadable_classes_allocated * sizeof(struct loadable_class));
    }
    loadable_classes[loadable_classes_used].cls = cls;
    loadable_classes[loadable_classes_used].method = method;
    loadable_classes_used++;
}
複製程式碼

這個 add_class_to_loadable_list 方法比較長,想要完整地通過 fishhook (考慮不同系統和版本)進行替換,其實難度比較高。但是其中 method = cls->getLoadMethod(); 這個過程其實是有一定機會的, cls->getLoadMethod 方法如下:

IMP objc_class::getLoadMethod(){
    runtimeLock.assertLocked();
    const method_list_t *mlist;
    assert(isRealized());
    assert(ISA()->isRealized());
    assert(!isMetaClass());
    assert(ISA()->isMetaClass());
    mlist = ISA()->data()->ro->baseMethods();
    if (mlist) {
        for (const auto& meth : *mlist) {
            const char *name = sel_cname(meth.name);
            if (0 == strcmp(name, "load")) {
                return meth.imp;
            }
        }
    }
    return nil;
}

複製程式碼

這個方法是有一定機會通過 fishhook 替換。難道包括且不只於以下幾個方面:

  • 這是 C++ 的類方法,如果通過 fishhook ,我們需要知道其經過函式簽名之後的方法名(這個可以通過包逆向做到),但是如何保證這個修飾後的名稱不變且穩定是一個困難點。
  • 通過 fishhook 的方案可行性、效能待論證。

關於 iOS load 機制的思考

為什麼會有 load 機制

從 objc 庫靜態解析結果和 Mach-O 檔案分析結果來看。實現了 load 方法的類都是存在 __objc_nlclslist__objc_nlcatlist 中。為什麼要存在這裡呢?其實這兩個段只是非懶載入類的索引。在 App 啟動前,載入的過程中需要執行 load 方法的類可以通過索引遍歷出來,然後執行 load 方法,這是 iOS 刻意提供的一種機制,並非一個自然的載入過程中一個順便的行為。

那麼其他懶載入的類全數會在App啟動的時候被載入嗎?我雖然還沒有對程式碼做一個更加深入的分析,但是有理由相信是:不會的。

為什麼呢?因為從軟體開發至今。記憶體從來都是昂貴且稀有的。App 絕大部分情況下會少佔用記憶體、有效地利用記憶體。而且程式執行的時候是有區域性性原理的。所以只有程式必須要用到、最常用到的部分才需要有效地駐留在記憶體內。

在軟體開發的早期階段,裝載軟體映象主要通過覆蓋載入,程式設計師需要手動管理程式碼段之間依賴關係。而現在採用的的是頁對映的方式。所以那些懶載入的類應該是不會被載入進記憶體,只有發生缺頁、斷頁的情況,才會載入對應的類、對應的程式碼段。

因為大部分的類都是懶載入的,而有些事情必須在 App 啟動的時候做、在 main 方法之前做,所以才提供了 load 的機制。如果沒有這種機制,那麼所有的操作只能在 main 方法之後做。對於有些操作來說就太遲了。

load 應該用來做什麼事情

我曾見過有專案在 load 中初始化了使用者資訊,讓其他業務模組能夠儘早使用到使用者資訊,而使用者資訊中包含了頭像 image ,頭像從 SDWebImage 中獲取。這麼做 直接導致 SDWebImage 庫初始化,在 SDWebImage 初始化的過程中還 create 了兩條 queue 來處理資料。這麼做直接導致了大量類的初始化樹和初始化週期被改變。

還見過有專案在 load 中初始化了三個 NSDateFormtter ,要知道 NSDateFormtter 的初始化是非常耗時的, 而且這個 NSDateFormtter 並沒有立刻用到(這是存心來找茬的吧)。

以上兩個都是反例。

那麼load 應該用來做什麼事情呢?

我覺得 method_swizzling 可以放在 load 中做,還有一些簡單的資料統計、鉤子相關的東西可以放在 load 中做,以求獲取到更多的資訊。

但是任何和業務相關的初始化都不應該放在load 中做。load 就是一個潘多拉魔盒, A 業務放在 load 中初始化了,那麼 A 依賴的業務 也務必要放在 load 中初始化,這裡無論是顯式依賴導致的被動初始化,還是隱式依賴所導致的主動初始化。一旦開啟了,就剎不住車了。

相關文章