iOS 崩溃排查不再靠猜!这份分层捕获指南请收好

简介: 从 Mach 内核异常到 NSException,从堆栈遍历到僵尸对象检测,阿里云 RUM iOS SDK 基于 KSCrash 构建了一套完整、异步安全、生产可用的崩溃捕获体系,让每一个线上崩溃都能被精准定位。

作者:高玉龙(元泊)


背景介绍


App 上线后,作为开发同学,最怕出现的情况就是应用崩溃了。但是,线下测试好好的 App,为什么上线后就发生崩溃了呢?这些崩溃日志信息是怎么采集的?


先看看几个常见的编写代码时的疏忽,是如何让应用崩溃的。


  • 数组越界:在取数据索引时越界,App 会发生崩溃。
  • 多线程问题:在子线程中进行 UI 更新可能会发生崩溃。多个线程进行数据的读取操作,因为处理时机不一致,比如有一个线程在置空数据的同时另一个线程在读取这个数据,可能会出现崩溃情况。
  • 主线程无响应:如果主线程超过系统规定的时间无响应,就会被 Watchdog 杀掉。
  • 野指针:指针指向一个已删除的对象访问内存区域时,会出现野指针崩溃。


为了解决这个问题,阿里云可观测研发团队进行了一些 iOS 异常监控方向的探索。


iOS 异常体系介绍


iOS 异常体系采用分层架构,从底层硬件到上层应用,异常在不同层次被捕获和处理。理解异常体系的分层结构,有助于我们更好地设计和实现异常监控方案。iOS 异常体系主要分为以下几个层次:


1. 硬件层异常

  • CPU 异常:由硬件直接产生的异常,如非法指令、内存访问错误等
  • 这是最底层的异常来源,所有其他异常最终都源于此

2. 系统层异常

  • Mach 异常macOS/iOS 系统最底层的异常机制,源于 Mach 微内核架构
  • Unix 信号Mach 异常会被转换为 Unix 信号,如 SIGSEGV、SIGABRT 等
  • 系统层异常是应用层异常监控的主要捕获点

3. 运行时层异常

  • NSExceptionObjective-C 运行时异常,如数组越界、空指针等
  • C++ 异常C++ 代码抛出的异常,通过 std::terminate() 处理
  • 运行时层异常通常由编程错误引起

4. 应用层异常

  • 业务逻辑异常:应用自定义的异常和错误
  • 性能异常:主线程死锁、内存泄漏等
  • 僵尸对象访问:访问已释放对象导致的异常


异常体系的分层关系如下图所示:

1766997716474_74a3249157a84f5e9f24c6f7b9baea58.png

异常捕获的层次关系:

  1. 硬件异常 → Mach 异常CPU 异常被 Mach 内核捕获,转换为 Mach 异常消息
  2. Mach 异常 → Unix 信号Mach 异常处理机制会将异常转换为对应的 Unix 信号
  3. 运行时异常NSException 和 C++ 异常在运行时层被捕获,如果未处理会触发系统层异常
  4. 应用层异常业务异常和性能问题需要应用层主动监控和检测

异常监控策略:

  • 系统层监控通过 Mach 异常和 Unix 信号捕获,可以捕获所有底层异常
  • 运行时层监控通过设置异常处理器(NSUncaughtExceptionHandler、terminate handler)捕获运行时异常
  • 应用层监控通过主动检测机制(死锁检测、僵尸对象检测)发现潜在问题


理解这个分层体系,有助于我们:

  • 选择合适的异常捕获机制
  • 理解不同异常类型的来源和处理方式
  • 设计完整的异常监控方案


主流异常监控方案


在 iOS 端侧异常监控领域,PLCrashReporter 与 KSCrash 是最常用的两个内核库。两者都是开源、生产可用,且被多家平台化产品或 SDK 采用作为底层能力。

1766997744574_cede474b7bf04c09a716560de5d083b9.png

基于以上对比分析,KSCrash 相比其他崩溃监控框架的核心优势在于:


  • 异常类型监测支持更全面(唯一同时支持 C++ 异常、死锁检测、僵尸对象检测的开源框架)
  • 异步安全设计(崩溃处理完全异步安全,双重异常处理线程确保可靠性)
  • 技术优势明显(堆栈游标抽象、内存内省、模块化架构等)


