扣丁书屋

iOS 符号化:基础与进阶

前言

符号化能帮助我们在定位 bug 、崩溃和性能瓶颈时,从运行时日志与堆栈找到根本的代码原因;相信大家了解 atos 或 dSYM 等常用符号化工具,但这些工具是如何运作的?本篇文章将围绕符号化的定义、原理、实践与技巧,带领大家对符号化进一步深层次了解;本篇文章是基于 Session 10211 - Symbolication: Beyond the basics 撰写,Session 的演讲者是Apple - 性能工具团队的 Alejandro Lucena 工程师

什么是符号化?

「将 App 运行时信息映射为源码」长话短说就是将运行时信息转换为源码信息,符号化是一种机制,将我们在设备运行时 App 的内存地址和关联的指令信息转换为源码文件中具体文件名、方法名、行数等;可以理解为将运行时机器如何看待处理我们 App 的信息转换成我们开发者如何看待处理我们的 App(源码)。如果缺少这层转换,哪怕只有几行的代码的 App,bug 定位也变得难以进行;

Demo

本文为了带领大家了解符号化的原理,全文所用到的项目是一个简单的只有几行的Demo App,他所有代码如下: demo 的逻辑很简单:randomValue() 可以生成值区间在1-100间的随机数numberChoices() 可以生成一个包含 10 个上述随机数的数组selectMagicNumber(choices: numbers) 可以从入参 numbers 数组中,取出一个指定下标的元素generateMagicNumber() 按部执行上述操作,返回取出下标的元素 此处的 MAGIC_CHOICE 是一个随机值

日常崩溃日志的符号化

第一次执行这个 App 就崩溃了,查看生成的错误日志,里面没有很直观的信息,是一堆内存地址,我只能看到 App 在主线程上 crash 了;

我尝试直接 debug 我的 App,但在执行中没有复现该问题,看来调试器也不一定能帮得了忙;多次尝试之后终于复现了,但程序崩溃在汇编中,也没有直观的信息,汇编太硬核,搞不定。

上面的崩溃日志和汇编堆栈显然都不能直接解决问题,但在符号化的帮助下,我们可以不从这些原始内存地址中挖掘错误;相信大家都知道在 Xcode Organizer 中载入 AppdSYM 文件,他会重新处理崩溃日志,载入后我们就可以得到下面这种可读的、可以获得调用信息、文件名、具体行数的崩溃日志,崩溃日志直接告诉我,崩溃时发生了数组越界访问,非常直观;根据这些信息回溯到代码,我们也容易发现随机值 MAGIC_CHOICE 容易导致,在访问只有 10 个长度数组访问时,发生数组越界;

使用 atos 命令行工具,我们也可以得到上述信息

日常 Instruments 堆栈的符号化

另一个符号化的例子是,在 Instruments 中进行性能优化时,检测提示该 App 会周期性的执行大量写入操作,出现了周期性的高负荷区间和低负荷区间;但是默认右下角显示的堆栈信息只能提示 App 正在写入文件,无论高负荷还是低负荷,都提示了同样堆栈;

很明显这两个区间不会执行同样代码,这原因是因为当前的 Instruments 堆栈是被部分符号化的,一般而言,在堆栈中没有具体文件名和具体行数时,符号化是不彻底,此时我们也可以手动在 Instruments 载入 dSYM 文件,载入后,我们再查看高负荷区,明确提示有多余的调试代码 addDebugLog() ,而在低负荷区没有该方法调用;dSYM 不仅可以使只包含内存地址信息的崩溃日志可读,还可以帮助 Instruments 堆栈信息更加有用,这些都能帮我们找到问题背后的代码问题;

符号化原理

既然符号化的工具可以帮助我们定位代码问题,你肯定会问,What ?why?为什么dSYM 可以帮助符号化?How?dSYM如何帮助完成了符号化?dSYM是符号化的一切吗?除了崩溃日志和Instruments ,别的地方还能载入 dSYM 吗?atos-o``-i``-l 各自有什么用处?Instruments 为什么未能直接提供完全符号化的堆栈?Xcode 编译设置对符号化有何影响?带着这些问题,让我们深入探究一些符号化的原理。

