1. iOS开发中常见的Crash汇总
iOS中Crash问题大体可以归为以下类型:
- unrecognized selector
- KVO
- 容器越界
- 野指针
- ...
1. unrecognized selector
原因: 一个OC对象调用了一个不存在的方法导致.
OC对象经过完整的消息查询, 动态方法解析, 消息转发依然没有处理消息, 就会调用NSObject的-/+doesNotRecognizeSelector:方法, 最终调用_objc_fatal(...)报错.
这里衍生问题在于, 我们实现一个对象的@property时, 对NSString *, NSArray, NSSet*等使用copy修饰而不要用strong进行内存管理的修饰.
以NSString *为例, 因为NSMutableString *是它的子类, 因此当使用strong修饰时, 如果不小心该属性引用的是一个NSMutableString *对象并且调用了一个NSString *没有的方法 -- appendString:, 触发unrecognized selector.
2. KVO
KVO导致的Crash可能有两类:
- KVO的被观察者在dealloc时, 依然注册着KVO
- KVO注册观察者与移除观察者不匹配导致Crash
多线程场景下 add/remove Observer 很容易出现Crash!
- 建议参考KVOController, 使用中间者维护并控制观察者与被观察者的添加和移除!!!
- 由于部分系统API会有使用KVO的场景, 建议add/remove操作在主线程中进行
之前碰到过系统API内部会进行 addObserver/removeObser操作, 而我们有一个多线程调用场景, 从而导致crash!!!
crash参考: stackoverflow.com/questions/2…
3. 容器篇
容器相关常见两大类:
- NSDictionary等插入nil等. (或者使用
NSHashTable/NSMapTable等可配置内存管理的容器) - 越界问题基本都与多线程相关. 做好容器对象的Data Race基本可以杜绝容器越界的问题.
4. 野指针
举个例子: 关联对象与TaggedPointer隐藏的内存管理问题
#import "ViewController.h"
#import <objc/runtime.h>
#import <objc/message.h>
@interface NSObject (AssociatedObject)
@property (nonatomic, assign) CGFloat someProperty;
@end
@implementation NSObject (AssociatedObject)
@dynamic someProperty;
- (void)setSomeProperty:(CGFloat)someProperty{
return objc_setAssociatedObject(self, @selector(someProperty), @(someProperty), OBJC_ASSOCIATION_ASSIGN);
}
- (CGFloat)someProperty{
return [objc_getAssociatedObject(self, @selector(someProperty)) floatValue];
}
@end
@interface ViewController ()
@end
@implementation ViewController
-(void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event{
// [self setSomeProperty:100.1]; // Crash - EXC_BAD_ACCESS
[self setSomeProperty:100.0]; // 不crash
NSLog(@"someProperty: %@", @(self.someProperty));
}
@end
上面代码中设置100.1会crash, 设置100.0不会crash!
原因分析如下:
- @property中使用的内存管理方式为assign, 但是在底层实际使用的关联对象实现, 有关联对象配置的option作为内存管理的手段
@(someProperty)是NSNumber*的字面量写法,NSNumber*是一个 OC对象, 类似NSString*, 因此内存管理手段只能是copy/retain!!!- 当使用
[self setSomeProperty:100.0]没有crash, 是因为当使用整数int数据时, 这里NSNumber *指针是一个TaggedPointer, 因此内存管理用assign也不会crash, 并能正常使用. 但是当使用[self setSomeProperty:100.1]时,NSNumber *不再是一个TaggedPointer必须通过完整的OC内存管理逻辑 --retain/release!!! 当对assign的指针指向的内存地址进行[无效内存 retain]操作时,发生EXC_BAD_ACCESS!!
具体的crash调用堆栈如下:
frame #0: 0x0000000199203490 libobjc.A.dylib`objc_retain + 16
* frame #1: 0x0000000102c5d90c testCrash`-[NSObject(self=0x00000001031099c0, _cmd="someProperty") someProperty] at ViewController.m:20:12
...
2. Xcode中的野指针检测工具篇
我们知道OC中向nil发送消息是不会导致崩溃的, 但是如果向一段已经标记删除的内存地址发送消息就很危险!!!
因此大部分的防范内存问题的工具都是为了增加Crash发生的概率!!!
Xcode中的工具主要有以下内容:
- Zombie Object -- 仅针对OC对象
- Malloc Scribble -- hook malloc 和 free, 填上不同的内容
- Malloc Guard Edges --
- Address Sanitizer
1. Zombie Object - 僵尸对象
基础原理:
xcode会hook OC对象的dealloc方法, 在对象释放时, 将待释放对象isa指向_Zombie_XXX. 这个僵尸对象仍然可以接受消息, 并相应, 然后会发送崩溃, 打印错误日志. 细节过程可以参考juejin.cn/post/696870…
很多人也在生产环境中使用类似的实现来监控OC的坏内存访问, 源码可以参考KSCrash中的实现.
另外也可以参考github.com/hdu-rtt/Wil…
2. Malloc Scribble
对象释放后只有出现被随机填入的数据是不可访问的时候才会必现Crash. 因此, 如果对象释放后在内存上填上不可访问的数据, 就能提高Crash率.
根据Apple的官方文档:
MallocScribble
1. If set, fill memory that has been allocated with 0xaa bytes.
原因: This increases the likelihood that a program making assumptions about the contents of freshly allocated memory will fail.
2. Also if set, fill memory that has been deallocated with 0x55 bytes.
原因: This increases the likelihood that a program will fail due to accessing memory that is no longer allocated. Note that due to the way in which freed memory is managed internally, the 0x55 pattern may not appear in some parts of a deallocated memory block.
简单来说, Apple 应该是 hook alloc和free方法, 然后通过如下两个操作:
- 内存分配时在内存上填
0xAA - 释放内存 释放时, 在使用过的内存上填
0x55
这样可以检查出以下情况:
-
内存区域没有初始化就被访问
-
内存被释放后, 还被访问
当访问到填充的 0xAA或者 0x55,程序就会出现异常。
Malloc Scribble 有一个问题:
如果内存被填充0x55以后, 这部分内存又被其他的内存覆盖, 可能会隐藏crash问题
由于Xcode工具无法脱离Xcode, Bugly团队的成员自己实现了一个类似的工具, 只在对象释放以后的内存中填上0x55, 具体实现可能有如下几个方面需要考虑:
-
需要HOOK系统接口, 让对象在释放时, 立刻在对象释放以后的内存填充
0x55 -
HOOK哪个释放接口, 从上到下, 有如下几个接口选择:
- NSObject的dealloc
- runtime的object_dispose
- C层的free
-
具体hook方式: 使用 fishhook 直接hook free()方法
-
填充长度: 可以用
size_t malloc_size(const void *ptr)获取填充的实际长度 -
另外, 为了防止内存被释放以后又被重用, 简单说就是, 不是真正的free这块内存
-
为了防止内存耗尽, 需要有一个内存保留的阈值, 超过以后就释放
-
内存警告时, 也需要释放部分内存
-
最后 hook以后的代码:
void safe_free(void* p){
size_t memSiziee = malloc_size(p);
memset(p, 0x55, memSiziee);
orig_free(p);
return;
}
github上有一个比较好的实践: github.com/hdu-rtt/Wil…
两个遗留问题:
- 为什么填写0x55. 因为对象地址读取时会是
0x555555就会被识别为0x55555555,而CPU访问这个地址就会抛出异常 - 另外一点, 就是方便区分野指针, 例如在Xcode启用Enable Scribble时, 指定alloc之后填写的地址为0xaa, 防止内存初始化就使用, 也是为了方便和free之后的内存做区分。
更多系统内存分配的内容可以参考: 内存优化-比glibc更快的tcmalloc
3. Address Sanitizer
这个名字翻译过来叫做地址消毒剂, 是google开发的. 它的原理是当程序创建变量分配一段内存时, 将此内存前后的一段内存也冻结住, 标识为中毒内存!!!
当程序访问到中毒内存时(越界访问), 就会抛出异常, 并打印出相应log信息. 另外如果变量释放了, 变量所占的内存也会标识为中毒内存, 这时候访问这段内存同样会抛出异常(访问已经释放的对象)
具体原理:
将malloc/free函数进行了hook. 在malloc函数中额外的分配禁止访问区域的内存, 在free函数中将所有分配的内存区域设为禁止访问, 并放到了隔离区域的队列中(保证在一定的时间内不会再被malloc函数分配). 如果访问到禁止访问的区域, 就直接crash.
因此这个工具能够检查出来的问题:
- 访问已经dealloc的内存/dealloc已经dealloc的内存
- dealloc还没有alloc的内存(但不能检查出访问未初始化的内存)
- 访问函数返回以后的栈内存/访问作用域之外的栈内存
- 缓冲区上溢出或下溢出, C++容器溢出(但不能检查integer overflow)
不能用于检查内存泄漏(Xcode版本的不行)
4. Malloc Stack
之前介绍的工具都是提高崩溃概率, 以拿到崩溃的对象和内存地址。
但是, 实际中拿到崩溃的对象之后也很难定位, Malloc Stack 可以记录所有对象的malloc调用时的堆栈信息.
还有其他几个, 没有以上的工具有用, 这里就不列举了!
参考
- juejin.cn/post/684490…
- juejin.cn/post/684490…
- juejin.cn/post/684490…
- juejin.cn/post/687443…
- juejin.cn/post/684490…
- www.isaced.com/post-235.ht…
- juejin.cn/post/693097…
- cloud.tencent.com/developer/a…
- cloud.tencent.com/developer/a…
- cloud.tencent.com/developer/a…
- cloud.tencent.com/developer/a…
- cloud.tencent.com/developer/a…
- cloud.tencent.com/developer/a…
- www.jianshu.com/p/8aba0ee41…