野指針定位

原文連接html

當所指向的對象被釋放或者收回,可是對該指針沒有做任何的修改,以致於該指針仍舊指向已經回收的內存地址,此狀況下該指針便稱野指針git

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

  • 隨機性強web

    儘管大公司已經有各類單元、行爲、自動化以及人工化測試,儘可能的去模擬用戶的使用場景,但野指針異常老是能巧妙的避開測試,在線上大發神威。緣由毫不僅僅在於測試沒法覆蓋全部的使用場景算法

    形成野指針是多樣化的:首先內存被釋放後不表明內存會馬上被覆寫或者數據受到破壞,這時候訪問這塊內存也不必定會出錯。其次,多線程技術帶來了複雜的應用運行環境,在這個環境下,未加保護的數據多是致命的。此外,設計不夠嚴謹的代碼一樣也是形成野指針異常的重要緣由之一安全

  • 難以定位多線程

    NSException是高抽象層級上的封裝,這意味着它能夠提供更多的錯誤信息給咱們參考。而野指針幾乎出自於C語言層面,每每咱們能得到的只有系統棧信息,單單是定位錯誤代碼位置已經很難了,更不要說去重現修復架構

定位

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

  • 採集輔助信息函數

    輔助信息包括設備信息、用戶行爲等信息,每每能夠用來重現問題。好比用戶行爲能夠造成用戶使用路徑,從而重現用戶使用場景。而在發生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(三)

關注個人公衆號獲取更新信息
相關文章
相關標籤/搜索