我见过不少项目在正式发布前跑得好好的,一换编译器版本、一开优化等级就出诡异问题:数组偶尔越界、整数溢出后逻辑跑偏、除法的除数是零却只在极端输入下触发。这种问题最难定位,因为不是每次运行都崩,一旦崩了又很难复现。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 + 1 |
| shift-base / shift-exponent | 移位操作的位数非法或移位导致溢出 | 1 << 32、1 << -1 |
| integer-divide-by-zero | 整数除法或取余时除数为零 | a / 0、a % 0 |
| bounds | 数组、指针访问越界 | arr[100] 但数组长度只有 10 |
| null | 空指针解引用 | (int)0 |
| alignment | 指针未对齐访问 | 对 char* 强转为 int* 后解引用 |
| vla-bound | 变长数组大小为负数或不合理 | int a[n] 其中 n 是负数 |
| object-size | 通过指针访问对象边界以外 | 用 memcpy 拷超过结构体大小的数据 |
| bool | 布尔值被修改为非 0/1 | 通过 memset 把 bool 置为 2 |
| float-cast-overflow | 浮点数转换为整数溢出 | (int)1e300 |
| return-nonnull-attribute | 非空属性函数返回了空指针 | 声明 nonnull 却返回 NULL |
| pointer-overflow | 指针运算溢出 | ptr + SIZE_MAX |
| enum | 枚举值超出定义范围 | 把 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 是 -2147483648,end 是 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 -fsanitize=undefined -fno-omit-frame-pointer -o test test.c如果项目是 CMake 管理的,通常这么写:
set(CMAKE_C_FLAGS "${CMAKE_C_FLAGS} -fsanitize=undefined -fno-omit-frame-pointer") set(CMAKE_EXE_LINKER_FLAGS "${CMAKE_EXE_LINKER_FLAGS} -fsanitize=undefined")或者用 CMake 自带的 sanitizer 支持:
target_compile_options(your_target PRIVATE -fsanitize=undefined -fno-omit-frame-pointer) target_link_options(your_target PRIVATE -fsanitize=undefined)第一行-fsanitize=undefined是总开关,等价于把常见检查项全打开。Clang 里还可以写成-fsanitize=undefined,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 -fsanitize=undefined -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_OPTIONS=halt_on_error=1 ./ub_demo这样一旦检测到 UB 就会立即中止,避免错误被后续流程掩盖。我强烈建议在本地调试时开启这个选项,查第一个错就先修掉,别等到一次跑出一堆互相关联的报错。
把代码修正之后,比如把divisor改成非零、把数组访问限制住,再重新编译运行,UBSAN 就不会再输出任何报告,程序安静地跑完,这时候你能比较有信心地说:“我权限范围内的这些路径,至少没有这几类 UB 了。”
2.3 单独检测某一类问题的精确参数
-fsanitize=undefined是个全家桶。有时候项目比较大,一开全家桶报告铺天盖地,这时候可以只开小范围。比如只想查有符号整数溢出和移位错误:
gcc -g -O1 -fsanitize=signed-integer-overflow,shift -fno-omit-frame-pointer -o demo demo.c各参数的命名在不同编译器间稍有差异,GCC 用shift,Clang 用shift-base和shift-exponent;GCC 可以用-fsanitize=undefined一揽子开启,Clang 还支持-fsanitize=integer这种把无符号整数回绕也纳入检查的“进阶模式”。
Clang 的-fsanitize=integer会连无符号整数溢出一起报。注意,C 标准里无符号整数溢出是有定义行为(按模回绕),所以这个检测不算 UB,算“实现定义中的危险行为”。某些项目参数合法性要求极高,比如密码学库、协议解析库,就会连这种也一起开,当作“可疑代码”来审视。
3. UBSAN 的工作机制与配套玩法
3.1 编译期插桩与运行期报告是如何配合的
UBSAN 的核心机制说起来并不复杂:编译器在遇到可能存在 UB 的运算操作时,会在生成的目标代码里额外插入一小段检查指令。程序真正运行到这一行时,检查指令会判断条件是否合法,如果不合法就跳到专门的错误处理函数,由该函数负责打印错误信息并中止或继续运行。
整个流程分两个阶段:
编译阶段,编译器干两件事。第一,它分析源代码里哪些操作属于 UBSAN 需要检查的“高危操作”,比如加法、减法、乘法、除法、移位、数组下标、指针解引用。第二,它往这些位置的指令流里插入“检查条件 + 跳转”的代码。在优化开启的情况下,编译器还会做一定的合并,比如同一个循环里多次同样的数组访问,可能只插一次检查,或者通过数据流分析提前发现某些检查一定是安全的,然后直接优化掉,从而降低性能开销。
运行阶段,当程序执行到检查点,但检查失败时,程序会调用一个运行时库函数,把错误类型、出现错误的源文件、行号、函数名以及相关变量的值组织成字符串,输出到 stderr 或日志。如果编译时带了-g且设定了打印堆栈,__ubsan相关的处理函数还会利用调试信息解析出当前调用栈,让你看到是“谁调用了这个函数导致出错”。
这里有一个容易误解的地方:UBSAN 并不是静态扫描器,它分析的不是“代码里可能存在的问题”,而是“这条路径执行到这里时真真切切发生的问题”。这既是优点也是缺点。优点是误报率低,报出来的错误基本都是真实发生的;缺点是你必须让出错路径被执行到,否则这个 UB 不会被发现。
3.2 和 AddressSanitizer 等其他检测工具怎么搭配
UBSAN 经常和 AddressSanitizer(ASAN)一起开。ASAN 负责内存相关错误,比如堆越界、栈越界、释放后使用、内存泄漏,UBSAN 负责运算层面的未定义行为,两个工具的检查范围互补性很强。
我的习惯组合是:
gcc -g -O1 -fsanitize=address,undefined -fno-omit-frame-pointer -o test test.c这个组合适合本地调试和测试阶段,但要注意两件事:第一,ASAN 的内存开销很大,通常到 2 倍以上,跑大规模程序时不建议长期开着;第二,两个 sanitizer 同时启用时,程序的运行速度会比只开 UBSAN 慢一些,但仍在可接受范围。
UBSAN 和 ASAN 的另一个不同点在于,UBSAN 对程序体积的影响很小,检查逻辑简单直接,性能开销通常也就 20% 到 2 倍之间,具体取决于检查项的多少和热点路径的密度。有些人会有“开 sanitizer 就是慢到不能跑”的印象,这多半是把 ASAN 的体感带到了 UBSAN 上,实际上 UBSAN 轻太多了。
还有一个容易忽略的工具是-fsanitize=thread(TSAN)和-fsanitize=memory(MSAN)。TSAN 针对的是多线程数据竞争,MSAN 针对的是读取未初始化内存,它们和 UBSAN 属于不同维度。如果你已经解决了 UBSAN 报出的问题,再把 ASAN、TSAN、MSAN 配合起来跑一轮,基本能覆盖绝大多数运行期内存和并发类问题。
3.3 UBSAN_OPTIONS 里的常见配置项
UBSAN 的行为由UBSAN_OPTIONS环境变量控制,格式是key=value:key=value。日常用得最多的几个:
| 配置项 | 作用 | 推荐值 |
|---|---|---|
| halt_on_error | 遇到第一个错误是否立即终止 | 1(本地调试)/ 0(批量收集) |
| print_stacktrace | 出错时打印调用栈 | 1 |
| suppressions | 指定抑制文件,过滤已知问题 | /path/to/suppressions |
| log_path | 把报告写入文件而非 stderr | /tmp/ubsan.log |
| report_error_type | 是否把错误类型拼在输出前缀里 | 1 |
我经常用的一行配置是:
UBSAN_OPTIONS=halt_on_error=1:print_stacktrace=1 ./test_program如果想让报告带时间戳并落地到文件,可以这样:
UBSAN_OPTIONS=log_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_TYPE=Debug \ -DCMAKE_C_FLAGS="-fsanitize=undefined -fno-omit-frame-pointer" \ -DCMAKE_EXE_LINKER_FLAGS="-fsanitize=undefined" cmake --build build-ubsan -j在 CI 脚本里,我一般会单独建一个 job,叫做test-ubsan,编译产物不用于发布,只用来跑测试集。好处是发布构建完全是干净的,不会被 sanitizer 影响,而测试覆盖又一直保留着。
如果项目用了第三方库,但这些库没有用 UBSAN 编译,链接的时候偶尔会出现链接错误,比如提示找不到__ubsan_handle_*符号。这时可以静态链接 UBSAN 运行时:
gcc -fsanitize=undefined -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.txt:
signed-integer-overflow:/usr/include/* shift:/usr/include/*然后运行:
UBSAN_OPTIONS=suppressions=suppressions.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 一开基本零报告。但如果是历史老项目,一开可能几千条报告涌过来,这时候千万别硬怼,否则团队里根本没人愿意看。
我的增量落地套路是这样:
第一步,先不开-fsanitize=undefined,只用一个自定义脚本统计代码里“高危操作”的密度,确定哪些模块风险最高。
第二步,按模块逐个开启。比如先对网络解析模块开,跑完一轮测试,把该模块的历史 UBSAN 报告清零,修不了的先在 suppressions 文件里挂起并注明责任人和工单号。
第三步,等所有模块都“清零”之后,再把 UBSAN 整体接入 CI,作为每次合并前的必跑项。这时候谁提交的代码引入了新的 UB,报告会直接关联到变更集,及时赶上问题。
第四步,发布前跑一轮 UBSAN 构建作为质量门禁,但发布产物仍然用正常的优化参数构建,两边互不干扰。
我见过一个团队用类似方法,在一个遗留了十年的 C++ 服务里,花了大约两个迭代周期把报告的 UBSAN 错误从 4000 多条降到了个位数,之后基本保持动态清零。整个过程对业务功能完全没有侵入,风险极低。
4.5 一个小众但好用的技巧:把 UBSAN 和模糊测试一起跑
模糊测试工具(比如 libFuzzer、AFL++)擅长生成极端输入,UBSAN 擅长发现极端输入触发后的 UB,这两者搭配效果特别好。
以 libFuzzer 为例,编译目标时直接带上-fsanitize=fuzzer,undefined,模糊测试每跑一条输入,UBSAN 就同步检查一次,一旦发现 UB,fuzzer 会记录当前输入并崩溃退出。这个输入文件就是复现 bug 的最小样例。我本身不是专业做安全的,但这个组合曾经帮我找到一个只在特定大小、特定内容下才触发的数组越界问题,单靠手写测试用例几乎不可能想到那种输入组合。
如果你不想引入完整的 fuzzer 框架,也可以写一个简单的“随机输入循环”,配合 UBSAN 在本地跑,同样能吃下不少意外输入。
5. 我在实际使用中的一点体会
UBSAN 确实是一个非常“低门槛、高收益”的检测工具。它不需要引入额外依赖,不需要改代码,编译参数里加一行就能用。我见过太多项目花大量时间排查“偶发崩溃”,最后发现就是INT_MAX + 1这类小小的溢出在优化后改变了程序分支。这种事情用静态检查扫描器看未必能发现,因为触发条件常常是外部输入和数据流共同作用的结果,而 UBSAN 恰恰是在真实运行路径上抓现行。
最后再分享一个小技巧:如果你用的是 Clang,可以试试-fsanitize=unsigned-integer-overflow或者-fsanitize=implicit-integer-truncation这类更严格的检查项。它们不是标准 UB,但经常能帮你发现许多“虽然定义明确但明显不对”的代码,比如把一个 long 隐式截断成 int、或者无符号数回绕出去的负数当成正常值传递。把这些检查项和标准 UBSAN 一起放到模糊测试或测试集里,几乎可以把整数类问题一网打尽。