查看原文
其他

Android崩在so里面,怎么定位Native堆栈呢?

鸿洋
2024-08-24

The following article is from 程序员陆业聪 Author 陆业聪

在Android系统中,我们有时需要获取Native层的堆栈信息,例如在进行性能分析、问题定位和调试等场景。本文将介绍如何在Android Native层获取堆栈信息,并提供示例代码。
1使用unwind函数

1.1 工具和方法

对于Android系统,不能直接使用backtrace_symbols函数,因为它在Android Bionic libc中没有实现。但是,我们可以使用dladdr函数替代backtrace_symbols来获取符号信息。

Android NDK提供了unwind.h头文件,其中定义了unwind函数,可以用于获取任意线程的堆栈信息。

1.2 获取当前线程的堆栈信息

如果我们需要获取当前线程的堆栈信息,可以使用Android NDK中的unwind函数。以下是使用unwind函数获取堆栈信息的示例代码:

#include <unwind.h>
#include <dlfcn.h>
#include <stdio.h>

struct BacktraceState {
    void** current;
    void** end;
};

_Unwind_Reason_Code unwind_callback(struct _Unwind_Context* context, void* arg) {
    BacktraceState* state = static_cast<BacktraceState*>(arg);
    uintptr_t pc = _Unwind_GetIP(context);
    if (pc) {
        if (state->current == state->end) {
            return _URC_END_OF_STACK;
        } else {
            *state->current++ = reinterpret_cast<void*>(pc);
        }
    }
    return _URC_NO_REASON;
}

void capture_backtrace(void** buffer, int max) {
    BacktraceState state = {buffer, buffer + max};
    _Unwind_Backtrace(unwind_callback, &state);
}

void print_backtrace(void** buffer, int count) {
    for (int idx = 0; idx < count; ++idx) {
        const void* addr = buffer[idx];
        const char* symbol = "";

        Dl_info info;
        if (dladdr(addr, &info) && info.dli_sname) {
            symbol = info.dli_sname;
        }

        // 计算相对地址
        void* relative_addr = reinterpret_cast<void*>(reinterpret_cast<uintptr_t>(addr) - reinterpret_cast<uintptr_t>(info.dli_fbase));

        printf("%-3d %p %s (relative addr: %p)\n", idx, addr, symbol, relative_addr);
    }
}

int main() {
    const int max_frames = 128;
    void* buffer[max_frames];

    capture_backtrace(buffer, max_frames);
    print_backtrace(buffer, max_frames);

    return 0;
}

在上述代码中,capture_backtrace函数使用_Unwind_Backtrace函数获取堆栈信息,然后我们使用dladdr函数获取到函数所在的SO库的基地址(info.dli_fbase),然后计算出函数的相对地址(relative_addr)。然后在打印堆栈信息时,同时打印出函数的相对地址。

1.3 libunwind的相关接口

1.3.1 _Unwind_Backtrace

_Unwind_Backtrace是一个在libunwind库中定义的函数,用于获取当前线程的调用堆栈。它的原理是遍历调用堆栈的栈帧,然后在每个栈帧上调用一个用户定义的回调函数。通过回调函数,我们可以获取栈帧的相关信息,例如函数地址、参数等。函数原型如下:

_Unwind_Reason_Code _Unwind_Backtrace(_Unwind_Trace_Fn trace, void *trace_argument);

_Unwind_Backtrace函数有两个参数:

  1. _Unwind_Trace_Fn trace:一个回调函数,它会在每个堆栈帧上被调用。回调函数的原型如下:

typedef _Unwind_Reason_Code (*_Unwind_Trace_Fn)(struct _Unwind_Context *context, void *arg);

回调函数接收两个参数:

  • struct _Unwind_Context *context:表示当前堆栈帧的上下文信息。

  • void *arg:一个用户自定义的参数,它会在调用_Unwind_Backtrace时传递给回调函数。

回调函数需要返回一个_Unwind_Reason_Code类型的值,表示回调的执行结果。如果返回_URC_NO_REASON,_Unwind_Backtrace将继续处理下一个堆栈帧;如果返回_URC_END_OF_STACK_Unwind_Backtrace将停止处理堆栈帧。

  1. void *trace_argument:一个用户自定义的参数,它会被传递给回调函数。通常用于存储堆栈信息或其他用户数据。

_Unwind_Backtrace函数的作用是遍历当前线程的调用堆栈,并在每个堆栈帧上调用回调函数。通过回调函数,我们可以获取堆栈帧的相关信息,例如函数地址、参数等。

