0
点赞
收藏
分享

微信扫一扫

[iOS] Crash来集合啦

萍儿的小确幸 2021-09-19 阅读 42
Bird日记

这篇文章其实想探讨一下 crash 都有哪些种类,以及如何解决酱紫,感觉自己之前好像有浅谈过log(https://www.jianshu.com/p/2df1418dd238),其实主要是这周给小姐姐看一个bug的时候觉得还是应该总结一下~

Exception Source

其实异常的来源有三种,分别是kernel、其他进程、以及App本身

因此,crash异常也分为三种:

  • Mach异常:是指最底层的内核级异常。用户态的开发者可以直接通过Mach API设置thread,task,host的异常端口,来捕获Mach异常。

  • Unix信号:又称BSD 信号,如果开发者没有捕获Mach异常,则会被host层的方法ux_exception()将异常转换为对应的UNIX信号,并通过方法threadsignal()将信号投递到出错线程。可以通过方法signal(x, SignalHandler)来捕获signal。

  • NSException:应用级异常,它是未被捕获的Objective-C异常,导致程序向自身发送了SIGABRT信号而崩溃,是app自己可控的,对于未捕获的Objective-C异常,是可以通过try catch来捕获的,或者通过NSSetUncaughtExceptionHandler()机制来捕获。


1. Mach异常

但是类,其实一般我们不会看到 Mach 异常有木有,因为其实如果 Mach 异常没有被捕获,它会被转为 Unix信号的~

Mach是一个XNU的微内核核心,Mach异常是指最底层的内核级异常 。每个thread,task,host都有一个异常端口数组,Mach的部分API暴露给了用户态,用户态的开发者可以直接通过Mach API设置thread,task,host的异常端口,来捕获Mach异常,抓取Crash事件。

所有Mach异常未处理,它将在host层被ux_exception转换为相应的Unix信号,并通过threadsignal将信号投递到出错的线程

所以其实所有我们看到的 Unix信号异常,都是从 Mach 传过来的,只是在 Mach 没有catch,所以转成Unix给我们处理。比如 Bad Access。

Q:那么这里会不会有个问题,既然 Unix 信号都是由 Mach Exception 转化的,为啥还要转呢,直接传 Mach 的异常不就行了?
A:其实这个是操作系统层面的问题,操作系统规定了一系列的标准,Unix也需要符合这个POSIX标准,所以无论是 Mac 手机还是 iPhone 都需要将 Mach 内核的异常转成 Unix 信号,作为一个 common 接口叭。这个其实也是一种适配器模式叭。

Q:那么如果我们想做 Crash 上报的库之类的,应该是监听Unix还是Mach异常呢?
A:按理说是优先监听Mach的,毕竟它会比较早的抛出来。而且如果Mach异常的handler让程序exit了,那么Unix信号就永远不会到达这个进程了。

Q:为什么第三方库PLCrashReporter即使在优选捕获Mach异常的情况下,也放弃捕获Mach异常EXC_CRASH,而选择捕获与之对应的SIGABRT信号?
We still need to use signal handlers to catch SIGABRT in-process. The kernel sends an EXC_CRASH mach exception to denote SIGABRT termination. In that case, catching the Mach exception in-process leads to process deadlock in an uninterruptable wait. Thus, we fall back on BSD signal handlers for SIGABRT, and do not register for EXC_CRASH.
A:大部分捕获异常的库都用的 Mach + Unix信号 监听的方式,但是EXC_CRASH是通过 Unix 信号监听的。这个其实我并木有特别理解为啥会死锁,猜测是因为其实我们日常的 OC Exception 在没有自己catch的时候其实会给 Mach 发个指令,让它 kill app,这个时候的 Mach 也会发出一个异常,就是EXC_CRASH,那么如果我们告诉 mach 要 kill app 是需要等待 mach 发异常通知回来的话,那么 app 监听 Mach 异常的地方就没有办法被执行,因为主线程在等 Mach kill,而 Mach 异常在等它的监听者都执行完。(这段纯属瞎扯)
然是如果是监听 unix 信号,因为其实已经是 Mach 的部分执行完抛给了 unix,app就已经可以执行监听了,就不会死锁了叭...

iOS系统自带的 Apple’s Crash Reporter 记录在设备中的Crash日志,Exception Type项通常会包含两个元素: Mach异常 和 Unix信号。

Exception Type:         EXC_BAD_ACCESS (SIGSEGV)
Exception Subtype:      KERN_INVALID_ADDRESS at 0x041a6f3

因此,EXC_BAD_ACCESS (SIGSEGV)表示的意思是:Mach层的EXC_BAD_ACCESS异常,在host层被转换成SIGSEGV信号投递到出错的线程。既然最终以信号的方式投递到出错的线程,那么就可以通过注册signalHandler来捕获信号:

signal(SIGSEGV,signalHandler); // 监听 Unix 信号

有个异常对应表挺好的可以参考:https://juejin.cn/post/6860022809646039053

※ 如何监听 Mach 异常呢?

#import <mach/mach.h>

+ (void)createAndSetExceptionPort {
    mach_port_t server_port;
    kern_return_t kr = mach_port_allocate(mach_task_self(), MACH_PORT_RIGHT_RECEIVE, &server_port);
    assert(kr == KERN_SUCCESS);
    NSLog(@"create a port: %d", server_port);

    kr = mach_port_insert_right(mach_task_self(), server_port, server_port, MACH_MSG_TYPE_MAKE_SEND);
    assert(kr == KERN_SUCCESS);

    kr = task_set_exception_ports(mach_task_self(), EXC_MASK_BAD_ACCESS | EXC_MASK_CRASH, server_port, EXCEPTION_DEFAULT | MACH_EXCEPTION_CODES, THREAD_STATE_NONE);

    [self setMachPortListener:server_port];
}

+ (void)setMachPortListener:(mach_port_t)mach_port {
  dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
      mach_msg_header_t mach_message;

      mach_message.msgh_size = 1024;
      mach_message.msgh_local_port = mach_port;

      mach_msg_return_t mr;

      while (true) {
          mr = mach_msg(&mach_message,
                        MACH_RCV_MSG | MACH_RCV_LARGE,
                        0,
                        mach_message.msgh_size,
                        mach_message.msgh_local_port,
                        MACH_MSG_TIMEOUT_NONE,
                        MACH_PORT_NULL);

          if (mr != MACH_MSG_SUCCESS && mr != MACH_RCV_TOO_LARGE) {
              NSLog(@"error!");
          }

          mach_msg_id_t msg_id = mach_message.msgh_id;
          mach_port_t remote_port = mach_message.msgh_remote_port;
          mach_port_t local_port = mach_message.msgh_local_port;

          NSLog(@"Receive a mach message:[%d], remote_port: %d, local_port: %d",
                msg_id,
                remote_port,
                local_port);
          abort();
      }
  });
}

