IOS底层原理之objc_msgSend

220 阅读10分钟

一、clang指令探查方法调用

Clang是一个由Apple主导编写,基于LLVM的C/C++/Objective-C编译器。如果你不知道clang,可以在这里找到你想要的。

在工程目录中的main.m文件目录下进入到终端,输入如下命令

clang -rewrite-objc main.m -o main.cpp

该命令会将main.m编译成C++的代码,但是不同平台支持的代码肯定是不一样的。

xcrun -sdk iphoneos clang -arch arm64 -rewrite-objc OC源文件 -o 输出的CPP文件 如果需要链接其他框架,使用-framework参数。比如-framework UIKit

在终端输入命令以后,会生成一个main.cpp文件。打开main.cpp文件,直接将代码拉到最下面,我们会看到这样的一段代码。

int main(int argc, const char *argv[])
{
    /* @autoreleasepool */ { __AtAutoreleasePool __autoreleasepool; 
        Person *person = ((Person *(*)(id, SEL))(void *)objc_msgSend)((id)objc_getClass("Person"), sel_registerName("alloc"));
        ((void (*)(id, SEL))(void *)objc_msgSend)((id)person, sel_registerName("sayHello"));
    }
    return 0;
}

可以看到在OC层面调用的sayHello方法于底层而言是调用了一个objc_msgSend方法,那么我们可以确认的是方法的调用其实是调用的objc_msgSend。

二、objc_msgSend底层实现

苹果公司开源了objc_msgSend的底层![代码]{opensource.apple.com/source/objc…},是用汇编语言编写的,其目的就是为了提高函数的执行速度。苹果公司提供诸多平台架构的汇编代码,我这里是针对arm64平台的汇编代码(objc-msg-arm64.s)进行分析。

1. 函数入口

全局搜索ENTRY _objc_msgSend,这个就是objc_msgSend汇编代码的入口。

ENTRY _objc_msgSend
	UNWIND _objc_msgSend, NoFrame
	cmp	p0, #0			// nil check and tagged pointer check
#if SUPPORT_TAGGED_POINTERS
	b.le	LNilOrTagged		//  (MSB tagged pointer looks negative)
#else
	b.eq	LReturnZero
#endif

cmp是一个判断指令。在这里判断p0是否是0,如果为0则表示传入的对象为nil,立即返回。 实际上SUPPORT_TAGGED_POINTERS的值定义为1,其定义在arm64-asm.h里面。b.le指令用来判断上面的cmp的值是否小于等于执行标号,否则直接往下走。如果p0<0,则表示传入的对象是tagged pointer。在这里我们不去讨论tagged pointer的情况。程序继续往下走,执行如下代码

ldr	p13, [x0]		// p13 = isa
GetClassFromIsa_p16 p13		// p16 = class

在这里将x0指向内存地址的值isa赋值给p13,然后通过GetClassFromIsa_p16拿到class的地址。接下来CacheLookup流程,从缓存中查询。

2、CacheLookup

来到CacheLookup流程,已经将class的地址赋值给了p16。

