0%

objc-msg-arm64源码深入分析

在 Objective-C 语言中,实例对象执行方法,而执行方法的过程也可以称为给实例对象发送消息。发送消息的过程执行在编译阶段会转化成对 objc_msgSend 函数的调用。本文将分析 objc_msgSend 汇编部分主要部分(fast path)。

文章中用到的汇编指令可以参考我个人的汇编学习笔记

Objective-C 实例对象执行方法步骤

objc_msgSend 前2个传入参数有对象实例 receiver 和方法名 selector,执行过程可以简单概括为:

  1. 获取 receiver 对应的类 Class
  2. 在 Class 缓存列表中根据选择子 selector 查找 IMP
  3. 若缓存中没有找到,则在方法列表中继续查找
  4. 若方法列表没有,则从父类查找,重复以上步骤
  5. 若最终没有找到,则进行消息转发操作

objc_msgSend 汇编源码内部逻辑

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
	ENTRY _objc_msgSend		// _objc_msgSend 入口
MESSENGER_START

cmp x0, #0 // nil check and tagged pointer check, cmp 指令执行完,设置Z-flag(零标志)
b.le LNilOrTagged // 如果 x0 的值==0,CPSR寄存器的 Z 标识==1,跳转标签判断是否 self 是否为 nil 或者是 tagged pointer 类型
// 跳转之前 lr 寄存器会保存 pc 寄存器当前内容
ldr x13, [x0] // x13 = isa,把 self 指针赋值到 x13,self 是 objc_object 结构体,结构体第一个属性是 isa,所以这里 x13 指向了 isa
and x9, x13, #ISA_MASK // x9 = class,与运算来移除掉这些多余的信息,将一个真实指向类的指针保存在 x9 里
LGetIsaDone:
CacheLookup NORMAL // calls imp or objc_msgSend_uncached

LNilOrTagged: // 执行到这里说明 self 的值等于 0。小于零则代表为 Tagged Pointer 情况,等于说明为 nil
b.eq LReturnZero // nil check,判断 self 是否为 nil

// tagged
// 这里加载了 _objc_debug_taggedpointer_classes 的地址,即 Tagged Pointer 主表
// ARM64 需要两条指令来加载一个符号的地址。这是 RISC 样架构上的一个标准技术。
// AMR64 上的指针是 64 位宽的,指令是 32 位宽。所以一个指令无法保存一个完整的指针

