☰
嵌入式代码静态分析实战:用Cppcheck提前挖出数组越界和空指针
2026/10/12 21:58:29 网站建设 项目流程

搞嵌入式的人多少都经历过这种场景:代码编译零告警,烧录跑起来也没毛病,可产品在实验室里连轴转几天,突然系统跑飞、打印乱码、看门狗复位。抓到最后,往往就是一个数组下标在某个特殊分支越界了,或者中断服务函数里改了一个别人正在读的变量。这类故障最大的问题不是难修,而是难复现,等堆栈现场抓到你面前时,时间成本已经烧掉一大把。而Cppcheck这样的静态分析工具,做的事情就是在代码还没进芯片之前,顺着源码把所有可疑路径翻一遍,提前把雷挖出来。

这篇文章我会围绕 Cppcheck 在嵌入式工程里的实战来写:它到底能查什么、查不到什么、在 Makefile 和 CMake 工程里怎么接入,以及嵌入式代码里硬件寄存器、中断相关的高频误报怎么处理。如果你正在做裸机或者 RTOS 开发,想用低成本手段把代码可靠性提一截,这篇文章可以给你一条直接能走的落地路径。

1. 嵌入式开发里为什么要给代码上“静态安检”:Cppcheck能拦下哪些典型问题

先说个很实际的前提:嵌入式项目做静态分析的收益,比普通桌面应用高得多。桌面程序出问题可以热修复、可以复现、可以远程打日志,但嵌入式系统一旦发布到现场,日志通道可能都不存在,只能靠用户现象反推。更麻烦的是,嵌入式代码本身就以指针操作、位操作、状态机跳转、中断共享变量为常态,这几类写法恰恰是空指针、越界、未初始化问题的高发土壤。

1.1 运行期排障太痛,才是做静态分析的真正动因

我最开始对静态分析工具并不上心,理由和很多人一样:我写了十几年 C,有没有 bug 自己心里有数,编译器开-Wall也扫不出什么大问题。直到有一次排查一个偶发死机,花了将近两周,最后定位到协议解析函数里一个memcpy的长度直接用了报文头部的长度字段,没有做上限校验。攻击者或者异常报文一旦触发,缓冲区就被写穿,程序跑飞。编译器对这个无话可说,因为它连目标 buffer 的实际容量都看不到。

类似问题不是靠代码评审就能完全拦住的。人工评审容易漏,团队大了以后,经验水平参差不齐,大家关注点也不一样。而静态分析工具是不休息的,同一套规则它会不厌其烦地检查每一行代码,不会因为“这段逻辑很熟悉”就走神。这就是我后来把Cppcheck引入团队的核心理由:它能在编译之前就把一批必然爆炸的隐患清掉,把人的注意力留给更复杂的逻辑问题。

1.2 Cppcheck 不是编译器,它更像是搬进代码里的逻辑检查员

要理解 Cppcheck 的价值,首先要摆正它的定位。编译器做的是语法和语义翻译,它关注的是“这段代码能不能通过解析”,很少主动追问“这段代码合不合理”。Cppcheck 恰恰相反,它在不生成目标文件的前提下,对源码做数据流分析和控制流分析:变量从哪里被写入、在哪里被使用、可能有哪些分支会跳过初始化、数组下标在某个分支下会不会超限,这些问题它都会沿着执行路径模拟一遍。

所以它报出来的很多问题,编译器是完全不会报的。举个例子:

int process_frame(uint8_t *buf, uint16_t len) { uint8_t tmp[16]; if (len > 32) { return -1; } memcpy(tmp, buf, len); return tmp[0]; }

这段代码在 GCC 下最多给你一个“stack guard”级别的模糊提示,整段默认编过。但 Cppcheck 会直接指出tmp[16]在第 7 行的memcpy中可能被写入 32 字节,触发数组越界。这类问题一旦上了目标板,表现就是随机死机,你根本想不到罪魁祸首在一段看起来很规整的协议解析代码里。

1.3 一张表看它最常拦下的问题类型

