一、概述

Valgrind 是一个开源的内存调试和性能分析工具,用于帮助开发者找出程序中的内存错误,如内存泄漏、使用未初始化的内存、非法内存访问等问题。它在 Linux 平台上广泛使用,并且支持下多种处理器架构。

二、Valgrind 的使用

1、基本格式

valgrind --tool=memcheck -–gen-suppressions=all -–show-leak-kinds=all --log-file=<filename> --leak-check=yes ./your_app arg1 arg2...
  • valgrind:这是一个内存调试工具集,其中的 memcheck 是其中的一个工具,它用于检查内存相关的错误。
  • -–gen-suppressions=all:误报是内存泄漏排查中的常见现象。使用该参数,我们可以标记那些误报,生成抑制规则,让 Valgrind 在后续的检查中忽略这些特定的情况。
  • –show-leak-kinds=all:显示所有的内存泄漏信息。
  • –log-file=<filename>:这是一个选项,用于指定 Valgrind 输出的日志文件的文件名。你可以将 <filename> 替换为你想要的文件名或路径。
  • –leak-check=yes:这个选项告诉 Valgrind 在程序运行结束后检查内存泄漏。它将会列出程序中存在的任何未释放的内存。(还有一种写法:--leak-check=full,意思是一样的)
  • ./your_app:这里应该是你要检查的可执行文件的路径。将 your_app 替换为你的程序的实际名称。
  • arg1 arg2…:这些是你的程序可能需要的命令行参数。用空格分隔,替换为你程序实际需要的参数。

2、Valgrind 工具集

Valgrind 工具集包含多个工具,每个工具都针对不同的调试、分析和性能优化任务。以下是 Valgrind 工具集中一些常用的工具:

  1. Memcheck:这是 Valgrind 最常用的工具之一,用于检测程序中的内存错误,例如内存泄漏、未初始化的内存读取、非法内存访问等。
  2. Cachegrind:用于模拟缓存和分支预测器的行为,帮助优化程序的缓存使用和执行路径。
  3. Callgrind:用于程序性能分析,跟踪函数调用关系和执行次数,帮助找出程序中的性能瓶颈。
  4. Helgrind:专门用于检测多线程程序中的并发错误,如数据竞争、死锁等问题。
  5. Massif:用于分析程序的堆内存使用情况,包括堆分配、释放和堆内存的快照。
  6. DHAT (Dynamic Heap Analysis Tool):用于深入分析程序的堆内存分配情况,帮助找出内存分配和使用方面的问题。
  7. BBV(Basic Block Vectors):可用于收集程序中基本块的统计信息,帮助理解程序的执行路径和性能特征。

每个工具都有其特定的用途和优势,可以根据需要选择合适的工具来进行程序调试、性能优化或内存分析。

接下来主要是介绍 Memcheck 工具的使用。

3、Memcheck

Valgrind 在内存检测方面主要有四个使用场景:

  1. 使用未初始化的内存
  2. 内存泄漏
  3. 在内存被释放后进行读/写
  4. 内存块的尾部进行读/写

3.1 使用未初始化的内存

首先看一个例子:

#include <stdio.h>
#include <stdlib.h>

int main(){
    char *p;
    char c = *p;

    printf("\n [%c]\n", c);

    return 0;
}

可以看出,这里访问了一个野指针。接下来编译:

$ gcc test

然后使用 Valgrind 工具分析:

$ valgrind --tool=memcheck ./a.out

报错信息和出现错误的位置都打印了出来:

3.2 内存泄漏

还是先看代码:

#include <stdio.h>
#include <stdlib.h>

int main(){
    char *p = malloc(1);
    *p = 'a';

    char c = *p;

    printf("\n [%c]\n", c);

    return 0;
}

可以看到,这里为 p 指针申请了一个地址,不过最后没有 free 掉这个地址就 return 0 了,也就是会照成内存泄漏。先编译:

$ gcc test

然后使用 Valgrind 工具分析:

$ valgrind --tool=memcheck --leak-check=full ./a.out

