前置知识: C

静态分析与 Sanitizers

27 min中级

C 代码静态分析与 Sanitizers 四件套:编译器警告分级、cppcheck/clang-tidy 工作流、ASan/UBSan/TSan/MSan 原理与实战、CI 警告门禁。

静态分析与 Sanitizers (Static Analysis and Sanitizers)

知识点地图

  • 知识类别:质量工程工具链——静态分析(不运行程序就找 bug)与运行期检查器 Sanitizers(让程序带着检查跑)。
  • 解决什么问题:C 语言「信任程序员」,编译器不拦数组越界、use-after-free、未定义行为;这些错误在 Java/Python 里被运行时机制自动捕获,在 C 里则以「看似正常 → 偶现崩溃 → 难以复现」的方式潜伏到生产。本篇建立一道从便宜到昂贵的拦截梯度:编译器警告 → 静态分析器 → Sanitizers → CI 门禁。
  • 什么时候用到:
    • 写完代码马上想自检:开 -Wall -Wextra -Wpedantic;
    • 代码评审与 CI 流水线:cppcheck / clang-tidy / GCC -fanalyzer 扫描;
    • 测试环境跑测试套件:ASan + UBSan 抓内存错误与未定义行为,TSan 抓数据竞争;
    • 发布前审计:工具选型决策表 + 警告基线管理。
  • 学完能做什么:给一个 C 项目配好「警告当门禁」的构建;用 ASan 把动态数组的一次 heap-overflow 从偶现变必现并逐行读懂报告;在 CI 里编排多个 sanitizer 构建。

学习目标

  • 掌握「第 1 章 引言与学习路径」的核心机制、典型用法与常见陷阱
  • 掌握「第 2 章 历史演进与设计哲学」的核心机制、典型用法与常见陷阱
  • 掌握「第 3 章 核心概念与术语体系」的核心机制、典型用法与常见陷阱
  • 掌握「第 4 章 编译器警告:第一道防线」的核心机制、典型用法与常见陷阱
  • 掌握「第 5 章 静态分析工具」的核心机制、典型用法与常见陷阱
  • 掌握「第 6 章 Sanitizers 详解」的核心机制、典型用法与常见陷阱
  • 掌握「第 7 章 常见陷阱」与「第 8 章 工具选型与 CI 集成」的核心机制、典型用法与常见陷阱

第 1 章 引言与学习路径

1.1 为什么静态分析与调试是 C 工程师的”第二语言”

C 语言以”信任程序员”为设计哲学,这赋予了开发者前所未有的控制力,也意味着编译器不会替你拦住绝大多数错误。一个忘记初始化的局部变量、一个越界访问的数组下标、一个悬空的指针——这些错误在 Java/Python 中往往会被运行时机制(垃圾回收、数组边界检查、空指针异常)自动捕获,而在 C 中则会以”看似正常 → 偶发崩溃 → 难以复现”的方式潜伏到生产环境。

统计数据印证了这一点:据 CVE Details 与 MITRE CWE Top 25 统计,C/C++ 程序中超过 70% 的高危漏洞属于内存安全问题(buffer overflow、use-after-free、double free、null pointer dereference 等)。Linux 内核、Chromium、OpenSSL 这些由顶级工程师维护的项目,每年依然会通过静态分析工具发现数百个潜在缺陷。

因此,静态分析与动态调试是 C 工程师必备的”第二语言”。它们不是”高级技巧”,而是”基础生存技能”:

  • 静态分析(Static Analysis):在不运行程序的情况下,通过分析源代码或二进制发现潜在问题
  • 动态调试(Dynamic Debugging):在程序运行时观察其行为,定位 bug 的根因

一个不会用 GDB 调试段错误、不会用 ASan 检测内存越界、不会用 cppcheck 扫描代码的 C 工程师,就像一个不会用 IDE 的 Java 工程师一样,工作效率会大大折扣。

1.2 静态分析与调试的核心挑战

1.2.1 误报与漏报的平衡

静态分析工具面临一个根本性矛盾:

  • 太宽松:漏报(miss)多,真正的 bug 被淹没在噪音中
  • 太严格:误报(false positive)多,开发者不堪其扰,最终关闭工具

例如,以下代码:

c
int *p = NULL;
if (cond) {
    p = &local_var;
}
*p = 42;  // 当 cond 为假时是空指针解引用

静态分析工具若不做路径敏感分析,会认为 p 一定非空,漏报;若做路径敏感分析,又会因路径爆炸导致分析无法终止。现代工具(如 Clang Static Analyzer、GCC -fanalyzer)采用符号执行(symbolic execution)与抽象解释(abstract interpretation)等技术,在精度与可扩展性之间寻找平衡。

1.2.2 调试的可复现性

调试最大的痛苦是”无法复现的 bug”。例如:

  • Heisenbug:加了 printf 或断点后 bug 消失(因为时序被改变)
  • 平台相关 bug:在开发机正常,在生产服务器崩溃(因为 glibc 版本、内核参数不同)
  • 数据相关 bug:仅在特定输入下触发(如超大文件、特殊字符)

应对这些挑战需要系统化的方法:控制变量、最小化复现用例、二分定位(bisect)、使用 sanitizer 替代 printf。

1.2.3 优化对调试的干扰

编译器优化(-O2、-O3)会重排代码、内联函数、消除变量,导致调试信息与实际执行不匹配:

c
int x = 42;
printf("%d\n", x);  // 在 -O2 下, x 可能被完全消除

调试时必须使用 -O0 -g,但这又掩盖了只在优化下才暴露的 bug(如未定义行为)。处理这类问题需要:

  • 在 -O0 下复现并修复逻辑错误
  • 在 -O2 + UBSan/ASan 下复现并修复未定义行为
  • 使用 -Og(GCC 专为调试设计的优化级别)在两者间平衡

1.3 本文档的目标读者

本文档面向以下读者:

  • C 进阶学习者:已掌握基本语法,希望写出更可靠的代码
  • 系统编程工程师:开发服务端、嵌入式等需要高可靠性的程序
  • 安全工程师:需要理解漏洞原理以进行代码审计或漏洞分析
  • 面试准备者:调试与静态分析是高级 C 工程师面试的常见话题

1.4 学习路径建议

本篇聚焦「不运行程序」的静态侧,章节递进如下:

  1. 第 1 章 引言:建立对静态分析的整体认识
  2. 第 2 章 历史演进:从 lint 调试到现代工具链
  3. 第 3 章 核心概念:静态 vs 动态、bug 分类、符号执行
  4. 第 4 章 编译器警告:GCC/Clang 警告选项详解
  5. 第 5 章 静态分析工具:cppcheck、clang-tidy、Clang Static Analyzer
  6. 第 6 章 Sanitizers:ASan、MSan、UBSan、TSan
  7. 第 7 章 常见陷阱:优化相关与工具局限
  8. 第 8 章 工具选型与 CI 集成:分层防御与流水线配置