结合嵌入式项目的常见场景,Cppcheck 最值得关注的检查项大概是下面这些:

检查类别典型告警信息嵌入式场景示例
空指针解引用Possible null pointer dereference外设驱动返回句柄后未判空直接使用
数组越界Array 'buf[16]' index 20 out of bounds通信协议按照固定长度读数据
未初始化变量Uninitialized variable: state状态机某个分支没赋初值就参与运算
内存泄漏Memory leak: ptr堆分配后在错误返回分支里忘了释放
资源泄漏Resource leak: fd打开外设或文件后异常路径不关闭
整数溢出Possible overflow in expression定时器计数溢出后参与计算
逻辑错误Opposite inner 'if' condition条件边界写错导致分支永远走不到

其中空指针、数组越界、未初始化这三类,是嵌入式运行期故障的高频来源。它们平时不一定触发,但一旦触发就是跑飞级别的问题,而且复现条件苛刻,靠测试很难覆盖完全。Cppcheck 能把这些“在特定分支下才爆炸”的雷提前找到,是它最值钱的贡献。

2. Cppcheck能查什么、查不到什么:能力边界与检查项速览

很多人在刚接触 Cppcheck 时会走两个极端:要么只配一条默认命令就跑,觉得没输出就是代码干净;要么把所有告警全开,然后被几万条输出吓退,从此再也不碰。这两种我都经历过。正确姿势是先搞清楚它的能力边界和开关含义,再决定怎么用。

2.1 先记住一个原则:它是 bug 探测器,不是编译器审校仪

Cppcheck 的强项是做局部数据流分析和可控复杂度内的过程间分析。它能把一个函数内部、以及少量跨函数调用关系里的变量生命周期追踪得比较清楚,从而发现逻辑上的可疑点。但它不会像某些商业级产品那样,做非常深的符号执行和全工程、全编译单元级别的路径穷举。也就是说,它的定位是帮你快速筛掉“明显有问题”的代码,而不是证明代码没有 bug。

这就意味着两件事。第一,它报出来的error级别问题,大概率是真实问题,值得逐条修。第二,它没报,不代表没有,尤其是跨文件、跨编译单元、依赖外部库深层语义的问题,它的覆盖是比较有限的。把这个预期管理好,后续就不会被工具带偏。

2.2 常用检查开关与嵌入式平台参数说明

Cppcheck 的检查项通过--enable分组控制,最常用的是这样几组:

  • warning:核心 bug 检测,包括空指针、越界、未初始化、逻辑错误等。
  • style:代码风格,包括未使用变量、冗余赋值、可读性问题。
  • performance:低效写法和可优化点。
  • portability:跨平台兼容性隐患。
  • unusedFunction:未使用的全局函数定义,需要全工程扫描才能得出准确结论。

我个人的建议是,第一次跑先开warning,performance,portability这三组,把核心逻辑问题清完再考虑 style。--enable=all看着很爽,但对存量代码基本等于洪水,因为 style 级别的信息多到足以淹没真正的问题。

嵌入式场景还有一个容易被忽略的参数:--platform=embedded。它会告诉 Cppcheck 使用 32 位 MCU 常见的整型宽度和指针宽度模型,这会直接影响整数溢出、位移、类型转换这类判断的准确性。如果你在 8 位或者 16 位 MCU 上开发,默认的嵌入式模型可能仍然不够准确,可以考虑用--library指定一份自定义平台描述文件,把int的位数等关键参数写清楚。

另外两个参数我建议从一开始就加上:

  • --std=c99或对应的语言标准。Cppcheck 默认会按较新的语言特性解释代码,和你的编译环境不一致时,会产生一批看不懂的告警。
  • --inconclusive。它会把“不能完全确定、但值得人工确认”的告警也输出出来。嵌入式场景里很多硬件寄存器代码带有特殊语义,这部分要么靠人工判,要么靠抑制规则管理,宁可先看到再处理,也不要默默丢掉信息。

2.3 哪些领域是它的盲区

有几个场景,Cppcheck 目前确实力不从心,如果你不知道,可能会误判工具的价值。

