25. 内存泄漏检测:CRT调试堆

内存泄漏,说白了就是「申请了内存,但忘了还」。

在C++里,new了没delete,malloc了没free,这种问题在小型demo里可能无所谓——程序跑完操作系统会回收。但在服务端程序里,跑个几天几周,内存一点点被吃掉,最后OOM崩溃,那叫一个惨。

我个人习惯,在Windows上用Visual Studio开发时,一定会开启CRT调试堆。这玩意儿是微软内置的「内存警察」,能帮你抓到绝大多数内存泄漏。

25.1 什么是CRT调试堆

CRT是C Runtime的缩写。调试堆(Debug Heap)是CRT在Debug模式下提供的一套内存管理机制。

它做了三件事:

  • 记录每次内存分配——谁、在哪、分配了多少
  • 检测内存错误——比如越界写、重复释放
  • 报告泄漏——程序退出时,没释放的内存会列出来

我在项目中遇到过好几次,线上服务莫名其妙内存上涨,本地Debug一跑,CRT直接告诉我「第42行new了没delete」。嗯,省了我至少两天的排查时间。

25.2 如何开启CRT调试堆

其实不需要额外安装什么。Visual Studio的Debug配置默认就带了。你只需要在代码里加几行宏定义:

#define _CRTDBG_MAP_ALLOC
#include <stdlib.h>
#include <crtdbg.h>

注意顺序:_CRTDBG_MAP_ALLOC必须在#include <stdlib.h>之前定义。否则映射不生效。

然后,在程序入口(比如main函数第一行)调用:

_CrtSetDbgFlag(_CRTDBG_ALLOC_MEM_DF | _CRTDBG_LEAK_CHECK_DF);

这两步做完,程序退出时,如果有泄漏,输出窗口会打印类似这样的信息:

Detected memory leaks!
Dumping objects ->
{45} normal block at 0x0078A8C0, 16 bytes long.
 Data: <...> CD CD CD CD CD CD CD CD CD CD CD CD CD CD CD CD
Object dump complete.

那个{45}是分配序号。有了它,就能定位到具体是哪次分配泄漏了。

小技巧: 如果你用的是new表达式,建议也重载一下new操作符的调试版本。CRT默认只跟踪malloc/free,对new/delete的支持需要额外配置。最简单的方法:在项目设置里把「基本运行时检查」设为「两者(/RTC1, /RTCu)」。

25.3 定位泄漏的精确位置

光知道「第45次分配泄漏了」还不够。你得知道是哪行代码干的。

这时候_CRTDBG_MAP_ALLOC就派上用场了。它会把malloc、realloc、free等函数映射到带文件名和行号的调试版本。

举个例子:

// 没有 _CRTDBG_MAP_ALLOC 时,输出只有:
{45} normal block at 0x0078A8C0, 16 bytes long.

// 有了 _CRTDBG_MAP_ALLOC 后,输出变成:
{45} normal block at 0x0078A8C0, 16 bytes long.
 D:\myproject\main.cpp(32) : malloc(16)

看到没?直接告诉你main.cpp第32行。这就是我为什么强调宏定义顺序不能错的原因。

注意: CRT调试堆只在Debug模式下生效。Release模式下这些宏会被忽略。所以你的泄漏检测代码不要写在Release分支里,否则白忙活。

25.4 用_CrtSetBreakAlloc定点断点

有时候泄漏的分配序号是固定的,比如每次运行都是{45}。这时候你可以用_CrtSetBreakAlloc让程序在分配序号为45时自动断下来。

// 在 main 开头加上
_CrtSetBreakAlloc(45);

然后F5运行,程序会在第45次内存分配时触发断点。调用堆栈里清清楚楚地显示是谁调用了malloc。我曾经用这招抓到一个藏在三层回调函数里的泄漏——那感觉,就像在草堆里找到了针。

25.5 检测内存越界

CRT调试堆还有一个隐藏技能:检测缓冲区溢出。

它在每次分配的内存前后各加了一段「哨兵字节」,默认填充为0xFD。如果程序写越界了,哨兵被破坏,CRT会在free或程序退出时报错:

HEAP CORRUPTION DETECTED: after Normal block (#45) at 0x0078A8C0.
CRT detected that the application wrote to memory after end of heap buffer.

嗯,这个错误我见过太多次了。尤其是处理字符串的时候,strcpy忘了算结尾的'\0',一不留神就越界了。

25.6 手动触发泄漏报告

有时候你不想等到程序退出才看结果。可以在关键位置手动调用:

_CrtDumpMemoryLeaks();

这个函数会立即输出当前未释放的内存。我习惯在模块卸载或单元测试结束时调用一次,确保没有残留。

但要注意:如果调用了_CrtDumpMemoryLeaks之后又分配了内存,那泄漏报告就不完整了。所以一般放在程序退出前。

25.7 一个完整的示例

把上面这些串起来,写个完整的泄漏检测模板:

#define _CRTDBG_MAP_ALLOC
#include <stdlib.h>
#include <crtdbg.h>

int main() {
    _CrtSetDbgFlag(_CRTDBG_ALLOC_MEM_DF | _CRTDBG_LEAK_CHECK_DF);
    // 如果知道泄漏序号,可以取消下面这行的注释
    // _CrtSetBreakAlloc(45);

    // 你的业务代码
    int* p = (int*)malloc(sizeof(int) * 10);
    // 忘记 free(p);  // 这就是泄漏

    return 0;
    // 程序退出时,CRT自动报告泄漏
}

运行后输出窗口会显示:

Detected memory leaks!
Dumping objects ->
{45} normal block at 0x0078A8C0, 40 bytes long.
 Data: <...> CD CD CD CD CD CD CD CD CD CD CD CD CD CD CD CD
 D:\test\main.cpp(10) : malloc(40)
Object dump complete.

看到main.cpp(10)了吗?就是第10行那个malloc。一目了然。

25.8 CRT调试堆的局限性

说实话,CRT调试堆不是万能的。有几点你得心里有数:

  • 只跟踪CRT分配的内存——如果你用了VirtualAllocHeapAlloc或者第三方内存池,CRT管不着。
  • 性能开销大——Debug模式下每次分配都记录调用堆栈、填充哨兵,速度会慢不少。别在生产环境开。
  • 不检测全局/静态对象的泄漏——全局对象的构造和析构顺序复杂,CRT有时会误报或漏报。

我曾经在一个项目里,全局单例里new了一个对象,程序退出时CRT报泄漏。但其实那个对象在进程退出时会被操作系统回收。这种「伪泄漏」需要你结合业务逻辑判断。

25.9 总结一下

CRT调试堆是Windows平台上最趁手的内存泄漏检测工具之一。它不需要额外安装,不需要配置复杂的工具链,几行宏定义就能用起来。

我个人建议:所有Windows上的C++项目,Debug配置里都默认开启CRT调试堆。养成习惯后,你会发现内存泄漏越来越少——因为每次写完代码跑一遍测试,CRT就帮你揪出来了。

核心要点:
  • 定义_CRTDBG_MAP_ALLOC并包含crtdbg.h
  • 调用_CrtSetDbgFlag开启泄漏检测
  • _CrtSetBreakAlloc定位特定分配
  • _CrtDumpMemoryLeaks手动触发报告
  • 注意:只在Debug模式下有效,Release模式自动关闭

嗯,CRT调试堆就聊到这儿。它虽然简单,但确实是我日常开发中离不开的工具。下次你遇到莫名其妙的内存上涨,不妨先试试它——说不定几分钟就找到问题了。

CRT调试堆工作流程 1. 开启CRT调试堆 _CrtSetDbgFlag(LEAK_CHECK_DF) 2. 记录每次内存分配 记录文件名、行号、分配序号、大小 3. 运行时检测 哨兵字节检查越界 | _CrtSetBreakAlloc定点断点 | _CrtDumpMemoryLeaks手动报告 4. 输出泄漏报告 Detected memory leaks! {45} main.cpp(32) 仅在Debug模式下生效 | 不跟踪VirtualAlloc/HeapAlloc 全局/静态对象可能误报
专注资料整理