ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Sanitizer实战指南:C/C++内存越界与未定义行为嗅探

Sanitizer实战指南:C/C++内存越界与未定义行为嗅探

把 Sanitizer 说成 bug 嗅探器,老 C/C++ 玩家基本秒懂。这组工具不靠猜,也不靠在大海里捞针式地翻日志,而是直接钻进编译器和运行时里,把内存越界、未定义行为、数据竞争这些最难缠的 Bug 一个个揪出来。我在给某个 C++ 服务项目做排障时,用这套工具链挖出了连 gdb 都追不上的偶发崩溃元凶,从那以后它就成了我所有 C/C++ 项目的标配调试手段。

这篇文章要讲清楚几件事:Sanitizer 到底是什么、为什么它能嗅到那些“看不见的 bug”、怎么用最短时间把它接入你的项目、以及我实际踩过的一些坑。适合写 C/C++、做底层库、搞网络服务、做游戏引擎或者嵌入式固件的朋友参考。如果你还在靠断点一步步抓内存问题,建议把这篇看完再动手。

1. Sanitizer 到底嗅的是什么

1.1 一次诡异崩溃把我带进 Sanitizer 的世界

先说一个真实经历,我用模拟项目 X 代称那个服务。这个 C++ 网络服务平时压测都很稳,一旦到了高峰期,每秒请求涨上去之后就开始偶发崩溃。最气人的是,每次拿 core dump 用 gdb 看调用栈,栈都不一样——有时候是哈希表内部,有时候是字符串拷贝,甚至有一次崩在内存分配函数里。

这种“每次栈不一样”的现象,老手基本心里有数:大概率是某段代码在某个时刻写坏了内存,导致后面任意一个内存操作都可能踩雷。但问题在于,写坏内存的那一行和最终崩溃的那一行通常离得很远,日志里根本看不出线索。后来同事建议试一下 Sanitizer,我们重新编译一版,跑了不到十分钟的回归测试,ASan 直接报了一个 heap-buffer-overflow,指向一个循环里对动态数组的越界写入。报错信息里清清楚楚写着访问栈、分配栈,业务代码的位置精确到行号。

这个案例的价值不在于“找到了一个 bug”,而在于它解释了 Sanitizer 的核心逻辑:它检测的是“程序产生了未定义行为”这件事本身,而不是“程序最终崩溃”这个结果。内存写坏的那一刻它就报警,不等事故发生才收拾现场。

1.2 编译插桩:Sanitizer 的看家本领

Sanitizer 最特别的地方,是它不需要你调用任何 API,也不需要改业务代码。它的工作发生在编译阶段:编译器在生成机器码的时候,自动把你代码里的每一次内存读写、每一次算术运算、每一次 malloc/free 调用点前后,都插入一段检查代码。

这里可以用“安检扫描仪”来类比。普通程序是裸跑的,出了事才报警;Sanitizer 编译出来的程序,相当于在每个关键通道上都站了一个安检员,任何越界访问、非法运算都当场拦截。

具体到实现机制,几个主要成员各有套路:

  • ASan,也就是 AddressSanitizer,维护了一块“影子内存”,把应用程序的内存按大约 8:1 的比例映射到一块状态区域。每个字节的状态记录着对应内存是否可读、可写、已分配、已释放。程序每次读写内存前,插桩代码先去查状态位,一旦发现状态和操作不匹配就立即报错。
  • UBSan,也就是 UndefinedBehaviorSanitizer,对整数溢出、移位越界、除零、空指针操作这类未定义行为加运行时检查。它不干预正常访问,只在你触发了标准认为“不可预测”的操作时告诉你。
  • TSan,ThreadSanitizer,记录每个线程的内存访问和同步操作,根据 happens-before 关系判断是否存在没有同步保护的并发访问,一旦发现就报 data race。
  • MSan,MemorySanitizer,同样用影子内存,但标记的是“这块内存有没有被初始化”,一旦读到未初始化的数据就报警。

这些机制有一个共同优势:它们在问题发生的那一刻就拿到第一手现场。传统的 gdb 事后调试,拿到的是破坏后的残留状态,很难反推出源头;Sanitizer 则是当场抓住现行,效率和准确性完全是两个维度。

1.3 和传统调试手段的对比

