谈谈如何利用 valgrind 排查内存错误

时间:2022-07-22
本文章向大家介绍谈谈如何利用 valgrind 排查内存错误,主要内容包括其使用实例、应用技巧、基本知识点总结和需要注意事项,具有一定的参考价值,需要的朋友可以参考一下。

导读

Valgrind 最为开发者熟知和广泛使用的工具莫过于 Memcheck,它是检查 c/c++ 程序内存错误的神器,报告结果非常之精准。

本文主要分享作者在使用该神器解决内存问题的过程中积累的一些实战经验,希望帮助你快速定位问题甚至在编码阶段就规避这些问题。

Memcheck 可以检查哪些内存错误?

Memcheck 可以检查 c/c++ 程序中常见的以下问题:

  1. 内存泄漏,包括进程运行过程中的泄漏和进程结束前的泄漏。
  2. 访问不应该访问的内存,即内存非法读写。
  3. 变量未初始化,即使用未定义的值。
  4. 不正确的释放堆内存,比如 double free 或者 malloc/new/new[]free/delete/delete[] 不匹配。
  5. 内存块重叠,比如使用 memcpy 函数时源地址和目标地址发生重叠。
  6. 向内存分配函数的 size 参数传递非法值(fishy value),比如,负值。

其中,问题 1 中的内存泄漏一般是比较好定位与解决的,但是作者在实际项目开发中遇到过 still reachable 错误掩盖 definitely lost 错误的情况,这就加大了定位内存泄漏点的难度。问题 2 和 3 属于出现频率较高的一类内存错误,它们往往会引发程序 crash,这类错误必须要高度重视,且一定要解决。问题 4、5、6 也属于典型的内存错误,使用 Memcheck 可以很快的定位并解决这些问题。

对于 c/c++ 开发者来说,如果不能及时发现并消除这些内存隐患,那么,偶尔的 crash、难以诊断的 coredump 将会是挥之不去的噩梦。而且这些内存问题可能很难通过一己之力去定位,尤其是当程序的代码量庞大、逻辑抽象且复杂的时候,更是会让人焦头烂额。此时,Memcheck 就是辅助我们解决这堆内存问题的神器。

使用 Memcheck 解决问题的原则

当使用 Memcheck 工具输出程序的内存检查报告后,我们该如何着手去解决报告中的问题呢?作者根据长期使用积累的经验,总结了如下四个原则。

原则 1,内存非法读写错误一定要解决

这类错误在检查报告中以 Invalid read/write of size x 的格式输出。这类错误出现的场景主要有三种:

  1. 动态分配的内存已经被释放,然而开发者还在对这块无效的内存进行读写操作。

比如悬挂指针,即基类指针指向的子对象已经被释放,然而却继续使用该基类指针调用其方法。

  1. 动态分配的内存未被释放,然而访问这块内存发生越界。

比如拷贝字符串时忘记结尾的字符

比如 memcpy(dst, src, len);,src 内存大小为 1024 B,然而 len 的值为 1025。

  1. 访问栈空间越界(即堆栈溢出)

比如对数组的越界访问。

其中,场景 1 的出现频率较高。因此,当我们处理 Invalid read/write 这类内存读写错误时,一个较为高效的解决思路是:首先要考虑的是非法读写的 block(内存块)是否在读写之前已经因为程序的某些异常处理被释放了,然后仔细的审查代码来验证这种可能性。如果排除了内存释放的可能,我们再看是否存在内存访问越界的可能,然后继续去验证。

在这个过程中,我们要充分阅读 Memcheck 输出的 Invalid read/write 的详细信息。比如,非法读写的内存块是在哪里分配的?在哪里释放的?又是在哪里非法读写的? 将这些线索结合到具体的项目代码中,帮助我们更高效的解决问题。

忽略这类错误将会给自己的程序带来巨大的隐患,最坏的结果是程序 crash,这对于服务器来说是致命的。

记得有一次使用 c++11 的范围循环语法遍历删除 map 中的元素,Memcheck 检查出了红黑树节点写内存错误。当时以为错误出现在 STL 库底层,且程序改动很小,便忽略了这个错误,熟不知底层的错误正是由于上层代码引起。后来在压测中发现程序频繁 crash,正是因为该错误导致。幸亏当时服务程序没有上线,否则后果不敢想象。所以,这类错误一定要解决,作为服务端开发者,再谨慎也不为过

最后,我们来演示一下这类错误,代码如下:

