質(zhì)量監(jiān)控-野指針定位

原文地址

野指針

當(dāng)所指向的對(duì)象被釋放或者收回,但是對(duì)該指針沒(méi)有作任何的修改,以至于該指針仍舊指向已經(jīng)回收的內(nèi)存地址,此情況下該指針便稱野指針

野指針異??胺Qcrash界的半壁江山,相比起NSException而言,野指針有這么兩個(gè)特點(diǎn):

  • 隨機(jī)性強(qiáng)
    盡管大公司已經(jīng)有各種單元、行為、自動(dòng)化以及人工化測(cè)試,盡量的去模擬用戶的使用場(chǎng)景,但野指針異??偸悄芮擅畹谋荛_測(cè)試,在線上大發(fā)神威。原因絕不僅僅在于測(cè)試無(wú)法覆蓋所有的使用場(chǎng)景

    造成野指針是多樣化的:首先內(nèi)存被釋放后不代表內(nèi)存會(huì)立刻被覆寫或者數(shù)據(jù)受到破壞,這時(shí)候訪問(wèn)這塊內(nèi)存也不一定會(huì)出錯(cuò)。其次,多線程技術(shù)帶來(lái)了復(fù)雜的應(yīng)用運(yùn)行環(huán)境,在這個(gè)環(huán)境下,未加保護(hù)的數(shù)據(jù)可能是致命的。此外,設(shè)計(jì)不夠嚴(yán)謹(jǐn)?shù)拇a同樣也是造成野指針異常的重要原因之一

  • 難以定位
    NSException是高抽象層級(jí)上的封裝,這意味著它可以提供更多的錯(cuò)誤信息給我們參考。而野指針幾乎出自于C語(yǔ)言層面,往往我們能獲得的只有系統(tǒng)棧信息,單單是定位錯(cuò)誤代碼位置已經(jīng)很難了,更不要說(shuō)去重現(xiàn)修復(fù)

定位

解決野指針最大的難點(diǎn)在于定位。通常線上出現(xiàn)了crash需要修復(fù)時(shí),開發(fā)者最重要的一個(gè)步驟是重現(xiàn)crash。而上文提到了野指針的兩個(gè)特性會(huì)阻礙我們定位問(wèn)題,對(duì)于這兩個(gè)特性,確實(shí)也能做一些對(duì)應(yīng)的處理來(lái)降低它們的干擾性:

  • 采集輔助信息
    輔助信息包括設(shè)備信息、用戶行為等信息,往往可以用來(lái)重現(xiàn)問(wèn)題。比如用戶行為可以形成用戶使用路徑,從而重現(xiàn)用戶使用場(chǎng)景。而在發(fā)生crash時(shí),采集當(dāng)前頁(yè)面信息,配合用戶使用路徑可以快速的定位到問(wèn)題發(fā)生的大概位置。經(jīng)過(guò)驗(yàn)證,輔助信息確實(shí)有效的減少了系統(tǒng)棧對(duì)于問(wèn)題重現(xiàn)的干擾

  • 提高野指針崩潰率
    由于野指針不一定會(huì)發(fā)生崩潰這一特性,即便我們通過(guò)堆棧信息輔助信息確定了大致范圍,不代表我們能順利的重現(xiàn)crash。一個(gè)優(yōu)秀的野指針崩潰可以造成一天開發(fā),三天debug,假如野指針的崩潰不是隨機(jī)的,那么問(wèn)題就簡(jiǎn)單的多

    Xcode提供了Malloc Scribble對(duì)已釋放內(nèi)存進(jìn)行數(shù)據(jù)填充,從而保證野指針訪問(wèn)是必然崩潰的。另外,Bugly借鑒這一原理,通過(guò)修改free函數(shù),對(duì)已釋放對(duì)象進(jìn)行非法數(shù)據(jù)填充,也有效的提高了野指針的崩潰率

  • Zombie Objects
    Zombie Objects是一種完全不同的野指針調(diào)試機(jī)制,將釋放的對(duì)象標(biāo)記為Zombie對(duì)象,再次給Zombie對(duì)象發(fā)送消息時(shí),發(fā)生crash并且輸出相關(guān)的調(diào)用信息。這套機(jī)制同時(shí)定位了發(fā)生crash的類對(duì)象以及有相對(duì)清晰的調(diào)用棧

