保护你的crash

本文深入探讨iOS应用中Crash的三种类型:machexception、signal及NSException,并详细讲解了各自的捕获方式。同时,文章提供了如何解决Crash采集冲突的方法,并提出了一种几乎无风险的Crash处理方案。

原文链接

如何去衡量一款应用的质量好坏?为了回答这一问题,APM这一目的性极强的工具向开发顺应而生。最早的APM开发只关注于crashcpu这类的硬性指标。而随着移动开发市场的成熟,越来越多的数据指标也被加入到了APM的采集范畴中,包括感官体验相关的数据和使用习惯等。

然而,无论APM最终如何发展,其最核心的采集指标一定是crash数据。一套完善的crash监控方案可以快速的发现并协助完成问题定位,从而能够及时止损,避免更多的损失。而反过来说,如果crash不能及时被发现,又或者因为采集链中出现异常导致了数据丢失,对于开发者和公司来说,这都会是一个噩梦。

crash采集

细分之下,crash分别存在mach exceptionsignal以及NSException三种类型,每一种类型表示不同分层上的crash,也拥有各自的捕获方式。

  • mach exception

    mach异常由处理器陷阱引发,在异常发生后会被异常处理程序转换成Mach消息,接着依次投递到threadtaskhost端口。如果没有一个端口处理这个异常并返回KERN_SUCCESS,那么应用将被终止。每个端口拥有一个异常端口数组,系统暴露了后缀为_set_exception_ports的多个API让我们注册对应的异常处理到端口中。

    mach异常即便注册了对应的处理,也不会导致影响原有的投递流程。此外,即便不去注册mach异常的处理,最终经过一系列的处理,mach异常会被转换成对应的UNIX信号,一种mach异常对应了一个或者多个信号类型。因此在捕获crash要提防二次采集的可能。

  • NSException

    NSException发生在CoreFoundation以及更高抽象层,在CoreFoundation层操作发生异常时,会通过__cxa_throw函数抛出异常。在通过NSSetUncaughtExceptionHandler注册NSException的捕获函数之后,崩溃发生时会调用这个捕获函数。但如果没有任何函数去捕获这个异常 如果在捕获函数中没有进行操作终止应用,最终异常会通过abort()来抛出一个SIGABRT信号。

    由于NSException的抽象层次足够高,相比较其他的crash类型,NSException是可以被人为的阻止crash的。比如@try-catch机制能够捕获块中发生的异常,避免应用被杀死。但由于try-catch的开销和回报不成正比,往往不会使用这种机制。其二是crash防护,这一手段通过hook掉上层接口来规避crash风险,但是只建议用于线上防护,而且hook未必不会导致其他的问题。

  • signal

    signa会导致crash,这是多数iOS开发者对于信号的印象。传递crash信息其实只是信号的一部分功能,信号是一套基于POSIX标准开发的通信机制,具体可以阅读Signal-wikipedia。在signal.h中声明了32种异常信号,下面列出一部分的信号异常对:

    信号异常
    SIGILL执行了非法指令,一般是可执行文件出现了错误
    SIGTRAP断点指令或者其他trap指令产生
    SIGABRT调用abort产生
    SIGBUS非法地址。比如错误的内存类型访问、内存地址对齐等
    SIGSEGV非法地址。访问未分配内存、写入没有写权限的内存等
    SIGFPE致命的算术运算。比如数值溢出、NaN数值等

    虽然存在三种crash,但由于mach exception会在BSD层被转换成UNIX信号NSException在未被捕获的情况下会调用abort抛出信号,因此即便是我们只注册了signal的处理,只要注册的signal足够多,理论上也是能捕获到全部的crash

采集冲突

由于crash的捕获机制只会保存最后一个注册的handle,因此如果项目中残留或者存在另外的第三方框架采集crash信息时,经常性的会存在冲突。解决冲突的做法是在注册自己的handle之前保存已注册的处理函数,便于发生崩溃后能将crash信息连续的传递下去。

struct sigaction my_action;
static struct sigaction registered_action;
static NSUncaughtExceptionHandler *previousHandle;
    
void signal_handler(int signal) {
    ......
}

void exception_handler(NSException *exception) {
    ......
}
    
void registerCrashHandle() {
    previousHandle = NSGetUncaughtExceptionHandler();
    NSSetUncaughtExceptionHandler(&exception_handler);
    
    myAction.sa_handler = &signal_handler;
    sigemptyset(&my_action.sa_mask);
    sigaction(SIGABRT, &my_action, &registered_action);
}
复制代码

