arm64 objc_msgSend 源码解读

最近参照 MikeAsh 的这篇文章,看了 arm64obj_msgSend 的实现。了解了其主体流程,同时对于 arm64 的汇编知识也有了更进一步的了解。

目前最新 obj4-781 中 objc-msg-arm64.s 的实现,跟 MikeAsh 文中的代码还是有些不一样,但总体思路一致。本着学习的原则,读了最新的源码实现,也算检验下学习成果。

总的来说,整个过程主要分为如下几部分:

  • 查找对象的 isa,也就是 class。

  • 查找缓存。

  • 缓存未命中时,走慢查找。

下面来总结回顾一下。

arm64 汇编基础

在阅读之前,可以先了解下 arm64 汇编的基础知识。

x0 ~ x31 是通用寄存器。而等会在下面的源码中,我们会看到用到了 p0 而不是 x0,p 代表 pointer-sized,表示指针的大小。在 arm64 下,p0 和 x0 是等价的。在 arm64-asm.h 中可以看到如下定义:


#if __LP64__

// true arm64

#define SUPPORT_TAGGED_POINTERS 1

#define PTR .quad

#define PTRSIZE 8

#define PTRSHIFT 3 // 1<

objc_msgSend 主流程

流程介绍

整体流程如下:

image

我将源码拆分成了如下 4 个文件,这样单个处理看起来会比较清晰,文件放到了 github 上。

  • Entry_objc_msgSend.s,objc_msgSend 主干流程。

  • GetClassFromIsa_p16.s,获取 class 流程。

  • CacheLookup.s,缓存查找流程。

  • LNilOrTagged.s,TaggedPointer/nil 的处理。

代码实现

objc_msgSend 的方法定义如下:


id objc_msgSend(id self, SEL _cmd, ...);

第一个参数是 self,第二个参数是 selector,后面跟不定长参数。当方法被调用时,第一个参数放入 x0,第二个参数放入 x1。也就是 x0 = self,x1 = _cmd

_objc_msgSend 的汇编实现如下:


ENTRY _objc_msgSend

UNWIND _objc_msgSend, NoFrame

// 将 self 和 0 进行比较
cmp  p0, #0  // nil check and tagged pointer check

#if SUPPORT_TAGGED_POINTERS

// <= 0,跳转到 LNilOrTagged,进行 nil 或者 tagged pointer 的处理。因为 tagged pointer 在 arm64 下,最高位为 1,作为有符号数 < 0
b.le LNilOrTagged // (MSB tagged pointer looks negative)

#else

b.eq  LReturnZero

#endif

// 将 isa 的值放到 x13
ldr  p13, [x0]  // p13 = isa

// 获取 class 的地址,放到 p16
GetClassFromIsa_p16 p13  // p16 = class

LGetIsaDone:

// calls imp or objc_msgSend_uncached
// 在缓存中查找或进行完整方法查找
CacheLookup NORMAL, _objc_msgSend

下面来逐句解析下实现过程:


// 将 self 和 0 进行比较
cmp  p0, #0

第一步,将 self0 比较,以便下一步进行判定走哪种分支处理。


#if SUPPORT_TAGGED_POINTERS

// <= 0,跳转到 LNilOrTagged,进行 nil 或者 tagged pointer 的处理。因为 tagged pointer 在 arm64 下,最高位为 1,作为有符号数 < 0
b.le LNilOrTagged // (MSB tagged pointer looks negative)

#else

b.eq  LReturnZero

#endif

  • 如果支持 SUPPORT_TAGGED_POINTERS。判断上面的比较结果,是否 ≤ 0,是则跳转到 LNilOrTagged 进行处理。因为在 arm64 下,当为 Tagged pointer 时,最高位是 1,作为有符号数,< 0。

  • 不支持的话,则判断比较结果是否为 0。如果为 0,则跳转到 LReturnZero 进行 nil 的处理。

关于 LNilOrTagged 的处理,我们留到后面再讲。先关注正常情况。


// 将 isa 的值放到 x13
ldr  p13, [x0]  // p13 = isa

ldrLoad Register 的缩写,[] 为间接寻址。它表示从 x0 所表示的地址中取出 8 字节数据,放到 x13 中。x0 中是 self 的地址,所以这里取出来的数据其实是 isa 的值。那有没有小伙伴疑惑这是为什么呢?简单解释一下。

