一、概述

什么是符号表?


符号表是内存地址与函数名、文件名、行号的映射表。符号表元素如下所示:

<起始地址> <结束地址> <函数> [<文件名:行号>]

编译器带-g就会有调试信息。这是gcc相关的。默认其实android编译的就是带符号表的,只是在strip掉了。

Android平台中,目标文件对应的是SO文件。Debug SO文件是指具有调试信息的SO文件。 为了方便找回Crash对应的Debug SO文件和还原堆栈,建议每次构建或者发布APP版本的时候,备份好Debug SO文件。

IDE如果使用Android Sutdio+NDK,默认情况下,Debug编译的Debug SO文件将位于:

../<Module>/build/intermediates/ndk/debug/obj/local<架构>/

而Release编译的Debug SO文件将位于:

../<Module>/build/intermediates/ndk/release/obj/local<架构>/

二. readelf 解析ELF格式文件符号表

在 linux 下,用 readelf 来看 ELF(linux 下目标文件的格式) 文件头部或者其它各 section 的内容,用 objdump 来对指定的内容(.text, .data等)进行反汇编。

读取ELF文件头
readelf -h **.so

在 readelf 的输出中:
第 1 行,ELF Header: 指名 ELF 文件头开始。
第 2 行,Magic 魔数,用来指名该文件是一个 ELF 目标文件。第一个字节 7F 是个固定的数;后面的 3 个字节正是 E, L, F 三个字母的 ASCII 形式。
第 3 行,CLASS 表示文件类型,这里是 64位的 ELF 格式。
第 4 行,Data 表示文件中的数据是按照什么格式组织(大端或小端)的,不同处理器平台数据组织格式可能就不同,如x86平台为小端存储格式。
第 5 行,当前 ELF 文件头版本号,这里版本号为 1 。
第 6 行,OS/ABI ,指出操作系统类型,ABI 是 Application Binary Interface 的缩写。
第 7 行,ABI 版本号,当前为 0 。
第 8 行,Type 表示文件类型。ELF 文件有 3 种类型,一种是如上所示的 Relocatable file 可重定位目标文件,一种是可执行文件(Executable),另外一种是共享库(Shared Library) 。
第 9 行,机器平台类型。
第 10 行,当前目标文件的版本号。
第 11 行,程序的虚拟地址入口点,因为这还不是可运行的程序,故而这里为零。
第 12 行,与 11 行同理,这个目标文件没有 Program Headers。
第 13 行,sections 头开始处,这里 208 是十进制,表示从地址偏移 0xD0 处开始。
第 14 行,是一个与处理器相关联的标志,x86 平台上该处为 0 。
第 15 行,ELF 文件头的字节数。
第 16 行,因为这个不是可执行程序,故此处大小为 0。
第 17 行,同理于第 16 行。
第 18 行,sections header 的大小,这里每个 section 头大小为 40 个字节。
第 19 行,一共有多少个 section 头,这里是 8 个。
第 20 行,section 头字符串表索引号,从 Section Headers 输出部分可以看到其内容的偏移在 0xa0 处,从此处开始到0xcf 结束保存着各个 sections 的名字,如 .data,.text,.bss等。

在 Section Headers 这里,可以看到 .bss 和 .shstrtab 的偏移都为 0xa0 。这是因为,没有被初始化的全局变量,会在加载阶段被用 0 来初始化,这时候它和 .data 段一样可读可写。但在编译阶段,.data 段会被分配一部分空间已存放数据(这里从偏移 0x6c 开始),而 .bss 则没有,.bss 仅有的是 section headers 。
链接器从 .rel.text 就可以知道哪些地方需要进行重定位(relocate)。 .symtab 是符号表。
Ndx 是符号表所在的 section 的 section header 编号。如 .data 段的 section header 编号是 3,而string1,string2,lenght 都是在 .data 段的。

读取节头表
readelf -s **.so

但是mac os X下没有这两个命令,可以用 brew 来安装。 使用命令:

brew install binutils

然后使用 greadelf 和 gobjdump 命令。

binutils is keg-only, which means it was not symlinked into /usr/local,
because because Apple provides the same tools and binutils is poorly supported on macOS.

If you need to have binutils first in your PATH run:
  echo 'export PATH="/usr/local/opt/binutils/bin:$PATH"' >> ~/.bash_profile

