ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

嵌入式C/C++代码静态分析实战:用Cppcheck揪出野指针与内存泄漏

嵌入式C/C++代码静态分析实战:用Cppcheck揪出野指针与内存泄漏

做嵌入式开发的朋友,十有八九都被野指针、数组越界、内存泄漏这些老毛病折腾过。这些bug平时在开发板上跑得好好的,一到现场就抽风,排查起来相当费劲。Cppcheck就是一个专门面向C/C++的静态分析工具,它能在不运行代码、不依赖目标板的前提下,直接从源代码里把这一类隐患揪出来。我在嵌入式项目里用了很多年,从裸机程序到带操作系统内核的项目都跑过,今天就把它的实际用法、嵌入式场景下的配置思路,以及那些文档里不会写的坑,一次性整理出来。这篇东西适合正在给固件做质量加固的开发者,也适合准备把静态分析接入自动化流水线的团队。

1. 为什么嵌入式项目需要一个“挑刺”的静态分析工具

1.1 嵌入式C/C++代码的痛点

嵌入式软件和桌面软件的难点完全不一样。桌面程序内存动不动几个G,崩溃了还能靠系统的保护机制兜底;嵌入式环境里,栈空间可能只有几十K,全局变量满天飞,代码直接操作寄存器地址,还要面对中断、多个任务并发访问同一块内存。这种环境下出现的bug,往往不是某一行的逻辑错误,而是跨模块的隐性约定被破坏。比如一个环形缓冲区的大小在A处配置、在B处读取,中间隔着三层接口,哪天顺序调整了,越界写就出现了;又比如一个标志位在主循环里被清除,在一个中断里被置位,看起来谁都没错,可现场就是会出随机故障。

这类问题最麻烦的一点是,编译器很难兜住。对编译器来说,每一个.c文件单独看都是合法的,类型没毛病、语法没毛病,它没有动力去跨函数、跨文件追问“这里读的变量到底有没有可能没初始化”。等到问题真正暴露,通常已经是设备交付后。手头往往没有仿真器,现场又不允许打断点,能依靠的只有日志和状态字,等崩溃现场被覆盖,证据早就没了。这也是为什么嵌入式团队普遍怕“偶发问题”。

1.2 静态分析能补上哪块短板

静态分析做的事情,可以粗略理解成把源码重读一遍,并按每一条可执行路径追问状态:这里会不会解引用空指针?这条路径上变量有没有被初始化?这个数组下标有没有可能超出声明边界?它不需要目标板,不需要编译产物,也不需要构造测试输入。它更像给代码做一次全身CT,而编译只是抽血化验,单元测试是日常健身,三者各有分工。

对比一下几类手段:编译器是语法和类型警察,只拦明显违法;人工代码评审关注设计和可读性,但人很容易疲劳、很容易漏,对重复劳动天生不耐受;运行时调试工具能抓到实际发生的bug,但需要设备、需要可复现的场景,而在嵌入式环境里,好多崩溃根本没法稳定复现。静态分析的定位,就是在这三者之间补一张提前预警网:代码刚写完、还没跑起来的时候,先筛掉一批确定性和高概率的问题,后面测试和现场调试就能把有限的精力放到真正复杂的行为问题上去。

1.3 为什么选Cppcheck作为主力

选型的时候我也评估过其他方案,各有各的优点,但日常主力最后还是落在Cppcheck上。原因很实在:上手成本低,拿到源码目录就能扫,不需要先把项目编过一遍;对开发机配置要求不高,很多嵌入式开发的电脑性能一般,Cppcheck配合并行参数能跑动中型代码库;规则数量够多且持续更新,从明显的空指针到比较隐蔽的整型溢出、无效位操作都能覆盖;告警有编号、有分级、有抑制机制,方便沉淀成团队规范;作为开源工具,命令行和配置文件都很干净,适合嵌进脚本和流水线。

不过有一点必须提前说清楚:没有哪个静态分析工具是万能的。Cppcheck的强项是“快速、确定性强、噪音可控”,而不是像有些重型工具那样追求彻底的形式化验证。理解了它的边界,后面的配置才能把它的价值发挥出来。

2. Cppcheck的工作机制与关键设计

2.1 从源码到告警的完整链路

Cppcheck并不真正编译你的代码。它拿到源代码后,先做词法分析,把代码拆成带类型的Token流,再生成抽象语法树;在这个基础上建立符号表和控制流信息,最后沿着各个执行路径做数据流分析。整个过程省掉了优化、汇编、链接这些后端环节,所以它能独立于构建系统工作,单文件分析甚至不需要任何外部依赖。

这里有个容易误解的点:它既然用不上编译器,那“编译选项”对它是不是就没用了?不是。编译选项中的宏定义、头文件路径、语言标准这些信息,会直接影响语法树长得什么样。没有正确的头文件路径,结构体定义就是不完整的,很多分析只能退回到保守猜测;没有正确的宏定义,一部分条件编译分支干脆不会被展开。后面第4节里我会专门讲怎么把这些信息喂给它。

2.2 核心检查器分类与现实价值

Cppcheck内置了一组规则函数,每条规则本质上是一个在语法树和数据流信息上做匹配的检查器。日常使用时会按类别分组暴露给你,常见的有这几类:

类别检查方向嵌入式场景里的典型例子
bug类空指针、缓冲区溢出、资源泄漏、未初始化变量拿到外设寄存器指针,未判断是否为空就直接访问
并发类共享变量写冲突、锁使用不一致中断服务函数修改全局数组,主循环同时读同一数组
可移植性整型溢出、有符号位运算、字节序假设协议拆包时对无符号字符做有符号扩展,结果全变负
性能类不必要的拷贝、低效表达式结构体整体作为参数传出,每次都做深拷贝
风格类冗余代码、不可达分支、过期写法if(flag == true)这类恒真判断长期掩盖逻辑问题

对嵌入式项目,我最关注的是bug类和可移植性类。原因很简单:嵌入式代码大量使用位操作、类型强制转换和指针运算,这三样正好是可移植性问题的重灾区,而且这类问题在桌面平台自测时根本看不出来,因为桌面端整型宽度和嵌入式目标平台不一样。

2.3 嵌入式场景下的几个独特处理

Cppcheck对嵌入式代码有几个细节做得比较到位。第一,它能指定目标平台字长,比如模拟常见的32位单片机环境,在分析整型提升和隐式转换时就会按32位模型的规则走,而不是按桌面64位模型的规则走,这样能少报一堆“假溢出”。第二,它能识别寄存器映射的开发手法,比如对映射到固定地址的结构体指针的访问,不会轻易把合法地址访问当成空指针错误。第三,头文件缺失时它不会直接放弃,而是退回不完整信息下的保守分析,仍然尽量报出确定性高的问题。

这也引出它的一个重要策略:宁可漏报,也不想制造大量噪音。所以很多检查项默认是不开的,需要你显式打开,比如不可达代码分析、成本较高的跨函数深度检查。这也是为什么很多人第一次跑Cppcheck觉得“扫不出来东西”,原因多半是没把该开的开关打开。

3. 安装与环境准备

3.1 在开发机上快速装好

Cppcheck的安装途径很常规。桌面系统上,可以直接从系统自带软件仓库安装,版本通常不会太新但够用;如果希望用最新版,可以去Cppcheck官方发布的预编译包,下载后解压就能用;愿意折腾的话,从源码构建也很简单,因为项目本身依赖少,主要就是一个C++编译器外加构建工具链。

我个人的建议是,开发和CI环境尽量用固定的较新版本。Cppcheck每个版本都会修误报、加新检查规则,旧版本报不出来的问题新版本可能一眼就能看穿,但版本之间告警编号和行为也可能有细微变化。团队协作时,所有人用同一版本,出来的结果才是可对比、可复现的。