基于以上优势,我们选择基于 KSCrash 作为崩溃异常监控的核心方案。


异常监控方案实现


架构设计

异常采集模块,是我们 SDK 数据采集层一个模块的具体实现,如下:

1766997771872_c95d28d35fd24a70aae8af9dd49cb05d.png

  • 监控器管理层:统一管理所有监控器,提供统一的异常处理入口
  • 异常捕获层:多种监控器,分别捕获不同类型的异常和状态信息
  • 异常处理层:构建崩溃上下文,收集堆栈、符号、内存等信息
  • 报告生成层:将崩溃上下文转换为 JSON 格式报告


接下来,我们介绍各种类型异常的捕获原理,以及对应监控器是如何实现的。


系统层异常捕获

系统层异常包括 Mach 异常和 Unix 信号,是应用层异常监控的主要捕获点。我们需要同时捕获这两种异常,确保不遗漏任何底层异常。


Mach 异常捕获

Mach 异常是 macOS/iOS 系统最底层的异常机制,源于 Mach 微内核架构。Mach 是 macOS/iOS 内核的基础,提供了进程间通信(IPC)和异常处理的核心机制。硬件异常(CPU 异常)会被 Mach 内核捕获并转换为 Mach 异常消息。Mach 异常与特定线程关联,可以精确捕获异常发生的线程。Mach 异常通过 Mach 消息异步传递异常信息,需要使用 Mach 端口(Mach Port)作为异常处理的通信通道。

1766997792660_86ecb5a148e944bcb29d92a923bd88c1.png

监控 Mach 异常,涉及以下几个核心的步骤:


1. 创建异常端口


// 创建新的异常处理端口
mach_port_allocate(mach_task_self(), MACH_PORT_RIGHT_RECEIVE, &g_exceptionPort);
// 申请端口权限
mach_port_insert_right(mach_task_self(), g_exceptionPort, g_exceptionPort, MACH_MSG_TYPE_MAKE_SEND);


为了与三方 SDK 兼容,在创建新的异常处理端口之前,需要对旧的异常处理端口进行保存,并在异常处理完毕后恢复旧的异常端口。


2. 注册异常处理器

把异常处理端口设置为刚才创建的:


// 设置异常端口,捕获所有异常类型
task_set_exception_ports(
    mach_task_self(),
    EXC_MASK_ALL,
    g_exceptionPort,
    EXCEPTION_DEFAULT,
    MACHINE_THREAD_STATE
);


3. 创建异常处理线程

为了防止异常处理线程本身崩溃,需要创建两个独立的异常处理线程:


  • 主处理线程:正常处理异常
  • 备用处理线程:主处理线程崩溃时的后备份方案


// 主异常处理线程
pthread_create(&g_primaryPThread, &attr, handleExceptions, kThreadPrimary);
// 备用异常处理线程(防止主线程崩溃)
pthread_create(&g_secondaryPThread, &attr, handleExceptions, kThreadSecondary);


主备线程之间的关系如下:

1767076449638_a772b01d476149d68d5b11b6e1f67893.png

  • 备用处理线程在创建后会立即挂起
  • 主线程在处理异常之前会通过 thread_resume()函数恢复备用处理线程
  • 备用处理线程恢复后,会进入 mach_msg() 等待
  • 如果主线程在处理异常时发生崩溃,备用处理线程可以继续处理崩溃信息(由于异常端口已恢复,此时备用线程可能也收不到消息)


4. 处理异常消息

异常处理线程通过 mach_msg() 接收异常消息:


mach_msg_return_t kr = mach_msg(
    &exceptionMessage.header,
    MACH_RCV_MSG | MACH_RCV_LARGE,
    0,
    sizeof(exceptionMessage),
    g_exceptionPort,
    MACH_MSG_TIMEOUT_NONE,
    MACH_PORT_NULL
);

1767076481702_f06db4b48538488dbcc3fb48d840cb59.png

  • 挂起所有线程:确保状态一致性
  • 标记已捕获异常:进入异步安全模式
  • 激活备用处理线程
  • 读取异常线程的机器状态
  • 初始化堆栈游标
  • 构建异常上下文
  • 异常类型
  • 机器状态
  • 地址信息等
  • 堆栈游标
  • 统一异常处理:不同异常类型统一处理
  • 恢复线程


