iOS 野指標定位:野指標嗅探器
一. 前言
最近最近被指派去解決一些線上的崩潰問題
,經常遇到野指標
導致的崩潰
。相對於其他的原因
引起的崩潰
來說,野指標
導致崩潰
是最難定位
的,這裡主要總結了兩種思路
來定位野指標
導致的崩潰。
二. 野指標
1.定義
當所指向的物件被釋放或者收回,但是對該指標沒有作任何的修改,以至於該指標仍舊指向已經回收的記憶體地址,此情況下該指標便稱野指標.
2. 為什麼Obj-C
野指標的Crash
那麼多?
一般app
版本釋出之前都會經過多輪研發自測
、測試內測
、灰度測試
、開放部分客戶公測
等,按理說很多Crash
的場景都應該覆蓋到了,但由於野指標
的隨機性
,很經常會使得測試
的時候,它是沒有問題,等到真正使用者
隨機性
問題可以大概分為兩類:
-
跑不進出錯的邏輯,執行不到出錯的程式碼,這種可以提高測試
場景覆蓋度
來解決。 -
跑進了有問題的邏輯,但是
野指標
指向的地址並不一定會導致Crash
,這就有點看人品了?
為什麼跑進了有問題
的邏輯
,但還是不一定會導致Crash
呢?
3.分析
野指標
是指指向一個已刪除
的物件
或未申請
訪問受限記憶體區域
的指標。本文說的Obj-C野指標
,說的是Obj-C物件
釋放之後指標未置空,導致的野指標
(Obj-C
裡面一般不會出現為初始化物件
的常識性錯誤)。
既然是訪問已經釋放的物件為什麼不是必現Crash
呢?
因為dealloc
執行後只是告訴系統,這片記憶體我不用了,而系統並沒有就讓這片記憶體
現實大概是下面幾種
可能的情況:
-
物件釋放
後記憶體
沒被改動過,原來的記憶體儲存完好,可能不Crash
或者出現邏輯錯誤(隨機Crash
)。 -
物件釋放
後記憶體
沒被改動過,但是它自己析構的時候已經刪掉某些必要的東西,可能不Crash
、Crash
在訪問依賴的物件
比如類成員上
、出現邏輯錯誤(隨機Crash)
。 -
物件釋放
後記憶體
被改動過,寫上了不可訪問
的資料
,直接就出錯了很可能Crash
在objc_msgSend
上面(必現Crash
,常見)。 -
物件釋放後
記憶體被改動過,寫上了可以訪問的資料,可能不Crash
、出現邏輯錯誤
、間接訪問到不可訪問的資料(隨機Crash)
-
物件釋放後
記憶體被改動過,寫上了可以訪問的資料,但是再次訪問的時候執行的程式碼把別的資料寫壞了,遇到這種Crash
只能哭了(隨機Crash,難度大,概率低)
!! -
物件釋放
後再次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
主要思路:
-
通過
objc
的runtime
方法進行方法交換
,交換了根類的NSObject
和NSProxy
的dealloc
方法為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 長期合作,不做預付,非誠勿擾