1. 程式人生 > >iOS 野指標定位:野指標嗅探器

iOS 野指標定位:野指標嗅探器

一. 前言

最近最近被指派去解決一些線上的崩潰問題,經常遇到野指標導致的崩潰。相對於其他的原因引起的崩潰來說,野指標導致崩潰最難定位的,這裡主要總結了兩種思路來定位野指標導致的崩潰。

二. 野指標

1.定義

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

2. 為什麼Obj-C野指標的Crash那麼多?

一般app版本釋出之前都會經過多輪研發自測測試內測灰度測試開放部分客戶公測等,按理說很多Crash的場景都應該覆蓋到了,但由於野指標隨機性,很經常會使得測試的時候,它是沒有問題,等到真正使用者

使用的時候才有問題,

隨機性問題可以大概分為兩類:

  • 跑不進出錯的邏輯,執行不到出錯的程式碼,這種可以提高測試場景覆蓋度來解決。

  • 跑進了有問題的邏輯,但是野指標指向的地址並不一定會導致Crash,這就有點看人品了?

為什麼跑進了有問題邏輯,但還是不一定會導致Crash呢?

3.分析

野指標是指指向一個已刪除物件未申請訪問受限記憶體區域的指標。本文說的Obj-C野指標,說的是Obj-C物件釋放之後指標未置空,導致的野指標Obj-C裡面一般不會出現為初始化物件的常識性錯誤)。

既然是訪問已經釋放的物件為什麼不是必現Crash呢?

因為dealloc執行後只是告訴系統,這片記憶體我不用了,而系統並沒有就讓這片記憶體

不能訪問。

現實大概是下面幾種可能的情況:

  1. 物件釋放記憶體沒被改動過,原來的記憶體儲存完好,可能不Crash或者出現邏輯錯誤(隨機Crash)。

  2. 物件釋放記憶體沒被改動過,但是它自己析構的時候已經刪掉某些必要的東西,可能不CrashCrash在訪問依賴的物件比如類成員上、出現邏輯錯誤(隨機Crash)

  3. 物件釋放記憶體被改動過,寫上了不可訪問資料,直接就出錯了很可能Crashobjc_msgSend上面(必現Crash,常見)。

  4. 物件釋放後記憶體被改動過,寫上了可以訪問的資料,可能不Crash、出現邏輯錯誤間接訪問到不可訪問的資料(隨機Crash)

  5. 物件釋放後記憶體被改動過,寫上了可以訪問的資料,但是再次訪問的時候執行的程式碼把別的資料寫壞了,遇到這種Crash只能哭了(隨機Crash,難度大,概率低)!!

  6. 物件釋放後再次release(幾乎是必現Crash,但也有例外,很常見)。

如圖所示:

正是因為野指標有如上多種情況,所以導致crash率一直降不下去。

三. 解決思路

1. 方案一

主要是依據騰訊Bugly工程師:陳其鋒的分享得來。

Demo: FJFZombieSnifferDemo

A. 主要思路

  • 通過fishhook替換C函式free方法為自身方法safe_free,就類似runtime方法交換

bool init_safe_free() {
    _unfreeQueue = ds_queue_create(MAX_STEAL_MEM_NUM);
    orig_free = (void(*)(void*))dlsym(RTLD_DEFAULT, "free");
    rebind_symbols((struct rebinding[]){{"free", (void*)safe_free}}, 1);
    return true;
}
  • 然後在safe_free方法中對已經釋放變數記憶體,填充0x55,使已經釋放變數不能訪問,從而使某些野指標從不必現Crash變成了必現

這裡之所以填充為0x55是因為Xcode殭屍物件填充的就是0x55。 如果填充為像0x22這樣的資料也是可以,因為之前這裡是儲存的是一個物件,這個物件被資料覆蓋了,當你呼叫方法的時候,資料無法響應對應的方法,因此也會導致崩潰