Valgrind 一族(Memcheck、Helgrind、Callgrind)属于「不重新编译」的动态插桩路线, 本篇只在第 8 章保留工具谱系与选型表;完整的 memcheck 实战走查、泄漏四分类解读 请读 C Valgrind 内存检测。

「运行程序」的动态调试侧——GDB/LLDB 断点、core dump、多线程调试、实战模式—— 已独立成篇,见 动态调试与 GDB 实战。

1.5 阅读前的预备知识

在开始阅读本文档前,你应该:

  • 掌握 C 基本语法、指针、内存管理
  • 了解编译流程(预处理 → 编译 → 汇编 → 链接)
  • 熟悉 Linux 基本命令(gcc、make、gdb)
  • 理解进程地址空间布局(代码段、数据段、堆、栈)
  • 能够在 Linux/Unix 环境下编译运行 C 程序

第 2 章 历史演进与设计哲学

2.1 早期调试:printf 与 core dump

1970 年代的 Unix 时代,调试工具非常简陋。程序员的主要手段是:

  • printf 调试法:在代码中插入 printf 输出变量值
  • core dump 分析:程序崩溃后,操作系统将内存映像写入 core 文件,使用 adb 或 dbx 分析

printf 调试法虽然原始,但有其独特优势:

  • 不需要学习复杂工具
  • 输出可以重定向到文件,适合长时间运行
  • 适用于任何平台,包括嵌入式系统

其缺点同样明显:

  • 修改代码后需要重新编译
  • 输出可能改变时序(导致 Heisenbug)
  • 难以观察复杂数据结构(如链表、树)

2.2 符号调试器的诞生:dbx 与 GDB

1980 年代,符号调试器(symbolic debugger)出现,允许程序员用源代码中的变量名、函数名、行号设置断点,而非使用内存地址。

dbx(1981 年,Berkeley):Unix 早期的符号调试器,语法影响深远:

plaintext
(dbx) stop at main
(dbx) run
(dbx) print x
(dbx) next

GDB(1986 年,Richard Stallman):GNU 项目的调试器,功能远超 dbx,支持:

  • 源码级调试(C/C++/Fortran/Go/Rust 等)
  • 条件断点、观察点、捕获点
  • 反汇编、寄存器查看
  • 远程调试(gdbserver)
  • Python/Guile 脚本扩展

GDB 至今仍是 Linux 下 C 调试的事实标准。

2.3 商业调试器:TotalView 与 LLDB

TotalView(1990s):面向高性能计算(HPC)的商业调试器,支持:

  • 千万级并行进程调试(MPI、OpenMP)
  • 内存泄漏检测(集成 Memcheck 功能)
  • GPU 代码调试(CUDA)

LLDB(2009 年,LLVM 项目):作为 GDB 的现代替代品,优势在于:

  • 与 Clang 共用解析器,对 C++ 模板、lambda 等新特性支持更好
  • 性能更高(启动快、表达式求值快)
  • Python 脚本原生支持,API 设计更现代
  • macOS/Xcode 默认调试器

2.4 静态分析工具的演进

2.4.1 Lint(1979 年,Stephen Johnson)

lint 是最早的 C 静态分析工具,随 Unix V7 发布。它能检测:

  • 未使用的变量与函数
  • 类型不一致(如 int 与 long 混用)
  • 可能的可移植性问题

但 lint 的检查规则与编译器警告高度重叠,且误报多,逐渐被遗忘。直到 2000 年代,新一代工具(splint、cppcheck、clang-tidy)才重振静态分析。

2.4.2 PC-lint(1985 年,Gimpel Software)

商业工具 PC-lint 是 Windows 平台 C/C++ 静态分析的事实标准,检查规则极其丰富(数千条),但价格昂贵且配置复杂。

2.4.3 Coverity(2002 年)与 Klocwork(2003 年)

Coverity 采用基于摘要的过程间分析(interprocedural analysis),能检测跨函数的缺陷,误报率较低。被 Synopsys 收购后,成为企业级代码审计的主流工具。

2.4.4 开源工具的崛起

  • cppcheck(2007 年):开源、轻量,专注于真正的 bug 而非风格问题
  • Clang Static Analyzer(2008 年):基于符号执行,精度高
  • clang-tidy(2013 年):集成于 Clang,可自动修复,规则可扩展

2.5 动态分析工具的演进

2.5.1 Purify(1990 年)与 BoundsChecker(1993 年)

Purify 通过二进制插桩(binary instrumentation)检测内存错误,是商业动态分析工具的鼻祖。其检查能力后来被开源的 Valgrind 与 ASan 超越。

2.5.2 Valgrind(2000 年,Julian Seward)

Valgrind 通过二进制翻译(binary translation)在虚拟机中运行程序,无需重新编译即可检测:

  • 内存泄漏与越界(Memcheck)
  • 线程数据竞争(Helgrind、DRD)
  • 函数调用性能分析(Callgrind)
  • 缓存命中率分析(Cachegrind)

缺点是性能开销极大(10-50 倍慢),不适合生产环境。

2.5.3 Sanitizers(2012 年,Google)

Google 团队推出的 Sanitizers 采用编译时插桩(compile-time instrumentation),在编译时插入检查代码,运行时开销远小于 Valgrind:

  • ASan(AddressSanitizer):检测内存越界、use-after-free、double free,2 倍慢
  • MSan(MemorySanitizer):检测未初始化内存读取,3 倍慢
  • UBSan(UndefinedBehaviorSanitizer):检测未定义行为,1.1 倍慢
  • TSan(ThreadSanitizer):检测线程数据竞争,5-15 倍慢

Sanitizers 已集成到 GCC 与 Clang 中,成为现代 C/C++ 开发的标配。

2.6 现代调试工具链的设计哲学

现代调试工具链体现了以下设计哲学:

2.6.1 分层防御

不应依赖单一工具,而应构建多层防御:

  1. 编译器警告(-Wall -Wextra):第一道防线,成本最低
  2. 静态分析(cppcheck、clang-tidy):CI 流水线中运行
  3. Sanitizers(ASan、UBSan):开发与测试环境运行
  4. 动态调试(GDB):定位具体 bug
  5. 运行时监控( Valgrind、core dump):生产环境事后分析

2.6.2 自动化优先

手动调试耗时且易错,现代实践强调自动化:

  • CI 中集成静态分析与 sanitizer 测试
  • fuzzing(AFL、libFuzzer)自动发现崩溃
  • 自动化测试覆盖率监控

2.6.3 失败快速(fail fast)

