LLVM 项目博客

LLVM 项目新闻和来自战壕的细节

每个 C 编程人员都应该了解的关于未定义行为 #1/3

人们偶尔会问,为什么使用 LLVM 编译的代码在优化器开启时有时会生成 SIGTRAP 信号。深入研究后,他们发现 Clang 生成了一个“ud2”指令(假设 X86 代码) - 与 __builtin_trap() 生成的指令相同。这里有几个问题,所有问题都围绕着 C 代码中的未定义行为以及 LLVM 如何处理它。

这篇博客文章(一个三部分系列中的第一部分)试图解释其中的一些问题,以便您更好地理解所涉及的权衡和复杂性,也许还可以学习一些 C 的黑暗面。事实证明,C 并不是像许多经验丰富的 C 编程人员(尤其是那些关注底层的人)喜欢认为的那样是“高级汇编程序”,而且 C++ 和 Objective-C 已经直接从 C 中继承了大量问题。


未定义行为简介


提供的翻译:日语西班牙语

LLVM IR 和 C 编程语言都具有“未定义行为”的概念。未定义行为是一个广泛的主题,有许多细微差别。我发现的关于它的最佳介绍是在 John Regehr 的博客 上的一篇文章。这篇文章的简短版本是,C 中许多看似合理的事情实际上具有未定义的行为,这是程序中常见的错误来源。除此之外,C 中的任何未定义行为都允许实现(编译器和运行时)生成格式化您的硬盘驱动器、执行完全意外的操作或 更糟 的代码。再次,我强烈建议您阅读 John 的文章

C 中存在未定义行为是因为 C 的设计者希望它成为一种极其高效的低级编程语言。相比之下,像 Java 这样的语言(以及许多其他“安全”语言)避免了未定义行为,因为它们希望在实现之间具有安全且可重复的行为,并且愿意牺牲性能来获得它。虽然两者都没有“正确的目标”,但如果您是 C 编程人员,您确实应该了解什么是未定义行为。

在深入了解细节之前,值得简要提及一下编译器要从各种 C 应用程序中获得良好的性能需要什么,因为没有万能药。在非常高的层面上,编译器通过以下方式生成高性能应用程序:a) 在寄存器分配、调度等基本算法方面做得很好。b) 了解许多“技巧”(例如 peephole 优化、循环变换等),并在有利可图时应用它们。c) 善于消除不必要的抽象(例如 C 中宏引起的冗余、内联函数、消除 C++ 中的临时对象等)和 d) 不要搞砸任何东西。虽然下面提到的任何优化听起来都很微不足道,但事实证明,在一个关键循环中节省一个周期就可以让某些编解码器运行速度提高 10% 或降低 10% 的功耗。

C 中未定义行为的优势,以及示例


在深入了解未定义行为的黑暗面以及 LLVM 在用作 C 编译器时的策略和行为之前,我认为考虑未定义行为的几个具体情况,并讨论每个情况如何比 Java 等安全语言实现更好的性能会很有帮助。您可以将其视为由未定义行为类别“启用的优化”或视为为了使每个情况定义而必须“避免的开销”。虽然编译器优化器有时可以消除一些这些开销,但要在一般情况下(对于每种情况)这样做,需要解决停机问题和许多其他“有趣的挑战”。

还值得指出的是,Clang 和 GCC 都确定了一些 C 标准未定义的行为。我将要描述的内容是根据标准是未定义的,并且在这些编译器在默认模式下的两种编译器中都被视为未定义的行为。

使用未初始化的变量:这通常被称为 C 程序中问题根源,并且有许多工具可以捕获这些问题:从编译器警告到静态和动态分析器。这通过不要求在所有变量进入作用域时都进行零初始化(就像 Java 所做的那样)来提高性能。对于大多数标量变量来说,这会导致很少的开销,但是堆栈数组和 malloc 的内存会产生对存储的 memset,这可能非常昂贵,尤其是因为存储通常会被完全覆盖。

有符号整数溢出:如果对“int”类型(例如)进行的算术运算溢出,则结果是未定义的。一个例子是“INT_MAX+1”不保证是 INT_MIN。此行为支持某些对某些代码很重要的优化类别。例如,知道 INT_MAX+1 是未定义的允许优化“X+1 > X”为“true”。知道乘法“不能”溢出(因为这样做将是未定义的)允许优化“X*2/2”为“X”。虽然这些看起来可能很微不足道,但这些东西通常会通过内联和宏展开来暴露出来。这允许的更重要的优化是对于像这样的“<=”循环

for (i = 0; i <= N; ++i) { ... }

在这个循环中,编译器可以假设如果“i”在溢出时是未定义的,则循环将精确地迭代 N+1 次,这允许各种循环优化开始生效。另一方面,如果变量被定义为在溢出时环绕,则编译器必须假设循环可能是无限的(如果 N 是 INT_MAX,则会发生这种情况) - 这反过来会禁用这些重要的循环优化。这特别影响 64 位平台,因为许多代码使用“int”作为归纳变量。

