逐行剖析objc_msgSend汇编源码

1,101 阅读21分钟

前言

本文会带领大家分析ARM64架构下objc_msgSend汇编部分的代码。在此之前希望你能够对汇编有一些简单的理解。当然这并不是说你需要记住arm指令集的各种指令以及用法,每一条指令的用途都会在第一次出现的时候给予说明,了解计算机底层的操作以及它如何存取使用数据才是我们关心的重点。


简述

每一个Objective-C的对象都属于一个类,每一个Objective-C的类都会有一个方法列表,而每一个方法都包含有一个selector,一个指向函数具体实现的函数指针(IMP) 和一些元数据。对于objc_msgSend来说它的任务就是传递对象和selector,寻找相关方法的函数指针,然后跳转到对应的函数指针处。

方法的寻找过程非常的复杂。如果一个方法没有在类的方法列表中被发现,那么程序需要从该类的父类里去查寻方法。如果找到根类还没有找到对应的方法,那么runtime的消息转发就会被触发。这里需要提醒的是,当一个类第一次接收到消息的时候,这个类会调用自身的+initialize方法。

消息处理流程.png

因为每一次方法的调用都需要去查寻,所以需要一个非常高效的查寻来满足日常场景下的使用。但是很显然,复杂麻烦的方法查寻流程很难满足这样一个需求。

Objective-C给出的解决方案是方法缓存。每一个类会有一个缓存,用哈希表的组织形式存储一些selector和函数指针(也就是Objective-C里的IMP)。这样的存储方式似的查寻效率非常的高效。在查寻一个方法的时候,runtime会先去缓存中查寻,如果没有命中缓存,就去走复杂完整的方法查寻流程,查找成功以后替换缓存中的内容。下次调用该方法查寻速度就非常的快了。

objc_msgSend是用汇编实现的。这样做主要是有两个原因。一方面是因为在C语言中,在一个函数里保护未知的参数,并且跳转到任一的函数指针不太可能实现。C语言并不含括实现这些需求的一些必要特性。另一方面是因为对于objc_msgSend而言,效率非常的重要,所以用汇编这样一个非常靠近底层的语言,让objc_msgSend能够运行的尽可能的快一点。

当然,没有人会愿意用汇编来实现整个复杂的方法查寻流程,这样做也确实没有多大的意义。对于复杂的查寻流程而言,编程语言效率上节省的时间对于整个流程来说是微乎其微的。消息发送这一部分的代码可以被分为两块。其一是快速查寻,也就是用汇编实现的objc_msgSend;另一个较慢的查寻方式是用C实现的。汇编的部分负责去在缓存中查寻方法,如果命中缓存就跳转到对应的IMP。否则调用C的代码去处理接下来的业务逻辑。

总结一下,objc_msgSend主要负责的有:

  1. 获取传入对象的类
  2. 获取类的方法缓存
  3. 通过selector在缓存中寻找方法
  4. 如果没有命中缓存,调用C实现的代码来继续查寻
  5. 命中缓存则跳到方法对应的IMP

接下来让我们看看objc_msgSend具体是如何实现的。


逐条指令分析

objc_msgSend会根据不同的情况给出不同的处理方法。一些消息为nil,指针类型是tagged pointers或者哈希表冲突的情况,会有对应的特殊代码来处理。这篇文章会从最普通的情况开始入手,以一个非空,非tagged pointers,查寻的方法命中缓存不再需要扫描这样一个线性的流程来展开描述。如果遇到分支会先略过,等到整个主体的流程走完以后,再回头分析这些分支的情况。

后续的文章会将指令逐条依次列出,但是部分相关的指令会放在一起展示。指令的后面会给出解释,这些指令做了什么,为什么这么做。不需要太过纠结指令本身的含义,请把注意力放在每一条指令的讨论上。

每一条指令的偏移量是根据函数的始地址计算出来的。这类似于一个计数器,标识出每一条指令。

ARM64架构的处理器有31个64bit的整数寄存器,分别被标记为x0 - x30。每一个寄存器也可以分离开来只使用低32bit,标记为w0 - w30。其中x0 - x7是用来传递函数的参数的。这意味着objc_msgSend接收的self参数放在x0上,_cmd参数放在x1上。

每一个方法调用都会有两个默认参数分别是self和_cmd

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

这条指令是将self和0做了一个比较。如果self的值小于等于0那么会跳转到0x6c继续处理。0代表的是nil。这是对发送消息的对象是否为nil的判断和处理。这条指令同样可以处理self是tagged pointers的情况。ARM64架构里tagged pointers会设置高位bit来标识自己(有趣的是在x86-64架构里设置的是低位bit)。高位bit被设置的情况,self会被解析成一个有符号的整数,它的值是负数。对于一般的情况,self作为一个普通的指针,这个分支是不会被触发的。


