objc_msgSend
的任务是把对象和选择器传入并查找相应方法的函数指针,然后跳转到这个函数指针指向的位置。objc_msgSend
是使用汇编写的,有两点原因:一点是在 C 中不可能来写一个函数保存未知的参数并且可以去跳转到任意的函数指针。C 语言没有必要来做这样的事情。另一个原因是因为对于 objc_msgSend
来说它需要更快来执行。 当然,你不想用汇编语言写整个复杂的信息查找程序。那是没有必要的,并且,事情是缓慢的无论从哪一刻开始。消息发送的代码可以分为两部分:快速路径是使用 objc_msgSend
本身汇编语言编写的部分,慢路径使用 C 语言来实现的。汇编的部分是在缓存中查找方法如果找不到就会跳转。如果方法没有在缓存中,之后就会调用 C 代码来处理事情。objc_msgSend
查找的时候,会遵循以下几点: objc_msgSend
根据不同的情况有一些不同的处理路径。它对于处理像消息为空、标记指针和哈希表碰撞这样的事情有特殊的代码。我们先来看看普通情况(消息非空,非标记指针并且方法可以在缓存中找到不需要其他的扫描)。当我们看完通常的情况后在回过头看其他情况。objc_msgSend
在 x0 接受 self 参数并且在 x1 中接收选择器 _cmd 参数。0x0000 cmp x0, #0x0
0x0004 b.le 0x6c
0x0008 ldr x13, [x0]
0x000c and x16, x13, #0xffffffff8
0x0010 ldp x10, x11, [x16, #0x10]
objc_msgSend
中我最喜欢的命令。它读取了类的缓存信息并将其放到了 x10 和 x11 寄存器中。ldp 命令将两个寄存器的数据从内存加载到两个参数命名的寄存器中。第三个参数描述了从哪里来读取数据,在这种情况下是 x16 的值再偏移 16 位上,这里存放着类的缓存信息。缓存结构看起来像这样:typedef unit32_t mask_t;
struct cache_t {
struct bucket_t * _buckets;
mask_t _mask;
mask_t _ocuupied;
}
objc_msgSend
来说不是很重要。mask 是重要的:它描述了哈希表的尺寸,方便用于与运算。它的值经常是一个2的幂减1,用二进制来标识就是像 0000000001111111 这样的后面以一堆 1 结尾的数。这个值指出了选择器的索引,当搜索表的时候可以包裹结尾。0x0014 and w12, wl, w11
objc_msgSend(id _Nullable self, SEL _Nonnull op, ...)
_cmd
的选择器的哈希表的开始索引。x1 保存了 _cmd
,所以 w1 拥有 cmd 的低 32 位。 w11 包含了上面提到的 _mask。这个命令将两个值做与操作并且将结果存入了 w12 中。这个结果就是计算 _cmd % tablesize 但是并没有进行高昂的模运算。0x0018 add x12, x10, x12, lsl #4
0x001c ldp x9, x17, [x12]
我们的朋友 ldp 又出现了。这回它读取了 x12 中指针,这个指针指向了要查找的bucket。每个bucket包含了一个选择器和一个 IMP 。 x9 现在包含了当前bucket的选择器, x17 包含了 IMP。
0x0020 cmp x9, x1
0x0024 b.ne 0x2c
这些命令将在 x9 中的选择器和在 x1 中的 _cmd 进行了比较。如果他们不相等那么这个bucket不包含我们正在寻找的选择器的条目,在这种情况下第二条命令跳转偏移地址 0x2c,这个命令负责处理不相等的情况。如果结果匹配,那么我们就找到了我们寻找的条目,然后会继续执行下一条命令。
0x0028 br x17
这是一个无条件跳转命令,跳转到 x17,包含了从当前 bucket 加载的 IMP 。从这里开始,将继续执行实际的目标方法的实现,并且这是快捷路径的 objc_msgSend
的结尾。所有的参数寄存器都不会干扰,目标方法将会接受所有传递进来的参数就像直接调用它一样。 当所有的信息被缓存之后,在现代的硬件上这个路径的执行时间不到3纳秒。 这就是快速路径,剩下的代码怎么办呢?让我们继续看看没有匹配上的 bucket。
0x002 cbz x9, __objc_msgSend_uncached
x9 包含了从 bucket 中读取的选择器,这条命令比较了它和0并且如果它不是0的话会跳转到 __objc_msgSend_uncached
。 这说明一个0的选择器代表一个空的 bucket ,并且一个空的 bucket 意味着这次查找是失败的。目标方法没有在缓存中,是时候回到 C 代码来进行一次详细的查找了。__objc_msgSend_uncached
处理了这种情况。否则, 说明 bucket 不是空的就是不匹配,会继续查找。
0x0030 cmp x12, x10
0x0034 b.eq 0x40
这条命令比较了在 x12 中当前 bucket 的地址和开始的在 x10 中的哈希表的开头。如果他们匹配,就跳转到搜索哈希表末端后执行代码的位置。我们还没有见过,但这里的哈希表查找执行实际上向后运行。查找索引会逐步减小索引直到表的开头,然后重新开始。我不清楚它为什么这样做而不是使用通常的从头增加地址,但是可以肯定的是,这样执行的更快。(缓存增序查找需要额外的一条或者两条命令来计算缓存的结尾在哪里。缓存的开头已经知道了,它是我们从类中加载的指针,所以我们降序查找。 ) 偏移量 0x40 处理了以上的情况,其余的情况,将会执行以下的语句。
0x0038 ldp x9, x17, [x12, #-0x10]!
另一个 ldp,又从缓存的 bucket 中读取。这次它从当前缓存 bucket 偏移 0x10 的地址开始读取。在地址最后的感叹号是一个有趣的特性。这代表了一个寄存器可回写,意味着这个寄存器被更新了新的计算的值。在这种情况下,它除了读取新的 bucket 还有效的做了 x12 -= 16 的操作,使得 x12 指向了新的 bucket 。
0x003c b 0x20
现在新的 bucket 已经被读取了,继续执行的代码会检查当前的 bucket 是否匹配。这个循环回到上面的 0x0020,然后使用新值再一次执行代码。如果它没有找到匹配的 bucket ,代码将会保持运行知道它找到匹配的,一个空的 bucket ,或者命中表的开始。
0x0040 add x12, x12, wll, uxtw #4
这是搜索的目标。x12 包含一个指向当前 bucket 的指针,这个 bucket 在当前情况下还是第一个块。w11 包含表的 mask,mask 代表表的大小。这两个叠加在一起,将 w11 左移4位,相当于乘以16。现在的结果是 x12 现在指向表的结尾,从这里可以继续查找。
0x0044 ldp x9, x17, [x12]
ldp 将一个新的 bucket 加载到了 x9 和 x17 中。
0x0048 cmp x9, x1
0x004c b.ne 0x54
0x0050 br x17
这段代码检查了 x9 和 x1 是否匹配并且跳转到 bucket 的 IMP。这是上面 0x0020 的重复代码。
0x0054 cbz x9, __objc_msgSend_uncached
就像以前一样,如果 bucket 是空的那么它是没有缓存的,之后会执行用 C 语言来实现的全面的查找代码中。
0x0058 cmp x12, x10
0x005c b.eq 0x68
再检查一遍是否已经到表头,如果再次命中表头会跳转到 0x68 。在这种情况下,它会跳转到 C 的全面查找代码中:
0x0068 b __objc_msgSend_uncached
实际上这是不应该发生的。随着表条目不断被添加,它从来没有100%的填充满。哈希表变得低效当条目过多时,因为碰撞变得太频繁。 这是为什么呢?源代码中的注释解释道:
当缓存被破坏时,扫描将会错过而不是挂起。 缓慢路径可能会检测到破坏,并在之后停止。
objc_msgSend
错误的处理了它 - 它立即终止而不是返回到 C 代码中,这样做运气不好的话会发生罕见的崩溃.0x0060 ldp x9, x17, [x12, #-0x10]!
0x0064 b 0x48
这个循环的剩余部分是一样的。读取下一个 bucket 到 x9 和 x17 中,刷新在 x12 中的块的指针,并且回到循环的顶部。 这就是 `objc_msgSend` 主体的结尾。剩下的是对 nil 和 标记指针特殊的处理。
0x006c b.eq 0xa4
0x0070 mov x10, #-0x1000000000000000
这里将前四位全部设置为1并且其他位设置为0。这将作为掩码方便 self 中获取标记。
0x0074 cmp x0, x10
0x0078 b.hs 0x90
这些是为了检查了扩展的标记指针。如果 self 大于等于 x10 中的值,那么这意味着前四位都被设置了。在这种情况下,会跳转到 0x90 来处理扩展类。否则,使用标记指针主表。
0x007c adrp x10, _objc_debug_taggedpointer_classes@PAGE
0x0080 add x10, x10, _objc_debug_taggedpointer_classes@PAGEOFF
这一小段读取了 objcdebugtaggedpointerclasses 的地址,它是标记指针主表。ARM64 需要两个命令来读取一个符号的地址。这是一个类似 RISC 架构的标准技术。在 ARM64 中指针是64位的,然而指令仅仅是32位的。一句指令是无法保存一个完整的指针。 x86 没有这个问题,因为它有可变长的指令。它可以仅仅使用 10 字节的指令,其中 2 字节用来区分指令本身和目标寄存器,8 字节用来保存指针值。 在一个固定长度指令的机器上,需要分块加载。在这种情况下,我们分为两块。adrp 指令读取值的头部,add 加载后面的。
0x0084 lsr x11, x0, #60
x0 的前四位保存了标记指针的索引。如果需要把它用于索引,则需要将其右移 60 位,这样它就变成一个0-15的整数了。这个指令执行了位移并将索引放到 x11 中。
0x0088 ldr x16, [x10, x11, lsl #3]
这句命令读使用 x11 中的索引来读取 x10 指向的表中的条目。x16 寄存器现在包含这个类的标记指针。
0x008c b 0x10
根据在 x16 中的类,我们能够返回到我们的主代码。代码从偏移量为 0x10 代码开始,使用 x16 中的类执行后续的操作。
0x0090 adrp x10, _objc_debug_taggedpointer_ext_classes@PAGE
0x0094 add x10, x10, _objc_debug_taggedpointer_ext_classes@PAGEOFF
扩展的标记类处理方式是一样的。这两个命令读取了指向扩展表的指针。
0x0098 ubfx x11, x0, #52, #8
这个命令读取了扩展类的序号。它提取了 self 中 52 位中的开始的8位存入到了 x11 中。
0x009c ldr x16, [x10, x11, lsl #3]
像之前一样,这个索引需要在表中查找类,并存入 x16 中。
0x00a0 b 0x10
根据在 x16 中的类,它可以返回到主代码。 这基本是所有的事情了。剩下的是关于 nil 的处理。
0x00a4 mov x1, #0x0
0x00a8 movi d0, #0000000000000000
0x00ac movi d1, #0000000000000000
0x00b0 movi d2, #0000000000000000
0x00b4 movi d3, #0000000000000000
0x00b8 ret
objc_msgSend
不知道返回的是什么值。这个方法会返回一个整数或两个或者浮点值,又或者其他的? 幸运的是,所有用于返回值的寄存器都能够被安全的覆盖,即使他们没有被用于这次特定的调用者的返回值。整型的返回值被保存在 x0 和 x1 中,浮点数返回值被保存在向量寄存器 v0 到 v3 中。还有多个寄存器被用于返回更小的结构。objc_msgSend
不能清除这块内存,因为它不知道返回值到底有多大。为了解决这个问题,编译器生成的代码会在调用 objc_msgSend
之前用 0 填满这块内存。objc_msgSend
的结尾。对于 objc_msgSend() 代码有一点不够清晰就是缓存中也包含了 "负" 的缓存值来记录 misses 的缓存。有许多代码调用了
respondsToSelector
方法,其返回值为 NO(主要作为响应层的一部分)。如果没有缓存这些失败的查找,你将在 misses 上花费比命中更多的时间。