OC方法调用快查找流程底层逻辑探究

As we know, OC上层的方法调用的底层逻辑都是通过objc_msgSend来实现的,那么如何验证呢?我们通过clang反编译看看就知道了:

上层的OC调用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
int main(int argc, const char * argv[]) {
@autoreleasepool {
// insert code here...
LLYModel *model = [LLYModel alloc];
[model func0];
[model func1];
NSLog(@"Hello, World!");
}
return 0;
}

使用clang命令:

1
clang -rewrite-objc main.m -o main.cpp

反编译之后的底层实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
int main(int argc, const char * argv[]) {
/* @autoreleasepool */ { __AtAutoreleasePool __autoreleasepool;
LLYModel *model = ((LLYModel *(*)(id, SEL))(void *)objc_msgSend)((id)objc_getClass("LLYModel"), sel_registerName("alloc"));
((void (*)(id, SEL))(void *)objc_msgSend)((id)model, sel_registerName("func0"));
((void (*)(id, SEL))(void *)objc_msgSend)((id)model, sel_registerName("func1"));
NSLog((NSString *)&__NSConstantStringImpl__var_folders_71_gd0d02_n7td1h8k9l7bx0jqh0000gp_T_main_f2985d_mi_0);
}
return 0;
}

可以看到,所有的方法调用全都被编译器转换为了objc_msgSend方法的调用,我们很自然的去runtime源码中找该函数的实现逻辑,结果在.mm文件中没有发现,以为线索就要断了的时候,在objc-msg-arm64.s文件内看到了下面几行代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
ENTRY _objc_msgSend
UNWIND _objc_msgSend, NoFrame
cmp p0, #0 //p0寄存器保存的应该是objc_msgSend()的第一个参数receive // nil check and tagged pointer check
// taggedpointer判断
#if SUPPORT_TAGGED_POINTERS
b.le LNilOrTagged // (MSB tagged pointer looks negative)
#else
b.eq LReturnZero
#endif
// 拿到receive的isa指针
ldr p13, [x0] // p13 = isa
// 拿到类对象
GetClassFromIsa_p16 p13 // p16 = class
LGetIsaDone:
// calls imp or objc_msgSend_uncached
CacheLookup NORMAL, _objc_msgSend

这里应该就是objc_msgSend的入口了,原来是使用汇编代码来实现的,只能硬着头皮看下去了。