为此我们首先分解介绍符号化的两个步骤:第一步:从内存地址回溯到文件**第二步:还原运行时调试信息**

第一步 - 与符号化相关的地址与转换

从内存地址回溯到文件地址,指的是将运行时随机的内存地址转换为磁盘上二进制文件中稳定可用的文件信息;正如内存地址有内存空间一样,二进制文件在磁盘上也有地址空间;但这两种地址空间不能直接转换,需要一种地址转换机制;

磁盘上的地址空间与二进制文件地址

磁盘地址空间的地址是编译时 Linker 链接器赋予二进制文件的地址;具体而言,linker 会把二进制代码分组,分组后的部分称为段 Segment,每个二进制段都包含了一些数据和属性,例如段的名称,大小,地址等;举例来说,二进制文件中的 __TEXT 段会包含对应的方法和函数,__DATA 段会包含程序的全局状态,例如全局变量;每个段都被赋予了一个独一无二的起始地址,这种设计保证了段与段之间不会重叠;

-w871

具体而言 linker 会把段信息记录在可执行文件头部,作为 Mach-O 头的一部分;众所周知, Mach-O 是一种可执行文件和库的文件格式,Mach-O 头中包含许多与段的属性信息相关的 load 指令,操作系统内核通过读取这些 load 指令来把对应的二进制段加载入内存;如果 App 用到了 Universal2 打包技术,那每种架构都会有与之对应的 Mach-O 头和相关段信息;

-w860

上面讲了段信息和 load 指令,让我们来结合最初的小 demo,实践查看一下相关的 load 指令;我们可以通过 otool -l 来输出 load 指令信息,结合 grep(字符串筛选工具)可以过滤出 LC_SEGMENT_64load 指令,如下图所示;输出结果提示 __TEXT 段的起始位置为 vmaddr 所示地址,段的长度为 vmsize 所示字节大小;

将二进制文件载入内存

由以上信息,我们了解到 load 指令会包含载入的地址和大小,那为什么内核实际通过 load 指令载入后,二进制段的内存地址和这个 linker 生成的地址不一致?下图中内存地址和 linkerABC 地址有啥关系?后文中会将 linker 生成的地址简称为 ABC

Address space layout randomization - 地址空间布局随机化技术

事实上,现在操作系统中都会有一种「地址空间布局随机化」技术,该技术是一种防范内存损坏漏洞被利用的计算机安全技术。ASLR 通过随机放置进程关键数据区域的地址空间来防止攻击者能可靠地跳转到内存的特定位置来攻击制定函数。现代操作系统一般都加设这一机制,以防范恶意程序对已知地址进行 Return-to-libc 攻击。简言之,内核在加载二进制段前,会初始化一个随机值,称为 ASLR Slide 「内存空间随机分布偏移量」,后文中会把该偏移量简称为 S;之后内核会将该偏移量 S 叠加到 linker 生成的 load 指令的地址 ABC上;因此,内核在执行 load 指令时,不会按照原始的 linker 地址直接载入到内存地址 ABC 中,而是载入到 A+SB+SC+S,我们可以把这些实际的 load 加载地址称为 Load Address「加载地址」,后文中,Load Address 将简称为 L

通过了解 ASLR 技术,我们弄明白了 linker addressload address 之间的差值是 ASLR Slide 随机内存地址分布偏移量;我们可以得到该公式 ALSR Slide = Load Address - Linker Address, 简化为 S = L - A

如何获取实际的 Linker Address 和 Load Address

前面已经提到 otool 可以帮助我们查看二进制文件的 load 指令信息,进而得到 linker address (该地址也可以视为 file address 「文件地址」) 而获得运行时内存地址中的 Load Address,可以通过崩溃日志中的 Binary Image 列表,Instruments 提供的堆栈,或者通过 vmmap 命令行工具来获取;具体如何使用 vmmap 在后文中会有讲解

计算 ASLR Slide 随机内存偏移量

结合实践,我们需要知道 ASLR Slide 随机内存偏移量,才能够从崩溃日志和 Instruments 堆栈中的内存地址,减去 ASLR Slide 而获得文件地址;因此需要先计算出 ASLR Slide,计算 ASLR Slide 一般以特定段(如 __TEXT)的 load addresslinker address 来相减得出,如何获取这俩地址上面已经说了,结合实践我们从崩溃日志中获取了 __TEXT 二进制段的 load address0x10045c000 ;通过 otool 我可以获得 __TEXT 二进制段的 linker address0x100000000 ;将这两者相减我们就可以的得到 ASLR Slide = 0x45c000

