野指標定位

騎著jm的hi發表於2019-03-04

原文連結

當所指向的物件被釋放或者收回,但是對該指標沒有作任何的修改,以至於該指標仍舊指向已經回收的記憶體地址,此情況下該指標便稱野指標

野指標異常堪稱crash界的半壁江山,相比起NSException而言,野指標有這麼兩個特點:

  • 隨機性強

    儘管大公司已經有各種單元、行為、自動化以及人工化測試,儘量的去模擬使用者的使用場景,但野指標異常總是能巧妙的避開測試,線上上大發神威。原因絕不僅僅在於測試無法覆蓋所有的使用場景

    野指標定位

    造成野指標是多樣化的:首先記憶體被釋放後不代表記憶體會立刻被覆寫或者資料受到破壞,這時候訪問這塊記憶體也不一定會出錯。其次,多執行緒技術帶來了複雜的應用執行環境,在這個環境下,未加保護的資料可能是致命的。此外,設計不夠嚴謹的程式碼同樣也是造成野指標異常的重要原因之一

  • 難以定位

    NSException是高抽象層級上的封裝,這意味著它可以提供更多的錯誤資訊給我們參考。而野指標幾乎出自於C語言層面,往往我們能獲得的只有系統棧資訊,單單是定位錯誤程式碼位置已經很難了,更不要說去重現修復

    野指標定位

定位

解決野指標最大的難點在於定位。通常線上出現了crash需要修復時,開發者最重要的一個步驟是重現crash。而上文提到了野指標的兩個特性會阻礙我們定位問題,對於這兩個特性,確實也能做一些對應的處理來降低它們的干擾性:

  • 採集輔助資訊

    輔助資訊包括裝置資訊、使用者行為等資訊,往往可以用來重現問題。比如使用者行為可以形成使用者使用路徑,從而重現使用者使用場景。而在發生crash時,採集當前頁面資訊,配合使用者使用路徑可以快速的定位到問題發生的大概位置。經過驗證,輔助資訊確實有效的減少了系統棧對於問題重現的干擾

  • 提高野指標崩潰率

    由於野指標不一定會發生崩潰這一特性,即便我們通過堆疊資訊輔助資訊確定了大致範圍,不代表我們能順利的重現crash。一個優秀的野指標崩潰可以造成一天開發,三天debug,假如野指標的崩潰不是隨機的,那麼問題就簡單的多

    野指標定位

    Xcode提供了Malloc Scribble對已釋放記憶體進行資料填充,從而保證野指標訪問是必然崩潰的。另外,Bugly借鑑這一原理,通過修改free函式,對已釋放物件進行非法資料填充,也有效的提高了野指標的崩潰率

  • Zombie Objects

    Zombie Objects是一種完全不同的野指標除錯機制,將釋放的物件標記為Zombie物件,再次給Zombie物件傳送訊息時,發生crash並且輸出相關的呼叫資訊。這套機制同時定位了發生crash的類物件以及有相對清晰的呼叫棧

解決方案

整理一下上述的內容,可以看到目前存在輔助資訊+物件記憶體填充以及Zombie Objects這兩種主要的應對方式。拿前者來說,填充已釋放物件的記憶體風險高,經過嘗試Xcode9Malloc Scribble啟動後已經不會填充物件的記憶體地址。其次,填充記憶體需要去hook更加底層的API,這意味著對程式碼能力要求更高。因此,借鑑Zombie Objects的實現思路去定位野指標異常是一個可行的方案

轉發

轉發是一項有趣的機制,它通過在通訊雙方中間,插入一箇中間層。傳送方不再耦合接收方,它只需要將資料傳送給中間層,由中間層來派發給具體的接收方。基於轉發的思想,可以做許多有趣的東西:

  • 訊息轉發

    iOS的訊息機制讓我們可以給物件傳送一個未註冊的訊息,通常這會引發unrecognized selector異常。但是在丟擲異常之前,存在一個訊息轉發機制,允許我們重新指定訊息的接收方來處理這個訊息。正是這一機制實現了防unrecognized selector crash的可行化

    野指標定位
  • 打破引用環

    迴圈引用是ARC環境下最容易出現的記憶體問題,當多個物件之間的引用形成了引用環時,極有可能會導致環中的物件都無法被釋放。借鑑Proxy的方式,可以實現破壞引用環的作用。XXShield以插入WeakProxy層的方式實現了防crash

    野指標定位
  • 路由轉發

    元件化是專案體量達到一定程度時必須考慮的架構方案,將專案拆分基礎元件和業務元件,加入中間層實現元件間解耦的效果。由於業務元件之間互不依賴,因此需要合適的方案實現元件通訊,路由設計是一種常用的通訊方式。各個模組實現canOpenURL:介面來判斷是否處理對應的跳轉邏輯,模組將引數資訊拼接在url中傳遞:

    野指標定位

