Native Crash收集与解析框架Breakpad的介绍与使用

1,250 阅读16分钟

1、背景

我们都知道,Android 崩溃分为 Java 崩溃和 Native 崩溃。简单来说,Java 崩溃就是在 Java 代码中,出现了未捕获异常,导致程序异常退出。那 Native 崩溃又是怎么产生的呢?一般都是因为在 Native 代码中访问非法地址,也可能是地址对齐出现了问题,或者发生了程序主动 abort,这些都会产生相应的 signal 信号,导致程序异常退出。

从安卓 APP 开发的角度,Java 崩溃捕获相对比较容易,JVM 给 Java 字节码提供了一个受控的运行环境,同时也提供了完善的 Java 崩溃捕获机制。Native 崩溃的捕获和处理相对比较困难,安卓系统的debuggerd 守护进程会为 native 崩溃自动生成详细的崩溃描述文件(tombstone)。

在开发调试阶段,可以通过系统提供的 bugreport 工具获取 tombstone 文件(或者将设备 root 后也可以拿到)。但是对于发布到线上的安卓 APP,如何获取 tombstone 文件,安卓操作系统本身并没有提供这样的功能。这个问题一直是安卓 native 崩溃分析和移动端 APM 系统的痛点之一。

Native 崩溃收集解析流程

一个完整的 Native 崩溃从捕获到解析要经历的流程为:

native crash流程

编译端:编译 C/C++ 代码时,需要将带符号信息的文件保留下来。

客户端:捕获到崩溃时候,将收集到尽可能多的有用信息写入日志文件,然后选择合适的时机上传到服务器。

服务端:读取客户端上报的日志文件,寻找适合的符号文件,生成可读的 C/C++ 调用栈。

so组成

我们先了解一下 so 的组成,一个完整的 so 由C代码加一些 debug 信息组成,这些debug信息会记录 so 中所有方法的对照表,就是方法名和其偏移地址的对应表,也叫做符号表,这种 so 也叫做not strip的,通常体积会比较大。

通常release的 so 都是需要经过一个strip操作的,调试和符号信息这样strip之后的 so 中的debug信息会被剥离,整个 so 的体积也会缩小。

如下图所示:

so的strip

可以简单将这个符号表理解为Java代码混淆中的mapping文件,只有拥有这个mapping文件才能进行堆栈分析。

如果堆栈信息丢了,基本上堆栈无法还原,问题也无法解决。

所以,这些符号表信息尤为重要,是我们分析Native Carash分析的关键信息,那么我们在编译 so 时候务必保留一份未被strip的so 或者剥离后的符号表信息,以供后面问题分析,并且每次编译的so 都需要保存,一旦产生代码修改重新编译,那么修改前后的符号表信息会无法对应,也无法进行分析。

原理

针对捕获和解析流程,是一个比较复杂的过程。常见的开源库有 coffeecatch 、 breakpad等。目前各种平台也是百花齐放,包括bugly,网易云捕,firebase crashlytics,sentry等。firebase crashlytics和sentry使用的breakpad实现。bugly、网易云捕等自己实现的捕获解析流程。

不管哪种方式,他们的捕获都与native底层的崩溃机制有关,具体可以阅读一下《Android 平台 Native 代码的崩溃捕获机制及实现》,此处只做简单介绍,就不再展开描述。

核心原理:

信号处理的核心原理

Naive 崩溃捕获需要注册这些信号的处理函数(signal handler),然后在信号处理函数中收集数据。

核心函数:

注册信号处理函数
​
#include <signal.h>  int sigaction(int signum,const struct sigaction *act,struct sigaction *oldact));

Chromium 的Breakpad是目前 Native 崩溃捕获中最成熟的方案,并且项目中也是使用的此方案,下面就基于此方案进行简单介绍。

2、breakpad介绍

breakpad 而是一个全平台的C/C++程序的崩溃日志收集工具,适配了Windows/MacOX/Linux,当然也支持了Android。

Breakpad 可以在移除编译器调试信息后,抓取、压缩 minidump 信息,将其发送回你的服务器,然后为 C/C++ 生成调用栈。

breadpad的工作原理如下图所示:

breakpad原理图

