27、Sanitizer与调试:AddressSanitizer、UndefinedBehaviorSanitizer、ThreadSanitizer、调试符号设置

调试这件事,说白了就是跟 bug 斗智斗勇。我做了这么多年跨平台开发,最怕的不是逻辑复杂,而是内存越界、未定义行为这种「幽灵 bug」——它们不一定会立刻崩溃,但会在某个深夜突然炸掉你的发布版本。

嗯,今天要聊的 Sanitizer 工具集,就是专门对付这些幽灵的利器。它们不是玄学,是实实在在的编译器插桩技术。

27.1 调试符号:-g 到底在干什么?

先说说最基础的调试符号设置。很多新手以为加个 -g 就完事了,其实这里头有讲究。

在 CMake 里,我们通常这样设置:

# 设置调试符号
set(CMAKE_CXX_FLAGS_DEBUG "${CMAKE_CXX_FLAGS_DEBUG} -g")
set(CMAKE_C_FLAGS_DEBUG "${CMAKE_C_FLAGS_DEBUG} -g")

# 或者更推荐的方式
set(CMAKE_BUILD_TYPE Debug)
# CMake 会自动添加 -g

我个人习惯在 Release 版本也保留部分调试符号:

# Release 版也生成符号,方便线上问题定位
set(CMAKE_CXX_FLAGS_RELEASE "${CMAKE_CXX_FLAGS_RELEASE} -g1")
# -g0: 无符号
# -g1: 最小符号(函数名、行号)
# -g2: 完整符号(默认)
# -g3: 包含宏定义
我的经验:线上环境用 -g1 就够了。既能定位崩溃堆栈,又不会让二进制膨胀太多。我曾经见过一个项目开了 -g3,二进制体积直接翻了三倍。

27.2 AddressSanitizer:内存错误的照妖镜

AddressSanitizer(简称 ASan)是我最常用的工具。它能在运行时检测内存越界、释放后使用、双重释放等问题。

在 CMake 中启用 ASan:

# 方法一:直接设置编译和链接标志
set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -fsanitize=address -fno-omit-frame-pointer")
set(CMAKE_EXE_LINKER_FLAGS "${CMAKE_EXE_LINKER_FLAGS} -fsanitize=address")

# 方法二:使用 CMake 3.13+ 的 target 属性
target_compile_options(my_target PRIVATE -fsanitize=address -fno-omit-frame-pointer)
target_link_options(my_target PRIVATE -fsanitize=address)

来看一个典型的例子:

#include <cstring>
#include <iostream>

int main() {
    char* buffer = new char[10];
    strcpy(buffer, "Hello, World!");  // 越界了!
    std::cout << buffer << std::endl;
    delete[] buffer;
    return 0;
}

编译运行后,ASan 会直接告诉你:

==12345==ERROR: AddressSanitizer: heap-buffer-overflow on address 0x60200000001f
WRITE of size 14 at 0x60200000001f thread T0
    #0 0x4a1b2c in main /path/to/main.cpp:6
注意:ASan 会显著增加内存占用(约 2-3 倍)和运行时间(约 2 倍)。所以只在 Debug 或测试环境开启,千万别带到生产环境。

27.3 UndefinedBehaviorSanitizer:未定义行为的侦探

UndefinedBehaviorSanitizer(UBSan)专门抓那些「编译器觉得你错了但不会报错」的行为。比如整数溢出、空指针解引用、类型双关等。

启用方式:

# 启用 UBSan
target_compile_options(my_target PRIVATE -fsanitize=undefined)
target_link_options(my_target PRIVATE -fsanitize=undefined)

# 也可以组合使用
target_compile_options(my_target PRIVATE 
    -fsanitize=undefined
    -fsanitize=address
    -fno-sanitize-recover=undefined  # 遇到 UB 直接终止
)

举个例子:

int main() {
    int x = INT_MAX;
    int y = x + 1;  // 整数溢出,未定义行为
    return 0;
}

UBSan 会输出:

runtime error: signed integer overflow: 2147483647 + 1 cannot be represented in type 'int'
避坑指南:我曾经在一个图像处理项目里,因为整数溢出导致某些图片出现诡异的条纹。当时排查了两天,最后用 UBSan 一跑就现原形了。从那以后,我所有涉及数学运算的模块都会开 UBSan。

27.4 ThreadSanitizer:多线程的照妖镜

ThreadSanitizer(TSan)专门检测数据竞争和死锁。它比 ASan 更「重」,但对付多线程问题非常有效。

启用方式:

# TSan 不能和 ASan 同时使用
target_compile_options(my_target PRIVATE -fsanitize=thread -fno-omit-frame-pointer)
target_link_options(my_target PRIVATE -fsanitize=thread)

来看一个经典的数据竞争例子:

#include <thread>

int counter = 0;

void increment() {
    for (int i = 0; i < 100000; ++i) {
        counter++;  // 数据竞争!
    }
}

int main() {
    std::thread t1(increment);
    std::thread t2(increment);
    t1.join();
    t2.join();
    return 0;
}

TSan 会报告:

WARNING: ThreadSanitizer: data race (pid=12345)
  Write of size 4 at 0x7f1234567890 by thread T1:
    #0 increment() /path/to/main.cpp:6

  Previous write of size 4 at 0x7f1234567890 by thread T2:
    #0 increment() /path/to/main.cpp:6
我的建议:TSan 会大幅拖慢程序(5-10 倍),所以只在单元测试或压力测试时开启。另外,记得加上 -fsanitize=thread 的链接标志,否则可能链接失败。

27.5 知识体系总览

下面这张图总结了 Sanitizer 工具集的核心逻辑:

Sanitizer 工具集知识体系 CMake Sanitizer 配置 AddressSanitizer UndefinedBehaviorSanitizer ThreadSanitizer 检测:内存越界、释放后使用 标志:-fsanitize=address 检测:整数溢出、空指针解引用 标志:-fsanitize=undefined 检测:数据竞争、死锁 标志:-fsanitize=thread 建议:Debug 环境全开,Release 环境慎用

27.6 实战:在 CMake 中统一管理 Sanitizer

我个人习惯写一个 CMake 函数来统一管理这些选项:

function(enable_sanitizer target mode)
    if(mode STREQUAL "asan")
        target_compile_options(${target} PRIVATE -fsanitize=address -fno-omit-frame-pointer)
        target_link_options(${target} PRIVATE -fsanitize=address)
    elseif(mode STREQUAL "ubsan")
        target_compile_options(${target} PRIVATE -fsanitize=undefined -fno-sanitize-recover=undefined)
        target_link_options(${target} PRIVATE -fsanitize=undefined)
    elseif(mode STREQUAL "tsan")
        target_compile_options(${target} PRIVATE -fsanitize=thread -fno-omit-frame-pointer)
        target_link_options(${target} PRIVATE -fsanitize=thread)
    elseif(mode STREQUAL "all")
        # 注意:TSan 不能和 ASan 同时使用
        target_compile_options(${target} PRIVATE 
            -fsanitize=address
            -fsanitize=undefined
            -fno-omit-frame-pointer
            -fno-sanitize-recover=undefined
        )
        target_link_options(${target} PRIVATE 
            -fsanitize=address
            -fsanitize=undefined
        )
    endif()
endfunction()

# 使用示例
add_executable(my_app main.cpp)
enable_sanitizer(my_app "asan")
核心要点:
  • 调试符号 -g 是基础,建议 Debug 用 -g2,Release 用 -g1
  • ASan 抓内存问题,UBSan 抓未定义行为,TSan 抓线程问题
  • ASan 和 TSan 不能同时启用,但 ASan 可以和 UBSan 组合
  • 所有 Sanitizer 都会降低性能,只适合调试和测试环境

嗯,这些工具用好了,能省下你大量排查 bug 的时间。我到现在还记得第一次用 ASan 抓到那个隐藏了两周的内存泄漏时的快感——就像在黑暗里突然开了灯。


公众号:蓝海资料掘金营,微信deep3321