iOS Crash处理方法(二):自己编写代码定位Crash

这篇接着iOS Crash处理方法(一):MethodSwizzle 继续描述Crash的问题。
这篇跟上篇不同,上篇是讲述如何避免Crash的问题,而这篇则是把Crash暴露出来,定位Crash位置。


本篇文章完整代码的github地址:https://github.com/WalkingToTheDistant/ErrorManager


上篇讲述了替换系统库API的IMP ,变成自定义API的IMP,这种方式在测试的时候好像是完美地解决了多数内存越界的问题,怎么测试就怎么爽,可是真正上线了,突然Crash数直飚,而且在iOS8 的Crash率奇高,而且还无法定位问题代码!!!
报错的信息大致是这样的:


屏幕快照 2017-08-24 15.25.02.png

好吧,任我怎么测试也没有出现类似的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

执行之后,输出结果(上面都是示例地址,下面这个是我实际测试用的)


屏幕快照 2017-09-06 15.17.52.png
-[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);

推荐阅读更多精彩内容