Valgrind 与 Sanitizers 实战

本页目录

这些工具通过观察程序实际执行发现错误。它们能给出比“某处崩溃”更早、更具体的证据,但未执行路径仍可能有问题。应先区分要检测的是地址越界、未初始化数据、语言未定义行为,还是多线程访问缺少同步。

快速选择

问题常用工具主要边界
堆越界、释放后使用ASan、Memcheck覆盖和对象布局、分配器集成有关
栈或全局对象越界ASan不等于发现一切对象内部越界
未初始化值影响行为Memcheck、MSanMSan 需要依赖库的插桩配合
有符号溢出、错位访问等UBSan只检查启用项目,不覆盖所有 UB
数据竞争TSan、Helgrind需覆盖相关执行与同步机制
堆分配增长、峰值Massif 等 profiler内存增长不一定是泄漏
可达性意义上的泄漏LSan、Memcheck仍可达对象可能是业务上不需要的保留

检测器和 profiler 的目的不同。Cachegrind 给出模拟或插桩统计,不等于真实 CPU 的 PMU 测量;Massif 描述内存随执行的变化,不保证替你判定所有对象的所有权错误。

Valgrind:对执行的机器码进行分析

Valgrind 通过动态二进制翻译,在执行路径上加入工具所需的分析。通常不要求对目标重新插桩,但调试符号有助于定位;目标架构、系统调用、指令集与运行时仍须受支持。

valgrind --tool=memcheck --leak-check=full --show-leak-kinds=all \
  --errors-for-leak-kinds=definite,possible --error-exitcode=99 ./prog
valgrind --tool=memcheck --track-origins=yes ./prog
valgrind --tool=helgrind ./threaded_prog
valgrind --tool=massif ./prog

track-origins 主要帮助追踪未初始化值的来源,不是“打开后才知道泄漏在哪分配”。Memcheck 分别跟踪地址可访问性与值是否已定义;一个地址可以合法,但里面的值尚未初始化。Memcheck 手册

Memcheck 不是源码级的每个数组边界检查器。栈或全局数组越界后若仍落在可访问区域,可能无法报告。静态链接也不能笼统写成绝对不支持:分配器函数替换可能受影响,需要按工具文档处理。Valgrind FAQ

它需要额外的影子状态和翻译缓存,因此“无额外内存开销”错误。时间和内存开销应按具体工具、负载测量,不能把整个工具族固定成一个倍率。

ASan:检查被访问地址是否允许使用

ASan 在编译时插入检查,并与运行时分配器等机制配合。常见实现用一个影子字节描述 8 个应用字节,配合对象周围红区和释放后毒化;隔离队列延迟地址复用,有助于发现释放后使用。

ASan 示意:有效地址范围与影子状态共同检查 分配 10 字节:两个 8 字节粒度的影子项 前 8 字节全部可访问 shadow = 0 2 字节 有效 后 6 字节 不可访问 第二个影子项 = 2 常见 ASan 映射;红区和释放后的毒化用于发现错误,不能证明所有对象边界都安全。

这能发现许多堆、栈与全局越界,但不追踪所有程序语义。例如越界恰好进入另一块仍合法的对象、对象内部某些越界,或没有被插桩的访问,未必报告。ASan 也不负责一般的未初始化值传播。AddressSanitizer 文档

一个 shadow 字节,如何描述八个应用字节?

对象长10字节时,两个粒度分别是00和02。起点9、宽度1合法,宽度改成2则包含偏移10的越界字节,即使起点仍合法也应拒绝。释放后原先有效的区域被毒化;这解释了为什么对象生命周期与当前地址是否仍有映射不能混为一谈。

正在呈现知识画面
一个 shadow 字节,如何描述八个应用字节?

shadow=0 表示八字节可访问,1–7表示此前缀可访问,毒化标记表示不可访问。检查必须覆盖整次访问,而不是只看起点。

一个实际堆越界例子

将下面保存为 bounds.c。它故意允许下标等于长度,通过单独的访问函数让错误更容易观察;代码仅用于本地检测器演示。

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

__attribute__((noinline))
static void store(unsigned char *p, size_t index) {
    p[index] = 7;
}

int main(int argc, char **argv) {
    size_t index = argc > 1 ? strtoul(argv[1], NULL, 10) : 10;
    unsigned char *p = malloc(10);
    if (p == NULL) return 1;
    store(p, index);
    printf("%u\n", (unsigned)p[index]);
    free(p);
    return 0;
}
clang -g -O1 -fno-omit-frame-pointer -fsanitize=address \
  bounds.c -o bounds-asan
