文章详情

短信预约-IT技能 免费直播动态提醒

请输入下面的图形验证码

提交验证

短信预约提醒成功

Android Native内存泄漏检测方案详解

2024-11-29 20:49

关注

一个完整的 Android Native 内存泄漏检测工具主要包含三部分:代理实现、堆栈回溯和缓存管理。代理实现是解决 Android 平台上接入问题的关键部分,堆栈回溯则是性能和稳定性的核心要素。

本文会从三个方面介绍如何实现 Native 内存泄漏监控:

一、代理内存管理函数实现

首先我们来介绍一下代理内存管理函数实现的三个方案 :

1. Native Hook

(1) 方案对比:Inline Hook和PLT/GOT Hook

目前主要有两种Native Hook方案:Inline Hook和PLT/GOT Hook。

指令重定位是指在计算机程序的链接和装载过程中,对程序中的相对地址进行调整,使其指向正确的内存位置。这是因为程序在编译时,无法预知在运行时会被装载到内存的哪个位置,所以编译后的程序中,往往使用相对地址来表示内存位置。然而在实际运行时,程序可能被装载到内存的任何位置,因此需要在装载过程中,根据程序实际被装载到的内存地址,对程序中的所有相对地址进行调整,这个过程就叫做重定位。

在进行Inline Hook时,如果直接修改目标函数的机器码,可能会改变原有的跳转指令的相对地址,从而使程序跳转到错误的位置,因此需要进行指令重定位,确保修改后的指令能正确地跳转到预期的位置。

(2) 案例:在Android应用中Hook malloc 函数

为了更好地理解Native Hook的应用场景,我们来看一个实际的案例:在Android应用中Hook malloc 函数,以监控文件的打开操作。

① Inline Hook实现

#include 
#include 
#include 
#include 
#include 
#include 

#define TAG "NativeHook"
#define LOGD(...) __android_log_print(ANDROID_LOG_DEBUG, TAG, __VA_ARGS__)

typedef void* (*orig_malloc_func_type)(size_t size);

orig_malloc_func_type orig_malloc;

unsigned char backup[8];  // 用于保存原来的机器码

void* my_malloc(size_t size) {
    LOGD("内存分配: %zu 字节", size);

    // 创建一个新的函数指针orig_malloc_with_backup,指向一个新的内存区域
    void *orig_malloc_with_backup = mmap(NULL, sizeof(backup) + 8, PROT_READ | PROT_WRITE | PROT_EXEC, MAP_ANONYMOUS | MAP_PRIVATE, -1, 0);
    
    // 将备份的指令A和B复制到新的内存区域
    memcpy(orig_malloc_with_backup, backup, sizeof(backup));

    // 在新的内存区域的末尾添加一个跳转指令,使得执行流跳转回原始malloc函数的剩余部分
    unsigned char *jump = (unsigned char *)orig_malloc_with_backup + sizeof(backup);
    jump[0] = 0x01;  // 跳转指令的机器码
    *(void **)(jump + 1) = (unsigned char *)orig_malloc + sizeof(backup);  // 跳转目标的地址

    // 调用orig_malloc_with_backup函数指针
    orig_malloc_func_type orig_malloc_with_backup_func_ptr = (orig_malloc_func_type)orig_malloc_with_backup;
    void *result = orig_malloc_with_backup_func_ptr(size);

    // 释放分配的内存区域
    munmap(orig_malloc_with_backup, sizeof(backup) + 8);

    return result;
}

void *get_function_address(const char *func_name) {
    void *handle = dlopen("libc.so", RTLD_NOW);
    if (!handle) {
        LOGD("错误: %s", dlerror());
        return NULL;
    }

    void *func_addr = dlsym(handle, func_name);
    dlclose(handle);

    return func_addr;
}

void inline_hook() {
    void *orig_func_addr = get_function_address("malloc");
    if (orig_func_addr == NULL) {
        LOGD("错误: 无法找到 'malloc' 函数的地址");
        return;
    }

    // 备份原始函数
    orig_malloc = (orig_malloc_func_type)orig_func_addr;

    // 备份原始机器码
    memcpy(backup, orig_func_addr, sizeof(backup));

    // 更改页面保护
    size_t page_size = sysconf(_SC_PAGESIZE);
    uintptr_t page_start = (uintptr_t)orig_func_addr & (~(page_size - 1));
    mprotect((void *)page_start, page_size, PROT_READ | PROT_WRITE | PROT_EXEC);

    // 构造跳转指令
    unsigned char jump[8] = {0};
    jump[0] = 0x01;  // 跳转指令的机器码
    *(void **)(jump + 1) = my_malloc;  // 我们的钩子函数的地址

    // 将跳转指令写入目标函数的入口点
    memcpy(orig_func_addr, jump, sizeof(jump));
}

