通過 Mach-O 檔案動態分析進行 iOS load 方法耗時檢測
背景
目前部分產品反饋啟動時間還是較慢。但目前啟動時間耗時統計方案無法統計到 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)
優點
這個方案中有一個非常值得借鑑的點:將 Hook 方法寫在動態庫中,若讓主工程包只依賴該動態庫。使得該動態庫一定可以最先被載入。在該動態庫中唯一一個 +load 方法中去檢測整個 App 中所有的類,確保可以在其他任何類載入前對其進行檢測,和方法替換。
缺點
以目前一箇中等大小的應用工廠組裝產品為例,需要耗時大約150ms,以99u這樣的平臺型 App 而言,耗時至少會增加一倍以上。而這一切都是在工程啟動的時候做的,若在每次啟動時都開始 load 耗時檢測,那這個 Hook 過程的耗時肯定不能接受。哪怕是選擇啟用,這樣的耗時也十分影響體驗。所以本篇文章將說明如何在這個方案的基礎上進行改進。
load 耗時檢測的思路:
思路一
通過在最早 load 的方法中加入一個獲取到所有需要被執行load方法的類及分類,並對其進行 method swizzling 替換。
思路二
對執行 load 方法的程式呼叫棧上的關鍵函式進行 fishhook ,從而實現獲取到 load 方法關鍵資訊,並對關資訊(如 IMP、SEL 、Class 等)做處理。
探究 + load 方法呼叫過程
dyld 和 objc 庫靜態分析
從 App 啟動到載入到這個動態庫第一個 load 方法過程中經歷了哪些過程呢?
我們可以通過打斷點的方式檢視這個堆疊。
這裡可以看到程式入口是:
_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]\n", 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
等段。
從NonlazyClass
的命名上可以推斷出:含有 load 方法的類屬於非懶載入類,
同理,從從NonlazyCategory
的命名上可以推斷出:含有 load 方法的類屬於非懶載入分類。
非懶載入類儲存方式
__objc_nlclslist
段部分資料展示如下:
裡面的資料如 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 方法。所以大致驗證我們的猜測正確。
objc 庫通過讀取 Mach-O 檔案中非懶載入類表和非懶載入分類表的方式實現 + load 方法載入的方案確實優於第三方提供的遍歷所有類然後篩選出實現了 + load 方法的類列表的方案。
非懶載入分類儲存方式
同理,我們可以找到 __objc_nlcatlist
段部分資料,如下所示:
裡面的資料如 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
)資料的地址。
方案實現
基於思路一實現:
弄懂 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 以內。雖然這樣的效果還達不到讓人無感知的程度,所以在生產環境下,目前只是在開發者工具中進行選擇啟用。
這是最終效果圖:
關於思路二的思考
因為思路一的實現方案,雖然是比第三方的實現方案快了十倍以上,但是我覺得還沒有到我很滿意的程度。所以我這裡也做了一些關於思路二可行性的思考,做一個簡單的記錄。
上文提到:在 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 的方案可行性、效能待論證。