./bounds-asan 9
./bounds-asan 10

9 是最后一个有效下标;10 指向分配区间之后,预期报告 heap-buffer-overflow。报告中的访问位置回答“哪里进行了非法访问”,分配栈回答“这片区域在哪里创建”,二者需要一起读。地址和具体栈格式因构建与运行而不同。

修复应明确输入范围及数据长度关系,例如访问前验证 index < 10;不能只把 malloc 改大一个字节而忽略任意下标的问题。保留触发输入作为回归用例,重新运行有效和无效路径。

修复输入解析与边界,而不是扩大分配掩盖一次报错

下面保存为 bounds-fixed.c。它只接受非空十进制数字,不允许负号、尾随文本或超出 size_t 的数值;转换成功后再检查业务下标范围。非法输入以退出码 2 拒绝,分配失败为 1。

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

static int parse_index(const char *text, size_t *out) {
    size_t value = 0;
    if (*text == '\0') return 0;
    for (; *text; ++text) {
        if (*text < '0' || *text > '9') return 0;
        size_t digit = (size_t)(*text - '0');
        if (value > (SIZE_MAX - digit) / 10) return 0;
        value = value * 10 + digit;
    }
    *out = value;
    return 1;
}
int main(int argc, char **argv) {
    const size_t length = 10;
    size_t index = 0;
    if (argc != 2 || !parse_index(argv[1], &index) || index >= length) {
        fputs("expected index in 0..9\n", stderr);
        return 2;
    }
    unsigned char *bytes = malloc(length);
    if (!bytes) return 1;
    bytes[index] = 7;
    printf("%u\n", (unsigned)bytes[index]);
    free(bytes);
    return 0;
}
clang -g -O1 -fno-omit-frame-pointer -fsanitize=address,undefined \
  -fno-sanitize-recover=undefined bounds-fixed.c -o bounds-fixed
./bounds-fixed 0
./bounds-fixed 9
./bounds-fixed 10
./bounds-fixed 9junk
./bounds-fixed -1

前两条成功并打印 7,后三条应在内存访问前拒绝,而不是依靠 ASan 再次终止进程。回归还要加入空字符串和超大数字;解析成功与地址合法是两层条件,不能只验证其中一层。原来 strtoul 没有检查 endptr 和转换错误,单纯追加 index < length 仍会把部分非预期文本解析成合法前缀。

报告中的三个位置分别回答不同问题

报告信息应如何追踪
WRITE/READ of size N核对实际访问宽度与索引,不仅看指针值
访问调用栈找出执行越界的路径,继续追查参数来源
分配区域与分配栈核对分配长度、元素单位与生命周期
freed by(若存在)找出提前释放和仍持有旧指针的拥有关系
shadow 状态帮助区分红区、释放后区域等,不作为业务原因本身

例如“地址位于 10 字节区域之后 0 字节”表示正好踩到 one-past 边界。C 允许在相应规则下构造 one-past 指针用于比较或迭代终点,但不允许解引用它;报告不是说第十个有效元素也被误拦截。

UBSan:按语言规则检查

clang -g -O1 -fsanitize=undefined -fno-sanitize-recover=undefined \
  prog.c -o prog-ubsan

常见检查包括有符号整数溢出、非法移位、空指针和对齐问题。无符号整数按模运算溢出在 C/C++ 中通常是有定义行为,不应统称所有“整数溢出”都是 UB;Clang 提供额外检查一些可疑但有定义的行为,这与 undefined 组有区别。

UBSan 的许多检查默认报告后继续执行;用于自动化时需明确恢复、退出或 trap 策略。-fno-sanitize-recover=undefined 使对应错误停止,不再依赖人读日志才知道运行失败。UBSan 文档

有符号溢出需要在运算前判断

下面的 overflow.c 故意执行超出 int 范围的加法,用于观察 UBSan 的首个错误;volatile 只让此例的读取保留,不会使溢出合法。

#include <limits.h>
#include <stdio.h>
int main(void) {
    volatile int maximum = INT_MAX;
    int result = maximum + 1;
    printf("%d\n", result);
    return 0;
}

使用前面的 UBSan 命令,将 prog.c 换成 overflow.c。启用不恢复模式后预期非零退出并报告 signed integer overflow,不应依赖溢出后的数字继续计算。

正确做法是在加法发生前判断,下面是独立的 checked-add.c:

#include <assert.h>
#include <limits.h>
static int checked_add(int a, int b, int *out) {
    if ((b > 0 && a > INT_MAX - b) || (b < 0 && a < INT_MIN - b)) return 0;
    *out = a + b;
    return 1;
}
int main(void) {
    int result = 7;
    assert(!checked_add(INT_MAX, 1, &result));
    assert(result == 7);
    assert(!checked_add(INT_MIN, -1, &result));
    assert(result == 7);
    assert(checked_add(INT_MAX, INT_MIN, &result) && result == -1);
    assert(checked_add(INT_MIN, 0, &result) && result == INT_MIN);
    assert(checked_add(20, 22, &result) && result == 42);
}

out 在本例中由调用者提供有效指针,失败时不修改已有结果。不要先算 a+b,再通过结果是否变小来检测有符号溢出,因为第一次加法已经可能发生 UB。也不要将类型改成无符号后宣称需求已修复;按模回绕是否符合业务契约需要另行决定。UBSan 的溢出检查

TSan:检查跨线程访问是否建立同步关系

clang -g -O1 -fsanitize=thread -pthread threaded.c -o threaded-tsan

TSan 结合内存访问和同步事件发现潜在数据竞争。关键不是“两个线程是否恰好同一纳秒写入”,而是冲突访问是否缺少适当的 happens-before 关系。报告通常给出两个访问栈及线程创建信息。

把普通变量改成 volatile 不能建立线程同步。应根据数据结构使用互斥锁、原子操作或其他正确协议;即使全是原子访问,也仍可能存在工具不报告的高层逻辑竞态。

TSan 与 ASan 通常需要分开构建,不能简单把两个参数拼成全能检测器。自定义同步、未插桩库、内联汇编及平台支持会影响结果。ThreadSanitizer 文档

MSan 与泄漏检测

MSan 跟踪值的初始化状态,主要用于具备相应支持的 Clang 环境。它通常要求程序及相关依赖一致插桩;仅重编译 main 后直接混用任意预编译库,可能导致不完整结果或误报。需要追踪来源时可启用 origins,但会增加开销。MemorySanitizer 文档

LeakSanitizer 在支持的平台上可与 ASan 集成,也可单独使用,默认启用情况依平台而定。可达性分析认为仍有引用的内存不是同一种泄漏,但全局缓存无限增长仍可能是业务问题。应结合内存增长曲线、生命周期和所有权判断,而不是只看“0 leaks”。

地址合法、值已初始化、并发有同步,是不同检查维度 malloc 后未写入 地址可访问,值尚未定义 free 后仍读取 旧值曾初始化,地址已失效 两个线程无同步写入 地址/初始化都可能正常 Memcheck / MSan ASan / Memcheck TSan / Helgrind 检测到什么取决于执行覆盖、插桩和运行时支持;任何单一工具都不是完整证明。

全部换成原子操作,也不等于算法正确

考虑两个线程都原子 load 得到 0,然后都原子 store 写入 1;这些访问可以没有 C/C++ 数据竞争,但“各自增加一次,最终应为 2”的业务要求仍失败。应该使用匹配需求的原子 read-modify-write,如 fetch_add,或用互斥区覆盖整个复合操作。

TSan 报告帮助找缺失的同步边,不会替业务证明余额守恒、任务只执行一次或退出不丢消息。并发回归应同时断言业务不变量,并通过屏障或可控调度制造关键交错,避免仅靠反复 sleep 碰运气。

把检测器失败与程序错误分开记录

如果运行时无法初始化影子地址、平台不支持相关指令或插桩库不匹配,说明检测器没有完成有效检查,不能记录为“未发现错误”。修复环境后重新运行;工具报告真实访问错误时,则保存触发输入与匹配产物,再修复程序契约。

自动化至少要区分成功、业务拒绝、检测器发现错误与检测器自身未能运行。对本页修复程序,退出码 2 是预期输入拒绝;对故意越界程序,非零退出还必须伴随预期 heap-buffer-overflow 报告,不能把任意启动失败也算作测试成功。

组织成稳定的调试流程

优先保存最小触发输入和匹配的二进制、符号,先修复最早的可信报告,因为早期越界可能破坏后续状态。随后扩大输入覆盖,包括边界长度、失败分支和并发路径。

编译和最终链接阶段都要正确传入 sanitizer 选项并链接匹配运行时。第三方问题的抑制应精确说明原因,避免用大范围屏蔽隐藏自有代码问题。检测器开销会改变调度和布局,报告消失不自动证明并发错误不存在。

性能评估最终应回到符合交付配置的构建;检测器构建用来找错,不能直接作为原程序性能基线。CPU 热点分析接着看 perf 深度使用。

关键词:Valgrind, Memcheck, ASan, UBSan, TSan, MSan, LSan, shadow memory, redzone, data race, uninitialized value