客户端堆栈还原原理及实现

3,006 阅读41分钟

本文首发于微信公众号“Shopee技术团队

摘要

MDAP(Multiple Dimension Analysis Platform)作为一个多维实时监控分析平台,能够支持业务应用侧自定义指标的监控与分析,并在自定义监控分析能力上,实现了对移动端应用性能数据的专项监控分析能力,以满足业务日益增长的数据分析需求。

这是 MDAP 系列的第四篇文章,前文回顾:

1. 背景

在软件调试及错误排查过程中,无论是客户端 App 还是后端服务,一个常见的手段是通过错误堆栈定位异常所在的源码位置,从而直接在源码层面剖析问题根因。MDAP 平台的移动端性能分析能力很大程度上也依赖于对堆栈数据的采集和分析,它的性能监控能力如下图所示。

其中,蓝色粗体部分的功能都需要 MDAP SDK 采集堆栈并上报到 MDAP 后台,帮助开发者定位异常问题,这意味着 MDAP 服务端需要支持 Android、iOS、Web H5、React Native 四类堆栈的高性能还原能力,以应对海量堆栈的上报。

1.1 堆栈还原的基本概念

所谓的堆栈,通常特指某一时刻的函数调用上下文关系。我们知道,函数的调用和返回会在内存的栈空间中新建和销毁栈帧,而栈帧就是一次函数调用的上下文关系。若能获取到某一时刻栈空间中的栈帧分布,即可知道该时刻的函数调用情况。

因此,堆栈通常可用于异常问题的排查。在这种场景下,我们通常只会关心函数名称(有时候还包含类名)、函数参数(若有)、文件名称、以及行号,而对诸如函数内部的局部变量等信息是不会太关心的。

堆栈还原的含义就是将栈空间中的原始栈帧信息转换为源码级别的函数调用关系,这个过程通常也称为堆栈符号化

另外,对于一些高级语言(例如 Java),其运行时环境(JVM)会直接输出类似于源码形式的堆栈信息,但是由于打包过程中会对源码进行压缩和混淆,因此这类堆栈也需要一个转换过程,通常会称为反混淆。

也就是说,根据语言和运行环境的不同,堆栈还原其实包含了不同的含义,其对应的堆栈还原原理自然也不尽相同。

1.2 堆栈的分类

目前我们公司内常用的客户端平台可以分为四类:

  • Android
  • iOS
  • Web H5
  • React Native

其中,Android 平台比较特殊,它不仅支持多种开发语言:Java(Kotlin)和 C++,并且它们两者之间是两套相对比较独立的体系(通常称为 Android JavaAndroid Native),因此其堆栈格式及堆栈还原原理完全不同。

而在其他平台上,即使使用不同的开发语言,其堆栈结构及堆栈还原原理都是基本统一的。

因此,基于客户端平台可以将堆栈类型分为以下几类:

上一小节也提到过,堆栈还原包含了两层不同的含义,这也对应着两类截然不同的堆栈还原基本原理。从这个角度,可以进一步把上述堆栈分为两大类:

  • 基于地址关系映射
    • Android Native
    • iOS
  • 基于符号关系映射
    • Android Java
    • Web H5
    • React Native

基于地址关系映射,即客户端采集到的堆栈就是内存空间中 stack 区的栈帧排列,通常符合以下特点:

  • 使用编译型语言编写,通常为 C 语言及其衍生语言;
  • 编译后直接生成目标操作系统上的可执行文件;
  • 客户端采集的原始堆栈实际是内存栈空间中每一层函数调用的返回地址,运行时无法获取源码级别的堆栈;
  • 堆栈还原实际上就是将堆栈中的函数地址转换为源代码中函数名称及对应的行(列)号。

基于符号关系映射,客户端并不能直接从 stack 区中获取相关函数相关调用信息,它的基本特点如下:

  • 通常使用解释型语言编写,例如 JavaScript;
  • 不能直接运行在操作系统之上,其运行时通常是一个操作系统之上的中间层,例如 JVM、V8 引擎等;
  • 通过这个中间层可以在运行时获取源码级别的堆栈,例如函数名和行列号;
  • 在工程化实践中,通常基于安全或者性能方面考虑,代码构建时会将源代码进行压缩与混淆,因此运行时获取的堆栈中的函数名和行列号都是经过混淆转换的;
  • 堆栈还原的本质,就是将堆栈中混淆过后的函数名称和行列号反混淆为真实源码中的函数名称及对应的行列号。

2. 方案调研

综合上述背景介绍,我们可以提炼出 MDAP 对于堆栈还原服务的两个基本要求:

1)高性能

因为需要实时还原海量的堆栈上报,尤其是在大促期间,MDAP 平台的数据上报 QPS 峰值会达到数万甚至数十万的级别,这对堆栈还原服务的性能要求非常高。

2)整体架构模型标准化、统一化

这是由于 MDAP 需要还原多种不同类型的堆栈,且堆栈格式与还原流程各异,统一的架构模型不仅可以降低开发和运维成本,还便于后期扩展新类型堆栈的还原能力。

事实上,我们最早期的规划中并没有 React Native 堆栈的还原功能,但正是得益于统一化的架构模型,我们在较短时间内快速开发并上线了该服务。

2.1 现有工具调研

上述几个平台都有成熟的堆栈还原工具,具体如下:

这些工具都有一个共同点:基本都是客户端开发套件中的配套工具,通常在本地开发环境中使用。那么,我们能否对这些工具进行简单的封装,作为 MDAP 平台的堆栈还原服务呢?

答案显然是否定的,具体原因有如下问题需要考虑:

  1. 性能问题
  • i. 上述一些工具可能需要封装为命令行的形式调用,相当于每次还原都需要启动一个子进程,进程频繁创建和销毁带来的性能消耗导致这种方式无法满足我们性能方面的需求;
  • ii. 同时,线上活跃的版本通常是比较集中的,一般就那么几个,并且符号表文件又比较大,几十 M 到几百 M 不等,这意味着同一时间段内会有大量相同版本的堆栈上报,而这些堆栈在还原时都需要重新读取符号表文件,也会造成大量的重复文件 IO 操作,产生性能瓶颈。
  1. 直接使用这些工具还会导致后续服务难以维护,这个问题体现在以下几个方面:
  • i. 这些工具的运行时环境不一致。例如 Java 的堆栈还原工具依赖 JRE 环境,JavaScript 的还原工具依赖 JS 的运行时环境,而 iOS 的还原工具 atos 甚至只能运行在 macOS 环境中,多样化的运行时环境无疑会大大增加服务端的运维成本;
  • ii. 开发语言不一致。上面提到过,复用这些工具的一个手段是通过命令行方式启动子进程;除此之外,还有一个手段就是在服务端集成这些工具的源码(前提是工具已开源)。但是这些工具使用的语言不一致,而我们的后台统一使用 Golang 作为开发语言,一方面从工程化的角度难以将多个语言的项目集成在一起,另一方面也不符合我们统一架构模型的理念;
  • iii. 服务不可观测。无论是源码形式调用还是命令行形式调用,服务运行时的一些状态可能都是黑盒,即使有源码,一些工具的源码也是集成在一个大项目之中,我们很难在合适的地方加入日志,也很难集成监控指标或者链路追踪。