breadpad 主要包含三个模块:

  • client: 编译进入项目中,随项目一起编译发布,发布出去的so是strip掉debug信息的。当在用户手机上崩溃的时候,负责崩溃上报的sdk抓取当前线程和当前的载入库,生成 minidump文件。文件最后被收集到服务端。

  • symbol dumper(dump_syms工具):

    当你在编译so的时候,除了编译strip后的so,还得保留strip前的so。dump_syms 就是用来从strip前的so 提取符号表信息.sym文件(Breakpad自己格式 的符号文件)

  • minidump processor(minidump_stackwalk工具):

    sym符号表文件和 minidump文件发送给Server端后,通过minidump_stackwalk指令,从.sym符号文件和包含崩溃信息的minidump文件中提取出完整的崩溃时的堆栈信息。

breakpad的官方文档比较完善,可以查看官方文档继续了解其他信息:

不同平台的实现原理

默认情况下,当崩溃时breakpad会生成一个minidump文件,在不同平台上的实现机制不一样:

  • 在windows平台上,使用微软提供的 SetUnhandledExceptionFilter() 方法来实现。
  • 在macOS平台上,通过创建一个线程来监听 Mach Exception port 来实现。
  • 在linux平台上,通过设置一个信号处理器来监听 SIGILL、SIGSEGV 等异常信号。

编译:

dump_syms工具和minidump_stackwalk工具会在编译BreakPad源码的时候产生。

breakpad 是跨平台的,支持linux、window和Mac os系统,不同平台上的编译配置也是不同的。

  • linux 平台编译出来的dump_syms 仅能在linux上运行,来解析linux上运行的so的符号信息
  • macOS 平台编译出来的dump_syms 仅能再mac OS 上运行,来解析mac 上运行的dylib的符号信息。
  • window 平台编译出来的dump_syms,仅能在Window上运行,并解析window上运行的dll的符号信息。

Android 是属于Linux系统的,如果捕捉Android上的natvie crash,解析崩溃时的堆栈,需要在Linux系统上编译breadpad 。否则编译出来的dump_syms 无法解析android的minidump信息的。

具体的编译流程可自己在网上查询,资料比较丰富,在此不展开描述。

解析minudump文件

1、 dump_syms 提取not striped so库的符号信息
./dump_syms libnativelib.so > libnativelib.so.sym
2、根据1中生成的libbreakpad-core.so.sym生成特定的目录结构:
├── symbol
│   └── libnativelib.so
│       └── 7101a9976989ac0174715fca686ca5ed0
│           └── libnativelib.so.sym

命令如下:

head -n1 libnativelib.so.sym 
MODULE Linux arm64 7101A9976989AC0174715FCA686CA5ED0 libnativelib.so
​
mkdir -p ./symbol/libnativelib.so/7101A9976989AC0174715FCA686CA5ED0
​
mv libnativelib.so.sym ./symbol/libnativelib.so/7101A9976989AC0174715FCA686CA5ED0/
3、调用minidump_stackwalk命令,将dmp文件和sym文件合成可读的crashinfo.txt
./minidump_stackwalk _ccrash.dmp ./symbol > crashinfo.txt

crashinfo.txt 部分内容如下:

Operating system: Android
                  0.0.0 Linux 5.4.86-qgki-ga5eec0eb1e4c #1 SMP PREEMPT Wed Apr 13 23:55:10 CST 2022 aarch64
CPU: arm64
     8 CPUs

GPU: UNKNOWN

Crash reason:  SIGSEGV /0x00000000
Crash address: 0x0
Process uptime: not available

Thread 0 (crashed)
 0  libnativelib.so!Java_com_example_nativelib_NativeLib_mockNativeCrash [nativelib.cpp : 20]
......
 1  libnativelib.so!Java_com_example_nativelib_NativeLib_mockNativeCrash [nativelib.cpp : 18]
......
 2  libart.so + 0xd7644
......
......
  

以上是使用breakpad工具解析minudump文件,得到native crash位置的方式。本地调试是没有问题的,网络上的资料也都是按照这种操作流程介绍的。

3、通用的符号表提取工具

背景

我们的业务场景是,sdk收集到native crash的minidump信息后,会上传到server中,使用minidump_stackwalk进行解析,如果没有符号表文件的话,解析出的crashinfo信息是没有具体的崩溃位置信息的,只有偏移位置,如下:

Operating system: Android
                  0.0.0 Linux 5.4.86-qgki-ga5eec0eb1e4c #1 SMP PREEMPT Wed Apr 13 23:55:10 CST 2022 aarch64
CPU: arm64
     8 CPUs

GPU: UNKNOWN

Crash reason:  SIGSEGV /0x00000000
Crash address: 0x0
Process uptime: not available

Thread 0 (crashed)
 0  libnativelib.so + 0xf250
......
 1  libnativelib.so + 0xf23c
......
......
  

这种只有偏移地址,没有具体崩溃信息的崩溃记录,对分析native crash来说是不足的。所以,需要使用sdk的业务方使用dump_syms提取so的符号表信息进行上传,然后在server进行解析。

但是dump_syms的使用有一个问题:见上文的“编译”部分。

因为我们应用场景是捕捉Android上的natvie crash,然后解析崩溃时的堆栈,而Android 是属于Linux系统的,所以编译得到的dump_syms必须在Linux系统上运行。

那么问题来了,由于大部分开发人员在本地都没有linux环境运行dump_syms。所以,比较好的方式是我们提供各个平台通用的符号表提取工具。

调研

网上的资料主要介绍的是本地调试工具的使用,没有对breakpad通用符号表提取工具的资料介绍。

  • 国内
  1. Bugly:自研,不开源,提供各平台通用的提取符号表工具jar包
  2. 网易云捕:自研,不开源,提供各平台通用的提取符号表工具jar包
  3. 听云:自研,不开源,提供各平台通用的提取符号表工具jar包
  • 国际
  1. firebase Crashlytics:基于brakpad,部分开源,提供各平台通用的提取符号表工具jar包
  2. Sentry:支持breakpad,部分开源,提供各平台通用的提取符号表工具jar包

对国内外各个平台的调研发现,firebase Crashlytics有各平台通用的符号表提取功能,并且本身就是基于breakpad采集和分析native crash,所以查看Crashlytics的相关源码查看其实现方式。

方案

查看firebase官方文档的描述可以看到,上传原生符号使用 Crashlytics Gradle 插件的方式,但是也提供了“为非 Gradle build 或无法访问的未剥离原生库上传符号”的方式,这种方式是使用Firebase CLI的方式。

得益于此tools是开源的,查看其开源库firebase-tools中处理符号表上传功能的实现类crashlytics-symbols-upload.ts,可以发现其内部会下载firebase-crashlytics-buildtools-${JAR_VERSION}.jar工具包,拼接下载地址后,可以得到下载jar包的链接:https://dl.google.com/android/maven2/com/google/firebase/firebase-crashlytics-buildtools/2.8.0/firebase-crashlytics-buildtools-2.8.0.jar

下载之后,反编译之后,可以看到此jar包是未混淆的。接下来查看其源码,分析其实现方式。

我们需要在本地生成符号表,所以主要看本地生成符号表文件的实现方式。

入口方法为:

Buildtools#public static void main(String[] args){
  ......对命令行命令解析
  new CommandLineHelper(properties).doMain();
}

前面是一些命令行命令解析,我们主要关注的实现在CommandLineHelper中

CommandLineHelper.java
​
private void doMain() throws IOException {
        configureWebApi();
        int commandCount = 0;
        for (String validCommand : VALID_COMMANDS) {
            if (this.cliArgs.containsKey(validCommand)) {
                commandCount++;
            }
        }
        if (commandCount != 1) {
            throw new IllegalArgumentException("Exactly ONE valid command required. Use '-help' valid arguments.");
        } else if (this.cliArgs.containsKey(CrashlyticsOptions.OPT_INJECT_MAPPING_FILE_ID)) {
        //向mapping文件中注入mappingFileId
            executeInjectMappingFileId();
        } else if (this.cliArgs.containsKey(CrashlyticsOptions.OPT_UPLOAD_MAPPING_FILE)) {//uploadMappingFile,上传mapping文件
            executeUploadMappingFile();
        } else if (this.cliArgs.containsKey(CrashlyticsOptions.OPT_GENERATE_NATIVE_SYMBOLS)) {
        //generateNativeSymbols,生成符号表
            executeGenerateSymbols();
        } else if (this.cliArgs.containsKey(CrashlyticsOptions.OPT_UPLOAD_NATIVE_SYMBOLS)) {
        //uploadNativeSymbols,上传符号表
            executeUploadSymbols();
        }
    }

