用 GDB 调试动态链接库

前言

写本文的目的是为了记录一个【基于 GDB 对动态链接库(DSO1)的错误进行调试】的过程,重要的内容包括:

  • 获取进程发生错误时的状态;
  • 查看进程在该状态下的内存分布;
  • 在 GDB 中用正确地址加载符号表,以恢复函数调用堆栈。

本文主要参考了 Ubuntu Wiki 上的这篇博文 。按照其版权及许可证要求,本文采用 Creative Commons Attribution-ShareAlike 3.0 进行许可。

Hybris 简介

Libhybris 是一种关于 libc 库兼容问题的解决方案,让我们在基于 GNU C Library (Glibc)的系统上2可以运行用 Bionic 编译的库(主要是 Android 中的闭源 HAL 库)。以一个 OpenGL ES 的应用为例,如果其要使用 Android 的硬件,调用过程如下:

1应用 -> Glibc -> Hybris -> OpenGL ES (libEGL, libGLESv2) -> Bionic

基于这个架构中,在动态链接时 Android 的动态链接器和 Hybris 都会参与,前者负责加载 Android 侧的库,后者负责建立两端的符号映射。

问题阐述

这个架构有个小问题:当 Android 侧的 DSO 发生了错误使得程序崩溃,基于 Glibc 的 Linux 侧调试工具无法解析和恢复出此时的函数调用栈(因为这些 DSO 是由 Android 的动态链接器加载的,Linux 侧的链接器应该不知道它们的存在)。因此即便我们获取到了程序崩溃时的 coredump,加载到 GDB 中之后,看到的也只是类似下图的结果,对调试没有帮助:

1(gdb) bt full
2#0  0x40aa87e4 in ?? ()
3No symbol table info available.
4#1  0x40bb2fc2 in ?? ()
5No symbol table info available.
6#2  0x40bb2fc2 in ?? ()
7No symbol table info available.
8Backtrace stopped: previous frame identical to this frame (corrupt stack?)

问题分析

然而,顶部的几个栈帧的执行地址还是有的。如果可以获得各个 DSO 在当前进程空间中的分布,我们就可以根据这些地址判断是哪个 DSO 出了问题。再进一步,如果我们有该 DSO 的符号表,那么以某种方式将其加载到 GDB 中后,理论上应该就可以恢复出完整的栈帧信息了。

实际上,上述的这些都是可以做到的。下面就将整个过程完整地记录一下。

调试过程

  1. 在 GDB 中运行程序,复现错误:

    1gdb ./test_XXX
    2
    3(gdb) r
    4Starting program: /tmp/test_XXX 
    5Program received signal SIGSEGV, Segmentation fault.
    60x40aa87e4 in ?? ()
    

    这里涉及到一个重点:如何获得一个程序出错时的进程空间。一般而言,程序运行一旦崩溃就直接退出了,相应的进程空间随即被清理,也就获取不到了。但是!若是改为在 GDB 中运行,错误信号会被 GDB 捕获,程序的执行会被暂停,进程空间因此得以保留!这是一切得以开展的基础。

  2. 在另一个 Terminal 中查看 test_XXX 进程的 PID:

    1pidof test_XXX
    2# or
    3ps -ef | grep test_XXX
    

    根据 PID(假设为 4226)就可以查看进程空间了:

    1cat /proc/4226/maps | grep 'system/lib'
    
    1...
    240a8e000-40ad0000 r-xp 00000000 103:02 231       /data/ubuntu/system/lib/libc.so
    340ad0000-40ad3000 rw-p 00042000 103:02 231       /data/ubuntu/system/lib/libc.so
    4...
    540b66000-40b97000 r-xp 00000000 103:02 313       /data/ubuntu/system/lib/libutils.so
    640b97000-40b99000 rw-p 00031000 103:02 313       /data/ubuntu/system/lib/libutils.so
    7...
    
  3. 从顶部的函数栈帧开始,查看当前执行地址(0x40aa87e4)属于哪个 DSO。

    可以看到,0x40aa87e4 落在 0x40a8e000 ~ 0x40ad0000 这个范围内,属于 libc.so 的一个段;另外这个段是可执行的(r-xp),执行地址坐落于此也是合理的。因此我们基本确定了是 libc.so 出了问题。

  4. 计算 DSO 的代码部分在进程空间中的起始位置。

    通过阅读动态链接器的源代码3我们了解到,加载 ELF 至内存的过程经历了以下几个步骤:读取 ELF 文件的 Program Header;计算所有需要加载到内存的段的总大小;在进程空间中腾出一段足够的内存空间;最终通过 mmap 的方式将各个段逐个加载到该空间内。因此,各个段在内存中相对于 DSO 加载首地址的偏移,就是该段在文件内的偏移4。DSO 的加载首地址,就是其在 maps 文件中出现的第一个地址(libc.so 是 0x40a8e000,libutils.so 是 0x40b66000)。那么只要确定代码部分在 DSO 文件中的偏移,就可以在进程空间中定位出代码部分的位置了。

    通过如下命令5

    1objdump -x /system/lib/libc.so | grep .text
    2# or `readelf -S`, with different output format
    

    并在输出中找到类似这样输出:

    1  6 .text         0002dfe0  0000c140  0000c140  0000c140  2**4
    

    其中第六列的数值就是我们要找的代码部分在文件中的偏移量。至于为什么是第六列,可以去看 objdump -x 的完整输出。

  5. 计算出正确的地址,将该 DSO 的符号表加载到 GDB 中。

    0x40a8e000 + 0xc140 = 0x40a9a140,我们以这一地址将 libc.so 的符号表加载到 GDB 中:

    1(gdb) add-symbol-file /tmp/libc.so 0x40a9a140
    2add symbol table from file "/tmp/libc.so" at
    3        .text_addr = 0x40a9a140
    4(y or n) y
    5Reading symbols from /tmp/libc.so...done.
    

此时再使用 bt 命令,就能看到关于函数栈帧的详细信息了。如此操作一次,能恢复顶部的一个或几个栈帧。反复操作,直到整个函数调用栈都恢复,或达到自己所需的深度即可。

参考链接


  1. 全称 Dynamic Shared Object,即平时所说的 .so 文件。 ↩︎

  2. 目前主要用于 Ubuntu 系统,其他发行版未知。 ↩︎

  3. 目前仅了解过 Android Bionic 的动态链接器,这部分的主要逻辑在 linker_phdr.cppElfReader 类的 Load()ReserveAddressSpace()LoadSegments()等几个成员函数中。 ↩︎

  4. 其实这并不完全准确。Program Header 对各个段的描述里有两个值:p_offset 为该段在文件内偏移,p_vaddr 为该段要被加载到的内存地址。然而,出于内存管理和安全方面的原因,ELF 加载到内存时都是需要重定位的。因此 p_vaddr 实际上是该段在内存中相对 DSO 首地址的偏移,DSO 的加载偏移加上段的 p_vaddr 才是段在内存中的实际地址。所以一个段在内存中和文件里的偏移分别是用两个值表示的,只是在(所有我见过的)实际的 ELF 文件中,p_offsetp_vaddr 总是相等。 ↩︎

  5. 这里实际查看的是 ELF 的节(section)而不是段(segment),两者存在着某种对应关系,分别代表了 ELF 的两种不同视图。详情请查阅其他资料,这里不赘述。 ↩︎


【C++ 小问答】4:数值运算中的小类型隐式转换
C++ 中的 Name Mangling