综合以上原因,我们最终没有选择直接使用这些现成的工具,而是决定自研堆栈还原服务。

2.2 业界方案调研及架构设计

既然决定了自研堆栈还原服务,在上面提出的问题中,除 2-ii 外其他都可以自然而然地得到解决。那么 2-ii 应该通过什么方式解决呢?

除了调研一些已有工具之外,我们还调研了一些其他性能分析平台如何实现堆栈还原能力。这些平台的实现存在以下两个共同点:

  1. 符号文件上传后预解析,避免重复文件 IO;
  2. 符号文件预解析为 KV 形式,并保存在 KV 缓存(Redis)或者 KV 数据库(HBase)中。

这实际上就是将堆栈还原所需的能力拆解为了两部分:符号表文件解析、堆栈还原。而通过符号表文件的预解析,我们也可以很好地解决问题 2-ii

遵循以上思路,我们基于 MDAP 的既有架构,可以初步设计出堆栈还原系统的相关架构,如下图所示:

与堆栈还原相关的主要有两个服务:

  • SymbolManager:符号表管理服务,负责符号表的上传、下载、解析、缓存等流程的管理。符号表上传之后,该模块会实时解析文件,并将其解析为 KV 的形式写入 Redis 缓存之中。同时,还会负责符号表文件存储和缓存的管理。
  • Stack Symbolicating:堆栈还原服务,负责堆栈还原,以 gRPC 的形式提供还原服务。当接收到还原请求时,会在 Redis 中查找相关的符号信息,并将这些信息拼接为完整的堆栈,返回给请求发送端。

3. 堆栈还原服务实现

上文给出了堆栈还原服务的相关架构,包含了符号表管理和堆栈还原两个服务。但是在具体的实现中需要支持多种类堆栈的还原,因此我们需要实现多个符号表解析实例,以及相应的堆栈还原实例。本章节将按照堆栈类型分别讨论符号表解析和堆栈还原的具体实现。

3.1 iOS 篇

前文在对堆栈进行分类时,将 iOS 和 Android Native 堆栈都分为了基于地址关系映射的类型。实际上,这两类堆栈还原原理可以说是完全一致的,但是由于 SDK 端采集堆栈时的处理方式有一些轻微差别,导致服务端的实现也会有相应的区别(后文会详细介绍这些区别),也正是这点微小的区别,导致 iOS 端堆栈还原的流程更具代表性,因此这里首先介绍 iOS 堆栈还原的实现。

3.1.1 iOS 堆栈格式

首先来看一下 iOS 平台还原前的原始堆栈格式。需要说明的是,这里的格式是 MDAP SDK 经过一定处理后上报上来的堆栈,其格式可能与其他方式获取到的堆栈格式有一些差别,但是其内容基本是一致的。为方便说明,后文对堆栈格式的介绍都以 MDAP SDK 上报的格式为准。

iOS 原始堆栈示例如下:

CoreFoundation  0x00007fff20422faa 0x7fff20311000 + 1122218 [8c17697f-2e84-39e5-b491-fcf5169106ff]
libobjc.A.dylib 0x00007fff20193ff5 0x7fff20174000 + 131061 [8c17697f-2e84-39e5-b491-fcf5169106ff]
CoreFoundation  0x00007fff204a1523 0x7fff20311000 + 1639715 [8c17697f-2e84-39e5-b491-fcf5169106ff]
CoreFoundation  0x00007fff203212d1 0x7fff20311000 + 66257 [8c17697f-2e84-39e5-b491-fcf5169106ff]
MDAP_testDemo9  0x00000001079b59d3 0x1079b3000 + 10707 [8c17697f-2e84-39e5-b491-fcf5169106ff]
MDAP_testDemo9  0x00000001079b5f49 0x1079b3000 + 12105 [8c17697f-2e84-39e5-b491-fcf5169106ff]

每一行包含以下信息:

  • BinaryImage,例如第一行中的 CoreFoundation,可理解为 iOS 中的可执行文件名称;
  • 栈帧的返回地址,例如第一行中的 0x00007fff20422faa
  • 该 BinaryImage 在内存中的加载地址,例如第一行中的 0x7fff20311000
  • 当前指令地址相对 BinaryImage 起始点的偏移,例如第一行中的 1122218,为十进制数字;
  • BinaryImageUUID,例如第一行中的 8c17697f-2e84-39e5-b491-fcf5169106ff,该栈帧所属可执行文件的唯一 ID。

3.1.2 iOS 符号表文件

在 iOS 平台中,符号表文件通常是指 dSYM 文件,即包含了调试信息的目标文件。在 Mac 上右键点击 dSYM 文件并选择“显示包内容”,即可看到其内部实际上是由一个包含了调试信息的 Mach-O 文件组成。Mach-O 是 Mach Object 文件格式的缩写,是 Mac 和 iOS 上的可执行文件。

Mach-O 文件可以通过 MachOView 应用进行可视化解析,如下图所示:

这个可执行文件内部包含了以 DWARF 格式保存的调试信息。

调试信息,顾名思义通常用于源码级调试。想象一下,我们调试源码时,通常会在代码的某一行打上断点,当程序执行到这一行时就会发生中断,从而暂停程序执行流程。也就是说,调试信息相当于源码和运行时之间的桥梁,其中描述了源代码与目标代码之间的关系,是在编译时生成的。因此,客户端在运行时采集到的堆栈也可以通过调试信息还原为源码级堆栈。

调试信息有多种格式,目前在 Unix & Linux 平台上,主流的调试信息格式即为 DWARF。

3.1.3 DWARF 调试信息

DWARF 是一种被广泛使用的标准化调试信息格式,最初是与 ELF 一起设计的,后来逐渐发展为标准化且可用于其他目标文件的格式。

实际上,iOS 和 Android Native 的目标文件中都包含了 DWARF 格式的调试信息,因此二者的堆栈都可以基于 DWARF 信息实现还原,且两者的原理基本相同。我们这里主要以 iOS 为例详细介绍 DWARF 的格式和还原原理,并在后文简述 Android Native 与 iOS 的差异。

DWARF 通常集成在二进制的目标文件中,其本身也是二进制的形式,这里暂不详细分析其二进制格式的协议细节,而是通过工具将其解析为可读性更强的文本形式进行介绍。

我们可以使用 dwarfdump 工具解析可执行文件中的 DWARF 信息:

1. dwarfdump --debug-info {obj_path} -o {debug_info_file}
2. dwarfdump --debug-line {obj_path} -o {debug_line_file}

命令 1 将 DWARF 中的调试信息转换为文本形式,并输出到指定文本文件中;命令 2 将 DWARF 中的行号信息转换为文本形式,并输出到指定文本文件中。下面结合示例介绍这两类信息的具体含义。

1)DebugInfo 调试信息格式

0x00195e54: Compile Unit: length = 0x000003ee, format = DWARF32, version = 0x0004, abbr_offset = 0x0000, addr_size = 0x08 (next unit at 0x00196246)
 