结果如下,可以看到提示信息显示 alloc 了 2 次,但却只 free 1 次,所以发生了内存泄漏,再下面是内存泄漏的详细信息。

3.3 在内存被释放后进行读/写

示例代码如下:

#include <stdio.h>
#include <stdlib.h>

int main(){
    char *p = malloc(1);
    *p = 'a';

    char c = *p;

    printf("\n [%c]\n", c);

    free(p);
    c = *p;

    return 0;
}

编译后,用 vallgrind 查看:

$ gcc test.c
$ valgrind --tool=memcheck ./a.out

上面的代码中,我们有一个释放了内存的指针 p,然后我们又尝试利用指针获取值。从下面的输出内容可以看到,Valgrind 检测到了无效的读取操作然后输出了警告"Invalid read of size 1’.

3.4 内存块的尾部进行读/写

代码如下:

#include <stdio.h>
#include <stdlib.h>

int main(){
    char *p = malloc(1);
    *p = 'a';

    char c = *(p+1);

    printf("\n [%c]\n", c);

    free(p);

    return 0;
}
$ gcc test.c
$ valgrind --tool=memcheck ./a.out

可以看到,这里依旧是非法的读,因为我们只申请了 1 个字节空间:

在这里插入图片描述

4、常见错误

下面是一些日志打印中常见的错误:

  • malloc/free: in use at exit :内存在退出前没有释放
  • invalid write of size:非法写入内存,一般为数组越界
  • invalid read of size:非法读内存:一般为数组越界
  • definitely lost /possibly lost /still reachable in loss record:内存未释放
    • definitely :确认丢失。程序中存在内存泄露,应尽快修复。
    • indirectly:间接丢失。当使用了含有指针成员的类或结构时可能会报这个错误 。
    • possibly:可能丢失。大多数情况下应视为与"definitely lost"一样需要尽快修复。
    • still reachable:可以访问,未丢失但也未释放。如果程序是正常结束的,那么它可能不会造成程序崩溃,但长时间运行有可能耗尽系统资源。
    • suppressed:已被解决。出现了内存泄露但系统自动处理了。可以无视这类错误。
  • invalid free()/delete/delete[]:同一指针被多次释放
  • source and destination overlay:一般是使用strncpy,memcpy引起
  • syscall param contains uninitialized byte:调用系统函数时传入了未初始化的变量
  • conditional jump or move depends on uninitialized value :条件判断时使用了未初始化的变量
  • access not with mapped region/stack overflow:栈溢出
  • mismatch free()/delete/delete[]/new:delete/malloc/free搭配错误

三、分析内存泄漏的使用技巧

1、Valgrind 协调 GDB 工作

在 Linux 内存泄漏的排查过程中,ValgrindGDB 的结合使用是一种强大的调试策略。Valgrind 能够帮助我们发现程序中的内存泄漏,而 GDB 则允许我们深入程序的执行,查看变量和内存状态,从而精确地定位问题。

Valgrind 提供了一个 --vgdb-error=0 的选项,允许我们在第一个错误发生时立即启动 GDB。这样,我们可以在程序执行到可能出现内存泄漏的地方时,立即进行检查。

下面是操作流程:

  1. 启动 Valgrind,带有 GDB调试支持:
$ valgrind --tool=memcheck --vgdb=yes --vgdb-error=0 ./a.out
  1. Valgrind 报告内存错误时,它会暂停程序执行。
  2. 另一个终端中,我们可以启动 GDB 并连接到 Valgrind
$ gdb ./a.out
(gdb) target remote | vgdb
  1. 然后就可以使用 GDB 的调试命令了,我们可以检查导致错误的代码行,查看变量的值和内存的状态。

2、利用 /proc 定位问题

Linux 的 /proc 文件系统包含了系统运行时的各种信息,其中也包括了进程的内存映射情况。通过分析 /proc/[pid]/maps 文件,我们可以得知进程的内存分配情况,这对于定位内存泄漏非常有用。

