前言
作为 C/C++ 开发者,你手上最强大的代码审查工具不是某个第三方软件,而是你每天都在用的编译器本身。GCC 和 Clang 都内置了完整的静态分析和动态分析能力,零成本、零额外依赖。
本文聚焦两类工具:
- 静态分析(不运行程序):编译器警告、GCC
-fanalyzer、Clang Static Analyzer (--analyze/scan-build)、clang-tidy - 动态分析(运行程序时捕获问题):AddressSanitizer (ASan)、ThreadSanitizer (TSan)、MemorySanitizer (MSan)、UndefinedBehaviorSanitizer (UBSan)
cppcheck、Valgrind 等第三方工具仅在文末作对比参考。
第一部分:静态分析(编译器自带)
1.1 编译器警告:零成本第一道防线
警告不是可选的,-Werror 是 CI 和本地开发的底线(发布构建可以酌情关闭,避免新编译器版本引入的新警告中断发布流程)。
推荐的最小警告集(GCC 和 Clang 通用)
# 所有构建必开
-Wall -Wextra -Wpedantic -Werror
# 推荐再加这些
# 注意:GCC 中 -Wconversion 不包含有符号/无符号隐式转换,需单独加 -Wsign-conversion
# Clang 中 -Wconversion 默认包含两者
-Wshadow -Wconversion -Wsign-conversion -Wnull-dereference
-Wformat=2 -Wundef -Wunused -Wcast-align -Wcast-qual
-Wnon-virtual-dtor -Woverloaded-virtual -Wold-style-cast
GCC 特有警告
| 选项 | 起始版本 | 说明 |
|---|---|---|
-Weffc++ | 很早 | Effective C++ 规则集 |
-Wstringop-overflow=4 | GCC 11 | 字符串操作溢出(需 -O2 才触发,级别 1-4,数字越大越严格) |
-Wdangling-reference | GCC 13 | 悬垂引用检测 |
-Wstrict-overflow=5 | GCC 4.x | 有符号整数溢出假设(需 -O2 才触发,级别 1-5) |
-Warray-bounds | 很早 | 数组越界(需 -O2 才触发) |
Clang 特有警告
| 选项 | 说明 |
|---|---|
-Weverything | 启用所有警告(不要在 CI 用,新警告会断构建) |
-Wno-c++98-compat | 忽略 C++98 兼容性(现代项目通常不需要) |
-Wdocumentation | Doxygen 文档注释格式检查 |
-Wthread-safety | 线程安全注解检查(代码中需加注解) |
1.2 GCC -fanalyzer:路径敏感静态分析
GCC 10 引入,GCC 11 完全重写。基于符号执行,能发现跨函数、跨路径的深度 Bug。
# GCC 10+ 支持。版本号可选,直接 gcc 也可以
gcc-14 -fanalyzer -O0 -g -c test.c
gcc -fanalyzer -O1 -g -c test.c # -O1 分析效果更好,-O0 也能用
它能发现普通警告抓不到的问题:
#include <stdio.h>
void process(int flag, FILE *f) {
if (flag) fclose(f);
// ... 其他逻辑
fclose(f); // flag 为真时 double-fclose!-Wall 不会报
}
主要检查项(Wanalyzer-*)
| 警告 | 检测内容 |
|---|---|
-Wanalyzer-double-free | 双重释放 |
-Wanalyzer-use-after-free | 释放后使用 |
-Wanalyzer-malloc-leak | malloc 泄漏 |
-Wanalyzer-file-leak | FILE* 泄漏 |
-Wanalyzer-null-dereference | 空指针解引用 |
-Wanalyzer-tainted-array-index | 污点数据用作数组索引 |
-Wanalyzer-mismatching-deallocation | new/delete 与 malloc/free 不匹配 |
架构师提示
- 慢:比普通编译慢 5-20 倍,不要本地每次都开
- C 支持好,C++ 有限(不支持异常处理)
- CI 每日构建或发布前跑即可
1.3 Clang Static Analyzer:深度路径分析
Clang 的路径敏感分析器,比 GCC -fanalyzer 更成熟(发展早了 10 年),C++ 支持完整。
使用方式一:clang --analyze(单文件,不生成目标文件)
# Clang 3.x+ 就支持。版本号可选,直接 clang 也可以
clang-18 --analyze -Xanalyzer -analyzer-output=text test.c
clang --analyze test.c
使用方式二:scan-build(拦截整个构建过程)
# make 项目
scan-build make -j4
# CMake 项目:配置阶段不需要 scan-build(不编译)
cmake -B build -S .
# 只有构建阶段需要 scan-build 拦截编译器调用
scan-build cmake --build build -j4
# CI 中用:发现 Bug 返回非零退出码
scan-build --status-bugs make
主要 Checker 分类
| 分类 | 示例 |
|---|---|
core.* | 空指针、除零、未初始化值 |
cplusplus.* | new/delete 不匹配、placement new 问题 |
unix.* | malloc/free 匹配、字符串操作错误 |
security.* | 不安全 API(gets、strcpy)、浮点循环计数器 |
alpha.* | 实验性:污点传播、pthread 锁误用 |
架构师提示
- 比
-fanalyzer更慢(5-30x),但误报率更低 - C++ 支持完整,报告质量好(HTML 可视化 + 完整路径轨迹)
- 每日构建深度扫描必开
1.4 clang-tidy:AST 级语义分析
基于 Clang 编译器前端,直接操作 AST。和前面两个路径敏感分析器不同,clang-tidy 是语法/语义级模式匹配,快得多。
# 需要 compile_commands.json
run-clang-tidy -p build -j$(nproc)
# 单个文件
clang-tidy test.c -p build
核心 Checker 分类
| 模块前缀 | 用途 |
|---|---|
bugprone-* | 易错模式(易被忽视的 Bug) |
modernize-* | 现代 C++ 迁移(auto、range-for、smart ptr) |
performance-* | 性能问题 |
readability-* | 可读性 |
cppcoreguidelines-* | C++ 核心准则 |
clang-analyzer-* | 复用 Clang Static Analyzer 的部分 checker |
架构师提示
- 快:接近编译速度,本地每次编译和 CI 每次提交都能跑
- 自动修复:约 50% 检查项支持
-fix自动修正(⚠️ 风险提示:modernize-*、readability-*等重构类 checker 在复杂代码上可能引入语义错误,自动修复后务必人工审查) - 注意拼写:是
clang-tidy(中间有连字符),不是clangtidy
静态分析结果的抑制方式
实际项目中总有少数情况工具会误报,或因历史遗留问题暂时无法修复。以下是各工具的抑制方式:
1. clang-tidy:使用 NOLINT 注释
// 抑制下一行的所有 clang-tidy 警告
char *p = malloc(1024); // NOLINT
// 抑制指定的 checker
char *p = malloc(1024); // NOLINT(cppcoreguidelines-no-malloc)
// 抑制一个代码块
// NOLINTBEGIN
void legacy_func() { /* ... */ }
// NOLINTEND
// 抑制指定 checker 的一个代码块
// NOLINTBEGIN(cppcoreguidelines-no-malloc, readability-identifier-naming)
void legacy_func() { /* ... */ }
// NOLINTEND
2. GCC/Clang 编译器警告:使用 #pragma
// 临时禁用某个警告(GCC 和 Clang 通用语法)
#pragma GCC diagnostic push
#pragma GCC diagnostic ignored "-Wdeprecated-declarations"
void legacy_func(); // 这里不会报 deprecated 警告
#pragma GCC diagnostic pop
// Clang 专用(更精确)
#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Wunused-variable"
int x; // 这里不会报未使用变量
#pragma clang diagnostic pop
3. Clang Static Analyzer:使用 #ifndef __clang_analyzer__
#ifndef __clang_analyzer__
// 这段代码 Clang SA 分析不到,可用于隐藏已知误报
#else
// 也可以在这里放为了分析器而写的桩代码
#endif
和 Sanitizer 抑制一样的原则:每加一条抑制,都应该在旁边用注释写清楚为什么要抑制,并在 Bug 系统中记录对应的清理任务。
1.5 静态工具关系图
速度(快 → 慢):
编译器警告 → clang-tidy → GCC -fanalyzer → Clang Static Analyzer
(0成本) (AST模式) (路径敏感+符号执行) (路径敏感+符号执行)
| | | |
必开 必开 每日构建 每日构建
1.6 与 cppcheck 的对比(参考)
| 维度 | clang-tidy | GCC -fanalyzer | Clang SA | cppcheck |
|---|---|---|---|---|
| 分析基础 | Clang AST | GCC GIMPLE IR | Clang CFG | 自建分析器 |
| 需编译上下文 | ✅ | ✅ | ✅ | ❌ |
| 速度 | 编译 1-2x | 编译 5-20x | 编译 5-30x | 快 |
| C++ 支持 | 优秀 | 有限 | 优秀 | 部分 |
| 误报率 | 很低 | 中 | 低 | 中 |
结论:cppcheck 适合没有编译数据库或第三方代码的快速扫描,主力还是编译器自带工具。
第二部分:动态分析(Sanitizer)
Sanitizer 是编译器插桩的动态分析工具,在程序运行时捕获问题。编译时加 -fsanitize=xxx,然后正常运行即可。
2.1 四大 Sanitizer 速查
| 工具 | 编译选项 | 检测内容 | 慢化 | 内存膨胀 |
|---|---|---|---|---|
| AddressSanitizer (ASan) | -fsanitize=address | 越界、use-after-free、double-free、泄漏 | ~2x | ~3x |
| ThreadSanitizer (TSan) | -fsanitize=thread | 数据竞争、死锁、锁误用 | 5x-15x | 5x-10x |
| MemorySanitizer (MSan) | -fsanitize=memory | 未初始化内存读取 | ~3x | 大 |
| UndefinedBehaviorSanitizer (UBSan) | -fsanitize=undefined | 有符号溢出、非法移位、除零、对齐违规 | <10% | 很小 |
注意:ASan、TSan、MSan 三者互斥——它们都要对内存访问做全局插桩,且各自使用不同的 Shadow Memory 映射方案(ASan 用 1:8,TSan 需要额外的线程状态追踪),无法在同一个二进制中共存。UBSan 可以和 ASan 或 TSan 组合(它只在特定操作点插桩,不做通用内存访问插桩)。
2.2 AddressSanitizer (ASan):最常用的内存检查器
# 编译(UBSan 一起开,几乎零额外成本)
gcc/clang -fsanitize=address,undefined -g -O0 -o asan_demo asan_demo.c
# 运行(和正常运行一样)
./asan_demo
触发代码示例(asan_demo.c):
#include <stdlib.h>
int main(void) {
int *p = malloc(sizeof(int) * 3);
p[0] = 1;
free(p);
p[0] = 42; // use-after-free!ASan 会抓到
return 0;
}
编译运行后 ASan 会直接 abort 并打印:
==ERROR: AddressSanitizer: heap-use-after-free on address 0x...
WRITE of size 4 at 0x... thread T0
#0 0x... in main asan_demo.c:8
freed by thread T0 here:
#0 0x... in free
#1 0x... in main asan_demo.c:7
ASan 检测能力
| 错误类型 | 检测 |
|---|---|
| 堆/栈/全局数组越界 | ✅ |
| use-after-free | ✅ |
| use-after-scope | ✅ |
| double-free | ✅ |
| 内存泄漏(LSan) | ✅(Linux 默认包含在 ASan 中,macOS 需单独 -fsanitize=leak。LSan 也可独立使用:-fsanitize=leak,无内存插桩,速度接近普通程序) |
| 未初始化读取 | ❌(用 MSan) |
ASan 常见错误模式
| 错误标识 | 触发代码 | 说明 |
|---|---|---|
heap-use-after-free | int *p=malloc(4); free(p); *p=1; | 释放后堆内存读写 |
heap-buffer-overflow | int *p=malloc(4); p[2]=1; | 堆缓冲区越界 |
stack-buffer-overflow | int a[2]; a[5]=1; | 栈缓冲区越界 |
global-buffer-overflow | int a[2]; int main(){a[5]=1;} | 全局变量越界 |
double-free | int *p=malloc(4); free(p); free(p); | 双重释放 |
alloc-dealloc-mismatch | int *p=new int; free(p); | new/delete 与 malloc/free 混用 |
use-after-scope | int *p; {int x=1; p=&x;} *p=2; | 栈变量离开作用域后使用 |
stack-use-after-return | int *f(){int x=1; return &x;} | 函数返回后使用栈地址(需显式开启:ASAN_OPTIONS=detect_stack_use_after_return=1,默认关闭以避免性能开销) |
detected-leaks (LSan) | int *p=malloc(4); /* 不 free */ | 内存泄漏(进程退出时检测) |
ASan 典型错误输出样例
以下是 ASan 报告各种错误时的实际输出片段(省略了部分调用栈细节):
1. heap-buffer-overflow(堆缓冲区越界)
==ERROR: AddressSanitizer: heap-buffer-overflow on address 0x602000000014 at pc ...
READ of size 4 at 0x602000000014 thread T0
#0 0x... in main demo.c:5
0x602000000014 is located 4 bytes to the right of 8-byte region [0x602000000010,0x602000000018)
allocated by thread T0 here:
#0 0x... in malloc
2. stack-buffer-overflow(栈缓冲区越界)
==ERROR: AddressSanitizer: stack-buffer-overflow on address 0x7ffd... at pc ...
WRITE of size 4 at 0x7ffd... thread T0
#0 0x... in main demo.c:4
Address 0x7ffd... is located in stack of thread T0 at offset 36 in frame
#0 0x... in main demo.c:1
This frame has 1 object(s):
[32, 40) 'a' <== Memory access at offset 36 partially overflows this variable
3. double-free(双重释放)
==ERROR: AddressSanitizer: attempting double-free on 0x602000000010 in thread T0:
#0 0x... in free
#1 0x... in main demo.c:6
0x602000000010 is located 0 bytes inside of 8-byte region [0x602000000010,0x602000000018)
freed by thread T0 here:
#0 0x... in free
#1 0x... in main demo.c:5
previously allocated by thread T0 here:
#0 0x... in malloc
4. use-after-scope(作用域后使用)
==ERROR: AddressSanitizer: stack-use-after-scope on address 0x7ffd... at pc ...
WRITE of size 4 at 0x7ffd... thread T0
#0 0x... in main demo.c:7
Address 0x7ffd... is located in stack of thread T0 at offset 32 in frame
#0 0x... in main demo.c:1
This frame has 2 object(s):
[32, 36) 'x' (line 3) <== Memory access at offset 32 is inside this variable
5. detected memory leaks(LSan 泄漏检测)
==ERROR: LeakSanitizer: detected memory leaks
Direct leak of 8 byte(s) in 1 object(s) allocated from:
#0 0x... in malloc
#1 0x... in main demo.c:3
SUMMARY: AddressSanitizer: 8 byte(s) leaked in 1 allocation(s).
⚠️ 重要风险提示
- Sanitizer 二进制绝不能用于生产环境:插桩会导致 2-3x 性能下降和 3x 内存膨胀,且 Shadow Memory 的固定映射会降低地址空间随机性,削弱 ASLR 防护
- 与自定义分配器冲突:ASan 会拦截
malloc/free,如果代码链接了 jemalloc、tcmalloc 等自定义分配器,可能导致检测失效或崩溃 - 进程崩溃时泄漏检测不执行:LSan 在进程正常退出时才扫描泄漏,如果 ASan 先因为其他错误 abort,不会报告泄漏。可设置
ASAN_OPTIONS=abort_on_error=0让程序继续运行到退出
各 Sanitizer 常用环境变量速查
以下是所有 Sanitizer 的常用环境变量汇总,放在一起便于查阅:
# ASan 常用选项
export ASAN_OPTIONS=detect_leaks=1:halt_on_error=1
# detect_leaks=1 检测泄漏(Linux 默认开启)
# halt_on_error=1 遇到第一个错误就 abort(默认继续运行)
# fast_unwind_on_malloc=0 获得完整调用栈(更慢,但信息全)
# detect_stack_use_after_return=1 检测栈变量返回后使用
# TSan 常用选项
export TSAN_OPTIONS=halt_on_error=1:second_deadlock_stack=1
# halt_on_error=1 遇到第一个错误就 abort
# second_deadlock_stack=1 死锁检测时记录两端的调用栈
# MSan 常用选项
export MSAN_OPTIONS=halt_on_error=1:poison_in_dtor=1
# halt_on_error=1 遇到第一个错误就 abort
# poison_in_dtor=1 在对象析构时标记内存为无效(防止析构后访问)
# UBSan 常用选项
export UBSAN_OPTIONS=print_stacktrace=1:halt_on_error=1
# print_stacktrace=1 打印完整调用栈(默认只打印行号)
2.3 ThreadSanitizer (TSan):并发问题杀手
gcc/clang -fsanitize=thread -g -O1 -o tsan_demo tsan_demo.c
./tsan_demo
触发代码示例(tsan_demo.c):
#include <pthread.h>
#include <stdio.h>
int shared = 0;
void *worker(void *arg) {
(void)arg;
for (int i = 0; i < 10000; i++) {
shared++; // 无锁并发写 → 数据竞争!
}
return NULL;
}
int main(void) {
pthread_t t1, t2;
pthread_create(&t1, NULL, worker, NULL);
pthread_create(&t2, NULL, worker, NULL);
pthread_join(t1, NULL);
pthread_join(t2, NULL);
printf("shared = %d\n", shared);
return 0;
}
编译运行后 TSan 会报告数据竞争:
WARNING: ThreadSanitizer: data race (pid=12345)
Write of size 4 at 0x... by thread T1:
#0 thread_func /path/to/program.c:25
#1 start_thread
Previous write of size 4 at 0x... by thread T2:
#0 thread_func /path/to/program.c:25
#1 start_thread
架构师提示
- TSan 和 ASan 不能同时用(两者都要对内存访问做全局插桩,Shadow Memory 映射方案冲突)
- 推荐
-O1及以上(不推荐-O0):TSan 依赖编译期的内存访问分析 pass 来识别变量读写模式,-O0下这些 pass 不执行会导致大量误报。技术上-O0也能运行,但实战不建议 - 非常慢(5-15x),CI 每日构建跑并发测试就行
- ⚠️ 只能检测已执行路径:数据竞争必须在运行时被实际触发才会被检测到。如果测试覆盖率不足或竞争窗口未被触发,TSan 会漏报。不要因为 TSan 没报警就认为没有数据竞争
- ⚠️ fork() 后失效:TSan 在
fork()后的子进程中状态不正确,子进程中的竞争可能无法被检测
TSan 常见错误模式
| 错误标识 | 触发代码 | 说明 |
|---|---|---|
data race | 两线程同时 shared++(无锁) | 最常见:无同步的并发内存访问 |
lock-order-inversion | 线程1: lock(A)→lock(B); 线程2: lock(B)→lock(A) | 潜在死锁:锁获取顺序不一致 |
use-after-free | 线程A free 后,线程B继续访问 | 跨线程的释放后使用 |
double-lock | 同一线程对非递归 mutex 连续 lock 两次 | 重复加锁导致死锁 |
mutex-unlock-of-mutex-not-owned-by-thread | 线程A lock,线程B unlock | 解锁非本线程持有的锁 |
signal-unsafe-call | 信号处理函数中调用 malloc/printf | 信号处理函数中调用了非异步信号安全的函数 |
TSan 典型错误输出样例
1. data race(数据竞争)
WARNING: ThreadSanitizer: data race (pid=12345)
Write of size 4 at 0x... by thread T1:
#0 worker /path/to/program.c:10
#1 start_thread
Previous write of size 4 at 0x... by thread T2:
#0 worker /path/to/program.c:10
#1 start_thread
Location is global 'shared' of size 4 at 0x... (program+0x...)
Thread T1 (tid=..., running) created by main thread at:
#0 pthread_create
#1 main /path/to/program.c:18
SUMMARY: ThreadSanitizer: data race /path/to/program.c:10 in worker
2. lock-order-inversion(锁顺序反转/潜在死锁)
WARNING: ThreadSanitizer: lock-order-inversion (potential deadlock) (pid=...)
Cycle in lock order graph: M1 (...) --> M2 (...) --> M1 (...)
Mutex M1 acquired here while holding Mutex M2 in thread T1:
#0 pthread_mutex_lock
#1 thread1_func /path/to/program.c:25
Mutex M2 previously acquired by the same thread here:
#0 pthread_mutex_lock
#1 thread1_func /path/to/program.c:23
Mutex M2 acquired here while holding Mutex M1 in thread T2:
#0 pthread_mutex_lock
#1 thread2_func /path/to/program.c:38
SUMMARY: ThreadSanitizer: lock-order-inversion (potential deadlock)
3. double-lock(重复加锁)
WARNING: ThreadSanitizer: double-lock of mutex M0 (pid=...)
Mutex M0 is already held by thread T1:
#0 pthread_mutex_lock
#1 worker /path/to/program.c:15
And is being locked again:
#0 pthread_mutex_lock
#1 worker /path/to/program.c:17
SUMMARY: ThreadSanitizer: double-lock of mutex M0
4. mutex-unlock-of-mutex-not-owned-by-thread(解锁非本线程锁)
WARNING: ThreadSanitizer: unlock of an unlocked mutex (or by a wrong thread) (pid=...)
#0 pthread_mutex_unlock
#1 thread2_func /path/to/program.c:42
Mutex M0 was created at:
#0 pthread_mutex_init
#1 main /path/to/program.c:8
SUMMARY: ThreadSanitizer: unlock of an unlocked mutex (or by a wrong thread)
2.4 MemorySanitizer (MSan):未初始化读取的终极武器
# 注意:只有 Clang 支持!GCC 从未实现 MSan
clang++ -fsanitize=memory -stdlib=libc++ -g -O1 -o msan_demo msan_demo.cpp
触发代码示例(msan_demo.cpp):
#include <cstdio>
int main() {
int value; // 未初始化
if (value > 10) { // MSan 会抓到这里读取了未初始化内存
printf("big\n");
} else {
printf("small\n");
}
return 0;
}
重要限制:MSan 通过 Shadow Memory 追踪每个字节的"有效值位",因此所有代码包括依赖库都必须用 MSan 重新编译,否则未被插桩的库函数返回值都会被当作"未初始化"从而产生海量误报。这也是为什么必须用 -stdlib=libc++——系统自带的 libstdc++ 没有被 MSan 插桩过。这也是 MSan 没有 ASan 普及的最主要原因。
⚠️ 额外提示
- Origin Tracking 默认关闭:MSan 默认只报告"哪里读取了未初始化值",不报告"这个值最初是从哪里来的"。需要加
-fsanitize-memory-track-origins=2(约 1.5x-2x 额外开销)才能看到完整的来源链
MSan 常见错误模式
| 错误标识 | 触发代码 | 说明 |
|---|---|---|
use-of-uninitialized-value | int a; if(a>5){} | 读取未初始化的栈/堆变量 |
uninitialized-condition | int a; if(a){} | 未初始化值用作条件判断 |
uninitialized-memory-copy | int a; memcpy(&b,&a,4); | 从未初始化内存拷贝数据 |
use-of-uninitialized-value (origin) | fread(&a,4,1,fp); if(a>5){} | 加 -fsanitize-memory-track-origins=2 时会显示来源链 |
MSan 典型错误输出样例
1. use-of-uninitialized-value(读取未初始化值)
==ERROR: MemorySanitizer: use-of-uninitialized-value
#0 0x... in main msan_demo.cpp:5
#1 0x... in __libc_start_main
Uninitialized value was stored to memory at
#0 0x... in main msan_demo.cpp:4
SUMMARY: MemorySanitizer: use-of-uninitialized-value msan_demo.cpp:5 in main
2. uninitialized-condition(未初始化值用作条件)
==WARNING: MemorySanitizer: use-of-uninitialized-value
#0 0x... in main msan_demo.cpp:5
(main)
#1 0x... in __libc_start_main
Uninitialized value was created by an allocation of 'a' in the stack frame of function 'main'
#0 0x... in main msan_demo.cpp:3
SUMMARY: MemorySanitizer: use-of-uninitialized-value
3. 带 Origin Tracking 的输出(-fsanitize-memory-track-origins=2)
==ERROR: MemorySanitizer: use-of-uninitialized-value
#0 0x... in main msan_demo.cpp:8
#1 0x... in __libc_start_main
Uninitialized value was stored to memory at
#0 0x... in main msan_demo.cpp:6
Memory was marked as uninitialized
#0 0x... in __interceptor_fread
#1 0x... in main msan_demo.cpp:6
#2 0x... in __libc_start_main
SUMMARY: MemorySanitizer: use-of-uninitialized-value msan_demo.cpp:8 in main
2.5 UndefinedBehaviorSanitizer (UBSan)
# 和 ASan 一起开(推荐)
gcc/clang -fsanitize=address,undefined -g -O0 -o ubsan_demo ubsan_demo.c
# 单独开
gcc/clang -fsanitize=undefined -g -O0 -o ubsan_demo ubsan_demo.c
触发代码示例(ubsan_demo.c):
#include <stdio.h>
#include <limits.h>
int main(void) {
int a = INT_MAX;
int b = a + 1; // 有符号整数溢出 → UB!UBSan 会抓到
printf("INT_MAX + 1 = %d\n", b);
int c = 1 << 32; // 移位超界(int 通常 32 位)→ UB!
printf("1 << 32 = %d\n", c);
return 0;
}
运行时 UBSan 会输出:
ubsan_demo.c:7:15: runtime error: signed integer overflow: 2147483647 + 1 cannot be represented in type 'int'
ubsan_demo.c:10:15: runtime error: shift exponent 32 is too large for 32-bit type 'int'
常用子项(除标注外均包含在 undefined 里):
| 子项 | 检测内容 |
|---|---|
signed-integer-overflow | 有符号整数溢出(UB!) |
shift | 移位超界、负移位 |
integer-divide-by-zero | 整数除零 |
float-divide-by-zero | 浮点除零(不在 undefined 组,需显式加 -fsanitize=float-divide-by-zero;IEEE 754 定义了结果为 Inf,但某些场景仍需检测) |
null | 空指针解引用 |
alignment | 对齐违规 |
float-cast-overflow | float 转 int 溢出(超出目标类型范围) |
vptr | 虚表指针损坏(C++ 专用) |
bool | 向 bool 类型加载非 0/1 的值(不在 undefined 组,需 -fsanitize=bool) |
enum | 向枚举类型加载不在枚举范围内的值(不在 undefined 组,需 -fsanitize=enum) |
UBSan 常见错误模式
| 错误标识 | 触发代码 | 说明 |
|---|---|---|
signed integer overflow | INT_MAX + 1 | 有符号整数溢出(最常见 UB) |
shift exponent 32 is too large | 1 << 32(32位 int) | 移位位数 ≥ 类型宽度或为负 |
division by zero | int a=1,b=0; a/b | 整数除零 |
null pointer dereference | int *p=NULL; *p=1 | 空指针解引用 |
member access within null pointer | struct S*p=NULL; p->x=1 | 通过空指针访问成员 |
misaligned address | int *p=(int*)0x1; *p=1 | 指针未按类型对齐 |
value 256 is outside the range | (unsigned char)256 不越界,但 float 转 int 超范围 | 浮点转整型溢出 |
downcast of address | 基类指针指向基类对象却转派生类 | 非法虚表/对象类型不匹配(vptr) |
load of value 2 is not a valid value for type 'bool' | bool b; memcpy(&b, "\x02", 1); if(b){} | 向 bool 加载非 0/1 的值(需 -fsanitize=bool) |
load of value 5 is not a valid value for type 'enum E' | enum E{A=1,B=2}; enum E e; memcpy(&e, "\x05", 1); | 向枚举加载不在范围内的值(需 -fsanitize=enum) |
UBSan 典型错误输出样例
1. signed integer overflow(有符号整数溢出)
ubsan_demo.c:7:15: runtime error: signed integer overflow: 2147483647 + 1 cannot be represented in type 'int'
SUMMARY: UndefinedBehaviorSanitizer: undefined-behavior ubsan_demo.c:7:15 in
2. shift exponent is too large(移位超界)
ubsan_demo.c:10:15: runtime error: shift exponent 32 is too large for 32-bit type 'int'
SUMMARY: UndefinedBehaviorSanitizer: undefined-behavior ubsan_demo.c:10:15 in
3. division by zero(除零)
ubsan_demo.c:5:10: runtime error: division by zero
SUMMARY: UndefinedBehaviorSanitizer: undefined-behavior ubsan_demo.c:5:10 in
4. null pointer dereference(空指针解引用)
ubsan_demo.c:6:5: runtime error: store to null pointer of type 'int'
SUMMARY: UndefinedBehaviorSanitizer: undefined-behavior ubsan_demo.c:6:5 in
5. member access within null pointer(通过空指针访问成员)
ubsan_demo.cpp:12:10: runtime error: member access within null pointer of type 'struct S'
SUMMARY: UndefinedBehaviorSanitizer: undefined-behavior ubsan_demo.cpp:12:10 in
6. misaligned address(对齐违规)
ubsan_demo.c:8:14: runtime error: store to misaligned address 0x... for type 'int', which requires 4 byte alignment
0x...: note: pointer points here
SUMMARY: UndefinedBehaviorSanitizer: undefined-behavior ubsan_demo.c:8:14 in
7. value outside the range of representable values(浮点转整型溢出)
ubsan_demo.c:9:25: runtime error: value 256.5 is outside the range of representable values of type 'unsigned char'
SUMMARY: UndefinedBehaviorSanitizer: undefined-behavior ubsan_demo.c:9:25 in
8. invalid vptr(非法虚表指针,C++ 专用)
ubsan_demo.cpp:15:12: runtime error: downcast of address 0x... which does not point to an object of type 'Derived'
0x...: note: object is of type 'Base'
SUMMARY: UndefinedBehaviorSanitizer: undefined-behavior ubsan_demo.cpp:15:12 in
9. invalid bool value(非法 bool 值,需 -fsanitize=bool)
ubsan_demo.c:8:7: runtime error: load of value 2, which is not a valid value for type '_Bool'
SUMMARY: UndefinedBehaviorSanitizer: undefined-behavior ubsan_demo.c:8:7 in
10. invalid enum value(非法枚举值,需 -fsanitize=enum)
ubsan_demo.c:10:10: runtime error: load of value 5, which is not a valid value for type 'enum E'
SUMMARY: UndefinedBehaviorSanitizer: undefined-behavior ubsan_demo.c:10:10 in
# 环境变量
export UBSAN_OPTIONS=print_stacktrace=1:halt_on_error=1
⚠️ 重要提醒
- 默认不终止程序:UBSan 默认只打印警告后继续运行。如果程序输出量大,警告很容易被淹没。务必设置
halt_on_error=1或编译时加-fno-sanitize-recover=undefined,让 UB 发生时直接 abort
2.5.1 Sanitizer 抑制机制(Suppressions)
实际项目中经常遇到第三方库的已知问题或暂时无法修复的历史遗留问题,这时候不能让 Sanitizer 一直报警。所有 Sanitizer 都支持通过抑制文件(suppression file)来屏蔽指定位置的报错。
抑制文件格式(asan_suppressions.txt):
# 格式:匹配规则类型:匹配模式
# 匹配规则类型:
# interceptor_name — 按函数名匹配(函数名中包含的关键字)
# source_file — 按源文件路径匹配(支持正则)
# module — 按模块/库名匹配(如 libcurl.so)
# 具体错误类型 — 如 heap-use-after-free、heap-buffer-overflow、leak 等
# 屏蔽第三方库 libcurl 中所有的泄漏
leak:libcurl.so
# 屏蔽某个文件中的所有 use-after-free
heap-use-after-free:legacy_code.cpp
# 屏蔽某个函数中的所有报错(函数名匹配)
interceptor_name:problematic_third_party_func
# 屏蔽某个源文件路径下的所有问题
source_file:third_party/.*
使用方法:
# ASan / LSan
export ASAN_OPTIONS=suppressions=asan_suppressions.txt
# TSan
export TSAN_OPTIONS=suppressions=tsan_suppressions.txt
# UBSan(注意:UBSan 抑制需要编译时指定 ignorelist)
# Clang: -fsanitize-ignorelist=ubsan_suppressions.txt
# GCC 12+: 同样支持 -fsanitize-ignorelist=ubsan_suppressions.txt
# 更早的 GCC: 不支持 ignorelist,只能用 __attribute__((no_sanitize("undefined"))) 标记函数
clang++ -fsanitize=undefined -fsanitize-ignorelist=ubsan_suppressions.txt ...
gcc-12 -fsanitize=undefined -fsanitize-ignorelist=ubsan_suppressions.txt ...
UBSan 抑制文件格式(ubsan_suppressions.txt):
# 屏蔽某个文件中的所有 UB
src:legacy/old_code.c
# 屏蔽某个函数中的所有 UB
fun:problematic_func
# 屏蔽某种类型的 UB(可选)
unsigned-integer-overflow:legacy/.*
架构师提示:抑制文件是技术债务,每加一条都应该在 Bug 跟踪系统中记录对应的清理任务。不要让抑制文件变成藏污纳垢的地方。
2.6 Sanitizer 推荐组合
| 场景 | 编译命令 |
|---|---|
| 日常开发/单元测试 | -fsanitize=address,undefined -g -O0 |
| 并发测试 | -fsanitize=thread -g -O1 |
| 发布前深度检查 (Clang) | -fsanitize=memory,undefined -stdlib=libc++ -g -O1 |
2.7 GCC 与 Clang 的 Sanitizer 支持差异
| 工具 | GCC | Clang | 备注 |
|---|---|---|---|
| ASan | ✅ 4.8+ | ✅ 3.1+ | 一致 |
| TSan | ✅ 4.8+ | ✅ 3.2+ | 一致 |
| MSan | ❌ 不支持 | ✅ 3.3+ | Clang 独占 |
| UBSan | ✅ 4.9+ | ✅ 3.3+ | 子项有差异(见下) |
| DFSan | ❌ | ✅ 3.5+ | Clang 独占 |
| HWASan | ✅ 13+ | ✅ 12+ | ARM64 |
UBSan 子项差异
| 子项 | GCC | Clang |
|---|---|---|
unsigned-integer-overflow | ❌ | ✅ |
pointer-overflow | ❌ | ✅ |
function(函数指针类型不匹配) | ❌ | ✅ |
宏定义差异(跨编译器检测)
// AddressSanitizer
#if defined(__clang__)
# if __has_feature(address_sanitizer)
# define HAS_ASAN 1
# endif
#elif defined(__GNUC__) && defined(__SANITIZE_ADDRESS__)
# define HAS_ASAN 1
#endif
// ThreadSanitizer
#if defined(__clang__)
# if __has_feature(thread_sanitizer)
# define HAS_TSAN 1
# endif
#elif defined(__GNUC__) && defined(__SANITIZE_THREAD__)
# define HAS_TSAN 1
#endif
// MemorySanitizer — 只有 Clang 有
#if defined(__clang__) && __has_feature(memory_sanitizer)
# define HAS_MSAN 1
#endif
2.8 与 Valgrind 的对比(参考)
| 维度 | ASan | TSan | MSan | Valgrind Memcheck |
|---|---|---|---|---|
| 需重编译 | ✅ | ✅ | ✅ | ❌ |
| 慢化 | ~2x | 5-15x | ~3x | 10-50x |
| 内存膨胀 | ~3x | 5-10x | 大 | ~10x |
| 未初始化读取 | ❌ | ❌ | ✅ | ✅ |
| 栈越界 | ✅ | — | — | ❌ |
| 平台 | 全平台 | Linux/macOS | Linux | 全平台 |
结论:Sanitizer 日常必用,Valgrind 只在拿不到源码或 MSan 跑不起来时用。
2.9 附录:CMake 项目集成指南
实际项目中几乎没人手动敲编译命令,以下是直接可用的 CMake 配置片段。
2.9.1 编译器警告 + clang-tidy
# CMakeLists.txt
cmake_minimum_required(VERSION 3.16)
project(my_project C CXX)
# 生成 compile_commands.json(clang-tidy 必需)
set(CMAKE_EXPORT_COMPILE_COMMANDS ON)
# 推荐警告集(GCC 和 Clang 通用)
add_compile_options(
-Wall -Wextra -Wpedantic -Werror
-Wshadow -Wconversion -Wsign-conversion -Wnull-dereference
-Wformat=2 -Wundef -Wunused -Wcast-align -Wcast-qual
)
# C++ 专用警告(只对 C++ 编译生效,避免 C 编译器报错)
if(CMAKE_CXX_COMPILER_ID MATCHES "GNU|Clang")
add_compile_options($<$<COMPILE_LANGUAGE:CXX>:-Wnon-virtual-dtor,-Woverloaded-virtual,-Wold-style-cast>)
endif()
# clang-tidy 集成(可选,通过 -DENABLE_CLANG_TIDY=ON 开启)
option(ENABLE_CLANG_TIDY "Enable clang-tidy during build" OFF)
if(ENABLE_CLANG_TIDY)
find_program(CLANG_TIDY_EXE NAMES clang-tidy clang-tidy-18 clang-tidy-17)
if(CLANG_TIDY_EXE)
set(CMAKE_CXX_CLANG_TIDY "${CLANG_TIDY_EXE};-checks=bugprone-*,modernize-*,performance-*,cppcoreguidelines-*")
set(CMAKE_C_CLANG_TIDY "${CLANG_TIDY_EXE};-checks=bugprone-*,performance-*")
endif()
endif()
2.9.2 Sanitizer 配置方案
# 通过 -DSANITIZE=address|thread|memory|undefined 切换
set(SANITIZE "" CACHE STRING "Sanitizer to enable: address, thread, memory, undefined")
if(SANITIZE STREQUAL "address")
add_compile_options(-fsanitize=address,undefined -g -O0)
add_link_options(-fsanitize=address,undefined)
elseif(SANITIZE STREQUAL "thread")
add_compile_options(-fsanitize=thread -g -O1)
add_link_options(-fsanitize=thread)
elseif(SANITIZE STREQUAL "memory")
# MSan 只有 Clang 支持,且需要 libc++(同时检查 C 和 C++ 编译器)
if(NOT (CMAKE_CXX_COMPILER_ID MATCHES "Clang" OR CMAKE_C_COMPILER_ID MATCHES "Clang"))
message(FATAL_ERROR "MSan only supported with Clang")
endif()
add_compile_options(-fsanitize=memory,undefined -stdlib=libc++ -g -O1
-fsanitize-memory-track-origins=2)
add_link_options(-fsanitize=memory,undefined -stdlib=libc++)
elseif(SANITIZE STREQUAL "undefined")
add_compile_options(-fsanitize=undefined -fno-sanitize-recover=undefined -g -O0)
add_link_options(-fsanitize=undefined)
endif()
使用方法:
# ASan + UBSan(日常开发)
cmake -B build -S . -DSANITIZE=address
cmake --build build -j$(nproc)
# TSan(并发测试)
cmake -B build-tsan -S . -DSANITIZE=thread
cmake --build build-tsan -j$(nproc)
# MSan(发布前,仅 Clang)
CXX=clang++ cmake -B build-msan -S . -DSANITIZE=memory
cmake --build build-msan -j$(nproc)
2.9.3 GCC -fanalyzer 配置
option(ENABLE_FANALYZER "Enable GCC -fanalyzer" OFF)
if(ENABLE_FANALYZER AND CMAKE_C_COMPILER_ID STREQUAL "GNU")
add_compile_options(-fanalyzer -O1)
endif()
常见问题 FAQ
Q1:Sanitizer 报告的错误一定是真的吗?会不会有误报?
ASan、TSan、UBSan 的误报率极低(接近 0%),因为它们是基于编译期插桩 + 运行时状态追踪的,检测逻辑非常严谨。如果这些工具报了错,极大概率是真的有问题,不要轻易忽略。
MSan 误报率相对较高,但根本原因通常不是工具本身,而是部分依赖库没有用 MSan 重新编译,导致库函数返回值被当作"未初始化"。这也是为什么 MSan 要求所有代码包括标准库都必须重新编译。
静态分析工具(-fanalyzer、Clang SA、clang-tidy)存在一定误报,因为它们不运行程序,只能做近似推理。遇到可疑的报告,人工确认后用抑制方式标注即可。
Q2:ASan 检测到了内存错误,但程序在正常环境下运行得好好的,需要修吗?
必须修! Sanitizer 检测到的内存错误(use-after-free、越界等)属于未定义行为(UB),“正常运行"只是因为:
- 刚好越界访问的内存是你进程合法拥有的(没触发页错误)
- 释放后的内存还没被操作系统回收,里面的数据还"凑巧"正确
- 当前编译器版本/优化级别下,代码生成刚好没出问题
换一个编译器版本、换一个优化级别、换一个操作系统、甚至只是程序运行久一点,随时可能崩溃。 内存错误是 C/C++ 程序最难调试的 Bug 类型,Sanitizer 已经帮你定位到了,不修真的对不起工具。
Q3:可以在发布版本中启用 Sanitizer 吗?
绝对不可以! 原因有三个:
- 性能开销太大:ASan 慢 2x、TSan 慢 5-15x、内存膨胀 3-10x
- 安全性反而降低:Sanitizer 需要固定映射 Shadow Memory,这会削弱 ASLR 地址空间随机化的防护效果
- 错误信息会暴露给用户:Sanitizer 报错时会打印完整的调用栈和内存布局
Sanitizer 是开发和测试工具,不是生产环境运行时防护。
Q4:MSan 太麻烦了(要重新编所有库),有没有替代方案?
有两个选择,但各有局限:
| 方案 | 优点 | 缺点 |
|---|---|---|
| Valgrind Memcheck | 不需要重编译,直接跑 | 慢 10-50x,检测能力不如 MSan(栈越界检测不到) |
| ASan + 编译器初始化填充 | ASan 已经在跑了,零额外成本 | 只能检测部分未初始化问题(比如通过编译器把栈变量初始化为 0xAA,越界时更显眼) |
实际项目中建议:测试覆盖率高的核心模块用 MSan,其他模块用 Valgrind 做兜底。
Q5:Sanitizer 和 gdb/lldb 调试器可以一起用吗?
可以! 而且非常推荐。当 Sanitizer 报了错,你往往需要在调试器中查看当时的完整状态。
最简单的方法:
# 方法一:直接用 gdb 运行(推荐,最简单)
gdb --args ./my_program
# 在 gdb 中设置环境变量,让 Sanitizer 遇到错误直接 abort
# (gdb) set environment ASAN_OPTIONS=abort_on_error=1
# (gdb) set environment UBSAN_OPTIONS=halt_on_error=1
# (gdb) run
# 报错后 gdb 会自动停在出错位置,可以 bt 看调用栈、p 看变量
# 方法二:先设置环境变量再运行(需要让信号交给调试器)
export ASAN_OPTIONS=abort_on_error=1
gdb --args ./my_program
Sanitizer 默认会在报错时打印调用栈,但调试器能让你看到所有线程的状态、所有局部变量的值,对复杂 Bug 的定位帮助极大。
Q6:开启 -Werror 后,第三方库的警告导致编译不过怎么办?
不要全局关闭 -Werror! 正确的做法是只对第三方库的代码关闭警告或 -Werror。
CMake 中可以这样配置:
# 为第三方库目标单独关闭警告
target_compile_options(third_party_lib PRIVATE -w) # 关闭所有警告
# 或者只关闭 -Werror,但仍然显示警告
target_compile_options(third_party_lib PRIVATE -Wno-error)
如果你是直接 #include 第三方头文件,可以用 #pragma 包起来:
#pragma GCC diagnostic push
#pragma GCC diagnostic ignored "-Wdeprecated"
#pragma GCC diagnostic ignored "-Wsign-conversion"
#include "third_party_lib.h"
#pragma GCC diagnostic pop
总结:生产级 C/C++ 代码审查工具链
静态(按使用频率)
每次编译: -Wall -Wextra -Wpedantic -Werror + 推荐警告集
每次提交: clang-tidy
每日构建: GCC -fanalyzer (GCC 构建) + scan-build (Clang 构建)
动态(按使用频率)
每次运行: ASan + UBSan (-fsanitize=address,undefined)
每日构建: TSan (-fsanitize=thread)
发布前: MSan (-fsanitize=memory, 仅 Clang)
GCC vs Clang 选择建议
| 场景 | 推荐 |
|---|---|
| 发布构建 | GCC |
| 日常开发/动态分析 | Clang(MSan 独占 + Sanitizer 子项更多) |
| 静态分析 | 两者都跑(警告和分析器互补) |
一句话:你不需要买任何第三方工具,用好 GCC 和 Clang 自带的分析能力,就已经超过了 90% 的 C/C++ 项目。