前言
写本文的目的是为了记录一个【基于 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 中后,理论上应该就可以恢复出完整的栈帧信息了。
实际上,上述的这些都是可以做到的。下面就将整个过程完整地记录一下。
调试过程
-
在 GDB 中运行程序,复现错误:
1gdb ./test_XXX 2 3(gdb) r 4Starting program: /tmp/test_XXX 5Program received signal SIGSEGV, Segmentation fault. 60x40aa87e4 in ?? ()
这里涉及到一个重点:如何获得一个程序出错时的进程空间。一般而言,程序运行一旦崩溃就直接退出了,相应的进程空间随即被清理,也就获取不到了。但是!若是改为在 GDB 中运行,错误信号会被 GDB 捕获,程序的执行会被暂停,进程空间因此得以保留!这是一切得以开展的基础。
-
在另一个 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...
-
从顶部的函数栈帧开始,查看当前执行地址(0x40aa87e4)属于哪个 DSO。
可以看到,0x40aa87e4 落在 0x40a8e000 ~ 0x40ad0000 这个范围内,属于 libc.so 的一个段;另外这个段是可执行的(
r-xp
),执行地址坐落于此也是合理的。因此我们基本确定了是 libc.so 出了问题。 -
计算 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
的完整输出。 -
计算出正确的地址,将该 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
命令,就能看到关于函数栈帧的详细信息了。如此操作一次,能恢复顶部的一个或几个栈帧。反复操作,直到整个函数调用栈都恢复,或达到自己所需的深度即可。
参考链接
- Ubuntu Debug of Android userspace via Hybris ,from Ubuntu Wiki
- 《libhybris及EGL Platform-在Glibc生态中重用Android的驱动》 ,出自 ariesjzj 的 CSDN 博客
-
全称 Dynamic Shared Object,即平时所说的 .so 文件。 ↩︎
-
目前主要用于 Ubuntu 系统,其他发行版未知。 ↩︎
-
目前仅了解过 Android Bionic 的动态链接器,这部分的主要逻辑在 linker_phdr.cpp 中
ElfReader
类的Load()
、ReserveAddressSpace()
和LoadSegments()
等几个成员函数中。 ↩︎ -
其实这并不完全准确。Program Header 对各个段的描述里有两个值:
p_offset
为该段在文件内偏移,p_vaddr
为该段要被加载到的内存地址。然而,出于内存管理和安全方面的原因,ELF 加载到内存时都是需要重定位的。因此p_vaddr
实际上是该段在内存中相对 DSO 首地址的偏移,DSO 的加载偏移加上段的p_vaddr
才是段在内存中的实际地址。所以一个段在内存中和文件里的偏移分别是用两个值表示的,只是在(所有我见过的)实际的 ELF 文件中,p_offset
和p_vaddr
总是相等。 ↩︎ -
这里实际查看的是 ELF 的节(section)而不是段(segment),两者存在着某种对应关系,分别代表了 ELF 的两种不同视图。详情请查阅其他资料,这里不赘述。 ↩︎