Android Native內存泄漏檢測方案詳解
作者 | yeconglu
一個完整的 Android Native 內存泄漏檢測工具主要包含三部分:代理實現、堆棧回溯和緩存管理。代理實現是解決 Android 平臺上接入問題的關鍵部分,堆棧回溯則是性能和穩定性的核心要素。
本文會從三個方面介紹如何實現 Native 內存泄漏監控:
- 介紹代理實現的三個方案 Inline Hook、PLT/GOT Hook、LD_PRELOAD 的實現方式和優缺點。
- 介紹檢測 Android Native 內存泄露的基本思路和包含緩存邏輯的示例代碼。
- 介紹獲取 Android Native 堆棧的方法,用于記錄分配內存時的調用棧。
一、代理內存管理函數實現
首先我們來介紹一下代理內存管理函數實現的三個方案 :
- Inline Hook
- PLT/GOT Hook
- LD_PRELOAD
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 <stdio.h>
#include <dlfcn.h>
#include <unistd.h>
#include <string.h>
#include <sys/mman.h>
#include <android/log.h>
#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函數的剩余部分:
- 在my_malloc函數中,創建一個新的函數指針orig_malloc_with_backup,它指向一個新的內存區域,該區域包含備份的指令以及一個跳轉指令。
- 將備份的指令復制到新的內存區域。
- 在新的內存區域的末尾添加一個跳轉指令,使得執行流跳轉回原始malloc函數的剩余部分。
- 在my_malloc中,調用orig_malloc_with_backup函數指針。
這里有三個難點,下面詳細解釋一下:
● 如何修改內存頁的保護屬性
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。為了實現這一目標,我們需要進行以下操作:
- 備份被覆蓋的指令:我們需要備份TargetFunction開頭的指令,因為它們將被我們的跳轉指令覆蓋。在這個例子中,我們需要備份mov x29, sp和sub sp, sp, #0x10兩條指令。
- 插入跳轉指令:在TargetFunction的開頭插入一個跳轉到HookFunction的跳轉指令。在ARM64匯編中,我們可以使用b指令實現這一目標:
b HookFunction
- 處理被覆蓋的指令:在HookFunction中,我們需要執行被覆蓋的指令。在這個例子中,我們需要在HookFunction中執行mov x29, sp和sub sp, sp, #0x10兩條指令。
- 重定位跳轉和數據引用:在HookFunction中,我們需要處理目標函數中的跳轉和數據引用。在這個例子中,我們需要重定位bl SomeFunction和b TargetFunctionEnd兩條跳轉指令。根據目標函數在內存中的新地址,我們需要計算新的跳轉地址,并修改這兩條指令的操作數。
- 返回到目標函數:在HookFunction中執行完被覆蓋的指令和其他自定義操作后,我們需要返回到目標函數的未被修改部分。在這個例子中,我們需要在HookFunction的末尾添加一個跳轉指令,將執行流跳轉回TargetFunction的sub sp, sp, #0x10指令。
經過以上步驟,我們成功地在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 <stdio.h>
#include <dlfcn.h>
#include <unistd.h>
#include <android/log.h>
#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 環境變量:在運行程序之前,我們需要設置 LD_PRELOAD 環境變量,使其指向自定義庫的路徑。這樣,程序在運行時會優先加載自定義庫,從而使用重載的內存管理函數。
- 運行程序:運行程序時,它將使用重載的內存管理函數,從而記錄內存分配和釋放的信息。我們可以在程序運行過程中或運行結束后,檢查全局內存分配表中仍然存在的內存塊,從而檢測內存泄漏。
通過使用 LD_PRELOAD 檢測內存泄漏,我們可以在不修改程序源代碼的情況下,動態地改變程序的行為,記錄內存分配和釋放的信息,從而檢測到內存泄漏并找出內存泄漏的來源。
3. 小結
最后我們以一個表格總結一下本節的三種代理實現方式的優缺點:
二、檢測Natie內存泄露
本節我們將基于PLT/GOT Hook的代理實現方案,介紹檢測Native層內存泄漏的整體思路。
1. 原理介紹
在Android中,要檢測Native層的內存泄漏,可以重寫malloc、calloc、realloc和free等內存分配和釋放函數,以便在每次分配和釋放內存時記錄相關信息。例如,我們可以創建一個全局的內存分配表,用于存儲所有分配的內存塊及其元數據(如分配大小、分配位置等)。然后,在釋放內存時,從內存分配表中刪除相應的條目。定期檢查內存分配表,找出沒有被釋放的內存。
2. 代碼示例
下面代碼的主要技術原理是重寫內存管理函數,并使用弱符號引用原始的內存管理函數,以便在每次分配和釋放內存時記錄相關信息,并能夠在程序運行時動態地查找和調用這些函數。
以下是代碼示例:
#include <cstdlib>
#include <cstdio>
#include <map>
#include <mutex>
#include <dlfcn.h>
#include <execinfo.h>
#include <vector>
#include <android/log.h>
#define TAG "CheckMemoryLeaks"
#define LOGD(...) __android_log_print(ANDROID_LOG_DEBUG, TAG, __VA_ARGS__)
// 全局內存分配表,存儲分配的內存塊及其元數據(如分配大小、調用棧等)
std::map<void*, std::pair<size_t, std::vector<void*>>> 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<void*> 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<void*> call_stack = record_call_stack();
// 在全局內存分配表中添加新分配的內存塊及其元數據
std::unique_lock<std::mutex> 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<void*> call_stack = record_call_stack();
// 更新全局內存分配表中的內存塊及其元數據
std::unique_lock<std::mutex> 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<std::mutex> lock(g_memoryAllocationsMutex);
g_memoryAllocations.erase(ptr);
// 調用原始的 free 函數
LT_FREE(ptr);
}
// 定義一個函數用于檢查內存泄漏
void check_memory_leaks() {
// 使用互斥鎖保護對全局內存分配表的訪問,防止在多線程環境下發生數據競爭
std::unique_lock<std::mutex> 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;
}
上面代碼的核心邏輯包括:
- 重寫內存管理函數:重寫malloc、calloc、realloc和free,在分配內存時將內存塊及其信息添加到全局內存分配表,釋放內存時從表中刪除相應內存塊。
- 弱符號引用原始內存管理函數:使用__attribute__((weak))定義四個弱符號引用glibc/eglibc中的內存管理函數。在init_original_functions函數中檢查弱符號定義,若未定義則使用dlsym函數查找原始內存管理函數。
- 全局內存分配表:定義全局內存分配表存儲所有分配的內存塊及其信息。表是一個map,鍵是內存塊地址,值是一個pair,包含內存塊大小和調用棧。
- 調用棧記錄:分配內存時記錄當前調用棧,有助于檢測內存泄漏時找出泄漏來源。
- 內存泄漏檢測:定義check_memory_leaks函數檢查全局內存分配表中仍存在的內存塊,表示存在內存泄漏。
(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 <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;
}
// 捕獲回溯信息,將其存儲到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<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)。然后在打印堆棧信息時,同時打印出函數的相對地址。
(3) libunwind的相關接口
① _Unwind_Backtrace
_Unwind_Backtrace是libunwind庫的函數,用于獲取當前線程調用堆棧。它遍歷棧幀并在每個棧幀上調用用戶定義的回調函數,以獲取棧幀信息(如函數地址、參數等)。函數原型如下:
_Unwind_Reason_Code _Unwind_Backtrace(_Unwind_Trace_Fn trace, void *trace_argument);
參數:
- trace:回調函數,會在每個堆棧幀上被調用。回調函數需返回_Unwind_Reason_Code類型值,表示執行結果。
- trace_argument:用戶自定義參數,傳遞給回調函數。通常用于存儲堆棧信息或其他用戶數據。
② _Unwind_GetIP
_Unwind_GetIP是libunwind庫的函數,用于獲取當前棧幀的指令指針(即當前函數的返回地址)。它依賴底層硬件架構(如ARM、x86等)和操作系統實現。函數原型如下:
uintptr_t _Unwind_GetIP(struct _Unwind_Context *context);
參數:
- context:當前棧幀的上下文信息。在_Unwind_Backtrace函數中創建并在每個棧幀上傳遞給回調函數。
- _Unwind_GetIP返回無符號整數,表示當前函數的返回地址。可用此地址獲取函數的符號信息,如函數名、源文件名和行號等。
③ 在不同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 <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) 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的情況下獲取堆棧信息的方式,有助于我們更好地理解和調試程序。
(3) 寄存器
在函數調用過程中,fp(Frame Pointer,幀指針)、lr(Link Register,鏈接寄存器)和sp(Stack Pointer,棧指針)是三個關鍵寄存器,它們之間的關系如下:
- fp(Frame Pointer):幀指針寄存器用于指向當前棧幀的幀基址。在函數調用過程中,每個函數都會有一個棧幀,用于存儲函數的局部變量、參數、返回地址等信息。fp寄存器有助于定位和訪問這些信息。在不同的架構中,fp寄存器可能有不同的名稱,例如,在ARM64架構中,fp寄存器對應X29;在ARM架構中,fp寄存器對應R11;在x86_64架構中,fp寄存器對應RBP。
- lr(Link Register):鏈接寄存器用于保存函數的返回地址。當一個函數被調用時,程序需要知道在函數執行完畢后返回到哪里繼續執行。這個返回地址就被保存在lr寄存器中。在不同的架構中,lr寄存器可能有不同的名稱,例如,在ARM64架構中,lr寄存器對應X30;在ARM架構中,lr寄存器對應R14;在x86_64架構中,返回地址通常被保存在棧上,而不是專用寄存器中。
- sp(Stack Pointer):棧指針寄存器用于指向當前棧幀的棧頂。在函數調用過程中,棧指針會根據需要分配或釋放棧空間。在不同的架構中,sp寄存器可能有不同的名稱,例如,在ARM64架構中,sp寄存器對應XSP;在ARM架構中,sp寄存器對應R13;在x86_64架構中,sp寄存器對應RSP。
fp、lr和sp三者在函數調用過程中共同協作,以實現正確的函數調用和返回。fp用于定位棧幀中的數據,lr保存函數的返回地址,而sp則負責管理棧空間。在遍歷棧幀以獲取堆棧信息時,我們需要利用這三個寄存器之間的關系來定位每個棧幀的位置和內容。
(4) 棧幀
棧幀(Stack Frame)是函數調用過程中的一個重要概念。每次函數調用時,都會在棧上創建一個新的棧幀。棧幀包含了函數的局部變量、參數、返回地址以及其他一些與函數調用相關的信息。下圖是一個標準的函數調用過程:
- EBP:基址指針寄存器,指向棧幀的底部。在 ARM 下寄存器為 R11。在 ARM64 中寄存器為 X29。
- ESP:棧指針寄存器,指向棧幀的棧頂 , 在 ARM 下寄存器為 R13。
- EIP:指令寄存器,存儲的是 CPU 下次要執行的指令的地址,ARM 下為 PC,寄存器為 R15。
每次函數調用都會保存 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,其中:
- _ZN和E是修飾前綴和后綴,用于標識這是一個C++符號。
- 4test表示命名空間名為test,4表示命名空間名的長度。
- 3foo表示函數名為foo,3表示函數名的長度。
- id表示函數的參數類型,i代表int,d代表double。
四、實踐建議
通過前文的詳細介紹,我們已經了解了如何實現Android Native內存泄漏監控的三個方面:包括代理實現、檢測Native內存泄露和獲取Android Native堆棧的方法。最后,我們再來看一下現有的一些內存泄露檢測工具對比,并給出一些實踐建議。
1. Native 內存泄露檢測工具對比
在實際應用中,我們需要根據具體場景選擇最合適的方案。下面表格中的前三種工具都是現成的,但是具有一定的局限性,特別是不適合在線上使用。
2. 實踐建議
在實際項目中,我們可以結合多種內存泄漏檢測方案來提高檢測效果。以下是一些建議:
- 編碼規范:在編寫代碼時,遵循一定的編碼規范和最佳實踐,例如使用智能指針、避免循環引用等,可以有效地降低內存泄漏的風險。
- 代碼審查:在開發過程中,定期進行代碼審查,檢查代碼中是否存在潛在的內存泄漏風險。代碼審查可以幫助我們及時發現和修復問題,提高代碼質量。
- 自動化測試:在項目中引入自動化測試,對關鍵功能進行內存泄漏檢測。可以在持續集成環境中使用ASan、LSan等工具來檢測內存泄漏,確保新提交的代碼不會引入新的內存泄漏問題。
- 性能監控:在線上環境中,定期監控應用程序的內存使用情況。如果發現內存使用異常,可以使用手動檢測方法或者將問題反饋到開發環境,使用其他工具進行進一步分析和處理。
- 問題定位:當發現內存泄漏問題時,根據工具提供的錯誤信息,快速定位問題發生的位置。結合堆棧信息、相對地址等,可以幫助我們更好地理解問題的原因,從而修復問題。
五、總結
在開發和測試階段,我們可以使用ASan、LSan和Valgrind等工具來檢測內存泄漏。而在線上環境中,由于這些工具的性能開銷較大,不適合直接使用。在這種情況下,我們可以采用手動檢測的方法,結合代碼審查和良好的編程習慣,來盡可能地減少內存泄漏的發生。
然而,這些工具并不能保證檢測出所有的內存泄漏。內存泄漏的發現和修復,需要我們對代碼有深入的理解,以及良好的編程習慣。只有這樣,我們才能有效地防止和解決內存泄漏問題,從而提高我們的應用程序的穩定性和性能。