上面的逻辑最终调用了CacheLookup这个函数,我们全局搜一下,找到了该函数的实现部分:

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
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
.macro CacheLookup
LLookupStart$1:
// p1 = SEL, p16 = isa
// #define CACHE (2 * __SIZEOF_POINTER__)
// 从isa指针首地址偏移16个字节,这一步操作是拿到类的cache数据,原理参考类结构的定义。
ldr p11, [x16, #CACHE] // p11 = mask|buckets
#if CACHE_MASK_STORAGE == CACHE_MASK_STORAGE_HIGH_16
// 参考cache_t中的结构 这一步是拿到缓存的buckts数组
and p10, p11, #0x0000ffffffffffff // p10 = buckets
// 计算_cmd(也就是sel)的索引
and p12, p1, p11, LSR #48 // x12 = _cmd & mask
#elif CACHE_MASK_STORAGE == CACHE_MASK_STORAGE_LOW_4
and p10, p11, #~0xf // p10 = buckets
and p11, p11, #0xf // p11 = maskShift
mov p12, #0xffff
lsr p11, p12, p11 // p11 = mask = 0xffff >> p11
and p12, p1, p11 // x12 = _cmd & mask
#else
#error Unsupported cache mask storage for ARM64.
#endif
// #define PTRSHIFT 3 // 1<<PTRSHIFT == PTRSIZE
// 根据上面的索引值获取具体的bucket数据 每一个bucket是16个字节 所以这一要向左平移4位
add p12, p10, p12, LSL #(1+PTRSHIFT)
// p12 = buckets + ((_cmd & mask) << (1+PTRSHIFT))
// 拿到sel和imp元组
ldp p17, p9, [x12] // {imp, sel} = *bucket
// 下面就是一个条件判断和分支语句了
// 找到的sel不是当前执行的方法
1: cmp p9, p1 // if (bucket->sel != _cmd)
// goto 2
b.ne 2f // scan more
// 否则直接命中
CacheHit $0 // call or return imp
// 未命中逻辑
2: // not hit: p12 = not-hit bucket
// 空判断
CheckMiss $0 // miss if bucket->sel == 0
// 当前是否已经寻址到buckets的第一个元素
cmp p12, p10 // wrap if bucket == buckets
// 是的话 goto 3
b.eq 3f
// 不是的话 向前移位继续查找
ldp p17, p9, [x12, #-BUCKET_SIZE]! // {imp, sel} = *--bucket
// 这是一个循环查找的过程
b 1b // loop
3: // wrap: p12 = first bucket, w11 = mask
#if CACHE_MASK_STORAGE == CACHE_MASK_STORAGE_HIGH_16
// 已经寻址到buckets的第一个元素,直接干到最后一个元素,再重新开始向前查找。
add p12, p12, p11, LSR #(48 - (1+PTRSHIFT))
// p12 = buckets + (mask << 1+PTRSHIFT)
#elif CACHE_MASK_STORAGE == CACHE_MASK_STORAGE_LOW_4
add p12, p12, p11, LSL #(1+PTRSHIFT)
// p12 = buckets + (mask << 1+PTRSHIFT)
#else
#error Unsupported cache mask storage for ARM64.
#endif
// Clone scanning loop to miss instead of hang when cache is corrupt.
// The slow path may detect any corruption and halt later.
// 这里的逻辑和上面类似
ldp p17, p9, [x12] // {imp, sel} = *bucket
1: cmp p9, p1 // if (bucket->sel != _cmd)
b.ne 2f // scan more
CacheHit $0 // call or return imp
2: // not hit: p12 = not-hit bucket
CheckMiss $0 // miss if bucket->sel == 0
// 如果再次寻址到第一个元素
cmp p12, p10 // wrap if bucket == buckets
// goto 3 未命中 跳出循环
b.eq 3f
ldp p17, p9, [x12, #-BUCKET_SIZE]! // {imp, sel} = *--bucket
b 1b // loop
LLookupEnd$1:
LLookupRecover$1:
3: // double wrap
JumpMiss $0
.endmacro
```
通过以上汇编代码分析,可以发现方法缓存的查找是一个从后往前遍历buckets的过程,如果当前遍历到第一个元素,会回到队尾继续遍历,知道找到我们需要的元素,或者在再一次查找到第一个元素后,会退出循环,然后我们看看缓存命中和未命中分别做了啥:
##### 缓存命中

.macro CacheHit
.if $0 == NORMAL
TailCallCachedImp x17, x12, x1, x16 // authenticate and call imp
.elseif $0 == GETIMP
mov p0, p17
cbz p0, 9f // don’t ptrauth a nil imp
AuthAndResignAsIMP x0, x12, x1, x16 // authenticate imp and re-sign as IMP
9: ret // return IMP
.elseif $0 == LOOKUP
// No nil check for ptrauth: the caller would crash anyway when they
// jump to a nil IMP. We don’t care if that jump also fails ptrauth.
AuthAndResignAsIMP x17, x12, x1, x16 // authenticate imp and re-sign as IMP
ret // return imp via x17
.else
.abort oops
.endif
.endmacro

1
2
##### 缓存未命中

.macro JumpMiss
.if $0 == GETIMP
b LGetImpMiss
.elseif $0 == NORMAL
b objc_msgSend_uncached
.elseif $0 == LOOKUP
b
objc_msgLookup_uncached
.else
.abort oops
.endif
.endmacro

```

都只是根据类型不同调用了对应的方法,这里已经不再是快查询的逻辑,我们暂时不看。

小结

这里先点个题,所谓的快查找就是对cache的查找,与之对应的慢查找就是对method_list的查找了,method_list的查找可能涉及到不同文件的遍历过程,所以会比cache慢很多。通过查看汇编实现,我的主要感受就是对类结构的熟悉是非常重要的,因为所有的查找逻辑都是基于类的结构来的。我们在实际编码过程中,也应该做到庖丁解牛,而不是盲人摸象。