值得注意的是,无符号溢出被保证为定义为 2 的补码(环绕)溢出,因此您始终可以使用它们。使有符号整数溢出定义的成本是这些类型的优化将简单地丢失(例如,一个常见的症状是在 64 位目标上循环内部进行大量的符号扩展)。Clang 和 GCC 都接受“-fwrapv”标志,该标志强制编译器将有符号整数溢出视为已定义(除了 INT_MIN 除以 -1)。

过大的移位量:将 uint32_t 向左移位 32 位或更多位是未定义的。我的猜测是,这最初是因为各种 CPU 上的底层移位操作对这方面做了不同的事情:例如,X86 将 32 位移位量截断为 5 位(因此 32 位移位与 0 位移位相同),但 PowerPC 将 32 位移位量截断为 6 位(因此 32 位移位产生零)。由于这些硬件差异,C 完全没有定义该行为(因此在 PowerPC 上移位 32 位可能会格式化您的硬盘驱动器,它*不*保证会产生零)。消除这种未定义行为的成本是编译器必须为可变移位发出额外的操作(例如“and”),这会使它们在常见 CPU 上贵两倍。

对野指针的解引用和数组越界访问:对随机指针(如 NULL、指向已释放内存的指针等)以及数组越界访问的特殊情况进行解引用是 C 应用程序中常见的错误,希望不需要解释。为了消除这种未定义行为来源,必须对每个数组访问进行范围检查,并且必须更改 ABI 以确保范围信息跟随任何可能受指针算术影响的指针。这对许多数值和其他应用程序来说将会有极高的成本,并且会破坏与所有现有 C 库的二进制兼容性。

对 NULL 指针的解引用:与普遍的看法相反,在 C 中对空指针进行解引用是未定义的。它没有定义为陷阱,如果您在 0 处映射一个页面,它没有定义为访问该页面。这来自于禁止对野指针进行解引用以及使用 NULL 作为哨兵的规则。NULL 指针解引用被定义为未定义,这支持了各种优化:相比之下,Java 使编译器无法跨任何无法通过优化器证明为非空的对象指针解引用移动副作用操作。这显着惩罚了调度和其他优化。在基于 C 的语言中,NULL 未定义允许大量简单的标量优化,这些优化是在宏展开和内联的结果中暴露出来的。

如果您使用的是基于 LLVM 的编译器,您可以对“易变”的空指针进行解引用以在需要时发生崩溃,因为优化器通常不会触及易变的加载和存储。当前没有标志可以使随机的 NULL 指针加载被视为有效访问,或者使随机加载知道它们的指针“允许为空”。

违反类型规则:将 int* 转换为 float* 并对其进行解引用(将“int”作为“float”访问)是未定义的行为。C 要求这些类型的转换通过 memcpy 进行:使用指针转换是不正确的,并且会产生未定义的行为。对此的规则非常细致,我不想在这里详细介绍(对于 char* 来说有一个例外,向量具有特殊属性,联合会改变事情等等)。这种行为允许一种称为“基于类型的别名分析”(TBAA)的分析,该分析由编译器中的各种内存访问优化使用,并且可以显着提高生成代码的性能。例如,此规则允许 clang 优化此函数

float *P;
void zero_array() {
int i;
for (i = 0; i < 10000; ++i)
P[i] = 0.0f;
}

为“memset(P, 0, 40000)”。这种优化还允许将许多加载提升出循环,消除公共子表达式等等。可以通过传递 -fno-strict-aliasing 标志来禁用这种未定义行为类别,这会禁止这种分析。当传递此标志时,Clang 被要求将此循环编译为 10000 个 4 字节存储(这要慢得多),因为它必须假设任何存储都可能更改 P 的值,就像这样

int main() {
P = (float*)&P; // cast causes TBAA violation in zero_array.
zero_array();
}

这种类型的滥用非常少见,这就是标准委员会认为重大的性能优势值得为“合理的”类型转换带来意外结果的原因。值得指出的是,Java 在没有这些缺点的情况下获得了基于类型的优化的优势,因为它根本没有在语言中提供不安全的指针转换。

无论如何,我希望这能让你了解 C 中未定义行为支持的一些优化类别。当然还有许多其他类型,包括像“foo(i, ++i)”这样的顺序点违规,多线程程序中的竞争条件,违反“restrict”,除以零等等。

在我们的 下一篇文章 中,我们将讨论为什么 C 中的未定义行为如果性能不是您唯一的目标,则是一件非常可怕的事情。在该系列的最后一篇文章中,我们将讨论 LLVM 和 Clang 如何处理它。

-Chris Lattner