0x00195e5f: DW_TAG_compile_unit
              DW_AT_producer    ("Apple clang version 12.0.5 (clang-1205.0.22.11)")
              DW_AT_language    (DW_LANG_ObjC)
              DW_AT_name    ("/Users/***/Desktop/hamster-ios/Example/Hamster/HamsterDemoCrashViewController.m")
              DW_AT_LLVM_sysroot    ("/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS14.5.sdk")
              DW_AT_APPLE_sdk   ("iPhoneOS14.5.sdk")
              DW_AT_stmt_list   (0x00050023)
              DW_AT_comp_dir    ("/Users/***/Desktop/hamster-ios/Example")
              DW_AT_APPLE_optimized (true)
              DW_AT_APPLE_major_runtime_vers    (0x02)
              DW_AT_low_pc  (0x000000010000c4d0)
              DW_AT_high_pc (0x000000010000cb30)  
 
0x0019604b:   DW_TAG_subprogram
                DW_AT_low_pc    (0x000000010000c8f4)
                DW_AT_high_pc   (0x000000010000cab0)
                DW_AT_frame_base    (DW_OP_reg29 W29)
                DW_AT_object_pointer    (0x00196065)
                DW_AT_call_all_calls    (true)
                DW_AT_name  ("-[HamsterDemoCrashViewController tableView:didSelectRowAtIndexPath:]")
                DW_AT_decl_file ("/Users/***/Desktop/hamster-ios/Example/Hamster/HamsterDemoCrashViewController.m")
                DW_AT_decl_line (58)
                DW_AT_prototyped    (true)
                DW_AT_APPLE_optimized   (true)

在 DWARF 调试信息中,最基本的调试信息单元叫 DIE(The Debugging Information Entry)。DIE 有多种不同的类型,并且它们之前会存在父子层级关系,在 dwarfdump 输出的 DebugLine 中可以通过缩进行数判断父子关系。

堆栈还原并不会用到所有类型的 DIE 及其属性,因此只要解析必须字段即可,我们主要需要以下几种类型的 DIE 及其属性:

  • DW_TAG_compile_unit:编译单元,代表一个源码文件,且一一对应,可以通过它获取源码文件名,以及该源码文件的行号相关信息(间接获取);
  • DW_AT_stmt_list:该编译单元在 DebugLine 中的偏移量,根据这个偏移可以在 DebugLine 信息中查找行号信息,关于 DebugLine 会在下文详细介绍;
  • DW_AT_name:该编译单元对应的源码文件名;
  • DW_TAG_subprogram:子程序,代表一个函数;
  • DW_AT_low_pc:函数的起始 PC 地址;
  • DW_AT_high_pc:函数的结束 PC 地址,需要注意的是,这两个地址只是在可执行文件中的偏移地址(file_addr);
  • DW_AT_name:函数名称。

2)DebugLine 调试行号信息

debug_line[0x00050023]
Line table prologue:
    total_length: 0x000004bf
          format: DWARF32
         version: 4
 prologue_length: 0x000002ee
 min_inst_length: 1
max_ops_per_inst: 1
 default_is_stmt: 1
       line_base: -5
      line_range: 14
     opcode_base: 13
...
 
Address            Line   Column File   ISA Discriminator Flags
------------------ ------ ------ ------ --- ------------- -------------
0x000000010000c880      0     27      3   0             0
0x000000010000c884     54     27      3   0             0
0x000000010000c88c     54     10      3   0             0
0x000000010000c89c      0     10      3   0             0
0x000000010000c8a0     54     10      3   0             0
0x000000010000c8a8     54     20      3   0             0
0x000000010000c8b8     54      5      3   0             0
0x000000010000c8d8     56      1      3   0             0  is_stmt
0x000000010000c8f4     58      0      3   0             0  is_stmt
0x000000010000c91c     61      5      3   0             0  is_stmt prologue_end
0x000000010000c934     62     29      3   0             0  is_stmt
0x000000010000c940     62     28      3   0             0

这是 DebugLine 文本形式的示例,具体说明如下:

  • 一个完整的 DWARF 信息中可能包含多个 DebugLine 实例,该示例只是其中之一。示例中第一行 debug_line[0x00050023] 中的 16 进制数值即为该 DebugLine 的偏移值,上一节介绍的 DW_TAG_compile_unit.DW_AT_stmt_list 属性记录的就是这个偏移值,可以通过该属性匹配到对应的 DebugLine 实例;
  • 第一行之后的元数据可以暂时略过,直接到下方的地址 - 行列号映射表,其中 AddressLineColumn 这三列即为地址与行列号的映射关系;
  • ISA 是一个无符号整数,这里一般都是 0,与堆栈还原无关;
  • Discriminator 无符号整数,标志当前的指令在多编译单元中的归属,在单编译单元的体系中一般是 0,与堆栈还原无关;
  • Flags 是一些标记位,这里解释两个与堆栈还原相关最重要的两个标记:
    • end_sequence 是目标文件机器指令结束地址 +1,所以可以认为在当前编译单元中,只有 end_sequence 对应地址之前的地址才是有效的指令;
    • is_stmt 表示当前指令是否为推荐的断点位置,一般而言 is_stmt 为 false 的代码可能对应的是编译器优化后的指令,这部分的指令一般行号都是 0,因为断点只可以打在一行,那么有 is_stmt 为标记的指令到下一条有该标记的指令之间,文件名与行号应该是完全相同的。

3)符号表文件解析实现

无论是 dSYM 格式的符号表文件还是包含在 dSYM 中的 DWARF 调试信息,都是二进制文件,且其格式比较复杂,直接解析存在一定门槛。幸而 Golang 标准库中包含了这些可执行文件的解析库,其中还包含了 DWARF 调试信息的解析能力,因此我们直接使用标准库的能力即可解析 iOS 的符号表文件。

解析 Demo 如下:

func parse(path string) error {
	file, err := macho.Open(path)
	if err != nil {
		return err
	}
	defer file.Close()
	
	textVmAddr := file.Segment("__TEXT").Addr		// 获取代码段的vm_addr偏移
	fmt.Println(textVmAddr)
	
	dwarfData, err := file.DWARF()	// 获取DWARF调试信息	
	if err != nil {
		return err
	}
	
	dwarfReader := dwarfData.Reader()
	for {
		// 遍历DWARF中的所有DIE
		entry, err := dwarfReader.Next()
		if err != nil || entry == nil {
			break
		}
		switch entry.Tag {
		case dwarf.TagCompileUnit
// 解析compileunit:
			fileName := entry.Val(dwarf.AttrName)
			attrStmtList := entry.Val(dwarf.AttrStmtList)
		case dwarf.TagSubprogram:
			// 解析subprogram
			lowPC := entry.Val(dwarf.AttrLowpc)
			highPC := entry.Val(dwarf.AttrHighpc)
			funcName := entry.Val(dwarf.AttrName)
		case dwarf.TagInlinedSubroutine:
			// 解析inlineSubroutine
			offset, _ := entry.Val(dwarf.AttrAbstractOrigin).(dwarf.Offset)
			originOffset := entry.Offset
			dwarfReader.Seek(offset)	// 跳转到另一个entry
			offsetFuncEntry, _ := dwarfReader.Next()
			funcName := offsetFuncEntry.Val(dwarf.AttrName)
			dwarfReader.Seek(originOffset) // 再跳转回来
			callLine := entry.Val(dwarf.AttrCallLine)
			callFile := entry.Val(dwarf.AttrCallFile)
		default:
			continue
		}
	}
}

3.1.4 基于 DWARF 调试信息的堆栈还原实现

