原文:https://www.mikeash.com/pyblog/friday-qa-2017-06-30-dissecting-objc_msgsend-on-arm64.html
我们回来了!WWDC 期间,我在 CocoaConf Next Door 上发言,其中一个专题的内容是关于 objc_msgSend
在 ARM64 架构中的实现。所以决定将其撰写成一篇文章并加入至 Friday Q&A 专题中。
每一个 Objective-C 对象都拥有一个类,每个类都有自己的方法列表。每个方法都拥有选择子、一个指向实现的函数指针和一些元数据(metadata)。objc_msgsend
的工作是使用对象和选择子来查询对应的函数指针,从而跳转到该方法的位置中。
查找的方法可能十分复杂。如果一个方法在当前类中无法查询,那么可能需要在其父类中继续查询。当在父类中也无法找到,则开始调用 runtime 中的消息转发机制。如果这是发送到该类的第一条信息,那么它将会调用该类的 +initialize
方法。
一般情况下,查找的方法需要迅速完成。这与其复杂的查找机制似乎是矛盾的。
Objective-C 解决这个矛盾的方法是利用方法缓存(Method Cache)。每个类都有一个缓存,它将方法存储为一组选择子和函数指针,在 Objective-C 中被称为 IMP。它们被组织成哈希表的结构,所以查找速度十分迅速。当需要查找方法时,runtime 首先会查询缓存。如果结构不被命中,则开始那一套复杂的查询过程,并将结果存储至缓存,以便下次快速查询。
objc_msgSend
是使用汇编语言编写的。其原因是:其一是使用纯 C 是无法编写一个携带未知参数并跳转至任意函数指针的方法。单纯从语言角度来讲,也没有必要增加这样的功能。其二,对于 objc_msgSend
来说速度是最重要的,只用汇编来实现是十分高效的。
当然,我们也不希望所有的查询过程都是通过汇编来实现。一旦启用了非汇编语言那么就会降低速度。所以我们将消息分成了两个部分,即 objc_msgSend
的高速路径(fast path),此处所有的实现使用的是汇编语言,以及缓慢路径(slow path)部分,此处的实现手段均为 C 语言。在高速路径中我们可以查询方法指针的缓存表,如果找到直接跳转。否则,则使用 C 代码来处理这次查询。
因此,整个 objc_msgSend
的过程大体如下:
- 获取传入对象所属的类。
- 获取该类的方法缓存表。
- 使用传入的选择子在缓存中查询。
- 如果缓存中不存在,则调用 C 的慢速代码段。
- 跳转至
IMP
映射位置的方法。
具体是怎么实现的呢?下面开始分析。
objc_msgSend
不同的情况有不同的执行路径。从中包含了处理如消息为 nil
(messages to nil),Tagged pointer 以及哈希表冲突的特殊代码。首相,先来看一个最常见最直观的情况,即消息发送到 non-nil
和 non-tagged
的情况,并且该指定函数指针在哈希表中可以直接获得。这里我将会记录遇到这些判断节点的各种情况,之后当我们到达运行终点后在回头看看其他情况。
我将会列出每个指令(instruction)或指令集(group of instructions),然后讲述它的功能以及调用原因。仅仅去理解每一条提及的指令即可。
每个指令前面都有一个偏移地址。他们就好比一个量化器,来告知程序跳转的位置。
ARM64 有 31 个 64 位寄存器。他们的位置符号从 x0
到 x30
。当然也可以使用寄存器中 w0
到 w30
的低 32 位,他们也是可独立使用的。x0
到 x7
前八位用于传递一个函数。这意味着 objc_msgSend
在 x0
中接收到选择子,在 x1
中接收 _cmd
参数。
0x0000 cmp x0, #0x0
0x0004 b.le 0x6c
如果此处的值小于或等于 0,则 self 与 0 的比较,并在其他位置进行跳转。零代表着 nil
,因此这会使得转发的消息也赋为 nil
。这也是处理 Tagged Pointers
的方案。Tagged Pointers
在 ARM64 上通过在高位来存储数据。(不同于 x86-64 ,其是在低位进行存储。)当高位被占用时,则被解释为存储的是一个有符号整数,且为负。当仅仅是普通指针的情况,程序不会运行到此处。
0x0008 ldr x13, [x0]
译者注:ARM 中的
LDR
为加载指令。LDR R0,[R1]
的意思是 将存储器地址为 R1 的字数据读入存储器 R0。LDR
指令用于从存储器中将一个 32 (ARM64 架构则为 64 位,后者相同)位字数据传送到目的存储器中。该指令通常用于存储器中读取 32 位的字数据套通用寄存器中,然后对数据进行处理。
此处是通过 x0 寄存器指向的 64 位字数据来加载 self
的 isa。x13
寄存器现在则存储着 isa
指针。
0x000c and x16, x13, #0xffffffff8
ARM64 中使用 Non-pointer isa。在以前的做法中 isa
是指向对象的 Class,但是 Non-pointer 将额外信息也天重置 isa 中来充分利用位空间。这个指令用 AND 逻辑算数符以掩码形式过滤额外信息,并将 Class 信息保留至 x16
寄存器中。
0x0010 ldp x10, x11, [x16, #0x10]
这是 objc_msgSend
中我最喜欢的一条指令。它将 Class 信息缓存至 x10
和 x11
中。ldp
指令会从内存中获得两个寄存器的数据保存到前两个指定的寄存器中。第三个参数说明所加载的数据源,当前情况中,以 x16
为基准在偏移 16 位从而得到保存 Class 信息的位置。此结构用 C 语言描述如下:
typedef uint32_t mask_t; // 无符号形 32 位来描述空间位置
struct cache_t {
struct bucket_t *_buckets;
mask_t _mask;
mask_t _occupied;
}
ldp
指令之后,x10
寄存器中已经写入 _buckets
的值,x11
在它的高 32 为中保存了 _occupied
,低 32 位保存了 _mask
。
_occupied
代表哈希表中的元素的数量,在 objc_msgSend
过程中没有太大的作用。而 _mask
相对来说就比较重要了:即象征了哈希表的位数,而且又构造一个等位掩码从而方便进行 AND 运算。这个值往往可以用 2 的整数幂减一来表达,例如 000000001111111
这样的,最后一位是可变不定的。通过该值可以求得选择子的查询索引,并在搜索时仅取得低位。
0x0014 and w12, w1, w11
这条指令用于计算通过 _cmd
传入的选择子在哈希表中的起始索引。x1
用于记录 _cmd
,则 w1
中记录的是 _cmd
的低 32 位。w11
中包含了上面提到的 _mask
。这条指令将两个值做与运算再存入 w12
中。结果相当于 _cmd % table_size
,但是避免模运算的巨大开销。
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
。
0x0020 cmp x9, x1
0x0024 b.ne 0x2c
这两天命令首相对 x9
中的选择子和 x1
中的 _cmd
进行比较。如果它们不相等则说明这个 bucket 中不包含我们正在查询的选择子。此时调用第二条命令跳转至 0x2c
位置,处理 bucket 不匹配问题。但如果选择子命中,则查询成功,并执行接下来的步骤。
0x0028 br x17
BR 命令用于跳转到reg内容地址。
这是一个无条件的跳转命令,直接跳转至 x17
中记录的位置,包含之前从 bucket 中加载的 IMP
。从这开始,之后的部分就是目标方法的代码实现,此处也是 objc_msgSend
的 Fast Path 结束为止。所有持有参数的寄存器将不会受到读写干扰,目标方法会接受传入的全部参数,正如直接的函数调用一般。
当所有信息均被缓存后,在现在设备上一路执行下来仅需要 3 纳秒即可结束。
下面来关注一下当缓存中没有所需匹配内容时的情况。
0x002c cbz x9, __objc_msgSend_uncached
x9
中记录了从 bucket 加载到的选择子。首先先将其与 0 进行比较,如果等于 0 则会跳转至 __objc_msgSend_uncached
。这说明 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 中加载。这一次它从偏移量为 0x10
位置加载当前缓存的 bucket
地址。在地址引用标记末尾使用感叹号是一个有趣的特性。这指定一个寄存器进行回写,就是寄存器会更新为计算后的值。这条指令实际上是执行 x12 -=16
来加载新的 bucket,并使 x12
指向这个新的 bucket 地址。
0x003c b 0x20
已经加载到一个新的 bucket,下面继续检查更新后的 bucket 是否匹配。这条命令会返回上面 0x0020
位置,使用新的值在执行一次所有的代码。如果仍旧没有匹配,则继续执行之后的这些代码,或是 bucket 为空情况的处理,或是命中表头后的处理。
0x0040 add x12, x12, w11, uxtw #4
这是当查询到之后的操作。x12
中包含了当前 bucket 的指针,即指向第一个 bucket。w11
存有表的掩码,描述了表的大小。这里将两个值进行了相加运算,并将 w11
左移 4 位。此时 x12
中的结果是指向表末尾的,并且可以从此次恢复查询。
0x0044 ldp x9, x17, [x12]
使用 ldp
操作加载一个新的 bucket 至 x9
和 x17
中。
0x0048 cmp x9, x1
0x004c b.ne 0x54
0x0050 br x17
这段代码用来检查 bucket
是否匹配,并跳转至 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% 的装满。哈希表会随着内容的变多而逐渐降低效率,因为哈希碰撞的概率也会上升。
为什么这段代码会出现在这里?源码中的一个注释给出了解释:
Clone scanning loop to miss instead of hang when cache is corrupt. The slow path may detect any corruption and halt later. 当缓存被破坏时,循环扫描将会忽略而不是进入挂起状态(hang)。Slow Path 可能会检测到缓存错误,并在之后终止。
笔者猜测这是很常见的情况,显然苹果公司的开发者已经观测到了内存损耗这种情况,从而导致缓存中填满了很多垃圾内容,所以增加了这个跳转,到 C 的代码中进行诊断。
这个检测对于未损坏的缓存的查询效率影响最小。如果没有此部分,原始的循环可以复用,可以节省在 Fast Path 中指令的存储空间,但是这个影响不足以考虑。这个处理的状况十分少见。只有在哈希表的开始位置查询到所需的选择子才会被调用,或是发生了哈希碰撞时被调用。
0x0060 ldp x9, x17, [x12, #-0x10]!
0x0064 b 0x48
之后的余剩部分与前面也十分相同。加载 Bucket 到 x9
和 x17
,更新 x12
中的指针,并返回循环的开始。
这是 objc_msgSend
全部过程的终止。之后的处理是对于 nil
的情况或者是 Tagged Pointer 的情况。
在第一行的汇编代码中对 nil
和 Tagged Pointer 的情况进行了判断,如果存在则直接跳转至 0x6c
位置进行处理。下面开始跟踪查看这种情况:
0x006c b.eq 0xa4
执行到这里说明 self
的值小于等于 0。小于零则代表为 Tagged Pointer 情况,等于说明为 nil
。这两种情况处理起来也是有差异的,所以首先判断如果是 nil
则跳转至 0xa4
,否则继续执行下面的代码,处理 Tagged Pointer 情况。
在我们继续往下之前,简单讨论下 Tagged Pointer 是如何工作的。Tagged Pointer 支持多个类。Tagged Pointer 的前四位(ARM 64上)指明对象的类是哪个。本质上就是 Tagged Pointer 的 isa 。当然 4 位不够保存一个类的指针。实际上,有一张特殊的表存储了可用的 Tagged Pointer 的类。这个对象的类的查找是通过搜索这张表中的索引,是否对应于这个 Tagged Pointer 的前 4 位。
Tagged Pointer(在 AMR64 上)也支持扩展类。当前四位都设置为 1,接下去的 8 位用于索引 Tagged Pointer 扩展类的表。减少存储代价,就允许运行时能够持有更多的 Tagged Pointer 类。
下面继续。
0x0070 mov x10, #-0x1000000000000000
这里将 x10
设置成一个整型值,只有前四位被设置,其余位都为0。作为掩码用于从 self 中提取标签位。
0x0074 cmp x0, x10
0x0078 b.hs 0x90
这步检查是为了扩展的 Tagged Pointer。如果 self
大于等于 x10
的值,意味着前四位都被设置了。这种情况下会跳转到 0x90
,处理扩展类。否则,使用 Tagged Pointer 主表。
0x007c adrp x10, _objc_debug_taggedpointer_classes@PAGE
0x0080 add x10, x10, _objc_debug_taggedpointer_classes@PAGEOFF
这里加载了 _objc_debug_taggedpointer_classes
的地址,即 Tagged Pointer 主表。ARM64 需要两条指令来加载一个符号的地址。这是 RISC 样架构上的一个标准技术。AMR64 上的指针是 64 位宽的,指令是 32 位宽。所以一个指令无法保存一个完整的指针。
x86 不会遇到这种问题,因为他有可变长指令。它只能使用 10 字节的指令,两个字节用于标识指令自己,以及目标寄存器,8 个字节用于持有指针的值。
在一个固定长度指令的机器上,就需要分块加载。这里我们需要两块, adrp
指令加载前半部分的值,add
指令添加了后半部分。
0x0084 lsr x11, x0, #60
x0
的前四位保存了 Tagged Pointer的 索引。如果需要把它用于索引,则需要将其右移 60 位,这样它就变成一个 0-15 的整数了。这个指令执行了位移并将索引放到 x11
中。
0x0088 ldr x16, [x10, x11, lsl #3]
这里通过 x11
里的索引到 x10
所指向的表中查找条目。x16
寄存器现在包含了这个 Tagged Pointer 的类。
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
扩展的 Tagged 类执行起来也是一样的。这两条指令加载了指向扩展表的指针。
0x0098 ubfx x11, x0, #52, #8
这条指令加载了扩展类的索引。它从 self
中的第 52 位开始,提取 8 位,保存到 x11
中。
0x009c ldr x16, [x10, x11, lsl #3]
和之前一样,这个索引用于在表中查找类,并存入 x16
。
0x00a0 b 0x10
也是一样,回到 0x10
处的主逻辑代码。
之后是 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
中。还有其他的寄存器用于返回更小的 struct
。
上面的代码中清楚了 x1
,以及 v0
至 v3
。d0
至 d3
指的是对应的 v
寄存器的后半部分,存储在这里可以清楚前半部分,所以四个 movi
命令的作用是清除这四个寄存器。执行该操作后,将控制权返还给调用者。
那么为什么不清楚 x0
呢?很简单,x0
中存放的是 self
,而现在的情况是 self = nil
,所以他本身就是 0。这样又可以节省一条清零的指令。
对于寄存器不够存储的,更大结构的返回值会怎样?这需要调用者的一些合作。通过调用者来分配足够多的内存存储大型的结构体,并将内存地址传入 x8
。函数通过写入这块内存来返回值。objc_msgSend
不能清除这块内存,因为它不知道返回值到底有多大。为了解决这个问题,编译器生成的代码会在调用 objc_msgSend
之前用 0 填满这块内存。
以上就是 nil
的处理方法,以及 objc_msgSend
的全部流程。