3.2 先跑一个最小案例

先不碰复杂工程,用一段故意写错的代码找感觉。新建一个测试文件,内容如下:

#include <cstring> void test(int *p) { if (p == nullptr) { *p = 1; } }

然后执行:

cppcheck --enable=warning example.c

输出会给出告警,明确指出空指针解引用的位置。这个例子里的逻辑错误非常明显:既然判断了指针为空,进入分支后就不该再解引用,Cppcheck沿着条件分支做路径分析,一下就抓住了。

在基础命令上再加一个参数,观察变化:

cppcheck --enable=all --inconclusive example.c

--enable=all会打开所有检查类别,--inconclusive允许它报告“不能百分百确定但值得人工复核”的问题。你会看到输出变多,有些不那么确定的问题也会被列出来。日常使用不建议直接无脑开这两个,告警太多会淹没真正需要优先处理的问题。

3.3 理解严重级别和告警编号

Cppcheck的告警通常分五个级别:error、warning、style、performance、portability。error是几乎可以确定的代码缺陷,默认就会报;warning是可疑逻辑,比如变量未初始化、空指针风险;style偏代码整洁和规范;performance关注不必要的性能损耗;portability关注跨平台移植问题。每个告警都会带一个编号,比如nullPointer、bufferAccessOutOfBounds,这两个东西在后续抑制误报时非常关键,先记住这个惯例。

4. 嵌入式专项配置实战

4.1 Include路径:决定分析质量的第一块基石

嵌入式项目的头文件结构往往很散:自己的业务头文件在一层,板级支持包在另一层,第三方协议栈又在一层。如果只是对某个子目录执行cppcheck src/,大量头文件会找不到,Cppcheck只能把结构体当作不完整类型处理,分析结论自然很有限。

所以基础命令必须把Include路径喂给工具:

cppcheck --enable=warning,style \ -I src/include \ -I bsp/inc \ -I third_party/comm \ src/

如果头文件确实不参与分析,或者某些系统库头在目标环境才有,可以补上:

cppcheck --suppress=missingIncludeSystem src/

这条抑制只屏蔽“找不到系统头文件”的提示,不会屏蔽业务代码里的真实缺陷。

4.2 平台模型和宏定义:别让桌面字长带偏结论

这是嵌入式场景最容易踩的坑。Cppcheck默认可能按桌面平台的整型宽度来分析,但很多单片机平台里int是16位,而桌面上int至少是32位。同一个表达式,在桌面平台看起来不会溢出,在目标平台上却已经回绕了。所以分析前要指定平台。

cppcheck --platform=unix32 ...

内置平台配置一般有unix32、unix64、win32、win64等;嵌入式专用CPU的完整字长模型,建议用自定义配置文件。准备一个mcu_platform.xml,描述目标平台的int、long、指针宽度等基本参数,然后这样使用:

cppcheck --platform=mcu_platform.xml ...

宏定义同样重要。嵌入式代码里条件编译极多,不定义宏会让大量代码分支根本进不去,等于漏检。建议把构建脚本里定义过的关键宏原样传进去:

cppcheck -DXLOG_ENABLE=1 -DUSE_RT_OS -UDEBUG

-U用于取消某些默认宏,和编译时的-U语义一致。一个实用的技巧是:去构建系统生成的编译命令里翻一遍,把其中-D和-U参数手工整理进一个文本文件,然后通过配置文件一次性传入,这样不会漏。

4.3 嵌入式专项检查项的推荐组合

不要一上来就--enable=all。对嵌入式项目,我建议先开这四类:

cppcheck \ --enable=warning,style,performance,portability \ --check-level=exhaustive \ --max-ctu-depth=8 \ --force \ -I ... -D... \ src/

