一、引出问题
前段时间开发FLEX+Relative库(使用Category和Runtime实现将FLEX库扩展出可以查看UIView相对间距的功能)。开发完成后遇到了一个奇怪的BUG,模拟器与真机调试时行为不一致,模拟器上可以正常实现预期功能,但是在真机上却出现问题:程序展示的视图与实际选中的视图不一致。先看问题代码:
1 | - (void)updateRelativeViewsForSelectionPoint:(CGPoint)selectionPointInWindow { |
通过调试,定位到问题出现在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。
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 | sub sp, sp, |
一些常用的汇编指令:
1 | MOV X1, X0 ;寄存器X0的值传给X1 |
2.2 定位问题
开启XCode的Always Show Disassembly,并在updateRelativeViewsForSelectionPoint:方法中的调用objc_msgSend代码处加断点,程序执行到断点处,XCode输出反汇编代码如下:
1 | GWMovie`-[FLEXExplorerViewController(Relative) updateRelativeViewsForSelectionPoint:]: |
从反汇编代码中可以看出,输入objc_msgSend函数的CGPoint参数与实际传入updateRelativeViewsForSelectionPoint:方法中的CGPoint参数的x、y值出现了互换:
1 | stur d0, [x29, |
定位到问题出现在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 | BOOL b = [array containsObject:obj]; |
当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函数的主要作用是:
- 根据传入的对象,获取其isa指针,并根据isa指针找到对象所属的类
- 获取类的方法缓存,并根据selector从缓存中查找对应的IMP
- 如果从缓存中找到IMP,直接调用
- 如果没有从缓存中找到,调用C函数(__class_lookupMethodAndLoadCache3)继续查询,并将查询结果存入类的方法缓存
首先看下objc_msgSend的完整指令(在lldb中输入disassemble -n objc_msgSend -c 100 -b命令):
1 | 0x180718900 <+0>: 0xf100001f cmp x0, |
完整的objc_msgSend流程包括了对receiver为空、Tagged Pointer、正常对象的判断,方法缓存命中、非命中等不同情况的处理,我们先从正常的情况开始分析,即:receiver非nil且非Tagged Pointer对象、并且在类的方法缓存中查找到对应的实现。
正常情况
1 | 0x180718900 <+0>: 0xf100001f cmp x0, |
这两个指令是对存储在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, |
由于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, |
这个指令是从Class对象偏移16的地址处加载数据到x10,x11两个寄存器中,我们看下obj_class结构体的构成:
1 | struct objc_class : objc_object { |
class对象首地址偏移16刚好指向cache_t,因此这条指令是将类的方法缓存加载到寄存器中,具体来说就是将_buckets加载到x10中,将_mask加载到x11的低32位,将_occupied加载到x11的高32位。cache_t的结构如下:
1 | typedef uint32_t mask_t; |
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 |
有了索引,再取得地址我们就可以从哈希表中加载数据了。这条指令通过_buckets的地址结合上面得到的索引取得要查找的bucket地址。lsl #4是逻辑左移,相当于乘以16,由于每个bucket的大小是16Bytes,此时x12中刚好保存了第一个要查找的bucket的地址。
1 | 0x18071891c <+28>: 0xa9404589 ldp x9, x17, [x12] |
这条指令将当前bucket的selector加载到x9,将IMP加载到x17中。
1 | 0x180718920 <+32>: 0xeb01013f cmp x9, x1 |
将当前receiver的selector与从bucket找到的selector进行比较,如果二者相等,则无条件跳转到x17,执行目标函数,至此,objc_msgSend的FAST_EXIT结束。
如果二者不相等,跳转到第44个指令,这条指令先是用当前查找的bucket的selector和0作比较,如果等于0则跳转到objc_msgSend_uncached。这说明这是一个空的bucket,并且意味着这次查找失败了。目标方法不在缓存中,这时候会回到C代码(objc_msgSend_uncached),执行更详细的查找。否则就说明bucket不是空的,只是没有匹配,则继续查找。
1 | 0x180718930 <+48>: 0xeb0a019f cmp x12, x10 |
FLAG:将x12中的bucket地址与x10中的哈希表起始地址作比较,如果不相等,继续执行下面的指令,
1 | 0x180718938 <+56>: 0xa9ff4589 ldp x9, x17, [x12, |
这里可以看到一个循环,不断地从哈希表中取出新的bucket,执行第32条指令,直到找到匹配的bucket或者空的bucket或者命中表的开头。这条指令中,地址引用末尾的感叹号是一个有趣的特性。这指定一个寄存器进行回写,意思就是寄存器会更新为计算后的值。
如果FLAG处的比较结果相同,执行以下指令
1 | 0x180718940 <+64>: 0x8b2b518c add x12, x12, w11, uxtw |
x12包含了当前bucket的指针,此时x12指向的是第一个bucket。w11包含了表的掩码,即表的大小。这里将两个值做了相加,同时将w11左移4位。现在x12中的结果是指向表的末尾,并且从这里可以恢复查找。然后执行ldp指令加载一个新的bucket到x9和x17寄存器,并检查该bucket是否与receiver的selector匹配,如果匹配,跳转到x17执行IMP,否则判断当前bucke是否为空bucket,如果为空,执行_objc_msgSend_uncached流程。
1 | 0x180718958 <+88>: 0xeb0a019f cmp x12, x10 |
再一次检查是否已到哈希表的表头,如果没有到表头,则重复上述步骤继续查找bucket;如果再次命中表头,跳转到104行指令处执行_objc_msgSend_uncached流程,调用C函数进行全面查找(SLOW_EXIT)。
额外的二次扫描检查是为了在遇到内存被破坏或者无效对象时,防止陷入无限循环而榨干性能。举个例子,堆损坏能够在缓存中塞满非0的数据,或者设置缓存的掩码为0,缓存不命中就会一直循环执行缓存扫描。额外的检查可以停止循环,将问题转变为崩溃日志。
还有一种情况,当有另一个线程同时修改缓存时会引起这个线程即不命中也不miss。C代码做了额外的工作来解决竞争。之前一个版本的objc_msgSend的做法是错误的,它会立即终止,而不是回到C代码,这样做的话运气不好的时候会发生罕见的崩溃。
receiver为nil时
1 | 0x18071896c <+108>: 0x540001c0 b.eq 0x1807189a4 ; <+164> |
当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 | 0x180718970 <+112>: 0xd2fe000a mov x10, |
x10被设置成一个整型值,只有前4位被设置为1,其余位都为0。x10被用作掩码从receiver中提取标志位,检查是否用户扩展的Tagged Pointer,如果receiver大于等于x10,则表示当前receiver为扩展的Tagged Pointer,跳转到144条指令处进行处理。否则说明receiver是系统自带的Tagged Pointer类型对象。
1 | 0x18071897c <+124>: 0xb0198daa adrp x10, 209333 |
1、系统自带Tagged Pointer类型对象的流程, 这里加载了系统Tagged Pointer主表到x10。
ARM64需要两条指令来加载一个符号的地址。这是RISC样架构上的一个标准技术。AMR64上的指针是64位宽的,指令是32位宽。所以一个指令无法保存一个完整的指针。
1 | 0x180718984 <+132>: 0xd37cfc0b lsr x11, x0, |
由于系统自带的Tagged Pointer对象的索引保存在对象地址的60~63位,这里将receiver的地址右移60位取得索引并保存到x11中,然后根据该索引从x10的系统Tagged Pointer主表中获取到对象的isa指针并存入x16寄存器,然后跳转到第16条指令处执行后续的方法缓存查找等流程。
2、用户扩展的Tagged Pointer对象的执行流程也是类似的
1 | 0x180718990 <+144>: 0xb0198daa adrp x10, 209333 |
不同的是扩展的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等等都值得我们更深入地研究。
参考文章