简介: Aspect使用了OC的消息转发流程,有必定的性能消耗。本文做者使用C++设计语言,并使用libffi进行核心trampoline函数的设计,实现了一个iOS AOP框架——Lokie。相比于业内熟知的Aspects,性能上有了明显的提高。本文将分享Lokie的具体实现思路。git
不自觉的想起本身从业的这十几年,如白驹过隙。如今谈到上还熟悉的的语言以ASM/C/C++/OC/JS/Lua/Ruby/Shell等为主,其余的基本上都是用时拈来过期忘,语言这种东西变化是在太快了, 不过大致换汤不换药,我感受近几年来全部的语言隐隐都有一种大统一的走势,一旦有个特性不错,你会在不一样的语言中都找到这种技术的影子。因此我对使用哪一种语言并非很执着,不过C/C++是信仰罢了 : )github
工做中大部分用OC和Ruby、Shell之类的东西,前段时间一直想找一款合适的iOS下能用的AOP框架。iOS业内比较被熟知的应该就是Aspect了。可是Aspect性能比较差,Aspect的trampoline函数借助了OC语言的消息转发流程,函数调用使用了NSInvocation,咱们知道,这两样都是性能大户。有一份测试数据,基本上NSInvocation的调用效率是普通消息发送效率的100倍左右。事实上,Aspect只能适用于每秒中调用次数不超过1000次的场景。固然还有一些其余的库,虽然性能有所提高,但不支持多线程场景,一旦加锁,性能又有明显的损耗。面试
找来找去也没有什么趁手的库,因而想了想,本身写一个吧。因而Lokie便诞生了。算法
Lokie的设计基本原则只有两条,第一高效,第二线程安全。为了知足高效这一设计原则,Lokie一方面采用了高效的C++设计语言,标准使用C++14。C++14因引入了一些很是棒的特性好比MOV语义,完美转发,右值引用,多线程支持等使得与C++98相比,性能有了显著的提高。另外一方面咱们抛弃了对OC消息转发和NSInvocation的依赖,使用libffi进行核心trampoline函数的设计,从而直接从设计上就砍倒性能大户。此外,对于线程锁的实现也使用了轻量的CAS无锁同步的技术,对于线程同步开销也下降了很多。api
经过一些真机的性能数据来看,以iPhone 7P为例, Aspect百万次调用消耗为6s左右,而相同场景Lokie开销仅有0.35s左右, 从测试数据上来看,性能提高仍是很是显著的。缓存
我是个急性子,看书的时候也是喜欢先看代码。因此我先帖lokie的开源地址:安全
https://github.com/alibaba/Lokie多线程
喜欢翻代码的同窗能够先去看看。app
Lokie的头文件很是简单, 以下所示只有两个方法和一个LokieHookPolicy的枚举。框架
#import <Foundation/Foundation.h> typedef enum : NSUInteger { LokieHookPolicyBefore = 1 << 0, LokieHookPolicyAfter = 1 << 1, LokieHookPolicyReplace = 1 << 2, } LokieHookPolicy; @interface NSObject (Lokie) + (BOOL) Lokie_hookMemberSelector:(NSString *) selecctor_name withBlock: (id) block policy:(LokieHookPolicy) policy; + (BOOL) Lokie_hookClassSelector:(NSString *) selecctor_name withBlock: (id) block policy:(LokieHookPolicy) policy; -(NSArray*) lokie_errors; @end
这两个方法的参数是同样的,提供了对类方法和成员方法的切片化支持。
拿一个场景来看看Lokie的威力。好比咱们想监控全部的页面生命周期,是否正常。
好比项目中的 VC 基类叫 BasePageController,designated initializer 是 @selector(initWithConfig)。
咱们暂时把这段测试代码放在application: didFinishLaunchingWithOptions中,AOP就是这么任性!这样咱们在app初始化的时候对全部的BasePageController对象生命周期的开始和结束点进行了监控,是否是很酷?
Class cls = NSClassFromString(@"BasePageController"); [cls Lokie_hookMemberSelector:@"initWithConfig:" withBlock:^(id target, NSDictionary *param){ NSLog(@"%@", param); NSLog(@"Lokie: %@ is created", target); } policy:LokieHookPolicyAfter]; [cls Lokie_hookMemberSelector:@"dealloc" withBlock:^(id target){ NSLog(@"Lokie: %@ is dealloc", target); } policy:LokieHookPolicyBefore];
block的参数定义很是有意思, 第一个参数是永恒的id target,这个selector被发送的对象,剩下的参数和selector保持一致。好比 "initWithConfig:" 有一个参数,类型是NSDNSDictionary , 因此咱们对 initWithConfig: 传递的是^(id target, NSDictionary param),而dealloc是没有参数的,因此block变成了^(id target)。换句话说,在block回调当中,你能够拿到当前的对象,以及执行这个方法的参数上下文,这基本上能够为你提供了足够的信息。
对于返回值也很好理解,当你使用LokieHookPolicyReplace对原方法进行替换的时候,block的返回值必定和原方法是一致的。用其余两个flag的时候,无返回值,使用void便可。
另外咱们能够对同一个方法进行屡次hook,好比像这个样子:
Class cls = NSClassFromString(@"BasePageController"); [cls Lokie_hookMemberSelector:@"viewDidAppear:" withBlock:^(id target, BOOL ani){ NSLog(@"LOKIE: viewDidAppear 调用以前会执行这部分代码"); }policy:LokieHookPolicyBefore]; [cls Lokie_hookMemberSelector:@"viewDidAppear:" withBlock:^(id target, BOOL ani){ NSLog(@"LOKIE: viewDidAppear 调用以后会执行这部分代码"); }policy:LokieHookPolicyAfter];
细心的你有木有感受到,若是咱们用个时间戳记录先后两次的时间,获取某个函数的执行时间就会很是容易。
前面两个简单的小例子算是抛砖引玉吧, AOP在作监控、日志方面来讲功能仍是很是强大的。
整个AOP的实现是基于iOS的runtime机制以及libffi打造的trampoline函数为核心的。因此这里我也聊聊iOS runtime的一些东西。这部分对于不少人来讲,可能比较熟悉了。
OC runtime里有几个基础概念:SEL, IMP, Method。
typedef struct objc_selector *SEL; typedef id (*IMP)(id, SEL, ...); struct objc_method { SEL method_name; char *method_types; IMP method_imp; } ; typedef struct objc_method *Method;
objc_selector这个结构体颇有意思,我在源码里面没有找到他的定义。不过能够经过翻阅代码来推测objc_selector的实现。在objc-sel.m当中,有两个函数代码以下:
const char *sel_getName(SEL sel) { if (!sel) return "<null selector>"; return (const char *)(const void*)sel; }
sel_getName这个函数出镜率仍是很高的,从它的实现来看,sel和const char *是能够直接互转的,第二个函数看的则更加清晰:
static SEL __sel_registerName(const char *name, int copy) ; //! 在 __sel_registerName 中有经过const char *name 直接获得 SEL 的方法 ... if (!result) { result = sel_alloc(name, copy); } ... //! sel_alloc的实现 static SEL sel_alloc(const char *name ,bool copy) { selLock.assertWriting(); return (SEL)(copy ? strdupIfMutable(name):name); }
看到这里,咱们基本上能够推测出来objc_selector的定义应该是相似与如下这种形式:
typedef struct { char selector[XXX]; void *unknown; ... }objc_selector;
为了提高效率, selecor的查找是经过字符串的哈希值为key的,这样会比直接使用字符串作索引查找更加高效。
//!objc4-208 版本的哈希算法 static CFHashCode _objc_hash_selector(const void *v) { if (!v) return 0; return (CFHashCode)_objc_strhash(v); } static __inline__ unsigned int _objc_strhash(const unsigned char *s) { unsigned int hash = 0; for (;;) { int a = *s++; if (0 == a) break; hash += (hash << 8) + a; } return hash; }
//! objc4-723 版本的hash算法 static unsigned _mapStrHash(NXMapTable *table, const void *key) { unsigned hash = 0; unsigned char *s = (unsigned char *)key; /* unsigned to avoid a sign-extend */ /* unroll the loop */ if (s) for (; ; ) { if (*s == '\0') break; hash ^= *s++; if (*s == '\0') break; hash ^= *s++ << 8; if (*s == '\0') break; hash ^= *s++ << 16; if (*s == '\0') break; hash ^= *s++ << 24; } return xorHash(hash); } static INLINE unsigned xorHash(unsigned hash) { unsigned xored = (hash & 0xffff) ^ (hash >> 16); return ((xored * 65521) + hash); }
至于为何会专门搞出一个objc_selector, 我想官方应该是想强调SEL和const char 是不一样的类型。
IMP的定义以下所示:
#if !OBJC_OLD_DISPATCH_PROTOTYPES typedef void (*IMP)(void /* id, SEL, ... */ ); #else typedef id _Nullable (*IMP)(id _Nonnull, SEL _Nonnull, ...); #endif
LLVM 6.0 后增长了 OBJC_OLD_DISPATCH_PROTOTYPES,须要在 build setting 中将 Enable Strict Checking of objc_msgSend Calls 设置为NO才可使用 objc_msgSend(id self, SEL op, ...)。有些同窗在调用objc_msgSend的时候,编译器会报以下错误,就是这个缘由了。
Too many arguments to function call, expected 0, have 2
IMP 是一个函数指针,它是最终方法调用是的执行指令入口。
objc_method能够说是很是关键了,它也是OC语言能够在运行期进行method swizzling 的设计基石, 经过objc_method 把函数地址,函数签名以及函数名称打包作个关联, 在 真正执行类方法的时候,经过selector名称,查找对应的IMP。一样,咱们也能够经过在运行期替换某个selector 名称与之对应的IMP来完成一些特殊的需求。
这三个概念明确了以后,咱们继续聊下消息发送机制。咱们知道当向某个对象发送消息的时候,有一个关键函数叫objc_msgSend, 这个函数里到底干了些什么事情, 咱们简单聊一聊。
//! objc_msgSend 函数定义 id objc_msgSend(id self, SEL op, ...);
这个函数内部是用汇编写的,针对不一样的硬件系统提供了相应的实现代码。不一样的版本实现应该是存在差别, 包括函数名称和实现(我查阅的版本是 objc4-208)。
objc_msgSend首先第一件事就是检测消息发送对象self是否为空,若是为空,直接返回,啥事不作。这也就是为何对象为nil时,发送消息不会崩溃的缘由。作完这些检测以后,会经过self->isa->cache去缓存里查找selector对应的Method, (cache里面存放的是Method ),查找到的话直接调用Method->method_imp。没有找到的话进入下一个处理流程,调用一个名为class_lookupMethodAndLoadCache的函数。
这个函数的定义以下所示:
IMP _class_lookupMethodAndLoadCache (Class cls, SEL sel) { ... if (methodPC == NULL) { //! 这里指定消息转发入口 // Class and superclasses do not respond -- use forwarding smt = malloc_zone_malloc (_objc_create_zone(), sizeof(struct objc_method)); smt->method_name = sel; smt->method_types = ""; smt->method_imp = &_objc_msgForward; _cache_fill (cls, smt, sel); methodPC = &_objc_msgForward; } ... }
消息转发机制这部分动态方法解析,备援接收者,消息重定向应该是不少面试官都喜欢问的环节 : ) ,我想你们确定是比较熟悉这部份内容,这里就再也不赘述了。
接下来的内容,咱们简单介绍下,从汇编的视角出发,如何实现一个trampline函数,完成c函数级别的函数转发。以x86指令集为例,其余类型原理也类似。
从汇编的角度来看,函数的跳转,最直接的方式就是插入jmp指令。x86指令集中,每条指令都有本身的指令长度,好比说jmp指令, 长度为5,其中包含一个字节的指令码,4个字节的相对偏移量。假定咱们手头有两个函数A和B, 若是想让B的调用转发到A上去, 毫无疑问,jmp指令是能够帮上忙的。接着咱们要解决的问题是如何计算出这两个函数的相对偏移量。这个问题咱们能够这样考虑, 但cpu碰到jmp的时候,它的执行动做为ip = ip + 5 + 相对偏移量。
为了更加直接的解释这个问题,咱们看看下面的额汇编函数(不熟悉汇编的同窗不用担忧, 这个函数没有干任何事情,只是作一个跳转)。
你也能够跟我一块儿来作,先写一个jump_test.s,定义了一个什么事情都没作的函数。
先看看汇编代码文件:(jump_test.s)翻译成C函数的话,就是void jump_test(){ return ; }。
.global _jump_test _jump_test: jmp jlable #!为了测试jmp指令偏移量,人为的给加几个nop nop nop nop jlable: rep;ret
接着,咱们在建立一个C文件:在这个文件里,咱们调用刚才建立的jump_test函数。
#include <stdio.h> extern void jump_test(); int main(){ jump_test(); }
最后就是编译连接了, 咱们建立一个build.sh生成可执行文件portal 。
#! /bin/sh cc -c -o main.o main.c as -o jump_test.o jump_test.s cc -o portal main.c jump_test.o
咱们使用 lldb 加载调试刚才生成的prtal文件,并把断点打在函数 jump_test 上。
lldb ./portal b jump_test r
在我机器上,是以下的跳转地址, 你的地址可能和个人不太同样,不过不要紧,这并不影响咱们的分析。
Process 22830 launched: './portal' (x86_64) Process 22830 stopped * thread #1, queue = 'com.apple.main-thread', stop reason = breakpoint 1.1 frame #0: 0x0000000100000f9f portal`jump_test portal`jump_test: -> 0x100000f9f <+0>: jmp 0x100000fa7 ; jlable 0x100000fa4 <+5>: nop 0x100000fa5 <+6>: nop 0x100000fa6 <+7>: nop
演示到这里的时候,咱们成功的从汇编的视角,看到了一些咱们想要的东西。
首先看看当前的 ip 是 0x100000f9f, 咱们汇编中使用的jlable此时已经被计算,变成了新的目标地址(0x100000fa7)。咱们知道,新的 ip 是经过当前 ip 加偏移算出来的, jmp的指令长度是5,前面咱们已经解释过了。因此咱们能够知道下面的关系:
new_ip = old_ip + 5 + offset;
把从 lldb 中获取的地址放进来,就变成了:
0x100000fa7 = 0x100000f9f + 5 + offset ==> offset = 3.
回头看看汇编代码, 咱们在代码中使用了三个nop, 每一个nop指令为1个字节, 恰好就是跳转到三个nop指令以后。作了个简单的验证以后,咱们把这个等式作个变形,因而获得 offset = new_ip - old_ip - 5; 当咱们知道 A函数和B函数以后,就很容易算出jmp的操做数是多少了。
讲到这里,函数的跳转思路就很是清晰了,咱们想在调用A的时候,实际跳转到B。好比咱们有个C api, 咱们但愿每次调用这个api的时候,实际上跳转到咱们自定义的函数里面, 咱们须要把这个api的前几个字节修改下,直接jmp到咱们本身定义的函数中。前5个字节第一个固然就是jmp的操做码了,后面四个字节是咱们计算出的偏移量。
最后给出一个完整的例子。汇编分析以及C代码一并打包放上来。
#include <stdio.h> #include <mach/mach.h> int new_add(int a, int b){ return a+b; } int add(int a, int b){ printf("my_add org is called!\n"); return 0; } typedef struct{ uint8_t jmp; uint32_t off; } __attribute__((packed)) tramp_line_code; void dohook(void *src, void *dst){ vm_protect(mach_task_self(), (vm_address_t)src, 5, 0, VM_PROT_ALL); tramp_line_code jshort; jshort.jmp = 0xe9; jshort.off = (uint32_t)(long)dst - (uint32_t)(long)src - 0x5; memcpy(my_add, (const void*)&jshort, sizeof(tramp_line_code)); vm_protect(mach_task_self(), (vm_address_t)src, 5, 0, VM_PROT_READ|VM_PROT_EXECUTE); } int main(){ dohook(add, new_add); int c = add(10, 20); //! 该函数默认实现是返回 0, hook以后,返回 30 printf("res is %d\n", c); return 0; }
编译脚本(系统 macOS):
gcc -o portal ./main.c 执行: ./portal 输出: res is 30
至此, 函数调用已经被成功转发了。