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

这篇博文(三篇博文系列中的第一篇)试图解释其中的一些问题,以便您能更好地理解其中的权衡和复杂性,或许还能学到一些 C 语言的阴暗面。事实证明,C 语言并不像许多经验丰富的 C 程序员(尤其是专注于底层的程序员)所想的那样是一个 "高级汇编器",C++ 和 Objective-C 直接继承了它的许多问题。

LLVM IR 和 C 编程语言都有 "未定义行为 "的概念。未定义行为是一个宽泛的话题,有很多细微差别。John Regehr 博客上的一篇文章是我找到的最好的介绍。这篇精彩文章的简短内容是:C 语言中许多看似合理的东西实际上都有未定义的行为,而这正是程序错误的常见根源。除此之外,C 语言中的任何未定义行为都会给实现(编译器和运行时)带来许可,使其产生的代码会格式化你的硬盘、做完全意想不到的事情,甚至更糟。我再次强烈推荐阅读约翰的文章。

基于 C 的语言中存在未定义行为,是因为 C 的设计者希望它成为一种极其高效的底层编程语言。相比之下,Java(以及许多其他 "安全 "语言)等语言则摒弃了未定义行为,因为它们希望在不同的实现中都能获得安全和可重现的行为,并愿意为此牺牲性能。虽然这两种语言都不是 "正确的目标",但如果你是一名 C 语言程序员,你确实应该了解什么是未定义行为。

在了解细节之前,值得简要提一下编译器如何才能使各种 C 应用程序获得良好的性能,因为没有什么灵丹妙药。从很高的层面上讲,编译器通过以下方式编译出高性能的应用程序:a)做好寄存器分配、调度等面包和黄油算法;b)掌握大量的 "技巧"(如窥视孔优化、循环转换等),并在有利可图时加以应用;c)善于消除不必要的抽象(如 C 语言中的宏导致的冗余、内联函数、消除 C++ 中的临时对象等);d)不搞砸任何事情。虽然下面的任何优化听起来都微不足道,但事实证明,只要从关键循环中节省一个周期,就能使某些编解码器的运行速度提高 10%,或耗电量降低 10%。