有了 ASLR Slide ,我们可以从崩溃日志的运行时内存地址,换算出磁盘地址空间中的文件地址,如下图所示,我们可以得到我们 demo 中崩溃的堆栈的文件地址为 0x10003b70 ,有了文件地址,我们可以用来查看源码,这个后续再说。我们先继续探索一下其他计算 ASLR Slide 的姿势

如下图所示,otool 命令行工具可以用来查看崩溃时发生问题的指令信息, 传入 -tV 可以输出汇编堆栈;-arch arm64 是为了让 otool 正确处理 Universal 2 技术编译的产物;输出结构对应上述文件地址,显示此是 brk 指令,汇编中的 brk 一般代表着 App 出现了异常或问题;

atos 命令行工具也可以帮我们计算 ASLR Slideatos-o 指令会输出 file segment address-l 指令会输出 load address;

除了 atosotool ,还有 vmmap 命令行工具也可以帮助我们获取 load address ,我们可以用 vmmap 来验证上面的计算结果, vmmap 输出崩溃时 __TEXT segmentload address ,使用之前公式可以计算出本次运行的 ASLR Slide0x104d14000 ,将本次崩溃日志中的 runtime address - ASLR Slide 得到了 file address0x100003b70 ,和之前计算的 file address 一样;

上述两次不同运行时, 不同崩溃日志,不同的 ASLR Slide 能够得到同一个 file address ,这不是巧合;是因为内核每次运行的 ASLR Slide 都不同,因此不同时间,不同设备的崩溃日志中所对应的内存地址会变化,但实际的 linkder address 是一样的;基于此,虽然内存地址每次变化,我们仍然可以定位到相同的 file address至此,我们发现了一种机制,能让我在随机的运行时内存中,定位到我们 App 源码级别所发生的的事;通过这种映射机制能够让我们从运行时的堆栈信息中,回溯到 App 源码中;

小结 - 从内存地址回溯到文件地址

以上内容就是「符号化两步走」中的第一步:从内存地址回溯到文件,总结一下该步骤中的内容和工具

  1. App 和 库的二进制文件格式是 Mach-O ,其中 Mach-O 的头中存放了二进制段的关联信息和 load 指令,这些二进制段是 linker 创建的,其中包括了二进制段的地址信息 linker address
  2. otool -l 可以帮助我们输出 Mach-O 中指定二进制段的地址和属性信息,其中包括 linker address
  3. 崩溃日志中的 binary image 列表中可以获取崩溃发生时的 load address
  4. vmmap 也可以获得正在运行 Appload address
  5. ASLR Slide + Linker address = Load address

第二步 - 分析调试信息

有了以上基础,我们可以进一步讨论符号化的第二步:分析调试信息;调试信息一般包含了 file address 和源码之间的关系信息;Xcode 会在编译时生成这些关系信息并存放为 dSYM 文件,也可以把这些关系信息内置在二进制编译产物中;

这些调试信息有三种类型,每一种都提供了不同级别与 file address 关联的调试信息;

  1. Function starts
  2. Nlist symbol table
  3. DWARF

下图中展示了这三种工具分别提供了对应维度的调试信息

Function Starts

从上图中可知,function starts 相较于其他工具提供最少的信息,该工具只能提供函数对应的起始地址,具体而言,function starts 会提供函数的起始地址和其调用的所在的地址;但这其中不会告诉你这调用地址里是否有其他函数,他只能告诉你这里有个函数出问题 ;

function starts 通过编码 __LINKEDIT 二进制段中的 linker 地址列表来提供该功能;function starts 基于直接内置在 App 编译产物中,通过 mach-O 文件的 load 指令的 LC_FUNCTION_STARTS 来描述 function starts

实践中,可以通过 symbols -onlyFuncStartsData 命令行工具来输出 function starts 相关信息,如下图所示,其中的 null 是因为 function starts 不提供函数名称,所以用 null 来做函数名称的占位符;

