ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

深入解析Sanitizer家族:ASan/LSan/UBSan/TSan内存调试实战指南

深入解析Sanitizer家族:ASan/LSan/UBSan/TSan内存调试实战指南 我印象里特别深的一次内存调试经历是在排查一个运行了快三个月的后台服务时它突然在凌晨崩了。core dump 拉了十天用 gdb 反复看堆栈代码走查了四遍最后发现是一块已经被 free 的内存被某个模块还在继续写。那行写内存的代码本身没错错的是所有者的生命周期管理——这种 bug 在 C/C 项目里太典型了不借助工具基本靠熬。后来我把项目统一接上了 sanitizer 这套内存检测工具这种问题基本就从“悬案”变成了“扫地机器人能扫的灰”。这篇博客就专门聊聊 sanitizer尤其是其中的 AddressSanitizerASan、LeakSanitizerLSan、UndefinedBehaviorSanitizerUBSan和 ThreadSanitizerTSan。我不打算只贴文档会把原理、编译参数、报告怎么读、哪些坑我踩过一次性讲清楚。适合正在写 C/C、Rust FFI、或者维护遗留代码的人也适合团队想建立内存安全 CI 门禁但不知道从哪下手的朋友。1. 内存问题为什么难查以及 sanitizer 为什么能查1.1 内存崩溃的迷惑性不是每段内存错误都会立刻崩先说说我那个凌晨崩溃的例子。如果是野指针解引用、空指针访问程序通常很快就崩了core dump 里问题地点写得清清楚楚。真正难缠的是另外两种写到了已经释放但还没被重新分配的内存以及越界写到了相邻对象里但没破坏关键数据。这种错误可能潜伏几周直到某个内存分配行为复用了那块区域程序才在完全无关的位置炸出来。这种 bug 的本质是你说不清楚是“谁先写错了内存”还是“谁在错误时间用了内存”。普通调试器只能看到爆炸点而爆炸点往往是无辜的。sanitizer 的思路不是“等爆炸去查”而是在每一块内存周围布上哨兵在每一次访问内存时做围栏检查。内存被写错的那一刻工具当场叫停而不是让错误在内存里流浪好几周之后再爆发。1.2 插桩和运行时守护sanitizer 的两层机制sanitizer 不是一个单一工具它最早来自 Google 的 LLVM 项目现在 GCC 和 Clang 都内置了完整实现。它的工作方式分为两层编译期插桩编译器会在每次内存访问、变量定义、函数调用位置插入检查代码。运行时库程序启动时一个专门的运行时库会接管 malloc/free 等内存操作维护额外的元数据比如哪块内存是活的、哪块已释放、对象边界在哪。这两层配合起来就能实现精确到“哪一行代码、哪一次访问、越界了多少字节”的检测。很多人觉得插桩会影响性能这是事实但收益是对应级别的。我自己的经验是与其在线上事故里熬几个通宵不如在测试环境里多花两分钟的构建时间。1.3 为什么比 Valgrind 更适合成为团队标配提内存检测很多老手会先想到 Valgrind。Valgrind 是纯动态二进制插桩不需要重新编译这是它的优势也是它的天花板性能开销太大通常慢 10 到 50 倍跑大一点的单元测试都让人难以忍受。而 ASan 在编译期插桩运行时开销大约是 2 倍速度、23 倍内存本地测试经验这个量级让它可以进 CI、可以跑完整测试套件。所以我的建议是如果代码完全不能重新编译、只能跑二进制那只能选 Valgrind只要条件允许重新编译优先接入 sanitizer。这不是淘汰关系而是不同场景的取舍。用生活类比来说Valgrind 像安检机你把行李放上去就能扫但每条行李都要花很久ASan 更像是给每件行李装了个定位器制造的时候就知道它会去哪出问题当场定位。2. 核心原理拆解ASan 和 LSan 到底在内存里做了什么2.1 Shadow Memory给每一块物理内存配一个“健康档案”ASan 最核心的设计叫 shadow memory影子内存。它的原理可以用一个很直白的方式来理解假设操作系统给了你一段物理内存空间ASan 在另一个特殊的映射区域为这段内存里的每 8 个字节维护 1 个字节的红绿灯信息。这个 1 字节记录的内容包括这段地址是否可读/可写距离对象边界的偏移量是多少这段内存是否已经被释放这段内存是不是某个函数栈帧的一部分。每次程序执行读写操作前插桩代码会先查一下对应的影子内存。如果红绿灯亮红灯直接触发报告并终止程序。整个查找过程在几十个指令内完成所以运行时开销远低于动态二进制翻译。说起来这就是“用空间换时间”的典型。多占 23 倍内存确实肉疼但换来了精确到位置的错误报告。早期 Android 系统也曾用这套机制做堆内存检测后来移动端逐渐换成 HWASan原理类似只是粒度从 8 字节变成了 16 字节并且用硬件特性降低了影子内存的开销。2.2 Redzone 和 Quarantine让越界与释放后使用无处可藏ASan 里的 malloc 实现会在真正返回给用户的内存前后额外增加一块称为 redzone红区的填充区域。这块红区在影子内存中标记为“不可访问”。如果你写代码越界不管是往前溢出还是往后溢出只要碰到红区ASan 立刻捕获。红区默认大小通常是 16 到 32 字节可通过编译选项调整。而对付 use-after-freeASan 用了另一招释放的内存不会立即归还操作系统而是进入一个被称为 quarantine隔离区的缓冲队列。这块内存被标记为“已释放但还没被重新分配”任何对它的访问都会触发报告。延迟归还的好处是很大概率上在程序再次访问已释放内存时这块内存还是原来的状态ASan 就能准确告诉你你在第 X 行访问了一块在第 Y 行释放的内存。当然quarantine 大小有上限用尽之后旧的内存还是会回到可用池。如果程序释放后很久才访问ASan 也有一定概率抓不到。但大部分 use-after-free 都在短时间窗口内暴露覆盖率已经相当可观。2.3 LSan 的原理靠“扫描根集合”找孤儿内存LeakSanitizerLSan通常集成在 ASan 里也可以独立使用。它的检测原理是在程序退出前把当前所有仍在堆上的内存块遍历一遍再从寄存器、栈、全局变量等根集合出发dfs 或者 bfs 可达性分析。如果某块内存没有任何路径能到达它就会被判定为“泄漏”。很多人会疑惑现代操作系统在进程退出时本来就会回收全部内存泄漏有什么好查的这是因为在长时间运行的服务里一天泄漏几 KB运行一个月后内存就爆了。LSan 的价值在于把“退出前的最终快照”导出来给你看分配序列、调用栈、泄漏大小直接把根因摆到面前。需要注意LSan 只精确检测“完全不可达”的块。如果是一个全局链表把一堆泄漏的节点还挂在上面那不算 LSan 的漏检而是这些节点确实还有引用你需要检查的是这个链表为什么被无限追加。实践中配合 ASan 的报告一起看一般能区分出这两种场景。3. 实操过程与核心环节实现3.1 编译选项怎么选从单文件到 CMake 项目接入 sanitizer第一步就是把编译和链接选项打开。以 GCC/Clang 为例单文件很简单gcc -fsanitizeaddress -g -O1 -o test test.c这里有三个关键选项-fsanitizeaddress开启 AddressSanitizer链接时也会自动链接 ASan 运行时-g保留调试信息否则报告里的调用栈只能看到地址看不到行号-O1这是官方推荐的最合适优化级别。-O0会让很多变量保留在栈上反而更容易漏报某些寄存器访问-O2及以上虽然也能用但优化可能改变代码执行顺序导致报告位置不够直观。CMake 工程里更建议把 Sanitizer 作为独立的构建类型而不是直接在全局编译选项里硬编码。我给团队推荐的方式是这样的# CMakeLists.txt 片段 option(ENABLE_ASAN Enable AddressSanitizer OFF) if(ENABLE_ASAN) add_compile_options(-fsanitizeaddress -fno-omit-frame-pointer -O1 -g) add_link_options(-fsanitizeaddress) endif()然后cmake -B build_asan -DENABLE_ASANON . cmake --build build_asan -- -j$(nproc)这样做的好处是构建产物和正常构建完全隔离ASan 版本不会污染发布产物。-fno-omit-frame-pointer是为了保留栈帧指针让报告的调用栈尽量完整。如果缺了这个选项很多时候报告底部会直接显示“??”非常影响排查。3.2 动态运行选项ASAN_OPTIONS 的环境变量实战编译期插桩只是把“探针”埋进去了真正控制探针灵敏度的是运行时的环境变量ASAN_OPTIONS。我第一次用的时候完全没配它结果程序崩了就停什么问题也查不出来——后来才意识到好多关键行为都需要通过这个变量打开。举几个最常用的export ASAN_OPTIONSdetect_leaks1:halt_on_error0:abort_on_error0:print_stacktrace1:quarantine_size_mb256detect_leaks1打开泄漏检测halt_on_error0遇到错误后不停止程序而是继续报告更多问题。注意这个选项不是所有场景都合适如果程序已经处于状态损坏继续跑出来的报告可能会满天飞quarantine_size_mb256调大隔离区提高 use-after-free 能检测到的概率窗口abort_on_error0使用 exit 而不是 abort便于测试框架捕获退出码。如果程序里有第三方库代码而这些库还没有用 sanitizer 重新编译可以把它们放入抑制文件避免误报export ASAN_OPTIONSsuppressionsasan.supp抑制文件格式很简单类似leak:libfoo.so或者use-after-poison:SomeFunctionName。但这里我必须强调一下我的态度抑制文件是用来处理“无法改动的外部依赖”的不是让你用来把自己的 bug 藏起来的。我见过不少团队把 ASan 报的错直接塞进抑制文件理由是“核心业务不会走到这段代码”结果是核心业务确实没走到但用户走到的时候程序就崩了。3.3 实战案例三个典型报告的完整解析纸上谈兵没意义我直接展示几个典型的 ASan 报告逐行拆解。第一个是堆缓冲区溢出12345ERROR: AddressSanitizer: heap-buffer-overflow on address 0x6020000000fa at pc 0x... WRITE of size 4 at 0x6020000000fa thread T0 #0 0x... in process_data src/processing.c:42 #1 0x... in main src/main.c:70 0x6020000000fa is located 2 bytes to the right of 16-byte region allocated by thread T0 here: #0 0x... in malloc #1 0x... in load_buffer src/loader.c:21第三行已经写得很直白了WRITE of size 4表示在某个地址写 4 个字节且在调用栈里带上了文件和行号倒数第三行更关键“位于一个 16 字节区域右边界右侧 2 字节处”——也就是你分配了 16 字节但写操作越过了 2 字节。通常这个原因就是循环边界写错了一位、或者 memcpy 的长度算错了。第二个是 use-after-free45678ERROR: AddressSanitizer: heap-use-after-free on address 0x61100000c2d0 at pc ... READ of size 8 at 0x61100000c2d0 thread T0 #0 0x... in get_name src/person.cpp:110 #1 0x... in main src/main.cpp:90 freed by thread T0 here: #0 0x... in free #1 0x... in release_person src/person.cpp:78报告里把“读取”和“释放”两处调用栈都列了出来。你在person.cpp:110还在访问对象但person.cpp:78就已经把它释放了。这种一眼就能看出生命周期漏洞。第三个是泄漏Direct leak of 4096 byte(s) in 1 object(s) allocated from: #0 0x... in malloc #1 0x... in create_node src/lru_cache.cpp:44 #2 0x... in lru_cache_insert src/lru_cache.cpp:120这个报告告诉你lru_cache_insert中创建的节点没有被释放。你顺着这条栈去检查 eviction 逻辑一般就能发现漏掉的操作。我整理了几个高频错误类型和应对方向的速查表方便参考错误类型报告关键字常见根因排查方向堆缓冲区溢出heap-buffer-overflow索引越界、memcpy长度错误检查循环边界、容器大小栈缓冲区溢出stack-buffer-overflow局部数组越界检查栈数组下标、字符串拷贝释放后使用heap-use-after-free对象生命周期管理错误检查共享指针、所有权转移重复释放attempting double-free多次 delete/free检查释放路径、所有权责任内存泄漏Direct leak / Indirect leak忘记释放、引用链残留检查资源释放函数是否全覆盖使用未初始化内存use-of-uninitialized-value局部变量未初始化检查声明处、补默认值4. 除了 ASanSanitizer 家族的其余成员怎么配合4.1 UBSan专门抓未定义行为的“语法警察”UndefinedBehaviorSanitizerUBSan是成本最低、见效最直接的一个。它的原理是编译时对可能产生未定义行为的语句插入检查比如整数溢出、除零、移位越界、空指针算术、错位的类型转换等等。最常见的做法是开这一组选项-fsanitizeundefined -fno-sanitize-recoverundefined-fno-sanitize-recoverundefined意味着一旦发现未定义行为立刻终止并报告而不是让程序带病继续。尤其在算法类、协议解析类代码里UBSan 经常能提前暴露你在补码运算和位宽转换里的问题。举个例子如果你把一个 int64 的值强制截断成 int32UBSan 会在截断点打出implicit conversion from type long to type int非常直观。这类问题在 C 的符号运算里特别容易埋雷。我见过一个服务稳定运行两个月后在高并发下出现偶发负数转账金额怎么查都查不到。后来开了 UBSan 跑压测立刻暴露了一个有符号整数溢出金额字段加总超过 int32 上限符号位变成了负数。4.2 TSan数据竞争的“雷达”ThreadSanitizerTSan和 ASan 不同它检测的不是内存是否越界而是多个线程是否在没有同步机制的情况下并发访问同一块内存且至少有一个是写操作。编译参数-fsanitizethread -g -O1TSan 因为要拦截所有内存访问性能开销大幅上升通常会慢 5 到 15 倍内存占用也涨得更多。所以它不太适合常驻 CI 跑全量测试更适合在压测、并发专项测试时专门跑一轮。报告中会出现WARNING: ThreadSanitizer: data race并且给出两个线程各自的调用栈以及共享内存地址直接用这部分信息去补锁或者调整数据结构。说个很反直觉的点TSan 的误报率比 ASan 高主要原因在于它对“同步”的建模很敏感C 里用了std::atomic但语义上不匹配、或者自己实现了无锁结构但缺少内存屏障都会出现报告。真要排查的时候先确认程序没有构造数据竞争的环境再怀疑工具误报。4.3 MSan 与 HWASan针对不同平台的补充方案MemorySanitizerMSan主要检测未初始化内存读取要求整个程序所有依赖都插桩否则会有大量误报使用门槛较高。在真实项目里我只有在纯自研且依赖可控的场景用过效果很好但接入成本确实不低。HWASan 是 ARM64 平台上的 AddressSanitizer 变体用硬件标签机制降低内存开销适用于移动端、以及内存受限的嵌入式环境。如果你在 Android NDK 上做 native 开发优先级应该排在 ASan 之上因为它在 64 位 ARM 上不仅快而且能捕获更多类型的 tag mismatch 错误。4.4 Sanitizer 家族选型对照面对一个具体项目很多人不知道该开哪几个选项我给出的建议是分三档场景推荐组合用途日常本地开发ASan UBSan最常用、开销中等快速定位内存和 UB 问题单元测试/CIASan UBSan LSan常驻自动化测试保证基础内存安全并发专项测试TSan只跑高并发场景分析数据竞争移动/嵌入式HWASan降低运行时开销适配 ARM 平台三个组合可以叠加不过你要有心理准备ASan TSan 同时开启是不支持的运行时冲突不能像-fsanitizeaddress,thread这样硬拼会直接报错。这也是不少新手容易踩的坑。UBSan 和 ASan 可以同时开因为它们一个在逻辑层检查、一个在内存层检查互不干扰。5. 项目落地的常见问题与排查技巧实录5.1 构建期踩坑链接失败、内联检测、LTO 组合你在接入时最容易踩的第一个坑就是链接时报错形如undefined reference to __asan_init。这通常是编译时开了-fsanitizeaddress但链接时没加。CMake 项目里要确保add_link_options(-fsanitizeaddress)是全局的不能只对源文件目录设置。第二个坑是 LTO链接时优化与 sanitizer 的组合。开了 LTO 之后部分检查可能因为跨编译单元优化而失效最稳妥的做法是检测构建关闭 LTO发布构建再开 LTO。性能和查错不能同时兼顾至少我尝试了几次之后老老实实分开用了。第三个坑是-static静态链接。如果用了静态链接的 libcASan 可能因为动态加载器机制而无法正确初始化。在 Docker 里跑 ASan 构建时注意不要顺手加-static。有些 CI 镜像默认的编译配置是从老项目复制来的最容易出这种问题。5.2 运行时崩溃在奇怪的地方先查栈大小和映射限制ASan 会占用大量虚拟地址空间导致它在某些受限环境里启动失败比如 macOS 上会看到unable to mmap之类的错误。这不是你的代码有 bug而是沙箱或者镜像对虚拟内存大小做了限制。解法是调大限制或者换用支持高虚拟地址的容器 runc/tini 配置。还有一次我在压测时发现程序在极其普通的位置比如一个字符串拼接直接 segfault但本地测试完全正常。排查到最后发现是测试环境到了ulimit -s的栈大小限制。ASan 插桩后会增加栈帧体积原本 8MB 够用现在不够了。可以把栈大小调到 16MB 或更大但如果程序本身有深递归还是要先解决递归深度问题。5.3 误报与抑制区分“工具误报”和“代码真问题”我把“误报”分成两类一类是真误报比如 Fortran 代码的公共块访问、自己用内联汇编操作内存、以及第三方库未插桩导致的错误归因。这些可以用抑制文件过滤。另一类是“业务逻辑里的真问题但被工具报告得位置不准”比如你用了 memory pool自己管理对象生命周期ASan 就可能把“从池子里取出一个已归还对象”的行为报告成 heap-use-after-free。第二类问题的正确处理方式不是抑制而是重构。池化内存本身不是错但池子管理不到位就是严重的稳定性隐患。我的经验是凡是 sanitizer 报告的先默认它是真的再去找反例。绝大多数情况下你最终会发现确实是代码的问题只是自己之前没意识到“那块内存在这个时间点已经属于别人了”。5.4 上层语言与 FFIJava、Go 调用 C 代码时怎么用如果项目是 Java JNI 或者 Go cgo 调用 C/C 库sanitizer 依然可以用但要注意运行时互相影响。比如 JVM 自己会进行垃圾回收、会管理大量线程ASan 动态库必须最先加载尤其需要在 JVM 启动参数里通过LD_PRELOAD确保顺序。不然ASan 的运行时无法接管后续加载的库检测效果大打折扣。Go 的 cgo 场景比较麻烦因为 Go 运行时和 C 代码运行在不同线程模型下TSan 会和 Go 自己的 race detector 冲突。我踩过一次之后在 cgo 项目里只开 ASan不开 TSan。如果必须查 C 代码的并发问题我会把 C 部分抽出来单独做一个可执行文件在纯 C 测试环境里跑 TSan再回到 cgo 里验证。5.5 CI 门禁里的最佳配置让内存问题在合并前就现形我把一个老项目的 CI 从“只编译不测试”改成“编译 跑测试 ASan/UBSan 报告门禁”之后三个季度里拦截了三十多个内存相关回归。具体做法是在 CI 矩阵里增加一个 asan 构建任务跑全量单元测试并且把 ASan 的退出码纳入 CI 判断。默认情况下ASan 发现错误会以非零码退出测试框架会捕获这个结果。有一点需要额外注意如果你的测试用例本身就是EXPECT_DEATH预期崩溃这种形式ASan 会在崩溃时输出大量报告反而干扰判断。这种情况下可以在特定测试标记中临时关闭 halt_on_error或者用__asan_address_is_poisoned这类 API 做白盒断言而不是依赖进程退出码。还有一个很容易被忽略的问题测试覆盖率不够高时sanitizer 也帮不了你。它只能检查“程序实际执行到的路径”。如果某条分支从来就没被测试覆盖过那 bug 就一直潜伏。这个我在上一家团队深有体会他们把 ASan 接上以后前两周什么问题都没抓到大家还以为编译器选错了后来把测试覆盖率从 40% 拉到 70%bug 报告立刻变得密集起来。所以接入 sanitizer 的正确姿势是先看自己的测试覆盖再谈检测强度。6. 最后的几点经验之谈我很少写“工具科普”类长文但这套工具确实救了我很多次也救了团队很多次。给刚接手的读者几个最核心的提醒第一sanitizer 不是银弹它解决的是“内存和并发在运行时的异常行为”不能替代代码评审、静态分析和设计审查第二接入顺序上我建议团队从 ASan UBSan 开始跑两周稳定了再加 LSan最后才上 TSan第三遇到报告先别急把第一份报告从头看到尾——它会直接告诉你“被访问的地址、分配它时的调用栈、释放它时的调用栈”这三件事拼在一起问题的性质就定了七八成。还有一个私人心得sanitizer 报告里的调用栈很多时候指向的不是 bug 的第一现场而是 bug 的“受害者现场”。真正写坏内存的地方可能在几十行之外但好在 ASan 会给你两条线索分配/释放的栈顺着时间线和对象生命周期捋一遍基本都能找到元凶。把这个排查思路养成习惯后你会发现内存问题虽然吓人但定位起来其实是有套路可循的。
返回列表