基于汇编实现objc_msgSend hook方法耗时的方案

4,000 阅读13分钟

hll.png

alex023,货拉拉移动架构组iOS负责人、资深工程师,负责平台iOS侧稳定性、APM、监控等基础设施建设工作。

前言

测算函数/方法执行耗时,对于每一位开发同学来说,似乎都是一道绕不过的坎,几乎都曾经历过。也许你会使用下面这种方式:

这种方法高效、成本低。但如果发散到测算成千上百个函数/方法执行耗时的时候,显然这并不是一个好办法。那么在iOS开发中有什么好方案吗?经过一番研究探索,我们实现了一套基于汇编hook objc_msgSend进行方法耗时计算的方案,开源地址见 ->github.com/HuolalaTech…<-。 接下来,带大家走进本文。

在开始之前,我们做以下约定:

本文中的 OC 指代 Objective C,ARM 指代 ARM 64。

背景

货拉拉移动端各条业务线产品,在每一次版本发布前,都会经过严格的各项性能测试,其中有一项启动性能测试,背后使用的是淘宝开源的tidevice工具(本质与libimobiledevice类似)。但这种性能测试处于流水线中末端,也有一定的分析成本。我们总结痛点如下:

  • 定位难,责任划分不清;

方法耗时问题比较难以定位,不能定位到业务所在的位置,也就容易出现归属和责任不清的尴尬境遇;

  • 动作滞后,影响发布;

启动性能测试处于流水线中末端,比较滞后,如果发现修复影响面比较大的问题,可能会影响版本发布;

  • 工时长,不可持续;

Xcode Instruments虽然可以在开发时使用,但使用时间比较长,分析结果难以持续,近似属于消费完便丢掉的状态。

我们的目标是:

  • 低成本:接入方低成本接入;
  • 无侵入:不侵入业务;
  • 高性能:性能足够高;
  • 可视化:分析结果可视化;

探索

站在巨人的肩膀上

在梳理完需求之后,值得做的第一件事便是翻阅前人在该领域的探索和实践过程中所积累下来的资料。站在巨人肩膀之上,我们的起点更高,能做的事情也更有高度。

针对OC方法耗时分析,业内有比较多的方案和工具。比如静态插桩、Messier工具、基于汇编语言实现的objc_msgSend hook、Xcode自带instruments等等。

  • 静态插桩

静态插桩通过往程序中插入探针(probe)的方式来采集代码中的信息,这些信息包括但不限于方法名、入参数、返回值等。它能够在指定的位置插入一个代码段,通过插入代码来收集程序运行态context(上下文环境),而且能够保证原程序在逻辑上的完整性。

静态插桩按时机分为编译时插桩、链接时插桩、运行时插桩。

编译时插桩可以覆盖静态目标文件内的所有函数符号;链接时插桩主要针对链接的目标共享库,所以仅对共享目标文件内的符号生效;运行时插桩对运行阶段,所执行路径上的符号都生效。

以支持编译时插桩的finstrument-functions为例,该工具支持 clang/gcc 的编译环境,使用 -finstrument-functions 编译参数进行编译插桩后,每个函数会在 entry edge 和 exit edge 时分别调用 __cyg_profile_func_enter 函数和 __cyg_profile_func_exit 函数,我们来实现这两个函数即可。

首先需要编译生成 test.o 目标文件,test.o 内包含 entry 和 exit 函数。其次在需要被插桩的源文件编译时,添加编译参数 -g -finstrument-functions 与 编译依赖文件 test.o 既可完成对目标源文件的插桩。

g++ main.cpp test.o -g -finstrument-functions
#import <dlfcn.h>


void __cyg_profile_func_enter(void *func, void *caller) { 
    Dl_info info = {0}; 
    dladdr(func, &info); 
    printf("test func enter: %s", info.dli_sname); 
} 



void __cyg_profile_func_exit(void *func, void *caller) { 
    Dl_info info = {0}; 
    dladdr(func, &info); 
    printf("test func exit: %s", info.dli_sname); 
}
  • Messier

Messier 可以用来跟踪iOS应用的Objective-C方法调用,完美的解决了Time Profiler 把调用方法都合并了起来,失去了时序的表现。首先要说明的是,目前Messier只支持arm64。

Messier的使用姿势,我们在前期的文章《货拉拉用户端体验优化--启动优化篇》中已作详细介绍,此处不再赘述。 使用步骤:首先安装Messier客户端,然后在工程中接入messier.framework并配置,最后使用Messier客户端运行APP,输出耗时分析产物。

  • objc_msgSend hook

关于hook iOS原生方法objc_msgSend进行方法耗时分析的实践是比较多的,但思路几乎都是一致的。无非做三件事:

  1. 自定义objc_msgSend
  2. 使用自定义objc_msgSend替换原生objc_msgSend
  3. 寻找合适时机进行替换