很多朋友会问:既然 gdb、Valgrind 这些工具也能查内存问题,我为什么要改用 Sanitizer?这个问题我整理过一个对比表格,几乎每次分享都会放出来。

手段工作机制性能损耗适合场景
gdb + watchpoint断点观察,逐条命中极慢,无法大规模自动化单次能稳定复现的问题定位
Valgrind二进制动态翻译,不需要重编译通常慢几十倍没有源码、无法重编译的二进制排查
Sanitizer 系列编译期插桩,运行时直接检测内存约 1.5 到 2 倍,时间约 1.5 到 10 倍开发自测、持续集成、回归测试

Valgrind 最大的优点是不用改构建系统,直接拿现成的程序跑就行,代价是性能开销巨大。一个需要跑几分钟的测试套件,用 Valgrind 可能要跑几个小时,这在团队日常开发里几乎是不可接受的。

Sanitizer 则相反,前期需要改一下编译参数,但跑起来的速度损耗小得多,完全可以放在测试流水线里长期开着。这也是现在主流 C/C++ 项目普遍选择 Sanitizer 而不是 Valgrind 作为“日常巡检工具”的核心原因。

2. 从零接入:编译、运行、CMake 三步走

2.1 编译参数到底怎么给

接入 Sanitizer 的第一步是编译。最常见的组合是 ASan 加 UBSan,一条命令就能同时开启:

gcc -g -O1 -fsanitize=address,undefined -fno-omit-frame-pointer \ -o demo demo.c

如果项目是 C++,把 gcc 换成 g++,或者直接用 clang/clang++,参数完全一致。这几个参数不是随便加的,每一个都有讲究:

  • -g:生成调试符号。没有它,Sanitizer 报告里的调用栈会是一串地址,符号化之后才能看到函数名和行号。
  • -O1:官方推荐的优化级别。-O0虽然编译出的代码信息最全,但运行时开销偏大;-O2和-O3可能让某些未定义行为被优化器“合法地”改变表现,导致检测结果不稳定。折中下来-O1最稳。
  • -fno-omit-frame-pointer:保留帧指针。这个参数让调用栈回溯更完整,不会出现中间几层函数凭空消失的情况。
  • -fsanitize=address,undefined:同时开启 ASan 和 UBSan。多个检测器用逗号分隔,一次性写进去即可。

还有一个特别容易忽略的点:编译和链接阶段必须带上同一个-fsanitize参数。Sanitizer 本质上是一个运行时库,链接器需要把它打进度量对象中。如果只是编译时开了头、链接时漏掉,或者子模块和主程序参数不一致,最后往往会出现诡异的链接错误或者运行时直接崩溃。

2.2 运行期配置与日志解读

编译只是第一步,运行时的环境变量配置决定你拿到的是“一堆刷屏警告”还是“一份精准报告”。我常用的启动命令是这样:

ASAN_OPTIONS=detect_leaks=1:halt_on_error=1:abort_on_error=0 \ UBSAN_OPTIONS=print_stacktrace=1:halt_on_error=1 \ ./demo

逐个解释一下这些选项想干什么:

  • detect_leaks=1:开启内存泄漏检测。ASan 里内置的 LSan 在 Linux 上默认就是开启的,这里显式写出来,一方面是为了明确意图,另一方面是方便排查特定问题。
  • halt_on_error=1:遇到第一个错误就停止退出。如果不加这个,ASan 默认会尽可能多地报告错误,但大型测试里这样容易刷屏,反而掩盖最关键的问题。
  • abort_on_error=0:出错后调用 exit 正常退出,而不是直接 abort。如果想让程序在出错时生成 core dump,可以改成 1。
  • print_stacktrace=1:UBSan 除了打印错误文本,还会输出完整调用栈。不加这个,你往往只看到一行“signed integer overflow”却不知道是哪儿触发的。
  • halt_on_error=1:让 UBSan 从“打印警告继续跑”变成“报错退出”,否则某些测试用例会在错误状态下继续执行,产生连锁反应。

ASan 的报告长这样,我截一个简化版:

==3456==ERROR: AddressSanitizer: heap-buffer-overflow on address 0x602000000010 at pc 0x4e0d ...READ of size 4 at 0x602000000010 thread T0 #0 0x4e0d9e in main /home/dev/demo.c:12 #1 0x7f...

