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] 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、 運維、產品經理、項目經理以及運營角色,提供一站式整體研發解決方案,助力企業研發模式升級,降低企業研發綜合成本。可點擊鏈接進入官網瞭解更多產品信息。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章