该方案要求对汇编指令、fishhook有一定的了解,下文会重点讲述。

  • Xcode instruments

相信做iOS开发的同学们,对Xcode instruments应该非常熟悉。它提供了非常丰富的线上调试分析工具,如下所示:

其中,Time Profiler是专门做耗时分析的,它能够捕获运行时的各个线程中方法执行耗时,并详细地列举了调用堆栈,如下所示:

在介绍完以上四种主流方案后,我们面临一个如何选择的问题。那么我们来对比四种方案的利弊,结合我们的原始需求来做出选择。

方案优点缺点
静态插桩可以覆盖函数的 entry edge 与 exit edge ,能够完成对函数的整个执行过程覆盖- 对包体积有负向影响
-无法应用到闭源库
-接入成本高
Messier分析产物可视化,具有时序性- 依赖三方维护,部分iOS系统无法应用
Xcode Instruments功能强大、支持子线程分析- 使用成本高分析结果不可持续无时许性
objc_msgSend hook侵入性低、性能高、使用成本低;可视化;是有时序性- 不支持模拟器
- 仅面向OC方法耗时

通过分析对比以及结合我们的需求,最终选择objc_msgSend hook方案来分析业务中的方法耗时。

我们还能做什么

在确定方案之后,我们在思考一个问题:基于现有的公开方案,我们还能做些什么?通过挖掘,我们整理出了一些新需求:

  1. 能否过滤部分OC方法的耗时分析,比如使用ReactiveCocoa框架的项目,会发现有大量的该框架调用栈,实则对我们业务的分析没有什么意义;
  2. 和Xcode Instrument一样,不仅仅分析主线程,也能够分析子线程;
  3. 可视化。能够使用火焰图进行可视化分析,同时也能在端上进行快速查看;
  4. 能够区分深浅耗时。所谓的某个方法深耗时指该方法自身的执行耗时以及所有子方法的执行耗时,某个方法浅耗时指该方法自身的执行耗时,不包括其子方法执行耗时;
  5. 能够自定义耗时阈值;

核心原理

本章节将介绍核心原理部分,通过本章节,大家将会对ARM汇编、obj_msgSend有更深入的了解。

让我们回到上文提到的三件事:

  1. 自定义objc_msgSend
  2. 使用自定义objc_msgSend替换原生objc_msgSend
  3. 寻找合适时机进行替换

核心原理部分也将围绕着这三点来展开。

首先,我们来谈谈objc_msgSend。objc_msgSend是OC方法的统一入口,换句话说,OC方法的调用最终都将转化成对系统底层objc_msgSend的调用,objc_msgSend负责对消息进行分发,查找方法对应的函数指针即IMP进行调用,传入objc_msgSend的参数将被传递给 IMP,IMP 的返回值将返回给objc_msgSend的调用者。

同时,objc_msgSend是用汇编实现的,这样设计的原因主要有两个:第一,该api调用量非常之大,承接了整个工程中百万级别的调用,因此对性能要求极高;第二,objc_msgSend需要按照调用约定(Calling Convention)将个数不定的参数摆放到寄存器或栈上。这一点,即使强如C语言也难以实现。我们在替换objc_msgSend之后,需要确保做到一点:调用原生objc_msgSend前后的寄存器状态和没有替换时是一致的,否则程序就会出错。综上,我们的自定义objc_msgSend也得用汇编语言来实现。

ARM汇编

在编写自定义objc_msgSend前,我们需要对ARM汇编作一些简介。

先介绍下寄存器部分,寄存器(Register)是中央处理器内用来暂存指令、数据和地址的电脑存储器。寄存器的存贮容量有限,读写速度非常快。在计算机体系结构里,寄存器存储在已知时间点所作计算的中间结果,通过快速地访问数据来加速计算机程序的执行。

寄存器位于存储器层次结构的最顶端,也是CPU可以读写的最快的存储器。寄存器又分为通用寄存器、特殊寄存器、向量寄存器(浮点型)和状态寄存器。这里仅介绍通用寄存器和特殊寄存器。

通用寄存器

ARM64拥有31个64位的通用寄存器 x0 到 x30, 通常用来存放一般性的数据,称为通用寄存器(有时也有特定用途)

