iOS Crash及其符号化

符号

维基百科关于符号(Symbol)定义:

A symbol in computer programming is a primitive data type whose instances have a unique human-readable form.

在计算机编程中符号是一种原始的数据类型,其实例具有独一无二的人类可读形式。也就是说符号是一个数据结构,它包含了名称和类型等元数据,符号对应一个函数或者数据的地址。

符号表(Symbol Table)

符号表是内存地址与符号的映射表,存储了当前文件的符号信息,静态链接器ld和动态链接器dyld在链接的过程中都会读取符号表。

打包上线的时候(Release模式)会把调试符号等裁剪掉,但是线上一旦出现问题上报了Crash log我们如何知道对应的报错代码位置和调用堆栈呢,这就需要把符号写到另外一个单独的文件里,也就是dSYM文件。dSYM(debug symbols)是iOS的符号表文件,存储着16进制地址信息和符号的映射文件,可以帮我们将Crash堆栈信息中的地址信息符号化。

文件名样式:MyApp.app.dSYM,它可以使我们将堆栈信息中的地址信息还原成对应的符号,以便于问题的定位和修复。

如何生成dSYM文件

crash_03.png

符号相关配置

Deployment Postprocessing

Deployment Postprocessing是编译生成目标文件后是否要进行后续处理的配置项

  • 配置为Yes,编译生成目标文件后要进行后续处理,比如符号裁剪

  • 配置为No,不会进行后续处理

Strip Linked Product

Deployment Postprocessing为Yes时,Strip Linked Product的设置才会有效。

  • 配置为Yes,进行符号裁剪

  • 配置为No,不进行符号裁剪

crash_02.png

日常开发过程中,我们是需要符号信息存在的,通常Debug模式下会将Deployment Postprocessing设置为No,Release模式下设置为Yes,这样Debug模式下一旦有问题可以及时暴露并修复。

Crash分析

一般来说发现Crash有开发阶段和发布线上阶段两种情况。

开发阶段

开发阶段debug模式下,在Xcode中碰到Crash时控制台会打印出崩溃信息,帮助我们排查问题。此时log信息大致如下

2021-06-18 20:52:01.931022+0800 MyApp[2706:35625050] *** Terminating app due to uncaught exception 'NSInvalidArgumentException', reason: '-[__NSCFNumber length]: unrecognized selector sent to instance 0xbed641d619417a05'
*** First throw call stack:
(
    0   CoreFoundation         0x00007fff20421af6 __exceptionPreprocess + 242
    1   libobjc.A.dylib        0x00007fff20177e78 objc_exception_throw + 48
    2   CoreFoundation         0x00007fff204306f7 +[NSObject(NSObject) instanceMethodSignatureForSelector:] + 0
    3   CoreFoundation         0x00007fff20426036 ___forwarding___ + 1489
    4   CoreFoundation         0x00007fff20428068 _CF_forwarding_prep_0 + 120
    5   MyApp                  0x0000000101a2cfb0 -[MIHomeINViewController loadWebPageRequestWithUrl:] + 208
    6   MyApp                  0x0000000101a2ce9b -[MIHomeINViewController loadWebPageRequestWithUrl:moduleDict:type:frameIndex:] + 379
    7   CoreFoundation         0x00007fff204282fc __invoking___ + 140
    8   CoreFoundation         0x00007fff204257b6 -[NSInvocation invoke] + 303
    9   MyApp                  0x0000000101d7d56f __ASPECTS_ARE_BEING_CALLED__ + 4111
    10  CoreFoundation         0x00007fff20425dc0 ___forwarding___ + 859
    
    ...
    
)

这样根据崩溃log信息我们能够很快定位到崩溃问题所在的类文件、方法。

日常开发中还可以利用断点来快速定位代码崩溃位置。

crash_01.png

发布线上阶段

前面讲过了,一旦到了发布线上(Release)阶段,符号会被裁剪掉,为什么要裁剪呢

  • 减少包体积大小

  • 避免被逆向分析(符号裁剪不能保证不被逆向分析,符号化就是逆向工程的研究重点研究内容之一)

这就意味着Release包发生Crash后,拿到的Crash log是未经符号化的.

一般Crash日志来源有以下两种:

  • 苹果收集的Crash日志

    • Xcode -> Window -> Organize -> Crashes中查看

    • 用户手机设置 -> 隐私 -> 分析与改进 -> 分析数据里查看

  • 应用内收集

    • Crash收集SDK,比如项目中所用的OMGCrashReportsSDK,第三方的KSCrash等,上报到自建分析平台

    • 接入APM产品,比如Bugly、Fabric等

