iOS 符号二三事


一、了解符号

1、基础概念

  • 符号(Symbol):简单来说,类、函数和变量的统称;类名、函数名或变量名称为符号名(Symbol Name);

  • 按类型分,符号可以分三类:

    • 全局符号:目标文件外可见的符号,可以被其他目标文件引用,或者需要其他目标文件定义;
    • 局部符号:只在目标文件内可见的符号,指只在目标文件内可见的函数和变量;
    • 调试符号:包括行号信息的调试符号信息,行号信息中记录了函数和变量所对应的文件和文件行号
  • 符号表(Symbol Table):符号表是内存地址与函数名、文件名、行号的映射表;每个定义的符号有一个对应的值,叫做符号值(Symbol Value),对于变量和函数来说,符号值就是他们的地址;符号表元素如下所示:

    1
    <起始地址> <结束地址> <函数> [<文件名:行号>]
  • dSYM(debug symbols):是iOS的符号表文件,存储16进制地址信息和符号的映射文件;文件名通常为:xxx.app.dSYM,类似Android构建release产生的mapping文件;利用dSYM文件文件,可以将堆栈信息中地址信息还原成对应的符号,帮助问题排查;

2、符号的存储位置

  • Mach-O 是 Mac/iOS 平台上通用的二进制格式;App可执行文件、动态库、静态库等都是 Mach-O 格式;更多Mach-O的知识可看Mach-O文件周边二三事;
  • Mach-O 中可以保存有调试信息,Mach-O采用DWARF (Debug With Arbitrary Record Foramt) 的标准调试信息格式。DWARF 中调试信息也可以单独用文件保存,叫 dSYM (Debug Symbol File) 文件,格式后缀名称为 .dSYM。
  • DWARF(debugging with attributed record formats):是一种调试信息的存储格式,用来支持源代码级别的调试。Release打包时候会把调试符号等裁剪掉,但是线上统计到的堆栈我们仍然要能够知道对应的源代码,这时候就需要把符号写到另外一个单独的文件里,这个文件就是dSYM。
  • 一般地,静态库的全局符号、局部符号以及行号信息等保存在对应的二进制文件中;文件中 Symbol Table 存储着全局符号和局部符号DWARF 存储着符号的行号信息。
  • 一般地,App可执行文件和动态库的Mach-O文件和dSYM都会保存全局符号局部符号,而dSYM文件中的 DWARF存储着行号信息。

3、符号的作用

  • 符号让库文件可以被引用,让目标文件可以相互链接生成可执行文件;
  • 符号还可以帮助开发者定位问题,比较常见的是:将Crash日志中的地址信息转化成对应的函数名以及文件名、文件行号信息
  • Release模式会将二进制中裁剪掉符号的,不过Release模式下默认有dSYM文件,可以根据dSYM文件来做符号化。

二、Xcode符号相关配置

1、配置简介

Xcode 编译时有几个选项是和符号是相关的。

  • Debug Information FormatDWARF OR DWARF with dSYM File。 这配置对于静态库会无影响;对动态库有影响:设置为 DWARF with dSYM File,生成动态库时会生成相应的 dSYM 文件;如果设置为 DWARF,则 dwarf 段即调试信息没有地方存放将丢失。

  • Generate Debug Symbols:设置为YES,编译生成目标文件时会生成调试信息;设置为 NO,那么 dwarf 段不会生成,也不会有 dSYM 文件生成,并且调试过程使用的断点也不会生效,因为地址已经无法和对应代码行关联起来了。

  • Deployment

    • Deployment Postprocessing:如果为 YES在编译生成目标文件之后要进行后续处理;如果为 NO,则不会有后续处理;使用 Xcode Archive 进行编译,Deloyment Postprocessing 的值恒为YES

    • Strip Linked Product:如果为 YES,则进行裁剪;如果为 NO,则不进行裁剪;至于裁剪什么级别的符号由 Strip Style 配置决定;如果Deployment Postprocessing为NO,Strip Linked Product设置无效;

    • Strip Style(Deployment Postprocessing和Strip Linked Product都为YES,才生效;去除的是二进制中的符号):

      • Debugging Symbols :会将调试符号从二进制中删除掉,即去除 DWARF 信息;
      • Non-Global Symbols :会将局部符号和调试符号从二进制中删除掉,即去除 DWARF 信息以及部分 Symbol Table 中的信息;
      • All Symbols :去除全部符号,即去除 DWARF 中的调试信息以及Symbol Table 中目标模块定义的全局、局部符号信息。

      补充1: 动态库和静态库不能去除全部符号(Strip All Symbols),要保留全局符号(选择Non-Global Symbols),他们是库和其他库链接时沟通的桥梁;失去了全局符号,动态库和静态库就成为了黑盒。

      补充2: 去除符号的操作对于 dSYM 文件中的符号信息没有影响;对于动态库和可执行二进制文件,可以将符号尽可能去除掉减少二进制体积的大小。需要符号进行符号化崩溃日志时,再从 dSYM 文件中找对应符号。

  • Symbols Hidden by Default :这是全局的开关,用来设置符号的默认可见性,设置为YES,会把所有符号都定义成”private extern”;

    • 也可以可以使用编译器属性__attribute__((visibility("default")))__attribute__((visibility("hidden")))来控制符号的可见性;

      1
      2
      __attribute__((visibility("default"))) void MyFunction1() {} //可见
      __attribute__((visibility("hidden"))) void MyFunction2() {}  //不可见

