0%

通过汇编解读 objc_msgSend

基础知识提要

调用方法,本质是发送消息。比如:

1
2
3
4
5
Person *p = [[Person alloc] init];
[p test];
// 本质是发送消息: clang -rewrite-objc main.m
((void (*)(id, SEL))(void *)objc_msgSend)((id)p, sel_registerName("test"));

当编译器遇到一个方法调用时,它会将方法的调用翻译成以下函数中的一个,
objc_msgSend、 objc_msgSend_stret、 objc_msgSendSuper 和 objc_msgSendSuper_stret。

  • 发送给对象的父类的消息会使用 objc_msgSendSuper ;
  • 有数据结构作为返回值的方法会使用 objc_msgSendSuper_stret 或 objc_msgSend_stret ;
  • 其它的消息都是使用 objc_msgSend 发送的。

也就是说所有的方法调用,都是通过 objc_msgSend(或其大类)来实现转发的。

objc_msgSend 的具体实现由汇编语言编写而成,不同平台有不同的实现,objc-msg-arm.s、objc-msg-arm64.s、objc-msg-i386.s、objc-msg-simulator-i386.s、objc-msg-simulator-x86_64.s、objc-msg-x86_64.s。
本文以 ARM64 平台为例。

汇编分析

汇编概览

如下图所示:

流程图分析

分支1:X0 = 0

这条分支很简单,对照图1的总图来讲,就是蓝色的那条线,第1行->第2行-> 29 -> 35~41 ret。
先对传入的 X0(即对象地址)作判断,如果 X0=0,则直接返回。

分支2:X0 < 0 (Tagger Pointer)

对照图1来讲,流程为黄色的那根线,1~2 -> 29~34 -> 6 -> …

判断 X0<0,即地址最高位为1,这是 Tagger Pointer 类型的标志(对于 ARM64 架构来讲),关于这个类型,部分内容在我之前的文章copy 与 mutableCopy(传说中的深浅拷贝)中5.4节有提到。

