前文提要

iOS 底层探索文章系列

我们在前面探索了对象和类的底层原理,接下来我们要探索一下方法的本质,而在探索之前,我们先简单了解 Runtime 的知识点。

1、Runtime 简介

1.1 Runtime 版本

首先 Runtime 分为两个版本,legacymodern,分别对应 Objective-C 1.0Objective-C 2.0。我们通常只需要专注于 modern 版本即可。

1.2 Runtime 三种交互方式

我们与 Runtime 的交互有以下三种方式

  • 直接在 OC 层进行交互:比如 @selector
  • NSObjCRuntime 的:NSSelectorFromString 方法;
  • Runtime APIsel_registerName

2、探索方法的本质

2.1 clang 重写

我们先将下面这段代码进行 clang 重写

image.png

  1. int main(int argc, const char * argv[]) {
  2. /* @autoreleasepool */ { __AtAutoreleasePool __autoreleasepool;
  3. ZLStudent *student = ((ZLStudent *(*)(id, SEL))(void *)objc_msgSend)((id)objc_getClass("ZLStudent"), sel_registerName("alloc"));
  4. ((void (*)(id, SEL))(void *)objc_msgSend)((id)student, sel_registerName("sayHello"));
  5. }
  6. return 0;
  7. }

我们可以看到,经过重写之后,sayHello 方法在底层其实就是一个消息的发送。我们把上面的发送消息的代码简化一下:

  1. ZLStudent *student = objc_msgSend((id)objc_getClass("ZLStudent"), sel_registerName("alloc"));
  2. objc_msgSend((id)student, sel_registerName("sayHello"));

如果要在工程当中直接使用 objc_msgSend API,我们需要导入头文件 <objc/message.h> 和 将 Enbale Strict Checking of objc_msgSend Calls 设置为 NO,这样才不会报错。

image.png

接下来我们打印一下结果,

image.png

可以看到,也是可以正常调用方法的,由此可见,真正发送消息的地方是 objc_msgSend,这个方法有基本的两个参数,第一个参数是消息的接收者为 id 类型,第二个参数是方法编号为 SEL 类型。

2.2 发送方法的几种形式

image.png

上诉代码表示的是向对象 student 发送 sayHello 消息

image.png

上述代码表示向 ZLStudent 这个类发送 sayHappy 消息。

image.png

上述代码表示向父类 ZLTeacher 发送 sayGoodMorning 消息。

image.png

上述代码表示向父类的类,也就是 ZLTeacher 类对象的元类发送 sayByebye 消息。

3、探索 objc_msgSend(重点)

objc_msgSend 之所以采用汇编来实现,有以下两种主要因素:

  • 汇编更容易能被机器识别
  • 参数未知、类型未知对于 CC++ 来说不如汇编更得心应手

3.1 消息查找机制

消息查找机制分为两个机制

  • 快速流程
  • 慢速流程

接下来我们开始本文的重点,针对 方法缓存 cache_t 的分析(本文采用的源码版本为 objc4-781 )

3.2 cache_t 源码分析

我们知道,当我们的 OC 项目在编译完成之后,类的实例方法(方法编号 SEL 和函数指针地址 IMP)会保存在类的方法列表中。

OC 为了实现其动态性,将 方法的调用包装成了 SEL 寻找 IMP 的过程。我们可以想象一下,如果每次调用方法,都要去类的方法列表或者父类、根类的方法列表里面去查询函数地址的话,必然会对性能造成极大的损耗。为了解决这个问题,OC 采用了方法缓存的机制来提高调用效率,也就是 cache_t,其作用就是缓存已调用的方法。当调用方法时,objc_msgSend 会先去缓存中查找,如果找到就执行该方法;如果不在缓存中,则去类的方法列表或者父类、根类的方法列表去查找,找到后会将方法的 SELIMP 缓存到 cache_t 中,以便下次调用时能够快速执行。

3.3 objc_msgSend 读取缓存