因为 self 是个指针,指向 struct objc_object,它的定义如下。


struct objc_object {

private:

 isa_t isa;

...

}

objc_object 中只有一个成员 isa, 因此取出指针指向的内容,也就获取到了 isa 的值。


// 获取 class 的地址,放到 p16
GetClassFromIsa_p16 p13  // p16 = class

调用 GetClassFromIsa_p16,进一步获取 class 地址。这是比较关键的一步,因为后续操作都需要用到 class

获取 class

GetClassFromIsa_p16 实现如下:


.macro GetClassFromIsa_p16 /* src */

// 64-bit packed isa
// p13 & 0x0000000ffffffff8ULL,获取真正的类地址
and  p16, $0, #ISA_MASK

.endmacro

源码中关于 SUPPORT_INDEXED_ISA 的部分(这里为了简化代码,我删除了),标识是否做了 isa 指针优化。主要在 watchOS 上支持,这里我们不做深究。如想了解,可以参看里面的注释。

主要思想是从 isa 中获取 indexCls,然后从 indexed_classes 表中获取到 class。有兴趣可以查看 runtime 源码中 isa.h 关于 indexcls 的布局。

在 arm64 下,我们主要看这一行就好。


and p16, $0, #ISA_MASK

and 表示与运算,p16 = $0 & ISA_MASK$0 是传进来的第一个参数,也就是 isa 的值。那么在 64 位下,为什么要和 ISA_MASK 进行与运算才能获取到呢?而 ISA_MASK 的值是 0xffffffff8

貌似我们之前的印象里,上述获取到的 isa 就是 class 地址。这是因为老的 struct objc_object 的类型定义如下:


struct objc_object {

 Class _Nonnull isa OBJC_ISA_AVAILABILITY;

};

而在 64 位中,isa 的类型由 Class 变为了 union,并不直接是一个指针,cls 的信息存储在其中,如下所示。


struct objc_object {

private:

 isa_t isa;

...

}

union isa_t {

 isa_t() { }

 isa_t(uintptr_t value) : bits(value) { }

 Class cls;

 uintptr_t bits;

#if defined(ISA_BITFIELD)

 struct {

 ISA_BITFIELD; // defined in isa.h

 };

#endif

};

下面我们再来看一下 ISA_BITFIELD 的定义,在 isa.h 中,这里是获取 class 的关键。它主要定义了一些位域来存储不同的信息。


#  define ISA_MASK 0x0000000ffffffff8ULL

#  define ISA_BITFIELD \

 uintptr_t nonpointer : 1;  \

 uintptr_t has_assoc  : 1;  \

 uintptr_t has_cxx_dtor : 1;  \

 uintptr_t shiftcls : 33; /*MACH_VM_MAX_ADDRESS 0x1000000000*/ \

 uintptr_t magic  : 6;  \

 uintptr_t weakly_referenced : 1;  \

 uintptr_t deallocating : 1;  \

 uintptr_t has_sidetable_rc : 1;  \

 uintptr_t extra_rc : 19

重点关注第 4 项 shiftcls,它占 33 位,存放的是 cls 的地址。ISA_MASK,这个掩码就是用来取出中间的 33 位。

不知道大家有没有跟我一样的疑问?虽然照这样是取出了 33 位,可末尾还有 3 位是 0。那么按照常规思路,不是应该右移 3 位将其去除吗?

但事实上这样计算是没错的。因为 shiftcls 在赋值时,就将地址右移了 3 位。由于是 8 字节对齐,最后 3 位肯定为 0,右移之后也无影响,到时再补上 0 即可。不过也看到其他文章说这样做是为了节省空间。所以按照上述方式取出,正好是原始地址。

到底对不对,我们看下源码就知道了。可以看到在代码最后一行,的确是右移了 3 位。


inline void 

objc_object::initIsa(Class cls, bool nonpointer, bool hasCxxDtor) {

  // 省略部分代码

 newisa.bits = ISA_MAGIC_VALUE;

  // isa.magic is part of ISA_MAGIC_VALUE

  // isa.nonpointer is part of ISA_MAGIC_VALUE

 newisa.has_cxx_dtor = hasCxxDtor;

// 这里已经右移了 3 位
 newisa.shiftcls = (uintptr_t)cls >> 3;

}