Sanitizer 的设计哲学是”一旦发现错误立即崩溃”,而非”尽量继续运行”。这样能:

  • 防止错误传播掩盖根因
  • 强制开发者修复而非忽略
  • 在测试环境暴露问题而非生产环境

第 3 章 核心概念与术语体系

3.1 静态分析 vs 动态分析

维度静态分析动态分析
运行时不运行程序运行程序
输入源代码或二进制可执行文件 + 测试输入
覆盖率理论上覆盖所有路径仅覆盖测试输入触发的路径
误报率较高(无法判断运行时实际值)极低(基于真实执行)
漏报率中等(受分析精度限制)高(未执行的路径不会被检查)
性能开销无运行时开销2-50 倍慢
典型工具cppcheck、clang-tidyValgrind、ASan、GDB
适用场景CI、代码审计测试、调试、生产监控

二者互补:静态分析能发现”潜在”问题,动态分析能确认”真实”问题。成熟的工程实践两者并用。

3.2 Bug 的分类体系

3.2.1 内存安全 Bug

C 程序中最常见也最危险的类别:

Bug 类型英文示例
缓冲区溢出Buffer Overflowarr[10] 当 arr 只有 5 个元素
越界读Out-of-bounds Readmemcpy(dst, src, len) 当 len 超过 src
越界写Out-of-bounds Write同上,但写方向
Use-After-FreeUAFfree(p); p->x = 1;
Double FreeDFfree(p); free(p);
空指针解引用NULL Pointer Dereference*p = 1; 当 p == NULL
未初始化读取Uninitialized Memory Readint x; if (x > 0) ...
内存泄漏Memory Leakp = malloc(...); 但无 free
栈缓冲区溢出Stack Buffer Overflow局部数组越界覆盖返回地址
堆栈冲突Stack Clash堆与栈边界破坏

3.2.2 并发 Bug

Bug 类型说明
数据竞争多线程同时访问同一变量,至少一个为写
死锁多个线程互相等待对方持有的锁
活锁线程不断改变状态但无法推进
原子性违反应该原子的操作被打断
顺序违反操作顺序与预期不符

3.2.3 未定义行为(Undefined Behavior, UB)

C 标准规定了一类”未定义行为”,编译器可以任意处理,包括:

  • 有符号整数溢出
  • 移位位数超过位宽
  • 对同一表达式多次修改同一变量(i = i++ + ++i)
  • 违反严格别名(strict aliasing)
  • 访问已释放内存

UB 是 C 程序的”隐形杀手”:在 -O0 下可能正常,在 -O2 下崩溃;在 GCC 下正常,在 Clang 下异常。UBSan 是检测 UB 的利器。

3.3 符号执行(Symbolic Execution)

符号执行是高级静态分析的核心技术,其思路是:用”符号变量”代替具体值,跟踪程序所有可能路径。

例如:

c
int foo(int x) {
    int y = x * 2;
    if (y > 10) {
        return y - 1;
    } else {
        return y + 1;
    }
}

符号执行器会同时分析两条路径:

  • 路径 1:x > 5 → 返回 2x - 1
  • 路径 2:x <= 5 → 返回 2x + 1

若在某路径发现潜在的空指针解引用,则报告一个 bug。

符号执行的主要挑战是路径爆炸(path explosion):分支数量随代码深度指数增长。现代工具(如 KLEE、Clang Static Analyzer)采用:

  • 路径合并(path merging)
  • 状态缓存
  • 超时与深度限制

来缓解路径爆炸问题。

3.4 抽象解释(Abstract Interpretation)

抽象解释是另一种静态分析技术,通过对变量取值进行”抽象”(如区间、符号、类型)而非精确跟踪:

  • 区间抽象:x ∈ [0, 100]
  • 符号抽象:x > 0
  • 类型抽象:x 是非空指针

抽象解释的优势是可扩展性好(无路径爆炸),劣势是精度较低(更多误报)。GCC 的 -fanalyzer 主要基于抽象解释。

3.5 二进制插桩 vs 源码插桩

动态分析工具按插桩方式分为两类:

3.5.1 二进制插桩(Binary Instrumentation)

在已编译的二进制上插入检查代码,无需源码:

  • Valgrind:运行时二进制翻译
  • Intel Pin:动态插桩框架
  • DynamoRIO:类似 Pin

优点:无需重新编译,可分析闭源程序 缺点:性能开销大,无法利用类型信息

3.5.2 源码插桩(Source Instrumentation)

在编译时插入检查代码,需要源码:

  • ASan/MSan/UBSan/TSan:编译器插桩
  • gcov:覆盖率插桩

优点:性能开销小,精度高 缺点:需要重新编译,无法分析第三方闭源库

3.6 调试信息格式

调试信息让调试器能将机器码映射回源代码:

  • DWARF:Linux/ELF 标准格式,GCC/Clang 默认
  • STAB:SunOS 早期格式,已淘汰
  • PDB(Program Database):Windows/MSVC 格式
  • CodeView:Windows 早期格式

-g 选项生成调试信息,-g3 额外包含宏定义,-gdwarf-4 指定 DWARF 版本(默认 v4,v5 更紧凑)。

3.7 调用栈(Call Stack)与栈帧(Stack Frame)

调试时最频繁查看的信息是调用栈。每次函数调用会创建一个栈帧,包含:

  • 函数参数
  • 局部变量
  • 返回地址
  • 保存的寄存器

GDB 的 backtrace 命令显示调用栈:

plaintext
(gdb) bt
#0  process_string (str=0x0) at segfault.c:8
#1  0x00401789 in main () at segfault.c:18

#0 是当前栈帧(崩溃点),#1 是调用者。使用 frame N 切换到第 N 层栈帧,使用 info locals 查看该帧的局部变量。

第 4 章 编译器警告:第一道防线

4.1 GCC/Clang 警告选项体系

编译器警告是成本最低的”静态分析”,应在所有项目中启用。GCC 与 Clang 共享大部分警告选项。

4.1.1 基础警告选项

选项说明
-Wall启用常见警告(并非所有警告)
-Wextra启用 -Wall 未包含的额外警告
-Wpedantic严格遵循 ISO C 标准,禁用扩展
-Werror将警告视为错误,阻止编译

推荐的”起步配置”:

bash
gcc -Wall -Wextra -Wpedantic -Werror -std=c17 program.c -o program

4.1.2 重要警告选项详解