loc_1800b9c30 这个模块取出了 Tagger Pointer 的类索引表,赋值给 X10。
下一行 UBFM X11,X0,#0x3C,#0x3F,取 0x3C~0x3F 中的值赋给 X11,其余位以0填充,与图1第32行的意思相同,都是取出最高4位,比如 NSString 类型的 Tagger Pointer 最高4位为 a,运算过后,x11 = 0xa 。
接着 LDR X9,[X10,X11,LSL#3],先运算 X11 左移3位等于 0x50。x9 = x10[0x50],也就是在类索引表中查找所属类。找到后跳到 loc_1800b9BD0,也就是图1中的第6行。

分支3:X0 > 0

这是大多数情况会走的流程。

1
2
3
4
5
6
7
//类的结构
struct objc_class : objc_object {
// Class ISA; //继承自objc_object
Class superclass; // 父类引用
cache_t cache; // 用来缓存指针和虚函数表
class_data_bits_t bits; // class_rw_t 指针加上 rr/alloc 标志
}

接下来我们根据汇编指令一条条来分析。
LDR X13,[X0] 取出调用方法的对象指针保存的地址(从上面代码可以看出,就是 isa 指针地址),赋给 X13。

AND X9,X13,#0x1FFFFFFF8 解读这条指令之前,要先了解 isa 指针的结构。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
union isa_t {
isa_t() { }
isa_t(uintptr_t value) : bits(value) { }
Class cls;
uintptr_t bits;
struct {
uintptr_t indexed : 1;
uintptr_t has_assoc : 1;
uintptr_t has_cxx_dtor : 1;
uintptr_t shiftcls : 33;
uintptr_t magic : 6;
uintptr_t weakly_referenced : 1;
uintptr_t deallocating : 1;
uintptr_t has_sidetable_rc : 1;
uintptr_t extra_rc : 19;
};
};

首先先来看一下这 64 个二进制位每一位的含义:

区域名 代表信息
indexed (0位) 0 表示普通的 isa 指针,1 表示使用优化,存储引用计数
has_assoc (1位) 表示该对象是否有关联引用,如果没有,则析构时更快
has_cxx_dtor (2位) 表示该对象是否有 C++ 或 ARC 的析构函数,如果没有,则析构时更快
shiftcls (3~35位) 类的指针
magic (36~41位) 固定值,用于在调试时分辨对象是否未完成初始化
weakly_referenced (42位) 表示该对象是否有过 weak 对象,如果没有,则析构时更快
deallocating (43位) 表示该对象是否正在析构
has_sidetable_rc (44位) 表示该对象的引用计数值是否过大无法存储在 isa 指针
extra_rc (45~63位) 存储引用计数值减一后的结果

也就是说 0x1FFFFFFF8 取1的位数刚好是 shiftcls 的区域,是 isa 指针中存储的该对象的类指针。所以 X9 = isa->cls。

LDP X10,X11,[X9,#0X10]: X9+16个字节,也就是跳过了8个字节的 isa 指针,和8个字节的 superclass 指针,到了 cache 指针这里。 cache 的结构如下:

1
2
3
4
5
6
7
8
9
10
struct bucket_t {
void *sel;
void *imp;
};
struct cache_t {
struct bucket_t *buckets;
mask_t mask;
mask_t occupied;
};

因此,X10=buckets 指针,X11 的低32位为 mask,高32位为 occupied(mask_t 是 int 类型)。 occupied是 cache 中实际拥有的方法个数。

AND W12,W1,W11: 将 _cmd 的低32位和 cache->mask 作与运算。
ADD X12,X10,X12,LSL#4: 与运算后的结果左移4位,作为buckets的索引(相当于数组下标)。这里也可以看出 mask 的作用,应该是一种优化的 hash 表搜索算法。将取得的指针赋给 X12。
LDP X16,X17,[X12]: 由 bucket 的结构可以知道,这里是将 bucket [(_cmd&mask)<<4] 中的 sel 赋给 X16,imp 赋给 X17(imp 为方法的入口地址)。
这三条指令就是通过 mask 找到一个 bucket 元素。

CMP X16,X1, B.NE loc_1800B9BEC, BR X17: 这3条指令很好理解,比较 bucket 元素中的 sel 和 _cmd 的值是否相等,不相等,则跳到 loc_1800B9BEC 模块,相等则直接进入对应 imp(方法入口地址)。

loc_1800B9BEC CBZ X16,_objc_msgSend_uncached_impcache: 如果 X16=0 则跳到 objc_msgSend_uncached 这个函数去,不等于0则继续执行。
CMP X12,X10, B.EQ loc_1800B9C00: 判断是否已搜索到最后一个 bucket(即 bucket 的初始地址),是则跳到 loc_1800B9C00,否则继续执行。

  • 先讨论没有搜索完的情况,
    loc_1800B9C00 LDP X16,X17,[X12,#-0X10], B loc_1800B9BE0: bucket 元素减16字节,即跳到前一个 bucket 元素,同样将 sel 和 imp 指针赋值,然后跳回与 _cmd 比较的那条指令循环。

  • 直到搜索完毕,
    ADD X12,X12,W11,UXTW #4: x12 = buckets+(mask<<4),扩大搜索范围,在缓存内全面搜索。(进行到这一步,说明 bucket [(_cmd&mask)<<4] 元素之前的 bucket 已全部被占满,且均不是我们要找的方法)
    LDP X16,X17,[X12]: 跟之前的命令意思一样。

可以看到,之后的流程跟前面的循环一模一样,但是加大了搜索范围,从 bucket [mask<<4] 往前开始搜索(进行到这一步说明 bucket [(_cmd&mask)<<4] 前面的缓存都占满了)。从以上分析,我们可以看出,能在缓存 cache 里找到的方法,会直接跳到入口地址 X17。而没有在 cache 里的方法,则要继续调用 objc_msgSend_uncached 函数。现在,返回图1再查看,是不是觉得思路清晰很多呀!

关于缓存 cahce

cache 的原则是缓存那些可能要执行的函数地址。

有一种说法是,只要函数执行过一次的方法,都会存入缓存。但在我的测试中,有时候会遵循这种说法,有时候又不尽然,执行过的方法不一定会被放入缓存,但没有被执行过的肯定不会进入缓存。具体什么样的操作会导致方法被载入缓存,还需要从类的初始化探讨起,此点存疑。

cahce 其实是一个 hash 表,通过 _cmd&mask 的结果再左移4位,作为索引值,如果这个地址存的方法 _cmd2 与 _cmd 不同,那么有两种原因:一是 _cmd 压根儿没被载入缓存;二是由于它的索引值跟 _cmd 相同,但 _cmd2 先进入缓存,因此 _cmd2 占据了这个位置。这时,如果 _cmd 被载入缓存的话,则在 _cmd2 索引值-1的位置存入,如果这个位置也不为0,那么继续前往索引值-2的位置,直到找到一个0位,然后存入。

在上面的汇编分析中,我们也能看到这个思路。在图1中第8行,取 bucket 索引值;第10行,比较 _cmd 值;如果不同则第13行,查看是否为0,如果为0,则不再搜索,直接进入 uncache 函数(因为是0的话,由上一段分析可以知道,说明这个方法没有在缓存里);如果不为0,则前往索引值-1(地址-16)的位置查找;第17行返回循环到第10行。

下面来做一个测试,

1
2
3
4
5
6
7
8
9
@implementation aboutObjectiveC
-(void)objc_msgSend1 {
NSLog(@"objc_msgSend1");
[self objc_msgSend2];
}
-(void)objc_msgSend1 {
NSLog(@"objc_msgSend2");
}

如上图所示,在 main.m 第17行下断点(即第二次执行 objc_msgSend1 方法时),si 进入 objc_msgSend 函数,然后执行到图1中的第7行,打印各值如下

w11 是 mask 的值为0011,跟 init的 SEL(0x1883910b6) 指针作与运算,为0x2,左移4位为0x20,因此在 x10+0x20 处载入 cache;跟 objc_msgSend1 的 SEL(0x10008ecac) 作与运算,为0x0,左移4位还是0x0,因此在 x10 bucket 处载入 cache;同样对 objc_msgSend2 作与运算左移4位,也是0x20,而 bucket[0x20] 处已经被 init 占用了,因此前往 bucket[0x20-0x10] 处,这个位置是0,所以将 objc_msgSend2 填入缓存的这个位置。如下图所示:

lookUpImpOrForward 函数

我们已经知道如果缓存中没有找到该方法,则跳转执行 _objc_msgSend_uncached_impcache,在这里又会执行 bl _class_lookupMethodAndLoadCache3 指令,跳转到 _class_lookupMethodAndLoadCache3,由汇编语言的实现回到了 C 函数的实现,这个函数只是简单的调用了另外一个函数 lookUpImpOrForward,并传入参数 cache=NO,这个函数是 Runtime 消息机制中非常重要的一环。

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
99
100
101
102
103
104
IMP lookUpImpOrForward(Class cls, SEL sel, id inst,
bool initialize, bool cache, bool resolver)
{
Class curClass;
IMP imp = nil;
Method meth;
bool triedResolver = NO;
runtimeLock.assertUnlocked();
//因为 _class_lookupMethodAndLoadCache3 传入的 cache = NO,
//所以这里会直接跳过 if 中代码的执行,
//在 objc_msgSend 中已经使用汇编代码查找过了。
if (cache) {
imp = cache_getImp(cls, sel);
if (imp) return imp;
}
//根据 cls->isRealized() 来判断是否要调用 realizeClass 函数在
// Objective-C 运行时 初始化的过程中会对其中的类进行第一次初始化
//也就是执行 realizeClass 方法,为类分配可读写结构体 class_rw_t
//的空间,并返回正确的类结构体。
if (!cls->isRealized()) {
rwlock_writer_t lock(runtimeLock);
realizeClass(cls);
}
//根据 cls->isInitialized() 来判断类的是不是 initialized,
//也就是类的首次被使用的时候,其 initialize 方法要在此时被调用
//一次,也仅此一次。没有 initialized 的话,则调用
//_class_initialize 函数去触发这个类的 initialize 方法,然后
//会设置 isInitialized 状态为 initialized
if (initialize && !cls->isInitialized()) {
_class_initialize (_class_getNonMetaClass(cls, inst));
// If sel == initialize, _class_initialize will send +initialize and
// then the messenger will send +initialize again after this
// procedure finishes. Of course, if this is not being called
// from the messenger then it won't happen. 2778172
}
// The lock is held to make method-lookup + cache-fill atomic
// with respect to method addition. Otherwise, a category could
// be added but ignored indefinitely because the cache was re-filled
// with the old value after the cache flush on behalf of the category.
retry:
runtimeLock.read();
// 是否开启GC(垃圾回收); 新版本这一段代码已经没有了。
if (ignoreSelector(sel)) {
imp = _objc_ignored_method;
cache_fill(cls, sel, imp, inst);
goto done;
}
// 这里再次查找 cache 是因为有可能 cache 真的又有了,因为锁的原因
imp = cache_getImp(cls, sel);
if (imp) goto done;
// Try this class's method lists.
meth = getMethodNoSuper_nolock(cls, sel);
if (meth) {
log_and_fill_cache(cls, meth->imp, sel, inst, cls);
imp = meth->imp;
goto done;
}
// Try superclass caches and method lists.
curClass = cls;
while ((curClass = curClass->superclass)) {
// Superclass cache.
imp = cache_getImp(curClass, sel);
if (imp) {
if (imp != (IMP)_objc_msgForward_impcache) {
// Found the method in a superclass. Cache it in this class.
log_and_fill_cache(cls, imp, sel, inst, curClass);
goto done;
}
else {
// Found a forward:: entry in a superclass.
// Stop searching, but don't cache yet; call method
// resolver for this class first.
break;
}
}
// Superclass method list.
meth = getMethodNoSuper_nolock(curClass, sel);
if (meth) {
log_and_fill_cache(cls, meth->imp, sel, inst, curClass);
imp = meth->imp;
goto done;
}
}
// No implementation found. Try method resolver once.
if (resolver && !triedResolver) {
runtimeLock.unlockRead();
_class_resolveMethod(cls, sel, inst);
// Don't cache the result; we don't hold the lock so it may have
// changed already. Re-do the search from scratch instead.
triedResolver = YES;
goto retry;
}
// No implementation found, and method resolver didn't help.
// Use forwarding.
imp = (IMP)_objc_msgForward_impcache;
cache_fill(cls, sel, imp, inst);
done:
runtimeLock.unlockRead();
// paranoia: look for ignored selectors with non-ignored implementations (新版本没有这两句断言)
assert(!(ignoreSelector(sel) && imp != (IMP)&_objc_ignored_method));
// paranoia: never let uncached leak out (新版本没有这两句断言)
assert(imp != _objc_msgSend_uncached_impcache);
return imp;
}

lookUpImpOrForward 主要做了以下几个工作

  • 判断类的初始化 cls->isRealized() 和 cls->isInitialized() ;
  • 是否开启GC(垃圾回收);(新版本没有这一步)
  • 再次尝试去缓存中获取IMP;(因为锁的原因)
  • 找不到接着去 class 的方法列表查找,找到会加入缓存列表然后返回 IMP;
  • 找不到,去父类的缓存列表找,然后去父类的方法列表找,找到了会加入自己的缓存列表,然后返回 IMP,找不到循环此步骤,直到找到基类;
  • 都找不到则 _class_resolveMethod 函数会被调用,进入消息动态处理、转发阶段。

对于 objc_msgSend 反汇编的分析就结束啦!如果是在动态调试过程中,遇到 objc_msgSend 想要进入被调用的方法的话,有 cache,则直接 si 进入 br X17,如果没有 cache,则在 _objc_msgSend_uncached_impcache 函数中最后几行中的 br X17 指令输入 si 即可进入被调用方法。


Reference

[1] ObjC Runtime(五):消息传递机制 https://xiuchundao.me/post/runtime-messaging
[2] 从源代码看 ObjC 中消息的发送 http://draveness.me/message/
[3] objc_msgSend内部到底做了什么? http://oriochan.com/14710029019312.html
[4] 用 isa 承载对象的类信息 http://www.desgard.com/isa/
[5] 深入解析 ObjC 中方法的结构
[https://github.com/Draveness/iOS-Source-Code-Analyze/blob/master/contents/objc/\%E6\%B7\%B1\%E5\%85\%A5\%E8\%A7\%A3\%E6\%9E\%90\%20ObjC\%20\%E4\%B8\%AD\%E6\%96\%B9\%E6\%B3\%95\%E7\%9A\%84\%E7\%BB\%93\%E6\%9E\%84.md](<https://github.com/Draveness/iOS-Source-Code-Analyze/blob/master/contents/objc/深入解析 ObjC 中方法的结构.md>)

----------------------END END----------------------