ldp	p10, p11, [x16, #CACHE]	// p10 = buckets, p11 = occupied|mask
#define SUPERCLASS       __SIZEOF_POINTER__
#define CACHE            (2 * __SIZEOF_POINTER__)

这里将class地址的偏移CACHE得到的地址给到p10和p11。superclass占用8个字节,所以这里的偏移量是16字节。而类的底层定义是一个结构体:

struct objc_class : objc_object {
    Class ISA;
    Class superclass;
    cache_t cache;             // formerly cache pointer and vtable
    class_data_bits_t bits;    // class_rw_t * plus custom rr/alloc flags
    ...
};

isa占用8字节,superclass占用8字节,所以类地址偏移16字节可以得到cache。而对于cache_t结构体的定义如下:

struct cache_t {
    struct bucket_t *_buckets;
    mask_t _mask;
    mask_t _occupied;
    ...
};

buckets是结构体指针,占用8字节,mask占4字节,occupied占4字节,因此p16偏移16字节后得到buckets存储在p10,p11存了mask和occupied,其中低32位表示mask,高32位表示occupied。

and	w12, w1, w11		// x12 = _cmd & mask
add	p12, p10, p12, LSL #(1+PTRSHIFT)  // p12 = buckets + ((_cmd & mask) << (1+PTRSHIFT))

p1是SEL,其低32位w1表示的时候SEL对应的key,将key和mask相与得到函数方法在buckets哈希表中的索引。p10是buckets的首地址,而bucket_t结构体占用16字节,所以buckets的首地址加上索引向左偏移4字节得到的值就是函数方法在缓存中的地址。因此p12就是函数方法对应的bucket地址。

ldp	p17, p9, [x12]		// {imp, sel} = *bucket

将bucket装在到p17和p9中,p17中存放imp,p9中存放key也就是sel。

1:	cmp	p9, p1			// if (bucket->sel != _cmd)
	b.ne	2f			//     scan more
	CacheHit $0			// call or return imp

将找到的sel和传入的sel进行比较,如果相同就表示已经找到了执行CacheHit,否则执行2继续查找。

2:	// not hit: p12 = not-hit bucket
	CheckMiss $0			// miss if bucket->sel == 0
	cmp	p12, p10		// wrap if bucket == buckets
	b.eq	3f
	ldp	p17, p9, [x12, #-BUCKET_SIZE]!	// {imp, sel} = *--bucket
	b	1b			// loop

在这一步对buckets的首地址p10和我们找到的bucket的地址p12进行比较 ,如果不相等则查找前一个bucket,并跳回到1执行,否则跳到3执行。

3:	// wrap: p12 = first bucket, w11 = mask
	add	p12, p12, w11, UXTW #(1+PTRSHIFT)
		                        // p12 = buckets + (mask << 1+PTRSHIFT)

	// Clone scanning loop to miss instead of hang when cache is corrupt.
	// The slow path may detect any corruption and halt later.
	ldp	p17, p9, [x12]		// {imp, sel} = *bucket

在这里其实拿到的就是buckets中的第一个bucket,p12 = first bucket。继续往下执行。接下来的操作其实和上面的执行流程是一样的,唯一不同的是3执行的是JumpMiss

1:	cmp	p9, p1			// if (bucket->sel != _cmd)
	b.ne	2f			//     scan more
	CacheHit $0			// call or return imp
2:	// not hit: p12 = not-hit bucket
	CheckMiss $0			// miss if bucket->sel == 0
	cmp	p12, p10		// wrap if bucket == buckets
	b.eq	3f
	ldp	p17, p9, [x12, #-BUCKET_SIZE]!	// {imp, sel} = *--bucket
	b	1b			// loop
3:	// double wrap
	JumpMiss $0

3、CacheHit

从上面的流程分析我们知道了如果在缓存中找到了和传入的一直的函数方法就会执行CacheHit。我们来看下CacheHit做了什么。

// CacheHit: x17 = cached IMP, x12 = address of cached IMP
.macro CacheHit
.if $0 == NORMAL
	TailCallCachedImp x17, x12	// authenticate and call imp
.elseif $0 == GETIMP
	mov	p0, p17
	AuthAndResignAsIMP x0, x12	// authenticate imp and re-sign as IMP
	ret				// return IMP
.elseif $0 == LOOKUP
	AuthAndResignAsIMP x17, x12	// authenticate imp and re-sign as IMP
	ret				// return imp via x17
.else
.abort oops
.endif
.endmacro

走这一步是已经在缓存找到了相应的函数方法,p17(x17)中存储了imp,p12(x12)中存放了imp的地址,TailCallCachedImp直接调用函数方法。

4、JumpMiss

.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

走到JumpMiss来则表示在缓存中并没有找到对应的函数方法,则会跳到__objc_msgSend_uncached执行MethodTableLookup

5、MethodTableLookup

.macro MethodTableLookup
    // push frame
    SignLR
    stp	fp, lr, [sp, #-16]!
    mov	fp, sp
    
    // save parameter registers: x0..x8, q0..q7
    sub	sp, sp, #(10*8 + 8*16)
    stp	q0, q1, [sp, #(0*16)]
    stp	q2, q3, [sp, #(2*16)]
    stp	q4, q5, [sp, #(4*16)]
    stp	q6, q7, [sp, #(6*16)]
    stp	x0, x1, [sp, #(8*16+0*8)]
    stp	x2, x3, [sp, #(8*16+2*8)]
    stp	x4, x5, [sp, #(8*16+4*8)]
    stp	x6, x7, [sp, #(8*16+6*8)]
    str	x8,     [sp, #(8*16+8*8)]
    
    // receiver and selector already in x0 and x1
    mov	x2, x16
    bl	__class_lookupMethodAndLoadCache3
    
    // IMP in x0
    mov	x17, x0
    
    // restore registers and return
    ldp	q0, q1, [sp, #(0*16)]
    ldp	q2, q3, [sp, #(2*16)]
    ldp	q4, q5, [sp, #(4*16)]
    ldp	q6, q7, [sp, #(6*16)]
    ldp	x0, x1, [sp, #(8*16+0*8)]
    ldp	x2, x3, [sp, #(8*16+2*8)]
    ldp	x4, x5, [sp, #(8*16+4*8)]
    ldp	x6, x7, [sp, #(8*16+6*8)]
    ldr	x8,     [sp, #(8*16+8*8)]
    
    mov	sp, fp
    ldp	fp, lr, [sp], #16
    AuthenticateLR
.endmacro

MethodTableLookup中的这些操作其实是在从bits中的方法列表去找函数方法,这篇文章中有分析bits。最终跳到__class_lookupMethodAndLoadCache3去执行。从这里开始进入到方法的查找流程。

三、方法查找

从上面的objc_msgSend汇编源码分析来看,当在缓存cache中未能命中方法的时候,最终会走到__class_lookupMethodAndLoadCache3。__class_lookupMethodAndLoadCache3对应上层C实现的_class_lookupMethodAndLoadCache3方法,该方法定义在objc-runtime-new.mm中。

1、_class_lookupMethodAndLoadCache3方法

IMP _class_lookupMethodAndLoadCache3(id obj, SEL sel, Class cls)
{
    return lookUpImpOrForward(cls, sel, obj, 
                              YES/*initialize*/, NO/*cache*/, YES/*resolver*/);
}

obj 类的实例对象 sel 方法的名称 cls 类

_class_lookupMethodAndLoadCache3调用了lookUpImpOrForward方法,我们看到这的cache传入的是NO,表示函数方法没有缓存命中,resolver是消息的接受者。

2、lookUpImpOrForward方法的准备工作

lookUpImpOrForward的代码实现如下:

IMP lookUpImpOrForward(Class cls, SEL sel, id inst, 
                       bool initialize, bool cache, bool resolver)
{
    IMP imp = nil;
    bool triedResolver = NO;

    runtimeLock.assertUnlocked();

    // Optimistic cache lookup
    if (cache) {
        imp = cache_getImp(cls, sel);
        if (imp) return imp;
    }

    // runtimeLock is held during isRealized and isInitialized checking
    // to prevent races against concurrent realization.

    // runtimeLock is held during method search to make
    // method-lookup + cache-fill atomic with respect to method addition.
    // Otherwise, a category could be added but ignored indefinitely because
    // the cache was re-filled with the old value after the cache flush on
    // behalf of the category.

    runtimeLock.lock();
    checkIsKnownClass(cls);

    if (!cls->isRealized()) {
        realizeClass(cls);
    }

    if (initialize  &&  !cls->isInitialized()) {
        runtimeLock.unlock();
        _class_initialize (_class_getNonMetaClass(cls, inst));
        runtimeLock.lock();
    }
  ...
}

这里的代码是lookUpImpOrForward函数方法的部分代码,这样一段代码做了一下几步准备工作。

  1. runtimeLock.lock() 加锁避免在多线程的情况下出现错乱的情况。
  2. checkIsKnownClass(cls) 判断class的有效性。
  3. realizeClass(cls)class_rw_tclass_ro_t中加载方法,具体的可以参阅realizeClass方法的实现。

做好上面的准备工作后,程序会执行retry的代码开始方法的查找。其实这里还是会到类的缓存中再去查找一遍。

3、 再去缓存中查找

为了避免在多线程的情况下可能存在方法缓存慢于方法命中的情况,会再次去缓存中查找一次方法。

imp = cache_getImp(cls, sel);
if (imp) goto done;

在这里cache_getImp其实是汇编中_cache_getImp上层C代码映射。

STATIC_ENTRY _cache_getImp
GetClassFromIsa_p16 p0
CacheLookup GETIMP

重新走CacheLookup流程从缓存中查找,如果在缓存中有查找到则直接goto done释放锁,返回imp,结束方法调用,否则会先从本类中开始查找方法。

4、本类中查找

如果方法在缓存中未能找到,会在本类的方法列表中查找方法的实现。

Method meth = getMethodNoSuper_nolock(cls, sel);
if (meth) {
    log_and_fill_cache(cls, meth->imp, sel, inst, cls);
    imp = meth->imp;
    goto done;
}

这段是在本类的方法列表中查找方法实现的代码。调用getMethodNoSuper_nolock从方法列表中查找,如果查找到则调用log_and_fill_cache方法进行方法的缓存,goto done释放锁,返回imp。这里的查找算法是一个二分查找算法。如果本类中没有方法的实现,便会从类的父类中查找方法的实现。

5、父类中查找

如果我们调用的方法在本类中未能实现,则会从父类的方法列表中查找。

 {
    unsigned attempts = unreasonableClassCount();
    for (Class curClass = cls->superclass;
         curClass != nil;
         curClass = curClass->superclass)
    {
        // Halt if there is a cycle in the superclass chain.
        if (--attempts == 0) {
            _objc_fatal("Memory corruption in class list.");
        }
        
        // Superclass cache.
        imp = cache_getImp(curClass, sel);//父类缓存中查找
        if (imp) {
            if (imp != (IMP)_objc_msgForward_impcache) {//是否是消息转发的方法
                // Found the method in a superclass. Cache it in this class.
                log_and_fill_cache(cls, imp, sel, inst, curClass);
                goto done;
            }
            else {
                // Found a forward:: entry in a superclass.
                // Stop searching, but do not cache yet; call method 
                // resolver for this class first.
                break;
            }
        }
        
        // Superclass method list.
        Method meth = getMethodNoSuper_nolock(curClass, sel);
        if (meth) {
            log_and_fill_cache(cls, meth->imp, sel, inst, curClass);
            imp = meth->imp;
            goto done;
        }
    }
}
  1. 先从父类的缓存中查找,如果在缓存中有找到,则会先判断是否是消息转发的方法。
  2. 如果是消息转发的方法则会走消息转发的流程,终止方法的查找。
  3. 如果是非消息转发的方法则会调用log_and_fill_cache进行方法的缓存,终止方法的查找并执行方法。
  4. 如果在父类的缓存中没有找到,则会从父类的方法列表中查找,如果找到了则会调用log_and_fill_cache进行方法的缓存,终止方法的查找并执行方法。
  5. 如果在父类的方法列表中没有找到,重复执行1、3、4步骤,直到父类为nil为止。
  6. 如果直到父类为nil还是未能找到方法的实现,则会走动态方法解析流程。

四、总结

  1. 方法调用的底层实现是objc_msgSend,即方法的本质是消息发送。
  2. objc_msgSend是用汇编实现的。objc_msgSend从缓存中查找方法,如果有查找到就会执行方法,否则会去调用的_class_lookupMethodAndLoadCache3这样的一个C函数进行方法的查找。
  3. _class_lookupMethodAndLoadCache3方法中会做一些准备的工作,然后会再次汇编查找一次缓存,如果找到就执行方法,否则会从本类的方法列表中查找。
  4. 在本类的方法列表中没有找到则去父类的缓存中查找,如果有查找到则会判断是否走消息转发流程。否则去父类的方法列表中查找。
  5. 如果在本类缓存、本类方法列表、父类缓存、父类方法列表中都未找到,走动态方法解析流程。

五、参考资料

汇编指令

方法缓存cache_t

深入OC底层探索NSObject的结构

动态方法解析和消息转发