bash
gcc -Wall -Wextra \
    -Wformat=2          \  # 检查 printf/scanf 格式串
    -Wconversion        \  # 隐式类型转换可能丢失数据
    -Wshadow            \  # 局部变量遮蔽外层变量
    -Wundef             \  # #if 中使用未定义宏
    -Wpointer-arith     \  # 指针运算警告
    -Wstrict-prototypes \  # 函数声明必须写参数类型
    -Wmissing-prototypes\  # 非静态函数必须有原型
    -Wredundant-decls   \  # 重复声明
    -Wnested-externs    \  # 嵌套 extern
    -Wold-style-definition \  # K&R 风格函数定义
    -Wmissing-include-dirs \  # 不存在的 include 目录
    -Wundef             \  # #if 中未定义宏
    -Wcast-align        \  # 强制转换导致对齐问题
    -Wwrite-strings     \  # 字符串字面量应为 const
    -Wswitch-enum       \  # switch 未覆盖枚举值
    -Wswitch-default    \  # switch 缺少 default
    -Wuninitialized     \  # 未初始化变量
    -Winit-self         \  # 变量用自身初始化 (如 int x = x;)
    -Wmissing-field-initializers \  # 结构体初始化不全
    -Wmissing-declarations  \  # 全局函数无声明
    -Wformat-nonliteral     \  # printf 格式串不是字面量
    -Wformat-security       \  # printf 安全问题
    -Wformat-y2k            \  # 日期格式 Y2K
    -Wnull-dereference      \  # 空指针解引用
    -Wdouble-promotion      \  # float 隐式转 double
    -Wframe-larger-than=4096 \ # 栈帧过大
    -Wlarger-than=8192      \  # 单个对象过大
    -Wstack-usage=4096      \  # 栈使用过大
    -Wvla                   \  # 使用变长数组 VLA
    -Walloca                \  # 使用 alloca
    -Warray-bounds=2        \  # 数组越界检查级别 2
    -Wimplicit-fallthrough=3 \ # switch case 穿透
    -Wshift-overflow=2      \  # 移位溢出
    -Wstringop-overflow=4   \  # 字符串操作溢出
    -Wtrampolines           \  # 嵌套函数跳板
    -Wfloat-equal           \  # 浮点数相等比较
    -Wlogical-op            \  # 逻辑运算符可疑
    -Waggregate-return      \  # 返回结构体
    -Wpadded                \  # 结构体填充
    -Wsync-nand             \  # __sync_nand 不安全
    -Wunsuffixed-float-constants \  # 浮点常量无后缀
    program.c -o program

4.1.3 启用与禁用特定警告

bash
# 启用某个警告
gcc -Wunused-variable program.c -o program

# 禁用某个警告(在 -Wall 后)
gcc -Wall -Wno-unused-parameter program.c -o program

# 将某个警告视为错误
gcc -Werror=format-security program.c -o program

# 将某个错误降级为警告
gcc -Wno-error=format program.c -o program

4.2 在代码中控制警告

4.2.1 GCC/Clang pragma

c
#pragma GCC diagnostic push
#pragma GCC diagnostic ignored "-Wunused-variable"

int unused_demo(void) {
    int x = 42;  // 警告被抑制
    return 0;
}

#pragma GCC diagnostic pop  // 恢复警告设置

4.2.2 函数属性控制

c
/* 标记函数的某些参数可能未使用 */
int callback(int event, void *data)
    __attribute__((unused));  // 整个函数标记为可能未使用

/* 标记参数未使用 */
int callback(int event, void *data) {
    (void)data;  // 常用技巧:转换为 void 抑制警告
    return event;
}

4.2.3 MSVC 警告控制

c
#pragma warning(push)
#pragma warning(disable: 4996)  // 禁用"不安全 CRT 函数"警告
FILE *f = fopen("file.txt", "r");
#pragma warning(pop)

4.3 警告选项的最佳实践

4.3.1 渐进式启用

对于已有大型项目,一次性启用所有警告会产生数千个警告,难以处理。推荐渐进式策略:

  1. 第一阶段:启用 -Wall -Wextra,修复主要警告
  2. 第二阶段:启用 -Wpedantic -Wconversion,处理类型问题
  3. 第三阶段:启用 -Werror,将警告阻断 CI

4.3.2 警告基线管理

对于无法立即修复的警告,使用基线文件管理:

bash
# 生成当前警告基线
gcc -Wall -Wextra program.c 2> warnings.baseline

# CI 中对比新警告
gcc -Wall -Wextra program.c 2> warnings.new
diff warnings.baseline warnings.new

4.3.3 区分开发与生产

cmake
# CMakeLists.txt
if(CMAKE_BUILD_TYPE STREQUAL "Debug")
    add_compile_options(-Wall -Wextra -Wpedantic -Werror -g -O0)
elseif(CMAKE_BUILD_TYPE STREQUAL "Release")
    add_compile_options(-Wall -Wextra -O2)
    # 生产不强制 -Werror,避免阻塞发布
endif()

第 5 章 静态分析工具

5.1 cppcheck

cppcheck 是开源、轻量的 C/C++ 静态分析工具,设计目标:误报少,只报告真正的 bug。

5.1.1 安装与基本用法

bash
# Ubuntu/Debian
sudo apt install cppcheck

# macOS
brew install cppcheck

# Windows
choco install cppcheck

# 基本用法
cppcheck program.c

# 启用所有检查
cppcheck --enable=all --suppress=missingIncludeSystem program.c

# 检查整个项目
cppcheck --enable=all --inline-suppr src/

# 生成 XML 报告(可被 CI 工具解析)
cppcheck --enable=all --xml --xml-version=2 src/ 2> report.xml

5.1.2 检查级别

bash
# 默认:仅 error 级别
cppcheck program.c

# 启用 warning、style、performance、portability
cppcheck --enable=warning,style program.c

# 全部启用
cppcheck --enable=all program.c

5.1.3 内联抑制

在代码中通过注释控制 cppcheck:

c
void f(void) {
    int x;
    // cppcheck-suppress uninitvar
    printf("%d\n", x);  // 抑制"未初始化变量"警告
}

5.1.4 cppcheck 能检测的典型问题

c
#include <stdlib.h>
#include <string.h>

/* 1. 空指针解引用 */
void null_deref(int *p) {
    if (p == NULL) {
        *p = 42;  // cppcheck 报告: Null pointer dereference
    }
}

/* 2. 内存泄漏 */
void leak(void) {
    char *buf = malloc(100);
    /* 忘记 free, cppcheck 报告: Memory leak */
}

/* 3. 缓冲区溢出 */
void overflow(void) {
    char buf[10];
    strcpy(buf, "Hello, World!");  // cppcheck 报告: Buffer overflow
}

/* 4. 数组越界 */
void out_of_bounds(void) {
    int arr[5];
    for (int i = 0; i <= 5; i++) {  // cppcheck 报告: Array index out of bounds
        arr[i] = i;
    }
}

/* 5. 资源泄漏 */
void fd_leak(void) {
    FILE *f = fopen("file.txt", "r");
    if (!f) return;
    /* 忘记 fclose, cppcheck 报告: Resource leak */
}

