gitbook/Android开发高手课/docs/80498.md
2022-09-03 22:05:03 +08:00

10 KiB
Raw Permalink Blame History

Native下如何获取调用栈

你好我是simsun曾在微信从事Android开发也是开源爱好者、Rust语言“铁粉”。应绍文邀请很高兴可以在“高手课”里和你分享一些编译方面的底层知识。

当我们在调试Native崩溃或者在做profiling的时候是十分依赖backtrace的高质量的backtrace可以大大减少我们修复崩溃的时间。但你是否了解系统是如何生成backtrace的呢今天我们就来探索一下backtrace背后的故事。

下面是一个常见的Native崩溃。通常崩溃本身并没有任何backtrace信息可以直接获得的就是当前寄存器的值但显然backtrace才是能够帮助我们修复Bug的关键。

pid: 4637, tid: 4637, name: crasher  >>> crasher <<<
signal 6 (SIGABRT), code -6 (SI_TKILL), fault addr --------
Abort message: 'some_file.c:123: some_function: assertion "false" failed'
    r0  00000000  r1  0000121d  r2  00000006  r3  00000008
    r4  0000121d  r5  0000121d  r6  ffb44a1c  r7  0000010c
    r8  00000000  r9  00000000  r10 00000000  r11 00000000
    ip  ffb44c20  sp  ffb44a08  lr  eace2b0b  pc  eace2b16
backtrace:
    #00 pc 0001cb16  /system/lib/libc.so (abort+57)
    #01 pc 0001cd8f  /system/lib/libc.so (__assert2+22)
    #02 pc 00001531  /system/bin/crasher (do_action+764)
    #03 pc 00002301  /system/bin/crasher (main+68)
    #04 pc 0008a809  /system/lib/libc.so (__libc_init+48)
    #05 pc 00001097  /system/bin/crasher (_start_main+38)

在阅读后面的内容之前你可以先给自己2分钟时间思考一下系统是如何生成backtrace的呢我们通常把生成backtrace的过程叫作unwindunwind看似和我们平时开发并没有什么关系但其实很多功能都是依赖unwind的。举个例子比如你要绘制火焰图或者是在崩溃发生时得到backtrace都需要依赖unwind。

书本中的unwind

1. 函数调用过程

如果你在大学时期修过汇编原理这门课程,相信你会对下面的内容还有印象。下图就是一个非常标准的函数调用的过程。

  • 首先假设我们处于函数main()并准备调用函数foo(),调用方会按倒序压入参数。此时第一个参数会在调用栈栈顶。

  • 调用invoke foo()伪指令压入当前寄存器EIP的值到栈然后载入函数foo()的地址到EIP。

  • 此时由于我们已经更改了EIP的值为foo()的地址相当于我们已经进入了函数foo()。在执行一个函数之前编译器都会给每个函数写一段序言prologue这里会压入旧的EBP值并赋予当前EBP和ESP新的值从而形成新的一个函数栈。

  • 下一步就行执行函数foo()本身的代码了。

  • 结束执行函数foo() 并准备返回这里编译器也会给每个函数插入一段尾声epilogue用于恢复调用方的ESP和EBP来重建之前函数的栈和恢复寄存器。

  • 执行返回指令ret被调用函数的尾声epilogue已经恢复了EBP和ESP然后我们可以从被恢复的栈中依次pop出EIP、所有的参数以及被暂存的寄存器的值。

读到这里相信如果没有学过汇编原理的同学肯定会有一些懵我来解释一下上面提到的寄存器缩写的具体含义上述命名均使用了x86的命名方式。讲这些是希望你对函数调用有一个初步的理解其中有很多细节在不同体系结构、不同编译器上的行为都有所区别所以请你放松心情跟我一起继续向后看。

EBP基址指针寄存器指向栈帧的底部。
在ARM体系结构中R11ARM code或者R7Thumb code起到了类似的作用。在ARM64中此寄存器为X29。
ESP栈指针寄存器指向栈帧的栈顶 在ARM下寄存器为R13。
EIP指令寄存器存储的是CPU下次要执行的指令的地址ARM下为PC寄存器为R15。

2. 恢复调用帧

如果我们把上述过程缩小站在更高一层视角去看所有的函数调用栈都会形成调用帧stack frame每一个帧中都保存了足够的信息可以恢复调用函数的栈帧。