2、App的Debug模式下配置

  • Debug Information Format 设置为 DWARF;因为生成 dSYM 文件是一个比较耗时的过程,选择DWARF能节省调试时间;

  • Generate Debug Symbols:设置为 YES;这样才能支持断点调试;注意Debug模式下,Deployment Postprocessing 一定要NO,否则Generate Debug Symbols的设置了YES,也不支持断点调试;

  • Deployment配置

    • Deployment Postprocessing 设置为 NO
    • Strip Linked Product 设置为 NO
    • Strip Style 设置为 All Symbols (因为Strip Linked Product 是NO,Strip Style随便配置什么都无影响)

    如此配置后,App二进制中带有全局符号和局部符号信息,二进制本身可以支持不使用 dSYM 文件自解析出符号(自解析出的符号不包含行号)。

  • Symbols Hidden by Default 设置为YES;

3、App的Release模式下配置

  • Debug Information Format 设置为 DWARF with dSYM File;这样生成ipa的同时,会一并生成 dSYM文件。

  • Generate Debug Symbols:设置为 NO;这样才能支持断点调试;注意Debug模式下,Deployment Postprocessing 一定要NO,否则Generate Debug Symbols的设置了YES,也不支持断点调试;

  • Deployment配置

    • Deployment Postprocessing 设置为 YES
    • Strip Linked Product 设置为 YES
    • Strip Style 设置为 All Symbols (因为Strip Linked Product 是NO,Strip Style随便配置什么都无影响)

    如此配置,App中不带任何符号,可以减少安装包大小,还能避免符号泄漏。定位问题时,可以通过 dSYM 文件去获取符号。

  • Symbols Hidden by Default 设置为YES;