// 构造BAD MEM ACCESS Crash
- (void)makeCrash {
  NSLog(@"********** Make a [BAD MEM ACCESS] now. **********");
  *((int *)(0x1234)) = 122;
}

注意哦其实这里在没有发生异常的时候是block的,等有异常才会走到下一步哦~


所以有木有那位大佬知道为啥监听 EXC_CRASH 会死锁啊?明明监听的地方是其他线程啊... 懵逼树下懵逼果,懵逼果里你和我...


EXC_BAD_ACCESS 的处理

一般都是多线程造成的,某一个线程在操作一个对象时,另一个线程将此对象释放,此时就有可能造成野指针的问题。一种解决办法是如果都是UI操作则将这些操作都放在主线程去执行。

哪些情况会触发野指针异常呢?总的而言都是对内存的处理:

  • 多线程对同一块内存读写
  • 向已经释放的对象发送消息

贴个代码大家可以试试,包含两种由autoreleasepool引发的向释放对象发消息导致的bad access哈:

- (void)testAutoRelease
{
    __autoreleasing UIView* myView;
    
    @autoreleasepool {
        myView = [UIView new];
        NSLog(@"inside autoreleasepool myView:%@", myView);
    }
    NSLog(@"outside autoreleasepool myView:%@", myView);
}

- (void)testAutoRelease2 {
    NSError *error; //尽管这里默认是strong,但是downloadUrl函数里给error赋值的时候会根据函数的形参的修饰符来去决定是__strong还是__autorelease
    [self downloadUrl:@"http://xxx.png" error:&error];
    NSLog(@"error:%@", error); //crash,EXC_BAD_ACCESS
}

- (void)downloadUrl:(NSString*)url error:(NSError**)error {//这里的NSError*默认是autorelease的,相当于(NSError * __autorelease *)error, 要解决这个问题可以强制把它变成strong的,如(NSError* __strong*)error
    @autoreleasepool {
        *error = [[NSError alloc] init];
    }
}

常用的解决方式有哪些呢?
比如Zombies;选择Product->Analyze,或者快捷方式Shift+Command+B,来启用Xcode对你的项目的分析等。

