本文主要讲解两种野指针检测的原理及实现
技术点:野指针探测
本文的主要目的是理解野指针的形成过程以及如何去检测野指针
引子
在介绍野指针之前,首先说下目前的异常处理类型,附上苹果官网链接)
异常类型
异常大致可以分为两类:
- 1、
软件异常
:主要是来自kill()、pthread_kill()、iOS中的NSException未捕获、absort等 - 2、
硬件异常
:硬件的信号始于处理器trap,是和平台相关的,野指针崩溃大部分是硬件异常
而在处理异常时,需要关注两个概念
Mach异常
:Mach层
捕获UNIX信号
:BSD层
获取
iOS中的POSIX API就是通过Mach之上的BSD层实现的,如下图所示
Mach
是一个受 Accent 启发而搞出的Unix兼容系统。BSD
层是建立在Mach之上,是XNU中一个不可分割的一部分。BSD负责提供可靠的、现代的APIPOSIX
表示可移植操作系统接口(Portable Operating System Interface)
所以,综上所述,Mach异常和UNIX信号存在对应的关系
- 1、硬件异常流程:硬件异常 -> Mach异常 -> UNIX信号
- 2、软件异常流程:软件异常 -> UNIX信号
Mach异常与UNIX信号的转换
下面是Mach异常
与 UNIX信号
的转换关系代码,来自 xnu
中的 bsd/uxkern/ux_exception.c
switch(exception) { case EXC_BAD_ACCESS: if (code == KERN_INVALID_ADDRESS) *ux_signal = SIGSEGV; else *ux_signal = SIGBUS; break; case EXC_BAD_INSTRUCTION: *ux_signal = SIGILL; break; case EXC_ARITHMETIC: *ux_signal = SIGFPE; break; case EXC_EMULATION: *ux_signal = SIGEMT; break; case EXC_SOFTWARE: switch (code) { case EXC_UNIX_BAD_SYSCALL: *ux_signal = SIGSYS; break; case EXC_UNIX_BAD_PIPE: *ux_signal = SIGPIPE; break; case EXC_UNIX_ABORT: *ux_signal = SIGABRT; break; case EXC_SOFT_SIGNAL: *ux_signal = SIGKILL; break; } break; case EXC_BREAKPOINT: *ux_signal = SIGTRAP; break; }
将其对应关系汇总成一个表格,如下所示
- 其中Mach异常有以下
- UNIX信号有以下几种
野指针
所指向的对象被释放或者收回
,但是该指针没有作任何的修改
,以至于该指针仍旧指向已经回收的内存地址
。这个指针就是野指针
野指针分类
这个参考腾讯Bugly团队的总结,大致分为两类
- 内存没被覆盖
- 内存被覆盖
如下图所示
为什么OC野指针的crash这么多?
我们一般在app发版前,都会经过多轮的自测、内侧、灰度测试
等,按照常理来说,大部分的crash应该都被覆盖了,但是由于野指针的随机性
,使得经常在测试时不会出现crash,而是在线上出现crash
,这对app体验来说是非常致命的
而野指针的随机性问题大致可以分为两类:
- 1、跑不进出错的逻辑,执行不到出错的代码,这种可以通过
提高测试场景覆盖率
来解决 - 2、跑进有问题的逻辑,但是野指针指向的地址并不一定会导致crash,原因是因为:
野指针
其本质是一个指向已经删除的对象
或受限内存区域
的指针
。这里说的OC野指针
,是指OC对象释放后指针未置空而导致的野指针
。这里不必现的原因是因为dealloc
执行后只是告诉系统,这片内存我不用了,而系统并没有让这片内存不能访问
野指针解决思路
这里主要是借鉴Xcode中的两种处理方案:
1、Malloc Scribble ,其官方解释如下:申请内存 alloc
时在内存上填0xAA
,释放内存 dealloc
在内存上填 0x55
。
2、Zombie Objects,其官方解释如下:一个对象已经解除了它的引用,已经被释放掉,但是此时仍然是可以接受消息,这个对象就叫做Zombie Objects
(僵尸对象)。这种方案的重点就是将释放的对象,全都转为僵尸对象
两种方案对比
- 1、
僵尸对象
相比Malloc Scribble
,不需要考虑会不会崩溃的问题
,只要野指针指向僵尸对象,那么再次访问野指针就一定会崩溃 - 2、僵尸对象这种方式,
不如Malloc Scribble覆盖面广
,可以通过hook free方法将c函数也包含在其中
1、Malloc Scribble
思路:当访问到对象内存中填充的是0xAA、0x55
时,程序就会出现异常
- 申请内存
alloc
时在内存上填0xAA
, - 释放内存
dealloc
在内存上填0x55
。
以上的申请和释放的填充分别对应一下两种情况
- 申请:没有做初始化就直接被访问
- 释放:释放后访问
所以综上所述,针对野指针,我们的解决办法是:在对象释放时做数据填充0x55
即可。关于对象的释放流程可以参考这篇文章iOS-底层原理 33:内存管理(一)TaggedPointer/retain/release/dealloc/retainCount 底层分析
野指针探测实现1
这个实现主要依据腾讯Bugly工程师:陈其锋的分享,在其代码中的主要思路是
- 1、通过
fishhook
替换C函数
的free
方法为自定义的safe_free
,类似于Method Swizzling - 2、在
safe_free
方法中对已经释放变量的内存
,填充0x55
,使已经释放变量不能访问
,从而使某些野指针的crash从不必现安变成必现
。
- 为了
防止填充0x55的内存被新的数据内容填充
,使野指针crash变成不必现,在这里采用的策略是,safe_free不释放这片内存,而是自己保留着
,即safe_free方法中不会真的调用free。 - 同时为了
防止系统内存过快消耗
(因为要保留内存),需要在保留的内存大于一定值时释放一部分
,防止被系统杀死,同时,在收到系统内存警告
时,也需要释放一部分内存
- 3、发生crash时,得到的崩溃信息有限,不利于问题排查,所以这里采用代理类(即继承自
NSProxy
的子类),重写消息转发的三个方法(参考这篇文章iOS-底层原理 14:消息流程分析之 动态方法决议 & 消息转发),以及NSObject的实例方法,来获取异常信息。但是这的话,还有一个问题,就是NSProxy只能做OC对象的代理,所以需要在safe_free中增加对象类型的判断
以下是完整的野指针探测实现代码
- 引入fishhook
- 实现NSProxy的代理子类
<!--1、MIZombieProxy.h--> @interface MIZombieProxy : NSProxy @property (nonatomic, assign) Class originClass; @end <!--2、MIZombieProxy.m--> #import "MIZombieProxy.h" @implementation MIZombieProxy - (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]; } #define MIZombieThrowMesssageSentException() [self _throwMessageSentExceptionWithSelector: _cmd] - (Class)class{ MIZombieThrowMesssageSentException(); return nil; } - (BOOL)isEqual:(id)object{ MIZombieThrowMesssageSentException(); return NO; } - (NSUInteger)hash{ MIZombieThrowMesssageSentException(); return 0; } - (id)self{ MIZombieThrowMesssageSentException(); return nil; } - (BOOL)isKindOfClass:(Class)aClass{ MIZombieThrowMesssageSentException(); return NO; } - (BOOL)isMemberOfClass:(Class)aClass{ MIZombieThrowMesssageSentException(); return NO; } - (BOOL)conformsToProtocol:(Protocol *)aProtocol{ MIZombieThrowMesssageSentException(); return NO; } - (BOOL)isProxy{ MIZombieThrowMesssageSentException(); return NO; } - (NSString *)description{ MIZombieThrowMesssageSentException(); return nil; } #pragma mark - MRC - (instancetype)retain{ MIZombieThrowMesssageSentException(); return nil; } - (oneway void)release{ MIZombieThrowMesssageSentException(); } - (void)dealloc { MIZombieThrowMesssageSentException(); [super dealloc]; } - (NSUInteger)retainCount{ MIZombieThrowMesssageSentException(); return 0; } - (struct _NSZone *)zone{ MIZombieThrowMesssageSentException(); return nil; } #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]; } @end
- hook free方法的具体实现
<!--1、MISafeFree.h--> @interface MISafeFree : NSObject //系统警告时,用函数释放一些内存 void free_safe_mem(size_t freeNum); @end <!--2、MISafeFree.m--> #import "MISafeFree.h" #import "queue.h" #import "fishhook.h" #import "MIZombieProxy.h" #import <dlfcn.h> #import <objc/runtime.h> #import <malloc/malloc.h> //用于保存zombie类 static Class kMIZombieIsa; //用于保存zombie类的实例变量大小 static size_t kMIZombieSize; //用于表示调用free函数 static void(* orig_free)(void *p); //用于保存已注册的类的集合 static CFMutableSetRef registeredClasses = nil; /* 用来保存自己保留的内存 - 1、队列要线程安全或者自己加锁 - 2、这个队列内部应该尽量少申请和释放堆内存 */ struct DSQueue *_unfreeQueue = NULL; //用来记录自己保存的内存的大小 int unfreeSize = 0; //最多存储的内存,大于这个值就释放一部分 #define MAX_STEAL_MEM_SIZE 1024*1024*100 //最多保留的指针个数,超过就释放一部分 #define MAX_STEAL_MEM_NUM 1024*1024*10 //每次释放时释放的指针数量 #define BATCH_FREE_NUM 100 @implementation MISafeFree #pragma mark - Public Method //系统警告时,用函数释放一些内存 void free_safe_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 memSize = malloc_size(unfreePoint); //原子减操作,多线程对全局变量进行自减 __sync_fetch_and_sub(&unfreeSize, (int)memSize); //释放 orig_free(unfreePoint); } #endif } #pragma mark - Life Circle + (void)load{ #ifdef DEBUG loadZombieProxyClass(); init_safe_free(); #endif } #pragma mark - Private Method 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_safe_mem(BATCH_FREE_NUM); }else{ //创建p申请的内存大小 size_t memSize = malloc_size(p); //有足够的空间才覆盖 if (memSize > kMIZombieSize) { //指针强转为id对象 id obj = (id)p; //获取指针原本的类 Class origClass = object_getClass(obj); //判断是不是objc对象 char *type = @encode(typeof(obj)); /* - strcmp 字符串比较 - CFSetContainsValue 查看已注册类中是否有origClass这个类 如果都满足,则将这块内存填充0x55 */ if (strcmp("@", type) == 0 && CFSetContainsValue(registeredClasses, origClass)) { //内存上填充0x55 memset(obj, 0x55, memSize); //将自己类的isa复制过去 memcpy(obj, &kMIZombieIsa, sizeof(void*)); //为obj设置指定的类 object_setClass(obj, [MIZombieProxy class]); //保留obj原本的类 ((MIZombieProxy*)obj).originClass = origClass; //多线程下int的原子加操作,多线程对全局变量进行自加,不用理会线程锁了 __sync_fetch_and_add(&unfreeSize, (int)memSize); //入队 ds_queue_put(_unfreeQueue, p); }else{ orig_free(p); } }else{ orig_free(p); } } } //加载野指针自定义类 void loadZombieProxyClass(){ registeredClasses = CFSetCreateMutable(NULL, 0, NULL); //用于保存已注册类的个数 unsigned int count = 0; //获取所有已注册的类 Class *classes = objc_copyClassList(&count); //遍历,并保存到registeredClasses中 for (int i = 0; i < count; i++) { CFSetAddValue(registeredClasses, (__bridge const void *)(classes[i])); } //释放临时变量内存 free(classes); classes = NULL; kMIZombieIsa = objc_getClass("MIZombieProxy"); kMIZombieSize = class_getInstanceSize(kMIZombieIsa); } //初始化以及free符号重绑定 bool init_safe_free(){ //初始化用于保存内存的队列 _unfreeQueue = ds_queue_create(MAX_STEAL_MEM_NUM); //dlsym 在打开的库中查找符号的值,即动态调用free函数 orig_free = (void(*)(void*))dlsym(RTLD_DEFAULT, "free"); /* rebind_symbols:符号重绑定 - 参数1:rebindings 是一个rebinding数组,其定义如下 struct rebinding { const char *name; // 目标符号名 void *replacement; // 要替换的符号值(地址值) void **replaced; // 用来存放原来的符号值(地址值) }; - 参数2:rebindings_nel 描述数组的长度 */ //重绑定free符号,让它指向自定义的safe_free函数 rebind_symbols((struct rebinding[]){{"free", (void*)safe_free}}, 1); return true; } @end
- 测试
- (void)viewDidLoad { [super viewDidLoad]; id obj = [[NSObject alloc] init]; self.assignObj = obj; // [MIZombieSniffer installSniffer]; } - (IBAction)mallocScribbleAction:(id)sender { 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]; }
打印结果如下
2、Zombie Objects
僵尸对象
- 可以用来检测内存错误(
EXC_BAD_ACCESS
),它可以捕获任何阐释访问坏内存的调用 - 给僵尸对象发送消息的话,它仍然是可以响应的,然后会发生崩溃,并输出错误日志来显示野指针对象调用的类名和方法
苹果的僵尸对象检测原理
首先我们来看下Xcode中僵尸对象是如何实现的,具体操作步骤可以参考这篇文章iOS Zombie Objects(僵尸对象)原理探索
- 从
dealloc
的源码中,我们可以看到“Replaced by NSZombie”
,即对象释放
时,NSZombie 将在 dealloc 里做替换
,如下所示
所以僵尸对象的生成过程伪代码如下
//1、获取到即将deallocted对象所属类(Class) Class cls = object_getClass(self); //2、获取类名 const char *clsName = class_getName(cls) //3、生成僵尸对象类名 const char *zombieClsName = "_NSZombie_" + clsName; //4、查看是否存在相同的僵尸对象类名,不存在则创建 Class zombieCls = objc_lookUpClass(zombieClsName); if (!zombieCls) { //5、获取僵尸对象类 _NSZombie_ Class baseZombieCls = objc_lookUpClass(“_NSZombie_"); //6、创建 zombieClsName 类 zombieCls = objc_duplicateClass(baseZombieCls, zombieClsName, 0); } //7、在对象内存未被释放的情况下销毁对象的成员变量及关联引用。 objc_destructInstance(self); //8、修改对象的 isa 指针,令其指向特殊的僵尸类 objc_setClass(self, zombieCls);
当僵尸对象再次被访问时,将进入消息转发流程,开始处理僵尸对象访问,输出日志并发生crash
所以僵尸对象触发流程伪代码如下
//1、获取对象class Class cls = object_getClass(self); //2、获取对象类名 const char *clsName = class_getName(cls); //3、检测是否带有前缀_NSZombie_ if (string_has_prefix(clsName, "_NSZombie_")) { //4、获取被野指针对象类名 const char *originalClsName = substring_from(clsName, 10); //5、获取当前调用方法名 const char *selectorName = sel_getName(_cmd); //6、输出日志 Log(''*** - [%s %s]: message sent to deallocated instance %p", originalClsName, selectorName, self); //7、结束进程 abort();
所以综上所述,这中野指针探测方式的思路是:dealloc
方法的替换,其关键是调用objc_destructInstance
来解除对象的关联引用
野指针探测实现2
这种方式的思路主要是来源sindrilin的源码,其主要思路是:
- 野指针检测流程
- 1、开启野指针检测
- 2、设置监控到野指针时的回调block,在block中打印信息,或者存储堆栈
- 3、检测到野指针是否crash
- 4、最大内存占用空间
- 5、是否记录dealloc调用栈
- 6、监控策略
- 1)只监控自定义对象
- 2)白名单策略
- 3)黑名单策略
- 4)监控所有对象
- 7、交换NSObject的dealloc方法
- 触发野指针
- 1、开始处理对象
- 2、是否达到替换条件
- 1)根据监控策略,是否属于要检测的类
- 2)空间是否足够
- 3、如果符合条件,则获取对象,并解除引用,如果不符合则正常释放,即调用原来的dealloc方法
- 4、向对象内填充数据
- 5、赋值僵尸对象的类指针替换isa
- 6、对象+dealloc调用栈,保存在僵尸对象中
- 7、根据情况是否清理内存和对象
通过僵尸对象检测的实现思路
- 1、通过OC中
Mehod Swizzling
,交换根类NSObject和NSProxy
的dealloc
方法为自定义的dealloc
方法 - 2、为了
避免内存空间释放后被重写造成野指针
的问题,通过字典存储被释放的对象
,同时设置在30s后调用dealloc方法将字典中存储的对象释放,避免内存增大
- 3、为了获取更多的崩溃信息,这里同样需要创建NSProxy的子类
具体实现
- 1、创建NSProxy的子类,其实现与上面的
MIZombieProxy
是一模一样的 - 2、hook dealloc函数的具体实现
<!--1、MIZombieSniffer.h--> @interface MIZombieSniffer : NSObject /*! * @method installSniffer * 启动zombie检测 */ + (void)installSniffer; /*! * @method uninstallSnifier * 停止zombie检测 */ + (void)uninstallSnifier; /*! * @method appendIgnoreClass * 添加白名单类 */ + (void)appendIgnoreClass: (Class)cls; @end <!--2、MIZombieSniffer.m--> #import "MIZombieSniffer.h" #import "MIZombieProxy.h" #import <objc/runtime.h> // typedef void (*MIDeallocPointer) (id objc); //野指针探测器是否开启 static BOOL _enabled = NO; //根类 static NSArray *_rootClasses = nil; //用于存储被释放的对象 static NSDictionary<id, NSValue*> *_rootClassDeallocImps = nil; //白名单 static inline NSMutableSet *__mi_sniffer_white_lists(){ //创建白名单集合 static NSMutableSet *mi_sniffer_white_lists; //单例初始化白名单集合 static dispatch_once_t onceToken; dispatch_once(&onceToken, ^{ mi_sniffer_white_lists = [[NSMutableSet alloc] init]; }); return mi_sniffer_white_lists; } static inline void __mi_dealloc(__unsafe_unretained id obj){ //获取对象的类 Class currentCls = [obj class]; Class rootCls = currentCls; //获取非NSObject和NSProxy的类 while (rootCls != [NSObject class] && rootCls != [NSProxy class]) { //获取rootCls的父类,并赋值 rootCls = class_getSuperclass(rootCls); } //获取类名 NSString *clsName = NSStringFromClass(rootCls); //根据类名获取dealloc的imp指针 MIDeallocPointer deallocImp = NULL; [[_rootClassDeallocImps objectForKey:clsName] getValue:&deallocImp]; if (deallocImp != NULL) { deallocImp(obj); } } //hook交换dealloc static inline IMP __mi_swizzleMethodWithBlock(Method method, void *block){ /* imp_implementationWithBlock :接收一个block参数,将其拷贝到堆中,返回一个trampoline 可以让block当做任何一个类的方法的实现,即当做类的方法的IMP来使用 */ IMP blockImp = imp_implementationWithBlock((__bridge id _Nonnull)(block)); //method_setImplementation 替换掉method的IMP return method_setImplementation(method, blockImp); } @implementation MIZombieSniffer //初始化根类 + (void)initialize { _rootClasses = [@[[NSObject class], [NSProxy class]] retain]; } #pragma mark - public + (void)installSniffer{ @synchronized (self) { if (!_enabled) { //hook根类的dealloc方法 [self _swizzleDealloc]; _enabled = YES; } } } + (void)uninstallSnifier{ @synchronized (self) { if (_enabled) { //还原dealloc方法 [self _unswizzleDealloc]; _enabled = NO; } } } //添加百名单 + (void)appendIgnoreClass:(Class)cls{ @synchronized (self) { NSMutableSet *whiteList = __mi_sniffer_white_lists(); NSString *clsName = NSStringFromClass(cls); [clsName retain]; [whiteList addObject:clsName]; } } #pragma mark - private + (void)_swizzleDealloc{ static void *swizzledDeallocBlock = NULL; //定义block,作为方法的IMP static dispatch_once_t onceToken; dispatch_once(&onceToken, ^{ swizzledDeallocBlock = (__bridge void *)[^void(id obj) { //获取对象的类 Class currentClass = [obj class]; //获取类名 NSString *clsName = NSStringFromClass(currentClass); //判断该类是否在白名单类 if ([__mi_sniffer_white_lists() containsObject: clsName]) { //如果在白名单内,则直接释放对象 __mi_dealloc(obj); } else { //修改对象的isa指针,指向MIZombieProxy /* valueWithBytes:objCType 创建并返回一个包含给定值的NSValue对象,该值会被解释为一个给定的NSObject类型 - 参数1:NSValue对象的值 - 参数2:给定值的对应的OC类型,需要使用编译器指令@encode来创建 */ NSValue *objVal = [NSValue valueWithBytes: &obj objCType: @encode(typeof(obj))]; //为obj设置指定的类 object_setClass(obj, [MIZombieProxy class]); //保留对象原本的类 ((MIZombieProxy *)obj).originClass = currentClass; //设置在30s后调用dealloc将存储的对象释放,避免内存空间的增大 dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(30 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ __unsafe_unretained id deallocObj = nil; //获取需要dealloc的对象 [objVal getValue: &deallocObj]; //设置对象的类为原本的类 object_setClass(deallocObj, currentClass); //释放 __mi_dealloc(deallocObj); }); } } copy]; }); //交换了根类NSObject和NSProxy的dealloc方法为originalDeallocImp NSMutableDictionary *deallocImps = [NSMutableDictionary dictionary]; //遍历根类 for (Class rootClass in _rootClasses) { //获取指定类中dealloc方法 Method oriMethod = class_getInstanceMethod([rootClass class], NSSelectorFromString(@"dealloc")); //hook - 交换dealloc方法的IMP实现 IMP originalDeallocImp = __mi_swizzleMethodWithBlock(oriMethod, swizzledDeallocBlock); //设置IMP的具体实现 [deallocImps setObject: [NSValue valueWithBytes: &originalDeallocImp objCType: @encode(typeof(IMP))] forKey: NSStringFromClass(rootClass)]; } //_rootClassDeallocImps字典存储交换后的IMP实现 _rootClassDeallocImps = [deallocImps copy]; } + (void)_unswizzleDealloc{ //还原dealloc交换的IMP [_rootClasses enumerateObjectsUsingBlock:^(Class rootClass, NSUInteger idx, BOOL * _Nonnull stop) { IMP originDeallocImp = NULL; //获取根类类名 NSString *clsName = NSStringFromClass(rootClass); //获取hook后的dealloc实现 [[_rootClassDeallocImps objectForKey:clsName] getValue:&originDeallocImp]; NSParameterAssert(originDeallocImp); //获取原本的dealloc实现 Method oriMethod = class_getInstanceMethod([rootClass class], NSSelectorFromString(@"dealloc")); //还原dealloc的实现 method_setImplementation(oriMethod, originDeallocImp); }]; //释放 [_rootClassDeallocImps release]; _rootClassDeallocImps = nil; } @end
- 3、测试
@interface ViewController () @property (nonatomic, assign) id assignObj; @end @implementation ViewController - (void)viewDidLoad { [super viewDidLoad]; id obj = [[NSObject alloc] init]; self.assignObj = obj; [MIZombieSniffer installSniffer]; } - (IBAction)zombieObjectAction:(id)sender { NSLog(@"%@", self.assignObj); }
打印崩溃信息如下