我们这里忽略掉其他不相关的细节重点关注一下EBP、ESP和EIP。你可以看到EBP和ESP分别指向执行函数栈的栈底和栈顶。每次函数调用都会保存EBP和EIP用于在返回时恢复函数栈帧。这里所有被保存的EBP就像一个链表指针不断地指向调用函数的EBP。 这样我们就可以此为线索,十分优雅地恢复整个调用栈。

这里我们可以用下面的伪代码来恢复调用栈:

void debugger::print_backtrace() {
    auto curr_func = get_func_from_pc(get_pc());
    output_frame(curr_func);

    auto frame_pointer = get_register_value(m_pid, reg::rbp);
    auto return_address = read_mem(frame_pointer+8);

    while (dwarf::at_name(curr_func) != "main") {
        curr_func = get_func_from_pc(ret_addr);
        output_frame(curr_func);
        frame_pointer = read_mem(frame_pointer);
        return_address = read_mem(frame_pointer+8);
    }

但是在ARM体系结构中出于性能的考虑天才的开发者为了节约R7/R11寄存器使其可以作为通用寄存器来使用因此无法保证保存足够的信息来形成上述调用栈的即使你向编译器传入了“-fno-omit-frame-pointer”。比如下面两种情况ARM就会不遵循一般意义上的序言prologue感兴趣的同学可以具体查看APCS Doc

  • 函数为叶函数,即在函数体内再没有任何函数调用。

  • 函数体非常小。

Android中的unwind

我们知道大部分Android手机使用的都是ARM体系结构那在Android中需要如何进行unwind呢我们需要分两种情况分别讨论。

1. Debug版本unwind

如果是Debug版本我们可以通过“.debug_frame”有兴趣的同学可以了解一下DWARF来帮助我们进行unwind。这种方法十分高效也十分准确但缺点是调试信息本身很大甚至会比程序段.TEXT段更大所以我们是无法在Release版本中包含这个信息的。

DWARF 是一种标准调试信息格式。DWARF最早与ELF文件格式一起设计, 但DWARF本身是独立的一种对象文件格式。本身DAWRF和ELF的名字并没有任何意义侏儒、精灵是不是像魔兽世界的角色 :)后续为了方便宣传才命名为Debugging With Attributed Record Formats。引自wiki

2. Release版本unwind

对于Release版本系统使用了一种类似“.debug_frame”的段落是更加紧凑的方法我们可以称之为unwind table具体来说在x86和ARM64平台上是“.eh_frame”和“.eh_frame_hdr”在ARM32平台上为“.ARM.extab”和“.ARM.exidx”。

由于ARM32的标准早于DWARF的方法所有ARM使用了自己的实现不过它们的原理十分接近后续我们只讨论“.eh_frame”如果你对ARM32的实现特别感兴趣可以参考ARM-Unwinding-Tutorial

“.eh_frame section”也是遵循DWARF的格式的但DWARF本身十分琐碎也十分复杂这里我们就不深入进去了只涉及一些比较浅显的内容你只需要了解DAWRF使用了一个被称为DIDebugging Information Entry的数据结构去表示每个变量、变量类型和函数等在debug程序时需要用到的内容。

“.eh_frame”使用了一种很聪明的方法构成了一个非常大的表表中包含了每个程序段的地址对应的相应寄存器的值以及返回地址等相关信息下面就是这张表的示例你可以使用readelf --debug-dump=frames-interp去查看相应的信息Release版中会精简一些信息但所有帮助我们unwind的寄存器信息都会被保留

“.eh_frame section”至少包含了一个CFICall Frame Information。每个CFI都包含了两个条目独立的CIECommon Information Entry和至少一个FDEFrame Description Entry。通常来讲CFI都对应一个对象文件FDE则描述一个函数。

.eh_frame_hdr section”包含了一系列属性除了一些基础的meta信息还包含了一列有序信息初始地址指向“.eh_frame”中FDE的指针这些信息按照function排序从而可以使用二分查找加速搜索。

总结

总的来说unwind第一个栈帧是最难的由于ARM无法保证会压基址指针寄存器EBP进栈所以我们需要借助一些额外的信息.eh_frame来帮助我们得到相应的基址指针寄存器的值。即使如此生产环境还是会有各种栈破坏所以还是有许多工作需要做比如不同的调试器GDB、LLDB或者breakpad都实现了一些搜索算法去寻找潜在的栈帧这里我们就不展开讨论了感兴趣的同学可以查阅相关代码。

扩展阅读

下面给你一些外部链接你可以阅读GCC中实现unwind的关键函数有兴趣的同学可以在调试器中实现自己的unwinder。