这样我们就完成了 class 地址的获取。关于实例验证部分,可参照我写的这篇文章。接下来下一步,跳转到 LGetIsaDone,进行缓存的查找 CacheLookup

缓存查找

缓存结构

由于对象的实例方法存储在所属的类中,那么必定方法缓存相关的也在类里面。我们先看下类 objc_class 的定义,类同样也是个对象,继承于 objc_object


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

...

}

我们可以看到,第三项 cache 就是缓存信息。缓存查找也就是查找这个变量中的信息。cache_t 的定义如下:


struct cache_t {

// 包含 mask 和 buckets,mask 是高 16 位,剩余 48 位是 buckets 地址。
 explicit_atomic _maskAndBuckets;

 mask_t _mask_unused;

...

}

_maskAndBucketsmaskbuckets 的信息存放在了一起。

  • buckets 是哈希表,每一项包含了 sel 和相应的 imp。

  • mask,表示 buckets 表的总大小,它在高 16 位。

结构如下图所示:

image

其中 buckets 中每一项 bucket_t 定义如下:


struct bucket_t {

private:

 explicit_atomic _imp;

 explicit_atomic _sel;

}

了解了这些后,你是不是也能大致猜到缓存是如何查找的呢?

查找过程

首先点击这里查看完整的过程,代码比较长就不贴出来了。(注:我将代码中与 arm64 无关部分删除了)。

这段稍微有点长,别担心,下面我们来逐一分析。

首先我们明确下在这个状态下寄存器中的数据情况,即 x1 = SEL, x16 = isa


// p11 = [x16 + CACHE],取出 x16+CACHE 地址中的数据,8 字节
ldr  p11, [x16, #CACHE]

上面我们提到过,ldr 是取数据指令。它表示从 x16 寄存器中偏移 CACHE 的位置取出一个 8 字节数据,放入 p11 中。x16 中存放的是 class 地址。在 objc-msg-arm64.s 中,CACHE 的定义如下,2 个指针大小,也就是 16 字节。


#define CACHE (2 * __SIZEOF_POINTER__)

为什么要偏移 16 字节呢?我们再次来看下 objc_object 的定义。


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

...

}

从定义可以看出,cache 的偏移是 16 字节,在 ISAsuperclass 之后。

因此,x16 + #CACHE 正好指向 cache_t。此时再取出 8 字节的内容,就得到了 cache_t 结构的第一项 _maskAndBuckets。忘记的同学可往上翻看 cache_t 的定义。

这样,p11 中存放的是 _maskAndBuckets 的值。


// p10 = p11 & 0x0000ffffffffffff,取出 buckets 地址
and p10, p11, #0x0000ffffffffffff

由于 buckets 是低 48 位,将 p11 进行与运算得到 buckets 地址。


// 前 16 位是 mask,表示缓存表总共有多少项,x1 里面是 _cmd,根据 _cmd & mask 求出 _cmd 在表中对应的 index。
and p12, p1, p11, LSR #48  // x12 = _cmd & mask

LSR 表示逻辑右移,即 p11 右移 48 位后,再与 p1 进行与运算。若进行步骤拆分,可表示为如下:


// 得到 mask 的值
p11 = p11 >> 48

// 求出 index
p12 = p1 & p11

这时候,p11 是 mask 的值,也就是总表项大小。p1 是 _cmd_cmd & mask 是为了求出 _cmd 在表中的索引,也就等同于 _cmd % mask


// PTRSHIFT = 3,表中每一项大小为 16 字节,左移 4 位,相当于乘以 16。获取 index 对应项的地址,放到 x12。
// p12 = buckets + ((_cmd & mask) << (1+PTRSHIFT))
add  p12, p10, p12, LSL #(1+PTRSHIFT)

这步是为了求出索引在缓存表中的表项地址。

LSL 表示逻辑左移,PTRSHIFT = 3,也就是 p12 <<= 4,相当于乘以 16。因为表中每项大小为 16 字节。而 p10 表示缓存表地址,加上偏移量,就可得到索引项地址,再放入 p12 中。


// 从定位到的表项地址中,取出 2 个 8 字节数据放到 p17, p9 中。其中 p17 里是 imp,p9 里是 sel。
ldp  p17, p9, [x12] // {imp, sel} = *bucket

ldpLoad Register Pair 的缩写,它表示从 x12 所表示的地址中取出 2 个 8 字节数据,分别放入 p17、p9 中。从 bucket_t 的定义,我们可以得知缓存表中的每一项是 {imp, sel}。正好得到 p17 = imp,p9 = sel


