基础知识提要
调用方法,本质是发送消息。比如:
|
|
当编译器遇到一个方法调用时,它会将方法的调用翻译成以下函数中的一个,
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
这是大多数情况会走的流程。
|
|
接下来我们根据汇编指令一条条来分析。LDR X13,[X0]
取出调用方法的对象指针保存的地址(从上面代码可以看出,就是 isa 指针地址),赋给 X13。
AND X9,X13,#0x1FFFFFFF8
解读这条指令之前,要先了解 isa 指针的结构。
|
|
首先先来看一下这 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 的结构如下:
|
|
因此,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行。
下面来做一个测试,
|
|
如上图所示,在 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 消息机制中非常重要的一环。
|
|
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>)