解決方案

整理一下上述的內(nèi)容,可以看到目前存在輔助信息+對(duì)象內(nèi)存填充以及Zombie Objects這兩種主要的應(yīng)對(duì)方式。拿前者來(lái)說(shuō),填充已釋放對(duì)象的內(nèi)存風(fēng)險(xiǎn)高,經(jīng)過(guò)嘗試Xcode9Malloc Scribble啟動(dòng)后已經(jīng)不會(huì)填充對(duì)象的內(nèi)存地址。其次,填充內(nèi)存需要去hook更加底層的API,這意味著對(duì)代碼能力要求更高。因此,借鑒Zombie Objects的實(shí)現(xiàn)思路去定位野指針異常是一個(gè)可行的方案

轉(zhuǎn)發(fā)

轉(zhuǎn)發(fā)是一項(xiàng)有趣的機(jī)制,它通過(guò)在通信雙方中間,插入一個(gè)中間層。發(fā)送方不再耦合接收方,它只需要將數(shù)據(jù)發(fā)送給中間層,由中間層來(lái)派發(fā)給具體的接收方?;谵D(zhuǎn)發(fā)的思想,可以做許多有趣的東西:

  • 消息轉(zhuǎn)發(fā)
    iOS的消息機(jī)制讓我們可以給對(duì)象發(fā)送一個(gè)未注冊(cè)的消息,通常這會(huì)引發(fā)unrecognized selector異常。但是在拋出異常之前,存在一個(gè)消息轉(zhuǎn)發(fā)機(jī)制,允許我們重新指定消息的接收方來(lái)處理這個(gè)消息。正是這一機(jī)制實(shí)現(xiàn)了防unrecognized selector crash的可行化

  • 打破引用環(huán)
    循環(huán)引用是ARC環(huán)境下最容易出現(xiàn)的內(nèi)存問(wèn)題,當(dāng)多個(gè)對(duì)象之間的引用形成了引用環(huán)時(shí),極有可能會(huì)導(dǎo)致環(huán)中的對(duì)象都無(wú)法被釋放。借鑒Proxy的方式,可以實(shí)現(xiàn)破壞引用環(huán)的作用。XXShield以插入WeakProxy層的方式實(shí)現(xiàn)了防crash

  • 路由轉(zhuǎn)發(fā)
    組件化是項(xiàng)目體量達(dá)到一定程度時(shí)必須考慮的架構(gòu)方案,將項(xiàng)目拆分基礎(chǔ)組件和業(yè)務(wù)組件,加入中間層實(shí)現(xiàn)組件間解耦的效果。由于業(yè)務(wù)組件之間互不依賴,因此需要合適的方案實(shí)現(xiàn)組件通信,路由設(shè)計(jì)是一種常用的通信方式。各個(gè)模塊實(shí)現(xiàn)canOpenURL:接口來(lái)判斷是否處理對(duì)應(yīng)的跳轉(zhuǎn)邏輯,模塊將參數(shù)信息拼接在url中傳遞:

消息發(fā)送

都說(shuō)消息發(fā)送Objective-C的核心機(jī)制,任何一個(gè)對(duì)象方法調(diào)用都會(huì)被轉(zhuǎn)換成objc_msgSend的方式執(zhí)行。這一過(guò)程中涉及到一個(gè)重要的變量:isa指針。多數(shù)開發(fā)者對(duì)isa指針停留在它指向了類的類結(jié)構(gòu)本身的地址,用來(lái)表示對(duì)象的類型。但是實(shí)際上isa指針要比我們想想的復(fù)雜的多,比如objc_msgSend依賴于isa來(lái)完成消息的查找,通過(guò)閱讀通過(guò)匯編解讀 objc_msgSend可以了解更詳細(xì)的匹配過(guò)程:

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;
    };
};