void foo() {
  char* buffer = (char*)malloc(5);
  strcpy(buffer, "01234");
  cout << "buffer[5]="
       << buffer[5] << endl;
  free(buffer);
}

foo 函数中动态分配了 5 个字节大小的内存块,随后拷贝字符串 "01234" 到这块内存,但是忽略了字符串的结尾字符 ,最终将 6 字节大小的字符串写入到 5 字节大小的内存空间,导致内存写越界,Memcheck 报错为 Invalid write of size 2

最后一行代码在打印 buffer[5] 时发生内存读越界,即字符数组越界访问,Memcheck 报错为 Invalid read of size 1

这里只演示了部分内存非法读写的场景,其它的诸多内存非法读写的场景,读者可自己尝试编码复现。

原则 2,变量未初始化错误一定要解决

这类错误在检查报告中以 Use of uninitialised value of size x 或者 Conditional jump or move depends on uninitialised value(s) 的格式输出。即程序中使用了未初始化的变量或者从上层未初始化的变量中逐层传递下来的未定义的值。

一般来讲,这类错误都是变量定义后未初始化导致。所以,一定要养成变量定义并同时初始化的良好的编程习惯,将这类错误扼杀在摇篮里。其次,如果检查报告出现这类错误,那么千万不要忽略这个错误,一定要及时修复,及时止损。

作者曾经因为没有将指针变量初始化为空,导致它成为野指针,各种指针判空逻辑均对它无效,从而造成了程序各种匪夷所思的 crash,花了很多天时间才最终定位该问题。所以,不要给自己找麻烦。

如果很难确定这类错误的根本原因,可以尝试使用 --track-origins yes 跟踪未初始化变量的问题,来获取额外的信息。不过这会使得 Memcheck 运行得更慢,但是得到的额外信息通常可以节省很多时间来找出未初始化的值从哪里来。

最后我们来演示一下这类错误,代码如下:

void foo(int y) {
  cout << y << endl;
}

int main() {
  int x;
  foo(x);
  return0;
}

main 函数中定义了一个没有被初始化的变量 x,接下来传入 foo 函数,该函数的功能是打印传入的参数。由于变量 y 的值依赖于 x,所以 y 的值是未定义的,此时打印变量 y 相当于间接使用了未初始化的变量,Memcheck 会报告这类错误。

原则 3,开启 -show-reachable=yes 命令行选项

强烈建议在运行 Memcheck 时增加 -show-reachable=yes 命令行选项,它可以帮我们检查全局指针、static 静态指针相关的内存泄漏问题。

强烈建议在进程结束时,正确而优雅的释放所有资源,包括关闭定时器和套接字、释放全局或者静态对象、回收线程资源等。培养严谨的编程风格。

为何一定要开启 reachable 命令行选项呢?别急,在原因揭晓之前,我们先来了解一下内存泄漏的定义以及 Memcheck 工具报告的四种内存泄漏形式。

究竟如何定义内存泄漏?

作者认为内存泄漏有如下两种场景:

  1. 内存已经分配,但是在进程结束之前没有被优雅的释放。

也就是说,在进程结束之前的那一刻,进程依然拥有指向该内存块的指针,指针并未丢失,仍然可以获取并访问(still reachable)。

具有进程级别的生命周期的静态指针或者全局指针指向的内存块没有在进程结束前被释放是造成这种场景下的内存泄漏的主要原因。

  1. 内存已经分配,但是在进程运行过程中不能被正常释放。

此时,进程不再拥有指向该内存块的指针,指针丢失。这种场景是为 c/c++ 开发者所熟知的真正意义上的“内存泄漏”。造成这种场景下的内存泄漏的原因主要有:

  • 开发者在编码过程中忘记了释放内存。
  • 内存释放操作在某些异常处理逻辑之后,而这些异常处理逻辑在 return 之前并未做好内存释放的工作。
  • 一些需要实时缓存的数据虽然在连接建立时能被正常释放,但是在连接断开时却并未做好资源清理工作,比如流媒体服务中的重传缓存、gop 缓存。

Memcheck 输出的四种内存泄漏形式

内存检查报告按照丢失字节数从小到大排序展示。下面来认识下 Memcheck 工具输出的检查报告中的四种内存泄漏形式:

  1. definitely lost,指针确认丢失。

