一、捕獲iOS Crash
1、設定異常斷點並執行
說明:設定Xcode異常斷點後執行程式,發生Crash時,斷點會定位到出錯的程式碼行,但僅適用於開發階段。線上APP的Crash還需要通過收集Crash機制來捕獲Crash並記錄在日誌中。
2、Mach異常 和 Unix訊號
- iOS Crash發生時,先產生Mach異常(最底層的核心級異常),然後Mach異常在host層被ux_exception轉換為相應的Unix訊號,並通過threadsignal將訊號投遞到出錯的執行緒。
- 在捕獲Crash事件時,優選Mach異常。因為Mach異常處理會先於Unix訊號處理髮生,如果Mach異常的handler讓程式exit了,那麼Unix訊號就永遠不會到達這個程式了。而轉換Unix訊號是為了相容更為流行的POSIX標準(SUS規範),這樣就不必瞭解Mach核心也可以通過Unix訊號的方式來相容開發。
- 在方案實現時,通過捕獲Mach異常+Unix訊號組合方式來捕獲Crash事件。在選擇具體方案時,可以選擇PLCrashReporter這樣優秀的開源專案,也可以選擇友盟、Bugly 這類完善的Crash上報和統計的產品(試專案需求而定)。
3、捕獲Crash
並不是所有的Crash都可以捕獲到NSException,如果捕獲不到,可以使用signal機制來捕獲Crash發生時的錯誤內容。
1) 可以捕獲的NSException,通過註冊NSUncaughtExceptionHandler捕獲異常資訊
1 2 3 4 5 6 7 8 |
//註冊異常處理函式 NSSetUncaughtExceptionHandler(&uncaught_exception_handler); //異常處理函式 static void uncaught_exception_handler (NSException *exception) { //可以取到 NSException 資訊 //... abort(); } |
說明: 使用Objective-C的異常處理是不能得到signal的。
2) 無法捕獲的NSException,利用Unix標準的signal機制,註冊SIGABRT, SIGBUS, SIGSEGV等訊號發生時的處理函式。
1 2 3 4 5 6 7 |
//註冊處理SIGSEGV訊號 signal(SIGSEGV,handleSignal); // 註冊處理其他訊號 .... //訊號處理函式 static void handleSignal( int sig ) { } |
二、Crash日誌組成
上部分介紹了Crash的捕獲,這部分來看看Crash日誌的組成。
1、日誌內容Demo
日誌主要分為六個部分:程式資訊、基本資訊、異常資訊、執行緒回溯、執行緒狀態和二進位制映像。下面是從某APP具體的Crash日誌抽出的主要資訊,展示如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 |
//1、程式資訊 Hardware Model: iPhone9,2 Process: AppName [3580] Path: /var/containers/Bundle/Application/C7B90C8A-E269-4413-A011-552971D1ED39/AppName.app Identifier: xxxx.xxx.xxxx.xxx Version: xx.xx Code Type: ARM-64 (Native) Parent Process: [1] //2、基本資訊 Date/Time: 2017-05-22 03:05:06.743 +0800 OS Version: iPhone OS 10.2.1 (14D27) //3、異常資訊 Exception Type: NSInvalidArgumentException(SIGABRT) Exception Codes: -[NSNull integerValue]: unrecognized selector sent to instance 0x1a9d88ef8 at 0x00000001835c7014 Crashed Thread: 0 //4、執行緒回溯 (展示發生Crash執行緒的回溯資訊,其他略) Thread 0 Crashed: 0 libsystem_kernel.dylib 0x00000001835c7014 __pthread_kill + 4 1 libsystem_c.dylib 0x000000018353b400 abort + 140 2 AppName 0x0000000100a26704 0x0000000100028000 + 10479360 3 CoreFoundation 0x00000001845f9538 ___handleUncaughtException + 644 2 CoreFoundation 0x0000000184600268 ___methodDescriptionForSelector 3 CoreFoundation 0x00000001845fd270 ____forwarding___ + 916 4 CoreFoundation 0x00000001844f680c _CF_forwarding_prep_0 + 80 5 AppName 0x0000000100205280 0x0000000100028000 + 1954432 6 AppName 0x00000001002ae59c 0x0000000100028000 + 2647440 7 AppName 0x0000000100482944 0x0000000100028000 + 4565312 16 CoreFoundation 0x00000001845a6810 ___CFRUNLOOP_IS_SERVICING_THE_MAIN_DISPATCH_QUEUE__ + 12 + 12 17 CoreFoundation 0x00000001845a43fc ___CFRunLoopRun + 1660 18 CoreFoundation 0x00000001844d22b8 CFRunLoopRunSpecific + 436 //5、程式狀態(展示部分) Thread 0 crashed with ARM 64 Thread State: x0: 000000000000000000 x1: 000000000000000000 x2: 000000000000000000 x3: 0xffffffffffffffff x4: 0x0000000000000010 x5: 0x0000000000000020 x6: 000000000000000000 x7: 000000000000000000 x8: 0x0000000008000000 x9: 0x0000000004000000 x10: 000000000000000000 x11: 0x00000001ac336c83 x12: 0x00000001ac336c83 x13: 0x0000000000000018 x14: 0x0000000000000001 x15: 0x0000000000000881 x16: 0x0000000000000148 x17: 000000000000000000 x18: 000000000000000000 x19: 0x0000000000000006 //6、二進位制映像 (展示部分) Binary Images: 0x100028000 - 0x1011dbfff +AppName arm64 /var/containers/Bundle/Application/C7B90C8A-E269-4413-A011-552971D1ED39/AppName.app/AppName 0x18368a000 - 0x183693fff libsystem_pthread.dylib arm64 /usr/lib/system/libsystem_pthread.dylib 0x1835a8000 - 0x1835ccfff libsystem_kernel.dylib arm64 /usr/lib/system/libsystem_kernel.dylib 0x1834b1000 - 0x1834b5fff libdyld.dylib arm64 /usr/lib/system/libdyld.dylib 0x1834d8000 - 0x183556fff libsystem_c.dylib arm64 /usr/lib/system/libsystem_c.dylib 0x183481000 - 0x1834b0fff libdispatch.dylib arm64 /usr/lib/system/libdispatch.dylib 0x183028000 - 0x183401fff libobjc.A.dylib arm64 /usr/lib/libobjc.A.dylib |
2、日誌內容組成分析
整個日誌內容中,直接和Crash資訊相關,最能幫助開發者定位問題部分是: 異常資訊 和 執行緒回溯部分的內容。
1) 程式資訊:發生Crash閃退程式的相關資訊
- Hardware Model : 標識裝置型別。 如果很多崩潰日誌都是來自相同的裝置型別,說明應用只在某特定型別的裝置上有問題。上面的日誌裡,崩潰日誌產生的裝置是iPhone 7 Plus (iPhone 7 Plus 也是2個版本 iPhone9,2 和 iPhone9,4. 硬體代號為 D11AP 和 D111AP. 型號有: A1661, A1784, A1785 和 A1786. )
- Process 是應用名稱。中括號裡面的數字是閃退時應用的程式ID。
2) 基本資訊:給出了一些基本資訊,包括閃退發生的日期和時間,裝置的iOS版本。
3) 異常資訊:閃退發生時丟擲的異常型別。還能看到異常編碼和丟擲異常的執行緒。
1 2 3 4 |
//以上面內容中的異常資訊為例: Exception Type: NSInvalidArgumentException(SIGABRT) Exception Codes: -[NSNull integerValue]: unrecognized selector sent to instance 0x1a9d88ef8 at 0x00000001835c7014 Crashed Thread: 0 |
- Exception Type異常型別:通常包含1.7中的Signal訊號和EXC_BAD_ACCESS,NSRangeException等。
- Exception Codes:異常編碼:
- Crashed Thread:發生Crash的執行緒id
4) 執行緒回溯:回溯是閃退發生時所有活動幀清單。它包含閃退發生時呼叫函式的清單。
5) 執行緒狀態:閃退時暫存器中的值。一般不需要這部分的資訊,因為回溯部分的資訊已經足夠讓你找出問題所在。
6) 二進位制映像:閃退時已經載入的二進位制檔案。
三、異常資訊解讀
1、Exception Type(異常型別)
- Exception Type:通常包含Signal訊號 和 EXC_BAD_ACCESS,NSRangeException等。
異常型別 | 可能的原因 | 除錯方法 |
---|---|---|
EXC_CRASH | unrecognized selector | All Exception Point |
EXC_BAD_ACCESS | 記憶體訪問錯誤 | NSZombie |
SIGSEGV | 引用了released物件 / 引用未init的物件 / 陣列越界/ 試圖往沒有寫許可權的記憶體地址寫資料 | NSZombie |
SIGABRT | 邏輯錯誤導致的Crash,比如嘗試多次釋放同一個沒存 | 邏輯檢查 |
SIGPIPE | TCP突然斷開,再傳送資料 | 新增signal(SIGPIPE,XX) |
具體訊號說明參見iOS異常捕獲
2、Exception Code(異常編碼)
- Exception Code:以一些文字開頭,緊接著是一個或多個十六進位制值。這些數值說明了Crash發生的本質。
- 從Exception Code中,可以區分出Crash是因為程式錯誤、非法記憶體訪問還是其他原因。常見的異常編碼如下表:
異常編碼 | 描述 |
---|---|
0x8badf00d | ate bad food ,表示應用是因為發生watchdog超時而被iOS終止的。通常是應用花費太多時間而無法啟動、終止或響應用系統事件。 |
0xdeadfa11 | dead fall,使用者強制退出。 |
0xbaaaaaad | 使用者按住Home鍵和音量鍵,獲取當前記憶體狀態,不代表崩潰。 |
0xbad22222 | VoIP 應用因為過於頻繁重啟而被終止 |
0xc00010ff | cool off,因為太燙了被幹掉 |
0xdead10cc | dead lock,表明應用因為在後臺執行時佔用系統資源(如通訊錄資料庫) |
0xbbadbeef | bad beef,發生致命錯誤 |
說明1:詳細的異常編碼代表的含義請參考:Hexspeak
說明2:在後臺任務列表中關閉已掛起的應用不會產生崩潰日誌。 因為應用一旦被掛起,它何時被終止都是合理的。所以不會產生崩潰日誌。
四、Crash日誌符號化
1、概述
執行緒回溯部分內容如下:
1 2 |
5 AppName 0x0000000100205280 0x0000000100028000 + 1954432 6 AppName 0x00000001002ae59c 0x0000000100028000 + 2647440 |
這兩條記錄包括四列:(以第一條記錄為例子)
- 幀編號—— 5(數字越小,發生時間越晚,發生順序越往後,越好鎖定問題的範圍)
- 二進位制庫的名稱 ——此處是 AppName.
- 呼叫方法的地址 ——此處是 0x0000000100205280.
- 第四列分為兩個子列,一個基本地址和一個偏移量。此處是 x0000000100028000 + 1954432, 第一個數字指向檔案,第二個數字指向檔案中的程式碼行。
說明1:執行緒回溯部分並不是我們習慣使用方法名和行數,而是十六進位制地址。所以我們在分析Crash前需要將這些十六進位制地址轉化成方法名稱和行數,改過程被稱為符號化。
說明2:符號化Crash日誌需要獲取對應的應用二進位制檔案以及生成二進位制檔案時產生的 .dSYM 檔案(符號表)。必需完全匹配才行。否則,日誌將無法被完全符號化。
說明3: Xcode編譯專案後,會得到同名的 dSYM 檔案(符號表),dSYM 檔案(符號表)是儲存 16 進位制函式地址對映資訊的中轉檔案,我們除錯的 symbols 都會包含在這個檔案中,並且每次編譯專案的時候都會生成一個新的 dSYM 檔案,位於 /Users//Library/Developer/Xcode/Archives 目錄下,對於每一個釋出版本我們都很有必要儲存對應的 Archives 檔案。
說明4:符號化可以使用Xcode的兩種命令 symbolicatecrash命令 + atos命令
2、symbolicatecrash命令
1)首選找到symbolicatecrash命令的位置
1 2 |
find /Applications -name symbolicatecrash -type f //我的本機命令的位置:/Applications/Xcode.app/Contents/SharedFrameworks/DVTFoundation.framework/Versions/A/Resources/symbolicatecrash |
2)找到線上版本對應的xcarchive檔案。從中找到.dSYM和.app檔案
1 |
xcarchive所在的路徑一般在: /Users//Library/Developer/Xcode/Archives 目錄下 |
3)獲取crash日誌檔案
- 線上App的Crash日誌經由Crash日誌收集服務獲得(主要來源)。
- 也可以從真機上獲取Crash日誌檔案。點選Window -> Devices,選擇你自己的機器,然後點選View Device Logs,右鍵可以匯出Crash檔案。
- 獲取的這些日誌檔案都需要符號化處理。
4)將symbolicatecrash、.dSYM、.app、crash.crash拷貝到桌面下同一個資料夾下
5)檢查 xx.app 和 xx.app.dSYM 檔案以及crash 檔案這三種的 UUID是否一致。
- 檢視 xx.app 檔案的 UUID,terminal 中輸入命令 :
1dwarfdump --uuid xx.app/xx (xx代表你的專案名) - 檢視 xx.app.dSYM 檔案的 UUID ,在 terminal 中輸入命令:
1dwarfdump --uuid xx.app.dSYM - 檢視crash 日誌中的Incident Identifier (crash 檔案的 UUID)
6)使用命令,生成“可定位問題的crash檔案”
1 2 |
//symbolreportXXX.crash就是符號化後的檔案 ./symbolicatecrash crashXXX.crash appName.app.dSYM > symbolreportXXX.crash |
7) 根據符號化後的執行緒回溯資訊,可以幫助定位出問題的程式碼行。
說明:如果執行symbolicatecrash命令出現 Error: “DEVELOPER_DIR” is not defined at ./symbolicatecrash…這樣的錯誤,可以在執行命令前,輸入export DEVELOPER_DIR=”/Applications/XCode.app/Contents/Developer”
3、atos命令
在符號化時候,還可以使用atos命令。發現armv7處理器上的crash使用symbolicatecrash無法符號化。
1)將.dSYM、.app、crash.crash放到同一個資料夾下。
2) 知道crash檔案的UUID:執行grep “AppName arm” *crash,得到結果
1 2 3 |
crash1.crash:0x100040000 - 0x100e23fff +AppName arm64 /var/containers/Bundle/Application/55A4D641-847F-4D24-86E1-129B28461858/AppName.app/AppName crash2.crash:0x100060000 - 0x100e43fff +AppName arm64 /var/containers/Bundle/Application/3229ED68-8D19-406D-A3F5-EC0310C9DB7C/QAppName.app/AppName crash3.crash: 0x5000 - 0xce8fff +AppName armv7 /var/containers/Bundle/Application/C6BE271D-2EAC-42C0-8E72-4523F88C76B2/AppName.app/AppName |
其中0x100040000、0x100060000、0x5000是載入地址(loadingAddress), 而arm64、armv7 是 architecture 的值(architectureValue),這兩個值後面都要用。
3)然後執行atos命令,輸入成功,進入待輸入狀態
1 |
xcrun atos -o appName.app.dSYM/Contents/Resources/DWARF/appName -l loadingAddress -arch architectureValue |
4) 此時輸入App對應的Crash地址,得到發生crash的資訊。
例項1:
1 2 |
grep "AppName arm" *crash xcrun atos -o AppName.app.dSYM/Contents/Resources/DWARF/AppName -l 0x100040000 -arch arm64 |
例項2:
1 2 |
grep "AppName arm" *crash xcrun atos -o AppName.app.dSYM/Contents/Resources/DWARF/AppName -l 0x5000 -arch armv7 |
五、常見的Crash
有一些Crash比較常見,下面羅列出5種常見的Crash。
1、陣列操作
- 場景1:取資料索引越界。一般發生在UITableView的使用中,因為cellForRowAtIndexPath代理方法是非同步執行的,UITableView物件的dataSource一旦在載入資料過程中發生變化,極有可能發生陣列越界的異常。在多執行緒場景下,列表介面的資料有可能經常變化,很可能發生;當列表介面資料不怎麼變化的時候,幾乎感知不到這種異常的存在。解決辦法:從陣列中取資料前,校驗索引是否正確。
12345678910111213@implementation NSMutableArray (Safe)- (id)safeObjectAtIndex:(NSUInteger)index{if (index < self.count){return [self objectAtIndex:index];}else{NSLog(@"警告:陣列越界!!!");}return nil;}@end - 場景2:陣列新增資料物件時nil解決辦法:新增物件到陣列前,判斷是否是nil
說明:陣列的刪除等操作處理類似,陣列操作前要進行資料校驗。
2、多執行緒下的Crash
- 一般多執行緒發生的Crash,會收到SIGSEGV訊號,表明試圖訪問未分配給自己的記憶體, 或試圖往沒有寫許可權的記憶體地址寫資料。
- 場景1:子執行緒中更新UI
解決辦法:將UI更新操作放在主執行緒中,可以使用performSelectorOnMainThread 或 GCD
123456789//子執行緒中,使用巨集將更新UI的任務派發到主佇列#define dispatch_main_sync_safe(block) \if ([NSThread isMainThread]) { \block(); \} else { \dispatch_sync(dispatch_get_main_queue(), block); \}#define dispatch_async_main(block) dispatch_async(dispatch_get_main_queue(), block) - 場景2:多執行緒中建立單例解決辦法:使用dispatch_once,保證程式碼只執行一次,保證執行緒安全。
1234567891011121314151617181920212223//以QSAccountManager單例為例static QSAccountManager *_shareManager = nil;+ (instancetype)shareManager{static dispatch_once_t once;dispatch_once(&once, ^{_shareManager = [[self alloc] init];});return _shareManager;}+ (instancetype)allocWithZone:(struct _NSZone *)zone{static dispatch_once_t onceToken;dispatch_once(&onceToken, ^{_shareManager = [super allocWithZone:zone];});return _shareManager;}- (nonnull id)copyWithZone:(nullable NSZone *)zone{return _shareManager;}
- 場景3:多執行緒下非執行緒安全類的使用,如NSMutableArray、NSMutableDictionary解決辦法:使用派發佇列或鎖保證資料讀寫安全。具體實現詳見 iOS實錄12:NSMutableArray使用中忽視的問題中第一部分。
- 場景4:資料快取到磁碟和讀取。解決辦法:使用派發佇列或鎖保證資料讀寫安全。如將資料的讀取和寫非同步放入序列同步佇列,保證資料同步,執行緒安全。
3、WatchDog 超時造成的Crash
- 一般異常編碼是0x8badf00d ,表示應用是因為發生watchdog超時而被iOS終止的。通常是應用花費太多時間而無法啟動、終止或響應用系統事件。
- 場景1:主執行緒中執行耗時的操作,導致主執行緒被卡超過一定的時間。解決辦法:主執行緒中只負責UI的更新和響應,將耗時的操作採用非同步的方式放到後臺執行緒執行。耗時操作包括:網路請求,資料庫讀寫等。
4、performSelector:withObject:afterDelay下的Crash
- 場景1:物件釋放比performSelector:afterDelay要早解決辦法:在對應類的dealloc中執行cancelPreviousPerformRequestsWithTarget取消執行。
5、SIGPIPE導致的程式退出
- 當伺服器close一個連線時,若client端接著發資料。根據TCP協議的規定,會收到一個RST響應,client再往這個伺服器傳送資料時,系統會發出一個SIGPIPE訊號給程式,告訴程式這個連線已經斷開了,不要再寫了。而根據訊號的預設處理規則,SIGPIPE訊號的預設執行動作是terminate(終止、退出),所以client會退出。
- 場景:長連線socket或重定向管道進入後臺,沒有關閉解決辦法1:切換到後臺時,關閉長連線和管道,回到前臺再重建;解決辦法2:使用signal(SIGPIPE,SIG_IGN),將SIGPIPE交給了系統處理。這麼做將SIGPIPE設為SIG_IGN,使得客戶端不執行預設動作,即不退出。
End