原文连接:swift.gg/2018/08/06/…
做者:Mike Ash
译者:BigNerdCoding
校对:pmst,shanks
定稿:CMB
html
很高兴,我又回来了。在刚刚过去的 WWDC 期间,我在 CocoaConf Next Door 作个一个关于剖析 ARM64 上 objc_msgSend
运行流程的发言。如今我将整理后的内容从新发布到 Friday Q&A 上。swift
每一个 Objective-C 对象都会指向一个类,而每一个类又包含一个方法列表。每一个方法则由选择器(selector
)、函数指针和一些元数据(metadata
)构成。objc_msgSend
职责就是接收对象(object
)和选择器(selector
),根据选择器名称找到对应方法的函数指针并跳转执行该函数。缓存
查找过程相对来讲仍是比较复杂的。若某个方法在当前类中未找到,就须要沿着继承链继续在父类中查找。若是在父类中也未查询到的话,则会触发 runtime 机制中的消息转发机制。任何对象在接收到第一条消息后都会触发类方法 +initialize
。安全
由于每次方法调用都会触发上述流程,因此在常见场景下的查找速度必须很是快。显然这与复杂的操做过程之间存在必定冲突。数据结构
为了解决这对矛盾提升查询速度,Objective-C 采用了方法缓存策略。每一个类都会使用哈希表将其方法按照 Selector - IMPs(函数指针) 键值对关系缓存起来。这样在查询方法时,runtime 首先会直接去哈希表中查询。若是哈希表中不存在的话则转而执行原有复杂、缓慢的处理流程,并将最终结果缓存起来已备下次使用。架构
objc_msgSend
用汇编语言进行实现,具体理由有两个:首先纯 C 语言没法实现这么一个函数:接收不定个数且未知类型的参数做为入参跳转至任意函数指针(即调用实现);其次,执行速度对 objc_msgSend
来讲很是重要,汇编语言能最大化提高该项指标。框架
固然,使用汇编语言实现整个复杂的消息处理过程是不现实的,并且也没这种必要。由于有些流程一旦触发程序都会变慢,不管采用何种语言层面的实现。整个消息处理流程代码能够分为两个部分:经过汇编代码实现的快速路径部分(fast path) ,C 语言实现的慢路径流程(slow path)。其中汇编代码对应缓存表中查询方法部分而且未命中时跳转 C 代码来进行下一步处理。函数
所以,objc_msgSend
代码处理流程大体以下:oop
下面开始分析其具体实现。ui
objc_msgSend 在不一样情形下执行路径不尽相同。对于向 nil
发送消息,标记指针(tagged pointers),哈希表冲突会相应特殊代码中进行处理。下面我将经过最多见也是最简单的情形来解释 objc_msgSend
的执行,即处理 non-nil、non-tagged 消息而且哈希表也能命中该方法。我会在该过程当中标记出那些须要注意的处理路径岔路口,而后回过头来进行详细讲解。
我将列出单条或一组指令,而后在下面紧接相关解释内容。
每条指令前面都会有一个地址偏移量,能够将其看做一个指示跳转位置的标记量。
ARM64 架构中包含 31 个 64 位整型寄存器,对应符号表示为 x0 - x30 。每一个寄存器的低 32 位也能够经过 w0 到 w30 进行访问,就像它也是一个单独的寄存器。其中 x0 到 x7 被用来保存函数调用时的前 8 个参数。这意味着 objc_msgSend
函数中的 self
参数保存在 x0 而 _cmd
保存在 x1 。
起始指令以下:
0x0000 cmp x0, #0x0
0x0004 b.le 0x6c
复制代码
该段指令是将 self
与 0 进行有符号比较,若是 self
不大于 0 的话则会进行跳转处理。等于 0 其实就至关于 nil
对象,也就是说此时会调用向 nil 发送消息情形下对应的特定代码。另外,该指令也被用于标记指针(tagged pointers
)的处理。ARM64 经过设置最高位为 1 来标记 Tagged Pointers(x86-64 则是最低位),此时对应有符号数比为负。对于普通指针来讲,上述处理分支都会不被触发。
0x0008 ldr x13, [x0]
复制代码
该指令将 x0 中所表示的 self 的 isa 地址加载到 x13 寄存器中。
0x000c and x16, x13, #0xffffffff8
复制代码
由于 ARM64 架构下可以使用 non-pointer isas
技术,因此与以前相比 isa 字段不只能够包含指向 Class 的信息,它还能利用多余比特位存储其它有效信息(例如,引用计数)。这里经过 AND 逻辑运算去除低位的冗余信息获得最终的 Class 的地址并将其存入 x13 寄存器中。
0x0010 ldp x10, x11, [x16, #0x10]
复制代码
这是整个 objc_msgSend
处理流程中我最喜欢的指令。该指令会将 Class 中的方法缓存哈希表加载到 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 寄存器的高 32 位保存的是 _occupied
低 32 位则保存了 _mask
。
_occupied
表示哈希表中的元素的数量,在 objc_msgSend
处理过程当中没有太大的做用。而 _mask
则相对重要:它将哈希表大小描述为了一个便于进行与操做的掩码。_mask
值为 2^n - 1 ,换句话说它的二进制表示将以一组 1 做为结尾,形如 000000001111111 。该值为查询 selector 的哈希表索引以及标记表尾的必要条件。
0x0014 and w12, w1, w11
复制代码
该指令用于计算 _cmd
所传递过来的 selector
在哈希表中的起始位置。由于 _cmd
保存在 x1 寄存器中,因此 w1 寄存器则包含了 _cmd
的低 32 位信息。而 w11 寄存器保存了上面提到的 _mask
信息。经过 AND 指令咱们将这两个寄存器中数值与操做结果保存到 w12 寄存器中。计算结果至关于 _cmd % table_size
,可是它却避免了模操做的昂贵开销。
0x0018 add x12, x10, x12, lsl #4
复制代码
仅仅获得索引是不够,为了从表中加载数据,咱们须要获得最终的实际地址。而这正是该指令的目的。由于哈希表的 bucket 都是 16 个比特位,因此这里先对 x12 寄存器中的索引值左移 4 位也就是乘以 16 ,而后再将其与表首地址相加后的确切 bucket 地址信息保存到 x12 中。
0x001c ldp x9, x17, [x12]
复制代码
再一次经过 ldp
指令,将上一步保存在 x12 寄存器中 bucket 对应的信息加载到 x9 和 x17 寄存器中。由于 bucket 由 selector 和 IMP 两部分构成,因此 x9 对应保存了 selector 信息而 x17 则保存了 IMP 信息。
0x0020 cmp x9, x1
0x0024 b.ne 0x2c
复制代码
该段指令会将 x9 寄存器中的内容和 x1 中的 _cmd
进行对比,若是它们不等则意味着 bucket 中不包含咱们所操做的 selector ,而且在此时跳转到 0x2c 处执行对应的未匹配处理。若是相同的话则表示命中,继续执行下一条指令。
0x0028 br x17
复制代码
该指令为无条件跳转到 x17 寄存器所指位置,也就是跳转到 IMP 所指处执行具体实现代码。此时 objc_msgSend
处理流程中最快的路径已经结束。其他参数所作寄存器都没有被干扰,目标方法会接受传入的所有参数,一切行如直接调用目标函数。
在最理想的情形下,objc_msgSend
处理流程最快能够在 3 纳秒内执行完毕。
在介绍完理想的最快情形后,接下来咱们须要关注其他几种情形。首先,咱们来看下当方法未缓存时的处理。
0x002c cbz x9, __objc_msgSend_uncached
复制代码
前面提到 x9 寄存器包含了加载后的 selector 信息。将寄存器中的信息与零进行比较,若是等于 0 的话就跳转到 __objc_msgSend_uncached
代码处。由于等于 0 就意味着 bucket 为空也就是说方法查询失败,selector 对应的方法没有被缓存到哈希表中。此时咱们须要调用 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 所指位置。该指令中的 !符号表示寄存器回写操做,也就是说会使用计算后的结果更新 x12 寄存器。将其用数学方式表示就是:x12 -= 16,将 x12 中表示的地址前移 16 个单位。
0x003c b 0x20
复制代码
加载新的 bucket 信息后,代码从新跳转到 0x20 处循环查询过程,直到出现下列情形:找到匹配项,bucket 为空,再次回到了哈希表的起始处。
0x0040 add x12, x12, w11, uxtw #4
复制代码
当查询到匹配想后会触发该指令。此时 x12 寄存器为最新的 bucket 地址,而 w11 保存了包含哈希表大小的掩码值。该指令将 w11 左移 4 位后将两个值进行叠加获得哈希表尾地址,并将结果保存到 x12 寄存器中,而后接着恢复查询操做。
0x0044 ldp x9, x17, [x12]
复制代码
该指令为加载新 bucket 信息到 x9,x17 寄存器中。
0x0048 cmp x9, x1
0x004c b.ne 0x54
0x0050 br x17
复制代码
该段指令与前面的 0x0020 处的功能一致,只要寄存器内容匹配上了就跳转到对应 IMP 位置执行代码。
0x0054 cbz x9, __objc_msgSend_uncached
复制代码
一样的,若不匹配则执行与前面 0x002c 同样的处理流程。
0x0058 cmp x12, x10
0x005c b.eq 0x68
复制代码
该指令与 0x0030 处一致,只不过若是此时 x12 寄存器内容依旧是哈希表首地址的话程序会跳转到 0x68 处进行处理。
0x0068 b __objc_msgSend_uncached
复制代码
这种状况通常不太容易发生,由于它会致使哈希表持续膨胀。此时哈希表的查询效率会降低而去潜在哈希碰撞的可能性会变高。
至于缘由,源码中的注释是这些写的:
Clone scanning loop to miss instead of hang when cache is corrupt. The slow path may detect any corruption and halt later. 当缓存损坏时,须要跳出上面的循环查询流程而不是进入挂起状态。 转而执行慢速路径流程去检测任何可能的损坏并终止代码执行。
我怀疑这种状况很常见,但很显然苹果公司的员工已经看到内存损坏会让哈希表充满无效内容因此在此处跳转到 C 代码中进行错误诊断。
此项检查的存在应该将对未损坏的缓存的影响下降到最小。去除该检查,原来的循环处理流程能够被重用,这会节省一点指令缓存空间。 不管如何,该处理程序器并非常见的状况。 只会在哈希表的开始位置查询到所需的选择子或者发生了哈希碰撞时才会被调用。
0x0060 ldp x9, x17, [x12, #-0x10]!
0x0064 b 0x48
复制代码
该段指令与以前功能一致,加载新 bucket 信息到 x9,x17 寄存器中。更新 x12 中的地址,并跳转到 0x48 处重复查找流程。
objc_msgSend 的主要处理流程到此告一段落,剩下 Tagged Pointer 和 nil
两个特殊情形的处理。
咱们回到第一组汇编指令的跳转处来说解标记指针(Tagged Pointer
)的处理。
0x006c b.eq 0xa4
复制代码
当参数 self 不大于 0 时,该指令就会被触发。其中小于 0 对应标记指针,而等于零则对应 nil
。这两种情形有各自的处理流程,因此第一步就是要区分出究竟是哪一种情形。若为 nil 情形则跳转到 0xa4 处进行处理,不然继续执行。
在继续讲解以前,先简单讨论下标记指针工做原理。 标记指针支持多个类。其中高 4 位(在 ARM64 上)指明了“对象”的类信息,本质上就是 Tagged Pointer 的 isa 。固然 4 个比特位不足以容纳一个类指针,实际上这些信息都被存在了一张特殊表中。咱们能够以高 4 位的值为索引去表中查询真正的类信息。
这还不是所有,标记指针(至少在 ARM64 上)支持拓展类。当高 4 位全为 1 时,紧接着的 8 个比特位将被用做拓展类表中的索引值。 这样在运行时支持更多的标记指针类,不过代价就是能存储的有效信息会变少。
下面继续指令的执行。
0x0070 mov x10, #-0x1000000000000000
复制代码
该指令将一个整形值(高 4 位为 1 ,其他全为 0)写入 x10 寄存器中。这将用做下一步提取 self
标记位的掩码。
0x0074 cmp x0, x10
0x0078 b.hs 0x90
复制代码
这一步时检查拓展标记指针内容。若是 self
大于或者等于 x10 中的值,则意味这 self
的高 4 位也所有为 1 。此时代码会跳转到 0x90 处理拓展类部分的内容,不然就继续执行下面的指令去主标记指针表中的查询类信息。
0x007c adrp x10, _objc_debug_taggedpointer_classes@PAGE
0x0080 add x10, x10, _objc_debug_taggedpointer_classes@PAGEOFF
复制代码
该段指令主要就是加载 _objc_debug_taggedpointer_classes@PAGE
所指的主标记指针表地址。由于 ARM64 上的指针是 64 位宽,而指令只有 32 位宽,因此须要采用类 RISC 标准技术经过两个指令来加载符号地址。
x86 架构则不存在该问题,由于它采用可变长度指令集。它能够经过一个 10 字节长的指令处理上面的问题:2 个字节用来区分具体指令和寄存器,剩下 8 个字节用来保存指针地址。
而在定长指令集机器上,咱们只能经过一组命令加以应对。例如,上例就是经过两条指令实现 64 位指针地址的加载操做。adrp 指令加载高 32 位信息而后再经过 add 指令将其与低 32 位进行求和。
0x0084 lsr x11, x0, #60
复制代码
由于索引值保存在 x0 的高 4 位中,因此该指令将 x0 进行右移 60 位取出对应的索引值(取值范围为 0-15)并保存到 x11 中。
0x0088 ldr x16, [x10, x11, lsl #3]
复制代码
根据索引值获取标记指针的类信息并保存到 x16 中。
0x008c b 0x10
复制代码
得到类信息后程序会无条件跳回 0x10 处,并复用主分支中的代码进行方法查询处理。
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
复制代码
最后,咱们一样跳回到 0x10 处。
接下来,咱们来看 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
情形的处理与其余情形彻底不一样,它不会进行类查询和方法派发,而仅仅返回 0 给调用者。
该段指令最麻烦的事情是 objc_msgSend
不知道具体的返回值类型。是整型值、浮点值、亦或者是什么都不返回。
幸运的是,全部用于设置返回值的寄存器都能被安全覆写,即便这次调用过程不会使用到。整型返回值被保存在 x0 和 x1 中,而浮点值则保存在向量寄存器 v0 - v3 中。同时使用多个寄存器能够返回一个小型结构体类型返回值。
在处理 nil
情形时,上诉指令会将 x1 以及 v0 - v3 中的值所有清空并设置为 0。其中 d0 - d3 分别对应向量寄存器 v0 - v3 的后半部分,经过将其设置为 0 清除了后半部分而后在经过 movi 清除全部的寄存器内容。清空返回值寄存器后,控制权将从新回到调用方。
若是返回值为比较大的结构体,那么寄存器可能就变的不够用了。此时就须要调用者作出一些配合。调用者会在一开始为该结构体分配一块内存,而后将其地址提早写入到 x8 寄存器中。在设置返回值的时候,直接往该地址中写数据便可。 由于该内存大小对 objc_msgSend
是透明的,所以不能对其进行清空操做。取而代之的操做就是在调用 objc_msgSend
以前编译器会将其设置为 0 。
以上就是 nil
情形的处理,objc_msgSend
流程到此也宣告结束。
深刻框架底层仍是颇有趣的,而 objc_msgSend
就像一件艺术品,值得细细玩味。
今天的内容到此结束,下次再会为你们带来一些更好的内容。Friday Q&A 不少内容都是由读者驱动而来,因此欢迎你们在下面积极发言。
#0x0
:“#”修饰的数字表示当即数,可简单理解为数值,而非地址:b
:跳转指令,b.le 指比较结果小于等于的时候跳转至某内存地址;ldr
:从内存中读取数据到寄存器;and
:arm 的 and
指令,须要3个操做数,例如 AND R0,R0,#3
是将 R0 寄存器的值与数字3(0x0000003)逻辑与,将结果存储为 R0 寄存器add
:ADD[con][S] Rd,Rn,operand
,将 operand 数据与 Rn 的值相加,结果保存到 Rd 寄存器;lsl
: 逻辑左移指令,能够结合 add
指令一块儿使用,如ADDS R0,R1,R2,LSL#2
,将 R2 寄存器左移 2 位,接着 R1 和 R2 值相加,将结果存储到 R0 中;cbz
:c对应compare,b就是上面的跳转,z对应0 zero,所以这条命令当比较结果为零(Zero)就跳转至以后的指令;UXTW
: 32 位的逻辑左移指令,更多请见[llvm] r205861;LSR
: 逻辑右移;UBFX
:UBFX{cond} Rd, Rn, #lsb, #width
从一个寄存器中提取位域,cond —可选,条件码 ;Rd — 目标寄存器 ;Rn — 源寄存器 ;lsb —位域的最低有效位的位置,范围是 0 - 31; width — 位域的宽度,范围是1到 32-lsb