根据前文对 DWARF 调试信息的介绍,可以将堆栈还原的基本流程概括为以下几个步骤:

  1. 根据栈帧中的地址范围在 DWARF 信息中找到对应的 Subprogram,获取函数名;
  2. 通过步骤 1 定位到的 Subprogram 找到其所属的 Parent CompileUnit,获取原文件名;
  3. 通过步骤 2 找到的 CompileUnit 定位 DebugLine,再通过栈帧中的地址范围匹配行号。

1)内联函数栈帧还原

内联函数是指编译器将一些特殊函数的函数体复制一份副本,直接嵌入调用该函数的地方,从而节省函数调用带来的额外开支。内联函数通常是编译器的一种优化手段,在部分编程语言中也可以指定函数为内联函数。

由于在运行时内联函数时不会产生真实函数调用,那么内存中将不会包含内联函数的栈帧,即原始堆栈中将不会包含内联函数这一层的调用关系,这与源码中的函数调用关系不符。因此,堆栈还原时需要把内联函数对应的栈帧额外插入到堆栈之中,这样才能更加准确地反映出源码中函数之间的调用关系,从而帮助开发者更加准确地定位问题。

在 DWARF 中还存在一类 DIE,其中包含了内联函数的调用关系,我们结合下面的示例说明涉及内联函数的堆栈应该如何还原。

// Debug Info
0x00056dac:   DW_TAG_subprogram
                DW_AT_linkage_name  ("_ZL23dispatch_get_main_queuev")
                DW_AT_name  ("dispatch_get_main_queue")
                DW_AT_decl_file ("/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneSimulator.platform/Developer/SDKs/iPhoneSimulator14.5.sdk/usr/include/dispatch/queue.h")
                DW_AT_decl_line (584)
                DW_AT_type  (0x0000000000056dbe "dispatch_queue_main_t")
                DW_AT_inline    (DW_INL_inlined)
 
0x00056dce:   DW_TAG_subprogram
                DW_AT_low_pc    (0x00000001000113f0)
                DW_AT_high_pc   (0x0000000100011440)
                DW_AT_frame_base    (DW_OP_reg6 RBP)
                DW_AT_object_pointer    (0x00056de8)
                DW_AT_name  ("-[MatrixTester generateMainThreadLagLog]")
                DW_AT_decl_file ("/Users/***/Desktop/***/MatrixTester.mm")
                DW_AT_decl_line (219)
 
0x00056e02:     DW_TAG_inlined_subroutine
                  DW_AT_abstract_origin (0x0000000000056dac "_ZL23dispatch_get_main_queuev")
                  DW_AT_low_pc  (0x0000000100011407)
                  DW_AT_high_pc (0x000000010001140f)
                  DW_AT_call_file   ("/Users/***/Desktop/***/MatrixTester.mm")
                  DW_AT_call_line   (220)
                  DW_AT_call_column (0x14)

上面的示例中包含了三个 DIE,下面逐一说明:

  • 第一个 DIE 是一个 Subprogram,即函数。观察它的所有属性,发现没有 low_pchigh_pc 两个代表地址范围的属性,而只包含了名称信息,同时由于它包含了 DW_AT_inline 属性,说明这个函数是内联函数;
  • 第二个 DIE 是一个普通的 Subprogram,包含地址信息,也包含函数名称;
  • 第三个 DIE 是 inline_subroutine 类型,说明这是一个内联函数的调用。由于它是第二个 DIE 的子 DIE,并且观察它的地址范围属性,可以发现该函数地址范围是第二个 DIE 地址范围的子集,这说明这个内联函数被嵌入到第二个 DIE 对应的函数之中。其次,观察 DW_AT_abstract_origin 属性,上面提到过,这个值是一个偏移并指向该内联函数的声明处,而该偏移值刚好指向的是第一个 DIE,所以这是第一个 DIE 所定义的内联函数的一处调用,我们可以通过第一个 DIE 获取到内联函数的函数名,通过第三个 DIE 的 DW_AT_call_lineDW_AT_call_file 获知内联函数具体的嵌入位置。

假设有一条栈帧的地址范围刚好位于第三个 DIE 的地址范围之间,那么这一栈帧指向内联函数调用(嵌入处),所以从第二个 DIE 中提取函数名即文件名即可。

值得注意的是行号,不能直接在 DebugLine 中获取,这是因为 DebugLine 会获取到最里层内联函数的行号,而内联函数的调用处要通过 inline_subroutine 中的 call_line 属性获取。则这一原始栈帧还原出来了两层源码级的栈帧:

栈帧一:
{inline_func} ({inline_file}:{inline_line}) > dispatch_get_main_queue (queue.h:{inline_line})

栈帧二:
{call_func} ({call_file}:{call_line}) >  -[MatrixTester generateMainThreadLagLog] (MatrixTester.mm:220)

这只是内联函数嵌套一层的情况,实际上内联函数之间也可以多级嵌套,多级内联的栈帧还原原理与上面的推导基本一致,唯一有区别的地方在于,多级内联的情况下,根据地址会匹配到多个 inlined_subroutine,而判断它们之间的调用关系也很简单:内联函数地址范围一定是调用函数地址范围的子集,因此通过地址范围大小进行排序就可得到多级内联函数的级联嵌套顺序。

综上所述,结合内联函数的定义,基于 DWARF 信息的堆栈还原原理可以总结为以下几个步骤:

  1. 根据 file_addr 定位 Subprogram,并获取函数名 func_name
  2. 反查该 Subprogram 所属的 CompileUnit,获取 file_name 和 DebugLine 的偏移;
  3. 通过 DebugLine 的偏移获取 DebugLine 信息,再通过地址,在该 DebugLine 中定位出文件行号 line
  4. 根据地址 file_addr 定位 ineline_subroutine,可以找到内联函数调用处所属文件 call_file 和内联函数调用处行号 call_line
  5. 根据 DW_AT_abstract_origin 定位定义了该内联函数的 Subprogram,可以获取内联函数名 inline_func

2)KV 设计

根据前文对于堆栈还原流程的介绍,我们可以看到,除步骤 2 匹配 CompileUnit 之外,其他每个步骤实际上就是通过堆栈中的地址与 DWARF 中的相关 DIE 的地址范围进行匹配直到找到目标 DIE。

在实际实现中,这个流程可以这样简化:由于 Subprogram 和 CompileUnit 存在父子关系,即一个 Subprogram 只会拥有一个父 CompileUnit,那么在解析 Subprogram 的数据结构中可以添加几个字段用于保存其父 CompileUnit 的必要信息,例如源码文件名、DebugLine 偏移等,因此上述流程就变成了根据栈帧地址匹配 SubprogramDebugLineInlineSubroutine 三个 DIE。如下图所示:

如何将这三个 DIE 的相关信息转换为 KV 呢?由于堆栈还是通过栈帧中的地址信息找到 DWARF 中对应的 DIE,因此可以确定这样的基本思路:DIE 的地址信息可以作为 key,DIE 中包含的信息作为 value。