void unhook() {
    void *orig_func_addr = get_function_address("malloc");
    if (orig_func_addr == NULL) {
        LOGD("错误: 无法找到 'malloc' 函数的地址");
        return;
    }

    // 更改页面保护
    size_t page_size = sysconf(_SC_PAGESIZE);
    uintptr_t page_start = (uintptr_t)orig_func_addr & (~(page_size - 1));
    mprotect((void *)page_start, page_size, PROT_READ | PROT_WRITE | PROT_EXEC);

    // 将备份的机器码写入目标函数的入口点
    memcpy(orig_func_addr, backup, sizeof(backup));
}

在my_malloc中,我们需要先执行备份的指令,然后将执行流跳转回原始malloc函数的剩余部分:

这里有三个难点,下面详细解释一下:

● 如何修改内存页的保护属性

orig_func_addr & (~(page_size - 1)) 这段代码的作用是获取包含 orig_func_addr 地址的内存页的起始地址。这里使用了一个技巧:page_size 总是2的幂,因此 page_size - 1 的二进制表示形式是低位全为1,高位全为0,取反后低位全为0,高位全为1。将 orig_func_addr 与 ~(page_size - 1) 进行与操作,可以将 orig_func_addr 的低位清零,从而得到内存页的起始地址。

mprotect((void *)page_start, page_size, PROT_READ | PROT_WRITE | PROT_EXEC); 这行代码的作用是修改内存页的保护属性。mprotect 函数可以设置一块内存区域的保护属性,它接受三个参数:需要修改的内存区域的起始地址,内存区域的大小,以及新的保护属性。在这里,我们将包含 orig_func_addr 地址的内存页的保护属性设置为可读、可写、可执行(PROT_READ | PROT_WRITE | PROT_EXEC),以便我们可以修改这个内存页中的代码。

● 如何恢复原函数

想要恢复原来的函数,我们需要在Hook之前保存原来的机器码,然后在需要恢复时,将保存的机器码写回函数的入口点。

代码中的backup数组用于保存原始机器码。在inline_hook函数中,我们在修改机器码之前先将原始机器码复制到backup数组。然后,我们提供了一个unhook函数,用于恢复原始机器码。在需要恢复malloc函数时,可以调用unhook函数。

需要注意的是,这个示例假设函数的入口点的机器码长度是8字节。在实际使用时,你需要根据实际情况确定机器码的长度,并相应地调整backup数组的大小和memcpy函数的参数。

●如何实现指令重定位

我们以一个简单的ARM64汇编代码为例,演示如何进行指令重定位。假设我们有以下目标函数:

TargetFunction:
    mov x29, sp
    sub sp, sp, #0x10
    ; ... 其他指令 ...
    bl SomeFunction
    ; ... 其他指令 ...
    b TargetFunctionEnd

我们要在TargetFunction的开头插入一个跳转指令,将执行流跳转到我们的HookFunction。为了实现这一目标,我们需要进行以下操作:

b HookFunction

经过以上步骤,我们成功地在TargetFunction中插入了一个跳转到HookFunction的跳转指令,并对目标函数中的跳转和数据引用进行了重定位。这样,当执行到TargetFunction时,程序将跳转到HookFunction执行,并在执行完被覆盖的指令和其他自定义操作后,返回到目标函数的未被修改部分。

(2)PLT/GOT Hook实现

PLT(Procedure Linkage Table)和GOT(Global Offset Table)是Linux下动态链接库(shared libraries)中用于解析动态符号的两个重要表。

PLT(Procedure Linkage Table):过程链接表,用于存储动态链接库中函数的入口地址。当程序调用一个动态链接库中的函数时,首先会跳转到PLT中的对应条目,然后再通过GOT找到实际的函数地址并执行。

GOT(Global Offset Table):全局偏移表,用于存储动态链接库中函数和变量的实际地址。在程序运行时,动态链接器(dynamic linker)会根据需要将函数和变量的实际地址填充到GOT中。PLT中的条目会通过GOT来找到函数和变量的实际地址。

