iOS 符号解析重构之路

2021年11月26日 阅读数:3
这篇文章主要向大家介绍iOS 符号解析重构之路,主要内容包括基础应用、实用技巧、原理机制等方面,希望对大家有所帮助。

做者:字节跳动终端技术——丰亚东html

1、背景前端

1.1 什么是符号解析

所谓的符号解析就是就是将崩溃日志中的地址映射成为可读的符号和源文件中的行号,方便开发者定位和修复问题。以下图,第一份彻底不可读的崩溃日志通过完整的符号解析变成了第三份彻底可读的日志。对于字节的稳定性监控平台而言,须要支持 iOS 端的崩溃/卡死 /卡顿/自定义异常等各类日志类型的反解,所以符号解析也是监控平台必备的一项底层基础能力。c++

1.2 系统原生符号解析工具

symbolicatecrash

Xcode 提供的 symbolicatecrash。该命令位于:/Applications/Xcode.app/Contents/SharedFrameworks/DVTFoundation.framework/Versions/A/Resources/symbolicatecrash,是一个perl 脚本,里面整合了逐步解析的操做(也能够将命令拷贝出来,直接进行调用)。git

用法:symbolicatecrash log.crash -d xxx.app.dSYMgithub

优势:能很是方便的符号化整份 crash 日志。golang

缺点:redis

  1. 耗时比较久。
  2. 粒度比较粗,没法符号化特定的某一行。

atos

用法:atos -o xxx.app.dSYM/Contents/Resources/DWARF/xxx -arch arm64/armv7 -l loadAddress runtimeAddress编程

优势:速度快,能够符号化特定的某一行,方便上层作缓存。后端

1.3 原生工具的问题

可是上面的这两个工具都有两个最大的缺陷就是:api

  1. 都仅仅是单机的工具,没法做为在线服务提供。
  2. 必须依赖 macOS 系统,因 为字节服务端基建所有基于Linux,致使没法复用集团各类平台和框架,这就带来了很是高的机器成本,部署成本和运维成本。

2、历史方案探索

为了解决这两大痛点,搭建一套 Linux 上能够提供 iOS 在线符号解析的服务,历史上咱们依次作了以下探索:

方案1:llvm-atosl

这其实就是基于 llvm 自带的符号解析工具作了一些定制化的改造。单行日志在线解析流程图以下:

\

这套方案起初没有太大的问题,可是随着时间的推移,晚高峰期间常常出现由于解析超时致使解析失败进而只能看到地址偏移而看不到符号的问题,所以还须要找到瓶颈再进一步优化。

方案2:llvm-atosl-cgo

其实就是将 llvm-atosl 工具经过cgo而不是命令行形式调用。方案1上线以后咱们观察到在晚高峰期间单行解析pct99很是夸张,由于超时致使的解析失败愈来愈多,甚至有一次晚高峰期间整个服务直接夯住,登陆到线上机器看到大量too many open files报错,当时怀疑到是fd占用超过上限,又联想到每次执行 llvm-atosl 脚本会占用至少 3 个 fd(stdin,stdout和stderr),所以咱们尝试将 llvm-atosl 从命令行工具的形式封装为一个c的library,再经过cgo在 golang 侧调用:

package main

/*
#cgo CFLAGS: -I./tools
#cgo LDFLAGS: -lstdc++ -lncurses -lm -L${SRCDIR}/tools/ -lllvm-atosl
#include "llvm-atosl-api.h"
#include <stdlib.h>
*/
import "C"

import (
  "fmt"
  "strconv"
  "strings"
  "unsafe"
)

func main() {
    result = symbolicate("~/dsym/7.8.0(78007)eb7dd4d73df0329692003523fc2c9586/Aweme.app.dSYM/Contents/Resources/DWARF/Aweme","arm64","0x100008000","0x0000000102cff4b8");
    fmt.Println(result)
}

func symbolicate(go_path string, go_arch string, go_loadAddress string, go_address string) string {
    c_path := C.CString(go_path)
    c_arch := C.CString(go_arch)

    loadAddress := hex2int(go_loadAddress)
    c_loadAddress := C.ulong(loadAddress)

    address := hex2int(go_address)
    c_address := C.ulong(address)

    c_result := C.getSymbolicatedName(c_path, c_arch, c_loadAddress, c_address)

    result := C.GoString(c_result)

    C.free(unsafe.Pointer(c_path))
    C.free(unsafe.Pointer(c_arch))
    C.free(unsafe.Pointer(c_result))

    return result;
}

func hex2int(hexStr string) uint64 {
     // remove 0x suffix if found in the input string
     cleaned := strings.Replace(hexStr, "0x", "", -1)

     // base 16 for hexadecimal
     result, _ := strconv.ParseUint(cleaned, 16, 64)
     return uint64(result)
 }