但是,仔细推敲一下会发现,DIE 中的地址信息都是地址范围的形式(high_pclow_pc),而栈帧与 DIE 的匹配方式为:low_pc <= frame_address <= high_pc。在还原堆栈时,我们只知道 frame_address,而不知道其对应的 high_pclow_pc。也就是说,如果直接使用 low_pchigh_pc 作为 Redis key,我们在还原时可能需要逐个 key 去匹配栈帧地址,这与在符号表文件中逐个扫描 DIE 信息并没有本质的区别。

为了解决上述问题,我们换了一种思路,不以 DIE 的地址范围直接作为 key,而是将每个类型的 DIE 根据 low_pc 分为若干个桶,每个桶都分配一个 bucket_idbucket_id 的计算方式为:low_pc >> bucket_range << bucket_range

其中 bucket_range 是可配置的参数,这种计算方式可以简单理解为将 low_pc 的若干位低位地址抹零。bucket_id 相同的多个 DIE 作为一个分桶,那么 Redis key 就是这个 bucket_id,而 value 就是这个分桶中所有的 DIE 信息。在还原堆栈时,通过同样的算法,计算出栈帧中地址的 bucket_id,即可获取到整个分桶的 DIE 信息。

此时,虽然我们可能仍然无法避免逐个匹配这个分桶中的所有 DIE,但是至少可以通过 bucket_range 参数控制分桶的大小。另外,桶内的地址匹配也可以通过二分法(需先排序)代替低效的逐个轮询。

同时,由于使用 low_pc 作为分桶依据,可能会出现某个 DIE 的地址范围横跨两个甚至多个分桶的情况,例如下图中的 DIE3,若栈帧地址位于图中的位置,其地址属于 Bucket2,但 DIE3 却属于 Bucket1,因此无法在 Bucket2 中匹配到 DIE。为避免这种临界情况,若没有在当前 Bucket 中匹配到相应的 DIE,还需要尝试在前一个 Bucket 中进行匹配。

需要说明的是,我们在系统的实现中,会尽量选择使用基本的字符串作为 Redis 的数据结构,较复杂的 value 会转换为 JSON 字符串形式存储,这一点对于所有的堆栈还原服务都是适用的。虽然 Redis 中的一些数据结构看似很适合堆栈还原的场景(例如 Hash、Sorted Set 等),但是这些数据结构对于单 key 数据大小都有一定的限制。同时,由于符号表文件由业务提供,存在一定的不可控因素,比较容易产生大 key 等问题。因此我们选择简化 Redis KV 存储结构,将一些复杂的逻辑前移到还原服务内部。

至此我们介绍了如何在一个特定的 dSYM 文件中如何匹配还原信息,那么如何确定某一行栈帧对应哪个 dSYM 文件呢?

常规的思路为通过 MDAP 平台中注册的 app_name 和每个构建版本唯一识别 ID,与分桶 bucket_id 共同组成 key。但是这种方式存在一个问题,iOS 堆栈中不仅包含应用栈,还包含系统栈,系统栈需要通过系统符号表才能还原。而 key 中包含 app_name 意味着符号信息在应用间是隔离的,那么系统符号表和一些常用第三方动态库的符号 KV 信息就难以给所有应用在还原时共同使用。

前文在介绍堆栈格式时提到过,每行栈帧中都会包含一个 BinaryImageUUID,可以理解为可执行文件的唯一 ID,因此我们可以用 BinaryImageUUIDbucket_id 组成 key,这样不仅可以在 Redis 中精确匹配到对应的符号信息,一些系统库和公用库的符号 KV 信息在也是所有应用共享的,这样我们只需要上传一份系统符号表即可直接支持系统栈的还原,而无需定制化开发。综上所述,DWARF 调试信息的 Redis key 组成成分如下:

DIEType + machoUUID + bucketID

3)地址转换规则

栈帧中的地址实质上是代码段中的指令地址,操作系统在加载可执行文件时,代码段加载在内存中的地址实际上并不固定,而 DWARF 信息中的地址范围却都是在文件中写死的,因此我们需要对栈帧中的地址做相应的转换,才能用于 DWARF 调试信息的匹配。

首先明确几个与地址相关的几个概念:

  • file_addr:即 DWARF 信息中记录的地址,或者说可用于 DWARF 调试信息匹配的地址。我们需要先获取 file_addr,才能执行前文中介绍的一系列通过地址匹配调试信息的过程;
  • load_addr:代码段在内存中加载的基址;
  • runtime_addr:栈帧中的指令地址;
  • aslr_offset:操作系统 ASLR (Address Space Layout Randomization,地址空间布局随机化)机制随机加上的偏移;
  • text_vm_addr:代码段相对可执行文件的偏移。

它们之间满足以下关系:

runtime_addr = file_addr + aslr_offset     (1)
aslr_offset = load_addr - text_vm_addr     (2)
通过 (1) (2) 化简可得:
file_addr = runtime_addr - load_addr + text_vm_addr  (3)

通过公式 3,我们无需知道 ASLR 随机偏移值,即可计算出 file_addr

上述推导只是为了方便读者了解这几个地址之间的换算关系。而在实际实现中,并不会每一行栈帧还原时都会做上述转换,而是在符号表预解析时就对地址进行转换,等式 3 可以进一步转换为下面的形式:

file_addr - text_vm_addr = runtime_addr - load_addr (4)

其中,等式 4 等号右侧,实际上就是栈帧地址相对 BinaryImage 起始点的偏移,在上报的堆栈中实际上也包含这个字段,而等式 4 的左侧,都可以从符号表文件中直接获取。因此,我们在解析符号表文件时,存储的地址实际上是等式 4 左侧部分计算出来的值。

在进行堆栈还原时,只需要直接用栈帧中的相对偏移进行调试信息的匹配,这样就无需每次还原时都做一次地址转换。

3.2 Android Native 篇

由于 Android 底层是 Linux 系统,并且 Google 提供了 NDK(Android Native Development Kit),可以让开发者使用 C/C++ 语言编写代码,并直接编译为可执行文件,即 Linux 下的 elf(so) 文件。

它同样会包含 DWARF 格式的调试信息(也有可能会被裁剪),因此 Android Native 堆栈的还原原理与 iOS 几乎是完全一致的,Android Native 的符号表文件就是这个包含了调试信息的 so 文件。

3.2.1 Android Native 堆栈格式

Android Native 还原前堆栈示例如下:

pc 0x0000000000022074 libc.so [arm64-v8a::77e6f9ea7bad92cd845bdfb83dcb29d9]
pc 0x0000000000010d78 libapmDemo.so [arm64-v8a::ba78e30f9664d0150fba525ab5981b19]
pc 0x0000000000010ee8 libapmDemo.so [arm64-v8a::ba78e30f9664d0150fba525ab5981b19]
pc 0x000000000000e118 libapmDemo.so [arm64-v8a::ba78e30f9664d0150fba525ab5981b19]

其中每个栈帧包含以下信息:

  1. 这一栈帧的返回地址,例如第一行中的 0x0000000000022074
  2. so 名称,例如第一行中的 libc.so
  3. CPU 架构,例如第一行中的 arm64-v8a
  4. UUID,例如第一行中的 77e6f9ea7bad92cd845bdfb83dcb29d9,栈帧所属 so 文件的唯一 ID,可用于符号表文件的匹配。

可以看到,Android Native 的堆栈与 iOS 堆栈最大的区别在于,iOS 堆栈中包含了两个地址,即上文中介绍的 runtime_addr load_addr,而 Android Native 的栈帧中只有一个地址,这个地址实际上就是 file_addr