void safe_free(void* p){
    size_tmemSiziee=malloc_size(p);
    memset(p,0x55, memSiziee);
    orig_free(p);
    return;
  • 但是由於填充了0x55的記憶體地址很可能被新的資料內容填充,使得野指標crash又變得不必現。

例如下面這種情況:

UIView *testObj = [[UIView alloc] init];
    [testObj release];
    for (int i = 0; i < 10; i++) {
        UIView* testView = [[UIView alloc] initWithFrame:CGRectMake(0,200,CGRectGetWidth(self.view.bounds), 60)];
        [self.view addSubview:testView];
    }
    [testObj setNeedsLayout];

這裡的testObj指向的記憶體空間內容被填充為0x55,然後呼叫free真正釋放了,這塊記憶體空間,被系統回收利用,但testObj仍然指向這塊記憶體空間,

緊接著新生成的UIView很快的就會覆蓋了testObj指向的記憶體空間,這時候testObj指向的仍然還是一個UIView物件,這時候呼叫UIView的例項方法setNeedsLayout方法完全不會發生Crash.

沒有發生Crash可不是好事,因為這種情況如果後續再Crash,問題就非常難查,因為你看到的Crash棧很可能和出錯的程式碼完全沒有關聯。既然這個問題這麼棘手,最好還是和之前一樣,讓這個Crash提前暴露

  • 為了防止上面這種情況,我們乾脆就不釋放這片記憶體了。也就是當free被呼叫的時候我們不真的呼叫free,而是自己保留著記憶體,這樣系統不知道這片記憶體已經不需要用了,自然就不會被再次寫上別的資料.

struct DSQueue* _unfreeQueue = NULL;//用來儲存自己偷偷保留的記憶體:1這個佇列要執行緒安全或者自己加鎖;2這個佇列內部應該儘量少申請和釋放堆記憶體。
int unfreeSize = 0;//用來記錄我們偷偷儲存的記憶體的大小
#define MAX_STEAL_MEM_SIZE 1024*1024*100//最多存這麼多記憶體,大於這個值就釋放一部分
#define MAX_STEAL_MEM_NUM 1024*1024*10//最多保留這麼多個指標,再多就釋放一部分
#define BATCH_FREE_NUM 100//每次釋放的時候釋放指標數量
  • 為了防止系統記憶體過快耗盡,我們需要在自己保留的記憶體大於一定值的時候就釋放一部分,防止被系統殺死。同時在系統記憶體警告的時候,也要釋放一部分記憶體

//系統記憶體警告的時候呼叫這個函式釋放一些記憶體
void free_some_mem(size_t freeNum){
#ifdef DEBUG
    size_t count = ds_queue_length(_unfreeQueue);
    freeNum= freeNum > count ? count:freeNum;
    for (int i=0; i<freeNum; i++) {
        void *unfreePoint = ds_queue_get(_unfreeQueue);
        size_t memSiziee = malloc_size(unfreePoint);
        __sync_fetch_and_sub(&unfreeSize, memSiziee);
        orig_free(unfreePoint);
    }
#endif
}
  • 但是如果只是對已經釋放的物件記憶體空間填充為0x55,這樣發生Crash的時候,我們得到的崩潰資訊非常有限,但對於崩潰資訊,我們肯定希望知道更具體一點:比如是哪個類,調了什麼方法物件的地址之類。

  • 為了解決上述的問題,我們引入了一個代理類MOACatcher繼承自NSProxy,同時MOACatcher持有一個originClass,重寫訊息轉發的三個方法以及NSObject的例項方法,來進行異常資訊的列印。

為什麼選擇NSProxy做代理: 使用NSProxy和NSObject設計代理類的差異

- (BOOL)respondsToSelector: (SEL)aSelector
{
    return [self.originClass instancesRespondToSelector:aSelector];
}

- (NSMethodSignature *)methodSignatureForSelector: (SEL)sel
{
    return [self.originClass instanceMethodSignatureForSelector:sel];
}

- (void)forwardInvocation: (NSInvocation *)invocation
{
    [self _throwMessageSentExceptionWithSelector: invocation.selector];
}

#pragma mark - Private
- (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];
}
  • 因為NSProxy只能作為Objc物件的代理,所以safe_free函式需要新增判斷。