For compilers to find binutils you may need to set:
  export LDFLAGS="-L/usr/local/opt/binutils/lib"
  export CPPFLAGS="-I/usr/local/opt/binutils/include"

binutils 并没有符号链接,并且 macOS 对他的支持并不好。

在使用之前,首先需要配置的路径(存放在环境变量里去),能正确链接,根据提示操作就可以了。

echo 'export PATH="/usr/local/opt/binutils/bin:$PATH"' >> ~/.bash_profile

然后使 .bash_profile 生效

source ~/.bash_profile

后面依次执行下面的命令就可以了。

export LDFLAGS="-L/usr/local/opt/binutils/lib"
export CPPFLAGS="-I/usr/local/opt/binutils/include"

下面就可以使用 gobjdump 和 greadelf 命令了

三. ndk-build生成so

AndroidStudio里 在执行gradle assembleRelease 之后,我们的工程就会输出三个so 文件

其中有两个是带符号表信息的,另外一个是不带符号表信息的。

gradle 生成so对应下面两个Task,生成对应带符号表的so

> Task :app:transformNativeLibsWithMergeJniLibsForRelease UP-TO-DATE
> Task :app:transformNativeLibsWithStripDebugSymbolForRelease UP-TO-DATE

而裁剪调符号表的so, 执行的Task对应是

task 'transformNativeLibsWithStripDebugSymbolForDebug'...

在用户目录.gradle文件夹下 找到 gradle-***.jar包, 查看里面的源码:

StripDebugSymbolTransform.java 在包 com.android.build.gradle.internal.transforms 路径下, 其中,去掉符号的操作,是在stripFile实现的。

private void stripFile(@NonNull File input, @NonNull File output, @Nullable Abi abi)
        throws IOException {
    FileUtils.mkdirs(output.getParentFile());
    ILogger logger = new LoggerWrapper(project.getLogger());
    File exe =
            stripToolFinder.stripToolExecutableFile(
                    input,
                    abi,
                    msg -> {
                        logger.warning(msg + " Packaging it as is.");
                        return null;
                    });

    if (exe == null) {
        // The strip executable couldn't be found and a message about the failure was reported in getPathToStripExecutable.
        // Fall back to copying the file to the output location
        FileUtils.copyFile(input, output);
        return;
    }

    ProcessInfoBuilder builder = new ProcessInfoBuilder();
    builder.setExecutable(exe);
    builder.addArgs("--strip-unneeded");
    builder.addArgs("-o");
    builder.addArgs(output.toString());
    builder.addArgs(input.toString());
    ProcessResult result = new GradleProcessExecutor(project).execute(
            builder.createProcess(),
            new LoggedProcessOutputHandler(logger));
    if (result.getExitValue() != 0) {
        logger.warning(
                "Unable to strip library '%s' due to error %s returned "
                        + "from '%s', packaging it as is.",
                result.getExitValue(), exe, input.getAbsolutePath());
        FileUtils.copyFile(input, output);
    }
}

我们现在NDK的开发一般都是使用cmake的方式来开发,如果你在gradle中使用过cmake,你会发现在gradle执行sync操作后,app目录下就会生成一个叫.externalNativeBuild的文件夹,这个文件夹就是用来进行C/C++代码的编译的,

四. addr2line

addr2line是一个十分有用的debug工具,这个工具在ndk的安装目录下就有

在ndk \toolchains\aarch64-linux-android-4.9\prebuilt\windows-x86_64\bin目录下, 我们可以把这个路径配在环境变量PATH里, 这样在终端里就不用每次打印绝对路径了。

可以用于帮助我们分析jni 里面的bug,下面我们故意在jni代码中留一个异常,在运行到memcpy就会发生空指针异常,应用会闪退.

用法如下, 输入so和对应的地址就可以查看函数名字了:

aarch64-linux-android-addr2line -f -C -e libmnistcore.so 6148

需要注意的是Gradle 不同的Task 生成不同版本的so, 他们共享一套地址, 也就意味着我们可以在发布的时候使用strip过符号表的so, 然后线下可以使用带符号表的so还原出堆栈信息.

我们也可以接入腾讯Bugly, 不用每次闪退都看log。 当应用发生闪退时, bugly直接会上传堆栈到server, 然后解析符号表,生成奔溃信息。

如果你的项目集成了CI, 在打包的时候自动上传符号表,然后crash的时候自动去查询奔溃堆栈, 一套组合拳打下来,工作效率会提升很多。

bugly 上传符号表