由于方法調(diào)用與isa指針相關(guān),因此如果我們修改一個(gè)類的isa指針使其指向一個(gè)目標(biāo)類,那么可以實(shí)現(xiàn)對(duì)象方法調(diào)用的攔截,也可以稱作對(duì)象方法轉(zhuǎn)發(fā)。我們并不能直接修改isa指針,但runtime提供了一個(gè)object_setclass接口允許我們動(dòng)態(tài)的對(duì)某個(gè)類進(jìn)行重定位

ClassA被重定位成ClassB需要保證兩個(gè)類的內(nèi)存結(jié)構(gòu)是對(duì)齊的,否則可能會(huì)發(fā)生超出意外的問(wèn)題

一般來(lái)說(shuō)我們都不應(yīng)該違背重定位類的內(nèi)存結(jié)構(gòu)對(duì)齊原則。但在野指針問(wèn)題中,對(duì)象擁有的內(nèi)存被釋放后是不確定狀態(tài),因此做適當(dāng)?shù)钠茐?/code>并不一定是壞事,只是記住在最終釋放對(duì)象內(nèi)存時(shí),應(yīng)當(dāng)再次重定位回來(lái),防止內(nèi)存泄漏的風(fēng)險(xiǎn)

代碼實(shí)現(xiàn)

借鑒于Zombie Objects的機(jī)制,我們可以實(shí)現(xiàn)一套類Zombie Proxy機(jī)制。通過(guò)重定位類型的做法,在對(duì)象dealloc之前將其isa指針指向一個(gè)目標(biāo)類,實(shí)現(xiàn)后續(xù)調(diào)用的轉(zhuǎn)發(fā)。而目標(biāo)類中所有的方法調(diào)用都采用NSException的機(jī)制拋出異常,并且輸出調(diào)用對(duì)象的實(shí)際類型和調(diào)用方法幫助定位:

重定位后的類由于其實(shí)際用于轉(zhuǎn)發(fā)的用途,更符合Proxy的屬性,因此我將其設(shè)置為NSProxy的子類,多數(shù)人可能不知道iOS一共有NSProxyNSObject兩個(gè)根類。另外,為了實(shí)現(xiàn)對(duì)retain等內(nèi)存管理相關(guān)方法的重寫,目標(biāo)類應(yīng)該設(shè)置為不支持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的方法實(shí)際上是以向上調(diào)用的鏈?zhǔn)綑C(jī)制實(shí)現(xiàn)的,因此只需要hook掉兩個(gè)根類的dealloc方法就能保證對(duì)對(duì)象類型的重定位。在hookdealloc之后有幾個(gè)需要注意的點(diǎn):

  • 對(duì)象的釋放
    由于我們需要實(shí)現(xiàn)轉(zhuǎn)發(fā)機(jī)制,這代表著本該釋放的對(duì)象在類型重定位后不能被釋放。隨著時(shí)候時(shí)間的推移,重定位類對(duì)象的數(shù)量會(huì)越來(lái)越多。根據(jù)經(jīng)驗(yàn)來(lái)說(shuō),一般的野指針在30s內(nèi)被再次訪問(wèn)的概率很大,因此我們可以在類型重定位完成后延后30s釋放對(duì)象?;蛘呖梢詷?gòu)建一個(gè)Zombie Pool,當(dāng)內(nèi)存占用達(dá)到一定大小時(shí),使用恰當(dāng)?shù)乃惴ㄌ蕴?/p>

  • 白名單機(jī)制
    并不是所有的類對(duì)象都被監(jiān)控,比如系統(tǒng)私有類、監(jiān)控相關(guān)工具類、明確不存在野指針的類等。我們需要一個(gè)全局的白名單系統(tǒng),來(lái)確保這些類的dealloc是正常執(zhí)行的,無(wú)需被轉(zhuǎn)發(fā)

  • 潛在的crash
    通過(guò)method_setImplementation替換dealloc的代碼實(shí)現(xiàn),由于我采用block轉(zhuǎn)IMP的方式來(lái)實(shí)現(xiàn)的方式,會(huì)對(duì)捕獲的外界對(duì)象進(jìn)行引用。而對(duì)象在重定位后,任何調(diào)用都會(huì)引發(fā)crash,因此需要針對(duì)這種情況做對(duì)應(yīng)的處理