每个进程的 /proc/[pid]/maps 文件都记录了该进程的内存映射。我们可以通过以下命令查看特定进程的内存映射:

$ cat /proc/[pid]/maps

[pid] 需要替换为我们怀疑存在内存泄漏的进程ID。通过分析这个文件,我们可以看到进程的内存分配情况,包括哪些库文件被加载,以及它们的内存地址范围。


从左向右的六列数据的含义如下:

  • 地址范围:表示内存段的起始和结束地址。
  • 权限:表示内存段的访问权限。
  • 偏移量:表示从文件开始到映射区域开始的偏移。
  • 设备:表示关联的设备。
  • 节点:表示文件系统中的节点号。
  • 路径:表示映射到的文件路径,如果是 [heap] 则表示堆内存区域。

如果发生内存泄漏,表格中的某些行会显示出异常的模式,特别是在堆或者可能的匿名映射(通常是堆或栈的扩展)区域。以下是一些可能表明内存泄漏的情况:

  1. 堆内存增长:如果 [heap] 区域的地址范围随时间不断增长,这可能表明堆内存正在泄漏。
  2. 频繁的小块分配:大量小块内存分配并且没有对应的释放,可能会在表格中显示为许多小范围的内存映射。
  3. 匿名映射:大量的匿名映射(没有关联路径的映射)可能是动态分配内存未被释放的迹象。

比如,下列数据展示了可能的内存泄漏的情况:

02557000-03578000           rw-p 00000000 00:00 0	        [heap]
...
7ff3c8c00000-7ff3c8e21000	rw-p 00000000 00:00	0	

在这个例子中,我们看到:

  • [heap] 区域的大小异常,表明可能有大量的内存分配没有得到释放。
  • 存在连续的 rw-p 权限的匿名映射,这些可能是由于内存分配(如 mallocnew)造成的,如果这些区域的大小不断增长,且没有相应的释放,那么很可能是内存泄漏的地方。

3、使用 top、ps 识别异常进程

3.1 使用 top

$ top -o %MEM

这个命令会将进程按内存使用率进行排序,帮助我们更快地定位到内存使用异常的进程。

在使用 top 命令观察进程的内存使用情况时,我们需要关注的是内存使用量(RES)和虚拟内存使用量(VIRT)。内存泄漏通常表现为随着时间的推移,这两个值会不断增加。

  • 内存使用量(RES):进程实际使用的物理内存大小。如果一个进程存在内存泄漏,我们会看到 RES 值不断上升,即使在没有新的活动产生时也是如此。这是因为泄漏的内存没有被操作系统回收,从而导致物理内存的持续占用。
  • 虚拟内存使用量(VIRT):包括进程使用的所有内存,不仅包括RES,还包括进程未使用但已分配的内存。内存泄漏会导致VIRT值不断增加,这是因为进程请求了更多的内存,但并未释放。

3.2 使用 ps

我们可以使用 ps 命令的 -o 选项来自定义输出,以便专注于内存相关的信息。例如:

$ ps -eo pid,ppid,cmd,%mem,%cpu --sort=-%mem

这个命令将列出所有进程,并按内存使用率降序排列,显示每个进程的 PID、PPID、命令行、内存使用率和 CPU 使用率。

结合历史数据,我们可以分析进程的内存使用趋势。通过定期记录 ps 命令的输出,我们可以创建一个内存使用的时间序列,这有助于我们识别内存泄漏的长期趋势。

#! /bin/sh
while true; do 
	ps -eo pid,cmd,%mem,%cpu --sort=-%mem | head -n 10 >> memory_usage.log; 
	sleep 60; 
done

这段脚本会每分钟记录内存使用最高的 10 个进程,并将结果追加到 memory_usage.log 文件中。

Logo

开放原子开发者工作坊旨在鼓励更多人参与开源活动,与志同道合的开发者们相互交流开发经验、分享开发心得、获取前沿技术趋势。工作坊有多种形式的开发者活动,如meetup、训练营等,主打技术交流,干货满满,真诚地邀请各位开发者共同参与!

更多推荐