iOS野指標定位總結

weixin_34146805發表於2018-08-23

成因

野指標就是指向一個已刪除的物件或者受限記憶體區域的指標。
我們寫C++的時候強調指標初始化為NULL,強呼叫完後也為其賦值為NULL,誰分配的誰回收,來避免野指標的問題。
比較常見的就是這個指標指向的記憶體,在別處被回收了,但是這個指標不知道,依然還指向這塊記憶體。
MRC 時代因為引用計數手動控制,所以記憶體很容易在別處被回收。ARC解決了大部分這種問題。、
在iOS9之前,系統庫的delegatetarget-action有一部分是assign(unsafe_unretain)的形式,這時候如果記憶體在別處被回收了,也是會出現野指標的。
所以iOS9之後這些地方就改成了weak記憶體修飾符,記憶體被回收的時候通過weak表,把這些指標設為nil。也大幅度減少了野指標的出現。

如果現在在工程中依然頻繁出現野指標,幾乎可以肯定是錯誤地使用了記憶體。

表現:Crash

對於MachUnixNSException三種不同層級的crash,NSException比較好說,可以直接定位到OC程式碼。問題主要來自EXC_BAD_ACCESS(SIGSEGV)這種異常,難以在我們的應用程式碼中定位。

611240-f75ab440e032aceb.jpg
image
  • SIGILL 執行了非法指令,一般是可執行檔案出現了錯誤
  • SIGTRAP 斷點指令或者其他trap指令產生
  • SIGABRT 呼叫abort產生
  • SIGBUS 非法地址。比如錯誤的記憶體型別訪問、記憶體地址對齊等
  • SIGSEGV 非法地址。訪問未分配記憶體、寫入沒有寫許可權的記憶體等
  • SIGFPE 致命的算術運算。比如數值溢位、NaN數值等

實際我們遇到Mach Exception絕大部分都是野指標的問題。SIGSEGV/SIGABRT/SIGTRAP 比較多見。
野指標問題表現千奇百怪,而且因為崩潰的地方並不是造成野指標的地方,而且難以重現,所以問題往往難以定位。

611240-947f1c375d4d8929.jpg
image

騰訊Bugly的這張圖可以看到,野指標幾乎可以造成各種型別的Mach Exception

定位工具

Zoombie Object

這是目前幫助最大的除錯模式。實現原理就是 hook 住了物件的dealloc方法,通過呼叫自己的__dealloc_zombie方法來把物件進行殭屍化。

id object_dispose(id obj)
{
    if (!obj) return nil;

    objc_destructInstance(obj);    
    free(obj);

    return nil;
}

正常的物件釋放方法如上,但是殭屍物件呼叫了objc_destructInstance後就直接return了,不再free(obj);。同時生成一個"_NSZombie_" + clsName類名,呼叫objc_setClass(self, zombieCls);修改物件的 isa 指標,令其指向特殊的殭屍類。
如果這個物件再次收到訊息,objc_msgsend的時候,呼叫abort()崩潰並列印出呼叫的方法。

野指標指向的記憶體沒有被覆蓋的時候,或者被覆蓋成可以訪問的記憶體的時候,不一定會出現崩潰。這個時候向物件傳送訊息,不一定會崩潰(可能剛好有這個方法),或者向已經釋放的物件傳送訊息。 但是如果野指標指向的是殭屍物件,那就一定會崩潰了,會崩潰在殭屍物件第一次被其它訊息訪問的時候。

Zombie Object without Xcode

殭屍物件必須在連線Xcode中debug的時候使用,如果我們想跟我們的崩潰收集工具整合在一起,就需要自己實現類似Zombie Object的東西。
邏輯是通過hook住NSObject的根類的dealloc方法,然後在新的dealloc方法中將本來即將釋放的物件的isa指標改為指向我們建立的一個新的殭屍類。

iOS使用程式碼排查野指標錯誤
開發自己的NSZombie這兩篇文章裡介紹了在程式碼裡實現類似Zoombie Object的方法,然而實際上是無法使用的,這兩種實現跟 Zombie Object 實現上不小的區別,實際應用中有大量誤判的情況。

