在这个循环中,编译器可以假定该循环将迭代N+1次,如果“i”的溢出是未定义的,从而允许介入范围广泛的循环优化。另一方面,如果该变量被定义为溢出时回绕,那么编译器必须假设这个循环可能是无限的(当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位产生0)。由于这些硬件上的差异,其行为在C中是完全未定义的(因此,移32位在PowerPC上可以格式化你的硬盘驱动器,它*不*保证生产零)。消除这种未定义行为的成本是使编译器对变量移位产生一个额外的操作(类似一个“与”运算)为变量的变化,这将在通常的CPU上使代价翻倍。
解引用野指针和数组越界访问:
随机指针解引用(像NULL,指向已被free的内存,等等)和数组访问越界的特殊情况,是在C应用程序的常见错误(希望不用解释)。为了消除这种未定义行为的来源,每个数组访问需要检查范围,并且ABI(译注:应用程序二进制接口)将不得不改变,以确保任何指针算术操作伴随范围信息。这对许多数值和其它应用程序成本极高,同时破坏了与每一个现有的C库的二进制兼容性。
解引用一个NULL指针:
和流行的看法相反,解引用一个空指针在C中是未定义的。它并没有被定义为引起陷入,并且如果你mmap一个0页面,访问这个页面是未定义的。这在禁止解引用野指针的规则之外,使NULL指针作为哨位不可行。NULL解引用成为未定义使广泛的优化成为可能:作为对比,Java使编译器在任何对象指针解引用之间移动具有副作用的操作无效,因为它无法被优化器证明是非空的。这显著地不利于调度和其它优化。在基于C的语言中,NULL成为未定义的使大量简单标量优化能被暴露为宏展开和内联。
如果你使用的是基于LLVM的编译器,你可以解引用“volatile”空指针来获得崩溃,如果这就是你需要的,因为优化器一般不关心volatile的存取。目前没有标识使随机NULL指针读取能被作为有效的访问或使随机读取知道它们的指针是“允许为空”的。
违反类型规则:转换int*为float*并解引用(像存在一个“float”一样访问一个“int”)是未定义的。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; // 转换导致TBAA在zero_array中违例。
zero_array();
}
此类类型滥用是相当少见的,这就是为什么标准委员会决定,为了显著的性能提升,“合理”的类型转换可以导致预期外的结果。值得指出的是Java获取基于类型的优化同时没有这些缺陷,因为它在语言中根本没有不安全的指针转换。
无论如何,我希望这使你了解在C中未定义行为能使某些类别的优化成为可能。当然有许多其它种类,类似序列点违例如“foo(i,
++i)”,多线程程序的竞争条件,“restrict”违例,除以零,等等。
在我们的下一篇文章里,我们将讨论为什么C的未定义行为是一件非常可怕的事,如果性能不是你的唯一目标。在系列的最后一篇文章中,我们将谈论LLVM和Clang如何处理它。