也就是说,Android Native 堆栈还原时无须进行地址转换,可以直接使用栈帧中的地址在 DWARF 信息中匹配符号信息。这也是 Android Native 堆栈还原与 iOS 堆栈还原的唯一区别,并且如前文所述,这里的些许区别也是由于 MDAP SDK 侧堆栈处理逻辑的差异导致。

在系统实现层面,Android Native 也与 iOS 几乎没有区别,我们尽可能做到了代码层面的复用。因此本章不再赘述实现层面的原理和细节。

3.3 Android Java 篇

根据基于原理的分类, Android Java 的堆栈还原属于“基于符号映射”,也就是说,Android Java 的堆栈还原本质上是堆栈的反混淆。

Android App 在构建过程中,会对 Java 代码进行压缩优化以及混淆转换,一方面是为了优化字节码文件(.class),另一方面也是为了防止字节码文件被反编译,起到源码保护的目的。

构建完成之后会生成一个 mapping.txt 文件,里面包含了 Java 代码混淆前后的映射关系,这个 mapping 文件就是 Android Java 的符号表文件。

3.3.1 Android Java 堆栈格式

Android Java 层的堆栈格式示例如下。由于该类型的堆栈还原实际上是符号信息的堆栈反混淆,因此其还原前后的堆栈格式是一致的,这点与 iOS 和 Android Native 有一点差异。

tg.a.e(MethodRecorder.kt:1)
tg.a.f(MethodRecorder.kt:2)
com.***.apm.trace.method.MethodTracingModule.storeCurrentRecords(MethodTracingModule.kt:1)
com.***.apm.trace.method.MethodTraceActivity$b.onClick(MethodTraceActivity.kt:1)
android.view.View.performClick(View.java:4788)
android.view.View$PerformClick.run(View.java:19923)

其中,每行栈帧中包含以下信息

  • 类名。且形式为全限定类名,例如第一行中的 tg.a,很明显这个类名已经经过了混淆;
  • 方法名。例如第一行中的 e,该方法名也经过了混淆;
  • 文件名。例如第一行中的 MethodRecorder.kt,文件名不会被混淆;
  • 行号。例如第一行中的 1,这个行号也是经过了转换,也需要对行号进行还原。

3.3.2 Android Java 符号表文件

如上文所述,Android Java 的符号表文件是 mapping 文件,示例如下:

com.***.hamster.similate.CrashActivity -> com.***.hamster.similate.CrashActivity:
    java.util.HashMap _$_findViewCache -> a
    1:1:void <init>():10:10 -> <init>
    android.view.View _$_findCachedViewById(int) -> a
    1:1:void access$catchFunc(com.***.hamster.similate.CrashActivity):10:10 -> a
    1:1:void access$loadSoError(com.***.hamster.similate.CrashActivity):10:10 -> b
    1:1:void access$newJavaCrash(com.***.hamster.similate.CrashActivity):10:10 -> c
    1:1:void access$nullPoint(com.***.hamster.similate.CrashActivity):10:10 -> d
    1:1:void access$onOOMCrash(com.***.hamster.similate.CrashActivity):10:10 -> e
    1:1:void catchFunc():57:57 -> k
    2:2:void catchFunc():59:59 -> k
    1:1:void loadSoError():78:78 -> l
    1:1:void middleFun():65:65 -> m
    1:1:void newJavaCrash():82:82 -> n
    1:1:void nullPoint():69:69 -> o
    1:2:void onCreate(android.os.Bundle):12:13 -> onCreate
    3:3:void onCreate(android.os.Bundle):15:15 -> onCreate
    4:4:void onCreate(android.os.Bundle):18:18 -> onCreate
    5:5:void onCreate(android.os.Bundle):21:21 -> onCreate
    6:6:void onCreate(android.os.Bundle):25:25 -> onCreate
    7:7:void onCreate(android.os.Bundle):28:28 -> onCreate
    8:8:void onCreate(android.os.Bundle):31:31 -> onCreate
    9:9:void onCreate(android.os.Bundle):34:34 -> onCreate
    10:10:void onCreate(android.os.Bundle):37:37 -> onCreate
    1:1:void nullPoint2():74:74 -> p
    1:1:void onOOMCrash():43:43 -> q
    2:3:void onOOMCrash():47:48 -> q

mapping 文件由多个 class 块组成,每个 class 块中包含了一个类的所有混淆前后的信息,并且均使用 -> 作为分隔符。

每个 class 块的第一行是混淆前后的全限定类名,如示例中第一行,这一行没有缩进,分隔符之前是混淆前类名,分隔符之后是混淆后类名,最后用 : 标识该行结束,同时标识这一行下面的内容是这个类的字段名和方法名映射。

字段名和方法名映射行都会有 4 格缩进,其中,成员变量名映射与类名映射相似,同样用 -> 作为分隔符,分隔符之前的是混淆前字段类型和字段名,分隔符之后的是混淆后变量名。

最后,是最重要的方法名和行号映射,同样通过 -> 分隔,它的格式如下:

[startline:endline:]originalreturntype[originalclassname.]originalmethodname(originalargumenttype,...)[:originalstartline[:originalendline]] -> obfuscatedmethodname
  • originalreturntype:原始返回类型,全限定类名,或者是基本类型,或者是 void;
  • originalmethodname:原始方法名;
  • originalclassname:可选,当方法不属于所在的类块时,需要特别通过全限定类名引用;
  • originalargumenttype:原始参数类型,全限定类名或者基本类型,多个参数通过 , 分隔;
  • obfuscatedmethodname:混淆后方法名。

除此之外还有行号信息,行号信息更加复杂一些,要分为无内联优化和有内联优化两种情况进行讨论。

1)无内联优化:

  • [startline:endline:]:表示原始代码的行号范围;
  • [:originalstartline[:originalendline]]:无内联优化时这个字段不存在。

2)有内联优化:

  • [startline:endline:]:可以理解为混淆过后的行号;
  • [:originalstartline[:originalendline]]:这个字段中 originalendline 也是可选的,所以还要分为两种情况:
    • [:originalstartline]:只有起始行号,表示这是内联函数展开的位置 ;
    • [:originalstartline:originalendline]:有行号范围,表示这是内联函数展开。

3.3.3 堆栈还原实现

相比二进制形式的 DWARF 信息,mapping 文件的格式比较直观,我们可以很容易地推导出基于 mapping 的堆栈还原的步骤:

  1. 按行扫描 mapping 文件,找到该栈帧中混淆类名对应的 class 块,同时可获取到还原后的类名;
  2. 按行扫描该 class 块,通过栈帧中的混淆方法名匹配类方法信息。需要注意的是,真实场景下经常会出现多个不同的方法混淆为相同的一个方法名的情况,因此这一步可能会得到多个方法信息作为候选;
  3. 扫描从上个步骤中获取到的方法信息候选清单,对比堆栈中的行号是否能够处于混淆后行号的区间,若可以命中,则取这个方法的混淆前名称作为还原后栈帧的方法名;
  4. 计算还原前栈帧中的行号与 startline 的相对偏移 offset,再用 originalstartline + offset 可以计算出还原前栈帧的行号;
  5. 文件名通常不会被混淆,直接取原始栈帧中的文件名即可。

