深入探秘:Linux内存管理与泄漏检测

目录

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进程的虚拟内存结构。内存被划分为多个区域,每个区域都有不同的用途。从下到上依次是:

  1. 代码段 (.text)

    • 这里保存了程序的可执行代码。当程序加载时,代码从磁盘读入并开始执行。
    • 所有进程共享相同的代码,通常是只读的,确保了代码的安全性和一致性。
  2. 已初始化数据段 (.data)

    • 这个区域存放已初始化的全局变量和静态变量。
    • 比如说,你在程序中定义的全局数组或静态计数器,就会被存放在这里。
  3. 未初始化数据段 (.bss)

    • 这里保存未初始化的全局变量和静态变量,初始值为0。
    • 运行时会动态分配内存给这些变量,确保它们在使用前被正确初始化。
  4. 运行时堆 (heap)

    • 堆区是用来动态分配内存的地方。每当你调用 malloccallocrealloc 时,内存就会从这里分配出来。
    • brk 指向堆的顶部,随着堆的扩展而增长。
    • 内存泄漏问题通常发生在这里。如果程序分配了内存却没有正确释放,堆区会不断增大,导致内存资源浪费。
  5. 共享库的内存映射区域

    • 这个区域用来存放共享库(比如 .so 文件)的内存映射。
    • 多个进程可以共享相同的内存区域,节省了系统资源。
  6. 用户栈 (stack)

    • 栈区用来存储函数调用时的局部变量和函数参数。
    • 每次函数调用都会在栈中压入一个新的帧,函数返回时弹出这个帧。
    • 栈是从高地址向低地址增长的,内存分配是自动的,由系统管理。
  7. 物理内存、内核代码和数据

    • 这是系统保留的区域,包括物理内存和与进程相关的数据结构(如页表、任务描述符等)。
    • 这些区域对用户进程是不可见的,确保了系统的稳定性和安全性。
  8. 内核虚拟内存

    • 这个区域保存了与内核相关的虚拟内存,供系统操作使用。
    • 它与用户空间是分离的,保证了内核操作的高效和安全。

2. 内存分配与回收:让你的程序跑得更稳健

内存分配与回收是程序运行过程中至关重要的部分。合理的内存管理不仅能提升程序的性能,还能防止那些讨厌的内存泄漏问题。让我们来看看内存分配与回收的基本流程吧!

2.1. 内存分配与内存泄漏

堆区是内存分配的核心。程序员可以通过调用 malloccallocrealloc 等函数,动态地分配和释放内存。然而,问题就出在这里——如果分配了内存却没有释放,或者在程序退出时忘记释放内存,这就会导致内存泄漏。

内存泄漏的危害

  • 内存堆逐渐增大:未释放的内存会让堆区不断膨胀,系统的可用内存减少。
  • 性能下降:内存资源被浪费,程序运行效率降低。
  • 系统崩溃:长时间运行的程序可能耗尽系统内存,导致程序或整个系统崩溃。

相比之下,栈区的内存分配是自动的,由系统管理。每次函数调用时分配内存,函数返回时自动释放。因此,栈区不会出现内存泄漏的问题。但要注意,栈溢出也是一个潜在的问题,尤其是在递归调用过多的情况下。

3. 内存泄漏检测代码分析

要确保你的程序不会出现内存泄漏,检测和监控内存的分配与释放是必不可少的。代码实现了两种内存监控方式,帮助我们检测内存泄漏:

  1. 预处理宏替换(通过宏定义替换 mallocfree
  2. 动态链接库挂钩(通过 dlsym 挂钩 mallocfree

让我们一起来看看这两种方法是如何工作的,以及它们各自的优缺点。

3.1. 预处理宏替换方法

实现原理

通过预处理宏,将代码中的 mallocfree 函数替换为自定义的 nMallocnFree 函数。这种方法在编译阶段完成替换,所有对 mallocfree 的调用都会被替换为带有额外参数(如文件名、函数名、行号)的自定义函数。

代码片段

#if 0

void *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

工作流程

  1. 替换调用

    • 所有代码中的 malloc(size) 被替换为 nMalloc(size, __FILE__, __func__, __LINE__)
    • 所有代码中的 free(ptr) 被替换为 nFree(ptr, __FILE__, __func__, __LINE__)
  2. 记录分配

    • nMalloc 调用原始的 malloc 分配内存。
    • 创建一个以指针地址命名的文件(例如 ./block/0x7f8c8b.mem),记录分配信息(包括文件名、函数名、行号、指针地址和分配大小)。
  3. 记录释放

    • nFree 尝试删除对应的 .mem 文件。
    • 如果文件不存在,说明可能存在双重释放或释放未分配的内存,输出警告信息。
  4. 内存泄漏检测

    • 程序结束后,检查 ./block/ 目录中是否存在未删除的 .mem 文件,这些文件对应未释放的内存块,表示存在内存泄漏。

优缺点

  • 优点

    • 实现简单,易于理解和维护。
    • 能够在编译时捕获内存分配和释放的调用,记录详细的源代码位置信息,有助于定位内存泄漏的源头。
  • 缺点

    • 需要修改源码,并且在所有使用 mallocfree 的地方生效。
    • 无法监控通过第三方库分配的内存,因为这些库的代码不会被预处理宏替换。
    • 性能开销较大,频繁的文件操作会显著降低程序的运行效率。
    • 不适用于多线程环境,缺乏线程安全机制。
3.2. 动态链接库挂钩方法

实现原理

利用动态链接库的特性,通过 dlsym 函数获取原始的 mallocfree 地址,重定义这两个函数,实现函数挂钩(Hooking)。这种方法不需要修改源代码,可以在运行时拦截所有对 mallocfree 的调用,包括第三方库。

代码片段

// 钩子函数部分

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);
}

工作流程

  1. 获取原始函数地址

    • 使用 dlsym(RTLD_NEXT, "malloc") 获取原始的 malloc 函数地址,并存储在 malloc_f 中。
    • 使用 dlsym(RTLD_NEXT, "free") 获取原始的 free 函数地址,并存储在 free_f 中。
    • RTLD_NEXT 确保获取的是下一个符号,即原始的标准库函数,避免递归调用。
  2. 重定义 malloc

    • 在重定义的 malloc 函数中,调用原始的 malloc 分配内存。
    • 获取调用者的返回地址 caller,通过 __builtin_return_address(0) 实现。
    • 使用 TranslateToSymbol 将调用地址转换为符号地址,便于定位调用源。
    • 创建一个以指针地址命名的 .mem 文件,记录分配信息(包括调用符号、指针地址和分配大小)。
  3. 重定义 free

    • 在重定义的 free 函数中,尝试删除对应的 .mem 文件。
    • 如果文件不存在,说明可能存在双重释放或释放未分配的内存,输出警告信息。
    • 如果文件存在,调用原始的 free 释放内存。
  4. 内存泄漏检测

    • 程序运行结束后,检查 ./block/ 目录中是否存在未删除的 .mem 文件,这些文件对应未释放的内存块,表示存在内存泄漏。

优缺点

  • 优点

    • 无侵入性:无需修改源代码,通过动态链接库即可拦截所有对 mallocfree 的调用,包括第三方库。
    • 全面性:能够监控整个进程中的内存分配和释放,覆盖所有模块。
    • 灵活性:可以在运行时启用或禁用监控,适用于多种运行环境。
  • 缺点

    • 复杂性:实现较为复杂,需要处理函数挂钩、符号解析等问题。
    • 性能开销:同样存在频繁的文件操作带来的性能影响,尤其是在高频率的内存分配和释放场景下。
    • 多线程安全:当前实现缺乏线程同步机制,在多线程环境中可能出现竞态条件,导致数据不一致或文件操作错误。
    • 错误处理:对一些异常情况(如内存分配失败、文件操作失败等)的处理较为简单,可能需要更健壮的错误处理机制。
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_f
            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);
}

#endif

int main() {
    size_t size = 5;

    void *p1 = malloc(size);
    void *p2 = malloc(size * 2);
    void *p3 = malloc(size * 3);

    free(p1);
    free(p3);
}

关键部分解析

  1. 宏定义部分

    • #if 0 包含了预处理宏替换的方法,该部分代码被禁用。
    • #else 部分启用了动态链接库挂钩的方法。
  2. 函数指针定义

    • 定义了函数指针 malloc_ffree_f,用于存储原始的 mallocfree 函数地址。
  3. TranslateToSymbol 函数

    • 使用 dladdr1 将调用地址转换为符号地址,以便记录调用源。
    • dladdr1 返回包含符号信息的 Dl_info 结构和链接映射 link_map
  4. 重定义 malloc 函数

    • 检查 malloc_f 是否已初始化,若未初始化,则使用 dlsym 获取原始的 malloc 函数地址。
    • 使用 __builtin_return_address(0) 获取调用者的返回地址。
    • 调用原始的 malloc 分配内存。
    • 创建一个以指针地址命名的 .mem 文件,记录分配信息(调用符号、指针地址、分配大小)。
    • 通过 enable_malloc 标志防止递归调用 malloc(因为 fopen 可能内部调用 malloc)。
  5. 重定义 free 函数

    • 检查 free_f 是否已初始化,若未初始化,则使用 dlsym 获取原始的 free 函数地址。
    • 尝试删除对应的 .mem 文件。
    • 如果文件不存在,输出双重释放或无效释放的警告。
    • 调用原始的 free 释放内存。
  6. 主函数

    • 分配了三块内存 p1p2p3,并释放了 p1p3
    • 运行结束后,./block/ 目录中应存在 p2 对应的 .mem 文件,提示内存泄漏。

4. 动态链接库挂钩的具体操作

为了更清晰地理解“无需修改源码,只需在运行时加载自定义的动态链接库”,让我们通过一个实际的例子来说明。

4.1. 创建一个动态链接库用于挂钩 mallocfree

文件名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 中定义了与标准库相同的函数(如 mallocfree),它们会覆盖标准库中的实现。

操作步骤

  1. 编写并编译动态链接库:包含你想要挂钩的函数实现。
  2. 使用 LD_PRELOAD:在运行程序时,通过环境变量指定要预加载的动态链接库。
  3. 程序运行时:动态链接器按顺序加载库,优先加载 LD_PRELOAD 指定的库,从而拦截和替换标准库函数。

5. 两种方法的综合比较

让我们来做一个小对比,看看预处理宏替换方法和动态链接库挂钩方法各自的特点:

特性预处理宏替换方法动态链接库挂钩方法
实现方式编译时通过宏替换 mallocfree运行时通过动态链接库挂钩 mallocfree
代码侵入性需要修改源码,所有 mallocfree 调用都被替换无需修改源码,适用于现有二进制文件
覆盖范围仅覆盖被宏替换的代码,无法监控第三方库全局覆盖,监控所有 mallocfree 调用
性能开销高,频繁的文件操作和宏替换带来的开销高,同样存在频繁的文件操作带来的开销
多线程支持缺乏线程安全机制缺乏线程安全机制
灵活性需要重新编译代码,灵活性较低高,可以在不修改源码的情况下进行监控
易用性实现相对简单,但需要源码支持实现较为复杂,但不需要源码支持
错误处理简单,依赖文件系统操作复杂,需要处理动态链接库的相关问题
扩展性受限于宏定义的功能更易扩展,可以添加更多的监控逻辑

6. 总结与建议

通过今天的分享,我们深入了解了Linux内存的工作原理、内存分配与回收的基本流程,以及如何通过两种不同的方法来检测内存泄漏。每种方法都有其独特的优势和不足,关键在于根据你的具体需求来选择最合适的方案。

综合建议

  1. 根据需求选择方法

    • 如果你需要详细追踪特定模块或源码级的内存操作,且能够修改源码,预处理宏替换方法是一个不错的选择。
    • 如果你需要对整个进程进行全面监控,且无法修改源码,动态链接库挂钩方法更为适合。
  2. 优化实现

    • 减少文件操作的频率:采用内存数据结构记录内存分配和释放信息,提升性能。
    • 引入线程同步机制:确保在多线程环境下的安全性,避免竞态条件。
    • 自动生成内存泄漏报告:在程序结束时自动扫描未释放的内存块,生成详细的报告,提升使用体验。
  3. 结合使用成熟工具

    • ValgrindAddressSanitizer 等成熟的内存检测工具功能更强大,经过广泛验证,能够提供更准确和全面的内存分析报告。
    • 在开发和测试阶段,结合使用这些工具,可以大大提升内存管理的准确性和效率,帮助你更快地定位和解决内存问题。
  4. 代码维护与扩展

    • 确保代码的可维护性:处理各种异常情况,提升监控系统的稳定性和可靠性。
    • 逐步扩展监控功能:如记录内存分配堆栈、统计内存使用量等,根据实际需求,逐步增强监控系统的功能。

 参考:

0voice · GitHub

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值