天天看點

漫談 iOS Crash 收集架構

為了能夠第一時間發現程式問題,應用程式需要實作自己的崩潰日志收集服務,成熟的開源項目很多,如 KSCrash,plcrashreporter,CrashKit 等。追求友善省心,對于保密性要求不高的程式來說,也可以選擇各種一條龍 Crash 統計産品,如 Crashlytics,Hockeyapp ,友盟,Bugly 等等。

  • 是否內建越多的 Crash 日志收集服務就越保險?
  • 自己收集的 Crash 日志和系統生成的 Crash 日志有分歧,應該相信誰?
  • 為什麼有大量 Crash 日志顯示崩在 main 函數裡,但函數棧中卻沒有一行自己的代碼?
  • 野指針類的 Crash 難定位,有何妙招來應對?

想解釋清這些問題,必須從 Mach 異常說起。

Mach 異常與 Unix 信号

iOS 系統自帶的 Apple’s Crash Reporter 記錄在裝置中的 Crash 日志,Exception Type 項通常會包含兩個元素: Mach 異常 和 Unix 信号。

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

Mach 異常是什麼?它又是如何與 Unix 信号建立聯系的?

Mach 是一個 XNU 的微核心核心,Mach 異常是指最底層的核心級異常,被定義在

<mach/exception_types.h>

下 。每個 thread,task,host 都有一個異常端口數組,Mach 的部分 API 暴露給了使用者态,使用者态的開發者可以直接通過 Mach API 設定 thread,task,host 的異常端口,來捕獲 Mach 異常,抓取 Crash 事件。

所有 Mach 異常都在 host 層被

ux_exception

轉換為相應的 Unix 信号,并通過

threadsignal

将信号投遞到出錯的線程。iOS 中的 POSIX API 就是通過 Mach 之上的 BSD 層實作的。

漫談 iOS Crash 收集架構

是以,

EXC_BAD_ACCESS (SIGSEGV)

表示的意思是:Mach 層的

EXC_BAD_ACCESS

異常,在 host 層被轉換成 SIGSEGV 信号投遞到出錯的線程。既然最終以信号的方式投遞到出錯的線程,那麼就可以通過注冊 signalHandler 來捕獲信号:

signal(SIGSEGV,signalHandler);
           

捕獲 Mach 異常或者 Unix 信号都可以抓到 crash 事件,這兩種方式哪個更好呢?優選 Mach 異常,因為 Mach 異常處理會先于 Unix 信号處理發生,如果 Mach 異常的 handler 讓程式 exit 了,那麼 Unix 信号就永遠不會到達這個程序了。轉換 Unix 信号是為了相容更為流行的 POSIX 标準 (SUS 規範),這樣不必了解 Mach 核心也可以通過 Unix 信号的方式來相容開發。

小貼士:

因為硬體産生的信号 (通過 CPU 陷阱) 被 Mach 層捕獲,然後才轉換為對應的 Unix 信号;蘋果為了統一機制,于是作業系統和使用者産生的信号 (通過調用

kill

pthread_kill

) 也首先沉下來被轉換為 Mach 異常,再轉換為 Unix 信号。

Crash 收集的實作思路

正如上述所說,可以通過捕獲 Mach 異常、或 Unix 信号兩種方式來抓取 crash 事件,于是總結起來實作方案就一共有 3 種。

1)Mach 異常方式

漫談 iOS Crash 收集架構

2)Unix 信号方式

signal(SIGSEGV,signalHandler);
           

3)Mach 異常 +Unix 信号方式

Github 上多數開源項目都采用的這種方式,即使在優選捕獲 Mach 異常的情況下,也放棄捕獲

EXC_CRASH

異常,而選擇捕獲與之對應的 SIGABRT 信号。著名開源項目 plcrashreporter 在代碼注釋中給出了詳細的解釋:

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

.

另外,需要重點說明的是:對于應用級異常 NSException,還需要特殊處理。