1)KV 设计

根据上文对 mapping 文件的介绍可知,mapping 中混淆前后的符号信息都是成对出现的,基于此我们也可以确定一个 KV 设计的基本思路:混淆后符号信息作为 key,混淆前符号信息作为 value,与基于 DWARF 信息地址范围匹配不同,这里的符号信息作为 key 都是可以精确匹配的,因此 KV 可以相对比较直观。

具体实现上,我们将混淆后的类名作为 key,将这个类的混淆前名称和其所有的成员方法名映射、行号映射等信息打包为一个 JSON 作为 value。这样在堆栈还原时,可以直接通过栈帧中的混淆类名获取包含这个类所有映射信息,并以此拼接还原后的栈帧。

由于 Android Java 堆栈中没有相关信息可以协助我们匹配 mapping 文件,所以需要借助一个用于标记构建版本的 ID 关联 mapping 文件和上报堆栈。

具体关联方式如下:我们会提供 Gradle 插件,在 App 构建时将 mapping 文件上传到 MDAP 平台并指定该 mapping 的构建 ID,后台解析 mapping 为 KV 时,会将构建 ID 和混淆类名组成为 key,同时该插件会将这个构建 ID 注入到代码中去。

MDAP SDK 在上报堆栈时,会获取到这个构建 ID 并作为参数与堆栈一同上报,后台就可以通过构建 ID 和栈帧中的类名组成为 key,在 Redis 中获取这个 key 的相关符号信息。幸而 Android Java 端不会有系统堆栈或公共库的堆栈还原需求,我们可以通过这种方式将不同应用不同版本的符号 KV 在 Redis 中完全隔离。

综上所述,Android Java 端的 key 组成成分如下:

app_name + build_id + obf_class_name

3.4 Web & React Native 篇

Web H5 与 React Native 技术栈十分接近,且都是用 JavaScript/TypeScript 作为主要开发语言。两者的堆栈格式、符号表格式、堆栈还原原理基本一致,因此可合并讨论。

前端开发发展至今,开发编写的 JS 代码通常不会直接发布并运行在生产环境(用户浏览器)之中,而是会使用 Webpack 等打包工具将源码进行打包,这类打包工具的主要功能是将编写的代码进行一系列的压缩和转换,例如删除没有引用的函数和变量;将多个 JS 文件的代码合并到同一个文件中并转换为单行的形式。

这其实与 Android Java 应用的打包有点类似,只是前端打包工具打包出来的代码仍然是 JS 源码,且输出的 JS 代码通常已不可读,同理,浏览器执行这些代码时产生的错误堆栈,同样也是不可读。

打包工具在输出 JS 文件的同时,也会输出一个 sourcemap 文件,里面包含了打包前 JS 文件和打包后 JS 文件之间的映射关系,这些映射具体包含了文件名、函数名、变量名、行列号,这个 soucemap 文件正是 Web H5 和 React Native 端的符号表文件。

3.4.1 JavaScript 堆栈格式

Web H5 和 React Native 的堆栈基本结构都几乎一致,只是格式细节略有不同,包括 H5 侧在不同浏览器下采集到的堆栈格式也会有一些细节上的差异。因此这里只给出最通用的一种格式示例:

at n.capture (https://***.test.***/static/js/207.e017fea1.chunk.js:2:3768321)
at https://***.test.***/static/js/main.f6b17586.chunk.js:1:17348

一行栈帧由四部分组成:

  • 函数名。例如第一行中的 n.capture,可能不存在这一项,此时说明这一帧对应的是匿名函数,例如第二行;
  • 文件名。在浏览器中通常会连同域名输出完整路径;
  • 行号。在文件名后,与文件名用 : 分隔;
  • 列号。在行号后,与行号用 : 分隔。

3.4.2 JavaScript 符号表文件

JS 的符号表文件是 sourcemap,它也是文本文件,其格式是 JSON 字符串,下面结合简单示例进行介绍。

{
    "version": 3,
    "sources": [
        "constants/map/mapName/AL.ts"
    ],
    "names": [
        "supportRegionMap",
        "supportRegionNameMap"
    ],
    "mappings": "2GAAA,6GAAO,IAAMA,EAAmB,CAC9BC,OAAQ,YACRC,MAAO,QACPC,MAAO,QACPC,OAAQ,SACRC,QAAS,UACTC,MAAO,QACPC",
    "file": "static/js/20.1f019b33.chunk.js",
}

主要包含以下字段:

  • file:该 sourcemap 对应的打包后 JS 文件,sourcemap 文件与打包后 JS 文件通常是一一对应的关系;
  • sources:数组形式,该 sourcemap 对应的打包前文件,sourcemap(及其打包后文件)与打包前文件是一对多的关系,即打包时通常会将多个 JS 文件合并到一个 JS 文件中;
  • names:数组形式,该 sourcemap 对应打包前 JS 代码中包含的所有函数名和变量名;
  • mappings:通过 VLQ 编码的字符串,是 sourcemap 中最关键的部分,下面详细介绍 mapping 部分的组成。

mappings 部分是一个长字符串,其中可以分成三层含义进行解析:

  • 行信息映射,以 ; 分隔,分隔后每一个部分代表打包后的一行源码。例如第一个 ; 前的内容对应打包后该 JS 文件的第一行,以此类推;
  • 位置信息映射,以 , 分隔,分隔后每个部分代表打包后代码的某一段列区间,即代码位置;
  • 位置转换映射,每一段通过 ;, 分隔后的字符串,代表一个位置的转换信息,通过 VLQ 解码后,可以获取到 4-5 个数字,分别代表以下含义:
    • 第一位:该位置在打包后 JS 文件中的起始列(相对上一个位置);
    • 第二位:该位置对应打包前 JS 文件在 sources 数组中的 index 位置;
    • 第三位:该位置在打包前 JS 文件中的起始行(相对上一个位置);
    • 第四位:该位置在打包前 JS 文件中的起始列(相对上一个位置);
    • 第五位:该位置对应符号名在 names 数组中的 index 位置,可能不存在。

3.4.3 堆栈还原实现

1)基本流程

根据 sourcemap 格式分析,我们可以知道,位置是符号信息转换的最小粒度。也就是说,针对一行原始栈帧,只要在 sourcemap 中找到了它对应的行信息映射、位置信息映射以及位置转换映射,自然就可以组装出还原之后的栈帧。

具体步骤如下:

  1. 通过原始栈帧中的文件名找到 sourcemap 文件,并解析该文件;
  2. 通过原始栈帧中的行号,找到 sourcemap 中 mappings 字段对应的行信息;
  3. 将该行信息中的所有位置信息映射及其转换信息全部通过 VLQ 解码解析出来;
  4. 通过原始栈帧中的列号,去匹配找到对应的位置信息及位置准换映射(范围匹配,可用二分查找);
  5. 通过位置信息映射中的 2-5 位,可以获得还原之后的源码级堆栈。

2)KV 设计

虽然从堆栈还原的本质上看,JS 堆栈还原与 Java 都是堆栈反混淆的过程,但是由于它们符号表设计上的差异,两者还原的流程实际上很少有相似之处。

