ios异常监控方案实现

简介: 本文介绍SDK异常采集模块的架构设计与实现原理,涵盖Mach异常、Unix信号、NSException、C++异常及应用层异常(如主线程死锁、僵尸对象)的捕获机制。通过监控器管理层统一调度,结合多层异常捕获、上下文构建与报告生成,实现全面崩溃监控。深入解析堆栈遍历、符号化、异步安全等关键技术,确保稳定高效地收集崩溃信息。

架构设计
异常采集模块,是我们 SDK 数据采集层一个模块的具体实现,如下:
• 监控器管理层:统一管理所有监控器,提供统一的异常处理入口
• 异常捕获层:多种监控器,分别捕获不同类型的异常和状态信息
• 异常处理层:构建崩溃上下文,收集堆栈、符号、内存等信息
• 报告生成层:将崩溃上下文转换为 JSON 格式报告
接下来,我们介绍各种类型异常的捕获原理,以及对应监控器是如何实现的。
系统层异常捕获
系统层异常包括 Mach 异常和 Unix 信号,是应用层异常监控的主要捕获点。我们需要同时捕获这两种异常,确保不遗漏任何底层异常。
Mach 异常捕获
Mach 异常是 macOS/iOS 系统最底层的异常机制,源于 Mach 微内核架构。Mach 是 macOS/iOS 内核的基础,提供了进程间通信(IPC)和异常处理的核心机制。硬件异常(CPU 异常)会被 Mach 内核捕获并转换为 Mach 异常消息。Mach 异常与特定线程关联,可以精确捕获异常发生的线程。Mach 异常通过 Mach 消息异步传递异常信息,需要使用 Mach 端口(Mach Port)作为异常处理的通信通道。
监控 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);
    主备线程之间的关系如下:
    • 备用处理线程在创建后会立即挂起
    • 主线程在处理异常之前会通过 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
    );
    • 挂起所有线程:确保状态一致性
    • 标记已捕获异常:进入异步安全模式
    • 激活备用处理线程
    • 读取异常线程的机器状态
    • 初始化堆栈游标
    • 构建异常上下文
    • 异常类型
    • 机器状态
    • 地址信息等
    • 堆栈游标
    • 统一异常处理:不同异常类型统一处理
    • 恢复线程
    Unix 信号捕获
    作为 Mach 异常捕获的补充,也需要直接捕获 Unix 信号,确保在 Mach 异常处理失败时,仍能捕获到崩溃。Unix 信号的捕获处理涉及:
    为了能够通过 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 架构为例,堆栈布局如下:
    为了还原崩溃发生时的调用栈,我们需要对堆栈帧进行遍历。堆栈帧遍历的核心原理是通过帧指针链向上遍历:
  5. 第 1 帧:从 PC 寄存器获取当前崩溃点
  6. 第 2 帧:从 LR 寄存器获取调用者
  7. 第 3 帧及以后:通过帧指针链从堆栈内存中读取
    堆栈帧遍历的完整流程如下:
    在堆栈遍历过程中,有下面几个关键点需要注意:
    • 在遍历堆栈时,必须安全地访问内存,防止访问无效内存导致崩溃
    • 堆栈溢出检测,防止在堆栈损坏时无限遍历
    • 地址规范化,不同 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块,如果未找到异常会继续向上传播。当异常未被捕获时:
  8. C++ 运行时会调用 std::terminate()
  9. std::terminate()会调用已注册的 terminate handler
  10. 我们设置的 cpp_exception_terminate_handler会被调用
    在我们的 terminate handler 中处理完异常后,还需要调用原始的 terminate handler,以便其他异常处理器能正确处理异常。
    应用层异常捕获
    应用层异常包括业务逻辑异常和性能问题,需要应用层主动监控和检测。主要包括主线程死锁检测和僵尸对象检测。
    主线程死锁检测
    主线程死锁(Deadlock)是 iOS 开发中一种严重的运行时问题,会导致 App 界面完全卡死(无响应),最终通常会被系统的看门狗(Watchdog)强制终止。
    针对这类问题,一种可行的方式是通过“看门狗”机制检测主线程死锁:
  11. 监控线程:独立的监控线程,定期检查主线程状态
  12. 心跳机制:向主线程发送“心跳”任务,检查是否及时响应
  13. 死锁判定:如果主队列在指定时间内未响应,则判定为死锁
    需要注意:
    • 误报风险:如果主线程有长时间运行的任务,可能产生误报
    • 超时时间:需要根据应用实际情况,调整超时时间,避免误报
    僵尸对象检测
    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 修饰
    僵尸对象检测的主要思路是:
  14. hook NSObject 和 NSProxy 的 dealloc 方法
  15. 在对象释放时,计算对象的 hash,然后记录 class 信息
  16. 检测是否为 NSException,如果是,则保存异常详情
  17. 各类异常发生时,读取保存的异常详情
    • 为了降低 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:符号名称
    由于在符号化时,我们需要的是调用指令的地址,但堆栈上存储的是返回地址,因此需要对地址调整:
    函数调用过程:
  18. 调用指令:call function_name (地址: 0x1000)
  19. 函数执行:function_name() (地址: 0x2000)
  20. 返回地址:0x1001 (存储在堆栈上)
    堆栈上存储的是返回地址(0x1001),
    但我们需要的是调用指令的地址(0x1000),所以需要减 1。
    不同 CPU 架构对应的地址调整有所不同,以 ARM64 为例:
    uintptr_t address = (return_address &~ 3UL) - 1;
    运行时符号化的完整流程如下图所示:
    异步安全
    除了以上内容外,在处理 iOS 平台异常捕获时,我们还需要关注异步安全。
    在 Unix 信号处理函数,或 Mach 异常处理中,只能使用异步安全函数,主要是因为:
    • 崩溃时系统状态不稳定
    • 可能持有锁,调用非异步安全函数可能导致死锁
    • 堆可能已损坏,此时分配内存可能会失败
    一般情况下,malloc()、free()、NSLog()、printf(),Objective-C 方法的调用,任何可能分配内存的函数都不允许在处理异常过程中调用。
相关文章
|
2天前
|
云安全 人工智能 算法
以“AI对抗AI”,阿里云验证码进入2.0时代
三层立体防护,用大模型打赢人机攻防战
1292 1
|
9天前
|
编解码 人工智能 自然语言处理
⚽阿里云百炼通义万相 2.6 视频生成玩法手册
通义万相Wan 2.6是全球首个支持角色扮演的AI视频生成模型,可基于参考视频形象与音色生成多角色合拍、多镜头叙事的15秒长视频,实现声画同步、智能分镜,适用于影视创作、营销展示等场景。
697 4
|
2天前
|
机器学习/深度学习 安全 API
MAI-UI 开源:通用 GUI 智能体基座登顶 SOTA!
MAI-UI是通义实验室推出的全尺寸GUI智能体基座模型,原生集成用户交互、MCP工具调用与端云协同能力。支持跨App操作、模糊语义理解与主动提问澄清,通过大规模在线强化学习实现复杂任务自动化,在出行、办公等高频场景中表现卓越,已登顶ScreenSpot-Pro、MobileWorld等多项SOTA评测。
537 2
|
3天前
|
人工智能 Rust 运维
这个神器让你白嫖ClaudeOpus 4.5,Gemini 3!还能接Claude Code等任意平台
加我进AI讨论学习群,公众号右下角“联系方式”文末有老金的 开源知识库地址·全免费
|
2天前
|
存储 弹性计算 安全
阿里云服务器4核8G收费标准和活动价格参考:u2a实例898.20元起,计算型c9a3459.05元起
现在租用阿里云服务器4核8G价格是多少?具体价格及配置详情如下:云服务器ECS通用算力型u2a实例,配备4核8G配置、1M带宽及40G ESSD云盘(作为系统盘),其活动价格为898.20元/1年起;此外,ECS计算型c9a实例4核8G配置搭配20G ESSD云盘,活动价格为3459.05元/1年起。在阿里云的当前活动中,4核8G云服务器提供了多种实例规格供用户选择,不同实例规格及带宽的组合将带来不同的优惠价格。本文为大家解析阿里云服务器4核8G配置的实例规格收费标准与最新活动价格情况,以供参考。
232 150
|
9天前
|
机器学习/深度学习 人工智能 前端开发
构建AI智能体:七十、小树成林,聚沙成塔:随机森林与大模型的协同进化
随机森林是一种基于决策树的集成学习算法,通过构建多棵决策树并结合它们的预测结果来提高准确性和稳定性。其核心思想包括两个随机性:Bootstrap采样(每棵树使用不同的训练子集)和特征随机选择(每棵树分裂时只考虑部分特征)。这种方法能有效处理大规模高维数据,避免过拟合,并评估特征重要性。随机森林的超参数如树的数量、最大深度等可通过网格搜索优化。该算法兼具强大预测能力和工程化优势,是机器学习中的常用基础模型。
355 164