Clang Static Analyzer 实战:CMake 工程接入与 GitHub Actions 自动化静态分析
2026/9/16 7:55:37 网站建设 项目流程

先说个真实经历。我之前维护的一个C++工具模块,单元测试全过、CI 编译全绿、Release 都快打了,结果上线当天现场崩了一次。定位了半天,问题出在一个简单逻辑上:某个函数在极端输入下返回空指针,调用方没判空,直接解引用了。这种问题,编译期不报、运行时偶尔崩、单元测试还不一定覆盖得到。后来我把Clang Static Analyzer引入项目,几分钟就挖出好几处同类隐患。它的核心价值就在这里:在编译阶段走一遍完整的路径分析,你不用真正把程序跑起来,就能看到哪些地方可能在运行时出问题。

这篇实战文章是我基于Clang Static Analyzer + CMake + GitHub Actions搭完一整套可复用分析流程的完整记录,整体思路分成三块:本地怎么用、CMake 工程怎么接入、CI 里怎么自动化。C/C++ 开发者,尤其是维护 CMake 构建的团队,可以照着这篇文章把静态分析从“偶尔手动跑一次”变成“每次提交自动扫描”。

1. 为什么要做静态分析:编译通过不等于代码安全

1.1 编译器默认不深入查逻辑问题

很多 C/C++ 开发者对静态分析的第一反应是:编译器本身就有-Wall -Wextra,打开不就行了?这个想法能覆盖一部分问题,但远远不够。

GCC 和 Clang 的告警主要集中在语法、类型、未使用变量、隐式转换这类“从语法和语义上能判断出来”的问题。但对于跨语句、跨函数的路径敏感问题,编译器是克制且保守的。举个例子:

int *get_ptr(int flag) { if (flag > 0) { return NULL; } return valid_ptr; } void use() { int *p = get_ptr(0); // 跟踪不到 flag 的具体值 *p = 42; // 这里 p 会不会是 NULL? }

编译器看到get_ptr(0)只知道它返回int *,至于运行时到底是不是空指针,它不会去模拟执行路径。而Clang Static Analyzer做的事情恰恰是在编译时做符号执行:它会跟踪flag的值、p的可能取值、走到解引用语句时p的状态,从而给出“这里可能空指针解引用”的结论。

所以结论很直接:-Wall -Wextra解决的是“代码写得规范不规范”的问题,静态分析解决的是“代码跑起来会不会炸”的问题。两者互补,不冲突。

1.2 为什么选 Clang Static Analyzer 而不是其他工具

市面上的 C/C++ 静态分析工具不少,我按照实际使用体验和适用场景做了个对比:

工具许可模式分析方式适合场景
Clang Static Analyzer开源(LLVM 项目)路径敏感、符号执行与 Clang 编译生态天然集成
Cppcheck开源规则匹配为主快速扫全局,误报偏多
Coverity商业深度路径分析大规模团队、企业采购
PVS-Studio商业(有社区版)规则匹配 + 部分路径分析习惯 IDE 插件的团队

如果你的项目已经用 Clang 或者正在用 CMake,Clang Static Analyzer 的优势非常明显:同源工具链,不需要额外适配规则库,scan-build直接包装现有构建命令就能跑;GitHub Actions 的ubuntu-latest环境对 LLVM 生态支持又好。不夸张地说,它是我见过的“从 0 到 1 落地成本最低”的静态分析方案。

2. 环境准备:CMake 与 Clang 的安装,版本和 PATH 是两道坎

2.1 CMake 安装的三个平台避坑

先说 CMake。静态分析本身不依赖 CMake,但我们分析的是一个 CMake 工程,构建阶段绕不开它。这些年我见过太多因为 CMake 安装问题卡住的分析流程,先在这里把坑填平。

Windows 系统

如果你在 PowerShell 里敲cmake --version报出下面这个错误:

cmake : 无法将“cmake”项识别为 cmdlet、函数、脚本文件或可运行程序的名称。

说明 CMake 没装,或者装了没进 PATH。前者去 cmake.org/download 下载官方.msi安装包,安装到例如C:\Program Files\CMake。后者需要手动把 CMake 的 bin 目录加入环境变量:

  1. 右键“此电脑” -> “属性” -> “高级系统设置” -> “环境变量”。
  2. 在“系统变量”里找到Path,点“编辑”,新增一行C:\Program Files\CMake\bin
  3. 重新打开 PowerShell,验证cmake --version

这一步失败的根源在于 Windows 的 GUI 安装包默认不勾选“Add CMake to the system PATH”,很多人装完没手动加,就出现了上面的报错。装的时候也可以直接勾选该选项。

Ubuntu / Debian 系统

直接用sudo apt install cmake装出来的版本通常比较旧。比如 Ubuntu 20.04 默认仓库是 CMake 3.16,而一些较新的FetchContentCUDA相关特性要求 3.18 以上;另外,如果你要分析的项目用了CMakePresets.json,又要求 3.19 之后的版本。这里我推荐从官方下载二进制包,版本可控,卸载也干净:

wget https://cmake.org/files/v3.28/cmake-3.28.1-linux-x86_64.tar.gz tar xf cmake-3.28.1-linux-x86_64.tar.gz sudo mv cmake-3.28.1-linux-x86_64 /opt/cmake sudo ln -s /opt/cmake/bin/cmake /usr/local/bin/cmake cmake --version

这样装完,系统里可能还残留 apt 版 CMake。为了避免混淆,建议先卸载旧版:sudo apt remove cmake。如果之前没有用 apt 装过,那跳过这步即可。手动安装在/opt/cmake的版本,卸载时直接删目录和软链接就行。

macOS

brew install cmake即可,没什么特别要交代的,装完cmake --version验证。

2.2 确认 scan-build 可用

Clang Static Analyzer 的分析入口是scan-build工具。注意,scan-build不一定随clang一起安装,需要单独确认。

在 Ubuntu 上,如果执行scan-build --version提示找不到命令,先安装clang-tools

sudo apt-get update sudo apt-get install -y clang clang-tools

macOS 装了 Xcode Command Line Tools 之后一般会自带。Windows 上,如果通过 LLVM 官方安装包安装,scan-build也包含在内;但要确保它在 PATH 里。

注意:不同发行版对scan-build的包归属不一样。有的在clang-tools里,有的在clang里。装完一定要执行scan-build --version验证,这一步能省掉后面大量排查时间。

3. 本地实战:scan-build 包装 CMake 构建,一条命令跑完整分析

3.1 scan-build 是怎么“拦截”编译的

scan-build的工作方式非常巧妙:它不直接解析源码,而是把自己的编译包装器(compiler wrapper)暴露给构建系统。构建系统调用gccg++clang这些编译器时,实际上调用了scan-build的包装器,包装器在真正编译之前把一份副本交给 Clang Static Analyzer 做符号执行分析,编译完成后再把结果汇总成报告。

这带来一个结论:任何能调用编译器的构建系统,理论上都能被 scan-build 分析。CMake 工程也一样,只要构建命令最终是调用编译器,scan-build 就能在中间拦截到。

这个设计对 CMake 工程特别友好,因为它不需要你改CMakeLists.txt,不需要给项目增加任何依赖,纯粹是“外部包装”。

3.2 完整实操:扫描一个 CMake 工程

假设项目结构如下:

my-project/ ├── CMakeLists.txt └── src/ ├── main.cpp └── util.cpp

先用 scan-build 包装 CMake 的配置阶段,再包装构建阶段:

cd my-project mkdir -p build-analyze scan-build --status-bugs -o /tmp/scan-report \ cmake -S . -B build-analyze -DCMAKE_BUILD_TYPE=Debug scan-build --status-bugs -o /tmp/scan-report \ cmake --build build-analyze -j$(nproc)

解释一下关键参数:

  • --status-bugs:如果分析器发现了 bug,scan-build进程返回非零退出码。本地调试时可以不加,但后面接 CI 时必须加,这样才能让流水线感知到告警。
  • -o /tmp/scan-report:报告输出目录。
  • 两次包装都要加scan-build。第一次 configure 时 CMake 会做编译器探测,编译器路径会在这一步被缓存到CMakeCache.txt;如果只在第二步 build 时加 scan-build,CMake 可能仍调用缓存里的真实编译器,导致包装失败。

这里有个经验:分析用的 build 目录最好单独建。不要直接复用平时编译用的build目录,因为旧的CMakeCache.txt可能缓存的编译器路径和 scan-build 的 wrapper 不一致。我的习惯是固定用build-analyze作为分析专用目录。

3.3 看报告:scan-view 与 HTML

分析完成后,/tmp/scan-report下会生成一个 HTML 报告目录,里面按告警级别列出了所有发现的问题。最简单的查看方式是用scan-view起一个本地服务:

scan-view /tmp/scan-report/2024-01-15-10-30-00-12345

scan-view会起一个本地端口,浏览器打开后按源码位置、缺陷类型分组展示告警,还能跳转到具体的代码行。这条命令在 macOS 和 Linux 上都能直接跑,排查问题时比直接翻 HTML 树高效得多。

4. 把分析能力沉淀进 CMake 工程:不污染代码的接入方式

4.1 为什么我不建议改 CMakeLists.txt

很多人接入静态分析时,第一反应是往CMakeLists.txt里加option(ENABLE_STATIC_ANALYZER)target_compile_options之类的东西,把分析选项硬编码进构建脚本。我个人的建议是:不要这么做

原因有两个。第一,静态分析是一个“临时介入”的过程,它不应该成为日常编译的一部分。把--analyze相关参数写进 CMakeLists 后,每次编译都会变慢,开发者容易反感,最后反而把功能关掉。第二,CMake 里塞分析逻辑会让构建脚本变得复杂,尤其是大型项目里还要处理不同编译器的差异。相比之下,用scan-build从外部包装构建命令,干净利落,项目本身零修改。

4.2 生成 compile_commands.json,给更多工具留后路

虽然不改 CMakeLists 是我推荐的接入方式,但有一个 CMake 选项值得打开,那就是CMAKE_EXPORT_COMPILE_COMMANDS。它会在 build 目录下生成compile_commands.json,把整个工程每个源文件的精确编译命令都列出来。

cmake -S . -B build-analyze \ -DCMAKE_EXPORT_COMPILE_COMMANDS=ON \ -DCMAKE_BUILD_TYPE=Debug

生成之后,这个文件可以被 clang-tidy、clangd、include-what-you-use 等工具复用。举个例子,想用 clang-tidy 里的clang-analyzer-*检查器做补充分析:

clang-tidy -p build-analyze src/util.cpp \ -checks=-*,clang-analyzer-*,clang-analyzer-core.NullDereference

-p指定compile_commands.json所在目录,它就会按这份编译数据库精确复现编译参数,不会因为 include 路径缺失而产生误报。这一步的价值在于:Clang Static Analyzer 查出的是整体路径问题,clang-tidy 可以针对单个文件、单个源码目录做更细粒度的扫掠。两者共用同一份compile_commands.json,互不冲突。

4.3 封装一个 analyze.sh,让团队所有人都能跑

既然不改 CMakeLists,那怎么让团队零门槛跑分析?答案是项目根目录放一个scripts/analyze.sh,把上面的命令封装起来:

#!/usr/bin/env bash set -euo pipefail BUILD_DIR="${BUILD_DIR:-build-analyze}" REPORT_DIR="${REPORT_DIR:-/tmp/scan-report}" rm -rf "$BUILD_DIR" mkdir -p "$BUILD_DIR" scan-build --status-bugs -o "$REPORT_DIR" \ cmake -S . -B "$BUILD_DIR" \ -DCMAKE_BUILD_TYPE=Debug \ -DCMAKE_EXPORT_COMPILE_COMMANDS=ON scan-build --status-bugs -o "$REPORT_DIR" \ cmake --build "$BUILD_DIR" -j"$(nproc)" echo "报告目录: $REPORT_DIR"

脚本开头rm -rf "$BUILD_DIR"是为了保证每次都是全新目录,避免 CMake 缓存导致 scan-build 静默失效。配合项目文档里“跑分析只需要执行scripts/analyze.sh”这一句话,团队接入成本会低很多。

5. GitHub Actions 自动化:每次提交都自动跑一遍静态分析

5.1 自动化要解决三个问题

本地能跑通只是第一步。静态分析最大的价值在于“持续”:每次代码变更都能自动扫一遍,而不是等发版前手动跑一次。落到 GitHub Actions 上,流水线本质上要解决三个问题:

  1. 拉取最新代码,安装 clang、cmake、scan-build 工具链。
  2. 执行与本地完全一致的分析流程。
  3. 把报告传给开发者,并且让流水线在发现高危问题时能给出明确信号。

5.2 workflow 配置详解

下面这个 workflow 是我在多个项目里跑过的、稳定可用版本:

name: static-analysis on: push: branches: [ main, master ] pull_request: jobs: clang-static-analyzer: runs-on: ubuntu-latest steps: - name: 检出代码 uses: actions/checkout@v4 - name: 安装工具链 run: | sudo apt-get update sudo apt-get install -y --no-install-recommends \ clang \ clang-tools \ cmake \ make - name: 运行 scan-build 静态分析 run: | rm -rf build-analyze scan-build --status-bugs -o scan-report \ cmake -S . -B build-analyze -DCMAKE_BUILD_TYPE=Debug scan-build --status-bugs -o scan-report \ cmake --build build-analyze -j$(nproc) - name: 上传分析报告 if: always() uses: actions/upload-artifact@v4 with: name: scan-build-report path: scan-report

几个关键决策点:

为什么用ubuntu-latestGitHub Actions 的 Ubuntu 镜像预装了大部分构建工具,虽然版本不一定最新,但配合apt-get install补装clang-tools之后,scan-build 一定可用。这也是成本最低的跑法。

为什么显式安装cmake镜像里有自带的 CMake,但版本会随着镜像更新而变化。显式装一次,行为明确可复现。如果你的项目需要更高版本的 CMake,这个步骤可以替换成前面第 2.1 节里的官方二进制安装逻辑。

为什么if: always()要放在上传报告那一步?一旦 scan-build 发现 bug,--status-bugs会让这一步以非零状态退出,整个 job 标记为失败。如果上传报告这一步不加if: always(),job 失败后报告就不会被上传,开发者拿不到任何信息。这是自动化流水线最容易踩的坑。

build-analyze 目录哪里去了?Actions 里的路径是工作目录下的相对路径scan-report,上传 artifact 之后,可以在 GitHub Actions 页面右上角“Artifacts”区域下载整个 HTML 报告。这样即使流水线失败,也能在本地打开报告逐项排查。

5.3 三种运行策略,按团队接受度选择

策略做法适用阶段
只提示不阻断去掉--status-bugs,报告上传,job 恒为绿色刚开始接入、团队对工具还不熟悉时
阻断有告警的提交保留--status-bugs,有 bug 时 job 失败告警清理到可控数量后
仅主干扫描on.push.branches只留main,PR 不触发想避免 PR 频繁失败的团队

我个人推荐的路径:先跑两周“只提示不阻断”,让团队观察告警模式,清理完既有问题后再加上--status-bugs。一步到位上“阻断”,很容易因为告警太多而让流水线长期红着,最后大家反而无视它了。

6. 告警解读实战:从报告里筛出真问题

6.1 典型告警一:空指针解引用

静态分析报告里最常见、优先级最高的一类就是空指针解引用。下面是我实际项目中遇到过的模式:

#include <string.h> const char *parse_value(const char *key) { if (key == nullptr || key[0] == '\0') { return nullptr; } // 模拟一个正常情况 return "value"; } void handle_config(const char *k) { const char *v = parse_value(k); size_t len = strlen(v); // 如果 v 为 nullptr… (void)len; }

scan-build会在strlen(v)这一行标红,提示 “Dereference of null pointer”。它的推理过程是:当k为空字符串时,parse_value返回nullptr,随后strlen接收了空指针。这个推理需要跨函数跟踪,一般的-Wall根本不可能发现,但静态分析能沿着路径走下来。

修复方式不复杂,调用方判空即可;关键是它能帮你在上线前发现这种“某些输入下才触发”的问题,而不是等着线上崩。

6.2 典型告警二:资源泄漏

C 语言代码里最容易出现的就是内存泄漏路径。比如:

#include <stdlib.h> char *duplicate(const char *s) { char *copy = (char *)malloc(strlen(s) + 1); // 这里省略拷贝逻辑 return copy; } int process(const char *input) { char *tmp = duplicate(input); if (tmp == NULL) { return -1; } // 某些分支直接 return,没有 free(tmp) if (input[0] == 'x') { return 0; // 泄漏:tmp 未被释放 } free(tmp); return 0; }

Clang Static Analyzer 会报告 “Potential leak of memory pointed to by 'tmp'”,并且指出泄漏路径从mallocreturn 0。这类告警在有多个提前 return 分支的函数里特别常见,手动 code review 容易漏,静态分析却每次都能抓到。

6.3 误报识别与屏蔽

静态分析器也会有误报,但不能因为偶发误报就否定整个工具。我在实践中把误报来源分成三类:

  • 抽象边界导致的误报:分析器对跨函数调用的建模是有限度的,某些被函数指针、虚函数、外部库绕过的逻辑,它可能给出不准确的结论。
  • 项目契约导致的误报:代码里隐含“调用方保证传入非空”“这个函数只在某状态下调用”等约定,分析器不知道。这种可以算“分析器理解力不足”而不是代码错误。
  • 环境相关误报:第三方源码被扫进来,出现一堆与目标代码无关的告警。

针对这些情况,处理方式如下:

排除第三方目录

scan-build --status-bugs -exclude /path/to/third_party -o scan-report cmake --build build-analyze

屏蔽特定检查器。如果某个 checker 在你的项目里长期误报,可以直接关掉:

scan-build --status-bugs \ -disable-checker deadcode.DeadStores \ -disable-checker osx.cocoa.RetainCount \ -o scan-report cmake --build build-analyze

__clang_analyzer__宏跳过特殊代码段。这个方法比较冷门,但很实用。Clang Static Analyzer 在分析时定义__clang_analyzer__宏,普通编译时不存在,因此可以在代码里做条件编译:

void legacy_interface(int *p) { #ifndef __clang_analyzer__ // 这段逻辑分析器无法建模,且实际业务上 p 不会为空 *p = 1; #endif }

不过要提醒一句:__clang_analyzer__是最后手段,不要一上来就用。先用排除目录和 checker 配置,解决不了再动代码。滥用这个宏会让分析盲区越来越大,等于把刚建立的防线又拆掉。

7. 排错实录:从安装到 CI 的坑位复盘

7.1 场景一:cmake 命令找不到

我在 Windows 上处理过一个同事的问题,他的 PowerShell 里输入cmake --version直接报“无法将 cmake 项识别为 cmdlet”,但 C 盘明明装了 CMake。根因就是 2.1 节说的 PATH 没配置。另外还有一个隐藏细节:改完系统环境变量之后,已经打开的 PowerShell 窗口不会自动刷新,必须重开一个终端,或者手动执行refreshenv(需要 Chocolatey 环境)。很多人在“改完 PATH 还是不行”上卡住,其实就是没重开终端。

7.2 场景二:Ubuntu CMake 版本过低导致 configure 失败

项目里用了FetchContent_Declare配合本地缓存目录的写法,这套 API 对 CMake 版本有要求。Ubuntu 20.04 自带 CMake 3.16,在 configure 阶段直接报错。我当时没有用 apt 折腾,直接按 2.1 节拉官方二进制包,解压到/opt/cmake并建立/usr/local/bin/cmake软链接,一分钟解决。这里要特别说一句:不要用sudo apt install cmake去覆盖旧版本,apt 装新版依赖 PPA,还可能与系统包冲突;官方二进制包反而是最省心的。

如果之前是通过 apt 安装的,想干净卸载:

sudo apt remove cmake sudo apt autoremove

然后检查which cmake,如果还有残留路径,多半是手动安装的旧版,手动删目录和软链接即可。

7.3 场景三:scan-build 在 Actions 里没生效

有次流水线日志显示 scan-build 跑了,但最终报告里什么告警都没有。排查后发现问题出在build-analyze目录不是全新的:它在 configure 阶段用了之前的CMakeCache.txt,编译器路径是 GCC,scan-build 的 wrapper 根本没被调用。此后的 commit 我把rm -rf build-analyze放进了命令开头,问题彻底消失。这也是为什么我在 5.2 节的 workflow 里特别保留了这个清理步骤。

7.4 场景四:scan-build 和 Ninja 生成器配合不完整

如果你在 CMake 里指定了-G Ninjascan-build也能跑,但我在某些 LLVM 版本上遇到过分析报告缺失的情况,尤其是并发任务较多时,部分编译单元的 hook 没被扫到。定位路径比较曲折:我先看scan-build标准输出,发现 “analysed N files” 的数值远小于实际源码文件数,再对照compile_commands.json里的文件列表确认。

最终我的规避方案是:在analyze.sh和 workflow 里直接指定 Unix Makefiles 生成器:

scan-build --status-bugs -o scan-report \ cmake -S . -B build-analyze \ -G "Unix Makefiles" \ -DCMAKE_BUILD_TYPE=Debug

分析用的构建目录和日常编译目录分开后,用 Makefiles 生成器完全没有引入格式差异问题,反而更稳定。

7.5 场景五:Actions 中 scan-build 命令找不到

GitHub Actions 的ubuntu-latest镜像并不保证scan-build一定预装。我第一次跑 workflow 时,在“运行 scan-build 静态分析”那一步直接报scan-build: command not found。解决方式就是 workflow 里的安装步骤:

sudo apt-get install -y clang clang-tools

至少我测试过的 Ubuntu 22.04、24.04 镜像上,装完clang-tools之后scan-build一定可用。不要漏掉这一步,更不要把希望寄托在镜像的隐式预装上。


整套流程跑完,最直观的感受是:Clang Static Analyzer 并不是一个“跑完报告看一眼”的玩具工具,它真正值钱的地方在于和构建体系、CI 流水线融在一起,形成持续反馈。如果你手头的项目还在靠 code review 盯着空指针和内存泄漏,不妨花一个下午把本文这套流程搭起来,先跑一周看看报告,再决定要不要用--status-bugs把流水线卡严。就算有些告警是误报,那些被它揪出来的真实问题,大概率已经值回票价了。

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

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

立即咨询