void safe_free(void* p){

    int unFreeCount = ds_queue_length(_unfreeQueue);
    // 保留的記憶體大於一定值的時候就釋放一部分
    if (unFreeCount > MAX_STEAL_MEM_NUM*0.9 || unfreeSize>MAX_STEAL_MEM_SIZE) {
        free_some_mem(BATCH_FREE_NUM);
    }
    else{
        size_t memSiziee = malloc_size(p);
        if (memSiziee > sYHCatchSize) {//有足夠的空間才覆蓋
            id obj=(id)p;
            Class origClass= object_getClass(obj);
            // 判斷是不是objc物件
            char *type = @encode(typeof(obj));
            if (strcmp("@", type) == 0) {
                memset(obj, 0x55, memSiziee);
                memcpy(obj, &sYHCatchIsa, sizeof(void*));//把我們自己的類的isa複製過去

                object_setClass(obj, [MOACatcher class]);
                ((MOACatcher *)obj).originClass = origClass;
                __sync_fetch_and_add(&unfreeSize,(int)memSiziee);//多執行緒下int的原子加操作,多執行緒對全域性變數進行自加,不用理執行緒鎖了
                ds_queue_put(_unfreeQueue, p);
            }else{
               orig_free(p);
            }
        }else{
           orig_free(p);
        }
    }
}

這裡騰訊Bugly分享的有點不同:

  • object_setClass可以替換一個isa,但是如果直接替換會發生死鎖。這裡先對obj物件進行0x55填充,然後將自己類的isa複製過去,之後呼叫object_setClass將原有類替換為代理類MOACatcher,而Bugly的分享也是先對obj物件進行0x55填充,然後將自己類的isa複製過去,之後強轉為MOACatcher.

  • 同樣這裡使用了編碼型別來判斷是不是objc物件,Bugly的分享是通過先獲取所有的objc的類儲存在陣列中,通過判斷陣列中是否含有當前類來進行判斷。

2. 方案二

方案二是騎神提出的一種思路:

Demo地址: LXDZombieSniffer

主要思路:

  • 通過objcruntime方法進行方法交換,交換了根類的NSObjectNSProxydealloc方法為originalDeallocImp

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)];
    }
    _rootClassDeallocImps = [deallocImps copy];
  • 為了避免 記憶體空間釋放之後被複寫造成野指標問題,通過字典_rootClassDeallocImps儲存被釋放的物件,同時設定在30秒之後呼叫dealloc方法將儲存的物件釋放,避免記憶體空間增大

static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{
        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;

                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];
    });
  • 也同樣為了獲取更多的崩潰資訊採用了繼承自NSProxy 類的LXDZombieProxy的來進行訊息轉發,重寫訊息轉發方法以及記憶體管理相關的方法。

  • 因為objc內部還有一些底層的類,這些類我們專案中一般不涉及,因此不會是這些類造成野指標,就可以通過白名單機制,放棄對這些類的dealloc方法的捕獲。

static inline NSMutableSet *__lxd_sniff_white_list() {
    static NSMutableSet *lxd_sniff_white_list;
    static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{
        lxd_sniff_white_list = [[NSMutableSet alloc] init];
    });
    return lxd_sniff_white_list;
}

四. 方法對比

第一種方案: 通過free函式來進行野指標定位

  • 優點: 覆蓋範圍廣,覆蓋了OC、C++、C函式,對於iOS專案適用於混編的工程。

  • 缺點: 想要獲得具體的崩潰資訊,還是需要進行Objc物件的判斷,同時free函式的覆蓋範圍廣,也會造成一定效能的損耗,畢竟我們在safe_free中添加了一些判斷。

第二種方案:

通過dealloc函式來進行野指標定位

優點: 針對OC語言,利用OC的方法交換訊息轉發等特性,對於iOS專案來說更具有針對性可擴充套件性

缺點: 相對作用範圍較小

五. 詳見:

iOS監控-野指標定位 如何定位Obj-C野指標隨機Crash(一):先提高野指標Crash率 如何定位Obj-C野指標隨機Crash(二):讓非必現Crash變成必現 如何定位Obj-C野指標隨機Crash(三):加點黑科技讓Crash自報家門

作者:林大鵬天地  ios-Swift/Object C開發上架稽核交流群 869685378 歡迎各位大牛來分享交流 IOS,馬甲包,低要求,內容開發沒有限制,報酬豐厚,實力誠信 Q:782675105 長期合作,不做預付,非誠勿擾