你是否見過崩潰在 main 函數的 crash 日志,但是函數棧裡面沒有你的代碼:

Thread 0 Crashed:
0 libsystem_kernel.dylib 0x3a61757c __semwait_signal_nocancel + 0x18
1 libsystem_c.dylib 0x3a592a7c nanosleep$NOCANCEL + 0xa0
2 libsystem_c.dylib 0x3a5adede usleep$NOCANCEL + 0x2e
3 libsystem_c.dylib 0x3a5c7fe0 abort + 0x50
4 libc++abi.dylib 0x398f6cd2 abort_message + 0x46
5 libc++abi.dylib 0x3990f6e0 default_terminate_handler() + 0xf8
6 libobjc.A.dylib 0x3a054f62 _objc_terminate() + 0xbe
7 libc++abi.dylib 0x3990d1c4 std::__terminate(void (*)()) + 0x4c
8 libc++abi.dylib 0x3990cd28 __cxa_rethrow + 0x60
9 libobjc.A.dylib 0x3a054e12 objc_exception_rethrow + 0x26
10 CoreFoundation 0x2f7d7f30 CFRunLoopRunSpecific + 0x27c
11 CoreFoundation 0x2f7d7c9e CFRunLoopRunInMode + 0x66
12 GraphicsServices 0x346dd65e GSEventRunModal + 0x86
13 UIKit 0x32124148 UIApplicationMain + 0x46c
14 XXXXXX 0x0003b1f2 main + 0x1f2
15 libdyld.dylib 0x3a561ab4 start + 0x0
           

可以看出是因為某個 NSException 導緻程式 Crash 的,隻有拿到這個 NSException,擷取它的

reason

name

callStackSymbols

資訊才能确定出問題的程式位置。

/* NSException Class Reference */
@property(readonly, copy) NSString *name;
@property(readonly, copy) NSString *reason;
@property(readonly, copy) NSArray *callStackSymbols;
@property(readonly, copy) NSArray *callStackReturnAddresses;
           

方法很簡單,可通過注冊

NSUncaughtExceptionHandler

捕獲異常資訊:

static void my_uncaught_exception_handler (NSException *exception) {
// 這裡可以取到 NSException 資訊
}
NSSetUncaughtExceptionHandler(&my_uncaught_exception_handler);
           

将拿到的 NSException 細節寫入 Crash 日志,精準的定位出錯程式位置:

Application Specific Information:
*** Terminating app due to uncaught exception 'NSUnknownKeyException', reason: '[<__NSDictionaryI 0x14554d00> setValue:forUndefinedKey:]: this class is not key value coding-compliant for the key key.'
Last Exception Backtrace:
0 CoreFoundation 0x2f8a3f7e __exceptionPreprocess + 0x7e
1 libobjc.A.dylib 0x3a054cc objc_exception_throw + 0x22
2 CoreFoundation 0x2f8a3c94 -[NSException raise] + 0x4
3 Foundation 0x301e8f1e -[NSObject(NSKeyValueCoding) setValue:forKey:] + 0xc6
4 DemoCrash 0x00085306 -[ViewController crashMethod] + 0x6e
5 DemoCrash 0x00084ecc main + 0x1cc
6 DemoCrash 0x00084cf8 start + 0x24
           

那麼,是不是收到了大量 crash 在 main 函數卻沒有 NSException 資訊的日志,就代表自己內建的 Crash 日志收集服務沒有注冊 NSUncaughtExceptionHandler 呢?不一定,還有另外一種可能,就是被同時存在的其他 Crash 日志收集服務給坑了。

多個 Crash 日志收集服務共存的坑

是的,在自己的程式裡內建多個 Crash 日志收集服務實在不是明智之舉。通常情況下,第三方功能性 SDK 都會內建一個 Crash 收集服務,以及時發現自己 SDK 的問題。當各家的服務都以保證自己的 Crash 統計正确完整為目的時,難免出現時序手腳,強行覆寫等等的惡意競争,總會有人默默被坑。

