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

54 篇文章 2 订阅
24 篇文章 11 订阅
译自: http://blog.llvm.org/2011/05/what-every-c-programmer-should-know_21.html (可能需翻墙)
原日译版: http://blog-ja.intransient.info/2011/05/c-13.html
  
  在第一部分中,我们对 C 中的未定义行为和它允许 C 较更“安全”之语言更为高效的几个情况略作了解。在第二部分中,我们看到了上述情况所引发的惊人 bug、以及一些广为流传的关于 C 的误解。在本文中,我们将看到编译器在提供关于这些咬人陷阱警告中所面临的挑战,并讨论一些 LLVM 和 Clang 提供的、帮助既能得到这些性能提升又能移除某些此类惊吓的特性及工具。  为啥你不在进行基于未定义行为的优化时提出警告?
  人们经常问编译器“既然任何的此种情况实际上都可能意味着用户代码中的 bug,为什么不在利用未定义行为进行优化时提出警告”。这种做法的难度在于:一是可能提出远远超过有用程度的警告——因为每次就算没有 bug,这些优化也会掺和进来;二是想在只有人们想要时才产生这些警告是个太需要技巧的活;三是我们没办法(对用户)表述一串优化是怎么搅合到一起才会给这种(基于未定义行为的)优化暴露出机会的。下面我们分条解释:
  
  做有用功“真不容易”
  看一个例子。即使不合法的类型转换 bug 经常被 TBAA 暴露出来,在优化第一部分中的那个 zero_array() 的例子时产生一个“优化器正在假定 P 和 P[i] 并不互为别名”的警告也无甚助益。
      float *P;
      void zero_array(){
        int i;
        for(i=0; i<10000; ++i){
          P[i]=0.0f;
        }
      }
  除开这个“假积极”的问题之外,一个符号逻辑学上的问题是优化器并没有足够用来产生一个合理警告的信息。首先,优化器是在一个已经抽象过的、远异于 C 的代码表示形式(LLVM IR, http://llvm.org/docs/LangRef.html )上工作的;其次,在优化器尝试“把读 P 操作从循环中外提”时不知道 TBAA 才是解决指针互为别名查询的分析手段,而此时编译器已经来到较高层次了。嗯,这里是“编译器娘抽抽搭搭时间”,但这也确实是个大难题。
  
  只在人们想要时才产生警告实在太难
  Clang 针对简单、明显的未定义情况实现了许多的警告,比如 x<<421 这种过头离谱的移位。你可能认为这只不过是小打小闹,但事实上这很难,因为人们不想在死代码里收到这些未定义行为的警告( http://llvm.org/bugs/show_bug.cgi?id=5544 ,还有复本  http://llvm.org/bugs/show_bug.cgi?id=6933 )。
  死代码可以以多种形式出现:比如被传了某个参数时以滑稽方式展开的宏。我们已经收到抱怨说应当在需要对 switch 语句进行控制流分析( http://llvm.org/bugs/show_bug.cgi?id=9322 )以证明是无法到达的一些 case 上提出警告。“C 语言的 switch 并不一定要有着良好结构的(英文喂鸡,Duff's device)”这一事实对此并没有帮助。
  Clang 中的解决方案是一个不断增长的基础结构,用来处理“运行时行为”的警告,还有把这些剪枝掉的代码——如果后来证明这一块不会被执行到,那么就不提出警告。不过这有些像是跟程序员们玩军备竞赛,因为总会有我们预想不到的用法,而且在前端这么干意味着它并不总能抓住人们想要它抓住的情况。
  
  表述可以暴露出优化机会的优化串
  如果前段在生成优质警告时有困难,没准可以让优化器代劳!但这里最大的问题是关于数据跟踪的。编译器的优化器包括成打的优化过程,每次都把代码改得更正规或者(理想情况下)更快。比如,如果内联器决定要内联掉一个函数,那就可能露出优化掉 X*2/2 这种表达式的机会。
  虽然我已经(在第一部分)给出了一些简单例子来演示,许多这种优化介入的情况还是在编译器执行宏实例化、内联、以及其它消除抽象操作所形成的代码中出现。事实上人类通常不会直接写出这种蠢东西来,那么对于警告来说,这意味着为了把对用户代码提出的质疑接力传回来,警告信息必须重建“编译器是如何得到它现在所处理的中间代码”这一过程的。我们需要这样说话的能力:
    警告:经过三次内联(可能因链接时优化跨越了文件而产生)、一些公共子表达式消除、在将其外提出循环并证明这 13 个指针并不互为别名之后,编译器发现有个 case 里正在干一些未定义的事。这可能是代码里的一个 bug,或者是因为使用了宏和内联、并且这些无效的代码是不会在运行时执行到的——但编译器并不能证明它是死代码。
  不幸的是,我们并没有内部追踪用的基础结构来产生这种警告。还有,即使我们有那个,编译器也没有一个好到能向用户报告这些的用户界面。

  最终,未定义的行为对优化器是重要的,因为它表示“此操作非法——你可以假定它永远不会发生”。一个像 *P 这样的例子让优化器推断 P 不会空,而一个 *NULL(比如,经过几次常量推送和内联之后)则会让优化器知道这段代码一定是不可达的。这里最重要的建言是,因为编译器不可能解决停机问题,也就无从得知这段代码事实上是不是死代码(如 C 标准所言,它一定是)、或者是不是在一段(可能很长)的优化后暴露出来的一个 bug。因为没有一个通用的好方法去区分刚才那两种情况,基本所有的警告基本上都会是噪音。

  Clang 处理未定义行为的做法
  有鉴于我们在论及未定义行为时所处的尴尬地位,你可能在奇怪 Clang 和 LLVM 是如何改变这一困境的。我曾经提到过几点:Clang 静态分析器、Klee 项目、-fcatch-undefined-behavior 选项是追踪一些此类错误的有用工具,但问题是这些并不像编译器一样被广泛使用,所以任何可以直接在编译器里动的手脚都能提供比那些工具里做同样事多得多的好处。不过记住,编译器受限于没有动态信息,编译时间也不能拖得太长。
  Clang 拯救世界上代码(喂)的第一步是比别的编译器在默认情况下多开一整套的警告。即使一些开发人员信奉 -Wall -Wextra 之类的宗教,许多人还是不知道或懒得写这些参数。默认情况下打开更多警告在多数时间下可以抓住更多 bug。
  第二步是对代码中明显可见的多种未定义行为(包括 *NULL、移位移过头、等等)生成警告,以便捕捉一些常见的错误。一些这种警告在上文中有提到,但实际中它们似乎工作得还算不错。
  第三步是 LLVM 优化器经常采取比它所能更少的行动。即使标准说了任何未定义行为的实例在程序中都有完全无限制的效果,这也不是一个有用的、对开发者友好的可利用行为。反之,LLVM 优化器以几种不同的方式处理这些优化(下面描述的是 LLVM IR 的规则而不是 C 的,不好意思啦):
    一、一些未定义行为悄悄换成隐式自陷操作,如果有好方法的话。比如,Clang 会把这个 C++ 函数:
        int *foo(long x){
         return new int[x];
        }
      编译成这段 x86-64 机器码:
        __Z3fool:
          movl $4, %ecx
          movq %rdi, %rax
          mulq %rcx
          movq $-1, %rdi    # 溢出时把 size 置成 -1
          cmovnoq %rax, %rdi  # 用来让 new 抛出 std::bad_alloc
          jmp __Znam
      而不是 GCC 这样:
        __Z3fool:
          salq $2, %rdi
          jmp __Znam       # 溢出时有安全性 bug!
      这里的不同点在于我们决定投入几个周期来防止一个严重的、可能导致缓冲区溢出及此类攻击的整数溢出 bug(operator new 经常很昂贵,所以这里的开销基本觉察不到)(http://cert.uni-stuttgart.de/ticker/advisories/calloc.html)。GCC 那帮子人至少从 2005 年(http://gcc.gnu.org/bugzilla/show_bug.cgi?id=19351)就知道有这么回事,但在我现在写这个时候还没修好它呢。
    二、在未定义的值上执行算术操作(http://llvm.org/docs/LangRef.html)被认为是产生一个未定义值、而非一个未定义行为,区别在于未定义值不会干出格掉你的硬盘或者别的啥缺德事。一个有用的改善发生在算术操作将对未定义值的任何可能实例产生相同的位花样时,比如,优化器假设 undef&1 只有最低位未定义、高位都是零,这样 (undef&1)>>1 在 LLVM 下结果就总是 0,不是未定义。
    三、动态执行某个导致未定义操作的算术运算(比如有符号整数溢出)产生一个逻辑陷阱值(http://llvm.org/docs/LangRef.html),可以污染其上施加的任何计算操作,但并不会干掉你的整个程序。这意味着那个未定义操作在逻辑上的下游操作可能受到影响,但你的整个程序并没坏掉。这也就是为什么优化器会砍掉操作未初始化变量的代码——举例而言。

    四、对空指针的写以及调用(函数指针)操作换成调用 __builtin_trap()(在 x86 上产生 ud2 之类的自陷指令)。这在优化过的代码(作为其它内联、常量推送之类变换的结果)中到处可见,并且我们也习惯于直接咔嚓掉包含那些的代码,因为它们是“显然不可达”的。
      即使(从学术性语言律师的立场来说)这是严格正确的,我们还是很快了解到人们确实偶尔会 *NULL,而让执行过程直接掉进下一个函数开头的做法也使问题变得更扑朔迷离。从性能角度来看,暴露出这些的最重要方面是压扁下游的代码。因此,Clang 把这些换成运行时的自陷:如果这些中的任何一条确实被执行到了,程序立即中断并可以调试。这样做的缺点是代码量因为多了这些操作和控制它们的谓词而稍有膨胀。
    五、当程序员的想法很明确时,优化器确实会稍微努力去“做正确的事”(比如 float *p; 之后 *(int*)P 这样)。这在大多数通常情况下有用,但你最好别想依赖这个。另外,也有不少你可能认为是“明显”、但经过一大长串变换之后就不再“明显”了的例子。
    六、不属于这些类别的优化,比如第一部分中的 zero_array() 和 set()/call() 两个例子,是悄悄执行不给用户提示的。我们这样做是因为没啥有用的好说,还有(bug 多多的)真实代码也不大可能被这些优化弄坏掉。
  一个可以有所作为的主要改进方面是关于自陷的插入。我认为添加一个(默认情况下关掉的)开关、让优化器每次生成一个自陷指令就报一次警告。这可能对有些代码来说极其うるさい,但对别的还是挺有用的。这里,首要的制约因素是为了能使优化器报告错误而在基础结构上动的手脚:优化器没有有用的源代码位置信息,除了打开调试(但这是可以修正的)。而另一个、或者说更重要的制约因素是警告内容并不会包含任何能够解释“这个操作是展开循环三次、内联了四层函数调用而得到”的“追踪用”信息。我们至多只能指出原操作所在的文件、行、列这种最为普适的信息,而在其它情况下(所报的警告)仍然是云里雾里的。不管怎么说,这也从来不是我们实现的优先目标,因为它又不友好、又不会让我们默认打开、又不好实现。
  
  用 C 语言的一个较为安全的方言(以及其它选项)
  一个最终的选项,如果你不关心“极品性能”的话,就是使用各种编译器选项以启用 C 的消除掉这些未定义行为的方言。比如,-fwrapv 消除了有符号整数溢出的未定义性(不过,注意它并没有消除可能的整数溢出所导致的安全脆弱性);-fno-strict-aliasing 禁用 TTAA,所以你可以随便无视那些类型规则。如果有需求的话,我们甚至可以给 Clang 加一个隐式零初始化所有局部变量、或者一个在每次移位操作后添一条与操作、之类之类的选项。不幸的是,并没有一个顺利方式可以完全消除掉 C 中未定义行为、又不打破二进制接口、还不会完全毁掉性能。另一个问题是你写的不再是 C 语言,而是它的一个很相似但不可移植的方言。
  如果在不可移植的 C 方言里写代码不是你的菜,那么 -ftrapv 和 -fcatch-undefined-behavior 选项(还有上面提到过的其它工具)会是你军械库里有用的追踪 bug 武器。在调试编译中启用它们会是提早发现相关 bug 的好方法。这些选项也可能在建构安全关键程序中起重要的作用。虽然它们并不保证一定能找到所有 bug,但最少能找到一个有帮助的子集。
  最后,这里的真实问题是 C 根本就不是一门“安全”的语言,而且(尽管是个人生的赢家)许多人还不真正地了解这门语言是怎么工作的。在它早在 1989 年标准化之前跨越数年代的发展中,C 从一个“PDP 汇编之上的一薄层低级系统编程语言”变迁到“通过打破多数人的期待来尝试提供相当高性能的低级系统编程语言”。一方面,这些“开挂”的 C 几乎总是好猫,并且代码也拜其所赐而效率挺高(在有些场合下甚至高得多);另一方面,C 的开挂之处也经常是最令人惊讶、最火上浇油的。
  C 语言,有时在非常惊人的方面上来说,远不仅仅是一个可移植的汇编语言。希望这些讨论能有助于解释一些 C 未定义行为之后的论题,至少从一个编译器实现人员的角度来看。
  
  Chris Lattner 2011-05-21 00:48

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

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值