本篇是是上篇的续作,请先看上篇。
https://www.jianshu.com/p/0eb7238326f5
上一篇博客介绍了如何使用内联汇编给任意方法添加AOP切面,然后给了一个实现,文末曾提到最多仅支持6个显式(self,_cmd会占用前两个寄存器)非浮点的参数和8个浮点参数。虽然绝大部分情况下都是少于6个参数的,但是超过6个情况还是时有发生。以上只是其一,其二是对包含匿名参数的函数的支持,匿名参数都是存储在栈上的,比如:stringWithFormat:,第一个参数以后的那些匿名参数,因此还是需要提供对更多参数的支持。
如果对ARM64参数传递规则了解的就知道,大于8个参数会通过栈来传递。我大致画一个图就容易理解了。
![](https://img.haomeiwen.com/i2108339/27cbf382091da91f.png)
当前sp在最底下,x29到sp范围是当前调用函数所需要的所有暂存的空间,x29往上16个Byte保存的是上一次x29,30,当前函数调用完后恢复x29,30使用。再往上是上一次sp的地址,其存储的就是额外的参数。所以当前函数在调用的时候会去该位置(x29+0x10)获取额外的参数。
了解了这个再看看遇到的情况,原始调用函数A调用函数B在加入Swizzle的情况变成了A->Swizzle->...->B,所以此时函数B到同样的位置拿到的数据就不正确了。怎么办呢?一种是让B到A存参数的位置读取,这明显行不通,毕竟B代码已经编译完成,正常方法无法改变,Swizzle之后B无法知道A存参数的位置。第二种将A的放置的参数再拷贝一份到B需要的位置,换句话说就是建伪栈。大致的思路是有了,但可行性呢?
众所周知sp寄存器的重要性,我们在函数中定义的临时变量一般都需要sp+偏移量来读取写入,一旦随意改变,后果可想而知,所以我们必须在伪栈调用完后立即还原sp指针,而且存参数的位置也需要传递过来。解决了这个问题,还有第二个问题就是栈上到底存了多少参数,不需要知道其如何存储,只需要知道其大小。
NSMethodSignature的frameLength方法可以获取总共参数大小,然后我从二进制源码中发现frameLength-0xe0才是栈参大小。NSMethodSignature可以根据签名字符串构建,字符串可以从Method中获取,但频繁创建NSMethodSignature对象还是开销较大,这里我将其和Class,selector关联起来缓存。OK,原理大致如此。
ZWFrameLength
先实现frameLength获取
/* 0xe0是基础大小,其中包含9个寄存器共0x48,8浮点寄存器共0x80,还有0x18是额外信息,比如frameLength,
超过0xe0的部分为栈参数大小
*/
int ZWFrameLength(void **sp) {
id obj = (__bridge id)(*sp);
SEL sel = *(sp + 1);
Class class = object_getClass(obj);
if (!class || !sel) return 0xe0;
[_ZWLock lock];
NSMutableDictionary *methodSigns = _ZWAllSigns[NSStringFromClass(class)];
[_ZWLock unlock];
NSString *selName = class_isMetaClass(class) ? ZWGetMetaSelName(sel) : NSStringFromSelector(sel);
NSMethodSignature *sign = methodSigns[selName];
if (sign) {
return (int)[sign frameLength];
}
Method method = class_isMetaClass(class) ? class_getClassMethod(class, sel) : class_getInstanceMethod(class, sel);
const char *type = method_getTypeEncoding(method);
sign = [NSMethodSignature signatureWithObjCTypes:type];
[_ZWLock lock];
if (!methodSigns) {
_ZWAllSigns[NSStringFromClass(class)] = [NSMutableDictionary dictionaryWithObject:sign forKey:selName];
} else {
methodSigns[selName] = sign;
}
[_ZWLock unlock];
return (int)[sign frameLength];
}
函数实现还是比较简单的,class_getInstanceMethod调用,NSMethodSignature对象创建还是有一定开销的,特别是frameLength会并频繁调用,所以还是需要缓存一下。这里type作为Key来缓存性能应该也不差,特别是内存开销小很多,type一致,sign也就一致。但有一点需要注意,class_getInstanceMethod在类定义方法少的时候开销不大,开销较大的情况是Swizzle了大量父类的方法,特别是苹果提供的父类方法较多,所以如果使用这种方式Swizzle需要指明方法实现所在的类,可以减少循环。我这里空间换时间,按class和selector来映射。lock前后使用了两次,主要是为了减少临界区的大小,可以提高效率。
构造伪栈
接下来讲调用时候如何构造伪栈和注意事项
void ZWAopInvocation(void **sp, NSDictionary *Invocation, ZWInvocationOption option) {
id obj = (__bridge id)(*sp);
SEL sel = *(sp + 1);
if (!obj || !sel) return;
NSInteger count = ZWGetInvocationCount(Invocation, obj, sel);
__autoreleasing NSArray *arr = @[obj, [NSValue valueWithPointer:sel], @(option)];
NSInteger frameLenth = ZWFrameLength(sp) - 0xe0;
for (int i = 0; i < count; ++i) {
ZWGetAopImp(Invocation, obj, sel, i);
asm volatile("cbz x0, LZW_20181107");
asm volatile("mov x17, x0");
asm volatile("ldr x14, %0": "=m"(arr));
asm volatile("ldr x11, %0": "=m"(sp));
asm volatile("ldr x13, %0": "=m"(frameLenth));
asm volatile("cbz x13, LZW_20181110");
asm volatile("add x12, x11, 0xc0");
asm volatile("sub sp, sp, x13");//增长sp
asm volatile("bl _ZWCopyParams");
asm volatile("LZW_20181110:");
asm volatile("bl _ZWLoadParams");
asm volatile("mov x1, x14");
asm volatile("blr x17");
asm volatile("sub sp, x29, 0x1e0");//恢复sp
asm volatile("LZW_20181107:");
}
}
相比较于上篇博客的版本,函数上半部分是一样的,这里需要调用frameLength获取栈帧大小,这里使用NSInteger存储,其和寄存器大小一致,方便操作。
新增代码读取frameLength-0xe0的值到x13,x12=x11(sp的值)+0xc0。其中0xc0=0xb0+0x10,而0xb0为ZWGlobalOCSwizzle中栈的大小。如果x13=0,则直接跳到LZW_20181110,否则顺序执行。
接下来就是比较危险的操作sub sp, sp, x13
,将sp增长x13的长度,新增的空间就是我之前说的伪栈,此时,在sp未恢复之前,之前定义的C变量frameLenth,sp,arr等全部失效了,所以不要再使用这些变量了,当然实在是需要可以强行通过x29+偏移量来读取,只不过比较麻烦,需要知道这些临时变量的具体位置,再计算相对于x29的偏移量。
跳转ZWCopyParams,其作用是将x12指向的栈中参数全部复制到伪栈上。
不管是否调用ZWCopyParams都需要调用ZWLoadParams,加载所有的寄存器参数。
blr x17
跳转x17,调用函数。
恢复sp,这个时候x29寄存器还是有效的, 所有可以根据它恢复sp。
注意
0x1e0怎么来的,将代码全部汇编,去_ZWAopInvocation入口处找,看其开始将sp调整的大小,需要注意的是编译器开启优化后这个值可能会改变,比如我这里Xcode10.1开启Os级优化会变成0xa0,不同的版本的编译器,不同的优化等级可能该值都不一样,这个是我比较头疼的地方,该函数稍有改动就需要处理这个问题,所以这个库最好在指定的环境下编译成.a再使用(不开启优化生成.a库性能也不差,另外不知道为什么函数级别的优化关闭选项__attribute__((optnone))
无效,不然可以关闭Xcode对该函数的优化),实在不行可以通过Debug和Release环境来区分该值。我再想想看有没有别的办法解决这个问题,人不能被问题憋死,不要撞死胡同,总是有各种招可以解决的。
ZWCopyParams
OS_ALWAYS_INLINE void ZWCopyParams(void) {
//x12=原始栈参数地址,x13=frameLength-0x0e0
asm volatile("mov x15, sp");
asm volatile("LZW_20181108:");
asm volatile("cbz x13, LZW_20181109");
asm volatile("ldr x0, [x12]");
asm volatile("str x0, [x15]");
asm volatile("add x15, x15, #0x8");
asm volatile("add x12, x12, #0x8");
asm volatile("sub x13, x13, #0x8");
asm volatile("cbnz x13, LZW_20181108");
asm volatile("LZW_20181109:");
}
本函数接收x12,x13作为参数,将x12指向的栈中参数依次复制到伪栈,比较简单就不具体讲解了。
这里需要注意的是,寄存器的选择。这里我使用的是x9到x15这些寄存器,这些属于易失性寄存器,简单来说就是临时暂存,很容易被修改,不可靠。使用它们的好处是不用考虑将其之前存的数据转存到内容,使用完后还要恢复原数据。
x16-x17是调用暂存的,比如我这里就将函数入口存入x17,然后通过blr命令跳转的。x18是平台保留寄存器,一般用不上。
函数A{
返回值C = 函数B//存储在x19
函数D
函数E(C)//mov x0, x19
}
函数B{}
函数D{
使用x19之前需要将x19存储在内存中,之后再恢复
}
函数E(C){}
x19-x28是调用上下文暂存数据的,其是非易失性,具体来讲就是在函数若干范围内的,例如:我在函数A开始处调用了函数B获得了一个返回值C,函数A实现代码体接下来需要多次使用C,就可以将C存在x19-x28中,存在栈上也是可以的,但效率较差。然而如果在函数A的某处调用了函数D,D中也使用x19-x28中相同的寄存器,这就需要先暂存之前数据,之后再恢复,比较麻烦。
对于OC这类的动态调用语言,调用关系不固定的情况下,最好不要使用x0-x8,q0-q7之外的寄存器传参。我这里之所以使用x11,x12,x13这些易失性寄存器传参是因为调用关系简单且固定,调用上下文也不会有其他操作来破坏其内容(什么系统中断啥的,就不用我们操心了,其会保存上下文并恢复的),同时省下暂存原数据的操作。
ZWInvocation
void ZWInvocation(void **sp) {
__autoreleasing id obj;
SEL sel;
void *obj_p = &obj;
void *sel_p = &sel;
NSInteger frameLenth = ZWFrameLength(sp)- 0xe0;
asm volatile("ldr x11, %0": "=m"(sp));
asm volatile("ldr x10, %0": "=m"(obj_p));
asm volatile("ldr x0, [x11]");
asm volatile("str x0, [x10]");
asm volatile("ldr x10, %0": "=m"(sel_p));
asm volatile("ldr x0, [x11, #0x8]");
asm volatile("str x0, [x10]");
asm volatile("ldr x11, %0": "=m"(sp));
asm volatile("ldr x0, [x11]");
asm volatile("ldr x1, [x11, #0x8]");
asm volatile("bl _ZWGetOriginImp");
asm volatile("cbnz x0, LZW_20181105");
__autoreleasing NSArray *arr = @[obj, [NSValue valueWithPointer:sel], @(ZWInvocationOptionReplace)];
asm volatile("ldr x11, %0": "=m"(sp));
asm volatile("ldr x0, [x11]");
asm volatile("ldr x1, [x11, #0x8]");
asm volatile("bl _ZWGetCurrentImp");
asm volatile("cbz x0, LZW_20181106");
asm volatile("mov x17, x0");
asm volatile("ldr x14, %0": "=m"(arr));
asm volatile("ldr x11, %0": "=m"(sp));
asm volatile("ldr x13, %0": "=m"(frameLenth));
asm volatile("cbz x13, LZW_20181111");
asm volatile("add x12, x11, 0xc0");//0xb0 + 0x10
asm volatile("sub sp, sp, x13");
asm volatile("bl _ZWCopyParams");
asm volatile("LZW_20181111:");
asm volatile("bl _ZWLoadParams");
asm volatile("mov x1, x14");
asm volatile("blr x17");
asm volatile("sub sp, x29, 0x70");
asm volatile("b LZW_20181106");
asm volatile("LZW_20181105:");
asm volatile("mov x17, x0");
asm volatile("ldr x11, %0": "=m"(sp));
asm volatile("ldr x13, %0": "=m"(frameLenth));
asm volatile("cbz x13, LZW_20181112");
asm volatile("add x12, x11, 0xc0");
asm volatile("sub sp, sp, x13");//增长sp
asm volatile("bl _ZWCopyParams");
asm volatile("LZW_20181112:");
asm volatile("bl _ZWLoadParams");
asm volatile("blr x17");
asm volatile("sub sp, x29, 0x70");//恢复sp
asm volatile("LZW_20181106:");
}
本函数与ZWAopInvocation改动类似,这里就不作具体讲解了。
给出测试
- (void)viewDidLoad {
ZWAddAop(self, @selector(aMethod2::::::::), ZWInvocationOptionAfter, ^(NSArray *info, NSString *str ,NSString *a2 ,NSString *a3 ,NSString *a4 ,NSString *a5 ,NSString *a6 ,NSString *a7 ,NSString *a8){
NSLog(@"after2: %@\n%@\n%@\n%@\n%@", str, a5, a6, a7, a8);
});
ZWAddAop(self, @selector(aMethod2::::::::), ZWInvocationOptionReplace | ZWInvocationOptionOnly, ^(NSArray *info, NSString *str,NSString *a2 ,NSString *a3 ,NSString *a4 ,NSString *a5 ,NSString *a6 ,NSString *a7 ,NSString *a8){
NSLog(@"replace2 | after2: %@\n%@\n%@\n%@\n%@", str, a5, a6, a7, a8);
});
ZWAddAop(self, @selector(aMethod4::::::::), ZWInvocationOptionAfter, ^int (NSArray *info,NSInteger str, NSInteger a2, NSInteger a3, NSInteger a4, NSInteger a5, NSInteger a6, NSInteger a7, NSInteger a8){
NSLog(@"after43: %ld %ld %ld %ld %ld",str, a5, a6, a7, a8);
return 11034;
});
[self aMethod2:@"test str" :@"this is a test" :@"this is a test":@"this is a test":@"this is a test":@"this is a test":@"this is a test a7":@"this is a test a8"];
[self aMethod4:1 :2 :3 :4 :5 :6 :7 :8];
}
- (void)aMethod2:(NSString *)str :(NSString *)a2 :(NSString *)a3 :(NSString *)a4 :(NSString *)a5 :(NSString *)a6 :(NSString *)a7 :(NSString *)a8 {
NSLog(@"method2: %@\n%@\n%@\n%@\n%@", str, a5, a6, a7, a8);
}
- (void)aMethod4:(NSInteger)str :(NSInteger)a2 :(NSInteger)a3 :(NSInteger)a4 :(NSInteger)a5 :(NSInteger)a6 :(NSInteger)a7 :(NSInteger)a8 {
NSLog(@"method4: %ld %ld %ld %ld %ld",str, a5, a6, a7, a8);
}
2018-11-23 14:54:56.943007+0800 DEMO[12814:3984030] replace2 | after2: test str
this is a test
this is a test
this is a test a7
this is a test a8
2018-11-23 14:54:56.943074+0800 DEMO[12814:3984030] after2: test str
this is a test
this is a test
this is a test a7
this is a test a8
2018-11-23 14:54:56.943584+0800 DEMO[12814:3984030] method4: 1 5 6 7 8
2018-11-23 14:54:56.943607+0800 DEMO[12814:3984030] after43: 1 5 6 7 8
总结
之所以费力的做AOP,是因为这货确实好用。例如:debug,日志输出,代码优化,非侵入式埋点,网络接口记录等等。之后如果有时间我会将代码再优化,增加些必要功能,提高其效率和可靠性。
最后聊几句:使用内联汇编,可以结合高级语言和低级语言两者的优点,低级语言完成高级语言无法完成的工作,提高效率,高级语言则减少开发难度,从这个角度看确实是很棒。
呵呵,一切听上去挺美故事总有曲折可怖的过程。两者混合使用,各自的缺点也就糅在一起了。汇编书写易出错,C/OC会生成额外的复杂操作,稍微操作不当,程序就Crash了,一脸懵逼,而且错误难以排查,如果要增加功能,修改代码也比较麻烦,很多东西都是写死的(不写死就意味着更多的工作量,同时内联汇编不支持汇编宏,当然宏还是支持的,这倒是可以简化不少代码)。所以一般只有在必须使用的时候才使用,比如极致的效率优化,完成后不怎么修改的库。随随便便嵌入汇编,就是自己给自己挖坑,当然要是有特殊用途🙂🙂🙂,比如吹牛逼,整人啥的,就多多益善了。因此内联汇编谨慎使用,书写的时候要遵循两者的机制,如果不知道C/OC背地里偷偷干了什么就将其汇编排查,这可以解决很多问题。
补充1:目前后续优化了部分代码,开销减少到之前1/3以下。同一方法重复百万次调用成本0.9s左右(iPhone6s,iOS12),单次成本不到1微秒,第一次调用没有缓存开销是后续有缓存的50倍左右。目前AOP调用过程的主要开销为字典的查询(查询frameLength,查询AOP调用),当然字典查询插入的效率已经很高了,如果还需要大幅提高性能可能就需要自定义容器,或者改变实现原理。简单的测试了一下,优化后效率是Aspect的20倍左右(Xcode调试模式,空调用的情况下,Aspect 十万次调用大概在1.6-2.0s,本方案0.085-0.1s),单次成本百万分之一秒,让AOP在大规模使用的时候也不会成为性能瓶颈。内存开销比较小,除了存储AOP,就只有frameLength缓存,最多也就几十KB。目前调用过程中的锁在多线程竞争的情况下可能会造成开销增大,必要时可以优化。
补充2:之前只是在保证基本原理不动的基础上,通过减少对象创建,使用Tagged Pointer对象,重复利用中间数据等等,将效率优化到3倍。说白了还是因为懒,动原理层就意味着改变大量改动和汇编改动,比较麻烦,这次勤快了一下,又优化了一个版本,更新了部分实现方案原理,效率又提升了几倍了。
这次优化办法包括以下内容:
1、自定义切面结构体,将切面调用存储在该数组中,减少数组创建和访问带来的额外操作。2、使用OC拟对象,将所切面调用和调用信息等存储在同一结构体,并定义isa指针指向NSObject。拟对象是我定义的概念,其没有直接的class模板,直接通过malloc分配,强行将isa指向NSObject(这会比alloc+init快),并修改nonpointer值,表示这不是一个纯指针,撸苹果objc源码可以了解不修改该值,调用retain会触发sidetable_retain比较耗费性能,release同理。这种结构体对象会拥有一些OC对象的性质,比如在这里可以使用__bridge id
转换成id类型放入NSDictionary,这就是我想要的效果之一。
3、一次查询多次使用,将字典拟对象查出后传递给后面所有函数(还好汇编改动不大,不然调试就费事了)。
4、frameLength处理,其需要缓存没什么说的,但第一次获取还是有优化的空间,这里有几种处理方法:A、参数少于6个,可以直接认为frameLength=0xe0(至于为什么几句话说不清楚);B、写一个frameLength预估函数,通过签名预估是否frameLength=0xe0,预估失败则使用耗时NSMethodSignature来获取;C、将问题抛给使用者。目前使用第一种,优化的话可以使用第二种。
最后存在的问题:在使用结构体容器的情况下,如果切面调用过程中,正好调用了remove,这会导致crash,所以需要使用类似于OC对象计数器机制来延迟释放。嘿嘿,给isa指针就可以像OC对象一样直接使用retain,release,甚至于autorelease机制了。看着很稀奇,实际上就跟C/C++中malloc一段内容,使用struct或者class模板解析是一码事,只不过这里是OC class,而且只关注isa,不解析具体内容。但还是要提醒一下这种拟对象和真正OC的对象还是有差异的,差异应该在isa指针部分位域的差异,这是通过malloc无法直接初始化的,如果对isa的每一个逻辑细节都清楚可以尝试手动修改。
最后说一下成果:目前相较于初版性能提升10+倍,比Aspect高大概100倍。主要耗时在三个地方,字典查询(40%),加解锁(20%),retain+release操作(15%)。如果想要再优化就只能针对它们入手了。当然已经算是快到飞起了,再优化的意义其实不大了,除非再有个两三倍的提高。
目前我已经将retain+release操作基本替换为手动管理方式,性能消耗可以忽略了。加解锁为互斥锁在并发的时候性能下降到1/3,换成读写锁后会少一半的开销。
性能高度优化版
Github源码地址
网友评论