这个阶段拿到的Crash日志大部分都是以下样式:

Incident Identifier: *******
CrashReporter Key: *******
Hardware Model: iPhone6,2
Process: MyApp [1148]
Path: /var/containers/Bundle/Application/*******/MyApp.app/MyApp
Identifier: *******
Code Type: ARM-64
Parent Process: ? [1]

Date/Time: 2021-05-11T11:24:28Z
Launch Time: 2021-05-11T11:21:59Z
OS Version: iOS 12.5.2 (16H30)
Report Version: 104

Exception Type: EXC_BAD_ACCESS (SIGSEGV)
Exception Codes: KERN_INVALID_ADDRESS at 0x0000000000000001
Crashed Thread: 0

Thread 0 Crashed:
0 libGPUSupportMercury.dylib 0x23c67efe4 0x23c67d000 + 8164
1 libGPUSupportMercury.dylib 0x23c67ffac 0x23c67d000 + 12204
2 xxxxxxxx 0x240ce8404 0x240cc2000 + 156676
3 GLEngine 0x241d92234 0x241cb1000 + 922164
4 OpenGLES 0x22393eaa4 0x223937000 + 31396
5 MyApp 0x10369c584 0x101044000 + 40207748     
6 MyApp 0x10368f9a0 0x101044000 + 40155552
7 MyApp 0x10370e7e0 0x101044000 + 40675296
8 MyApp 0x1036f4364 0x101044000 + 40567652
9 MyApp 0x10373aa64 0x101044000 + 40856164
10 MyApp 0x1036f2a54 0x101044000 + 40561236
11 MyApp 0x1036f1b78 0x101044000 + 40557432
12 QuartzCore 0x224b43ff0 0x224b32000 + 73712

...

Crash log分析

既然线上发布阶段Crash log是未经符号化的,那么一旦发生Crash,如何进行Crash分析呢?

先来看下Crash log的结构以及每个字段包含的信息是什么,这些内容可以帮助我们诊断崩溃来源的信息。

crash_04.png

一份Crash log打开后结构划分大致如图所示。对我们来说需要重点关注以下三个部分:

  1. Header(标题),用来描述发生崩溃的环境

    Incident Identifier: // 报告唯一标识符
    CrashReporter Key:   // 匿名的每个设备的标识符
    Hardware Model:      // 运行应用程序的设备型号
    Process:             // 崩溃进程的可执行文件名
    Path:                // 可执行文件在磁盘上的位置
    Identifier:          // 崩溃的进程
    Code Type:           // 崩溃进程的CPU架构
    Parent Process:      // 启动崩溃进程的名称和进程ID
    Date/Time:           // 崩溃的日期和时间
    Launch Time:         // 应用程序启动的日期和时间
    OS Version:          // 发生崩溃的系统版本号
    
  2. Exception Information

    Exception Type:      // 终止进程的异常的名称
    Exception Codes:     // 异常编码信息
    

    这块信息会告诉我们进程终止的原因是什么,但是它无法完全解释应用程序终止的原因,因为这块提供的信息是有限的。

  3. Exception Backtrace

    Crashed Thread: 0
    
    Thread 0 Crashed:
    0 libGPUSupportMercury.dylib 0x23c67efe4 0x23c67d000 + 8164
    1 libGPUSupportMercury.dylib 0x23c67ffac 0x23c67d000 + 12204
    2 xxxxxxxx 0x240ce8404 0x240cc2000 + 156676
    3 GLEngine 0x241d92234 0x241cb1000 + 922164
    4 OpenGLES 0x22393eaa4 0x223937000 + 31396
    5 MyApp 0x10369c584 0x101044000 + 40207748     
    6 MyApp 0x10368f9a0 0x101044000 + 40155552
    7 MyApp 0x10370e7e0 0x101044000 + 40675296
    8 MyApp 0x1036f4364 0x101044000 + 40567652
    9 MyApp 0x10373aa64 0x101044000 + 40856164
    10 MyApp 0x1036f2a54 0x101044000 + 40561236
    11 MyApp 0x1036f1b78 0x101044000 + 40557432
    12 QuartzCore 0x224b43ff0 0x224b32000 + 73712
    

    Exception Backtrace记录了程序终止时在线程上运行的代码,回溯的代码调用堆栈和Xcode调试暂时或崩溃时看到的类似,区别在于调用信息都是16进制地址。

    • 第一列数字序号代表堆栈帧号,堆栈帧调用顺序排列,第0帧是最后在执行的方法,第1帧是调用第0帧方法的方法,以此类推,也就是反向调用的顺序。

    • 第二列(MyApp)代表正在执行方法的二进制文件名

    • 第三列(0x10369c584)是正在执行的机器指令的地址

    • 第四列(0x101044000)是要二进制镜像入口地址,在符号化后会显示为要执行的方法名(SEL)

    • 第五列(+ 40207748)是从方法入口点到方法中当前指令的字节偏移量

