前言
在上一篇iOS探索底层-cahce_t初探文章中,我们探索了cache_t
的基本结构和怎么去插入数据的,今天这篇文章,我们继续探索cache_t
缓存的数据,是什么时候读取的。在源码中,全局搜索插入方法cache_t::insert
我们会发现这样一些注释
可以看到苹果的注释写的很明白了,缓存的读取使用的方法是objc_msgSend
和cache_getImp
两个方法,在探索他们之前,我们先来补充一些运行时的知识
Runtime运行时
编译时
顾名思义就是正在编译
的时候。那么什么叫做编译
呢?实际上就是编译器把源代码翻译成机器能够识别的代码(Ps.当然只是一般意义上,实际上可能只是翻译成某个中间态的语言,例如汇编)。
那编译时
就是简单的做一些翻译工作,比如检查是否存在错误的关键字、词法分析、语法分析之类的过程,其实就是静态的检查你有没有错别字和语病。我们常用的Xcode,在编译的时候出现警告和错误,都是编译器检查出来的。这种错误我们称之为编译时错误,这个过程中的类型检查也就叫做编译时类型检查或者静态类型检查。所以有时一些人说编译时还分配内存什么的,肯定是错误的!
运行时
就是代码运行起来了,已经加载进了内存中。而运行时类型检查就跟编译时的类型检查(静态类型检查)不一样了。并不是简单的直接扫描代码有没有问题,而是在内存中做了一些操作、一些判断。
Runtime
有两个版本:
- 早起版本对应的编程接口:
Objective-C 1.0
,用于32位的Mac OS X平台
上; - 现行版本对应的编程接口:
Objective-C 2.0
,用于iPhone程序
和Mac OS X v10.5及以后
的系统中的64位程序 (参考来源Objective-C Runtime Programming Guide)
三种Runtime的发起方式
- 通过OC的方法调用:例如
[p sayBye]
- 通过NSObject提供的api:例如
isKindofClass
- 通过底层提供的api:例如
class_getInstanceSize
接下来我们举个🌰,继续拿出我们的老朋友DMPerson
#import <Foundation/Foundation.h>
#import <objc/message.h>
@interface DMPerson : NSObject
- (void)sayBye;
- (void)eat;
@end
@implementation DMPerson
- (void)sayBye {
NSLog(@"bye");
}
@end
int main(int argc, const char * argv[]) {
@autoreleasepool {
// insert code here...
DMPerson *p = [[DMPerson alloc] init];
[p sayBye];
[p eat];
}
return 0;
}
首先这里DMPerson
申明了两个方法sayBye
和eat
,然后我们运行它
libc++abi: terminating with uncaught exception of type NSException
*** Terminating app due to uncaught exception 'NSInvalidArgumentException', reason: '-[DMPerson eat]: unrecognized selector sent to instance 0x10072c740'
terminating with uncaught exception of type NSException
理所当然的,它崩溃了,因为我们并没有实现eat
方法,在编译时类型检查我们并没有问题,但是运行时检查的时候,发现并没有实现eat
方法,因此抛出异常。接下来我们使用clang
转换成底层C/C++代码来看看方法调用时候的实现。
int main(int argc, const char * argv[]) {
/* @autoreleasepool */ { __AtAutoreleasePool __autoreleasepool;
DMPerson *p = ((DMPerson *(*)(id, SEL))(void *)objc_msgSend)((id)((DMPerson *(*)(id, SEL))(void *)objc_msgSend)((id)objc_getClass("DMPerson"),
sel_registerName("alloc")), sel_registerName("init"));
((void (*)(id, SEL))(void *)objc_msgSend)((id)p, sel_registerName("sayBye"));
((void (*)(id, SEL))(void *)objc_msgSend)((id)p, sel_registerName("eat"));
}
return 0;
}
从底层代码中,我们可以很明显的看到我们在OC
中的方法调用,在底层中最后都转换成了objc_msgSend
这么一个方法,因此我们得出了一个结论
OC方法的调用实质上是消息发送的流程
了解了其本质以后,我们尝试使用objc_msgSend
去调用方法,将eat
方法进行实现,同时修改Main
函数中的代码如下
发现编译器给我们报了个一个错误,objc_msgSend
方法的参数太多了,我们下面进行编译条件的修改
将
Build Settings
中的Enable Strict Checking of objc_msgSend Calls
改为NO
,默认为YES
;在真机和M1电脑的arm64架构下,修改参数也没用,因此直接进行强制类型转换,将
objc_msgSend
转化成((void (*)(id, SEL))(void *)objc_msgSend)
.
进行修改后,我们来看看打印结果
成功的打印出来了,说明使用这种方式,我们也能够成功的调用方法。
方法的快速查找流程
竟然系统底层是通过objc_msgSend
来调用方法的,那么我们你就来重点研究下,他是怎么去调用我们的方法的,打开源码全局搜索objc_msgSend
我们可以看到在22个文件中有644个结果,刚刚我们在使用objc_msgSend
的过程中,是需要导入头文件message.h
的,于是我们在里面先寻找,结果发现只有函数的申明,并没有函数的实现,那么就只能在更底层的地方去找了,最后我们锁定到了objc-msg-arm64.s
文件中,以.s
结尾的都是汇编写的文件,而arm64
是我们的真机架构
在其中最后找到了ENTRY _objc_msgSend
也就是进入_objc_msgSend
方法,于是顺着往下看下去,我们一行一行来分析他
ENTRY _objc_msgSend
UNWIND _objc_msgSend, NoFrame
//**p0是我们通过调用_objc_msgSend方法传过来的第一个参数,也就是调用者本身**
cmp p0, #0 // nil check and tagged pointer check
//**判断是否是tagged pointer指针**
#if SUPPORT_TAGGED_POINTERS
//**如果是tagged pointer指针,则判断上面cmp的值是否小于等于0**
//**则直接跳转到LNilOrTagged:这行去,否则直接往下走 **
b.le LNilOrTagged // (MSB tagged pointer looks negative)
#else
//**如果不是tagged pointer指针类型,则判断cmp的值是否等于0**
//**如果等于则直接跳转到LReturnZero:这行,否则往下走**
b.eq LReturnZero
#endif
//**把寄存器x0中的值赋值给栈p13,也就等价于p13 = isa**
ldr p13, [x0]
//**通过p13也就是isa来获取class,具体的在下面会讲**
GetClassFromIsa_p16 p13, 1, x0 // p16 = class
LGetIsaDone:
//**去缓存中查找我们调用的方法,如果找到了则直接调用**
//**否则调用__objc_msgSend_uncached **
// calls imp or objc_msgSend_uncached
CacheLookup NORMAL, _objc_msgSend, __objc_msgSend_uncached
#if SUPPORT_TAGGED_POINTERS
LNilOrTagged:
b.eq LReturnZero // nil check
GetTaggedClass
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
这一部分代码,实际上就是先检测我们调用_objc_msgSend
的调用方,是否为空,如果为空,则直接去进行方法的调用,直接返回。否则就将调用方的isa
指针存到p13
寄存器里面去,其中比较重要的一个方法是GetClassFromIsa_p16
他是通过isa
来获取class
,下面我们来重点看下他
.macro GetClassFromIsa_p16 src, needs_auth, auth_address /* note: auth_address is not required if !needs_auth */
#if SUPPORT_INDEXED_ISA
// Indexed isa
mov p16, \src // optimistically set dst = src
tbz p16, #ISA_INDEX_IS_NPI_BIT, 1f // done if not non-pointer isa
// isa in p16 is indexed
adrp x10, _objc_indexed_classes@PAGE
add x10, x10, _objc_indexed_classes@PAGEOFF
ubfx p16, p16, #ISA_INDEX_SHIFT, #ISA_INDEX_BITS // extract index
ldr p16, [x10, p16, UXTP #PTRSHIFT] // load class from array
1:
// **因为是真机环境,所以走这里**
#elif __LP64__
//如果needs_auth参数等于0,暂时不用管他的含义,在objc_msgSend中,这里传入的是1
.if \needs_auth == 0 // _cache_getImp takes an authed class already
// ** 将src,也就是我们的入参isa指针,存放到寄存器P16中**
mov p16, \src
.else
// 64-bit packed isa
//**调用ExtractISA**
ExtractISA p16, \src, \auth_address
.endif
#else
// 32-bit raw isa
mov p16, \src
#endif
.endmacro
//**这个方法有两个实现,一个是针对A12芯片以上的手机,我们这里看A12以下的**
.macro ExtractISA
//**实际上就是将传入的参数,对象的isa与isa_mask按位与,也就是得到Class**
and $0, $1, #ISA_MASK
.endmacro
这一段内容,简单来说,就是将对象的isa
传入GetClassFromIsa_p16
然后,这个方法针对不同的isa
类型做了不同的处理,最终得到了类对象Class
。在下面就是CacheLookup
方法了,我们继续往下看
.macro CacheLookup Mode, Function, MissLabelDynamic, MissLabelConstant
// **将x16寄存器的中isa存储到x15寄存器中,实际上就是做个备份**
mov x15, x16 // stash the original isa
LLookupStart\Function:
//**这里我们只研究真机环境,也就是CACHE_MASK_STORAGE ==CACHE_MASK_STORAGE_HIGH_16**
//**为了阅读方便,我将其他架构的代码给删掉了**
#elif CACHE_MASK_STORAGE == CACHE_MASK_STORAGE_HIGH_16
//** p1 = SEL, p16 = isa**
//**将x16也就是isa,平移#CACHE个位置,然后存入p11寄存器中**
//**全局搜索后#define CACHE=(2 * __SIZEOF_POINTER__)**
//**所以实际上就是isa平移2*8个位置,就是我们从之前类的结构中我们知道就是cache的首地址,也就是_bucketsAndMaybeMask的地址**
ldr p11, [x16, #CACHE] // p11 = mask|buckets
//**在真机环境下,CONFIG_USE_PREOPT_CACHES==1**
#if CONFIG_USE_PREOPT_CACHES
//**是否为A12芯片以上的机型**
#if __has_feature(ptrauth_calls)
tbnz p11, #0, LLookupPreopt\Function
and p10, p11, #0x0000ffffffffffff // p10 = buckets
#else
//**_bucketsAndMaybeMask按位与上0x0000fffffffffffe**
//实际上就是在真机架构上获取buckets,在上篇文章中我们讲过**
and p10, p11, #0x0000fffffffffffe // p10 = buckets
//**如果p11的第0位不等于0,则跳转到LLookupPreopt\Function,一般来说都会等于0**
tbnz p11, #0, LLookupPreopt\Function
#endif
//**p1也就是SEL右移7位,并且异或上SEL,存到P12 x12 = (_cmd ^ (_cmd >> 7))**
eor p12, p1, p1, LSR #7
//**p11右移48位,获取高16位的值,也就是mask,并且按位与上p12 x12 = x12 & mask**
//**这里就是通过哈希算法,算出存储的初始index的值**
and p12, p12, p11, LSR #48
#else
and p10, p11, #0x0000ffffffffffff // p10 = buckets
and p12, p1, p11, LSR #48 // x12 = _cmd & mask
#endif // CONFIG_USE_PREOPT_CACHES
这一段主要是获取到我们缓存也就是之前讲过的cache_t
的首地址,并且获取到buckets
和mask
。接着继续往下走
//**在真机环境下PTRSHIFT = 3,所以这里就是将p10也就是buckets平移index*16个字节**
//**来找到我们通过哈希算法计算出存储方法的位置**
add p13, p10, p12, LSL #(1+PTRSHIFT)
//**从x13读取2个字节分别存入p17和p9,实际是就是p17=IMP,p9=SEL**
//**然后将x13,向左平移BUCKET_SIZE个字节,也就是指向了前一个bucket**
//do {
1: ldp p17, p9, [x13], #-BUCKET_SIZE // {imp, sel} = *bucket--
//**比较p9和我们传入的SEL**
cmp p9, p1 // if (sel != _cmd) {
//**如果不相等,则跳转到3标签处继续执行**
b.ne 3f // scan more
//**缓存命中** // } else {
2: CacheHit \Mode // hit: call or return imp
// }
//**缓存没命中,则比较p9的SEL是否为0,如果是则执行MissLabelDynamic**
3: cbz p9, \MissLabelDynamic // if (sel == 0) goto Miss;
//**p9的SEL不等于0,则比较bukect(p13)和buckets(p10)的位置**
cmp p13, p10 // } while (bucket >= buckets)
//**如果bucket(p10)大于等于buckets(p13),则回到标记位1,继续执行循环**
b.hs 1b
#elif CACHE_MASK_STORAGE == CACHE_MASK_STORAGE_HIGH_16
//**p11右移48-4(maskZeroBits),获取到的结果就是mask在往右平移四位**
//**实际上就是mask*16字节大小**
//**所以这里实际上就是将buckets(p10)平移mask*16个字节**
//**也就是最后一个bucket的位置,存到p13中**
add p13, p10, p11, LSR #(48 - (1+PTRSHIFT))
// p13 = buckets + (mask << 1+PTRSHIFT)
// see comment about maskZeroBits
//**同理,这里就是将计算出的初始index位置(p12),往后一个buket存储到p12中**
add p12, p10, p12, LSL #(1+PTRSHIFT)
// p12 = first probed bucket
//**从x13读取2个字节分别存入p17和p9,实际是就是p17=IMP,p9=SEL**
//**然后将x13,向左平移BUCKET_SIZE个字节,也就是指向了前一个bucket**
// do {
4: ldp p17, p9, [x13], #-BUCKET_SIZE // {imp, sel} = *bucket--
//**比较获取的p9(SEL)和传入的p1(SEL)**
cmp p9, p1 // if (sel == _cmd)
//**相等,则缓存命中**
b.eq 2b // goto hit
//**否则比较p9(SEL)是否为0**
cmp p9, #0 // } while (sel != 0 &&
//**同时比较p13(当前bucket的前一个bucket)与p12(初始位置的后一个bucket)**
ccmp p13, p12, #0, ne // bucket > first_probed)
//**都不为0,且p13大于p12,则继续回标记位4,循环继续**
b.hi 4b
这一段是查找缓存中最核心的代码,通过循环来查找方法在缓存中的位置,如果找到了则调用CacheHit \Mode
,否则调用MissLabelDynamic
。接下来我们看看CacheHit
的实现
// CacheHit: x17 = cached IMP, x10 = address of buckets, x1 = SEL, x16 = isa
.macro CacheHit
//**objs_msgSend中$0==NORMAL**
.if $0 == NORMAL
//调用找到的方法
TailCallCachedImp x17, x10, x1, x16 // authenticate and call imp
.elseif $0 == GETIMP
mov p0, p17
cbz p0, 9f // don't ptrauth a nil imp
AuthAndResignAsIMP x0, x10, 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, x10, x1, x16 // authenticate imp and re-sign as IMP
cmp x16, x15
cinc x16, x16, ne // x16 += 1 when x15 != x16 (for instrumentation ; fallback to the parent class)
ret // return imp via x17
.else
.abort oops
.endif
.endmacro
//**依旧有两个实现,我们看A12以下的**
.macro TailCallCachedImp
// $0 = cached imp, $1 = address of cached imp, $2 = SEL, $3 = isa
//**$0(imp) ^ $3(isa)**
//**实际上就是一个解码的过程**
eor $0, $0, $3
//**跳转到$0(imp)的地址,就是调用IMP**
br $0
.endmacro
比较简单的实现,就是去调用查找到的SEL
对应的IMP
,实现方法的调用。到此,objc_msgSend
调用方法,在缓存中查找方法的流程就全部结束了,这个流程我们也称之为方法的快速查找流程
总结
在这篇文章中,我们从类结构中的cache_t
方法的插入,引申到了方法的读取,通过方法的读取我们了解到了objc_msgSend
方法,并且了解了运行时Runtime
相关的概念和三种调用方式。最后我们探索了在调用objc_msgSend
的过程中,是怎么样在缓存中快速查找到我们的方法的,也就是方法的快速查找流程
,这里我们可以通过下面的流程图来进行加深了解