FEATURED · 精选文章

C++内存泄漏检测全攻略:从ASan到Valgrind的工具与实践

发布时间 / 2026/9/7 19:03:59
来源 / 创域科博编辑部
栏目 / 资讯中心
C++内存泄漏检测全攻略:从ASan到Valgrind的工具与实践 1. 为什么每个 C 开发者都需要一套内存泄漏检测方案内存泄漏大概是 C 项目里最让人头疼的问题之一。不像数组越界会立刻崩溃泄漏是慢性的——程序跑着跑着内存占用越来越高最后在客户现场或者线上环境突然挂掉而你本地怎么复现都复现不出来。更难受的是C 不像 Java 或 Go 自带垃圾回收你 new 出来的每一个对象都必须自己负责回收。哪怕只是一个分支路径上忘记 delete长时间运行下来就是灾难。我见过不少团队项目跑了一两年内存占用从启动时的 200MB 慢慢涨到 2GB最后 OOM 被系统杀掉。查到最后根源往往就是一两个看起来人畜无害的地方某个容器里存了裸指针没释放、某个单例里缓存越攒越多、某个回调闭包意外延长了对象的生命周期。这类问题用代码走查的方式找效率极低因为代码量一旦上来人的注意力根本没有办法覆盖所有路径。所以与其靠“眼神执法”和“经验堆砌”不如直接用工具。我现在的要求是任何一个 C 项目只要生命周期会超过半个月、或者要交付给客户部署必须有一套内存泄漏检测方案接入到日常开发流程里。这篇文章就把我这些年用过的工具、踩过的坑、总结出来的工作流一次讲清楚从 Linux 到 Windows从开源免费到商业收费全给你捋一遍。不管你是刚接触 C 的新手还是被内存泄漏折磨了挺久的老人都应该能从这里找到适合自己项目的方案。2. 工具选型之前先搞清楚内存泄漏到底是怎么发生的在推荐具体工具之前我建议你先花几分钟理解一下内存泄漏的几种常见形态。因为不同的泄漏类型适合的检测工具是不一样的。选对了工具事半功倍选错了折腾一整天也定位不到问题。2.1 四种常见的泄漏形态第一种是常规泄漏也就是你 new 了一个对象但丢失了指向它的所有指针导致这个内存块既无法使用也无法释放。这种情况多数发生在函数提前返回、异常抛出或者赋值覆盖了原有指针的时候。这类问题最好查几乎所有检测工具都能找出来。第二种是集合类泄漏。你在 map、vector、list 这些容器里存了指针程序结束遍历时忘了逐元素 delete。这类问题检测工具能报告出来但定位起来稍微麻烦一点因为泄漏报告指向的往往是容器插入元素的代码位置而不是你逻辑上应该释放的位置。第三种是第三方库或系统资源泄漏。比如你调用了某个 C 库的 API文档里要求你用完必须调用对应的释放函数你忘了。或者你反复打开文件句柄、数据库连接、GDI 对象这些不算传统意义上的堆内存泄漏但效果一样——系统资源被耗尽。这类问题纯内存检测工具往往无能为力需要结合系统监控工具一起看。第四种是最阴间的生命周期异常。你的对象并没有真正泄漏但是因为某个全局缓存、观察者模式里的订阅关系、或者 lambda 捕获了 this 指针导致对象一直无法被销毁。这类问题从堆内存的角度看可能根本检测不出来或者报告出来的泄漏数量极少但非常顽固。处理这类问题往往需要靠分析工具给出来的“泄漏对象调用栈”结合业务逻辑来判断。2.2 检测工具的三大流派理解了泄漏形态再看工具就清楚了。目前主流的检测工具分三个流派第一类是编译插桩型代表是 AddressSanitizerASan。它在编译时对每次内存分配/释放操作插入检测代码运行时记录内存块的状态和调用栈。优点是精度高、速度相对快比 Valgrind 快一个数量级缺点是需要重新编译代码而且对编译器版本有要求。第二类是动态二进制插桩型代表是 Valgrind 的 Memcheck。它不需要重新编译直接在你的可执行文件外面包一层所有内存操作都会被拦截并分析。优点是省事、覆盖全缺点就是慢程序运行速度会慢 10-50 倍不适合跑大型程序。第三类是GC 或智能指针辅助型严格说不是检测工具而是预防工具。比如 Visual Studio 的 CRT 调试堆、各种商业内存检测库如 Intel Inspector。这类工具往往是运行时检查分配与释放是否匹配有的还能自动填充内存模式来帮助识别未初始化内存和越界。我的实践经验是ASan 日常用Valgrind 定期跑Visual Studio 诊断工具在 Windows 下用商业工具在实在搞不定的时候上。下面我逐个展开给你讲清楚。3. 我的主力工具推荐与实操指南3.1 Linux/macOS 下的首选AddressSanitizerASan如果你用 GCC 或 Clang 编译代码那 ASan 是你最应该先掌握的工具。它是编译器内置的不需要安装额外软件只需要在编译时加一个 flag然后跑一次程序泄漏报告直接就打出来了。以 GCC 为例假设你原来是这样编译的g -o myapp main.cpp utils.cpp只需要改成g -fsanitizeaddress -g -o myapp main.cpp utils.cpp这里-fsanitizeaddress开启 AddressSanitizer编译出来的程序会带着完整的内存检查逻辑-g加上调试信息这样报告里显示的就是源码行号而不仅仅是地址。运行的时候直接执行编译出来的程序就行了。如果程序退出时确实有内存泄漏你会在终端看到类似这样的输出 12345ERROR: LeakSanitizer: detected memory leaks Direct leak of 200 byte(s) in 5 object(s) allocated from: #0 0x4b2d50 in operator new(unsigned long) #1 0x4d2a3f in createObject() /home/user/project/leak.cpp:15:14 #2 0x4d2a6e in main /home/user/project/leak.cpp:28:5 #3 0x7f5a03b34a86 in __libc_start_main /build/glibc-Src/...看到没有它直接告诉你泄漏了多少字节、在哪个对象的哪个分配点、谁的调用栈一路追下来。你只需要打开leak.cpp第 15 行大概率一眼就能看到那个没有释放的new。ASan 对编译器的要求GCC 4.8 以上基本可用5.1 以上比较完善Clang 3.1 以上基本可用3.6 以上比较完善Windows 下 MSVC 从 Visual Studio 2019 16.4 版本开始也支持了但体验不如 Linux使用的时候有几个细节需要注意。第一最好开启-fno-omit-frame-pointer保证调用栈完整。第二ASan 报告有一个上限默认是 10 个泄漏点如果你觉得不够可以通过环境变量调整export ASAN_OPTIONSdetect_leaks1:max_leaks100第三如果你不想让 ASan 因为某些第三方库的内部行为误报还可以通过suppressions文件过滤这个后面详细说。3.2 老牌经典Valgrind 的 MemcheckValgrind 是很多老 C 程序员接触的第一个内存检测工具。它的原理是在运行时动态编译你的二进制文件把每条内存读写指令都拦下来做检查。好处是你不用重新编译直接拿现成的程序就能跑坏处就是慢非常慢。使用方式很简单valgrind --leak-checkfull --show-leak-kindsall ./myapp--leak-checkfull是必须的这样才会给出每一个泄漏内存块的分配调用栈--show-leak-kindsall会把 definitely lost确定泄漏、indirectly lost间接泄漏、possibly lost可能泄漏、still reachable仍可达严格说不是泄漏全部显示出来。跑完之后你会得到一份很长的报告里面逐条列出了每个泄漏点。我最常用的处理流程是先把报告重定向到文件里valgrind --leak-checkfull --show-leak-kindsdefinite --log-filevalgrind.log ./myapp然后用 grep 统计 definitely lost 的总数和位置grep -n definitely lost valgrind.log找到问题之后修代码再跑一遍确认清零。Valgrind 最大的痛点就是慢。我曾经在一个负责图像处理的模块上跑 Valgrind正常 2 秒钟出结果的操作硬是跑了 3 分多钟。所以我的建议是不要拿 Valgrind 跑完整流程只跑测试用例或者小规模数据。你在 CI 里跑一组精心设计的内存压力测试每个用例数据量控制在几百 MB 以内这样一来速度可以接受二来覆盖也基本够。补充一句Valgrind 不只是检查泄漏它还能检查未初始化内存使用、越界读写、重复释放、非法地址访问等问题。这些都是 C 程序员平时很难靠肉眼发现的。3.3 Windows 下的选择Visual Studio 诊断工具与 CRT 调试堆Windows 下做 C 开发的兄弟们最方便的还是 Visual Studio 自带的能力。微软的 C 运行时库内置了一个调试堆可以通过_CrtDumpMemoryLeaks()来输出泄漏报告。基础用法很简单就在你的程序入口附近加上#include crtdbg.h int main() { _CrtSetDbgFlag(_CRTDBG_ALLOC_MEM_DF | _CRTDBG_LEAK_CHECK_DF); // 你的业务代码... // 程序退出时会自动检查泄漏 }这样设置之后程序运行结束时如果检测到堆内存泄漏输出窗口会打印类似这样的信息Detected memory leaks! Dumping objects - {1452} normal block at 0x0105C5C8, 40 bytes long. Data: CD CD CD CD CD CD CD CD CD CD CD CD CD CD CD CD那个{1452}是分配序号在 VS 的调试器里通过_CrtSetBreakAlloc(1452)可以让程序在这个分配点直接中断然后你就能看调用栈了。这个技巧在处理“报出来泄漏但找不到在哪 new 的”情况时非常有用。Visual Studio 2019 及以后版本还集成了诊断工具菜单路径是“调试 - 性能分析器 - 内存使用率”。你启动诊断后程序运行期间的内存分配都会被记录下来随时可以停下来看快照、对比两个快照之间的增量点击单条记录还能看到完整的分配调用栈。这个工具在排查整体内存增长趋势时特别直观但它比 ASan 重一般用于“知道大概有泄漏但不知道在哪里”的初步排查。Windows 下还有一个选项是Dr. Memory它跟 Valgrind 一样是动态二进制插桩不需要重新编译支持 32 位和 64 位程序。说实话它的检出率和 Valgrind 差不多但在 Windows 下偶尔误报多一点。我一般把它当作 Visual Studio 诊断工具的补充。3.4 商业工具什么时候值得花钱如果你遇到的是很复杂的泄漏场景比如大型游戏引擎、交易系统、或者嵌入式环境下的 C 程序开源工具可能就不太够用了。这时候商业工具的介入效率会更高一点。Intel Inspector是 Intel 家的内存和线程调试工具对 Intel 平台上的性能开销优化得很好而且 UI 做得很友好能按时间轴查看分配/释放事件。它是 Visual Studio 的插件装完直接在 VS 里点几下就能用不需要改代码。BoundsChecker是比较老牌的工具早年是 MicroFocus 家的之前叫 DevPartner功能非常全面。但问题是对新版本编译器的支持跟进得慢而且价格相当贵。我个人只在客户明确要求、或者项目安全性要求极高时推荐使用。Visual Studio Enterprise 的代码分析其实也值得一提。VS 企业版自带了一套静态分析规则其中有一部分能检查出常见的泄漏模式比如在循环里 new 对象但没有释放、动态分配的内存被再次赋值覆盖等等。静态分析的好处是快、一次覆盖全缺点是不能验证动态运行时行为。我的建议是团队预算有限先用好 ASan Valgrind VS 诊断工具。商业工具的效果确实好但好得有限如果你连 ASan 都没有接入到日常开发流程里就算买了商业工具问题也还是会反复出现。4. 从零到一在项目中落地内存泄漏检测的完整流程工具列了这么多光有工具不会用等于白搭。我给你走一遍我实际在项目里落地这套方案的完整流程你可以直接照着做。4.1 第一步建立基线不管接入什么工具第一步都是先搞清楚现状——你这个项目现在到底有没有泄漏有多少以 Linux 项目为例我会先在本地把代码用 ASan 重新编译一遍跑一遍核心测试用例然后把报告存下来。第一次跑往往能扫出一堆问题这个不用慌很正常。# 构建脚本里加两个 flag cmake -DCMAKE_CXX_FLAGS-fsanitizeaddress -fno-omit-frame-pointer -g .. # 跑测试把 ASan 报告存到日志文件 ASAN_OPTIONSdetect_leaks1:log_pathasan_report ./run_tests这一步的目标不是立刻把泄漏修干净而是得到一个报告知道泄漏点有多少、集中在哪些模块。把报告按模块分类你就知道优先级该怎么排了。4.2 第二步按模块逐个修复拿到基线报告后不要试图一次性修完所有问题。我修过量很大的项目经验是一次只处理一个模块修完立刻验证不要把改动堆在一起。举个例子假设报告里显示network/目录下的代码有 10 个泄漏点。那你就把network/相关的代码拿出来单独跑测试逐个修复每修一个就重新编译跑一次。确认network/目录的泄漏清零之后再换下一个模块。这里有一个非常实用的技巧ASan 支持按函数名或源文件路径做 suppression。如果一个模块是第三方库的你暂时不能改它那就把它过滤掉专心看自己的代码# 新建一个 suppressions.txt leak:internal_third_party_lib # 运行的时候指定 ASAN_OPTIONSsuppressionssuppressions.txt ./myapp这样报告会干净很多。4.3 第三步接入 CI让机制沉淀下来工具用一次是救火接入 CI 才算真正建立防线。我在 CI 里是这样配置的每个 Pull Request 提交之后跑一遍专门的“内存检测任务”。这个任务不跑全量测试只跑一小部分精心挑选的内存敏感用例包括反复创建和销毁对象的压力测试覆盖所有主要业务路径的集成测试程序启动和退出流程的完整性测试Jenkins 或者 GitHub Actions 里配置起来都很快。关键是把 ASan 编译出的检测程序跑起来的命令写成一个脚本日志留档如果检测到泄漏直接让构建失败。GitHub Actions 的一个示例步骤大概是这样的- name: Build with ASan run: | cmake -DCMAKE_CXX_FLAGS-fsanitizeaddress -g .. make -j$(nproc) - name: Run memory tests run: | ASAN_OPTIONSdetect_leaks1 ./build/tests/mem_related_tests env: ASAN_SYMBOLIZER_PATH: /usr/bin/llvm-symbolizer这样做的效果就是当天泄漏当天发现不会拖到产品发布前才爆发。4.4 第四步性能调优与白名单管理凡事都有代价ASan 也不是免费的午餐。开启 ASan 之后程序运行时间大概增加 1.5-2 倍内存占用增加 2-3 倍这在调试阶段完全可以接受。但如果你的程序本身对性能极其敏感——比如实时渲染引擎、高并发网关——那你需要挑一个性能窗口来跑检测任务或者只在 CI 的测试分支里开启 ASan日常开发分支保持正常编译。另外随着代码长期演进你会发现有些“泄漏”其实是误报或者设计如此。比如程序启动时加载一个全局单例故意不释放程序退出时让系统回收。这种情况下与其每次报告都去解释“这是设计如此”不如直接加入白名单leak:createGlobalInstance管理白名单的原则是每个白名单条目必须带有注释说明为什么这个泄漏是可接受的。不然时间一长后人看到白名单一头雾水反而容易掩盖真的问题。5. 实战排查几个我踩过的典型泄漏案例工具选好了流程也跑通了下面这些是我在实际项目中反复碰到过的泄漏场景每个都有代表性。你可以对照着看看自己的代码里有没有类似的问题。5.1 容器里存了裸指针vectorFoo* 忘了遍历 delete这个简直是 C 泄漏的第一大来源。很多人图省事在 vector 里直接存new出来的对象指针结果容器clear()的时候只是清掉了指针本身对象还在堆上。// 错误的写法 std::vectorMyClass* v; for (int i 0; i 10; i) { v.push_back(new MyClass()); } v.clear(); // 泄漏了 10 个 MyClass 对象 // 正确的写法 for (auto* p : v) { delete p; } v.clear();ASan 报告会直接指出new MyClass()那一行是泄漏分配点。修复方式是把裸指针换成智能指针或者至少写一个辅助的清理函数别把释放逻辑散落各处。5.2 多态析构函数不是 virtual有一个泄漏场景特别隐蔽基类析构函数没有声明为virtual你通过基类指针删除派生类对象时派生类的析构函数根本不会被调用。class Base { public: ~Base() {} // 应该加 virtual }; class Derived : public Base { int* data; public: Derived() { data new int[100]; } ~Derived() { delete[] data; } // 永远不被调用! }; Base* p new Derived(); delete p; // 只调用了 Base::~Base()data 泄漏这种泄漏麻烦的地方在于从 LeakSanitizer 的视角看泄漏点指向的是data new int[100]那行但如果你不认识 pattern会花很长时间去查为什么这里的delete没生效。5.3 跨模块分配释放不匹配Windows 上有一个非常经典的坑在一个 DLL 里new出来的内存却在 EXE 里delete。如果 DLL 和 EXE 使用的运行时库不一致一个用/MD一个用/MT它们的堆是不同的delete一个来自不同堆的指针会导致崩溃或者内存损坏。这类问题用检测工具能查但提示往往不够直观。Valgrind 在 Linux 上不会太冲突因为所有库共享同一个堆但在 Windows 上这类问题极其常见。我的建议是定义清晰的接口边界跨模块传递指针时必须同时提供创建和销毁函数保证谁创建谁销毁。// module.h #ifdef __cplusplus extern C { #endif void* module_create_data(); void module_destroy_data(void* p); #ifdef __cplusplus } #endif5.4 lambda 捕获生命周期陷阱现代 C 里 lambda 用得多泄漏也变得隐蔽了。比如你在一个循环里创建了很多 lambda每个 lambda 捕获了 this 指针然后把这些 lambda 存到某个容器里异步执行。如果容器的生命周期比 this 指向的对象长等 lambda 真正执行时this 指向的对象已经销毁了。这种情况严格说不是内存泄漏而是悬垂指针。检测工具报告的往往是“栈上局部变量在某地址被访问”之类的信息。排查这类问题最有效的手段是开启 ASan 的 use-after-free 检测默认开启报告里会直接写明 “heap-use-after-free”并给出释放点和访问点的调用栈。那时候就看到直观很多了。5.5 第三方 C 库的申请释放不匹配最后说一个很多人在嵌入式环境下会遇到的问题你调用了某个 C 库它内部用的是malloc你释放时却用了delete。或者反过来某个库要求你用free释放它返回的指针你却写成了delete[]。这类问题在纯 C 内存检测工具里有时会漏报因为工具只关注new/delete的匹配不关注malloc/free的匹配。Valgrind 在这方面做得更好它会同时检查malloc/free和new/delete这两套体系。所以遇到三方库相关的泄漏优先上 Valgrind。6. 工具对比总结与实践建议为了让你看得更清楚我把主流工具放在一起做了个对比工具平台是否需要重新编译运行开销检出能力上手难度适合场景ASan / LeakSanitizerLinux/macOS/Windows(MSVC)是低1.5-2x堆泄漏、越界、use-after-free低日常开发、CI 集成Valgrind MemcheckLinux/macOS否高10-50x堆泄漏、越界、未初始化低深入排查、三方库问题VS CRT 调试堆Windows是Debug 模式低堆泄漏低Windows 下快速检测VS 诊断工具Windows否中堆泄漏、内存增长趋势低Windows 下性能分析Dr. MemoryWindows/Linux否高堆泄漏、越界中Windows 下替代 ValgrindIntel InspectorWindows/Linux是中堆泄漏、线程错误、内存增长中大规模复杂项目、性能敏感场景GCC/Clang -fanalyzerLinux/macOS是编译期静态模式识别低代码静态检查补充我个人最常用的组合是日常开发ASan每次编译跑测试都带着发现问题当场解决。定期巡检每周跑一次 Valgrind 全量测试专门抓 ASan 可能漏掉的三方库问题。Windows 交付前VS 诊断工具跑一遍整体内存轨迹确认没有明显增长趋势。疑难杂症如果上面三招都搞不定那就是比较深的问题了。这时我会打开 Intel Inspector配合日志分析逐帧排查。关于选型给你一个更简单的判断逻辑如果项目从零开始、可以在本地自由编译无脑 ASan如果项目依赖很深、没法重新编译全套依赖就用 Valgrind如果是 Windows 下的商业项目优先 Visual Studio 家族如果既要性能又要覆盖面可以考虑商业工具。还有一个容易被忽略的点内存泄漏检测工具要组合使用而不是只迷信某一个。ASan 检测内存泄漏很准但对某些情况比如mmap分配的内存就不会报告Valgrind 覆盖面广但慢配套 fast 工具做日常开发很互补。比如我经常在代码提交前先跑一次 ASan 相关的单测跑完再用 Valgrind 复盘一遍核心模块整个流程下来基本能把问题拦在发布之前。7. 实操中的细节与避坑心得工具用多了你会发现很多文档里不写的细节这些往往才是决定你能不能快速定位问题的关键。我捡几个亲测有用的分享给你。7.1 符号化调用栈用 ASan 或者 Valgrind 时如果报告里的调用栈全是地址而不是函数名多半是缺-g调试信息或者symbolizer没找到。ASan 默认会找llvm-symbolizer但如果你用的是 GCC 编译需要确保ASAN_SYMBOLIZER_PATH指向正确的可执行文件export ASAN_SYMBOLIZER_PATH/usr/lib/llvm/bin/llvm-symbolizer如果实在找不到也可以先把报告原样保存再用addr2line手动解析addr2line -e myapp -f -C 0x4d2a3f7.2 环境变量控制检测开关ASan 的行为可以由ASAN_OPTIONS环境变量精细控制我常用的几个环境变量作用detect_leaks1开启泄漏检测halt_on_error1遇到第一个错误就停fast_unwind_on_malloc0使用慢速但更准确的调用栈回溯max_leaks100报告泄漏的最大条数allocator_may_return_null1内存不足时返回 NULL 而不是崩溃suppressionspath指定过滤文件如果你不希望检测工具因为某些已知但无害的泄漏反复打断流程这个列表值得收藏。7.3 别忽视“still reachable”和“possibly lost”Valgrind 报告里会有“still reachable”——意思是这块内存仍然能通过某个指针访问到程序退出前理论上可以释放但你没有释放。很多人直接忽略它但有经验的开发者会关注它static 局部或者全局缓存通常表现为 still reachable如果你发现它随着运行时间不断增长那就说明你的缓存策略没做好仍然是需要修的。Quoting Valgrind 官方文档still reachable 不一定是泄漏但如果出现在持续运行的服务器程序里它的累积效应跟泄漏一样致命。7.4 用最小复现来压榨大型程序有时候泄漏只在大数据量下出现而 ASan/Valgrind 跑大程序又慢又不方便调试。我的办法是先根据报告里的调用栈构造一个最小复现。比如报告指向某个图像处理函数那你就写一个小的独立程序反复调用这个函数几千次在 ASan 下看看内存是否稳定增长。这一步做出来问题基本就锁定了。7.5 善用宏与 RAII最后分享一个预防层面的建议与其每次泄漏后修不如在代码里就把容易出错的地方用 RAII 封装起来。比如需要手动管理多个资源的函数尽量用std::unique_ptr或者自定义的 guard。哪怕只是为了内存检测RAII 都能帮你把“忘记释放”这种低级错误从根上规避掉。我自己写代码时有一个不成文的规定凡是需要new的地方除非有十倍理由否则一律用std::make_unique或std::make_shared。裸new只出现在算法核心、性能热点里明确需要控制内存布局的场景。8. 后续可以怎么扩展这套工具链跑顺之后你会发现可以继续往下做不少东西。比如把 ASan 接入到模糊测试fuzzing流程里因为 fuzz 生成了大量边界输入最容易触发平时测不到的路径或者结合性能剖析工具profiler对比内存增长曲线找出泄漏与特定阶段的对应关系。我现在的做法是项目里所有 C 服务从 CI 到测试环境只要条件允许全部用 ASan 编译的构建产物跑一遍核心用例。这么做了大半年线上“内存静默增长”的问题基本销声匿迹了。说实话内存泄漏检测这件事投入产出比非常高。花半天时间把工具链搭好省下来的是无数个熬夜排查线上问题的夜晚。也希望这篇文章能帮你在自己的项目里少走点弯路。
RELATED — 相关阅读

相关资讯

LATEST — 最新资讯

最新发布

TODAY — 本日精选

新闻

WEEKLY — 本周精选

新闻

MONTHLY — 本月精选

新闻