在PLT/GOT Hook中,我们可以修改GOT中的函数地址,使得程序在调用某个函数时实际上调用我们自定义的函数。这样,我们可以在自定义的函数中添加额外的逻辑(如检测内存泄漏),然后再调用原始的函数。这种方法可以实现对程序的无侵入式修改,而不需要重新编译程序。

#include 
#include 
#include 
#include 

#define TAG "NativeHook"
#define LOGD(...) __android_log_print(ANDROID_LOG_DEBUG, TAG, __VA_ARGS__)

typedef void* (*orig_malloc_func_type)(size_t size);

orig_malloc_func_type orig_malloc;

void* my_malloc(size_t size) {
    LOGD("Memory allocated: %zu bytes", size);
    return orig_malloc(size);
}

void plt_got_hook() {
    void **got_func_addr = (void **)dlsym(RTLD_DEFAULT, "malloc");
    if (got_func_addr == NULL) {
        LOGD("Error: Cannot find the GOT entry of 'malloc' function");
        return;
    }

    // Backup the original function
    orig_malloc = (orig_malloc_func_type)*got_func_addr;

    // Replace the GOT entry with the address of our hook function
    *got_func_addr = my_malloc;
}

上面代码中的RTLD_DEFAULT是一个特殊的句柄值,表示在当前进程已加载的所有动态链接库中查找符号。当使用RTLD_DEFAULT作为dlsym()的handle参数时,dlsym()会在当前进程已加载的所有动态链接库中查找指定的符号,而不仅仅是某个特定的动态链接库。

(3) 再看 Inline Hook 和 Got Hook 的区别

关键在于,两种 Native Hook 方式的实现中,dlsym返回的地址含义是不一样的:

① Inline Hook

void *get_function_address(const char *func_name) {
    void *handle = dlopen("libc.so", RTLD_NOW);
    ...
    void *func_addr = dlsym(handle, func_name);
    dlclose(handle);

    return func_addr;
}

void *orig_func_addr = get_function_address("malloc");
memcpy(orig_func_addr, jump, sizeof(jump));

dlsym 返回的地址是函数在内存中的实际地址,这个地址通常指向函数的入口点(即函数的第一条指令)。

 ②Got Hook

void **got_func_addr = (void **)dlsym(RTLD_DEFAULT, "malloc");
*got_func_addr = my_malloc;

dlsym 返回的是 malloc 函数在 GOT 中的地址,注意void **got_func_addr是双重指针。

2. 使用LD_PRELOAD

使用LD_PRELOAD的方式,可以在不修改源代码的情况下重载内存管理函数。虽然这种方式在Android平台上有很多限制,但是我们也可以了解下相关的原理。

LD_PRELOAD 是一个环境变量,用于在程序运行时预加载动态链接库。通过设置 LD_PRELOAD,我们可以在程序运行时强制加载指定的库,从而在不修改源代码的情况下改变程序的行为。这种方法通常用于调试、性能分析和内存泄漏检测等场景。

使用 LD_PRELOAD 检测内存泄漏的原理和方法如下:

(1) 原理

当设置了 LD_PRELOAD 环境变量时,程序会在加载其他库之前加载指定的库。这使得我们可以在自定义库中重载(override)一些原始库(如 glibc)中的函数。在内存泄漏检测的场景中,我们可以重载内存分配和释放函数(如 malloc、calloc、realloc 和 free),以便在分配和释放内存时记录相关信息。

(2) 方法:

通过使用 LD_PRELOAD 检测内存泄漏,我们可以在不修改程序源代码的情况下,动态地改变程序的行为,记录内存分配和释放的信息,从而检测到内存泄漏并找出内存泄漏的来源。

3. 小结

最后我们以一个表格总结一下本节的三种代理实现方式的优缺点:

二、检测Natie内存泄露

本节我们将基于PLT/GOT Hook的代理实现方案,介绍检测Native层内存泄漏的整体思路。

1. 原理介绍

在Android中,要检测Native层的内存泄漏,可以重写malloc、calloc、realloc和free等内存分配和释放函数,以便在每次分配和释放内存时记录相关信息。例如,我们可以创建一个全局的内存分配表,用于存储所有分配的内存块及其元数据(如分配大小、分配位置等)。然后,在释放内存时,从内存分配表中删除相应的条目。定期检查内存分配表,找出没有被释放的内存。

2. 代码示例

下面代码的主要技术原理是重写内存管理函数,并使用弱符号引用原始的内存管理函数,以便在每次分配和释放内存时记录相关信息,并能够在程序运行时动态地查找和调用这些函数。

以下是代码示例:

#include 
#include 
#include 
#include 
#include 
#include 
#include 
#include   

#define TAG "CheckMemoryLeaks" 
#define LOGD(...) __android_log_print(ANDROID_LOG_DEBUG, TAG, __VA_ARGS__)

// 全局内存分配表,存储分配的内存块及其元数据(如分配大小、调用栈等)
std::map>> g_memoryAllocations;
std::mutex g_memoryAllocationsMutex;

// 定义弱符号引用原始的内存管理函数
extern "C" void* __libc_malloc(size_t size) __attribute__((weak));
extern "C" void  __libc_free(void* ptr) __attribute__((weak));
extern "C" void* __libc_realloc(void *ptr, size_t size) __attribute__((weak));
extern "C" void* __libc_calloc(size_t nmemb, size_t size) __attribute__((weak));

void* (*lt_malloc)(size_t size);
void  (*lt_free)(void* ptr);
void* (*lt_realloc)(void *ptr, size_t size);
void* (*lt_calloc)(size_t nmemb, size_t size);

#define LT_MALLOC  (*lt_malloc)
#define LT_FREE    (*lt_free)
#define LT_REALLOC (*lt_realloc)
#define LT_CALLOC  (*lt_calloc)

// 在分配内存时记录调用栈
std::vector record_call_stack() {
  //  ...
}

// 初始化原始内存管理函数,如果弱符号未定义,则使用 dlsym 获取函数地址
void init_original_functions() {
  if (!lt_malloc) {
    if (__libc_malloc) {
      lt_malloc = __libc_malloc;
    } else {
      lt_malloc = (void*(*)(size_t))dlsym(RTLD_NEXT, "malloc");
    }
  }
  //calloc realloc free 的实现也类似
  ...
}

// 重写 malloc 函数
extern "C" void* malloc(size_t size) {
  // 初始化原始内存管理函数
  init_original_functions();

  // 调用原始的 malloc 函数
  void* ptr = LT_MALLOC(size);

  // 记录调用栈
  std::vector call_stack = record_call_stack();

  // 在全局内存分配表中添加新分配的内存块及其元数据
  std::unique_lock lock(g_memoryAllocationsMutex);
  g_memoryAllocations[ptr] = std::make_pair(size, call_stack);

  return ptr;
}

// 重写 calloc 函数
extern "C" void* calloc(size_t nmemb, size_t size) {
  // 跟 malloc 实现类似
  // ...
}

// 重写 realloc 函数
extern "C" void* realloc(void* ptr, size_t size) {
  // 初始化原始内存管理函数
  init_original_functions();

  // 调用原始的 realloc 函数
  void* newPtr = LT_REALLOC(ptr, size);

  // 记录调用栈
  std::vector call_stack = record_call_stack();
  
  // 更新全局内存分配表中的内存块及其元数据
  std::unique_lock lock(g_memoryAllocationsMutex);
  g_memoryAllocations.erase(ptr);
  g_memoryAllocations[newPtr] = std::make_pair(size, call_stack);

  return newPtr;
}

// 重写 free 函数
extern "C" void free(void* ptr) {
  // 初始化原始内存管理函数
  init_original_functions();

  // 从全局内存分配表中删除释放的内存块
  std::unique_lock lock(g_memoryAllocationsMutex);
  g_memoryAllocations.erase(ptr);

  // 调用原始的 free 函数
  LT_FREE(ptr);
}

// 定义一个函数用于检查内存泄漏
void check_memory_leaks() {
  // 使用互斥锁保护对全局内存分配表的访问,防止在多线程环境下发生数据竞争
  std::unique_lock lock(g_memoryAllocationsMutex);

  // 如果全局内存分配表为空,说明没有检测到内存泄漏
  if (g_memoryAllocations.empty()) {
    LOGD("No memory leaks detected.");
  } else {
    // 如果全局内存分配表不为空,说明检测到了内存泄漏
    LOGD("Memory leaks detected:");
    // 遍历全局内存分配表,打印出所有未被释放的内存块的地址和大小
    for (const auto& entry : g_memoryAllocations) {
      LOGD("  Address: %p, Size: %zu bytes\n", entry.first, entry.second.first);
      LOGD("  Call stack:");
      for (void* frame : entry.second.second) {
        LOGD("    %p\n", frame);
      }
    }
  }
}

