Objective-C objc_msgSend 方法的新原型

本文翻译自 mikeash 的 objc_msgSend's New Prototype

苹果的新操作系统已经面世。如果您仔细阅读了文档,可能已经注意到 objc_msgSend 的原型已经更改。以前,它被声明为一个函数,接受 id,SEL 和可变参数,然后返回 id。现在,它被声明为接受并返回 void 的函数。类似的函数,如 objc_msgSendSuper 也变为void/void。那为什么要这样修改呢?

真正的原型

这个问题背后有一个很大且令人惊讶的难题:objc_msgSend 的真正原型是什么?也就是说,它实际上需要什么参数,并且它实际上返回什么?这个问题没有简单的答案。

您可能已经听说过 objc_msgSend 是在汇编中实现的,因为它是如此普遍,以至于它需要获得尽可能多的性能。这是对的,但还不完整。不可能以任意速度在 C 中实现它。

objc_msgSend 做了一些关键的事情:

  • 加载对象的类。
  • 在该类的方法缓存中查找 selector。
  • 跳转到在缓存中找到的方法实现。

从方法实现的角度来看,它看起来像调用方直接调用了它。因为 objc_msgSend 直接跳转到方法实现而不进行函数调用,所以一旦完成工作,它就会消失。该实现非常小心,没有涉及到任何可用于将参数传递给函数的寄存器。调用方调用 objc_msgSend 就像直接调用方法实现一样,以与直接函数调用相同的方式传递所有参数。一旦 objc_msgSend 查找实现并跳转到该实现,这些参数仍然完全处在调用方期望它们处在的位置。当实现返回时,它直接返回到调用者,并且返回值由标准机制提供。

这回答了上述问题:objc_msgSend 的原型是最终调用的方法实现的原型

但是,等等,动态方法查找和消息发送的全部目的难道不是您不知道要调用哪种方法实现吗?这是真的!但是,您确实知道实现将具有哪种类型签名。编译器可以从 @interface 或 @protocol 块中的方法声明中获取此信息,并使用该信息生成适当的参数传递和返回值获取代码。如果覆盖一个方法,则编译器会在您不匹配类型签名时发出警告。可以通过在运行时隐藏声明或添加方法来解决此问题,在这种情况下,您可能会得到与调用点不匹配的方法实现的类型签名。然后,此类调用的行为取决于这两种类型的签名在 ABI 级别如何匹配,或者是完全合理和正确的行为(如果ABI匹配,因此所有参数恰好对齐),或者是完全无意义(如果它们没有匹配)。

这暗示了对本文问题的答案:旧的原型在某些情况下(当ABI匹配时)工作,而在其他情况下却奇怪地失败了(在ABI不匹配时)。除非先将其转换为适当的类型,否则新的原型永远无法工作。只要将其强制转换为正确的类型,它始终有效。因此,新的处理方式鼓励正确操作,并且使做错事情变得更加困难。

最小原型

尽管 objc_msgSend 的原型取决于将要调用的方法实现,但是在所有方法实现中都有两个相同的参数:第一个参数始终是 id self,第二个参数始终是 SEL _cmd。附加参数的数量和类型以及返回类型都是未知的,但是这两个参数是已知的。objc_msgSend 需要这两条信息来执行其方法派发工作,因此它们必须始终在同一位置才能找到它们。

我们可以为 objc_msgSend 写一个近似的通用原型来表示这一点:

??? objc_msgSend(id self, SEL _cmd, ???)

其中 ??? 表示未知事物,这取决于将要调用的特定方法实现。当然,C 无法使用这样的通配符。

对于返回值,我们可以尝试选择一些通用的东西。由于 Objective-C 都是关于对象的,因此假设返回值是 id 是有意义的:

id objc_msgSend(id self, SEL _cmd, ???)

这不仅包括返回值是对象的情况,还包括返回值是 void 的情况,以及某些其他情况,即返回值是不同类型但未使用值的情况。

那参数呢?C 实际上确实有一种方法来表示任意数量的任意类型的参数,即可变参数列表的形式的。参数列表末尾的省略号表示跟随着可变数量的任意类型的值:

id objc_msgSend(id self, SEL _cmd, ...)

这正是原型在最近更改之前的样子。

ABI 不匹配

