iOS底层探究--------Runimte 运行时&方法的本质

331 阅读11分钟

前言引入

前面在探究cache一文中iOS底层探究--------cache分析,在分析其整个流程时,发现类里面方法在进行缓存的时候,最开始是进行了消息发送,后面才有一系列的操作。消息发送是通过objc_msgSend完成的,那么接下来,就对objc_msgSend做一个深层次的探究。

资源准备

进入主题

runtime介绍

runtime从字面上看,是运行时,但是,还有个叫法是编译时。两者的区别就是:

  • 编译时:就是正在编译的时候.编译时就是简单的作⼀些翻译⼯作,会进行词法分析,语法分析,主要是检查代码是否符合苹果的规范,这个检查的过程通常叫做静态类型检查;
那什么是编译呢?
就是编译器帮你把源代码翻译成机器能识别的代码.

那什么是静态了?
所谓静态嘛就是没把真把代码放内存中运⾏起来,⽽只是把代码当作⽂本来扫描下。所以是不会分配内存空间的
  • 运行时:就是代码跑起来了.被装载到内存中去了。而运⾏时类型检查就与前⾯讲的编译时类型检查(或者静态类型检查)不⼀样.不是简单的扫描代码.⽽是在内存中做些操作,做些判断。

runtime版本简述

runtime两个版本:⼀个Legacy版本(早期版本),⼀个Modern版本(现⾏版本)。

  • 早期版本对应的编程接⼝:Objective-C1.0 ,早期版本⽤于Objective-C1.032位MacOSX的平台上

  • 现⾏版本对应的编程接⼝:Objective-C2.0 ,现⾏版本:iPhone程序和MacOSXv10.5及以后的系统中的64位程序

runtime调起底层的三种方法

  • 第一种:从OC层面调起相关的方法,比如:[person testMethod]
  • 第二种:从NSObject层,调起提供的相关的API、接口,比如:isKindOfClass
  • 第三种:底层提供的objc下层的API,比如:class_getInstanceSize

几者之间的关系图: 未命名文件-7.png 底层库的api的还原,是可以被编译层给拦截,然后就再给FrameWorkRuntime提供相关接口。

方法的本质

探究方法底层

定义一个TestPerson类,再添加testMethod()实例方法和类方法sayHappy(),然后在main.m文件调用:

int main(int argc, const char * argv[]) {
    @autoreleasepool {
        TestPerson *person  = [TestPerson alloc];
        [person testMethod];
        [TestPerson sayHappy];
    }
    return 0;
}

通过终端,用clang命令,生成mian.m的名字为main.cppC++文件。然后在main.cpp文件中,找main函数代码:

034ACF27-3381-416A-92A2-50A0AF0AD97B.png

  • 通过源码分析:无论是初始化方法,还是实例方法,还是类方法,他们的调用都是通过objc_msgSend()进行的,而objc_msgSend(消息接收者,消息主体(sel + 参数))就是消息发送,所以,方法的本质,就是消息发送。(sel_registerName()属于第三种调用,调用底层api

既然方法调用都是需要通过objc_msgSend来进行,那么我们是不是可以直接通过objc_msgSend消息呢? 这里有两个小小的注意点:

  • 必须导入相应的头文件#import <objc/message.h>

  • 关闭objc_msgSend检查机制:target --> Build Setting -->搜索objc_msgSend -- Enable strict checking of obc_msgSend calls设置为NO

int main(int argc, const char * argv[]) {
    @autoreleasepool {
        TestPerson *person  = [TestPerson alloc];
        [person testMethod];
        
        //objc_msgSend(void /* id self, SEL op, ... */ )
        objc_msgSend((id)person, sel_registerName("testMethod"));
       
    }
    return 0;
}

运行后,得到的输出结果:

2021-06-28 12:09:40.573019+0800 objc_msgSend代码调试[1481:37470] TestPerson say : -[TestPerson testMethod]
2021-06-28 12:09:40.573588+0800 objc_msgSend代码调试[1481:37470] TestPerson say : -[TestPerson testMethod]
Program ended with exit code: 0

根据这个返回的结果,发现objc_msgSend[person testMethod]的效果是一样的,所以,就验证了方法的本质是消息发送

子类调用父类方法

通过刚刚的操作,发现如果是调用本类中的方法,实际是通过objc_msgSend发送的; 如果是调用父类的方法,那么消息发送是什么样的呢?自定义TestGrandPerson父类,TestPerson继承于TestGrandPerson类。在TestGrandPerson父类中自定义testGrandMethod()TestPerson子类对象调用testGrandMethod()方法:

int main(int argc, const char * argv[]) {
    @autoreleasepool {
        TestPerson *person  = [TestPerson alloc];
        [person testGrandMethod];
    }
    return 0;
}

再次执行下clan命令,查看main.cpp文件,发现还是调用的objc_msgSend来发送消息。 6FA6D065-A4E7-4886-A576-949D29DEC234.png

TestPerson中调用testGrandMethod()方法,再通过clang命令把TestPerson.m生成TestPerson.cpp文件,查询TestPerson函数的实现: 07ADA35A-EC02-4C3B-B8E5-A93BCC03E544.png

还可以通过objc_msgSendSuper()方法直接来验证:

int main(int argc, const char * argv[]) {
    @autoreleasepool {
        TestPerson *person  = [TestPerson alloc];
        
        struct objc_super scott_objc_super;
        scott_objc_super.receiver = person;
        scott_objc_super.super_class = TestPerson.class;
        objc_msgSendSuper(&scott_objc_super, @selector(testGrandMethod));
    }
    return 0;
}

同样能够获取方法的打印结果:

2021-06-28 14:24:38.515774+0800 objc_msgSend代码调试[3590:141152] TestGrandPerson say : -[TestGrandPerson testGrandMethod]
  • 那么,我们就可以验证得到:子类对象可以通过objc_msgSendSuper()方式调用父类的方法,方法的本质还是消息发送,只不过方式有些许不同而已。

根据objc源码

  • objc_msgSend的底层源码定义:
OBJC_EXPORT id _Nullable
objc_msgSend(id _Nullable self, SEL _Nonnull op, ...)
    OBJC_AVAILABLE(10.0, 2.0, 9.0, 1.0, 2.0);
  • 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);

其中,第一个参数:*super

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 */
};

那么就能得到这个一个大胆的猜想:当子类对象调用父类的方法时,先在本类中找,如果没有就到父类中找。

通过汇编对objc_msgSend进行探索

如果要直观清楚的知道objc_msgSend在底层库里面的调用情况,还是需要通过汇编进行直接调试。 未命名文件.png 可以看到objc_msgSend函数是在libobjc.A.dylib里面,那么,就能在objc源码中去索引了。找到真机的汇编objc-msg-arm64.s。通过关键字ENTRY(进入),找到入口。汇编里用到p0-p17寄存器,在arm64环境中,就是x0-x7寄存器,用来存储参数:

ENTRY _objc_msgSend    //_objc_msgSend的入口,还伴随两个参数(一个是id receiver消息接收者(isa),还有一个是SEL _cmd)
	UNWIND _objc_msgSend, NoFrame

	cmp	p0, #0			// p0为消息接收者(id receiver)地址,和0进行比较,判断有无消息
#if SUPPORT_TAGGED_POINTERS
	b.le	LNilOrTagged		//小于等于0,则支持TaggedPointer类型
#else
	b.eq	LReturnZero             //等于0 直接返回 nil ,当前的此次消息为空
#endif                                  // 对象有值,或者是有接收者(isa不为空)
	ldr	p13, [x0]	        //p13 = isa    把x0寄存器的里的地址读取到p13寄存器里面,对象的地址等于isa的地址
	GetClassFromIsa_p16 p13, 1, x0	// p16 = class     p13、1、x0三者作为参数,传给GetClassFromIsa_p16
LGetIsaDone:
	// 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(receiver,_cmd),接着判断receiver是否为nil, 再判断是否支持Taggedpointer类型:

  • 支持Taggedpointer类型且receiver != nil,返回nil,处理isa获取class跳转CacheLookup流程;

  • 不支持Taggedpointer类型receiver = nil,跳转LReturnZero流程,返回nil

  • 不支持Taggedpointer类型且receiver != nil,通过GetClassFromIsa_p16把获取到class 存放在p16寄存器中,然后走CacheLookup流程。

GetClassFromIsa_p16如何获取class

//----宏定义.macro
.macro GetClassFromIsa_p16 src, needs_auth, auth_address /* note: auth_address is not required if !needs_auth */

