每个C程序员应该知道的未定义行为#1/3

来源:互联网 发布:老公是唇膏男 知乎 编辑:程序博客网 时间:2024/05/18 03:48

作者:Chris Lattner

原作:http://blog.llvm.org/2011/05/what-every-c-programmer-should-know.html

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

这篇博客贴(三篇系列中的第一篇)尝试解释其中的一些问题,使你可以更好地理解其中涉及的权衡与复杂性,并可能了解更多一点C的黑暗面。事实证明,C不是一个许多有经验的C程序员(特别是关注底层的人)所想象的“高级汇编语言”,而C++与Objective-C都从它直接继承了大量的问题。

未定义行为的介绍

LLVM IR(译注:IR即中间表示,immediate representation)及C编程语言具有“未定义行为”的概念。未定义行为是一个带有许多细微差别的广泛的话题。我见过最好的介绍是在John Regehr博客上的一篇帖子(译注:即“C、C++中未定义行为的指引”)。这篇优秀文章的简要内容是:在C中许多看起来合理的东西实际上有未定义行为,这是程序中bug的一个常见来源。除此之外,C中任何未定义行为给予实现(编译器与运行时)产生格式化你硬盘的代码的许可,执行完全意外的操作,或更糟。再次,我强烈推荐读一下John的论文。

基于C的语言存在未定义行为,因为C的设计者希望它成为一个极其高效的低级编程语言。相反,像Java(及许多其它“安全”语言)的语言已经避开了未定义行为,因为他们希望实现之间安全及可重现的行为,并愿意牺牲性能来得到它。尽管两者都不是“要追求的正确目标”,如果你是一个C程序员,你确实应该理解未定义行为是什么。

在进入细节之前,值得提一下编译器从宽泛的C应用程序中获得好的性能的必要条件,因为没有魔弹。在非常高的层次,通过:a)做好基本的算法,像寄存器分配、调度等;b)知道许多、许多的“技巧”(比如窥孔优化,循环变换等),只要有利可图就应用它们;c)善于消除不必要的抽象(比如在C中由于宏导致的重复,函数内联,在C++中消除临时对象等);d)不要把事情弄砸;编译器产生高性能的应用程序。尽管下面的任一优化可能听上去无关紧要,事实证明仅节省一个关键循环的一个周期,可以使某些解码器快10%,或节省10%的功耗。

C中未定义行为的好处及例子

在进入未定义行为的黑暗面,及在用作一个C编译器时,LLVM的策略与行为前,我想考虑未定义行为的几个特定案例,并讨论每个如何实现比一个安全的语言更好的性能,比如Java,是有帮助的。你可以把这看做:由未定义行为类别“启用的优化”,或“避免”使每个情形有定义所要求的“开销”。尽管编译器优化器有时可以消除这些开销中的某些,要普遍这样做(对每个情形)将要求解决停机问题(thehalting problem)及许多其它“有趣的挑战”。

还值得指出Clang与GCC明确了C标准留作未定义的几个行为。我将描述标准未定义的行为,及在缺省模式下,这两个编译器处理作未定义行为的行为。

使用非初始化变量:这通常被认为是C程序中问题的来源,有许多工具捕捉它们:从编译器警告到静态及动态分析器。这通过在进入作用域时,不要求0初始化所有的变量(像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) { ... }

在这个循环中,编译器可以假定该循环将实际迭代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取消定义(因此在PowerPC上偏移32位可以格式化你的硬盘,它不保证产生0)。消除这个未定义行为的代价是:对变量偏移,编译器将必须产生一个额外的操作(像一个'and'),在普通的CPU上,这将使得它们代价加倍。

解引用野指针及数组越界访问:解引用随机指针(像NULL,指向已释放内存的指针等)及访问一个越界数组的特殊情形是C应用程序中一个常见的bug,但愿这无需解释。要消除这类未定义行为,每个数组访问必须检查边界,而且必须改变ABI以确保边界信息跟随任何受指针算术支配的指针。对许多数值及其它应用,这将是一个极高的代价,而且打破与现存C库的二进制兼容性。

解引用空指针:与普通的看法相反,在C中解引用一个空指针是未定义的。它不是定义为陷入,如果你mmap一个页面到0,它不是定义为访问该页面。这放弃了禁止解引用野指针并使用NULL作为一个哨兵的规则。NULL指针解引用被取消定义启动了广泛的优化:相反,Java使得编译器跨越任何对象指针解引用移动一个副作用操作无效,如果优化器不能证明该指针不是空。这严重地损害了调度及其它优化。在基于C的语言中,未定义NULL使大量简单的标量优化成为可能,这些优化由宏展开及内联发现。

如果正在使用基于LLVM的编译器,你可以解引用一个"volatile"空指针以得到一次崩溃,如果这是你所期望的,因为volatile保存与载入通常不会被优化器触及。
目前没有标记使任意NULL指针载入被处理作有效访问,或使随机载入知道它们的指针是“允许为空的”。

违反类型规则:把一个int*强制转换到float*并解引用它是未定义行为(访问"int",仿佛它是一个"float")。C要求这些类型的转换通过memcpy发生:使用指针强转是不正确的,会导致未定义的行为。这个规则是相当微妙的,在这里我不想深入细节(char*、具有特殊属性的向量、union改变等,是一个例外)。这个行为使一个称为“基于类型的别名分析(Type-BasedAlias Analysis,TABB)”的分析成为可能,在编译器中它被各种内存访问优化使用,并能显著提升生成代码的性能。
例如,该规则允许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',除0等。

在我们下一贴中,我们将讨论为什么C中的未定义行为是一件相当可怕的事,如果性能不是你的唯一目标。在我们该系列的最后一贴中,我们将讨论LLVM与Clang如何处理它。