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进行办法耗时剖析的实践是比较多的,但思路简直都是共同的。无非做三件事:
- 自定义objc_msgSend
- 运用自定义objc_msgSend替换原生objc_msgSend
- 寻觅适宜机遇进行替换
该方案要求对汇编指令、fishhook有一定的了解,下文会要点讲述。
- Xcode instruments
信任做iOS开发的同学们,对Xcode instruments应该十分了解。它供给了十分丰富的线上调试剖析东西,如下所示:
其间,Time Profiler是专门做耗时剖析的,它能够捕获运转时的各个线程中办法履行耗时,并详细地列举了调用仓库,如下所示:
在介绍完以上四种主流方案后,咱们面对一个怎么挑选的问题。那么咱们来比照四种方案的利弊,结合咱们的原始需求来做出挑选。
方案 | 长处 | 缺陷 |
---|---|---|
静态插桩 | 能够覆盖函数的 entry edge 与 exit edge ,能够完结对函数的整个履行进程覆盖 | – 对包体积有负向影响 -无法运用到闭源库 -接入本钱高 |
Messier | 剖析产品可视化,具有时序性 | – 依靠三方保护,部分iOS体系无法运用 |
Xcode Instruments | 功能强大、支撑子线程剖析 | – 运用本钱高剖析成果不行继续无时许性 |
objc_msgSend hook | 侵入性低、功能高、运用本钱低;可视化;是有时序性 | – 不支撑模拟器 – 仅面向OC办法耗时 |
经过剖析比照以及结合咱们的需求,终究挑选objc_msgSend hook方案来剖析事务中的办法耗时。
咱们还能做什么
在确认方案之后,咱们在考虑一个问题:根据现有的公开方案,咱们还能做些什么?经过挖掘,咱们收拾出了一些新需求:
- 能否过滤部分OC办法的耗时剖析,比方运用ReactiveCocoa结构的项目,会发现有很多的该结构调用栈,实则对咱们事务的剖析没有什么意义;
- 和Xcode Instrument一样,不仅仅剖析主线程,也能够剖析子线程;
- 可视化。能够运用火焰图进行可视化剖析,一起也能在端上进行快速查看;
- 能够区分深浅耗时。所谓的某个办法深耗时指该办法自身的履行耗时以及一切子办法的履行耗时,某个办法浅耗时指该办法自身的履行耗时,不包括其子办法履行耗时;
- 能够自定义耗时阈值;
中心原理
本章节将介绍中心原理部分,经过本章节,咱们将会对ARM汇编、obj_msgSend有更深入的了解。
让咱们回到上文说到的三件事:
- 自定义objc_msgSend
- 运用自定义objc_msgSend替换原生objc_msgSend
- 寻觅适宜机遇进行替换
中心原理部分也将围绕着这三点来翻开。
首要,咱们来谈谈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汇编指令集可大致区分为数据处理指令、汇编搬运指令、汇编加载指令和其他指令。
这儿咱们要点介绍其间的搬运指令和加载指令。
Name | Effect | Description |
---|---|---|
br | pc ← Xn | Copy register Xn to the program counter(pc) |
ret | pc ← X30 or pc ← Xn | Copy 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
Name | Effect | Description |
---|---|---|
br | X30 ← pc + 4pc ← target_address | Save address of next instruction in link register(X30), then load pc with new address. |
blr | X30 ← pc + 4pc ← Xn | Save 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发动速度怎么做优化与监控.戴铭