誤判的原因主要是dealloc的實現和殭屍類的實現跟Zombie Object不一樣。
參考Apple的原始碼,可以看到Apple是完全呼叫了objc_destructInstance函式的。而其它人的實現要麼沒有呼叫這個函式,要麼只做了一部分。對於一個OC物件的dealloc來說,主要包括兩部分,一部分是objc_destructInstance,一部分是free(self)objc_destructInstance裡包括了移除弱引用,移除關聯物件,c++析構等等。這些邏輯不能省略。

- (void)dealloc
{
    const char *className = object_getClassName(self);
    char *zombieClassName = NULL;
    do {

        //...
        Class zombieClass = objc_getClass(zombieClassName);

        objc_destructInstance(self); //關鍵

        object_setClass(self, zombieClass);

    } while (0);

    if (zombieClassName != NULL)
    {
        free(zombieClassName);
    }
}

而對於殭屍類的實現,Zombie Object的實現簡潔而且有效。不像其它人的實現那麼臃腫。就只是申明瞭一個沒有任何方法的根類而已,所以任何訊息發給它都會crash。

NS_ROOT_CLASS
@interface _NSZombie_ {
    Class isa;
}

@end

所以我從Apple的原始碼中提取出來的一套實現NSZombie,跟Zombie Object的實現保證完全一致,解決誤判的情況。

Scribble

Scribble 工具能夠在alloc的時候填上0xAA,dealloc的時候填上0x55,就是物件釋放後在記憶體上填上不可訪問的資料,如果再次訪問物件就會必現crash。

Bugly的這篇文章如何定位Obj-C野指標隨機Crash 就是採用這種方式提高crash率,來方便定位問題。
為了不限制在xcode中使用,自己在程式碼中實現了類似的邏輯。通過fishhook去hook free函式的方法,實現如下:

void safe_free(void* p){
    size_tmemSiziee=malloc_size(p);
    memset(p,0x55, memSiziee);
    orig_free(p);
    return;
}

雖然已經給被釋放的物件寫上了0x55,但是如果是記憶體在被訪問(觸發crash)之前被其它覆蓋了,則可能無法觸發crash。 這種情況也不少見。 所以Bugly為了記憶體不被覆蓋,就不再呼叫free來釋放這個記憶體。保持這個記憶體一直在。 這樣的原理就非常類似Zombie Object了。

製造crash的方式也是採用修改rsa指標的方式,當物件收到訊息的時候abort()。

Address Sanitizer

malloc/free函式進行了替換。在malloc函式中額外的分配了禁止訪問區域的記憶體。 在free函式中將所有分配的記憶體區域設為禁止訪問,並放到了隔離區域的佇列中(保證在一定的時間內不會再被malloc函式分配)。 如果訪問到禁止訪問的區域,就直接crash。

對CPU影響2~5⨉, 增加記憶體消耗 2~3⨉。

能夠檢查出來的問題:

  • 訪問已經dealloc的記憶體/dealloc已經dealloc的記憶體
  • dealloc還沒有alloc的記憶體(但不能檢查出訪問未初始化的記憶體)
  • 訪問函式返回以後的棧記憶體/訪問作用域之外的棧記憶體
  • 緩衝區上溢位或下溢位,C++容器溢位(但不能檢查integer overflow)

不能用於檢查記憶體洩漏。有些文章說ASan能檢查記憶體洩漏是不對的,Google的LSan可以,但是Xcode的Asan不行。

Malloc Stack

之前介紹的工具都是提高崩潰概率,以拿到崩潰的物件和記憶體地址。拿到崩潰的物件之後也很難定位,因為崩潰地方離釋放的地方已經很遠了。而且有些物件在工程中初始化了很多個,不知道是對應的哪個地方出了問題。所以如果能知道物件是在哪初始化的就好了。
Malloc Stack 能夠記錄下來所有物件的malloc呼叫時的堆疊資訊。然後我們執行命令:

script import lldb.macosx.heap
malloc_info --stack-history 0x7fbf0dd4f5c0