当进程在运行或者进程结束时,如果一块动态分配的内存没有被释放,并且程序中已经找不到能够正常访问这块内存的指针,则会报这个错误。也就是说指针已丢失,但是内存未释放,这是真正的需要被关注的内存泄漏,需要尽快修复。

  1. indirectly lost,指针间接丢失。

当使用了含有指针成员的类或结构时可能会报这个错误。这类错误无需直接修复,他们总是与 definitely lost 一起出现,只要修复definitely lost 即可

  1. possibly lost,指针可能丢失。

当进程结束时,如果一块动态分配的内存没有被释放,且通过程序内的指针均无法访问这块内存的起始地址,但是可以访问这块内存的部分数据时,那么指向该内存块的指针可能丢失。也就是说原本指向内存起始地址的指针被重新指向了这块内存的中间的某个地址(即非起始地址)则会报这个错误。

大多数情况下应视为与 definitely lost 一样需要尽快修复,除非这是你有意而为之,并且你可以让已经指向内存非起始地址的指针经过某些运算重新指向这块内存的起始地址并释放它。

  1. still reachable,仍然可以获取指针并访问内存。

指针未丢失,内存未释放。如果程序是正常结束的,那么这类报错一般不会造成程序 crash,一般可以忽略掉。

这类指针基本上是静态指针或者全局指针,所以这些 still reachable 的内存块通常是只分配一次,并且具有进程级别的生命周期,正如 valgrind 官方手册描述的那样:

these blocks are usually one-time allocations, references to which are kept throughout the duration of the process's lifetime.

综上,对于这四种不同的内存泄漏形式,我们应该按照 definitely lostpossibly loststill reachable 的顺序依次解决。

still reachable 是内存泄漏吗?

其实,这种场景下的泄漏在严格意义上来讲也许并不能称之为内存泄漏,因为在进程运行过程中并没有泄漏问题。

虽然内存在进程结束之前确实未被释放, 但是指向这块内存的指针是 reachable 的,操作系统会获取这些指针并帮助我们释放内存。

但是,请注意,still reachable 可能会掩盖真正的内存泄漏 definitely lost,这就是作者为何强烈建议开启 reachable 命令行选项的原因。

作者曾经遇到过一个非常隐秘的内存泄漏问题:某次查看线上服务物理内存占用达到了 2G,开始以为是底层 jemalloc 未将内存归还操作系统导致,再加之 Memcheck 并未报出 definitely lost 错误,所以并没有认为是内存泄漏。过了一周,再次查看发现内存占用已经超过了 10G,这次毋庸置疑,绝对是内存泄漏了,但是 Memcheck 仍然检测不出哪里泄漏。最终不得已开启了 reachable 选项,让 Memcheck 报告出所有的 still reachable 信息,逐一排查这些可疑信息,终于定位了内存泄漏的点:原来是拉流缓存的数据包未在用户停止拉流后释放。后来,再次回顾这次解决内存泄漏的过程,发现逐一排查 still reachable 信息定位问题实在是效率低下,况且这次内存泄漏为何没有被报告出 definitely lost 错误?这是个问题。最终,将数据缓存结构的上层全局指针在进程退出时主动释放,结果这一次的内存检查报告不仅精确的定位到了内存泄露的地方,而且也没有了 still reachable 的错误。

所以,作者强烈建议养成在进程结束之前优雅的释放掉静态/全局指针、做好资源的清理工作的良好编程习惯,并在使用 Memcheck 时开启 reachable 参数,竭尽所能的消灭 still reachable 报错,这样不仅能暴露 definitely lost 错误,检查报告看起来也会清爽很多。

原则 4,周密思考!保证 Memcheck 测试到程序的每一个逻辑分支

在运行 Memcheck 之前,我们要周密的思考,列举出所有重要的测试场景,确保最大化的发挥 Memcheck 的作用。比如下面这几种测试场景就很重要:

  1. 弱网场景下是否进行了测试?

实验室环境总是比较理想的,也许 Memcheck 测试不出程序应对弱网环境的逻辑漏洞,所以,在丢包、延迟、乱序的弱网环境下使用 Memcheck 才能真正的暴露问题。

  1. 进程结束前的资源清理和释放逻辑是否进行了测试?

也就是说,你的程序是否具有捕捉并处理信号的能力?比如,捕捉并处理了 SIGINT 或者 SIGTERM 信号,那么当执行 ctrl + c 后,Memcheck 就可以在进程结束前检查信号处理函数的处理逻辑。

