首页
学习
活动
专区
圈层
工具
发布
社区首页 >专栏 >把Clang-Tidy和scan-build塞进GitHub Actions后,代码质量再也不靠人肉review

把Clang-Tidy和scan-build塞进GitHub Actions后,代码质量再也不靠人肉review

作者头像
早起的鸟儿有虫吃
发布2026-05-21 20:10:58
发布2026-05-21 20:10:58
1390
举报

把Clang-Tidy和scan-build塞进GitHub Actions后,代码质量再也不靠人肉review

凌晨三点,线上服务因为一个未初始化的off变量突然崩溃。

回滚、查日志、加断言——折腾两小时后发现,这个问题早在两周前的提交中就存在,只是代码审查时没人注意到那个else分支里漏掉的赋值。

这不是运气差,这是工程方法的缺陷。

在C/C++项目中,内存泄漏、空指针解引用、使用已释放的内存,这些Bug像地雷一样埋在代码里。人肉review能挡住语法错误,却很难发现跨越多行的逻辑漏洞。

编译器警告(-Wall)速度快但深度有限,而真正需要运行程序甚至写测试才能捕获的深层错误,往往留到线上才引爆。

解决问题的正确姿势,不是靠更仔细的review,

而是把静态分析工具——Clang Static Analyzer、Clang-Tidy、scan-build——塞进CI/CD流水线,让机器在每次提交时自动扫一遍

一、 什么是CLang 和CI/CD(持续集成/持续部署)有什么关系?

unsetunset1.1 CI/CD 是 什么unsetunset

持续集成(Continuous Integration)持续部署/交付(Continuous Delivery/Deployment) 的缩写,

是一种通过自动化流程频繁将代码集成、测试和部署到生产环境的方法。 [如果掌握这个方式提高你开发效率] 典型的 CI/CD 流水线会包括:

  • 拉取代码
  • 编译构建
  • 运行代码检查(静态分析、lint)
  • 运行单元测试、集成测试
  • 打包
  • 部署到目标环境 [如何一键部署到生产环境是难题]

unsetunset1.2 Clang 与 CI/CD 的关系unsetunset

Clang 作为编译器和代码分析工具,在 CI/CD 流水线中可以扮演以下几个核心角色:

  • 编译器:使用 Clang 构建项目,替代 GCC 或 MSVC。
  • 静态分析:集成 scan-buildClang-Tidy 自动扫描潜在 bug,并生成报告或直接阻断有问题的提交。
  • 代码格式化:集成 Clang-Format 检查代码风格,不符合规范则构建失败。
  • 动态检查:在测试阶段使用 AddressSanitizer 等运行时检测工具,捕获内存错误。

简单说,Clang 是工具,CI/CD 是流程; CI/CD 流程会调用 Clang 及其工具链来保证代码质量和可构建性

Clang 的设计目标之一就是轻量快速:

  • 编译速度:在大多数场景下,Clang 比 GCC 更快,尤其是在增量编译和并行编译上。
  • 内存占用: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

自动化流水线

整合以上所有工具,实现自动化质量保

unsetunset1.3 如何将 Clang 集成到 CI/CD?unsetunset

GitHub Actions 中使用 Clang-Tidy

Jenkins 中使用 scan-build

代码语言:javascript
复制
scan-build --status-bugs make

如果发现 bug,scan-build 会返回非零退出码,从而让构建失败

  • http://releases-origin.llvm.org/20.1.0/tools/clang/docs/analyzer/user-docs.html

Jenkins 和 GitHub Actions 最大的区别在于: Jenkins 是一个需要自己找地方安装, 而 GitHub Actions 是 GitHub 自带的、开箱即用的云上工作流

个人使用,直接选 GitHub Actions。

原因是:

  • 你的代码大概率已经托管在 GitHub 上,直接用 Actions 零额外成本启动。
  • 不需要找一台 7x24 小时开机的机器来跑 Jenkins。
  • YAML 写法简单,配置好之后只要有 push 就会自动运行。
  • 每月免费额度(2000 分钟)对个人项目绰绰有余。