运行时的相关问题是调用点上的 ABI 是否与方法实现的 ABI 相匹配。就是说,接收方会从调用方传递参数的相同位置和格式中检索参数吗?如果调用方将参数放入 $rdx,则实现需要从 $rdx 检索该参数,否则会造成严重破坏。

最小原型可能能够表达传递任意数量的任意类型的概念,但是要使其在运行时真正起作用,它需要使用与方法实现相同的 ABI。该实现几乎可以肯定是使用不同的原型,并且通常具有固定数量的参数。

不能保证可变参数函数的 ABI 与参数固定的函数的 ABI 相匹配。在某些平台上,它们几乎完美匹配。而在其它平台上,它们可能根本不匹配。

Intel ABI

让我们看一个具体的例子。macOS 使用 x86-64 的标准 System V ABI。ABI 中有很多细节,但我们将重点关注下最基本的。

参数在寄存器中传递。整数参数按此顺序传递到寄存器 rdi,rsi,rdx,rcx,r8 和 r9 中。浮点型参数在 SSE 寄存器 xmm0 至 xmm7 中传递。调用可变参数函数时,将寄存器 al 设置为用于传递参数的 SSE 寄存器的数量。整数返回值放在 rax 和 rdx 中,浮点返回值放在 xmm0 和 xmm1 中。

可变函数的 ABI 与正常函数的 ABI 几乎相同。一个例外是传递 al 中使用的 SSE 寄存器的数量。但是,当使用可变参数 ABI 调用普通函数时,这是无害的,因为普通函数将忽略 al 的内容。

C 语言使事情变得有些混乱。C 指定将某些类型作为可变参数传递给更大的类型。小于 int 的整数(例如char和short)被提升为 int,而 float 则被提升为 double。如果您的方法签名包括这些类型之一,则调用者无法使用可变参数原型将参数作为该确切类型传递。

对于整数,这实际上并不重要。整数将存储在适当寄存器的低位中,并且两种方式的位最终都位于相同的位置。但是,这对于浮动而言是灾难性的。将较小的整数转换为 int 只需要将其填充额外的位即可。将 float 转换为 double 涉及将值完全转换为不同的结构。浮点数中的位与双精度中的对应位不对齐。如果尝试使用可变参数原型来调用带有 float 参数的非可变函数,则该函数将收到垃圾值。

为了说明这个问题,下面是一个简单的例子:

// 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);

它的输出是:

3.141593
3370280550400.000000

如您所见,该值在作为消息发送写入时正确通过,但是在通过对 objc_msgSend 的显式调用传递时被完全弄乱了。

这可以通过将 objc_msgSend 强制转换为正确的签名来解决。回想一下 objc_msgSend 的实际原型是最终将被调用的任何方法,因此使用它的正确方法是将其强制转换为相应的函数指针类型。此调用正常工作:

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

ARM64 ABI

让我们看另一个相关的例子。iOS 使用 ARM64 标准 ABI 的变体。

整数参数在寄存器 r0 至 r7 中传递。浮点参数在 v0 到 v7 中传递。其他参数在堆栈上传递。返回值放在将作为参数传递的同一寄存器中。

这仅适用于常规参数。可变参数从未在寄存器中传递。即使参数寄存器可用,它们也总是在堆栈上传递。

无需仔细分析如何在实践中实现。ABI 完全不匹配,使用未强制转换的 objc_msgSend 调用的方法将在其参数中接收到垃圾值。

新的原型

新的原型简短而有趣:

void objc_msgSend(void);

这根本是不正确的。但是,旧的原型也一样。这显然是不正确的,但这是一件好事。旧的原型无需处理就可以轻松使用它,并且经常能工作,以至于您最终很容易以为一切都很好。当您遇到有问题的案例时,很难搞清楚问题在哪。

该原型甚至不允许您传递 self 和 _cmd 的两个必需参数。您可以完全不使用任何参数来调用它,但是它将立即崩溃,出了什么问题也是很明显的。如果您尝试使用它而不进行强制转换,则编译器会提示出来,这比怪异的破碎参数值要好得多。

由于它仍然是函数类型,因此您仍然可以将其强制转换为适当类型的函数指针,然后以这种方式调用它。只要输入正确的类型,这就将正常工作。

编辑于 2019-10-12