基于 function starts 我们可以对未符号化的崩溃日志进行处理,先从崩溃日志的内存地址 0x10045fb70 减去之前计算好的 ASLR Slide``0x45c000得到 file address``0x100003b70; 然后结合 function starts 输出结果,我们发现只有第一个地址 0x100003a68 小于我们算出的 file address``0x100003b70,所以只有这第一个地址包含了错误发生的地址;基于此我们计算这两个地址之间偏移了 0x108,换算成十进制 是 264,也就是我们 file address 与实际错误发生地址之间有 264 字节的偏移量;

至此 function starts 可以帮助我们理解崩溃日志中的函数如何被设置,修改了哪些寄存器;但因为 function starts 不提供函数名,我们只能在低级的机器码层面来分析这些错误日志,对于调试开发 App 来说挺有用,但对于分析错误日志,我们还需要其他工具;

Nlist symbols List - Nlist 符号表

nlist 是一个结构体,他具体结构如下图所示,nlist 符号表建立在 function starts 和一个编码后的 __LINKEDIT``segment 的信息列表,当然 nlist 有自己的 load 指令;与 function starts 不同的是 nlist 不只是编码内存地址,他在其结构体中编码了更多信息;如下图所示,nlist 结构体中包含了名称和其他几个属性,具体而言 nlist 的类型由 n_type 所决定

n_type 有三种类型是我们符号化所感兴趣的,这里我们先着重聊一聊其中两种;第一种是 direct symbole - 直接符号;直接符号关联的是在 App 和二方库中,包含了已被完整定义的方法和函数;直接符号在 nlist_64 结构体中存储了函数名字和函数文件地址;

Nlist 直接符号

n_type 中的指定二进制位的值决定了该 nlist 的类型,具体而言,n_type 中的第二、三、四的二进制位为 1 时,表明该 nlist 类型为直接符号,这三个位的组合还被叫做 N_SECT

我们可以通过 nm -defined-only —numberic-sort 命令行工具来查看 N_SECT;在这里 nm 遍历了 magicNumbers``App 的制定符号,并以地址顺序罗列出来,具体参照下图中的输出;注意此处我们还是用了 xcrun -swift-demangle 来解析 Swift mangling 后的函数名称;

上图所示,我们已经可以从结果中获得了方法名 numberChoices()、类名 MagicNumbers、文件名 main;这是因为这些信息直接在 App 内定义;symbols 查看直接符号 和 nm 工具相似, symbols 命令行工具也提供查看 nlist 数据的方法,并且支持自动 demangle ,具体如下图 以上两个方法,让我们从崩溃日志中的内存地址,关联到了源码中的具体函数名称,至此,崩溃日志的符号化的信息丰富程度更进一步;至此,我们通过 fuction starts 提供的函数入口偏移地址从 direct symbols 中匹配到一个函数入口,并且这个入口有名字,把这些信息放在一起,我们可以发现 crash 发生在 main 方法地址的 264字节偏移处;但 main 并不是崩溃中唯一的函数,这表明我们还有更多的信息有待挖掘;例如我们还没有弄清楚代码中的行数信息

我们已经弄清 main 并不是唯一与崩溃关联的函数,我们还有更多的信息有待挖掘;例如我们还没获得文件的行数信息;并且在上述符号化中,部分函数被序列化,还有部分堆栈和崩溃日志信息没有被符号化

我们在 Instruments 的堆栈中遇到了类似的情况,一些函数名被符号化而可读,但部分仍是内存地址;发生这种现象的原因是,直接符号表中所包含的函数,只限于在链接时被直接链接的部分,动态库等运行时加载的二进制文件不被包含在内,这些未能符号化的方法就是跨模块从动态库中调用的方法;我们需要其他手段了符号化这些调试信息;

这种直接符号表的逻辑,有助于减少编译产物体积;毕竟换位思考,如果把打包时所有相关函数信息都存入符号表,这种操作才有违常识;对于 FrameworksLibraries,我们需要处理记录那些被调用的方法,而剥离没用到的;当然了如果把直接符号表里的主程序内的函数剥离,那符号表里啥也不剩了;

Xcode 编译设置对 nlist 直接符号的影响