如果程序在退出逻辑中未对一些资源(内存,套接字,定时器,io 事件等)做释放,那么Memcheck 会检查到这些错误,也许是 still reachable 错误,上文已经提到,这个错误建议解决。

  1. 进程运行时的一些异常处理逻辑是否测试到位?

比如对于流媒体服务来讲,停止推拉流、推拉流失败、回源失败等相关的逻辑是否被测试到。

Memcheck 四种指针丢失情形的代码演示

definitely lost 与 still reachable 代码演示

首先,我们先演示绝对丢失和 still reachable 这两种情况。

void test01() {
  char* p = newchar[1024];
}

void test02() {
  staticchar* p = newchar[1024];
}

int main() {
  test01();
  test02();
  return0;
}

在 test01 中,new 出来的数组赋值给局部指针变量 p,test01 测试结束后,局部变量 p 丢失,内存未被释放,造成内存泄漏,Memcheck 会报告 definitely lost 错误。

在 test02 中,new 出来的数组赋值给具有进程级生命周期的静态指针变量 p,test02 测试结束后直到 main 函数返回前,静态指针 p 依然可以获取到,但是内存并未在进程结束前释放,Memcheck 会报告 still reachable 错误。

indirectly lost 代码演示

接下来演示间接丢失的情况。

class Object {
public:
  Object() { _p = newchar[1024]; }
  ~Object() { if(_p) delete _p; }
private:
  char* _p = nullptr;
};

void test03() {
  Object* obj = new Object();
};

int main() {
  test03();
  return0;
}

在 test03 中,我们 new 了一个 Object 类型的局部对象指针 obj,它的成员 _p 指向动态分配的数组,test03 测试结束后,局部变量 obj 丢失,内存未被释放且其内部成员 _p 指针也间接丢失,没有被释放。Memcheck 会报告 definitely lostindirectly lost 错误。

possibly lost 代码演示

接下来演示可能丢失的情况。

void test04() {
  char* data = newchar[1024];
  staticchar* p = data + 1;
}

int main() {
  test04();
  return0;
}

在 test04 中,我们 new 一个数组并返回给局部变量 data,随后声明静态指针 p 并指向数组第二个元素的地址,test04 测试结束后直到 main 函数返回前,静态指针 p 仍然可获得,但是 p 已经不再指向数组的起始地址。Memcheck 认为指向这块内存的指针可能已经丢失,会报告 possibly lost 错误。

接下来,我们在 test04 函数中增加一行代码 p = data;

void test04() {
  char* data = newchar[1024];
  staticchar* p = data + 1;
  p = data;
}

此时,静态指针 p 重新指向了数组的起始地址,所以 Memcheck 不会再报告 possibly lost 错误。但是 Memcheck 会报告 still reachable 错误,这是因为静态指针指向的数组空间没有被释放,在测试进程结束前仍然可以获取到导致,只要再加一行 delete [] data 或者 delete [] p 即可解决。

最后,我们在 test04 函数中再增加一行代码 p = nullptr;

void test04() {
  char* data = newchar[1024];
  staticchar* p = data + 1;
  p = data;
  p = nullptr;
}

现在,Memcheck 又会输出什么呢?答案是输出 definitely lost 错误。因为 p 为空指针,不指向任何已分配的内存块,且没有指向数组的非起始地址,所以不会有 still reachablepossibly lost 这两种错误。

此时,只有局部指针 data 指向数组首地址,但是在 test04 函数测试结束之前我们并没有释放这块内存,所以 test04 测试结束后局部指针 data 确认丢失,程序出现内存泄漏。

still reachable 掩盖 definitely lost 代码演示

最后来演示未释放全局或者静态指针导致 still reachable 掩盖了 definitely lost 报错的情况。

下面的代码就是模拟的上文提到那次隐秘的线上服务内存泄漏问题。简单描述一下代码逻辑:首先有一个 RtcStreamMgr 类型的全局指针,该类的内部成员是一个流名到数据包缓存队列的映射。接下来构造一个流名为 666,数据包缓存队列大小为 1 的键值对并插入到 map。最后来模拟删除 map 中流名为 666 的元素时忘记了 delete 其对应数据包缓存队列的场景。

class RtcPacket {
public:
  RtcPacket(int seq, int len)
   : _seq(seq), _len(len) {}
  ~RtcPacket() {}
private:
  int _seq;
  int _len;
};

