使用 Valgrind 调试 Linux C++ 程序
使用 Valgrind 调试 Linux C++程序
C/C++由于足够底层,并且“相信程序员”,所以性能极高,但带来的负面影响就是,这两门语言对程序员要求更高。因为稍不注意,就会有各种底层问题,比如最常见的内存泄漏。
最近发现一个已经上线的服务的内存使用会随着运行时间不断增长,然后稳定在 5GB 左右,其实内存增长是预期内的,但最后峰值稳定在 5GB 上下却超过了我的预期。所以准备用 Valgrind 调试一下程序,本文做个记录。
Valgrind 介绍
Valgrind
是一个开源工具包,提供了许多调试和分析工具,可以帮助程序员让程序更快、更正确。其中为人熟知的是Memcheck
,它可以检测许多与内存相关的错误,这些错误在 C 和 c++程序中很常见,可能导致崩溃和不可预测的行为。
其他工具包括:
Cachegrind
Callgrind
Helgrind
DRD
Massif
DHAT
BBV
还有两个小工具,Lackey 和 Nulgrind。
下载 &安装
基本使用
Valgrind 是非侵入式的,你只需要调用 Valgrind 运行你的程序即可,而不需要重新链接和重新编译。就像这样:
valgrind-options
里最重要的的一项是--tool
(该选项默认为 memcheck),它决定你使用valgrind
工具包中的那一个程序做分析。比如,如果你想看看ls -l
这个命令执行时耗费的内存,你可以这么做:
无论那种工具,Valgrind 都是非侵入性的,Valgrind 通过读取调试信息获取链接库、执行库,来输出程序运行时状态,并且定位异常代码位置。这体现在,待调试程序会在 Valgrind 的控制下运行,Valgrind 内核会将待调试程序的代码交给所选工具(比如memcheck
),相应工具会添加一些自己的代码然后运行,将运行结果返回给 Valgrind 内核。
Valgrind 能模拟待调试程序执行的每条指令,所以,所选工具不仅检查或概要分析应用程序中的代码,而且还检查所有支持的动态链接库(包括 C 库,GUI 库等)中的代码。
下面讲使用 Valgrind 调试要做的一些准备工作。
编译选项
重新编译你的程序,使其附带调试信息(开启-g 选项)。如果没有调试信息,Valgrind 无法定位异常代码位置。
如果待调试的是 C++程序,考虑去掉函数内联调用(开启-fno-inline),这样可以更简单的查看函数调用堆栈。或者,使用 Valgrind 选项--read-inline-info = yes 可以读取内联函数的调试信息,这样,即便程序使用了内联函数也能正确的显示调用堆栈信息。
关闭编译优化(-O)。在
O1
以上的优化级别下,memcheck
工具会误报一些未初始化值的错误。使用-Wall 编译代码,他能识别 Valgrind 在较高优化级别上可能会遗漏的部分。
输出信息
Valgrind 运行中会输出一些文本信息,包括错误报告和其他重要事件。所有行均采用以下格式:
其中12345
是 PID。采用这种格式可以吧程序输出可 Valgrind 输出很好的区分开。默认情况下,Valgrind 只输出重要信息,如果需要输出更多细节,可以增加-v
选项。
Valgrind 一些选项来丰富输出路径,包括文件句柄(--log-fd=9)、文件(--log-file=filename)、socket(--log-socket=192.168.0.1:12345)。
异常报告
当错误检查工具检测到程序中发生的不良情况时,错误消息也会被输出。这是 Memcheck 的示例:
以上消息表明该程序对地址0xBFFFF74C
进行了非法的 4 字节读取,,该地址不是有效的堆栈地址,也不对应于任何当前堆块或最近释放的堆块。该异常发生在bogon.cpp
的第 45 行,从同一文件的第 66 行调用,依此类推。对于与已标识(当前或已释放)堆块相关的错误,例如读取已释放的内存,Valgrind 不仅报告错误发生位置,还有分配/释放的相关的内存块。
Valgrind 会记住所有错误报告,并且过滤重复的异常信息,使用-v
选项可以查看同一个异常发生的次数。
Valgrind 会在相应异常操作发生之前,输出异常信息。对于memcheck
,如果你的程序尝试读取 0 地址,memcheck
会发出一条相关的异常,然后你的程序才会因为段错误而退出。
错误抑制
错误检查工具可检测操作系统中预装的系统库中的许多问题,例如 C 库。无法轻松地解决这些问题,因此 Valgrind 读取了要在启动时消除的错误列表。构建系统时,默认的禁止文件由./configure 脚本创建。你可以随意修改并添加到抑制文件,或者更好地编写自己的文件。
命令行选项
如上所述,Valgrind 的核心接受一组通用选项。每种工具还接受特定的选项。
Valgrind 的默认设置在大多数情况下都能成功地提供合理的行为。我们按粗略类别将可用选项分组。
工具选择
基础选项
提供有关程序各个方面的额外信息,例如:加载的共享对象,使用的抑制,检测和执行引擎的进度以及有关异常行为的警告。重复该选项会增加详细程度。
启用后,Valgrind 将跟踪通过 exec 系统调用启动的子流程。这对于多进程程序是必需的。
注意,Valgrind 会追踪到 fork 出来的子进程(这很难避免,因为 fork 会复制进程的相同副本),因此此选项的命名可能很差。但是,大多数 fork 调用的子级仍然会立即调用 exec。
启用后,Valgrind 将不会显示由 fork 调用导致的子进程的任何调试或日志记录输出。在处理创建子进程时,这可以使输出的混乱程度降低
当指定--vgdb = yes 或--vgdb = full 时,Valgrind 将提供“ gdbserver”功能。当程序在 Valgrind 上运行时,这允许外部 GNU GDB 调试器控制和调试程序。
启用后,Valgrind 将在退出或请求时通过 gdbserver 监视命令 v.info open_fds 打印出打开文件描述符的列表。与每个文件描述符一起打印的是文件打开位置以及与文件描述符有关的任何详细信息(例如文件名或套接字详细信息)的堆栈回溯。
启用后,每条消息之前都会显示自启动以来经过的挂钟时间,以天,小时,分钟,秒和毫秒表示。
Memcheck 使用
前文说过,Valgrind 是一个多功能的工具包,他有一个内核引擎和很多检测工具。在这些工具中,memcheck
无疑是最受欢迎的。使用memcheck
你可以检查如下问题:
内存非法访问
使用未初始化的变量
错误的释放堆内存(比如 double free)
在类似 memcpy 的函数中重叠了
src
和dst
指针内存泄漏
诸如此类的问题可能很难通过其他方式发现,通常很长一段时间都未被发现,继而导致偶发性的,难以诊断的崩溃。
memcheck 的异常信息
Memcheck 发出一系列错误消息。本节简要介绍了错误消息的含义。
非法读取/非法写入错误
当您的程序在 Memcheck 认为不应该的位置读取或写入内存时,会发生这种情况。
在此示例中,程序在系统提供的库 libpng.so.2.1.0.9 中某个位置的地址 0xBFFFF0E0 处进行了 4 字节读取,该库是从同一个库中的其他位置(从 qpngio.cpp 的第 326 行调用)调用的, 等等。
使用未初始化的值
当您的程序使用尚未初始化的值(换句话说,未定义)时,会报告未初始化值的使用错误。在这里,未定义的值在 C 库的 printf 机械内部的某处使用。运行以下小程序时,报告了此错误。
在系统调用中使用未初始化或无法寻址的值
Memcheck 检查系统调用的所有参数:
是否已初始化。
检查整个缓冲区是否可寻址,并且其内容已初始化。
如果系统调用需要写入用户提供的缓冲区,则 Memcheck 会检查缓冲区是否可寻址。
非法 free 内存
Memcheck 使用 malloc / new 跟踪程序分配的块,因此它可以准确知道 free / delete 的参数是否合法。在这里,此测试程序已释放相同的块两次。与非法的读/写错误一样,Memcheck 尝试使释放的地址有意义。
使用不合适的释放函数释放堆块时
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 官方手册上,列出了以下九种内存泄露的情况:
Memcheck 在其输出中合并了其中一些情况,从而导致以下四种泄漏类型:
Still reachable,包括上述的 1、2。意味着
BBB
内存块仍然是可访问的,有可能在未来某个时刻被释放。Definitely lost,包括上述第 3 种情况。
BBB
无法再被访问,确认泄漏。Indirectly lost,包括上述第 4、9 种情况。由于
AAA
无法被访问,AAA
和BBB
都无法被正常释放。Possibly lost,包括上述第 5-8 中情况。
在存在内存异常的情况下,memcheck
会给输出一个汇总的信息,格式如下:
如果指定了--leak-check = full,则 Memcheck 将提供每个绝对丢失或可能丢失的块的详细信息,包括分配位置。(实际上,它会将泄漏类型相同且堆栈跟踪充分相似的所有块的结果合并到单个“丢失记录”中。--leak-resolution 使您可以控制“充分相似”的含义。)它无法告诉你何时,如何或为什么丢失指向泄漏块的指针;您必须自己解决。通常,你应该尝试确保你的程序在退出时没有任何绝对丢失或可能丢失的块。
举个例子:
常用命令选项
内存泄漏的类别,definite
、indirect
、possible
、reachable
四选一。默认为definite,possible
。
启用后,在客户端程序完成时搜索内存泄漏。如果设置为摘要,则表示发生了多少泄漏。如果设置为“完全”或“是”,则会按照--show-leak-kinds 和--errors-for-leak-kinds 选项指定,详细显示每个泄漏并/或将其计为错误。
在执行泄漏检查时,确定为了将多个泄漏合并到一个泄漏报告中,Memcheck 如何愿意考虑不同的回溯是相同的。设置为低时,仅前两个条目需要匹配。中度时,必须匹配四个条目。高时,所有条目都需要匹配。
指定在泄漏搜索期间要使用的一组泄漏检查试探法。启发式方法控制哪个内部指针指向一个块,使其被认为是可到达的。
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
。如果其中任何一个指示值未定义(甚至部分未定义),则会报告错误。
举个例子:
上面代码的功能是将一个未定义的数组a
复制到b
,但memcheck
并不会有什么异常信息输出,以为这种行为并没有实际造成什么影响。
再来看一个例子:
memcheck
会报出数组a
未初始化的异常信息。因为在下面的if
语句中使用到了j
的值。
注意,在这个例子中,并不是j += a[i]
这一行导致的memcheck
的报错,因为这里的未定义行为对于memcheck
是不可见的。仅在必须决定是否执行 printf(程序的可观察动作)时,Memcheck 才会报错误信息。
大多数低级操作(例如加法)都会导致Memcheck
使用操作数的 V 位来计算结果的V bit
。即使结果是部分或全部未定义,也不会报错。
对定义性的检查仅在以下三个地方进行:
当使用一个值生成内存地址
需要做出控制流决策
检测到系统调用,
Memcheck
会根据需要检查参数的定义性。
为什么不是每当从内存中读取数据的时候进行检查呢,然后判断是否将未定义的值加载到 CPU 寄存器中呢?这行不通,因为完全合法的 C 程序会在内存中例行复制未初始化的值,并且我们不希望对此有进行报错。考虑这样的一个例子:
这里的结构体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 中。目的是尽可能长时间地重新分配此块。在此之前,所有尝试访问它的尝试都会引发一个无效地址错误。
版权声明: 本文为 InfoQ 作者【Simon】的原创文章。
原文链接:【http://xie.infoq.cn/article/4440d2f6dcc279618fbf00626】。文章转载请联系作者。
评论