怎么看这份报告:第一行告诉你错误类型是堆缓冲区越界;接着告诉你这次操作是读还是写、读了多少字节;最关键的是下面的调用栈。调用栈里前几行经常以__interceptor_或__asan_开头,这些是 Sanitizer 内部函数,不用看,直接找到第一个业务代码函数,那就是你真正要去改的地方。

2.3 CMake 一键开启 Sanitizer 的推荐写法

如果项目比较小,直接在命令行加参数没问题。但稍微正式一点的项目,我强烈建议把 Sanitizer 做成一个构建选项,用 CMake 统一管理,避免每个开发者人肉拼接编译参数。

推荐写法如下:

option(ENABLE_SANITIZER "Build with AddressSanitizer and UndefinedBehaviorSanitizer" OFF) if(ENABLE_SANITIZER) message(STATUS "Enable AddressSanitizer + UndefinedBehaviorSanitizer") add_compile_options(-fsanitize=address,undefined -fno-omit-frame-pointer -g -O1) add_link_options(-fsanitize=address,undefined) endif()

add_compile_options和add_link_options会让这个目录下所有目标都继承配置,不用在每个 target 里重复写。但注意,如果项目里有目标链了外部闭源库、或者有手写的汇编文件,可能需要用target_compile_options单独覆盖,不要全局一刀切。

在 CI 里,可以单独开一个任务来跑 Sanitizer 构建。我的习惯是把 ASan+UBSan 构建放在一个独立 job 里,让它跑完整的测试套件,因为这个 job 的耗时明显会比普通构建长,混在主任务里容易拖垮流水线节奏。

3. 四大嗅探模式怎么选

3.1 ASan:内存越界和释放后使用的克星

ASan 是 Sanitizer 家族里使用最广、性价比最高的一员。它能检测的问题清单很长,最常遇到的是这几类:

  • heap-buffer-overflow:堆缓冲区越界,也就是 malloc 出来的内存越界访问。
  • stack-buffer-overflow:栈缓冲区越界,比如局部数组下标写超了。
  • global-buffer-overflow:全局变量越界。
  • use-after-free:释放后使用,指针指向的内存被 free 之后又去读写。
  • double-free:同一块内存被释放两次。
  • alloc-dealloc-mismatch:malloc 出来的内存用 delete 释放,或者反过来。

我实际排查过的线上问题里,use-after-free 是最隐蔽的一类。它通常不会马上崩,因为操作系统可能还没有把这块内存回收给别的程序用,第一次读写看起来是正常的。但一旦堆被大规模复用,踩到被修改过的元数据,就会以一种完全随机的方式崩溃。ASan 的价值在于,它在 free 之后会把这块内存的状态标记为“已释放”,后续任何读写都会当场触发报错,直接把问题暴露在你面前。

3.2 UBSan:未定义行为猎手

UBSan 的定位和 ASan 不同,它检查的不是内存布局问题,而是标准明确禁止的未定义行为。常见有:

  • signed integer overflow:有符号整数加法乘法溢出。
  • divide by zero:除零,包括整数和浮点数的某些边界情况。
  • shift-out-of-bounds:移位位数超过类型宽度。
  • null pointer access:空指针解引用。
  • misaligned pointer:指针未按类型对齐。

有一个项目案例让我印象很深。某个服务里一个统计请求量的 int 变量,在极端流量下发生了有符号整数溢出。这个溢出在当时的编译优化下没有导致崩溃,数据看起来只是“不太对”,线上排查了半个月毫无头绪。后来把 UBSan 打开,跑一遍历史流量回放,不到十分钟就抓到了signed integer overflow: 2147483647 + 1 cannot be represented in type 'int'的报错。这类 bug 是静态分析很难发现的,因为代码路径依赖特定输入数据,而 UBSan 的运行时检测恰好能在真实数据流下命中。

UBSan 的性能开销比 ASan 小得多,几乎可以常驻。我自己的经验是,日常开发构建里长期开启 UBSan 基本无感,但能拦截大量“看起来没崩但严格来说已经踩到未定义行为”的隐患。

3.3 TSan:多线程数据竞争专用

