在 ARC 下对非 ObjC 类型的指针进行操作的编译器陷阱

1,834 阅读10分钟

前言

在通常情况下,我们的代码在通过 LLVM 进行编译时,如果开启了 ARC 模式,在 backend 阶段会通过几个 ObjcARC Pass 插入基于引用计数的内存管理语句,这建立在编译器的类型推导和控制流分析等基础之上。

如果某些隐式操作逃过了 ObjCARC Pass 的“火眼”,可能会生成不配对的 RC 语句,从而导致运行时异常,本文将介绍两个引发此问题的场景并分析原理,来帮助大家了解 ARC 的底层工作和优化原理,进而规避 ARC 相关的编译器陷阱。

C-Style 引用赋值的 AutoreleasePool 陷阱

在返回多值、引用赋值等场景下,我们常常会以二级指针作为函数形参,对象指针的地址为实参,一个常见的例子为获取 NSInvocation 动态调用方法的返回值:

- (void)getReturnValue:(void *)retLoc;

由于 NSInvocation 无法确定返回类型是否是 ObjC 类型,因此采用了 C-Style 的万能指针来接收目标地址,为了能正确接收 ObjC 类型的返回值,有两种写法:

通过 __unsafe_unretained 声明避免生成多余的 objc_release

由于 NSInvocation 的返回值为 void * 类型,外部在默认情况下并非是返回对象的 owner,如果外部接收者是 ObjC 指针,必须声明为 __unsafe_unretained 才能正确的平衡引用计数,正确的代码示例如下:

- (NSObject *)getObject {
    return [NSObject new];
}

- (void)invocationTrap {
    NSInvocation *invoker = [NSInvocation invocationWithMethodSignature:[self methodSignatureForSelector:@selector(getObject)]];
    invoker.target = self;
    invoker.selector = @selector(getObject);
    [invoker invoke];
    // 通过 __unsafe_unretained 声明 retObj 不是返回对象的 owner
    __unsafe_unretained NSObject *retObj = nil;
    [invoker getReturnValue:&retObj];
    NSLog(@"invoke return obj %@", retObj);
}

如果去掉了 __unsafe_unretained 会发生什么呢?代码会崩溃在 AutoreleasePool 调用 objc_release 释放 retObj 的调用中,具体原因会在本文接下来的章节分析。

通过 Bridging 显式转换声明 non-ownership

根据 ARC 文档,通过 __bridge 将其他类型转为 ObjC 类型时,不转移 ownership,即不会生成额外的内存管理代码,也就不会生成多余的 objc_release 了。

__bridge transfers a pointer between Objective-C and Core Foundation with no transfer of ownership.

- (void)invocationTrap {
    NSInvocation *invoker = [NSInvocation invocationWithMethodSignature:[self methodSignatureForSelector:@selector(getObject)]];
    invoker.target = self;
    invoker.selector = @selector(getObject);
    [invoker invoke];
    void *retObj_c = nil;
    [invoker getReturnValue:&retObj_c];
    // 通过 bridging 桥接转换
    NSObject *retObj = (__bridge NSObject *)retObj_c;
    NSLog(@"invoke return obj %@", retObj);
}

在这里如果采用 __bridge_transfer 编译器也会错误的生成额外的 objc_release 导致引用计数不平衡。

C-Style 引用赋值的 objc_release 陷阱

除去上述场景的 AutoreleasePool 陷阱外,还有一种场景能在 AutorelasePool 之前就出现对象的重复释放问题,示例代码如下:

void arcTrickAssign(void *location, id obj) {
    *((void **)location) = (__bridge void *)obj;
}

以这种方式进行赋值时,由于 location 使用了 void * 类型,编译器无法感知到 obj 被 location 指向的指针所引用,从而导致少了一次 retain 操作,进而发生重复释放问题。根据 obj 的创建方式,会有两种表现。

只被形参引用

如果对象只被形参引用,在离开函数作用域后会立即释放,从而导致外部无法正确获取到返回对象:

- (void)arcTrickAssign {
    NSObject *ptr = nil;
    arcTrickAssign(&ptr, [NSObject new]);
    NSLog(@"the ptr %@\n", ptr);
}

这种情况下 ptr 将变成悬垂指针,直接崩溃在 NSLog 对已释放对象访问时:

被外部指针引用

如果对象是被外部指针引用的,则会在离开外部作用域时被多释放一次:

- (void)arcTrickAssign {
    NSObject *ptr = nil;
    NSObject *objToAssign = [NSObject new];
    arcTrickAssign(&ptr, objToAssign);
    NSLog(@"the ptr %@\n", ptr);
}

这种情况下崩溃发生在外部作用域返回时:

重复释放原因分析

上述场景主要有两个,一个发生于 AutoreleasePool 释放时,另一个发生于作用域离开时,我们对这两个场景进行适当的抽象,并通过二进制的反编译结果深入分析原因。

NSInvocation 场景

NSInvocation 场景抽象而言,是以 C-Style 对一个 ObjC 指针间接赋值,且被赋予的值来自于另一个函数或方法的返回值,用代码表示为:

- (NSObject *)getObject {
    return [NSObject new];
}

- (void)getReturnValue:(void *)retLoc {
    *(void **)retLoc = (__bridge void *)[self getObject];
}

- (void)abstractInvocationTrap {
    NSObject *retObj = nil;
    [self getReturnValue:&retObj];
}

下面我们对包含上述代码的二进制进行反编译,为了使得反编译的代码更具可读性,我们先将工程的 Build Configuration 调整为 Release,在 ARM64 架构下编译,得到的 C 伪代码如下:

// getObject
id __cdecl -[ViewController getObject](ViewController *self, SEL _cmd) {
  void *obj = objc_msgSend(&OBJC_CLASS___NSObject, "new");
  return (id)_objc_autoreleaseReturnValue(obj);
}

// getReturnValue:
void __cdecl -[ViewController getReturnValue:](ViewController *self, SEL _cmd, void *retLoc) {
  *(_QWORD *)retLoc = -[ViewController getObject](self, "getObject");
}

// abstractInvocationTrap
void __cdecl -[ViewController abstractInvocationTrap](ViewController *self, SEL _cmd) {
  id location = 0LL;
  -[ViewController getReturnValue:](self, "getReturnValue:", &location);
  objc_storeStrong(&location, 0LL);
}

我们根据控制流一步步分析:

  1. 首先在 abstractInvocationTrap 方法内创建了一个名为 location 的指针,将其地址传入了 getReturnValue: 方法,getReturnValue: 又调用了 getObject 方法获取 object 并以 C-Style 将对象的地址间接赋值给 location;
  2. 在 getObject 中创建对象时,先调用 new 方法创建 object 对象,此时 object 的引用计数为 1,随后调用了一个 ARC 在 Runtime 的返回值优化函数 objc_autoreleaseReturnValue,我们可以在苹果提供的 objc 源码中查看具体实现。
// Prepare a value at +1 for return through a +0 autoreleasing convention.
id objc_autoreleaseReturnValue(id obj) {
    if (prepareOptimizedReturn(ReturnAtPlus1)) return obj;
    return objc_autorelease(obj);
}

可以看到它尝试对返回值执行 ReturnAtPlus1 优化,如果符合优化条件,则不进行任何操作,否则对对象进行 autorelease 操作,那么什么情况下能够执行 ReturnAtPlus1 优化呢,我们需要去 prepareOptimizedReturn 中寻找答案:

// Try to prepare for optimized return with the given disposition (+0 or +1).
// Returns true if the optimized path is successful.
// Otherwise the return value must be retained and/or autoreleased as usual.
static ALWAYS_INLINE bool prepareOptimizedReturn(ReturnDisposition disposition) {
    assert(getReturnDisposition() == ReturnAtPlus0);
    if (callerAcceptsOptimizedReturn(__builtin_return_address(0))) {
        if (disposition) setReturnDisposition(disposition);
        return true;
    }
    return false;
}