实际上,JS 堆栈的还原与基于 DWARF 地址匹配的还原流程有一定的相似之处。在 JS sourcemap 的设计中,JS 源码被分割为了若干个位置,sourcemap 中包含的是每个位置在打包前后的映射信息,而待还原栈帧与位置信息主要通过列号产生关联,每个位置包含若干列,因此需要通过列号范围进行匹配。

这里的 KV 设计的基本思路如下:先来看单个 sourcemap 文件内部符号如何匹配,考虑到符号信息是通过列号范围进行匹配,可以将位置按起始列号进行分桶,每个分桶拥有一个 BucketID,可以将行号和 BucketID 作为 key,而属于这个 Bucket 的所有位置信息均作为 value,以 JSON 的形式。

堆栈还原时,将栈帧中的行号和列号转化为 key,即可获取整个 Bucket 的所有位置信息,然后再通过二分法从 Bucket 中匹配出相应的位置信息,并以此获取还原后栈帧。

可以看到,这里的 KV 设计与 DWARF 调试信息的 KV 设计有一定的相似性,并且由于相同的原因,还原时若没有找到列号对应的位置信息,仍需尝试从上一个 Bucket 中搜索。

最后再来看一下如何匹配 sourcemap 文件,这一点其实 Web H5 和 React Native 的实现略有不同。

首先讨论 Web H5,在 Shopee 内部存在大量的微前端项目。微前端项目的基本特点为:在一个站点中,代码实际上来自不同的微前端模块,这些模块都是独立的工程项目,可能由不同的团队开发和维护,更重要的是它们都是可以独立部署上线的,而且不同微前端模块之间可以直接进行函数调用。

这就意味着堆栈中的栈帧可能来自不同的模块,因此这种场景下不能使用统一的构建 ID 来匹配 sourcemap,与 iOS 类似,我们需要一个在栈帧中和 sourcemap 中同时存在的元素,且该元素能够基本保证唯一,才能使用该元素进行符号匹配。

恰好,当前 H5 工程打包出来的 JS 文件名中,通常都会带有 ChunkHash(几乎已成为业界标准共识),基本可以保证唯一性,而堆栈和 sourcemap 中都会包含打包后的 JS 文件名,因此,我们可以直接通过打包后的 JS 文件名称作为 sourcemap 匹配的依据。

综上所述,Web H5 符号 KV 的 key 组成成分如下:

app_name + js_name + line + pos_bucket_id

再来看看 React Native,由于 React Native 打包后的 bundle 并没有业界共识的命名标准规范,再加上 React Native 没有类似于微前端的特殊场景,因此可以参照 Android Java 的方式,通过唯一构建 ID 来匹配符号信息,key 组成成分如下:

app_name + build_id + line + pos_bucket_id

3.5 痛点与优化

前文介绍的堆栈还原服务架构和实现,实际上就是我们最初版本的堆栈还原服务,并上线运行过一段时间。但是细心的读者可能已经发现,虽然我们对堆栈还原服务的内部原理和细节做了很详尽的介绍,但是基本没有提及符号表管理服务的相关实现。

前面提到过,符号表管理服务不仅负责符号表文件的解析,还会负责符号 KV 的管理。Redis 作为内存缓存空间有限且数据易失,无法缓存全量符号信息,因此当内存空间接近上限时,部分符号 KV 会被淘汰。

虽然 Redis 集成了许多淘汰算法,但是其淘汰粒度都是单 key,为了便于管理符号 KV 的缓存状态,需要将淘汰粒度控制在应用构建版本(构建 ID)的粒度,这就意味着我们需要维护许多元数据,例如各构建版本分别包含哪些 key,以及各构建版本近期的堆栈还原请求量,并根据这些元数据以及当前内存使用情况,定期淘汰部分版本的符号 KV。

因此会存在以下两个痛点:

  1. 符号 KV 的淘汰策略逻辑较为复杂,维护成本较高,再加上除应用符号表外,还需要维护系统符号表的相关状态,会让这里的管理逻辑进一步复杂化;
  2. 由于符号 KV 缓存会被淘汰,同时堆栈还原服务仅能通过符号 KV 进行还原,而不能降级为通过符号表文件还原堆栈,因此如果符号表 KV 信息被 Redis 淘汰后,要么直接返回错误,要么重新解析符号表文件并写入 Redis。前者会影响服务成功率,后者影响服务接口耗时,无论采用哪种方案都会降低整体服务质量。

上述几个问题的本质在于 Redis 内存空间的局限,因此,我们后续又对架构进行了一次升级。主要升级点是在符号文件存储与符号 KV 缓存之间加了一层符号 KV 持久化存储,用于全量存储符号 KV 信息。堆栈还原时,即使没有命中符号 KV 缓存,也可以从符号 KV 存储中快速获取符号 KV 信息并进行还原,避免了再次解析符号文件。

在技术方案选型上,我们没有参考一些业界方案使用 HBase 或者其他 KV 数据库,而是选用了 Shopee 自研的 COPI2 服务,COPI2 是对 Redis 和 KV 数据库 RocksDB 的集成和封装。最重要的是,它兼容 Redis 协议。也就是说,我们无需修改业务代码,只需修改配置即可完成升级。与其说是架构升级,不如说是中间件选型切换。

对于 COPI2,我们可以认为这就是一个存储空间非常大的 Redis,无需考虑单个符号表究竟是保存在 cache 中还是 storage 中,这帮助我们大大简化了符号表的 KV 管理逻辑,同时也便于支持系统符号表的管理与系统堆栈的还原。

3.6 Benchmark

我们使用 4 核(CPU)4G(内存)的配置在容器中部署了各类堆栈还原服务并进行压测(CPU 型号为 Intel(R) Xeon(R) Silver 4216 CPU @ 2.10GHz),结果如下:

4. 未来规划

本文主要介绍了 MDAP 平台对 Shopee 内部主流客户端类型的堆栈还原解决方案,通过将符号表文件转换为 KV 形式存储的思路,大幅度优化了堆栈还原过程中符号查找的开销,从而支撑海量堆栈数据上报的还原需求。

目前已有多个 Shopee 内部业务接入 MDAP 堆栈上报相关功能,并通过堆栈还原能力帮助业务开发定位解决了各自业务中的各类问题。后续 MDAP 将继续完善这一部分的能力,主要包括以下几部分:

完善系统符号表和主流第三方库符号表的管理。

因为对于一些应用类型来说,堆栈中包含了许多系统层堆栈或第三方库堆栈,而这些堆栈对定位问题也有十分重要的作用,业务主动上传的符号表中通常不会包含这部分符号信息,因此这些符号表需要平台侧主动收集和维护,提升堆栈还原的整体质量,从而进一步提升通过堆栈定位问题的能力。

进一步挖掘海量堆栈数据中隐含的深层次信息,提升解决问题的效率。

由于大部分堆栈实际上都是重复的或者相似的,这就意味着可以通过一些算法对堆栈进行聚合,并提取一些公共特征,方便开发者更有针对性的解决问题。同时,由于还原后的堆栈包含了源码信息,我们可以与代码仓库的提交记录结合起来,精准匹配问题代码的责任人。只要实现了精准聚类和精准匹配责任人,就可以以此为依据进行精准的告警或提单,实现完整功能闭环,为研发流程赋能提效。

本文作者

Weizhe,后端工程师,来自 Shopee Engineering Infrastructure 团队。