Crash符号化

  1. Xcode本身也提供了工具来帮助开发者完成符号化的工作。

    • symbolicatecrash,这是一个将堆栈地址符号化的脚本,执行命令:./symbolicatecrash MyApp.crash MyApp.app.dSYM > MyApp.log

      这种方式局限性较大

      • 只能分析官方格式的Crash log,Crash log需要从具体设备中取出,存在一定的限制性

      • 会出现符号化失败的情况

    • atos,这个命令的特点是可以对单行堆栈进行符号化操作

      // 命令格式
      atos -arch  -o /Contents/Resources/DWARF/  -l  
      
      // 示例
      atos -o MyApp.app.dSYM/Contents/Resources/DWARF/MyApp -l 0x104a10000 0x00000001066f56f4
      // 输出
      -[HomeViewController configureViewTabBarViewWhenApplicationActive] (in MyApp) (HomeViewController.m:370)
      

      atos命令可以把地址里的16进制信息替换成等价的符号。如果调试符号信息是完备的,则atos的输出信息将会包含文件名和对应的资源行数。atos命令可以被用来单独符号化那些未符号化或者部分符号化过的crash log中的堆栈信息里的地址。

  2. Crash收集SDK收集Crash log并上传自建分析平台,打包时脚本上传dSYM文件,在自建平台完成log解析

    crash_05.png

    如图所示,平台在完成Crash log解析后,调用堆栈、崩溃发生App的信息、用户信息等都完整的解析出来,对于开发同学来说是最友好的,能够更加全面的对问题进行分析和解决。

特殊需求

上面的Crash分析说的都是针对普通情况下的,面对业务中的特殊场景和特殊需求,仍然有着特殊的Crash分析和解决方案。

比如在我们的业务中,App启动时会根据业务的不同需求来加载AB两套服务,当然A、B服务加载哪一个都能正常的保证App业务流程的正常使用。如果在App的启动过程中A服务发生崩溃,不管是A服务自己SDK不稳定导致,还是我们的使用存在问题,受影响的都会是用户。

特殊场景需求:

  1. 能够自动识别出是A服务的必现Crash,如果是偶现的crash不在兜底范围之内

  2. 确认是A服务导致Crash后,App下次启动时切换为B服务,等A服务修复完Crash之后能够自动从兜底的底图恢复到A服务

这个需求是要在线上发布的App中完成Crash的收集和分析处理,前面也讲到了Release的App都会将符号裁剪掉,这种信息是无法做Crash分析处理的,但是线上Release包也没有dSYM文件,这种况下如不借助dSYM文件一般是无法符号化的,那么有没有其他思路进行符号化呢

符号化思路

从上面的Crash log可以了解到,调用堆栈的第三列是正在执行的机器指令地址,那么这个地址(目标地址)再往前推进肯定就是当前正在调用的方法地址,如果能够拿到所有的方法地址,然后拿买一个方法地址和目标地址进行比较,与目标地址距离最近的那个地址所对应的方法就是当前正在调用的方法,也就是我们要得到的符号。

// 示例
5 MyApp 0x10369c584 0x101044000 + 40207748   

取0x10369c584和项目中拿到的每一个方法地址作比较,与0x10369c584差值最小的地址所对应的方法和其所属的类就是最终的目标符号信息。

取0x10369c584和项目中拿到的每一个方法地址作比较,与0x10369c584差值最小的地址所对应的方法和其所属的类就是最终的目标符号信息。

那么如何拿到所有的方法地址?

  1. 自己去解析内存中加载的Mach-O文件,根据Mach-O文件格式先找到Class信息,然后找到对应的Method信息,Method中保存了方法IMP(方法地址)和SEL(方法名)。但是这种方案涉及到了逆向工程的一些东西,过于复杂。

  2. App在点击App启动到main()之前,会使用dyld初始化运行环境,加载程序相关依赖库,并对其链接和初始化,然后runtime会项目中所有类进行类结构初始化,然后调用所有load方法,最后dyld返回main函数地址,然后main函数被调用。这个过程会把程序中的所有方法加载到内存中,我们在main()之后就可以使用objc提供的一系列方法拿到所有的Class和其对应的Method。

    unsigned int classCount;
    const char **classNames;
    // 获取指定类所在的动态库
    const char * _Nonnull image = class_getImageName(self.class);
    // 获取指定库或框架中所有类的名称
    classNames = objc_copyClassNamesForImage(image, &classCount);
    