/* 6. 重复代码 */
void duplicate_code(int x) {
    if (x > 0) {
        printf("positive\n");
    } else if (x > 0) {  // cppcheck 报告: duplicateCondition
        printf("positive again\n");
    }
}

5.2 clang-tidy

clang-tidy 是 Clang 项目下的 lint 工具,既能做静态分析,也能自动修复代码。

5.2.1 基本用法

bash
# 基本用法
clang-tidy program.c -- -std=c17

# 使用特定检查集
clang-tidy -checks='bugprone-*,modernize-*,performance-*,readability-*' program.c -- -std=c17

# 自动修复
clang-tidy -fix -checks='modernize-*,readability-*' program.c -- -std=c17

# 列出所有可用检查
clang-tidy -list-checks -checks='*'

# 使用 .clang-tidy 配置文件
clang-tidy program.c

5.2.2 .clang-tidy 配置文件

在项目根目录创建 .clang-tidy:

yaml
---
Checks: >
  -*,
  bugprone-*,
  cert-*,
  cppcoreguidelines-*,
  clang-analyzer-*,
  modernize-*,
  performance-*,
  readability-*,
  -bugprone-narrowing-conversions,
  -modernize-use-trailing-return-type,
  -readability-magic-numbers,
  -readability-identifier-name-length
WarningsAsErrors: ''
HeaderFilterRegex: '^(?!.*(third_party|build)).*$'
FormatStyle: file
CheckOptions:
  - key:   readability-identifier-naming.VariableCase
    value: lower_case
  - key:   readability-identifier-naming.FunctionCase
    value: lower_case
  - key:   readability-identifier-naming.ClassCase
    value: CamelCase
  - key:   readability-identifier-naming.EnumConstantCase
    value: UPPER_CASE
  - key:   cppcoreguidelines-avoid-magic-numbers.IgnoredIntegerValues
    value: '0;1;2;3;4;'
...

5.2.3 常用检查集

检查集说明
bugprone-*容易引发 bug 的代码模式
cert-*CERT C/C++ 安全编码标准
cppcoreguidelines-*C++ Core Guidelines (部分适用 C)
clang-analyzer-*Clang Static Analyzer 检查
modernize-*现代化 C/C++ 代码
performance-*性能相关建议
readability-*代码可读性
misc-*杂项
google-*Google 编码风格
llvm-*LLVM 编码风格

5.2.4 clang-tidy 的自动修复能力

bash
# 自动修复所有可修复的问题
clang-tidy -fix -checks='modernize-*,readability-braces-around-statements' \
    program.c -- -std=c17

# 仅显示建议的修复,不实际修改
clang-tidy -fix-errors -checks='modernize-*' program.c -- -std=c17

clang-tidy 可以自动完成的修复示例:

  • modernize-use-nullptr:NULL → nullptr(C++)
  • modernize-use-override:添加 override 关键字
  • readability-braces-around-statements:为 if/for/while 添加大括号
  • readability-delete-null-pointer:简化 if (p) delete p 为 delete p

5.3 Clang Static Analyzer

Clang Static Analyzer 是基于符号执行的高级静态分析器,精度高于 cppcheck。

5.3.1 命令行使用

bash
# 单文件分析
clang --analyze -Xanalyzer -analyzer-checker=core program.c

# 启用所有检查器
clang --analyze -Xanalyzer -analyzer-checker=alpha,alpha.security,alpha.unix program.c

# 生成 HTML 报告
scan-build -o reportDir clang program.c

# 查看可用检查器
clang -cc1 -analyzer-checker-help

5.3.2 重要检查器

检查器说明
core核心检查(NULL 解引用、内存泄漏)
core.CallAndMessage调用错误(参数不匹配、空函数指针)
core.DivideZero除零
core.NullDereference空指针解引用
core.StackAddressEscape栈地址逃逸(返回局部变量地址)
core.uninitialized.ArraySubscript未初始化数组下标
core.uninitialized.Assign未初始化赋值
cplusplus.NewDeleteC++ new/delete 不匹配
deadcode.DeadStores死代码(赋值后未使用)
security.insecureAPI.*不安全 API(gets、strcpy)
unix.*Unix API 检查
osx.*macOS API 检查
alpha.*实验性检查(可能高误报)

5.3.3 scan-build 工作流

scan-build 是 Clang 提供的构建系统包装器,用于分析整个项目:

bash
# 包装 make
scan-build make

# 包装 cmake
scan-build cmake --build build/

# 指定输出目录
scan-build -o /tmp/scan-results make

# 分析完成后,自动在浏览器中查看结果
scan-view /tmp/scan-results/2024-01-01-*/  # scan-build 会输出实际路径

scan-build 生成的 HTML 报告会显示每个 bug 的完整路径,例如:

plaintext
路径:
1. 调用 malloc() 返回指针 p
2. 检查 p == NULL
3. 当 p == NULL 时,解引用 *p
   ↑ Bug: Null pointer dereference

5.4 GCC -fanalyzer

GCC 10 引入了 -fanalyzer 选项,提供基于抽象解释的静态分析。

5.4.1 基本用法

bash
# 启用静态分析
gcc -fanalyzer -O0 program.c -o program

# 启用更详细的分析
gcc -fanalyzer -fanalyzer-call-summaries -fdump-analyzer program.c -o program

# 指定检查的函数
gcc -fanalyzer -fanalyzer-function-attr=analyze program.c

5.4.2 -fanalyzer 能检测的问题

  • 双重释放(double-free)
  • Use-after-free
  • 内存泄漏
  • 空指针解引用
  • 使用未初始化值
  • taint 检查(用户输入传播到敏感操作)
c
#include <stdlib.h>

void double_free_demo(void) {
    int *p = malloc(sizeof(int));
    free(p);
    free(p);  // -fanalyzer 报告: double-free of 'p'
}

void use_after_free_demo(void) {
    char *s = strdup("hello");
    free(s);
    printf("%s\n", s);  // -fanalyzer 报告: use after free
}

void leak_demo(void) {
    char *buf = malloc(1024);
    if (some_condition()) {
        return;  // -fanalyzer 报告: memory leak
    }
    free(buf);
}

5.5 其他静态分析工具

5.5.1 CppDepend

商业工具,专注于代码度量(圈复杂度、耦合度、内聚度),适合大型项目的架构分析。

5.5.2 Polyspace

MathWorks 出品,基于抽象解释,能数学证明代码无运行时错误。被用于航空航天、汽车等安全关键领域。价格昂贵(年费数万美元)。

5.5.3 CodeQL(GitHub)

GitHub 推出的语义化代码查询工具,使用类 SQL 语法查询代码:

sql
-- 查找所有 strcpy 调用
import c

from CallExpr call
where call.getTarget().getName() = "strcpy"
select call, "Use of unsafe strcpy"

