监控所有的OC方法耗时

13,071 阅读6分钟

更新计划

  1. 已做:
    • 类似os_signpost,支持针对每个功能点监控性能问题。 pic
    • 支持显示调用堆栈。(维护stack frame)
    • 增加耗时方法排序功能和耗时方法中调用次数排序功能(已做)

  1. 未做:
    • 优化代码质量和性能问题(未做)
    • 增加打印卡顿时候,所有线程堆栈 (未做)

前言

看了戴铭大神App 启动优化与监控 ,受益良多。我运用其中的hook objc_msgSend思想,写一个监控App里所有耗时的OC方法,以便以后开发过程中,能时刻监控App耗时性能问题。本文主要包含两方面:1、高性能hook objc_msgSend(我看了许多hook objc_msgSend,发现都没把性能做到极致。);2、把耗时OC方法的调用堆栈打印出来。

阅读建议

如果对arm64iOS ABI,还不是很了解,请看我前两篇文章。

源码

点击这里请在github上下载。

效果图

用法

把文件夹里的代码放到项目里,运行App时,摇一摇手机,就可以看到所有的OC方法耗时堆栈。

适用机型 (arm64的机型)

由于现在手机基本都是iPhone5s和更新的iPhone手机;而且性能问题本来就需要在真机上测试。因此只支持iPhone5s及更新的真机(arm64的iPad也适用),不适用模拟器

高性能hook objc_msgSend

源码

//由于显示调用堆栈(复制栈帧)有一定性能消耗,可自行评估。1表示显示调用堆栈;0表示不显示调用堆栈
#define SUPPORT_SHOW_CALL_STACK 1