可以看到CommandLineHelper会根据不同的命令处理不同的功能,我们关注的是生成符号表,所以查看executeGenerateSymbols方法。

CommandLineHelper.java
​
private void executeGenerateSymbols() throws IOException {
        //not striped so文件路径
        String unstrippedLib = this.cliArgs.getProperty(CrashlyticsOptions.OPT_NATIVE_UNSTRIPPED_LIB);
        //not striped so文件夹路径
        String unstrippedLibsDir = this.cliArgs.getProperty(CrashlyticsOptions.OPT_NATIVE_UNSTRIPPED_LIBS_DIR);
        boolean useSingleLib = unstrippedLib != null;
        if (!Boolean.logicalXor(useSingleLib, unstrippedLibsDir != null)) {
            throw new IllegalArgumentException("generateNativeSymbols requires either 1) unstrippedLibrary or 2) unstrippedLibrariesDir");
        }
        File csymDir = new File(getPropertyOrThrow(this.cliArgs, CrashlyticsOptions.OPT_CSYM_CACHE_DIR));
        FileUtils.verifyDirectory(csymDir);
        //选择解析单个so文件还是多个so文件的文件夹
        //createSymbolGenerator方法会根据参数创建不同的NativeSymbolGenerator,我们关注BreakpadSymbolGenerator
        Buildtools.getInstance().generateNativeSymbolFiles(useSingleLib ? new File(unstrippedLib) : new File(unstrippedLibsDir), csymDir, createSymbolGenerator(this.cliArgs));
    }

我们先来看一下生成符号表文件的方法:generateNativeSymbolFiles

  Buildtools.java
  
  public void generateNativeSymbolFiles(File path, File symbolFileOutputDir, NativeSymbolGenerator symbolGenerator) throws IOException {
       .......
            try {
                for (File soFile : soFiles) {
                //使用symbolGenerator生成符号表
                    File symbolFile = symbolGenerator.generateSymbols(soFile, symbolFileOutputDir);
                    if (symbolFile == null) {
                        logW(String.format("Null symbol file generated for %s", soFile.getAbsolutePath()));
                    } else {
                        logD(String.format("Generated symbol file: %s (%,d bytes)", symbolFile.getAbsolutePath(), Long.valueOf(symbolFile.length())));
                    }
                }
            } catch (CodeMappingException ex) {
                throw new IOException(ex);
            }
        .......
    }

使用上面的到的BreakpadSymbolGenerator生成符号表文件。

BreakpadSymbolGenerator.java
​
public File generateSymbols(File nativeLib, File symbolFileOutputDir) throws IOException, CodeMappingException {
        Buildtools.logD("Crashlytics generating Breakpad Symbol file for: " + nativeLib.getAbsolutePath());
        File tempOutputFile = File.createTempFile(nativeLib.getName(), ".tmp", symbolFileOutputDir);
        Buildtools.logD("Extracting Breakpad symbols to temp file: " + tempOutputFile.getAbsolutePath());
        //生成命令行,执行命令,生成{soName}.tmp文件
        Process proc = new ProcessBuilder(this.dumpSymsBin.getAbsolutePath(), nativeLib.getAbsolutePath()).redirectOutput(tempOutputFile).start();
        try {
            proc.waitFor();
            if (proc.exitValue() != 0) {
                throw new IOException("Breakpad symbol generation failed (exit=" + proc.exitValue() + "), see STDERR");
            }
            BreakpadRecords breakpadRecords = BreakpadRecords.createFromBreakpadFile(tempOutputFile);
            File breakpadOutputFile = new File(symbolFileOutputDir, NativeSymbolGenerator.createSymbolFileBasename(nativeLib.getName(), breakpadRecords.getArchitecture(), breakpadRecords.getCodeId() != null ? breakpadRecords.getCodeId() : breakpadRecords.getModuleId()) + ".sym");
            Buildtools.logD("Renaming Breakpad symbol file to: " + breakpadOutputFile.getAbsolutePath());
            Files.move(tempOutputFile.toPath(), breakpadOutputFile.toPath(), StandardCopyOption.REPLACE_EXISTING);
            return breakpadOutputFile;
        } catch (InterruptedException e) {
            throw new IOException("Dump symbols was unexpectedly interrupted.", e);
        }
    }

