在 ARC 下對非 ObjC 類型的指針進行操做的編譯器陷阱

前言

在一般狀況下,咱們的代碼在經過 LLVM 進行編譯時,若是開啓了 ARC 模式,在 backend 階段會經過幾個 ObjcARC Pass 插入基於引用計數的內存管理語句,這創建在編譯器的類型推導和控制流分析等基礎之上。架構

若是某些隱式操做逃過了 ObjCARC Pass 的「火眼」,可能會生成不配對的 RC 語句,從而致使運行時異常,本文將介紹兩個引起此問題的場景並分析原理,來幫助你們瞭解 ARC 的底層工做和優化原理,進而規避 ARC 相關的編譯器陷阱。函數

C-Style 引用賦值的 AutoreleasePool 陷阱

在返回多值、引用賦值等場景下,咱們經常會以二級指針做爲函數形參,對象指針的地址爲實參,一個常見的例子爲獲取 NSInvocation 動態調用方法的返回值:oop

- (void)getReturnValue:(void *)retLoc;
複製代碼

因爲 NSInvocation 沒法肯定返回類型是不是 ObjC 類型,所以採用了 C-Style 的萬能指針來接收目標地址,爲了能正確接收 ObjC 類型的返回值,有兩種寫法:優化

經過 __unsafe_unretained 聲明避免生成多餘的 objc_release

因爲 NSInvocation 的返回值爲 void * 類型,外部在默認狀況下並不是是返回對象的 owner,若是外部接收者是 ObjC 指針,必須聲明爲 __unsafe_unretained 才能正確的平衡引用計數,正確的代碼示例以下:ui

- (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 的調用中,具體緣由會在本文接下來的章節分析。spa

經過 Bridging 顯式轉換聲明 non-ownership

根據 ARC 文檔,經過 __bridge 將其餘類型轉爲 ObjC 類型時,不轉移 ownership,即不會生成額外的內存管理代碼,也就不會生成多餘的 objc_release 了。指針

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

- (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 致使引用計數不平衡。cdn

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 工做異常,這種異常每每是由內存管理語句不正確配對致使的引用計數不平衡。

相關文章
相關標籤/搜索