使用Valgrind调试Linux C++程序

"Valgrind教程"

Posted by Simon on August 4, 2020

“Better code, better life. ”

使用Valgrind调试Linux C++程序

C/C++由于足够底层,并且“相信程序员”,所以性能极高,但带来的负面影响就是,这两门语言对程序员要求更高。因为稍不注意,就会有各种底层问题,比如最常见的内存泄漏。

最近发现一个已经上线的服务的内存使用会随着运行时间不断增长,然后稳定在5GB左右,其实内存增长是预期内的,但最后峰值稳定在5GB上下却超过了我的预期。所以准备用Valgrind调试一下程序,本文做个记录。

Valgrind介绍

Valgrind是一个开源工具包,提供了许多调试和分析工具,可以帮助程序员让程序更快、更正确。其中为人熟知的是Memcheck,它可以检测许多与内存相关的错误,这些错误在C和c++程序中很常见,可能导致崩溃和不可预测的行为。

其他工具包括:

  • Cachegrind
  • Callgrind
  • Helgrind
  • DRD
  • Massif
  • DHAT
  • BBV

还有两个小工具,LackeyNulgrind

下载&安装

wget https://sourceware.org/pub/valgrind/valgrind-3.16.1.tar.bz2
tar xf valgrind-3.16.1.tar.bz2
cd valgrind-3.16.1/
./configure
make -j
sudo make install

基本使用

Valgrind是非侵入式的,你只需要调用Valgrind运行你的程序即可,而不需要重新链接和重新编译。就像这样:

valgrind [valgrind-options] your-prog [your-prog-options]

valgrind-options里最重要的的一项是--tool(该选项默认为memcheck),它决定你使用valgrind工具包中的那一个程序做分析。比如,如果你想看看ls -l这个命令执行时耗费的内存,你可以这么做:

valgrind --tool=memcheck ls -l

无论那种工具,Valgrind都是非侵入性的,Valgrind通过读取调试信息获取链接库、执行库,来输出程序运行时状态,并且定位异常代码位置。这体现在,待调试程序会在Valgrind的控制下运行,Valgrind内核会将待调试程序的代码交给所选工具(比如memcheck),相应工具会添加一些自己的代码然后运行,将运行结果返回给Valgrind内核。

Valgrind能模拟待调试程序执行的每条指令,所以,所选工具不仅检查或概要分析应用程序中的代码,而且还检查所有支持的动态链接库(包括C库,GUI库等)中的代码。

下面讲使用Valgrind调试要做的一些准备工作。

编译选项

  1. 重新编译你的程序,使其附带调试信息(开启-g选项)。如果没有调试信息,Valgrind无法定位异常代码位置。
  2. 如果待调试的是C++程序,考虑去掉函数内联调用(开启-fno-inline),这样可以更简单的查看函数调用堆栈。或者,使用Valgrind选项–read-inline-info = yes可以读取内联函数的调试信息,这样,即便程序使用了内联函数也能正确的显示调用堆栈信息。
  3. 关闭编译优化(-O)。在O1以上的优化级别下,memcheck工具会误报一些未初始化值的错误。
  4. 使用-Wall编译代码,他能识别Valgrind在较高优化级别上可能会遗漏的部分。

输出信息

Valgrind运行中会输出一些文本信息,包括错误报告和其他重要事件。所有行均采用以下格式:

==12345== some-message-from-Valgrind

其中12345是PID。采用这种格式可以吧程序输出可Valgrind输出很好的区分开。默认情况下,Valgrind只输出重要信息,如果需要输出更多细节,可以增加-v选项。

Valgrind一些选项来丰富输出路径,包括文件句柄(–log-fd=9)、文件(–log-file=filename)、socket(–log-socket=192.168.0.1:12345)。

异常报告

当错误检查工具检测到程序中发生的不良情况时,错误消息也会被输出。这是Memcheck的示例:

==25832== Invalid read of size 4
==25832==    at 0x8048724: BandMatrix::ReSize(int, int, int) (bogon.cpp:45)
==25832==    by 0x80487AF: main (bogon.cpp:66)
==25832==  Address 0xBFFFF74C is not stack'd, malloc'd or free'd

以上消息表明该程序对地址0xBFFFF74C进行了非法的4字节读取,,该地址不是有效的堆栈地址,也不对应于任何当前堆块或最近释放的堆块。该异常发生在bogon.cpp的第45行,从同一文件的第66行调用,依此类推。对于与已标识(当前或已释放)堆块相关的错误,例如读取已释放的内存,Valgrind不仅报告错误发生位置,还有分配/释放的相关的内存块。

Valgrind会记住所有错误报告,并且过滤重复的异常信息,使用-v选项可以查看同一个异常发生的次数。

Valgrind会在相应异常操作发生之前,输出异常信息。对于memcheck,如果你的程序尝试读取0地址,memcheck会发出一条相关的异常,然后你的程序才会因为段错误而退出。

错误抑制

错误检查工具可检测操作系统中预装的系统库中的许多问题,例如C库。无法轻松地解决这些问题,因此Valgrind读取了要在启动时消除的错误列表。构建系统时,默认的禁止文件由./configure脚本创建。你可以随意修改并添加到抑制文件,或者更好地编写自己的文件。

命令行选项

如上所述,Valgrind的核心接受一组通用选项。每种工具还接受特定的选项。

Valgrind的默认设置在大多数情况下都能成功地提供合理的行为。我们按粗略类别将可用选项分组。

工具选择

--tool=<toolname> [default: memcheck]

基础选项

-v, --verbose

提供有关程序各个方面的额外信息,例如:加载的共享对象,使用的抑制,检测和执行引擎的进度以及有关异常行为的警告。重复该选项会增加详细程度。

--trace-children=<yes|no> [default: no]

启用后,Valgrind将跟踪通过exec系统调用启动的子流程。这对于多进程程序是必需的。

注意,Valgrind会追踪到fork出来的子进程(这很难避免,因为fork会复制进程的相同副本),因此此选项的命名可能很差。但是,大多数fork调用的子级仍然会立即调用exec。

--child-silent-after-fork=<yes|no> [default: no]

启用后,Valgrind将不会显示由fork调用导致的子进程的任何调试或日志记录输出。在处理创建子进程时,这可以使输出的混乱程度降低

--vgdb=<no|yes|full> [default: yes]

当指定–vgdb = yes或–vgdb = full时,Valgrind将提供“ gdbserver”功能。当程序在Valgrind上运行时,这允许外部GNU GDB调试器控制和调试程序。

--track-fds=<yes|no> [default: no]

启用后,Valgrind将在退出或请求时通过gdbserver监视命令v.info open_fds打印出打开文件描述符的列表。与每个文件描述符一起打印的是文件打开位置以及与文件描述符有关的任何详细信息(例如文件名或套接字详细信息)的堆栈回溯。

--time-stamp=<yes|no> [default: no]

启用后,每条消息之前都会显示自启动以来经过的挂钟时间,以天,小时,分钟,秒和毫秒表示。

Memcheck使用