这里有个MRC环境下的野指针可以参考:https://blog.csdn.net/moto0421/article/details/84243272


2. Unix signal

Unix Signal 其实是由 Mach port 抛出的信号转化的,那么都有哪些信号呢?

  • SIGHUP
    本信号在用户终端连接(正常或非正常)结束时发出, 通常是在终端的控制进程结束时, 通知同一session内的各个作业, 这时它们与控制终端不再关联。

  • SIGINT
    程序终止(interrupt)信号, 在用户键入INTR字符(通常是Ctrl-C)时发出,用于通知前台进程组终止进程。

  • SIGQUIT
    和SIGINT类似, 但由QUIT字符(通常是Ctrl-)来控制. 进程在因收到SIGQUIT退出时会产生core文件, 在这个意义上类似于一个程序错误信号。

  • SIGABRT
    调用abort函数生成的信号。
    SIGABRT is a BSD signal sent by an application to itself when an NSException or obj_exception_throw is not caught.

  • SIGBUS
    非法地址, 包括内存地址对齐(alignment)出错。比如访问一个四个字长的整数, 但其地址不是4的倍数。它与SIGSEGV的区别在于后者是由于对合法存储地址的非法访问触发的(如访问不属于自己存储空间或只读存储空间)。

  • SIGFPE
    在发生致命的算术运算错误时发出. 不仅包括浮点运算错误, 还包括溢出及除数为0等其它所有的算术的错误。

  • SIGKILL
    用来立即结束程序的运行. 本信号不能被阻塞、处理和忽略。如果管理员发现某个进程终止不了,可尝试发送这个信号。

  • SIGSEGV
    试图访问未分配给自己的内存, 或试图往没有写权限的内存地址写数据.

  • SIGPIPE
    管道破裂。这个信号通常在进程间通信产生,比如采用FIFO(管道)通信的两个进程,读管道没打开或者意外终止就往管道写,写进程会收到SIGPIPE信号。

我们这里看下一下是怎么监听它们好啦:

#include <execinfo.h>

void InstallSignalHandler(void) {
    signal(SIGHUP, handleSignalException); // 注册监听
    signal(SIGINT, handleSignalException);
    signal(SIGQUIT, handleSignalException);
    signal(SIGABRT, handleSignalException);
    signal(SIGILL, handleSignalException);
    signal(SIGSEGV, handleSignalException);
    signal(SIGFPE, handleSignalException);
    signal(SIGBUS, handleSignalException);
    signal(SIGPIPE, handleSignalException);
}

void handleSignalException(int signal) {
    NSMutableString * crashInfo = [[NSMutableString alloc]init];
    [crashInfo appendString:[NSString stringWithFormat:@"signal:%d\n",signal]];
    [crashInfo appendString:@"Stack:\n"];
    void* callstack[128];
    int i, frames = backtrace(callstack, 128);
    char** strs = backtrace_symbols(callstack, frames);
    for (i = 0; i <frames; ++i) {
        [crashInfo appendFormat:@"%s\n", strs[i]];
    }
    NSLog(@"%@", crashInfo);
}

// 构造BAD MEM ACCESS Crash
- (void)makeCrash {
  NSLog(@"********** Make a [BAD MEM ACCESS] now. **********");
  *((int *)(0x1234)) = 122;
}

unix监听和mach的不太一样,mach的如果打断点在监听的地方,crash的时候是可以走到断点的,但是unix signal不会。于是只能不用调试的方式看控制台的输出啦:


signal的ID可以参考这个:https://blog.csdn.net/github_33873969/article/details/77744382


