最近参照 MikeAsh 的这篇文章,看了 arm64
下 obj_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 主流程
流程介绍
整体流程如下:
我将源码拆分成了如下 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
第一步,将 self
和 0
比较,以便下一步进行判定走哪种分支处理。
#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
ldr
是 Load 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;
...
}
_maskAndBuckets
将 mask
和 buckets
的信息存放在了一起。
buckets
是哈希表,每一项包含了 sel 和相应的 imp。mask
,表示buckets
表的总大小,它在高 16 位。
结构如下图所示:
其中 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 字节,在 ISA
和 superclass
之后。
因此,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
ldp
是 Load 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。
缓存表扫描过程
当第一步缓存未命中时,又是如何处理呢?这步会复杂一些,因为涉及到两次缓存表扫描。
从索引对应的缓存项不断向上查找,直到表头。
当到达表头后,继续从表尾开始全表扫描,直至重新回到表头。
过程如下图所示:
前面我们说到,当未命中时,会跳转到 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 pointer
和 nil
的处理,即跳转到 LNilOrTagged
。
Tagged Pointer 处理
为什么 Tagged Pointer
要单独拎出来呢?由于 Tagged Pointer
的特殊性,它本身不是个指针,而是存储了实际的数据,其 class 地址的获取方式跟普通对象不一样,所以需要单独处理。
而 Tagged Pointer
分为 Extend Tagged Pointer
和 Basic Tagged Pointer
,并且两者的内存布局不太一样,这无疑又增加了复杂度,需分别处理。
Tagged Pointer
下面先简要介绍一下 Tagged Pointer
,它是一项在 64 位下节省内存空间的技术。当使用一些小对象,比如 NSNumber
、NSDate
时,可能它们的值用不着 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
表中查找。
对象布局如下图所示:
获取 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
这里通过 adrp
和 add
两条指令,获取 _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