13. 编译器诊断与程序检查
在某些开发场景中,自动化测试套件即使全部标绿通过,程序一旦迁移至另一台物理机器或特定操作系统版本下,却依然可能突发段错误崩溃。这类偶发性故障在系统级 C 语言工程中往往最为棘手:缺陷真实潜伏在内存操作之中,只是在常规的执行路径下未曾显露破绽。例如,在堆内存申请时少分配了一个末尾字节,多写出的数据恰好落在了分配器内部的填充对齐区中;读取越界指针恰巧拿到的是邻近已清零的旧内存;或者有符号整型在发生溢出回绕后,计算出的数值歪打正着仍能勉强维持后续逻辑运转。
现代编译器与运行时分析工具的设计初衷,正是为了在潜在隐患引发灾难性后果之前将其拦截。本章将围绕四大维度的检查机制展开探讨:以编译器的严苛告警(Warning)为第一道防线;引入 GCC 内置的路径敏感静态分析器;横向对比 cppcheck 与 clang-tidy 这类脱离编译器的独立静态代码审计工具;最后深入探究基于插桩技术的动态分析利器——AddressSanitizer(ASan)与 UndefinedBehaviorSanitizer(UBSan),并展示如何将它们系统化地接入 CMake 构建图谱,在测试执行期间自动捕获内存越界、资源泄漏与未定义行为。
文中所记录的诊断输出取自 Debian 13 环境下的 GCC 14.2 与 Windows 11 环境下的 MinGW-w64 GCC 13.2,各组示例均注明了运行平台。两套工具链在诊断格式上严格遵循现代工业标准,若在措辞或特定检查项的覆盖范围上存在细微差别,正文中会做专门比对。正文所涉及的轻量验证用例存放在 code/开发与工程基础/13_编译器诊断与程序检查/demos/ 目录下,而带有特定缺陷的对比版本则位于同级的 bugs/ 目录中。
1. 读懂一条诊断
编译器在词法分析、语法解析、类型检查以及中间代码生成阶段向开发者发出的排错提示,统称为编译器诊断(Diagnostic)。一条标准的诊断通常由若干结构化要素构成。我们以下面这段将 total 拼写为 totl 的小程序为例展开观察:
#include <stdio.h>
int main(void) {
int total = 0;
printf("%d\n", totl);
return 0;
}2
3
4
5
6
7
$ gcc -std=c17 -Wall -Wextra -fsyntax-only typo.c
typo.c: In function 'main':
typo.c:5:20: error: 'totl' undeclared (first use in this function); did you mean 'total'?
5 | printf("%d\n", totl);
| ^~~~
| total
typo.c:5:20: note: each undeclared identifier is reported only once for each function it appears in
typo.c:4:9: warning: unused variable 'total' [-Wunused-variable]
4 | int total = 0;
| ^~~~~2
3
4
5
6
7
8
9
10
首行的 typo.c: In function 'main': 标明了后续诊断所属的作用域上下文。随后的核心诊断行采用了标准的 文件名:行号:列号: 严重级别: 详细消息 规范。诊断级别主要划分为三等:error 表示遇到了阻碍编译的语法或语义违规,编译器将直接终止后续的代码生成,绝不产出目标文件;warning 表示源码虽能勉强翻译为机器码,但在语义上极大概率违背了开发常理或潜藏风险;note 则是对上一条诊断的补充说明,本身不作为单独的裁定依据。
在告警信息的末尾,方括号中的 [-Wunused-variable] 明确标注了该诊断所属的编译选项开关。在 GCC 与 Clang 体系中,绝大多数警告均支持通过在选型名称前追加 no-(例如 -Wno-unused-variable)进行单独抑制,这是后续工程化定制警告策略的语法基础。
在诊断信息的下方,编译器会打印出对应的源代码切片。其中的 ^~~~ 符号形成了视觉指示线,插入符精确指向引发诊断的具体源码列。而在 'totl' undeclared 之后,编译器甚至通过符号表模糊距离匹配推断出了 did you mean 'total'? 的修正建议,并在源代码下方单独给出了修改参照。
参数 -fsyntax-only 指示编译器仅执行纯粹的前端语法与静态语义校验,完全跳过耗时的汇编代码生成与目标文件写入环节,在日常编码时非常适合用来快速复核语法合法性:
$ gcc -std=c17 -Wall -Wextra -fsyntax-only syntax.c
$ ls syntax.o
ls: cannot access 'syntax.o': No such file or directory2
3
如果源文件本身语法完全合规,控制台将保持完全静默,磁盘上也不会生成残留的 .o 临时文件。现代代码编辑器的语言服务器插件(如 clangd 或 ccls)在用户每次保存文件时,通常正是借助类似选项快速刷新行内诊断红线。
在编译遭遇密集报错时,务必养成先看最顶层第一条错误的排查习惯。由于语法解析器在遭遇严重语法缺失后往往会丢失上下文同步,后续连续打印的十数行报错绝大多数只是第一条错误的连锁反应。例如,漏写一个分号所引发的连环车祸:
#include <stdio.h>
int main(void) {
int count = 0
for (int i = 0; i < 3; i++) {
count += i;
}
printf("%d\n", count);
return 0;
}2
3
4
5
6
7
8
9
10
$ gcc -std=c17 -Wall -Wextra -fsyntax-only missing_semicolon.c
missing_semicolon.c: In function 'main':
missing_semicolon.c:5:5: error: expected ',' or ';' before 'for'
5 | for (int i = 0; i < 3; i++) {
| ^~~
missing_semicolon.c:5:21: error: 'i' undeclared (first use in this function)
5 | for (int i = 0; i < 3; i++) {
| ^
missing_semicolon.c:5:21: note: each undeclared identifier is reported only once for each function it appears in
missing_semicolon.c:5:31: error: expected ';' before ')' token
5 | for (int i = 0; i < 3; i++) {
| ^
| ;
missing_semicolon.c:5:31: error: expected statement before ')' token2
3
4
5
6
7
8
9
10
11
12
13
14
在上面的输出中,四条错误实际上都因第 5 行而起。第一条诊断直截了当地指出 for 关键字前面缺少一个分号;一旦将这个字符补齐,后续关于未声明标识符和语句结构的连锁报错便会随之消失。如果在排查时先去关注后几条看似严重的次生错误,往往会徒劳地修改正常的逻辑。
除了直接指出错误位置,现代 GCC 还具备给出代码修正补丁的能力。添加 -fdiagnostics-generate-patch 选项后,编译器会在完成常规诊断输出后,自动在末尾附加一段标准 unified diff 格式的补丁内容:
$ gcc -std=c17 -Wall -Wextra -fdiagnostics-generate-patch -fsyntax-only typo.c
typo.c: In function 'main':
typo.c:5:20: error: 'totl' undeclared (first use in this function); did you mean 'total'?
...
--- typo.c
+++ typo.c
@@ -2,6 +2,6 @@
int main(void) {
int total = 0;
- printf("%d\n", totl);
+ printf("%d\n", total);
return 0;
}2
3
4
5
6
7
8
9
10
11
12
13
14
上方展示的内容截取了原始输出的关键首尾部分,并用省略号隐去了中间冗长的上下文信息。
2. 警告选项
默认情况下,GCC 只会启用极少数可能导致编译中断的基本警告。为了让编译器帮助我们拦截更多潜在的缺陷,通常会借助编译选项来扩展检查范围。其中 -Wall 汇集了一整套广泛认可的基础代码质量检查,而 -Wextra 则在此基础上进一步补全了一些对代码风格和潜在隐患要求更为严苛的诊断规则。
为了直观体会这两组选项的拦截能力,我们可以通过一个刻意留有几处经典问题的代码示例进行对比观察:
#include <stdio.h>
static int classify(int value) {
switch (value) {
case 0:
puts("zero");
/* 这里漏了 break */
case 1:
puts("small");
break;
default:
puts("other");
break;
}
return value;
}
static int sum(const int *items, int n, int scale) {
int total = 0;
for (int i = 0; i < n; i++) {
total += items[i];
}
return total;
}
int main(int argc, char *argv[]) {
int items[] = {1, 2, 3};
size_t count = sizeof items / sizeof items[0];
int limit = argc;
long wide = sizeof(int);
int unused_local = 0;
int flag = 0;
if (flag = argc) {
puts("flag is nonzero");
}
printf("%d\n", wide);
if (count > limit) {
puts("more items than limit");
}
printf("%d\n", classify(0) + sum(items, 3, limit));
return 0;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
首先观察仅开启 -Wall 时的编译反馈:
$ gcc -std=c17 -Wall -c warn.c -o warn.o
warn.c: In function 'main':
warn.c:34:9: warning: suggest parentheses around assignment used as truth value [-Wparentheses]
34 | if (flag = argc) {
| ^~~~
warn.c:37:14: warning: format '%d' expects argument of type 'int', but argument 2 has type 'long int' [-Wformat=]
37 | printf("%d\n", wide);
| ~^ ~~~~
| | |
| int long int
| %ld
warn.c:31:9: warning: unused variable 'unused_local' [-Wunused-variable]
31 | int unused_local = 0;
| ^~~~~~~~~~~~2
3
4
5
6
7
8
9
10
11
12
13
14
控制台输出的三条警告分别揭示了三处截然不同的隐患:
if (flag = argc)误将相等判断==写成了赋值操作符=。-Wparentheses选项提醒开发者对此处添加一层括号(例如if ((flag = argc))),借此要求编写者明确确认这确实是有意为之的赋值行为。%d与long存在类型不匹配。在 64 位 Linux 平台下,long占据 8 字节,而%d仅按照 4 字节的int宽度解析参数。格式化占位符与传入实参类型的严格对应至关重要,此处应规范地写为%ld。- 未使用的本地对象
unused_local。声明之后从未参与任何运算的对象空占命名,容易对后续阅读和维护代码的人产生误导,让人怀疑背后是否存在遗漏的逻辑。
顺便值得一提的是,若将相同的源码放在 Windows 环境下的 MinGW-w64 GCC 13.2 中编译,这条格式警告可能会原样重复输出两次。部分工具链在特定构建组合下存在少量诊断去重不完全的现象,排查时视作单次告警处理即可。
进一步在编译命令中追加 -Wextra 选项:
$ gcc -std=c17 -Wall -Wextra -c warn.c -o warn.o
warn.c: In function 'sum':
warn.c:18:45: warning: unused parameter 'scale' [-Wunused-parameter]
18 | static int sum(const int *items, int n, int scale) {
| ~~~~^~~~~
warn.c: In function 'main':
warn.c:34:9: warning: suggest parentheses around assignment used as truth value [-Wparentheses]
34 | if (flag = argc) {
| ^~~~
warn.c:37:14: warning: format '%d' expects argument of type 'int', but argument 2 has type 'long int' [-Wformat=]
37 | printf("%d\n", wide);
| ~^ ~~~~
| | |
| int long int
| %ld
warn.c:38:15: warning: comparison of integer expressions of different signedness: 'size_t' {aka 'long unsigned int'} and 'int' [-Wsign-compare]
38 | if (count > limit) {
| ^
warn.c:31:9: warning: unused variable 'unused_local' [-Wunused-variable]
31 | int unused_local = 0;
| ^~~~~~~~~~~~
warn.c:26:26: warning: unused parameter 'argv' [-Wunused-parameter]
26 | int main(int argc, char *argv[]) {
| ~~~~~~^~~~~~
warn.c: In function 'classify':
warn.c:6:9: warning: this statement may fall through [-Wimplicit-fallthrough=]
6 | puts("zero");
| ^~~~~~~~~~~~
warn.c:8:5: note: here
8 | case 1:
| ^~~~2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
-Wextra 在此基础上额外捕获了三类更深层次的代码气味:
sum函数中的scale参数从未被读取,传入的实参被静默忽略。这类问题往往是重构接口时遗留下的废弃参数。count > limit将无符号类型size_t与有符号整数int混在一起比较。当limit取负值时,隐式类型转换会先将其转为极大的无符号数,导致判定结果与人类直觉完全相悖。classify中的case 0分支漏掉了break,执行流程会不受控地跌落穿透至case 1。- 至于
main函数中未用到的argv参数,虽然在实际业务中往往无伤大雅,但-Wunused-parameter并不会对其区别对待,同样会提出警示。
在 C 语言的日常开发中,-Wsign-compare(符号混淆比较)、-Wunused-parameter(未使用形参)以及 -Wimplicit-fallthrough(隐式分支穿透)在默认与 -Wall 下均不会触发,只有开启 -Wextra 后才会生效。这三项诊断规则也是项目中坚持开启 -Wextra 最充分的理由。
2.1 标准符合性与额外检查
-Wpedantic 专门用于检视代码是否严格遵从 ISO C 标准,它会揪出编译器默认允许但标准并未采纳的扩展语法(例如 GCC 特有的 GNU 扩展)。以零长度数组为例,GCC 在日常模式下会将其作为拓展特性静默放行,但在 ISO C 标准中这属于非法的类型定义:
#include <stdio.h>
int main(void) {
int values[0];
return (int)sizeof values;
}2
3
4
5
6
$ gcc -std=c17 -Wall -Wextra -Wpedantic -c zero_array.c -o zero_array.o
zero_array.c: In function 'main':
zero_array.c:4:9: warning: ISO C forbids zero-size array 'values' [-Wpedantic]
4 | int values[0];
| ^~~~~~2
3
4
5
如果追加 -pedantic-errors,编译器便会把所有标准背离警告一律升级为硬性编译错误,直接终止构建。在对代码可移植性要求极高、需要分发至各类专有编译器,或者参与严苛的行业标准合规评审时,直接启用 -pedantic-errors 能够从源头杜绝任何方言特性的渗入;而日常自主开发时,保持 -Wpedantic 即可提供足够清晰的合规参照。
除了标准遵从性,还有几组能捕获微妙缺陷但默认未包含在 -Wall 和 -Wextra 中的重要选项。我们将它们各自覆盖的经典问题汇集在下面的示例中:
#include <stdio.h>
int open_file();
int double_value(int x) {
return x * 2;
}
int open_file() {
int count = 1;
{
int count = 2;
printf("%d\n", count);
}
int values[count];
for (int i = 0; i < count; i++) {
values[i] = i;
}
return values[0];
}
static void log_value(const char *fmt, int value) {
printf(fmt, value);
}
int main(void) {
log_value("%d\n", double_value(open_file()));
return 0;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
编译结果:
$ gcc -std=c17 -Wall -Wextra -Wshadow -Wformat=2 -Wmissing-prototypes \
-Wstrict-prototypes -Wvla -c warn_more.c -o warn_more.o
warn_more.c:3:1: warning: function declaration isn't a prototype [-Wstrict-prototypes]
3 | int open_file();
| ^~~
warn_more.c:5:5: warning: no previous prototype for 'double_value' [-Wmissing-prototypes]
5 | int double_value(int x) {
| ^~~~~~~~~~~~
warn_more.c:9:5: warning: function declaration isn't a prototype [-Wstrict-prototypes]
9 | int open_file() {
| ^~~~~~~~~
warn_more.c: In function 'open_file':
warn_more.c:12:13: warning: declaration of 'count' shadows a previous local [-Wshadow]
12 | int count = 2;
| ^~~~~
warn_more.c:10:9: note: shadowed declaration is here
10 | int count = 1;
| ^~~~~
warn_more.c:15:5: warning: ISO C90 forbids variable length array 'values' [-Wvla]
15 | int values[count];
| ^~~
warn_more.c: In function 'log_value':
warn_more.c:23:5: warning: format not a string literal, argument types not checked [-Wformat-nonliteral]
23 | printf(fmt, value);
| ^~~~~~2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
这些选项分别从不同维度填补了静态防线:
-Wmissing-prototypes:检出拥有外部链接属性却未在头文件中预先声明的全局函数(如double_value)。如果某个函数未在头文件中声明原型,其他翻译单元在调用时便只能依赖隐式假设,实参与形参类型是否匹配也失去编译器静态校验,链接阶段同样不会进行签名校验。遇到该警告时,若该函数仅限于本文件内部使用,应声明为static;若确实需要对外暴露,则应在对应模块的公共头文件中显式写明原型,我们在第 6 章中曾详细讨论过这一接口边界设计准则。-Wstrict-prototypes:要求函数原型必须严格指明形参类型。在 C 语言历史语法中,空参数列表int open_file()并不代表无参数,而是表示「参数列表未知且不作检查」,符合现代标准的无参声明必须明确写为int open_file(void)。-Wshadow:捕捉内层局部作用域对外部同名对象的遮蔽(shadowing)。内层块中重新定义的count会掩盖外层的count,容易让阅读者混淆当前操作的对象上下文,引发逻辑分歧。直接重命名内层对象即可化解这一歧义。-Wformat=2:在基础格式化检查之上,进一步限制格式字符串必须是字面量常量。示例中的log_value将格式串作为动态指针传入,导致编译器在编译期无法推导后续变长参数的实际类型是否与占位符匹配。这不仅会绕过类型安全检查,在处理不受信任的输入时还会埋下格式化字符串漏洞的隐患。-Wvla:拦截变长数组(Variable Length Array)。变长数组在运行时根据动态长度在栈帧上分配空间,一旦输入的尺寸超出预期,容易直接引发栈溢出崩溃,因此在追求高可靠性与资源可控的工程环境(特别是嵌入式系统)中往往被明令禁用。
此外,针对数值计算中隐蔽的截断风险,-Wconversion 能够主动监控可能导致数值截断、符号改变或精度损失的隐式类型转换:
$ gcc -std=c17 -Wall -Wextra -Wconversion -c conversion.c -o conversion.o
conversion.c: In function 'main':
conversion.c:5:13: warning: conversion from 'size_t' {aka 'long unsigned int'} to 'int' may change value [-Wconversion]
5 | int n = strlen(argv[0]);
| ^~~~~~
conversion.c:6:27: warning: conversion from 'int' to 'unsigned char' may change value [-Wconversion]
6 | unsigned char small = argc + 300;
| ^~~~2
3
4
5
6
7
8
在庞大的既有工程中直接开启 -Wconversion 往往会引发海量的警报输出,其中多数可能属于开发者已知安全的数值窄化。在实际工程落地时,更稳妥的做法是将其设为新开发模块的基准门禁;对于既有历史模块,则结合代码重构以模块为单位逐一收敛修复,避免单次引入过大的改动风险。
2.2 把警告当错误
启用 -Werror 选项后,编译器会将所有警告一律升级为硬性错误,任何一处未消除的警告都会直接中断编译进程:
$ gcc -std=c17 -Wall -Wextra -Werror -c warn.c -o warn.o
warn.c: In function 'sum':
warn.c:18:45: error: unused parameter 'scale' [-Werror=unused-parameter]
...
warn.c:6:9: error: this statement may fall through [-Werror=implicit-fallthrough=]
...
cc1: all warnings being treated as errors2
3
4
5
6
7
这一机制的核心价值在于守住工程代码库的底线,防止新增的潜在隐患被开发者无意中忽略:只要产生任何一条警告,构建过程就会立即中止,从而倒逼代码提交前完成清理。
在工程实践中,-Werror 通常建议配置在开发者的本地调试构建以及自动化持续集成(CI)流水线中,而不要轻易硬编码在面向终端用户的发布源码包或构建脚本里。这是因为不同的用户可能会采用不同版本乃至不同厂商的编译器,编译器版本的小幅迭代往往会引入新的、更严格的诊断规则;若在发布包中开启 -Werror,很可能会因为新版编译器对某处无害代码报出额外警告而直接导致用户的构建中断,而终端用户通常缺乏修改第三方源码的背景与意愿。我们在第 18 章搭建持续集成流水线时,就会通过 CI 专用的编译配置将其启用。
在 Windows 平台使用 MSVC 工具链时,与之相对应的严苛警告级别通常为 /W4(其告警编号形如 C4100),而将警告升级为错误的对应选项则是 /WX。针对不同编译平台与工具链自适应配置警告参数的具体方法,将在第 16 章深入阐述。
关于警告选项,下列说法正确的是哪一个?
3. 静态分析
常规的编译器警告往往只聚焦于单个表达式或单个语句的局部句法结构。然而在真实的业务逻辑中,大量内存与资源隐患深藏于复杂的控制流分支里。自 GCC 10 起引入的深层静态分析器(通过 -fanalyzer 开启),能够在编译期沿函数的控制流图(Control Flow Graph)展开符号执行,全面追踪指针生命周期与系统资源(如文件描述符、堆内存句柄)在各条执行路径上的流向。它能精准拦截那些跨语句、跨分支甚至跨深层错误处理的隐藏漏洞——例如在某个罕见的异常退出分支中,打开的 FILE * 指针未被关闭即直接返回。
以 wordfreq 工程为例,假设我们在 app/main.c 中扩展一个通用的整文件读取函数 load_file:通过 fopen 打开磁盘文件,利用 fseek 与 ftell 获取实际大小,随后分配动态缓冲区并用 fread 完成读取。在最初的实现中,我们在 fseek 失败的异常处理分支留下一处疏漏,未调用 fclose 便草率返回:
static int load_file(const char *path, char **out, size_t *len) {
FILE *in = fopen(path, "rb");
if (in == NULL) {
return -1;
}
if (fseek(in, 0, SEEK_END) != 0) {
/* 缺陷:这里直接返回,没有 fclose(in) */
return -1;
}
long size = ftell(in);
/* ... */
}2
3
4
5
6
7
8
9
10
11
12
13
$ gcc -std=c17 -Wall -Wextra -fanalyzer -c bugs/main_load_file_leak.c
bugs/main_load_file_leak.c: In function 'load_file':
bugs/main_load_file_leak.c:30:16: warning: leak of FILE 'in' [CWE-775] [-Wanalyzer-file-leak]
30 | return -1;
| ^
'load_file': events 1-7
|
| 23 | FILE *in = fopen(path, "rb");
| | ^~~~~~~~~~~~~~~~~
| | |
| | (1) opened here
| 24 | if (in == NULL) {
| | ~
| | |
| | (2) assuming 'in' is non-NULL
| | (3) following 'false' branch (when 'in' is non-NULL)...
|......
| 28 | if (fseek(in, 0, SEEK_END) != 0) {
| | ~~~~~~~~~~~~~~~~~~~~~~~
| | ||
| | |(4) ...to here
| | (5) following 'true' branch...
| 29 | /* 缺陷:这里直接返回,没有 fclose(in) */
| 30 | return -1;
| | ~
| | |
| | (6) ...to here
| | (7) 'in' leaks here; was opened at (1)
|
bugs/main_load_file_leak.c:30:16: warning: leak of 'in' [CWE-401] [-Wanalyzer-malloc-leak]
'load_file': events 1-7
|
| 23 | FILE *in = fopen(path, "rb");
| | ^~~~~~~~~~~~~~~~~
| | |
| | (1) allocated here
...
| | (7) 'in' leaks here; was allocated at (1)
|2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
这份诊断报告的核心在于按时序展开的事件路径(编号从 (1) 到 (7))。分析器清晰还原了整个推演过程:资源在何处申请打开、遇到条件判定时假定了哪条分支流向、最终又在何处离开作用域导致句柄彻底失联。在审阅此类复杂路径时,高效的做法是先定位触发警报的代码行与最后一步事件,再逆向回溯整条推演链条,核验其前提条件在实际业务中是否真的成立。输出中附带的 CWE-775(Common Weakness Enumeration)为国际通用的软件缺陷分类标识,方便对接安全合规审计。
此外,在 GCC 14.2 等较新版本中,编译器还会连带抛出一条 [-Wanalyzer-malloc-leak] 告警,这是因为底层实现将 fopen 创建的 FILE 结构同样纳入了堆内存生命周期的追踪体系。两条诊断本质上对应同一处资源遗漏,修复该分支的关闭逻辑即可同时平息两条警报;而在 GCC 13.2 等稍早版本中则仅输出第一条文件泄漏提示。
值得清醒认识的是,GCC 官方文档明确指出了静态分析器在理论上既非严格健全亦非完备(neither sound nor complete):它捕获的往往是高置信度的真实路径缺陷,但无法保证揪出所有潜在隐患。未报告问题绝不意味着代码绝对滴水不漏。在实际测试中,以下几种典型边界情况分析器往往会主动放弃追踪:
- 指针被赋值转存至具有文件作用域的全局或静态对象后,分析器不再继续追踪其全局生命周期;
- 指针作为实参传递给仅有函数原型声明、未在本翻译单元内提供函数体的外部接口后,追踪也会自然截断;
- 资源泄漏发生于
main函数正常返回退出的末尾时刻; - 当重构
load_file的签名,改为直接返回缓冲区指针char *并通过输出形参传出长度时,由于多重解引用与返回值的推导复杂度上升,某些编译器版本可能对同一条fseek异常分支停止告警。
针对前述缺陷,规范的重构策略是遵循我们在第 14.5 节中介绍的错误清理模式,将释放资源的收尾动作统一收归到函数末尾的跳转标签下:
static int load_file(const char *path, char **out, size_t *len) {
FILE *in = fopen(path, "rb");
if (in == NULL) {
return -1;
}
char *buf = NULL;
int status = -1;
if (fseek(in, 0, SEEK_END) != 0) {
goto done;
}
long size = ftell(in);
if (size < 0) {
goto done;
}
rewind(in);
buf = malloc((size_t)size + 1);
if (buf == NULL) {
goto done;
}
size_t got = fread(buf, 1, (size_t)size, in);
buf[got] = '\0';
*out = buf;
*len = got;
buf = NULL; /* 所有权已交给调用方,done 里不能再释放 */
status = 0;
done:
free(buf);
fclose(in);
return status;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
在重构后的实现中,若数据成功读取,我们在将缓冲区交接给调用方指针后,立即将本地指针 buf 显式重置为 NULL;而在末尾的 done 标签处,free(NULL) 依据 C 语言标准属于合法的安全空操作。如此一来,无论走向成功还是遇到任意中间步骤失败,资源释放路径高度统一。重新使用 -fanalyzer 进行分析,控制台彻底恢复了平静。完整的重构代码见 bugs/load_file_fixed.c。
#include <stdlib.h>
/* 分配之后只在函数内部使用 */
static int leak(int n) {
char *buf = malloc(32);
if (buf == NULL) {
return -1;
}
if (n > 0) {
return 0; /* 报告 malloc-leak */
}
free(buf);
return 0;
}2
3
4
5
6
7
8
9
10
11
12
13
14
#include <stdlib.h>
void sink(void *p); /* 实现在另一个 .c 中 */
/* 指针交给外部函数之后,分析器不再跟踪 */
static int leak(int n) {
char *buf = malloc(32);
if (buf == NULL) {
return -1;
}
sink(buf);
if (n > 0) {
return 0; /* 不报告 */
}
free(buf);
return 0;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
与分支复杂的泄漏检测相比,静态分析器在捕捉“释放后使用”(use-after-free)这类明确的时序违规时表现得更为敏锐果断。当一块内存经由 free 释放之后,紧接着若有代码再次解引用该指针,不仅常规警告选项中的 -Wuse-after-free 会立即响应,静态分析器的 -Wanalyzer-use-after-free 也会同步跟进,并给出带有分配与释放时序的完整调用证据:
$ gcc -std=c17 -Wall -Wextra -fanalyzer -c uaf.c
uaf.c: In function 'main':
uaf.c:11:5: warning: pointer 'p' used after 'free' [-Wuse-after-free]
11 | printf("%d\n", *p);
| ^~~~~~~~~~~~~~~~~~
uaf.c:10:5: note: call to 'free' here
10 | free(p);
| ^~~~~~~
uaf.c:11:5: warning: use after 'free' of 'p' [CWE-416] [-Wanalyzer-use-after-free]
11 | printf("%d\n", *p);
| ^~~~~~~~~~~~~~~~~~
'main': events 1-6
|
| 5 | int *p = malloc(sizeof *p);
| | ^~~~~~~~~~~~~~~~~
| | |
| | (1) allocated here
...
| 10 | free(p);
| | ~~~~~~~
| | |
| | (5) freed here
| 11 | printf("%d\n", *p);
| | ~~~~~~~~~~~~~~~~~~
| | |
| | (6) use after 'free' of 'p'; freed at (5)
|2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
3.1 cppcheck
与紧密嵌入编译流程的分析器不同,cppcheck 是一款独立的轻量级静态分析工具。它无需调用具体的编译前端,而是直接解析 C 源码的抽象结构,专门用于挖掘未初始化对象的读取、越界下标访问、动态资源遗失以及冗余的无效赋值等常见隐患。我们先尝试对干净的 wordfreq 工程执行一次常规扫描:
$ cppcheck --enable=warning,style --std=c11 -I include src app
Checking src/counter.c ...
1/4 files checked 38% done
Checking src/report.c ...
2/4 files checked 40% done
Checking src/token.c ...
3/4 files checked 51% done
Checking app/main.c ...
4/4 files checked 100% done2
3
4
5
6
7
8
9
在基准工程中,cppcheck 顺利通过,未引发任何警报。接下来将目标切换至本章刻意注入缺陷的文件,cppcheck 给出的判断与 GCC 静态分析器的结论高度一致:
$ cppcheck --enable=warning,style --std=c11 bugs/main_load_file_leak.c
Checking bugs/main_load_file_leak.c ...
bugs/main_load_file_leak.c:30:9: error: Resource leak: in [resourceLeak]
return -1;
^2
3
4
5
在上述调用命令中,--enable=warning,style 启用了告警与风格两类检测:前者聚焦于明确的代码缺陷,后者用于提示不易察觉的风格隐患。--std=c11 指定了语法解析遵从的 ISO C 标准版本,而 -I include 则补全了头文件的搜索目录。需要理解的是,纯静态模式很难洞悉复杂的内存物理排布(例如 dup_string 中由于长度计算疏漏导致少分配 1 字节),它构成了代码质检的第一道漏斗,但绝无法完全替代运行期的内存动态检测。
此外,针对 Windows 环境使用 WinLibs 分发的 cppcheck 2.14 时有一处环境细节需要留意:cppcheck.exe 启动时默认要求在同级相对路径下找到 share/Cppcheck/cfg 规则目录,若目录缺失便会抛出 Failed to load library configuration file 'std.cfg' 错误。此时只需将解压包中的 share/Cppcheck 目录完整拷贝至可执行文件同级目录即可正常运行。
3.2 clang-tidy
clang-tidy 构建在 Clang 强大的 AST(抽象语法树)与静态分析引擎之上,提供了极其丰富的可插拔规则库,诊断项通常按领域前缀划分(例如关注潜在代码缺陷的 bugprone-*、移植自 Clang 静态分析器的 clang-analyzer-* 等)。为了让其准确理解宏展开与头文件引用,clang-tidy 需要获取与编译时完全一致的参数选项。在实际项目中,最顺畅的集成方式是直接读取 CMake 构建生成的 compilation database(即 compile_commands.json,我们在第 9 章中已开启过 CMAKE_EXPORT_COMPILE_COMMANDS 选项):
$ clang-tidy -p build src/counter.c -checks='-*,bugprone-*,clang-analyzer-*'
56 warnings generated.
C:\work\ch13\wordfreq\src\counter.c:28:9: warning: Call to function 'memcpy' is insecure ...
[clang-analyzer-security.insecureAPI.DeprecatedOrUnsafeBufferHandling]
C:\work\ch13\wordfreq\src\counter.c:112:28: warning: 2 adjacent parameters of
'compare_entries' of similar type ('const void *') are easily swapped by mistake
[bugprone-easily-swappable-parameters]
C:\work\ch13\wordfreq\src\counter.c:137:5: warning: Call to function 'memcpy' is insecure ...
[clang-analyzer-security.insecureAPI.DeprecatedOrUnsafeBufferHandling]
Suppressed 53 warnings (53 in non-user code).2
3
4
5
6
7
8
9
10
参数中的 -checks='-*,bugprone-*,clang-analyzer-*' 采用了先以 -* 禁用全部默认检查、再精细激活目标组的白名单策略;-p build 则明确指定了存放 compile_commands.json 的目录。末尾被抑制的 53 条诊断全部源自系统底层头文件,被工具自动过滤。
仔细甄别输出中的三条警告,它们并不属于实际代码缺陷:
- 前后两条关于
memcpy的警告来自 Clang 安全分析规则,提示其存在缓冲区溢出风险并建议替换为带边界检查的memcpy_s。但memcpy_s仅为 C11 标准的可选附录(Annex K)扩展,主流的 glibc 与 MinGW-w64 均未提供支持,盲目替换反而会破坏可移植性。 compare_entries提示两个相邻的const void *形参可能在调用时不慎颠倒,但该签名受限于标准库qsort回调函数的法定接口规范,业务无法擅自变更。因此这类诊断无需盲目修改业务实现。
当我们将分析目标对准包含真实隐患的源文件时,clang-tidy 展现出了敏锐的排查深度:
$ clang-tidy realloc_uaf.c -checks='-*,bugprone-*,clang-analyzer-*' -- -std=c17
C:\work\ch13\realloc_uaf.c:28:20: warning: 'c->items' may be set to null if 'realloc'
fails, which may result in a leak of the original buffer
[bugprone-suspicious-realloc-usage]
C:\work\ch13\realloc_uaf.c:39:5: warning: Call to function 'strcpy' is insecure ...
CWE-119 [clang-analyzer-security.insecureAPI.strcpy]
C:\work\ch13\realloc_uaf.c:49:20: warning: Potential leak of memory pointed to by
'c.items' [clang-analyzer-unix.Malloc]2
3
4
5
6
7
8
输出中的第一条诊断直击要害:c->items = realloc(...) 将重分配的结果直接写回了原指针对象。一旦系统内存耗尽导致 realloc 返回空指针,原先所指向的旧缓冲区便瞬间陷入内存泄漏的困境。回顾 wordfreq 中 reserve_one 的实现,我们正是通过引入临时指针接收返回值、并在确认非空后再更新原始字段,从而杜绝了这一高危隐患。相关缺陷用例保存在 bugs/realloc_uaf.c。
工程中应对工具误报的健康心态是优先审视代码表达。凡是能通过更清晰的编码意图(例如显式类型转换、提取具名语义函数、收敛指针作用域等)来平息的告警,应积极重构消除;若确属工具无法理解的标准库强制约定(例如 qsort 回调签名),则应在项目的 .clang-tidy 配置文件中为特定规则配置局部例外,并附注豁免原因。切忌因为几条误报就粗暴关闭整个规则集合,否则无异于因噎废食,将真正的隐患一并屏蔽。
4. AddressSanitizer
前面介绍的静态分析工具与编译警告主要立足于静态文本和语法结构推演。然而,在 C 语言的底层世界中,有相当一部分内存破坏隐患只有在程序实际运转、数据进入内存网格时才会动态浮现。典型如经典的一字节堆越界(off-by-one):即使物理上多写了一个字节,若恰好落入堆管理器为了内存对齐而预留的填充垫片中,程序可能在数万次测试中表现得毫无异常,直到在生产环境特定内存压力下突然崩溃。
为了从根本上解决这类幽灵缺陷,Google 提出的 AddressSanitizer(简称 ASan)采用了“编译期插桩 + 运行时影子内存(Shadow Memory)监控”的混合架构。在编译阶段,ASan 会在每一次内存读取与写入指令之前插入微小的合法性校验桩代码;而在运行时,它将进程虚拟地址空间的大致八分之一划分为专用的影子内存区域,精确记录每一个应用字节的可访问状态。一旦读写越界落入不可访问的保护隔离区(Redzone),插桩代码便会当场拦截并输出详尽的崩溃证据。
为了观察其敏锐的捕获能力,我们在 wordfreq 的 src/counter.c 中故意植入一段存在一字节偏差的 dup_string 实现:
/* 缺陷:少分配一个字节,memcpy 时写到缓冲区之外 */
static char *dup_string(const char *s) {
size_t len = strlen(s);
char *copy = malloc(len);
if (copy != NULL) {
memcpy(copy, s, len + 1);
}
return copy;
}2
3
4
5
6
7
8
9
代码中 malloc(len) 疏忽了字符串末尾必须携带的空终止字符 '\0',仅按裸字符数分配;而紧随其后的 memcpy 却拷贝了 len + 1 字节。若在普通的构建模式下编译并运行既有的自动化单元测试:
$ cp ../bugs/counter_dup_off_by_one.c src/counter.c
$ cmake -S . -B build
$ cmake --build build
$ ctest --test-dir build
Test project /home/minsecrus/tutorial/work/p055/ch13/wordfreq/build
Start 1: token
1/3 Test #1: token ............................ Passed 0.00 sec
Start 2: counter
2/3 Test #2: counter .......................... Passed 0.00 sec
Start 3: e2e
3/3 Test #3: e2e .............................. Passed 0.01 sec
100% tests passed, 0 tests failed out of 32
3
4
5
6
7
8
9
10
11
12
13
测试结果竟然显示全部通过!这是因为多写入的那一个空字符碰巧踩在堆内存分配器的填充缝隙中,尚未破坏临近的关键元数据。然而隐患一旦进入复杂系统,便随时可能演变为致命的内存撕裂。
现在我们开启针对 Sanitizer 的独立构建配置,配合 Debug 模式(-g 保留调试符号以输出精准的文件名与行号)重新执行测试程序:
$ cmake -S . -B build-asan -DWF_SANITIZE=ON -DCMAKE_BUILD_TYPE=Debug
$ cmake --build build-asan
$ ./build-asan/test_counter
=================================================================
==1668653==ERROR: AddressSanitizer: heap-buffer-overflow on address 0x502000000013 ...
WRITE of size 4 at 0x502000000013 thread T0
#0 0x7fbd24af27ee in memcpy ../../../../src/libsanitizer/sanitizer_common/...
#1 0x561637882a26 in dup_string .../src/counter.c:27
#2 0x56163788341c in wf_counter_add .../src/counter.c:91
#3 0x5616378813dc in test_add_and_get .../tests/test_counter.c:12
#4 0x561637882597 in main .../tests/test_counter.c:64
0x502000000013 is located 0 bytes after 3-byte region [0x502000000010,0x502000000013)
allocated by thread T0 here:
#0 0x7fbd24af4c57 in malloc ../../../../src/libsanitizer/asan/asan_malloc_linux.cpp:69
#1 0x5616378829d1 in dup_string .../src/counter.c:25
#2 0x56163788341c in wf_counter_add .../src/counter.c:91
#3 0x5616378813dc in test_add_and_get .../tests/test_counter.c:12
SUMMARY: AddressSanitizer: heap-buffer-overflow ... in memcpy2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
ASan 的崩溃报告条理极为清晰,建议按如下三个维度逐步切入:
- 异常类型与访问规格:首行直接定性为堆缓冲区溢出(
heap-buffer-overflow),并给出了具体的内存物理地址。紧随其后的WRITE of size 4说明程序试图执行一次 4 字节的写操作。结合后续上下文,这里正在拷贝包含 3 个英文字母的"the"及其末尾空字符(合计 4 字节),而系统实际为其分配的空间仅有 3 字节。 - 事故现场调用栈(Where):第一段调用栈清晰还原了发生越界写的那一瞬间。从入口测试用例,逐层深入到
wf_counter_add,最终停留在counter.c:27的memcpy调用上。 - 源头分配调用栈(Where Allocated):在
allocated by thread T0 here:之后,第二段调用栈精准指认了这块受害内存最初是在何处申请的——同样顺藤摸瓜回溯到了counter.c:25的malloc调用。 - 相对拓扑定位:两段调用栈之间的一句话极为精炼:
0 bytes after 3-byte region。它明确告知开发者:发生非法写入的地址,紧挨着那块原本合法的 3 字节缓冲区的正后方,偏移量刚好为 0 字节。
报告底部还附带有一张影子字节映射表(Shadow bytes),利用诸如 fa(堆左侧保护区红区)、03(仅前 3 字节合法访问)等编码详细描绘了故障点周边的内存状态。日常定位代码漏洞时,查阅调用栈与 SUMMARY 总结信息便足以完成根因分析;影子字节的底层图景则主要在深度排查自定义分配器或内存覆写时发挥作用。
完整报告连同影子字节表在下面的折叠块里。
完整的 ASan 报告(Debian 13,GCC 14.2)
=================================================================
==1668653==ERROR: AddressSanitizer: heap-buffer-overflow on address 0x502000000013 at pc 0x7fbd24af27ef bp 0x7ffc788c70f0 sp 0x7ffc788c68b0
WRITE of size 4 at 0x502000000013 thread T0
#0 0x7fbd24af27ee in memcpy ../../../../src/libsanitizer/sanitizer_common/sanitizer_common_interceptors_memintrinsics.inc:115
#1 0x561637882a26 in dup_string /home/minsecrus/tutorial/work/p055/ch13/wordfreq/src/counter.c:27
#2 0x56163788341c in wf_counter_add /home/minsecrus/tutorial/work/p055/ch13/wordfreq/src/counter.c:91
#3 0x5616378813dc in test_add_and_get /home/minsecrus/tutorial/work/p055/ch13/wordfreq/tests/test_counter.c:12
#4 0x561637882597 in main /home/minsecrus/tutorial/work/p055/ch13/wordfreq/tests/test_counter.c:64
#5 0x7fbd24035ca7 (/lib/x86_64-linux-gnu/libc.so.6+0x29ca7) (BuildId: c495b62edadd6c356265942ec1282d98058a7b41)
#6 0x7fbd24035d64 in __libc_start_main (/lib/x86_64-linux-gnu/libc.so.6+0x29d64) (BuildId: c495b62edadd6c356265942ec1282d98058a7b41)
#7 0x5616378811e0 in _start (/home/minsecrus/tutorial/work/p055/ch13/wordfreq/build-asan/test_counter+0x51e0) (BuildId: 1f8b4aa4ec4c7104e9dd4233a30aca0d4a893077)
0x502000000013 is located 0 bytes after 3-byte region [0x502000000010,0x502000000013)
allocated by thread T0 here:
#0 0x7fbd24af4c57 in malloc ../../../../src/libsanitizer/asan/asan_malloc_linux.cpp:69
#1 0x5616378829d1 in dup_string /home/minsecrus/tutorial/work/p055/ch13/wordfreq/src/counter.c:25
#2 0x56163788341c in wf_counter_add /home/minsecrus/tutorial/work/p055/ch13/wordfreq/src/counter.c:91
#3 0x5616378813dc in test_add_and_get /home/minsecrus/tutorial/work/p055/ch13/wordfreq/tests/test_counter.c:12
#4 0x561637882597 in main /home/minsecrus/tutorial/work/p055/ch13/wordfreq/tests/test_counter.c:64
#5 0x7fbd24035ca7 (/lib/x86_64-linux-gnu/libc.so.6+0x29ca7) (BuildId: c495b62edadd6c356265942ec1282d98058a7b41)
SUMMARY: AddressSanitizer: heap-buffer-overflow ../../../../src/libsanitizer/sanitizer_common/sanitizer_common_interceptors_memintrinsics.inc:115 in memcpy
Shadow bytes around the buggy address:
0x501ffffffd80: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
0x501ffffffe00: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
0x501ffffffe80: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
0x501fffffff00: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
0x501fffffff80: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
=>0x502000000000: fa fa[03]fa fa fa fa fa fa fa fa fa fa fa fa fa
0x502000000080: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
0x502000000100: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
0x502000000180: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
0x502000000200: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
0x502000000280: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
Shadow byte legend (one shadow byte represents 8 application bytes):
Addressable: 00
Partially addressable: 01 02 03 04 05 06 07
Heap left redzone: fa
Freed heap region: fd
Stack left redzone: f1
Stack mid redzone: f2
Stack right redzone: f3
Stack after return: f5
Stack use after scope: f8
Global redzone: f9
Global init order: f6
Poisoned by user: f7
Container overflow: fc
Array cookie: ac
Intra object redzone: bb
ASan internal: fe
Left alloca redzone: ca
Right alloca redzone: cb2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
随后将包含缺陷的代码重新放入 ctest 批量测试套件中验证:
$ ctest --test-dir build-asan
Test project /home/minsecrus/tutorial/work/p055/ch13/wordfreq/build-asan
Start 1: token
1/3 Test #1: token ............................ Passed 0.02 sec
Start 2: counter
2/3 Test #2: counter ..........................***Failed 0.03 sec
Start 3: e2e
3/3 Test #3: e2e ..............................***Failed 0.03 sec
33% tests passed, 2 tests failed out of 32
3
4
5
6
7
8
9
10
可以看到,counter 单元测试与 e2e 端到端集成测试均被敏锐拦截并标记失败,唯有未涉及字典操作的 token 测试独立通过。这正是将 Sanitizer 深度融入日常工程测试的巨大价值所在:我们无需为每种内存边界异常单独编写繁复的断言逻辑,只要在日常运行既有自动化测试套件(正如第 11 章所搭建的工程测试)时开启插桩,测试用例便自动升级为了严密的内存安全哨兵。
4.1 LeakSanitizer
在内存安全检查中,动态内存泄漏的排查同样属于重中之重。LeakSanitizer(简称 LSan)专门负责在程序生命周期结束时,对堆内存中尚未释放的分配节点进行遍历溯源。在主流的 Linux 与 macOS 环境下,当启用 -fsanitize=address 时,LSan 默认处于自动联动的激活状态:
$ gcc -std=c17 -Wall -Wextra -g -fsanitize=address -o leak leak.c
$ ./leak
=================================================================
==1717893==ERROR: LeakSanitizer: detected memory leaks
Direct leak of 64 byte(s) in 1 object(s) allocated from:
#0 0x7f1607ef4c57 in malloc ../../../../src/libsanitizer/asan/asan_malloc_linux.cpp:69
#1 0x563d3eb3f1aa in main /home/minsecrus/tutorial/work/p055/ch13/demo/leak.c:5
#2 0x7f1607c35ca7 (/lib/x86_64-linux-gnu/libc.so.6+0x29ca7) (BuildId: c495b62edadd6c356265942ec1282d98058a7b41)
SUMMARY: AddressSanitizer: 64 byte(s) leaked in 1 allocation(s).
$ echo $?
12
3
4
5
6
7
8
9
10
11
12
13
在极简的 leak.c 演示中,代码在调用 malloc(64) 之后未执行配对的 free 便直接退出。LSan 在进程退出前夕捕获了这处失联的内存对象,不仅准确汇报了泄漏的具体字节数与分配对象计数,还附带给出了最初发生分配操作的完整源码调用栈与具体行号,使资源泄漏的排查变得极为直接。
在默认行为下,一旦探测到未归还的内存分配,LSan 会将进程的最终退出码强行修改为非 0(如输出所示的 $? = 1),从而促使 CI 流水线或自动化测试脚本及时判定测试失败。若某些大型框架出于特定设计需要保留少量随进程生命周期自然消亡的长期单例缓存,开发者也可以在运行时通过环境变量局部关闭泄漏拦截:
$ ASAN_OPTIONS=detect_leaks=0 ./leak
wordfreq
$ echo $?
02
3
4
ASAN_OPTIONS 提供了极其丰富的运行时行为控制参数。除 detect_leaks 外,工程排查中常用的参数还包括 halt_on_error=1(在捕获首次非法访问后立即停止进程,避免后续连锁输出干扰判断)、abort_on_error=1(触发硬性 abort 终止,便于在崩溃瞬间生成 coredump 供 GDB 进一步现场还原)等。若需查看目标环境支持的完整参数清单,可在控制台中执行 ASAN_OPTIONS=help=1 ./prog 获取详细帮助。
4.2 编译选项与平台
为了让 AddressSanitizer 发挥出最佳的诊断效率并保证调用栈的完整性,工程推荐搭配如下编译选项组合:
-fsanitize=address -fno-omit-frame-pointer -g -O1参数解析:-g 确保二进制产物中注入完备的调试符号,以便将故障地址直接映射为易读的文件路径与行号;-fno-omit-frame-pointer 强制编译器在函数调用时保留基址指针寄存器(如 x86_64 下的 rbp),确保异常发生时能够展开最完整准确的回溯调用栈;-O1 则在关闭过度内联优化的同时提供合理的执行速度,如果使用纯调试级别的 -O0 同样可行,但在处理大规模测试集时运行耗时会显著放大。
跨平台工具链支持概况:
- Linux:GCC 与 Clang 原生提供完整支持,发行版自带的运行时库开箱即用;
- macOS:系统内置的 Apple Clang 完整内置 ASan 运行时;
- Windows 原生工具链:MSVC 自 Visual Studio 2019 起正式支持
/fsanitize=address,Clang for Windows 亦全面适配,在 Visual Studio 的 CMake 设置界面中勾选对应选项即可开启; - MinGW-w64 GCC:由于 Windows 动态链接运行时的特殊性,多数预编译的 MinGW-w64 发行版并未打包
libasan运行时库。直接传入-fsanitize=address虽然能通过编译前端,但在链接阶段通常会报出cannot find -lasan错误。在 Windows 下排查动态内存问题时,更建议使用 MSVC 或 LLVM Clang。
Sanitizer 只发现执行到的路径
AddressSanitizer 在每次内存访问处插入检查,只有程序真的执行到越界的那条语句才会报告。一份没被测试覆盖的代码,或者一个从未被调用的错误分支,Sanitizer 什么也发现不了。
Sanitizer 的这一动态特性深刻指明了其在工程质量体系中的定位:它捕获的崩溃均具备真实的破坏力,不存在任何虚假的猜忌;但如果某段脆弱的分支从未在自动化测试中被触发,Sanitizer 便完全无法感知其潜在风险。因此,我们在第 14 章将引入代码覆盖率与模糊测试(Fuzzing),主动探索深层执行路径,驱动更多冷门分支在插桩环境下运行。将 Sanitizer 构建接入测试体系,是为既有的测试用例赋予洞悉内存违规的火眼金睛,而绝不意味着可以取代测试用例本身的设计。
5. UndefinedBehaviorSanitizer
在 C 语言规范中,未定义行为(Undefined Behavior,简称 UB)是一类极其隐蔽却极具杀伤力的灰色地带。常见的未定义行为包括有符号整数溢出、移位位数超出或等于类型物理宽度、除以零以及对空指针进行解引用等。之所以称其凶险,是因为在特定的硬件平台或优化级别下,发生 UB 的代码往往不会立即表现为段错误崩溃,甚至可能计算出看似“合乎直觉”的结果;但 ISO C 标准明确规定编译器在面对未定义行为时拥有不受任何约束的解释权——编译器完全有权假定未定义行为绝不会发生,从而在激进优化(如死代码消除、循环重组)时直接剔除相邻的关键分支,导致难以预料的灾难性后果。
为了在运行时敏锐拦截这些不合规的操作,GCC 与 Clang 提供了 UndefinedBehaviorSanitizer(简称 UBSan)。它通过在算术与位运算等敏感指令处嵌入运行时合法性断言,精准捕获每一次违规尝试。
我们准备两个小巧的独立用例进行观察。第一个用例用于制造有符号整数溢出:
#include <limits.h>
#include <stdio.h>
int main(int argc, char *argv[]) {
int value = INT_MAX;
value += argc;
printf("%d %s\n", value, argv[0]);
return 0;
}2
3
4
5
6
7
8
9
在这段代码中,参与加法运算的数值依赖于运行时的命令行参数个数 argc,这有效阻止了编译器在编译期直接对表达式进行常量折叠(Constant Folding)。第二个用例则用于演示超出位宽的非法移位操作:
#include <stdio.h>
int main(void) {
unsigned int shift = 40;
unsigned int value = 1u << shift;
printf("%u\n", value);
return 0;
}2
3
4
5
6
7
8
$ gcc -std=c17 -Wall -Wextra -g -fsanitize=undefined -o ub_overflow bugs/ub_overflow.c
$ ./ub_overflow
bugs/ub_overflow.c:11:11: runtime error: signed integer overflow: 2147483647 + 1 cannot be represented in type 'int'
-2147483648 ./ub_overflow
$ echo $?
0
$ gcc -std=c17 -Wall -Wextra -g -fsanitize=undefined -o ub_shift bugs/ub_shift.c
$ ./ub_shift
bugs/ub_shift.c:9:29: runtime error: shift exponent 40 is too large for 32-bit type 'unsigned int'
256
$ echo $?
02
3
4
5
6
7
8
9
10
11
12
观察终端输出,UBSan 打印的诊断信息结构与编译器的常规诊断十分相似:精确给出了文件名、行号、列号、醒目的 runtime error 标志以及清晰的操作细节阐述(例如指出 2147483647 + 1 超出了 int 的表示范围,或者提示移位量 40 对于 32 位的 unsigned int 而言过大)。
然而格外值得警惕的是:两次运行在打印出报错信息后,程序均若无其事地继续向下执行直至正常退出,最终的退出码依然是表示成功的 0,printf 甚至还输出了溢出回绕后的错误结果。这恰恰印证了未定义行为最具欺骗性的一面——在浅层测试中,它常常伪装成“正常运行”的假象。
在默认模式下,UBSan 采取“告警并继续执行”的宽容策略,便于开发者一次性摸排全局存在的未定义行为数量。但在接入自动化工程测试时,这种宽容会导致测试框架误以为一切正常。为了让测试真正起到卡点拦截的效果,必须追加 -fno-sanitize-recover=all 选项,强制进程在遭遇首个未定义行为时立即异常终止:
$ gcc -std=c17 -Wall -Wextra -g -fsanitize=undefined \
-fno-sanitize-recover=all -o ub_overflow_strict bugs/ub_overflow.c
$ ./ub_overflow_strict
bugs/ub_overflow.c:11:11: runtime error: signed integer overflow: 2147483647 + 1 cannot be represented in type 'int'
$ echo $?
12
3
4
5
6
终端返回的退出码 $? = 1 明确表明程序异常中止,此时 ctest 即可准确将该用例标记为失败。
与 ASan 类似,MinGW-w64 GCC 通常同样未随附完备的 UBSan 运行时库。如果在此类缺乏运行时的环境下必须检测 UB,一种折中方案是追加 -fsanitize-trap=all 选项。此时编译器不再链接冗长的诊断库,而是在判定违规时直接就地插入一条非法 CPU 指令(如 x86 架构下的 ud2),让操作系统在触发瞬间通过 SIGILL 信号立即强杀进程:
$ gcc -std=c17 -Wall -Wextra -g -fsanitize=undefined -fsanitize-trap=all \
-o ub_overflow_trap bugs/ub_overflow.c
$ ./ub_overflow_trap
bash: line 1: 80224 Illegal instruction ./ub_overflow_trap
$ echo $?
1322
3
4
5
6
采用 trap 模式虽然能够保证进程绝对不带病运行,但代价是彻底失去了友好的文本诊断日志,开发者只能感知到程序异常崩溃,却无法直接获知具体发生的行号与违规操作类别。因此,在 Windows 平台上开发和调试时,依然首选支持完整运行时诊断的 MSVC 或 LLVM Clang,MinGW 的 trap 模式通常仅作为简易的兜底手段。
关于这些工具的使用方式,下列说法正确的是哪些?
6. Valgrind
谈及 C 语言的运行时动态分析,Linux 平台上历史悠久的名将 Valgrind 是绕不开的经典工具。Valgrind 核心套件中最为熟知的是 Memcheck 组件,其功能与 AddressSanitizer 存在相当程度的重合:涵盖堆栈越界读写、释放后使用(UAF)、动态内存泄漏以及读取未初始化数据等。
但在实现哲学上,Valgrind 与 Sanitizer 截然不同:它不要求在编译阶段注入任何插桩代码,而是通过动态二进制翻译技术,在一个模拟的软件虚拟 CPU 上即时解析并接管目标程序的机器指令。因此,面对任何已编译就绪的原生 ELF 可执行文件,开发者无需源码重新构建,即可直接套用 Valgrind 启动监控:
$ valgrind --leak-check=full ./prog
==19182== Invalid write of size 4
==19182== at 0x804838F: f (test.c:6)
==19182== by 0x80483AB: main (test.c:11)
==19182== Address 0x1BA45050 is 0 bytes after a block of size 40 alloc'd
==19182== at 0x1B8FF5CD: malloc (vg_replace_malloc.c:130)
==19182== by 0x8048385: f (test.c:5)
==19182== by 0x80483AB: main (test.c:11)2
3
4
5
6
7
8
(上方输出示例截取自 Valgrind 官方快速入门文档中经典测试用例的运行日志,内存绝对物理地址随宿主环境而异。)
然而,这种完全基于指令即时翻译执行(JIT-like translation)的纯动态模拟机制伴随着昂贵的性能开销。按照 Valgrind 官方文档评估,Memcheck 通常会导致目标程序运行速度慢上 20 至 30 倍,内存占用量也会剧烈膨胀数倍。如果让包含密集计算的大型工程或数百个测试用例在 Valgrind 下完整跑完,往往需要忍受漫长的等待;相比之下,仅增加约 2 倍开销的 AddressSanitizer 在吞吐与迭代效率上具有压倒性优势。
因此,在拥有源码和自主构建控制权的现代化项目演进中,通常优先选择配合度更高、运行更为敏捷的 Sanitizer 系列工具。Valgrind 则更适合在如下特殊场景中大显身手:
- 仅持有第三方提供的裸二进制制品,缺乏重构编译的源码与完整工具链;
- 需要使用 Sanitizer 覆盖范围之外的专项分析套件(例如利用 Cachegrind 分析 CPU 缓存命中率与分支预测、使用 Callgrind 生成精细的函数调用开销图谱、借助 Helgrind 追踪多线程锁竞争与死锁风险等)。
需要注意的是,Valgrind 深度绑定 Linux 内核与 ELF 二进制规范,在 macOS 上的支持长年受限,且完全不原生支持 Windows 环境。
7. 组合进构建
在前面的章节中,为了逐一剖析底层机制,我们均直接使用裸命令行手工触发各类工具。但在实际的工业级团队协作中,不可能要求团队成员在日常编码中死记硬背这一长串晦涩的参数。将这些检查策略规范化地编织入构建系统,通过清晰的语义开关统一调度,才是工程化落地的高阶实践。
我们先将这几类质量工具的特性定位归纳为一张全景图:
| 工具 | 发现什么 | 代价 | 什么时候跑 |
|---|---|---|---|
警告(-Wall -Wextra 加补充项) | 可疑的表达式、类型不匹配、未使用名字 | 编译稍慢,几乎不影响运行 | 每次编译 |
-fanalyzer | 跨语句的资源泄漏、空指针、释放后使用 | 编译明显变慢,单个文件数秒 | 开发构建与 CI |
| cppcheck、clang-tidy | 未初始化读取、越界、可移植性问题、风格 | 独立运行,分钟级 | CI,或改动后手动跑 |
| ASan、LSan、UBSan | 运行时真实发生的越界、泄漏、未定义行为 | 运行慢约两倍,内存增加 | 开发构建的测试、CI |
在 wordfreq 项目的工程骨架中,我们在根目录 CMakeLists.txt 中抽象出两个直观的构建配置项:
option(WF_SANITIZE "Build with AddressSanitizer and UndefinedBehaviorSanitizer" OFF)
option(WF_WERROR "Treat warnings as errors" OFF)
set(WF_WARNINGS -Wall -Wextra -Wshadow -Wformat=2
-Wmissing-prototypes -Wstrict-prototypes -Wvla)2
3
4
5
当 WF_SANITIZE 被显式激活时,构建系统将为静态库、CLI 主程序以及自动化测试可执行目标同步注入相应的编译与链接参数:
if(WF_SANITIZE)
foreach(target wf wordfreq test_token test_counter)
target_compile_options(${target} PRIVATE
-fsanitize=address,undefined -fno-omit-frame-pointer)
target_link_options(${target} PRIVATE -fsanitize=address,undefined)
endforeach()
endif()2
3
4
5
6
7
Sanitizer 选项要同时出现在编译与链接命令中
-fsanitize=address(或 undefined)在编译时插入检查代码,在链接时把对应的运行库接进来。编译和链接两处缺一不可:少了编译选项,代码里没有检查;少了链接选项,检查代码引用的 __asan_* 等符号无从解析。
为什么必须在编译与链接两个环节同时追加参数?通过 --verbose 观察 CMake 展开后的底层真实命令调用,其协作分工便清晰可见:
$ cmake --build build-asan --verbose
[ 8%] Building C object CMakeFiles/wf.dir/src/counter.c.o
/usr/bin/cc -I/home/minsecrus/tutorial/work/p055/ch13/wordfreq/include -std=c17 \
-Wall -Wextra -Wshadow -Wformat=2 -Wmissing-prototypes -Wstrict-prototypes \
-Wvla -fsanitize=address,undefined -fno-omit-frame-pointer \
-MD -MT CMakeFiles/wf.dir/src/counter.c.o -MF CMakeFiles/wf.dir/src/counter.c.o.d \
-o CMakeFiles/wf.dir/src/counter.c.o -c .../src/counter.c
[ 91%] Linking C executable test_counter
/usr/bin/cc -fsanitize=address,undefined -Wl,--dependency-file=... \
CMakeFiles/test_counter.dir/tests/test_counter.c.o \
CMakeFiles/test_counter.dir/tests/check.c.o -o test_counter libwf.a2
3
4
5
6
7
8
9
10
11
在编译阶段(生成 .o 目标文件),-fsanitize=address,undefined 负责在机器指令中注入检查插桩代码;在最终的链接阶段(生成可执行文件),由于插桩代码大量引用了如 __asan_init、__asan_report_error 等运行库内部符号,必须由链接命令再次携带该参数,以指示驱动程序自动引入对应的支持库。若在手动组织构建时不慎遗漏了链接参数,链接器就会抛出海量的未解析符号报错:
$ gcc -std=c17 -Wall -Wextra -fsanitize=address -c dup_demo.c -o dup_demo.o
$ gcc dup_demo.o -o dup_demo_plain
/usr/bin/ld: dup_demo.o: in function `_sub_D_00099_0':
dup_demo.c:(.text+0xa2): undefined reference to `__asan_unregister_globals'
/usr/bin/ld: dup_demo.o: in function `_sub_I_00099_1':
dup_demo.c:(.text+0xad): undefined reference to `__asan_init'
/usr/bin/ld: dup_demo.c:(.text+0xb2): undefined reference to `__asan_version_mismatch_check_v8'
/usr/bin/ld: dup_demo.c:(.text+0xc6): undefined reference to `__asan_register_globals'
collect2: error: ld returned 1 exit status2
3
4
5
6
7
8
9
现代 CMake 提供的 target_link_options 命令正是优雅解决链接阶段参数传递的标准做法。
基于不同场景对严苛程度与执行性能的差异化诉求,推荐在工程实践中划分三种清晰的构建模式:
| 构建 | 打开的选项 | 用途 |
|---|---|---|
| 开发 | -Wall -Wextra -Wshadow -Wformat=2 -Wmissing-prototypes -Wstrict-prototypes -Wvla、WF_SANITIZE=ON | 本地写代码、跑测试 |
| CI | 开发的一组,再加 WF_WERROR=ON、-fanalyzer、clang-tidy | 每次推送自动检查 |
| 发布 | 只保留基本警告,或完全不打开警告 | 交给用户构建 |
在多配置管理上,推荐遵循我们在第 9 章确立的最佳实践——采用多目录并行架构(如 build-dev/、build-asan/、build-ci/、build-release/)。各构建目录独立拥有专属的 CMakeCache.txt 缓存,彼此互不污染。需要再次强调的是:携带 Sanitizer 插桩代码的调试二进制文件在内存占用与执行耗时上有额外负担,且强依赖宿主开发环境特定的运行时库,严禁将其直接打包作为生产环境的发布产物。
_FORTIFY_SOURCE:另一层编译期与运行期之间的检查
glibc 在标准 C 库层面提供了一套极其轻量、几乎零额外开销的运行时内存防护机制:_FORTIFY_SOURCE。当代码启用编译优化(-O1 或更高)并显式定义了 _FORTIFY_SOURCE=2(在现代 Linux 发行版中甚至逐步推广至 =3)时,系统头文件会通过内联替换,将 memcpy、strcpy、sprintf 等极易发生溢出的标准函数透明重定向至附带缓冲区上限检查的安全变体(如 __strcpy_chk)。当检测到固定的栈缓冲区被破坏穿透时,防护逻辑会当场拦截并终止进程。
#include <stdio.h>
#include <string.h>
int main(int argc, char *argv[]) {
char buf[8];
if (argc > 1) {
strcpy(buf, argv[1]);
printf("%s\n", buf);
}
return 0;
}2
3
4
5
6
7
8
9
10
11
若在 -O2 -D_FORTIFY_SOURCE=2 保护下向程序传入一个远超 8 字节的 22 字节长参数,控制台会立即输出清晰的 *** buffer overflow detected ***: terminated 告警,进程以状态码 134 干净利落地安全退出;而如果未定义该宏,程序则会继续踩坏栈帧返回地址,最终在函数退出时引发破坏性无法预测的通用段错误(退出状态 139)。
_FORTIFY_SOURCE 的检查范围严格局限于编译器在编译期能够静态推导确定物理尺寸的本地对象,其保护面远不及全方位的 AddressSanitizer,无法替代深层的 Sanitizer 测试体系。但鉴于其执行开销极低、且无需外挂沉重的专用运行时,非常适合作为基准安全防护网,长驻在面向生产环境的正式 Release 发布构建中。
8. 习题
8.1 巩固
code/开发与工程基础/13_编译器诊断与程序检查/demos/ 下有 typo.c 与 missing_semicolon.c 两个文件。对每个文件做三件事:
- 跑
gcc -std=c17 -Wall -Wextra -fsyntax-only,按「文件:行:列: 级别: 消息」的格式把每一条诊断拆开,指出哪几条是error、哪几条是note、哪几条是warning; missing_semicolon.c报了四条错误,全部指向第 5 行。补上缺的那一个字符再编一次,确认另外三条一起消失;说明第二、三条为什么是第一条的连锁反应;- 对
typo.c加-fdiagnostics-generate-patch,把输出的补丁段抄下来,用git apply打上,确认源码被改成了total。再对missing_semicolon.c生成一次补丁并应用,检查它改的位置是否正确——这一条容易出意外,务必实际运行后再说结论。
demos/warn.c 在 -Wall、-Wall -Wextra 下报出的警告数量不同。要求:
- 分别记录两种选项下每一条警告的选项名(方括号里的
-W...),按出现顺序列出; - 只被
-Wextra打开的有三项,说明每一项对应的代码问题是什么、不改会出什么错; - 逐个修改
warn.c,让它在-Wall -Wextra -Wshadow -Wformat=2 -Wmissing-prototypes -Wstrict-prototypes -Wvla -Wconversion -Werror下一条警告都没有,同时保持程序行为不变:运行修改前后的程序,用diff确认输出完全相同; - 其中
if (flag = argc)有两种消除方式:改成==,或者加一层括号保留赋值。两种改法的程序行为不同,说出各自的意图,并解释-Wparentheses为什么用「加括号」而不是「必须改成==」来表达这条建议。
demos/warn_more.c 用了 -Wshadow、-Wformat=2、-Wmissing-prototypes、-Wstrict-prototypes、-Wvla 五个选项。要求:
- 对每个选项写出它报出的那一条警告、对应的源码行,以及这条检查在防什么;
-Wvla报的是int values[count];。把count改成编译期常量之后这条警告消失,但程序的行为变了——说明变在哪里,为什么这不是「消除警告」而是「改变语义」;- 给
open_file补一个正确的原型说明(写成int open_file(void)并放进一个头文件),确认-Wstrict-prototypes与-Wmissing-prototypes各少了一条,而-Wvla仍在; - 把
log_value改成用"%d\n"固定的形式,再改成把可变参数转发给vfprintf的形式。后一种写法-Wformat=2不再报警,说明为什么。
demos/uaf.c 与 demos/leak.c 分别演示释放后使用与内存泄漏。要求:
- 对
uaf.c分别用-Wall、-Wall -Wextra、-Wall -Wextra -fanalyzer编译,记录每种组合报出什么,指出哪一条来自普通警告、哪一条只有分析器才报; - 对
leak.c做同样的事。为什么-fanalyzer对leak.c什么都不报?把泄漏从main挪进一个static函数、并把snprintf换成直接写buf[0] = 0;,再跑分析器,记录变化。再试一次保留snprintf、只把函数挪出来的版本:这一次报不报?两次结果的差别说明分析器对哪些库函数的调用会放弃跟踪; - 把
uaf.c的free(p)之后加一句p = NULL;,再分别用-Wall -Wextra与-Wall -Wextra -fanalyzer编译。普通警告与两种分析器报告各剩下什么?解释为什么置空之后「释放后使用」换成了一条别的警告,以及这条新警告在讲同一件事时比原来更准确; - 准备一个
void sink(void *p);只有声明没有定义的例子,让指针在free之前传给sink,确认分析器不再报告泄漏。这一条说明分析器的能力边界在哪里。
把编译器的诊断变成一项自动测试,登记进 CTest:
- 在
wordfreq/CMakeLists.txt里加一条add_test,命令是${CMAKE_C_COMPILER}编译demos/warn.c的-c调用,用set_tests_properties(... PROPERTIES PASS_REGULAR_EXPRESSION "may fall through")要求输出里出现那条穿透警告; - 同时加
FAIL_REGULAR_EXPRESSION "error:",说明这两条属性同时存在时判定规则是什么; - 再登记一条测试,要求某个来源在
-Werror下编译必须失败,用WILL_FAIL表达;把WILL_FAIL与PASS_REGULAR_EXPRESSION同时用在一个测试上,观察结果,解释为什么这组组合容易出问题; - 运行
ctest -R 诊断相关的名字 --output-on-failure,把输出贴进答案。如果PASS_REGULAR_EXPRESSION命中但进程退出状态非 0,测试算通过还是失败?实测给出结论。
第 1 问里的 demos/warn.c 在 wordfreq/ 目录之外,用 ${CMAKE_CURRENT_SOURCE_DIR}/../demos/warn.c 这样的路径引用,或把文件复制进工程。注意这条 add_test 的退出状态:警告不影响 gcc -c 的成功,测试会以 0 结束;第 4 问要求你实测的正是「命中正则但退出状态非 0」时 CTest 怎么判。
8.2 编程
写一个 codedup.c,故意保留三处能被静态检查发现的问题,并写一份对照报告:
- 一处
malloc之后在错误路径上直接return,漏掉free; - 一处
realloc的返回值直接覆盖原指针,失败时原缓冲区丢失; - 一处
fopen之后在fseek失败的分支上漏掉fclose。
要求:
- 三个缺陷分别放在三个函数里,每个函数都能被
main调用到,且不必真的触发失败; - 用
-Wall -Wextra、-fanalyzer、clang-tidy -checks='-*,bugprone-*,clang-analyzer-*' -- -std=c17各跑一遍,把三个工具各自报出的警告列表写成表格:缺陷编号 × 工具,命中记√,未命中记×,并附上警告的选项名或检查项名; - 修好三个缺陷(用统一的
goto done清理标签),三个工具全部无输出; - 结论里说明三件实测到的事:普通警告对这三个缺陷是否有效;
-fanalyzer对realloc直接覆盖原指针报的是哪一条(提示:它把这件事看成「原缓冲区的分配再也没人释放」);clang-tidy 的哪一个检查项专门针对这种写法,报出的文字是什么。
用 sanitizer 把一个「测试全绿但实际越界」的缺陷抓出来。缺陷文件是 bugs/counter_dup_off_by_one.c:
- 把它换进
wordfreq/src/counter.c,用普通构建跑ctest,记录结果;再用-DWF_SANITIZE=ON -DCMAKE_BUILD_TYPE=Debug配一个build-asan,跑./build-asan/test_counter,把 ASan 报告的三个部分(错误类型与地址、越界处的调用栈、分配的调用栈)分别摘出一行; - 从报告里的
0 bytes after N-byte region推出分配的字节数与写入的字节数,指出dup_string里哪一行少算了一个字节; - 修复后重跑两个构建,确认普通构建与 Sanitizer 构建都通过;
- 把修复前的
dup_string改成malloc(len + 1)但memcpy仍写len + 1,再用 ASan 跑一次,说明为什么这一次不报——问题出在「哪一块内存被越界」上,而不是字节数算错。
这一题在 Linux 或 macOS 上做,MinGW 的 GCC 没有 ASan 运行库,链接会报 cannot find -lasan。
写一个 ub_report.c,把未定义行为与「实现定义但合法」的行为放在一起对照:
- 四段操作各做成一个单独的分支,用命令行参数选择要跑哪一段:有符号整数溢出(
INT_MAX + argc)、移位位数等于类型宽度(1u << (argc + 31))、无符号整数回绕(UINT_MAX + argc)、除数为零(100 / (argc - 2))。分支用if/else分开,不要写成同一个表达式里连着算——前一段一旦停下,后面的就执行不到; - 在 Linux 上用
-fsanitize=undefined编译运行,逐个分支记录哪几段报runtime error、报完之后程序是否继续、退出状态是多少; - 加上
-fno-sanitize-recover=all再跑一次,比较退出状态;再把同一份源码用-fsanitize=undefined -fsanitize-trap=all编译(MinGW 上只能用这种方式),记录进程被什么信号终止、退出状态是什么; - 结论回答两个问题:为什么无符号回绕不报、有符号溢出报;为什么默认的 UBSan 不适合直接放进测试流程。
把本章的检查工具组合成一个「三种构建」的验证脚本 tools/verify.sh,在 wordfreq 上运行:
(1) [3.3] 开发构建:-Wall -Wextra -Wshadow -Wformat=2 -Wmissing-prototypes -Wstrict-prototypes -Wvla,加 WF_SANITIZE=ON,跑 ctest。记录配置命令、构建命令与测试结果。
(2) [3.3] CI 构建:开发构建的一组加 WF_WERROR=ON 与 -fanalyzer,在另一个构建目录 build-ci 里配置与构建。记录 -fanalyzer 让整个构建慢了多少(用 time 或 PowerShell 的 Measure-Command 对比两个目录的构建时间)。
(3) [4.4] 错误注入:把 bugs/counter_dup_off_by_one.c 换进 src/counter.c,用 build-asan 跑 ctest,要求脚本正确识别「哪一个测试失败、失败原因是什么」,并把 ASan 报告的 SUMMARY 行提取出来;然后恢复原文件。
(4) [4.4*] 发布构建:只保留基本警告(或完全不打开),在一个新的 build-release 里配置 -DCMAKE_BUILD_TYPE=Release,确认能构建、ctest 通过,并比较三个构建产物的大小。解释为什么发布构建不打开 -Werror 与 Sanitizer。
脚本在任何一步失败时立即退出并打印是哪一步。验收:在干净目录下从零运行,三步全部按预期结束,输出里能看到三个构建目录各自的结果。
9. 参考资料
- GCC 手册《Warning Options》,
-Wall、-Wextra、-Wpedantic、-Werror各选项的完整说明:https://gcc.gnu.org/onlinedocs/gcc/Warning-Options.html - GCC 手册《Options That Control Static Analysis》,
-fanalyzer与-Wanalyzer-*报告的含义:https://gcc.gnu.org/onlinedocs/gcc/Static-Analyzer-Options.html - GCC 手册《Program Instrumentation Options》,
-fsanitize=address、-fsanitize=undefined、-fsanitize-trap与-fno-sanitize-recover:https://gcc.gnu.org/onlinedocs/gcc/Instrumentation-Options.html - GCC 10 Release Notes,
-fanalyzer首次引入:https://gcc.gnu.org/gcc-10/changes.html - AddressSanitizer 项目主页,报告格式、影子字节与
ASAN_OPTIONS:https://github.com/google/sanitizers/wiki/AddressSanitizer - Clang 文档《UndefinedBehaviorSanitizer》,检查项列表与运行时配置:https://clang.llvm.org/docs/UndefinedBehaviorSanitizer.html
- clang-tidy 文档,检查项分组与
-p、-checks选项:https://clang.llvm.org/extra/clang-tidy/ - Cppcheck 手册,
--enable、--std、--check-level与误报处理:https://cppcheck.sourceforge.io/manual.pdf - Valgrind 手册《Quick Start》,Memcheck 的用法与示例输出:https://valgrind.org/docs/manual/quick-start.html
- Microsoft Learn《AddressSanitizer for Windows C/C++》,MSVC 的
/fsanitize=address:https://learn.microsoft.com/en-us/cpp/sanitizers/asan - 《Software Engineering at Google》第 20 章「Static Analysis」,静态检查在工程中的定位与误报处理:https://abseil.io/resources/swe-book/html/ch20.html