CodeQL 集成在 GitHub 代码扫描中,免费用于开源项目。

第 6 章 Sanitizers 详解

6.1 AddressSanitizer (ASan)

ASan 是最常用的 sanitizer,检测内存越界与 use-after-free。

6.1.1 编译选项

bash
# GCC 与 Clang 都支持
gcc -fsanitize=address -g -O1 program.c -o program

# 推荐组合(开发环境)
gcc -fsanitize=address \
    -fno-omit-frame-pointer \
    -g -O1 \
    program.c -o program

# 检测内存泄漏(默认开启,可显式控制)
gcc -fsanitize=address -fsanitize=leak program.c -o program

# Clang 专属:检测栈使用后返回
clang -fsanitize=address -fsanitize-address-use-after-scope program.c -o program

6.1.2 ASan 能检测的错误

错误类型说明
Heap buffer overflow堆缓冲区溢出
Stack buffer overflow栈缓冲区溢出
Global buffer overflow全局缓冲区溢出
Use after free释放后使用
Use after return函数返回后使用栈变量(需特殊编译)
Use after scope离开作用域后使用(Clang)
Double free重复释放
Invalid free释放非堆指针
Memory leaks内存泄漏(集成 LeakSanitizer)

6.1.3 ASan 错误报告解读

c
#include <stdlib.h>

int main(void) {
    int *arr = malloc(5 * sizeof(int));
    arr[5] = 42;  // 堆缓冲区溢出
    free(arr);
    return 0;
}

ASan 输出:

plaintext
=================================================================
==12345==ERROR: AddressSanitizer: heap-buffer-overflow on address 0x602000000014 at pc 0x4011c7 bp 0x7ffe9cff13d0 sp 0x7ffe9cff13c0
WRITE of size 4 at 0x602000000014 thread T0
    #0 0x4011c7 in main /path/program.c:4:5
    #1 0x7f1234568082 in __libc_start_main (/lib/x86_64-linux-gnu/libc.so.6:243)
    #2 0x40110d in _start (/path/program+0x40110d)

0x602000000014 is located 0 bytes to the right of 20-byte region [0x602000000000,0x602000000014)
allocated by thread T0 here:
    #0 0x40a26f in malloc (/usr/lib/x86_64-linux-gnu/libasan.so.5+0xe926f)
    #1 0x4011b8 in main /path/program.c:3:16
    #2 0x7f1234568082 in __libc_start_main (/lib/x86_64-linux-gnu/libc.so.6:243)

SUMMARY: AddressSanitizer: heap-buffer-overflow /path/program.c:4:5 in main
Shadow bytes around the buggy address:
  0x0c047fff7fb0: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
  0x0c047fff7fc0: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
  0x602000000000: fa fa fa fa 00 00 00 00
                              ^
  0x0c047fff7fe0: fa fa fa fa fa fa fa fa
==12345==ABORTING

关键信息解读:

  • 错误类型: heap-buffer-overflow
  • 访问地址: 0x602000000014
  • 访问类型: WRITE of size 4
  • 栈帧: main 函数第 4 行
  • 分配信息: 20 字节区域,在第 3 行分配
  • 位置: 在分配区域右侧 0 字节处(刚好越界)
  • Shadow bytes: ASan 的”影子内存”可视化

6.1.4 ASan 运行时选项

通过环境变量 ASAN_OPTIONS 配置:

bash
# 默认行为:出错即 abort
ASAN_OPTIONS=abort_on_error=1 ./program

# 不立即退出,继续报告其他错误
ASAN_OPTIONS=halt_on_error=0:print_legend=0 ./program

# 检测 use-after-return(有性能开销)
ASAN_OPTIONS=detect_stack_use_after_return=1 ./program

# 检测容器越界(C++ std::vector)
ASAN_OPTIONS=detect_container_overflow=1 ./program

# 禁用内存泄漏检测
ASAN_OPTIONS=detect_leaks=0 ./program

# 输出到文件
ASAN_OPTIONS=log_path=asan.log ./program

# 启用调试日志
ASAN_OPTIONS=verbosity=1 ./program

6.2 MemorySanitizer (MSan)

MSan 检测未初始化内存读取,这是 ASan 不检测的类别。

6.2.1 编译选项

bash
# MSan 仅 Clang 支持
clang -fsanitize=memory -fno-omit-frame-pointer -g -O1 program.c -o program

# 必须用 Clang 重新编译所有依赖(包括标准库)
# 推荐使用 libc++ 而非 libstdc++
clang++ -fsanitize=memory -stdlib=libc++ program.cpp -o program

6.2.2 MSan 检测示例

c
#include <stdio.h>
#include <stdlib.h>

int main(void) {
    int *p = malloc(sizeof(int));
    if (*p > 0) {  // MSan 报告: 使用未初始化值
        printf("positive\n");
    }
    free(p);
    return 0;
}

MSan 输出:

plaintext
==12345==WARNING: MemorySanitizer: use-of-uninitialized-value
    #0 0x4011c7 in main /path/program.c:5:9
    #1 0x7f1234568082 in __libc_start_main

  Uninitialized value was created by a heap allocation
    #0 0x40a26f in malloc
    #1 0x4011b8 in main /path/program.c:4:14

6.2.3 MSan 的限制

  • 仅 Clang 支持(GCC 无 MSan)
  • 需要用 MSan 重新编译所有依赖(包括 libc)
  • 不能与 ASan 同时使用(互斥)
  • 性能开销:约 3 倍慢

6.3 UndefinedBehaviorSanitizer (UBSan)

UBSan 检测 C 标准定义的”未定义行为”。

6.3.1 编译选项

bash
# 启用所有 UB 检查
gcc -fsanitize=undefined -g program.c -o program

# 启用特定检查
gcc -fsanitize=signed-integer-overflow,shift,alignment program.c -o program

# 推荐组合
gcc -fsanitize=undefined \
    -fno-sanitize-recover=all \  # 出错立即终止
    -g -O1 \
    program.c -o program

# Clang 专属:整数溢出 traps
clang -fsanitize=integer program.c -o program

6.3.2 UBSan 能检测的 UB

检查项说明
alignment未对齐的指针访问
boolbool 值非 0/1
builtin内置函数参数错误
bounds数组越界(C 静态数组)
enumenum 值超出范围
float-cast-overflow浮点转整数溢出
function函数指针调用类型不匹配
implicit-unsigned-integer-truncation无符号整数截断
implicit-signed-integer-truncation有符号整数截断
implicit-integer-sign-change整数符号变化
integer-divide-by-zero整数除零
nonnull-attribute违反 nonnull 属性
null空指针解引用
object-size对象大小检查
pointer-overflow指针运算溢出
return非 void 函数未返回
returns-nonnull-attribute违反 returns_nonnull 属性
shift移位运算错误
signed-integer-overflow有符号整数溢出
unreachable执行到 __builtin_unreachable()
vla-boundVLA 长度非正
vptrC++ 虚指针错误

