从一个BUG谈起,剖析objc_msgSend函数的底层实现

一、引出问题


前段时间开发FLEX+Relative库(使用Category和Runtime实现将FLEX库扩展出可以查看UIView相对间距的功能)。开发完成后遇到了一个奇怪的BUG,模拟器与真机调试时行为不一致,模拟器上可以正常实现预期功能,但是在真机上却出现问题:程序展示的视图与实际选中的视图不一致。先看问题代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
- (void)updateRelativeViewsForSelectionPoint:(CGPoint)selectionPointInWindow {
[self removeAndClearRelativeLines];
UIView *selectedView = objc_msgSend(self, @selector(viewForSelectionAtPoint:), selectionPointInWindow);
if ([self.relativeViews containsObject:selectedView]) {
[self.relativeViews removeObject:selectedView];
} else {
[self.relativeViews addObject:selectedView];
}
if (self.relativeViews.count > 2) {
[self.relativeViews removeObjectAtIndex:0];
}
[self updateRelativeDimensionLines];
[self.view bringSubviewToFront:(UIView *)[self valueForKey:@"explorerToolbar"]];
[self performSelector:@selector(updateButtonStates)];
}

通过调试,定位到问题出现在objc_msgSend函数调用这一行,这行代码的作用是调用FLEX库的viewForSelectionAtPoint:方法返回用户点击的UIView,入参为用户点击的坐标。调试过程中发现,通过objc_msgSend函数调用时,传入viewForSelectionAtPoint:方法的坐标发生了变化(x和y值发生了互换,在模拟器上正常)。

我们知道objc_msgSend是OC中的核心函数,所有的方法调用最终都会转化成objc_msgSend函数调用,Apple为了优化其性能,该函数内部使用汇编语言实现,而且不同平台对应不同的汇编文件,你可以在这里objc_msgSend汇编源码查阅相关源代码。objc_msgSend中使用了cache,而且为了实现极致的性能优化,该函数使用了ldp指令、编译内存屏障(Compile Memory Barrier)、内存垃圾回收等技术代替锁来解决多线程环境下的读写竞争和死锁问题。

通过查阅资料发现,objc_msgSend函数与正常的C函数不同,调用时要准确地指定其返回值、入参等的类型:

This unusual casting situation arises because objc_msgSend is not intended to be called like a normal C function. It is (and must be) implemented in assembly, and just jumps to a target C function after fiddling with a few registers. In particular, there is no consistent way to refer to any argument past the first two from within objc_msgSend. Another case where just calling objc_msgSend straight wouldn’t work is a method that returns an NSRect, say, because objc_msgSend is not used in that case, objc_msgSend_stret is. In the underlying C function for a method that returns an NSRect, the first argument is actually a pointer to an out value NSRect, and the function itself actually returns void. You must match this convention when calling because it’s what the called method will assume. Further, the circumstances in which objc_msgSend_stret is used differ between architectures. There is also an objc_msgSend_fpret, which should be used for methods that return certain floating point types on certain architectures.

因此,修改objc_msgSend函数调用后,问题得到解决:

1
UIView *selectedView = ((UIView* (*)(id, SEL, CGPoint p))objc_msgSend)(self, @selector(viewForSelectionAtPoint:), selectionPointInWindow);

二、问题定位


以下分析均以ARM64架构为例

2.1 预备知识

开始之前,我们先了解一下ARM架构下程序的内存分配和汇编指令的相关知识。

2.1.1 内存分配与管理

我们知道,任何指令的执行都离不开内存的操作,而根据用途的不同内存又被划分为堆(heap)、栈(stack)、常量区、全局(静态)区、代码区等。代码区存放可执行文件的指令,可以看做是可执行文件在内存中的镜像(只读);全局区存放静态变量和全局变量;常量区,顾名思义,存放的是常量。这里重点介绍栈和堆。

