
把Clang-Tidy和scan-build塞进GitHub Actions后,代码质量再也不靠人肉review
凌晨三点,线上服务因为一个未初始化的off变量突然崩溃。
回滚、查日志、加断言——折腾两小时后发现,这个问题早在两周前的提交中就存在,只是代码审查时没人注意到那个else分支里漏掉的赋值。
这不是运气差,这是工程方法的缺陷。
在C/C++项目中,内存泄漏、空指针解引用、使用已释放的内存,这些Bug像地雷一样埋在代码里。人肉review能挡住语法错误,却很难发现跨越多行的逻辑漏洞。
编译器警告(-Wall)速度快但深度有限,而真正需要运行程序甚至写测试才能捕获的深层错误,往往留到线上才引爆。
解决问题的正确姿势,不是靠更仔细的review,
而是把静态分析工具——Clang Static Analyzer、Clang-Tidy、scan-build——塞进CI/CD流水线,让机器在每次提交时自动扫一遍
持续集成(Continuous Integration) 和 持续部署/交付(Continuous Delivery/Deployment) 的缩写,

是一种通过自动化流程频繁将代码集成、测试和部署到生产环境的方法。 [如果掌握这个方式提高你开发效率] 典型的 CI/CD 流水线会包括:
Clang 作为编译器和代码分析工具,在 CI/CD 流水线中可以扮演以下几个核心角色:
scan-build 或 Clang-Tidy 自动扫描潜在 bug,并生成报告或直接阻断有问题的提交。Clang-Format 检查代码风格,不符合规范则构建失败。简单说,Clang 是工具,CI/CD 是流程; CI/CD 流程会调用 Clang 及其工具链来保证代码质量和可构建性。
Clang 的设计目标之一就是轻量快速:
Clang 是模块化的,提供了一整套库(libclang、libTooling 等),让开发者可以轻松构建:
GCC 虽然也有插件机制,但 Clang 的架构从设计上就鼓励第三方工具开发。这也是为什么 Clang 生态里出现了 Clang-Tidy、Clang-Format、Clangd、scan-build 等一大堆高质量工具
Clang 对 Windows(MSVC ABI 兼容)、macOS(系统默认)、Linux 等平台都有很好的支持,行为一致性强。 GCC 主要统治 Linux 世界,在 Windows 上使用更麻烦一些。
Clang/LLVM:使用 Apache 2.0 许可证,商业友好,可以闭源使用和分
关键名词:
组件 | 定位 | 在 CI/CD 中的作用 |
|---|---|---|
Clang | 编译器/前端 | 构建项目、生成可执行文件 |
Clang-Tidy | Linter/静态分析 | 自动检查代码风格和潜在错误 |
scan-build | 静态分析报告工具 | 深度 bug 检测并生成报告 |
Sanitizers | 动态分析 | 运行时捕获内存/并发错误 |
CI/CD | 自动化流水线 | 整合以上所有工具,实现自动化质量保 |
GitHub Actions 中使用 Clang-Tidy
Jenkins 中使用 scan-build
scan-build --status-bugs make
如果发现 bug,scan-build 会返回非零退出码,从而让构建失败
Jenkins 和 GitHub Actions 最大的区别在于: Jenkins 是一个需要自己找地方安装, 而 GitHub Actions 是 GitHub 自带的、开箱即用的云上工作流
个人使用,直接选 GitHub Actions。
原因是:
什么时候才考虑 Jenkins?
Clang Static Analyzer(简称CSA)是基于Clang/LLVM的源代码静态分析引擎, 它通过模拟程序运行路径来发现深层的逻辑错误, 比如内存泄漏、空指针解引用、使用未初始化变量等。
官方地址:https://clang-analyzer.llvm.org/
The Clang Static Analyzer is a source code analysis tool that finds bugs in C, C++, and Objective-C programs
Clang Static Analyzer 已被 Apple、Google、Mozilla 等公司广泛应用于生产项目。
维度 | Clang Static Analyzer | Clang-Tidy |
|---|---|---|
分析方式 | 路径敏感、符号执行,模拟运行 | 基于AST匹配模式,看代码长什么样 |
检查深度 | 深,能发现跨越多行的逻辑错误 | 较浅,主要针对局部代码模式 |
性能 | 较慢,需分析完整编译单元 | 较快 |
自动修复 | 不提供 | 部分规则支持自动修复 |
使用入口 | 独立工具(scan-build)或通过Clang-Tidy调用 | 命令行工具,可集成到IDE/C |
"a collection of algorithms and techniques used to analyze source code in order to automatically find bugs"
"similar in spirit to compiler warnings ... but to take that idea a step further"
-Wall -Wextra):看的是局部、语法层面的问题,比如未使用的变量、类型不匹配。速度快,但深度有限。Access to field 'xx' results in a dereference of a null pointer
(loaded from variable 'pCtx'
// 修改后
if (pCtx != NULL) {
pCtx-> = ...;
} else {
// 错误处理:返回错误码、记录日志或使用默认值
}
后就:clang 没检测到 在函数内部依据判断,通过check函数判断的,使用goto 统一返回出口在结束位置。
Assigned value is uninitialized
// 修改前(假设)
int a; // 未初始化
int b = a; // 警告:a 未初始化
// 修改后
int a = 0; // 显式初始化
int b = a;
void * 直接传给期望 long long 类型的参数。incompatible pointer to integer conversion passing
'void *' to parameter of type 'int_64' (aka 'long long')
平台 | void * 大小 | long long 大小 | 风险 |
|---|---|---|---|
32位 | 4 字节 | 8 字节 | 截断:指针值只填充低4字节,高4字节未定义 |
64位 | 8 字节 | 8 字节 | 值能容纳,但未定义行为(UB),且 Clang 禁止隐式转换 |
函数指针不能隐式转为整数,这是 Clang 的硬性错误。 lint !e2453` 说明之前 PC-Lint 也报过同类问题,但被忽略了
void * 的大小由CPU 架构的寻址位数决定,不是由 void 本身决定。
架构 | 地址总线宽度 | 寻址空间 | 指针大小 |
|---|---|---|---|
32位 | 32 bit | 2³² = 4 GB | 4 字节 |
64位 | 64 bit | 2⁶⁴ | 8 字节 |
在同一平台上,不管指向什么类型,所有指针的大小都相同
类型 | 32位系统 | 64位系统 |
|---|---|---|
char * | 4 字节 | 8 字节 |
int * | 4 字节 | 8 字节 |
double * | 4 字节 | 8 字节 |
void * | 4 字节 | 8 字节 |
void (*)(void *) | 4 字节 | 8 字节 |
指针存的是内存地址编号,不是指向的数据。
int *p 里的 p 存的是 int 变量所在的地址门牌号char *p 里的 p 存的也是地址门牌号int 还是 char 无关所以所有指针类型(
int *、char *、void *、void (*)(void *))在32位平台上都是 4 字节这个课本上内容,记住 限制都是x64平台,限制8字节大小
incompatible pointer to integer conversion passing 'void *' to parameter of type 'int_64' (aka 'long long')
pKv 可能为 NULL,但代码直接访问 pKv->poolIdBranch condition evaluates to a garbage value
单词:
英文 | 中文 |
|---|---|
evaluate | 求值、计算 |
expression evaluates to true | 表达式求值为真 |
condition evaluates to zero | 条件计算结果为零 |
garbage value | 垃圾值、未定义值、随机值 |
分析: 某个 if/while/for 的条件表达式使用了未初始化的变量。
典型场景:
c
int off; // ← 未初始化,值不确定
if (flag) {
off ==10
}
// else 没有设置
变量 | 问题路径 | 修复 |
|---|---|---|
off | flag=true 时跳过 else 分支,导致 off 未初始化 | 函数开头 off = 0 |
规则:变量在函数声明的时候初始化,开头位置,中间位置不声明变量
warning: The left operand of '!=' is a garbage value
使用goto 提前 退出
garbage value 本质就是变量当前持有的值未定义
错误代码::传递空指针,函数内部 修改,函数返回值结束后 依然是空指针
void alloc_memory(int **pp) { // 接收"指针的地址"
*pp = malloc(sizeof(int));
if (*pp != NULL) {
**pp = 100;
}
}
// 调用
int *ptr = NULL;
alloc_memory(&ptr); // ← 传 ptr 的地址
// 现在 ptr 指向新分配的内存
C++ 引用(仅限 C++)
void alloc_memory(int* &p) { // p 是引用,不是副本
p = new int(100);
}
// 调用
int *ptr = nullptr;
alloc_memory(ptr); // ptr 被真正修改
特性 | 指针(值传递) | 引用(C++) |
|---|---|---|
语法 | void f(int *p) | void f(int* &p) |
传参方式 | f(ptr) | f(ptr) |
能否修改原指针 | ❌ 不能(只改了副本) | ✅ 能(就是原变量) |
能否为空 | 可以为 NULL | 必须绑定有效对象,不能为空 |
能否重新指向 | ✅ 可以 p = &x; p = &y; | ❌ 一旦绑定,终身不变 |
解引用语法 | *p = 10;(手动 *) | p = new int;(自动,像普通变量) |
适用语言 | C、C++ | 仅 C++ |
大小计算:
表达式 | 计算对象 | 结果 |
|---|---|---|
sizeof(指针) | 指针变量自身(存地址的容器) | 固定 8 字节 |
sizeof(引用) | 被引用的原对象 | 随目标类型变化 |
层面 | 说法 | 正确性 |
|---|---|---|
C++ 语义层面 | "引用不是对象,不占内存" | ✅ 标准这么规定 |
汇编实现层面 | "引用不占任何存储" | ❌ 错误。实现上通常需要一个位置来保存地址 |
当引用作为函数参数时,底层和传指针完全一样——都是把对象的地址塞到寄存器里。
void foo(int &ref) {
ref = 100;
}
// 调用
int x;
foo(x);
x86-64 汇编思路(System V AMD64 ABI):
; 调用方
lea rdi, [x] ; 把 x 的地址放进 rdi 寄存器
call foo
; foo 内部
mov dword ptr [rdi], 100 ; 通过 rdi 里的地址写入
看见了吗?rdi里存的就是x` 的地址。 这个地址虽然语义上叫"引用", 但汇编里就是一个指针值,占用寄存器。
如果寄存器不够用,或者引用变量在函数内部生命周期较长, 编译器会在栈帧上分配一个栈槽(stack slot)来存这个地址。
; 函数序言
push rbp
mov rbp, rsp
sub rsp, 16
; 把引用(地址)存到栈上
mov qword ptr [rbp-8], rdi ; 隐藏指针存在栈里
; 后续使用
mov rax, qword ptr [rbp-8] ; 取出隐藏指针
mov dword ptr [rax], 100
优化后的汇编里,ref 这个概念可能完全消失,直接对 x 对应的寄存器/栈位置赋值。
引用在语义上是别名, 但在实现上通常需要一个位置"来记录原变量在哪里。 这个位置可以是寄存器,也可以是内存。
所以你的质疑完全正确:
更灵活
如果指针是全局变量或静态局部变量, 它存储的地址值放在全局数据区(.data 或 .bss 段),既不在栈也不在寄存器(长期驻留内存)
// 情况 1:绑定到全局变量 ✅ int g_x = 10; int& g_ref = g_x;
// 情况 2:绑定到临时对象 ❌(除非 const) int& g_ref2 = 20; // ❌ 错误:不能绑定到右值 const int& g_cref = 20; // ✅ 合法:const 引用延长临时对象生命周期
// 情况 3:绑定到函数返回值 ❌(除非 const) int& g_ref3 = get_int(); // ❌ 如果 get_int() 返回 int const int& g_cref2 = get_int(); // ✅
不存在引用的引用:
C++ 标准(以 C++11 及以后版本为例)在 dcl.ref (声明符:引用) 和 basic.types (基本类型) 中有明确规定:
**"A reference type is said to be cv-qualified if the referenced type is cv-qualified. There are no references to
void, no references to references, and no pointers to references. A reference is not an object."
assert 是有效的空指针保护rc = GetClonePair( &pClone);
ASSERT(rc >= 0 && pClonePair != NULL);
//小于0的情况并没有保证,小于0的情况下pClonePair为null
if (unlikely(rc < 0 || pClone == NULL)) {
goto l_out; // 或 return rc;
}
这个 bugprone-sizeof-expression 警告的 bug 非常典型,而且后果严重:
表达式 | 含义 | 在 64 位系统上的结果 |
|---|---|---|
sizeof(pCo) | 指针变量 pCo 本身的大小 | 8 字节 |
sizeof(*pCo) | pCoTask 指向的` 结构体大小 | 可能是 80+ 字节 |
memset(pCoTask, 0, sizeof(*pCo)); // ✅ 计算结构体实际大小
memset(pCoTask, 0, sizeof(gtask_t)); // ✅ 直接写类型名
于数组和指针的初始化, 核心区别在于:数组名和指针在 sizeof 中的行为完全不同。
int arr[100];
ofs_memset(arr, 0, sizeof(arr)); // ✅ 正确:sizeof(arr) = 400 字节(整个数组)
关键点:数组名 arr 在 sizeof 中会退化为整个数组",不是指针
表达式 | 类型 | 结果 |
|---|---|---|
sizeof(arr) | int[100] | 100 * sizeof(int) = 400 |
sizeof(arr[0]) | int | 4 |
指针初始化(容易出错) |
int *p = malloc(100 * sizeof(int));
ofs_memset(p, 0, sizeof(p));
// ❌ 错误:sizeof(p) = 8(指针本身大小),只清 8 字节
int *p = malloc(100 * sizeof(int));
场景 | 代码 | sizeof 行为 | 正确初始化 |
|---|---|---|---|
数组 | int arr[100]; | sizeof(arr) = 整个数组 | memset(arr, 0, sizeof(arr)) ✅ |
指针(堆分配) | int *p = malloc(...) | sizeof(p) = 指针大小(8) | memset(p, 0, 分配的总字节数) ✅ |
指针(指向结构体) | Task_t *p = &task; | sizeof(p) = 8 | memset(p, 0, sizeof(*p)) ✅ |
为什么 Clang 报警?
l_exit 是函数的统一出口标签。
l_exit 作为统一退出标签,会汇聚所有执行路径(包括失败路径)。 如果 pIterator 在某条路径上未被赋值(仍为 NULL), 走到 l_exit 时访问 pIterator->Id 就会崩溃。 必须在访问前加 if (pIterator != NULL) 保护
使用三元运算符简化
pIterator ? pIterator->Id : 0,
如果这篇文章确实帮助到了你, 希望可以点赞、收藏、关注一下, 这也是我持续创作的最大动力!