本觉得从跨进程调用切换到进程内调用,能够同时减小 fd 的占用和进程间通讯的开销,可是上线以后解析的效率不只没有提高,反而降低了。参考一篇博客《如何把Go调用C的性能提高 10 倍?》(连接见参考资料[1])中的结论,cgo性能不佳的两大缘由:

  1. 线程的栈在 Go 运行时是比较少的,受到 P(Processor,能够理解为 goroutine 的管理调度者)以及 M(Machine,能够理解为物理线程)数量的限制,通常能够简单的理解成受到GOMAXPROCS限制,go 1.5 版本以后的GOMAXPROCS默认是机器 CPU 核数,所以一旦cgo并发调用的方法数量超过GOMAXPROCS,就会发生调用阻塞。
  2. 因为须要同时保留 C/C++ 的运行时,cgo须要在两个运行时和两个 ABI(抽象二进制接口)之间作翻译和协调。这就带来了很大的开销。

这说明关于 fd 占用过多以及跨进程调用的性能瓶颈的猜测实际上是不成立的,所以这个方案也被证明是不可行的

方案3:golang-atos

基于 golang 原生的系统库debug/dwarf,能够实现对 DWARF 文件的解析,将地址解析为符号,能够替换 llvm-atosl 的实现,而且能够自然利用 golang 协程的特性实现高并发。实现方案能够参考下面这段源码:

package dwarfexample
import (
    "debug/macho"
    "debug/dwarf"
    "log"
    "github.com/go-errors/errors")
func ParseFile(path string, address int64) (err error) {
    var f *macho.FatFile
    if f, err = macho.OpenFat(path); err != nil {
        return errors.New("open file error: " + err.Error())
    }

    var d *dwarf.Data
    if d, err = f.Arches[1].DWARF(); err != nil {
        return
    }

    r := d.Reader()

    var entry *dwarf.Entry
    if entry, err = r.SeekPC(address); err != nil {
        log.Print("Not Found ...")
        return
    } else {
        log.Print("Found ...")
    }

    log.Printf("tag: %+v, lowpc: %+v", entry.Tag, entry.Val(dwarf.AttrLowpc))

    var lineReader *dwarf.LineReader
    if lineReader, err = d.LineReader(entry); err != nil {
        return
    }

    var line dwarf.LineEntry

    if err = lineReader.SeekPC(0x1005AC550, &line); err != nil {
        return
    }

    log.Printf("line %+v:%+v", line.File.Name, line.Line)

    return
}

可是在单元测试的时候发现 golang-atos 单行解析的效率比 llvm-atosl 的解析效率慢 10 倍,缘由是对 DWARF 文件的解析 golang 版本的实现就是要比 llvm 的 C++ 版本更耗时。所以这个方案也不可行

3、终极解决方案

3.1 方案总体设计

后来经过监控发现,每次解析效率下降,大量报错的时候,存储符号表文件的分布式文件系统 CephFS 的读流量都特别高:这才意识到符号解析的真正瓶颈在网络 IO,由于抖音和头条等一些超级 App 的符号表文件大小常常超过 1GB,并且天天内测包上传的数量很是多,虽然符号表在物理机本地有缓存,可是总有一些长尾的符号表是没法命中缓存的,在晚高峰期间须要从分布式文件系统向后端容器实例同步,同时也由于符号解析是随机的分发到集群中的某台物理机,所以会放大这个问题:网络 IO 流量越高,符号解析就越慢,符号解析越慢,就越容易堆积,反过来可能形成网络 IO 流量更高,这样一个恶性循环最终可能致使整个服务彻底夯住。咱们最终采用了符号表上传时全量解析符号表文件中地址与符号的映射关系,线上直接查在线缓存的终极解决方案:核心改动点:

  1. 将符号和地址的映射从崩溃时查找对应的符号表文件调用命令行工做解析改为了符号表文件上传时全量预解析全部地址与符号的映射关系,而后将映射关系结构化存储,崩溃时查找缓存便可。
  2. 为了解决部分 C++ 与 Rust 符号 demangle 失效以及各类语言 demangle 工具不一致的问题。将本来 llvm 自带的 demangle 工具替换成了一个 Rust 实现,支持全语言的 demangle 工具 symbolic-demangle(连接见参考资料[2]),极大的下降了运维成本。
  3. 优先采用新方案作符号解析,新方案没命中放量或者新方案解析失败用老方案作兜底。

3.2 方案实现细节

3.2.1 符号表文件格式

DWARF

文件结构

DWARF 是一种调试信息格式,一般用于源码级别调试,也可用于从运行时地址还原源码对应的符号以及行号的工具(如: atos)。

Xcode 打包若是在 Build Options -> Debug Infomation format 设置了DWARF with dSYM以后,Xcode 会生成一个 dSYM 文件,其中显式包含 DWARF 从而帮助咱们根据地址,找到方法符号及文件名和行号等信息,方便开发者在版本正式发布以后排查问题。咱们以 AwemeDylib.framework.dSYM 中的 DWARF 文件为例,用 macOS 下的 file 指令观察下它的文件类型:

经过上图能够看出来,DWARF 其实也是 Mach-O 文件的一种类型,所以它也能够用 MachOView 工具打开分析。从上图中看到它的 Mach-O 文件的类型是MH_DSYM。既然是 Mach-O 文件,使用 size 命令能够查看 AwemeDylib 这个 DWARF 文件中包含的 Segment 和 Section,以 arm64 架构为例:

~/Downloads/dwarf/AwemeDylib.framework.dSYM/Contents/Resources/DWARF > size -x -m -l AwemeDylib
AwemeDylib (for architecture arm64):
Segment __TEXT: 0x18a4000 (vmaddr 0x0 fileoff 0)
        Section __text: 0x130fd54 (addr 0x5640 offset 0)
        Section __stubs: 0x89d0 (addr 0x1315394 offset 0)
        Section __stub_helper: 0x41c4 (addr 0x131dd64 offset 0)
        Section __const: 0x1a4358 (addr 0x1321f40 offset 0)
        Section __objc_methname: 0x47c15 (addr 0x14c6298 offset 0)
        Section __objc_classname: 0x45cd (addr 0x150dead offset 0)
        Section __objc_methtype: 0x3a0e6 (addr 0x151247a offset 0)
        Section __cstring: 0x1bf8e4 (addr 0x154c560 offset 0)
        Section __gcc_except_tab: 0x1004b8 (addr 0x170be44 offset 0)
        Section __ustring: 0x1d46 (addr 0x180c2fc offset 0)
        Section __unwind_info: 0x67c40 (addr 0x180e044 offset 0)
        Section __eh_frame: 0x2e368 (addr 0x1875c88 offset 0)
        total 0x189e992
Segment __DATA: 0x5f8000 (vmaddr 0x18a4000 fileoff 0)
        Section __got: 0x4238 (addr 0x18a4000 offset 0)
        Section __la_symbol_ptr: 0x5be0 (addr 0x18a8238 offset 0)
        Section __mod_init_func: 0x1850 (addr 0x18ade18 offset 0)
        Section __const: 0x146cb0 (addr 0x18af670 offset 0)
        Section __cfstring: 0x1b2c0 (addr 0x19f6320 offset 0)
        Section __objc_classlist: 0x1680 (addr 0x1a115e0 offset 0)
        Section __objc_nlclslist: 0x28 (addr 0x1a12c60 offset 0)
        Section __objc_catlist: 0x208 (addr 0x1a12c88 offset 0)
        Section __objc_protolist: 0x2f0 (addr 0x1a12e90 offset 0)
        Section __objc_imageinfo: 0x8 (addr 0x1a13180 offset 0)
        Section __objc_const: 0xb2dc8 (addr 0x1a13188 offset 0)
        Section __objc_selrefs: 0xf000 (addr 0x1ac5f50 offset 0)
        Section __objc_protorefs: 0x48 (addr 0x1ad4f50 offset 0)
        Section __objc_classrefs: 0x16a8 (addr 0x1ad4f98 offset 0)
        Section __objc_superrefs: 0x1098 (addr 0x1ad6640 offset 0)
        Section __objc_ivar: 0x42c4 (addr 0x1ad76d8 offset 0)
        Section __objc_data: 0xe100 (addr 0x1adb9a0 offset 0)
        Section __data: 0xc0d20 (addr 0x1ae9aa0 offset 0)
        Section HMDModule: 0x50 (addr 0x1baa7c0 offset 0)
        Section __bss: 0x1e9038 (addr 0x1baa820 offset 0)
        Section __common: 0x1058e0 (addr 0x1d93860 offset 0)
        total 0x5f511c
Segment __LINKEDIT: 0x609000 (vmaddr 0x1e9c000 fileoff 4096)
Segment __DWARF: 0x2a51000 (vmaddr 0x24a5000 fileoff 6332416)
        Section __debug_line: 0x3e96b7 (addr 0x24a5000 offset 6332416)
        Section __debug_pubnames: 0x16ca3a (addr 0x288e6b7 offset 10434231)
        Section __debug_pubtypes: 0x2e111a (addr 0x29fb0f1 offset 11927793)
        Section __debug_aranges: 0xf010 (addr 0x2cdc20b offset 14946827)
        Section __debug_info: 0x12792a4 (addr 0x2ceb21b offset 15008283)
        Section __debug_ranges: 0x567b0 (addr 0x3f644bf offset 34378943)
        Section __debug_loc: 0x674483 (addr 0x3fbac6f offset 34733167)
        Section __debug_abbrev: 0x2637 (addr 0x462f0f2 offset 41500914)
        Section __debug_str: 0x5d0e9e (addr 0x4631729 offset 41510697)
        Section __apple_names: 0x1a6984 (addr 0x4c025c7 offset 47609287)
        Section __apple_namespac: 0x1b90 (addr 0x4da8f4b offset 49340235)
        Section __apple_types: 0x137666 (addr 0x4daaadb offset 49347291)
        Section __apple_objc: 0x13680 (addr 0x4ee2141 offset 50622785)
        total 0x2a507c1