第一是跨编译单元的全局分析。比如 A 文件里分配内存、B 文件里释放,中间经过函数指针或者回调注册,这种链路过长时,Cppcheck 的追踪能力会明显下降。第二是中断并发。它理解不了“中断随时可能打断主循环”这种并发模型,所以对共享变量的数据竞争问题,它既可能漏报,也可能误报。第三是依赖动态输入的高度动态行为,例如跳转表、大量间接调用、运行时才确定的分支组合,这类场景的分析结果只能作为参考。

2.4 与编译器告警的互补关系

编译器开足告警之后能发现一部分问题,比如未使用变量、隐式类型转换、可疑的运算符优先级。但编译器的告警本质上是“编译过程中顺带发现的可疑点”,它不会主动做数据流模拟。Cppcheck 和 GCC 的-Wall -Wextra、以及部分编译器自带的-fanalyzer类选项,构成的是互补关系:编译器负责语法和语义层面的基础检查,Cppcheck 负责在源码层面模拟执行路径。

所以我的习惯是两者都开,且两套规则各自维护。不要因为 Cppcheck 报了某条信息,就觉得编译器告警没必要看;也不要因为编译器没报,就断定代码一定安全。

3. 从命令行到工程化的落地配置:Makefile与CMake接入实操

工具本身讲解得再多,不落地到工程里都是空的。这一部分给你可以直接抄走的配置模板,以及每个选项为什么要这么配。

3.1 一条能直接用的命令行模板

假设你的代码在src/和test/两个目录下,头文件在include/,用 C99 标准,目标是 32 位 MCU,那么一条比较稳的命令长这样:

cppcheck --enable=warning,performance,portability \ --std=c99 \ --platform=embedded \ --error-exitcode=1 \ --suppress=missingIncludeSystem \ --inline-suppr \ --template="{file}:{line}: {severity}: {message}" \ -I include \ src/ test/

拆开解释一下几个关键选项:

--enable=warning,performance,portability前面已经说了,是核心问题的检查组。--error-exitcode=1的意思是只要 Cppcheck 发现了任何它认为的问题,就返回非零退出码,这样在 CI 流水线里可以直接作为失败条件。--suppress=missingIncludeSystem很重要,它会把“找不到系统头文件”这类信息直接压掉,因为单个静态分析命令很难提前配置完整的系统包含路径,这类信息大多是噪音。--inline-suppr允许你在源码里写// cppcheck-suppress 规则名这样的注释来实现局部抑制,后面讲误报处理时会具体说。

--template是用来控制输出格式的。默认输出直接用没问题,但当你把 Cppcheck 接入 IDE、VSCode 或者 CI 的告警展示面板时,一个规范化的模板会让解析方便很多。上面这个模板把文件、行号、严重级别和消息都打出来了,已经很够用。

3.2 通过 Makefile 提供“静态分析”专属目标

如果你的项目还在用 Makefile,不要直接改编译命令,那样会影响构建速度,也会让日常编译变得异常缓慢。正确做法是加一个独立的static-analysis目标:

CPPCHECK_FLAGS := --enable=warning,performance,portability \ --std=c99 \ --platform=embedded \ --error-exitcode=1 \ --suppress=missingIncludeSystem \ --inline-suppr \ --template="{file}:{line}: {severity}: {message}" \ -I include .PHONY: static-analysis static-analysis: @mkdir -p build/cppcheck cppcheck $(CPPCHECK_FLAGS) src/ test/ 2>&1 | tee build/cppcheck/result.txt

这样你只需要执行make static-analysis,它就会单独跑一遍分析,不会影响普通编译。tee会把结果同时打到屏幕和文件里,方便留档。设置--error-exitcode=1之后,只要扫描出问题,这条make目标就会直接失败,这在本地检查和 CI 里都能起到门禁效果。

3.3 在 CMake 工程里用自定义 target 接管全量扫描

CMake 工程接入 Cppcheck 有几种方式,我推荐的是自定义 target,因为它能实现“全工程一次扫描”,比逐文件触发效率更高,也方便做unusedFunction这类需要全局视角的检查。

