ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

ASan实战指南:C/C++内存错误检测与修复

ASan实战指南:C/C++内存错误检测与修复 1. 为什么要写这份ASan实战指南我先说一个真实的场景。前阵子一个做Delphi的老同事跑过来找我说编译完的程序一运行就报“内存错误”程序也没有崩溃就是总是出现不可预期的异常值他对着代码反复检查了无数遍都找不到问题所在。我听了之后第一反应就是——这兄弟缺的正是AddressSanitizer这类的工具。Delphi严格来说是Pascal系语言有自己的编译器和运行时ASan还真帮不上忙。但如果是C/C的世界ASan就是那层最重要的安全网。我一直在跟身边做C/C后端的同事强调只要你的项目里有指针、有malloc/free、有数组下标操作不跑ASan就发布跟不系安全带开高速一样危险。这篇博文我会把ASan从原理到实战、从单文件到大型工程集成、从报告阅读到问题修复完整地走一遍希望能帮你少踩几个我踩过的坑。文章主要针对两类读者第一类是刚接触C/C没多久、被各种内存错误折磨得焦头烂额的初学者你照着做就能给自己构建起一套可靠的内存检测流程第二类是工程经验不少但没系统用过ASan的从业者我会重点讲集成方式、报告解读和性能调优这些经验沉淀帮你直接把ASan用进现有工程里。2. 内存错误的类型与ASan的检测原理2.1 常见内存错误的分类首先得把“内存错误”这个词先拆开。这些年我在代码里碰到的内存错误基本逃不出下面这几类堆缓冲区溢出malloc出来的内存块不够大或者写数据的时候越界了写到别人家的地盘上。这是最典型的一类。栈缓冲区溢出数组开在栈上比如char buf[128]往里拷数据的时候没在意长度把栈上其他东西给盖了。释放后使用free完了指针忘了置空或者代码逻辑中某个对象被释放了但另一个地方还在用。这是C里最阴险的一类问题因为它在不同编译器下表现得还不一样。重复释放同一个内存块被free了两次堆管理器的元数据被搞坏轻则报错重则被攻击者利用。内存泄漏malloc了不freenew了不delete程序跑起来内存曲线一路往上走。这类错误不会立刻让程序崩溃但会慢慢搞死运行中的服务。对未初始化内存的读取有的内存内容没初始化就拿来用读出来一个随机的“垃圾值”。这些错误在普通运行下有时候根本不会暴露问题。今天能跑明天换个环境就崩了本地跑没事线上压测就爆炸。这也是为什么ASan这种工具能成为现代C/C开发流程中的刚需。2.2 ASan的核心检测机制AddressSanitizer之所以比传统工具好用是因为它跟编译器深度整合在编译期和运行期做了两手活。编译期ASan会在你代码的每一次内存访问处插入检测代码。不管是普通的a[i]数组访问、ptr-field指针解引用还是memcpy这类库函数的调用都会被编译器改写在真正的内存访问前加入一个检查逻辑。运行期ASan管理着一片叫影子内存的地址空间。主内存地址通过位运算映射到对应的影子地址那一个字节的影子值就记录了主内存区域当前的状态可读写、越界、已释放等等。每次检测代码访问影子内存时如果发现当前区域状态不对就立刻报告。听着挺玄乎其实简单说就是ASan在你视野的每一寸地板上都画好了线你一旦越线警报就响了而且会告诉你往哪个方向越了线、踩到了哪块区域。2.3 为什么用ASan而不是Valgrind很多人问我这个问题。Valgrind我也用而且用了很多年它检测类别的确很广像“未初始化内存读取”这类问题Valgrind比ASan检测得更早。但它的最大短板是性能开销。实测下来Valgrind能把程序拖慢20到50倍。本地调试小程序可以接受但到了真实业务逻辑、完整测试集、或者CI自动化流程里面谁也扛不住这种开销。ASan正常场景下的性能开销是2到3倍内存开销在1.5到2倍左右这个量级在开发测试阶段是完全能接受的。所以我的选择很明确日常开发调试、CI自动化测试阶段都用ASanValgrind作为辅助手段查ASan覆盖不到的问题。这类工具之间其实是配合关系不是替代关系。3. ASan的集成与命令行使用3.1 编译器的选择与参数我在实际工程中最常用的就是GCC和Clang这两个编译器族它们从4.8和3.1版本开始就已经原生支持ASan了。最基础的使用方式就是在编译命令中加上gcc -fsanitizeaddress -g -o my_program my_program.c这里面的-fsanitizeaddress是核心开关-g用来生成调试信息ASan的报告要靠它也能定位到代码行号。建议再带上-O1这个优化级别配合ASan既能检测出问题又不至于把代码优化得面目全非。C工程还建议加上-fno-omit-frame-pointer参数保留栈帧指针会让ASan打印的调用栈更完整报错后能推理出从哪个调用链上进来的。编译时如果没加这个参数有时候你看报告只能看到一个孤零零的地址没有函数名定位起来非常痛苦。链接阶段通常不需要额外加参数-fsanitizeaddress已经自动把ASan运行时库链接进去了。但如果是较老的编译器版本或者动态库、静态库混用的复杂工程可能需要显式加gcc -fsanitizeaddress -o my_program my_program.c -lasan3.2 CMake工程中的标准集成方式我的项目多数都用CMake管理直接为Debug和CI构建类型统一开启ASan配置方式如下# 在 CMakeLists.txt 中 set(CMAKE_C_FLAGS_DEBUG ${CMAKE_C_FLAGS_DEBUG} -fsanitizeaddress -fno-omit-frame-pointer) set(CMAKE_CXX_FLAGS_DEBUG ${CMAKE_CXX_FLAGS_DEBUG} -fsanitizeaddress -fno-omit-frame-pointer) set(CMAKE_EXE_LINKER_FLAGS_DEBUG ${CMAKE_EXE_LINKER_FLAGS_DEBUG} -fsanitizeaddress) set(CMAKE_SHARED_LINKER_FLAGS_DEBUG ${CMAKE_SHARED_LINKER_FLAGS_DEBUG} -fsanitizeaddress)每次配置工程时直接cmake -B build -DCMAKE_BUILD_TYPEDebug cmake --build build写到这里我发现一个工程越复杂、模块之间耦合越紧密就越容易在“编译期没有报错”的情况下藏着一堆运行时内存问题。所以我会在工程里加一个专门的CMake选项方便随时一键开启ASan构建option(ENABLE_ASAN Enable AddressSanitizer OFF) if(ENABLE_ASAN) add_compile_options(-fsanitizeaddress -fno-omit-frame-pointer -g) add_link_options(-fsanitizeaddress) endif()用起来就是cmake -B build -DENABLE_ASANON cmake --build build这个方案的好处是不污染正常的Release构建也不影响团队里其他人不开ASan时的日常编译。3.3 ASan运行时参数ASAN_OPTIONS详解ASan不仅是一个编译期工具它还提供了一组运行时选项通过环境变量ASAN_OPTIONS来控制行为。我常用的几个参数值得专门说一下参数作用我常用的值halt_on_error遇到第一个错误是否立即停止1立即停止防止后续错误干扰排查detect_leaks启用内存泄漏检测LeakSanitizer1默认就是开abort_on_error报错后是否调用abort而非exit1让coredump生成print_stacktrace是否打印完整调用栈1默认开quarantine_size_mb释放后内存延迟重用的大小影响use-after-free检测按程序规模设256或512verbosity运行时日志详细度0除非排查ASan本身的问题举个实际例子我在跑测试时通常这样启动ASAN_OPTIONShalt_on_error1:abort_on_error1:detect_leaks1 ./unit_testsabort_on_error1还能让崩溃时留下coredump文件后续可以用gdb直接分析ASan检测点附近的状态这在排查棘手的并发内存问题时特别有用。提示如果在容器里跑ASan有时会报“Shadow memory range interleaves with an existing memory mapping”之类的错。这通常是容器限制或者地址空间布局的问题可以通过设置ASAN_OPTIONSallocator_may_return_null1缓解也可以考虑升级到新版本编译器对容器场景的兼容性会更好。4. 典型内存错误的报告解读与修复实战4.1 堆缓冲区溢出的完整排查过程下面这个代码是我从一个真实业务模块里简化出来的。模块说自己“出了一个奇怪的线上问题”我本地带ASan一跑问题直接现形#include stdio.h #include stdlib.h #include string.h int main() { char *buf (char*)malloc(16); strcpy(buf, Hello, AddressSanitizer!); printf(%s\n, buf); free(buf); return 0; }字符串“Hello, AddressSanitizer!”的长度是24个字节但buf只分配了16个字节。这行代码根本没意识到自己正在往别人的地盘里写数据。编译运行后的ASan报告开头是这样的ERROR: AddressSanitizer: heap-buffer-overflow on address 0x60200000eff1 at pc ... WRITE of size 2 at 0x60200000eff1 thread T0 #0 0x... in __interceptor_strcpy #1 0x... in main /home/user/test.c:6:5 0x60200000eff1 is located 0 bytes to the right of 16-byte region allocated by thread T0 here: #0 0x... in __interceptor_malloc #1 0x... in main /home/user/test.c:5:19这几行报告要会读heap-buffer-overflow说明是堆上缓冲区越界。WRITE of size 2说明在写操作时被抓的。如果显示READ of size 4就是读操作。#1 ... in main /home/user/test.c:6:5直接告诉你精确到文件和行号。0 bytes to the right of 16-byte region非常贴心地指出了在申请的内存块右边界越界了0字节意思是你写的第一块非法数据就在合法区域的紧后面。这类问题的修复比较直接要么把缓冲区开大到足够装下数据char *buf (char*)malloc(32);要么用带长度限制的安全函数snprintf(buf, 16, %s, Hello, AddressSanitizer!);但这里我想多说一句修复这类问题的关键不是在“这一个地方”把长度改对而是建立“每次拷贝都必须明确边界”的编码习惯。尤其是在解析用户输入、网络报文、文件数据的场景里越界写往往是安全漏洞的温床不能只当普通的bug去看。4.2 释放后使用的报告分析释放后使用是另一个高频问题很多情况下它比越界写更难缠。看这段代码#include iostream int main() { int *p new int(42); delete p; std::cout *p std::endl; return 0; }ASan的报告类型显示为heap-use-after-freeERROR: AddressSanitizer: heap-use-after-free on address 0x61400000fe00 at pc ... READ of size 4 at 0x61400000fe00 thread T0 #0 0x... in main /home/user/use_after_free.cpp:7:18 freed by thread T0 here: #0 0x... in operator delete #1 0x... in main /home/user/use_after_free.cpp:5:3 previously allocated by thread T0 here: #0 0x... in operator new #1 0x... in main /home/user/use_after_free.cpp:4:14报告给出了非常清晰的调用链三连在哪一行被分配、在哪一行被释放、在哪一行被使用。这种“分配-释放-使用”的完整链路就是ASan报告最有价值的地方。修复上首选用智能指针#include memory int main() { std::unique_ptrint p std::make_uniqueint(42); std::cout *p std::endl; return 0; }如果因为历史代码原因必须用裸指针那就要在delete之后把指针置为nullptr并且在解引用前做判空。但老实说最省心的方案还是把对象的生命周期管理交给RAII机制。4.3 栈缓冲区溢出与全局缓冲区溢出栈缓冲区溢出和堆缓冲区溢出的报告类型不同它会标记为stack-buffer-overflow。例如#include string.h int main() { char buf[8]; strcpy(buf, too long); return 0; }注意“too long”加上结尾的\0是9个字节超出了栈上的8字节。ASan报错时通常会指出地址是located ... to the right of 8-byte region还会明确标出这个区域是在栈上由哪个函数分配的。全局变量缓冲区溢出会显示为global-buffer-overflow。这类问题一般出现在用全局数组存数据、但索引写超了的场景。ASan同样会定位到全局变量名和它所在的源文件。栈相关的内存错误有一个特点它跟编译器优化级别关系很大。某些越界访问在-O0下不会触发但在-O2下就炸了。所以排查这类问题时建议把优化级别从-O0到-O2各跑一遍。4.4 内存泄漏检测的使用ASan内置的LeakSanitizer会检测程序退出时仍然存活且没有被释放的堆内存。运行程序时可以看到类似这样的输出 12345ERROR: LeakSanitizer: detected memory leaks Direct leak of 40 byte(s) in 1 object(s) allocated from: #0 0x... in __interceptor_malloc #1 0x... in main /home/user/leak_demo.c:5:5 SUMMARY: AddressSanitizer: 40 byte(s) leaked in 1 allocation(s).这在排查服务端长驻进程时特别有用。但要注意一点LeakSanitizer默认是在进程正常退出时才做检测的。如果程序是被abort或者kill的泄漏报告就不一定出了。所以我们做内存泄漏例行检查时建议让程序正常跑完生命周期再退出或者用__lsan_do_recoverable_leak_check()这类接口在关键节点手动触发检测。5. 大型工程中的ASan集成实践5.1 动态库与静态库场景的处理大型项目的模块通常编译成动态库或静态库让ASan在整个工程中生效有几个值得注意的地方。对于静态库编译时需要确保库本身也开启了-fsanitizeaddress链接可执行文件时再带上ASan链接参数。其实ASan更像一个运行时库加编译插桩的复合体所有访问内存的代码都会被插桩最终在链接可执行文件时统一接入ASan运行时。对于动态库要求相对高一些。第一动态库必须用相同的-fsanitizeaddress编译第二链接动态库也要带上ASan运行时第三保证ASan运行时库在进程加载时排在动态库初始化之前。这些环节只要有一个没对上ASan可能能编译通过但运行时会出现“ASan runtime does not come first in initial library list”这类错误意思是ASan运行时没有被最先加载。现代Linux系统下我推荐的解决方案是链接时加上-Wl,-z,now让动态库在加载时立即重定位gcc -fsanitizeaddress -Wl,-z,now -o app app.o libfoo.so5.2 CI流水线集成ASan的姿势我在项目的CI流水线里专门分配了一个job执行ASan构建和测试与普通的Debug构建完全分开。这样做的原因是ASan构建耗时更长、测试运行也更慢如果跟着常规构建一起跑整个流水线都会被拖慢。CI里用的脚本核心部分大概是这样的cmake -B build_asan \ -DCMAKE_BUILD_TYPEDebug \ -DENABLE_ASANON \ -DCMAKE_CXX_FLAGS-fsanitizeaddress -fno-omit-frame-pointer cmake --build build_asan -j$(nproc) ASAN_OPTIONShalt_on_error1:detect_leaks1 \ ctest --test-dir build_asan --output-on-failureCI里跑ASan测试我有一条很重要的经验不要只看“测试挂没挂”要主动抓ASan报告。配备了--output-on-failure可以保证输出完整的ASan报告而不是只有一个测试失败名。我第一次把ASan跑进CI时就犯过这个错测试环境只告诉我有三个用例挂了但没有保留当时的ASan报错信息浪费了我整整半天时间才重新复现。5.3 与GDB的组合使用ASan定位到问题后有时候还需要深入到出错现场去看变量值、调用栈帧这时候GDB就是ASan的最佳搭档。用ASan配合GDB有两种姿势。第一种是让ASan直接拦截错误并进入调试器运行时加上ASAN_OPTIONSabort_on_error1:handle_segv0 gdb ./program在GDB里运行时当ASan触发abort后会停在raise调用处这时用bt就能看到完整的调用栈用frame切换帧查看变量。第二种姿势是ASan已经报出了地址你想看看这个地址周围的堆内存到底有什么内容也可以直接在GDB里执行x/16w 0x60200000eff1这能看到报错地址附近的原始内存内容。我去排查一些诡异数据覆盖问题时这个命令确实帮我看出过很多线索。6. ASan性能调优与常见坑6.1 性能开销的实测与调优策略ASan在Debug测试环境跑完整套单元测试我实测的开销在1.5到2.5倍之间。如果是大内存遍历类的算法代码可能达到3倍。这个开销在大多数集成测试场景中是可以接受的。但如果你跑的是大规模性能回归测试ASan的开销会明显影响结果判断。这种情况下我有几个建议不要用ASan构建的数据做性能基准。基准测试必须用Release构建ASan构建的数据只用来验证正确性。用黑名单排除已知安全的热点模块。在.ignore文件里配置不插桩的函数或文件但前提是这些模块你已经确认过没有内存错误。大内存分配场景下调整shadow内存设置。如果程序频繁分配超大块内存可以设置ASAN_OPTIONShard_rss_limit_mb或allocator_may_return_null1来避免ASan本身撞上内存限制。6.2 启动报错的经典坑我总结了自己和身边同事踩过的几个经典坑列成表方便查阅报错信息原因解决办法ASan runtime does not come first in initial library list动态库加载顺序不对编译可执行文件时加-Wl,-z,now确保ASan运行时最先加载Shadow memory range interleaves with an existing memory mapping容器或特殊内存布局冲突升级编译器版本或调整ASAN_OPTIONSAddressSanitizer:DEADLYSIGNAL在ASan插桩代码触发了段错误如空指针解引用配合GDB或者看报告里具体的SEGV on unknown address信息符号全是十六进制地址看不到函数名没加-g或者没有安装symbolizer编译加-g安装llvm-symbolizer并设置ASAN_SYMBOLIZER_PATH其中“看不到函数名”这个问题很容易被忽略。第一次用ASan时如果报告只输出十六进制地址而不显示函数名修复的难度立刻上升一个量级。正常情况下ASan会调用llvm-symbolizer将地址翻译成代码位置如果没有安装这个工具你就只会得到一串地址。注意macOS下Xcode自带的Clang对ASan支持也不错但在处理异常时会调用系统级的崩溃报告报告输出样式和Linux下略有区别。Windows下MSVC同样提供了类似ASan的/fsanitizeaddress支持但从我同事的反馈看一些边界场景下的报告详细度还是不如Linux。6.3 Delphi和原生Windows工具链的替代方案文章开头我提到老朋友那个Delphi编译报内存错误的场景这里有几句实在话要说。Delphi自带的内存管理器和ASan完全不兼容所以ASan这套方案在Delphi项目里用不上。如果遇到Delphi程序报内存错误通常的做法是借助其自带的ReportMemoryLeaksOnShutdown、第三方工具如MadExcept、或者用FastMM的诊断模式来追踪。核心思路是一样的换一个能在运行时把内存操作记录下来、并指出错误位置的机制。不过话说回来如果你现在正被内存错误折磨、但项目用的不是C/C工具链也别急着照抄ASan的用法。ASan的插桩机制依赖编译器对代码的深度改写Delphi、Pascal这类自成一体的编译体系没有对应的插桩钩子强行套用就是缘木求鱼。7. 日常开发中的ASan使用习惯这套工具真正发挥价值不在于偶尔跑一次而在于养成习惯。我自己的使用习惯是手写的新代码每次提交前都必须跑一遍ASan历史代码模块在改动后也跑核心用例CI里专门挂ASan构建保证内存问题只活在开发阶段不流到测试和线上。这个习惯帮我避免过很多麻烦。举个例子我有一次改了一个内部字符串处理函数只是把参数类型从char*换成了std::string_view代码编译、普通测试全部通过。但在ASan测试中立刻暴露了一个已经潜藏很久的use-after-free问题——函数内部实际上捕获了即将被销毁的临时字符串视图旧代码碰巧没事新代码把这个隐患暴露出来了。没有ASan这个问题可能需要线上跑几个月直到某次巧合的内存复用才会爆发。再补一句使用频次的经验ASan不是只在“怀疑有内存问题”时才用。它以2到3倍运行开销换来的内存安全洞察在开发期的每个阶段都有价值。尤其是那些“感觉行为有点怪”的疑难杂症先在ASan下跑一遍再开始怀疑业务逻辑这个顺序能省很多时间。我在这个行业里待了十几年见过的问题越多越觉得一个朴素的道理无比重要内存安全的坑与其靠人眼去守不如靠工具去挡。ASan是我在C/C工具链里最依赖的内存工事它的价值不是写出完美的代码之后锦上添花而是当代码不完美的时候让你在还来得及的时候发现问题。如果你的工程还没集成ASan就从今天这份指南开始吧。
返回列表