6.3.3 UBSan 检测示例

c
#include <stdio.h>
#include <limits.h>

int main(void) {
    int x = INT_MAX;
    int y = x + 1;  // 有符号整数溢出(UB)

    int arr[5] = {0};
    arr[10] = 42;   // 数组越界(UB)

    int shift = 32;
    int z = 1 << shift;  // 移位溢出(UB)

    int *p = NULL;
    *p = 42;        // 空指针解引用(UB)

    return 0;
}

UBSan 输出:

plaintext
program.c:5:11: runtime error: signed integer overflow: 2147483647 + 1 cannot be represented in type 'int'
program.c:8:5: runtime error: index 10 out of bounds for type 'int [5]'
program.c:11:13: runtime error: shift exponent 32 is too large for 32-bit type 'int'
program.c:14:5: runtime error: load of null pointer of type 'int'

6.4 ThreadSanitizer (TSan)

TSan 检测线程数据竞争。

6.4.1 编译选项

bash
# GCC 与 Clang 都支持
gcc -fsanitize=thread -g -O1 program.c -o program -lpthread

# 必须用 -pie 与 -fPIC(位置无关代码)
gcc -fsanitize=thread -pie -fPIC -g -O1 program.c -o program -lpthread

6.4.2 TSan 检测示例

c
#include <stdio.h>
#include <pthread.h>

int shared_counter = 0;

void *increment(void *arg) {
    for (int i = 0; i < 1000000; i++) {
        shared_counter++;  // 数据竞争!
    }
    return NULL;
}

int main(void) {
    pthread_t t1, t2;
    pthread_create(&t1, NULL, increment, NULL);
    pthread_create(&t2, NULL, increment, NULL);
    pthread_join(t1, NULL);
    pthread_join(t2, NULL);
    printf("counter = %d\n", shared_counter);
    return 0;
}

TSan 输出:

plaintext
==================
WARNING: ThreadSanitizer: data race
  Write of size 4 by thread T1:
    #0 increment /path/program.c:7:9
    #1 <null> <null>

  Previous write of size 4 by thread T2:
    #0 increment /path/program.c:7:9
    #1 <null> <null>

  Location is global 'shared_counter' of size 4 at 0x7f...program

  Thread T1 (tid=12346, running) created by main thread at:
    #0 pthread_create <null>
    #1 main /path/program.c:13:5

  Thread T2 (tid=12347, finished) created by main thread at:
    #0 pthread_create <null>
    #1 main /path/program.c:14:5
==================

6.4.3 TSan 的限制

  • 性能开销大:5-15 倍慢,内存增加 5-10 倍
  • 不能与 ASan/MSan 同时使用
  • 仅检测”真实”竞争,需要程序实际执行该路径
  • 不能检测原子性违反(除非表现为数据竞争)

6.5 多个 Sanitizer 组合

不同 sanitizer 不能随意组合:

组合是否支持备注
ASan + UBSan是推荐组合,常用
ASan + MSan否互斥
ASan + TSan否互斥
MSan + UBSan是可组合
TSan + UBSan是可组合
ASan + LeakSan是LeakSan 默认集成于 ASan

推荐的 CI 流水线配置:

  • 构建 1:-fsanitize=address,undefined
  • 构建 2:-fsanitize=thread(如有并发代码)
  • 构建 3:-fsanitize=memory(如能用 Clang 全套编译)

Valgrind 工具谱系与本篇分工

Valgrind 一族走的是另一条技术路线:二进制插桩——把程序放进一个「虚拟 CPU」逐条指令执行,因此不需要重新编译,代价是慢 10-50 倍。它的四个成员各自盯一类问题:

工具盯什么什么时候选它
Memcheck内存越界、UAF、泄漏第三方闭源库、无法重编译的场景
Helgrind / DRD数据竞争、锁顺序无法用 TSan 重编译全部依赖时
Callgrind函数调用次数与指令数定位热点(比 perf 更精确但更慢)
Cachegrind / Massif缓存命中率 / 堆增长缓存优化、内存峰值分析

分工边界:本篇第 6 章的 Sanitizers 走「编译时插桩」路线——重编译但快(ASan 约 2 倍慢),是开发与 CI 的日常主力;Valgrind 是「不重编译但慢」的重武器。Memcheck 的完整实战走查、泄漏四分类(definitely/indirectly/possibly lost 与 still reachable)逐行解读、与 ASan 的详细对比表,见专篇 C Valgrind 内存检测——本篇不再重复其实操内容。

一句话决策:CI 与日常用 ASan(快、要源码),疑难杂症与闭源依赖用 Valgrind(全、无需源码)。

第 7 章 常见陷阱:优化与工具局限

7.1 优化相关陷阱

7.1.1 -O0 与 -O2 行为不一致

c
#include <stdio.h>

int main(void) {
    int x = 0;
    /* 未定义行为: 多次修改 */
    x = x++ + ++x;
    printf("x = %d\n", x);
    return 0;
}

-O0 下可能输出 2,-O2 下可能输出 3。UBSan 能检测。

7.1.2 严格别名违规

c
int x = 0x3F800000;  /* 1.0f 的位模式 */
float *fp = (float *)&x;  /* 严格别名违规 */
printf("%f\n", *fp);

GCC -O2 可能”优化”掉这个访问。用 memcpy 替代:

c
float f;
memcpy(&f, &x, sizeof(f));

7.1.3 调试信息与优化不匹配

c
int x = 42;
/* -O2 下 x 可能被消除 */
printf("%d\n", x);

GDB 中 print x 可能报 <optimized out>。解决:

  • 调试用 -O0 或 -Og
  • 关键变量用 volatile 防止优化

7.2 工具局限陷阱

7.2.1 Sanitizer 检测不到栈外越界

c
int arr[5];
arr[10] = 42;  /* 越栈帧, 可能不崩溃, ASan 可能不报 */

ASan 通过”红区”(redzone)检测,但远距离越界可能跳过红区。

7.2.2 Valgrind 不能检测已优化代码

c
/* -O2 下, 编译器可能消除未初始化读取 */
int *p = malloc(sizeof(int));
if (*p > 0) { ... }

-O2 下 *p 可能被预测为某常量,Valgrind 看不到读取。必须用 -O0。

7.2.3 静态分析的误报

c
void f(int *p) {
    if (p) {
        *p = 42;
    }
    /* cppcheck 可能报告: p 可能为 NULL */
}

某些工具路径分析精度不足,产生误报。需结合人工判断或用抑制注释。

第 8 章 工具选型与 CI 集成

8.1 分层防御体系