// 比较缓存中的 sel 和传入的 _cmd
1:  cmp  p9, p1 // if (bucket->sel != _cmd)

// 不相等,跳转到 2
b.ne  2f  //  scan more

// 命中缓存,调用 imp
CacheHit $0  // call or return imp

接着,将 p9 中的 sel 与传入的 _cmd 进行比较,如果不相等,则跳转到 2 处理,继续扫描;如果相等,则表示命中缓存。这里我们先不看 2 的处理,紧接着看看缓存命中的实现。

缓存命中

命中缓存会调用 CacheHit,其实现代码如下(删除了无关代码):


// 命中缓存
.macro CacheHit

// 调用 imp
TailCallCachedImp x17, x12, x1, x16  // authenticate and call imp

.endmacro

其实,这里的处理很简单,就是进一步调用 TailCallCachedImp,此时各个寄存器值如下。


x17 = 缓存 imp

x12 = 查找到的缓存项地址

x1 = sel

x16 = class

TailCallCachedImp 的实现如下:


.macro TailCallCachedImp

// $0 = cached imp, $1 = address of cached imp, $2 = SEL, $3 = isa
eor  $0, $0, $3

br  $0

.endmacro

最终使用 br 指令调用 $0,br 表示有返回的跳转,可认为是 x86 中的 call 指令。而 $0 是查找到的缓存 imp。这样缓存命中的分支就走完了,最终调用 imp。

缓存表扫描过程

当第一步缓存未命中时,又是如何处理呢?这步会复杂一些,因为涉及到两次缓存表扫描。

  1. 从索引对应的缓存项不断向上查找,直到表头。

  2. 当到达表头后,继续从表尾开始全表扫描,直至重新回到表头。

过程如下图所示:

image

前面我们说到,当未命中时,会跳转到 2 去继续扫描缓存表。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

首先用 CheckMiss 检查缓存表项是否为空。

  • 如果为空,则进行 __objc_msgSend_uncached 的查找。

CheckMiss 实现如下:


// 检查缓存项是否为空
.macro CheckMiss

// 检查 p9 中的 sel 是否为空,若为空,则跳转到 __objc_msgSend_uncached,再进行缓存未命中的查找
cbz  p9, __objc_msgSend_uncached

.endmacro

cbz 判断 p9 是否为空,p9 表示 sel。也就是说,缓存表中这一项是空的,会进行 c 方法的慢查找。

  • 如果不为空,则会进行如下判断。

cmp  p12, p10

b.eq  3f

p10 是缓存表地址,这里判断当前缓存表项是否是表头。如果不是,则循环往前遍历缓存表,不断的进行比较。


