iOS 函数耗时统计原理分析

起源

  公司项目里,开发中长期在使用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原理
  • 汇编基本指令

虽然大部分都有相关的文章可以辅助理解,但当自己去做的时候,还是会发现很多新的理解,一点点的积累一点点的进步。

©著作权归作者所有,转载或内容合作请联系作者
平台声明:文章内容(如有图片或视频亦包括在内)由作者上传并发布,文章内容仅代表作者本人观点,简书系信息发布平台,仅提供信息存储服务。

推荐阅读更多精彩内容