博客 / 詳情

返回

從原理到落地:阿里雲 RUM 如何賦能開發者構建穩定可靠的 iOS 應用?

作者:高玉龍(元泊)

背景介紹

App 上線後,作為開發同學,最怕出現的情況就是應用崩潰了。但是,線下測試好好的 App,為什麼上線後就發生崩潰了呢?這些崩潰日誌信息是怎麼採集的?

先看看幾個常見的編寫代碼時的疏忽,是如何讓應用崩潰的。

  • 數組越界:在取數據索引時越界,App 會發生崩潰。
  • 多線程問題:在子線程中進行 UI 更新可能會發生崩潰。多個線程進行數據的讀取操作,因為處理時機不一致,比如有一個線程在置空數據的同時另一個線程在讀取這個數據,可能會出現崩潰情況。
  • 主線程無響應:如果主線程超過系統規定的時間無響應,就會被 Watchdog 殺掉。
  • 野指針:指針指向一個已刪除的對象訪問內存區域時,會出現野指針崩潰。

為了解決這個問題,阿里雲可觀測研發團隊進行了一些 iOS 異常監控方向的探索。

iOS 異常體系介紹

iOS 異常體系採用分層架構,從底層硬件到上層應用,異常在不同層次被捕獲和處理。理解異常體系的分層結構,有助於我們更好地設計和實現異常監控方案。iOS 異常體系主要分為以下幾個層次:

1. 硬件層異常

  • CPU 異常:由硬件直接產生的異常,如非法指令、內存訪問錯誤等
  • 這是最底層的異常來源,所有其他異常最終都源於此

2. 系統層異常

  • Mach 異常: macOS/iOS 系統最底層的異常機制,源於 Mach 微內核架構
  • Unix 信號: Mach 異常會被轉換為 Unix 信號,如 SIGSEGV、SIGABRT 等
  • 系統層異常是應用層異常監控的主要捕獲點

3. 運行時層異常

  • NSException: Objective-C 運行時異常,如數組越界、空指針等
  • C++ 異常: C++ 代碼拋出的異常,通過 std::terminate() 處理
  • 運行時層異常通常由編程錯誤引起

4. 應用層異常

  • 業務邏輯異常:應用自定義的異常和錯誤
  • 性能異常:主線程死鎖、內存泄漏等
  • 殭屍對象訪問:訪問已釋放對象導致的異常

異常體系的分層關係如下圖所示:

image

異常捕獲的層次關係:

  1. 硬件異常 → Mach 異常: CPU 異常被 Mach 內核捕獲,轉換為 Mach 異常消息
  2. Mach 異常 → Unix 信號: Mach 異常處理機制會將異常轉換為對應的 Unix 信號
  3. 運行時異常: NSException 和 C++ 異常在運行時層被捕獲,如果未處理會觸發系統層異常
  4. 應用層異常: 業務異常和性能問題需要應用層主動監控和檢測

異常監控策略:

  • 系統層監控: 通過 Mach 異常和 Unix 信號捕獲,可以捕獲所有底層異常
  • 運行時層監控: 通過設置異常處理器(NSUncaughtExceptionHandler、terminate handler)捕獲運行時異常
  • 應用層監控: 通過主動檢測機制(死鎖檢測、殭屍對象檢測)發現潛在問題

理解這個分層體系,有助於我們:

  • 選擇合適的異常捕獲機制
  • 理解不同異常類型的來源和處理方式
  • 設計完整的異常監控方案

主流異常監控方案

在 iOS 端側異常監控領域,PLCrashReporter 與 KSCrash 是最常用的兩個內核庫。兩者都是開源、生產可用,且被多家平台化產品或 SDK 採用作為底層能力。

image

基於以上對比分析,KSCrash 相比其他崩潰監控框架的核心優勢在於:

  • 異常類型監測支持更全面(唯一同時支持 C++ 異常、死鎖檢測、殭屍對象檢測的開源框架)
  • 異步安全設計(崩潰處理完全異步安全,雙重異常處理線程確保可靠性)
  • 技術優勢明顯(堆棧遊標抽象、內存內省、模塊化架構等)

基於以上優勢,我們選擇基於 KSCrash 作為崩潰異常監控的核心方案。

異常監控方案實現

架構設計

異常採集模塊,是我們 SDK 數據採集層一個模塊的具體實現,如下:

image

  • 監控器管理層:統一管理所有監控器,提供統一的異常處理入口
  • 異常捕獲層:多種監控器,分別捕獲不同類型的異常和狀態信息
  • 異常處理層:構建崩潰上下文,收集堆棧、符號、內存等信息
  • 報告生成層:將崩潰上下文轉換為 JSON 格式報告

接下來,我們介紹各種類型異常的捕獲原理,以及對應監控器是如何實現的。

系統層異常捕獲

系統層異常包括 Mach 異常和 Unix 信號,是應用層異常監控的主要捕獲點。我們需要同時捕獲這兩種異常,確保不遺漏任何底層異常。

Mach 異常捕獲

Mach 異常是 macOS/iOS 系統最底層的異常機制,源於 Mach 微內核架構。Mach 是 macOS/iOS 內核的基礎,提供了進程間通信(IPC)和異常處理的核心機制。硬件異常(CPU 異常)會被 Mach 內核捕獲並轉換為 Mach 異常消息。Mach 異常與特定線程關聯,可以精確捕獲異常發生的線程。Mach 異常通過 Mach 消息異步傳遞異常信息,需要使用 Mach 端口(Mach Port)作為異常處理的通信通道。

image

監控 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);

主備線程之間的關係如下:

image

  • 備用處理線程在創建後會立即掛起
  • 主線程在處理異常之前會通過 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
);

image

  • 掛起所有線程:確保狀態一致性
  • 標記已捕獲異常:進入異步安全模式
  • 激活備用處理線程
  • 讀取異常線程的機器狀態
  • 初始化堆棧遊標
  • 構建異常上下文

    • 異常類型
    • 機器狀態
    • 地址信息等
    • 堆棧遊標
  • 統一異常處理:不同異常類型統一處理
  • 恢復線程

Unix 信號捕獲

作為 Mach 異常捕獲的補充,也需要直接捕獲 Unix 信號,確保在 Mach 異常處理失敗時,仍能捕獲到崩潰。Unix 信號的捕獲處理涉及:

image

為了能夠通過 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 架構為例,堆棧佈局如下:

image

為了還原崩潰發生時的調用棧,我們需要對堆棧幀進行遍歷。堆棧幀遍歷的核心原理是通過幀指針鏈向上遍歷:

  1. 第 1 幀:從 PC 寄存器獲取當前崩潰點
  2. 第 2 幀:從 LR 寄存器獲取調用者
  3. 第 3 幀及以後:通過幀指針鏈從堆棧內存中讀取

堆棧幀遍歷的完整流程如下:

image

在堆棧遍歷過程中,有下面幾個關鍵點需要注意:

  • 在遍歷堆棧時,必須安全地訪問內存,防止訪問無效內存導致崩潰
  • 堆棧溢出檢測,防止在堆棧損壞時無限遍歷
  • 地址規範化,不同 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 會被調用

image

在我們的 terminate handler 中處理完異常後,還需要調用原始的 terminate handler,以便其他異常處理器能正確處理異常。

應用層異常捕獲

應用層異常包括業務邏輯異常和性能問題,需要應用層主動監控和檢測。主要包括主線程死鎖檢測和殭屍對象檢測。

主線程死鎖檢測

主線程死鎖(Deadlock)是 iOS 開發中一種嚴重的運行時問題,會導致 App 界面完全卡死(無響應),最終通常會被系統的看門狗(Watchdog)強制終止。

針對這類問題,一種可行的方式是通過“看門狗”機制檢測主線程死鎖:

image

  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 NSObject 和 NSProxy 的 dealloc 方法
  2. 在對象釋放時,計算對象的 hash,然後記錄 class 信息
  3. 檢測是否為 NSException,如果是,則保存異常詳情
  4. 各類異常發生時,讀取保存的異常詳情

image

  • 為了降低 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;

運行時符號化的完整流程如下圖所示:

image

異步安全

除了以上內容外,在處理 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/mo...

點擊此處查看產品詳情。

user avatar
0 位用戶收藏了這個故事!

發佈 評論

Some HTML is okay.