this.dumpSymsBin是从哪里来的呢,需要看回到createSymbolGenerator方法

private static NativeSymbolGenerator createSymbolGenerator(Properties props) throws IllegalArgumentException, IOException {
        String symbolGenMode = props.getProperty(CrashlyticsOptions.OPT_SYMBOL_GENERATOR_TYPE, "breakpad");
        if ("breakpad".equals(symbolGenMode)) {
            return new BreakpadSymbolGenerator(resolveDumpSymsBinary(props));
        }
        if (CrashlyticsOptions.SYMBOL_GENERATOR_CSYM.equals(symbolGenMode)) {
            return new NdkCSymGenerator();
        }
        throwInvalidSymbolGeneratorMode(symbolGenMode);
        return null;
    }

需要注意resolveDumpSymsBinary方法,处理DumpSyms二进制文件。

private static File resolveDumpSymsBinary(Properties props) throws IllegalArgumentException, IOException {
        File toReturn;
        if (props.containsKey(CrashlyticsOptions.OPT_DUMP_SYMS_BINARY)) {
            toReturn = new File(props.getProperty(CrashlyticsOptions.OPT_DUMP_SYMS_BINARY));
        } else {
            File defaultBinaryDir = new File(".crashlytics");
            if (!defaultBinaryDir.isDirectory()) {
                if (defaultBinaryDir.isFile()) {
                    throw new IOException("Could not create Crashlytics directory, a file already exists at that location: " + defaultBinaryDir.getAbsolutePath());
                }
                defaultBinaryDir.mkdir();
            }
            //导出默认的DumpSyms
            toReturn = BreakpadSymbolGenerator.extractDefaultDumpSymsBinary(defaultBinaryDir);
        }
        return toReturn;
    }

接下来看extractDefaultDumpSymsBinary方法

    public static File extractDefaultDumpSymsBinary(File destPath) throws IOException {
        String osString = getOsForDumpSyms();
        File outputFile = new File(destPath, OS_WINDOWS.equals(osString) ? "dump_syms.exe" : "dump_syms.bin");
        if (outputFile.exists()) {
            Buildtools.logD("Skipping dumpsyms extraction, file exists: " + outputFile.getAbsolutePath());
            return outputFile;
        }
        String resource = "dump_syms/" + osString + "/dump_syms.bin";
        Buildtools.logD("Extracting dump_syms from " + resource + " to " + outputFile.getAbsolutePath());
        extractResource(resource, outputFile);
        if (!OS_WINDOWS.equals(osString)) {
            Files.setPosixFilePermissions(outputFile.toPath(), DUMP_SYMS_PERMISSIONS);
        } else {
            if (!outputFile.setExecutable(true)) {
                Buildtools.logW("File#setExecutable() failed for " + outputFile.getAbsolutePath() + "; library extracted without setting permissions.");
            }
            Buildtools.logD("Extracting mingw DLLs to " + destPath);
            String[] strArr = MINGW_DLLS;
            for (String dll : strArr) {
                extractResource("dump_syms/windows/" + dll, new File(destPath, dll));
            }
        }
        return outputFile;
    }

在extractDefaultDumpSymsBinary方法中,根据不同的操作系统,导出对应的dump_syms.bin文件。

查看jar包中的资源:

firebase-build-tools dump_syms资源

可以看到会有各平台对应的bin二进制执行文件,使用这些不同平台的执行文件,可以在不同的平台提取so文件的符号表。原来是只能在linux中使用breakpad编译之后的dump_syms可执行文件提取so文件的符号表。

通过使用firebase-crashlytics-buildtools工具中的这些bin文件,可以实现跨平台提取so文件符号表。

4、崩溃地址符号化工具

背景

回顾上面的“解析minudump文件”的流程,可以发现在使用minidump_stackwalk解析dmp时,需要指定symbol文件。所以,需要在产生崩溃之前先上传符号表文件。

如果可以改变一下流程,使用minidump_stackwalk解析dmp时,先不指定symbol文件,当产生崩溃时,用户想要查看崩溃信息,此时再上传对应的符号表进行解析。

针对这种情况,网上的方案流程是:

1、直接使用minidump_stackwalk 解析minidump文件(不指定symbol文件):

./minidump_stackwalk _ccrash.dmp > crashinfo.txt

