在iOS底层之cache_t探究分析了方法写入缓存的流程。类似于数据库的增删改查,OC对象在调用方法的时候,在写入缓存之前,也会先执行查找缓存的过程。下面我们来分析objc_msgSend
消息发送过程中是怎么查找方法缓存的。
首先,我们先了解下Runtime
的机制。
Runtime
Runtime
,顾名思义,即运行时,区别于编译时。
- 编译时:就是正在编译的时刻。 而编译,就是编译器把
源代码翻译成机器能识别的代码(实际上可能只是翻译成某个中间状态的语言)。编译时,做了一些检查、翻译的工作。会检查你的关键字、词法、语法,如果发现了错误,就会编译不通过,提示错误信息。这个过程也会进行静态类型分析,这时候只是扫描代码而已,并没有真正放到内存中运行,也不存在分配内存。 - 运行时:将代码运行起来,装载到内存中去。而运行时做的类型检查就跟编译时类型检查(静态类型检查)不一样,不是简单的扫描代码,会将其运行在内存中做些判断和操作。例如我们通过使用
performSelector
给对象发送执行方法选择器的消息的方式,就是运行时的操作。
Runtime
的使用方式有三种:
-
Objective-C
,例如[person eat];
,类的实例对象调用方法; - 通过框架和接口引入,比如
isKindOf
; - 通过
Runtime API
,比如class_getInstanceSize
。
其层级可以表示为
也就是Runtime
通过编译层到底层库,比如alloc
方法是通过发送消息在LLVM
编译阶段以objc_alloc
实现的。
OC运行时
#import
#import
@interface BKPerson : NSObject
- (void)sayHello;
@end
@implementation BKPerson
- (void)sayHello{
NSLog(@"Hello!");
}
@end
@interface Man : BKPerson
- (void)sayHello;
- (void)sayBye;
@end
@implementation Man
- (void)sayBye{
NSLog(@"88");
}
@end
定义两个类,Man
继承于BKPerson
。
将下列方法Clang
编译成.cpp
文件,可以看到
Man *man = [Man alloc];
[man sayBye];
[man sayHello];
//.cpp
Man *man = ((Man *(*)(id, SEL))(void *)objc_msgSend)((id)objc_getClass("Man"), sel_registerName("alloc"));
((void (*)(id, SEL))(void *)objc_msgSend)((id)man, sel_registerName("sayBye"));
((void (*)(id, SEL))(void *)objc_msgSend)((id)man, sel_registerName("sayHello"));
可以发现,实例对象调用方法,本质是通过objc_msgSend
实现的。
为了验证这个发现。以objc_msgSend方法
方式来调用[man sayBye]
。
Man *man = [Man alloc];
objc_msgSend(man, sel_registerName("sayBye"));
[man sayBye];
打印如下,两种方式打印的结果一致,验证成功。
拓展:
1、调用objc_msgSend
,需要导入头文件#import
2、使用obc_msgSend
方法时,编译器会有参数错误警告。 需要将target
-->Build Setting
-->搜索msg
-- 将enable strict checking of obc_msgSend calls
由YES
改为NO
,将严厉的检查机制关掉。
实例方法调用机制初探
@interface BKPerson : NSObject
- (void)sayHello;
@end
@implementation BKPerson
- (void)sayHello{
NSLog(@"Hello!");
}
@end
@interface Man : BKPerson
- (void)sayHello;
- (void)sayBye;
@end
@implementation Man
- (void)sayBye{
NSLog(@"88");
}
上面的代码,Man
继承自BKPerson
,BKPerson
实现了sayHello
方法,Man
却没有实现sayHello
方法。
消息的接收者都是Man
的实例对象,分别使用OC
对象调用和C语言
的API
调用的方式:
- OC中对象调用:
Man *man = [Man alloc];
BKPerson *person = [BKPerson alloc];
[man sayHello];
- 直接调用底层
C
的API
:
struct objc_super mansuper;
mansuper.receiver = man;
mansuper.super_class = [BKPerson class];
objc_msgSendSuper(&mansuper, sel_registerName("sayHello"));
objc_msgSendSuper
的定义:
OBJC_EXPORT id _Nullable
objc_msgSendSuper(struct objc_super * _Nonnull super, SEL _Nonnull op, ...)
OBJC_AVAILABLE(10.0, 2.0, 9.0, 1.0, 2.0);
struct objc_super {
/// Specifies an instance of a class.
__unsafe_unretained _Nonnull id receiver;
/// Specifies the particular superclass of the instance to message.
#if !defined(__cplusplus) && !__OBJC2__
/* For compatibility with old objc-runtime.h header */
__unsafe_unretained _Nonnull Class class;
#else
__unsafe_unretained _Nonnull Class super_class;
#endif
/* super_class is the first class to search */
};
在__OBJC2__
需要指定receiver
和super_class
。
执行打印结果:
Man并没有实现
sayHello
,却不会崩溃,而且两种方式都打印出了方法里的信息。猜测原因在于其父类实现了sayHello
。
那么可猜想,类的实例方法调用的查找是会向父类查找。而方法调用的本质是
objc_msgSend
发送消息,那么它是怎么通过调用的方法编号sel
找到imp
函数地址指针,从而找到函数内容的呢?
带着这个疑惑我们来查看
objc_msgSend
的流程。
objc_msgSend快速查找流程
首先要知道objc_msgSend
是通过汇编语言写的,区别于C/C++
实现的源码,优势是汇编快,由于方法查找是经常发生的高频率的事务,所以如果能提高一点点速度,那么在运行中进程的量级上是一个质的飞跃。另外一点是为了实现参数的不确定性(动态性),而C/C++
大多使用静态方法,要实现动态性则麻烦得多。
objc_msgSend
消息的接收者是对象,对象和方法的关系是:对象 ->ISA
-> 类(元类)-> class->data()->methods()
。由于会先查找方法缓存,会从类(元类)->cache_t -> buckets()查找缓存bucket_t,这一步称为objc_msgSend快速查找。
第一步.获取isa的类信息
在objc4-781
源码中,搜索objc_msgSend
,在arm64.s
后缀的文件中查找objc_msgSend
汇编实现。入口函数为
其汇编实现如下:
//消息发送汇编入口:这一步主要获取isa类信息
ENTRY _objc_msgSend
//无窗口化
UNWIND _objc_msgSend, NoFrame
//p0存放是objc_msgSend的第一个参数-消息接收者receiver,receiver和空对比,判断消息接收者是否存在
cmp p0, #0 // nil check and tagged pointer check
//是否支持小对象类型,在arm64架构下,恒为true
#if SUPPORT_TAGGED_POINTERS
//1.支持则走小对象流程
b.le LNilOrTagged // (MSB tagged pointer looks negative)
#else
//0.不支持则返回空
b.eq LReturnZero
#endif
//p13存放isa
ldr p13, [x0] // p13 = isa
//从p13的isa中获取class,放在p16
GetClassFromIsa_p16 p13 // p16 = class
LGetIsaDone://获取isa完毕
// calls imp or objc_msgSend_uncached
//开始从缓存中获取imp地址(CacheLookup方法参数为NORMAL)
CacheLookup NORMAL, _objc_msgSend
#if SUPPORT_TAGGED_POINTERS
LNilOrTagged://小对象流程
//判断为空则返回空
b.eq LReturnZero // nil check
// tagged
adrp x10, _objc_debug_taggedpointer_classes@PAGE
add x10, x10, _objc_debug_taggedpointer_classes@PAGEOFF
ubfx x11, x0, #60, #4
ldr x16, [x10, x11, LSL #3]
adrp x10, _OBJC_CLASS_$___NSUnrecognizedTaggedPointer@PAGE
add x10, x10, _OBJC_CLASS_$___NSUnrecognizedTaggedPointer@PAGEOFF
cmp x10, x16
b.ne LGetIsaDone
// ext tagged
adrp x10, _objc_debug_taggedpointer_ext_classes@PAGE
add x10, x10, _objc_debug_taggedpointer_ext_classes@PAGEOFF
ubfx x11, x0, #52, #8
ldr x16, [x10, x11, LSL #3]
b LGetIsaDone
// SUPPORT_TAGGED_POINTERS
#endif
LReturnZero:
// x0 is already zero
mov x1, #0
movi d0, #0
movi d1, #0
movi d2, #0
movi d3, #0
ret
END_ENTRY _objc_msgSend
GetClassFromIsa_p16
获取类信息的汇编实现:
.macro GetClassFromIsa_p16 /* src */
//是否支持INDEXED_ISA
#if SUPPORT_INDEXED_ISA
//# if __ARM_ARCH_7K__ >= 2 || (__arm64__ && !__LP64__)
// 仅支持armv7k or arm64_32
// Indexed isa
//将isa指针存入p16
mov p16, $0 // optimistically set dst = src
//判断是否是 non-pointer isa
tbz p16, #ISA_INDEX_IS_NPI_BIT, 1f // done if not non-pointer isa
// isa in p16 is indexed
//将_objc_indexed_classes所在页的基址 读入x10寄存器
adrp x10, _objc_indexed_classes@PAGE
//x10 = x10 + _objc_indexed_classes(偏移量),对x10基址根据偏移量进行内存偏移
add x10, x10, _objc_indexed_classes@PAGEOFF
//将p16的isa从第ISA_INDEX_SHIFT位(第2位)开始,提取 ISA_INDEX_BITS 位(15位) 到 p16寄存器,剩余的高位用0补充
ubfx p16, p16, #ISA_INDEX_SHIFT, #ISA_INDEX_BITS // extract index
ldr p16, [x10, p16, UXTP #PTRSHIFT] // load class from array
1:
//arm64架构64位处理器
#elif __LP64__
// 64-bit packed isa
//p16的isa & ISA_MASK 得到class存到p16
and p16, $0, #ISA_MASK
#else
// 32-bit raw isa
mov p16, $0
#endif
.endmacro
主要流程是:
- 获取
isa
指针,判断是否为空,空则返回。 - 非空时判断是否支持
tagged pointer
小对象类型,支持则走小对象流程。不支持则返回空。 - 之后获取
isa
中的类信息。通过isa & ISA_MASK
获取bits
的shiftcls
位域的类信息class
。
第二步.CacheLookup查找缓存
#define CACHE (2 * __SIZEOF_POINTER__)
.macro CacheLookup
//
// Restart protocol:
//
// As soon as we're past the LLookupStart$1 label we may have loaded
// an invalid cache pointer or mask.
//
// When task_restartable_ranges_synchronize() is called,
// (or when a signal hits us) before we're past LLookupEnd$1,
// then our PC will be reset to LLookupRecover$1 which forcefully
// jumps to the cache-miss codepath which have the following
// requirements:
//
// GETIMP:
// The cache-miss is just returning NULL (setting x0 to 0)
//
// NORMAL and LOOKUP:
// - x0 contains the receiver
// - x1 contains the selector
// - x16 contains the isa
// - other registers are set as per calling conventions
//
LLookupStart$1:
// p1 = SEL, p16 = isa
//从isa平移16字节到cache_t
ldr p11, [x16, #CACHE] // p11 = mask|buckets
#if CACHE_MASK_STORAGE == CACHE_MASK_STORAGE_HIGH_16
//p11 & 0x0000ffffffffffff 取出buckets放在p10
and p10, p11, #0x0000ffffffffffff // p10 = buckets
//将p11右移48位,得出mask,p1(_cmd) & mask取出了缓存的下标 放在p12
and p12, p1, p11, LSR #48 // x12 = _cmd & mask
#elif CACHE_MASK_STORAGE == CACHE_MASK_STORAGE_LOW_4
and p10, p11, #~0xf // p10 = buckets
and p11, p11, #0xf // p11 = maskShift
mov p12, #0xffff
lsr p11, p12, p11 // p11 = mask = 0xffff >> p11
and p12, p1, p11 // x12 = _cmd & mask
#else
#error Unsupported cache mask storage for ARM64.
#endif
//buckets首地址向左平移 _cmd下标 * 1<<4(16),得到所在bucket,放在p12
add p12, p10, p12, LSL #(1+PTRSHIFT)
// p12 = buckets + ((_cmd & mask) << (1+PTRSHIFT))
//从p12拿到imp、sel,存入p17、p9
ldp p17, p9, [x12] // {imp, sel} = *bucket
//比较p9上的sel是否等于p1的cmd
1: cmp p9, p1 // if (bucket->sel != _cmd)
//如果不相等,则跳到2f
b.ne 2f // scan more
//相等,则缓存命中,返回imp
CacheHit $0 // call or return imp
//2f:如果没有命中就走到这里
2: // not hit: p12 = not-hit bucket
//判断bucket的sel是否为空,空则执行CheckMiss,因为是NORMAL类型,所以__objc_msgSend_uncached,表示找不到缓存
CheckMiss $0 // miss if bucket->sel == 0
//比较p12的bucket和p10的buckets的首地址,也就是第一个bucket,也就是判断当前的bucket是否是第一个
cmp p12, p10 // wrap if bucket == buckets
//相等,则跳转第三步
b.eq 3f
//从p12的地址向前平移一个bucket的size,得到的bucket的imp和sel分别存入p17,p9
ldp p17, p9, [x12, #-BUCKET_SIZE]! // {imp, sel} = *--bucket
//重复第一步,对比sel和cmd
b 1b // loop
//如果计算的下标是在第一个,则执行第三步
3: // wrap: p12 = first bucket, w11 = mask
#if CACHE_MASK_STORAGE == CACHE_MASK_STORAGE_HIGH_16
//p11(mask|buckets)右移44位,相当于mask左移4位,也就是mask * 16, 而mask是buckets最后一个元素的下标,16为存储着sel和imp的bucket的size,这一步相当于将地址平移到最后一个bucket,将该bucket存储于p12,缓存查找顺序是向前查找
add p12, p12, p11, LSR #(48 - (1+PTRSHIFT))
// p12 = buckets + (mask << 1+PTRSHIFT)
#elif CACHE_MASK_STORAGE == CACHE_MASK_STORAGE_LOW_4
add p12, p12, p11, LSL #(1+PTRSHIFT)
// p12 = buckets + (mask << 1+PTRSHIFT)
#else
#error Unsupported cache mask storage for ARM64.
#endif
// Clone scanning loop to miss instead of hang when cache is corrupt.
// The slow path may detect any corruption and halt later.
//将最后的bucket的imp、sel分别放在p17、p9
ldp p17, p9, [x12] // {imp, sel} = *bucket
//--- 比较p9的sel和传入参数cmd是否一致
1: cmp p9, p1 // if (bucket->sel != _cmd)
//--- 不一致则跳到步骤2f(继续往前查找)
b.ne 2f // scan more
//--- 一致则命中,返回imp
CacheHit $0 // call or return imp
2: // not hit: p12 = not-hit bucket
//--- 判断是否为空,空则执行CheckMiss里的($0 == NORMAL)分支的指令__objc_msgSend_uncached,未命中缓存
CheckMiss $0 // miss if bucket->sel == 0
//--- 比较p12和第一个bucket p10 是否一致,也就是判断是否是当前比较的是第一个bucket了,再往前已经没有了
cmp p12, p10 // wrap if bucket == buckets
//--- 如果是第一个,则跳到3f
b.eq 3f
//--- 不是第一个,则向前平移BUCKET_SIZE,找前一个bucket,imp、sel分别放入p17,p9
ldp p17, p9, [x12, #-BUCKET_SIZE]! // {imp, sel} = *--bucket
//--- 返回第一步,继续对比sel和cmd
b 1b // loop
LLookupEnd$1:
LLookupRecover$1:
3: // double wrap
//--- 跳转到jumpMiss,因为是normal,跳转至__objc_msgSend_uncached,表示缓存没找到
JumpMiss $0
.endmacro
// 以下是跳转的函数
// CacheHit: x17 = cached IMP, x12 = address of cached IMP, x1 = SEL, x16 = isa
.macro CacheHit
.if $0 == NORMAL
TailCallCachedImp x17, x12, x1, x16 // authenticate and call imp
.elseif $0 == GETIMP
mov p0, p17
cbz p0, 9f // don't ptrauth a nil imp
AuthAndResignAsIMP x0, x12, x1, x16 // authenticate imp and re-sign as IMP
9: ret // return IMP
.elseif $0 == LOOKUP
// No nil check for ptrauth: the caller would crash anyway when they
// jump to a nil IMP. We don't care if that jump also fails ptrauth.
AuthAndResignAsIMP x17, x12, x1, x16 // authenticate imp and re-sign as IMP
ret // return imp via x17
.else
.abort oops
.endif
.endmacro
.macro CheckMiss
// miss if bucket->sel == 0
//--- $0:传入的第一个参数,也就是方法执行的类型,根据不同的类型,执行不同指令
.if $0 == GETIMP
cbz p9, LGetImpMiss
.elseif $0 == NORMAL
cbz p9, __objc_msgSend_uncached
.elseif $0 == LOOKUP
cbz p9, __objc_msgLookup_uncached
.else
.abort oops
.endif
.endmacro
.macro JumpMiss
.if $0 == GETIMP
b LGetImpMiss
.elseif $0 == NORMAL
b __objc_msgSend_uncached
.elseif $0 == LOOKUP
b __objc_msgLookup_uncached
.else
.abort oops
.endif
.endmacro
这一步是查找缓存,CacheLookup
传入的类型为normal
,64
位处理器下的主要流程为:
- 从
isa
平移16
字节获取到cache_t
,cache_t
的首地址存放着mask|buckets
(32
位处理器是buckets
),放在一个8
字节,将mask|buckets
&
0x0000ffffffffffff
得到了buckets
,mask|buckets
右移48
位,得到mask
,通过cmd & mask
取出了缓存bucket
的下标,cmd(sel) & mask
的算法是在方法缓存的时候计算哈希下标的算法,所以查找缓存也是用这个算法。
static inline mask_t cache_hash(SEL sel, mask_t mask)
{
return (mask_t)(uintptr_t)sel & mask;
}
buckets + ((_cmd & mask) << (1+PTRSHIFT))
将buckets
首地址向后平移下标*16
。算出所在的下标的bucket
。因为sel
和imp
各占8
字节,即一个bucket
的size
为16
字节。这样得到了bucket
的imp
和sel
。
- 步骤1:比较这个
bucket
的sel
是否等于我们传入的cmd
,如果不相等,则跳到步骤2,相等则缓存命中,返回bucket
的imp
。 - 步骤2:判断当前
bucket
的sel
是否为空,空则执行__objc_msgSend_uncached,表示这个方法没有缓存。非空,则判断bucket
是不是第一个bucket
,是第一个则跳转到步骤3,否则将bucket
向前平移一个BUCKET_SIZE
,找前面的一个bucket
,返回执行步骤1。 - 步骤3:如果是第一个
bucket
,将mask|buckets
右移44
位,相当于mask
左移4
位,也就是mask * 16
, 而mask
是buckets
最后一个元素的下标,16
为存储着sel
和imp
的bucket
的size
,这一步相当于将地址平移到最后一个bucket
,获得最后一个bucket
的sel
和imp
,这步骤3里也分了3个分支。
- 注意这里是双层嵌套,下面是第二层
- 步骤3分支1:比较这个
bucket
的sel
是不是等于传入的cmd
,不一致跳到分支2,一致则缓存命中,返回imp。 - 步骤3分支2:如果是从最后一个元素遍历过来的,当前
bucket
的sel
是0
,也就是这个槽没有缓存,则执行CheckMiss
,因为是NORMAL
类型,所以__objc_msgSend_uncached
,表示找不到缓存。如果sel
不是0
,判断这个bucket
是不是第一个bucket
,是的话就跳转到分支3,不是第一个,那么向前平移16
字节获取前面一个bucket
,获取它的sel
和imp
,返回执行分支1。 - 步骤3分支3:跳转到
jumpMiss
,因为是NORMAL
,跳转至__objc_msgSend_uncached
,表示缓存没找到。
如果经历CacheLookup
后没找到缓存,则会开始慢速查找,从methodList
查找。
整个快速查找流程图: