本文將分三個部份闡述如何實作 Android Native 記憶體泄漏監控,包括代理實作的三種方案(Inline Hook、PLT/GOT Hook、LD_PRELOAD)及其優缺點,以及如何檢測Native記憶體泄露和獲取Android Native堆疊的方法。本文將提供一套比較全面的解決思路,幫助大家更好地檢測 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.1 Native Hook
1.1.1 方案對比:Inline Hook和PLT/GOT Hook
目前主要有兩種Native Hook方案:Inline Hook和PLT/GOT Hook。
指令重定位是指在電腦程式的連結和裝載過程中,對程式中的相對地址進行調整,使其指向正確的記憶體位置。這是因為程式在編譯時,無法預知在執行時會被裝載到記憶體的哪個位置,所以編譯後的程式中,往往使用相對地址來表示記憶體位置。然而在實際執行時,程式可能被裝載到記憶體的任何位置,因此需要在裝載過程中,根據程式實際被裝載到的記憶體地址,對程式中的所有相對地址進行調整,這個過程就叫做重定位。
在進行Inline Hook時,如果直接修改目標函式的機器碼,可能會改變原有的跳轉指令的相對地址,從而使程式跳轉到錯誤的位置,因此需要進行指令重定位,確保修改後的指令能正確地跳轉到預期的位置。
1.1.2 案例:在Android套用中Hook
malloc
函式
為了更好地理解Native Hook的套用場景,我們來看一個實際的案例:在Android套用中Hook
malloc
函式,以監控檔的開啟操作。
1.1.2.1 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__)
typedefvoid* (*orig_malloc_func_type)(size_t size);
orig_malloc_func_type orig_malloc;
unsignedchar 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函式的剩余部份
unsignedchar *jump = (unsignedchar *)orig_malloc_with_backup + sizeof(backup);
jump[0] = 0x01; // 跳轉指令的機器碼
*(void **)(jump + 1) = (unsignedchar *)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(constchar *func_name){
void *handle = dlopen("libc.so", RTLD_NOW);
if (!handle) {
LOGD("錯誤: %s", dlerror());
returnNULL;
}
void *func_addr = dlsym(handle, func_name);
dlclose(handle);
return func_addr;
}
voidinline_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);
// 構造跳轉指令
unsignedchar jump[8] = {0};
jump[0] = 0x01; // 跳轉指令的機器碼
*(void **)(jump + 1) = my_malloc; // 我們的勾點函式的地址
// 將跳轉指令寫入目標函式的入口點
memcpy(orig_func_addr, jump, sizeof(jump));
}
voidunhook(){
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
函式指標。
這裏有三個難點,下面詳細解釋一下。
####### 1.1.2.1.1 如何修改記憶體頁的保護內容
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
),以便我們可以修改這個記憶體頁中的程式碼。
####### 1.1.2.1.2 如何恢復原函式
想要恢復原來的函式,我們需要在Hook之前保存原來的機器碼,然後在需要恢復時,將保存的機器碼寫回函式的入口點。
程式碼中的
backup
陣列用於保存原始機器碼。在
inline_hook
函式中,我們在修改機器碼之前先將原始機器碼復制到
backup
陣列。然後,我們提供了一個
unhook
函式,用於恢復原始機器碼。在需要恢復
malloc
函式時,可以呼叫
unhook
函式。
需要註意的是,這個範例假設函式的入口點的機器碼長度是8字節。在實際使用時,你需要根據實際情況確定機器碼的長度,並相應地調整
backup
陣列的大小和
memcpy
函式的參數。
####### 1.1.2.1.3 如何實作指令重定位
我們以一個簡單的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
執行,並在執行完被覆蓋的指令和其他自訂操作後,返回到目標函式的未被修改部份。
1.1.2.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__)
typedefvoid* (*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);
}
voidplt_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()
會在當前行程已載入的所有動態連結庫中尋找指定的符號,而不僅僅是某個特定的動態連結庫。
1.1.2.3 再看 Inline Hook 和 Got Hook 的區別
關鍵在於,兩種 Native Hook 方式的實作中,dlsym返回的地址含義是不一樣的:
Inline Hook
void *get_function_address(constchar *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
是雙重指標。
1.2 使用
LD_PRELOAD
使用
LD_PRELOAD
的方式,可以在不修改原始碼的情況下多載記憶體管理函式。雖然這種方式在Android平台上有很多限制,但是我們也可以了解下相關的原理。
LD_PRELOAD
是一個環境變量,用於在程式執行時預載入動態連結庫。透過設定
LD_PRELOAD
,我們可以在程式執行時強制載入指定的庫,從而在不修改原始碼的情況下改變程式的行為。這種方法通常用於偵錯、效能分析和記憶體泄漏檢測等場景。
使用
LD_PRELOAD
檢測記憶體泄漏的原理和方法如下:
原理 :當設定了
LD_PRELOAD
環境變量時,程式會在載入其他庫之前載入指定的庫。這使得我們可以在自訂庫中多載(override)一些原始庫(如 glibc)中的函式。在記憶體泄漏檢測的場景中,我們可以多載記憶體分配和釋放函式(如malloc
、calloc
、realloc
和free
),以便在分配和釋放記憶體時記錄相關資訊。方法 :
a. 建立自訂庫 :首先,我們需要建立一個自訂記憶體泄露檢測庫,並在其中多載記憶體分配和釋放函式。在這些多載的函式中,我們可以呼叫原始的記憶體管理函式,並在分配記憶體時將記憶體塊及其相關資訊(如分配大小、呼叫棧等)添加到全域記憶體分配表中,在釋放記憶體時從全域記憶體分配表中刪除相應的記憶體塊。
b. 設定
LD_PRELOAD
環境變量 :在運行程式之前,我們需要設定LD_PRELOAD
環境變量,使其指向自訂庫的路徑。這樣,程式在執行時會優先載入自訂庫,從而使用多載的記憶體管理函式。c. 運行程式 :運行程式時,它將使用多載的記憶體管理函式,從而記錄記憶體分配和釋放的資訊。我們可以在程式執行過程中或執行結束後,檢查全域記憶體分配表中仍然存在的記憶體塊,從而檢測記憶體泄漏。
透過使用
LD_PRELOAD
檢測記憶體泄漏,我們可以在不修改程式原始碼的情況下,動態地改變程式的行為,記錄記憶體分配和釋放的資訊,從而檢測到記憶體泄漏並找出記憶體泄漏的來源。
1.3 小結
最後我們以一個表格總結一下本節的三種代理實作方式的優缺點:
二、檢測Natie記憶體泄露
本節我們將基於PLT/GOT Hook的代理實作方案,介紹檢測Native層記憶體泄漏的整體思路。
2.1 原理介紹
在Android中,要檢測Native層的記憶體泄漏,可以重寫
malloc
、
calloc
、
realloc
和
free
等記憶體分配和釋放函式,以便在每次分配和釋放記憶體時記錄相關資訊。例如,我們可以建立一個全域的記憶體分配表,用於儲存所有分配的記憶體塊及其後設資料(如分配大小、分配位置等)。然後,在釋放記憶體時,從記憶體分配表中刪除相應的條目。定期檢查記憶體分配表,找出沒有被釋放的記憶體。
2.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 獲取函式地址
voidinit_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"voidfree(void* ptr){
// 初始化原始記憶體管理函式
init_original_functions();
// 從全域記憶體分配表中刪除釋放的記憶體塊
std::unique_lock<std::mutex> lock(g_memoryAllocationsMutex);
g_memoryAllocations.erase(ptr);
// 呼叫原始的 free 函式
LT_FREE(ptr);
}
// 定義一個函式用於檢查記憶體泄漏
voidcheck_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 (constauto& 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);
}
}
}
}
intmain(){
// 初始化原始記憶體管理函式
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();
return0;
}
上面程式碼的核心邏輯包括:
重寫記憶體管理函式 :重寫
malloc
、calloc
、realloc
和free
,在分配記憶體時將記憶體塊及其資訊添加到全域記憶體分配表,釋放記憶體時從表中刪除相應記憶體塊。弱符號參照原始記憶體管理函式 :使用
__attribute__((weak))
定義四個弱符號參照glibc/eglibc中的記憶體管理函式。在init_original_functions
函式中檢查弱符號定義,若未定義則使用dlsym
函式尋找原始記憶體管理函式。全域記憶體分配表 :定義全域記憶體分配表儲存所有分配的記憶體塊及其資訊。表是一個map,鍵是記憶體塊地址,值是一個pair,包含記憶體塊大小和呼叫棧。
呼叫棧記錄 :分配記憶體時記錄當前呼叫棧,有助於檢測記憶體泄漏時找出泄漏來源。
記憶體泄漏檢測 :定義
check_memory_leaks
函式檢查全域記憶體分配表中仍存在的記憶體塊,表示存在記憶體泄漏。
2.2.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.2.2 關於RTLD_NEXT的解釋
RTLD_NEXT
是一個特殊的「偽控制代碼」,用於在動態連結庫函式中尋找下一個符號。它常常與
dlsym
函式一起使用,用於尋找和呼叫原始的(被覆蓋或者被截獲的)函式。
在Linux系統中,如果一個程式連結了多個動態連結庫,而這些庫中有多個定義了同名的函式,那麽在預設情況下,程式會使用第一個找到的函式。但有時候,我們可能需要在一個庫中覆蓋另一個庫中的函式,同時又需要呼叫原始的函式。這時候就可以使用
RTLD_NEXT
。
dlsym(RTLD_NEXT, "malloc")
會尋找下一個名為"malloc"的符號,即原始的
malloc
函式。然後我們就可以在自訂的
malloc
函式中呼叫原始的
malloc
函式了。
2.2.3 註意事項
檢測記憶體泄漏可能會增加程式的執行時開銷,並可能導致一些與執行緒安全相關的問題。在使用這種方法時,我們需要確保程式碼是執行緒安全的,並在不影響程式效能的情況下進行記憶體泄漏檢測。同時,手動檢測記憶體泄漏可能無法發現所有的記憶體泄漏,因此建議大家還要使用其他工具(如AddressSanitizer、LeakSanitizer或Valgrind)來輔助檢測記憶體泄漏。
三、獲取Android Native堆疊
大家可能也註意到了,在第二部份的Native記憶體泄露檢測實作中,
record_call_stack
的實作省略了。所以我們還遺留了一個問題:應該如何記錄分配記憶體時的呼叫棧呢?最後一節我們就來闡述獲取Android Native堆疊的方法。
3.1 使用
unwind
函式
3.1.1 工具和方法
對於Android系統,不能直接使用
backtrace_symbols
函式,因為它在Android Bionic libc中沒有實作。但是,我們可以使用
dladdr
函式替代
backtrace_symbols
來獲取符號資訊。
Android NDK提供了
unwind.h
表頭檔,其中定義了
unwind
函式,可以用於獲取任意執行緒的堆疊資訊。
3.1.2 獲取當前執行緒的堆疊資訊
如果我們需要獲取當前執行緒的堆疊資訊,可以使用Android NDK中的
unwind
函式。以下是使用
unwind
函式獲取堆疊資訊的範例程式碼:
#include<unwind.h>
#include<dlfcn.h>
#include<stdio.h>
// 定義一個結構體,用於儲存回溯狀態
structBacktraceState {
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中
voidcapture_backtrace(void** buffer, int max){
BacktraceState state = {buffer, buffer + max};
_Unwind_Backtrace(unwind_callback, &state);
}
// 打印回溯資訊
voidprint_backtrace(void** buffer, int count){
for (int idx = 0; idx < count; ++idx) {
constvoid* addr = buffer[idx];
constchar* 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);
}
}
// 主函式
intmain(){
constint max_frames = 128;
void* buffer[max_frames];
// 捕獲回溯資訊
capture_backtrace(buffer, max_frames);
// 打印回溯資訊
print_backtrace(buffer, max_frames);
return0;
}
在上述程式碼中,
capture_backtrace
函式使用
_Unwind_Backtrace
函式獲取堆疊資訊,然後我們使用
dladdr
函式獲取到函式所在的SO庫的基地址(
info.dli_fbase
),然後計算出函式的相對地址(
relative_addr
)。然後在打印堆疊資訊時,同時打印出函式的相對地址。
3.1.3 libunwind的相關介面
3.1.3.1 _Unwind_Backtrace
_Unwind_Backtrace
是libunwind庫的函式,用於獲取當前執行緒呼叫堆疊。它遍歷棧幀並在每個棧幀上呼叫使用者定義的回呼函式,以獲取棧幀資訊(如函式地址、參數等)。函式原型如下:
_Unwind_Reason_Code _Unwind_Backtrace(_Unwind_Trace_Fn trace, void *trace_argument);
參數:
trace
:回呼函式,會在每個堆疊幀上被呼叫。回呼函式需返回_Unwind_Reason_Code
型別值,表示執行結果。trace_argument
:使用者自訂參數,傳遞給回呼函式。通常用於儲存堆疊資訊或其他使用者數據。
3.1.3.2 _Unwind_GetIP
_Unwind_GetIP
是libunwind庫的函式,用於獲取當前棧幀的指令指標(即當前函式的返回地址)。它依賴底層硬體架構(如ARM、x86等)和作業系統實作。函式原型如下:
uintptr_t _Unwind_GetIP(struct _Unwind_Context *context);
參數:
context
:當前棧幀的上下文資訊。在
_Unwind_Backtrace
函式中建立並在每個棧幀上傳遞給回呼函式。
_Unwind_GetIP
返回無符號整數,表示當前函式的返回地址。可用此地址獲取函式的符號資訊,如函式名、原始檔名和行號等。
3.1.3.3 在不同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系統中可用,但它們的行為可能受編譯器最佳化、偵錯資訊等影響。實際使用中,我們需要根據具體情況選擇最合適的方法。
3.2 手動遍歷棧幀來實作獲取堆疊資訊
在Android系統中,
_Unwind_Backtrace
的具體實作依賴於底層硬體架構(例如ARM、x86等)和作業系統。它會使用特定於架構的寄存器和數據結構來遍歷棧幀。例如,在ARM64架構上,
_Unwind_Backtrace
會使用Frame Pointer(FP)寄存器和Link Register(LR)寄存器來遍歷棧幀。
如果不使用
_Unwind_Backtrace
,我們可以手動遍歷棧幀來實作獲取堆疊資訊。
3.2.1 ARM64架構下的範例程式碼
以下是一個基於ARM64架構的範例程式碼,展示如何使用Frame Pointer(FP)寄存器手動遍歷棧幀:
#include<stdio.h>
#include<dlfcn.h>
voidprint_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
變量,以便在下一次迴圈中處理上一個棧幀。
3.2.2 ARM架構下的範例程式碼
在ARM架構下,我們可以使用Frame Pointer(FP)寄存器(R11)和Link Register(LR)寄存器(R14)來手動遍歷棧幀。以下是一個基於ARM架構的範例程式碼,展示如何手動遍歷棧幀以獲取堆疊資訊:
#include<stdio.h>
#include<dlfcn.h>
voidprint_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.2.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則負責管理棧空間。在遍歷棧幀以獲取堆疊資訊時,我們需要利用這三個寄存器之間的關系來定位每個棧幀的位置和內容。
3.2.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寄存器中)以及其他相關資訊。
3.2.5 名字修飾(Name Mangling)
Native堆疊的符號資訊跟程式碼中定義的函式名字相比,可能會有一些差別,因為GCC生成的符號表有一些修飾規則。
C++支持函式多載,即同一個函式名可以有不同的參數型別和個數。為了在編譯時區分這些函式,GCC會對函式名進行修飾,生成獨特的符號名稱。修飾後的名稱包含了函式名、參數型別等資訊。例如,對於如下C++函式:
namespace test {
intfoo(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堆疊的方法。最後,我們再來看一下現有的一些記憶體泄露檢測工具對比,並給出一些實踐建議。
4.1 Native 記憶體泄露檢測工具對比
在實際套用中,我們需要根據具體場景選擇最合適的方案。下面表格中的前三種工具都是現成的,但是具有一定的局限性,特別是不適合線上上使用。
4.2 實踐建議
在實際計畫中,我們可以結合多種記憶體泄漏檢測方案來提高檢測效果。以下是一些建議:
編碼規範 :在編寫程式碼時,遵循一定的編碼規範和最佳實踐,例如使用智慧指標、避免迴圈參照等,可以有效地降低記憶體泄漏的風險。
程式碼審查 :在開發過程中,定期進行程式碼審查,檢查程式碼中是否存在潛在的記憶體泄漏風險。程式碼審查可以幫助我們及時發現和修復問題,提高程式碼品質。
自動化測試 :在計畫中引入自動化測試,對關鍵功能進行記憶體泄漏檢測。可以在持續整合環境中使用ASan、LSan等工具來檢測記憶體泄漏,確保新送出的程式碼不會引入新的記憶體泄漏問題。
效能監控 :線上上環境中,定期監控應用程式的記憶體使用情況。如果發現記憶體使用異常,可以使用手動檢測方法或者將問題反饋到開發環境,使用其他工具進行進一步分析和處理。
問題定位 :當發現記憶體泄漏問題時,根據工具提供的錯誤資訊,快速定位問題發生的位置。結合堆疊資訊、相對地址等,可以幫助我們更好地理解問題的原因,從而修復問題。
五、總結
在開發和測試階段,我們可以使用ASan、LSan和Valgrind等工具來檢測記憶體泄漏。而線上上環境中,由於這些工具的效能開銷較大,不適合直接使用。在這種情況下,我們可以采用手動檢測的方法,結合程式碼審查和良好的編程習慣,來盡可能地減少記憶體泄漏的發生。
然而,這些工具並不能保證檢測出所有的記憶體泄漏。記憶體泄漏的發現和修復,需要我們對程式碼有深入的理解,以及良好的編程習慣。只有這樣,我們才能有效地防止和解決記憶體泄漏問題,從而提高我們的應用程式的穩定性和效能。