crash.info 内容:

Operating system: Android
                  0.0.0 Linux 5.4.86-qgki-ga5eec0eb1e4c #1 SMP PREEMPT Wed Apr 13 23:55:10 CST 2022 aarch64
CPU: arm64
     8 CPUs

GPU: UNKNOWN

Crash reason:  SIGSEGV /0x00000000
Crash address: 0x0
Process uptime: not available

Thread 0 (crashed)
 0  libnativelib.so + 0xf250
......
 1  libnativelib.so + 0xf23c
......
......

由此可知程序崩溃在了libnativelib.so 的相对偏移位置 0xf250的地址

2、利用addr2line 根据发生crash的so文件,以及偏移地址,得出产生carsh的方法、行数和调用堆栈关系

aarch64-linux-android-addr2line 工具也是在android sdk 安装目录下自带的,可以自行查找。

我mac上aarch64-linux-android-addr2line的目录为

/Users/xxx/Library/Android/sdk/ndk-bundle/toolchains/aarch64-linux-android-4.9/prebuilt/darwin-x86_64/bin/aarch64-linux-android-addr2line

解析libnativelib.so 的 崩溃地址0xf250对应哪个函数符号

{aarch64-linux-android-addr2line path} -f -C -e {libnativelib.so path} 0xf250
Java_com_example_nativelib_NativeLib_mockNativeCrash
{path}/nativelib.cpp:20

可以看到解析出崩溃文件({path}/nativelib.cpp)、崩溃函数(Java_com_example_nativelib_NativeLib_mockNativeCrash)、崩溃行数(20),与真实崩溃信息相同。

arm-linux-androideabi-addr2line 使用方法介绍:

arm-linux-androideabi-addr2line -C -f -e ${SOPATH} ${Address}
-C -f           //打印错误行数所在的函数名称
  -e                //打印错误地址的对应路径及行数
  ${SOPATH}         //so库路径 
  ${Address}        //需要转换的堆栈错误信息地址,可以添加多个,但是中间要用空格隔开

使用addr2line结合not striped so库能够解析出崩溃的地址等信息。但是需要使用到debug版的so文件,让用户上传这个debug版本的so文件会有so代码泄露的隐患,并且此文件的size也比较大。

那么有没有通过用户上传的符号表代替debug so文件,解析出详细崩溃信息的方式呢?

在得到下面的crash.info 内容之后,在服务端结合符号表对“0xf250”这个崩溃地址进行解析,实现类似于addr2line工具的功能。

Operating system: Android
                  0.0.0 Linux 5.4.86-qgki-ga5eec0eb1e4c #1 SMP PREEMPT Wed Apr 13 23:55:10 CST 2022 aarch64
CPU: arm64
     8 CPUs

GPU: UNKNOWN

Crash reason:  SIGSEGV /0x00000000
Crash address: 0x0
Process uptime: not available

Thread 0 (crashed)
 0  libnativelib.so + 0xf250
......
 1  libnativelib.so + 0xf23c
......
......

调研

使用符号表解析崩溃地址的这种解析方式,在网上没有找到类似的方案。

查看各平台对崩溃地址的解析方式:

  1. Bugly:当产生崩溃时,如果有对应的符号表,会解析为详细信息。如果没有对应的符号表信息,会直接解析出带有崩溃地址的信息,当用户上传符号表时,再将崩溃地址解析为详细的崩溃信息。
  2. Firebase Crashlytics:当产生崩溃时,如果有对应的符号表,会解析为详细信息。如果先产生崩溃,再上传符号表,不会解析为详细信息。
  3. Sentry:同Firebase Crashlytics的方式。

针对Bugly这种解析方式,可以在上传符号表时,再次使用minidump_stackwalk工具结合符号表,将原始的dmp文件重新解析一遍,这种方式是最简单的。

但是我们服务端的数据由于数据量比较大,都是提前通过hadoop集群解析完成的,所以只能在现有的解析完成的数据基础上结合符号表进行解析,上面这种再次解析的方式不满足现有的业务需求。

既然breakpad是开源的工程,那就看一下源码吧。主要看stack_frame_symbolizer.cc中的FillSourceLineInfo方法开始分析。