一般来说,一个经验丰富的开发者在注册crash回调时都会主动的去保存其他函数,避免因为冲突导致别人的数据丢失。但是即便按照这样的方式来注册你的回调,也不代表我们的处理函数是安全的。最重要的原因在于完成回调的注册之后,我们无法保证后续会不会有其他人继续注册,如果有就会存在被替换掉的风险

解决方案

按照正常方式的做法,能保证先于我们注册的crash回调不会被我们拦截导致失败,但如果在我们后方存在另外的注册,我们需要一个有效的机制来保护我们的采集数据。解决问题的收益是不变的,所以解决方案理当尽可能的低开销和低风险。

如何去判断我们的handle是否安全?这要求我们对已注册的handle进行检测。首先检测时机要选择在哪?由于crash是可能发生在应用启动阶段的,因此crash采集一般也是发生在didLaunch这个时间,下图是我绘制的应用启动到完全启动的几个重要阶段:

applicationActive这个阶段基本上是能保证crash相关的注册都完成的,因此冲突检测可以放到这个阶段进行。

周期性检测

利用已有的周期性机制或者使用定时器来进行handle冲突检测。可以分别使用通知定时器两个机制来完成周期性检测方案

  • 监听应用状态

    监听UIApplicationDidBecomeActiveNotification在应用进入活跃状态时做检测:

      - (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions {
          ......
          [[NSNotificationCenter defaultCenter] addObserver: [SignalHandler sharedHandler] selector: @selector(checkRegisterCrashHandler) name: UIApplicationDidBecomeActiveNotification object: nil];
          ......   
      }
      
      static struct sigaction existActions[32];
      static int fatal_signals[] = {
          SIGILL,
          SIGBUS,
          SIGABRT,
          SIGPIPE,
      };
      
      - (void)checkRegisterCrashHandler {
          struct sigaction oldAction;
          for (int idx = 0; idx < sizeof(fatal_signals) / sizeof(int); idx++) {
              sigaction(fatal_signals[idx], NULL, &oldAction);
              if (oldAction.sa_handler != &signal_handler) {
                  existActions[fatal_signals[idx]] = oldAction;
                  
                  struct sigaction myAction;
                  myAction.sa_handler = &signal_handler;
                  sigemptyset(&myAction.sa_mask);
                  sigaction(SIGABRT, &myAction, NULL);
              }
          }
      }
    复制代码
  • 定时器检测

    创建定时器来进行周期性的检测,相比通知的机制,可以控制检测间隔:

      - (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions {
          ......
          NSTimer *timer = [[NSTimer alloc] initWithFireDate: [NSDate date] interval: 30 target: [SignalHandler sharedHandler] selector: @selector(checkRegisterCrashHandler) userInfo: nil repeats: YES];
          [[NSRunLoop currentRunLoop] addTimer: timer forMode: NSRunLoopCommonModes];
          [timer fire];
          ......   
      }
    复制代码

hook注册函数

通过hook调用注册handle的对应函数,建立一个回调数组来保存非exception_handle的所有回调,后续处理完我们的采集,再逐个调起。由于捕获函数都是基于C接口的,因此我们需要fishhook来提供相应的hook功能。

struct SignalHandler {
    void (*signal_handler)(int);
    struct SignalHandler *next;
}
struct SignalHandler *previousHandlers[32];

void append(struct SignalHandler *handlers, struct SignalHandler *node) { 
    ......
}

static int (*origin_sigaction)(int, const struct sigaction *__restrict, struct sigaction * __restrict) = NULL;

int custom_sigaction(int signal, const struct sigaction *__restrict new_action, struct sigaction * __restrict old_action) {
    if (new_action.sa_handler != signal_handler) {
        append(previousHandlers[signal], new_action);
        return origin_sigaction(signal, NULL, old_action);
    } else {
        return origin_sigaction(signal, new_action, old_action);
    }
}
复制代码

风险

在周期性检测的方案下,假设存在handle注册链(依次从左到右):

previous <- exception_handle <- other

在检测时发现当前回调是other,于是重新注册我们的回调,保存other。但是假如other也保存了我们的回调,这样可能会导致崩溃发生的时候,调用顺序变成一个死循环。

hook方案则是因为在调用origin_sigaction时会传入old_action,可能导致另外的注册者保存了我们的exception_handle,并在最后处理的时候出现同样的循环调用问题。对于hook方案来说,解决方法要简单很多,只需要在非我们的注册调用origin_sigaction时不传入old_action就能保证其他注册者无法获取到我们的回调:

int custom_sigaction(int signal, const struct sigaction *__restrict new_action, struct sigaction * __restrict old_action) {
    if (new_action.sa_handler != signal_handler) {
        append(previousHandlers[signal], new_action);
        return origin_sigaction(signal, NULL, NULL);
    } else {
        return origin_sigaction(signal, new_action, old_action);
    }
}
复制代码

而使用周期性监测,就需要考虑是否放弃other的回调,最终只保证exception_handleprevious和更早之前的注册能够被顺利调起。

另外,hook还存在一个风险是假如第三方同样做了hook掉注册函数的处理,并且做了筛选处理,最终导致的结果是没办法完成任何一个注册。两害相较取其轻,个人的建议是使用周期性检测方案。

最简单的方式

上述的两套方案都存在风险点,而且这些风险点对于应用来说都算是致命的。那么有没有几乎没有风险又能解决问题的办法呢?答案是肯定的,那就是不要用有潜在风险的第三方,或者和第三方开发者商量提供一个无需crash采集的版本。

在应用发生崩溃的时候,此时的崩溃所在线程是极不稳定的,不稳定性包括几点:

  • 内存不稳定

    如果是内存相关错误引发的crash,比如内存过载、野指针等,此时线程的内存是危险状态。如果这时候在handle中再次分配内存,极有可能导致二次crash

  • 死锁

    大多数底层的的核心API会涉及到加锁处理,这一情况在signal错误中出现的较多。而作为上层调用方的我们是不自知的,此时错误的操作可能导致线程陷入死锁状态

理论上当我们拦截了一个signal的时候,此时的应用会陷入内核并停止工作,应用页面卡死,这时候我们可执行时长是无限的。如果处理链过长,耗时过多或者陷入某种循环,会造成一种应用卡死而非崩溃的错觉,而经过我厂大量的统计,应用卡死要比应用崩溃更让人难以接受。此外,过多的处理链会增加回调流程上的风险点。如果链条上的某个点发生了二次崩溃,会导致后续的处理都无法执行。因此,不用第三方或者让第三方去除crash采集,是一种可行且高效的手段。

其他

文中提到过一次现在比较流行的crash防护手段,这里还是想说两句。在开发中,crash防护会造成依赖心理,降低对风险的敏感。而在线上,这种方案可能屏蔽了大量的低级错误,也是让我不能容忍的,当然循环引用的防护属于例外。最后安利一波寒神的XXShield,除了容器类的防crash都值得学习,尤其是正确的method swizzling姿势。

参考

Foundation

iOS异常捕获

libc++ api spec

Linux信号处理机制

浅谈Mach Exceptions

漫谈iOS Crash收集框架

源码剖析signal和sigaction的区别

iOS Crash捕获及堆栈符号化思路剖析

### libhwui Crash原因分析 对于libhwui崩溃的情况,大部分集中发生在特定版本的安卓系统上。特别是在Android 4.4系统的设备中,在动画结束后的`onAnimationEnd (Animation animation)`回调期间处理某些操作可能会引发此类问题[^1]。 当涉及到图形渲染库如libhwui时,如果存在未定义行为或资源管理不当,则可能导致严重的稳定性问题。例如,C++编写的原生代码部分如果没有正确设置返回值也可能触发异常终止信号(signal),比如signal 5(SIGTRAP)[^2]。这类错误通常表明执行到了不应该被执行的位置,可能是由于逻辑缺陷或是非法指令引起。 另外,内存泄漏也是导致应用程序不稳定的重要原因之一。特别是当单例模式被误用时,容易造成不必要的对象持有,进而消耗过多堆空间并最终耗尽可用RAM,这同样适用于依赖于高效图像处理机制的应用场景下可能出现的大尺寸Bitmap加载失败等问题[^3][^4]。 ### 解决方案建议 针对上述提到的各种潜在诱因: - 对于由特定API级别引起的兼容性问题,应考虑更新至最新稳定版SDK,并遵循官方文档中的最佳实践来编写跨平台兼容性强的UI组件。 - 如果怀疑是由本地层代码失误所造成的,请务必审查所有涉及native bridge交互的地方,确保每条路径都有恰当的结果反馈给Java层面;同时利用工具链提供的调试辅助功能定位具体位置再做针对性修正。 - 面向可能存在的泄露风险点,开发者应当定期运用静态分析器扫描源码质量,及时发现并消除任何可疑的对象引用关系;此外还可以通过配置ProGuard混淆规则进一步优化打包产物大小的同时保护知识产权不受侵犯。 - 关于大图载入方面带来的挑战,推荐采用按需渐进式读取策略代替一次性全部解压的方式,以此减轻瞬时间对硬件的压力程度;必要时候可以引入第三方开源框架简化实现难度提高开发效率。 ```java // 示例:检查并释放不再使用的位图资源 @Override protected void onDestroy() { super.onDestroy(); if(bitmap != null && !bitmap.isRecycled()){ bitmap.recycle(); // 手动回收位图占用的空间 bitmap = null; } } ```
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值