前文说过,Valgrind是一个多功能的工具包,他有一个内核引擎和很多检测工具。在这些工具中,memcheck无疑是最受欢迎的。使用memcheck你可以检查如下问题:

  • 内存非法访问
  • 使用未初始化的变量
  • 错误的释放堆内存(比如double free
  • 在类似memcpy的函数中重叠了srcdst指针
  • 内存泄漏

诸如此类的问题可能很难通过其他方式发现,通常很长一段时间都未被发现,继而导致偶发性的,难以诊断的崩溃。

memcheck的异常信息

Memcheck发出一系列错误消息。本节简要介绍了错误消息的含义。

非法读取/非法写入错误
Invalid read of size 4
   at 0x40F6BBCC: (within /usr/lib/libpng.so.2.1.0.9)
   by 0x40F6B804: (within /usr/lib/libpng.so.2.1.0.9)
   by 0x40B07FF4: read_png_image(QImageIO *) (kernel/qpngio.cpp:326)
   by 0x40AC751B: QImageIO::read() (kernel/qimage.cpp:3621)
 Address 0xBFFFF0E0 is not stack'd, malloc'd or free'd

当您的程序在Memcheck认为不应该的位置读取或写入内存时,会发生这种情况。

在此示例中,程序在系统提供的库libpng.so.2.1.0.9中某个位置的地址0xBFFFF0E0处进行了4字节读取,该库是从同一个库中的其他位置(从qpngio.cpp的第326行调用)调用的, 等等。

使用未初始化的值
Conditional jump or move depends on uninitialised value(s)
   at 0x402DFA94: _IO_vfprintf (_itoa.h:49)
   by 0x402E8476: _IO_printf (printf.c:36)
   by 0x8048472: main (tests/manuel1.c:8)

当您的程序使用尚未初始化的值(换句话说,未定义)时,会报告未初始化值的使用错误。在这里,未定义的值在C库的printf机械内部的某处使用。运行以下小程序时,报告了此错误。

在系统调用中使用未初始化或无法寻址的值

Memcheck检查系统调用的所有参数:

  • 是否已初始化。
  • 检查整个缓冲区是否可寻址,并且其内容已初始化。
  • 如果系统调用需要写入用户提供的缓冲区,则Memcheck会检查缓冲区是否可寻址。
非法free内存
Invalid free()
   at 0x4004FFDF: free (vg_clientmalloc.c:577)
   by 0x80484C7: main (tests/doublefree.c:10)
 Address 0x3807F7B4 is 0 bytes inside a block of size 177 free'd
   at 0x4004FFDF: free (vg_clientmalloc.c:577)
   by 0x80484C7: main (tests/doublefree.c:10)

Memcheck使用malloc / new跟踪程序分配的块,因此它可以准确知道free / delete的参数是否合法。在这里,此测试程序已释放相同的块两次。与非法的读/写错误一样,Memcheck尝试使释放的地址有意义。

使用不合适的释放函数释放堆块时
Mismatched free() / delete / delete []
   at 0x40043249: free (vg_clientfuncs.c:171)
   by 0x4102BB4E: QGArray::~QGArray(void) (tools/qgarray.cpp:149)
   by 0x4C261C41: PptDoc::~PptDoc(void) (include/qmemarray.h:60)
   by 0x4C261F0E: PptXml::~PptXml(void) (pptxml.cc:44)
 Address 0x4BB292A8 is 0 bytes inside a block of size 64 alloc'd
   at 0x4004318C: operator new[](unsigned int) (vg_clientfuncs.c:152)
   by 0x4C21BC15: KLaola::readSBStream(int) const (klaola.cc:314)
   by 0x4C21C155: KLaola::stream(KLaola::OLENode const *) (klaola.cc:416)
   by 0x4C21788F: OLEFilter::convert(QCString const &) (olefilter.cc:272)

memcheck会检查堆内存的分配和释放是否一致。如果你是由malloc, calloc, realloc, valloc 或者 memalign分配的内存,必须使用free来释放;使用new分配的内存,必须使用delete来释放;使用new[]分配的内存,必须使用delete[]来释放。

重叠的源块和目标块

以下C库函数将一些数据从一个存储块复制到另一个(或类似的存储块):memcpy,strcpy,strncpy,strcat,strncat。不允许它们的src和dst指针指向的块重叠。 POSIX标准的措辞大致如下:“如果在重叠的对象之间进行复制,则行为未定义。”因此,Memcheck对此进行检查。

内存泄漏

Memcheck跟踪响应于对malloc / new等的调用而发出的所有堆块。因此,当程序退出时,它知道哪些块尚未释放。 如果正确设置了–leak-check,则对于每个剩余的块,Memcheck会根据根集中的指针确定该块是否可访问。

在Memcheck官方手册上,列出了以下九种内存泄露的情况:

     Pointer chain            AAA Leak Case   BBB Leak Case
     -------------            -------------   -------------
(1)  RRR ------------> BBB                    DR
(2)  RRR ---> AAA ---> BBB    DR              IR
(3)  RRR               BBB                    DL
(4)  RRR      AAA ---> BBB    DL              IL
(5)  RRR ------?-----> BBB                    (y)DR, (n)DL
(6)  RRR ---> AAA -?-> BBB    DR              (y)IR, (n)DL
(7)  RRR -?-> AAA ---> BBB    (y)DR, (n)DL    (y)IR, (n)IL
(8)  RRR -?-> AAA -?-> BBB    (y)DR, (n)DL    (y,y)IR, (n,y)IL, (_,n)DL
(9)  RRR      AAA -?-> BBB    DL              (y)IL, (n)DL

Pointer chain legend:
- RRR: a root set node or DR block
- AAA, BBB: heap blocks
- --->: a start-pointer
- -?->: an interior-pointer

Leak Case legend:
- DR: Directly reachable
- IR: Indirectly reachable
- DL: Directly lost
- IL: Indirectly lost
- (y)XY: it's XY if the interior-pointer is a real pointer
- (n)XY: it's XY if the interior-pointer is not a real pointer
- (_)XY: it's XY in either case

Memcheck在其输出中合并了其中一些情况,从而导致以下四种泄漏类型:

  • Still reachable,包括上述的1、2。意味着BBB内存块仍然是可访问的,有可能在未来某个时刻被释放。
  • Definitely lost,包括上述第3种情况。BBB无法再被访问,确认泄漏。
  • Indirectly lost,包括上述第4、9种情况。由于AAA无法被访问,AAABBB都无法被正常释放。
  • Possibly lost,包括上述第5-8中情况。

在存在内存异常的情况下,memcheck会给输出一个汇总的信息,格式如下:

LEAK SUMMARY:
   definitely lost: 4 bytes in 1 blocks
   indirectly lost: 0 bytes in 0 blocks
     possibly lost: 0 bytes in 0 blocks
   still reachable: 95 bytes in 6 blocks
                      of which reachable via heuristic:
                        stdstring          : 56 bytes in 2 blocks
                        length64           : 16 bytes in 1 blocks
                        newarray           : 7 bytes in 1 blocks
                        multipleinheritance: 8 bytes in 1 blocks
        suppressed: 0 bytes in 0 blocks

如果指定了–leak-check = full,则Memcheck将提供每个绝对丢失或可能丢失的块的详细信息,包括分配位置。(实际上,它会将泄漏类型相同且堆栈跟踪充分相似的所有块的结果合并到单个“丢失记录”中。–leak-resolution使您可以控制“充分相似”的含义。)它无法告诉你何时,如何或为什么丢失指向泄漏块的指针;您必须自己解决。通常,你应该尝试确保你的程序在退出时没有任何绝对丢失或可能丢失的块。

举个例子:

8 bytes in 1 blocks are definitely lost in loss record 1 of 14
   at 0x........: malloc (vg_replace_malloc.c:...)
   by 0x........: mk (leak-tree.c:11)
   by 0x........: main (leak-tree.c:39)

88 (8 direct, 80 indirect) bytes in 1 blocks are definitely lost in loss record 13 of 14
   at 0x........: malloc (vg_replace_malloc.c:...)
   by 0x........: mk (leak-tree.c:11)
   by 0x........: main (leak-tree.c:25)

常用命令选项

--show-leak-kinds=<set>

内存泄漏的类别,definiteindirectpossiblereachable四选一。默认为definite,possible

--leak-check=<no|summary|yes|full> [default: summary]

启用后,在客户端程序完成时搜索内存泄漏。如果设置为摘要,则表示发生了多少泄漏。如果设置为“完全”或“是”,则会按照–show-leak-kinds和–errors-for-leak-kinds选项指定,详细显示每个泄漏并/或将其计为错误。

--leak-resolution=<low|med|high> [default: high]

在执行泄漏检查时,确定为了将多个泄漏合并到一个泄漏报告中,Memcheck如何愿意考虑不同的回溯是相同的。设置为低时,仅前两个条目需要匹配。中度时,必须匹配四个条目。高时,所有条目都需要匹配。

--leak-check-heuristics=<set> [default: all]

指定在泄漏搜索期间要使用的一组泄漏检查试探法。启发式方法控制哪个内部指针指向一个块,使其被认为是可到达的。

memcheck的工作原理

Valid-value (V) bits

在忽略一些细节的前提下,我们可以认为,memcheck实现了一个合成CPU。真实CPU所处理、存储的数据的每一个bit,在memcheck合成CPU中都有一个与之关联的Valid-value (V) bit,它标识着真实数据的有效性。在后面的讨论中,我们称之为V bit

因此,系统中的每个字节都有一个8 V bit,随其流逝。例如,当CPU从内存中加载一个字大小的项(4个字节)时,它还会从位图中加载相应的32个V bit,该位图存储了进程整个地址空间的V bit。如果CPU稍后再将该值的全部或部分内容写入另一个地址的内存中,则相关的V bit将被存储回V bit位图中。

简而言之,系统中的每个位(概念上)都具有一个关联的V bit,该位随处可见,甚至在CPU内部也是如此。所有CPU的寄存器(整数,浮点,向量和条件寄存器)都有自己的V bit向量。为此,Memcheck使用了大量压缩来紧凑地表示V bit

对值的复制行为不会导致Memcheck检查或报告错误。但是,当使用某个值可能会影响程序的外部可见行为时,将立即检查相关的V bit。如果其中任何一个指示值未定义(甚至部分未定义),则会报告错误。

举个例子:

int i, j;
int a[10], b[10];
for ( i = 0; i < 10; i++ ) {
  j = a[i];
  b[i] = j;
}

上面代码的功能是将一个未定义的数组a复制到b,但memcheck并不会有什么异常信息输出,以为这种行为并没有实际造成什么影响。

再来看一个例子:

for ( i = 0; i < 10; i++ ) {
  j += a[i];
}
if ( j == 77 ) 
  printf("hello there\n");

memcheck会报出数组a未初始化的异常信息。因为在下面的if语句中使用到了j的值。

注意,在这个例子中,并不是j += a[i]这一行导致的memcheck的报错,因为这里的未定义行为对于memcheck是不可见的。仅在必须决定是否执行printf(程序的可观察动作)时,Memcheck才会报错误信息。

大多数低级操作(例如加法)都会导致Memcheck使用操作数的V位来计算结果的V bit。即使结果是部分或全部未定义,也不会报错。

对定义性的检查仅在以下三个地方进行:

  • 当使用一个值生成内存地址
  • 需要做出控制流决策
  • 检测到系统调用,Memcheck会根据需要检查参数的定义性。

为什么不是每当从内存中读取数据的时候进行检查呢,然后判断是否将未定义的值加载到CPU寄存器中呢?这行不通,因为完全合法的C程序会在内存中例行复制未初始化的值,并且我们不希望对此有进行报错。考虑这样的一个例子:

struct S { int x; char c; };
struct S s1, s2;
s1.x = 42;
s1.c = 'z';
s2 = s1;

这里的结构体S的大小是4+1=5个字节吗?不是的,由于C语言的字节对齐,结构体S的实际大小应该是8个字节。所以当执行s2 = s1;时,依然有3个未定义的字节进行了复制操作。

还有一种情况,在多线程/多进程程序里,一块内存可能由一个线程/进程写,另外一个线程/进程读,对于这种情况,如果在读取的时候检查,也会发生误报的情况。

Valid-address (A) bits

现在我们已经知道如何建立和维护值的有效性,但仍然不清楚程序是否有权访问任何特定的存储位置。

我们已经对内存中的所有字节(而不是CPU中的寄存器)都建立了一个A bit,用来标识程序是否可以合法的读取或者写入该位置。每当你的程序读取或者写入内存时,memcheck都会检查与地址相关联的A bit。如果其中任意一个为非法地址,则报出异常。

那么我们如何来设置/清除地址的A bit呢?步骤是这样的:

  • 程序启动时,所有全局数据区域都标记为可访问。
  • 当程序执行malloc / new时,恰好分配的区域的A bit(而不是更多字节)被标记为可访问。释放区域后,A bit将更改以指示不可访问性。
  • 一些系统调用也会更改A bit。例如,mmap可以使文件出现在进程的地址空间中,因此,如果mmap成功,则必须更新A bit
总体逻辑

Memcheck的检查机制可以概括如下:

  • 存储器中的每个字节都有8个相关的V(有效值)位,表示该字节是否具有定义的值,还有一个A(有效地址)位,表示程序当前是否有权读取/写那个地址。memcheck在存储A位和V位时用了压缩算法,只会增加25%的内存开销。
  • 读取或写入存储器时,将查阅相关的A位。如果它们指示无效地址,则Memcheck会发出无效读取或无效写入错误。
  • 当将存储器读入CPU的寄存器时,相关的V位将从存储器中取出并存储在模拟的CPU中。
  • 当寄存器写出到存储器时,该寄存器的V位也写回到存储器。
  • 当使用CPU寄存器中的值生成内存地址或确定条件分支的结果时,将检查这些值的V位,如果未定义则发出错误。
  • 如果将CPU寄存器中的值用于其他目的,则Memcheck会计算结果的V位,但不对其进行检查。
  • 一旦检查了CPU中某个值的V位,便将其设置为指示有效性。

Memcheck拦截对malloc,calloc,realloc,valloc,memalign,free,new,new [],delete和delete []的调用。相应的行为是:

  • malloc / new / new []:返回的内存被标记为可寻址但没有有效值。这意味着必须先写入才能读取。
  • calloc:返回的内存被标记为可寻址和有效,因为calloc会将区域清除为零。
  • realloc:如果新大小大于旧大小,则新部分是可寻址的,但无效,与malloc一样。如果新的尺寸较小,则将删除的部分标记为不可寻址。
  • free / delete / delete []:只能将相应的分配函数先前向您发出的指针传递给这些函数,否则会报错。如果指针有效,则Memcheck会将其指向的整个区域标记为不可寻址,并将该块放置在freed-blocks-queue中。目的是尽可能长时间地重新分配此块。在此之前,所有尝试访问它的尝试都会引发一个无效地址错误。