本文是基于戴铭大佬的课程iOS开发高手课,加上个人实践+理解编写
本文已同步至掘金:App启动速度监控-方法级别启动耗时检查工具
如何做一个方法级别启动耗时检查工具来辅助分析和监控
使用hook objc_msgSend 方式来检查启动方法的执行耗时时,我们需要实现一个称手的启动时间检查工具
首先,需要了解为什么hook
了objc_msgSend
方法,就可以hook
全部Objective-C
的方法
Objective-C
里每个对象都会指向一个类,每个类都会有一个方法列表,方法列表里的每个方法都是由selector
、函数指针
和metadata
组成的
objc_msgSend
方法在运行时根据对象和方法的selector
去找到对应的函数指针
,然后执行。换句话说,objc_msgSend
是Objective-C
里方法执行的必经之路,能够控制所有的Objective-C
方法
objc_msgSend本身是用汇编语言写的,这样做的原因主要有两个:
- objc_msgSend的调用频次最高,在它上面进行的性能优化能够提升整个App生命周期的性能。而汇编语言在性能优化上属于原子级优化,能够吧优化做到极致
- 其他语言难以实现未知参数跳转到任意函数指针的功能
苹果开源了objective-c的运行时代码,可以在苹果开源网站找到objc_msgSend的源码
objc_msgSend 全架构实现源代码文件列表上图列出的是所有架构的实现,包括x86_64等。objc_msgSend是iOS方法执行最核心的部门
objc_msgSend方法执行的逻辑是:先获取对象对应类的信息,再获取方法的缓存,根据方法的selector查找函数指针,经过异常错误处理后,最后跳转到对应函数的实现
hook objc_msgSend方法
Facebook开源了一个库,可以在iOS上运行的Mach-O
二进制文件中动态的重新绑定符号,这个库叫fishhook : GitHub地址
fishhook实现的大致思路是,通过重新绑定符合,可以实现对c方法的hook。dyld是通过更新Mach-O
二进制的_DATA segment
特定的部分中的指针来绑定lazy
和non-lazy
符号,通过确认传递给rebind_symbol
里每个符号更新的位置,就可以找出对应替换来重新绑定这些符号。
fishhook的实现原理
首先,遍历dyld
里的所有image
, 取出image header
和slide
if (!_rebindings_head->next) {
_dyld_register_func_for_add_image(_rebind_symbols_for_image);
}else {
uint32_t c = _dyld_image_count();
//遍历所有image
for (uint32_t i = 0; i < c; i++) {
//读取 image header 和 slider
_rebind_symbols_for_image(_dyld_get_image_header(i), _dyld_get_image_vmaddr_slide(i));
}
}
接下来,找到符号表相关的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; 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 command
linkedit_segment = cur_seg_cmd;
}
}else if (cur_seg_cmd->cmd == LC_SYMTAB){
//symtab command
symtab_cmd = (struct symtab_command*)cur_seg_cmd;
}else if (cur_seg_cmd->cmd == LC_DYSYMTAB){
//dysymtab command
dysymtab_cmd = (struct dysymtab_command*)cur_seg_cmd;
}
}
然后,获得base
和indirect
符号表:
//找到base符号表地址
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符号表
uint32_t *indirect_symtab = (uint32_t *)(linkedit_base + dysymtab_cmd->indirectsymoff);
最后,有了符号表和传入的方法替换数组,就可以进行符号表访问指针地址的替换了:
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].replaced!= 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:;
}
以上,就是fishhook的实现原理了,fishhook是对底层的操作,其中查找符号表的过程和堆栈符号化实现原理基本类似,了解其中原理对于理解可执行文件Mach-O内部结构会有很大的帮助。
接下来,我们再看一个问题:只靠fishhook
就能够搞定objc_msgSend
的hook
了吗?
当然不够,objc_msgSend
是用汇编语言实现的,所以我们还需要从汇编层多加点料
需要先实现两个方法pushCallRecord
和popCallRecord
,来分别记录objc_msgSend
方法调用前后的时间,然后相减就能够得到方法的执行耗时。
下面针对arm64架构,编写一个可保留未知参数并跳转到c中任意函数指针的汇编代码,实现对objc_msgSend的hook。
arm64
有 31
个 64 bit
的整数型寄存器,分别用x0
到x30
表示,主要的实现思路是:
- 入栈参数,参数寄存器是x0~x07。对应objc_msgSend方法来说,x0第一个参数是传入对象,x1第二个参数是选择器_cmd, syscall的number会放到x8里。
- 交换寄存器中保存的参数,将用于返回的寄存器lr中的数据移到想x1里
- 使用 bl label 语法调用pushCallRecord函数
- 执行原始的objc_msgSend,保存返回值
- 使用bl label 语法调用popCallRecord函数
具体汇编代码如下:
static void replacementObjc_msgSend() {
__asm__ volatile (
// sp 是堆栈寄存器,存放栈的偏移地址,每次都指向栈顶。
// 保存 {q0-q7} 偏移地址到 sp 寄存器
"stp q6, q7, [sp, #-32]!\n"
"stp q4, q5, [sp, #-32]!\n"
"stp q2, q3, [sp, #-32]!\n"
"stp q0, q1, [sp, #-32]!\n"
// 保存 {x0-x8, lr}
"stp x8, lr, [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"
// 交换参数.
"mov x2, x1\n"
"mov x1, lr\n"
"mov x3, sp\n"
// 调用 preObjc_msgSend,使用 bl label 语法。bl 执行一个分支链接操作,label 是无条件分支的,是和本指令的地址偏移,范围是 -128MB 到 +128MB
"bl __Z15preObjc_msgSendP11objc_objectmP13objc_selectorP9RegState_\n"
"mov x9, x0\n"
"mov x10, x1\n"
"tst x10, x10\n"
// 读取 {x0-x8, lr} 从保存到 sp 栈顶的偏移地址读起
"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, lr, [sp], #16\n"
// 读取 {q0-q7}
"ldp q0, q1, [sp], #32\n"
"ldp q2, q3, [sp], #32\n"
"ldp q4, q5, [sp], #32\n"
"ldp q6, q7, [sp], #32\n"
"b.eq Lpassthrough\n"
// 调用原始 objc_msgSend。使用 blr xn 语法。blr 除了从指定寄存器读取新的 PC 值外效果和 bl 一样。xn 是通用寄存器的64位名称分支地址,范围是0到31
"blr x9\n"
// 保存 {x0-x9}
"stp x0, x1, [sp, #-16]!\n"
"stp x2, x3, [sp, #-16]!\n"
"stp x4, x5, [sp, #-16]!\n"
"stp x6, x7, [sp, #-16]!\n"
"stp x8, x9, [sp, #-16]!\n"
// 保存 {q0-q7}
"stp q0, q1, [sp, #-32]!\n"
"stp q2, q3, [sp, #-32]!\n"
"stp q4, q5, [sp, #-32]!\n"
"stp q6, q7, [sp, #-32]!\n"
// 调用 postObjc_msgSend hook.
"bl __Z16postObjc_msgSendv\n"
"mov lr, x0\n"
// 读取 {q0-q7}
"ldp q6, q7, [sp], #32\n"
"ldp q4, q5, [sp], #32\n"
"ldp q2, q3, [sp], #32\n"
"ldp q0, q1, [sp], #32\n"
// 读取 {x0-x9}
"ldp x8, x9, [sp], #16\n"
"ldp x6, x7, [sp], #16\n"
"ldp x4, x5, [sp], #16\n"
"ldp x2, x3, [sp], #16\n"
"ldp x0, x1, [sp], #16\n"
"ret\n"
"Lpassthrough:\n"
// br 无条件分支到寄存器中的地址
"br x9"
);
}
现在,你就可以得到每个 Objective-C 方法的耗时了。接下来,我们再看看怎样才能够做到像下图那样记录和展示方法调用的层级关系和顺序呢?
方法调用层级和顺序
网友评论