訊息傳送

都說訊息傳送Objective-C的核心機制,任何一個物件方法呼叫都會被轉換成objc_msgSend的方式執行。這一過程中涉及到一個重要的變數:isa指標。多數開發者對isa指標停留在它指向了類的類結構本身的地址,用來表示物件的型別。但是實際上isa指標要比我們想想的複雜的多,比如objc_msgSend依賴於isa來完成訊息的查詢,通過閱讀通過彙編解讀 objc_msgSend可以瞭解更詳細的匹配過程:

union isa_t {
    isa_t() { }
    isa_t(uintptr_t value) : bits(value) { }
    Class cls;
    uintptr_t bits;
    struct {
        uintptr_t indexed           : 1;
        uintptr_t has_assoc         : 1;
        uintptr_t has_cxx_dtor      : 1;
        uintptr_t shiftcls          : 33; 
        uintptr_t magic             : 6;
        uintptr_t weakly_referenced : 1;
        uintptr_t deallocating      : 1;
        uintptr_t has_sidetable_rc  : 1;
        uintptr_t extra_rc          : 19;
    };
};
複製程式碼

由於方法呼叫與isa指標相關,因此如果我們修改一個類的isa指標使其指向一個目標類,那麼可以實現物件方法呼叫的攔截,也可以稱作物件方法轉發。我們並不能直接修改isa指標,但runtime提供了一個object_setclass介面允許我們動態的對某個類進行重定位

ClassA被重定位成ClassB需要保證兩個類的記憶體結構是對齊的,否則可能會發生超出意外的問題

一般來說我們都不應該違背重定位類的記憶體結構對齊原則。但在野指標問題中,物件擁有的記憶體被釋放後是不確定狀態,因此做適當的破壞並不一定是壞事,只是記住在最終釋放物件記憶體時,應當再次重定位回來,防止記憶體洩漏的風險

程式碼實現

借鑑於Zombie Objects的機制,我們可以實現一套類Zombie Proxy機制。通過重定位型別的做法,在物件dealloc之前將其isa指標指向一個目標類,實現後續呼叫的轉發。而目標類中所有的方法呼叫都採用NSException的機制丟擲異常,並且輸出呼叫物件的實際型別和呼叫方法幫助定位:

野指標定位

重定位後的類由於其實際用於轉發的用途,更符合Proxy的屬性,因此我將其設定為NSProxy的子類,多數人可能不知道iOS一共有NSProxyNSObject兩個根類。另外,為了實現對retain等記憶體管理相關方法的重寫,目標類應該設定為不支援ARC

@interface LXDZombieProxy : NSProxy

@property (nonatomic, assign) Class originClass;

@end

@implementation LXDZombieProxy

- (void)_throwMessageSentExceptionWithSelector: (SEL)selector
{
    @throw [NSException exceptionWithName:NSInternalInconsistencyException 
                                   reason:[NSString stringWithFormat:@"(-[%@ %@]) was sent to a zombie object at address: %p", NSStringFromClass(self.originClass), NSStringFromSelector(selector), self] 
                                 userInfo:nil];
}

#define LXDZombieThrowMesssageSentException() [self _throwMessageSentExceptionWithSelector: _cmd]

- (id)retain
{
    LXDZombieThrowMesssageSentException();
    return nil;
}

- (oneway void)release
{
    LXDZombieThrowMesssageSentException();
}

- (id)autorelease
{
    LXDZombieThrowMesssageSentException();
    return nil;
}

- (void)dealloc
{
    LXDZombieThrowMesssageSentException();
    [super dealloc];
}

- (NSUInteger)retainCount
{
    LXDZombieThrowMesssageSentException();
    return 0;
}

@end
複製程式碼

由於iOS的方法實際上是以向上呼叫的鏈式機制實現的,因此只需要hook掉兩個根類的dealloc方法就能保證對物件型別的重定位。在hookdealloc之後有幾個需要注意的點:

  • 物件的釋放

    由於我們需要實現轉發機制,這代表著本該釋放的物件在型別重定位後不能被釋放。隨著時候時間的推移,重定位類物件的數量會越來越多。根據經驗來說,一般的野指標在30s內被再次訪問的概率很大,因此我們可以在型別重定位完成後延後30s釋放物件。或者可以構建一個Zombie Pool,當記憶體佔用達到一定大小時,使用恰當的演算法淘汰

  • 白名單機制

    並不是所有的類物件都被監控,比如系統私有類監控相關工具類明確不存在野指標的類等。我們需要一個全域性的白名單系統,來確保這些類的dealloc是正常執行的,無需被轉發

  • 潛在的crash

    通過method_setImplementation替換dealloc的程式碼實現,由於我採用blockIMP的方式來實現的方式,會對捕獲的外界物件進行引用。而物件在重定位後,任何呼叫都會引發crash,因此需要針對這種情況做對應的處理