breakpad/src/processor/stack_frame_symbolizer.cc
​
StackFrameSymbolizer::SymbolizerResult StackFrameSymbolizer::FillSourceLineInfo(
    const CodeModules* modules,
    const CodeModules* unloaded_modules,
    const SystemInfo* system_info,
    StackFrame* frame,
    std::deque<std::unique_ptr<StackFrame>>* inlined_frames){
    
    ......
    case SymbolSupplier::FOUND: {
      //加载符号文件
      bool load_success = resolver_->LoadModuleUsingMemoryBuffer(
          frame->module,
          symbol_data,
          symbol_data_size);
      if (resolver_->ShouldDeleteMemoryBufferAfterLoadModule()) {
        supplier_->FreeSymbolData(module);
      }
​
      if (load_success) {
        //为当前帧提供函数名称和源行信息
        resolver_->FillSourceLineInfo(frame, inlined_frames);
        return resolver_->IsModuleCorrupt(frame->module) ?
            kWarningCorruptSymbols : kNoError;
      } else {
        BPLOG(ERROR) << "Failed to load symbol file in resolver.";
        no_symbol_modules_.insert(module->code_file());
        return kError;
      }
      ......
    }

然后进入resolver_->FillSourceLineInfo(frame, inlined_frames)方法的实现中查看。

breakpad/src/processor/source_line_resolver_base.cc
​
void SourceLineResolverBase::FillSourceLineInfo(
    StackFrame* frame,
    std::deque<std::unique_ptr<StackFrame>>* inlined_frames) {
  if (frame->module) {
    ModuleMap::const_iterator it = modules_->find(frame->module->code_file());
    if (it != modules_->end()) {
    //
      it->second->LookupAddress(frame, inlined_frames);
    }
  }
}

看到it->second->LookupAddress(frame, inlined_frames)方法中。

breakpad/src/processor/basic_source_line_resolver.cc
​
void BasicSourceLineResolver::Module::LookupAddress(
    StackFrame* frame,
    deque<unique_ptr<StackFrame>>* inlined_frames) const {
  MemAddr address = frame->instruction - frame->module->base_address();
​
  // First, look for a FUNC record that covers address. Use
  // RetrieveNearestRange instead of RetrieveRange so that, if there
  // is no such function, we can use the next function to bound the
  // extent of the PUBLIC symbol we find, below. This does mean we
  // need to check that address indeed falls within the function we
  // find; do the range comparison in an overflow-friendly way.
  linked_ptr<Function> func;
  linked_ptr<PublicSymbol> public_symbol;
  MemAddr function_base;
  MemAddr function_size;
  MemAddr public_address;
  if (functions_.RetrieveNearestRange(address, &func, &function_base,
                                      NULL /* delta */, &function_size) &&
      address >= function_base && address - function_base < function_size) {
    frame->function_name = func->name;
    frame->function_base = frame->module->base_address() + function_base;
​
    linked_ptr<Line> line;
    MemAddr line_base;
    if (func->lines.RetrieveRange(address, &line, &line_base, NULL /* delta */,
                                  NULL /* size */)) {
      FileMap::const_iterator it = files_.find(line->source_file_id);
      if (it != files_.end()) {
        frame->source_file_name = files_.find(line->source_file_id)->second;
      }
      frame->source_line = line->line;
      frame->source_line_base = frame->module->base_address() + line_base;
    }
​
    // Check if this is inlined function call.
    if (inlined_frames) {
      ConstructInlineFrames(frame, address, func->inlines, inlined_frames);
    }
  } else if (public_symbols_.Retrieve(address,
                                      &public_symbol, &public_address) &&
             (!func.get() || public_address > function_base)) {
    frame->function_name = public_symbol->name;
    frame->function_base = frame->module->base_address() + public_address;
  }
}

还有一些细节,就不再继续分析了,摘抄官方的描述总结上面的方法的功能:

通过从当前帧的指令指针中减去包含当前帧的模块的基地址来完成的,以获得相对虚拟地址(RVA),它是相对于模块开始的代码偏移量。然后将此 RVA 用作对函数表(来自符号文件的FUNC 行)的查找,每个函数表都有一个关联的地址范围(函数起始地址、函数大小)。如果找到地址范围包含 RVA 的函数,则使用其名称。然后将 RVA 用作对源行表(行记录)的查找来自符号文件),每一个也有一个关联的地址范围。如果找到匹配项,它将提供与当前帧关联的文件名和源代码行。如果在函数表中未找到匹配项,则可以查阅另一张公开导出的符号表(符号文件中的PUBLIC 行)。公共符号仅包含一个起始地址,因此查找只是查找小于提供的 RVA 的最近符号。