Unix 信号捕获

作为 Mach 异常捕获的补充,也需要直接捕获 Unix 信号,确保在 Mach 异常处理失败时,仍能捕获到崩溃。Unix 信号的捕获处理涉及:

1767076498315_1b23765f6d214755a357b361fe741eb9.png

为了能够通过 Unix 信号捕获到异常,需要先安装信号处理器:


// 获取信号列表
const int* fatal_signals = signal_fatal_signals();
// 配置信号动作
struct sigaction action = {{0}};
action.sa_flags = SA_SIGINFO | SA_ONSTACK;
action.sa_sigaction = &signal_handle_signals;
// 安装信号处理器
sigaction(fatal_signal, &action, &previous_signal_handler);


Unix 信号的产生主要有以下情况:

  • 来自 Mach 异常如果 Mach 异常未被应用层处理,系统会将其转换为对应的 Unix 信号
  • 直接产生如调用 abort() 直接产生 SIGABRT,或 NSException/C++ 异常未捕获时产生的信号


当信号产生后,系统会找到我们安装的信号处理器,并调用我们注册的信号处理函数:


void signal_handle_signals(int sig_num, siginfo_t *signal_info, void* user_context)
{
  // sig_num: 信号编码,如 SIGSEGV=11
  // signal_info: 信号详细信息
  //     - si_signo: 信号编码
  //     - si_code: 信号代码,如 SEGV_MAPERR
  //     - si_addr: 异常地址
  // user_context: CPU 寄存器状态
}


后续对异常的处理,同 Mach 异常处理流程。


注意并非所有异常都源于 Mach 异常。例如,NSException 未捕获时通常会调用 abort() 产生 SIGABRT 信号,这个过程不经过 Mach 异常。因此,异常监控需要同时捕获 Mach 异常、Unix 信号和运行时异常处理器。


机器上下文堆栈

在崩溃发生时,堆栈追踪可以帮助开发者定位问题发生的代码位置。在基于 Mach 或 Unix 信号捕获的场景,需要从 CPU 寄存器和堆栈内存中恢复完整的调用栈。核心原理:每个函数调用,都会在堆栈上创建一个堆栈帧,包含:


  • 返回地址:函数返回后继续执行的地址
  • 帧指针(FP):指向当前堆栈帧的指针
  • 局部变量:函数的局部变量
  • 参数:传递给函数的参数


以 ARM64 架构为例,堆栈布局如下:

1767076559405_efde71e3539742989f0467faeb37c051.png

为了还原崩溃发生时的调用栈,我们需要对堆栈帧进行遍历。堆栈帧遍历的核心原理是通过帧指针链向上遍历:

  1. 第 1 帧:从 PC 寄存器获取当前崩溃点
  2. 第 2 帧:从 LR 寄存器获取调用者
  3. 第 3 帧及以后:通过帧指针链从堆栈内存中读取


堆栈帧遍历的完整流程如下:

1767076756479_7e3d50144cca48418396e0a9a75f3114.png

在堆栈遍历过程中,有下面几个关键点需要注意:


  • 在遍历堆栈时,必须安全地访问内存,防止访问无效内存导致崩溃
  • 堆栈溢出检测,防止在堆栈损坏时无限遍历
  • 地址规范化,不同 CPU 架构的地址可能有特殊标记,需要规范化处理


运行时异常捕获

运行时异常包括 NSException 和 C++ 异常,通常由编程错误引起。我们需要通过设置异常处理器来捕获这些未处理的异常。


NSException 异常捕获

iOS 需要通过设置 NSUncaughtExceptionHandler来捕获未捕获的 NSException


// 在设置exception handler之前,先保存之前的设置
NSUncaughtExceptionHandler *previous_uncaught_exceptionhandler = NSGetUncaughtExceptionHandler();
// 设置我们的exception handler
NSSetUncaughtExceptionHandler(&handle_uncaught_exception);


Objective-C代码抛出异常,且未被 @catch块捕获时,Objective-C 运行时会调用我们设置的异常处理器。在处理完 NSException异常后,还需要主动调用 previous_uncaught_exceptionhandler,以便其他异常处理器能够正确处理异常。


注意在异常监控场景中,通常需要在 handler 中收集完崩溃信息后,主动调用 abort() 来终止程序,确保程序不会在异常状态下继续运行。