Xcode 的编译设置中,strip 配置项有 strip linked productstrip stylestrip swift symbols 三个选项。这些编译设置的选项控制了 App 在编译链接过程中的剥离多余符号表的逻辑;具体来讲,strip linked productYES 时,二进制文件中将根据 strip style 的值进行符号表剥离;举例来说,strip style 值为 all symbols 时,符号表中将执行最激进的剥离策略,最终符号表中只包含最核心的方法;Non globals 类型会剥离应用中不同模块中共同使用的直接符号,但会留下用于其他 APP 中的符号;Debugging symbols 则删除了第三种 nlist 类型的符号,这个后续讨论 DWARF 时会讲到,但该类型的剥离会保留直接用到的符号。

-w875

举例来说,这里有一个定义了两个 public interface接口和一个 internal shared 实现的方法的 framework ,由于所有这些函数在链接环节中有用,他们都拥有直接的符号项。

如果我按照 non globals 进行剥离,那只有两个 interface 会留下;由于共享实现的函数只在 framework 内使用,所以它不是全局的,进而也不会被放入符号表; 类似的如果是 all symbols 剥离策略时时,如果这两个 interface 有被 framework 外部所调用时,他们仍然会被留下;

symbols —onlyNListData 会输出一些分布在直接符号之间 function starts 的条目;这些条目也表示了函数是存在于直接符号表中,亦或是已经被剥离了。你可以利用这些剥离设定,来实现你需要的符号表可见性;有了这些信息,我们就可以确定什么时候需要直接符号表。在实际应用中,有时候我们能符号化出函数名,但没有具体行数和文件名;或者符号化结果包含了方法名和方法起始地址,正如此处 frameworksymbols 指令的例子;

间接符号 - Indirect symbols

与直接符号类似,间接符号的 n_type 的第一位二进制位为 1 ,或称为 n_EXT

通过 nm -m -arch arm64 -undefined-only --numberic-sort MagicNumbers 输出间接符号的信息;这其中使用 —undefined-only 来替换 —defined-only ,该指令用于查看间接符号;-m ,这可以让你看到这些方法源自哪个 frameworklibraries。下面图中的输出结果提示 MagicNumbers``App 依赖了 libSwiftCore 中的一系列 Swift 基础方法如 print()

####小结 - Function starts 与 nlist 符号表 文章开头,我们约定了要讨论 function startsnlist 符号表DWARF 三种符号化工具;截止现在已经讨论了前两种,在此回顾一下;

  • Function starts 能提供地址列表,缺少方法名,可以帮助计算崩溃对应的文件地址偏移量;
  • Nlist 符号表把关联到一个地址的详细信息构成结构体存储,nlist 符号能提供函数名称,还可以描述在 App 内定义的直接符号和在二方库中提供的间接符号;直接符号表通常保留与链接有关的函数,Xcode 项目设置中的 strip build style 会影响直接符号表中的内容;
  • 这两种符号表都直接嵌入在 App 二进制文件 Mach-O 头中的 __LINKEDIT 二进制段中

DWARF

截止现在我们还没能看到诸如文件名、函数所在行数、崩溃所在行数等符号化信息;这些信息在 DWARF 中都有提供,我们在此详细讨论一下 DWARF ;相较于 nlist 符号表只保留函数部分信息,DWARF 几乎记录了函数的所有上下文信息;回顾 function starts 只在一个维度上提供偏移量信息;nlist 基于编码 nlist_64 结构体将调试信息升级到两个维度,即地址信息和函数名称;作为比较 DWARF 增加了第三个维度:关系信息;实际项目中函数不是孤立存在的,函数会被调用和在其内部调用其他函数,函数会有出参入参;通过记录这些函数的上下文关系信息;DWARF 会带我们解锁符号化最牛逼的姿势;

当我们分析 DWARF 时,一般指的是引用分析一个 dSYM bundle,该 bundle 中存在由元数据组成的 plist,还包括一个 DWARF 二进制文件;二进制文件中将 DWARF 的信息记录在 __DWARF 二进制段中;DWARF 在该二进制段中记录了我们需要关注的三个数据流;具体而言三个数据流分别是 debug_info, debug_abbrev, debug_linedebug_info 包含了原始数据,debug_abbrev 为原始数据进行了结构化处理,debug_line 包含了文件名和行号;除此之外 DWARF 还定义了需要讨论的两种 vocabulary list 词汇表:compile unit 编译单元和 subprogram 子程序;后文会提到第三种词汇表 - 内联子程序

