級(jí)別:★★☆☆☆
標(biāo)簽:「iOS」「hook」「耗時(shí)監(jiān)控」「objc_msgSend」
作者: 647
審校: QiShare團(tuán)隊(duì)
前言:
最近,小編在看戴銘老師的技術(shù)分享,感覺收獲很多?;谧罱膶W(xué)習(xí),小編總結(jié)了一些App啟動(dòng)優(yōu)化上的知識(shí)點(diǎn),并計(jì)劃落地一系列App啟動(dòng)優(yōu)化的文章。
目錄如下:
iOS App啟動(dòng)優(yōu)化(一)—— 了解App的啟動(dòng)流程
iOS App啟動(dòng)優(yōu)化(二)—— 使用“Time Profiler”工具監(jiān)控App的啟動(dòng)耗時(shí)
iOS App啟動(dòng)優(yōu)化(三)—— 自己做一個(gè)工具監(jiān)控App的啟動(dòng)耗時(shí)
前兩篇介紹了《iOS App的啟動(dòng)流程》、《Time Profiler工具的使用》。
本篇將介紹通過hook底層objc_msgSend來掌握所有Objective-C方法的執(zhí)行耗時(shí)。
一、什么是hook?
定義:hook是指在原有方法開始執(zhí)行時(shí),換成你指定的方法?;蛟谠蟹椒ǖ膱?zhí)行前后,添加執(zhí)行你指定的方法。從而達(dá)到改變指定方法的目的。
例如:
- 使用
runtime的Method Swizzle。 - 使用
Facebook所開源的fishhook框架。
前者是ObjC運(yùn)行時(shí)提供的“方法交換”能力。
后者是對(duì)Mach-O二進(jìn)制文件的符號(hào)進(jìn)行動(dòng)態(tài)的“重新綁定”,已達(dá)到方法交換的目的。
問題1: fishhook的大致實(shí)現(xiàn)思路是什么?
在《iOS App啟動(dòng)優(yōu)化(一)—— 了解App的啟動(dòng)流程》中我們提到,動(dòng)態(tài)鏈接器dyld會(huì)根據(jù)Mach-O二進(jìn)制可執(zhí)行文件的符號(hào)表來綁定符號(hào)。而通過符號(hào)表及符號(hào)名就可以知道指針訪問的地址,再通過更改指針訪問的地址就能替換指定的方法實(shí)現(xiàn)了。
問題2:為什么hook了objc_msgSend就可以掌握所有objc方法的耗時(shí)?
因?yàn)?code>objc_msgSend是所有Objective-C方法調(diào)用的必經(jīng)之路,所有的Objective-C方法都會(huì)調(diào)用到運(yùn)行時(shí)底層的objc_msgSend方法。所以只要我們可以hook objc_msgSend,我們就可以掌握所有objc方法的耗時(shí)。(更多詳情可看我之前寫的《iOS 編寫高質(zhì)量Objective-C代碼(二)》的第六點(diǎn) —— 理解objc_msgSend(對(duì)象的消息傳遞機(jī)制))
另外,objc_msgSend本身是用匯編語言寫的,蘋果已經(jīng)開源了objc_msgSend的源碼??稍诠倬W(wǎng)上下載查看:objc_msgSend源碼。
二、如何hook底層objc_msgSend?
第一階段:與fishhook框架類似,我們先要擁有hook的能力。
- 首先,設(shè)計(jì)兩個(gè)結(jié)構(gòu)體:
一個(gè)是用來記錄符號(hào)的結(jié)構(gòu)體,一個(gè)是用來記錄符號(hào)表的鏈表。
struct rebinding {
const char *name;
void *replacement;
void **replaced;
};
struct rebindings_entry {
struct rebinding *rebindings;
size_t rebindings_nel;
struct rebindings_entry *next;
};
- 其次,遍歷動(dòng)態(tài)鏈接器
dyld內(nèi)所有的image,取出其中的header和slide。
以便我們接下來拿到符號(hào)表。
static int fish_rebind_symbols(struct rebinding rebindings[], size_t rebindings_nel) {
int retval = prepend_rebindings(&_rebindings_head, rebindings, rebindings_nel);
if (retval < 0) {
return retval;
}
// If this was the first call, register callback for image additions (which is also invoked for
// existing images, otherwise, just run on existing images
//首先是遍歷 dyld 里的所有的 image,取出 image header 和 slide。注意第一次調(diào)用時(shí)主要注冊(cè) callback
if (!_rebindings_head->next) {
_dyld_register_func_for_add_image(_rebind_symbols_for_image);
} else {
uint32_t c = _dyld_image_count();
// 遍歷所有dyld的image
for (uint32_t i = 0; i < c; i++) {
_rebind_symbols_for_image(_dyld_get_image_header(i), _dyld_get_image_vmaddr_slide(i)); // 讀取image內(nèi)的header和slider
}
}
return retval;
}
- 上一步,我們?cè)?code>dyld內(nèi)拿到了所有
image。
接下來,我們從image內(nèi)找到符號(hào)表內(nèi)相關(guān)的segment_command_t,遍歷符號(hào)表找到所要替換的segname,再進(jìn)行下一步方法替換。方法實(shí)現(xiàn)如下:
static void rebind_symbols_for_image(struct rebindings_entry *rebindings,
const struct mach_header *header,
intptr_t slide) {
Dl_info info;
if (dladdr(header, &info) == 0) {
return;
}
// 找到符號(hào)表相關(guān)的command,包括 linkedit_segment command、symtab command 和 dysymtab command。
segment_command_t *cur_seg_cmd;
segment_command_t *linkedit_segment = NULL;
struct symtab_command* symtab_cmd = NULL;
struct dysymtab_command* dysymtab_cmd = NULL;
uintptr_t cur = (uintptr_t)header + sizeof(mach_header_t);
for (uint i = 0; i < header->ncmds; i++, cur += cur_seg_cmd->cmdsize) {
cur_seg_cmd = (segment_command_t *)cur;
if (cur_seg_cmd->cmd == LC_SEGMENT_ARCH_DEPENDENT) {
if (strcmp(cur_seg_cmd->segname, SEG_LINKEDIT) == 0) {
linkedit_segment = cur_seg_cmd;
}
} else if (cur_seg_cmd->cmd == LC_SYMTAB) {
symtab_cmd = (struct symtab_command*)cur_seg_cmd;
} else if (cur_seg_cmd->cmd == LC_DYSYMTAB) {
dysymtab_cmd = (struct dysymtab_command*)cur_seg_cmd;
}
}
if (!symtab_cmd || !dysymtab_cmd || !linkedit_segment ||
!dysymtab_cmd->nindirectsyms) {
return;
}
// 獲得base符號(hào)表以及對(duì)應(yīng)地址
uintptr_t linkedit_base = (uintptr_t)slide + linkedit_segment->vmaddr - linkedit_segment->fileoff;
nlist_t *symtab = (nlist_t *)(linkedit_base + symtab_cmd->symoff);
char *strtab = (char *)(linkedit_base + symtab_cmd->stroff);
// 獲得indirect符號(hào)表
uint32_t *indirect_symtab = (uint32_t *)(linkedit_base + dysymtab_cmd->indirectsymoff);
cur = (uintptr_t)header + sizeof(mach_header_t);
for (uint i = 0; i < header->ncmds; i++, cur += cur_seg_cmd->cmdsize) {
cur_seg_cmd = (segment_command_t *)cur;
if (cur_seg_cmd->cmd == LC_SEGMENT_ARCH_DEPENDENT) {
if (strcmp(cur_seg_cmd->segname, SEG_DATA) != 0 &&
strcmp(cur_seg_cmd->segname, SEG_DATA_CONST) != 0) {
continue;
}
for (uint j = 0; j < cur_seg_cmd->nsects; j++) {
section_t *sect =
(section_t *)(cur + sizeof(segment_command_t)) + j;
if ((sect->flags & SECTION_TYPE) == S_LAZY_SYMBOL_POINTERS) {
perform_rebinding_with_section(rebindings, sect, slide, symtab, strtab, indirect_symtab);
}
if ((sect->flags & SECTION_TYPE) == S_NON_LAZY_SYMBOL_POINTERS) {
perform_rebinding_with_section(rebindings, sect, slide, symtab, strtab, indirect_symtab);
}
}
}
}
}
- 最后,通過符號(hào)表以及我們所要替換的方法的實(shí)現(xiàn),進(jìn)行指針地址替換。
這是相關(guān)方法實(shí)現(xiàn):
static void perform_rebinding_with_section(struct rebindings_entry *rebindings,
section_t *section,
intptr_t slide,
nlist_t *symtab,
char *strtab,
uint32_t *indirect_symtab) {
uint32_t *indirect_symbol_indices = indirect_symtab + section->reserved1;
void **indirect_symbol_bindings = (void **)((uintptr_t)slide + section->addr);
for (uint i = 0; i < section->size / sizeof(void *); i++) {
uint32_t symtab_index = indirect_symbol_indices[i];
if (symtab_index == INDIRECT_SYMBOL_ABS || symtab_index == INDIRECT_SYMBOL_LOCAL ||
symtab_index == (INDIRECT_SYMBOL_LOCAL | INDIRECT_SYMBOL_ABS)) {
continue;
}
uint32_t strtab_offset = symtab[symtab_index].n_un.n_strx;
char *symbol_name = strtab + strtab_offset;
if (strnlen(symbol_name, 2) < 2) {
continue;
}
struct rebindings_entry *cur = rebindings;
while (cur) {
for (uint j = 0; j < cur->rebindings_nel; j++) {
if (strcmp(&symbol_name[1], cur->rebindings[j].name) == 0) {
if (cur->rebindings[j].replaced != NULL &&
indirect_symbol_bindings[i] != cur->rebindings[j].replacement) {
*(cur->rebindings[j].replaced) = indirect_symbol_bindings[i];
}
indirect_symbol_bindings[i] = cur->rebindings[j].replacement;
goto symbol_loop;
}
}
cur = cur->next;
}
symbol_loop:;
}
}
到這里,通過調(diào)用下面的方法,我們就擁有了hook的基本能力。
static int fish_rebind_symbols(struct rebinding rebindings[], size_t rebindings_nel);
第二階段:通過匯編語言編寫出我們的hook_objc_msgSend方法
因?yàn)?code>objc_msgSend是通過匯編語言寫的,我們想要替換objc_msgSend方法還需要從匯編語言下手。
既然我們要做一個(gè)監(jiān)控方法耗時(shí)的工具。這時(shí)想想我們的目的是什么?
我們的目的是:通過hook原objc_msgSend方法,在objc_msgSend方法前調(diào)用打點(diǎn)計(jì)時(shí)操作,在objc_msgSend方法調(diào)用后結(jié)束打點(diǎn)和計(jì)時(shí)操作。通過計(jì)算時(shí)間差,我們就能精準(zhǔn)的拿到方法調(diào)用的時(shí)長。
因此,我們要在原有的objc_msgSend方法的調(diào)用前后需要加上before_objc_msgSend和after_objc_msgSend方法,以便我們后期的打點(diǎn)計(jì)時(shí)操作。
arm64 有 31 個(gè) 64 bit 的整數(shù)型寄存器,分別用 x0 到 x30 表示。主要的實(shí)現(xiàn)思路是:
- 入棧參數(shù),參數(shù)寄存器是 x0~ x7。對(duì)于 objc_msgSend 方法來說,x0 第一個(gè)參數(shù)是傳入對(duì)象,x1 第二個(gè)參數(shù)是選擇器 _cmd。syscall 的 number 會(huì)放到 x8 里。
- 交換寄存器中保存的參數(shù),將用于返回的寄存器 lr 中的數(shù)據(jù)移到 x1 里。
- 使用 bl label 語法調(diào)用 pushCallRecord 函數(shù)。
- 執(zhí)行原始的 objc_msgSend,保存返回值。
- 使用 bl label 語法調(diào)用 popCallRecord 函數(shù)。
- 返回
里面涉及到的一些匯編指令:
| 指令 | 含義 |
|---|---|
| stp | 同時(shí)寫入兩個(gè)寄存器。 |
| mov | 將值賦值到一個(gè)寄存器。 |
| ldp | 同時(shí)讀取兩個(gè)寄存器。 |
| sub | 將兩個(gè)寄存器的值相減 |
| add | 將兩個(gè)寄存器的值相加 |
| ret | 從子程序返回主程序 |
詳細(xì)代碼如下:
#define call(b, value) \
__asm volatile ("stp x8, x9, [sp, #-16]!\n"); \
__asm volatile ("mov x12, %0\n" :: "r"(value)); \
__asm volatile ("ldp x8, x9, [sp], #16\n"); \
__asm volatile (#b " x12\n");
#define save() \
__asm volatile ( \
"stp x8, x9, [sp, #-16]!\n" \
"stp x6, x7, [sp, #-16]!\n" \
"stp x4, x5, [sp, #-16]!\n" \
"stp x2, x3, [sp, #-16]!\n" \
"stp x0, x1, [sp, #-16]!\n");
#define load() \
__asm volatile ( \
"ldp x0, x1, [sp], #16\n" \
"ldp x2, x3, [sp], #16\n" \
"ldp x4, x5, [sp], #16\n" \
"ldp x6, x7, [sp], #16\n" \
"ldp x8, x9, [sp], #16\n" );
#define link(b, value) \
__asm volatile ("stp x8, lr, [sp, #-16]!\n"); \
__asm volatile ("sub sp, sp, #16\n"); \
call(b, value); \
__asm volatile ("add sp, sp, #16\n"); \
__asm volatile ("ldp x8, lr, [sp], #16\n");
#define ret() __asm volatile ("ret\n");
__attribute__((__naked__))
static void hook_objc_msgSend() {
// Save parameters.
save() // stp入棧指令 入棧參數(shù),參數(shù)寄存器是 x0~ x7。對(duì)于 objc_msgSend 方法來說,x0 第一個(gè)參數(shù)是傳入對(duì)象,x1 第二個(gè)參數(shù)是選擇器 _cmd。syscall 的 number 會(huì)放到 x8 里。
__asm volatile ("mov x2, lr\n");
__asm volatile ("mov x3, x4\n");
// Call our before_objc_msgSend.
call(blr, &before_objc_msgSend)
// Load parameters.
load()
// Call through to the original objc_msgSend.
call(blr, orig_objc_msgSend)
// Save original objc_msgSend return value.
save()
// Call our after_objc_msgSend.
call(blr, &after_objc_msgSend)
// restore lr
__asm volatile ("mov lr, x0\n");
// Load original objc_msgSend return value.
load()
// return
ret()
}
這時(shí)候,每當(dāng)?shù)讓诱{(diào)用hook_objc_msgSend方法時(shí),會(huì)先調(diào)用before_objc_msgSend方法,再調(diào)用hook_objc_msgSend方法,最后調(diào)用after_objc_msgSend方法。
單個(gè)方法調(diào)用,流程如下圖:

舉一反“三”,然后多層方法調(diào)用的流程,就變成了下圖:

這樣,我們就能拿到每一層方法調(diào)用的耗時(shí)了。
三、如何使用這個(gè)工具?
第一步,在項(xiàng)目中,導(dǎo)入QiLagMonitor類庫。
第二步,在所需要監(jiān)控的控制器中,導(dǎo)入QiCallTrace.h頭文件。
[QiCallTrace start]; // 1. 開始
// your codes(你所要測試的代碼區(qū)間)
[QiCallTrace stop]; // 2. 停止
[QiCallTrace save]; // 3. 保存并打印方法調(diào)用棧以及具體方法耗時(shí)。
PS:目前該工具只能hook所有objc方法,并計(jì)算出區(qū)間內(nèi)的所有方法耗時(shí)。暫不支持swift方法的監(jiān)聽。
本文源碼:Demo
最后,我是站在iOS業(yè)界巨人的肩膀上完成了App啟動(dòng)優(yōu)化(一)、(二)、(三),感謝戴銘老師精彩的技術(shù)分享。
另附上,戴銘老師課程鏈接:《iOS開發(fā)高手課》
推薦文章:
iOS 給UILabel添加點(diǎn)擊事件
用SwiftUI給視圖添加動(dòng)畫
用SwiftUI寫一個(gè)簡單頁面
iOS 控制日志的開關(guān)
iOS App中可拆卸一個(gè)framework的兩種方式
自定義WKWebView顯示內(nèi)容(一)
Swift 5.1 (7) - 閉包
Swift 5.1 (6) - 函數(shù)
Swift 5.1 (5) - 控制流
Xcode11 新建工程中的SceneDelegate
iOS App啟動(dòng)優(yōu)化(二)—— 使用“Time Profiler”工具監(jiān)控App的啟動(dòng)耗時(shí)
iOS App啟動(dòng)優(yōu)化(一)—— 了解App的啟動(dòng)流程