数据竞争是并发程序里最阴间的 bug。两个线程同时读写同一个变量,理论上每次执行结果都可能不同,而且很多时候跑几万次都不出错,偏偏在极端调度下踩一脚。TSan 就是专门干这个的。

它的基本原理是:插桩记录每次内存访问和每条同步操作,然后根据 happens-before 关系判断两个线程是否在没有同步保护的情况下访问了同一处内存,如果是,就报 data race。运行时会输出类似下面的警告:

WARNING: ThreadSanitizer: data race (pid=...)

TSan 的检测能力很强,但代价也明显:内存占用大约是正常的 5 倍,运行时间可能慢 5 到 10 倍。这决定了它不适合全量常驻在测试流水线里,更适合在并发模块或锁密集代码的专项测试阶段开启。

用 TSan 还要注意一个兼容性问题:它检测的是程序里所有线程的同步行为,如果程序链接了某些没有经过 TSan 插桩的库,这个库内部的同步操作对 TSan 来说是“透明”的,可能导致误报或漏报。另外,TSan 不能和 ASan、MSan 同时开启,互斥规则后面专门讲。

3.4 MSan:未初始化内存雷达

MSan 检测的是“读取未初始化内存”的问题。听起来不如越界、竞争那么常见,但实际项目里这类问题一点不少。最常见的是结构体成员忘记初始化,或者用 malloc 分配的对象只填了一部分字段就整体拷走。

MSan 有个非常硬的使用限制:只能配合 Clang 系编译器使用,GCC 不支持。更麻烦的是,程序链接的所有代码,包括第三方库,都必须用-fsanitize=memory重新编译,否则初始化状态会在库边界断裂,产生大量误报。单凭这一条,很多大型项目就推行不下去,因为总有一两个闭源或者编译器不兼容的依赖。

所以我的建议是:只有当你对项目的所有依赖源码都有完全控制权时,才去碰 MSan。其他场景,优先把 ASan+UBSan 做好。

3.5 组合策略与互斥规则

不少文章把 Sanitizer 讲得像“越多越好”,但实际组合有一些硬性约束。这是我整理的一张可用性表:

组合方案能否共存备注
ASan + UBSan + LSan可以日常回归最推荐,顺带内存泄漏检测
TSan + 其他否TSan 必须单独开启
MSan + 其他否MSan 必须单独开启

原因不难理解:TSan 和 ASan 都需要拦截内存操作,两个运行时同时介入会互相干扰,轻则漏报,重则直接崩。MSan 因为要维护独立的初始化状态元数据,和其他检测器同时开启也会冲突。UBSan 相对独立,只检查算术和指针相关操作,所以能和 ASan 共存。

我的推荐策略很简单:

  • 日常开发和常规回归测试:ASan + UBSan,LSan 默认带着。
  • 并发模块专项测试:单独开 TSan。
  • 底层库完全自控源码:单独开 MSan。
  • 发布前的最后一轮测试:ASan+UBSan 全量回归,这是底线。

4. 常见坑与排查技巧实录

4.1 第三方库导致的误报怎么处理

用 Sanitizer 迟早会遇到一种尴尬局面:程序自己没问题,但某个第三方库内部的内存越界被 ASan 逮住了。这时候不少人第一反应是“Sanitizer 误报”,其实大多数情况下是那个库真的有问题。

我的排查思路分三步:

  1. 看报告里的分配栈和操作栈。如果操作点在整个库内部,而且你的调用方式也挑不出毛病,那基本可以判定是这个库的 bug。
  2. 去查这个库有没有新版本,或者有没有已知问题的修复补丁。能升级就升级,不要急着屏蔽。
  3. 如果实在不能升级,再用 suppression 文件针对性地过滤。格式大概是这样的:
leak:known_leak_function interceptor:strlen

然后在启动时指定:

ASAN_OPTIONS=suppressions=supp.txt ./demo

但这里有个原则:能精确到函数就精确到函数,不要整个文件或者整个模块地屏蔽。否则这个库以后真的引入新的内存问题,你的 Sanitizer 也会跟着失明。

4.2 性能暴涨和内存用量问题

Sanitizer 不是免费的,ASan 的内存占用大约翻倍,运行时间慢 1.5 到 2 倍;TSan 更夸张。第一次跑的时候,很多人会被内存占用吓到,以为程序出了问题。实际上这是正常的。

