ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

C++内存泄漏的编译期拦截:Clang-Tidy与PVS-Studio实战

2026/9/9 18:29:15 拓冰建站 浏览量
C++内存泄漏的编译期拦截:Clang-Tidy与PVS-Studio实战 1. 为什么内存泄漏要提前到编译期来查1.1 一个典型的线上泄漏事故复盘先说一件真实的事。去年我维护的一个 C 网关服务每天凌晨都会触发一次内存告警RSS 稳定涨 200MB 左右。一开始怀疑是某个第三方库没释放用 Valgrind 拷了两天没复现又开 AddressSanitizer 跑完整回归也干干净净。但线上就是每天都在涨最后靠 gdb attach 上去手工数堆对象才定位到一个异常分支里new[]之后直接return的路径。这个 bug 其实非常简单简单到让整个排查过程显得很荒谬ParseConfig()里先new了一块缓冲区中间有个if (!Validate()) return false;漏了delete[]。正常输入永远走不到那个分支只有被特意构造的畸形配置才会触发。编译器不会报因为语法合法Valgrind 不会测到因为回归用例里没人构造这种畸形输入。但一旦有人构造了泄漏就发生了而且是在线上最不想看到的时候发生。那次之后我做了一个决定把静态分析纳入日常编译流程在代码还没跑到运行时之前就把这类问题拦下来。这篇文章就是记录我怎么把 Clang-Tidy 和 PVS-Studio 用起来在编译前拦截内存泄漏类问题的完整过程。1.2 编译器、Valgrind、ASan 都拦不住的那个时间点要理解静态分析的定位得先看清楚其他工具在哪一环失效。编译器的-Wall -Wextra管的是语法、类型、未使用变量这类问题对于new/delete配对这种需要跨语句、跨函数推理的场景编译器基本不提供检查。-fsanitizeaddress很强大但它要求代码实际运行到泄漏路径上触发不到就白搭。Valgrind 同理它是动态检测必须让问题代码被执行才能抓现行。这三类工具的共同特点是它们都在代码运行之后才能发现问题。而对于某些分支极深、依赖特定输入才触发的泄漏路径你根本不知道什么时候会跑到甚至根本不会在你的测试环境里跑到。开发阶段没问题、回归测试没问题、一上线就出问题原因就在这里。静态分析走的是另一条路在编译期间分析源代码本身构造抽象语法树和数据流图模拟执行所有可能的路径。不需要真实输入不需要构造用例直接把代码从头到尾“读”一遍。对于内存泄漏这种“谁分配、谁释放、中间路径是否可能中断”的问题静态分析天然是比动态检测更前置的手段。1.3 静态分析在“拦截时机”上的独特位置静态分析真正值钱的地方在于拦截时机。IDE 里的实时检查能让开发者在写完代码的瞬间就看到告警CI 里的门禁能让不合规的代码合不进主干。对比一下开销一个泄漏 bug 在编译前发现修复成本是五分钟改代码在 Code Review 时发现需要解释、讨论、重新提交在线上的凌晨被发现就是一场事故。Clang-Tidy 和 PVS-Studio 是我用的两套主力工具。Clang-Tidy 开源免费和 CMake 的集成非常顺滑适合作为默认防线PVS-Studio 是商业工具分析深度和数据流能力更强能补上 Clang-Tidy 漏掉的部分。两个配合使用基本可以覆盖日常开发中 90% 以上的内存泄漏模式。接下来我会从配置开始一步步讲清楚它们各自怎么用、效果如何、踩过什么坑。2. Clang-Tidy落地CMake接入、规则裁剪与真实检出效果2.1 让CMake生成编译数据库compile_commands.jsonClang-Tidy 的工作原理是逐个编译单元地读取源码和编译参数所以在跑分析之前得先把工程里每个源文件是怎么编译的告诉它。标准做法是让 CMake 导出compile_commands.json这个文件包含了每个源文件的编译目录、命令行参数和所有宏定义。在 CMake 里只需要加一行set(CMAKE_EXPORT_COMPILE_COMMANDS ON)然后把 Clang-Tidy 的二进制路径配给 IDE 或直接在命令行调用clang-tidy -p build analysis_demo.cpp -checks*-p参数指向包含compile_commands.json的目录。如果没有这个文件Clang-Tidy 就得靠猜测来推断头文件路径和宏误报率会直线上升。所以第一步务必确认编译数据库真实生成在 build 目录下执行ls compile_commands.json有文件再往下走。实际使用中你会发现编译数据库不仅服务于 Clang-Tidy后续接 PVS-Studio 的 analyzer 也同样需要它只是格式和入口不同。所以这个东西值得在工程初始化时就配好属于一劳永逸的基础设施。2.2 .clang-tidy配置里与内存泄漏直接相关的检查Clang-Tidy 默认启用的检查项偏向风格和可读性内存泄漏相关的检查默认并不全开。我的项目里在根目录放了一个.clang-tidy文件把和内存管理相关的检查单独列了出来Checks: clang-analyzer-cplusplus.NewDelete, clang-analyzer-cplusplus.NewDeleteLeaks, clang-analyzer-core.CallAndMessage, clang-analyzer-unix.Malloc, clang-analyzer-unix.MallocSizeof, clang-analyzer-unix.cstring.NullArg, clang-analyzer-alpha.unix.cstring.OutOfBounds, clang-analyzer-alpha.unix.Stream, clang-analyzer-alpha.cplusplus.DeleteWithNonVirtualDtor, clang-analyzer-alpha.cplusplus.ArrayDelete, llvm-header-guard, performance-unnecessary-copy-initialization, modernize-raw-string-literal HeaderFilterRegex: .* WarningsAsErrors: *clang-analyzer-cplusplus.NewDelete检查new和delete是否匹配可以捕获“分配了内存但任何路径上都没释放”和“释放方式与分配方式不匹配”两种情况。clang-analyzer-cplusplus.NewDeleteLeaks专门盯着直接泄漏在某个函数返回路径上局部指针指向的堆内存既没有被delete也没有被传出去。就我实际经验90% 的 C 内存泄漏告警都来自这两个检查项。clang-analyzer-alpha.*是实验性检查报的准但也会有更多误报。如果团队刚上手建议先把非 alpha 的检查稳定跑一周再用 alpha 做补充。2.3 我跑出的第一波泄漏告警长什么样接入 Clang-Tidy 第一天印象最深的一条告警来自这段代码std::string getErrorMessage(int code) { char *buf new char[1024]; snprintf(buf, 1024, error code: %d, code); return std::string(buf); // 这里没有 delete[] buf }buf被用来构造临时std::string指针既没有被存储也没有被 ownership transfer函数返回后这块内存成了孤儿。Clang-Tidy 报的是Potential leak of memory pointed to by buf。这类代码在 C 风格浓厚的 C 项目里非常常见自己写的时候觉得“反正都返回了无所谓”实际上每次调用都泄漏 1KB。还有一条更隐蔽的涉及异常路径void process(const std::vectorint data) { int *copy new int[data.size()]; std::transform(data.begin(), data.end(), copy, [](int x){ return x * 2; }); if (data.size() 1000) { throw std::runtime_error(too many elements); } delete[] copy; }data.size() 1000抛异常时delete[]永远执行不到。Clang-Tidy 对这条路径的检查是通过数据流分析做的它模拟执行了 throw 分支发现 copy 在后续路径上没有释放点。实际工程里这种“提前返回/抛异常导致的泄漏”占比非常高靠人眼 review 根本看不全。3. PVS-Studio的差异化能力跨函数数据流与漏检补位3.1 安装与集成pvs-studio-analyzer的用法PVS-Studio 分析的底层路径和 Clang-Tidy 类似也是吃编译数据库但集成的入口是它自己的pvs-studio-analyzer。安装完 PVS-Studio 之后在 build 目录下执行pvs-studio-analyzer analyze -f compile_commands.json -o project.pvslog plog-converter -a GA:1,2;OP:1 -t json -o project.json project.pvsloganalyze生成原始日志plog-converter转成适合阅读和接入 CI 的格式。如果想直接在终端看告警可以用-t errorfile输出纯文本格式。整个流程走下来也就两分钟对于几十万行的中型项目PVS-Studio 全量分析时间基本可以接受。PVS-Studio 的激活很直接官方申请一个 trial license然后在环境变量里指一下export PVS_STUDIO_LICENSE~/PVS-Studio.lic我个人的做法是把它作为 Clang-Tidy 的补充层不替代。原因很简单Clang-Tidy 免费但有些场景深度不够PVS-Studio 的商业算法在跨函数数据流分析上明显更强能发现 Clang-Tidy 漏掉的复杂 case。3.2 内存泄漏相关的核心诊断V773、V680等PVS-Studio 的内存泄漏检查有自己的诊断编号我使用频率最高的是这几个诊断编号诊断含义典型场景V773函数退出未释放指针new 完多个对象中间 returnV680显式类型转换导致 delete 行为异常(char*)new Obj() 然后 deleteV611内存分配与释放函数不匹配new[] 配 delete、malloc 配 deleteV701重叠拷贝memcpy 重叠通常伴随泄漏风险V1071析构函数未声明 virtualdelete 基类指针导致派生类资源未释放V1025new 数组时的常见误用括号位置导致的不匹配V773 和 Clang-Tidy 的 NewDeleteLeaks 功能叠了一部分但 V773 在跨函数场景上更强。举个例子一个函数分配了内存然后把指针传给另一个函数释放Clang-Tidy 有时候会因为跨翻译单元而放弃分析PVS-Studio 仍能追踪。V1071 是另一个经常被忽略的问题基类的析构函数不是virtual当通过基类指针delete派生类对象时派生类部分不会照常析构资源直接泄漏。PVS-Studio 的告警信息会把这条继承链打出来一眼就能看出问题在哪。3.3 两个工具跑同一份代码结论为什么会不一样我最初以为两个都是静态分析结果应该大同小异实际用下来发现差异非常明显。同一份代码Clang-Tidy 倾向于保守它只报它确定的问题宁可漏报也不误报。PVS-Studio 的告警则更激进它基于更深的推测分析会报出 Clang-Tidy 不吱声的问题但也因此偶尔会有一些“理论上可能、实际上不会”的告警。结论不一样的根本原因在于分析深度和策略。Clang-Tidy 的很多检查是模式匹配加局部数据流速度快但浅PVS-Studio 使用全量数据流分析能跨函数地追踪指针生命周期自然能“看到”更多路径。但这也意味着它的资源开销更大不适合在每次编辑器的实时分析里跑。所以我的分工是Clang-Tidy 挂在保存文件和 IDE 实时检查上PVS-Studio 放在睡前全量扫描或 CI 定时任务里两个互补而不是二选一。4. 一段典型泄漏代码的完整检出过程复现4.1 样例代码埋了四个隐藏泄漏点为了把工具的真实行为讲清楚我构造了一个小型示例程序刻意在里面埋了几个不同模式的泄漏点。这个例子不复杂但足够说明问题。#include string #include vector #include stdexcept class Resource { public: Resource() : data_(new int[64]) {} ~Resource() { delete[] data_; } private: int *data_; }; std::string buildMessage(int id) { char *msg new char[128]; snprintf(msg, 128, id%d, id); return std::string(msg); } void processItem(const std::string item) { int *temp new int[100]; if (item.empty()) { return; } delete[] temp; } class Base { public: Base() {} ~Base() {} }; class Derived : public Base { public: Derived() : buffer_(new int[50]) {} ~Derived() { delete[] buffer_; } private: int *buffer_; }; int main() { Resource *r new Resource(); delete r; std::string msg buildMessage(42); std::vectorBase * vec; vec.push_back(new Derived()); for (auto *ptr : vec) { delete ptr; } processItem(); return 0; }这个 30 行左右的小程序里有四个问题buildMessage中msg的new char[]从未释放processItem在item.empty()时提前 return 导致temp泄漏Base的析构函数非 virtualdelete ptr不会触发Derived的析构buffer_泄漏Resource类看起来正常但如果构造抛异常也会有问题。我把四个样例代码保存到leak_demo.cpp依次用两个工具分析。4.2 排查链路复现从告警到确认根因先跑 Clang-Tidyclang-tidy -p build leak_demo.cpp -checksclang-analyzer-cplusplus.NewDelete,clang-analyzer-cplusplus.NewDeleteLeaks,clang-analyzer-alpha.cplusplus.DeleteWithNonVirtualDtor输出结果warning: Potential leak of memory pointed to by msg [clang-analyzer-cplusplus.NewDeleteLeaks] warning: Potential leak of memory pointed to by temp [clang-analyzer-cplusplus.NewDeleteLeaks] warning: Delete called on non-virtual destructor that might delete derived class object [clang-analyzer-alpha.cplusplus.DeleteWithNonVirtualDtor]三个问题全部命中。注意temp那一条Clang-Tidy 能定位到if (item.empty()) { return; }这一行——它模拟执行了提前 return 路径发现delete[] temp没有被执行。这种路径级分析正是 Clang-Tidy 比普通代码检查强的地方。再跑 PVS-Studiopvs-studio-analyzer analyze -f compile_commands.json -o demo.pvslog plog-converter -t errorfile -o demo.err demo.pvslog输出leak_demo.cpp:13:9: warning: V773: The function was exited without releasing the msg pointer. A memory leak is possible. leak_demo.cpp:20:9: warning: V773: The function was exited without releasing the temp pointer. A memory leak is possible. leak_demo.cpp:42:31: warning: V1071: The Derived class contains a pointer to an array that will be lost because the Base class has a non-virtual destructor.三个问题同样命中诊断编号和 Clang-Tidy 对应V773 对应 NewDeleteLeaksV1071 对应 DeleteWithNonVirtualDtor。区别在于输出信息更易读——V773 直接指出了函数“退出时未释放指针”V1071 明确说“数组指针将因非虚析构而丢失”对于不太熟悉静态分析的开发者PVS-Studio 的提示几乎不需要额外理解成本。4.3 修复后的静态分析回归结果把四个问题统一修复buildMessage改用std::string直接拼字符串不再手写char[]processItem改用std::vectorint局部变量避免手动 deleteBase的析构函数加上virtualResource类改为智能指针持有成员。修改之后再跑一次clang-tidy -p build leak_demo.cpp -checksclang-analyzer-cplusplus.NewDelete,clang-analyzer-cplusplus.NewDeleteLeaks --quiet pvs-studio-analyzer analyze -f compile_commands.json -o demo_fixed.pvslog plog-converter -t errorfile -o demo_fixed.err demo_fixed.pvslog wc -l demo_fixed.err两个工具都返回零告警。这里有个经验静态分析的告警分为“根因告警”和“连带告警”。有时候修了一个根因连带告警会自然消失因为分析器发现分配路径已经没有问题了。所以处理告警时不要机械地一个个去修最好先看一遍全部告警找出共同的根因一次性解决效率和效果都好很多。5. 让静态分析真正进CI增量分析、门禁与误报管理5.1 全量扫描在CI里的性能陷阱把静态分析接进 CI 的第一个问题就是速度。一个 50 万行的 C 工程Clang-Tidy 全量扫描耗时按小时算。PVS-Studio 的pvs-studio-analyzer analyze虽然比 Clang-Tidy 快但也没快到可以随便塞进每次合并请求的流水线里。直接在全量代码上卡门禁结果就是开发者等得骂娘为了赶进度开始想方设法绕过检查。我见过最离谱的做法是为了让 CI 变绿把整个目录的 NOLINT 全加上。这种做法等于把工具废掉了比不接还糟。所以我不建议把全量扫描直接做成硬性门禁。更好的做法是全量扫描定时执行比如每天夜里一次结果发给负责人去推进存量清理增量扫描通过 MR 门禁只分析本次变更涉及的文件。这样既有前置拦截又不会拖慢主流程。5.2 增量分析的两种做法增量分析的实质是拿到 merge request 涉及的文件列表只对这批文件跑分析工具。Clang-Tidy 可以直接用文件列表驱动changed_files$(git diff --name-only origin/main...HEAD -- *.cpp *.h) for file in $changed_files; do clang-tidy -p build $file -checksclang-analyzer-* || echo $file 有告警 donePVS-Studio 的情况稍有不同analyze默认面向全量编译数据库但可以通过参数--exclude-path或者手动传文件列表来控制范围。不过 PVS-Studio 在增量方面不是强项它需要先构建一次全量中间表示实际省的时间有限。我的做法是MR 门禁只跑 Clang-Tidy 增量PVS-Studio 放在 nightly 全量构建里第二天把新告警汇总成报告。横在增量门禁面前的一个常见问题是“存量告警污染”工程里本来就有几千个未处理的告警新写的代码只有一行delete[]问题也会被淹没。解决办法是使用基线模式。Clang-Tidy 的--warnings-as-errors可以和基线文件组合只关注新增告警。PVS-Studio 提供了suppress命令第一次全量扫描后把所有告警标记为“存量”之后的报告只显示新增。plog-converter -t json -o after.json after.pvslog # 第一次全量跑完后 pvs-studio-analyzer suppress before.json # 之后对比只保留新增这个模式是产品成熟的标志——它承认存量历史债是客观存在的通过工具手段把它们隔离让新代码的合规检查不会被旧问题拖后腿。5.3 误报抑制的三层机制静态分析工具的误报是个绕不开的话题。Clang-Tidy 提供了// NOLINT注释来针对单行抑制// NOLINTNEXTLINE抑制下一行。PVS-Studio 有同等的//-V::773注释可以抑制指定诊断号。但直接抑制是下策。更合理的做法是分级处理第一层分析规则裁剪。通过.clang-tidy的Checks字段和 PVS-Studio 的.pvsconfig文件直接把不适用于本项目的规则关掉。比如你的项目不用异常那么异常路径相关的检查就可以直接关闭。第二层抑制文件。PVS-Studio 的suppress文件可以在不污染源码的前提下抑制告警适合“这个目录里的代码是第三方生成的不值得分析”这类情况。第三层源码内抑制。留给极少数的确认误报。我会要求开发者在// -V::773旁边写一行注释说明为什么这里需要抑制方便后续审查。这三层用完误报对开发的干扰会降到很低。我自己跑了几周的感受是真正值得人工 review 的告警大约占全部告警的三分之一剩下的要么是规则与代码风格不符要么是项目特定场景下的“假阳性”按上述机制处理掉完全不影响开发节奏。6. 团队落地静态分析的经验教训6.1 误报率如何量化判断静态分析工具在团队推广时最常被挑战的就是“误报太多”。但“太多”是个主观感受要解决它得先把误报率量化出来。我的做法是选一个中等规模模块大约 200 个文件先跑一次全量告警逐条人工标注“确认问题”“疑似问题”“误报”三类统计各自数量。如果确认问题超过 50%这个工具在这个项目上就是值得推广的如果大部分是误报说明要么规则裁剪不到位要么工具和项目的语言特性不匹配。根据这个统计结果可以决定是调整规则还是换工具。我实际跑下来Clang-Tidy 把clang-analyzer-*全开的前提下确认问题率大约在 60% 左右剩下 25% 是“代码确实危险但当前调用路径不会触发”的隐性风险真正完全没道理的误报只有 15% 上下。PVS-Studio 的确认问题率和 Clang-Tidy 差不多但它多报出来的那部分问题大概有三分之一是 Clang-Tidy 完全没发现的真实缺陷。这就是我坚持两者并用的理由。6.2 “先扫新代码再清历史债”的顺序如果团队代码库已经很庞大了千万不要一开始就提出“把历史告警全部清零”的目标。这会让团队产生对抗情绪觉得工具是来找茬的。我踩过的坑就是刚开始接入时我一股脑把全量告警发到群里结果被团队成员集体吐槽差点被否掉整个方案。正确顺序是先明确规则、配置好基线确保新提交的代码不再产生新增告警然后每周固定时间清理一部分历史告警按模块认领不设硬性期限。这样既守住了“新增零告警”的底线又给了存量问题逐步消化的空间。等历史告警降到一定程度再考虑是否收紧门禁阈值。6.3 我对这个方案的整体评价与后续扩展方向静态分析不能替代 code review也不能替代单元测试和动态检测但它填补的是一个特殊的缝隙在代码还没运行之前用自动化的方式把路径级的内存问题找出来。不需要构造用例、不需要特定输入只要代码写了分析器就会尽力读一遍。用下来我对这套方案的评价是Clang-Tidy 是成本最低的保底防线每个 C 工程都值得接入PVS-Studio 是强化版的数据流分析器花点授权费换来的跨函数检错能力非常值。两者互补而不是互斥我最终定为“Clang-Tidy 日常守门 PVS-Studio 定时全量”稳定性明显提升线上内存泄漏类问题锐减。后续扩展方向上我在尝试把 Clang-Tidy 的clang-analyzer-*规则继续放宽到bugs类别并给 PVS-Studio 接入更多自定义.pvsconfig微调规则。另外如果你用的是带静态分析的 IDE比如 CLion 内置 Clang-Tidy尽量让本地开发和 CI 的规则保持一致这样开发者在本地就能看到和 CI 相同的告警前置拦截的效果会更好。最后提一句静态分析的告警只是一个提示最终修复方案还是要靠人去判断。工具能在代码写出来的那一刻就告诉你“这里可能会泄漏”剩下的就是把每一处告警当作一次免费的 code review 来对待。这套流程跑顺了之后内存泄漏这类问题在你们的项目里也会从“线上事故”变成“编译期小事”。