//----因为类的首地址,就是isa的地址
#if SUPPORT_INDEXED_ISA
	// Indexed isa
        //----将isa的值存入p16寄存器
	mov	p16, \src			// optimistically set dst = src  
        //----判断是否是 nonapointer 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(page中的偏移量) --x10基址 根据 偏移量 进行 内存偏移
	add	x10, x10, _objc_indexed_classes@PAGEOFF
        //----从p16的第ISA_INDEX_SHIFT位开始,提取 ISA_INDEX_BITS 位 到 p16寄存器,剩余的高位用0补充
	ubfx	p16, p16, #ISA_INDEX_SHIFT, #ISA_INDEX_BITS  // extract index
	ldr	p16, [x10, p16, UXTP #PTRSHIFT]	// load class from array
1:

#elif __LP64__   //----用于64位系统
.if \needs_auth == 0 // _cache_getImp takes an authed class already
	mov	p16, \src
.else
	// 64-bit packed isa
	ExtractISA p16, \src, \auth_address
.endif
#else
	// 32-bit raw isa
	mov	p16, \src

#endif

GetClassFromIsa_p16其实就是把class存放在p16寄存器里面。联系以前ISA指针的分析,相当于isa & 偏移量这样的平移操作,来获取class

再看GetClassFromIsa_p16里面的ExtractISA

.macro ExtractISA   //宏定义
	and    $0, $1, #ISA_MASK  //and就是 & 符号,那么这句汇编的意思是:传入的$1 &  ISA_MASK ,再赋给$0
.endmacro

那么,ExtractISA 的功能就是: isa & ISA_MASK = class 存放到p16寄存器

接着进入CacheLookup流程

当找到class之后,进入下一个流程bucket和下标index处理。下面的源码是CacheLookup的宏定义:

//----在调用CacheLookup NORMAL, _objc_msgSend, __objc_msgSend_uncached  时,可以看到使用了三个参数,但是在宏定义中,有四个参数,那么说明最后那个参数MissLabelConstant是默认参数
.macro CacheLookup Mode, Function, MissLabelDynamic, MissLabelConstant

//①
//----把x16寄存器的地址赋给x15里面
	mov	x15, x16			// stash the original isa
LLookupStart\Function:

	// p1 = SEL, p16 = isa
#if CACHE_MASK_STORAGE == CACHE_MASK_STORAGE_HIGH_16_BIG_ADDRS //arm64的模拟器
	ldr	p10, [x16, #CACHE]				// p10 = mask|buckets
	lsr	p11, p10, #48			// p11 = mask
	and	p10, p10, #0xffffffffffff	// p10 = buckets
	and	w12, w1, w11			// x12 = _cmd & mask
        
//②
#elif CACHE_MASK_STORAGE == CACHE_MASK_STORAGE_HIGH_16 //arm64真机

//----拿到CACHE的定义:#define CACHE (2 * __SIZEOF_POINTER__),其中 __SIZEOF_POINTER__表示pointer的大小 ,即 2*8 = 16
//---- p11 = mask|buckets -- 从x16(即isa)中平移16字节,取出cache 存入p11寄存器 -- isa距离cache 正好16字节:isa(8字节)-superClass(8字节)-cache(mask高16位 + buckets低48位)

	ldr	p11, [x16, #CACHE]			// p11 = mask|buckets
        
#if CONFIG_USE_PREOPT_CACHES  //arm64真机
#if __has_feature(ptrauth_calls)
	tbnz	p11, #0, LLookupPreopt\Function
	and	p10, p11, #0x0000ffffffffffff	// p10 = buckets
//③
#else
//----p10 = p11 & #0x0000fffffffffffe = buckets
	and	p10, p11, #0x0000fffffffffffe	// p10 = buckets
//----tbnz 比较p11 如果p11地址第0位不为0,则跳转到LLookupPreopt
	tbnz	p11, #0, LLookupPreopt\Function
//④
#endif
//----如果p11为0 ,就把p1地址右移7位,赋给p12 ,p1 = _cmd,eor是异或,p12 = (_cmd ^ (_cmd >> 7)) 
	eor	p12, p1, p1, LSR #7
//----哈希处理,获取buchets下标。
	and	p12, p12, p11, LSR #48		// x12 = (_cmd ^ (_cmd >> 7)) & mask
#else
//⑤
	and	p10, p11, #0x0000ffffffffffff	// p10 = buckets
//---- p11, LSR #48(右移48位) --> p11 >> 48  --> mask
//---- p1(_cmd) & mask = index = p12
	and	p12, p1, p11, LSR #48		// x12 = _cmd & mask
#endif // CONFIG_USE_PREOPT_CACHES
#elif CACHE_MASK_STORAGE == CACHE_MASK_STORAGE_LOW_4
	ldr	p11, [x16, #CACHE]				// p11 = mask|buckets
	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

回溯源码分析:源码列出不同的架构判断,我们则以真机为例。上面这段源码主要分三步:

  • 获取p11的地址:p16 = isa(class),p16 + 0x10(#cache) = p11;
  • 获取buckets的首地址:p10 = p11 & #0x0000ffffffffffff = (p11 >> 48) - 1;
  • 比较p11,如果存在,则进入LLookupPreopt流程,如果不存在,通过哈希处理,获取下标p12 =(cmd ^ ( _cmd >> 7))& msak.

我们在cache里面找对应的方法,是通过sel去找对应的imp,而sel和imp组合是存在bucket里面的,但是在buckets数组里面,要找到对应的bucket,是需要通过对应的下标index,才能找到的。要获取这个index,是通过哈希函数推理的,哈希函数计算下标,使用的参数是selmask。现在sel已经知道了(p1 = SEL),想要获取index,就得拿到mask的值。计算的过程,在源码⑤号标志位置

循环遍历

接着上面的源码,把刚刚计算mask的过程放下来:

//---- p10 = p11 & #0x0000fffffffffffe = buckets
//---- p11, LSR #48 --> p11 >> 48  --> mask
//---- p1(_cmd) & mask = index = p12
//---- p13 = buckets & ((_cmd ^ (_cmd >> 7)) & mask)<<(1 + PTRSHIFT)   找到下标index对应的bucket的地址,其本质就是内存平移,相当于buckets + 平移内存,把一个int类型的index,转化成十六进制的地址,才能进行运算

	add	p13, p10, p12, LSL #(1+PTRSHIFT)
						// p13 = buckets + ((_cmd & mask) << (1+PTRSHIFT))    // PTRSHIFT = 3 ,宏定义#define PTRSHIFT 3

						// do {                                              
//----ldp 同时操作两个寄存器,就是[x13], #-BUCKET_SIZE得到的值,同时存储到p17和p9两个寄存器里面
//----BUCKET_SIZE = 16 = bucket的大小               
//----先拿到x13所对应的bucket里面的sel和imp,分别放到p9和p17中,p17 = imp    p9 = sel                       
//----然后将*bucket--   相当于x13的那个index所对应的bucket的地址,向前移动一个单位         
1:	ldp	p17, p9, [x13], #-BUCKET_SIZE	//     {imp, sel} = *bucket--
//---- 遍历查到的sel和我们想要查的方法的sel是否相等,如果一样,就执行下面2:处的代码,这个就是缓存命中,如果不相等,就进入3:处执行。
        cmp	p9, p1				//     if (sel != _cmd) {
	b.ne	3f				//         scan more
						//     } else {
//----缓存命中                                           
2:	CacheHit \Mode				// hit:    call or return imp
						//     }
//----不相等,就执行MissLabelDynamic
3:	cbz	p9, \MissLabelDynamic		//     if (sel == 0) goto Miss;
//----比较新的p13和首地址p10
        cmp	p13, p10			// } while (bucket >= buckets)
	b.hs	1b

源码过程回溯:

  • 先是通过首地址p10 & 偏移内存((_cmd & mask) << (1+PTRSHIFT))就得到p13的地址,也就是一个bucket

  • 1流程: 然后把这个bucket里面的impsel分存放到p17p9两个寄存器里面,紧接着*bucket--,也就是减去BUCKET_SIZE的值,向前移动一个单位。再比较p9拿到的sel和传入的_cmd是否相等,如果相等,就执行2流程:,不相等,就执行3流程:;

  • 2流程:缓存命中,直接跳转CacheHit流程;

  • 3流程:先判断sel = 0条件是否成立。如果成立说明buckets里面没有与传入的参数_cmd的相匹配的缓存,没必要往下走,直接跳转__objc_msgSend_uncached流程。如果sel != 0,说明这个bucketsel和当前要查找的sel不匹配,那么就直接找下一个新的p13(新的bucket)里面的sel进行比较地址大小,如果新的p13的地址大于p10的地址,那么就跳转到1流程去,这样就进行循环查找,如果是小于等于的话,就继续往下走。

缓存命中 -- CacheHit流程

CacheHit \Mode就是宏CacheLookup的第一个参数NORMAL

// CacheHit: x17 = cached IMP, x10 = address of buckets, x1 = SEL, x16 = isa
//---- 宏定义
.macro CacheHit
.if $0 == NORMAL
	TailCallCachedImp x17, x10, x1, x16	// authenticate and call imp

TailCallCachedImp的宏定义

.macro TailCallCachedImp
//---- CacheHit: x17 = cached IMP, x10 = address of buckets, x1 = SEL, x16 = isa
//---- $0 = cached imp, $1 = address of cached imp, $2 = SEL, $3 = isa
//---- 为什么要异或? $0 = x17 , $3 = isa (类) ,x17 ^ 类,因为imp在存储的时候进行了编码,那么取的时候,也要进行编码,才能得到真正的imp
//---- 再跳转到对应的imp
	eor	$0, $0, $3
	br	$0
.endmacro

这个就是objc_msgSend(sel -> imp),通过sel查找imp

buckets数组尾部开始循环遍历查找

为什么要从最后面找了,因为在之前的遍历的时候,是去的一个hashindex所对应的bucket,接着就是不断的向前平移查找,但是并不知道这个bucket之后,还有没有其余的bucket,所以要从尾部再进行查找。

#if CACHE_MASK_STORAGE == CACHE_MASK_STORAGE_HIGH_16_BIG_ADDRS
	add	p13, p10, w11, UXTW #(1+PTRSHIFT)
						// p13 = buckets + (mask << 1+PTRSHIFT)
#elif CACHE_MASK_STORAGE == CACHE_MASK_STORAGE_HIGH_16 //---- 真机
//----p11,LSR #(48 - (1+PTRSHIFT))的意思是:p11 >> 48,得到的新的p11后,再p11 << 4,那么就得到偏移量的最大值(mask)。
//----p13 = p10 & p11 = buckets + mask(最大值),得到最尾上的bucket的地址。
	add	p13, p10, p11, LSR #(48 - (1+PTRSHIFT))
						// p13 = buckets + (mask << 1+PTRSHIFT)
						// see comment about maskZeroBits
#elif CACHE_MASK_STORAGE == CACHE_MASK_STORAGE_LOW_4
	add	p13, p10, p11, LSL #(1+PTRSHIFT)
						// p13 = buckets + (mask << 1+PTRSHIFT)
#else
#error Unsupported cache mask storage for ARM64.
#endif
//----根据前文p12 = (_cmd ^ (_cmd >> 7)) & mask
//----经过add计算,p12 = buckets + p12 >> 4,拿到最开始的bucket
	add	p12, p10, p12, LSL #(1+PTRSHIFT)
						// p12 = first probed bucket

						// do {
//---- 和前文一样,接着就是把x13地址的bucket里面的imp和sel分别存到p17和p9寄存器里面。然后,*bucket--    
//---- 
4:	ldp	p17, p9, [x13], #-BUCKET_SIZE	//     {imp, sel} = *bucket--
//---- 比较当前取到的bucket的sel和传参进来的_cmd
	cmp	p9, p1				//     if (sel == _cmd)
//---- 如果相等,就执行2流程
	b.eq	2b				//         goto hit
//---- 当前的bucket的sel和0比较
	cmp	p9, #0				// } while (sel != 0 &&
//---- 当前的bucket和最开始的bucket的地址作比较,且sel != 0,(ne表示不等于)
	ccmp	p13, p12, #0, ne		//     bucket > first_probed)
//---- 当前bucket地址大于最开始的bucket地址,就执行4流程,(hi表示无符号大于)
	b.hi	4b
//---- 一直没找到,就走下面流程
LLookupEnd\Function:
LLookupRecover\Function:
	b	\MissLabelDynamic

源码回溯:查找流程

  • 找到最后一个bucket的位置:p13 = buckets + (mask << 1+3)
  • 拿到最开始的bucket的位置:p12 = buckets + p12 >> 4
  • 先获取对应的p13存的bucket然后取出impsel分别存放到p17p9两个寄存器里面,然后*bucket--向前平移;
  • 比较p9存的sel和传入的参数_cmd。如果相等走2流程;不相等,往下执行;
  • 比较当前的bucket和最开始的bucket的地址,且sel != 0,如果大于,就执行4流程,否则,一直往下找,遍历完成都没找到,就结束。

objc_msgSend流程图

未命名文件-2.png