objc_msgSend的新原型

去年(2019)底看到 Mike Ash关于 objc_msgSend 的文章,当时心里有点讶异:怎么Swift当道的时代,ObjC居然还在更新?!

objc_msgSend 的原型

从系统头文件将定义直接复制过来了,如下:

#if !OBJC_OLD_DISPATCH_PROTOTYPES
OBJC_EXPORT void objc_msgSend(void /* id self, SEL op, ... */ ) OBJC_AVAILABLE(10.0, 2.0, 9.0, 1.0, 2.0);
#else
/** 
 * Sends a message with a simple return value to an instance of a class.
 * 
 * @param self A pointer to the instance of the class that is to receive the message.
 * @param op The selector of the method that handles the message.
 * @param ... 
 *   A variable argument list containing the arguments to the method.
 * 
 * @return The return value of the method.
 * 
 * @note When it encounters a method call, the compiler generates a call to one of the
 *  functions \c objc_msgSend, \c objc_msgSend_stret, \c objc_msgSendSuper, or \c objc_msgSendSuper_stret.
 *  Messages sent to an objects superclass (using the \c super keyword) are sent using \c objc_msgSendSuper; 
 *  other messages are sent using \c objc_msgSend. Methods that have data structures as return values
 *  are sent using \c objc_msgSendSuper_stret and \c objc_msgSend_stret.
 */
OBJC_EXPORT id _Nullable
objc_msgSend(id _Nullable self, SEL _Nonnull op, ...)
    OBJC_AVAILABLE(10.0, 2.0, 9.0, 1.0, 2.0);
#endif

OBJC_OLD_DISPATCH_PROTOTYPES 为真时,就是以前的定义,参数从左到右分别是实例对象,实例对象的方法,和可变参数。乍一看,这个定义非常的“正确”,而且objc_msgSend在 runtime 中占据非常重要的地位。那为什么还会有改进呢?

ABI的匹配

对于原来的实现,最后的参数是一个可变参数,在转化成最终的函数调用时,系统需要将其转化成“固定”参数的调用。比如按照定义,调用者将参数 self 放入某个寄存器来传递,执行者去该寄存器取该参数,并认为是该类型的。但问题是,如果两者不一致问题就打了。而不同处理器架构上,这样的处理过程是不一样的。

Intel 架构对可变参数函数的处理

对标准的System V ABI for x86-64,参数是这样传递到寄存器的:

  • 整型参数:依次使用 rdi, rsi, rcx, r8 和 r9。
  • 浮点参数:使用 SSE 寄存器 xmm0 ~ xmm7 (每个128位)

当调用含可变参数函数时,可变参数的实际个数使用寄存器 al 存储;整型返回值放置在 rax 和 rdx,浮点型返回值放置在 xmm0 和 xmm1。
但是,当调用可变参数函数时,C语言中会将某些特定的数据类型字节数变宽:比 int 字节数少的会使用 int 的字节宽度,float 会使用 double 的字节数。对于整型数据而言,这不会有影响,因为数据优先存储在低位,高位为零。但是对于浮点数而言,float和double各个位数的定义不一样,不能像整型那样简单地进行高位填充。因此, 对于含可变参数的函数而言,传 float 类型的参数就会造成错误。
Mike Ashe 给了一个例子:

    // Should use the old variadic prototype for objc_msgSend.
    //#define OBJC_OLD_DISPATCH_PROTOTYPES 1 

    #import <Foundation/Foundation.h>
    #import <objc/message.h>

    @interface Foo : NSObject @end
    @implementation Foo
    - (void)log: (float)x {
        printf("%f\n", x);
    }
    @end

    int main(int argc, char **argv) {
        id obj = [Foo new];
        [obj log: (float)M_PI];
        objc_msgSend(obj, @selector(log:), (float)M_PI);
    }
// output:
    3.141593
    3370280550400.000000

注:在Xcode中,可以通过设置 Enable Strict Checking of objc_msgSend CallsNO 在模拟器上验证上述代码。

ARM64 架构对可变参数函数的处理

众所周知,iOS上使用的 ARM64 处理器,其使用的是 ARM64 标准 ABI 的变体

  • 整型参数:依次使用 x0 ~ x7。
  • 浮点参数:依次使用 v0 ~ v7。
  • 其余参数存储在栈上,返回值放置在对应的传参寄存器中。

对于含可变参数的函数,可变参数一直放置在栈上。因此,对于固定参数函数和可变参数函数而言,ABI 就不一致了。

新的 objc_msgSend

如开头所示,新的 objc_msgSend 定义: void objc_msgSend(void /* id self, SEL op, ... */ )。在使用的时候,需要强制转换。作为对上述例子的“修正”,使用新 prototype 发送消息的时候需要指定参数、返回类型等:

((void (*)(id, SEL, float)) objc_msgSend)(obj, @selector(log:), (float)M_PI);

显而易见,通过这样的强制转换,让调用者不得不注意类型的一致性,从而避免异常问题发生。
需要说明的是,大概在Xcode6时代,新的 objc_msgSend 已经成为 Xcode中的默认选项。根据 Mike Ashe 的说明,之所以称为“New Prototype” 是因为把这个设置体现在了系统头文件里并在Apple的官方文档中体现了出来。

总结 Summary

严格的类型检查可以降低代码出现异常的几率,因此:

  1. 尽量使用“新”的 objc_msgSend 如果需要自己传递消息
  2. 对 Mac 平台,使用可变参数形式的 objc_msgSend 时要注意避免 float 参数

参考 Reference

objc_msgSend’s New Prototype

Comments