什么时候才考虑 Jenkins?

  • 代码托管在内网 GitLab/Gitea 等平台,不对外公开。
  • 需要定制非常复杂的构建流程,且 GitHub Actions 的自由度满足不了你。
  • 你想深入理解 CI/CD 的底层运行机制、学习 DevOps 全流程。自己装一遍 Jenkins、配节点、写 Pipeline 是很好的学习过程。

二. c/c++代码静态检查 Clang Static Analyzer

unsetunset2.1 Clang Static Analyzer vs Clang-Tidy vs 编译告警unsetunset

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-Tidy 的区别

维度

Clang Static Analyzer

Clang-Tidy

分析方式

路径敏感、符号执行,模拟运行

基于AST匹配模式,看代码长什么样

检查深度

深,能发现跨越多行的逻辑错误

较浅,主要针对局部代码模式

性能

较慢,需分析完整编译单元

较快

自动修复

不提供

部分规则支持自动修复

使用入口

独立工具(scan-build)或通过Clang-Tidy调用

命令行工具,可集成到IDE/C

1. 静态分析的基本定义 【只要不是人工检查就好】

"a collection of algorithms and techniques used to analyze source code in order to automatically find bugs"

  • 静态:在不运行程序的情况下,直接分析源代码。
  • 自动:不是人工代码审查,而是由工具自动完成。
  • 找Bug:目标是发现代码中的潜在缺陷。

2. 与编译器警告的区别与联系

"similar in spirit to compiler warnings ... but to take that idea a step further"

  • 编译器警告(如 -Wall -Wextra):看的是局部、语法层面的问题,比如未使用的变量、类型不匹配。速度快,但深度有限。
  • 静态分析:比编译器警告走得更远,能发现需要运行程序甚至写测试用例才能捕获的深层Bug,比如跨函数的内存泄漏、特定路径下的空指针解引用。

unsetunset2.2 具体情况unsetunset

Null Dereference(空指针引用)