1.3.2 _Unwind_GetIP

_Unwind_GetIP是一个在libunwind库中定义的函数,用于获取当前栈帧的指令指针(Instruction Pointer),也就是当前函数的返回地址。函数原型如下:

uintptr_t _Unwind_GetIP(struct _Unwind_Context *context);

_Unwind_GetIP函数有一个参数:

  • struct _Unwind_Context *context:表示当前栈帧的上下文信息。这个上下文信息在_Unwind_Backtrace函数中被创建,并在每个栈帧上被传递给回调函数。

_Unwind_GetIP函数的实现原理依赖于底层硬件架构(例如ARM、x86等)和操作系统。它会使用特定于架构的寄存器和数据结构来获取当前栈帧的指令指针。例如,在ARM64架构上,_Unwind_GetIP会返回Link Register(LR)寄存器的值,该寄存器保存了函数的返回地址。

_Unwind_GetIP函数的返回值是一个无符号整数,表示当前函数的返回地址。我们可以使用这个地址来获取函数的符号信息,例如函数名、源文件名和行号等。

1.3.3 在不同Android版本中的可用性

_Unwind_Backtrace_Unwind_GetIP函数是在libunwind库中定义的,这个库是GNU C Library(glibc)的一部分。然而,Android系统并不使用glibc,而是使用一个更轻量级的C库,叫做Bionic libc。因此,_Unwind_Backtrace_Unwind_GetIP函数在Android系统中的可用性取决于Bionic libc的版本和Android系统的版本。

在早期的Android版本中(例如Android 4.x),Bionic libc并未完全实现libunwind库的功能,因此_Unwind_Backtrace_Unwind_GetIP函数可能无法正常工作。在这些版本中,我们通常需要使用其他方法来获取堆栈信息,例如手动遍历栈帧或者使用第三方的库。

从Android 5.0(Lollipop)开始,Bionic libc开始提供更完整的libunwind库的支持,包括_Unwind_Backtrace_Unwind_GetIP函数。因此,在Android 5.0及更高版本中,我们可以直接使用这两个函数来获取堆栈信息。

需要注意的是,虽然_Unwind_Backtrace_Unwind_GetIP函数在新版的Android系统中可用,但它们的行为可能会受到编译器优化、调试信息等因素的影响。在实际使用中,我们需要根据具体的情况来选择最合适的方法。
2手动遍历栈帧来实现获取堆栈信息

在Android系统中,_Unwind_Backtrace的具体实现依赖于底层硬件架构(例如ARM、x86等)和操作系统。它会使用特定于架构的寄存器和数据结构来遍历栈帧。例如,在ARM64架构上,_Unwind_Backtrace会使用Frame Pointer(FP)寄存器和Link Register(LR)寄存器来遍历栈帧。

如果不使用_Unwind_Backtrace,我们可以手动遍历栈帧来实现获取堆栈信息。

2.1 ARM64架构下的示例代码

以下是一个基于ARM64架构的示例代码,展示如何使用Frame Pointer(FP)寄存器手动遍历栈帧:

#include <stdio.h>
#include <dlfcn.h>

void print_backtrace_manual() {
    uintptr_t fp = 0;
    uintptr_t lr = 0;

    // 获取当前的FP和LR寄存器值
    asm("mov %0, x29" : "=r"(fp));
    asm("mov %0, x30" : "=r"(lr));

    while (fp) {
        // 计算上一个栈帧的FP和LR寄存器值
        uintptr_t prev_fp = *(uintptr_t*)(fp);
        uintptr_t prev_lr = *(uintptr_t*)(fp + 8);

        // 获取函数地址对应的符号信息
        Dl_info info;
        if (dladdr(reinterpret_cast<void*>(lr), &info) && info.dli_sname) {
            printf("%p %s\n"reinterpret_cast<void*>(lr), info.dli_sname);
        } else {
            printf("%p\n"reinterpret_cast<void*>(lr));
        }

        // 更新FP和LR寄存器值
        fp = prev_fp;
        lr = prev_lr;
    }
}

在上述代码中,我们首先获取当前的FP(x29)和LR(x30)寄存器值。然后,通过遍历FP链,获取每个栈帧的返回地址(存储在LR寄存器中)。最后,使用dladdr函数获取函数地址对应的符号信息,并打印堆栈信息。