综合来看,对于当前需求第二种方案会更加方便实现一些,那么现在来看第二种方案的技术实现。

技术实现

  1. 核心流程图

    crash_07.png
  2. 代码实现

    + (NSDictionary *)crashParseForKeyInfo:(NSArray *)stackSymbols {
        unsigned int classCount;
        const char **classNames;
        // 获取指定类所在的动态库
        const char * _Nonnull image = class_getImageName(self.class);
        // 获取指定库或框架中所有类的名称
        classNames = objc_copyClassNamesForImage(image, &classCount);
        NSDictionary *methodAddressDict = [self getMethodAdressDict:classNames classCount:classCount];
    
        ...
    
        NSMutableDictionary *tmpDict = [NSMutableDictionary dictionary];
        [stackSymbols enumerateObjectsUsingBlock:^(NSString * _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) {
    
            ...
            __block NSString *crashMethodAddress = nil;
            [methodAddressDict enumerateKeysAndObjectsUsingBlock:^(NSString *key, NSDictionary *obj, BOOL * _Nonnull stop) {
            
                ...
            
                if (crashMethodAddressNumber >= methodAddressNumber) {
                    tmpSpan = crashMethodAddressNumber - methodAddressNumber;
                    if (tmpSpan > kAdressSpanKey) {
                        return;
                    }
                    if (tmpSpan >= minSpan) {
                        return;
                    }
                    minSpan = tmpSpan;
                    crashMethodAddress = key;
                }
            }];
        
            NSDictionary *crashInfoDict = methodAddressDict[crashMethodAddress];
            tmpDict[kCrashClassNameKey] = crashInfoDict[kCrashClassNameKey];
            tmpDict[kCrashMethodNameKey] = crashInfoDict[kCrashMethodNameKey];
            *stop = YES;
        }];
        return [tmpDict copy];
    }
    
    // 获取当前项目所有类的className、methodName
    + (NSDictionary *)getMethodAdressDict:(const char **)classNames classCount:(unsigned int)count {
        NSMutableDictionary *tmpAddressDict = [NSMutableDictionary dictionary];
        for (unsigned int i = 0; i < count; i++) {
            const char *className = classNames[i];
            NSString *classNameStr = [NSString stringWithUTF8String:className];
            // 根据字段串反射为类对象
            Class cls = NSClassFromString(classNameStr);
            // 获取当前类对象的所有实例方法
            [tmpAddressDict addEntriesFromDictionary:[self getClassInfo:cls]];
            // 获取当前类对象的所有类方法
            [tmpAddressDict addEntriesFromDictionary:[self getClassInfo:object_getClass(cls)]];
        }
    
        return [tmpAddressDict copy];
    }
    
    // 获取当前类或元类的信息
    + (NSDictionary *)getClassInfo:(Class)cls {
        unsigned int methodCount;
        Method *methodList = class_copyMethodList(cls, &methodCount);
        NSMutableDictionary *tmpDict = [NSMutableDictionary dictionary];
        for (unsigned int j = 0; j < methodCount; j++) {
            Method method = methodList[j];
            // 获取方法IMP
            IMP imp = method_getImplementation(method);
            // 获取方法SEL
            SEL selector = method_getName(method);
            NSString *methodAddress = [NSString stringWithFormat:@"%p", imp];
            NSMutableDictionary *tmpInfoDict = [NSMutableDictionary dictionary];
            NSString *className = NSStringFromClass(cls) ? NSStringFromClass(cls) : @"";
            NSString *methodName = NSStringFromSelector(selector) ? NSStringFromSelector(selector) : @"";
            tmpInfoDict[kCrashClassNameKey] = className;
            tmpInfoDict[kCrashMethodNameKey] = methodName;
            tmpDict[methodAddress] = [tmpInfoDict copy];
        }
        free(methodList);
    
        return [tmpDict copy];
    }
    
  3. 效果

    线上stackSymbols crash日志

    stackSymbols: 
    0   CoreFoundation                      0x000000018c7d6768 4FBDF167-161A-324C-A233-D516922C67E5 + 1218408;
    1   libobjc.A.dylib                     0x00000001a129d7a8 objc_exception_throw + 60;
    2   CoreFoundation                      0x000000018c6d55f8 4FBDF167-161A-324C-A233-D516922C67E5 + 165368;
    3   MyApp                               0x0000000102f37cd8 MyApp + 5536984;
    4   UIKitCore                           0x000000018f3d71d0 33B02AB5-5DAF-3249-8DC6-5872DF830EC5 + 14537168;
    5   UIKitCore                           0x000000018f3d6d78 33B02AB5-5DAF-3249-8DC6-5872DF830EC5 + 14536056;
    6   UIKitCore                           0x000000018f3d7538 33B02AB5-5DAF-3249-8DC6-5872DF830EC5 + 14538040;
    7   UIKitCore                           0x000000018f6a1c98 33B02AB5-5DAF-3249-8DC6-5872DF830EC5 + 17464472;
    8   UIKitCore                           0x000000018f1d46c4 33B02AB5-5DAF-3249-8DC6-5872DF830EC5 + 12428996;
    9   UIKitCore                           0x000000018f1c303c 33B02AB5-5DAF-3249-8DC6-5872DF830EC5 + 12357692;
    10  UIKitCore                           0x000000018f1f6f10 33B02AB5-5DAF-3249-8DC6-5872DF830EC5 + 12570384;
    11  CoreFoundation                      0x000000018c74f5e0 4FBDF167-161A-324C-A233-D516922C67E5 + 665056;
    12  CoreFoundation                      0x000000018c749704 4FBDF167-161A-324C-A233-D516922C67E5 + 640772;
    13  CoreFoundation                      0x000000018c749cb0 4FBDF167-161A-324C-A233-D516922C67E5 + 642224;
    14  CoreFoundation                      0x000000018c749360 CFRunLoopRunSpecific + 600;
    15  GraphicsServices                    0x00000001a3d87734 GSEventRunModal + 164;
    16  UIKitCore                           0x000000018f1c4584 33B02AB5-5DAF-3249-8DC6-5872DF830EC5 + 12363140;
    17  UIKitCore                           0x000000018f1c9df4 UIApplicationMain + 168;
    18  MyApp                               0x00000001029f40c8 MyApp + 16584;
    19  libdyld.dylib                       0x000000018c405cf8 E574A365-9878-348A-8E84-91E163CFC128 + 7416
    

    线上堆栈符号化结果:

    symbolsString: 
    className:HomeViewController; methodName:getName:;
    className:AppDelegate; methodName:application:didFinishLaunchingWithOptions:
    
  4. 方案缺点

    • 受限于objc相关方法的局限性,目前堆栈符号化仅仅是针对OC代码的,C和C++代码无法完成符号化。

    • 需要用项目所有方法(目前项目大约19万条左右)进行遍历,需要消耗较长时间完成符号化工作,根据需求特点做完优化后耗时大约1s左右。

    但上述方案仅仅是在App启动即发生Crash时才会进入上述符号化流程,故不会影响正常启动。

