前言
本文分为两大部分
- Instruments - Time Profiler原理探究
- 实现一个方法级的耗时检查工具, 应该怎么做? (hook objc_msgSend)
Time Profiler原理探究
Time Profiler是Xcode工具套件里自带的工具,我们可以通过它来很方便地监测方法的耗时,进而优化我们的App。
首先,我先列出一张从官网截下来的图,详细展示了Time Profiler的工作过程.通过这张图来进一步分析

图上按照时间顺序从左到右,五条竖虚线分别代表了每隔一段时间(默认是1ms),TimeProfiler从应用中获得调用栈。先分析前两个虚线:
- main调用了method1, method1调用了method2, 然后TimeProfiler从应用中获取了一个调用栈(虚线顶部有所标识)
- 接下来method1调用了method3 -> method3结束 -> method1又调用了method2, 然后TimeProfiler再次获取调用栈, 看起来与上次获取的一样, 它会在上次的基础上对涉及到的方法做调用次数的增量
- 以此类推后续三根虚线是一样的道理
- 结果可以看图中下部分展示的调用树
从这个过程中我们可以看出, Time Profiler实际上不是测量某个方法真正执行所用的时间,它并不是记录这个方法的开始时间和结束时间,然后取差值来计算方法执行的时间.我们在TimeProfiler工具内所看到的时间, 其实是样本间隔时间 x 样本个数。
所以它的原理可以总结为一句:通过定时抓取主线程上的方法调用堆栈,来推算方法耗时
这种机制存在什么问题
TimeProfile不能区分某方法是被连续重复调用 还是仅调用一次. 在上图最终生成的调用树中,method1和method2出现了相同样本数量(4),而实际上method1调用了两次, 并且时间很长; method2调用了四次, 运行时间很短; method3因为很快就结束了所以并没有被捕捉到.
思考
虽然TimeProfiler机制精准度存在瑕疵, 但是也足够我们日常使用.况且比较方便使用.
但如果想要实现一个方法级的耗时检查工具,该怎么做?
如何实现一个方法级耗时检查工具
思路
在这之前先复习一下OC的消息发送机制:
- 根据selector方法名字与参数,会生成一个唯一编码,来标识此方法
- 寻找函数指针,按照优先级从高到低:当前class的cache表,methodlist,super class的methodlist来寻找该方法
- 若找不到,会进入动态方法解析和消息转发的机制
其中第二步,就是靠objc_msgSend来寻找函数指针,所以我们要是控制了objc_msgSend,就可以控制所有的OC的方法.
⭐️ 结论:对objc_msgSend方法进行hook来掌握所有方法的执行耗时
我是通过借鉴 戴铭的GCDFetchFeed和比较适合学习的ObjCHook, 阅读源码和其他一些资料来学习的,在此分享并记录一下学习过程.(文末会贴出相关学习资料)
这里我是通过ObjCHook来学习的,刚接触时发现比较吃力,然后温习了一下汇编的一些知识,在此分享一下我的笔记,这些都是做hook objc_msgSend前要掌握的知识。
寄存器
由于objc_msgSend是用汇编来实现的,我们要对汇编相关知识有了解,首先要知道寄存器的概念. CPU本身只负责运算,不负责存储数据的。寄存器是CPU内部用来存放数据的一些小型存储区域,用来暂时存放参与运算的数据和运算结果。 arm64有31个寄存器来处理整型和指针,64bits时用x0 ~ x31来表示. 32bits时用w0 ~ w31来表示.那他们都是有什么作用呢
| 寄存器 | 解释 |
|---|---|
| x0~x7 | 在函数调用过程中传递参数和返回值,对于objc_msgSend方法来说,x0第一个参数是传入对象(id类型), x1第二个参数是选择器(SEL类型) |
| x8 | 用于保存子程序返回地址 |
| x9~x15 | 临时寄存器,使用时不需要保存 |
| x16~x17 | 内部调用寄存器 |
| x18 | 平台寄存器,其使用与平台相关 |
| x19~x28 | 临时寄存器,使用时必须保存 |
| x29 | FP (Frame Pointer) 连接栈帧,使用时需要保存 |
| x30 | LR (Link Register) 保存子程序的返回地址 |
| x31 | SP (Stack Pointer) 存放栈的偏移地址,指向栈顶 |
常用指令
| 指令 | 作用 |
|---|---|
| mov | 数据传送指令,也是最基本的编程指令,用于将一个数据从源地址传送到目标地址 例:("mov lr, x0")将x0寄存器的内容传送到lr寄存器中 |
| lrd/ldp | 从内存中读取数据并存到寄存器中(ldp可以同时操作两个寄存器) 例:("LDR x1,[x2],#8") 将x2地址中的内容存到x1,并且x2 = x2 + 8 |
| str/stp | 写入指令, 格式为 STR{条件} 源寄存器,<存储器地址>, 从源寄存器读取数据并写入到存储器地址中(stp可以同时操作两个寄存器) (注:源寄存器在前面,与mov相反) |
| bl/blr | 将PC寄存器值的下一个值放到返回地址(链接寄存器 LR), blr与 bl类似,区别是新的PC的值是从特定的寄存器取得 |
| ret | 函数返回指令,将LR寄存器中的值赋给PC寄存器 |
*注: PC: program counter,程序计数器,保存着下一条将要执行的指令的内存地址
补充: ObjCHook源码中还用到了pthread_setspecific.它提供了在多线程中,不同函数间共享数据的方法.
- 通过pthread_key_create()来创建一个pthread_key_t。
- 通过pthread_setspecific(pthread_key_t , const void * _Nullable) 来存放共享数据
- 通过pthread_getspecific(pthread_key_t)来获取指定key的同一线程中不同方法的共享数据
大体思路
了解完这些内容,再结合ObjCHook源码中的注释,可以get到:
大体思路就是在hook前先保存objc_msgSend的信息到寄存器, 包括SEL、类名、开始/结束时间以及当前调用栈下一个函数地址(确保在hook结束之后lr寄存器可以正常执行) 等等的信息, 然后通过blr跳到方法before_objc_msgSend, 从寄存器获取原有参数, 并执行原函数,保存入参,再通过blr跳到after_objc_msgSend,这方法里有打印方法执行信息的操作.最后再恢复hook前lr寄存器的内容.
最后
感谢以下作者的分享
参考的链接:
https://developer.apple.com/videos/play/wwdc2016/418/ (WWDC- Using Time Profiler in Instruments)
https://juejin.cn/post/6844903875795763213 (arm64调用规则)
http://www.ruanyifeng.com/blog/2018/01/assembly-language-primer.html (汇编入门)
https://juejin.cn/post/6844903847039598600 (深入iOS系统底层之函数调用)
https://github.com/czqasngit/objc_msgSend_hook (ObjCHook)