在这段代码中,*(uintptr_t*)(fp)表示的是取fp所指向的内存地址处的值。fp是一个无符号整数,表示的是一个内存地址,(uintptr_t*)(fp)将fp转换成一个指针,然后*操作符取该指针所指向的值。

在ARM64架构中,函数调用时会创建一个新的栈帧。每个栈帧中包含了函数的局部变量、参数、返回地址以及其他与函数调用相关的信息。其中,Frame Pointer(FP,帧指针)寄存器(x29)保存了上一个栈帧的FP寄存器值,Link Register(LR)寄存器(x30)保存了函数的返回地址。

在这段代码中,fp变量保存了当前栈帧的FP寄存器值,也就是上一个栈帧的帧基址。因此,*(uintptr_t*)(fp)取的就是上一个栈帧的FP寄存器值,即上上个栈帧的帧基址。这个值在遍历栈帧时用来更新fp变量,以便在下一次循环中处理上一个栈帧。

2.2 ARM架构下的示例代码

在ARM架构下,我们可以使用Frame Pointer(FP)寄存器(R11)和Link Register(LR)寄存器(R14)来手动遍历栈帧。以下是一个基于ARM架构的示例代码,展示如何手动遍历栈帧以获取堆栈信息:

#include <stdio.h>
#include <dlfcn.h>

void print_backtrace_manual_arm() {
    uintptr_t fp = 0;
    uintptr_t lr = 0;

    // 获取当前的FP和LR寄存器值
    asm("mov %0, r11" : "=r"(fp));
    asm("mov %0, r14" : "=r"(lr));

    while (fp) {
        // 计算上一个栈帧的FP和LR寄存器值
        uintptr_t prev_fp = *(uintptr_t*)(fp);
        uintptr_t prev_lr = *(uintptr_t*)(fp + 4);

        // 获取函数地址对应的符号信息
        Dl_info info;
        if (dladdr(reinterpret_cast<void*>(lr), &info) && info.dli_sname) {
            printf("%p %s\n"reinterpret_cast<void*>(lr), info.dli_sname);
        } else {
            printf("%p\n"reinterpret_cast<void*>(lr));
        }

        // 更新FP和LR寄存器值
        fp = prev_fp;
        lr = prev_lr;
    }
}

在这个示例代码中,我们首先获取当前的FP(R11)和LR(R14)寄存器值。然后,通过遍历FP链,获取每个栈帧的返回地址(存储在LR寄存器中)。最后,使用dladdr函数获取函数地址对应的符号信息,并打印堆栈信息。

通过以上示例代码,我们可以看到,在不同架构上手动遍历栈帧以获取堆栈信息的方法大致相同,只是寄存器和数据结构有所不同。这种方法提供了一种在不使用_Unwind_Backtrace的情况下获取堆栈信息的方式,有助于我们更好地理解和调试程序。

2.3 寄存器

在函数调用过程中,fp(Frame Pointer,帧指针)、lr(Link Register,链接寄存器)和sp(Stack Pointer,栈指针)是三个关键寄存器,它们之间的关系如下:

  1. fp(Frame Pointer):帧指针寄存器用于指向当前栈帧的帧基址。在函数调用过程中,每个函数都会有一个栈帧,用于存储函数的局部变量、参数、返回地址等信息。fp寄存器有助于定位和访问这些信息。在不同的架构中,fp寄存器可能有不同的名称,例如,在ARM64架构中,fp寄存器对应X29;在ARM架构中,fp寄存器对应R11;在x86_64架构中,fp寄存器对应RBP。

  2. lr(Link Register):链接寄存器用于保存函数的返回地址。当一个函数被调用时,程序需要知道在函数执行完毕后返回到哪里继续执行。这个返回地址就被保存在lr寄存器中。在不同的架构中,lr寄存器可能有不同的名称,例如,在ARM64架构中,lr寄存器对应X30;在ARM架构中,lr寄存器对应R14;在x86_64架构中,返回地址通常被保存在栈上,而不是专用寄存器中。

  3. sp(Stack Pointer):栈指针寄存器用于指向当前栈帧的栈顶。在函数调用过程中,栈指针会根据需要分配或释放栈空间。在不同的架构中,sp寄存器可能有不同的名称,例如,在ARM64架构中,sp寄存器对应XSP;在ARM架构中,sp寄存器对应R13;在x86_64架构中,sp寄存器对应RSP。

fp、lr和sp三者在函数调用过程中共同协作,以实现正确的函数调用和返回。fp用于定位栈帧中的数据,lr保存函数的返回地址,而sp则负责管理栈空间。在遍历栈帧以获取堆栈信息时,我们需要利用这三个寄存器之间的关系来定位每个栈帧的位置和内容。