為了滿足保證對(duì)象能夠在達(dá)成釋放條件完成內(nèi)存的回收,需要存儲(chǔ)根類的dealloc原實(shí)現(xiàn),以根類類名作為key存儲(chǔ)在全局字典中。并且提供接口__lxd_dealloc來(lái)完成對(duì)象的釋放工作:

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)];
}

在對(duì)象的dealloc被調(diào)起之后,檢測(cè)對(duì)象類型是否存在白名單中。如果存在,直接繼續(xù)完成對(duì)對(duì)象的釋放工作。否則的話,延后30s進(jìn)行釋放工作。為了解除block引用造成的crash,使用NSValue存儲(chǔ)對(duì)象信息以及使用__unsafe_unretained來(lái)防止臨時(shí)變量的引用:

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秒釋放對(duì)象,避免造成內(nèi)存的浪費(fèi)
        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];

具體的實(shí)現(xiàn)代碼可以下載LXDZombieSniffer

疑難問(wèn)題

野指針問(wèn)題是訪問(wèn)了非法內(nèi)存導(dǎo)致的crash,也就是說(shuō)要符合兩個(gè)條件:內(nèi)存非法以及指針地址不為NULL。在iOS中存在三種不同修飾的指針:

  • __strong
    默認(rèn)修飾符。修飾的指針在賦值之后,會(huì)對(duì)指向的對(duì)象執(zhí)行一次retain操作,指針不因?qū)ο蟮纳芷谧兓淖?/p>

  • __unsafed_unretained
    非安全對(duì)象指針修飾符。修飾的指針不會(huì)持有指向?qū)ο螅膊灰驅(qū)ο蟮纳芷诎l(fā)生變化而改變,等同于assign

  • __weak
    弱對(duì)象指針修飾符。修飾的指針不會(huì)持有指向?qū)ο螅趯?duì)象的生命周期結(jié)束并且內(nèi)存被回收時(shí),修飾的指針內(nèi)容會(huì)被重置為nil

根據(jù)野指針異常的引發(fā)條件來(lái)說(shuō),三種修飾指針只有__strong__unsafed_unretained可以導(dǎo)致野指針訪問(wèn)異常。但是在使用類別重定位之后,本該釋放的對(duì)象會(huì)被延時(shí)或者不釋放,也就是本該被重置的弱指針也不會(huì)發(fā)生重置,這時(shí)使用弱指針訪問(wèn)對(duì)象應(yīng)該會(huì)被轉(zhuǎn)發(fā)到ZombieProxy當(dāng)中發(fā)生crash

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

然而在上面的測(cè)試中,發(fā)現(xiàn)即便對(duì)象被重定位為Zombie并且被阻止釋放之后,weakObj依舊被成功的設(shè)置成了nil。然后經(jīng)過(guò)objc_runtime源碼運(yùn)行和添加斷點(diǎn)測(cè)試之后,也沒(méi)有weak指針被重置的調(diào)用。甚至使用了LLVMwatch set var weakObj監(jiān)控弱指針,依舊無(wú)法找到調(diào)用。但weakObjdealloc調(diào)用之后,不管對(duì)象有沒(méi)有被釋放,都被重置成了nil。這也是截止文章出來(lái)為止,匪夷所思的疑難雜癥

參考

如何定位Obj-C野指針隨機(jī)Crash(一)
如何定位Obj-C野指針隨機(jī)Crash(二)
如何定位Obj-C野指針隨機(jī)Crash(三)

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡(jiǎn)書系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容