adrp x10, _objc_debug_taggedpointer_classes@PAGE // 将页(前半部分)的基址存在 x10
add x10, x10, _objc_debug_taggedpointer_classes@PAGEOFF // 将页(后半部分)的基址存在 x10
ubfx x11, x0, #60, #4 // 它从 self 中的第 60 位开始,提取 4 位,保存到 x11 中。
ldr x9, [x10, x11, LSL #3] // x9 = x10 + (x11<<3),这里通过 x11 里的索引到 x10 所指向的 Tagged Pointer 表中查找具体的类
b LGetIsaDone

LReturnZero:
// x0 is already zero
// 因为近来之前已经通过 `cmp x0, #0` 判断,所以 x0 寄存器的值是0

// 整型的返回值保存在 x0 和 x1 中
// 浮点型的返回值会被保存在 v0 到 v3 这几个向量寄存器中,
// d0 到 d3这几个寄存器是相关v寄存器的后半部分,向他们存值的时候会将对应 v 寄存器的前半部分置 0

mov x1, #0 // 1、首先先把 x1 清空,x0 这里是 self,已经是0,所以不需要清空,
movi d0, #0 // 2、清空 v 寄存器
movi d1, #0
movi d2, #0
movi d3, #0
MESSENGER_END_NIL
ret

END_ENTRY _objc_msgSend

_objc_msgSend 函数可以分解2个主线

  1. receiver 为 nil 或者属于 tagged pointer 类型
  2. receiver 不为空,正常查找 IMP

receiver 不为空

首先先分析 receiver 不为空的情况

1
2
3
4
1. ldr	x13, [x0]
2. and x9, x13, #ISA_MASK
3. LGetIsaDone:
CacheLookup NORMAL
  1. x0 当前存储的是 self 指针,ldr 指令 self 指针所指向的内存位置读取数据并保存到 x13 寄存器中,这时候 x13 存储了 isa

  2. isa 和 ISA_MASK 做与运算,移除掉这些多余的信息得到 Class 并存储到 x9

  3. 开始从 Class 缓存中查找 IMP

CacheLookup 是一个宏

1
2
3
4
5
6
7
.macro CacheLookup
// x1 = SEL, x9 = isa
// x9 保存着 objc_class 指针

...

.endmacro

进入宏之前,x1 保存了 SEL (ARM寄存器 x0-x7 寄存器是用来传递参数的,objc_msgSend 函数的前2个参数分别是 self 和 _cmd),还有之前处理得到的 isa 保存在 x9。

1
2
3
ldp	x10, x11, [x9, #CACHE]	// x10 = buckets, x11 = occupied|mask
and w12, w1, w11 // x12 = _cmd & mask
add x12, x10, x12, LSL #4 // x12 = buckets + ((_cmd & mask)<<4)
  1. 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

  2. and w12, w1, w11:进行 AND 运算,得到选择子的查询索引

  3. add x12, x10, x12, LSL #4: x12 左移4位也就是乘以16,这是因为每个哈希表的 bucket 是 16 字节,计算得出要搜索位置的 第一个 bucket 的地址并保存在 x12

1
2
3
4
5
6
7
8
9
10
11
12
13
14
	ldp	x16, x17, [x12]		// {x16, x17} = *bucket
1: cmp x16, x1 // if (bucket->sel != _cmd)
b.ne 2f // scan more
CacheHit $0 // call or return imp

2: // not hit: x12 = not-hit bucket
CheckMiss $0 // miss if bucket->cls == 0
cmp x12, x10 // wrap if bucket == buckets
b.eq 3f
ldp x16, x17, [x12, #-16]!
b 1b // loop

3: // wrap: x12 = first bucket, w11 = mask
add x12, x12, w11, UXTW #4 // x12 = buckets+(mask<<4)
  1. ldp x16, x17, [x12]: 从 bucket 指针指向的内存地址读取数据,x16 存储要查找 bucket 中的 key(选择子),x17 存储了 IMP

  2. cmp 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 进行更复杂的查找
  3. cmp x12, x10: 判断当前的 bucket 指针是不是和数组 buckets 指针相同,相同则说明在列表头

  4. 判断当前 bucket 的位置:

    1. 如果 bucket == buckets,则把指针指向 buckets 列表尾

      1
      2
      3
      b.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

    2. 如果 bucket != buckets,

      1
      2
      ldp	x16, x17, [x12, #-16]!
      b 1b // loop

      x12-16 获取新的 bucket 地址并重新写入到 x12 中 (!符号代表寄存器回写),指向前一个 bucket,x16 存储要查找 bucket 中的 key(选择子 ,x17 存储了 IMP,然后重复之前的步骤

接着上面的4.1步骤,bucket 指针指向 buckets 列表尾

1
2
3
4
5
6
7
8
9
10
11
12
13
1:	cmp	x16, x1			// if (bucket->sel != _cmd)
b.ne 2f // scan more
CacheHit $0 // call or return imp

2: // not hit: x12 = not-hit bucket
CheckMiss $0 // miss if bucket->cls == 0
cmp x12, x10 // wrap if bucket == buckets
b.eq 3f
ldp x16, x17, [x12, #-16]! // {x16, x17} = *--bucket
b 1b // loop

3: // double wrap
JumpMiss $0
  1. 判断当前 bucket 的选择子和传入参数 _cmd 是否相同,相同则跳转到 CacheHit 执行对应的 IMP,不相同则往下走
  2. 执行 CheckMiss 宏判断 bucket 的选择子是否为空,若未空跳转执行 __objc_msgSend_uncached_impcache C 函数
  3. cmp x12, x10 ,检查是否在 buckets 表头循环搜索完 或者 是hash碰撞,如果是则跳转到 JumpMiss,最终会执行 __objc_msgSend_uncached_impcache 函数执行,进行更复杂的查找
  4. 若步骤3不成立,则 bucket 指针前移,重复1-3的步骤

recever 不为空的情况下, objc_msgSend 全部过程分析到此完毕

receiver 等于 nil

1
2
LNilOrTagged:	// 执行到这里说明 self 的值小于等于 0。小于零则代表为 Tagged Pointer 情况,等于说明为 nil
b.eq LReturnZero // nil check,判断 self 是否为 nil

objc_msgSend 开始会将利用 cmp 指令将 receiver 和 0 做比较,若结果是小于等于0则会跳转到 LNilOrTagged 执行。

若 receiver == 0,则跳转到 LReturnZero

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
LReturnZero:
// x0 is already zero
// 因为近来之前已经通过 `cmp x0, #0` 判断,所以 x0 寄存器的值是0

// 整型的返回值保存在 x0 和 x1 中
// 浮点型的返回值会被保存在 v0 到 v3 这几个向量寄存器中,
// d0 到 d3这几个寄存器是相关v寄存器的后半部分,向他们存值的时候会将对应 v 寄存器的前半部分置 0

mov x1, #0 // 1、首先先把 x1 清空,x0 这里是 self,已经是0,所以不需要清空,
movi d0, #0 // 2、清空 v 寄存器
movi d1, #0
movi d2, #0
movi d3, #0
MESSENGER_END_NIL
ret

这里先后把整形寄存器和向量寄存器都置为0,这样做的好处是:

objc_msgSend 不知道调用者希望获得什么类型的返回值,是一个整型?两个?还是浮点类型或是其他类型?把所有返回值的寄存器都覆盖为0,后面调用者不管是想得到整型还是浮点型,都是0值。

那么如果调用者需要的返回值类型不是属于整型/浮点型,比如是寄存器不够存储的,更大结构的返回值需要调用者在内存中分配合适的内存空间并把内存地址传入 x8,函数通过写入这块内存来返回值。

objc_msgSend 执行过程中并不知道 x8 内存,所以在 LReturnZero 中并没有清除内存。解决办法是编译器生成代码会 objc_msgSend 执行前用0填满这块内存。

Tagged pointer 处理

Tagged Pointer 通过在其最后一个 bit 位设置一个特殊标记,用于将数据直接保存在指针本身中。具体细节可以参考深入理解 Tagged Pointer

1
2
3
4
5
6
7
8
9
10
// tagged
// 这里加载了 _objc_debug_taggedpointer_classes 的地址,即 Tagged Pointer 主表
// ARM64 需要两条指令来加载一个符号的地址。这是 RISC 样架构上的一个标准技术。
// AMR64 上的指针是 64 位宽的,指令是 32 位宽。所以一个指令无法保存一个完整的指针

adrp x10, _objc_debug_taggedpointer_classes@PAGE // 将页(前半部分)的基址存在 x10
add x10, x10, _objc_debug_taggedpointer_classes@PAGEOFF // 将页(后半部分)的基址存在 x10
ubfx x11, x0, #60, #4 // 它从 self 中的第 60 位开始,提取 4 位,保存到 x11 中。
ldr x9, [x10, x11, LSL #3] // x9 = x10 + (x11<<3)
b LGetIsaDone
  1. 通过 adrp 指令计算 _objc_debug_taggedpointer_classes 表(存储可用的 Tagged Pointer 的类)的数据地址到当前pc寄存器值相对偏移。
  2. AMR64 上的指针是 64 位宽的,指令是 32 位宽。所以一个指令无法保存一个完整的指针,于是还要通过 add 指令把后半部分读取存储到 x10
  3. ubfx 指令读取 x11 中最后4位数据,也就是 Tagged pointer 表所以标志
  4. x9 = x10 + (x11<<3),这里通过 x11 里的索引到 x10 所指向的 Tagged pointer 表中查找具体的 Tagged pointer 类
  5. 获取到 isa 之后进行 CacheLookup 步骤

自此 objc_msgSend 过程已经全部分析完,整个流程可以用下图表示:

sequence

为什么要用汇编实现

objc_msgSend 函数实现并不是用 Objective-C、C 或者 C++ 实现的,而是利用汇编语言开发。

那为什么会采用汇编语言实现呢?首先看一个例子:

1
2
NSUInteger n = [array count];
id obj = [array objectAtIndex:1];

我们可以理解上面2行代码编译时期会转化为:

1
2
NSUInteger n = objc_msgSend(array,  @selector(count));
id obj = objc_msgSend(array, @selector(objectAtIndex:), 1);

假设 objc_msgSend 是 C 或者 C++ 实现的,这里不可能编译成功,因为返回值也不能同时是 NSUIntegerid;这里可以使用类型强制转化来解决:

1
2
NSUInteger n = (NSUInteger (*)(id, SEL))objc_msgSend(array,  @selector(count));
id obj = (id (*)(id, SEL, NSUInteger))objc_msgSend(array, @selector(objectAtIndex:), 6);

从例子上可以看的出,objc_msgSend 有2个特点:

  1. 可以调用任意参数类型、数量的任意函数
  2. 支持不同类型的返回值

对于特点1,调用 objc_msgSend 的之前,栈帧(stack frame)的状态、数据,和各个寄存器的组合形式、数据,跟调用具体的函数指针(IMP)时所需的状态、数据,是完全一致的。

基于这个前提,遍历并找到 IMP 之后,只要所有的对栈、寄存器的操作回复到调用 objc_msgSend 之前的状态,通过 jump/call 指令执行函数即可。

在 ARM 上,IMP 函数执行完, r0 寄存器会保存其返回值,能满足其返回不同类型返回值的需求

参考文章:

为什么objc_msgSend必须用汇编实现

Dissecting objc_msgSend on ARM64