這篇接著iOS Crash處理方法(一):MethodSwizzle 繼續描述Crash的問題。
這篇跟上篇不同,上篇是講述如何避免Crash的問題,而這篇則是把Crash暴露出來,定位Crash位置。
本篇文章完整代碼的github地址:https://github.com/WalkingToTheDistant/ErrorManager
上篇講述了替換系統庫API的IMP ,變成自定義API的IMP,這種方式在測試的時候好像是完美地解決了多數內存越界的問題,怎么測試就怎么爽,可是真正上線了,突然Crash數直飚,而且在iOS8 的Crash率奇高,而且還無法定位問題代碼!!!
報錯的信息大致是這樣的:
好吧,任我怎么測試也沒有出現類似的Crash,看來不能再用這種投機取巧、無底線浪的方法了,踏踏實實改BUG吧。
改BUG就要收集Crash報告,然后才能找到在哪報錯了呀,而目前收集Crash報告的方法一般有這幾種方法:
- Xcode 的Crash收集:這個是iOS 系統自帶的Crash報告上傳,優點在于可以直接在Xcode里面定位代碼,缺點就是需要用戶同意,而且流程時間一般要好幾天:從用戶的手機上傳到蘋果服務器,服務器匯總再轉發給我們的Xcode。
- 第三方Crash收集SDK:目前比較常用的有友盟和騰訊bugly,他們會匯集Crash的詳細信息,比如iOS版本號以及Crash log,優點是能夠很快獲取到Crash log,不過有一小部分的log還是無法定位出BUG代碼。
- 第三種方法是導出Iphone本地存儲的Crash log,一般APP發生Crash的時候,iOS會存儲相關Crash的信息到本地,這時候需要使用Xcode連接該iphone,然后使用XCode->windows->Devices->view Device Logs,或者用Itunes同步手機獲取信息,就可以看到相關APP的Crash的信息。(這個需要符號化Crash信息)
- 額,Xcode連接手機運行項目,等待Crash的方法就不扯了…
通過CrashAddress 定位問題的方式也有幾種,不過目前比較常用的是atos命令從Symbol(Symbol文件是Xcode打包之后自動生成的符號表,記錄著每行代碼的位置信息。),在最后分析環節再說明如何使用atos定位問題。
現在我們來說說第三方一般都是怎么捕獲異常信息,然后也就解釋了友盟上很多Crash log定位的代碼最終都指向了UmengSignalhandler。
捕獲異常
(1)Mach異常 和 Unix信號
關于Mach和Unix的具體信息,念茜大神這篇講解的很詳細:漫談iOS Crash收集框架。簡單來說,Mach異常是指最底層的內核級異常,而為了更直觀友好的展示異常,就把Mach異常轉換成了Unix信號,所以也就存在這種情況:Mach異常處理會先于Unix信號處理發生,如果Mach異常的handler讓程序exit了,那么Unix信號就永遠不會到達這個進程了。例如,訪問野指針導致的EXC_BAD_ACCESS問題,Mach會轉換成 SIGSEGV 的Unix信號。
這篇文章暫時只處理Unix信號,捕獲Unix信號的方法:
/* 第一個參數是需要捕獲的信號值,第二個參數是處理函數的函數指針,也就是你寫的捕獲方法 */
void (*signal(int, void (*)(int)))(int);
#import <sys/signal.h>
/** 信號處理函數 */
void handleSignalHandler(int signalValue){
}
signal(SIGSEGV, handleSignalHandler); // 監聽 SIGSEGV 信號
/* 常用的信號值說明
* SIGABRT :由于abort()函數調用發生的程序中止信號
* SIGILL:由于非法指令產生的程序中止信號
* SIGSEGV:由于無效內存的引用導致的程序中止信號
* SIGFPE:由于浮點數異常導致的程序中止信號
* SIGBUS:由于內存地址未對齊導致的程序中止信號
* SIGPIPE:程序通過端口發送消息失敗導致的程序中止信號(這個信號比較特殊,后面會提到)
*/
(2)NSException 拋出
單單依靠捕獲Unix信號是不夠的,因為還有一種情況會導致Crash,那就是拋出的Objective-C異常報告(NSException)。這種Crash就不會觸發上面說的Unix信號,所以還要單獨處理這種情況。
捕獲 NSException 的方法:
/* 參數同樣為異常處理方法的函數指針 */
void NSSetUncaughtExceptionHandler(NSUncaughtExceptionHandler * _Nullable);
#import <Foundation/NSException.h>
void uncaughtExceptionHandler (NSException *exception){
}
NSSetUncaughtExceptionHandler(&uncaughtExceptionHandler); // 使用方法
獲取異常信息
(1)Unix 信號
當我們設置監聽的信號發出來的時候(這時候一般是Crash了),我們的回調函數handleSignalHandler 會被觸發,但是handleSignalHandler方法只有一個int參數,那就是信號值,我們想獲取更多Crash信息的話,需要從堆棧中取出信息。
#include <execinfo.h>
void handleSignalHandler(int signalValue){
void* callstack[128];
int frames = backtrace(callstack, 128); // Linux下,該函數用于獲取當前線程的調用堆棧,獲取的信息將會被存放在buffer中,它是一個指針列表。參數size 用來指定buffer中可以保存多少個void* 元素。函數返回值是實際獲取的指針個數,最大不超過size大小。
char** strs = backtrace_symbols(callstack, frames); // backtrace_symbols將從backtrace函數獲取的信息轉化為一個字符串數組;參數buffer應該是從backtrace函數獲取的指針數組,size是該數組中的元素個數(backtrace的返回值)。
// 轉換好了,這樣strs是一個指向字符串數組的指針,它的大小同buffer相同, 每個字符串包含了一個相對于buffer中對應元素的可打印信息.它包括函數 名,函數的偏移地址,和實際的返回地址
for (int i = 0; i <frames; i+=1) {
NSString *strTemp = [NSString stringWithUTF8String:strs[i]];
NSLog(@"%@", strTemp); // 可以直接輸出信息
}
free(strs); // 用完了要記得釋放
kill(getpid(), signalValue); // 最后要殺掉進程這句話不能少,不然你會看到你的APP卡死在那里
}
(2)NSExceoption
這個就獲取就比較容易了,NSExceoption對象本身就存儲了異常信息(callStackSymbols),我們要做的就是從callStackSymbols中輸出信息就可以了。
/** 這是 NSException 的聲明 */
@interface NSException : NSObject <NSCopying, NSCoding>
@property (readonly, copy) NSExceptionName name;
@property (nullable, readonly, copy) NSString *reason;
@property (nullable, readonly, copy) NSDictionary *userInfo;
@property (readonly, copy) NSArray<NSNumber *> *callStackReturnAddresses NS_AVAILABLE(10_5, 2_0);
@property (readonly, copy) NSArray<NSString *> *callStackSymbols NS_AVAILABLE(10_6, 4_0);
#import <Foundation/NSException.h>
void uncaughtExceptionHandler (NSException *exception){
NSLog(@"%@", exception.callStackSymbols.description);
NSSetUncaughtExceptionHandler(NULL); // 最后取消監聽,用完就回收嘛
}
異常信息分析
墨跡了那么久,終于開始進入重點啦。
我先寫個常見的錯誤代碼,然后看看輸出的異常信息是什么樣的:
- (void) clickBtn:(UIButton*)btn
{
NSArray *ary = [NSArray arrayWithObjects:@"dsfs", @"dsfsd", nil];
NSLog(@"%@", ary[4]); // 會觸發 uncaughtExceptionHandler 方法
}
輸出結果:
callStackSymbols:
(
0 CoreFoundation 0x0000000103c12b0b __exceptionPreprocess + 171
1 libobjc.A.dylib 0x00000001035cf141 objc_exception_throw + 48
2 CoreFoundation 0x0000000103b5038b -[__NSArrayI objectAtIndex:] + 155
3 TempPro 0x0000000102ff1f94 -[ViewController clickBtn:] + 242
4 UIKit 0x0000000104037d82 -[UIApplication sendAction:to:from:forEvent:] + 83
5 UIKit 0x00000001041bc5ac -[UIControl sendAction:to:forEvent:] + 67
6 UIKit 0x00000001041bc8c7 -[UIControl _sendActionsForEvents:withEvent:] + 450
7 UIKit 0x00000001041bb802 -[UIControl touchesEnded:withEvent:] + 618
8 UIKit 0x00000001040a57ea -[UIWindow _sendTouchesForEvent:] + 2707
9 UIKit 0x00000001040a6f00 -[UIWindow sendEvent:] + 4114
10 UIKit 0x0000000104053a84 -[UIApplication sendEvent:] + 352
11 UIKit 0x00000001048375d4 __dispatchPreprocessedEventFromEventQueue + 2926
12 UIKit 0x000000010482f532 __handleEventQueue + 1122
13 CoreFoundation 0x0000000103bb8c01 __CFRUNLOOP_IS_CALLING_OUT_TO_A_SOURCE0_PERFORM_FUNCTION__ + 17
14 CoreFoundation 0x0000000103b9e0cf __CFRunLoopDoSources0 + 527
15 CoreFoundation 0x0000000103b9d5ff __CFRunLoopRun + 911
16 CoreFoundation 0x0000000103b9d016 CFRunLoopRunSpecific + 406
17 GraphicsServices 0x000000010793fa24 GSEventRunModal + 62
18 UIKit 0x0000000104036134 UIApplicationMain + 159
19 TempPro 0x0000000102ff28ff main + 80
20 libdyld.dylib 0x00000001069d465d start + 1
)
前5行(0 ~ 4)一般都是最貼近你代碼里面的錯誤信息,這個異常比較容易,一看就大概知道什么原因(2 :[__NSArrayI objectAtIndex:]+ 155),以及什么位置了(3:[ViewController clickBtn:] + 242)。那如果想要更精確地確定是哪一行代碼出現了問題呢,那就需要使用atos命令進行解析(后面的+ 242 可不是代碼行數,不要那么耿直…)。
首先我們要從這里提取重要信息,這里哪一行信息才是我們需要的呢?首先前面標有CoreFoundation、 libobjc.A.dylib這些信息我們就不用看了,都是系統庫問題,你也找不到,我們要找的是 標有我們工程名的那一行,上面的例子中就是 TempPro 這一行,一般在0~5行,越往后定位越遠…
3 TempPro 0x0000000102ff1f94 -[ViewController clickBtn:] + 242
我們要的是這個地址:0x0000000102ff1f94,下面開始說明如何用這個地址。
(1)拿到dSYM文件
首先我們要拿到dSYM文件,dSYM文件是每次打包安裝包的時候,會自動生成的,里面類似一張列表,對應存儲著我們代碼的位置。獲取方法:Xcode -> Window -> Organrizer -> Archives -> 你的項目名(TempPro) -> 右鍵"Show in Finder" -> 右鍵TempPro. xcarchive 文件 -> 顯示包內容 -> dSYMs -> TempPro.app.dSYM -> 右鍵顯示包內容 -> Contents -> Resources -> DWARF -> TempPro(就是這個啦)(注:TempPro都是你的工程Targets名)把TempPro拷貝出來放在一個文件夾下。
(2)獲取dSYM內存地址
因為iOS采用了ASLR(Address space layout randomization),ASDL機制會在app加載時根據load address動態加一個偏移地址slide address。所以在捕獲錯誤地址stack address后,需要減去偏移地址才能得到正確的符號地址symbol address,上面我們從異常信息提取到的地址0x0000000102ff1f94 是stack address。額,簡單說,dSYM里面存儲的是一張連續的內存地址表(0~10),但是iOS 運行時從中拿了一部分(3~5),然后加載到一個起始內存地址為20的內存塊中,那么我們從異常信息中提取到的內存地址是 (23 ~ 25),這樣是無法映射到dSYM表(0~10)中,就需要減去一個偏移量(就是起始內存地址20),之后才可以在dSYM表中找到該代碼的位置了。
下面是獲取偏移量(偏移地址slide address)的代碼:
/** 獲取加載偏移地址 */
long long getSlide()
{
long long slide = 0;
for (uint32_t i = 0; i < _dyld_image_count(); i++) {
if (_dyld_get_image_header(i)->filetype == MH_EXECUTE) {
slide = _dyld_get_image_vmaddr_slide(i);
break;
}
}
return slide;
}
我們假設獲取到的地址slide address = 0x00f2d33
那么最終符號地址symbol address = 0x0000000102ff1f94 - 0x00f2d33 = 0x102EFF261
(3)atos命令分析
先看下atos命令
atos -arch arm64 -o /temp/TempPro 0x102EFF261
其中arm64是指CPU的型號,這個就需要根據APP是在哪個手機上運行決定的,這里有個型號對應表
armv6:iPhone、iPhone2、iPhone3G
armv7:iPhone4、iPhone4S
armv7s:iPhone5、iPhone5C
arm64:iPhone5S
而 /temp/TempPro 這個是剛才我們第一步提取到的dSYM文件的存放位置(我放在一個temp文件夾下)
而 0x102EFF261 這個就是我們要輸入的symbol address啦(第二步獲取到)
那么根據上面的步驟,我們最終要執行的atos命令如下:
atos -arch arm64 -o /temp/TempPro 0x102EFF261
執行之后,輸出結果(上面都是示例地址,下面這個是我實際測試用的)
-[ViewController clickBtn:] (in TempPro) (ViewController.m:267)
這句話就很清晰啦,ViewController.m 是位置文件,m:267就是代碼行數。
納尼!你不知道怎么顯示行數?!………………
Xcode -> Preferencs -> Text Editing -> Line numbers 勾選
(4)注意事項
有時候在獲取異常信息 callStackSymbols 的時候,是無法獲取到具體問題代碼位置的信息,是因為某些編譯器的優化選項對獲取正確的調用堆棧有干擾,另外內聯函數沒有堆棧框架,或者刪除框架指針,這些原因都會導致無法正確解析堆棧內容。(如果用友盟方法,經常定位到UmengSignalHandler就是這種情況(如果是上面的代碼,就是定位到handleSignalHandler方法))。
另外關于 SIGPIPE 信號,是關于iOS Socket長鏈接,APP處于前臺然后鎖屏,再重新解鎖打開APP,會Crash的問題,可以查看這篇文章
如何在 iOS 上避免 SIGPIPE 信號導致的 crash (Avoiding SIGPIPE signal crash in iOS)
解決辦法,在APP剛啟動時執行這句代碼
signal(SIGPIPE, SIG_IGN);