C 中的一切都是未定义的行为

2026-05-20 1 阅读 lycopodiopsida
如果黎塞留枢机主教是一名程序员,他会说“给我世界上最专业的 C 程序员亲手写的六行代码,我会在其中找到足够的内容来触发未定义的行为”。没有人能写出正确的 C 或 C++。作为一个 30 年来几乎每天都在编写 C 和 C++ 的人,我这么说。我听 C++ 播客。我观看 C++ 会议演讲。我喜欢阅读和编写 C++。 C++ 为我们提供了很好的服务,但现在是 2026 年了,1985 年(C++)或 1972 年(C)的环境已经不是今天的环境了。我绝对不是第一个这么说的人。我记得大约十年前读过一位知名人士的帖子,他说使用 C++ 是违反 SOX 规定的一个很好的例子。虽然我不同意他们其余的咆哮(也不同意他们对“它”与“它是”的混淆),但我从未不同意这一点。随着时间的推移,我发现它越来越真实。未定义行为 (UB) 的情况比您想象的要多得多。大家都知道双重释放、释放后使用、访问对象边界之外(例如数组)以及访问未初始化的内存都是UB。毕竟,C/C++ 不是内存安全的语言。然而,作为一个行业,我们似乎无法停止一遍又一遍地犯这些错误。但还有更多。更微妙。更不合逻辑。这与优化无关 有些人似乎认为,只要他们不打开优化进行编译,未定义的行为就不会伤害他们。他们认为编译器在某种程度上故意表现出敌意,“啊哈!UB!我可以在这里做任何我想做的事!”,如果不打开优化,它就不会。这是不正确的。 UB并不意味着编译器可以利用你的马虎。 UB 意味着编译器可以假设你的代码是有效的。这意味着代码的意图在人类阅读时非常明显,甚至没有办法在编译器阶段或模块之间表达。 UB 意味着编译器甚至不必在代码生成中实现某些特殊情况,因为它们“不可能发生”。编译器,以及实际上的底层硬件,正在与您的 UB 意图玩电话游戏。最终可能会得到你想要的结果,但现在或将来都不能保证。 UB 无处不在 下面并不是试图列举世界上所有的 UB。这只是说明 UB 无处不在,如果没有人能做对,那么责怪程序员又怎么公平呢?我的观点是,所有重要的 C/C++ 代码都有 UB。访问未正确对齐的对象 作为示例,请使用以下代码: int foo ( const int * p ) { return * p ;如果使用未正确对齐的指针调用此函数(可能意味着地址是 sizeof(int) 的倍数,但谁知道呢),这就是 UB。 C23 6.3.2.3。在 Linux Alpha 上,在某些情况下,这只会陷入内核,内核会通过软件模拟您的意图。在其他情况下,它(可能)会使您的程序因 SIGBUS 而崩溃。在 SPARC 上,它会导致 SIGBUS。当然,在 x86/amd64(以下简称“x86”)上这可能没问题。天哪,这甚至可能是原子读取。众所周知,x86 对缓存一致性的微妙之处极其宽容。所以这里我们有三种情况: 内核伸出了援助之手(对于某些负载来说是 Alpha) 崩溃(其他 Alpha 负载和 SPARC) 不是问题(x86) 那么 ARM、RISC-V 和其他呢?未来的架构又如何呢?未来的架构甚至可能具有不填充最低位的特殊 int 指针寄存器,因为此类指针不可能存在。即使它有效,也许编译器有一天会从使用一条加载指令更改为另一条加载指令,突然之间,内核不再修复它。因为编译器没有义务生成适用于未对齐指针的汇编指令。因为是UB。或者这样怎么样: void set_it ( std::atomic < int >* p ) { p -> store ( 123 ); } int get_it ( std::atomic < int >* p ) { return p -> load ();当对象未正确对齐时,此操作是原子操作吗?这是错误的问题。穆,不问这个问题。是UB。 (但也是的,在实践中这很容易成为原子性问题)如果您想更加确信,您可以尝试考虑如果您认为以原子方式读取的对象跨越页面会发生什么。但不要想太多,否则你可能会得出“没关系”的结论。它不是。是UB。实际上,在那之前就已经是 UB了,不要责怪上面的 foo() 函数。取消引用指针的行为不是问题。仅仅创建指针就足以成为一个问题。示例: bool parse_packet ( const uint8_t * bytes ) { const int * magic_intp = ( const int * ) bytes ; // 布! int magic_raw = foo ( magic_intp ); // 可能在 SPARC 上崩溃。 int magic = ntohl ( magic_raw ); // 这至少没问题。 [ … ] } 问题在于强制转换,而不是 foo() 。编译器分配特定含义是完全有效的,例如