代码语言:javascript
复制
Access to field 'xx' results in a dereference of a null pointer
(loaded from variable 'pCtx'

// 修改后
if (pCtx != NULL) {
    pCtx-> = ...;
} else {
    // 错误处理:返回错误码、记录日志或使用默认值
}

后就:clang 没检测到 在函数内部依据判断,通过check函数判断的,使用goto 统一返回出口在结束位置。

Uninitialized Assignment(未初始化赋值)

代码语言:javascript
复制
Assigned value is uninitialized

// 修改前(假设)
int a;          // 未初始化
int b = a;      // 警告:a 未初始化

// 修改后
int a = 0;      // 显式初始化
int b = a;

问题: 将数据指针void * 直接传给期望 long long 类型的参数。

代码语言:javascript
复制
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 字节

unsetunset所有指针大小相同unsetunset

在同一平台上,不管指向什么类型,所有指针的大小都相同

类型

32位系统

64位系统

char *

4 字节

8 字节

int *

4 字节

8 字节

double *

4 字节

8 字节

void *

4 字节

8 字节

void (*)(void *)

4 字节

8 字节

unsetunset为什么?编号统计方式有关系unsetunset

指针存的是内存地址编号,不是指向的数据。

  • int *p 里的 p 存的是 int 变量所在的地址门牌号
  • char *p 里的 p 存的也是地址门牌号
  • 门牌号的位数由 CPU 地址总线决定(32位/64位),和房间里住的是 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')

Null Dereference

  • 根因pKv 可能为 NULL,但代码直接访问 pKv->poolId
  • 风险:直接导致段错误(Segfault)

case10 函数内部申请,还是外部使用情况 合理,函数内部不释放

case 11 分支条件使用垃圾值

代码语言:javascript
复制
Branch condition evaluates to a garbage value

单词:

英文

中文

evaluate

求值、计算

expression evaluates to true

表达式求值为真

condition evaluates to zero

条件计算结果为零

garbage value

垃圾值、未定义值、随机值

分析: 某个 if/while/for 的条件表达式使用了未初始化的变量

典型场景:

c

代码语言:javascript
复制
int off;           // ← 未初始化,值不确定

if (flag) {        
    off ==10 
} 
// else 没有设置

变量

问题路径

修复

off

flag=true 时跳过 else 分支,导致 off 未初始化

函数开头 off = 0

规则:变量在函数声明的时候初始化,开头位置,中间位置不声明变量

case 12 代码错误:变量在没有初始化时候,go to 提前退出
代码语言:javascript
复制
warning: The left operand of '!=' is a garbage value 

使用goto  提前 退出 
garbage value 本质就是变量当前持有的值未定义

case 13 误报: 二级指针 在函数内部修改 空指针引用

错误代码::传递空指针,函数内部 修改,函数返回值结束后 依然是空指针

代码语言:javascript
复制
void alloc_memory(int **pp) {   // 接收"指针的地址"
    *pp = malloc(sizeof(int));
    if (*pp != NULL) {
        **pp = 100;
    }
}

// 调用
int *ptr = NULL;
alloc_memory(&ptr);   // ← 传 ptr 的地址
// 现在 ptr 指向新分配的内存

C++ 引用(仅限 C++)

代码语言:javascript
复制
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(引用)

被引用的原对象

随目标类型变化


unsetunset语义 vs 实现:两个层面必须分开unsetunset

层面

说法

正确性

C++ 语义层面

"引用不是对象,不占内存"

✅ 标准这么规定

汇编实现层面

"引用不占任何存储"

❌ 错误。实现上通常需要一个位置来保存地址

1. 最常见:存放在寄存器里

当引用作为函数参数时,底层和传指针完全一样——都是把对象的地址塞到寄存器里。

代码语言:javascript
复制
void foo(int &ref) {
    ref = 100;
}

// 调用
int x;
foo(x);

x86-64 汇编思路(System V AMD64 ABI):

代码语言:javascript
复制
; 调用方
lea  rdi, [x]      ; 把 x 的地址放进 rdi 寄存器
call foo

; foo 内部
mov  dword ptr [rdi], 100   ; 通过 rdi 里的地址写入

看见了吗?rdi里存的就是x` 的地址。 这个地址虽然语义上叫"引用", 但汇编里就是一个指针值,占用寄存器。

2. 也可能:存放在栈内存里

如果寄存器不够用,或者引用变量在函数内部生命周期较长, 编译器会在栈帧上分配一个栈槽(stack slot)来存这个地址。

代码语言:javascript
复制
; 函数序言
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 对应的寄存器/栈位置赋值。


unsetunset关键结论unsetunset

引用在语义上是别名, 但在实现上通常需要一个位置"来记录原变量在哪里。 这个位置可以是寄存器,也可以是内存。

所以你的质疑完全正确:

  • 寄存器角度:引用确实占用寄存器(传递和持有被引用对象的地址)。
  • 内存角度:未优化时,引用可能占用栈内存存地址;优化后可能什么都不占。
  • 和指针的区别:不是实现机制的区别(底层都是地址),而是语法约束的区别——引用不能重新绑定、不能为 null、不需要手动解引用。

更灵活

unsetunset全局指针:放在数据段unsetunset

如果指针是全局变量或静态局部变量, 它存储的地址值放在全局数据区.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."

case 2.10 Clang 不认为 assert 是有效的空指针保护

代码语言:javascript
复制
rc = GetClonePair( &pClone);

ASSERT(rc >= 0 && pClonePair != NULL);
//小于0的情况并没有保证,小于0的情况下pClonePair为null
if (unlikely(rc < 0 || pClone == NULL)) {
    goto l_out;   // 或 return rc;
}


case 2.11 meset初始化 **数组名和指针

这个 bugprone-sizeof-expression 警告的 bug 非常典型,而且后果严重

表达式

含义

在 64 位系统上的结果

sizeof(pCo)

指针变量 pCo 本身的大小

8 字节

sizeof(*pCo)

pCoTask 指向的` 结构体大小

可能是 80+ 字节

代码语言:javascript
复制
memset(pCoTask, 0, sizeof(*pCo));   // ✅ 计算结构体实际大小

memset(pCoTask, 0, sizeof(gtask_t));   // ✅ 直接写类型名

于数组和指针的初始化, 核心区别在于:数组名和指针在 sizeof 中的行为完全不同

代码语言:javascript
复制
int arr[100];
ofs_memset(arr, 0, sizeof(arr));   // ✅ 正确:sizeof(arr) = 400 字节(整个数组)


关键点:数组名 arrsizeof 中会退化为整个数组",不是指针

表达式

类型

结果

sizeof(arr)

int[100]

100 * sizeof(int) = 400

sizeof(arr[0])

int

4

指针初始化(容易出错)

代码语言:javascript
复制
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)) ✅