total 0x4ef6000

能够看到有一个名为 __DWARF 的 Segment, 下面包含 __debug_line__debug_aranges__debug_info等不少类 Section。咱们可使用dwarfdump来探索DWARF段中的内容,例如输入命令dwarfdump AwemeDylib --debug-info 可展现__debug_infoSection 下已经格式化以后的内容。关于dwarfdump指令的完整用法能够参考 llvm 工具链的官方文档(连接见参考资料[3])。参考《DWARF 文件格式官方文档》(连接见参考资料[4]),这些 section 之间的关系以下图所示:

debug_info

debug_infosection 是 DWARF 文件中最核心的信息。DWARF 用The Debugging Information Entry (DIE) 来以统一的形式描述这些信息,每一个 DIE 包含:

  • 一个 TAG 属性表达描述什么类型的元素, 如: DW_TAG_subprogram(函数)、DW_TAG_formal_parameter(形式参数)、DW_TAG_variable(变量)、DW_TAG_base_type(基础类型)。
  • N 个属性(attribute), 用于具体描述一个 DIE。

下面是一段示例:

0x0049622c:   DW_TAG_subprogram
                DW_AT_low_pc        (0x000000000030057c)
                DW_AT_high_pc        (0x0000000000300690)
                DW_AT_frame_base        (DW_OP_reg29 W29)
                DW_AT_object_pointer        (0x0049629e)
                DW_AT_name        ("+[SSZipArchive _dateWithMSDOSFormat:]")
                DW_AT_decl_file        ("/var/folders/03/2g9r4cnj3kqb5605581m1nf40000gn/T/cocoapods-uclardjg/Pods/SSZipArchive/SSZipArchive/SSZipArchive.m")
                DW_AT_decl_line        (965)
                DW_AT_prototyped        (0x01)
                DW_AT_type        (0x00498104 "NSDate*")
                DW_AT_APPLE_optimized        (0x01)

就其中的一部分关键数据解读以下:

  • DW_AT_low_pcDW_AT_high_pc 分别表明函数的起始/结束 PC 地址。
  • DW_AT_name 描述函数的名字为 +[SSZipArchive _dateWithMSDOSFormat:]。
  • DW_AT_decl_file 说这个函数在.../SSZipArchive.m 文件中声明。
  • DW_AT_decl_file指的是这个函数在.../SSZipArchive.m 文件第 965 行声明。
  • DW_AT_type描述的是函数的返回值类型,对于这个函数来讲,为 NSDate*。

值得注意的是:

  1. DWARF 只有有限种类的属性, 所有属性的列表能够参考 llvm api 文档(连接见参考资料[5])中 DW_TAG 开头的部分。
  2. DW_AT_low_pc 和 DW_AT_high_pc 描述的机器码地址不等价于程序在运行时的地址,咱们能够称之为 file_address。操做系统基于安全因素的考虑,会应用一种地址空间布局随机化的技术 ASLR,加载可执行文件到内存时,会作一个随机偏移(下文中用 load_address 代指),咱们获取到偏移后还须要加上__TEXTSegment 的 vmaddr 才能够还原出运行时地址。vmaddr 能够经过上面的size指令或者otool -l指令拿到。注意vmaddr通常跟架构有着直接的关系,对于 armv7 架构而言一般是0x4000,对于 arm64 架构而言一般是 0x100000000,可是也不绝对,例如这里放的 AwemeDylib 动态库符号表 arm64 架构的 vmaddr 就是 0。咱们将函数在 App 运行时的地址称之为 runtime_address。

上述几种地址他们之间的计算公式为:

file_address = runtime_address - load_address + vm_address

CompileUnit

CompileUnit 翻译过来就是编译单元。一个编译单元一般对应着一个 TAG 是DW_TAG_compile_unit的 DIE。编译单元表明的是一个可执行源文件编译后的__TEXT__DATA等产物,通常能够简单的理解为咱们代码中的一个参与编译的文件,例如.m,.mm,.cpp,.c等不一样编程语言对应的源文件。一个编译单元包含在这个编译单元中声明的全部DIE(包括方法,参数,变量等)。举一个典型的例子:

0x00495ea3: DW_TAG_compile_unit
              DW_AT_producer        ("Apple LLVM version 10.0.0 (clang-1000.11.45.5)")
              DW_AT_language        (DW_LANG_ObjC)
              DW_AT_name        ("/var/folders/03/2g9r4cnj3kqb5605581m1nf40000gn/T/cocoapods-uclardjg/Pods/SSZipArchive/SSZipArchive/SSZipArchive.m")
              DW_AT_stmt_list        (0x001e8f31)
              DW_AT_comp_dir        ("/private/var/folders/03/2g9r4cnj3kqb5605581m1nf40000gn/T/cocoapods-uclardjg/Pods")
              DW_AT_APPLE_optimized        (0x01)
              DW_AT_APPLE_major_runtime_vers        (0x02)
              DW_AT_low_pc        (0x00000000002fc8e8)
              DW_AT_high_pc        (0x0000000000300828)