.macro BACKUP_REGISTERS
    stp q6, q7, [sp, #-0x20]!
    stp q4, q5, [sp, #-0x20]!
    stp q2, q3, [sp, #-0x20]!
    stp q0, q1, [sp, #-0x20]!
    stp x6, x7, [sp, #-0x10]!
    stp x4, x5, [sp, #-0x10]!
    stp x2, x3, [sp, #-0x10]!
    stp x0, x1, [sp, #-0x10]!
    str x8,  [sp, #-0x10]!
.endmacro

.macro RESTORE_REGISTERS
    ldr x8,  [sp], #0x10
    ldp x0, x1, [sp], #0x10
    ldp x2, x3, [sp], #0x10
    ldp x4, x5, [sp], #0x10
    ldp x6, x7, [sp], #0x10
    ldp q0, q1, [sp], #0x20
    ldp q2, q3, [sp], #0x20
    ldp q4, q5, [sp], #0x20
    ldp q6, q7, [sp], #0x20
.endmacro

.macro CALL_HOOK_BEFORE
    BACKUP_REGISTERS
    mov x2, lr
    bl _hook_objc_msgSend_before
    RESTORE_REGISTERS
.endmacro

.macro CALL_HOOK_AFTER
    BACKUP_REGISTERS
    bl _hook_objc_msgSend_after
    mov lr, x0
    RESTORE_REGISTERS
.endmacro

.macro CALL_ORIGIN_OBJC_MSGSEND
    adrp    x17, _orgin_objc_msgSend@PAGE
    ldr    x17, [x17, _orgin_objc_msgSend@PAGEOFF]
    blr x17
.endmacro

.macro COPY_STACK_FRAME
#if SUPPORT_SHOW_CALL_STACK
    stp x29, x30, [sp, #-0x10]
    mov x17, sp
    sub x17, fp, x17
    sub fp, sp, #0x10
    sub sp, fp, x17
    stp x0, x1, [sp, #-0x10]
    stp x2, x3, [sp, #-0x20]
    mov x0, sp
    add x1, sp, x17
    add x1, x1, #0x10
    mov x3, #0x0
    cmp x3, x17
    b.eq #0x18
    ldr x2, [x1, x3]
    str x2, [x0, x3]
    add x3, x3, #0x8
    cmp x3, x17
    b.lt #-0x10
    ldp x0, x1, [sp, #-0x10]
    ldp x2, x3, [sp, #-0x20]
#endif
.endmacro

.macro FREE_STACK_FRAME
#if SUPPORT_SHOW_CALL_STACK
    mov sp, fp
    add sp, sp, #0x10
    ldr fp, [fp]
#endif
.endmacro

# todo: 目前是全量复制栈帧,但是其实只需要复制参数传递用到的栈,利用函数签名等手段,去判断需要复制的栈帧大小
ENTRY _hook_msgSend
    COPY_STACK_FRAME
    CALL_HOOK_BEFORE
    CALL_ORIGIN_OBJC_MSGSEND
    CALL_HOOK_AFTER
    FREE_STACK_FRAME
    ret
END_ENTRY _hook_msgSend

hook基本步骤

  1. 复制栈帧,debug时候(或crash时候),可以看到调用堆栈。
  2. 保存寄存器。
  3. 调用hook_objc_msgSend_before (保存lr和记录函数调用开始时间)
  4. 恢复寄存器。
  5. 调用objc_msgSend
  6. 保存寄存器。
  7. 调用hook_objc_msgSend_after (返回lr和函数结束时间减去开始时间,得到函数耗时)
  8. 恢复寄存器。
  9. ret。

为什么要用stack保存LR

  1. hook objc_msgSend里面调用了hook_objc_msgSend_before和hook_objc_msgSend_after函数,会覆盖LR寄存器,导致函数ret时候,不知道LR值,所以需要保存LR。
  2. objc_msgSend是可变参数函数,栈内存可能用到。所以也不能放栈内存里,只有构造一个stack。可保证函数的push和pop是一一对应的。
  3. 需要注意的是,保存LR的stack,每个线程都对应一个stack。(原因也是为了保证函数的push和pop是一一对应),所以引入了线程局部变量,pthread_setspecific(pthread_key_t , const void * _Nullable)和pthread_getspecific(pthread_key_t)函数,根据key,来设置和获取线程局部变量。

保存寄存器注意点

参数传递,和返回值的传递可能会用到x0-x8/q0-q7寄存器,所以需保存x0-x8/q0-q7这些寄存器。x9等临时寄存器,不需要保存。

调用hook_objc_msgSend_before

由于函数hook_objc_msgSend_before(id self, SEL sel, uintptr_t lr),有三个参数,其中x0和x1已经存放self和SEL了,只需要设置第三个参数x2=lr。

调用hook_objc_msgSend_after

hook_objc_msgSend_after返回值是lr,返回值此时存放在x0里,所以lr=x0。

hook性能优化

  1. 由于App卡顿,绝大部分都是因为主线程卡顿造成,所以我们只需要监控主线程里运行的所有OC方法。但是hook objc_msgSend是hook所有的OC方法。网上很多hook方法都是把记录函数调用和保存LR放在一个stack里,最终调用hook_objc_msgSend_after时候,也只会统计主线程的耗时情况。

我用两个stack,一个专门存放LR值;另一个记录函数调用。避免子线程中OC方法的调用记录。

void hook_objc_msgSend_before(id self, SEL sel, uintptr_t lr)
{
    if (CallRecordEnable && pthread_main_np()) {
        //仅仅主线程记录函数调用
        pushCallRecord(object_getClass(self), sel);
    }
    //存放LR值
    setLRRegisterValue(lr);
}
  1. 支持设置记录的最大深度和最小耗时;超过这个深度和小于最小耗时的函数不记录。

记录OC方法耗时,需要记录的信息

typedef struct {
    Class cls;   //通过类可知道类名和方法是类方法还是实例方法(类是元类,说明是类方法)
    SEL sel;  //可知道方法名
    uint64_t costTime; //单位:纳秒(百万分之一秒)
    int depth;  
} TPCallRecord;
  1. x0中是self,通过self可以获得Class。
  2. x1中是sel
  3. 通过函数开始时间和结束时间,可以获得耗时
  4. 通过记录栈的深度,获得函数的深度。(注意:这里的深度是相对深度,因为我们仅记录部分OC方法的耗时)

把耗时OC方法的调用堆栈打印出来

获取的函数记录部分打印出来如下:

由于函数调用的栈是先进后出,根函数肯定是最后被记录,叶子函数最先被记录;并且同一层的函数,是先进先出。那我们如何还原成人更容易理解的函数调用堆栈呢?

  1. 第一步,从上往下,标记这个深度的记录,出现的次数。
深度相同深度出现次数耗时方法名
41...+[Utility  isPbPackage]
31...-[SharedLib  implIsJailBrokenIPA]
21...-[SharedLib  isJailBrokenIPA]
11...+[OnlineSettingHelper  sharedInstance]
22...-[OnlineSettingHelper4AppStore  all]
12...-[OnlineSettingHelper4AppStore  default...
13...+[SDWebImageManager  sharedManager]
01...-[AppDelegate  setUAForSDWebImageView]
  1. 第二步,从下往上,从根函数开始,深度递增,出现次数相同的记录,挑选出来。得到:

  1. 第三步,从最上面一个没有挑选的记录区域(挑选的记录,把整个记录分割成多个未选择的区域。),递归第二步。这个例子比较特殊,只有剩下一个未选择的区域(如果中间被选择了,那就分成多个区域)如下:

得到:

结束语

这个工具我后面将持续更新,加入其它功能,更加方便开发过程中使用。假如它对你有益,不妨github上给个star~ 给本文点赞,让更多同学看到这个工具,帮助更多人。多谢~

引用和参考

  1. time.geekbang.org/column/arti…
  2. github.com/facebook/fi…

--EOF-- 转载请保留链接,谢谢