1)拒絕傳遞 UncaughtExceptionHandler

如果同時有多方通過 NSSetUncaughtExceptionHandler 注冊異常處理程式,和平的作法是:後注冊者通過 NSGetUncaughtExceptionHandler 将先前别人注冊的 handler 取出并備份,在自己 handler 處理完後自覺把别人的 handler 注冊回去,規規矩矩的傳遞。不傳遞強行覆寫的後果是,在其之前注冊過的日志收集服務寫出的 Crash 日志就會因為取不到 NSException 而丢失

Last Exception Backtrace

等資訊。(P.S. iOS 系統自帶的 Crash Reporter 不受影響)

在開發測試階段,可以利用 fishhook 架構去 hook

NSSetUncaughtExceptionHandler

方法,這樣就可以清晰的看到 handler 的傳遞流程斷在哪裡,快速定位污染環境者。不推薦利用調試器添加符号斷點來檢查,原因是一些 Crash 收集架構在調試狀态下是不工作的。

檢測代碼示例:

static NSUncaughtExceptionHandler *g_vaildUncaughtExceptionHandler;
static void (*ori_NSSetUncaughtExceptionHandler)( NSUncaughtExceptionHandler * );
void my_NSSetUncaughtExceptionHandler( NSUncaughtExceptionHandler * handler)
{
g_vaildUncaughtExceptionHandler = NSGetUncaughtExceptionHandler();
if (g_vaildUncaughtExceptionHandler != NULL) {
NSLog(@"UncaughtExceptionHandler=%p",g_vaildUncaughtExceptionHandler);
}
ori_NSSetUncaughtExceptionHandler(handler);
NSLog(@"%@",[NSThread callStackSymbols]);
g_vaildUncaughtExceptionHandler = NSGetUncaughtExceptionHandler();
NSLog(@"UncaughtExceptionHandler=%p",g_vaildUncaughtExceptionHandler);
}
           

對于越獄插件注入應用程序内部,惡意覆寫 NSSetUncaughtExceptionHandler 的情況,應用程式本身處理起來比較弱勢,因為越獄環境下操作時序的玩法比較多權利比較大。

2)Mach 異常端口換出 + 信号處理 Handler 覆寫

和 NSSetUncaughtExceptionHandler 的情況類似,設定過的 Mach 異常端口和信号處理程式也有可能被幹掉,導緻無法捕獲 Crash 事件。

3)影響系統崩潰日志準确性

應用層參與收集 Crash 日志的服務方越多,越有可能影響 iOS 系統自帶的 Crash Reporter。由于程序内線程數組的變動,可能會導緻系統日志中線程的

Crashed

标簽标記錯位,可以搜尋

abort()

等關鍵字來複查系統日志的準确性。

若程式因 NSException 而 Crash,系統日志中的

Last Exception Backtrace

資訊是完整準确的,不會受應用層的胡來而影響,可作為排查問題的參考線索。

ObjC 野指針類的 Crash

收集 Crash 日志這個步驟沒有問題的情況下,還是有很多全系統棧的日志的情況,沒有自己一行代碼,分析起來十分棘手,ObjC 野指針類的 Crash 正是如此,這裡推薦幾篇好文章:

如何定位 Obj-C 野指針随機 Crash(一):先提高野指針 Crash 率

http://bugly.qq.com/blog/?p=200

如何定位 Obj-C 野指針随機 Crash(二):讓非必現 Crash 變成必現

http://bugly.qq.com/blog/?p=308

如何定位 Obj-C 野指針随機 Crash(三):加點黑科技讓 Crash 自報家門

http://bugly.qq.com/blog/?p=335

分析 objc_msgSend() 處崩潰的小技巧

http://www.sealiesoftware.com/blog/archive/2008/09/22/objc_explain_So_you_crashed_in_objc_msgSend.html

除此之外,在 Crash 日志中補充記錄一些額外資訊可以輔助定位,如切面标記線程出處、隊列出處,記錄使用者操作軌迹等等……

繼續閱讀