就其中的一部分关键数据解读以下:

  • DW_AT_language,描述的是当前编译单元使用的是哪一种编程语言。
  • DW_AT_stmt_list 指的是当前编译单元对应的行号信息在debug_line section 中的偏移,在下一小结中咱们再详细介绍。
  • DW_AT_low_pcDW_AT_high_pc 这里分别表明编译单元包含的全部DW_TAG_subprogramTAG 的 DIE 的总体的起始/结束的 PC 地址。
debug_line

经过输入指令dwarfdump AwemeDylib --debug-line能够查看到debug_linesection 结构化以后的数据。而后咱们搜索上一小结中的DW_AT_stmt_list,也就是0x001e8f31

debug_line[0x001e8f31]
...
include_directories[  1] = "/var/folders/03/2g9r4cnj3kqb5605581m1nf40000gn/T/cocoapods-uclardjg/Pods/SSZipArchive/SSZipArchive"
...
file_names[  1]:
           name: "SSZipArchive.m"
      dir_index: 1
       mod_time: 0x00000000
         length: 0x00000000
...
Address                                     Line  Column File   ISA   Discriminator     Flags
------------------------ ------   ------    --- -----  -------------  --------
0x00000000002fc8e8        46           0       1         0                         0    is_stmt
0x00000000002fc908        48          32       1         0                         0    is_stmt prologue_end
0x00000000002fc920         0          32       1         0                         0 
0x00000000002fc928        48          19       1         0                         0 
0x00000000002fc934        49           9       1         0                         0    is_stmt
0x00000000002fc938        53          15       1         0                         0    is_stmt
0x00000000002fc940        54           9       1         0                         0    is_stmt
...
0x0000000000300828  1058               1       1         0                         0    is_stmt end_sequence

include_directoriesfile_names组合起来就是参与编译文件的绝对路径。而后下面的列表就是 file_address 对应的文件名和行号。

  • Address:这里指的是 FileAddress。
  • Line: 指的是 FileAddress 在源文件中对应的行号。
  • Column:FileAddress 在源文件中对应的列号。
  • File:源文件 index,与上面 file_names 中的下标是一致的。
  • ISA:无符号整数,指的是当前指令适用于哪些指令集架构,这里通常都是 0。
  • Discriminator:无符号整数,标志当前的指令在多编译单元中的归属,在单编译单元的体系中通常是 0。
  • Flags:一些标记位,这里解释其中最重要的两个:
    • end_sequence:是目标文件机器指令结束地址+1,因此能够认为在当前编译单元中,只有 end_sequence 对应地址以前的地址才是有效的指令。
    • is_stmt:表示当前指令是否为推荐的断点位置,通常而言 is_stmt 为 false 的代码可能对应的是编译器优化后的指令,这部分的指令通常行号都是 0,对咱们分析问题是有干扰的,下文中会讲如何校订。
符号解析原理

好比这行调用栈:

5 AwemeDylib 0x000000010035d580 0x10005d000 + 3147136

对应的 binaryImage 是:

0x10005d000 - 0x1000dffff AwemeDylib arm64

经过文件结构这一小节咱们能够经过公式计算出崩溃地址对应的 file_address:

file_address = 0x000000010035d580 - 0x10005d000 + 0x0 = 0x300580

而后咱们用dwarfdump --lookup指令能够查找出对应的方法名和行号:

咱们用流程图描述一下dwarfdump从地址到符号映射的原理(atos 等其余工具同理):

能够看到最终dwarfdump解析的结果与咱们手动人肉解析的结果也是彻底一致的,下图中 0x30057c~0x300593 这个地址范围解析出来的文件名和行号都是彻底一致的。

基于 DWARF 文件的符号解析咱们预期解析结果的格式是:

func_name (in binary_name) (file_name:line_number)

以 FileAddress 0x300580 为例,咱们手动人肉解析的结果是:

+[SSZipArchive _dateWithMSDOSFormat:] (in AwemeDylib) (SSZipArchive.m:965)

而后咱们用 atos 工具执行命令手动解析的结果是:

dwarf atos -o AwemeDylib.framework.dSYM/Contents/Resources/DWARF/AwemeDylib -arch arm64 -l 0x10005d000 0x000000010035d580 +[SSZipArchive _dateWithMSDOSFormat:] (in AwemeDylib) (SSZipArchive.m:965)

可见 atos 与咱们手动人肉解析的结果也是彻底一致的。

Symbol Table

