目录
1. 朋友,了解一下Linux的内存工作原理吧!
1.1. 这张图展示的是一个Linux进程的虚拟内存结构
2. 内存分配与回收:让你的程序跑得更稳健
2.1. 内存分配与内存泄漏
3. 内存泄漏检测代码分析
3.1. 预处理宏替换方法
3.2. 动态链接库挂钩方法
3.3. 代码具体解析
4. 动态链接库挂钩的具体操作
4.1. 创建一个动态链接库用于挂钩 malloc 和 free
4.2. 编写主程序
4.3. 运行程序并加载挂钩库
5. 两种方法的综合比较
6. 总结与建议
1. 朋友,了解一下Linux的内存工作原理吧!
朋友,你有没有遇到过这样的情况?你的Linux程序运行得挺顺利,但突然间发现内存占用飙升,系统变得卡顿,甚至有时候还会崩溃?别担心,今天我们就来聊聊Linux内存的工作原理,以及如何管理和监控内存,防止那些讨厌的内存泄漏。听起来有点复杂?别急,我会用最简单的方式,帮你把这些概念搞清楚。
1.1. 这张图展示的是一个Linux进程的虚拟内存结构
首先,让我们看看一个Linux进程的虚拟内存结构。内存被划分为多个区域,每个区域都有不同的用途。从下到上依次是:
-
代码段 (.text):
- 这里保存了程序的可执行代码。当程序加载时,代码从磁盘读入并开始执行。
- 所有进程共享相同的代码,通常是只读的,确保了代码的安全性和一致性。
-
已初始化数据段 (.data):
- 这个区域存放已初始化的全局变量和静态变量。
- 比如说,你在程序中定义的全局数组或静态计数器,就会被存放在这里。
-
未初始化数据段 (.bss):
- 这里保存未初始化的全局变量和静态变量,初始值为0。
- 运行时会动态分配内存给这些变量,确保它们在使用前被正确初始化。
-
运行时堆 (heap):
- 堆区是用来动态分配内存的地方。每当你调用
malloc
、calloc
或realloc
时,内存就会从这里分配出来。 brk
指向堆的顶部,随着堆的扩展而增长。- 内存泄漏问题通常发生在这里。如果程序分配了内存却没有正确释放,堆区会不断增大,导致内存资源浪费。
- 堆区是用来动态分配内存的地方。每当你调用
-
共享库的内存映射区域:
- 这个区域用来存放共享库(比如
.so
文件)的内存映射。 - 多个进程可以共享相同的内存区域,节省了系统资源。
- 这个区域用来存放共享库(比如
-
用户栈 (stack):
- 栈区用来存储函数调用时的局部变量和函数参数。
- 每次函数调用都会在栈中压入一个新的帧,函数返回时弹出这个帧。
- 栈是从高地址向低地址增长的,内存分配是自动的,由系统管理。
-
物理内存、内核代码和数据:
- 这是系统保留的区域,包括物理内存和与进程相关的数据结构(如页表、任务描述符等)。
- 这些区域对用户进程是不可见的,确保了系统的稳定性和安全性。
-
内核虚拟内存:
- 这个区域保存了与内核相关的虚拟内存,供系统操作使用。
- 它与用户空间是分离的,保证了内核操作的高效和安全。
2. 内存分配与回收:让你的程序跑得更稳健
内存分配与回收是程序运行过程中至关重要的部分。合理的内存管理不仅能提升程序的性能,还能防止那些讨厌的内存泄漏问题。让我们来看看内存分配与回收的基本流程吧!
2.1. 内存分配与内存泄漏
堆区是内存分配的核心。程序员可以通过调用 malloc
、calloc
或 realloc
等函数,动态地分配和释放内存。然而,问题就出在这里——如果分配了内存却没有释放,或者在程序退出时忘记释放内存,这就会导致内存泄漏。
内存泄漏的危害:
- 内存堆逐渐增大:未释放的内存会让堆区不断膨胀,系统的可用内存减少。
- 性能下降:内存资源被浪费,程序运行效率降低。
- 系统崩溃:长时间运行的程序可能耗尽系统内存,导致程序或整个系统崩溃。
相比之下,栈区的内存分配是自动的,由系统管理。每次函数调用时分配内存,函数返回时自动释放。因此,栈区不会出现内存泄漏的问题。但要注意,栈溢出也是一个潜在的问题,尤其是在递归调用过多的情况下。
3. 内存泄漏检测代码分析
要确保你的程序不会出现内存泄漏,检测和监控内存的分配与释放是必不可少的。代码实现了两种内存监控方式,帮助我们检测内存泄漏:
- 预处理宏替换(通过宏定义替换
malloc
和free
) - 动态链接库挂钩(通过
dlsym
挂钩malloc
和free
)
让我们一起来看看这两种方法是如何工作的,以及它们各自的优缺点。
3.1. 预处理宏替换方法
实现原理:
通过预处理宏,将代码中的 malloc
和 free
函数替换为自定义的 nMalloc
和 nFree
函数。这种方法在编译阶段完成替换,所有对 malloc
和 free
的调用都会被替换为带有额外参数(如文件名、函数名、行号)的自定义函数。
代码片段:
#if 0void *nMalloc(size_t size, const char *filename, const char *funcname, int line) {void *ptr = malloc(size);char buff[128] = {0};snprintf(buff, 128, "./block/%p.mem", ptr);FILE* fp = fopen(buff, "w");if (!fp) {free(ptr);return NULL;}fprintf(fp, "[+][%s:%s:%d] %p: %ld malloc\n", filename, funcname, line, ptr, size);fflush(fp);fclose(fp);return ptr;
}void nFree(void *ptr, const char *filename, const char *funcname, int line) {char buff[128] = {0};snprintf(buff, 128, "./block/%p.mem", ptr);if (unlink(buff) < 0) { // 文件不存在printf("double free: %p at %s:%s:%d\n", ptr, filename, funcname, line);return;}return free(ptr);
}#define malloc(size) nMalloc(size, __FILE__, __func__, __LINE__)
#define free(ptr) nFree(ptr, __FILE__, __func__, __LINE__)#endif
工作流程:
-
替换调用:
- 所有代码中的
malloc(size)
被替换为nMalloc(size, __FILE__, __func__, __LINE__)
。 - 所有代码中的
free(ptr)
被替换为nFree(ptr, __FILE__, __func__, __LINE__)
。
- 所有代码中的
-
记录分配:
nMalloc
调用原始的malloc
分配内存。- 创建一个以指针地址命名的文件(例如
./block/0x7f8c8b.mem
),记录分配信息(包括文件名、函数名、行号、指针地址和分配大小)。
-
记录释放:
nFree
尝试删除对应的.mem
文件。- 如果文件不存在,说明可能存在双重释放或释放未分配的内存,输出警告信息。
-
内存泄漏检测:
- 程序结束后,检查
./block/
目录中是否存在未删除的.mem
文件,这些文件对应未释放的内存块,表示存在内存泄漏。
- 程序结束后,检查
优缺点:
-
优点:
- 实现简单,易于理解和维护。
- 能够在编译时捕获内存分配和释放的调用,记录详细的源代码位置信息,有助于定位内存泄漏的源头。
-
缺点:
- 需要修改源码,并且在所有使用
malloc
和free
的地方生效。 - 无法监控通过第三方库分配的内存,因为这些库的代码不会被预处理宏替换。
- 性能开销较大,频繁的文件操作会显著降低程序的运行效率。
- 不适用于多线程环境,缺乏线程安全机制。
- 需要修改源码,并且在所有使用
3.2. 动态链接库挂钩方法
实现原理:
利用动态链接库的特性,通过 dlsym
函数获取原始的 malloc
和 free
地址,重定义这两个函数,实现函数挂钩(Hooking)。这种方法不需要修改源代码,可以在运行时拦截所有对 malloc
和 free
的调用,包括第三方库。
代码片段:
// 钩子函数部分typedef void *(*malloc_t)(size_t size);
typedef void (*free_t)(void *ptr);malloc_t malloc_f = NULL;
free_t free_f = NULL;int enable_malloc = 1;
int enable_free = 1;// 将地址转换为符号
void *TranslateToSymbol(void *addr) {Dl_info info;struct link_map *link;dladdr1(addr, &info, (void *)&link, RTLD_DL_LINKMAP);return (void *)(addr - link->l_addr);
}// 重定义 malloc
void *malloc(size_t size) {if (!malloc_f) {malloc_f = (malloc_t)dlsym(RTLD_NEXT, "malloc");}void *ptr = NULL;if (enable_malloc) {enable_malloc = 0;ptr = malloc_f(size);void *caller = __builtin_return_address(0);char buff[128] = {0};snprintf(buff, 128, "./block/%p.mem", ptr);FILE* fp = fopen(buff, "w");if (!fp) {free_f(ptr);return NULL;}fprintf(fp, "[+][%p] %p: %ld malloc\n", TranslateToSymbol(caller), ptr, size);fflush(fp);enable_malloc = 1;} else {ptr = malloc_f(size);}return ptr;
}// 重定义 free
void free(void *ptr) {if (!free_f) {free_f = (free_t)dlsym(RTLD_NEXT, "free");}char buff[128] = {0};snprintf(buff, 128, "./block/%p.mem", ptr);if (unlink(buff) < 0) { // 文件不存在printf("double free or invalid free: %p\n", ptr);return;}return free_f(ptr);
}
工作流程:
-
获取原始函数地址:
- 使用
dlsym(RTLD_NEXT, "malloc")
获取原始的malloc
函数地址,并存储在malloc_f
中。 - 使用
dlsym(RTLD_NEXT, "free")
获取原始的free
函数地址,并存储在free_f
中。 RTLD_NEXT
确保获取的是下一个符号,即原始的标准库函数,避免递归调用。
- 使用
-
重定义
malloc
:- 在重定义的
malloc
函数中,调用原始的malloc
分配内存。 - 获取调用者的返回地址
caller
,通过__builtin_return_address(0)
实现。 - 使用
TranslateToSymbol
将调用地址转换为符号地址,便于定位调用源。 - 创建一个以指针地址命名的
.mem
文件,记录分配信息(包括调用符号、指针地址和分配大小)。
- 在重定义的
-
重定义
free
:- 在重定义的
free
函数中,尝试删除对应的.mem
文件。 - 如果文件不存在,说明可能存在双重释放或释放未分配的内存,输出警告信息。
- 如果文件存在,调用原始的
free
释放内存。
- 在重定义的
-
内存泄漏检测:
- 程序运行结束后,检查
./block/
目录中是否存在未删除的.mem
文件,这些文件对应未释放的内存块,表示存在内存泄漏。
- 程序运行结束后,检查
优缺点:
-
优点:
- 无侵入性:无需修改源代码,通过动态链接库即可拦截所有对
malloc
和free
的调用,包括第三方库。 - 全面性:能够监控整个进程中的内存分配和释放,覆盖所有模块。
- 灵活性:可以在运行时启用或禁用监控,适用于多种运行环境。
- 无侵入性:无需修改源代码,通过动态链接库即可拦截所有对
-
缺点:
- 复杂性:实现较为复杂,需要处理函数挂钩、符号解析等问题。
- 性能开销:同样存在频繁的文件操作带来的性能影响,尤其是在高频率的内存分配和释放场景下。
- 多线程安全:当前实现缺乏线程同步机制,在多线程环境中可能出现竞态条件,导致数据不一致或文件操作错误。
- 错误处理:对一些异常情况(如内存分配失败、文件操作失败等)的处理较为简单,可能需要更健壮的错误处理机制。
3.3. 代码具体解析
让我们深入了解一下代码中的关键部分,看看这些函数是如何协同工作的。
#define _GNU_SOURCE
#include <dlfcn.h>
#include <link.h>
#include <stdlib.h>
#include <stdio.h>
#include <unistd.h>// #define malloc(size) nMalloc(size, __FILE__, __func__, __LINE__)
// #define free(ptr) nFree(ptr, __FILE__, __func__, __LINE__)// 预处理宏替换部分(被 #if 0 包含,未启用)#if 0
// ...预处理宏替换的实现...
#else
// 动态链接库挂钩部分typedef void *(*malloc_t)(size_t size);
typedef void (*free_t)(void *ptr);malloc_t malloc_f = NULL;
free_t free_f = NULL;int enable_malloc = 1;
int enable_free = 1;// 将地址转换为符号
void *TranslateToSymbol(void *addr) {Dl_info info;struct link_map *link;dladdr1(addr, &info, (void *)&link, RTLD_DL_LINKMAP);return (void *)(addr - link->l_addr);
}// 重定义 malloc
void *malloc(size_t size) {if (!malloc_f) {malloc_f = (malloc_t)dlsym(RTLD_NEXT, "malloc");}void *ptr = NULL;if (enable_malloc) {enable_malloc = 0;ptr = malloc_f(size);void *caller = __builtin_return_address(0);char buff[128] = {0};snprintf(buff, 128, "./block/%p.mem", ptr);FILE* fp = fopen(buff, "w");if (!fp) {malloc_f(ptr); // 应该使用 malloc_f 而不是 free_freturn NULL;}fprintf(fp, "[+][%p] %p: %ld malloc\n", TranslateToSymbol(caller), ptr, size);fflush(fp);enable_malloc = 1;} else {ptr = malloc_f(size);}return ptr;
}// 重定义 free
void free(void *ptr) {if (!free_f) {free_f = (free_t)dlsym(RTLD_NEXT, "free");}char buff[128] = {0};snprintf(buff, 128, "./block/%p.mem", ptr);if (unlink(buff) < 0) { // 文件不存在printf("double free or invalid free: %p\n", ptr);return;}return free_f(ptr);
}#endifint main() {size_t size = 5;void *p1 = malloc(size);void *p2 = malloc(size * 2);void *p3 = malloc(size * 3);free(p1);free(p3);
}
关键部分解析:
-
宏定义部分:
#if 0
包含了预处理宏替换的方法,该部分代码被禁用。#else
部分启用了动态链接库挂钩的方法。
-
函数指针定义:
- 定义了函数指针
malloc_f
和free_f
,用于存储原始的malloc
和free
函数地址。
- 定义了函数指针
-
TranslateToSymbol
函数:- 使用
dladdr1
将调用地址转换为符号地址,以便记录调用源。 dladdr1
返回包含符号信息的Dl_info
结构和链接映射link_map
。
- 使用
-
重定义
malloc
函数:- 检查
malloc_f
是否已初始化,若未初始化,则使用dlsym
获取原始的malloc
函数地址。 - 使用
__builtin_return_address(0)
获取调用者的返回地址。 - 调用原始的
malloc
分配内存。 - 创建一个以指针地址命名的
.mem
文件,记录分配信息(调用符号、指针地址、分配大小)。 - 通过
enable_malloc
标志防止递归调用malloc
(因为fopen
可能内部调用malloc
)。
- 检查
-
重定义
free
函数:- 检查
free_f
是否已初始化,若未初始化,则使用dlsym
获取原始的free
函数地址。 - 尝试删除对应的
.mem
文件。 - 如果文件不存在,输出双重释放或无效释放的警告。
- 调用原始的
free
释放内存。
- 检查
-
主函数:
- 分配了三块内存
p1
、p2
、p3
,并释放了p1
和p3
。 - 运行结束后,
./block/
目录中应存在p2
对应的.mem
文件,提示内存泄漏。
- 分配了三块内存
4. 动态链接库挂钩的具体操作
为了更清晰地理解“无需修改源码,只需在运行时加载自定义的动态链接库”,让我们通过一个实际的例子来说明。
4.1. 创建一个动态链接库用于挂钩 malloc
和 free
文件名:memhook.c
#define _GNU_SOURCE
#include <dlfcn.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>// 定义函数指针类型
typedef void *(*malloc_t)(size_t size);
typedef void (*free_t)(void *ptr);// 存储原始函数地址
malloc_t original_malloc = NULL;
free_t original_free = NULL;// 重定义 malloc
void *malloc(size_t size) {if (!original_malloc) {original_malloc = (malloc_t)dlsym(RTLD_NEXT, "malloc");if (!original_malloc) {fprintf(stderr, "Error in `dlsym`: %s\n", dlerror());exit(EXIT_FAILURE);}}void *ptr = original_malloc(size);printf("malloc(%zu) = %p\n", size, ptr); // 监控输出return ptr;
}// 重定义 free
void free(void *ptr) {if (!original_free) {original_free = (free_t)dlsym(RTLD_NEXT, "free");if (!original_free) {fprintf(stderr, "Error in `dlsym`: %s\n", dlerror());exit(EXIT_FAILURE);}}printf("free(%p)\n", ptr); // 监控输出original_free(ptr);
}
编译动态链接库:
gcc -shared -fPIC -o memhook.so memhook.c -ldl
4.2. 编写主程序
文件名:main.c
#include <stdlib.h>
#include <stdio.h>int main() {size_t size1 = 10;size_t size2 = 20;void *p1 = malloc(size1);void *p2 = malloc(size2);free(p1);free(p2);return 0;
}
编译主程序:
gcc -o main main.c
4.3. 运行程序并加载挂钩库
使用 LD_PRELOAD
加载挂钩库:
LD_PRELOAD=./memhook.so ./main
输出:
malloc(10) = 0x55f8d6b0e2a0
malloc(20) = 0x55f8d6b0e2c0
free(0x55f8d6b0e2a0)
free(0x55f8d6b0e2c0)
运行时加载动态链接库是指在程序启动或运行过程中,通过操作系统的动态链接机制加载额外的库文件,这些库文件可以覆盖或扩展现有的函数实现。
LD_PRELOAD
环境变量:- 这是一个在程序启动时告诉动态链接器(
ld.so
)优先加载某些动态链接库的机制。 - 当你设置
LD_PRELOAD=./memhook.so
时,memhook.so
中的函数会被优先加载和绑定。 - 如果
memhook.so
中定义了与标准库相同的函数(如malloc
和free
),它们会覆盖标准库中的实现。
- 这是一个在程序启动时告诉动态链接器(
操作步骤:
- 编写并编译动态链接库:包含你想要挂钩的函数实现。
- 使用
LD_PRELOAD
:在运行程序时,通过环境变量指定要预加载的动态链接库。 - 程序运行时:动态链接器按顺序加载库,优先加载
LD_PRELOAD
指定的库,从而拦截和替换标准库函数。
5. 两种方法的综合比较
让我们来做一个小对比,看看预处理宏替换方法和动态链接库挂钩方法各自的特点:
特性 | 预处理宏替换方法 | 动态链接库挂钩方法 |
---|---|---|
实现方式 | 编译时通过宏替换 malloc 和 free | 运行时通过动态链接库挂钩 malloc 和 free |
代码侵入性 | 需要修改源码,所有 malloc 和 free 调用都被替换 | 无需修改源码,适用于现有二进制文件 |
覆盖范围 | 仅覆盖被宏替换的代码,无法监控第三方库 | 全局覆盖,监控所有 malloc 和 free 调用 |
性能开销 | 高,频繁的文件操作和宏替换带来的开销 | 高,同样存在频繁的文件操作带来的开销 |
多线程支持 | 缺乏线程安全机制 | 缺乏线程安全机制 |
灵活性 | 需要重新编译代码,灵活性较低 | 高,可以在不修改源码的情况下进行监控 |
易用性 | 实现相对简单,但需要源码支持 | 实现较为复杂,但不需要源码支持 |
错误处理 | 简单,依赖文件系统操作 | 复杂,需要处理动态链接库的相关问题 |
扩展性 | 受限于宏定义的功能 | 更易扩展,可以添加更多的监控逻辑 |
6. 总结与建议
通过今天的分享,我们深入了解了Linux内存的工作原理、内存分配与回收的基本流程,以及如何通过两种不同的方法来检测内存泄漏。每种方法都有其独特的优势和不足,关键在于根据你的具体需求来选择最合适的方案。
综合建议:
-
根据需求选择方法:
- 如果你需要详细追踪特定模块或源码级的内存操作,且能够修改源码,预处理宏替换方法是一个不错的选择。
- 如果你需要对整个进程进行全面监控,且无法修改源码,动态链接库挂钩方法更为适合。
-
优化实现:
- 减少文件操作的频率:采用内存数据结构记录内存分配和释放信息,提升性能。
- 引入线程同步机制:确保在多线程环境下的安全性,避免竞态条件。
- 自动生成内存泄漏报告:在程序结束时自动扫描未释放的内存块,生成详细的报告,提升使用体验。
-
结合使用成熟工具:
- Valgrind、AddressSanitizer 等成熟的内存检测工具功能更强大,经过广泛验证,能够提供更准确和全面的内存分析报告。
- 在开发和测试阶段,结合使用这些工具,可以大大提升内存管理的准确性和效率,帮助你更快地定位和解决内存问题。
-
代码维护与扩展:
- 确保代码的可维护性:处理各种异常情况,提升监控系统的稳定性和可靠性。
- 逐步扩展监控功能:如记录内存分配堆栈、统计内存使用量等,根据实际需求,逐步增强监控系统的功能。
参考:
0voice · GitHub