在CMakeLists.txt里加这么一段:

find_program(CPPCHECK_EXECUTABLE cppcheck) if(CPPCHECK_EXECUTABLE) add_custom_target(static-analysis COMMAND ${CPPCHECK_EXECUTABLE} --enable=warning,performance,portability --std=c99 --platform=embedded --error-exitcode=1 --suppress=missingIncludeSystem --inline-suppr --template="cppcheck: {file}:{line}: {severity}: {message}" -I ${CMAKE_SOURCE_DIR}/include ${CMAKE_SOURCE_DIR}/src ${CMAKE_SOURCE_DIR}/test COMMENT "Running Cppcheck static analysis" VERBATIM ) endif()

使用VERBATIM能避免 CMake 对参数里的特殊字符做意外转义,这个在带模板字符串时尤其重要。如果你把${CMAKE_SOURCE_DIR}/src换成了绝对路径,命令也能正常执行,但建议用相对路径或者源目录变量,方便不同环境切换。

3.4 关于 CMAKE_C_CPPCHECK 的提醒

CMake 其实还有一个内置机制:设置CMAKE_C_CPPCHECK和CMAKE_CXX_CPPCHECK变量后,每次编译源文件时都会自动调用 Cppcheck。这个机制看似方便,但有两个实际问题。一是它按单个源文件触发,丢失了全局视图,unusedFunction这类全局检查结果会不准确。二是它嵌进编译流程,会让每次增量编译都变慢,团队协作时尤其明显。

所以我更建议只把自定义 target 作为标准接入方式,CMAKE_C_CPPCHECK可以是偶尔本地调试时用用,不要写进默认构建流程。

4. 嵌入式代码的误报重灾区:硬件寄存器和中断场景怎么调教

跑 Cppcheck 最难的地方不在让它报错,在于怎么处理那些“它觉得有问题、但嵌入式代码里其实是故意的”告警。硬件寄存器操作和中断上下文是误报最集中的区域,如果不会调教,你会在前三天就被海量告警劝退。

4.1 误报不等于工具不行,而是它不懂硬件

嵌入式代码大量操作 volatile 寄存器、内存映射 IO、位域和系统宏,这些写法在通用 C 语言的语义下确实有“冗余”或“可疑”的嫌疑,但在硬件层面却是必须的。

比如读一个状态寄存器来清除中断标志位,是最常见的硬件操作。代码可能是这样:

uint32_t clear_interrupt(void) { const volatile uint32_t status = *(volatile uint32_t *)0x40001000; return status; }

在 Cppcheck 看来,status这个局部变量的唯一用途就是被返回,逻辑上等于“读了一个值又原样给出去”,可能触发一些优化和可读性方面的提示。但在硬件层面,你这一读本身就产生了清中断标志的副作用,返回值只是附带产物,不能简单改成“不读”。这种“看起来多余、实际上是硬件要求”的场景在驱动代码里比比皆是。

4.2 高频误报清单和处理策略

我把自己见过的误报按照频率排了个序,方便你对照排查。

第一类,unreadVariable或value assigned but never used。典型场景就是上面说的读寄存器清标志,以及“读-改写”寄存器操作,比如先读、再置位、再写回。这种告警建议用内联抑制或者按文件抑制,不要改代码,更不要把读操作删掉。

第二类,possible null pointer dereference。硬件抽象层里很多 API 返回的指针指向固定寄存器地址,在特定芯片上永远不会是空指针,但 Cppcheck 不具备这个知识。如果你确定某个 API 的返回值不可能为空,与其在每处调用点加if (ptr == NULL)去哄工具,不如在配置层面声明这个 API 的语义。Cppcheck 支持的--library配置里可以描述返回值特性,这样全工程都能受益。

第三类,integer overflow。嵌入式代码里经常利用无符号整数的回绕特性做循环计数、超时判断。在 C 语言里无符号回绕是明确定义的,Cppcheck 默认会提示,但它不一定知道“这正是设计意图”。这类告警要根据实际情况判断:能改的类型就改,确实是有意为之的,通过注释和抑制规则声明意图。