之前我们已经分析过 cache_t 写入缓存的工作流程,下面我们来分析一下 objc_msgSend 读缓存的代码。( 以 arm64 架构汇编为例

3.3.1 _objc_msgSend 入口函数源码实现:

  1. ENTRY _objc_msgSend
  2. UNWIND _objc_msgSend, NoFrame
  3. // 判断P0,也就是我们 `objc_msgSend` 的第一个参数 `id` 消息的接收者是否存在
  4. cmp p0, #0 // nil check and tagged pointer check
  5. // 是否是 `taggedPointer` 对象判断处理
  6. #if SUPPORT_TAGGED_POINTERS
  7. // `tagged` 或者空判断
  8. b.le LNilOrTagged // (MSB tagged pointer looks negative)
  9. #else
  10. // 直接返回空
  11. b.eq LReturnZero
  12. #endif
  13. // 读取 `x0`,然后复制到 `p13`,这里 `p13` 拿到的是 `isa`。为什么要拿 `isa` 呢,因为不论是对象方法还是类方法,我们都需要在类或者元类的缓存或者方法列表中去查找,所以 `isa` 是必须的。
  14. ldr p13, [x0] // p13 = isa
  15. // 通过 `GetClassFromIsa_p16`,将获取到的 `class` 存到 `p16`
  16. GetClassFromIsa_p16 p13 // p16 = class
  17. LGetIsaDone:
  18. // calls imp or objc_msgSend_uncached
  19. // 获取完 `isa` 之后,接下来就要进行 `CacheLookup`,进行查找方法缓存,我们接下来到 `CacheLookup`的源码处
  20. CacheLookup NORMAL, _objc_msgSend

通过注释,我们知道 CacheLookup 有三种模式

  • NORMAL
  • GETIMP
  • LOOKUP

3.3.2 CacheLookup 源码实现:

  1. // CacheLookup NORMAL|GETIMP|LOOKUP <function>
  2. .macro CacheLookup
  3. // p1 = SEL, p16 = isa
  4. // `CacheLookup` 需要读取上一步拿到的类的 `cache` 缓存,然后进行 16 字节地址平移操作,把 `cache_t` 中的 `_maskAndBuckets` 复制给 `p11`
  5. ldr p11, [x16, #CACHE] // p11 = mask|buckets
  6. // `_maskAndBuckets` & `bucketsMask` 掩码,然后将结果放在 `p10 = buckets`
  7. and p10, p11, #0x0000ffffffffffff // p10 = buckets
  8. // LSP逻辑右移,将 `p11` 右移48位得到 `mask` `sel & mask` 后把结果放入到 `p12`,这里的本质就是我们在写入内存遇到的 `cache_hash` 方法一模一样,目的就是拿到方法缓存的哈希下标。
  9. and p12, p1, p11, LSR #48 // x12 = _cmd & mask
  10. // LSL逻辑左移,`p10` `buckets` 也是缓存数组的首地址,每个 `bucket(sel 8字节 + imp 8字节)` 的大小为 16 字节,`p12` 为方法缓存的哈希下标,`buckets + (index << 4)` 得到 下标处对应的 `bucket`,然后把结果放到 `p12`
  11. add p12, p10, p12, LSL #(1+PTRSHIFT)
  12. // p12 = buckets + ((_cmd & mask) << (1+PTRSHIFT))
  13. // `bucket` 结构体将中 `imp` `sel` 分别存到 `p17` `p9` 中。
  14. ldp p17, p9, [x12] // {imp, sel} = *bucket
  15. // 接着我们将上一步获取到的 `sel` 和我们要查找的 `sel`(在这里也就是所谓的 `_cmd`)进行比较,如果匹配了,就通过 `CacheHit` imp 返回;如果没有匹配,就走下一步流程。
  16. 1: cmp p9, p1 // if (bucket->sel != _cmd)
  17. b.ne 2f // scan more
  18. // 命中缓存,返回结果
  19. CacheHit $0 // call or return imp
  20. // 没找到 `bucket`
  21. 2: // not hit: p12 = not-hit bucket
  22. // 如果从最后一个元素往前遍历都找不到缓存,那么走 `CheckMiss`
  23. CheckMiss $0 // miss if bucket->sel == 0
  24. // 判断当前查询的 `bucket` 是否为第一个元素
  25. cmp p12, p10 // wrap if bucket == buckets
  26. // 如果是第一个元素,那么将当前查询的` bucket` 设置为最后一个元素 `(p12 = buckets + (mask << 1+PTRSHIFT))`
  27. b.eq 3f
  28. // 向前查找
  29. ldp p17, p9, [x12, #-BUCKET_SIZE]! // {imp, sel} = *--bucket
  30. // 进行递归搜索
  31. b 1b // loop
  32. 3: // wrap: p12 = first bucket, w11 = mask
  33. #if CACHE_MASK_STORAGE == CACHE_MASK_STORAGE_HIGH_16
  34. add p12, p12, p11, LSR #(48 - (1+PTRSHIFT))
  35. // p12 = buckets + (mask << 1+PTRSHIFT)
  36. #endif
  37. ldp p17, p9, [x12] // {imp, sel} = *bucket
  38. 1: cmp p9, p1 // if (bucket->sel != _cmd)
  39. b.ne 2f // scan more
  40. CacheHit $0 // call or return imp
  41. 2: // not hit: p12 = not-hit bucket
  42. CheckMiss $0 // miss if bucket->sel == 0
  43. cmp p12, p10 // wrap if bucket == buckets
  44. b.eq 3f
  45. ldp p17, p9, [x12, #-BUCKET_SIZE]! // {imp, sel} = *--bucket
  46. // 递归遍历
  47. b 1b // loop
  48. LLookupEnd$1:
  49. LLookupRecover$1:
  50. 3: // double wrap
  51. JumpMiss $0

3.3.3 CheckMiss 源码实现:

  1. .macro CheckMiss
  2. // miss if bucket->sel == 0
  3. .if $0 == GETIMP
  4. cbz p9, LGetImpMiss
  5. .elseif $0 == NORMAL
  6. // 由于我们是 `NORMAL` 模式,所以会来到这 `__objc_msgSend_uncached`
  7. cbz p9, __objc_msgSend_uncached
  8. .elseif $0 == LOOKUP
  9. cbz p9, __objc_msgLookup_uncached
  10. .else
  11. .abort oops
  12. .endif
  13. .endmacro

3.3.4 __objc_msgSend_uncached 源码实现:

  1. STATIC_ENTRY __objc_msgSend_uncached
  2. UNWIND __objc_msgSend_uncached, FrameWithNoSaves
  3. // THIS IS NOT A CALLABLE C FUNCTION
  4. // Out-of-band p16 is the class to search
  5. // 这里面最核心的逻辑就是 `MethodTableLookup`,查找方法列表。
  6. MethodTableLookup
  7. TailCallFunctionPointer x17
  8. END_ENTRY __objc_msgSend_uncached

3.3.5 MethodTableLookup 源码实现:

  1. .macro MethodTableLookup
  2. // push frame
  3. SignLR
  4. stp fp, lr, [sp, #-16]!
  5. mov fp, sp
  6. // save parameter registers: x0..x8, q0..q7
  7. sub sp, sp, #(10*8 + 8*16)
  8. stp q0, q1, [sp, #(0*16)]
  9. stp q2, q3, [sp, #(2*16)]
  10. stp q4, q5, [sp, #(4*16)]
  11. stp q6, q7, [sp, #(6*16)]
  12. stp x0, x1, [sp, #(8*16+0*8)]
  13. stp x2, x3, [sp, #(8*16+2*8)]
  14. stp x4, x5, [sp, #(8*16+4*8)]
  15. stp x6, x7, [sp, #(8*16+6*8)]
  16. str x8, [sp, #(8*16+8*8)]
  17. // lookUpImpOrForward(obj, sel, cls, LOOKUP_INITIALIZE | LOOKUP_RESOLVER)
  18. // receiver and selector already in x0 and x1
  19. mov x2, x16
  20. mov x3, #3
  21. // `重点`
  22. bl _lookUpImpOrForward
  23. // IMP in x0
  24. mov x17, x0
  25. // restore registers and return
  26. ldp q0, q1, [sp, #(0*16)]
  27. ldp q2, q3, [sp, #(2*16)]
  28. ldp q4, q5, [sp, #(4*16)]
  29. ldp q6, q7, [sp, #(6*16)]
  30. ldp x0, x1, [sp, #(8*16+0*8)]
  31. ldp x2, x3, [sp, #(8*16+2*8)]
  32. ldp x4, x5, [sp, #(8*16+4*8)]
  33. ldp x6, x7, [sp, #(8*16+6*8)]
  34. ldr x8, [sp, #(8*16+8*8)]
  35. mov sp, fp
  36. ldp fp, lr, [sp], #16
  37. AuthenticateLR
  38. .endmacro

我们观察 MethodTableLookup 内容之后会定位到 _lookUpImpOrForward。真正的方法查找流程核心逻辑是位于 _lookUpImpOrForward 里面的。 但是我们全局搜索 _lookUpImpOrForward 会发现找不到,这是因为此时我们会从 汇编 跳入到 C/C++。所以去掉一个下划线就能找到了

image.png

3.4 objc_msgSend 流程图

image.png

4、总结

  • 方法的本质就是消息发送,消息发送是通过 objc_msgSend 以及其派生函数来实现的。
  • objc_msgSend 为了执行效率以及 C/C++ 不能支持参数未知,类型未知的代码,所以采用 汇编 来实现 objc_msgSend
  • 消息查找或者说方法查找,会优先去从类中查找缓存,找到了就返回,找不到就需要去 类的方法列表 中查找。
  • 由汇编过渡到 C/C++,在类的方法列表中查找失败之后,会进行转发。核心逻辑位于 lookUpImpOrForward