0x0008 ldr    x13, [x0]

将x0所指向的对象也就是self赋值给x13。


0x000c and    x16, x13, #0xffffffff8

ARM64架构可以使用 non-pointer isas。一般来说isa指针指向一个对象的类。但是non-pointer isa会在闲置的bit位上插入一些信息。这一步的指令做了一个与运算来移除掉这些多余的信息,将一个真实指向类的指针保存在x16里


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

这条指令将类中缓存的信息加载到了x10和x11寄存器。ldp指令将内存中的数据加载到寄存器当中。这条ldp指令将第三个参数所指向的内存中的数据加载到前两个参数指定的寄存器当中。[x16, #0x10]的意思是x16中的地址偏移16个字节。cache的结构如下


typedef uint32_t mask_t;

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

这条指令执行结束之后,_buckets被加载进x10中,_mask加载进x11的高32bit,低32bit加载的是_occupied。_occupied只是简单描述了哈希表中有多少条目,实际在objc_msgSend当中不起任何的作用。_mask则非常的重要,它是一个描述了哈希表的大小用以做与运算的掩码。_mask的值总是2的N次幂减1,以二进制来表示就是一个类似000000001111111这样一个所有的1都在末尾连续排列的数。我们需要用_mask来计算求出需要查询的selector的index,并且在需要搜索表的时候环回到表的末尾处。


0x0014 and    w12, w1, w11

这条指令是为了计算出x0保存的_cmd在哈希表中开始的index。x1保存的是_cmd,所以w1保存的是_cmd的低32bit。w11如上所说保存的是_mask。将w1和w11进行与运算并将结果保存进w12。这个结果和_cmd%table_size是等同的只不过没有了复杂的模运算。

从这里可以看出,哈希表的构造方法是除留余数法。将selector地址低32bit表示的数除以哈希表的条目数,取余即为index。


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

只有_cmd的index是不够的。我们如果需要从哈希表中获取数据,还需要真实的地址。这条指令通过将_cmd在哈希表的index和哈希表起始地址相加来求出实际的地址。计算之前index先左移了4个bit,相当于index乘以16。这么做的原因是哈希表中每一个bucket的大小是16个字节。计算的结果会被存入x12,用以后面的方法查寻。

struct bucket_t *_buckets保存的是哈希表的首地址,在得出index之后我们需要去计算出实际bucket的地址 结果 = 首地址 + index * 16bytes(bucket的大小)


0x001c ldp    x9, x17, [x12]

此时x12保存的是一个查寻出的bucket的指针,这条ldp指令将该bucket加载进x9和x7两个寄存器当中。每一个bucket包含一个selector和一个IMP。x9保存当前bucket的selector,x17保存IMP。


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

这条指令将x9中保存的bucket的selector和x1的_cmd进行比较。如果两者不一致说明这一次的查寻没有成功,在这种情况下会跳转到0x2c处,也就是跳转到处理缓存未命中的情况。如果命中了缓存,那么查询selector成功,执行下一条指令。


0x0028 br    x17

如上所说x17中保存的是从查询到的bucket中加载的IMP,在_cmd和x9保存的selector匹配的情况下程序会直接跳入x17保存的IMP中。程序从这里开始真正的执行目标方法具体实现的内容。objc_msgSend的快速查寻到这一步结束。所有的参数寄存器都没有被修改,目标方法完整的接收了所有的参数,就像直接调用了它们一样。

在当前的硬件支持下,如果所有内容都已经缓存查询过程非常顺利,整个查寻过程耗时不超过3毫微秒。

当然这是最理想的查寻情况,实际过程中并不会这么简单,还会遇到各式各样其它的麻烦。那么objc_msgSend其余部分的代码做了些什么呢?让我们继续讨论,从上文提到的bucket和_cmd不匹配的情况开始。


0x002c cbz    x9, __**objc_msgSend**_uncached

x9保存的是从bucket中取出的selector。这条指令做的是核查selector是否为0。如果selector为0则跳转到**__objc_msgSend_uncached**。一个为0的selector指向的是一个空的bucket,这意味着之前的查寻失败了,目标函数并不在缓存当中。这个时候我们需要返回C的代码去执行完整的查询流程。__objc_msgSend_uncached处理的就是上述所说的情况。但是如果bucket不为空而selector又不匹配,那么我们还需要继续查询。

之前提到的,在这里哈希表的构造方法是除留余数法。得到的哈希地址是有可能发生冲突的。猜测这里哈希表处理冲突的方法应该是开放定址,在发生冲突的时候寻找下一个空的哈希地址去存储数据。


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

这条指令将x12中保存的bucket的地址和x10保存的哈希表的首地址进行了比较,如果他们匹配上的话,程序跳转到0x40处,查询的index会环回到hash table的结尾处倒序查询。这种做法很少见,hash table的查询会从当前位置倒序执行的。查询依次执行并减小index知道table首部,之后程序返回table的末尾。我不确定为什么这里要采取和一般的从表头开始逐个递增index不一样的做法,但可以肯定的是这样做的原因是这种查询方式更加高效。

如果不匹配的话程序会继续执行下一条指令。


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

又一个ldp指令。这一次是从x12缓存的bucket的地址偏移0x10的位置加载数据。地址后面跟着一个感叹号,这是一个非常有趣的特性。它指示寄存器write-back,寄存器会先更新自己的值,之后再进行其他操作。上面的这条指令会先x12 -= 16并保存到x12中,之后将指向的新bucket加载进x9和x17。


0x003c b      0x20

现在新的bucket已经被加载进来了。程序需要复用之前的代码来判断当前的bucket是否匹配。这个环回会返回到之前0x20处的指令,用新的值来重新执行一遍。如果它依旧没有找到匹配的bucket,那么代码会按照这个循环一直执行一下去直到匹配成功,或者匹配一个空的bucket,又或者一直找到了hash table的首部。


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

这一步的目的是为了环回查询。x12现在保存的是当前bucket的指针,在此处也就是第一个bucket的指针。w11保存的是描述表大小的掩码。将w11左移4个bit扩大十六倍(w11保存的是表里条目的个数,每一个bucket的大小是16bytes),然后和x12累加将结果保存到x12。指令执行结束之后x12里保存的就是表末尾bucket的指针了,之后从这里开始倒序查询。


0x0044 ldp    x9, x17, [x12]

这条熟悉的指令做的是将新的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处来执行完整的查寻流程。


0x0068 b      __objc_msgSend_uncached

这一步几乎不太可能遇到。表有足够的空间,但会随着添加新的条目而变的臃肿。一个过于庞大的表会因为查询过程中经常发生碰撞而变得非常的低效。

这里为什么会这样做?源码中有一个解释

在缓存出现错误的时候,克隆扫描的循环而不是挂起。慢速查询会检测到错误并在稍候停止。

我怀疑这种情况经常发生,很明显apple的人明白内存的错误会导致缓存被一些错误的条目填满,跳转到C部分的代码可以修复这一部分的问题。

这部分检查的代码不应该影响那些正常情况下运行的代码。如果没有检查,这个循环是可以被复用的,会节省一些指令的空间,但效果并不明显。这种非常笼统的处理方法并不会经常发生,只有在查询哈希表的首部附近排序的selectors,发生了冲突并且优先的条目都被占用的情况下会被触发。


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

这个循环剩余的部分和之前的是一模一样的,将下一个bucket加载进x9和x17(注意这里的下一个对应的是表内的上一个,因为是倒序查询的),更新x12保存的bucket的指针然后回到循环的顶部。


方法查寻.png

这里可以看出每次查寻实际都会从第一次查询到的位置开始倒序遍历表,到达表头之后返回表尾继续倒序遍历,直到匹配成功或者查找的bucket为空查找失败。在第四步的过程中,当它返回第一次查询的位置时实际整个表已经被遍历一遍了,并且表中的bucket都不为空且都不和我们需要搜寻的目标selector匹配。查寻再次环回表头程序会跳转到0x68处。当然,一般情况下这种情况是不可能发生的。原因如之前我们所说表足够大。所以只有出现错误缓存被错误填满才可能出现0x68的情况。这个情况下我们需要跳转到C部分的代码来修复问题。

以上就是objc_msgSend主体的全部。接下来让我们看看nil和tagged pointers两种特殊的情况。


#Tagged Pointer的处理 objc_msgSend开始的第一条指令就是核查self参数,如果不合法则跳转到0x6c。有关tagged pointer的部分从这里开始。

0x006c b.eq    0xa4

程序之所以会跳转到这里是因为self参数小于等于0。小于0表示self是一个tagged pointers,等于0则表示nil。对于这两种情况,所做的处理是完全不一样的,所以在这一步我们首先需要检查self。如果self为0,这意味着self是nil,程序会跳转到0xa4处进行处理;如果不是那么self就是一个tagged pointers,程序会继续执行下一条指令。

在我们继续讨论下去之前让我们简单讨论一下tagged pointer是如何工作的。tagged pointer支持多种类。在ARM64架构下tagged pointer的高4bit标识这个对象的类,这4个bit算是tagged pointer本质上的isa。当然,仅仅依靠4个bit是不足以表示一个类的指针的,为此内部会有一个特殊的表来存储可用的tagged pointer的类,这个表是私有的。所有tagged pointer指向对象的类都是根据指针的高4bit来从表中查询获得的。

除此之外,tagged pointer还可以拓展支持的类。当高4bit全部被置为1的时候,接下来的8个bit会被用做拓展的tagged pointer表的index。这样做可以在运行时使用较少的存储空间来支持更多的tagged pointer类。

继续往下看


0x0070 mov    x10, #-0x1000000000000000

将一个高四位bit为1其余bit为0的整型数值存入x10。这个数是一个掩码,用来从self也就是tagged pointer中获取标识类的tag。


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

这一步是为了检查是否为拓展的tagged pointer。如果self大于等于x10保存的值,那就意味着高四位的bit全部被置1了。这种情况下会跳转到0x90来出来拓展类。否则的话使用内部的tagged pointer表来匹配。


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

这两条指令是为了将私有的tagged pointer表_objc_debug_taggedpointer_classes的地址加载进x10中。ARM64架构要求加载一个符号的地址必须要两条指令。这是一个类精简指令集的架构中独立的技术。在ARM64下指针的大小是64bit,但是指令只能操作32bit,所以只靠一条指令来加载一个完整的指针是不可能做到的。

x86架构就没有这样的麻烦。这是因为它有变长的指令。只需要一条10字节的指令,其中两个字节标识指令本身和目标寄存器,剩余八个字节标识指针的值。

在一台使用固定长度指令的机器上,你只能一块一块的加载数据。在这个例子中,需要两块数据,adrp指令先加载首部的数据,然后通过add指令将剩余部分附加上去。

无需太过纠结为什么需要两条指令,只需要明白这一步是为了加载内部的tagged pointer类表即可。


0x0084 lsr    x11, x0, #60

tagged类的index是x0值的高位4bit。为了获取index我们需要将x0的值做一个向右平移60bit的位运算操作,获得一个值在0-15范围内的整型数据。我们将结果保存进x11。


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

我们使用x11中保存的index,从x10指向的表中获取tagged类,然后保存进x16。


0x008c b      0x10

现在x16保存了class的信息,我们可以从tagged pointer处理的这个分支跳回到程序的主干,从0x10处开始继续执行。tagged pointer之后的处理和普通的指针是一致的,所以只需要从分支跳回主干即可,避免了把相关处理的逻辑再复制一遍过来的麻烦。


0x0090 adrp   x10, _objc_debug_taggedpointer_ext_classes@PAGE
0x0094 add    x10, x10, _objc_debug_taggedpointer_ext_classes@PAGEOFF

拓展的tagged pointer类的处理看起来和上面比较相似,首先两条指令将拓展的table加载进x10。


0x0098 ubfx   x11, x0, #52, #8

这条指令是将拓展类的index从x0中取出,然后保存进x11.


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

和之前一样,从表中查询类的信息然后保存进x16


0x00a0 b      0x10

从分支跳回程序的主支继续

以上就是tagged pointer处理的全部,现在只剩下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

self为nil的情况的处理和其余的代码完全的不同。这一部分没有任何类的查询或者方法的执行。它所有的操作就是返回一个0给程序的调用者。

这个任务实际上会有一点棘手那就是objc_msgSend并不知道调用者期待的是什么类型的返回值。是一个整型数据,两个,或者是一个浮点型抑或什么都没有呢?

幸运的是所有用来保存返回的寄存器即使它们并没有在某个指定函数调用的返回里被用到,仍然可以被安全的复写。整型的返回值保存在x0和x1中,浮点型的返回值会被保存在v0到v3这几个向量寄存器中。各式寄存器都会返回一个较小的struct。

这一段代码清理了x1和v0到v3的所有数据。d0到d3这几个寄存器是相关v寄存器的后半部分,向他们存值的时候会将对应v寄存器的前半部分置0。所以四条movi指令执行之后相关的v寄存器都会被清理。这部分操作之后,控制权会返还给调用者。

你可能会奇怪为什么没有清理x0。答案非常的简单,x0保存的是self,当前情景下self是nil所以它的值已经是0了。所以你无需再多些一条指令来清理它了。

如果返回的是struct太大寄存器无法加载该如何处理?这种情况下会需要调用者的一些配合。调用者需要为返回值分配足够的内存空间,之后较大的struct的地址会被加载进x8。函数根据这个地址将返回值写入内存中。objc_msgSend无法清理memory这是因为它无法获取返回值的大小。为了解决这个问题,编译器会在objc_msgSend被调用之前生成代码来清理相关的内存。

这就是nil情景下的所有处理。


至此objc_msgSend汇编部分也已经结束了。感谢阅读:)

如有错误,欢迎各位指正。