阿里技术:

导读:Aspect使用了OC的消息转发流程,有一定的性能消耗。本文作者使用C++设计语言,并使用libffi进行核心trampoline函数的设计,实现了一个iOS AOP框架——Lokie。相比于业内熟知的Aspects,性能上有了明显的提升。本文将分享Lokie的具体实现思路

前言

不自觉的想起自己从业的这十几年,如白驹过隙。现在谈到上还熟悉的的语言以ASM/C/C++/OC/JS/Lua/Ruby/Shell等为主,其他的基本上都是用时拈来过时忘,语言这种东西变化是在太快了, 不过大体换汤不换药,我感觉近几年来所有的语言隐隐都有一种大统一的走势,一旦有个特性不错,你会在不同的语言中都找到这种技术的影子。所以我对使用哪种语言并不是很执着,不过C/C++是信仰罢了 : )

L****okie

工作中大部分用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无锁同步的技术,对于线程同步开销也降低了不少。

通过一些真机的性能数据来看,以iPhone 7P为例, Aspect百万次调用消耗为6s左右,而相同场景Lokie开销仅有0.35s左右, 从测试数据上来看,性能提升还是非常显著的。

我是个急性子,看书的时候也是喜欢先看代码。

喜欢翻代码的同学可以先去看看。

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


这两个方法的参数是一样的,提供了对类方法和成员方法的切片化支持。

selector_name:是你感兴趣的selector名称,通常我们可以通过NSStringFromSelector 这个API来获取。

block:是要具体执行的命令,block的参数和返回值我们稍后讨论。

policy:指定了想要在该selector执行前,执行后执行block,或者是干脆覆盖原方法。

监控效果

拿一个场景来看看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。

SEL:

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

IMP的定义如下所示:

#if !OBJC_OLD_DISPATCH_PROTOTYPEStypedef void (*IMP)(void /* id, SEL, ... */ ); #elsetypedef 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函数的实现

接下来的内容,我们简单介绍下,从汇编的视角出发,如何实现一个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函数。

最后就是编译链接了, 我们创建一个build.sh生成可执行文件portal 。

#! /bin/shcc -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./portalbjump_testr

在我机器上,是如下的跳转地址, 你的地址可能和我的不太一样,不过没关系,这并不影响我们的分析。

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_testportal`jump_test:-> 0x100000f9f <+0>: jmp 0x100000fa7 ; jlable 0x100000fa4 <+5>: nop 0x100000fa5 <+6>: nop 0x100000fa6 <+7>: nop


把从 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-oportal./main.c执行:./portal输出:resis30

至此, 函数调用已经被成功转发了。



更多相关文章

  1. 如何把C++的源程序改写成C语言
  2. 开发中总结的dart相关的技巧
  3. Go 1.17新特性详解:使用基于寄存器的调用惯例
  4. 运算符 赋值运算符 字符串函数及自定义函数
  5. 解构赋值 、函数参数中使用解构的方式、 dom元素的增删改查、 da
  6. php常用函数练习
  7. if 函数进阶及逻辑函数与 switch 的初步练习
  8. 如何通过PHP来识别不唯一的电子邮件地址
  9. PHP怎么通过给定符号来拆解字符串

随机推荐

  1. 图片在页面内随意飘动,遇到边界还会反弹
  2. 如何判断字符串是一个字符串化的JSON对象
  3. 可以使用不同文本框编辑的不同弹出窗口?
  4. JavaScript实现简单的四则运算
  5. 如何在JavaScript / jQuery中获取对象的
  6. Android平台上的QWebView HTML5地理定位
  7. 如何使用变量创建数组?
  8. 迭代angularjs中对象中的属性列表
  9. js和php时间戳的问题
  10. 如何在Safari浏览器中禁用Ajax缓存?