如果内存压力实在太大,有几个应急的调节项:

ASAN_OPTIONS=quarantine_size_mb=64:detect_stack_use_after_return=0:detect_leaks=0
  • quarantine_size_mb:控制被释放内存的保留池大小。缩小它可以显著降低内存占用,但对 use-after-free 的检出能力会有影响,属于权衡。
  • detect_stack_use_after_return:关闭这个检测能省一点内存,但也少了一层保护。
  • detect_leaks=0:直接关掉泄漏检测,内存占用还能再降一截。

不过我想强调:这些参数只适合在内存受限的环境里应急,不要为了省内存把 Sanitizer 的功能全关了。如果开 Sanitizer 跑不动,更合理的做法是缩小测试范围,而不是阉割检测能力。

另外,不要在 Sanitizer 构建里用-O3去“补偿”性能。优化级别越高,编译器对未定义行为的重排就越激进,你可能看到的是错误被优化掉或者错误位置偏到十万八千里的诡异现场。老老实实-O1,这是无数项目验证过的稳定组合。

4.3 动态库、dlclose 与多进程场景

如果项目里大量使用动态库,尤其是运行时用 dlopen 加载插件,Sanitizer 会遇到一些需要额外注意的情况。

已加载的动态库本身是能被检测的,但一旦 dlclose 卸载,ASan 内部记录的内存映射和符号信息可能出现错乱,个别时候会报出内部错误或者误报。我的建议是:在做 Sanitizer 测试的构建里,尽量不要在运行时卸载插桩库,把需要卸载的场景留到正常构建去验证。

多进程程序是另一个高频坑。每个进程崩溃时都会把报告写到同一个 stderr,如果父进程子进程同时出错,日志会搅在一起,看起来像两份报告互相穿插。解决方法是把日志按进程拆分:

ASAN_OPTIONS=log_path=/tmp/asan_log ./demo

这样 ASan 会按进程 PID 生成独立的文件,比如asan_log.12345和asan_log.12346。排查多进程问题的时候,这个习惯能省掉大量整理日志的时间。

还有一点是关于 fork 子进程。子进程崩溃时父进程不一定能及时捕获到关键栈信息,建议在子进程可能出错的场景里也加上log_path和abort_on_error=1,让每个进程都能独立留下现场。

4.4 CI 落地经验

把 Sanitizer 接入 CI,是让这套工具真正发挥价值的最后一步。我的经验是单独开一个 sanitizer job,不要混合在主测试任务里。

CI 里推荐的环境变量配置是这样:

export ASAN_OPTIONS=detect_leaks=1:halt_on_error=1:malloc_context_size=20:log_path=/tmp/asan export UBSAN_OPTIONS=print_stacktrace=1:halt_on_error=1

malloc_context_size=20表示 ASan 在记录内存分配栈时最多回溯 20 层,适当提高这个值能让分配点的上下文更清晰,但同时也会增加内存开销。测试阶段设为 20 是比较平衡的选择。

job 的超时时间要单独放宽,因为 Sanitizer 构建比普通构建慢不少。但也要设上限,比如正常任务 10 分钟,这个给 30 分钟,超过就报警,避免流水线被长时间卡死。

还有一个很容易忽略的细节:如果测试套件里存在历史内存泄漏用例,LSan 会在程序退出时报一堆泄漏,导致 job 一直红着。正确做法是把这些泄漏修掉,而不是用detect_leaks=0关掉。把泄漏检测打开,能让你在项目早期就意识到资源管理的问题,而不是等上线后内存一路飙升才回头补课。

我个人体会最深的一点是:Sanitizer 不是一个“偶尔想起来跑一次”的工具,而是要变成日常开发流程的一部分。我在实际项目里踩过几次坑之后,已经养成了固定习惯:新代码必须过 ASan+UBSan 才能合入,并发模块额外跑 TSan。它不会替你找到所有逻辑错误,但能让你从内存错误和未定义行为这个泥潭里解放出来,把精力真正花在处理业务逻辑上。如果你还没把这条 bug 嗅探器接进项目,我建议明天就试一次——改一下编译参数,跑一遍测试,你会闻到很多以前根本闻不到的 bug 味道。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进