Compile Unit - 编译单元

编译单元表示了在项目中会被编译的单个源码文件;具体来说,在项目中的每个 swift 文件都会有一个编译单元与之对应;DWARF 为每一个编译单元赋予了一些属性,诸如文件名、模块名称、__TEXT segment 的函数占位部分等;main.swift 文件对应的编译单元在 debug_info 数据流中储存了这些属性,如左侧所示;与之对应的,在 debug_addrev 数据流中包含了一个相关的条目,这些条目告诉我们这些值代表了什么,如右侧所示;我们看到图中右侧包含了文件名、语言和一个 low/high 对,用来表述 __TEXT``segment 的范围

Subprogram - 子程序

子程序表示已被定义的函数;我们已经在 nlist 符号表中找到过已定义的方法,但子程序还可以用来描述静态方法和本地方法;子程序当然也有自己的名称和对应的 __TEXT``segment 地址起始范围

DWARF 关系树

编译单元和子程序之间的一个基本关系是,子程序是在编译单元中被定义的;DWARF 使用树来表述这种关系;编译单元在根节点上,子程序是根节点的孩子节点;这些子节点可以通过他们的地址范围而被检索到;

我们可以通过 dwarfdump 命令行工具来验证上述 DWARF 的编译单元、子程序和关系树细节 首先我们将查看到一个编译单元,这句之前提到的编译单元所携带的属性相吻合(文件名、语言、行数等),dwarfdump 工具结合了 debug_infodebug_abbrev 内容来展示 dSYMs 文件中的数据结构与内容

输出很长,我们往下看,会看到一个子程序 subprogram;它所占用的地址范围存在于该编译单元的地址范围内,并且可以看到方法名;之前提到过 DWARF 非常详细的描述符号表和关系信息,我们不会在深入探究 DWARF 的关系树 设计细节,但了解这些细节能够帮助我们理解符号化背后的逻辑;

继续往下看输出结果,会发现其中还包括参数信息,DWARF 持有一个自己的词汇表,来描述参数的名称和类型;参数是子程序的一个子节点;下图中的输出,可以发现 numberofChoice 函数的参数 choices 的相关信息;文件名与行数信息

此外,debug_line 数据流中存储了函数关联的文件名和具体行数;但 debug_line 数据流不是树状结构,相反的,该数据流定义了一个 line table program 行表程序,这个航标程序可以让链接后的文件地址映射到源码文件中的具体行数;我们可以利用这个行表程序来查找文件地址关联的具体源码和行数;

综上,基于 debug_info 的树状结构和 debug_line 的行表程序,我们可以得到一个下面的结构;通过遍历这棵树,我们可以找到想要的文件地址;首先从编译单元开始,遍历其子节点,然后筛选出包含 debug_line 的子节点;

DWARF 与编译时函数内联优化

我们可以使用 atos 命令行工具来完成上述操作,这次我们省略 -i``flag,可以看到输出结果少了很多,只剩下方法名、文件名和行数;这里的结果提供了行数,因此我们可以断定我们在使用 DWARF 来进行符号化;但除了文件名和行数,这个输出结果和 nlist 符号表的符号化结果没有太大区别;然后我们再试一试给 atos 加上 -i``flag,输出结果是下面第二张图,大家可以对比这两个输出的差异,他们的命令只差了一个 -i``atos -o MagicNumbers.dSYM/Contents/Resources/DWARF/MagicNumbers -arch arm64 -l 0x10045c000 0x10045fb70``atos -o MagicNumbers.dSYM/Contents/Resources/DWARF/MagicNumbers -arch arm64 -l 0x10045c000 -i 0x10045fb70

-w974

大家也许会猜,这 -i 意味着什么;事实上 atos-i 意味着 inlined function 内联函数,内联化是一种编译器执行的常规优化;详细而言,内联化就是在编译中把函数的实现代码直接替换函数被调用的代码;这样的替换操作可以让函数调用的代码和函数的定义代码都「消失了」;在我们的 Demo 中也就是使用 numberOfChoice() 的实现代码替换了调用代码;numberOfChoice() 调用代码不见了~