这里的关键是对 callerAcceptsOptimizedReturn 的调用,它的入参很有趣,是通过 __builtin_return_address(0) 拿到的当前栈帧函数的返回地址,由于 prepareOptimizedReturn 被 inline,因此获取的是 objc_autoreleaseReturnValue 的返回地址,这需要我们在汇编视角观察一下 getObject 方法来进一步确定,我们重点看最后几句:

;id __cdecl -[ViewController getObject](ViewController *self, SEL) + 28
LDP     X29, X30, [SP+var_s0],#0x10
B       _objc_autoreleaseReturnValue

objc_autoreleaseReturnValue 的调用是 B 而非 BL,因此 __builtin_return_address(0) 拿到的其实是 getObject 的 Caller 的返回地址,即 getReturnValue: 调用 getObject 后的返回地址,那么返回地址具有什么特性时可执行优化呢?我们需要继续看 callerAcceptsOptimizedReturn 函数内部的实现:

static ALWAYS_INLINE bool callerAcceptsOptimizedReturn(const void *ra) {
    // fd 03 1d aa mov fp, fp
    // arm64 instructions are well-aligned
    if (*(uint32_t *)ra == 0xaa1d03fd) {
        return true;
    }
    return false;
}

如果返回地址处的语句是 mov x29, x29 则可执行优化,这条语句是编译器插入的,当编译器发现当前控制流可优化时,会在分支跳转语句后插入一个 mov x29, x29 作为 Optimization Flag,以便运行时动态执行优化逻辑,接下来我们去看看 getReturnValue: 方法调用 getObject 方法的返回地址是否符合此特征:

可以看到返回地址处并不是 mov x29, x29,因此不符合优化条件,优化不能执行,那么回到 objc_autoreleaseReturnValue 函数,也就不会执行 ReturnAtPlus1 优化,因此对对象执行了 autorelease 操作将其加入自动释放池:

// Prepare a value at +1 for return through a +0 autoreleasing convention.
id objc_autoreleaseReturnValue(id obj) {
    // 优化未执行,因此执行了 objc_autorelease
    if (prepareOptimizedReturn(ReturnAtPlus1)) return obj;
    return objc_autorelease(obj);
}

此时对象的引用计数为 1,且已经加入自动释放池,因此对象会在当前 RunLoop 结束时引用计数 -1,如果没有额外引用对象将被释放,由于 getReturnValue: 中未生成任何 ARC 语句,对象的引用计数仍然为 1:

// getReturnValue:
void __cdecl -[ViewController getReturnValue:](ViewController *self, SEL _cmd, void *retLoc) {
  *(_QWORD *)retLoc = -[ViewController getObject](self, "getObject");
}

最后我们来看 abstractInvocationTrap 方法,可以看到在作用域结束时将 location 指针置为 nil,与此同时会对 location 的旧值 object 进行 objc_release 操作,这就导致 object 的引用计数 -1 变为 0。

// abstractInvocationTrap
void __cdecl -[ViewController abstractInvocationTrap](ViewController *self, SEL _cmd) {
  id location = 0LL;
  -[ViewController getReturnValue:](self, "getReturnValue:", &location);
  objc_storeStrong(&location, 0LL);
}

在离开 abstractInvocationTrap 的作用域后,object 的引用计数已经为 0,但它仍然处于 AutoreleasePool 中,会在当前 RunLoop 结束时再释放一次,从而导致了重复释放问题。

到这里我们已经找到了问题的罪魁祸首,即 abstractInvocationTrap 中的 release,但仔细想一想,这个 release 没毛病,因为 location 指针离开了作用域理应进行一次释放操作,那么问题究竟出在哪里呢?

根本原因其实还是 getReturnValue: 方法,由于采用 C-Style 进行内存操作,编译器没有正确生成与返回值优化函数 objc_autoreleaseReturnValue 配对的取返回值函数 objc_retainAutoreleasedReturnValue,我们把它们放在一起来看:

// Prepare a value at +1 for return through a +0 autoreleasing convention.
id objc_autoreleaseReturnValue(id obj) {
    // 优化未执行,因此执行了 objc_autorelease
    if (prepareOptimizedReturn(ReturnAtPlus1)) return obj;
    return objc_autorelease(obj);
}

// Accept a value returned through a +0 autoreleasing convention for use at +1.
id objc_retainAutoreleasedReturnValue(id obj) {
    if (acceptOptimizedReturn() == ReturnAtPlus1) return obj;
    return objc_retain(obj);
}

由于 objc_autoreleaseReturnValue 不满足优化条件,ReturnAtPlus1 的标没有被存储到 TLS 中,所以 objc_retainAutoreleasedReturnValue 中的条件不满足,需要对 obj 进行一次 retain 操作,这次 retain 恰好与 abstractInvocationTrap 中的 release 相抵消,使得对象只被自动释放池引用,从而能正确的释放。

相反的,如果返回值满足了 ReturnAtPlus1 优化条件,那么对象不会被加入自动释放池,两个优化函数均为透传,最后对象的释放发生在 abstractInvocationTrap 的 release,在这种优化下少了许多繁琐的操作,但是否能执行这种优化取决于编译器对控制流的分析,最终依据为返回地址处的指令是否是 mov x29, x29

非返回值引用场景

上述场景中是由于存在不可优化的方法返回值引用被错误处理导致的,对于非返回值引用场景,将引发上文提到的 objc_release 陷阱同样导致重复释放问题,问题可抽象为:

__attribute__((noinline))
void arcTrickAssign(void *location, id obj) {
    *((void **)location) = (__bridge void *)obj;
}

- (void)arcTrickAssign {
    NSObject *ptr = nil;
    arcTrickAssign(&ptr, [NSObject new]);
    NSLog(@"the ptr %@\n", ptr);
}

这其实就是我们例子中的示例代码,它会在 arcTrickAssign 后对 ptr 的访问中触发 BAD_ACCESS,因为 ptr 这时候已经是一个悬垂指针。

下面我们依然从反编译的伪代码来分析,注意在 Release 模式下 arcTrickAssign 可能会被 inline,可通过 __attribute__((noinline)) 禁止其 inline 从而徒增分析工作量。

void __fastcall arcTrickAssign(void *location, id obj) {
  *(_QWORD *)location = obj;
}

void __cdecl -[ViewController arcTrickAssign](ViewController *self, SEL a2) {
  id location = 0LL;
  
  // obj is a temp obj for actual argument
  id obj = (struct objc_object *)objc_msgSend(&OBJC_CLASS___NSObject, "new");
  arcTrickAssign(&location, obj);
  // the temp obj is released after function call
  objc_release(obj);
  
  NSLog(CFSTR("the ptr %@\n"), location);
  objc_release(location);
}

我们从 arcTrickAssign 入手分析,obj 是 arcTrickAssign 函数的实参,即 arcTrickAssign 函数作用域中的临时对象,它的引用计数为 1,在通过 arcTrickAssign 赋值时没有被 location 指针 retain,因此在离开函数作用域 release 后被释放,location 变为悬垂指针。

显然,这是由于在给 location 间接赋值时少生成了一条 retain 语句导致 location 没有获得 obj 的 ownership,但是编译器却认为 location 已经获得 ownership,这从 arcTrickAssign 方法的最后一句 objc_release(location) 中可见一斑。

结语

综上所述,在 ARC 模式下 LLVM ObjCARC Pass 很难正确处理 C-Style 的 ObjC 对象间接赋值操作,因此编译器在 ObjC 对象和其他对象之间转换时强制要求使用 Bridging 表达式,但这种限制能通过花式的强制类型转化绕过。不正确使用 Bridging 进行类型转化可能导致 ARC 工作异常,这种异常往往是由内存管理语句不正确配对导致的引用计数不平衡。