就可以在lldb中列印出來該物件初始化位置的堆疊資訊。
Malloc Stack但是有兩個巨大的缺點,一個是隻能在模擬器上使用,第二是沒有列印出dealloc的資訊。如果想在真機上使用需要越獄。

lzMalloc

公司內部的大神開發的的lldb外掛,基於Malloc Stack開發的,通過呼叫私有函式拿到Malloc Stack記錄的資料。能夠支援真機除錯,能夠列印出dealloc的堆疊資訊。
能列印出dealloc的原因是hook了-dealloc方法,呼叫__disk_stack_logging_log_stack函式記錄當前的堆疊資訊。

幾個野指標的例子

錯誤的記憶體修飾符

遇到的這個例子可能是比較經典的野指標,崩潰日誌中出現了各種各樣的表現。

第一種表現是dealloc物件時崩潰:

0 libsystem_kernel.dylib 0x252fac5c __pthread_kill + 4
1 libsystem_c.dylib 0x2528f0ac abort + 103
2 libsystem_malloc.dylib 0x25324ef6 free + 431
3 libobjc.A.dylib 0x24e13e08 object_dispose + 19
4 Foundation 0x25de3cf2 -[NSIndexPath dealloc] + 66
5 libobjc.A.dylib 0x24e24f66 objc_object::sidetable_release(bool) + 150
6 libsystem_blocks.dylib 0x25243ac2 _Block_release + 215
7 CoreFoundation 0x25583384 -[__NSArrayI dealloc] + 64
5 libobjc.A.dylib 0x24e24f66 objc_object::sidetable_release(bool) + 150
9 UIKit 0x29e934f2 __runAfterCACommitDeferredBlocks + 310
10 UIKit 0x29e9f7da __cleanUpAfterCAFlushAndRunDeferredBlocks + 90
11 UIKit 0x29bddb1c __afterCACommitHandler + 84

可以看到這裡完全是系統library的崩潰,跟工程程式碼毫無關係,最開始也是一頭霧水。
這裡只有兩個線索,一個是NSIndexPath,另一個是隻發生於10.3.3之前的iphone5機型上。
因為10.3.3是iphone5支援的最後一個版本,所以使用者量並不少。

第二種表現是objc_msgsend, isEqual:是通過讀取ARM暫存器lr獲取到的方法名,這個是Bugly幫我們查到的。

0 libobjc.A.dylib 0x1a1b0dd6 objc_msgSend (isEqual:) + 15
1 UIKit 0x201afdfa -[UICollectionReusableView _setLayoutAttributes:] + 60
2 UIKit 0x209d0280 -[UICollectionView _applyLayoutAttributes:toView:] + 138
3 UIKit 0x209daf26 ___88-[UICollectionView _dequeueReusableViewOfKind:withIdentifier:forIndexPath:viewCategory:]_block_invoke + 28
4 UIKit 0x2015b5c2 +[UIView(Animation) performWithoutAnimation:] + 84
5 UIKit 0x209dae40 -[UICollectionView _dequeueReusableViewOfKind:withIdentifier:forIndexPath:viewCategory:] + 2156
6 UIKit 0x201af68a -[UICollectionView dequeueReusableCellWithReuseIdentifier:forIndexPath:] + 160
7 XXXXXXProject 0x00404c02 -[XXXXXXCollectionView collectionView:cellForItemAtIndexPath:] (XXXXXXClass.m:77)
8 UIKit 0x209cf850 -[UICollectionView _createPreparedCellForItemAtIndexPath:withLayoutAttributes:applyAttributes:isFocused:notify:] + 420
9 UIKit 0x201af5e0 -[UICollectionView _createPreparedCellForItemAtIndexPath:withLayoutAttributes:applyAttributes:] + 42
10 UIKit 0x201ad7f6 -[UICollectionView _updateVisibleCellsNow:] + 4076
11 UIKit 0x201a83d6 -[UICollectionView layoutSubviews] + 398
12 UIKit 0x2014b482 -[UIView(CALayerDelegate) layoutSublayersOfLayer:] + 1224

