iOS探索底层-objc_msgSend&快速方法查找

317 阅读11分钟

前言

在上一篇iOS探索底层-cahce_t初探文章中,我们探索了cache_t的基本结构和怎么去插入数据的,今天这篇文章,我们继续探索cache_t缓存的数据,是什么时候读取的。在源码中,全局搜索插入方法cache_t::insert我们会发现这样一些注释 image.png 可以看到苹果的注释写的很明白了,缓存的读取使用的方法是objc_msgSendcache_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的发起方式

  1. 通过OC的方法调用:例如[p sayBye]
  2. 通过NSObject提供的api:例如isKindofClass
  3. 通过底层提供的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申明了两个方法sayByeeat,然后我们运行它

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函数中的代码如下

image.png 发现编译器给我们报了个一个错误,objc_msgSend方法的参数太多了,我们下面进行编译条件的修改

Build Settings中的Enable Strict Checking of objc_msgSend Calls改为NO,默认为YES;

在真机和M1电脑的arm64架构下,修改参数也没用,因此直接进行强制类型转换,将objc_msgSend转化成((void (*)(id, SEL))(void *)objc_msgSend).

进行修改后,我们来看看打印结果

image.png 成功的打印出来了,说明使用这种方式,我们也能够成功的调用方法。

方法的快速查找流程

竟然系统底层是通过objc_msgSend来调用方法的,那么我们你就来重点研究下,他是怎么去调用我们的方法的,打开源码全局搜索objc_msgSend

image.png 我们可以看到在22个文件中有644个结果,刚刚我们在使用objc_msgSend的过程中,是需要导入头文件message.h的,于是我们在里面先寻找,结果发现只有函数的申明,并没有函数的实现,那么就只能在更底层的地方去找了,最后我们锁定到了objc-msg-arm64.s文件中,以.s结尾的都是汇编写的文件,而arm64是我们的真机架构

image.png 在其中最后找到了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的首地址,并且获取到bucketsmask。接着继续往下走

        //**在真机环境下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的过程中,是怎么样在缓存中快速查找到我们的方法的,也就是方法的快速查找流程,这里我们可以通过下面的流程图来进行加深了解

方法的快速查找流程.png