C语言一切都是未定义行为?深入理解UB的本质和编译器的’阴谋’

“如果你给我六行由世界上最专业的C程序员手写的代码,我能在里面找到足够的未定义行为。”——如果红衣主教黎塞留是程序员,他大概会这么说。

最近一篇在Hacker News上引起热议的文章《Everything in C is undefined behavior》揭示了一个令人不安的事实:C语言中的未定义行为(Undefined Behavior,简称UB)比你想象的要多得多,而且编译器对UB的处理方式可能比UB本身更危险。

什么是未定义行为?

在C语言标准中,有些操作的结果是”未定义”的。这意味着标准没有规定这些操作应该产生什么结果,编译器可以做任何事情——包括但不限于:产生正确的结果、产生错误的结果、崩溃、格式化你的硬盘(理论上),或者什么都不做。

常见的UB包括:

1. 整数溢出:有符号整数溢出是UB。比如int x = INT_MAX + 1;,标准说这是UB。但无符号整数溢出是定义良好的(会回绕)。

2. 空指针解引用:访问NULL指针是UB。虽然大多数系统会直接崩溃,但编译器有权做任何事情。

3. 数组越界:访问数组边界之外的内存是UB。这是很多安全漏洞的根源。

4. 修改字符串字面量:比如char *s = "hello"; s[0] = 'H';是UB。

5. 使用已释放的内存:free之后继续使用指针是UB。

6. 数据竞争:多个线程同时读写同一个非原子变量是UB。

为什么UB这么多?

文章的核心观点是:C标准的编写方式导致了UB的泛滥。标准试图定义所有可能的行为,但当它无法为某种操作指定一个合理的结果时,就会将其标记为”未定义”。

问题在于,”无法指定合理结果”和”实际会出问题”是两回事。很多UB在实践中是完全无害的,但标准仍然将其标记为UB,因为标准必须覆盖所有可能的实现。

比如,有符号整数溢出在几乎所有现代硬件上都会产生可预测的结果(二进制补码回绕),但标准仍然说这是UB,因为历史上存在过不使用二进制补码的机器。

编译器的’阴谋’

更危险的是编译器对UB的利用。现代编译器(GCC、Clang)会假设程序中不存在UB,然后基于这个假设进行激进的优化。

举个例子:

int foo(int x) {
    return x + 1 > x;
}

你可能期望这个函数总是返回1(true),因为x + 1应该大于x。但如果xINT_MAXx + 1就是有符号整数溢出,是UB。

编译器会说:”既然UB不存在,那么x不可能是INT_MAX,所以x + 1 > x总是true。”于是编译器直接把函数优化成return 1;

这在数学上是正确的推理,但在实践中可能导致令人困惑的bug。更糟糕的是,开启优化后(-O2-O3),这些优化会变得更加激进,导致”不开优化正常,开了优化就出bug”的情况。

如何避免UB?

1. 使用编译器警告:开启-Wall -Wextra -Wpedantic,让编译器告诉你潜在的UB。

2. 使用UBSan:Undefined Behavior Sanitizer可以在运行时检测UB。编译时加上-fsanitize=undefined

3. 使用静态分析工具:Clang Static Analyzer、Coverity、PVS-Studio等工具可以在编译前检测UB。

4. 避免危险操作:使用size_t代替int做数组索引;使用snprintf代替sprintf;使用strncpy代替strcpy

5. 使用Rust:如果你在开始新项目,考虑使用Rust。Rust在编译时就能捕获大多数C中的UB。

对站长的启示

虽然大多数站长不会直接写C代码,但理解UB的概念很重要:

1. 你的服务器软件是C写的:Nginx、MySQL、Redis等核心基础设施都是C语言写的,UB可能导致它们出现难以调试的问题。

2. 安全漏洞:很多安全漏洞(缓冲区溢出、空指针解引用等)本质上都是UB。了解UB有助于理解这些漏洞的成因。

3. 选择更安全的语言:如果你在开发新的服务端应用,考虑使用Rust、Go等内存安全的语言,而不是C/C++。

总结

C语言的未定义行为是一个深坑,但理解它对于写出安全、可靠的代码至关重要。编译器对UB的激进优化是UB真正危险的地方——它可能在你不知情的情况下改变程序的行为。

如果你在维护C语言项目,建议定期使用UBSan和静态分析工具进行检查。如果你在开始新项目,认真考虑使用内存安全的语言。

来源:Everything in C is undefined behavior | HN讨论(374 points)

© 版权声明
THE END
喜欢就支持一下吧
点赞13 分享
评论 抢沙发

请登录后发表评论

    暂无评论内容