每个 C 程序员都应知道的关于未定义行为的那点事(上篇)

54 篇文章 2 订阅
24 篇文章 11 订阅
译自: http://blog.llvm.org/2011/05/what-every-c-programmer-should-know.html (可能需翻墙)
原日译版: http://blog-ja.intransient.info/2011/05/c-13.html
  
  人们有时会问为什么打开优化后 LLVM 编译出的代码会引发 SIGTRAP。仔细钻研之后,他们发现 Clang 生成了一条 ud2 指令(x86 的情况下)——跟 __builtin_trap() 会生成的代码一样。这里,围绕着 C 代码中未定义行为和 LLVM 如何处理这种行为的方式上,有着很多的问题。
  本篇博客(上篇)将尝试着解释那些问题,以便让读者更深刻地理解其中的利弊权衡(没准也会让他们学到一些 C 的阴暗面)。到头来,C 并不是一个如许多有经验的(经常干底层活的)程序员所想的那样、是一个“高级汇编语言”,并且 C++ 和 Objective-C 也直接继承了它的许多问题。
  
  介绍:未定义行为
  LLVM IR(LLVM 中间语言)和 C 语言都有“未定义行为”的概念。未定义行为是一个蕴含许多微妙差异的广泛话题,我所能想到最好的介绍它的方式是 John Regehr 的一篇博文( http://blog.regehr.org/archives/213 )。这篇绝妙文章的简短版本就是这样:许多 C 语言中看起来有意义的东西都是未定义的,并且那也就是许多 bug 的震源地;此外,C 中任何的未定义行为都给实现(包括编译器和运行时)大开绿灯,使得它们可以生成格掉你家硬盘、做点没人会想到的、或者更糟的事( http://www.catb.org/jargon/html/N/nasal-demons.html )的代码。再说一遍,我极力推荐阅读一下 John 的那篇文章。
  基于 C 的语言中之所以存在未定义行为,是因为 C 的设计者想让它成为一个极度高效的低级编程语言。与之相反,Java(以及许多其它“安全”的语言)避免未定义行为,因为它们想要的是安全性、可在不同实现上再现的行为,并且是心甘情愿牺牲性能以换取这些的。但那两个都不是“正确的目标”——如果你是一个 C 程序员的话,你就应当理解究竟未定义行为是啥。
  在深入细节之前,有必要提一句编译器为了让广大 C 程序能跑出一个挺好的性能所要做的事,因为这事是没有万灵药的。在相当宽泛的层面上说,编译器通过如下的方式生成高性能的程序:
  一、实现一个好的基础算法,诸如寄存器分配、调度之类;
  二、了解许许多多的“技巧”(如:代码段优化(原文 peephole optimization,可参见英文喂鸡),循环变换,等等),并只要在有利可图时就应用它们;
  三、善于消除掉不必要的抽象(如:C 中的宏、C++ 中的内联函数和临时对象所带来的冗余性,等等);
  四、别把任何东西搞砸。
  即使下面(原文如此)的任何一条优化都听起来很平常,事实也证明在一个关键的循环中即使只节省一个周期、某些编解码器(原文就是 codec,不过猜测是 code 写错了?)就可以以 10% 的幅度提速或节能。  C 中未定义行为的优点,以及示例

  在深入未定义行为和 LLVM 在作为 C 编译器使用时的策略和行为的黑暗面之前,考虑未定义行为的少数几个特定情况、并且讨论一下其中的每一个是如何有助于产出比 Java 之类更安全的语言更高的性能,这些我想都是有益的。你既可以在未定义行为的类型角度用“打开优化”来看待这些,也可以认为它们是“被避免的、如果非让这些行为有定义不可所带来的额外开销”。虽然有些时候编译器会优化掉这些额外开销,但宽泛地(对每个情况都)这样做将需要停机问题以及许多其它“有趣的挑战”的解答。

  同样值得指出的是,Clang 和 GCC 都敲“定”了一些 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 作为迭代变量。
    值得注意,无符号整数溢出是保证定义为补码(即回绕)溢出的(ISO/IEC C99 P34 6.2.5-9),所以你总可以这么干。为使有符号整数溢出结果有定义的代价是这些优化简简单单就被禁止了(例如,在 64 位平台上,通常的症状是一吨的符号扩展)。Clang 和 GCC 都接受 -fwrapv 参数,用以强迫编译器定义有符号整数溢出的结果(除了 -INT_MIN 之外(原文 other than divide of INT_MIN by -1))。
  移位移过头:
    把一个 uint32_t(或者 unsigend __int32,对于 VC/VC++ 而言)移位 32 位或更多,结果是未定义的。我猜这起源于其所依赖的移位指令在不同的 CPU 上干不同的事:例如,x86 把移位量截断到 5 位(原文如此,应该是指非移位指令中内嵌的移位?),从而移 32 位等同于没移位;而 PowerPC 截断到 6 位,从而移 32 位结果总是 0。正因为存在着这些硬件上的差异,结果才被 C 扔着没定义——也就是说在 PowerPC 上移 32 位可能会格掉你家硬盘哦,结果又不保证是零的说。要定义这一未定义行为的开销,是编译器要为移位生成一个额外的诸如 and 的操作,从而使得移位操作在通常的 CPU 上贵出一倍去。
  对野指针的解引用以及数组越界:
    对一个随遍指的指针(指 NULL/nullptr、已经 free 掉的指针、等等)进行解引用操作、以及作为其特例的数组越界访问,是 C 程序中有一个应当不言自明的 bug 之源。为了从源头上根除这些未定义行为,数组访问需要检查范围,二进制接口也要做出改变以确保范围信息跟着任何一个遵循指针算术的指针到处晃悠。对于许多数值计算及其它程序来说,这将是一个极其昂贵的开销——同样需要打破的还有与每一个现存 C 库的二进制兼容性。
    与通常的想法不同,C 中 *NULL 是未定义的(ISO/IEC C99 P79 6.5.3.2-4)。这没被定义为自陷;而且如果你 mmap(0, ...),结果也不被定义成就访问那一页面(原文如此,不过据说第一参数为零的意思是由操作系统自行确定映射起点)。这违反了禁止解引用野指针的原则,还有把 NULL 作为哨兵的使用方法。*NULL 无定义,这允许了许多种类的优化:Java 禁止编译器把有副作用的操作挪过任何不能由优化器证明一定非 null 的对象指针的解引用操作,这对调度和其它优化的影响是显而易见的。基于 C 的语言中,不定义 NULL 允许为数众多的简单标量优化——拿宏展开和内联所生成的代码来开刀。

    如果使用一个基于 LLVM 的编译器,你可以用 *((volatile void *)(NULL)) 的方式故意弄出个崩溃来,因为 volatile 一般来说是优化器不会染指的东西。就目前来说,没有那么一个编译器参数可以允许让随便一个读取 *NULL 的操作合法化、或者让随便一个读操作知道它的指针“允许为 NULL”。
  违反类型规则:
    把一个 int* 转换为 float* 并将其解引用(换句话说,就是用 float 的格式读取原来 int 的二进制花样)是未定义的。C 要求这样的类型转换使用 memcpy,直接 *((float*)(&i)) 是无效的、并将导致未定义的行为。这条规则相当微妙,我这里也不想详细描述(例外包括 char*、vector(原文如此,是说 std::vector 还是 CPU 的向量指令?)的特殊属性、使用 union 来执行转换,等等)。这一行为允许使用一种在种种内存访问优化中广为使用的、称为“基于类型的别名解析(Type-Based Alias Analysis,TBAA,参见 http://www.drdobbs.com/cpp/type-based-alias-analysis/184404273?_requestid=510121)”的分析方法,并且可以显著地提升生成出代码的性能。例如,此一规则允许 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;//在 zero_array() 中导致 TBAA 违例。
        zero_array();
      }
    此种滥用相当罕见。这也就是为何标准委员会决定显著的性能提升值得冒这种“合理的”类型转换所带来意外结果的风险。值得指出,Java 可以从基于类型的优化中获益而不必付出那些代价,因为它根本就没有不安全的指针强制转换的语言机制。
  不管怎么说,我希望你至此可以对 C 中“不定义行为以允许执行某些种类的优化”的情况有个总体认识。当然也有其它的情况,比如 foo(i, ++i) 之类的顺序点违例、多线程程序中的竞争条件、违反“限制”、除以零,之类。
  下一篇文章中,我们将讨论为何 C 中的未定义行为相当吓人,如果性能不是你的唯一目标的话。而在最后一篇文章中,我们将讨论 LLVM 和 Clang 是如何处理它(未定义行为)的。
  
  Chris Lattner 2011-05-13 11:25

(原文转自:http://tieba.baidu.com/p/1803801220?pid=23301463095&see_lz=1

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值