Inlined subroutines - 内联子程序

DWARF 使用内联子程序来表述这种编译时内联优化;这就是我们要讨论的第三种 vocabulary list 词汇表类型 :inlined subroutines 内联子程序;内联子程序是子程序的一种,所以他也是一种方法,一种被内联到另一个子程序的方法;所以内联函数在 DWARF 关系树中是子程序的一个子节点;这样的定义意味着会出现递归关系;也就是说一个内联子程序可以有其他内联子程序作为子节点; 再次使用 dwarfdump 命令行工具,我们可以来检查一下 DWARF 中的内联子程序;这些内联子程序被列为其他节点的子节点,并且有着与子程序类似的属性,诸如名称和地址;但是在DWARF 文件中,这些属性一般会通过一个公共节点来访问,这种设计叫抽象源;如果存在一个特定函数有很多内联拷贝,则该函数的公共共享属性将存储在抽象源中,如此这些内联函数就不会被重复多余的拷贝;内联子程序有一个独特的属性是 call site 调用位置;该属性表述了在源码中实际调用函数的位置,编译优化器会替换这些函数调用代码;例如,我们在 main.swift 文件中第36行调用了 generateANumber() ,这使得需要在树中新增子节点来记录这个函数调用;

-w1010

到这里,我们对 DWARF 符号化有了更全面的了解,如下图所示,我们对 App 的调用逻辑也有了更广阔的视角。了解内联函数的优化方式和细节是完全符号化崩溃日志的关键所在;-i 指令实际会要求 atos 符号化过程中考虑到上述内联函数;这些内联函数的信息同样在 Instruments 堆栈中缺失;我们在崩溃日志和 Instruments 堆栈中都需要 dSYM 文件,正是由于 dSYM 中精确地包含了上述三种类型的信息:编译单元、子程序和 DWARF 关系树;

从库和目标文件中获取 DWARF

除了 dSYM 文件中,还可以在静态库和目标文件中找到 DWARF;也就是说即使没有 dSYM 文件,你仍然可以从静态库或目标文件中链接的函数,来生成 DWARF;这种情况下,你会找到调试符号表的 nlist 类型,这些本是可以被 strip 剥离的符号类型之一;但这些 nlist 类型并不直接包含 DWARF,相反,他们直接把函数关联到其源码文件;如果一个库在构建中包含调试信息,此时,这些 nlist 条目可以给我们提供 DWARF 的相关信息

上述类型的 nlist 条目可以通过 dsymutil -dump-debug-map 命令行工具来输出和详细查看;在此我们列出了不同函数方法和他们的出处;这些地址信息可以被扫描并处理成 DWARF 文件中所需的信息;

小结 - DWARF

  • DWARF 是深度符号化数据的重要来源
  • DWARF 描述了函数与文件之间的重要关系信息;
  • DWARF 妥当处理了编译时内敛优化的问题;
  • dSYM 文件和静态库可以都可包含 DWARF
  • 实践中推荐使用 dSYM 获取 DWARF,因为从 dSYM 中获取的 DWARF 可以方便的在其他工具中使用,并且 Xcode 许多内置工具也支持 DWARF

开发工具与符号化实践

Xcode 编译设置 - Debug info format

  • 针对本地开发配置建议设置为直接生成 DWARF
  • 针对发布编译配置,请确保生成包含 DWARFdSYM 文件
  • 提交至 App Store ConnectApp,你可以在那下载到 dSYM
  • 即使使用了 bitcode 技术 ,你也可以从 App Store Connect 下载到 dSYM 文件

查找和确认 dSYM 文件

如下图所示,在本地 Mac 上可以接住 mdfind 命令行工具检查 dSYM 文件;这个字母数字组成的字符串是编译二进制产物的 UUID,也是运行时 load 指令的唯一标识符;你还可以通过 symbols -uuid 来查看 dSYM 文件的 UUID;

在少数情况下,编译过程会生成一个无效的 DWARF,你可以通过 draftdump -verify 命令来检验 DWARF 的有效性;如果这个检查命令输出任何错误,请直接通过 https://feedbackassistant.apple.com 来进行Developer Tool - 开发工具bug 反馈;