问题

多个Crash信息收集功能并存

当工程大到需要多个团队共同开发一个App的时候,就可能会出现多个Crash收集功能并存的问题,如果处理不好后边加入的收集功能可能就会影响已经存在的Crash收集模块的正常使用。

比如多方均通过NSSetUncaughtExceptionHandler注册异常处理,如果想要让大家的收集功能都能发挥各自的作用,可以采用下面的解决方案。

+ (void)registerHandlerWithMonitorString:(NSString *)key {
     if (NSGetUncaughtExceptionHandler() != MyExceptionHandler) {
        OldHandler = NSGetUncaughtExceptionHandler();
    }
    NSSetUncaughtExceptionHandler(&MyExceptionHandler);
}

void MyExceptionHandler(NSException *exception) {
    NSArray *callStack = exception.callStackSymbols;
    // 处理crash信息
    
    // 调用之前已经注册的handler
    if (OldHandler) {
        OldHandler(exception);
    }
}

在注册时将之前别人注册的handler取出备份,在自己的MyExceptionHandler中处理完后将别人的handler注册传递回去,这样大家都能完成自己的工作,皆大欢喜~

附录

如何debug环境在Xcode中调试signal异常呢,因为Xcode屏蔽了signal回调,所以需要lldb命令来帮助我们拿到回调信息。在执行abort()代码处打断点,执行到这行代码时控制台输入:

pro hand -p true -s false SIGABRT

其中,SIGABRT可替换为其他signal异常类型。回车后控制台输出:

NAME         PASS   STOP   NOTIFY
===========  =====  =====  ======
SIGABRT      true   false  true 

表示已跳出Xcode屏蔽,可以拿到signal异常回调了。

你可能感兴趣的:(iOS Crash及其符号化)