w0 到 w28 这些是32位的。64位CPU可以兼容32位,因此可以只使用64位寄存器的低32位.

  • X0~X7:用于传递子程序参数和结果,使用时不需要保存,多余参数采用堆栈传递,64位返回结果采用X0表示,128位返回结果采用X1:X0表示。
  • X8:用于保存子程序返回地址, 尽量不要使用 。
  • X9~X15:临时寄存器,使用时不需要保存。
  • X16~X17:子程序内部调用寄存器,使用时不需要保存,尽量不要使用。
  • X18:平台寄存器,它的使用与平台相关,尽量不要使用。
  • X19~X28:临时寄存器,使用时必须保存。
  • X29:帧指针寄存器FP(栈底指针),用于连接栈帧,使用时需要保存。
  • X30:链接寄存器LR
  • X31:堆栈指针寄存器SP或零寄存器ZXR

特殊寄存器

特殊寄存器主要指SP、FP、LR、PC等。内存模型中,栈空间由高地址位向低地址位分配,SP是栈顶寄存器,用于保存栈顶位置,FP用于保存栈底位置, LR即X30寄存器,用于保存下一条指令地址,而PC是CPU当前指令的地址。

介绍完寄存器后,再来了解下ARM汇编指令集。ARM汇编指令集可大致划分为数据处理指令、汇编转移指令、汇编加载指令和其他指令。

这里我们重点介绍其中的转移指令和加载指令。

NameEffectDescription
brpc ← XnCopy register Xn to the program counter(pc)
retpc ← X30 or pc ← XnCopy the link register(X30), or any other register(Xn) to the program counter(pc)

br和ret指令是比较单纯的转移,他们都是简单的转移,不会产生副作用。比如br Xn这条指令,会将寄存器Xn内容复制到PC,刚才我们介绍了PC是当前指令地址,那么就实现了跳转。

同样的ret指令,后面的花括号表示可选,当指定了Xn,表示将寄存器Xn内容复制到PC。如果没有制定,则默认复制X30(LR)寄存器内容到PC

NameEffectDescription
brX30 ← pc + 4pc ← target_addressSave address of next instruction in link register(X30), then load pc with new address.
blrX30 ← pc + 4pc ← XnSave address of next instruction in link register(X30), then load pc with Xn.

bl和blr指令,是br和ret指令的升级,它们会有副作用,副作用表现在除了跳转意外,它们还会操作LR寄存器(其实就是在跳转之前,先将PC+4的地址保持到X30(LR)中)。

加载指令比较简单,LDR和LDP负责将内存内容转移至寄存器中,STR和STP则与之相反,将寄存器内容转移至内存中。

objc_msgSend override and replace

在介绍完汇编知识后,我们便可以设计自定义objc_msgSend实现流程如下:

暂时无法在文档外展示此内容

我们在原生objc_msgSend前后分别注入pre逻辑和post逻辑。pre逻辑中主要做了一些信息和方法执行起始时刻的记录,同时为了确保执行原生objc_msgSend时上下文环境不变,做了寄存器数据备份和恢复。post逻辑中主要做方法耗时计算、堆栈信息存储、上下文环境的备份与恢复(目的与pre中的处理是相同的)。

值得注意的是,在执行post_objc_msgSend后,需要将LR寄存器及时保存并在合适时机恢复,否则程序会由于LR寄存器地址不正确出现死循环。关于这一点,有兴趣的读者,可以结合下面的Demo理解下。在Xcode中打开汇编调试(操作路径:Debug->Debug Workflow ->Always Show Disassembly), 对Demo进行断点调试,看看LR寄存器内容是如何变化的,这里不再深入。

void A();


int main() {
    printf("pre");
    A();
    printf("post");
}
// asm.s
.text
.global _A, _B

_A:
    mov x0, #0xaaaa
    bl _B
    mov x0, #0xcccc
    ret

_B:
    mov x0,  #0xbbbb
    ret

自定义objc_msgSend汇编实现如下:

.macro GDN_STORE_REGISTERS
stp    fp, lr, [sp, #-0x10]!
mov    fp, sp
sub    sp, sp, #(10*8 + 8*16)
stp    x0, x1, [sp, #(8*16+0*8)]
stp    x2, x3, [sp, #(8*16+2*8)]
stp    x4, x5, [sp, #(8*16+4*8)]
stp    x6, x7, [sp, #(8*16+6*8)]
str    x8,     [sp, #(8*16+8*8)]
.endm

.macro GDN_LOAD_REGISTERS
ldp    x0, x1, [sp, #(8*16+0*8)]
ldp    x2, x3, [sp, #(8*16+2*8)]
ldp    x4, x5, [sp, #(8*16+4*8)]
ldp    x6, x7, [sp, #(8*16+6*8)]
ldr    x8,     [sp, #(8*16+8*8)]
mov    sp, fp
ldp    fp, lr, [sp], #0x10
.endm

.globl _gdn_objc_msgSend
_gdn_objc_msgSend:

GDN_STORE_REGISTERS
bl      _needs_profiler
cbnz    x0, GDN_NEEDS_PROFILER

GDN_LOAD_REGISTERS
adrp        x9, _origin_objc_msgSend@PAGE
add     x9, x9, _origin_objc_msgSend@PAGEOFF
ldr     x9, [x9]
br      x9

GDN_NEEDS_PROFILER:
ldp     x0, x1, [fp, #-0x50]
ldr     x2, [fp, #0x8]
bl      _pre_objc_msgSend

GDN_LOAD_REGISTERS
adrp        x9, _origin_objc_msgSend@PAGE
add     x9, x9, _origin_objc_msgSend@PAGEOFF
ldr     x9, [x9]
blr     x9

GDN_STORE_REGISTERS
bl      _post_objc_msgSend
mov     x9, x0
GDN_LOAD_REGISTERS
mov     lr, x9
ret

其次,我们来谈谈如何替换原生objc_msgSend。

objc_msgSend虽是汇编实现,但对外声明为C函数,因此可以使用facebook开源的fishhook方案来实现替换。fishhook能够在 Mach-O 二进制文件中动态地重新绑定符号,实现对 C 方法的 hook。下面通过一些关键代码来熟悉 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。实现代码如下:

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; i++, 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] != 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;
    }
}

具体到我们的需求中,便是如下替换:

rebind_symbols((struct rebinding[1]){{"objc_msgSend", (void *)gdn_objc_msgSend, (void **)&origin_objc_msgSend}}, 1);

最后,我们来谈谈什么时机替换。

现在,我们明确了汇编实现和替换方案,那么还需要寻找一个最佳的替换时机。这个替换时机应该尽可能的早,否则替换之前的那片将成为监控盲区。+(void)load是大家常用的在较早时机进行加工处理的口子。但项目中可能存在多个+load,执行顺序也会因为命名和编译顺序调整而变化。

回顾下+load的加载顺序:父类先于子类,本体先于Category;动态库按照项目中的排列顺序,排列位置靠前的先加载。

因此,我们可以根据这个加载顺序规则,给项目创建一个动态库,并将其设置为targets中排列在头部位置的动态库。如此便能实现该动态库中的+load方法第一个被加载,那么放置在该动态库+load方法中的替换逻辑也将在非常早的时机被执行。

// First BizDynamicLib
+ (void)load
{
    // replace original objc_msgSend with your custom objc_msgSend
}

效果展示

接入及使用

Step 1 添加依赖

pod 'Guldan'

Step 2 在待测试代码块前后插入计算节点

#import "GDNOCMethodTracer.h"
[GDNOCMethodTracer start];
//
// ... your code here
//
[GDNOCMethodTracer stop];

Step 3 生成计算结果

[GDNOCMethodTracer handleRecordsWithComplete:^(NSArray<NSString *> * _Nonnull filePaths) {
}];

Step 4 导出计算结果

可借助一些沙盒工具快速打开。也可以使用Xcode下载沙盒目录。这里仅介绍如何使用Xcode找到沙盒中的结果文件。

Xcode window/Devices and Simulators/选中目标APP/点击齿轮图标并选择「Download Container」

右击上一步下载的文件,选择「显示包内容」并找到oc_method_cost_mainthread文件

Step 5 借助Chrome实现桌面端可视化

在chrome浏览器中输入chrome://tracing/,拖入oc_method_cost_mainthread文件。

效果展示

桌面端trace分析结果展示如下:

移动端分析结果展示如下:

总结与展望

本文通过引入货拉拉移动端研发过程中的痛点,产生方法耗时分析的需求,在调研业内各种分析工具和方案后,结合需求整合出我们的方案。实现需求需要完成三件事:自定义objc_msgSend、使用自定义objc_msgSend替换原生objc_msgSend以及寻找合适时机进行替换。除此之外,可视化也是一部分工作。

未来,我们计划包含但不限于以下几件事情:

  • 提供更加便捷获取trace结果文件的能力;
  • 提供监控能力;
  • 提供数据分析能力;
  • 支持黑名单;
  • more;

参考资料

[1]史斌. ARM汇编语言和C/C++语言混合编程的方法[J]. 电子测量技术, 2006, 29(006):89-91.

[2]王应军, 曲培新, 赵晨萍. ARM汇编语言与C语言混合编程的实现方法[J]. 科技信息, 2010(3):2.

[3]刘峰, 陈斌, 付常超. ARM汇编语言[M]. 电子科技大学出版社, 2010.

[4]文全刚. 汇编语言程序设计:基于ARM体系结构[M]. 北京航空航天大学出版社, 2007.

[5]汇编语言入门教程 - 阮一峰的网络日志

[6]github.com/DavidGoldma…

[7]github.com/ming1016/GC…

[8]opensource.apple.com/source/objc…

[9]github.com/facebook/fi…

[10]APP启动速度怎么做优化与监控.戴铭