
我见过不少项目在正式发布前跑得好好的一换编译器版本、一开优化等级就出诡异问题数组偶尔越界、整数溢出后逻辑跑偏、除法的除数是零却只在极端输入下触发。这种问题最难定位因为不是每次运行都崩一旦崩了又很难复现。UBSAN 就是专门对付这一类的工具全称 Undefined Behavior Sanitizer中文常叫未定义行为检测器是 GCC 和 Clang 里内置的运行时动态检测能力不需要额外安装第三方组件直接在编译参数里开一个开关就能用。这篇文章我会用一次完整的排查经历把 UBSAN 能查什么、怎么用、为什么能查、踩过哪些坑全部讲清楚。1. UBSAN 到底在查什么“非法”行为1.1 先给 C/C 程序里的“未定义行为”画个像我们写 C/C 的时候经常会听到“未定义行为Undefined Behavior简称 UB”这个词。按 C/C 标准的规定如果程序运行过程中做了标准不允许的操作那整个程序的行为就完全不受约束它可能立刻崩溃也可能不崩但会在某个看起来毫不相关的地方悄悄出错甚至只在某次特定优化后被编译器抓住机会“整段删除”。这里有个关键点UB 不是“看起来危险的错误”而是标准层面直接放弃约束的行为。编译器看到 UB 后可以自己做任何假设比如有符号整数溢出是 UB编译器就默认“这段代码永远不可能溢出”然后基于这个假设做优化。如果你在代码里写了 x 1 并且相信它溢出后是负数那么在优化开启时编译器可能直接把后续的检查语句给优化没了因为编译器认定了“x 1 不可能溢出所以检查溢出代码是死代码”于是删掉。这种问题非常隐蔽单靠读写代码很难发现运行期也不一定崩溃。UBSAN 的价值就在于它在编译阶段往代码里插入检查逻辑在运行期真正执行到这些操作时立刻判断对错发现问题后当场打印出文件名、行号和具体的错误原因。传统上我们排查这类问题靠的是 code review 加运气或者用 Valgrind 这类重量级动态检测工具去跑完整场景。UBSAN 比 Valgrind 更轻、跟编译器结合更紧、能检查出更多类型的 UB而且开着它跑测试用例几乎感觉不到明显的性能断崖因此特别适合集成到日常测试和 CI 流程里。1.2 官方检查项里最常撞上的几类GCC 和 Clang 内置的 UndefinedBehaviorSanitizer 覆盖了一大票 UB 类型。我把自己在真实项目里撞得最多、也最推荐大家优先开启的几类列出来检查项检测场景典型误用写法signed-integer-overflow有符号整数运算溢出int a INT_MAX; a 1shift-base / shift-exponent移位操作的位数非法或移位导致溢出1 32、1 -1integer-divide-by-zero整数除法或取余时除数为零a / 0、a % 0bounds数组、指针访问越界arr[100] 但数组长度只有 10null空指针解引用(int)0alignment指针未对齐访问对 char* 强转为 int* 后解引用vla-bound变长数组大小为负数或不合理int a[n] 其中 n 是负数object-size通过指针访问对象边界以外用 memcpy 拷超过结构体大小的数据bool布尔值被修改为非 0/1通过 memset 把 bool 置为 2float-cast-overflow浮点数转换为整数溢出(int)1e300return-nonnull-attribute非空属性函数返回了空指针声明 nonnull 却返回 NULLpointer-overflow指针运算溢出ptr SIZE_MAXenum枚举值超出定义范围把 100 赋给只有 0/1/2 的枚举我在实际使用中最常碰见的是有符号整数溢出、除数为零、数组越界这三种。特别是 signed-integer-overflow开启 -O2 优化后这一类问题会变得非常“叛逆”因为编译器会拿“不可能溢出”当推理前提从而让程序在出错点之前就已经产生了逻辑分支上的偏差。1.3 一个真实的“隐形炸弹”有符号整数溢出我举一个自己踩过的例子。有一段处理网络字节序和时间戳换算的代码简化后大概是这个样子int get_duration(int start, int end) { return end - start; }当时 start 和 end 是从外部配置解析出来的正常情况下 end 一定大于 start。结果线上某个配置里 start 是 -2147483648end 是 2147483647两个差不经意间就超过了 int 的上限。此时end - start在有符号整数域里是未定义行为编译器完全有权利把它优化成它想要的结果。于是诡异的事情发生了同一个二进制在 -O0 下运行返回一个奇怪的正数在 -O2 下运行返回负数而 32 位和 64 位环境运行结果还可能不一样。这个案例非常适合说明 UBSAN 为什么值得加进日常测试。开了 UBSAN 之后运行到这一行会直接报出来runtime error: signed integer overflow: 2147483647 - -2147483648 cannot be represented in type int你都不用猜它直接把两侧的数值和类型全打出来了。2. 从零到一跑通一个 UBSAN 检测项目2.1 环境准备与基本编译参数UBSAN 本身是 GCC 和 Clang 的一部分不需要额外安装库。以 GCC 为例我一般推荐以下组合gcc -g -O1 -fsanitizeundefined -fno-omit-frame-pointer -o test test.c如果项目是 CMake 管理的通常这么写set(CMAKE_C_FLAGS ${CMAKE_C_FLAGS} -fsanitizeundefined -fno-omit-frame-pointer) set(CMAKE_EXE_LINKER_FLAGS ${CMAKE_EXE_LINKER_FLAGS} -fsanitizeundefined)或者用 CMake 自带的 sanitizer 支持target_compile_options(your_target PRIVATE -fsanitizeundefined -fno-omit-frame-pointer) target_link_options(your_target PRIVATE -fsanitizeundefined)第一行-fsanitizeundefined是总开关等价于把常见检查项全打开。Clang 里还可以写成-fsanitizeundefined,integer一类更细的组合或者反过来用-fno-sanitize...排除不想要的检查项。下面几个参数的作用值得展开说一下-g生成调试信息。没有它UBSAN 报告里就只有地址和偏移没有文件名、函数名、行号排查效率直线下降。-O1我建议至少开 O1。在 O0 下某些 UB 可能正好“碰巧”表现正常而 O1 是 UBSAN 最常用的运行优化等级因为它在保留一定调试信息的同时会让很多 UB 以更明显的方式暴露出来。-fno-omit-frame-pointer保留栈帧指针这样报告里的函数调用栈才完整。不推荐省略这一步否则定位到一个内联函数内部的错误时回溯栈会被截断。运行的时候如果只是普通执行UBSAN 检测到错误会默认向 stderr 输出一行“runtime error: ...”然后继续跑。这个行为后面还会讲怎么调。2.2 完整示例从埋雷到拆雷我准备了一个“埋了雷”的小程序覆盖三种常见 UB。你可以直接存成ub_demo.c试试#include stdio.h #include string.h #include limits.h #define ARRAY_SIZE 4 static void trigger_overflow(void) { int a INT_MAX; int b a 1; printf(overflow result: %d\n, b); } static void trigger_div_zero(int divisor) { int a 100; int b a / divisor; printf(div result: %d\n, b); } static void trigger_oob(void) { int arr[ARRAY_SIZE] {1, 2, 3, 4}; int idx 0; for (int i 0; i 8; i) { arr[idx i] i; } printf(arr[3] %d\n, arr[3]); } int main(void) { trigger_overflow(); trigger_div_zero(0); trigger_oob(); return 0; }用 UBSAN 编译gcc -g -O1 -fsanitizeundefined -fno-omit-frame-pointer -o ub_demo ub_demo.c ./ub_demo运行之后会看到类似这样的输出ub_demo.c:9:17: runtime error: signed integer overflow: 2147483647 1 cannot be represented in type int overflow result: -2147483648 ub_demo.c:15:17: runtime error: division by zero Floating point exception (core dumped)注意这里trigger_div_zero直接触发了硬错误程序收到 SIGFPE 信号后崩溃了trigger_oob也没机会执行。这就是 UBSAN 的一个重要行为特征除零这类错误会直接信号级终止程序而溢出这类错误默认只打印报告。如果想要程序在检测到错误时立刻停下来可以设置环境变量UBSAN_OPTIONShalt_on_error1 ./ub_demo这样一旦检测到 UB 就会立即中止避免错误被后续流程掩盖。我强烈建议在本地调试时开启这个选项查第一个错就先修掉别等到一次跑出一堆互相关联的报错。把代码修正之后比如把divisor改成非零、把数组访问限制住再重新编译运行UBSAN 就不会再输出任何报告程序安静地跑完这时候你能比较有信心地说“我权限范围内的这些路径至少没有这几类 UB 了。”2.3 单独检测某一类问题的精确参数-fsanitizeundefined是个全家桶。有时候项目比较大一开全家桶报告铺天盖地这时候可以只开小范围。比如只想查有符号整数溢出和移位错误gcc -g -O1 -fsanitizesigned-integer-overflow,shift -fno-omit-frame-pointer -o demo demo.c各参数的命名在不同编译器间稍有差异GCC 用shiftClang 用shift-base和shift-exponentGCC 可以用-fsanitizeundefined一揽子开启Clang 还支持-fsanitizeinteger这种把无符号整数回绕也纳入检查的“进阶模式”。Clang 的-fsanitizeinteger会连无符号整数溢出一起报。注意C 标准里无符号整数溢出是有定义行为按模回绕所以这个检测不算 UB算“实现定义中的危险行为”。某些项目参数合法性要求极高比如密码学库、协议解析库就会连这种也一起开当作“可疑代码”来审视。3. UBSAN 的工作机制与配套玩法3.1 编译期插桩与运行期报告是如何配合的UBSAN 的核心机制说起来并不复杂编译器在遇到可能存在 UB 的运算操作时会在生成的目标代码里额外插入一小段检查指令。程序真正运行到这一行时检查指令会判断条件是否合法如果不合法就跳到专门的错误处理函数由该函数负责打印错误信息并中止或继续运行。整个流程分两个阶段编译阶段编译器干两件事。第一它分析源代码里哪些操作属于 UBSAN 需要检查的“高危操作”比如加法、减法、乘法、除法、移位、数组下标、指针解引用。第二它往这些位置的指令流里插入“检查条件 跳转”的代码。在优化开启的情况下编译器还会做一定的合并比如同一个循环里多次同样的数组访问可能只插一次检查或者通过数据流分析提前发现某些检查一定是安全的然后直接优化掉从而降低性能开销。运行阶段当程序执行到检查点但检查失败时程序会调用一个运行时库函数把错误类型、出现错误的源文件、行号、函数名以及相关变量的值组织成字符串输出到 stderr 或日志。如果编译时带了-g且设定了打印堆栈__ubsan相关的处理函数还会利用调试信息解析出当前调用栈让你看到是“谁调用了这个函数导致出错”。这里有一个容易误解的地方UBSAN 并不是静态扫描器它分析的不是“代码里可能存在的问题”而是“这条路径执行到这里时真真切切发生的问题”。这既是优点也是缺点。优点是误报率低报出来的错误基本都是真实发生的缺点是你必须让出错路径被执行到否则这个 UB 不会被发现。3.2 和 AddressSanitizer 等其他检测工具怎么搭配UBSAN 经常和 AddressSanitizerASAN一起开。ASAN 负责内存相关错误比如堆越界、栈越界、释放后使用、内存泄漏UBSAN 负责运算层面的未定义行为两个工具的检查范围互补性很强。我的习惯组合是gcc -g -O1 -fsanitizeaddress,undefined -fno-omit-frame-pointer -o test test.c这个组合适合本地调试和测试阶段但要注意两件事第一ASAN 的内存开销很大通常到 2 倍以上跑大规模程序时不建议长期开着第二两个 sanitizer 同时启用时程序的运行速度会比只开 UBSAN 慢一些但仍在可接受范围。UBSAN 和 ASAN 的另一个不同点在于UBSAN 对程序体积的影响很小检查逻辑简单直接性能开销通常也就 20% 到 2 倍之间具体取决于检查项的多少和热点路径的密度。有些人会有“开 sanitizer 就是慢到不能跑”的印象这多半是把 ASAN 的体感带到了 UBSAN 上实际上 UBSAN 轻太多了。还有一个容易忽略的工具是-fsanitizethreadTSAN和-fsanitizememoryMSAN。TSAN 针对的是多线程数据竞争MSAN 针对的是读取未初始化内存它们和 UBSAN 属于不同维度。如果你已经解决了 UBSAN 报出的问题再把 ASAN、TSAN、MSAN 配合起来跑一轮基本能覆盖绝大多数运行期内存和并发类问题。3.3 UBSAN_OPTIONS 里的常见配置项UBSAN 的行为由UBSAN_OPTIONS环境变量控制格式是keyvalue:keyvalue。日常用得最多的几个配置项作用推荐值halt_on_error遇到第一个错误是否立即终止1本地调试/ 0批量收集print_stacktrace出错时打印调用栈1suppressions指定抑制文件过滤已知问题/path/to/suppressionslog_path把报告写入文件而非 stderr/tmp/ubsan.logreport_error_type是否把错误类型拼在输出前缀里1我经常用的一行配置是UBSAN_OPTIONShalt_on_error1:print_stacktrace1 ./test_program如果想让报告带时间戳并落地到文件可以这样UBSAN_OPTIONSlog_path/var/log/ubsan ./test_program它会生成/var/log/ubsan.pid这样的文件适合晚上挂机跑测试集第二天一起分析。注意 log_path 指定的目录要有写权限否则 UBSAN 会悄悄放弃文件输出继续打 stderr我一开始没注意权限问题还以为配置没生效。3.4 在 CMake 项目里优雅地接入 UBSAN真实项目一般不会只用一条命令编译CMake 是 C/C 社区最常用的构建工具。接入 UBSAN 最稳妥的方式不是全局改CMAKE_C_FLAGS而是专门开一个 Debug 或 Test 构建如下cmake -B build-ubsan -DCMAKE_BUILD_TYPEDebug \ -DCMAKE_C_FLAGS-fsanitizeundefined -fno-omit-frame-pointer \ -DCMAKE_EXE_LINKER_FLAGS-fsanitizeundefined cmake --build build-ubsan -j在 CI 脚本里我一般会单独建一个 job叫做test-ubsan编译产物不用于发布只用来跑测试集。好处是发布构建完全是干净的不会被 sanitizer 影响而测试覆盖又一直保留着。如果项目用了第三方库但这些库没有用 UBSAN 编译链接的时候偶尔会出现链接错误比如提示找不到__ubsan_handle_*符号。这时可以静态链接 UBSAN 运行时gcc -fsanitizeundefined -static-libubsan -o test test.c注意这不是所有平台都支持但在 Linux 上通常没问题。整体上UBSAN 的接入成本极低不需要改造代码也不需要改动测试框架只是编译多一个参数跑完看报告而已。4. 常见问题与排查技巧实录4.1 误报、外部库干扰与实际使用中的“狼来了”很多初学者开了 UBSAN 之后最困惑的是明明我的代码看着没问题怎么一直报错这里要先区分两个情况一是真出错了但你没看出来另一个是第三方库或系统头文件里的代码被插桩了导致误报。先说第一种。UBSAN 报出的 signed integer overflow 经常出现在“看起来绝对值没问题”的计算上。比如a * 100 / 100如果 a 特别大第一步a * 100就溢出了后面除以 100 其实是想“先放大再缩小”但没控制好范围。UBSAN 报错的位置就是乘法那行非常明确。第二种情况更棘手。如果你的系统里某个头文件是内联函数比如 glibc 里很多优化宏可能会触发 UBSAN 插桩但问题不在你的业务代码里。解决办法是用-fno-sanitize...指定排除某些检查项或者用 UBSAN_OPTIONS 的 suppressions 文件。比如新建一个suppressions.txtsigned-integer-overflow:/usr/include/* shift:/usr/include/*然后运行UBSAN_OPTIONSsuppressionssuppressions.txt ./test_program这样/usr/include下的代码即使触发溢出也不会输出报告。这里有个好习惯先不加 suppressions 跑一遍看清楚哪些是第三方库的问题哪些是自己业务代码的问题再决定要不要屏蔽而不是一上来就把所有报告都盖掉。我在接一个旧项目时遇到过类似情况启用 UBSAN 后一堆溢出报告排查下来一半来自业务代码一半来自一个很老的内存池第三方库。后来我给第三方库单独编了个不启用 UBSAN 的版本业务代码继续开检查两边互不干扰问题才看清。4.2 UBSAN 的性能开销有多大扛得住吗很多人对 sanitizer 的性能开销有心理阴影觉得开了以后程序慢到没法用。实际上UBSAN 和 ASAN 完全是两个量级。我拿一个内部消息处理服务做过粗略基准测试。这个服务单次请求要处理大量整数运算、数组索引、字符串拷贝。不开 UBSAN 时P99 延迟约 3.2ms开启 UBSAN 后P99 约 3.8ms上涨不到 20%。作为对比开 ASAN 后 P99 直接到了 7ms 以上。当然如果你的程序热点集中在密集的整数运算循环里比如音视频编解码、矩阵运算、哈希计算那 UBSAN 的开销会明显放大。成熟的工程做法是针对这类模块编译时不开 UBSAN而把检查责任转移到单元测试和模糊测试上单元测试阶段开 UBSAN 跑小样本模糊测试时开 UBSAN 跑异常输入生产发布版本保持完全干净。另外UBSAN 报告默认打印到 stderr如果你把报告重定向到日志文件做批量分析可以用log_path。但要注意如果程序是多进程模式千万记得让每个进程写独立文件log_path会自动附加进程号直接用它就行别自己拼文件名。4.3 开启优化后 UBSAN 报错行号不准怎么破UBSAN 和调试器一样受优化影响行号偶尔会“飘”。比如 clang 在 -O2 下某些 UB 被内联到调用方之后报告的行号可能是外层调用点而不是最原始的运算处。解决方法是给错误处理函数设上断点或者用更高的调试信息等级比如-g3甚至关掉部分内联-fno-inline。不过-fno-inline会影响性能我一般只在“需要精确定位”时才临时用一下。还有一个实用的招即使当时没开 UBSAN发现问题后我们可以立刻用 UBSAN 复现。很多 UB 问题在开启 UBSAN 后会被准确曝光。如果你在某个全量测试中看到“signed integer overflow at foo.cpp:120”马上把二进制换成 UBSAN 版重跑同一条测试路径通常会得到完全一致的报告配合日志文件基本能定位到具体某次调用。4.4 一次 CI 集成实录如何增量开启而不被历史问题淹没如果是一个全新的、写得很规范的项目UBSAN 一开基本零报告。但如果是历史老项目一开可能几千条报告涌过来这时候千万别硬怼否则团队里根本没人愿意看。我的增量落地套路是这样第一步先不开-fsanitizeundefined只用一个自定义脚本统计代码里“高危操作”的密度确定哪些模块风险最高。第二步按模块逐个开启。比如先对网络解析模块开跑完一轮测试把该模块的历史 UBSAN 报告清零修不了的先在 suppressions 文件里挂起并注明责任人和工单号。第三步等所有模块都“清零”之后再把 UBSAN 整体接入 CI作为每次合并前的必跑项。这时候谁提交的代码引入了新的 UB报告会直接关联到变更集及时赶上问题。第四步发布前跑一轮 UBSAN 构建作为质量门禁但发布产物仍然用正常的优化参数构建两边互不干扰。我见过一个团队用类似方法在一个遗留了十年的 C 服务里花了大约两个迭代周期把报告的 UBSAN 错误从 4000 多条降到了个位数之后基本保持动态清零。整个过程对业务功能完全没有侵入风险极低。4.5 一个小众但好用的技巧把 UBSAN 和模糊测试一起跑模糊测试工具比如 libFuzzer、AFL擅长生成极端输入UBSAN 擅长发现极端输入触发后的 UB这两者搭配效果特别好。以 libFuzzer 为例编译目标时直接带上-fsanitizefuzzer,undefined模糊测试每跑一条输入UBSAN 就同步检查一次一旦发现 UBfuzzer 会记录当前输入并崩溃退出。这个输入文件就是复现 bug 的最小样例。我本身不是专业做安全的但这个组合曾经帮我找到一个只在特定大小、特定内容下才触发的数组越界问题单靠手写测试用例几乎不可能想到那种输入组合。如果你不想引入完整的 fuzzer 框架也可以写一个简单的“随机输入循环”配合 UBSAN 在本地跑同样能吃下不少意外输入。5. 我在实际使用中的一点体会UBSAN 确实是一个非常“低门槛、高收益”的检测工具。它不需要引入额外依赖不需要改代码编译参数里加一行就能用。我见过太多项目花大量时间排查“偶发崩溃”最后发现就是INT_MAX 1这类小小的溢出在优化后改变了程序分支。这种事情用静态检查扫描器看未必能发现因为触发条件常常是外部输入和数据流共同作用的结果而 UBSAN 恰恰是在真实运行路径上抓现行。最后再分享一个小技巧如果你用的是 Clang可以试试-fsanitizeunsigned-integer-overflow或者-fsanitizeimplicit-integer-truncation这类更严格的检查项。它们不是标准 UB但经常能帮你发现许多“虽然定义明确但明显不对”的代码比如把一个 long 隐式截断成 int、或者无符号数回绕出去的负数当成正常值传递。把这些检查项和标准 UBSAN 一起放到模糊测试或测试集里几乎可以把整数类问题一网打尽。