内存区域说明

  • 堆:堆区是一块不连续的内存区域,由开发者分配和释放,我们开发时创建的一些对象等都是在堆中存储的,堆是由内存的低地址向高地址扩展的。
  • 栈:栈区是一块连续的内存区域,由编译器进行分配和释放,其中的内存随着函数的运行和结束而分配和释放,由系统自动完成。栈的大小是有限制的,如果申请的内存大于栈区的剩余内存(如:快速排序递归层级过多时),程序会crash并报Stack Overflow错误。OS X和iOS系统中,子线程的最大栈空间默认为512KB,而对于主线程的最大栈空间,二者有所不同,OS X默认为8MB,iOS默认为1MB。

Apple堆栈大小说明

2.1.2 ARM汇编指令介绍

ARM64有32个长度为64bit的通用寄存器:x0~x30和sp,可以只使用其中的32bit:w0~w30,其中,前8个寄存器x0~x7用于函数调用时传参,同时,x0寄存器也可以作为函数返回值的寄存器。x8寄存器用于间接寻址(当函数返回的内容>16Bytes时,该返回内容会被放入到内存中,然后将该内存地址存入x8寄存器)。

  • sp寄存器指向当前frame的栈顶(低地址),可以通过移动sp的位置为栈分配或释放内存空间;
  • x29通常作为fp寄存器,指向当前frame的栈底(高地址);
  • x30通常作为lr寄存器,它有两个作用:1、保存子程序的返回地址,2、当异常发生时,用来保存异常的返回地址;
  • pc,程序计数器,指向下一条指令。

stp/ldp指令结合sp、x29、x30寄存器,保证了函数调用结束后的正确返回。