3. Objective-C Exception
  • 非主线程刷新UI

  • NSInvalidArgumentException
    非法参数异常(NSInvalidArgumentException)是 Objective – C 代码最常出现的错误,所以平时在写代码的时候,需要多加注意,加强对参数的检查,避免传入非法参数导致异常,其中尤以nil参数为甚。

  1. 集合数据的参数传递
    比如NSMutableArray, NSMutableDictionary的数据操作
    (1) NSDictionary不能删除nil的key
    (2) NSDictionary不能添加nil的对象
    (3) 不能插入nil的对象
    (4) 其他一些nil参数
  2. 其他一些API的使用
    APP一般都会有网络操作,免不了使用网络相关接口,比如NSURL的初始化,不能传入nil的http地址:
  3. 未实现的方法
    (1) .h文件里函数名,却忘了修改.m文件里对应的函数名
    (2) 使用第三方库时,没有添加”-ObjC” flag
    (3) MRC时,大部分情况下是因为对象被提前release了,在你心里不希望他release的情况下,指针还在,对象已经不在 了。
  • NSRangeException
    越界异常(NSRangeException)也是比较常出现的异常,有如下几种类型:
  1. 数组最大下标处理错误
    比如数组长度count, index的下标范围[0, count -1], 在开发时,可能index的最大值超过数组的范围;
  2. 下标的值是其他变量赋值
    这样会有很大的不确定性, 可能是一个很大的整数值
  3. 使用空数组
    如果一个数组刚刚初始化,还是空的,就对它进行相关操作
    所以,为了避免NSRangeException的发生,必须对传入的index参数进行合法性检查,是否在集合数据的个数范围内。
  • NSGenericException
    NSGenericException这个异常最容易出现在foreach操作中,在for in循环中如果修改所遍历的数组,无论你是add或remove,都会出错 “for in”,它的内部遍历使用了类似 Iterator进行迭代遍历,一旦元素变动,之前的元素全部被失效,所以在foreach的循环当中,最好不要去进行元素的修改动作,若需要修改,循环改为for遍历,由于内部机制不同,不会产生修改后结果失效的问题。

  • NSInternalInconsistencyException
    不一致导致出现的异常
    比如NSDictionary当做NSMutableDictionary来使用,从他们内部的机理来说,就会产生一些错误
    NSMutableDictionary *info = method return to NSDictionary type;
    [info setObject:@“sxm” forKey:@”name”];
    比如xib界面使用或者约束设置不当

  • NSFileHandleOperationException
    处理文件时的一些异常,最常见的还是存储空间不足的问题,比如应用频繁的保存文档,缓存资料或者处理比较大的数据:
    所以在文件处理里,需要考虑到手机存储空间的问题。

  • NSMallocException
    这也是内存不足的问题,无法分配足够的内存空间
    此外还有

  • KVO Crash
    移除未注册的观察者
    重复移除观察者
    添加了观察者但是没有实现-observeValueForKeyPath:ofObject:change:context:方法
    添加移除keypath=nil
    添加移除observer=nil

  • unrecognized selector send to instance

下面来看下如何监听 OC 的异常:

#include <execinfo.h>

void InstallUncaughtExceptionHandler(void) {
    NSSetUncaughtExceptionHandler(&handleUncaughtException);
}

void handleUncaughtException(NSException *exception) {
    NSString * crashInfo = [NSString stringWithFormat:@"yyyy Exception name:%@\nException reason:%@\nException stack:%@",[exception name], [exception reason], [exception callStackSymbols]];
    NSLog(@"%@", crashInfo);
}

于是控制台输出是酱紫的:



※ 多个Crash日志收集服务共存的坑

是的,在自己的程序里集成多个Crash日志收集服务实在不是明智之举。通常情况下,第三方功能性SDK都会集成一个Crash收集服务,以及时发现自己SDK的问题。当各家的服务都以保证自己的Crash统计正确完整为目的时,难免出现时序手脚,强行覆盖等等的恶意竞争,总会有人默默被坑。

1)拒绝传递 UncaughtExceptionHandler

如果同时有多方通过NSSetUncaughtExceptionHandler注册异常处理程序,和平的作法是:后注册者通过NSGetUncaughtExceptionHandler将先前别人注册的handler取出并备份,在自己handler处理完后自觉把别人的handler注册回去,规规矩矩的传递。不传递强行覆盖的后果是,在其之前注册过的日志收集服务写出的Crash日志就会因为取不到NSException而丢失Last Exception Backtrace等信息。(P.S. iOS系统自带的Crash Reporter不受影响)

在开发测试阶段,可以利用 fishhook 框架去hookNSSetUncaughtExceptionHandler方法,这样就可以清晰的看到handler的传递流程断在哪里,快速定位污染环境者。不推荐利用调试器添加符号断点来检查,原因是一些Crash收集框架在调试状态下是不工作的。

2)Mach异常端口换出+信号处理Handler覆盖

和NSSetUncaughtExceptionHandler的情况类似,设置过的Mach异常端口和信号处理程序也有可能被干掉,导致无法捕获Crash事件。

3)影响系统崩溃日志准确性

应用层参与收集Crash日志的服务方越多,越有可能影响iOS系统自带的Crash Reporter。由于进程内线程数组的变动,可能会导致系统日志中线程的Crashed 标签标记错位,可以搜索abort()等关键字来复查系统日志的准确性。 若程序因NSException而Crash,系统日志中的Last Exception Backtrace信息是完整准确的,不会受应用层的胡来而影响,可作为排查问题的参考线索。


举报

相关推荐

0 条评论