4、静态库和动态库配置

  • 静态库配置:Debug Information Format默认就好,Generate Debug Symbols设置YES,Deployment Postprocessing设置为 NO,Strip Linked Product 设置为 NO,Strip Style 默认就行(Strip Linked Product设置为 NO,Strip Style配置无所谓Symbols Hidden by Default设置为NO;

    静态库如此配置,其实是没有裁剪二进制的符号的;因此,静态库的二进制的大小将会大大增加,但是静态库的大小并不影响最终安装包二进制的大小,同时调试符号能支持安装包或者链接的动态库生成相应的 dSYM 文件,方便定位静态库中的问题。

  • 动态库配置:Debug Information Format分Debug和Release,配置同App;Generate Debug Symbols设置YES,Symbols Hidden by Default设置为NO;

    • ReleaseDeployment Postprocessing 设置为 YES;Strip Linked Product 设置为 YES; Strip Style 设置为 Non-Global Symbols
    • Debug下Deployment Postprocessing设置为 NO;Strip Linked Product 设置为 NO;Strip Style 设置啥都可以(因为Strip Linked Product 是NO,Strip Style随便配置什么都无影响)

    如果动态库Symbols Hidden by Default设置为 YES,动态库仍然能编译通过,但是App会报一堆链接错误,因为符号变成了hidden。

三、符号小知识

1、weak symbol

  • symbol默认是strong的,但是可以增加 __attribute__ ((weak))属性将其变成weak symbol;weak symbol在链接时候比较特殊:

    • strong symbol必须有实现,否则会报错;
    • 不可以存在两个同名的strong symbol
    • strong symbol可以覆盖weak symbol的实现
  • 应用场景:用weak symbol提供默认实现,外部可以提供strong symbol把实现注入进来,以此来实现依赖注入

2、other linker flags配置

  • ld(静态链接器)链接静态库时,只有.a中的某个.o符号被引用的时候,这个.o才会被ld写到最后的二进制文件中,否则会被丢掉,other linker flags提供三个选项来解决保留代码的问题。
    • -ObjC 保留所有Objective C的代码;
    • -force_load 保留某一个静态库的全部代码;
    • -all_load 保留参与链接的全部的静态库代码;
  • 很多SDK在集成进App,会要求在other linker flags里添加*-ObjC*。

3、找不到符号的错误

  • 当链接的时候类找不到了,会报错符号_OBJC_CLASS_$_CLASSNAME找不到;

  • 之前在接入 AlipaySDK遇到过(原因是: AlipaySDK和阿里百川SDK冲突导致,需要接入UTDID framework

    1
    2
    3
    4
    5
    Undefined symbols for architecture x86_64:
    "_OBJC_CLASS_$_UTDevice", referenced from:
    objc-class-ref in AlipaySDK
    ld: symbol(s) not found for architecture x86_64
    clang: error: linker command failed with exit code 1 (use -v to see invocation)

    补充1:如果类的符号没有被裁减掉,运行时就用_OBJC_CLASS_$_CLASSNAME作为参数,通过dlsym来获取类指针。

    补充2:nm app_name.app/app_name 执行返回中,小写字母对应着本地符号,大写字母表示全局符号;U表示undefined,即未定义的外部符号;

4、lldb符号调试

  • 运行时,使用还可以用lldb去查询符号相关的信息;

  • 查看符号的定义

    1
    image lookup -t symbol_name
  • 查看符号的位置

    1
    image lookup -s symbol_name
  • 设置符号断点

    1
    breakpoint set -F "symbol" #也可通过Xcode的GUI能设置

四、符号裁剪后...

1、概述

  • 开发阶段,不会裁剪符号,所以一切都比较美好;对一个地址进行符号化比较直接:找到地址所属的内存镜像,然后定位该镜像中的符号表,最后从符号表中匹配目标地址的符号。

  • 但是裁剪符号的包,如企业内测包AppStore选择了裁剪符号的方式,甚至是裁剪全部符号(Strip Style 设置为 All Symbols );常规符号化不能解决问题;

    符号裁剪的好处:减少了安装包大小,还避免符号泄漏;

  • 企业内测包不同于AppStore包,主要用于内部测试和灰度,有时候需要收集问题的上下文信息,这些信息中包括发生问题的代码行数、代码文件和函数名,甚至包括堆栈符号;

  • 裁剪符号后,[NSThread callStackSymbols] 获取的很多堆栈地址需要符号恢复;而此时的dladdr也不能根据地址获取符号信息;

2、获取当前位置行号等

  • 不论符号有没有被裁剪,都可以通过以下C语言中的预定义符获取,具体如下:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    __FILE__      //File path
    __LINE__      //Code Line
    __FUNCTION__  //Funcation Name
     
    //demo
    printf("File = %s\
    Line = %d\
    Func=%s\
    ", __FILE__, __LINE__, __FUNCTION__);
  • 不论符号有没有被裁剪,也可以通过Objective-C的_cmd方法获取当前方法名,eg如下

    1
    printf("call %s", [NSStringFromSelector(_cmd) UTF8String]);
  • 通过预定义符_cmd方法获取当前调试符号信息,在系统API中也常有使用,如NSAssert宏的使用,源码如下:

    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
    #define NSAssert(condition, desc, ...)
    \\
        do {



    \\

    __PRAGMA_PUSH_NO_EXTRA_ARG_WARNINGS \\

    if (__builtin_expect(!(condition), 0)) {

    \\
                NSString *__assert_file__ = [NSString stringWithUTF8String:__FILE__]; \\
                __assert_file__ = __assert_file__ ? __assert_file__ : @"<Unknown File>"; \\

        [[NSAssertionHandler currentHandler] handleFailureInMethod:_cmd \\


    object:self file:__assert_file__ \\

       
    lineNumber:__LINE__ description:(desc), ##__VA_ARGS__]; \\

    }



    \\
            __PRAGMA_POP_NO_EXTRA_ARG_WARNINGS \\
        } while(0)
    #endif

    NSAssert适合于Objective-C方法,利用__FILE____LINE___cmd来获取发生问题时候的代码文件路径、代码行数、方法名,然后将这些交给[[NSAssertionHandler currentHandler] handleFailureInMethod:object:file:lineNumber:description:]处理;

3、恢复当前线程堆栈符号方案

  • 我们习惯性利用[NSThread callStackSymbols]获取当前线程调用堆栈符号信息,但是这只在Debug模式下比较理想;在符号被裁剪的情况下,获取的地址需要做符号恢复;

  • 如果利用dSYM文件来符号化是可以的;可以参考个另类方案:根据所有的类方法、方法名、方法实现地址,将调用栈的内存地址符号化;类似Frida调用栈符号恢复,方案具体描述:

    • App启动后x秒,获取所有的类方法、方法名、方法实现地址
    • 执行[NSThread callStackReturnAddresses]获取调用栈的内存地址;
    • 遍历所有的方法地址 与 调用栈的地址比较并计算距离,如果方法地址小于目标地址且距离最小,那么该方法就是我们要找到的符号。
    • 最后,将调用栈上面的所有地址替换成对应的符号即可。
  • 需要说明的是,这里的符号指的是:Objective-C的函数符号,因为如果C函数符号被strip后,是没有办法恢复其符号的;

  • 在符号裁剪情况下,dladdr一般不能通过地址获取到符号;可以用如下代码测试

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    NSArray<NSNumber *> *addresses = [NSThread callStackReturnAddresses];

    NSNumber *firstAddress = [addresses objectAtIndex:0];
    Dl_info info;
    int result = dladdr((const void *)[firstAddress integerValue], &info);
    if (result != 0 && info.dli_sname) {
        //Debug模式配置
        printf("通过dladdr函数获取symbol_name = %s", [[NSString stringWithUTF8String:info.dli_sname] UTF8String]);
    } else {
        //Release模式配置
        printf("符号裁剪后,不能通过dladdr函数获取符号,需要[新符号恢复方案]");
    }

    如果dladdr能通过地址拿到符号信息,就说明符号没有裁剪,可以直接用[NSThread callStackSymbols]

4、to be continued...

  • 在符号被裁剪的情况下,利用些必要手段获取更多的上下文信息,能很好帮助问题解决,这些上下文信息还包括:设备信息,用户主要操作路径,当前的ViewController等;
  • 对于Crash问题,做好符号化是非常正常的选择;是符号化的主要应用场景;

五、Crash与符号化

1、概述

  • 分析好Crash问题,必然要做好Crash捕获堆栈信息收集堆栈符号化三件大事;

2、Crash捕获

  • Crash主要有两类:Mach 异常Objective-C 异常(NSException)引起的;

  • Mach异常是最底层的内核级异常,如EXC_BAD_ACCESS(内存访问异常);而Objective-C 层不能获取Mach异常,但是Mach 异常到了 BSD 层会转换为对应的 Signal 信号,我们可以注册SIGABRT, SIGBUS, SIGSEGV等信号发生时的处理函数。

    1
    2
    3
    4
    5
    6
    7
    8
    //注册处理SIGSEGV信号
    signal(SIGSEGV,handleSignal);
    // 注册处理其他信号 ....

    //信号处理函数
    static void handleSignal( int sig ) {

    }
  • NSException异常是iOS库或者各种第三方库或Runtime验证出错误而抛出的异常。如NSRangeException(数组越界异常),它们可以被try catch捕获(苹果不建议用),如果未被捕获或被@throw抛出,可以通过注册NSSetUncaughtExceptionHandler函数来捕获处理。

    1
    2
    3
    4
    5
    6
    7
    8
    //注册异常处理函数
    NSSetUncaughtExceptionHandler(&uncaught_exception_handler);
    //异常处理函数
    static void uncaught_exception_handler (NSException *exception) {
      //可以取到 NSException 信息
      //...
      abort();
    }

3、堆栈信息收集

  • 捕获到Crash后,马上需要收集堆栈信息;目前这些是有现成的方案支持,如PLCrashReporter、KSCrash等;
  • 甚至友盟、Bugly 不仅提供Crash捕获和堆栈信息收集,还会集成分析,统计等服务,非常完善;

4、堆栈符号化

  • 目前常见的符号号手段

    • symbolicatecrash + atos命令:具体可见iOS实录14:浅谈iOS Crash(一) 的Crash日志符号化;
    • 通过 dSYM 文件提取地址和符号的对应关系,进行符号还原;
  • 第一种做法一般是研发自己用;第二种适用于做成标准方案,批量帮助将线上的Crash 堆栈符号还原;

5、后续

  • 符号后,就是Crash分析和解决了,是另一个课题了,早年总结了两篇Crash方面文章,可以作为入门材料看下:iOS实录14:浅谈iOS Crash(一)、iOS实录15:浅谈iOS Crash(二)。

参考文章

About macOS & iOS symbol

深入理解 Symbol

iOS Crash 捕获及堆栈符号化思路剖析

IOS 温习之路 ”Other Linker Flags“