2.4 栈帧

栈帧(Stack Frame)是函数调用过程中的一个重要概念。每次函数调用时,都会在栈上创建一个新的栈帧。栈帧包含了函数的局部变量、参数、返回地址以及其他一些与函数调用相关的信息。在Android系统中,栈帧的基本原理与其他操作系统相同,主要有以下几个特点:

  1. 栈的生长方向:在ARM64和ARM架构中,栈是向低地址方向生长的。也就是说,当新的栈帧被创建时,栈指针(SP)会减小;当栈帧被销毁时,栈指针(SP)会增加。

  2. 函数调用约定:在ARM64和ARM架构中,函数调用时需要遵循一定的约定,例如参数传递、寄存器保存等。这些约定保证了函数调用的正确性和高效性。

  3. 栈帧布局:在ARM64和ARM架构中,栈帧的布局包括局部变量、参数、返回地址等。栈帧的布局可能会受到编译器优化、调试信息等因素的影响。

通过SP和FP所限定的stack frame,就可以得到母函数的SP和FP,从而得到母函数的stack frame(PC,LR,SP,FP会在函数调用的第一时间压栈),以此追溯,即可得到所有函数的调用顺序。

在ARM64和ARM架构中,我们可以使用FP链(帧指针链)来遍历栈帧。具体方法是:从当前FP寄存器开始,沿着FP链向上遍历,直到遇到空指针(NULL)或者无效地址。在遍历过程中,我们可以从每个栈帧中提取返回地址(存储在LR寄存器中)以及其他相关信息。

3GCC生成符号的规律

Native堆栈的符号信息跟代码中定义的函数名字相比,可能会有一些差别,因为GCC生成的符号表有一些修饰规则。

GCC在编译C/C++等语言源代码时,会生成相应的符号。这些符号主要用于链接时解析和调试等目的。GCC生成的符号遵循一定的规则,主要包括以下几点:

  1. 名字修饰(Name Mangling):C++支持函数重载,即同一个函数名可以有不同的参数类型和个数。为了在编译时区分这些函数,GCC会对函数名进行修饰,生成独特的符号名称。修饰后的名称包含了函数名、参数类型等信息。例如,对于如下C++函数:

namespace test {
  int foo(int a, double b);
}

经过GCC修饰后,生成的符号可能类似于:_ZN4test3fooEid,其中:

  • _ZN和E是修饰前缀和后缀,用于标识这是一个C++符号。

  • 4test表示命名空间名为test,4表示命名空间名的长度。

  • 3foo表示函数名为foo,3表示函数名的长度。

  • id表示函数的参数类型,i代表int,d代表double。

  1. 变量符号:全局变量和静态变量的符号名称通常与其在源代码中的名称相同。局部变量的符号名称会包含函数名和变量名,以及作用域信息。

  2. 符号可见性(Symbol Visibility):根据符号在源代码中的声明,GCC会为生成的符号分配不同的可见性。例如:

  • static:局部符号,只在当前源文件内可见。

  • extern:外部符号,可以在其他源文件中使用。

  • 默认:如果没有指定static或extern,则符号的可见性取决于编译器的默认设置。

  1. 弱符号和强符号:弱符号(Weak Symbol)是可以被其他同名符号覆盖的符号,而强符号(Strong Symbol)是不会被覆盖的。当链接器在链接过程中遇到同名的强符号和弱符号时,会选择强符号。在GCC中,可以使用__attribute__((weak))修饰符来声明弱符号。

了解GCC生成符号的规律有助于我们在分析和调试程序时快速定位函数和变量,以及理解链接器在链接过程中如何处理这些符号。
4总结


获取Android Native堆栈信息是Android性能分析和问题定位的重要手段。通过使用unwind函数或者手动遍历栈帧,我们可以方便地获取到堆栈信息,并进行后续的分析和处理。



最后推荐一下我做的网站,玩Android: wanandroid.com ,包含详尽的知识体系、好用的工具,还有本公众号文章合集,欢迎体验和收藏!


推荐阅读

RecyclerView面试宝典:7大高频问题解析
2024 Google I/O Android 相关内容
Apk安装之谜


扫一扫 关注我的公众号

如果你想要跟大家分享你的文章,欢迎投稿~


┏(^0^)┛明天见!


继续滑动看下一个
鸿洋
向上滑动看下一个

您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存