int main() {
  // 初始化原始内存管理函数
  init_original_functions();
  
  // 示例代码
  void* ptr1 = malloc(10);
  void* ptr2 = calloc(10, sizeof(int));
  void* ptr3 = malloc(20);
  ptr3 = realloc(ptr3, 30);
  free(ptr1);
  free(ptr2);
  free(ptr3);

  // 检查内存泄漏
  check_memory_leaks();

  return 0;
}

上面代码的核心逻辑包括:

(1) 使用弱符号:防止对dlsym函数的调用导致无限递归

dlsym函数用于查找动态链接库中的符号。但是在glibc和eglibc中,dlsym函数内部可能会调用calloc函数。如果我们正在重定义calloc函数,并且在calloc函数中调用dlsym函数来获取原始的calloc函数,那么就会产生无限递归。

__libc_calloc等函数被声明为弱符号,这是为了避免与glibc或eglibc中对这些函数的强符号定义产生冲突。然后在init_original_functions函数中,我们检查了__libc_calloc等函数是否为nullptr。如果是,那么说明glibc或eglibc没有定义这些函数,那就使用dlsym函数获取这些函数的地址。如果不是,那么说明glibc或eglibc已经定义了这些函数,那就直接使用那些定义。

(2) 关于RTLD_NEXT的解释

RTLD_NEXT是一个特殊的“伪句柄”,用于在动态链接库函数中查找下一个符号。它常常与dlsym函数一起使用,用于查找和调用原始的(被覆盖或者被截获的)函数。

在Linux系统中,如果一个程序链接了多个动态链接库,而这些库中有多个定义了同名的函数,那么在默认情况下,程序会使用第一个找到的函数。但有时候,我们可能需要在一个库中覆盖另一个库中的函数,同时又需要调用原始的函数。这时候就可以使用RTLD_NEXT。

dlsym(RTLD_NEXT, "malloc")会查找下一个名为"malloc"的符号,即原始的malloc函数。然后我们就可以在自定义的malloc函数中调用原始的malloc函数了。

(3) 注意事项

检测内存泄漏可能会增加程序的运行时开销,并可能导致一些与线程安全相关的问题。在使用这种方法时,我们需要确保代码是线程安全的,并在不影响程序性能的情况下进行内存泄漏检测。同时,手动检测内存泄漏可能无法发现所有的内存泄漏,因此建议大家还要使用其他工具(如AddressSanitizer、LeakSanitizer或Valgrind)来辅助检测内存泄漏。

三、获取Android Native堆栈

大家可能也注意到了,在第二部分的Native内存泄露检测实现中,record_call_stack的实现省略了。所以我们还遗留了一个问题:应该如何记录分配内存时的调用栈呢?最后一节我们就来阐述获取Android Native堆栈的方法。

1. 使用unwind函数

(3) 工具和方法

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

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

(2) 获取当前线程的堆栈信息

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

#include 
#include 
#include 

// 定义一个结构体,用于存储回溯状态
struct BacktraceState {
    void** current;
    void** end;
};

// 回溯回调函数,用于处理每一帧的信息
_Unwind_Reason_Code unwind_callback(struct _Unwind_Context* context, void* arg) {
    BacktraceState* state = static_cast(arg);
    uintptr_t pc = _Unwind_GetIP(context);
    if (pc) {
        if (state->current == state->end) {
            return _URC_END_OF_STACK;
        } else {
            *state->current++ = reinterpret_cast(pc);
        }
    }
    return _URC_NO_REASON;
}

// 捕获回溯信息,将其存储到buffer中
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(reinterpret_cast(addr) - reinterpret_cast(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)。然后在打印堆栈信息时,同时打印出函数的相对地址。

(3) libunwind的相关接口

①  _Unwind_Backtrace

_Unwind_Backtrace是libunwind库的函数,用于获取当前线程调用堆栈。它遍历栈帧并在每个栈帧上调用用户定义的回调函数,以获取栈帧信息(如函数地址、参数等)。函数原型如下:

_Unwind_Reason_Code _Unwind_Backtrace(_Unwind_Trace_Fn trace, void *trace_argument);

参数:

②  _Unwind_GetIP

_Unwind_GetIP是libunwind库的函数,用于获取当前栈帧的指令指针(即当前函数的返回地址)。它依赖底层硬件架构(如ARM、x86等)和操作系统实现。函数原型如下:

uintptr_t _Unwind_GetIP(struct _Unwind_Context *context);

参数:

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

_Unwind_Backtrace和_Unwind_GetIP函数在libunwind库中定义,该库是GNU C Library(glibc)的一部分。但Android系统使用轻量级的C库Bionic libc,而非glibc。因此,这两个函数在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及更高版本中,可直接使用这两个函数获取堆栈信息。

尽管这两个函数在新版Android系统中可用,但它们的行为可能受编译器优化、调试信息等影响。实际使用中,我们需要根据具体情况选择最合适的方法。

2. 手动遍历栈帧来实现获取堆栈信息

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

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

(1) ARM64架构下的示例代码

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

#include 
#include 

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(lr), &info) && info.dli_sname) {
            printf("%p %s\n", reinterpret_cast(lr), info.dli_sname);
        } else {
            printf("%p\n", reinterpret_cast(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) ARM架构下的示例代码

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

#include 
#include 

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(lr), &info) && info.dli_sname) {
            printf("%p %s\n", reinterpret_cast(lr), info.dli_sname);
        } else {
            printf("%p\n", reinterpret_cast(lr));
        }

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

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

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