这部分代码是摘自minidump_stackwalk工具的源码中通过符号表解析崩溃地址信息的代码。从上面可以看出c++代码比较多,并且糅合了一些其他的功能,如果要从里面提取出崩溃地址符号化的功能代码,需要花一些时间。并且本人对c++语言也不是很熟练。

方案

继续从网上找资料,发现有一个go工程的crsym开源工程,是chrome浏览器分析崩溃地址的工程。虽然代码的上一次提交是2013年,但是功能简单,并且go语言实现的功能比breakpad中c++实现的功能更清晰。

自己实现的一个简单的单元测试代码

func TestParseAndroidSYM(t *testing.T) {
  //获取符号表
  table, err := getTable(kAndroidBreakpadTestFile)
  if err != nil {
    t.Fatal(err)
  }
  //通过符号表解析0xf250崩溃地址
  symbol := table.SymbolForAddress(0xf250)
  if symbol == nil {
    t.Fatal("Could not look up symbol")
  }
  fmt.Println(symbol)
}
func getTable(file string) (*breakpadFile, error) {
  //读取符号表文件
  data, err := testutils.ReadSourceFile(path.Join("breakpad/testdata", file))
  if err != nil {
    return nil, err
  }
  //创建table文件
  table, err := NewBreakpadSymbolTable(string(data))
  if err != nil {
    return nil, err
  }
​
  bf, _ := table.(*breakpadFile)
  return bf, nil
}
func NewBreakpadSymbolTable(data string) (SymbolTable, error) {
  table := &breakpadFile{
    files: make(map[int64]string),
  }
  //解析符号表文件
  err := table.parseBreakpad(data)
  return table, err
}

接下来的symbol文件解析也很简单,不再继续展开。可以参考breakpad中关于symbol_files文件的描述,了解文件格式。

接下来看一下SymbolForAddress方法,从解析出的breakpadFile文件中解析出address对应的信息。

func (b *breakpadFile) SymbolForAddress(address uint64) *Symbol {
  // Perform binary search on the FUNC records.
  low, high := 0, len(b.funcs)
  for low < high {
    mid := low + (high-low)/2
    f := b.funcs[mid]
    if address >= f.address && address < f.address+f.size {
      sym := &Symbol{Function: f.name}
      b.lineAtAddress(address, f, sym)
      return sym
    } else if address > f.address {
      low = mid + 1
    } else {
      high = mid
    }
  }
​
  // Perform an upper-bound search for |address| and return the PUBLIC
  // record before it, which is the function that contains |address|.
  l := len(b.publics)
  i := sort.Search(l, func(i int) bool {
    return b.publics[i].address > address
  })
  if i <= l && i > 0 {
    return &Symbol{Function: b.publics[i-1].name}
  }
​
  return nil
}
​
// lineAtAddress fills in debug file/line information for a Symbol, given an
// instruction address and a funcRecord.
func (b *breakpadFile) lineAtAddress(address uint64, f funcRecord, sym *Symbol) {
  for _, l := range f.lines {
    if address >= l.address && address < l.address+l.size {
      sym.File = b.files[l.file]
      sym.Line = l.line
      return
    }
  }
}

通过二分法找到address对应的FUNC,之后在FUNC的lines信息中找到对应的File(文件)和Line(行数)信息。

根据上面的go语言代码,自己实现一个java版本的也是很简单的,可以在短时间实现一个通过符号表解析崩溃地址的工具。

总结:

本文介绍了android端 native crash收集解析的流程和原理,据此引出breakpad框架。由于breakpad框架是跨平台的,以android为例介绍的breakpad收集和解析native crash的流程和原理,也可以应用在其他平台的客户端中。

接下来介绍了android端的符号表提取工具和崩溃地址符号化工具,使用这两个工具,可以更方便的在服务端对收集的native crash进行解析。

参考:

zhuanlan.zhihu.com/p/352651095

time.geekbang.org/column/arti…

mp.weixin.qq.com/s/bJKvrfO6B…

www.jianshu.com/p/1e15640fa…

github.com/google/brea…