解释一下关键参数的取舍:

  • --enable=warning,style,performance,portability:覆盖绝大多数有效告警,error即使不开也会默认输出。
  • --check-level=exhaustive:让分析更彻底,但速度会明显变慢,适合在CI里跑,不适合每次本地随手扫。
  • --max-ctu-depth=8:控制跨函数分析深度。嵌入式代码函数调用层级普遍深,深度太小会漏掉跨函数问题,太大则性能开销成倍增加,8是一个经过不少项目验证的折中值。
  • --force:强制对所有能在预处理阶段看到的#if分支都做检查。不开的话,Cppcheck默认可能只分析当前配置覆盖到的那条分支,一些隐藏分支里的越界就漏掉了。

4.4 抑制误报的三种姿势:别和工具硬刚

静态分析不可能零误报,怎么优雅地抑制误报,决定了这个工具在团队里能不能活下去。Cppcheck提供了三种方式,按优先级从高到低分别是源码内注释、配置文件、命令行参数。

源码内注释适合“这个文件这一行确实特殊”的场景。比如下面的代码,在对固定地址区域写入时,工具可能会误判为无意义的赋值:

void config_reg(void) { // cppcheck-suppress unreadVariable volatile uint32_t *reg = (volatile uint32_t *)0x40001000u; *reg = 1u; }

注释格式是// cppcheck-suppress 告警编号,放在告警行的上一行,后面还能跟文件名过滤,写成// cppcheck-suppress unreadVariable:config.cpp。我倾向于把抑制写在这类“硬件动作”旁边,后来人读代码时能一眼看到原因,不容易误删。

配置文件适合“某个文件或某种模式统一忽略”的场景。先创建一个suppress.cfg:

unusedVariable bufferAccessOutOfBounds:src/legacy/old_module.c missingIncludeSystem

然后在命令行里引入:

cppcheck --suppress=unusedVariable \ --suppress=bufferAccessOutOfBounds:src/legacy/old_module.c \ --suppress=missingIncludeSystem \ src/

或者用配置文件统一管理更干净。

命令行抑制适合临时排查,不适合长期维护。一旦团队里有多人参与,我建议把抑制规则收进集中配置文件,并且走代码评审,否则很容易出现“说不上为什么但就是被屏蔽了”的告警黑洞。

4.5 在真实项目里做批量分析

单条命令扫库在多目录工程里并不好用,最优解是先把项目编译数据库导出,然后让Cppcheck跟着编译信息走。你可以用构建系统自带的导出功能生成一个compile_commands.json,里面记录每个源文件的编译命令、头文件路径、宏定义,然后执行:

cppcheck --project=compile_commands.json \ --enable=warning,style,performance,portability \ --check-level=exhaustive \ --max-ctu-depth=8 \ --cppcheck-build-dir=cache

--cppcheck-build-dir参数会把中间分析结果缓存下来,下次增量扫描时省去重复解析,大项目提速非常明显。没有编译数据库时,就退而求其次,把所有-I和-D参数整理进一个脚本统一维护,也能得到可用结果。

4.6 把静态分析接进自动化流水线

静态分析只有定时跑、自动跑,才能真正形成约束。在持续集成环境里可以这样设计:每次提交跑一遍全量或增量分析,把严重告警当成质量门禁;只有error级别的告警直接让流水线失败,warning级别告警作为评审提示,给人留出处理空间。

一个简单的命令示例:

cppcheck --project=compile_commands.json \ --enable=error \ --error-exitcode=1 \ src/

当发现error级别问题时,--error-exitcode=1会让进程返回非零状态,流水线随即失败。如果想收集完整XML报告再统一分析:

cppcheck --project=compile_commands.json \ --enable=all \ --xml --xml-version=2 \ src/ 2> cppcheck_report.xml

跑完后用脚本解析XML,按告警编号、文件的分布情况来决定本轮该放开到哪个级别。这里要特别提醒:刚接流水线的第一个月,告警数量往往会很多,建议先跑通流程、收敛一部分合理告警后,再逐步放开更严格的级别,避免第一天就让团队失去信心。

5. 常见问题与排查技巧实录

5.1 误报太多:先排查信息喂得够不够

如果Cppcheck报的告警明显不合常理,十有八九不是工具发疯,而是它没拿到足够的信息。排查顺序建议这样走:先看Include路径齐不齐,少了结构体定义,很多判断都会变成“无法证实,按最高风险假设”;再看平台模型对不对,用桌面64位模型扫16位单片机的代码,整型溢出告警满天飞是必然;最后看宏定义,条件编译分支都没展开,工具当然会漏掉真实路径、误报各种可疑逻辑。

如果这些都对了还有误报,那就用第4.4节讲的三种方式去抑制,但一定要在抑制配置旁边写清原因。我见过太多项目最终抑制规则比源码还长,而且没人解释得清每一条在挡什么,这种“死规则”反而成了新缺陷的保护伞。

5.2 大项目扫描慢:分层处理

现代嵌入式工程动辄几十万行代码,Cppcheck跑全量确实可能花掉几分钟到几十分钟。提速方向有几个:一是用-j参数开并行,多核机器上提升明显;二是把--check-level=exhaustive和--max-ctu-depth这两个重参数调低,先保证常规告警稳定输出;三是配合--cppcheck-build-dir做增量,只扫描改动文件;四是分层扫描,全量RI跑warning以上级别,每日跑style,而不是每次提交都全量开满。

5.3 存量代码告警几千条:别想一次清零

接手老项目时,第一次跑Cppcheck报警几千条是很正常的。这时候最重要的是建立基线、分清优先级。我的做法是:先只处理error级别的告警,这类基本都是明确缺陷;然后处理可复现的空指针和越界,它们最容易在现场变成死机;最后才讨论style层面的大规模格式化问题。

同时建议在流水线里做“新增告警门禁”:同一份代码,新提交产生的告警数必须为零或低于阈值,存量告警单独记录在基线文件里,不在这次提交中强制清零。这样团队不会因为历史包袱被卡死,新代码又能保持干净。

5.4 告警和编译器不一致:正常现象

不少开发者第一次看到Cppcheck报出一个编译器完全没提的问题,会怀疑是不是误报。其实静态分析和编译器的目标不同:编译器追求“在合法代码上尽量少报错”,避免因为自己误判干扰正常开发;Cppcheck则更激进,它更愿意把“可疑”也标记出来,交给开发者判断。两个工具的结果不一致,不代表Cppcheck错了,关键是看告警编号、看产生路径、看能否复现,尤其是error级别的告警,值得认认真真复查一遍。我自己的经验是:一旦确认某个告警是真实问题,建议在源码旁边留一行注释说明,便于后续代码评审和变更维护。

6. 一点实操心得:怎么把静态分析用好

先分享一个容易被忽略的点:越早跑,成本越低。项目早期就引入Cppcheck,告警量小、定位容易、修改顺手;等代码已经堆到十万行再想起来,光是清理存量告警就得花掉整个迭代周期。所以,新项目从一开始就把它跑在每次提交里,远比事后追债划算。

再说一个细节:告警编号一旦定了,团队内部就不要轻易更换版本。不同版本之间行为会有细微变化,如果发现新版本带来的新告警是合理的,就按流程走评审、记录、再更新基线;如果是误报,也走同样的规则去更新抑制配置。把“工具行为变化”纳入变更管理,而不是让它默默漂移,团队才有可能长期用下去。

最后给一个具体建议:把Cppcheck和人工代码评审结合起来用。工具解决“有没有明显漏洞”,人解决“设计合不合理、边界有没有想清楚”。每次评审前,让工具先跑一遍,把告警贴到评审单里,评审人就可以把注意力放在更抽象的问题上。我用这个流程很长一段时间,效果比两拨人各自闷头干活强得多。嵌入式代码质量不是靠某一次重构就能升华的,它就是靠这些不起眼的检查项,一遍一遍把隐患从边缘挤出去的。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进