起源
公司项目里,开发中长期在使用DiDi的DoraemonKit库里的一些调试小工具, 刚好最近需要对一些代码耗时做分析,想到了,其中包含的DoraemonTimeProfiler类可以通过动态方式,无侵入的对函数的耗时进行统计,一时好奇,想看看具体是如何做到的。
基本逻辑
打开源码,其实这部分代码并不多,从逻辑上非常易于理解。我们知道OC方法的执行,本质上就是通过调用objc_msgSend函数来发送消息,所以基本思路是对objc_msgSend方法进行hook,通过对objc_msgSend方法执行开始前和结束后分别记录时间,再进行计算就可以得出方法的调用时长。
但由于为objc_msgSend是基于汇编实现的,在编译期间就决定了函数地址,所以无法通过动态的method_swizzle来进行替换。这里采用了fishhook库动态的对objc_msgSend进行了hook,
fishhook简单来讲,就是利用动态库的共享缓存库在运行时进行绑定的原理,把我们的hook代码注入进去,具体的原理,不是这篇文章的重点,就不做分析了。
fishhook对objc_msgSend进行hook的代码如下:
void dtp_hook_begin(void) {
_call_record_enabled = true;
pthread_key_create(&_thread_key, &release_thread_call_stack);
doraemon_rebind_symbols((struct doraemon_rebinding[1]){"objc_msgSend", (void *)hook_objc_msgSend, (void **)&orig_objc_msgSend},1);
}
void dtp_hook_end(void) {
_call_record_enabled =false;
doraemon_rebind_symbols((struct doraemon_rebinding[1]){"objc_msgSend", (void*)orig_objc_msgSend,NULL},1);
}
自定义objc_msgSend如何实现
上面分析了如何对objc_msgSend进行hook,接下来就到了本文的重点,这个hook_objc_msgSend方法,应该如何编写来进行时间统计呢?
DoraemonTimeProfiler给出的答案是这样的:
__attribute__ ((__naked__))
static void hook_objc_msgSend() {
//before之前保存objc_msgSend的参数
save()
//将objc_msgSend执行的下一个函数地址传递给before_objc_msgSend的第二个参数x0 self, x1 _cmd, x2: lr address
__asm volatile ("mov x2, lr\n");
__asm volatile ("mov x3, x4\n");
// 执行before_objc_msgSend blr 除了从指定寄存器读取新的 PC 值外效果和 bl 一...
call(blr, &before_objc_msgSend)
// 恢复objc_msgSend参数,并执行
load()
call(blr, orig_objc_msgSend)
//after之前保存objc_msgSend执行完成的参数
save()
//调用 after_objc_msgSend
call(blr, &after_objc_msgSend)
//将after_objc_msgSend返回的参数放入lr,恢复调用before_objc_msgSend前的lr地址
// x0 是整数/指针args的第一个arg传递寄存器 x0 是整数/指针值的(第一个)返回值寄存器
__asm volatile ("mov lr, x0\n");
//恢复objc_msgSend执行完成的参数
load()
//方法结束,继续执行lr
ret()
}
代码不长,我们来一步一步进行分析。
首先这里为什么实现都是C和汇编,是因为objc_msgSend本身是基于汇编进行实现,所以hook的方法对于objc_msgSend相关的部分都必须是基于汇编来实现。
__attribute__ ((__naked__))
这里声明是告诉编译器在函数调用的时候不使用栈保存参数信息,由于objc_msgSend本身使用了该修饰符,所以这里我们也必须同样的进行修饰。
这里简单介绍下:
在arm64汇编中,在小于9个参数时,通过x0-x8寄存器对参数进行保存,当超过时,会通过栈空间进行存储,objc_msgSend使用此声明猜测可能是基于性能考虑。
save()
保存objc_msgSend本身的方法栈信息。因为在objc_msgSend方法执行前,我们会执行方法before_objc_msgSend,从而对寄存器造成污染,为了确保能够正确的执行objc_msgSend方法,需要对当前的寄存器状态进行保存,等我们的方法执行完毕后,再对寄存器进行恢复,从而保证OC方法的正确执行。
call(blr, &before_objc_msgSend)
通过blr汇编语句,执行before_objc_msgSend方法,从而在OC方法执行前,记录方法开始的时间。
before_objc_msgSend的实现简单描述如下:
before_objc_msgSend {
获取当前堆栈信息
更新记录的开始执行时间到堆栈信息中
}
具体实现代码参考源码:
static inline void push_call_record(id _self, Class _cls, SEL _cmd, uintptr_t lr)
load()
恢复我们通过save()保存的objc_msgSend方法信息。
call(blr, orig_objc_msgSend)
调用原始的objc_msgSend,开始OC方法的执行。
save()
(此时方法执行完毕)
和之前的save()原因一样,这里是因为objc_msgSend方法执行完毕后,我们需要记录结束时间,会对寄存器造成污染,所以需要在after_objc_msgSend方法执行前,对寄存器状态进行保存。
call(blr, &after_objc_msgSend)
通过汇编语句,调用after_objc_msgSend方法,进行方法的耗时的计算,并将函数信息和耗时存储到dtp_records中。
after_objc_msgSend的实现简单描述如下:
after_objc_msgSend {
找到缓存中当前堆栈信息
读取之前缓存的执行时间
计算出方法的耗时
生成一条记录记录方法信息和耗时
更新记录到dtp_records中
}
具体实现代码参考源码:
static inline uintptr_t pop_call_record()
__asm volatile ("mov lr, x0\n");
恢复寄存器x0的内容到lr中,还原hook_objc_msgSend的lr寄存器。
这里需要讲一下,为什么x0的值是lr寄存器的内容?
首先我们要理解x0在函数结束时,会作为返回值寄存器,存储函数的返回内容, 而after_objc_msgSend的返回值为pRecord->lr,也就是hook_objc_msgSend原始的lr的内容。
pRecord->lr为什么是原始的lr寄存器的内容呢?
这里我们结合before_objc_msgSend和after_objc_msgSend的源码来仔细分析:
void before_objc_msgSend(id self, SEL _cmd, uintptr_t lr) {
push_call_record(self, object_getClass(self), _cmd, lr);
}
static inline void push_call_record(id _self, Class _cls, SEL _cmd, uintptr_t lr) {
thread_call_stack *cs = get_thread_call_stack();
if (cs) {
...
thread_call_record *newRecord = &cs->stack[nextIndex];
...
newRecord->lr = lr; // 此时存储了lr到thread_call_record中
...
}
}
uintptr_t after_objc_msgSend() {
return pop_call_record();
}
static inline uintptr_t pop_call_record() {
thread_call_record *pRecord = &cs->stack[nextIndex];
...
return pRecord->lr; // 返回before_objc_msgSend中存储的lr
}
在before_objc_msgSend时, "lr参数"被存入了thread_call_record对象中。
那么这个"lr参数"是什么呢?
我们发现在before_objc_msgSend执行前,有一行汇编语句:__asm volatile ("mov x2, lr\n");
,
它将lr寄存器的内容移到了x2寄存器中,而x2寄存器接下来做了什么呢?在arm64架构下,x2寄存器对应的是before_objc_msgSend方法的第三个入参(uintptr_t lr)。
因此寄存器lr的内容被存入了thread_call_record对象中。而在after_objc_msgSend时,pRecord->lr对应的即是thread_call_record所保存的lr寄存器的原始内容。
load()
寄存器还原到objc_msgSend刚执行结束后的状态。
ret()
源码:#define ret() __asm volatile ("ret\n");
汇编里的return语句,代表当前方法内容执行完毕,跳出方法块,继续执行。
这时会将lr寄存器读取到pc寄存器中,跳出hook_objc_msgSend函数继续执行,这也是为什么执行ret前要还原lr寄存器内容的原因。
整理的流程如图所示:
重要的方法
执行过程中,有几个重要的方法,这里着重分析下。
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");
save主要用户缓存当前方法的寄存器状态,所以将x0-x8的寄存器缓存到了栈上。
之所以用到了x9寄存器,是因为arm栈是按照16字节对齐,而由于寄存器大小固定为8字节,所以需要x9来进行字节补齐。
这里简单介绍几个知识:
- sp:栈顶寄存器,寄存器移动到栈上都需要依赖sp寄存器。
-
stp x8, x9, [sp, #-16]!
汇编语句属于精简的语句,省去了栈拉伸的代码。实际上等同于以下指令:
sub sp,sp,#0x16
stp x8,x9,[sp]
load()
源码:
#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");
load和save是对应关系,用于恢复save存储的寄存器信息。
call()
源码:
#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");
call方法的参数b,在当前环境下,都是用了blr跳转指令进行方法执行。
第一行和第三行是对x8寄存器进行暂存和恢复,之所以这么做,是因为__asm volatile ("mov x12, %0\n" :: "r"(value));
执行过程中,会通过x8来保存函数地址,再进行跳转。
总结
想不到简简单的函数耗时统计,却包含了好几个知识点:
- obj_msgSend()如何hook
- fishhook原理
- 汇编基本指令
虽然大部分都有相关的文章可以辅助理解,但当自己去做的时候,还是会发现很多新的理解,一点点的积累一点点的进步。