在 Objective-C 语言中,实例对象执行方法,而执行方法的过程也可以称为给实例对象发送消息。发送消息的过程执行在编译阶段会转化成对 objc_msgSend
函数的调用。本文将分析 objc_msgSend
汇编部分主要部分(fast path)。
文章中用到的汇编指令可以参考我个人的汇编学习笔记
Objective-C 实例对象执行方法步骤
objc_msgSend
前2个传入参数有对象实例 receiver
和方法名 selector
,执行过程可以简单概括为:
- 获取
receiver
对应的类 Class - 在 Class 缓存列表中根据选择子
selector
查找 IMP - 若缓存中没有找到,则在方法列表中继续查找
- 若方法列表没有,则从父类查找,重复以上步骤
- 若最终没有找到,则进行消息转发操作
objc_msgSend 汇编源码内部逻辑
1 | ENTRY _objc_msgSend // _objc_msgSend 入口 |
_objc_msgSend
函数可以分解2个主线
- receiver 为 nil 或者属于 tagged pointer 类型
- receiver 不为空,正常查找 IMP
receiver 不为空
首先先分析 receiver 不为空的情况
1 | 1. ldr x13, [x0] |
x0
当前存储的是 self 指针,ldr 指令 self 指针所指向的内存位置读取数据并保存到x13
寄存器中,这时候x13
存储了 isaisa 和 ISA_MASK 做与运算,移除掉这些多余的信息得到 Class 并存储到
x9
开始从 Class 缓存中查找 IMP
CacheLookup 是一个宏
1 | .macro CacheLookup |
进入宏之前,x1
保存了 SEL (ARM寄存器 x0-x7 寄存器是用来传递参数的,objc_msgSend 函数的前2个参数分别是 self 和 _cmd),还有之前处理得到的 isa 保存在 x9。
1 | ldp x10, x11, [x9, #CACHE] // x10 = buckets, x11 = occupied|mask |
ldp x10, x11, [x9, #CACHE]
:CACHE 是一个常数(0x10),以 objc_class 地址为基准,然后读 16 字节的数据(可以参考 objc-runtime-new.h objc_class 结构体),x10 = buckets,x11 = occupied|mask (高32位:occupied,低32位:mask);mask 代表哈希表的位数,它的值总是2 - 1的幂,或者用二进制表示就是000000001111111,末尾有一个可变的1
and w12, w1, w11
:进行 AND 运算,得到选择子的查询索引add x12, x10, x12, LSL #4
:x12
左移4位也就是乘以16,这是因为每个哈希表的 bucket 是 16 字节,计算得出要搜索位置的 第一个 bucket 的地址并保存在x12
中
1 | ldp x16, x17, [x12] // {x16, x17} = *bucket |
ldp x16, x17, [x12]
: 从 bucket 指针指向的内存地址读取数据,x16
存储要查找 bucket 中的 key(选择子),x17
存储了 IMPcmp x16, x1
: 判断第一个 bucket 中的 sel 跟参数 _cmd 是否相同- 相同: 跳转到 CacheHit 继续执行,改标签中会执行指令
br x17
,也就是执行 IMP - 不相同: 跳转到 CheckMiss 继续执行
cbz x16, __objc_msgSend_uncached_impcache
,cbz 指令比较寄存器值是否等于0,如果是0则跳转;这里x16
中记录了从 bucket 加载到的选择子。首先先将其与 0 进行比较,如果等于 0 则会跳转至 C 函数__objc_msgSend_uncached_impcache
进行更复杂的查找
- 相同: 跳转到 CacheHit 继续执行,改标签中会执行指令
cmp x12, x10
: 判断当前的 bucket 指针是不是和数组 buckets 指针相同,相同则说明在列表头判断当前 bucket 的位置:
如果 bucket == buckets,则把指针指向 buckets 列表尾
1
2
3b.eq 3f
add x12, x12, w11, UXTW #4
ldp x16, x17, [x12]cmp 指令执行之后如果,如果 x12 - x10 == 0,csrp 寄存器 Z 标志位置位1,反之为0。
b.eq 当 Z 标志位为 1,跳转到 3f,执行
add x12, x12, w11, UXTW #4
x12 存储了 buckets 指针,指向了第一个 bucket,w11 是存储表的掩码,描述了表的大小,相加之后当前指针指向最后一个 bucket
如果 bucket != buckets,
1
2ldp x16, x17, [x12, #-16]!
b 1b // loopx12-16 获取新的 bucket 地址并重新写入到 x12 中 (!符号代表寄存器回写),指向前一个 bucket,
x16
存储要查找 bucket 中的 key(选择子 ,x17
存储了 IMP,然后重复之前的步骤
接着上面的4.1步骤,bucket 指针指向 buckets 列表尾
1 | 1: cmp x16, x1 // if (bucket->sel != _cmd) |
- 判断当前 bucket 的选择子和传入参数 _cmd 是否相同,相同则跳转到 CacheHit 执行对应的 IMP,不相同则往下走
- 执行 CheckMiss 宏判断 bucket 的选择子是否为空,若未空跳转执行
__objc_msgSend_uncached_impcache
C 函数 cmp x12, x10
,检查是否在 buckets 表头循环搜索完 或者 是hash碰撞,如果是则跳转到 JumpMiss,最终会执行__objc_msgSend_uncached_impcache
函数执行,进行更复杂的查找- 若步骤3不成立,则 bucket 指针前移,重复1-3的步骤
recever 不为空的情况下, objc_msgSend
全部过程分析到此完毕
receiver 等于 nil
1 | LNilOrTagged: // 执行到这里说明 self 的值小于等于 0。小于零则代表为 Tagged Pointer 情况,等于说明为 nil |
objc_msgSend
开始会将利用 cmp
指令将 receiver 和 0 做比较,若结果是小于等于0则会跳转到 LNilOrTagged 执行。
若 receiver == 0,则跳转到 LReturnZero
1 | LReturnZero: |
这里先后把整形寄存器和向量寄存器都置为0,这样做的好处是:
objc_msgSend
不知道调用者希望获得什么类型的返回值,是一个整型?两个?还是浮点类型或是其他类型?把所有返回值的寄存器都覆盖为0,后面调用者不管是想得到整型还是浮点型,都是0值。
那么如果调用者需要的返回值类型不是属于整型/浮点型,比如是寄存器不够存储的,更大结构的返回值需要调用者在内存中分配合适的内存空间并把内存地址传入 x8
,函数通过写入这块内存来返回值。
objc_msgSend
执行过程中并不知道 x8
内存,所以在 LReturnZero 中并没有清除内存。解决办法是编译器生成代码会 objc_msgSend
执行前用0填满这块内存。
Tagged pointer 处理
Tagged Pointer
通过在其最后一个 bit 位设置一个特殊标记,用于将数据直接保存在指针本身中。具体细节可以参考深入理解 Tagged Pointer
1 | // tagged |
- 通过
adrp
指令计算 _objc_debug_taggedpointer_classes 表(存储可用的 Tagged Pointer 的类)的数据地址到当前pc寄存器值相对偏移。 - AMR64 上的指针是 64 位宽的,指令是 32 位宽。所以一个指令无法保存一个完整的指针,于是还要通过
add
指令把后半部分读取存储到x10
中 ubfx
指令读取x11
中最后4位数据,也就是 Tagged pointer 表所以标志x9 = x10 + (x11<<3)
,这里通过 x11 里的索引到 x10 所指向的 Tagged pointer 表中查找具体的 Tagged pointer 类- 获取到 isa 之后进行 CacheLookup 步骤
自此 objc_msgSend
过程已经全部分析完,整个流程可以用下图表示:
为什么要用汇编实现
objc_msgSend
函数实现并不是用 Objective-C、C 或者 C++ 实现的,而是利用汇编语言开发。
那为什么会采用汇编语言实现呢?首先看一个例子:
1 | NSUInteger n = [array count]; |
我们可以理解上面2行代码编译时期会转化为:
1 | NSUInteger n = objc_msgSend(array, @selector(count)); |
假设 objc_msgSend
是 C 或者 C++ 实现的,这里不可能编译成功,因为返回值也不能同时是 NSUInteger
和 id
;这里可以使用类型强制转化来解决:
1 | NSUInteger n = (NSUInteger (*)(id, SEL))objc_msgSend(array, @selector(count)); |
从例子上可以看的出,objc_msgSend
有2个特点:
- 可以调用任意参数类型、数量的任意函数
- 支持不同类型的返回值
对于特点1,调用 objc_msgSend 的之前,栈帧(stack frame)的状态、数据,和各个寄存器的组合形式、数据,跟调用具体的函数指针(IMP)时所需的状态、数据,是完全一致的。
基于这个前提,遍历并找到 IMP 之后,只要所有的对栈、寄存器的操作回复到调用 objc_msgSend 之前的状态,通过 jump/call 指令执行函数即可。
在 ARM 上,IMP 函数执行完, r0
寄存器会保存其返回值,能满足其返回不同类型返回值的需求
参考文章: