Crash 符号化 2. symbolicatecrash 源码浅析与优化

上一章谈到符号化的流程,其中介绍了两种符号化方式:atossymbolicatecrash。接下来,详谈 symbolicatecrash 的源码实现与遇到的问题。

环境信息

Xcode 9.0


symbolicatecrash 命令由 perl 编写,位于 /Applications/Xcode.app/Contents/SharedFrameworks/DVTFoundation.framework/Versions/A/Resources/symbolicatecrash。它是对 atosmdfind 等一系列命令的封装。这个命令也能拷贝出来单独调用(因为目前这个命令有坑,所以我司的解析是调用的修复之后的脚本,这部分会在文末进行补充,优化过后的脚本详见 Gist)。

其实关于 symbolicatecrash 命令的调用,上一章已经谈到不少了,但是依然有很多细节方面的东西需要梳理,来看下都有哪些:

  • 符号化的整体流程是怎样的?
  • 为什么可以在不指定符号表的情况下,正确的符号化?
  • 除了 App 的符号表,系统符号表是如何查找的?
  • 如何将地址解析为符号?

整体流程

完整调用 symbolicatecrash 的方式为:

1
2
3
4
symbolicatecrash log.crash -d xxx.dSYM -o output.crash

# 当然,也可以指定多个符号表
symbolicatecrash log.crash -d xxx.dSYM -d xxx.dSYM -d xxx.dSYM -o output.crash

主要步骤为:

  1. 初始化需要用到的命令路径:otoolatossymbolstoolsize
  2. 解析日志中的一些参数:Hardware ModelOS Version 等。
  3. 遍历 Binary Imges ,获取所有加载的库的:UUID、路径等。
  4. 遍历每一个线程的调用栈,解析出栈帧、bundle、地址、偏移量等信息。
  5. 将调用栈中没有出现在 Binary Imges 中的 image 进行清理,减少遍历次数。
  6. 读取系统版本,并根据系统版本找到系统符号表对应目录:/System/Library/Developer/Xcdoe/DeviceSupport/osversion/Symbols
  7. 开始遍历所有的符号表,进行验证,并缓存二进制。如果未指定符号表,则调用 mdfind 进行全局搜索。
  8. 执行 atos 进行符号化。

其实整体流程过一遍以后,基本的逻辑就大致了解了:

  • 如何自动匹配符号表:依赖于 Spotlight 进行全局搜索。
  • 如何匹配系统符号表:在读取到 Crash 发生的系统版本后,直接在固定的路径下进行查找,而这个路径,就是当手机连接 mac 以后,系统会自动将符号表拷贝到的路径。
  • 如何解析地址:这一步其实综合为了一个命令:atos,关于这个命令,准备在下一篇再进行详细介绍。

查找符号表

所以,这个脚本其实就做了两件重要的事情:

  1. 查找符号表。
  2. 调用 atos 🌝。

所以,了解了如何查找符号表,就基本上了解了整个脚本。

读取 Binary Images

通过读取 crash 日志中的 Binary Images,将每一行信息进行格式化,并存储到字典中(对应源码的 parse_images 函数):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# 下面展示的信息为格式化 libmacho.dylib 的结果
# 0x196464000 - 0x196469fff libmacho.dylib arm64 <cddfd20412643baeb9023095eb2cf29f> /usr/lib/system/libmacho.dylib

images = {
"libmacho.dylib": {
"path": "/usr/lib/system/libmacho.dylib",
"uuid": "cddfd20412643baeb9023095eb2cf29f",
"arch": "arm64",
"bundlename": "libmacho.dylib",
"base": "0x196464000", # 起始地址,具体含义会在 atos 命令中一起介绍
"extent": "0x196469fff",
"plus": ""
},
{
...
}
}

为了防止应用的二进制名称和系统库的二进制名称冲突,也就是存在相同的 bundlename,冲突的 name 会重命名为 bundlename + base。

删除多余的二进制

Binary Images 中读取到的,是当前 app 链接到的所有库,而这远比 crash 发生的二进制要多, 所以在处理时,会删除掉多余的。这一步优化,算是解析调用栈的副产品。读取到所有线程后,会遍历调用,进行解析,并将结果存储在 frames 字典中(对应源码的 parse_backtrace 函数):

1
2
3
4
5
6
7
8
9
10
# 比如下面这个调用
# 0 libsystem_kernel.dylib 0x000000019655ce7c 0x19655c000 + 3708

frames = {
"0x000000019655ce7c 0x19655c000 + 3708": {
"address": "0x000000019655ce7c",
"raw_address": "0x000000019655ce7c",
"bundle": "libsystem_kernel.dylib"
}
}

这一步,将发生 crash 的地址作为 key,其他格式化的信息作为 value,进行存储。

在这之后,整个 crash 日志的信息就基本读取完毕了,接下来需要做的,就是根据这些信息,找到对应的符号表,并调用 atos 进行符号化。

查找符号表

这部分源码主要在 fetch_symbolled_binaries 函数中,而这个函数主要有两个作用:

  1. 定义 uuid_cache 变量,对找到的二进制进行缓存;
  2. 调用 getSymbolPathAndArchFor 函数 🌝。

所以我们还是来着重看一下 getSymbolPathAndArchFor

这个函数的流程非常明确,代码一看便懂,主要分为四种查找方式,接下来一一介绍。

手动指定

这一步中,会读取外部指定的符号表,然后自动检索到 xxx.dSYM/Contents/Resources/DEARF/* 目录下,并验证指定的符号表是否包含当前 crash 的架构(验证是通过二进制的 uuid 进行判断的,上一章有提到过)。

如果验证通过,则直接返回,如果验证未通过,则进入下一流程。

在 Cache 中查找

Cache 文件夹位于 /Volumes/Build/UUIDToSymbolMap,但不太清楚什么情况下会缓存到这个目录,通过源码看,缓存的目录结构与 UUID 相关:

1
2
3
# 将 UUID 分割为:4-4-4-4-4-4-8

/Volumes/Build/UUIDToSymbolMap/4/4/4/4/4/4/8

如果未找到,进入下一流程。

在 Search Path 中查找

Search Path 即工程配置中的 Search Path,也就是库的查找目录。在 crash 中,体现为 Binary Images 的目录,也就是解析产物 images 中的 path 字段。通过读取系统版本,可以找到系统符号表对应在 mac 上的目录:/System/Library/Developer/Xcdoe/DeviceSupport/osversion/Symbols,而符号表的准确路径,就是该目录加上 Search Path。比如 libmacho.dylib(对应源码的 getSymbolPathAndArchFor_searchpaths 函数):

1
2
3
4
5
6
7
8
9
10
11
"libmacho.dylib": {
"path": "/usr/lib/system/libmacho.dylib",
"uuid": "cddfd20412643baeb9023095eb2cf29f",
"arch": "arm64"
"bundlename": "libmacho.dylib",
"base": "0x196464000", # 起始地址,具体含义会在 atos 命令中一起介绍
"extent": "0x196469fff",
"plus": ""
}

result = "/System/Library/Developer/Xcdoe/DeviceSupport/osversion/Symbols" + images["libmacho.dylib"]["path"]

如果未找到,进入下一流程。

通过 Spotlight 查找

其实一般到 Search Path 这一步就已经能正确符号化了,除非:

  1. 外部指定的符号表路径错误;
  2. 外部未完全指定所有的符号表(比如 extension 和 framework 之类的)。

首先需要将 UUID 格式化为 8-4-4-4-12 的格式,然后调用 mdfind 进行全局查找:

1
mdfind "com_apple_xcode_dsym_uuids == CDDFD204-1264-3BAE-B902-3095EB2CF29F"

到此,如果还是没能找到符号表,那就无法正确符号化当前调用栈了,最终得到的,也就是一份没有完全符号化的日志。

问题与坑

整体流程走下来还是比较顺畅的,整个源码实现也只有 1500 行,算是非常精干了。但其中也不乏有很多可以优化的点和坑,接下来一一谈一下(优化之后的脚本,见 Gist)。

问题

首先说问题吧,也是目前觉得这个命令用得不顺手的地方。

  1. 实验发现,如果指定了所有符号表,反而会比不指定或只指定 application 符号表要慢一倍;
  2. 另外,系统会优先检索指定的符号表,但其实在符号化的过程中,系统库会占到 90%;
  3. 无法指定 application 符号表的检索范围。

指定符号表

首先来看指定全部符号表的问题。比如,美图秀秀一共有四个符号表,一个 application + 三个 extension 的。所以我在调用的时候,命令为:

1
symbolocatecrash log.crash -d MTXX.app.dSYM -d extension1.appex.dSYM -d extension2.appex.dSYM -d extension3.appex.dSYM -o result.crash

解析耗时 20s。

如果仅指定 application 的符号表:

1
symbolicatecrash log.crash -d MTXX.app.dSYM -o result.crash

解析耗时 11s 🌝。

讲道理,全部指定少去了 Spotlight 搜索的步骤,应该更快才对,但实际却恰恰相反。

其实原因看源码就很清楚了,一般的 crash 都发生在 application,很少有发生在 extension 的情况,如果指定了全部的符号表,脚本在执行过程中,每次都会判断所有指定的符号表。也就是说,每次在查找 90% 的系统符号表之前,都会先判断是不是指定的 application 的符号表,这也就是指定得越多,耗时越多的原因。

符号表查找顺序

和上一个问题一样,在系统符号表居多的情况下,因为脚本的执行顺序,会优先判断指定的。比如会判断当前 UIKit 是否匹配指定的符号表,这明显造成了无意义的判断,所以我将源码的【手动指定】与【在 Search Path 中查找】两个步骤调换了一下,果然,解析时间从以前的 11s,缩短到了 6s,又是一半的时间。

查找范围

通过查看 mdfind 文档,可以看到 Spotlight 是可以通过指定 -onlyin 来限制查找范围的。但是接口抽象到 symbolicatecrash 的时候,这个参数就不再提供了,可能这是为了最大限度的符号化的考量,不过在明确 application 符号表范围的情况下,也可以单独再提一个函数出来,针对可能的范围进行查找。

这个坑同样是在符号化美图秀秀的一个 crash 时出现的,目前已被苹果标记为 bug,但什么时候能修复就不清楚了。

解决起来很简单,先来看一下是如何出现的。

有一天秀秀的开发找到我说,crash 没能完全符号化,日志发来一看,在 crash thread 符号化正确的情况下,Thread 19 的 application 却没能符号化。大致如下:

在排除各种因素之后,在源码中找到了这样一行代码:

原因找到了,因为在解析线程的时候,只解析了 HighlightedCrashed,而 Tread19 是 Attributed,所以未能正确解析。所以果爹的脚本是没办法用了,改改再外部调用吧。至于为什么这里要写死关键字,我也不太清楚是出于什么考虑,只好在后面默默的加上了 Attributed 🌝。

最后

到此,符号化的坑都还没踩完,前几天项目说 CoreMotion 没能符号化,我一看还真是,在 UUID 匹配的情况下,iPhone OS 11.0.2 (15A421)CoreMotion 直接调用 atos 也没能正确符号化,原因还未找到。

顺便加一个广告,深圳美图招 DevOps,iOS 方向,熟悉 node.js 优先。