在捕获到 NSException 异常之后,一般通过以下方式获取 Objective-C 的调用栈信息。


// NSException 提供了 callStackReturnAddresses
NSArray* addresses = [exception callStackReturnAddresses];


通过 [NSException callStackReturnAddresses] 获取到 return address 之后,还需要进一步处理,如:过滤掉无效地址等。


C++ 异常捕获

通过设置 C++ terminate handler 可以捕获未处理的 C 异常。当 C 异常未被捕获时,C++ 运行时会调用 std::terminate(),我们通过拦截这个调用来捕获异常。


// 保存原始 terminate handler
std::terminate_handler original_terminate_handler = std::get_terminate();
// 设置我们的 terminate handler
std::set_terminate(cpp_exception_terminate_handler);


当 C 代码抛出异常时,throw 语句会调用 __cxa_throw(),C 运行时会查找匹配的 catch块,如果未找到异常会继续向上传播。当异常未被捕获时:


  1. C++ 运行时会调用 std::terminate()
  2. std::terminate()会调用已注册的 terminate handler
  3. 我们设置的 cpp_exception_terminate_handler会被调用

1767077150882_a3a959bb8e3941aabd2037392ccd56d5.png

在我们的 terminate handler 中处理完异常后,还需要调用原始的 terminate handler,以便其他异常处理器能正确处理异常。


应用层异常捕获

应用层异常包括业务逻辑异常和性能问题,需要应用层主动监控和检测。主要包括主线程死锁检测和僵尸对象检测。


主线程死锁检测

主线程死锁(Deadlock)是 iOS 开发中一种严重的运行时问题,会导致 App 界面完全卡死(无响应),最终通常会被系统的看门狗(Watchdog)强制终止。


针对这类问题,一种可行的方式是通过“看门狗”机制检测主线程死锁:

1767077268985_da9a7c8d96f548e484f073ea33a92587.png

  1. 监控线程:独立的监控线程,定期检查主线程状态
  2. 心跳机制:向主线程发送“心跳”任务,检查是否及时响应
  3. 死锁判定:如果主队列在指定时间内未响应,则判定为死锁


需要注意

  • 误报风险:如果主线程有长时间运行的任务,可能产生误报
  • 超时时间:需要根据应用实际情况,调整超时时间,避免误报


僵尸对象检测

iOS 僵尸对象(Zombie Object)是 iOS 开发中导致应用崩溃(Crash)最常见的内存问题之一。僵尸对象是指已经被释放(dealloc)的内存块,但对应的指针仍然指向这块内存,并且代码试图通过这个指针去访问它(发送消息)。访问僵尸对象可能会导致崩溃,通常表现为 EXC_BAD_ACCESS崩溃。


  • 这是一个内存访问错误,意味着你试图访问一块你无法访问或无效的内存。
  • 因为这块内存可能已经被系统回收并分配给了其他对象,或者变成了一块杂乱的数据区域,所以访问结果是不可预知的。


产生僵尸对象的原因主要有以下几点:

  • unsafe_unretained 或 assign 指针如果一个属性被修饰为 assign(修饰对象时)或 unsafe_unretained,当对象被释放后,指针不会自动置为 nil(变成悬垂指针)。此时再次访问就会变成僵尸对象访问
  • 多线程竞争线程 A 刚刚释放了对象,但线程 B 几乎同时在尝试访问该对象
  • CoreFoundation 与 ARC 的桥接不当在使用 __bridge__bridge_transfer等转换时,所有权管理混乱导致对象过早释放
  • Block 或 Delegate 循环引用某些老旧代码中 Delegate 依然使用 assign 修饰


僵尸对象检测的主要思路是:

  1. hook NSObjectNSProxydealloc 方法
  2. 在对象释放时,计算对象的 hash,然后记录 class 信息
  3. 检测是否为 NSException,如果是,则保存异常详情
  4. 各类异常发生时,读取保存的异常详情

1767077403926_ca55853298be46c687e9112d0cb1ffe0.png

  • 为了降低 CPU 和内存占用,僵尸对象的记录上限是 0x8000个,即:32768
  • 计算哈希时,通过 ((uintptr_t)object >> (sizeof(uintptr_t) - 1)) & 0x7FFF计算


