一. 前言
最近被指派去解决一些线上的崩溃问题
,经常遇到野指针
导致的崩溃
。相对于其他的原因
引起的崩溃
来说,野指针
导致崩溃
是最难定位
的,这里主要总结了两种思路
来定位野指针
导致的崩溃。
二. 野指针
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
但是如果只是对已经释放的对象
内存空间
填充为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自报家门