Auther:Mike Ash
总览
- 每一个 OC 对象都有一个类,并且每个 OC 类都有一个方法列表。每个方法都有一个指向实现的函数指针和一些元数据的选择器。
objc_msgSend
的任务是把对象和选择器传入并查找相应方法的函数指针,然后跳转到这个函数指针指向的位置。 - 查找方法的过程是很复杂的。如果一个方法没有在一个类上被找到,之后它需要在父类中继续寻找。如果父类都没有找到,就需要调用运行时消息转发。如果这是指定类第一次接受消息,那么它将调用那个类的 initialize 方法。
- 通常查找一个方法也需要非常的迅速,因为它需要被每个方法所调用。当然,这与复杂的查找过程是相冲突的。
- Objective-C 对于这个冲突的解决方式是方法缓存。每个类有一个缓存,它将方法存储为选择器和函数指针对。在 Objective-C 中被称为 IMPs。它们为了被查找的更快被组成了一个哈希表。当需找一个方法时,首先会查询缓存,如果方法没在缓存中,它会遵循缓慢且复杂的过程,之后将查找的结果放入缓存以便于下一次能够更快的查找。
objc_msgSend
是使用汇编写的,有两点原因:一点是在 C 中不可能来写一个函数保存未知的参数并且可以去跳转到任意的函数指针。C 语言没有必要来做这样的事情。另一个原因是因为对于objc_msgSend
来说它需要更快来执行。 当然,你不想用汇编语言写整个复杂的信息查找程序。那是没有必要的,并且,事情是缓慢的无论从哪一刻开始。消息发送的代码可以分为两部分:快速路径是使用objc_msgSend
本身汇编语言编写的部分,慢路径使用 C 语言来实现的。汇编的部分是在缓存中查找方法如果找不到就会跳转。如果方法没有在缓存中,之后就会调用 C 代码来处理事情。- 因此,在
objc_msgSend
查找的时候,会遵循以下几点:- 1、获取传入对象的类
- 2、从类中获取方法的缓存
- 3、使用传入的 selector 来在缓存中寻找方法
- 4、如果没在缓存中,调用 C 的方法。
- 5、跳转方法对应的实现 IMP
它如何工作?让我们来瞅瞅!
一个指令接着一个指令来分析
objc_msgSend
根据不同的情况有一些不同的处理路径。它对于处理像消息为空、标记指针和哈希表碰撞这样的事情有特殊的代码。我们先来看看普通情况(消息非空,非标记指针并且方法可以在缓存中找到不需要其他的扫描)。当我们看完通常的情况后在回过头看其他情况。- 我将列出每条指令或一组指令和它所做的描述和原因。请注意我将会在列出来的指令下面做描述。
- 每个指令前面是基于函数开始的偏移量。这被用来作为一个计数,可以让你明确跳转的目标代码。
- ARM64 有 31 个 64 位的寄存器。他们提供的符号从 x0 到 x30。它也可以使用 w0 到 w30 来访问寄存器的低 32 位。寄存器 x0 到 x7 被用来接收一个函数传递进来的前八个参数。这意味着
objc_msgSend
在 x0 接受 self 参数并且在 x1 中接收选择器 _cmd 参数。 - 让我们开始吧!
0x0000 cmp x0, #0x0
0x0004 b.le 0x6c
- 这里对 self 和 0 做了带符号的比较,当值小于或者等于 0 的时候会跳转到其他地方(0x6c)。值为 0 代表空,所以这里处理了消息为空的特殊情况。这也会处理标记指针。标记指针在 ARM64 中通过设置高位指针来表示。 (这是一个和 x86-64 有意思的对比, x86-64 是设置在低位。) 如果高位被设置了1,当被作为有符号整数时,值就为负。通常情况下 self 如果是正常的指针,不会进入这些分支。
0x0008 ldr x13, [x0]
- 这句命令通过读取 x0 64 位指针来读取 self 的 isa 指针,现在 x13 寄存器现在包含了 isa 。
0x000c and x16, x13, #0xffffffff8
- ARM64 能使用非指针的 isa。通常 isa 指向对象的类,但是非指针 isa 有更多的比特来在 isa 存放额外的信息。这条命令执行了一个 AND 来分离所有额外的位,并且将剩下的真实的类指针存放在 x16 寄存器中。
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;
}
- 根据 ldp 命令, x10 保存了 _buckets 的值,x11 在它的高32位保存了 _occupied,将 _mask 保存在它的低32位。
- occupied 表示哈希表有多少条目,对于
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
- 光得到索引是不够的。为了从表中读取数据,我们需要一个真实的地址来加载。这条命令通过表索引加上表指针来计算这个地址。它将表索引向左移4位,相当于乘以16,因为每个表的bucket是16字节,x12 现在包含了第一个查找bucket的地址。
0x001c ldp x9, x17, [x12]
我们的朋友 ldp 又出现了。这回它读取了 x12 中指针,这个指针指向了要查找的bucket。每个bucket包含了一个选择器和一个 IMP 。 x9 现在包含了当前bucket的选择器, x17 包含了 IMP。
代码语言:javascript复制0x0020 cmp x9, x1
0x0024 b.ne 0x2c
这些命令将在 x9 中的选择器和在 x1 中的 _cmd 进行了比较。如果他们不相等那么这个bucket不包含我们正在寻找的选择器的条目,在这种情况下第二条命令跳转偏移地址 0x2c,这个命令负责处理不相等的情况。如果结果匹配,那么我们就找到了我们寻找的条目,然后会继续执行下一条命令。
代码语言:javascript复制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 处理了以上的情况,其余的情况,将会执行以下的语句。
代码语言:javascript复制0x0038 ldp x9, x17, [x12, #-0x10]!
另一个 ldp,又从缓存的 bucket 中读取。这次它从当前缓存 bucket 偏移 0x10 的地址开始读取。在地址最后的感叹号是一个有趣的特性。这代表了一个寄存器可回写,意味着这个寄存器被更新了新的计算的值。在这种情况下,它除了读取新的 bucket 还有效的做了 x12 -= 16 的操作,使得 x12 指向了新的 bucket 。
代码语言:javascript复制0x003c b 0x20
现在新的 bucket 已经被读取了,继续执行的代码会检查当前的 bucket 是否匹配。这个循环回到上面的 0x0020,然后使用新值再一次执行代码。如果它没有找到匹配的 bucket ,代码将会保持运行知道它找到匹配的,一个空的 bucket ,或者命中表的开始。
代码语言:javascript复制0x0040 add x12, x12, wll, uxtw #4
这是搜索的目标。x12 包含一个指向当前 bucket 的指针,这个 bucket 在当前情况下还是第一个块。w11 包含表的 mask,mask 代表表的大小。这两个叠加在一起,将 w11 左移4位,相当于乘以16。现在的结果是 x12 现在指向表的结尾,从这里可以继续查找。
代码语言:javascript复制0x0044 ldp x9, x17, [x12]
ldp 将一个新的 bucket 加载到了 x9 和 x17 中。
代码语言:javascript复制0x0048 cmp x9, x1
0x004c b.ne 0x54
0x0050 br x17
这段代码检查了 x9 和 x1 是否匹配并且跳转到 bucket 的 IMP。这是上面 0x0020 的重复代码。
代码语言:javascript复制0x0054 cbz x9, __objc_msgSend_uncached
就像以前一样,如果 bucket 是空的那么它是没有缓存的,之后会执行用 C 语言来实现的全面的查找代码中。
代码语言:javascript复制0x0058 cmp x12, x10
0x005c b.eq 0x68
再检查一遍是否已经到表头,如果再次命中表头会跳转到 0x68 。在这种情况下,它会跳转到 C 的全面查找代码中:
代码语言:javascript复制0x0068 b __objc_msgSend_uncached
实际上这是不应该发生的。随着表条目不断被添加,它从来没有100%的填充满。哈希表变得低效当条目过多时,因为碰撞变得太频繁。 这是为什么呢?源代码中的注释解释道:
当缓存被破坏时,扫描将会错过而不是挂起。 缓慢路径可能会检测到破坏,并在之后停止。
- 我怀疑这是常见的,但显然当苹果的朋友们发现由于内存的损坏导致缓存充满坏的条目,之后跳到 C 代码来提高了诊断。
- 这个检查必须对代码有很小的影响并且不会受损坏的影响。除此之外,原来的循环是可重用的,这将节约一些指令的缓存空间,但是影响是微小的。这个概括的处理并不是常见的情况。它将仅仅被哈希表开始时被排序的选择器调用,然后只有当有一个碰撞并且之前所有的条目都被占用。 两次检查是为了防止由于内存损坏或无效对象造成的无限循环进而产生性能耗尽。举个例子,堆损坏可以在缓存中填充满非0的数据,或将缓存的掩码设为0,像堆这样如果不命中或者丢弃就会一直循环扫描下去。额外的检查可以停止这样的循环转而使用崩溃日志。 还有另一种情况就是在第一次扫描过程中同时有另一个线程在修改缓存能够让这个线程不命中也不丢弃。C 代码为了解决竞争来做额外的工作。之前的一个版本的
objc_msgSend
错误的处理了它 - 它立即终止而不是返回到 C 代码中,这样做运气不好的话会发生罕见的崩溃.
0x0060 ldp x9, x17, [x12, #-0x10]!
0x0064 b 0x48
这个循环的剩余部分是一样的。读取下一个 bucket 到 x9 和 x17 中,刷新在 x12 中的块的指针,并且回到循环的顶部。 这就是 `objc_msgSend` 主体的结尾。剩下的是对 nil 和 标记指针特殊的处理。
标记指针的处理
- 你会记得非常靠前的一个指令用来检查空指针和标记指针的判断,如果是则会跳转偏移量 0x6c 处理它们。让我们继续从那里开始:
0x006c b.eq 0xa4
- 比 0 小代表一个标记指针,0 代表空。这两种情况的处理方法是完全不同的,所以首先代码在这里会先检查 self 是否为空。如果 self 等于0 那么这个命令将会跳转到 0xa4,这里来处理nil 的情况。其他情况,它是标记指针的情况,将会继续执行下一个命令。
- 在我们往下之前,让我们简要的讨论一下标记指针。标记指针支持多种类。标记指针的前4位(在ARM64 中)代表对象的类。它们实际上是标记指针的 isa。当然,4位不够保存一个类的指针。实际上,有一个特殊的表来存放可用的标记指针类。查找标记指针对象的类是通过查找表的索引与这个对象的前4位是否对应。
- 这不仅仅是全部。标记指针(至少在 ARM64 中)也指针额外的类。当前四位都被设置为1 那么下 8 位 被用来作为标记指针类表的扩展索引。这也让运行时去支持更多的标记指针类,减少更多的存储成本。 让我们继续:
0x0070 mov x10, #-0x1000000000000000
这里将前四位全部设置为1并且其他位设置为0。这将作为掩码方便 self 中获取标记。
代码语言:javascript复制0x0074 cmp x0, x10
0x0078 b.hs 0x90
这些是为了检查了扩展的标记指针。如果 self 大于等于 x10 中的值,那么这意味着前四位都被设置了。在这种情况下,会跳转到 0x90 来处理扩展类。否则,使用标记指针主表。
代码语言:javascript复制 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 加载后面的。
代码语言:javascript复制0x0084 lsr x11, x0, #60
x0 的前四位保存了标记指针的索引。如果需要把它用于索引,则需要将其右移 60 位,这样它就变成一个0-15的整数了。这个指令执行了位移并将索引放到 x11 中。
代码语言:javascript复制 0x0088 ldr x16, [x10, x11, lsl #3]
这句命令读使用 x11 中的索引来读取 x10 指向的表中的条目。x16 寄存器现在包含这个类的标记指针。
代码语言:javascript复制0x008c b 0x10
根据在 x16 中的类,我们能够返回到我们的主代码。代码从偏移量为 0x10 代码开始,使用 x16 中的类执行后续的操作。
代码语言:javascript复制 0x0090 adrp x10, _objc_debug_taggedpointer_ext_classes@PAGE
0x0094 add x10, x10, _objc_debug_taggedpointer_ext_classes@PAGEOFF
扩展的标记类处理方式是一样的。这两个命令读取了指向扩展表的指针。
代码语言:javascript复制0x0098 ubfx x11, x0, #52, #8
这个命令读取了扩展类的序号。它提取了 self 中 52 位中的开始的8位存入到了 x11 中。
代码语言:javascript复制0x009c ldr x16, [x10, x11, lsl #3]
像之前一样,这个索引需要在表中查找类,并存入 x16 中。
代码语言:javascript复制 0x00a0 b 0x10
根据在 x16 中的类,它可以返回到主代码。 这基本是所有的事情了。剩下的是关于 nil 的处理。
关于 nil 的处理
- 最后我们来看一下 nil 的处理。
0x00a4 mov x1, #0x0
0x00a8 movi d0, #0000000000000000
0x00ac movi d1, #0000000000000000
0x00b0 movi d2, #0000000000000000
0x00b4 movi d3, #0000000000000000
0x00b8 ret
- 对于 nil 的处理是完全不同于其他的代码。没有类的查找或者方法调度。所有对于 nil 的处理就是返回给调用者 0。
- 这实际上对于那些关心返回值是什么的调用者来说会有些麻烦因为
objc_msgSend
不知道返回的是什么值。这个方法会返回一个整数或两个或者浮点值,又或者其他的? 幸运的是,所有用于返回值的寄存器都能够被安全的覆盖,即使他们没有被用于这次特定的调用者的返回值。整型的返回值被保存在 x0 和 x1 中,浮点数返回值被保存在向量寄存器 v0 到 v3 中。还有多个寄存器被用于返回更小的结构。 - 上面的代码清除了 x1,以及 v0 至 v3。d0 至 d3 指的是对应的 v 寄存器的底部后半部分,存储在其中可以清除前半部分,所以4条 movi 指令的作用就是清空这4个寄存器。然后将控制权返回给调用者。 你可能想知道为什么不清除 x0。答案很简单:x0 保存了 self,在这种情况下它已经是0!你可以节省一条清零的指令。
- 对于寄存器不够存储的更大的结构体怎么办?这就需要调用者间的合作。通过调用者来分配足够多的内存存储大型的结构体,并将内存地址传入 x8。函数通过写入这块内存来返回值。
objc_msgSend
不能清除这块内存,因为它不知道返回值到底有多大。为了解决这个问题,编译器生成的代码会在调用objc_msgSend
之前用 0 填满这块内存。 - 这就是 nil 处理的结尾,也是整个
objc_msgSend
的结尾。
对于 objc_msgSend() 代码有一点不够清晰就是缓存中也包含了 "负" 的缓存值来记录 misses 的缓存。有许多代码调用了
respondsToSelector
方法,其返回值为 NO(主要作为响应层的一部分)。如果没有缓存这些失败的查找,你将在 misses 上花费比命中更多的时间。
原文连接