这是一种设计权衡的结果。因为这种检测方式并不是非常准确,不能捕获所有僵尸对象。因为 hash 的计算会产生一定的碰撞,导致对象被覆盖,可能会产生误报或错误的类型。


运行时符号化

在异常监控系统中,除了需要检测和记录异常类型(如僵尸对象访问、主线程死锁等),还需要处理异常发生时的堆栈信息。堆栈信息通常以内存地址的形式存在,这些地址对于开发者来说是不可读的。为了能够快速定位问题,我们需要将这些内存地址转换为可读的函数名、文件名和行号信息,这个过程就是符号化(Symbolication)。


符号化一般分为两种:

  • 运行时符号化:使用 dladdr() 获取符号信息(函数名、镜像名等)
  • 完整符号化:使用 dSYM文件获取文件名和行号


运行时符号化只能获取公开符号。


我们主要讨论 iOS 平台上如何在运行时符号化。iOS 平台主要通过 dladdr()进行运行时符号化,通过 dladdr()可以获取到如下信息:

  • imageAddress:image 镜像基址
  • imageName:image 镜像路径
  • symbolAddress:符号地址
  • symbolName:符号名称


由于在符号化时,我们需要的是调用指令的地址,但堆栈上存储的是返回地址,因此需要对地址调整:


函数调用过程:
1. 调用指令:call function_name  (地址: 0x1000)
2. 函数执行:function_name()     (地址: 0x2000)
3. 返回地址:0x1001              (存储在堆栈上)
堆栈上存储的是返回地址(0x1001),
但我们需要的是调用指令的地址(0x1000),所以需要减 1。


不同 CPU 架构对应的地址调整有所不同,以 ARM64 为例:


uintptr_t address = (return_address &~ 3UL) - 1;


运行时符号化的完整流程如下图所示:

1767077658715_c8a8709da5a9478a97bc9b64984dd785.png


异步安全

除了以上内容外,在处理 iOS 平台异常捕获时,我们还需要关注异步安全。


在 Unix 信号处理函数,或 Mach 异常处理中,只能使用异步安全函数,主要是因为:

  • 崩溃时系统状态不稳定
  • 可能持有锁,调用非异步安全函数可能导致死锁
  • 堆可能已损坏,此时分配内存可能会失败


一般情况下,malloc()free()NSLog()printf()Objective-C 方法的调用,任何可能分配内存的函数都不允许在处理异常过程中调用。


结语和展望