一个成熟的 C 项目应建立 5 层防御:

  1. 编译器警告:-Wall -Wextra -Wpedantic -Werror,所有警告必须修复
  2. 静态分析:CI 中运行 cppcheck、clang-tidy、GCC -fanalyzer
  3. Sanitizer 测试:CI 中运行 ASan + UBSan 测试套件
  4. 代码审查:人工审查 + CodeQL 自动扫描
  5. 运行时监控:生产环境 core dump + 错误上报

8.2 CI 流水线配置示例

yaml
# .github/workflows/ci.yml
name: CI
on: [push, pull_request]

jobs:
  build-test:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        sanitizer: [none, asan, ubsan, tsan, msan]
        compiler: [gcc, clang]
    steps:
      - uses: actions/checkout@v3
      - name: Install dependencies
        run: |
          sudo apt update
          sudo apt install -y cppcheck clang-tidy valgrind
      - name: Configure
        run: |
          mkdir build && cd build
          if [ "${{ matrix.sanitizer }}" = "asan" ]; then
            cmake -DCMAKE_C_FLAGS="-fsanitize=address,undefined -g" ..
          elif ...
      - name: Static analysis
        run: |
          cppcheck --enable=all --error-exitcode=1 src/
          run-clang-tidy -checks='*' src/
      - name: Build
        run: cmake --build build
      - name: Test
        run: cd build && ctest --output-on-failure

8.3 工具选择决策表

场景推荐工具
编写新代码编译器警告 + clang-tidy
CI 自动检查cppcheck + clang-tidy + GCC -fanalyzer
内存越界/UAFASan
未初始化读取MSan(优先)或 Valgrind
数据竞争TSan(优先)或 Helgrind
死锁GDB + Helgrind
内存泄漏ASan LeakSanitizer(优先)或 Valgrind
性能分析Callgrind 或 perf
崩溃事后分析core dump + GDB
嵌入式调试printf + 远程 GDB
第三方库(无源码)Valgrind

动手实践

练习 1:把警告变成 CI 门禁

给下面这个含三处隐患的文件配置「零警告」构建,找出全部问题:

c
/* sloppy.c */
#include <stdio.h>
#include <string.h>

int compute(int n) {
    int total;
    for (int i = 0; i < n; i++) {
        total += i;          /* 隐患 A */
    }
    return total;
}

int main(void) {
    char buf[8];
    strcpy(buf, "0123456789");  /* 隐患 B */
    int big = 2147483647;
    big = big + 1;              /* 隐患 C */
    printf("%d %s\n", compute(5), buf);
    return 0;
}

提示:

  • 用 gcc -Wall -Wextra -Wpedantic -Werror -std=c17 sloppy.c 编译,第一轮警告先修哪几个?
  • 未初始化变量(隐患 A)编译器一定报吗?换 -O2 再试一次,观察警告是否变化。
  • 隐患 C 是未定义行为,编译警告只能给出「提示」级别,换成 -fsanitize=undefined 运行时验证。

参考实现(先自己做完再看):

text
第一轮编译(-Wall -Wextra)会报:
- 'total' is used uninitialized(隐患 A,部分 GCC 版本在 -O0 下漏报,-O2 才稳定报出)
- '__builtin_strcpy' writing 11 bytes into a region of size 8
  (隐患 B,-O2 下的 -Wstringop-overflow 才报,-O0 可能不报)

第三处 big + 1 溢出编译警告通常不报,运行
gcc -fsanitize=undefined -g sloppy.c && ./a.out
会打印:signed integer overflow: 2147483647 + 1 cannot be represented in type 'int'

修复:
A. int total = 0;
B. 改为 strncpy(buf, "0123456789", sizeof(buf) - 1); buf[sizeof(buf)-1] = '\0';
   或者干脆把缓冲区放大到 12;
C. 若语义允许,改用 unsigned 或先检查 big < INT_MAX。

练习 2:用 ASan 抓动态数组的 heap-overflow

写一个 grow.c:int *arr = malloc(4 * sizeof(int)); 之后写入 arr[4] = 1(越界一位),分别用普通编译与 ASan 编译运行两次,对比输出。

提示:

  • ASan 编译命令:gcc -fsanitize=address -fno-omit-frame-pointer -g -O1 grow.c -o grow
  • 注意看报告里的两个关键句:WRITE of size 4(谁在写)与 allocated by thread T0 here(内存哪来的)。
  • 把 arr[4] 改成 arr[400] 再跑一次,观察「远距离越界」还能不能被抓到,思考红区(redzone)机制的局限。

参考实现(先自己做完再看):

text
普通编译:程序正常退出,什么也不发生——这就是 C 的危险之处。
ASan 编译运行:
  ERROR: AddressSanitizer: heap-buffer-overflow on address 0x602000000010
  WRITE of size 4 at 0x602000000010 thread T0
      #0 0x4011c7 in main grow.c:5
  0x602000000010 is located 0 bytes to the right of 16-byte region
  allocated by thread T0 here:
      #0 malloc
      #1 0x4011a0 in main grow.c:3

报告读法:越界地址恰好在 16 字节分配块的右侧 0 字节处(刚好压在红区上),
栈帧指回 main 第 5 行,分配栈指回第 3 行的 malloc——一次定位,无需复现技巧。
把 4 改成 400 后跳出了整个红区,可能落在别的合法内存上,ASan 不一定报:
这解释了为什么 sanitizer 也需要配合 fuzzing 与静态分析使用。

练习 3:给 CI 加静态分析关卡(工程场景)

假设你维护一个 Makefile 项目 src/(约 20 个 .c 文件),要在 GitHub Actions 里加两道关卡:cppcheck 与 clang-tidy。写出关键步骤。

提示:

  • cppcheck 有 --error-exitcode 让发现问题即失败;
  • clang-tidy 需要 compile_commands.json,回看 构建系统 里 CMake 的哪个开关能生成它;
  • 两个工具都要考虑「存量警告太多」的落地策略。

参考实现(先自己做完再看):

yaml
# .github/workflows/static.yml 关键 job
jobs:
  static:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: sudo apt-get install -y cppcheck clang-tidy
      # 关卡 1:error 级别直接失败
      - run: cppcheck --enable=warning,error --error-exitcode=1 src/
      # 关卡 2:clang-tidy 用构建数据库,先 CMake 生成
      - run: cmake -B build -DCMAKE_EXPORT_COMPILE_COMMANDS=ON
      - run: run-clang-tidy -p build 'src/.*\.c'

落地策略与 4.3.3 节的警告基线同理:存量问题先生成基线清单放进仓库, CI 只对「新增警告」失败;每周安排清理日削减基线,而不是一上来就 -Werror 全量。

12.5.1 官方文档

参考与致谢

以上均为各项目/机构官方公开文档,本篇工具行为描述以其为准。