1
2
3
4
5
6
7
8
sub    sp, sp, #0x50
stp x29, x30, [sp, #0x40]

...

ldp x29, x30, [sp, #0x40]
add sp, sp, #0x50
ret

一些常用的汇编指令:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
MOV X1, X0  ;寄存器X0的值传给X1
ADD X0, X1, X2 ;寄存器X1和X2的值相加后给X0
SUB X0, X1, X2 ;寄存器X1和X2的值相减后给X0

AND X0, X0, #0xF ;X0和0xF相与后的值给X0
ORR X0, X0, #0x10 ;X0和0x10相或后的值给X0
EOR X0, X0, #0x11 ;X0和0x11相异或后的值给X0

LDR(LDUR) X5, [X6, #0x8] ;X6寄存器的值(地址)加0x8的地址内的值给X5
STR(STUR) X0, [SP, #0x8] ;X0的值给(SP+0x8)地址指向的空间

STP X29, X30, [SP, #0x1] ;入栈操作
LDP X29, X30, [SP, #0x1] ;出栈操作

CBZ 比较,如果结果为0,就跳转到后面的指令
CBNZ 比较,如果结果非0,就跳转到后面的指令

CMP 比较指令,结果影响CSPR状态

B/BL 绝对跳转,无返回值/绝对跳转,返回值地址保存到LR(X30)
RET 子程序返回,返回地址保存到LR(X30)

ADRP 用来定位数据段中的数据, 因为ASLR会导致代码及数据的地址随机化, 用ADRP来根据PC做辅助定位

2.2 定位问题

开启XCode的Always Show Disassembly,并在updateRelativeViewsForSelectionPoint:方法中的调用objc_msgSend代码处加断点,程序执行到断点处,XCode输出反汇编代码如下:

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
GWMovie`-[FLEXExplorerViewController(Relative) updateRelativeViewsForSelectionPoint:]:
0x1007dd3a0 <+0>: sub sp, sp, #0x50 ; =0x50 // 移动sp,分配0x50大小的空间,sp指向当前栈的栈顶
0x1007dd3a4 <+4>: stp x29, x30, [sp, #0x40] // 开始执行方法之前,将fp、lr寄存器入栈,状态保存到sp+0x40地址
0x1007dd3a8 <+8>: add x29, sp, #0x40 ; =0x40 // fp寄存器指向sp+0x40地址,fp指向当前frame的栈底
0x1007dd3ac <+12>: stur d0, [x29, #-0x10] // 将d0寄存器(存储浮点类型数据)的内容存到fp-0x10地址指向的空间
0x1007dd3b0 <+16>: stur d1, [x29, #-0x8] // 将d1寄存器的内容存入fp-0x8地址指向的空间,d0和d1寄存器中存储的是CGPoint结构体的x、y值
0x1007dd3b4 <+20>: stur x0, [x29, #-0x18]
0x1007dd3b8 <+24>: str x1, [sp, #0x20]
0x1007dd3bc <+28>: ldur x0, [x29, #-0x18]
0x1007dd3c0 <+32>: adrp x1, 8156
0x1007dd3c4 <+36>: ldr x1, [x1, #0x810]
0x1007dd3c8 <+40>: bl 0x101f86fa8 ; symbol stub for: objc_msgSend
// 这句开始到bl指令之前,是编译器根据calling convention为objc_msgSend的函数调用设置寄存器、栈帧的数据和状态,但是由于未明确指定objc_msgSend函数的原型,导致了calling convention mismatch,这里取的数据和之前存的数据不一致,从而引起了程序的异常行为。
-> 0x1007dd3cc <+44>: ldur x0, [x29, #-0x18] // 将fp-0x18地址的内容存入x0,即:x0中存储的为消息的receiver,在该方法中为self
0x1007dd3d0 <+48>: adrp x1, 8153
0x1007dd3d4 <+52>: ldr x1, [x1, #0xf28] // 将selector存入x1寄存器

// 问题所在,这两句ldr指令将上文存储的d1的数据加载进d0,将d0的数据加载进了d1,从而导致了CGPoint结构体中x、y值的互换
0x1007dd3d8 <+56>: ldur d0, [x29, #-0x8] // 将fp-0x8地址(存储了d1的值)的值给d0
0x1007dd3dc <+60>: ldur d1, [x29, #-0x10] // 将fp-0x10地址(存储了d0的值)的值给d1

0x1007dd3e0 <+64>: mov x30, sp
0x1007dd3e4 <+68>: str d0, [x30, #0x8]
0x1007dd3e8 <+72>: str d1, [x30]
0x1007dd3ec <+76>: bl 0x101f86fa8 ; symbol stub for: objc_msgSend
0x1007dd3f0 <+80>: mov x29, x29
0x1007dd3f4 <+84>: bl 0x101f87008 ; symbol stub for: objc_retainAutoreleasedReturnValue
0x1007dd3f8 <+88>: mov x1, #0x0
0x1007dd3fc <+92>: add x30, sp, #0x18 ; =0x18
0x1007dd400 <+96>: str x0, [sp, #0x18]
0x1007dd404 <+100>: mov x0, x30
0x1007dd408 <+104>: bl 0x101f87068 ; symbol stub for: objc_storeStrong
0x1007dd40c <+108>: ldp x29, x30, [sp, #0x40] // 恢复到该方法调用之前的状态
0x1007dd410 <+112>: add sp, sp, #0x50 ; =0x50 // 方法结束,释放空间
0x1007dd414 <+116>: ret // 返回,这一步直接执行lr的指令

从反汇编代码中可以看出,输入objc_msgSend函数的CGPoint参数与实际传入updateRelativeViewsForSelectionPoint:方法中的CGPoint参数的x、y值出现了互换:

1
2
3
4
5
stur   d0, [x29, #-0x10]
stur d1, [x29, #-0x8]
...
ldur d0, [x29, #-0x8]
ldur d1, [x29, #-0x10]

定位到问题出现在objc_msgSend函数的调用上,通过明确指定函数的入参类型和返回值类型,解决该问题。

三、刨根问底——objc_msgSend函数分析


那么为什么会出现这种奇怪的BUG呢?实际上,这个问题早在苹果发布的64-Bit Transition Guide for Cocoa Touch中提到:

Test your app on actual 64-bit hardware. iOS Simulator can also be helpful during development, but some changes, such as the function calling conventions, are visible only when your app is running on a device.

出现这个问题的原因是,在64-bit runtime下,ARM的调用约定(calling conventions)比32-bit更加严格。消息函数并不与runtime调用的方法函数共享同一个原型(prototype),因此,当没有指定消息函数的原型时,runtime会直接调用该方法的函数实现,这样就导致了调用约定不匹配(calling convention mismatch)。以objc_msgSend函数为例,它被设计成一个通用的函数,通过calling convention来满足不同入参和不同返回值类型的函数调用,但是,当发生calling convention mismatch时,函数调用者会将参数放到非objc_msgSend函数期望的地方,这样一来,当objc_msgSend函数取值时,可能会取到错误的值,从而导致程序crash或异常行为。

An exception to the casting rule described above is when you are calling the objc_msgSend function or any other similar functions in the Objective-C runtime that send messages. Although the prototype for the message functions has a variadic form, the method function that is called by the Objective-C runtime does not share the same prototype. The Objective-C runtime directly dispatches to the function that implements the method, so the calling conventions are mismatched, as described previously. Therefore you must cast the objc_msgSend function to a prototype that matches the method function being called.

If you pass a function pointer in your code, its calling conventions must stay consistent. It should always take the same set of parameters. Never cast a variadic function to a function that takes a fixed number of parameters (or vice versa). Listing 2-13 is an example of a problematic piece function call. Because the function pointer was cast to use a different set of calling conventions, a caller is going to place the parameters in a place that the called function is not expecting. This mismatch may cause your app to crash or to exhibit other unpredictable behaviors.

什么是Calling Convention?

顾名思义,Calling Convention(调用约定)即一个函数调用另一个函数时的约定,包括了参数如何传递、如何从函数返回结果等,编译器必须严格遵循调用约定进行代码编译,这样才能保证生成的代码能够正确地运行。想了解更多有关Calling Convention的内容,请戳这里:ARM64 Function Calling Conventions

其实,objc_msgSend函数之所以用汇编实现,不仅仅是出于性能的考虑,我们知道objc_msgSend是一个特殊的函数,它被设计可以接收任意数量和类型的参数并可以返回任意类型的值,比如:

1
2
3
4
5
BOOL b = [array containsObject:obj];
NSUInteger n = [array count];
// 上述代码会被转换成以下函数调用
BOOL b = (BOOL (*)(id, SEL, id))objc_msgSend(array, @selector(containsObject), obj);
NSUInteger n = (NSUInteger (*)(id, SEL))objc_msgSend(array, @selector(containsObject));

当objc_msgSend通过selector找到对应的IMP后,只需要通过传入的参数和相应的IMP函数指针调用即可。Objective-C中有各种参数类型、数量的方法调用,objc_msgSend就必须支持任意参数类型、数量和任意返回值的函数调用,如果用C语言实现,就必须加很多switch分支,把所有的参数类型、数量的组合全都覆盖到,这显然是不可能实现的。

Apple的解决办法是,通过不同的Calling Convention,将objc_msgSend开始执行时所需要的栈帧(stack frame)的状态,各寄存器的参数、组合形式和状态设置等都交给编译器来设置,这样就保证了调用objc_msgSend函数时,栈和各个寄存器中的数据、状态正是调用具体函数时所需要的。

当objc_msgSend找到要调用的函数实现IMP后,只需要把所有的对栈、寄存器的操作“倒”回到objc_msgSend执行开始的状态(类似于函数执行完成return返回前,做的“收尾处理”工作一样,即epilogue),直接jump/call到IMP函数指针对应的地址,执行指令即可,因为所有的参数已经被设置好了。

同时,当IMP执行完成后,返回值也被正确的设置好了(在x86平台上,返回值被设置到了指定的寄存器eax/rax里,在arm上,则是r0寄存器),所以,我们也不必担心不同类型的返回值问题了。

objc_msgSend函数汇编源码分析

objc_msgSend函数的主要作用是:

  1. 根据传入的对象,获取其isa指针,并根据isa指针找到对象所属的类
  2. 获取类的方法缓存,并根据selector从缓存中查找对应的IMP
  3. 如果从缓存中找到IMP,直接调用
  4. 如果没有从缓存中找到,调用C函数(__class_lookupMethodAndLoadCache3)继续查询,并将查询结果存入类的方法缓存

首先看下objc_msgSend的完整指令(在lldb中输入disassemble -n objc_msgSend -c 100 -b命令):

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
0x180718900 <+0>:   0xf100001f   cmp    x0, #0x0                  ; =0x0 
0x180718904 <+4>: 0x5400034d b.le 0x18071896c ; <+108>
0x180718908 <+8>: 0xf940000d ldr x13, [x0]
0x18071890c <+12>: 0x927d81b0 and x16, x13, #0xffffffff8
0x180718910 <+16>: 0xa9412e0a ldp x10, x11, [x16, #0x10]
0x180718914 <+20>: 0x0a0b002c and w12, w1, w11
0x180718918 <+24>: 0x8b0c114c add x12, x10, x12, lsl #4
0x18071891c <+28>: 0xa9404589 ldp x9, x17, [x12]
0x180718920 <+32>: 0xeb01013f cmp x9, x1
0x180718924 <+36>: 0x54000041 b.ne 0x18071892c ; <+44>
0x180718928 <+40>: 0xd61f0220 br x17
0x18071892c <+44>: 0xb40016a9 cbz x9, 0x180718c00 ; _objc_msgSend_uncached
0x180718930 <+48>: 0xeb0a019f cmp x12, x10
0x180718934 <+52>: 0x54000060 b.eq 0x180718940 ; <+64>
0x180718938 <+56>: 0xa9ff4589 ldp x9, x17, [x12, #-0x10]!
0x18071893c <+60>: 0x17fffff9 b 0x180718920 ; <+32>
0x180718940 <+64>: 0x8b2b518c add x12, x12, w11, uxtw #4
0x180718944 <+68>: 0xa9404589 ldp x9, x17, [x12]
0x180718948 <+72>: 0xeb01013f cmp x9, x1
0x18071894c <+76>: 0x54000041 b.ne 0x180718954 ; <+84>
0x180718950 <+80>: 0xd61f0220 br x17
0x180718954 <+84>: 0xb4001569 cbz x9, 0x180718c00 ; _objc_msgSend_uncached
0x180718958 <+88>: 0xeb0a019f cmp x12, x10
0x18071895c <+92>: 0x54000060 b.eq 0x180718968 ; <+104>
0x180718960 <+96>: 0xa9ff4589 ldp x9, x17, [x12, #-0x10]!
0x180718964 <+100>: 0x17fffff9 b 0x180718948 ; <+72>
0x180718968 <+104>: 0x140000a6 b 0x180718c00 ; _objc_msgSend_uncached
0x18071896c <+108>: 0x540001c0 b.eq 0x1807189a4 ; <+164>
0x180718970 <+112>: 0xd2fe000a mov x10, #-0x1000000000000000
0x180718974 <+116>: 0xeb0a001f cmp x0, x10
0x180718978 <+120>: 0x540000c2 b.hs 0x180718990 ; <+144>
0x18071897c <+124>: 0xb0198daa adrp x10, 209333
0x180718980 <+128>: 0x9109c14a add x10, x10, #0x270 ; =0x270
0x180718984 <+132>: 0xd37cfc0b lsr x11, x0, #60
0x180718988 <+136>: 0xf86b7950 ldr x16, [x10, x11, lsl #3]
0x18071898c <+140>: 0x17ffffe1 b 0x180718910 ; <+16>
0x180718990 <+144>: 0xb0198daa adrp x10, 209333
0x180718994 <+148>: 0x910bc14a add x10, x10, #0x2f0 ; =0x2f0
0x180718998 <+152>: 0xd374ec0b ubfx x11, x0, #52, #8
0x18071899c <+156>: 0xf86b7950 ldr x16, [x10, x11, lsl #3]
0x1807189a0 <+160>: 0x17ffffdc b 0x180718910 ; <+16>
0x1807189a4 <+164>: 0xd2800001 mov x1, #0x0
0x1807189a8 <+168>: 0x2f00e400 movi d0, #0000000000000000
0x1807189ac <+172>: 0x2f00e401 movi d1, #0000000000000000
0x1807189b0 <+176>: 0x2f00e402 movi d2, #0000000000000000
0x1807189b4 <+180>: 0x2f00e403 movi d3, #0000000000000000
0x1807189b8 <+184>: 0xd65f03c0 ret
0x1807189bc <+188>: 0xd503201f nop

完整的objc_msgSend流程包括了对receiver为空、Tagged Pointer、正常对象的判断,方法缓存命中、非命中等不同情况的处理,我们先从正常的情况开始分析,即:receiver非nil且非Tagged Pointer对象、并且在类的方法缓存中查找到对应的实现。

正常情况
1
2
0x180718900 <+0>:   0xf100001f   cmp    x0, #0x0                  ; =0x0 
0x180718904 <+4>: 0x5400034d b.le 0x18071896c ; <+108>

这两个指令是对存储在x0寄存器中的receiver与0进行比较,如果receiver小于等于0,跳转到第108行指令处,执行receiver == nil || Tagged Pointer流程。

1
0x180718908 <+8>:   0xf940000d   ldr    x13, [x0]

这条指令是加载receiver的第一个64bit(ARM64架构下)指针到x13寄存器。由于所有继承自NSObject的类实例化后的对象都会包含一个类型为isa_t的结构体(即,isa指针),该指针存在对象的第一个字中,因此,x13中保存的即是receiver的isa指针。

1
0x18071890c <+12>:  0x927d81b0   and    x16, x13, #0xffffffff8

由于ARM64可以使用非指针的isa(比如Tagged Pointer对象),通常情况下,对象的isa指针指向的是对象的类,但是Tagged Pointer的isa利用了备用的bit位(比如,正常的OC对象地址的最高位为0,如果为1则表示对象是Tagged Pointer对象,而如果对象地址的最高4位位0xf,则代表该对象是用户自定义的Tagged Pointer对象,否则是系统自带的Tagged Pointer对象,如NSNumber、NSDate等),因此,这里通过isa指针与0xffffffff8得到receiver所属的Class对象obj_class,并将其保存到x16寄存器。

1
0x180718910 <+16>:  0xa9412e0a   ldp    x10, x11, [x16, #0x10]

这个指令是从Class对象偏移16的地址处加载数据到x10,x11两个寄存器中,我们看下obj_class结构体的构成:

1
2
3
4
5
6
struct objc_class : objc_object {
isa_t isa;
Class superclass;
cache_t cache;
class_data_bits_t bits;
}

class对象首地址偏移16刚好指向cache_t,因此这条指令是将类的方法缓存加载到寄存器中,具体来说就是将_buckets加载到x10中,将_mask加载到x11的低32位,将_occupied加载到x11的高32位。cache_t的结构如下:

1
2
3
4
5
6
7
8
9
10
11
12
typedef uint32_t mask_t;

struct bucket_t {
SEL selector; // selector
IMP imp; // 方法实现对应的函数指针
}

struct cache_t {
struct bucket_t *_buckets; //缓存方法的哈希桶数组指针,桶的数量 = mask + 1
mask_t _mask; //描述了哈希表的尺寸,方便用于与运算的掩码。它的值总是一个2的幂减一,用二进制的方法描述看起来就像是000000001111111,末尾是可变数量的1。通过这个值可以知道selector的查找索引,并在查找表的时候包裹着结尾。
mask_t _occupied; //哈希表中的条目。
}
1
0x180718914 <+20>:  0x0a0b002c   and    w12, w1, w11

从上文介绍的内容知道,x1中存储的是selector,而w1代表了x1寄存器的低32位,w11位x11寄存器的低32位,因此这条指令将selector的低32位与上面提到的_mask进行与运算并将结果放入x12寄存器的低32位,结果相当于是计算selector % table_size,但是避免了开销很大的模运算。这条指令的作用是计算selector的起始哈希表的索引

1
0x180718918 <+24>:  0x8b0c114c   add    x12, x10, x12, lsl #4

有了索引,再取得地址我们就可以从哈希表中加载数据了。这条指令通过_buckets的地址结合上面得到的索引取得要查找的bucket地址。lsl #4是逻辑左移,相当于乘以16,由于每个bucket的大小是16Bytes,此时x12中刚好保存了第一个要查找的bucket的地址。

1
0x18071891c <+28>:  0xa9404589   ldp    x9, x17, [x12]

这条指令将当前bucket的selector加载到x9,将IMP加载到x17中。

1
2
3
4
0x180718920 <+32>:  0xeb01013f   cmp    x9, x1
0x180718924 <+36>: 0x54000041 b.ne 0x18071892c ; <+44>
0x180718928 <+40>: 0xd61f0220 br x17
0x18071892c <+44>: 0xb40016a9 cbz x9, 0x180718c00 ;

将当前receiver的selector与从bucket找到的selector进行比较,如果二者相等,则无条件跳转到x17,执行目标函数,至此,objc_msgSend的FAST_EXIT结束。
如果二者不相等,跳转到第44个指令,这条指令先是用当前查找的bucket的selector和0作比较,如果等于0则跳转到objc_msgSend_uncached。这说明这是一个空的bucket,并且意味着这次查找失败了。目标方法不在缓存中,这时候会回到C代码(objc_msgSend_uncached),执行更详细的查找。否则就说明bucket不是空的,只是没有匹配,则继续查找。

1
2
0x180718930 <+48>:  0xeb0a019f   cmp    x12, x10
0x180718934 <+52>: 0x54000060 b.eq 0x180718940 ; <+64>

FLAG:将x12中的bucket地址与x10中的哈希表起始地址作比较,如果不相等,继续执行下面的指令,

1
2
0x180718938 <+56>:  0xa9ff4589   ldp    x9, x17, [x12, #-0x10]!
0x18071893c <+60>: 0x17fffff9 b 0x180718920 ; <+32>

这里可以看到一个循环,不断地从哈希表中取出新的bucket,执行第32条指令,直到找到匹配的bucket或者空的bucket或者命中表的开头。这条指令中,地址引用末尾的感叹号是一个有趣的特性。这指定一个寄存器进行回写,意思就是寄存器会更新为计算后的值。

如果FLAG处的比较结果相同,执行以下指令

1
2
3
4
5
6
0x180718940 <+64>:  0x8b2b518c   add    x12, x12, w11, uxtw #4
0x180718944 <+68>: 0xa9404589 ldp x9, x17, [x12]
0x180718948 <+72>: 0xeb01013f cmp x9, x1
0x18071894c <+76>: 0x54000041 b.ne 0x180718954 ; <+84>
0x180718950 <+80>: 0xd61f0220 br x17
0x180718954 <+84>: 0xb4001569 cbz x9, 0x180718c00 ; _objc_msgSend_uncached

x12包含了当前bucket的指针,此时x12指向的是第一个bucket。w11包含了表的掩码,即表的大小。这里将两个值做了相加,同时将w11左移4位。现在x12中的结果是指向表的末尾,并且从这里可以恢复查找。然后执行ldp指令加载一个新的bucket到x9和x17寄存器,并检查该bucket是否与receiver的selector匹配,如果匹配,跳转到x17执行IMP,否则判断当前bucke是否为空bucket,如果为空,执行_objc_msgSend_uncached流程。

1
2
3
4
5
0x180718958 <+88>:  0xeb0a019f   cmp    x12, x10
0x18071895c <+92>: 0x54000060 b.eq 0x180718968 ; <+104>
0x180718960 <+96>: 0xa9ff4589 ldp x9, x17, [x12, #-0x10]!
0x180718964 <+100>: 0x17fffff9 b 0x180718948 ; <+72>
0x180718968 <+104>: 0x140000a6 b 0x180718c00 ; _objc_msgSend_uncached

再一次检查是否已到哈希表的表头,如果没有到表头,则重复上述步骤继续查找bucket;如果再次命中表头,跳转到104行指令处执行_objc_msgSend_uncached流程,调用C函数进行全面查找(SLOW_EXIT)。

额外的二次扫描检查是为了在遇到内存被破坏或者无效对象时,防止陷入无限循环而榨干性能。举个例子,堆损坏能够在缓存中塞满非0的数据,或者设置缓存的掩码为0,缓存不命中就会一直循环执行缓存扫描。额外的检查可以停止循环,将问题转变为崩溃日志。

还有一种情况,当有另一个线程同时修改缓存时会引起这个线程即不命中也不miss。C代码做了额外的工作来解决竞争。之前一个版本的objc_msgSend的做法是错误的,它会立即终止,而不是回到C代码,这样做的话运气不好的时候会发生罕见的崩溃。

receiver为nil时
1
2
3
4
5
6
7
0x18071896c <+108>: 0x540001c0   b.eq   0x1807189a4               ; <+164>
0x1807189a4 <+164>: 0xd2800001 mov x1, #0x0
0x1807189a8 <+168>: 0x2f00e400 movi d0, #0000000000000000
0x1807189ac <+172>: 0x2f00e401 movi d1, #0000000000000000
0x1807189b0 <+176>: 0x2f00e402 movi d2, #0000000000000000
0x1807189b4 <+180>: 0x2f00e403 movi d3, #0000000000000000
0x1807189b8 <+184>: 0xd65f03c0 ret

当receiver为nil时,仅仅是将x0、x1、d0~d3寄存器的值设置为0(整型的返回值被保存在x0、x1中,浮点数返回值被保存在向量寄存器v0~v3中),然后返回给调用者。不清除x0是因为此时receiver为nil,x0中本来就是0。

receiver为Tagged Pointer时

简单讨论下tagged pointer是如何工作的。tagged pointer支持多个类。tagged pointer的前四位(ARM 64上)指明对象的类是哪个。本质上就是tagged pointer的isa。当然4位不够保存一个类的指针。实际上,有一张特殊的表存储了可用的tagged pointer的类。这个对象的类的查找是通过搜索这张表中的索引,是否对应于这个tagged pointer的前4位。

tagged pointer(至少在AMR64上)也支持扩展类。当前四位都设置为1,接下去的8位用于索引tagged pointer扩展类的表。减少存储他们的代价,就允许运行时能够支持更多的tagged pointer类。

1
2
3
0x180718970 <+112>: 0xd2fe000a   mov    x10, #-0x1000000000000000
0x180718974 <+116>: 0xeb0a001f cmp x0, x10
0x180718978 <+120>: 0x540000c2 b.hs 0x180718990 ; <+144>

x10被设置成一个整型值,只有前4位被设置为1,其余位都为0。x10被用作掩码从receiver中提取标志位,检查是否用户扩展的Tagged Pointer,如果receiver大于等于x10,则表示当前receiver为扩展的Tagged Pointer,跳转到144条指令处进行处理。否则说明receiver是系统自带的Tagged Pointer类型对象。

1
2
0x18071897c <+124>: 0xb0198daa   adrp   x10, 209333
0x180718980 <+128>: 0x9109c14a add x10, x10, #0x270 ; =0x270

1、系统自带Tagged Pointer类型对象的流程, 这里加载了系统Tagged Pointer主表到x10。

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

1
2
3
0x180718984 <+132>: 0xd37cfc0b   lsr    x11, x0, #60
0x180718988 <+136>: 0xf86b7950 ldr x16, [x10, x11, lsl #3]
0x18071898c <+140>: 0x17ffffe1 b 0x180718910 ; <+16>

由于系统自带的Tagged Pointer对象的索引保存在对象地址的60~63位,这里将receiver的地址右移60位取得索引并保存到x11中,然后根据该索引从x10的系统Tagged Pointer主表中获取到对象的isa指针并存入x16寄存器,然后跳转到第16条指令处执行后续的方法缓存查找等流程。

2、用户扩展的Tagged Pointer对象的执行流程也是类似的

1
2
3
4
5
0x180718990 <+144>: 0xb0198daa   adrp   x10, 209333
0x180718994 <+148>: 0x910bc14a add x10, x10, #0x2f0 ; =0x2f0
0x180718998 <+152>: 0xd374ec0b ubfx x11, x0, #52, #8
0x18071899c <+156>: 0xf86b7950 ldr x16, [x10, x11, lsl #3]
0x1807189a0 <+160>: 0x17ffffdc b 0x180718910 ; <+16>

不同的是扩展的Tagged Pointer对象的索引保存在对象地址的52~59位,这里用了一个位域提取指令ubfx提取对象地址的52~59位作为索引存入x11寄存器中,然后通过索引取得对象的isa指针,并进行后续的方法缓存查找等流程。

四、结束语


以上就是objc_msgSend函数的汇编分析的所有内容。理解objc_msgSend的实现原理,有助于我们深入理解Runtime机制、定位程序BUG等。
这篇文章只是浅显地分析了objc_msgSend函数的汇编实现,其中用到的的诸如Calling Convention、使用Compiler Memory Barrier(编译内存屏障)实现方法缓存的无锁读写同步的技术、使用享元设计模式从Tagged Pointer对象中获取isa等等都值得我们更深入地研究。

参考文章