本文主要介绍了当下主流的 iOS 异常监控方案,和基于 KSCrash 的异常监控实现细节,包括 Mach、Unix 信号、NSException 等异常类型的捕获的处理等。异常监控能力还在持续进化,后续还有不少可以优化和提升的点,如支持实时上传和崩溃回调,支持 App 日志记录,dump 寄存器地址附近内存等。目前这套方案已经应用在阿里云用户体验监控 RUM iOS SDK 中,您可以参考接入文档[1]体验使用。阿里云 RUM SDK 当前也支持 Android 、 HarmonyOS 、Web 等平台下异常监控能力。相关问题可以加入“RUM用户体验监控支持群”(钉钉群号:67370002064进行咨询。


相关链接:

[1] 接入文档

https://help.aliyun.com/zh/arms/user-experience-monitoring/monitor-ios-apps/


点击阅读原文查看产品详情。

相关实践学习
通过轻量消息队列(原MNS)主题HTTP订阅+ARMS实现自定义数据多渠道告警
本场景将自定义告警信息同时分发至多个通知渠道的需求,例如短信、电子邮件及钉钉群组等。通过采用轻量消息队列(原 MNS)的主题模型的HTTP订阅方式,并结合应用实时监控服务提供的自定义集成能力,使得您能够以简便的配置方式实现上述多渠道同步通知的功能。
相关文章
|
20天前
|
数据采集 人工智能 运维
AgentRun 实战:快速构建 AI 舆情实时分析专家
搭建“舆情分析专家”,函数计算 AgentRun 快速实现从数据采集到报告生成全自动化 Agent。
710 56
|
12天前
|
人工智能 测试技术 开发者
AI Coding后端开发实战:解锁AI辅助编程新范式
本文系统阐述了AI时代开发者如何高效协作AI Coding工具,强调破除认知误区、构建个人上下文管理体系,并精准判断AI输出质量。通过实战流程与案例,助力开发者实现从编码到架构思维的跃迁,成为人机协同的“超级开发者”。
879 84
|
21天前
|
存储 缓存 NoSQL
即将开源 | 阿里云 Tair KVCache Manager:企业级全局 KVCache 管理服务的架构设计与实现
阿里云 Tair 联合团队推出企业级全局 KVCache 管理服务 Tair KVCache Manager,通过中心化元数据管理与多后端存储池化,实现 KVCache 的跨实例共享与智能调度。该服务解耦算力与存储,支持弹性伸缩、多租户隔离及高可用保障,显著提升缓存命中率与资源利用率,重构大模型推理成本模型,支撑智能体时代的规模化推理需求。
|
1月前
|
机器学习/深度学习 缓存 物联网
打造社交APP人物动漫化:通义万相wan2.x训练优化指南
本项目基于通义万相AIGC模型,为社交APP打造“真人变身跳舞动漫仙女”特效视频生成功能。通过LoRA微调与全量训练结合,并引入Sage Attention、TeaCache、xDIT并行等优化技术,实现高质量、高效率的动漫风格视频生成,兼顾视觉效果与落地成本,最终优选性价比最高的wan2.1 lora模型用于生产部署。(239字)
944 102
|
12天前
|
存储 缓存 调度
阿里云Tair KVCache仿真分析:高精度的计算和缓存模拟设计与实现
在大模型推理迈向“智能体时代”的今天,KVCache 已从性能优化手段升级为系统级基础设施,“显存内缓存”模式在长上下文、多轮交互等场景下难以为继,而“以存代算”的多级 KVCache 架构虽突破了容量瓶颈,却引入了一个由模型结构、硬件平台、推理引擎与缓存策略等因素交织而成的高维配置空间。如何在满足 SLO(如延迟、吞吐等服务等级目标)的前提下,找到“时延–吞吐–成本”的最优平衡点,成为规模化部署的核心挑战。
256 38
阿里云Tair KVCache仿真分析:高精度的计算和缓存模拟设计与实现
|
16天前
|
人工智能 安全 API
Nacos 安全护栏:MCP、Agent、配置全维防护,重塑 AI Registry 安全边界
Nacos安全新标杆:精细鉴权、无感灰度、全量审计!
384 65
|
1月前
|
存储 缓存 NoSQL
阿里云 Tair 联手 SGLang 共建 HiCache,构建面向“智能体式推理”的缓存新范式
针对智能体式推理对KVCache的挑战,阿里云Tair KVCache团队联合SGLang社区推出HiCache技术,通过多级存储卸载与全局共享机制,实现缓存命中率翻倍、TTFT降低56%、QPS提升2倍,构建面向长上下文、高并发、多智能体协作的下一代推理缓存基础设施。
319 27
阿里云 Tair 联手 SGLang 共建 HiCache,构建面向“智能体式推理”的缓存新范式
|
14天前
|
Kubernetes 应用服务中间件 API
应对 Nginx Ingress 退役,是时候理清这些易混淆的概念了
本文希望提供一种更简单的方式,来理解这些容易混淆的技术概念:Nginx、Ingress、Ingress Controller、Ingress API、Nginx Ingress、Higress、Gateway API。
354 35
|
12天前
|
设计模式 XML NoSQL
从HITL(Human In The Loop) 实践出发看Agent与设计模式的对跖点
本文探讨在ReactAgent中引入HITL(人机回路)机制的实践方案,分析传统多轮对话的局限性,提出通过交互设计、对话挂起与工具化实现真正的人机协同,并揭示Agent演进背后与工程设计模式(如钩子、适配器、工厂模式等)的深层关联,展望未来Agent的进化方向。
322 44
从HITL(Human In The Loop) 实践出发看Agent与设计模式的对跖点
|
1月前
|
存储 人工智能 运维
一行代码实现智能异常检测:UModel PaaS API 架构设计与最佳实践
阿里云 UModel PaaS API 发布:通过 Table + Object 双层抽象,屏蔽存储差异、自动处理字段映射与过滤条件,让每一个实体都成为一个‘可调用的对象’,真正实现‘以实体为中心’的智能可观测。
834 119

热门文章

最新文章