(3) 寄存器

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

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

(4) 栈帧

栈帧(Stack Frame)是函数调用过程中的一个重要概念。每次函数调用时,都会在栈上创建一个新的栈帧。栈帧包含了函数的局部变量、参数、返回地址以及其他一些与函数调用相关的信息。下图是一个标准的函数调用过程:

每次函数调用都会保存 EBP 和 EIP 用于在返回时恢复函数栈帧。这里所有被保存的 EBP 就像一个链表指针,不断地指向调用函数的 EBP。

在Android系统中,栈帧的基本原理与其他操作系统相同,通过SP和FP所限定的stack frame,就可以得到母函数的SP和FP,从而得到母函数的stack frame(PC,LR,SP,FP会在函数调用的第一时间压栈),以此追溯,即可得到所有函数的调用顺序。

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

(5) 名字修饰(Name Mangling)

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

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

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

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

四、实践建议

通过前文的详细介绍,我们已经了解了如何实现Android Native内存泄漏监控的三个方面:包括代理实现、检测Native内存泄露和获取Android Native堆栈的方法。最后,我们再来看一下现有的一些内存泄露检测工具对比,并给出一些实践建议。

1. Native 内存泄露检测工具对比

在实际应用中,我们需要根据具体场景选择最合适的方案。下面表格中的前三种工具都是现成的,但是具有一定的局限性,特别是不适合在线上使用。

2. 实践建议

在实际项目中,我们可以结合多种内存泄漏检测方案来提高检测效果。以下是一些建议:

五、总结

在开发和测试阶段,我们可以使用ASan、LSan和Valgrind等工具来检测内存泄漏。而在线上环境中,由于这些工具的性能开销较大,不适合直接使用。在这种情况下,我们可以采用手动检测的方法,结合代码审查和良好的编程习惯,来尽可能地减少内存泄漏的发生。

然而,这些工具并不能保证检测出所有的内存泄漏。内存泄漏的发现和修复,需要我们对代码有深入的理解,以及良好的编程习惯。只有这样,我们才能有效地防止和解决内存泄漏问题,从而提高我们的应用程序的稳定性和性能。

来源:腾讯技术工程内容投诉

免责声明:

① 本站未注明“稿件来源”的信息均来自网络整理。其文字、图片和音视频稿件的所属权归原作者所有。本站收集整理出于非商业性的教育和科研之目的,并不意味着本站赞同其观点或证实其内容的真实性。仅作为临时的测试数据,供内部测试之用。本站并未授权任何人以任何方式主动获取本站任何信息。

② 本站未注明“稿件来源”的临时测试数据将在测试完成后最终做删除处理。有问题或投稿请发送至: 邮箱/279061341@qq.com QQ/279061341

软考中级精品资料免费领

  • 历年真题答案解析
  • 备考技巧名师总结
  • 高频考点精准押题
  • 2024年上半年信息系统项目管理师第二批次真题及答案解析(完整版)

    难度     813人已做
    查看
  • 【考后总结】2024年5月26日信息系统项目管理师第2批次考情分析

    难度     354人已做
    查看
  • 【考后总结】2024年5月25日信息系统项目管理师第1批次考情分析

    难度     318人已做
    查看
  • 2024年上半年软考高项第一、二批次真题考点汇总(完整版)

    难度     435人已做
    查看
  • 2024年上半年系统架构设计师考试综合知识真题

    难度     224人已做
    查看

相关文章

发现更多好内容

猜你喜欢

AI推送时光机
位置:首页-资讯-后端开发
咦!没有更多了?去看看其它编程学习网 内容吧
首页课程
资料下载
问答资讯