第四类,duplicate branch或条件重叠。条件宏展开之后,在预处理器眼里可能看起来两个分支条件一样,但宏展开的具体数值其实不同。这种情况不要急着删分支,先看宏定义再决定。

第五类,条件编译导致的大量“找不到声明”。芯片厂商的头文件生成方式千奇百怪,Cppcheck 在处理时经常因为缺少完整的宏定义链条而报出大量缺头文件、未知宏之类的信息。这些基本都可以用--suppress=missingIncludeSystem和--file-filter过滤掉,不必逐条纠结。

4.3 统一抑制清单与 API 语义描述

对于确认过、可以安全屏蔽的告警,不要散落在命令行里,建议集中管理。Cppcheck 支持--suppressions-list=file.txt,格式如下:

// 厂商自动生成代码里的空指针检查告警全部跳过 possibleNullPointerDereference:*generated/*.c // 系统头文件缺失信息 missingIncludeSystem // 工具无法理解的硬件语义 unreadVariable:*src/hal/*.c

关键是每个抑制项后面都要写注释说明理由。团队协作时,抑制规则就是技术决策的记录,别人 review 的时候能知道你当时为什么这么判断。如果只是一味压告警,不写原因,半年后没人敢动这些规则,工具的价值就打折了。

还有一种更细粒度的方式,是在源码里直接用行内注释抑制。比如:

volatile uint32_t dummy = *(volatile uint32_t *)REG_STATUS; // cppcheck-suppress unreadVariable

注意cppcheck-suppress这段注释必须和目标代码在同一行,而且格式要严格按照规则名来写,否则不生效。行内抑制适合低频、局部、理由非常明确的场景;高频规则问题还是尽量走统一抑制清单。

4.4 首次导入时的处理顺序

如果你是在一个存量项目上第一次引入 Cppcheck,我强烈建议按这个顺序来,而不是一上来就开全量检查。

第一步,先只跑--enable=warning这一组,把最核心的错误和告警全部修完。第二步,再打开performance和portability,人工确认那批硬件相关的提示,把真正可以优化的顺手改掉,剩下的进抑制清单。第三步,等前面两步都稳定了,再考虑开style。第四步,把厂商生成的芯片驱动目录加入扫描排除范围,或者用--file-filter限制只扫描你自己维护的业务代码目录,这会立竿见影地减少 80% 以上的无关输出。

在跑完整分析之前,不要急着定 CI 门禁。先让工具跑上几次,输出稳定了,告警数量收敛了,再把“有新告警就让构建失败”的门槛立起来,否则第一天十条、第二天变成三百条,后期日子很难过。

5. 接入CI质量门禁的团队玩法:告警分级、增量检查与版本固化

工具本身能跑通只是第一步,真正让 Cppcheck 在团队里产生长期价值,是靠 CI 流程把它变成一条硬性纪律。这部分聊聊我在团队落地时的做法,包括门禁怎么分级、增量检查怎么写、为什么版本必须锁死。

5.1 门禁怎么设才不吓人

一开始就把--enable=all设成 CI 失败条件,基本等于宣告工具不可用。我更推荐分级推进:

第一阶段,只把error 级问题作为失败条件,warning 先做记录不做拦截。绝大多数用户对这个阶段没有异议,因为 error 级意味着“几乎肯定是 bug”。第二阶段,存量 error 清零、warning 也清理得差不多之后,再把 warning 级问题纳入失败条件。第三阶段,等团队习惯了,再把performance和portability这类建议性信息接入人工周报,但不强制当天清零。

关键是每条规则都要真正有人负责。没人负责的门禁,等于没有门禁。哪怕一开始只有一个负责人,也可以先把流程跑起来,再逐步扩大。

5.2 把 XML 报告变成流水线里看得懂的东西

Cppcheck 支持--xml输出,这个格式很适合在 CI 里做进一步解析。我的做法是让流水线先跑一次全量扫描,生成 XML 报告,再通过一些转换脚本把 XML 转成类似 JUnit 的报告格式,这样 CI 平台的仪表板上就能直观看到问题数量、分布和趋势。

转换脚本本身不复杂,就是把<error>节点里的file、line、severity、msg字段取出来,映射成标准格式。真正麻烦的是初始基线的确定。我建议在接入 CI 的第一周,把每次扫描的 XML 都存档,比较正常和故障两种状态下的告警数量差异,判断工具输出是否稳定。如果每天告警数量上下浮动很大,先排查是不是 Cppcheck 版本或参数不一致,再排查是不是代码本身改动剧烈。

5.3 增量检查脚本与全量巡检的搭配

全量扫描在项目大了之后会变慢,十几分钟甚至更久都有可能,不适合每个提交都跑。合并请求阶段更适合做增量检查:只针对本次改动涉及的.c文件做检查。一条简单可靠的脚本大概长这样:

#!/usr/bin/env bash set -euo pipefail CHANGED_FILES=$(git diff --name-only -- '*.c' '*.h' || true) if [ -z "$CHANGED_FILES" ]; then echo "No changed C source files." exit 0 fi echo "$CHANGED_FILES" | xargs cppcheck \ --enable=warning,performance \ --std=c99 \ --platform=embedded \ --error-exitcode=1 \ --suppress=missingIncludeSystem \ --inline-suppr

增量检查的优点是快,适合在合并请求阶段拦截新增问题。但它有盲区,比如改动一个头文件可能导致其他编译单元出现新问题,增量脚本感知不到。所以我的做法是双轨制:合并请求阶段跑增量检查,存放阶段跑全量检查,每晚触发一次。增量负责“新代码别带病进来”,全量负责“整个工程别在重构中悄悄变坏”。

5.4 版本锁死:被忽视的坑

我见过太多项目在接入 Cppcheck 时踩同一个坑:本地随机装了一个版本,CI 里又是另一个版本,两个版本的告警结果差异巨大,导致“我本地明明没告警,CI 却红了”。

Cppcheck 的规则库是随版本迭代的,2.x 的几个版本之间,对某些结构体的判定逻辑都改过,更不用说跨大版本的差异。所以接入 CI 的第一天,就要把 Cppcheck 固定成一个明确版本,写入依赖锁定或者构建镜像描述里。本地开发可以用源码编译同一个 tag,避免版本漂移。

如果团队决定升级 Cppcheck 版本,不要直接替换。先在一个单独分支上跑一遍全量分析,对比新旧版本的告警差异,把新增告警全部 review 一遍,确认是规则增强还是误报之后,再决定合并进主流程。否则升级就是给自己找麻烦。

5.5 它不能替代代码评审和测试

把 CI 门禁做好,不意味着静态分析可以取代其他质量手段。Cppcheck 擅长的是“快速围剿逻辑类问题”,它能证明代码里没有它认识的那些反模式,但证明不了代码设计正确、并发安全、需求实现完整。所以我的团队里,Cppcheck 是质量防线上的第一道闸,后面仍然跟着代码评审、单测、集成测试和硬件在环测试。

规则的优先级也是一样:先把编译器告警清零,再把 Cppcheck 核心告警清零,最后才是代码评审和测试。每一层承担的职责不同,不要越俎代庖,也不要因为某一层做得好就放松另一层。

我自己在接入 Cppcheck 时也曾被上千条信息吓了一跳,后来学聪明了:先把warning级别清零,再用统一抑制规则管理掉硬件寄存器代码的噪音,最后才把 CI 门槛拉起来。它带来的最直接影响是,那些在大家眼里“看起来没问题”的代码,在合入之前就被拦住了好几次,尤其是空指针和数组越界这类数据流问题,到后期基本不再出现在现场故障清单里。

如果你也是第一次引入,我给的建议很简单:先把版本锁死,再跑全量扫描,不要急着开 style,真正该盯的是error和warning级别的数据流问题。把这一层做扎实,Cppcheck 会成为嵌入式工程里性价比最高的一道质量闸门。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询