上一个大的章节,咱们介绍了经过 DWARF 文件来实现符号解析的原理。可是这种方案并不能覆盖 100% 的场景。缘由是:

  1. 若是被静态连接的 Framework 在打包的时候将编译参数GCC_GENERATE_DEBUGGING_SYMBOLS改为 NO,那么最终 App 打包时候生成的 dSYM 文件将没有这部分代码生成机器指令对应的文件名和行号信息。
  2. 对于系统库而言,并无提供 dSYM 文件,咱们有的仅仅是.dylib 或者 .framework 等格式的 MachO 文件,例如libobjc.A.dylibFoundation.framework等。

对于没有 DWARF 文件的符号,咱们就须要用另一种手段:Symbol Table String来进行符号解析。

文件结构

MachO 文件中 Symbol Table 部分在 MachoView 工具中的格式以下:

关键信息解读:

  • String Table Index:就是 String 表中的偏移量。经过这个偏移量能够访问到符号对应的具体字符串,例如上图中圈中的第一个 symbol info 的偏移量是 0x0048C12B,再加上 String Table 的起始地址 0x02BBC360 ,等于 0x304848B。查询以后果真是 _ff_stream_add_bitstream_filter。

  • value:当前方法对应的起始的 FileAddress。
符号解析原理
  1. 对 Symbol Table 列表的 value 排序。
  2. 将 value 排好序,查找到刚恰好小于 value的index,则崩溃的信息就存在于 index-1下标的数据区中,再用 index-1 下标数据区中的 String Table Index 就能够在 String Table 索引到对应的方法名。而后 FileAddress - 目标数据区的 value 就是崩溃地址距离方法起始地址的偏移字节数。

基于 Symbol Table 的符号解析咱们预期解析结果的格式是:

func_name (in binary_name) + func_offset

以 FileAddress 0x56C1DE 为例,咱们手动人肉解析的结果是:

_ff_stream_add_bitstream_filter (in AwemeDylib) + 2

而后咱们用 atos 工具执行命令手动解析的结果是:

dwarf atos -o AwemeDylib.framework.dSYM/Contents/Resources/DWARF/AwemeDylib -arch arm64 -l 0x0 0x56C1DE ff_stream_add_bitstream_filter (in AwemeDylib) + 2

可见 atos 与咱们手动人肉解析的结果也能够认为是彻底一致的,惟一一点差别在于 atos 移除了编译器默认给 c 函数加的_前缀。

3.2.2 线上预解析方案实现

Golang 原生实现

Golang 使用原生系统库debug/dwarf解析 DWARF 文件 ,能够很是方便的打印出 address 对应的文件名及行号,而 Golang 自然的就支持跨平台。可是 Golang 的原生实现其实并不能知足咱们的需求,主要缘由有如下几点:

  1. debug/dwarf并无提供直接解析方法名的 api,这就致使解析结果不完整。
  2. 对于内联函数的文件名和行号等更加复杂的场景也没有兼容。
  3. 这里的实现其实仍是基于已知 FileAddress 的前提,并无提供全量预解析的方案。
  4. 仅支持 Dwarf 文件的解析,不支持 Symbol Table 的解析。

所以咱们仍是得本身分别实现 DWARF 文件和 Symbol Table 的解析。

全量预解析实现

依据上面的原理,咱们首先很天然而然能够想到的一个思路就是:咱们只要把__TEXTSegment 中的__textSection可能出现的地址范围逐一解析出来,而后存到后端的分布式缓存好比 Hbase 或者 redis 不就行了吗?答案是能够,可是没有必要。

经过上面这张图咱们能够看出来,代码段的 size 是 0x130FD54,转成 10 进制的话是将近 2000w 的数量级!这还只是单个符号表文件的单个架构,然而字节稳定性监控平台线上存量的符号表已经有几十万数量级,这种量级的存储太消耗机器资源,显然是不太现实的。基于符号解析的原理咱们不难发现,一段连续的地址他们的解析结果多是彻底相同的。例如上面咱们也提到过,这里 AwemeDylib dSYM 文件 arm64 架构下的 0x30057c 到 0x300593 这个地址范围解析出来的结果都是+[SSZipArchive _dateWithMSDOSFormat:] (in AwemeDylib) (SSZipArchive.m:965)。这样就起码有了 20 倍的压缩率,并且这个策略不管对 DWARF 文件仍是 Symbol Table 而言都是适用的。那么下一个问题又来了,咱们已知 AwemeDylib dSYM 文件 arm64 架构下 0x30057c~0x300593 地址范围对应的符号解析结果是[SSZipArchive _dateWithMSDOSFormat:] (in AwemeDylib) (SSZipArchive.m:965)。写入 Hbase 中的 value 很简单,咱们能够把一段地址范围的最低地址,最高地址,对应符号解析的方法名,文件名,行号等信息封装成一个 struct,定义为 value,咱们称之为 unit{}。那么 key 又是什么呢?这里其实有一个比较棘手的问题就是:在预解析数据存储的时候咱们是存储的一段地址范围,可是在线上解析的时候咱们的输入只有一个地址,那么怎么从这一个地址反推出 Hbase 存储的 key 呢?咱们给出的解决方案是:

hbase_key = [table_name]+image_name+uuid+chunk_index

各个部分分别解释以下:

  • table_name:用于区分dwarf和 symbol_table 两种类型。
  • image_name:binary 的名字,例如 Aweme,libobjc.A.dylib 等。
  • uuid:一个符号表文件的惟一标示,注意通常 dSYM 为多架构的胖二进制文件,而不一样架构的 MachO 文件 uuid 也不一样。
  • chunk_index:指的是以连续长度为一个常数 N(这里以 10000 为例)的地址空间为单位切分,计算当前地址能落到哪一个下标中,也能够认为是当前地址除以常数N而后向下取整。对于单个地址而言是很是明确的,可是对于一段地址范围的话就比较复杂了,若是一段地址范围的下限和上限除以常数 N 向下取整相同的话,他们就落在相同的下标中,可是若是不一样的话,为了保证读取的时候落到这一段地址范围中的每一个地址都可以被正确的解析,所以地址范围首尾横跨的全部 chunk_index,都须要写入该地址范围。

基于此策略,咱们 Hbase 中的 value,也就不能是单个地址范围和对应的解析结果了,而应该是落到这个区间内全部的地址范围的数组,记为[]unit{}。示意图以下:

咱们能够清晰的看到,由于 29001~41000 这个地址范围横跨了 3 个 chunk_index,由于他们同时被写入了 Hbase 的三条缓存中,虽然有一点冗余,但仍是最大程度的兼顾了性能和吞吐。在线上查询调用栈地址对应解析结果的时候咱们只要用偏移地址除以常数 N 再向下取整计算出这个偏移地址落到哪一个 chunk_index 中,而后再用二分法找到第一个恰好大于这个地址的 unit_index,再往前挪一个就能查到咱们须要的解析结果了。注意:线上优先查询 dwarf 表中的 Hbase 缓存,将方法名,文件名和行号拼接成咱们须要的格式;若是没有话再查询 symbol_table 表中的 Hbase 缓存,而且计算出距离函数起始地址的偏移。为了防止一些冷数据在符号表上传以后一直没用到长期占用存储资源,咱们对上图中每一个 chunk 设置了 45 天的过时时间,若是线上有被查询到的话,就更新该 chunk 的过时时间为当前时间以后的 45 天。

DWARF 文件解析

全量 CompileUnit 解析

从基于 DWARF 文件的符号解析原理那一小节中咱们知道,不管是文件名行号仍是函数名的解析都须要依赖 CompileUnit,经过 DWARF 官方文档咱们了解到全部 CompileUni t在debug_info section 中的偏移地址都保存在debug_arranges section 中。

上面文档也同时给出了debug_arrangesbinary 中的结构,基于文档中的结构,咱们须要把全部的debug_info_offset都手动解析出来,由于篇幅的缘由这里就不贴代码实现了,须要特别留意一点的就是 binary 手动解析的时候必定要留意大小端。

地址全量解析流程

下图是地址全量解析的流程,须要特别注意的3点是:

  1. 内联函数函数名仍是以函数的声明为准,可是文件名和行号要以被内联的位置为准,这与 atos 的解析结果是一致的。不然连续的两层调用栈信息就可能出现跳跃,影响分析问题的效率。
  2. 从《DWARF 文件格式官方文档》中咱们能够了解到,debug_line中Flags那一列若是有is_stmt的话,表示当前指令是编译器推荐的断点位置,不然对应的指令就是编译器自动生成的编译器推荐的断点位置。由于断点只能够打在同一行,那么咱们能够判断出从有is_stmtflag 的那行指令到下一次有is_stmtflag 的这若干行指令对应的源码文件名和行号都是彻底相同的,那么针对没有is_stmtflag 的那行指令,咱们只须要找到挨得最近,且地址比它小,且有is_stmtflag 的那行信息,就能够准确的获取到对应地址解析后的文件名和行号。因此总结一下结论就是:debug_line 连续几行的行号信息是否能够合并的标志就是is_stmt,只有连续两行is_stmt为 true 之间的的 debug line info 才能够被合并。
  3. 这里写入到 Hbase 中的地址范围指的是偏移地址,计算公式是:offset = file_address - __TEXT.vmaddr。这样在解析的时候就不须要关心对应 DWARF 文件的__TEXTSegment 的起始地址。

Symbol Table 解析

Symbol Table 的解析相对来讲比较简单,咱们只须要把 Symbol Table 中的信息按 value 排序,而后将每一部分起止地址以及对应的函数名按照上述章节中的策略写入 Hbase 便可。

3.2.3 踩坑记

