C 标准把未定义行为留给实现自由处理。越界访问、有符号整数溢出、空指针解引用、读未初始化的布尔值,这些都算。麻烦在于编译器可以假设它们不发生,一段看起来多余的判断会被整段删掉。
编译器为什么敢删掉判断
未定义行为给编译器留了一块自由地带。它有权假设代码不会除以零、不会越界。于是 x = b ? 42 : 43; 后面接 a/b,当 b 等于 0 时,那个三元测试可能直接消失。推演的过程是,如果 b 是 0,后面的除法已经不合法,测试的结果没有意义,删掉它不影响任何合法程序。
这类删除有个麻烦的地方,源码和二进制对不上。调试时看到的现场和源码逻辑脱节,多数能在这一类行为里找到解释。
清理工作一直在做。公开的草案变更记录显示,C 标准里的未定义行为约一百处,工作草案已经删掉四十五处左右。C23 删掉了三字符组、K&R 风格函数定义、符号幅值和反码这两种整数表示,补进位精确整数和带检查的整数运算,还加了一条禁止时间旅行的规则,编译器不能把可能触发未定义行为的运算提到 volatile 访问之前。
同一个程序在两台机器上结果不同
1UL >> 64 是典型。x86 上用掩码截断移位计数,AArch64 的行为不一样,同一个表达式在两种架构上给出不同答案。结构体的填充字节也算争议点,读它算不算未定义行为,社区长期没有共识。
这些事情离业务代码不远。一次有符号溢出、一处越界写、一个忘记判空的指针,都可能让优化后的二进制文件和源码对不上。
怎么在自己的代码里定位和减少
- 打开编译警告。GCC 和 Clang 的
-Wall -Wextra已经能报整数溢出、释放后使用、缓冲区溢出这几类。 - 用 sanitizer。加
-fsanitize=undefined插运行时检查,检测空间问题再加-fsanitize=address。它的开销决定了生产环境通常不开。 - 静态分析器大多已集成进编译器,编译阶段就报告潜在未定义行为。
- 评审时盯五件事,有符号整数运算、解引用前是否判空、数组边界、移位位数、volatile 的访问顺序。
- 关注标准动向,用带检查的整数运算替代手写溢出判断,能少一类问题。
sanitizer 的用法分两种。开发期本地跑,把它当测试的一部分;CI 里给关键模块单独排一条 nightly,让运行时检查覆盖到真实数据路径。两类都不适合开在生产环境,开销决定了它只出现在测试流程里。
内存安全分了三层
类型安全可以用注解加链接时检查补。空间安全靠 counted_by 这类属性和边界检查部分解决。时序安全最难,也是 Rust 的明显优势所在,CHERI 和 Fil-C 都在尝试用不同的路子做时序内存安全。C2y 不可能一步到位,完全的内存安全要么付运行时检查的代价,要么上形式化验证。
C++26 换了术语,把这类问题叫错误行为。Rust 的 checked_shr 是另一条路,移位超范围时返回空值,让错误在类型系统里显式出现。
常见问题
删掉未定义行为会破坏老代码吗。可能。指望除零得到固定值的老写法,在 x86_64 上仍会触发 SIGFPE,标准改了,硬件行为不会跟着改。
要不要现在按 C23 写。可以先用带检查的整数运算、明确移位范围这类小改动,成本低。整体迁移看编译器和工具链支持,不急于一次做完。
和写业务代码有什么关系。有符号溢出和移位位数是最高频的两类,评审时多看一眼,比事后查崩溃现场便宜。
未定义行为和未指定行为是一回事吗。不是。未指定行为允许多种结果,但每一种都有定义,比如函数参数的求值顺序。未定义行为是标准不施加任何要求,编译器和硬件可以随便挑一种。