ldp  p17, p9, [x12, #-BUCKET_SIZE]!  // {imp, sel} = *--bucket

// 循环进行比较
b  1b // loop

ldp 最后跟了一个 ,它表示将 x12 减去 BUCKET_SIZE,然后写回到 x12 中。分步表示如下:


x12 -= BUCKET_SIZE

ldp p17, p9, [x12]

若当前缓存表项是表头时,会跳转到 3 进行如下处理:


3: // wrap: p12 = first bucket, w11 = mask

// p12 = buckets + (mask << 1+PTRSHIFT)

add  p12, p12, p11, LSR #(48 - (1+PTRSHIFT))

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

将 p12 指向表尾,然后从表尾向表头遍历比较。


add  p12, p12, p11, LSR #(48 - (1+PTRSHIFT))

这句可能不太好理解,下面来解释一下。

因为 mask 是高 16 位,需右移 48 位得到 mask 大小。而每项大小是 16 字节,需左移 4 位得到整个表偏移,因此总共需右移 44 位。分步表示如下:


mask = p11 >> 48

offset = mask << 4

那么为什么在缓存表项是表头时,需要再次扫描缓存表呢?代码中有如下注释:


// Clone scanning loop to miss instead of hang when cache is corrupt.

// The slow path may detect any corruption and halt later.

重复扫描是为了处理缓存被破坏时的情况。

因为正常情况下,缓存表中,要么是有效数据,要么是空表项,这两种结果都会退出循环。而当缓存破坏时,会存在三种结果,有效数据/无效数据/空表项。当为无效数据时,肯定与 sel 不匹配。假设在极端情况下,缓存被破坏,这样会导致一直查找到表头都是无效数据项。

所以苹果工程师们,做了这样一种补救措施。当第一次从定位的缓存表项反向扫描到表头后,重新从表尾开始扫描,进行全表查找。当第二次再次扫描到表头时,就会跳转到 JumpMiss,表示缓存未命中,进入慢查找过程更新缓存。

最后看下这个过程的完整代码:


1:  cmp  p9, p1 // if (bucket->sel != _cmd)

// 不相等,跳转到 2
b.ne  2f  //  scan more

// 命中缓存,调用 imp
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: // wrap: p12 = first bucket, w11 = mask

add  p12, p12, p11, LSR #(48 - (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

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

// 相等,跳转到 3
b.eq  3f

// 从当前表项开始,继续往上找上一项
ldp  p17, p9, [x12, #-BUCKET_SIZE]!  // {imp, sel} = *--bucket

// 然后再次跳转到 1,进行循环比较
b  1b  // loop

LLookupEnd$1:

LLookupRecover$1:

3: // double wrap

JumpMiss $0

从代码中可以看到上下分别有 1、2、3 标签。下面的 1、2 跟上面的 1、2 处理过程很类似,只不过下面的 2 跳转到 3 的处理有点不一样。


3: // double wrap

JumpMiss $0

下面 2 中同样是判断表项与表头是否相等,若相等,表示已经遍历完全表,但仍未找到缓存,则跳入到 JumpMiss 的处理。同样,JumpMiss 会调用 __objc_msgSend_uncached


// 缓存未命中的处理
.macro JumpMiss

// 调用 __objc_msgSend_uncached,进行缓存未命中的查找
b  __objc_msgSend_uncached

.endif

缓存未命中处理

缓存未命中时,都会走到 __objc_msgSend_uncached 去处理。

__objc_msgSend_uncached 的实现很简单,调用 MethodTableLookup 进行方法查找。


// 缓存未命中的查找

STATIC_ENTRY __objc_msgSend_uncached

UNWIND __objc_msgSend_uncached, FrameWithNoSaves

// THIS IS NOT A CALLABLE C FUNCTION

// Out-of-band p16 is the class to search

// 开始方法查找过程
MethodTableLookup

TailCallFunctionPointer x17

END_ENTRY __objc_msgSend_uncached

MethodTableLookup 的实现如下:


// 方法查找
.macro MethodTableLookup

// 保存寄存器
SAVE_REGS

// lookUpImpOrForward(obj, sel, cls, LOOKUP_INITIALIZE | LOOKUP_RESOLVER)
// receiver and selector already in x0 and x1

// 第 3 个参数是 cls,x16 中保存了 cls
mov  x2, x16

// LOOKUP_INITIALIZE = 1, LOOKUP_RESOLVER = 2, 两者或运算 = 3
mov  x3, #3

// 调用 _lookUpImpOrForward 进行查找,最后查找到的 imp 放到 x0 中
bl  _lookUpImpOrForward

// IMP in x0
// 将 imp 放到 x17
mov  x17, x0

// 恢复寄存器
RESTORE_REGS

.endmacro

它主要做了如下事情:

  • 保存寄存器

  • 设置 lookUpImpOrForward 调用所需的参数,函数调用时前 8 个参数放在 x0 ~ x7 中。

  • 调用 lookUpImpOrForward 进行方法查找

  • 由于返回结果是放在 x0 中,之前缓存查找结果的 imp 是放在 x17 中,这里保持一致

  • 恢复寄存器

所以最终是调用到 lookUpImpOrForward 去进行方法的查找,在 objc-runtime-new.mm 中有相应的实现。

通过 lookUpImpOrForward 查找到结果后,会调用 TailCallFunctionPointer x17 来完成最后一步使命。

TailCallFunctionPointer 实现如下,可见它也只是执行 br 指令,调用传入的 imp


.macro TailCallFunctionPointer

// $0 = function pointer value
br  $0

.endmacro

到这里,完整的流程就走完了。但是,还未完,在文章开头我们跳过了 Tagged pointernil 的处理,即跳转到 LNilOrTagged

Tagged Pointer 处理

为什么 Tagged Pointer 要单独拎出来呢?由于 Tagged Pointer 的特殊性,它本身不是个指针,而是存储了实际的数据,其 class 地址的获取方式跟普通对象不一样,所以需要单独处理。

Tagged Pointer 分为 Extend Tagged PointerBasic Tagged Pointer,并且两者的内存布局不太一样,这无疑又增加了复杂度,需分别处理。

Tagged Pointer

下面先简要介绍一下 Tagged Pointer,它是一项在 64 位下节省内存空间的技术。当使用一些小对象,比如 NSNumberNSDate 时,可能它们的值用不着 64 位来表示。如果使用普通对象的存储方式,需要分配的内存空间 = 指针 8 字节 + 对象大小,会很浪费空间。这时候,我们可以做一些优化,在 64 位中分配一些位用于存储数据,然后做一些标记,表示它非普通对象的指针。此时,它不再是一个指针,不指向任何内存地址,而是真实的值。当然,如果数值较大,还是会用普通对象存储。

判断 Tagged Pointer

在代码的最开头部分,判定 self ≤ 0 跳入 LNilOrTagged 的处理。我们说当小于 0 时,表示是 Tagged Pointer。这一点,从源码中可以找到答案。


static inline bool 

_objc_isTaggedPointer(const void * _Nullable ptr)
{

 return ((uintptr_t)ptr & _OBJC_TAG_MASK) == _OBJC_TAG_MASK;

}

arm64 下,_OBJC_TAG_MASK 定义如下。


#define _OBJC_TAG_MASK (1UL<<63)

那么根据上述代码,我们可以得知:

在 arm64 下,当最高位为 1 时,则为 Tagged Pointer

另外,Tagged Pointer 中还会区分是否为 Extend Tagged Pointer,实现在 objc-object.h 中。


inline bool 

objc_object::isExtTaggedPointer() 
{

 uintptr_t ptr = _objc_decodeTaggedPointer(this);

 return (ptr & _OBJC_TAG_EXT_MASK) == _OBJC_TAG_EXT_MASK;

}

其中,_OBJC_TAG_EXT_MASK 定义如下,在 objc-internal.h


#define _OBJC_TAG_EXT_MASK (0xfUL<<60)

若高 4 位是全为 1,则表示 Extend Tagged Pointer

class index

Tagged Pointer 中记录了对象指向的 class 的索引信息,可根据索引到 Tagged Pointer Table 中查找到对应的 class。索引的信息的布局根据是否为 Extend Tagged Pointer 有所不同。

  • 如果是普通的 Tagged Pointer,高 4 位为索引,到 Tagged Pointer Table 中查找 class

  • 如果是 Extend Tagged Pointer,由于高 4 位都为 1,那么接下来的 8 位表示索引,到 Extend Tagged Pointer Table 表中查找。

对象布局如下图所示:

image

获取 index 的方式,我们从通过源码中 objc-object.h 可以得到验证。下面代码中 slot 就代表 index。


#define _OBJC_TAG_SLOT_SHIFT 60

#define _OBJC_TAG_SLOT_MASK 0xf

#define _OBJC_TAG_EXT_SLOT_SHIFT 52

#define _OBJC_TAG_EXT_SLOT_MASK 0xff

inline Class 

objc_object::getIsa() 
{

 if (fastpath(!isTaggedPointer())) return ISA();

 extern objc_class OBJC_CLASS_$___NSUnrecognizedTaggedPointer;

 uintptr_t slot, ptr = (uintptr_t)this;

 Class cls;

// 右移 60 位,获取高 4 位
 slot = (ptr >> _OBJC_TAG_SLOT_SHIFT) & _OBJC_TAG_SLOT_MASK;

 cls = objc_tag_classes[slot];

// 如果是 extend tagged pointer,则获取到的 cls 的特殊的 ___NSUnrecognizedTaggedPointer
 if (slowpath(cls == (Class)&OBJC_CLASS_$___NSUnrecognizedTaggedPointer)) {

// 获取 extend 的索引,右移 52 位后,取低 8 位
 slot = (ptr >> _OBJC_TAG_EXT_SLOT_SHIFT) & _OBJC_TAG_EXT_SLOT_MASK;

 cls = objc_tag_ext_classes[slot];

 }

 return cls;

}

下面将其拆分来解释一下。


#define _OBJC_TAG_SLOT_SHIFT 60

#define _OBJC_TAG_SLOT_MASK 0xf

// 右移 60 位,获取高 4 位
slot = (ptr >> _OBJC_TAG_SLOT_SHIFT) & _OBJC_TAG_SLOT_MASK;

这一步获取 index。将指针右移 60 位,得到高 4 位地址,然后跟掩码 0xf 做与运算。


cls = objc_tag_classes[slot];

objc_tag_classes 表中获取到 cls。


// 如果是 extend tagged pointer,则获取到的 cls 的特殊的 ___NSUnrecognizedTaggedPointer
if (slowpath(cls == (Class)&OBJC_CLASS_$___NSUnrecognizedTaggedPointer)) {

 ...

}

接着判断是否为 OBJC_CLASS_$___NSUnrecognizedTaggedPointer。

为什么要判断呢 __NSUnrecognizedTaggedPointer ?在 NSObject.mm 中有这样一段注释。大致意思是它是作为旧调试器的占位符,当检查 extend tagged pointer 时,得到的 cls 会是 __NSUnrecognizedTaggedPointer


// Placeholder for old debuggers. When they inspect an 

// extended tagged pointer object they will see this isa.

@interface __NSUnrecognizedTaggedPointer : NSObject

@end

前面我们说过高 4 位是 tagged pointer 的索引。当全为 1 时,则表示是 extend tagged pointer。所以用了一个占位的 cls 来表示是 extend 类型。

如果是 __NSUnrecognizedTaggedPointer,表明它是 Extend Tagged Pointer,需要再取出 extend index


#define _OBJC_TAG_EXT_SLOT_SHIFT 52

#define _OBJC_TAG_EXT_SLOT_MASK 0xff

// 获取 extend 的索引,右移 52 位后,取低 8 位
slot = (ptr >> _OBJC_TAG_EXT_SLOT_SHIFT) & _OBJC_TAG_EXT_SLOT_MASK;

cls = objc_tag_ext_classes[slot];

这一步,指针右移 52 位,与上 0xff,获取 8 位的索引。最后从 objc_tag_ext_classes 表中获取到 cls。

这一段的源码分析对于理解下一节中的流程处理很有帮助,因为汇编也是按照这个方式来处理的。

处理流程

LNilOrTagged 的处理如下:


LNilOrTagged:

// 如果是 nil,跳转 LReturnZero 处理
b.eq  LReturnZero  // nil check

// tagged
// 获取 _objc_debug_taggedpointer_classes 表地址,放入 x10
adrp  x10, _objc_debug_taggedpointer_classes@PAGE

add  x10, x10, _objc_debug_taggedpointer_classes@PAGEOFF

// 从 x0 中,提取 60 ~ 63 位,也就是 索引值,放入 x11
ubfx  x11, x0, #60, #4

// 从表中取出索引对应的项,也就是 class 地址,放入 x16。由于每项为 8 字节,所以左移 3 位
ldr  x16, [x10, x11, LSL #3]

// 获取 _OBJC_CLASS_$___NSUnrecognizedTaggedPointer 地址
adrp  x10, _OBJC_CLASS_$___NSUnrecognizedTaggedPointer@PAGE

add  x10, x10, _OBJC_CLASS_$___NSUnrecognizedTaggedPointer@PAGEOFF

// 将取出的 class 地址与 NSUnrecognizedTaggedPointer 地址进行比较
cmp  x10, x16

// 不相等,则跳回主流程,进行缓存查找或者方法查找
b.ne  LGetIsaDone

// ext tagged
// 如果相等,那么表示它是 extend tagged pointer,取出 _objc_debug_taggedpointer_ext_classes 地址放到 X10
adrp  x10, _objc_debug_taggedpointer_ext_classes@PAGE

add  x10, x10, _objc_debug_taggedpointer_ext_classes@PAGEOFF

// 从 x0 中,提取 52 ~ 59 位,得到索引值
ubfx  x11, x0, #52, #8

// 获取 class 的地址
ldr  x16, [x10, x11, LSL #3]

// 跳回主流程
b  LGetIsaDone

// SUPPORT_TAGGED_POINTERS

我们来一步步分析:


// 如果是 nil,跳转 LReturnZero 处理
b.eq  LReturnZero  // nil check

判定 self 与 0 的比较结果是否相等,是则跳转 nil 处理。


// 获取 _objc_debug_taggedpointer_classes 表地址,放入 x10
adrp  x10, _objc_debug_taggedpointer_classes@PAGE

add  x10, x10, _objc_debug_taggedpointer_classes@PAGEOFF

这里通过 adrpadd 两条指令,获取 _objc_debug_taggedpointer_classes 的地址。因为 arm64 指令是固定长度 32 位,操作数中不能放下 64 位的地址。因此先用 adrp 来获取地址的高 32 位部分,然后再加上低 32 位,放入到 x10。


// 从 x0 中,提取 60 ~ 63 位,也就是 索引值,放入 x11
ubfx  x11, x0, #60, #4

ubfx 是字节提取指令,从第 60 位开始,总共提取 4 位。这里 x0 = self,从 x0 中提取高 4 位,放入 x11。


// 从表中取出索引对应的项,也就是 class 地址,放入 x16。由于每项为 8 字节,所以左移 3 位
ldr  x16, [x10, x11, LSL #3]

ldr 指令我们应该比较熟悉,上面用到很多。_objc_debug_taggedpointer_classes 表项大小为 8, 所以 x11 左移 3 位,计算相对于表头的偏移,然后将地址中的数据放到 x16 中。这里就获取到了 class 地址。


// 获取 _OBJC_CLASS_$___NSUnrecognizedTaggedPointer 地址
adrp  x10, _OBJC_CLASS_$___NSUnrecognizedTaggedPointer@PAGE

add  x10, x10, _OBJC_CLASS_$___NSUnrecognizedTaggedPointer@PAGEOFF

// 将取出的 class 地址与 NSUnrecognizedTaggedPointer 地址进行比较
cmp  x10, x16

// 不相等,则跳回主流程,进行缓存查找或者方法查找
b.ne  LGetIsaDone

这里,以同样的方式获取到 _OBJC_CLASS_$___NSUnrecognizedTaggedPointer 的地址。用来判定是否是 extend tagged pinter

如果不是,则跳回主流程,执行我们前面部分讲解的过程。


// 如果相等,那么表示它是 extend tagged pointer,取出 _objc_debug_taggedpointer_ext_classes 地址放到 X10
adrp  x10, _objc_debug_taggedpointer_ext_classes@PAGE

add  x10, x10, _objc_debug_taggedpointer_ext_classes@PAGEOFF

// 从 x0 中,提取 52 ~ 59 位,得到索引值
ubfx  x11, x0, #52, #8

// 获取 class 的地址
ldr  x16, [x10, x11, LSL #3]

// 跳回主流程
b  LGetIsaDone

否则表明它是 extend tagged pointer,得从 _objc_debug_taggedpointer_ext_classes 获取 class 地址。

extend tagged pointer 的索引位在高 4 位后面接下来的 8 位,也就是 52 ~ 59 位中,进行上述类似的提取过程,然后获取 class 的地址,放到 x16 中,跳回主流程继续处理。

nil 处理

nil 的处理如下,比较简单,将可能用于存储函数返回值的寄存器清 0。


LReturnZero:

// x0 is already zero
// 将寄存器清 0
mov  x1, #0

movi  d0, #0

movi  d1, #0

movi  d2, #0

movi  d3, #0

ret

x0 和 x1 用来存储整形返回值,v0 ~ v3 用来存储浮点型返回值,d0 ~ d3 表示其低 32 位。

总结

这里我将 objc_msgSend 的流程大致捋了一遍,包括 class 查找、缓存查找、缓存未命中的处理、taggedPointer 和 nil 处理。对于 class 查找的过程,是比较核心的一部分。不同类型的对象有着不同的查找方式,相信如果弄懂了这部分,对于对象结构的布局会有进一步的理解。

另外,看 objc_msgSend 的源码对于学习 arm64 汇编的基础指令也是一种比较好的途径,因为大部分同学对于 x86 的指令会熟悉一些。虽然汇编起初会让人觉得云里雾里,还没看就放弃。但是如果一句句读下来,你会发现它和平常我们写的代码逻辑也没啥两样。

最后,希望这篇文章能带给你一些不同的知识。

参考资料

  • https://www.mikeash.com/pyblog/friday-qa-2017-06-30-dissecting-objc_msgsend-on-arm64.html

  • http://madmark.cc/2017/08/01/ARM64_objc-msgSend/

  • runtime 源码:[email protected]:0xxd0/objc4.git

你可能感兴趣的:(arm64 objc_msgSend 源码解读)