在这个方案实现的过程当中也踩到了各类各样的坑,这里记录下几个典型的例子,方便你们参考:

  1. 写入耗时远远大于预期。

    问题缘由: 在写入 Hbase 以前调用了 demangle 工具,每一次都有额外几十ms的性能开销,在量级夸张的状况下这个问题会被放大。

    解决方案: 将 demangle 的时机从 Hbase 写入以前改到了从 Hbase 查询以后,毕竟崩溃的方法比起全量的方法而言仍是少得多得多。

  2. CompileUnit 获取失败。

    问题缘由: 绝大部分状况下,从.debug_arranges section 中取出的 compile unit offset 须要手动加一个 0xB 的偏移才恰好是咱们预期的 CompileUnit 的偏移。

    可是在这个case就出现的意外:
    首先咱们看到它的偏移并非 0xB,并且从 debug_arranges section 中取出的 compile unit offset 就直接是正确的了,缘由暂时未知。
    解决方案: 作一个兼容,若是加上 0xB 的 offset 取 compile unit 出错的话,那就减去 0xB 再重试一次。

  3. debug_line 中连续两行出现了如出一辙的地址,致使解析结果有歧义。

    问题缘由: 虽然连续两行地址相同,可是文件名和行号却不一致,这就致使告终果有歧义。

    解决方案: 参考 atos 的解析结果,之前面的那一行为准。

  4. debug_line已经读到end_sequence那行也就是最后那行,可是当前 CompileUnit 还有一部分 TAG 为DW_TAG_subprogram的 DIE 没有被debug_line中的任何地址索引到。那么这一部分地址范围就被漏掉了。

    问题缘由: 怀疑与编译器优化有关,这部分 DIE 的方法名通常都是以_OUTLINED_FUNCTION_开头。

    解决方案: 若是已经解析完end_sequence那行,当前CompileUnit还有TAG为DW_``TAG_subprogram的DIE没被索引到,那么这部分DIE地址范围对应的文件名和行号就是end_sequence这行的的文件名和行号。

  5. Symbol Table 中出现非法数据。

    问题缘由: Symbol Table 中这条数据的 FileAddress 竟然比 __TEXT.vmaddr 还要小,这就致使 offset 变成负数了,又由于一开始对地址偏移咱们定义的是 uint_64 类型,致使 offset 被强转成了一个特别大的整数,不符合预期。

    解决方案: 过滤掉地址偏移为负数的数据段。

4、上线效果

本解决方案在全量上线以前AB测试了大概2周左右,修复了全部已知与老方案有 diff 的 badcase。各项性能指标在全量上线以后的表现以下:

4.1 单行解析耗时

7.7 10:46 最近 6h 平均耗时优化了 70倍,pct99 300多倍

4.2 crash接口总体耗时

从 7.7 到 7.10 crash 解析接口总体平均耗时降低了 50%+。

从 7.7 到 7.0 crash 解析接口总体 pct99 耗时降低了 70%+。

4.3 符号表文件访问量级

从 7.7->7.10日符号表文件访问的量级下降了 50%+。

4.4 解析报错

从放量开始后的 7.7 号开始,解析报错就已经彻底消失了。

4.5 物理机性能

选取线上一台比较有表明性的物理机监控,能够看到机器负载,内存占用,CPU 占用,网络 IO 同比都有很是明显的优化。

下面截取部分核心指标优化前和优化后的指标看板做对比:

  • 优化前时间范围: 7.3 12:00 - 7.5 12:00
  • 优化后时间范围: 7.10 12:00 - 7.12 12:00

15min 负载

15min 负载平均:5.76 => 0.84,能够理解为集群总体的解析效率提高至原来的 6.85 倍

IOWait CPU 占用

IOWait CPU 占用平均:4.21 => 0.16,优化 96%。

内存占用

内存占用平均:74.4GiB => 31.7GiB,优化57%。

网络 Input 流量

网络 Input 流量:13.2MB/s=>4.34MB/s,优化 67%。

参考资料

[1] https://my.oschina.net/linker/blog/1529928

[2] https://docs.rs/crate/symbolic-demangle/8.3.0

[3] https://llvm.org/docs/CommandGuide/llvm-dwarfdump.html

[4] http://www.dwarfstd.org/doc/DWARF4.pdf

[5]http://formalverification.cs.utah.edu/llvm_doxy/2.9/namespacellvm_1_1dwarf.html#a85bda042c02722848a3411b67924eb47


关于字节终端技术团队

字节跳动终端技术团队(Client Infrastructure)是大前端基础技术的全球化研发团队(分别在北京、上海、杭州、深圳、广州、新加坡和美国山景城设有研发团队),负责整个字节跳动的大前端基础设施建设,提高公司全产品线的性能、稳定性和工程效率;支持的产品包括但不限于抖音、今日头条、西瓜视频、飞书、懂车帝等,在移动端、Web、Desktop等各终端都有深刻研究。

火山引擎应用开发套件MARS是字节跳动终端技术团队过去九年在抖音、今日头条、西瓜视频、飞书、懂车帝等 App 的研发实践成果,面向移动研发、前端开发、QA、 运维、产品经理、项目经理以及运营角色,提供一站式总体研发解决方案,助力企业研发模式升级,下降企业研发综合成本。可点击连接进入官网了解更多产品信息。