這裡線索就比較豐富,可以找到對應的類了,XXXXXXProject 是我們的工程,明顯崩潰在UICollectionView中。在重用collectionViewCell的過程中,呼叫_setLayoutAttributes 的方法,在+60的位置呼叫了isEqual:,經過反編譯這個方法得知呼叫isEqual:的物件的是UICollectionViewLayoutAttributes(反編譯過程省略)。
這裡也是隻發生於10.3.3之前的iphone5機型上。所以基本確定是同一個問題。

但是並沒有什麼了卵用,正如之前所說的,野指標崩潰的地方跟出錯的地方相去甚遠。
唯一能確定的地方,就是引起崩潰的物件是NSIndexPath

第三種表現比較奇怪,報[UITransitionView initialize] unrecognized selector,這個類一臉懵逼。不知道在哪使用過

Exception Type: NSInvalidArgumentException(SIGABRT)
Exception Codes: -[UITransitionView initialize]: unrecognized selector sent to instance 0x165f22c0 at 0x1c4d1acc
Crashed Thread: 0
0 CoreFoundation 0x1cd03b3d ___exceptionPreprocess + 129
1 libobjc.A.dylib 0x1bf8b067 objc_exception_throw + 31
2 CoreFoundation 0x1cd08fd1 ___methodDescriptionForSelector + 1
3 CoreFoundation 0x1cd070c3 ____forwarding___ + 697
4 CoreFoundation 0x1cc2fdc8 _CF_forwarding_prep_0 + 24
5 libobjc.A.dylib 0x1bf8bbad _CALLING_SOME_+initialize_METHOD + 23
6 libobjc.A.dylib 0x1bf8bdf3 __class_initialize + 579
7 libobjc.A.dylib 0x1bf92c15 _lookUpImpOrForward + 173
8 libobjc.A.dylib 0x1bf92b65 __class_lookupMethodAndLoadCache3 + 27
9 libobjc.A.dylib 0x1bf991af __objc_msgSend_uncached + 15
10 UIKit 0x21f98167 -[UICollectionViewLayoutAttributes isEqual:] + 95
11 UIKit 0x21f97dfb -[UICollectionReusableView _setLayoutAttributes:] + 61
12 UIKit 0x227b8281 -[UICollectionView _applyLayoutAttributes:toView:] + 139
13 UIKit 0x227c2f27 ___88-[UICollectionView _dequeueReusableViewOfKind:withIdentifier:forIndexPath:viewCategory:]_block_invoke + 29
14 UIKit 0x21f435c3 +[UIView(Animation) performWithoutAnimation:] + 85
15 UIKit 0x227c2e41 -[UICollectionView _dequeueReusableViewOfKind:withIdentifier:forIndexPath:viewCategory:] + 2157
16 UIKit 0x21f9768b -[UICollectionView dequeueReusableCellWithReuseIdentifier:forIndexPath:] + 161

看下面的堆疊就發現還是同一個問題,但是為啥會報這麼奇怪的錯? 這就是野指標的表現。這一塊記憶體被別的東西覆蓋了。

實際上還有其它的表現,但是比較具有代表性的就這三個了。從崩潰日誌中只能得到有限的資訊,一個是這個是野指標問題。第二個是這個野指標物件很可能是一個NSIndexPath物件(也不能完全確定)。

如果不知道是野指標的問題,就很容易誤入歧途,花大量時間在研究UICollectionView 或者在研究UITransitionView上。其實都是浪費時間,因為造成野指標的地方地方已經很遠了。

正如Bugly這篇文章說的,定位野指標最重要還是增大野指標出現的概率。 所以這次我是採用Zombie Object,並且限制在iPhone5 和 iOS10.3.3的情況下重現的。

經過多次重現,確定了是NSIndexPath的問題,而且所有的UICollectionViewUITableView都受到了影響。所以我開始懷疑是不是工程中有全域性的程式碼被hook了。果然不出所料:

- (void)forwardInvocation:(NSInvocation *)invocation
{
    [invocation invokeWithTarget:self.target];
    if (kiOS9Later) {
        if ([NSStringFromSelector(invocation.selector) isEqualToString:@"collectionView:didSelectItemAtIndexPath:"]) {
            //無痕打點
            __unsafe_unretained UICollectionView *collectionView = nil;
            id indexPath;
            [invocation getArgument:&collectionView atIndex:2];
            [invocation getArgument:&indexPath atIndex:3];
            [FPPVHelper reportMTAEventId:[collectionView hotTagId] Index:[indexPath row] info:nil];
        }
    }
}

這是某一段神奇的打點程式碼,不知道誰寫的。很明顯indexPath此處的修飾符應為__unsafe_unretained,如果為strong的話物件在這裡就會被ARC釋放掉,然而因為傳遞的是C指標,其它地方的某個指標不知道這裡釋放了,依然指向了這裡。產生了野指標。

iOS9之前的delegate 崩潰

在iOS9之前的tableview的delegate和datasource都是assign記憶體修飾符的。iOS9之後才使用weak

// iOS 8 之前
@property(nonatomic, assign) id<UITableViewDataSource> dataSource
@property(nonatomic, assign) id<UITableViewDelegate> delegate
// iOS 9 之後
@property(nonatomic, weak, nullable) id<UITableViewDataSource> dataSource
@property(nonatomic, weak, nullable) id<UITableViewDelegate> delegate

這種情況,如果delegatetableview本身更早被釋放,此時的dataSource就會成為一個野指標。常見的情況比如block呼叫延長了tableview的生命週期,就可能會發生這種情況,導致野指標crash。 一般崩潰日誌裡是objc_msgsend + 15 的崩潰,崩潰在delegate或者datasource的方法裡。

解決方法也很簡單,在dealloc的時候把dataSource和delegate設為nil即可。

- (void)dealloc
{
    _tableView.delegate = nil;
    _tableView.dataSource = nil;
}

iOS9 之前的target-action崩潰

崩潰堆疊也是最常見的objc_msgSend,這裡可以看到是工程中hook的某個方法崩潰了

libobjc.A.dylib objc_msgSend (pv_gestureRecongizerAction:)
UIKit -[UIGestureRecognizer _updateGestureWithEvent:buttonEvent:]
UIKit ____UIGestureRecognizerUpdate_block_invoke662
UIKit __UIGestureRecognizerRemoveObjectsFromArrayAndApplyBlocks
UIKit __UIGestureRecognizerUpdate
SEGV_ACCERR

我們自己的程式碼如下,就是在addGestureRecognizer方法中加了一層呼叫,加了一層target-action。這相當於是給gestureRecognizer加了兩個target-action

-(void)pv_addGestureRecognizer:(UIGestureRecognizer *)gestureRecognizer {
    [gestureRecognizer addTarget:self action:@selector(pv_gestureRecongizerAction:)];
    [self pv_addGestureRecognizer:gestureRecognizer];
}

由於target對於gesture來說在iOS8上也是類似assign的,所以這裡就是self被釋放了,變成野指標了,但是gestureRecognizer的target依然指向了self的記憶體。 當self已經被釋放了,但是gestureRecognizer還沒被釋放的時候就會發生這種情況。

總結

野指標定位有幾個關鍵:

  • 第一是意識到這是野指標的問題:Mach Exception大多數都是野指標的問題,崩潰日誌裡最多見objc_msgSendunrecognized selector sent to等等。而且往往跟iOS SDK版本和iphone型號有關。 認識到野指標的問題後,就不必要拘泥於崩潰日誌,因為崩潰的地方離崩潰的原因比較遠了。
  • 第二是儘可能重現。利用Zombie Object/Scribble/Aasn 都可以。個人認為自己實現的Zombie Object最好,既可以脫離Xcode debug的限制,使用又比較簡單。
  • 第三是根據野指標指向的物件來判斷出錯的位置,而不是崩潰的方法。因為崩潰的方法離崩潰的原因比較遠了,但是野指標指向的物件多半還是出錯的物件(有時也可能被覆蓋了)。
  • 第四是利用malloc stack/lzMalloc找到野指標指向物件初始化的位置和dealloc的位置,判斷是否過早釋放等。

相關文章