2.12 warning: Access to field 'lcsId' results in a dereference of a null pointer

为什么 Clang 报警?

l_exit 是函数的统一出口标签

l_exit 作为统一退出标签,会汇聚所有执行路径(包括失败路径)。 如果 pIterator 在某条路径上未被赋值(仍为 NULL), 走到 l_exit 时访问 pIterator->Id 就会崩溃。 必须在访问前加 if (pIterator != NULL) 保护

使用三元运算符简化

代码语言:javascript
复制
pIterator ? pIterator->Id : 0,

2.13 Use of memory after it is freed 使用已释放的内存

unsetunset参考unsetunset

  • Clang 20.1.0 documentation
  • https://developer.aliyun.com/article/233255 # 深入研究Clang(十) Clang Static Analyzer简介
  • 如何在CentOS上配置C++代码风格检查 clang-tidy CMake
  • 什么是 CI/CD?
  • 【运维教程】CICD流水线实战,使用Git+Jenkins+Harbor+Docker实现自动化!开发\测试\IT\DevOps\工程师

如果这篇文章确实帮助到了你, 希望可以点赞、收藏、关注一下, 这也是我持续创作的最大动力!

本文参与 腾讯云自媒体同步曝光计划,分享自微信公众号。
原始发表:2026-05-20,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 后端开发成长指南 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 一、 什么是CLang 和CI/CD(持续集成/持续部署)有什么关系?
    • unsetunset1.1 CI/CD 是 什么unsetunset
    • unsetunset1.2 Clang 与 CI/CD 的关系unsetunset
    • unsetunset1.3 如何将 Clang 集成到 CI/CD?unsetunset
  • 二. c/c++代码静态检查 Clang Static Analyzer
    • unsetunset2.1 Clang Static Analyzer vs Clang-Tidy vs 编译告警unsetunset
      • Clang-Tidy 的区别
      • 1. 静态分析的基本定义 【只要不是人工检查就好】
      • 2. 与编译器警告的区别与联系
    • unsetunset2.2 具体情况unsetunset
      • Null Dereference(空指针引用)
      • Uninitialized Assignment(未初始化赋值)
      • 问题: 将数据指针void * 直接传给期望 long long 类型的参数。
    • unsetunset所有指针大小相同unsetunset
    • unsetunset为什么?编号统计方式有关系unsetunset
      • Null Dereference
      • case10 函数内部申请,还是外部使用情况 合理,函数内部不释放
      • case 11 分支条件使用垃圾值
    • unsetunset语义 vs 实现:两个层面必须分开unsetunset
      • 1. 最常见:存放在寄存器里
      • 2. 也可能:存放在栈内存里
    • unsetunset关键结论unsetunset
    • unsetunset全局指针:放在数据段unsetunset
      • case 2.10 Clang 不认为 assert 是有效的空指针保护
      • case 2.11 meset初始化 **数组名和指针
      • 2.13 Use of memory after it is freed 使用已释放的内存
    • unsetunset参考unsetunset
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档