class RtcStreamMgr {
public:
  std::map<std::string, std::list<
   std::shared_ptr<RtcPacket>>*>
    rtc_packet_map;
};
auto g_stream_mgr = new RtcStreamMgr();

void test05() {
  // 构造缓存数据包的map
  std::shared_ptr<RtcPacket>
    packet(new RtcPacket(1, 1024));
  autolist = newstd::list<
    std::shared_ptr<RtcPacket>>();
  list->push_back(packet);
  g_stream_mgr->rtc_packet_map["666"] = list;
  // 删除map元素,但未删除该元素对应的动态内存
  auto it = g_stream_mgr->rtc_packet_map.find("666");
  g_stream_mgr->rtc_packet_map.erase(it);
}

int main() {
  test05();
  return0
}

首先,删除 map 元素时未释放其对应的动态内存,显然,这会造成内存泄漏。其次,全局对象 g_stream_mgr 也是动态分配的内存,但是由于其生命周期是进程级,所以很多开发者不会在进程退出前去主动释放它,即使在原则上我们确实该释放它。然而,问题出现了:

  • 当在进程退出前不主动释放全局对象 g_stream_mgr 时,Memcheck 输出的都是 still reachable 错误。

这使得大多数开发者认为自己的程序并没有真正的内存泄漏问题,于是不会仔细阅读大篇幅的 reacable 报错,也就无法解决内存泄漏问题。

  • 当在进程退出前主动释放全局对象 g_stream_mgr 时,Memcheck 不再输出 still reachable 错误,而是精确的输出了 definitely lost 错误。

这使得开发者一眼便定位到了内存泄漏问题并轻松的解决它。

所以这就是上文提到的问题:在某些场景下,still reachable 报错会掩盖掉 definitely lost 报错,从而加大内存泄漏问题的排查难度。

不过这个掩盖的问题作者只在工作的开发机(CentOS,gcc 4.8.4,glibc 2.12,valgrind 3.11.0)上复现过,当为写这篇文章准备再次复现时(因为某些原因,之前复现过的开发机被回收了,只能在其他机器上复现)却无论如何也无法复现,回天乏术。

不过这也是个好消息,这意味着无论是否主动释放全局或者静态指针,都能精准定位到真正的内存泄漏问题。

最后,完整的内存泄漏演示代码[1]已经提交到了我的 github,你可以下载并亲自动手去验证。

Valgrind 的编译与使用

最后,说一下如何使用 valgrind,非常简单。首先通过 wget 命令下载 valgrind。

wget http://valgrind.org/downloads/valgrind-3.16.1.tar.bz2

接着执行 ./configure && make && make install,完成编译与安装。最后运行 valgrind,只需要执行下面的命令即可。

valgrind --tool=memcheck --leak-check=full --show-reachable=yes --log-file=path_of_log path_of_bin

也可以不指定 --took=memcheck,因为 Memcheck 是默认工具。

在运行 valgrind 时可能并不会一帆风顺,可能会出现如下报错:

valgrind: the 'impossible' happened: LibVEX called failure_exit().

遇到这种情况时,在运行时增加命令行选项 --vex-guest-max-insns=2 即可解决问题。

也有可能会出现如下报错:

valgrind: failed to start tool 'memcheck' for platform 'amd64-linux': No such file or directory

遇到这种情况时,我们需要执行 autogen.sh 脚本,之后再重新编译并安装 valgrind。

另外,还有几点需要说明:

  1. 在使用前需要保证你的可执行文件已经在编译时增加了产生调试信息的命令行参数 -g,否则检查报告不会输出问题代码的具体行数。
  2. 根据 Valgrind 的官方文档,它会导致可执行文件的速度减慢 20 至 30 倍。所以一般来讲,Valgrind 是无法应用到压力测试的场景之中的。
  3. 结束 Memcheck 检查的做法一般是发送 SIGINT 信号,即 ctrl + c。不要发送 SIGKILL 信号结束进程,否则无法生成检查报告。

关于 Memcheck 输出信息与相关命令行的更详尽且权威的介绍以及 Memcheck 的检测原理,可以阅读 valgrind-memcheck 官方手册[2]

最后,希望大家编写的程序能够输出和下图一样的 Memcheck 检查报告:no leaks,no errors。

完美的 memcheck 检查报告

至此,本文结束,感谢阅读。

参考资料

[1]valgrind_memcheck.cpp: https://github.com/yujitai/valgrind_test

[2]Memcheck: a memory error detector: https://www.valgrind.org/docs/manual/mc-manual.html