為了滿足保證物件能夠在達成釋放條件完成記憶體的回收,需要儲存根類的dealloc原實現,以根類類名作為key儲存在全域性字典中。並且提供介面__lxd_dealloc來完成物件的釋放工作:

static inline void __lxd_dealloc(__unsafe_unretained id obj) {
    Class currentCls = [obj class];
    Class rootCls = currentCls;
    
    while (rootCls != [NSObject class] && rootCls != [NSProxy class]) {
        rootCls = class_getSuperclass(rootCls);
    }
    NSString *clsName = NSStringFromClass(rootCls);
    LXDDeallocPointer deallocImp = NULL;
    [[_rootClassDeallocImps objectForKey: clsName] getValue: &deallocImp];
    
    if (deallocImp != NULL) {
        deallocImp(obj);
    }
}

NSMutableDictionary *deallocImps = [NSMutableDictionary dictionary];
for (Class rootClass in _rootClasses) {
    IMP originalDeallocImp = __lxd_swizzleMethodWithBlock(class_getInstanceMethod(rootClass, @selector(dealloc)), swizzledDeallocBlock);
    [deallocImps setObject: [NSValue valueWithBytes: &originalDeallocImp objCType: @encode(typeof(IMP))] forKey: NSStringFromClass(rootClass)];
}
複製程式碼

在物件的dealloc被調起之後,檢測物件型別是否存在白名單中。如果存在,直接繼續完成對物件的釋放工作。否則的話,延後30s進行釋放工作。為了解除block引用造成的crash,使用NSValue儲存物件資訊以及使用__unsafe_unretained來防止臨時變數的引用:

swizzledDeallocBlock = [^void(id obj) {
    Class currentClass = [obj class];
    NSString *clsName = NSStringFromClass(currentClass);
    /// 如果為白名單,則不重定位類的型別
    if ([__lxd_sniff_white_list() containsObject: clsName]) {
        __lxd_dealloc(obj);
    } else {
        NSValue *objVal = [NSValue valueWithBytes: &obj objCType: @encode(typeof(obj))];
        object_setClass(obj, [LXDZombieProxy class]);
        ((LXDZombieProxy *)obj).originClass = currentClass;
        
        /// 延後30秒釋放物件,避免造成記憶體的浪費
        dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(30 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
            __unsafe_unretained id deallocObj = nil;
            [objVal getValue: &deallocObj];
            object_setClass(deallocObj, currentClass);
            __lxd_dealloc(deallocObj);
        });
    }
} copy];
複製程式碼

具體的實現程式碼可以下載LXDZombieSniffer

疑難問題

野指標問題是訪問了非法記憶體導致的crash,也就是說要符合兩個條件:記憶體非法以及指標地址不為NULL。在iOS中存在三種不同修飾的指標:

  • __strong

    預設修飾符。修飾的指標在賦值之後,會對指向的物件執行一次retain操作,指標不因物件的生命週期變化而改變

  • __unsafed_unretained

    非安全物件指標修飾符。修飾的指標不會持有指向物件,也不因物件的生命週期發生變化而改變,等同於assign

  • __weak

    弱物件指標修飾符。修飾的指標不會持有指向物件,在物件的生命週期結束並且記憶體被回收時,修飾的指標內容會被重置為nil

根據野指標異常的引發條件來說,三種修飾指標只有__strong__unsafed_unretained可以導致野指標訪問異常。但是在使用類別重定位之後,本該釋放的物件會被延時或者不釋放,也就是本該被重置的弱指標也不會發生重置,這時使用弱指標訪問物件應該會被轉發到ZombieProxy當中發生crash

__weak id weakObj = nil;
@autoreleasepool {
    NSObject *obj = [NSObject new];
    weakObj = obj;
}
/// The operate should be crashed
NSLog(@"%@", weakObj);
複製程式碼

然而在上面的測試中,發現即便物件被重定位為Zombie並且被阻止釋放之後,weakObj依舊被成功的設定成了nil。然後經過objc_runtime原始碼執行和新增斷點測試之後,也沒有weak指標被重置的呼叫。甚至使用了LLVMwatch set var weakObj監控弱指標,依舊無法找到呼叫。但weakObjdealloc呼叫之後,不管物件有沒有被釋放,都被重置成了nil。這也是截止文章出來為止,匪夷所思的疑難雜症

參考

如何定位Obj-C野指標隨機Crash(一)

如何定位Obj-C野指標隨機Crash(二)

如何定位Obj-C野指標隨機Crash(三)

關注我的公眾號獲取更新資訊

相關文章