减少 C 语言中的未定义行为

C 和 C++ 中的未定义行为——即语言对某些操作不作任何保证的情况,如有符号整数溢出或解引用空指针——仍然是 bug、安全漏洞和令人意外的编译器优化的重要来源。评论者在 UB 的历史原因和激进优化需求之间进行权衡,同时呼吁将更多情况转换为实现定义行为、增加可选的陷阱或消毒模式,并推动 C2y 缩小“UB 动物园”。许多人认为,真正稳健的安全性将来自更强的语言(如 Rust)或硬件/ABI 级机制(如 CHERI 和 Fil-C),而 C 应主要保留给开发者有意识接受这些权衡的底层系统代码。

C 中未定义行为(UB)的范围和性质

  • UB 被描述为“当你违反了一个强制前置条件时会发生的事情”,这让编译器可以假定这些情况永远不会发生。
  • 一些评论认为,这使得对整个程序进行推理极其困难,因为编译器可以基于“UB 永远不会发生”来优化,甚至在到达 UB 点之前就改变行为。
  • 另一些人指出,C/C++ 中的 UB 相比许多其他语言要激进得多,实际上允许任意行为(“鼻涕恶魔”),而不仅仅是“硬件会怎么做就怎么做”。

优化 vs. 正确性

  • UB 的支持者认为它对优化至关重要:假设没有 UB 使得内联、常量传播、死代码消除、寄存器分配以及去除冗余检查成为可能。
  • 批评者反驳说,编译器如今会“武器化” UB,导致看似惊人的空指针检查或分支被移除,使调试更加困难。
  • 有人建议编译器至少在基于 UB 的推理移除代码时发出警告;也有人认为这不可行,因为几乎所有优化都依赖于假设没有 UB。

实现定义 vs. 未定义

  • 多位评论者认为,许多行为(例如空指针解引用、读取未初始化值)应该是实现定义的,或者至少保证会触发陷阱,而不是完全未定义。
  • 反对观点认为,异构硬件、WASM 以及性能约束使完全定义行为不切实际;C 委员会也不愿破坏现有实现。

整数溢出

  • 关于有符号溢出是否应被定义,存在强烈争论(例如采用二补码回绕、触发陷阱,或可配置)。
  • 支持定义的一方认为:运行时检查更容易,较少被 UB 利用,行为更一致;像 -fwrapv/-ftrapv 这样的标志已经存在。
  • 反对定义的一方认为:UB 让编译器能够提示明显错误、使用 sanitizer,并保留强大的优化;无符号溢出已经被定义,但仍然容易出错。

未初始化内存

  • 示例代码展示了编译器如何利用对未初始化的 malloc 内存的读取进行出人意料的优化(例如常量折叠为总是走某个分支)。
  • 有人希望得到“值不确定但稳定”或实现定义的语义;也有人表示 C2y 不会这样做,因为这会破坏太多实现。

替代方案与缓解措施

  • 建议包括:应用程序使用内存安全语言(Rust、Go);将 C 保留给低层内核;尽管有局限,仍依赖 sanitizer(UBSan、ASan、TSan)、Valgrind 和静态分析器。
  • CHERI 和 Fil-C 被提及为 ABI 层面的方案,它们严格约束内存行为并降低可利用性,但在兼容性和部署方面存在取舍。

标准演进与分层

  • 据报道,C2y 删除了一些 UB 实例,但为了优化和可移植性,仍保留了许多。
  • 讨论还涉及:应由哪一层(语言、操作系统、ABI、硬件)承担安全复杂性,但尚无明确共识。