objc_msgSend理解分析

475 阅读17分钟

说到objc_msgSend可能大家都知道这是runtime的核心代码,是用来发送消息的,但你真的了解objc_msgSend吗?你清楚objc_msgSend是如何完成自己的职责吗?你清楚如何和arm处理器如何协作的吗?下面让我们一起进入objc_msgSend的世界。

资深开发者可以忽略该部分:

我们简单来了解下objc_msgSend是用来做什么的?每一个Objective-C对象都有一个类,并且每一个Objective-C类都有一个方法列表(objc_method_list方法链表中存放的是该类的成员方法(-方法),类方法(+方法)存在meta-class的objc_method_list链表中。)。每一个方法都有一个选择器(SEL),函数指针(IMP)和元数据。objc_msgSend的工作就是获取传入的对象和选择器,查找到相对应方法的函数指针,然后跳到对应的函数指针。

那么objc_msgSend是如何查找方法的呢?它的查找方式是这样的

1)如果在当前类上找不到方法。

2)那么就去它的父类中搜索。

3)如果还是找不到任何方法,则需要调用运行时的消息转发代码。

4)如果这是发送给特定类的第一条消息,则它必须调用该类的+initialize方法。

在通常情况下,我们需要查找方法执行非常快,因为它是针对每个方法调用完成的。那么,这可能会引发我们思考: 查找的过程这么复杂,如何保证快速的查找速度呢?

Objective-C给出的解决方法就是:方法缓存。每个类都有一个高速缓存,该高速缓存将方法存储为成对的选择器和函数指针,在Objective-C中称为IMPs。它们采用的是哈希表的数据结构,因此查找速度很快。在查找方法时,会在运行时先查询缓存,如果该方法不在缓存中,它将遵循缓慢且复杂的过程,然后再将结果放入高速缓存中,以便下次可以更快。

那么又引发一个思考,objc_msgSend是用什么语言来优化查找呢?答案就是objc_msgSend底层是用汇编编写的。这有两个原因:一个是不能编写一个函数既包含未知参数还能够跳转到C语言中任意函数指针。 另一个原因是objc_msgSend的运行速度非常重要(汇编执行速度很快)。

其中消息转发可以分为两个部分:一部分是objc_msgSend本身自己的快速路径(汇编编写的部分),另一部分是慢速路径(用C实现)。如果它是在汇编方式找到的缓存中的方法就跳转它。如果方法不在缓存中,它将调用C代码来处理事件。

因此,在查看objc_msgSend时,它会执行以下操作:

  1. 获取传入的对象的类。
  2. 获取该类的方法缓存。
  3. 使用传入的选择器在缓存中查找方法。
  4. 如果不在缓存中,则调用C代码。
  5. 跳转至IMP方法。

那么它是如何做到所有这些的?让我们来看看!

objc_msgSend可以根据情况采用不同的方式。它有特殊的命令去处理比如nil,标记指针,哈希表之类的冲突。我们将从最常见的线性情况开始,例如消息发送给一个非nil,非标记指针,并且无需扫描即可在缓存中找到的方法。

我们将列出每条指令或一组指令,然后说明其作用。

首先我们需要了解每条指令都是以函数开始的偏移量为先导。它的作用就像一个计数器,让你能够明确跳转对象。

我们先了解下ARM64具有31个64位的整数寄存器。用x0到x30来表示。它也能被任何低32位寄存器适用,作为一个单独的寄存器,用w0通过w30表示。寄存器x0到x7用于函数的前八个参数传递。也就是说objc_msgSend接收self参数在x0,选择器_cmd参数在x1。

让我们开始进入正题。

0x0000 cmp     x0, #0x0
0x0004 b.le    0x6c

这段代码向我们展示了self和有符号0的比较,它的意思是如果该值小于或等于零,就跳转到别处。因为0的值为nil,所以它会走特殊情况处理为nil。同时也处理标记的指针。通过设置指针的高位来指示ARM64上的标记指针。(与x86_64的低位是一个有趣的对比。)如果设置了高位,则当解析有符号整型时,该值为负。通常情况下,对于self作为普通指针,不会被采用。

0x0008 ldr    x13, [x0]

表示该self是isa通过加载所指向的64位数量x0(其中包含)来加载的。x13寄存器包含了isa。

0x000c and    x16, x13, #0xffffffff8

ARM64可以使用非指针isa。isa通常指向对象的类,但非指针isa相比于isa可以将一些其它的信息塞入空闲的字节空间。该指令执行逻辑是用来屏蔽所有额外的字节空间,并将实际的类指针保留在寄存器x16中。