单个 DWARF 二进制文件大小上线是 4GB,如果上述校验中报告超过 4GB 的错误,你可以考虑将项目的进行组件化拆分,以便每个组件会有一个较小的 dSYM

实际操作中,通过比较 dSYMUUID 和崩溃日志中 binary imageUUID 性来匹配两者;除了在崩溃日志中查看 App 二进制镜像的 UUID ,你还可以通过 symbols 命令行工具来获取 UUID,参照下图;实际符号化中,需要 dSYM 和崩溃日志的 UUID 匹配;

其他符号化的细节

symbols 命令行工具还可以帮你检查你 App 编译产物中包含的可用调试信息;输出内容的方括号中的标签,告诉了这些调试信息的来源;当你不知道在调试时使用哪些调试信息时,使用该指令可以看看有哪些调试信息可用;

如果你确信已经有可用 dSYM 文件了,但是仍旧未能将 Instruments 中的堆栈信息符号化,请检查一下项目的 Entitlements 和代码签名配置;具体来说使用 codesign 命令行工具,你可以验证是否拥有正确的代码签名配置;

同时,你还需要检查本地开发的 entitlement 中是否包含了 get-task-allow 项,该配置授予 Instruments 这类工具在调试中执行对应 App 符号化的权利;一般来说,Xcode 默认自动会设置这个 get-task-allow 配置项;但 Instruments 不能符号化的时候,可以排查一下这个配置项;如果你发现 entitlement 中没有 get-task-allow ,可以检查确保 build-setting -> code signing -> code signing inject base entitlemens 的值为 true ,来解决该问题;

最后,对于使用 Universal 2 技术的 App, 在使用文章中提到的命令行工具时,都可以指定架构,诸如 symbolsotooldwarfdump 都有 -arch 的参数可供配置,如此可以只执行特定架构的相关操作;

总结

正如名称中的「符号化进阶」,用以下几个关键点来总结本 Session

  • 符号化UUID 和文件地址是一致且可靠的方式来识别 App 在运行时的问题,因为这两者不受 ASLR Slide 偏移量的影响;UUID 和文件地址是运行时信息符号化关键的第一步
  • 实践中,尽可能利用 dSYM 完成符号化;dSYMDWARF 的形式记录了最丰富细节的调试信息,并且被 XcodeInstruments 所良好支持
  • 文中介绍了几款命令行符号化工具,诸如 otool, vmmap, nm, symbols, dwarfdump, atos;这些工具包含在 Xcode Command line tool中,提供了强大的诊断和检视符号化过程与细节信息的能力;必要时,大家可以将这些工具集成进自己的工作流;

如果你有兴趣学习更多链接与符号化知识,我在此推荐两个WWDC18的Session :他们帮助你了解 App 在启动时如何运行起来,一个是Optimizing app startup time - 优化 App 启动速度,另一个是App startup time: past ,present, and future - App 启动的时间线:过去、现在和将来;


https://mp.weixin.qq.com/s/iRxvrOsEdW1wPZ3tSPKeIg

最多阅读

快速配置 Sign In with Apple 2年以前  |  4751次阅读
APP适配iOS11 2年以前  |  3550次阅读
使用 GPUImage 实现一个简单相机 2年以前  |  3446次阅读
App Store 审核指南[2017年最新版本] 2年以前  |  3397次阅读
所有iPhone设备尺寸汇总 2年以前  |  3315次阅读
开篇 关于iOS越狱开发 2年以前  |  3219次阅读
在越狱的iPhone设置上使用lldb调试 2年以前  |  3175次阅读
给数组NSMutableArray排序 2年以前  |  3126次阅读
UITableViewCell高亮效果实现 2年以前  |  2898次阅读
使用ssh访问越狱iPhone的两种方式 2年以前  |  2892次阅读
关于Xcode不能打印崩溃日志 2年以前  |  2743次阅读
使用ssh 访问越狱iPhone的两种方式 2年以前  |  2639次阅读
使用最高权限操作iPhone手机 2年以前  |  2390次阅读
为对象添加一个释放时触发的block 2年以前  |  2386次阅读
iOS14 隐私适配及部分解决方案 1年以前  |  2361次阅读

手机扫码阅读