C 语言未定义行为的优势(附示例

在深入探讨未定义行为的阴暗面以及 LLVM 作为 C 语言编译器时的策略和行为之前,我认为考虑一下未定义行为的几种特定情况会有所帮助,并谈谈每种未定义行为是如何实现比 Java 等安全语言更好的性能的。您可以将其视为未定义行为类别 "带来的优化",也可以将其视为定义每种情况所需的 "避免的开销"。虽然编译器优化器可以在某些时候消除其中一些开销,但要在一般情况下(针对每种情况)做到这一点,需要解决停止问题和许多其他 "有趣的挑战"。

还值得指出的是,Clang 和 GCC 都确定了一些 C 标准未定义的行为。我将描述的这些行为既是标准未定义的,也是这两种编译器在默认模式下作为未定义行为处理的。

使用未初始化变量:这通常是 C 程序中的问题根源,有许多工具可以捕捉这些问题:从编译器警告到静态和动态分析器。这种情况下,所有变量在进入作用域时都无需初始化为零(Java 就是这样做的),从而提高了性能。对于大多数标量变量来说,这几乎不会造成什么开销,但堆栈数组和 malloc'd 内存会导致存储空间的 memset,这可能会造成相当大的损失,尤其是因为存储空间通常会被完全覆盖。

有符号整数溢出:如果 "int "类型(例如)的算术运算溢出,结果将是未定义的。例如,"INT_MAX+1 "就不能保证是 INT_MIN。这种行为可以实现某些类别的优化,这对某些代码非常重要。例如,如果知道 INT_MAX+1 是未定义的,就可以将 "X+1 > X "优化为 "true"。知道乘法 "不能 "溢出(因为这样做是未定义的),就可以将 "X*2/2 "优化为 "X"。虽然这些看似微不足道,但内联和宏扩展通常会暴露出这类问题。这样做还有一个更重要的好处,那就是可以将"<="循环优化为 "X "循环:

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

在这个循环中,编译器可以假设,如果 "i "在溢出时未定义,那么循环将恰好遍历 N+1 次,这样就可以启动一系列循环优化。另一方面,如果变量被定义为在溢出时缠绕,那么编译器就必须假定循环可能是无限的(如果 N 为 INT_MAX,就会出现这种情况)--这样就会禁用这些重要的循环优化。由于很多代码使用 "int "作为诱导变量,这对 64 位平台的影响尤其大。

值得注意的是,无符号溢出保证定义为 2 的补码(包装)溢出,因此可以一直使用它们。将有符号整数溢出定义为无符号整数溢出的代价是,此类优化功能将完全丧失(例如,在 64 位目标上,一个常见的症状是循环内部出现大量符号扩展)。Clang 和 GCC 都接受"-fwrapv "标志,它可以强制编译器将有符号整数溢出视为已定义(INT_MIN 除以-1 除法除外)。

超大移位量:将 uint32_t 移位 32 位或更多位是未定义的。例如,X86 将 32 位的移位量截断为 5 位(因此 32 位的移位与 0 位的移位相同),但 PowerPC 将 32 位的移位量截断为 6 位(因此 32 位的移位产生 0)。由于这些硬件差异,C 语言完全无法定义这种行为(因此,在 PowerPC 上 32 位移位可能会格式化硬盘,但并不能保证产生 0)。消除这种未定义行为的代价是,编译器必须为变量移位执行额外的操作(如 "和"),这将使它们在普通 CPU 上的运行成本增加一倍。

对随机指针的取消引用和对数组的越界访问:取消引用随机指针(如 NULL、指向空闲内存的指针等)以及访问超出范围的数组这种特殊情况是 C 应用程序中常见的错误,希望无需解释。要消除这种未定义行为的来源,必须对数组访问进行范围检查,并修改 ABI 以确保范围信息与任何可能进行指针运算的指针相关联。这将给许多数值和其他应用程序带来极高的代价,同时也会破坏与所有现有 C 库的二进制兼容性。

取消引用空指针:与普遍的看法相反,在 C 语言中取消引用空指针是未定义的。它没有被定义为陷阱,如果你在 0 位毫米映射一个页面,它也没有被定义为访问该页面。这与禁止引用空指针和使用 NULL 作为哨兵的规则不符。未定义的 NULL 指针引用可以实现广泛的优化:相比之下,Java 则规定,如果优化器无法证明任何对象指针引用是非空的,那么编译器在该对象指针引用上移动副作用操作是无效的。这极大地削弱了调度和其他优化功能。在基于 C 的语言中,NULL 未定义使得大量简单的标量优化得以实现,这些优化都是宏扩展和内联的结果。

违反类型规则:将 int* 转换为 float* 并取消引用(将访问 "int "当作访问 "float")是未定义的行为。C 语言要求通过 memcpy 进行此类类型转换:使用指针转换是不正确的,会导致未定义的行为。这方面的规则相当微妙,我不想在此赘述(char*有例外、向量有特殊属性、联合体会改变事物等)。这种行为使得一种被称为 "基于类型的别名分析"(Type-Based Alias Analysis,TBAA)的分析成为可能,它被编译器中的各种内存访问优化所使用,并能显著提高生成代码的性能。例如,该规则允许 clang 优化该函数:

float *P;
 void zeroo_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; // 在zero_array中,投递会导致违反TBAA。  
  zeroo_array();
}  

这种滥用类型的情况并不常见,因此标准委员会认为,对于 "合理的 "类型转换来说,显著的性能提升值得产生意想不到的结果。值得指出的是,由于 Java 语言中根本不存在不安全的指针转换,因此它能获得基于类型的优化所带来的好处,却没有这些缺点。

当然还有很多其他类型的优化,包括违反序列点(如 "foo(i, ++i)")、多线程程序中的竞赛条件、违反 "限制"、除以零等。

在下一篇文章中,我们将讨论为什么如果性能不是你的唯一目标,那么 C 语言中的未定义行为是一件相当可怕的事情。在本系列的最后一篇文章中,我们将讨论 LLVM 和 Clang 如何处理未定义行为。