0x0010 ldp    x10, x11, [x16, #0x10]

这是我最喜欢的objc_msgSend指令。它将类的缓存信息加载到x10和x11中。该ldp指令就是将两个寄存器在内存中有价值的数据加载到用前两个参数命名的寄存器中。第三个参数描述了要去哪加载数据,在这个例子当中是从x16偏移16位,在类的这个区域当中保存缓存信息。缓存本身看起来像这样:

typedef uint32_t mask_t;

struct cache_t {
    struct bucket_t *_buckets;
    mask_t _mask;
    mask_t _occupied;
}

按照ldp指令,x10包含_buckets的值,x11包含_occupied在它的高32位_mask在它的低32位。

_occupied具体规定了哈希表包含多少个成员,哪些在objc_msgSend中不起作用。_mask很重要:它用方便的位掩码描述了哈希表的大小。它的值始终是2的幂减一,或者是用末尾是1的有效数字看起来像是000000001111111这样的二进制表示。需要它的值来找出选择器的查找索引,在搜索表时绕回到末尾。

0x0014 and    w12, w1, w11

该指令计算传递过来的选择器在哈希表的起始索引作为_cmd。因为x1包含_cmd,所以w1包含底部的32位_cmd。w11包含如上所述的_mask。该指令将两者进行位与运算,并将结果放入w12。结果等同于计算: _cmd % table_size但没有高级的取模运算。

0x0018 add    x12, x10, x12, lsl #4

索引不够的时候。要开始从表中加载数据,加载自实际地址。该指令通过将表索引添加到表指针来计算该地址。它首先将表索引左移4位,再乘以16,因为每个表存储区都是16字节。x12现在包含要搜索的第一个存储区的地址。

0x001c ldp    x9, x17, [x12]

ldp有另一种表现形式。这次它加载的是x12中的指针。每个存储桶区都包含一个选择器和一个IMP。x9现在包含当前存储区的选择器,x17包含IMP。

0x0020 cmp    x9, x1
0x0024 b.ne   0x2c

这些指令把将x9中存储区的选择器与x1中的_cmd进行比较。如果它们不相等,则此存储区不包含我们要查找的选择器的对象,在这种情况下,第二条指令跳转到偏移0x2c的地址,该指令处理不匹配的存储区。如果选择器确实匹配,那么我们已经找到了要查找的对象,并继续执行下一条指令。

0x0028 br    x17

该指令会无条件跳转到x17,x17包含从当前存储区中加载的IMP。从这里开始,执行将继续在目标方法的实际实现中进行,这是objc_msgSend的快速实现的终点。所有的参数寄存器都保持不变,因此目标方法将接收所有传入的参数,就像直接调用它一样。

当所有内容都被缓存并且总是对齐时,objc_msgSend的实现可以在不到3纳秒的时间内执行完成。

针对快速实现已经说明完毕,但是objc_msgSend还有其他的代码做什么呢?让我们继续学习当存储区不匹配的时候代码做了什么。

0x002c cbz    x9, __objc_msgSend_uncached

x9包含从存储区加载的选择器。该指令将其与零进行比较,如果为零则跳转至__objc_msgSend_uncached。选择器为零表示空的存储桶,而空的存储桶则表示搜索失败。目标方法不在缓存中,是时候回到执行更全面查找的C代码了。__objc_msgSend_uncached处理这些。否则存储区将不匹配,但它还不为空,所以搜索将继续。

0x0030 cmp    x12, x10
0x0034 b.eq   0x40

该指令将存储在x12中存储区地址与存储在x10哈希表的首地址进行比较。如果它们匹配,它将跳转到将搜索回绕到哈希表末尾的代码。这里执行的哈希表搜索实际上是向后运行的。搜索过程将检查逐渐递减的索引,直到它到达表的开头,然后从末尾开始。

偏移0x40处理环绕情况。否则,执行进行到下一条指令。

0x0038 ldp    x9, x17, [x12, #-0x10]!

另一个ldp,再次加载缓存区。这次,它从当前缓存存储区的地址偏移0x10加载。在地址末尾的感叹号是一个有趣的功能。这表示寄存器回写,这意味着用新计算的值更新寄存器。在这种情况下,x12 -= 16除了加载新存储区(x12指向新存储区)之外,它还可以有效地进行工作。

0x003c b      0x20

现在已经加载了新的存储区,可以使用检查当前存储桶是否匹配的代码继续执行。这将循环回到0x0020上面标记的指令,并使用新值再次遍历所有代码。如果它继续找到不匹配的存储区,则此代码将一直运行,直到找到一个匹配项,一个空存储区或命中表的开头。

0x0040 add    x12, x12, w11, uxtw #4

这是搜索结束时的目标。x12包含指向当前存储区的指针,在当前情况下,它也是第一个存储区。w11包含表格掩码,即表格的大小。这将两者相加,同时还w11向左偏移4位,再乘以16。结果是x12现在指向表的末尾,然后开始搜索。

0x0044 ldp    x9, x17, [x12]

ldp将新存储桶加载到x9和中x17。

0x0048 cmp    x9, x1
0x004c b.ne   0x54
0x0050 br     x17

此代码检查存储桶是否匹配并跳转到存储区的IMP。这是0x0020上面代码的重复。

0x0054 cbz    x9, __objc_msgSend_uncached

就像以前一样,如果存储区为空,那么就是缓存未命中,就是用C的代码去实现。

0x0058 cmp    x12, x10
0x005c b.eq   0x68

这将再次循环检查,直到我们在一起名字表的开头就跳转到0x68。在这种情况下,就会从C的实现去查找:

0x0068 b      __objc_msgSend_uncached

这是不应该发生的事情。该表随着实体的添加而增长,并且永远不会100%充满。当哈希表太满时,哈希表将变得效率低下,冲突就会变得很常见。

源代码中的注释说明:

缓存损坏时,克隆扫描循环会丢失而不是挂起。C语言实现的过程可能会检测到任何损坏并稍后停止。

显然苹果公司的人们已经看到内存损坏,导致缓存中填充了错误的实体对象,而跳转到C代码中可以改善诊断。

此检查的存在对没有遭受损坏的代码的影响应该最小。没有它,原始循环可以被重用,这将节省一些缓存空间,但是效果很小。无论如何,这种环绕处理程序并不常见。仅对在哈希表的开头附近进行排序的选择器调用此函数,然后仅在发生冲突且所有先前实体对象都被占用时才调用它。

0x0060 ldp    x9, x17, [x12, #-0x10]!
0x0064 b      0x48

此循环的其余部分与以前相同。将下一个存储区加载到x9和x17区,在更新指向x12的存储区指针,然后返回循环顶部。

objc_msgSend的主要部分到此为止。剩下的是特殊情况nil和带标记的指针。

标记的指针处理程序

我们从最开始最初检查指令,并跳转到偏移 0x6c进行处理那里继续:

0x006c b.eq    0xa4

如果self小于或等于零就会执行这里。小于零表示标记的指针,零为nil。这两种情况的处理方式完全不同,因此代码在这里所做的第一件事是检查,看看是否self为nil。如果self等于零则该指令分支0xa4,nil处理的地方。否则,它是一个带标记的指针,继续执行下一条指令。

在移动之前,让我们简要讨论标记指针的工作方式。标记指针支持多个类。带标记的指针的前四位(在ARM64上)指示“对象”属于哪个类。它们本质上是标记指针的isa。当然,四位不足以容纳一个类指针。相反,有一个特殊的表存储可用的标记指针类。通过在该表中查找对应于前四位的索引,可以找到标记的指针“对象”的类。

这还不是全部。标记指针(至少在ARM64上)还支持扩展类。当高4位全部设置为1,那么接下来的8位将用于索引扩展的标记指针类表。这允许运行时以较少的存储空间为代价,支持更多带标记的指针类。

让我们继续。

0x0070 mov    x10, #-0x1000000000000000

这将设置x10的前4位为一个整数值,而所有其他位被设置为0。这将用作从self中提取标记位的掩码。

0x0074 cmp    x0, x10
0x0078 b.hs   0x90

这将检查扩展的标记指针。如果self大于或等于x10中的值,则意味着前4位都已设置。在这种情况下,0x90将处理扩展类的分支。否则使用标记指针表。

0x007c adrp   x10, _objc_debug_taggedpointer_classes@PAGE
0x0080 add    x10, x10, _objc_debug_taggedpointer_classes@PAGEOFF

该指令载入了_objc_debug_taggedpointer_classes的地址,用来标记指针表。ARM64需要两条指令来加载符号的地址。这是类似RISC的体系结构。ARM64上的指针为64位,而指令仅为32位。不可能将整个指针放入一条指令中。

x86不受此问题的困扰,因为它具有可变长度的指令。它只能使用10个字节的指令,其中两个字节标识指令本身和目标寄存器,八个字节保存指针值。

在具有定长指令的机器上,您可以分段加载该值。在这种情况下,只需要两块。该adrp指令将加载值的顶部,add然后将其添加到底部。

0x0084 lsr    x11, x0, #60

带标签的类索引位于x0的前4位。要将其用作索引,必须将其右移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

该指令加载扩展的类索引。它提取52位self开始的8位到x11中。

0x009c ldr    x16, [x10, x11, lsl #3]

与之前一样,该索引用于在表中查找类并将其加载到x16。

0x00a0 b      0x10

使用x16中的类,它可以回到主代码。 With the class in x16, it can branch back into the main code.

剩下的就是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的寄存器的底半部,存储到他们清除上半部分,所以四个movi指令的效果是清除那四个寄存器。完成此操作后,它将控制权返回给调用方。

您可能想知道为什么此代码无法清除x0。这个问题的答案很简单:x0拥有self,在这种情况下slef就是nil,所以它已经是零!可以通过不清除x0来保存指令,因为它已经拥有我们想要的值。

较大的结构体返回值怎么不适合寄存器呢?这需要和调用者协作。大的结构体返回值通过使调用者分配足够的内存,然后将该内存的地址传递到中,来执行x8中的内存地址。然后,该函数写入该内存以返回一个值。objc_msgSend无法清除此内存,因为它不知道返回值有多大。为了解决这个问题,编译器会在调用objc_msgSend之前生成用零填充内存的代码。

这就是nil处理程序以及objc_msgSend整个过程的结束。

以上,就是我所整理的objc_msgSend的内容,欢迎大家指正,以下是我参考的资料内容:

  1. iOS Class结构分析
  2. 深入iOS系统底层之CPU寄存器
  3. iOS高级调试&逆向技术-汇编寄存器调用
  4. objc_message寄存器