关于客户端:客户端堆栈还原原理及实现

39次阅读

共计 25537 个字符,预计需要花费 64 分钟才能阅读完成。

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

摘要

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

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

  • 《MDAP:可观测性数据分析平台设计与实际》
  • 《机器学习在基于 URL 的客户端监控剖析中的优化和实际》
  • 《Android 卡顿与 ANR 的剖析实际》

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 操作,产生性能瓶颈。
  2. 间接应用这些工具还会导致后续服务难以保护,这个问题体现在以下几个方面:

    • 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 团队。

正文完
 0