iOS 符号解析重构之路

·  阅读 1916
iOS 符号解析重构之路

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

一、背景

1.1 什么是符号解析

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

1.2 系统原生符号解析工具

symbolicatecrash

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

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

优点:能非常方便的符号化整份 crash 日志。

缺点:

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

atos

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

优点:速度快,可以符号化特定的某一行,方便上层做缓存。

1.3 原生工具的问题

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

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

二、历史方案探索

为了解决这两大痛点,搭建一套 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.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 被强转成了一个特别大的整数,不符合预期。

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

四、上线效果

本解决方案在全量上线之前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] my.oschina.net/linker/blog…

[2] docs.rs/crate/symbo…

[3] llvm.org/docs/Comman…

[4] www.dwarfstd.org/doc/DWARF4.…

[5]formalverification.cs.utah.edu/llvm_doxy/2…


关于字节终端技术团队

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

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

分类:
iOS
标签: