做嵌入式这行久了,多少都有个共识:编译器的-Wall -Wextra是底线,不是上限。项目里真正让人半夜爬起来看设备的那些问题,往往不是语法错误,也不是类型不匹配,而是某条路径上指针忘了判空、某个数组在极端工况下越了一位、某个中断和主循环共享的变量没加volatile、某个资源在错误分支里漏了释放。这类缺陷编译器看不见,单元测试覆盖不到,跑几个月才在客户现场冒出来。Cppcheck 这类静态分析工具补的就是这一块:不跑代码,只读代码,把可疑的路径和控制流翻一遍。这篇东西聊的是我这几年的实际用法——怎么让 Cppcheck 真正看懂嵌入式工程(而不是把它当成一个只会刷几万条missingInclude的噪声源)、误报怎么分类治理、流水线里怎么接才能既挡得住问题又不拖慢构建。
1. 编译器警告之外,静态分析在嵌入式里补的是哪块短板
先把定位说清楚,不然很容易对工具产生不切实际的期待。Cppcheck 不做形式化验证,不做跨翻译单元的链接期分析,也不理解你的中断优先级和时序约束。它能做的是单文件(加头文件展开)级别的模式匹配和有限的数据流追踪:数组下标是否可能越界、指针解引用前是否有判空、局部变量是否在赋值前被读、switch是否漏了default、memcpy的长度参数是不是来自一个可能溢出的表达式、malloc和free是否配对。这些东西编译器大多不报,因为它要保证不误报,而静态分析的定位是宁可多报让你自己判。
1.1 嵌入式和桌面端代码的缺陷形态差别
桌面程序的缺陷大头在内存管理和并发,嵌入式项目里更常见的是一批"看起来合理"的写法:
- 直接在地址上做强制类型转换访问寄存器,比如
*(volatile uint32_t *)0x40021000 = val;。Cppcheck 对常量地址的指针操作会报nullPointer或者arrayIndexOutOfBounds一类的告警,因为它不知道那是合法的内存映射区。 - 中断服务函数和主循环共享的环形缓冲区,索引的更新和读取不在同一个临界区里。Cppcheck 不会报数据竞争,但会报索引可能越界、变量的值在此路径下未初始化。
- 启动代码、链接脚本里定义的符号,比如
_estack、__data_start,在 C 里以extern声明使用。工具看不到定义,一堆unusedVariable或者unknownMacro就出来了。 - 为了省 RAM,大量使用全局静态缓冲区加自定义的内存池。指针的生命周期靠人工约定,静态分析完全无法判断谁该释放。
我印象很深的一次,一个电机控制的项目,故障停机分支里有个goto cleanup,cleanup标签位于函数末尾,但其中一个错误分支在跳转前把 PWM 输出寄存器关掉了,另一个分支忘了关。编译没报,测试台架上也没复现,因为那个分支要同时满足过流和通信超时才进得去。后来用--enable=style跑了一遍,unusedLabel和几条variableScope的提示把我引到那段代码上,人工一读就发现了不对称。静态分析很多时候不是直接给出答案,是把你推到该看的那几行代码前面。
1.2 工具的能力边界,早点接受省得走弯路
有几个事 Cppcheck 明确做不到,提前说清楚能省掉不少折腾:
| 做不到的事 | 实际影响 | 替代方案 |
|---|---|---|
| 跨文件追踪函数调用的副作用 | 定义在 A 文件的函数修改了全局变量,B 文件里的调用点看不到 | 靠--project提供编译数据库做编译单元级分析,配合代码规范约束 |
| 理解中断上下文与并发 | 竞态、临界区缺失不会报 | 人工评审 + 代码规范检查脚本 |
| 理解链接脚本与内存布局 | 栈溢出、段越界不报 | 运行时栈水位监测 + 链接期检查 |
| 精确的宏展开语义 | 多层嵌套宏下的逻辑错误可能漏检 | 减少宏的复杂度,用 inline 函数替代 |
| 编译器内建扩展 | __attribute__、__asm块处理不完整 | 用-D补宏定义,或用抑制规则兜底 |
接受这些边界之后,用法就清晰了:把它当成一个不知疲倦的代码审阅者,专门盯那些"写的时候觉得没问题、读的时候一眼能看出来"的低级错误,而不是指望它发现架构层面的设计缺陷。
2. 先让工具看懂工程:平台声明、宏对齐与头文件路径
这一步是整件事的地基,也是最容易被跳过的一步。很多人第一次跑 Cppcheck 就加了--enable=all,然后对着一屏几千条的missingInclude和unknownMacro骂工具不好用,实际上问题出在工具压根没搞清楚你在给什么芯片写代码。
2.1--platform选错,宽度假设直接带偏判断
Cppcheck 默认按native平台走,也就是按运行分析的那台机器来推算类型宽度。这在交叉编译场景下是致命的:long在 64 位主机上是 8 字节,在很多 32 位 MCU 上是 4 字节;size_t的宽度也跟着变。宽度判断错了,越界检测、整数溢出检测、位运算的符号性判断全都会跟着错。
Cppcheck 内置了一批平台,常用的有unix32、unix64、avr8、pic8、pic16、pic32。对绝大多数 32 位 ARM Cortex-M 的目标,unix32的宽度模型(int4 字节、long4 字节、size_t4 字节、指针 4 字节)基本吻合,实际我一般就这么用:
cppcheck --platform=unix32 --std=c11 ...但如果你用的芯片比较特殊,比如某些 DSP 上char是 16 位,或者指针宽度和size_t宽度不一致,那就得自己写平台描述文件:
<?xml version="1.0"?> <platform> <char_bit>8</char_bit> <default-sign>signed</default-sign> <sizeof> <short>2</short> <int>4</int> <long>4</long> <long-long>8</long-long> <float>4</float> <double>8</double> <long-double>8</long-double> <pointer>4</pointer> <size_t>4</size_t> <wchar_t>2</wchar_t> </sizeof> </platform>调用方式是--platform=file --platform-file=./tools/mcu_platform.xml。我一般会把这个文件跟工程一起入库,放在tools/cppcheck/下面,这样换机器、换 CI 环境行为一致,不会出现本地干净、流水线报一堆的情况。
提示:平台文件的
size_t和pointer两个字段一定要写对。这两个错了,后面所有的缓冲区长度检查都会失真。
2.2 宏和头文件路径:先跑通--check-config再谈检查
编译器有一整套内建宏,比如__GNUC__、__arm__、__STDC_VERSION__。这些宏在头文件里大量用于条件编译,Cppcheck 不知道的话,#if defined(__GNUC__)里的代码就被整段跳过了,等于没检查。所以第一件事是补齐宏定义。我一般的做法是把工程里用到的编译器相关宏统一写进一个参数文件:
cppcheck \ --platform=unix32 \ --std=c11 \ -D__GNUC__=10 \ -D__arm__=1 \ -D__STDC_VERSION__=201112L \ -DFW_VERSION_MAJOR=2 \ -I./inc \ -I./drivers/include \ -I./middleware/hal/include \ ./src ./drivers然后单独跑一次配置检查:
cppcheck --check-config --platform=unix32 --std=c11 \ -I./inc -I./drivers/include ./src 2>&1 | head -50--check-config不做代码检查,只输出头文件找不到、宏未定义之类的问题。这一步通过的标志是输出里基本没有missingInclude和unknownMacro。剩下的missingIncludeSystem可以放心抑制,因为那是系统头文件(stdint.h、string.h),Cppcheck 自带一份简化实现,找不到实际文件是正常的:
--suppress=missingIncludeSystem这一步别嫌麻烦。我在一个项目上见过这样的场景:#ifdef USE_DMA里的 DMA 传输函数因为宏没定义被整段跳过,跑了三个月静态分析,那段代码从来没被检查过一次。后来补上-DUSE_DMA=1,第一次跑就报出来一个memcpy的目标缓冲区大小算错了。这种"假干净"是最危险的。
2.3 自定义 library 配置,让工具认识你的寄存器和外设接口
--library这个参数知道的人不多,但它对嵌入式特别有用。Cppcheck 支持通过.cfg文件描述函数的行为契约:参数是否可以为空、返回值是否可能为空、函数是否不会返回、参数之间的关联关系。官方自带std.cfg(标准库)、posix.cfg、gnu.cfg等,也可以写自己的。
举个最典型的例子。假设你封装了一层闪存写入接口:
int flash_write(uint32_t addr, const uint8_t *buf, size_t len);如果不告诉工具buf不能为 NULL,它就不会在调用点做判空提醒。写一个mcu_hal.cfg:
<?xml version="1.0"?> <def> <function name="flash_write"> <arg nr="1" direction="in"/> <arg nr="2" direction="in"> <not-null/> </arg> <arg nr="3" direction="in"/> <returnValue type="int"/> </function> <function name="hal_enter_critical"> <noreturn>false</noreturn> </function> </def>然后--library=./tools/cppcheck/mcu_hal.cfg。效果是调用flash_write时如果传了一个可能为 NULL 的指针,会直接报nullPointer。这比事后人工评审有效得多。
寄存器访问那块的误报,处理思路不太一样。比较稳妥的做法是不要在业务代码里直接写裸地址,而是统一走一层内联封装,然后用抑制规则把封装层整体屏蔽掉:
static inline uint32_t reg_read(volatile uint32_t *addr) { return *addr; }然后在抑制文件里加一条--suppress=nullPointer:src/hal/reg_access.c。把误报的边界收在一个只有几十行的文件里,比在几十个业务文件里到处撒抑制注释干净得多。
3. 开关拧到哪一档:检查类别、--force与--inconclusive的取舍
配置跑通之后就是调档位。这里的核心矛盾很朴素:检查开得越多,找到的真问题越多,同时需要人工判读的噪声也越多。噪声多了,团队就会开始无视告警,静态分析就废了。所以档位的选择标准不是"能开就开",而是"团队愿意为每条告警花时间看"。
3.1 各类检查的实际收益与噪声比
Cppcheck 的--enable可以组合error、warning、style、performance、portability、information、unusedFunction。默认只开error。我的经验配置是:
--enable=warning,style,performance,portability,unusedFunctioninformation我一般不开,它主要报missingInclude之类的配置问题,属于调试阶段用的东西,不适合放进门禁。
| 类别 | 典型检查项 | 嵌入式场景的实际价值 | 噪声情况 |
|---|---|---|---|
| error | 空指针解引用、数组越界、除零 | 极高,几乎每条都要看 | 低,但寄存器操作会造成误报 |
| warning | 未初始化变量、未使用变量、隐式类型转换、赋值与比较混淆 | 高,未初始化变量在裸机启动阶段特别常见 | 中,弱符号和链接脚本符号会引起误报 |
| style | 函数过长、变量作用域过大、多余的const、C 风格转换 | 中,主要用来统一代码风格 | 中高,需要团队约定阈值 |
| performance | 按值传递大结构体、循环内重复计算 | 中,嵌入式对栈开销敏感,按值传结构体确实是坑 | 低 |
| portability | 位运算的可移植性、char符号性依赖、类型宽度假设 | 高,特别是要在多个芯片平台间复用的驱动代码 | 低到中 |
| unusedFunction | 未被调用的静态函数 | 中,能揪出死代码 | 高误报,中断向量表引用、函数指针注册都会导致误报 |
unusedFunction这个类别要单独说。它在嵌入式项目里的误报率非常高,因为大量函数是通过中断向量表、函数指针表、或者__attribute__((section))的方式被引用的,静态分析看不到这种引用关系。我的做法是开,但配好抑制规则,把驱动层的函数指针表所在文件整体屏蔽,只让它在业务逻辑层工作:
--suppress=unusedFunction:drivers/vectors.c --suppress=unusedFunction:drivers/startup.c3.2--force和条件编译:检查所有配置组合的代价
代码里有#ifdef的时候,Cppcheck 默认只检查"常见"的那几种配置组合。--force会强制检查所有可能的宏组合,理论上覆盖更全。问题是组合数量是指数级的:10 个用#ifdef控制的宏,理论上 1024 种组合。实践中我见过一个工程加了--force之后,单次分析时间从 4 分钟涨到 70 多分钟,而且报出来的大量告警来自"开发者这辈子都不会打开"的配置。
实用的折中方案是用--max-configs=N限制探索的组合数,比如:
--force --max-configs=12同时对项目里真正会用到的配置组合,用显式-D定义来固定。比如产品有三个硬件版本,用-DHW_REV=1/2/3分别跑三次完整分析,这比让工具漫无目的地探索 1024 种组合有效得多:
for rev in 1 2 3; do cppcheck --platform=unix32 --std=c11 -DHW_REV=$rev \ --enable=warning,style,performance,portability \ --output-file=build/cppcheck_rev$rev.xml --xml --xml-version=2 \ -I./inc ./src done3.3--inconclusive与检查深度:什么时候该加
--inconclusive会打开"不确定"级别的告警,典型场景是某个变量在某条复杂路径上可能未初始化,但工具无法完全证明。这类告警里有真金,但判读成本高。我的经验是:新项目接入阶段不开,等--enable=warning,style的告警清理到接近零之后再开,否则新手很容易被淹没。
检查深度方面,如果你用的版本支持--check-level,默认的normal对绝大多数项目够用;exhaustive会做更深的路径探索,代价是时间和内存。我在一个 80 万行左右的固件工程上测过,exhaustive相比normal增加约 40% 的耗时,多找出十几条问题,其中三条有价值。这种投入产出比取决于项目风险等级,功能安全的项目值得开,普通消费电子项目就未必。
3.4 增量缓存:分析目录别放错地方
Cppcheck 支持--cppcheck-build-dir,把中间分析结果缓存到目录里,下次分析只重算改动过的文件。这在 CI 上能省下大量时间:
cppcheck --cppcheck-build-dir=./build/cppcheck-cache ...两个注意事项。第一,缓存目录必须能在 CI 的多次构建之间持久化,否则等于没有。第二,缓存对宏定义敏感,如果不同构建任务的宏定义不同,必须用不同的缓存目录,否则会拿到错误的缓存结果——我曾经因为共用缓存目录,导致一个改了-DHW_REV的构建仍然复用了旧结果,漏掉了一个版本相关的越界问题,这个坑值得记一笔。
4. 误报治理:把"随手 suppress"变成可审计的抑制体系
假如一个团队用静态分析用了半年,抑制文件里堆了 400 条规则,没人说得清每条为什么存在,那这套体系基本就废了。误报治理的关键不在于怎么抑制,而在于抑制这个动作本身必须留下可追溯的依据,并且数量要能被监控。
4.1 先把误报的成因分个类,不同类处理方式完全不同
| 成因 | 典型表现 | 处理方式 |
|---|---|---|
| 工具不懂硬件语义 | 寄存器地址被当空指针、外设结构体被当越界 | 收敛到 HAL 层,整文件抑制 |
| 工具看不到定义 | 链接脚本符号、启动代码、汇编模块 | --suppress=*:path/*局部屏蔽 |
| 间接引用不可见 | 函数指针表、中断向量、弱符号 | 屏蔽对应文件,或用// cppcheck-suppress内联 |
| 工具本身的误判 | 复杂的宏展开、编译器的特殊语法 | 内联抑制加注释说明原因 |
| 代码确实有问题但暂时不改 | 历史遗留、老代码不敢动 | 进基线抑制,登记技术债编号 |
分类的意义在于:前三类是工具能力问题,抑制是合理的;第四类要写清楚原因;第五类必须挂账跟踪,不能一抑制了之。我在的项目里要求每条进基线的抑制都必须带一个工单编号或者维护人名字,写在抑制文件的注释里。
4.2 内联抑制与抑制文件的边界怎么划
Cppcheck 有两种抑制方式。内联抑制写在代码里:
// cppcheck-suppress nullPointer *(volatile uint32_t *)0x40021000 = 0x01; // 新版本支持一条注释抑制多个 id // cppcheck-suppress[unreadVariable, unusedVariable]注意内联抑制需要在命令行加--inline-suppr才会生效,这个很容易忘。
另一种是抑制文件,通过--suppressions-list=加载:
// 说明:寄存器裸访问封装层,非空指针误报 nullPointer:src/hal/reg_access.c // 说明:启动代码中的符号由链接脚本定义 unusedStructMember:src/boot/* // 说明:第三方协议栈,不修改 *:third_party/*我的划分原则是:
- 内联抑制用于"这一行确实特殊"的情况,范围最小,距离代码最近,读代码的人一眼能看到。缺点是污染了源码,而且要开
--inline-suppr。 - 抑制文件用于"整个文件或整个目录都遵循某种约定"的情况,比如寄存器访问层、第三方库、自动生成的代码。优点是集中管理,方便评审时可一次性看全部。
反面模式是给某条规则写一个全局抑制,比如--suppress=nullPointer。这等于把这个检查关掉了,绝对不要做。必须带路径限定,至少限定到文件。
4.3 抑制数量当作技术债指标来盯
这是我觉得最值得做的一件事。统计抑制文件里每个 id 出现的次数,做成一个趋势图:
grep -oE '^[a-zA-Z]+:' .cppcheck-suppressions | sort | uniq -c | sort -rn输出的东西长这样:
48 nullPointer: 31 unusedFunction: 12 uninitvar: 6 arrayIndexOutOfBounds:对外说的时候可以说"静态分析零告警",但内部要看的是抑制总量的变化趋势。抑制数量只增不减就是坏信号,说明要么代码在恶化,要么团队在偷懒。反过来,nullPointer抑制突然增长,往往意味着有新的代码在绕过 HAL 层直接操作地址,这是个架构层面的预警。
4.4 基线抑制法:老项目接入的正确姿势
一上来就给一个跑了十年的老工程开静态分析,结果一定是几千条告警,然后团队集体放弃。正确做法是基线法:
第一步,全量跑一次,输出 XML:
cppcheck --xml --xml-version=2 --output-file=baseline.xml \ --enable=warning,style,performance,portability,unusedFunction \ --platform=unix32 --std=c11 -I./inc ./src第二步,写个小脚本把 XML 里的id、file、line提取出来,生成抑制文件:
import xml.etree.ElementTree as ET tree = ET.parse('baseline.xml') root = tree.getroot() lines = [] for err in root.iter('error'): eid = err.get('id') if eid in ('missingInclude', 'missingIncludeSystem', 'checkersReport'): continue loc = err.find('location') if loc is None: continue f = loc.get('file').replace('./', '') n = loc.get('line') lines.append(f'{eid}:{f}:{n}') with open('.cppcheck-baseline', 'w') as fh: fh.write('\n'.join(sorted(set(lines))))第三步,CI 里同时加载基线抑制和人工抑制文件,只对新代码报警:
cppcheck --suppressions-list=.cppcheck-baseline \ --suppressions-list=.cppcheck-manual ...这套做法的意义在于:存量问题冻结,增量问题零容忍。老代码的问题挂在技术债清单上按计划清理,新提交的代码必须干净。团队的心理压力小很多,落地成功率高得多。缺点是基线文件会随时间失效(代码行号变动),需要定期重新生成,我一般配一个季度维护的任务。
5. 接进流水线:退出码、增量策略和报告怎么用
静态分析不接进 CI,基本等于没有。本地跑不跑全凭自觉,而自觉这个东西在赶进度的时候最先被牺牲。
5.1 退出码与分级门禁设计
--error-exitcode=1让 Cppcheck 发现问题时返回非零,CI 就能判失败。但直接这么配有个副作用:任何一条 style 级别的提示都会让构建红掉,团队很快就会开始想办法绕过它。
我的做法是分两遍跑。第一遍跑严格档位,error 和 warning 级别,用--error-exitcode=1阻断构建:
cppcheck \ --platform=unix32 --std=c11 \ --enable=warning,performance,portability \ --inline-suppr \ --suppressions-list=.cppcheck-baseline \ --suppressions-list=.cppcheck-manual \ --error-exitcode=2 \ --template='{file}:{line}: {severity}: {message} [{id}]' \ -I./inc ./src第二遍跑 style 和 unusedFunction,不阻断,只输出报告:
cppcheck \ --enable=style,unusedFunction \ --suppressions-list=.cppcheck-manual \ --template='{file}:{line}: {severity}: {message} [{id}]' \ --output-file=build/cppcheck-style.log \ -I./inc ./src || true这种分级的逻辑是:能确定是问题的(空指针、越界、未初始化)必须挡;属于风格建议的不挡,但保留可见性,定期看趋势。--error-exitcode我通常用 2 而不是 1,因为 1 有时会被构建脚本误认为是其他失败,用非 0 非 1 的值更容易区分。
提示:
--template一定要设。默认输出格式在 CI 日志里很难定位,用{file}:{line}:开头的格式,大多数 IDE 和代码托管平台都能直接识别成可点击的跳转链接。
5.2 增量分析:只查改动文件不等于只看改动文件
工程大到一定程度,全量分析会变得不可接受。常见的优化是只分析 git diff 出来的改动文件:
git diff --name-only --diff-filter=ACMR origin/main...HEAD \ | grep -E '\.(c|cpp|cc)$' > build/changed.txt if [ -s build/changed.txt ]; then cppcheck --project=build/compile_commands.json \ --file-list=build/changed.txt \ --cppcheck-build-dir=./build/cppcheck-cache \ --error-exitcode=2 ... fi这里有两个坑必须说清楚。
第一个坑:改动一个头文件,会影响到所有包含它的源文件。如果只分析改动的.c文件,头文件里的改动带来的问题就漏了。解决办法是:检测到改动的文件里有.h/.hpp时,退化成全量分析,或者用依赖关系反查出所有包含该头文件的源文件:
if echo "$CHANGED" | grep -qE '\.(h|hpp)$'; then echo "头文件有变更,执行全量分析" FULL_SCAN=1 fi第二个坑:--project=compile_commands.json加--file-list这种组合,在不同版本上行为不太一致,有的版本会把--file-list当成全局文件列表而不是 project 的过滤器。稳妥起见,如果工程能生成编译数据库,我更倾向于直接用--project全量分析加--cppcheck-build-dir缓存,靠缓存来提速,而不是靠过滤文件列表。
5.3 报告落地:让告警出现在该出现的地方
XML 报告在流水线里没人看。要让告警真正被处理,得让它出现在开发者每天看的地方。两条路子:
第一条,转成 JUnit 格式,让 CI 平台把告警渲染成测试结果。这需要一个转换脚本,把 Cppcheck XML 里的<error>节点映射成<testcase>+<failure>。好处是能和现有的测试报告体系合并展示,坏处是转换脚本要自己维护。
第二条,在合并请求的评论里贴出差异部分的告警。这需要拉取目标分支的分析结果做对比,只显示本次新增的告警。实现成本高一些,但效果最好,因为开发者能直接看到"我这个改动引入了什么问题"。
我在的项目最后用的是简化方案:CI 任务失败时把--template输出的文本直接打到日志里,同时在合并请求上留一条固定格式的机器评论,包含告警条数统计和各类别的分布。人不用点进日志就能判断这次改动有没有引入新问题。
5.4 耗时控制:并行、缓存与分阶段
静态分析慢起来是很折磨人的。几个实测有效的优化:
--jobs=N开多进程。默认单进程,在 8 核的构建机上开 8 个并行,耗时能降到原来的三分之一左右。- 缓存目录放到持久化存储上,CI 的缓存机制要利用好。
- 把
--enable=style这类低优先级检查放到夜间定时任务,每次提交只跑高优先级类别。 - 不要对第三方库和自动生成的代码做分析,用
-i排除掉:
cppcheck -i./third_party -i./generated ... ./src-i排除比用抑制规则划算得多,因为它连分析都不做,直接省时间。
6. 跑了大半年之后,几条没人写在文档里的体会
第一件事,别指望一次配置到位。静态分析工具的参数是要跟着代码演进的,项目里加了新外设、换了新芯片、引入了新中间件,配置就得跟着调。我现在的习惯是每季度花半天时间重新审视一遍抑制文件,把已经修掉的、失效的清理掉,把新出现的误报归类。
第二件事,unusedFunction这个检查在新版本上的行为有变化,某些版本对static函数的判断逻辑调整过,升级 Cppcheck 版本之后一定要先跑一遍全量对比,看看告警数有没有异常跳变。我有一次升级之后告警从 80 条涨到 900 条,一查是新版本对某些宏展开的模式识别变严格了,其实是好事,但如果没有对比机制,很可能被当成工具退化直接回滚。
第三件事,关于volatile。Cppcheck 有个redundantAssignment的检查,对volatile变量会误报,因为它假设赋值之后没人读。中断上下文里的读,工具是看不到的。这类告警在嵌入式项目里必须内联抑制加注释说明,千万别改成非volatile去消警告,那是拿正确性换安静。
第四件事,团队推广的真实障碍不是技术,是"告警数量和人的注意力"的平衡。我见过最有效的做法是每周发一次静态分析周报,只列三样东西:本周新增告警条数、top 3 高频告警类型、抑制文件净增量。数据化的东西比讲道理有用得多,两三周之后大家自然会注意不去写那些会触发告警的代码。
第五件事,值不值得给 Cppcheck 写自定义规则。它支持--addon加载 Python 写的扩展规则,理论上可以检查"中断服务函数里不能调阻塞接口"、"寄存器写入必须带屏障"这类项目约定。我试过,可行,但维护成本不低,而且 Python 脚本执行慢。折中方案是只给最关键的 3 到 5 条约定写 addon,其余靠代码评审和 clang-tidy 的 clang-query 补位。工具链的组合比单一工具的极致配置更实用。
最后提一句配置文件的组织方式。我现在的工程里tools/cppcheck/下面固定放四样东西:platform.xml(平台描述)、mcu_hal.cfg(外设接口契约)、suppressions.txt(人工维护的抑制清单,每条带注释)、baseline.txt(自动生成的基线,定期刷新)。CI 脚本只引用这几份文件,参数不散落在各处。换项目的时候把目录拷过去,改改宏定义就能用,这个复用率比想象中高。