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

时间:2023-02-21 19:10:50
译自: http://blog.llvm.org/2011/05/what-every-c-programmer-should-know_14.html (可能需*)
原日译版: http://blog-ja.intransient.info/2011/05/c-23.html
  
  在第一部分,我们讨论了是什么未定义行为,以及它如何允许 C 及 C++ 编译器产生较“安全”语言性能为高之代码。本部分通过解释未定义行为可能导致的一些相当惊人的效果来谈论 C 是如何地“不安全”。下一部份,我们讨论一个友好的编译器可以怎样安抚这份惊吓——即使并不是必须这样做。
  其实我想把这篇博文起名叫《为啥未定义行为对 C 程序员来说总那么吓人》了,嘿嘿。
  
  编译器优化之间的互相作用导致惊人的结果
  一个现代编译器的优化器包含以指定顺序运行的许多优化操作(甚至有时候还会循环进行),而这些操作也随着诸如版本升级之类的事情而与时俱进。另外,不同的编译器也含有相当不同的优化器。因为优化操作在不同的阶段发生,前一更改代码的优化可能在后续阶段导致新生的效果。
  为了有更加具体的理解,看看一个蠢例子(从 Linux 内核里被发现的一个可利用的 bug 简化而来):
    void contains_null_check(int *P){
      int dead=*P;
      if(P==0){
        return;
      }
      *P=4;
    }
  在这个例子里,程序“清楚地”检查了空指针,如果编译器碰巧在“消除冗余的 NULL 检测(Redundant NULL-Check Elimination)”阶段前先进行了“消除死代码(Dead Code Elimination)”,那这段代码很可能像这样演化:
    void contains_null_check_after_DCE(int *P){
      //int dead=*P;//**掉了。
      if(P==0){
        return;
      }
      *P=4;
    }
  然后:
    void contains_null_check_after_DCE_and_RNCE(int *P){
      if(P==0){//干不掉,因为不是冗余的——前面没有检测过。
        return;
      }
      *P=4;
    }
  但是,如果优化器碰巧在消除死代码之前先消除了冗余的 NULL 检测,那么情况会变成这样:、
    void contains_null_check_after_RNCE(int *P){
      int dead=*P;
      if(false){//已经 *P 过了,所以它不应当是 NULL。
        return;
      }
      *P=4;
    }
  然后消除死代码:
    void contains_null_check_after_RNCE_and_DCE(int *P){
      //int dead=*P;
      //if(P==0){
      //  return;
      //}
      *P=4;
    }
  对于许多(这么说是很合理的!)的程序员来说,从这个函数中删除掉 NULL 检测是十分惊人的(或许他们会提交一个编译器 bug,哈)。然而,无论是先 DCE 后 RNCE 还是先 RNCE 后 DCE,这两个版本按照标准来说都是严格合法的优化版;而这两种优化操作对于各种程序来说也是很重要的。
  虽然这是个有意为之的简单示例,这种东西在内联过程中无时无刻不在发生:函数内联之后经常暴露出一堆第二轮优化的机会。这意味着如果优化器决定把一个函数内联掉,那么种种局部性优化就可以趁虚而入、改变代码的行为。这不仅是合乎标准的,实际上对于性能来说也很重要。
  
  未定义行为与安全**恶
  C 家族的编程语言被用于编写种类繁多的安全关键的代码,比如内核、setuid 守护进程、网络浏览器、等等。这些代码暴露在恶意输入下,而其中的 bug 可以导致各式各样可利用的安全问题。一个广为传颂的 C 的优点是,你在读代码时可以相对轻松地读懂发生了什么。

  然而。未定义行为剥夺了这一属性。毕竟,大多数程序员都会认为上面的“contains_null_check”函数会检测一下空指针。虽然这一情况并不是很吓人(在通过了空指针检测之后的写操作上崩溃,这还是比较容易调试的),许多看起来相当合理的 C 片段却是完全非法的。这问题已经咬过许多项目了(包括 Linux 内核、OpenSSL、glibc 等等),而且甚至导致 CERT(计算机安全应急响应小组)针对 GCC 放出了个关于脆弱性的警告(http://www.kb.cert.org/vuls/id/162289)——虽然我个人意见是所有广为使用的 C 编译器都有这毛病,不只是 GCC。

  看这段仔细编写的 C 代码:
    void process_something(int size){
      //捕捉整数溢出。
      if(size>size+1){
        abort();
      }
      ...
      char *string=malloc(size+1);
      read(fd, string, size);
      string[size]=0;
      do_something(string);
      free(string);
    }
  这段代码检查并确保了 malloc 分配了足够装下文件里读入的数据的内存(因为最后要加个 NUL 终结符),如果整数溢出时直接逃掉。但是,这也就是前面允许编译器合法地把检测操作优化掉的例子。这意味着编译器相当可能把这段代码变成:
    void process_something(int *data, int size){
      char *string=malloc(size+1);
      read(fd, string, size);
      string[size]=0;
      do_something(string);
      free(string);
    }
  在一个 64 位平台上编译时,如果 size 是 INT_MAX 那就出错了——这可能是磁盘上某个文件的大小啊(32 位版的 Linux 不支持超 4G 大文件么??)。想想这有多差劲:一个读到这段代码的核心审计员很可能认为已经进行了合适的溢出检查、某个测试员除了故意用错误的路径(这里应当是指此时找不到文件、而不是 size 溢出)测试之外没有发现问题,这段安全代码似乎是可以工作的——直到某人发现并开始利用这一点。总体来说,这是一类令人吃惊的 bug;不过幸运的是这个例子中修复的方法很简单:改成 size==INT_MAX 之类的就好。
  事实证明整数溢出基于许多理由来看都是个安全问题。即使你使用了有完整定义的整数算术(-fwrapv 或者无符号整数),也有另外一整套(英文喂鸡,Integer overflow)的整数溢出 bug 在等着你。幸运的是,这一类可以从代码里看出来,聪明的安全审计员也经常会警惕此种问题。
  
  调试已经优化过的代码可能没有任何意义
  有些人(例如,喜欢看生成的机器码的底层嵌入式程序员)在开发时把所有的优化都打开。因为代码在开发时经常出现 bug,这些人最后就会发现能导致运行时不好调试行为的惊人优化的数量是如此的不成比例。例如,偶然地丢掉上一篇中那个 zero_memory() 例子中的 i=0,可能导致编译器直接整个扔掉那个循环——只编译出一句 return;,因为 i 未初始化。
  另一个最近咬过人的有趣例子是关于一个(全局)函数指针。简化版的例子就像这样:
    static void (*FP)()=NULL;
    static void impl(){
      printf("hello\n");
    }
    void set(){
      FP=impl;
    }
    void call(){
      FP();
    }
  Clang 把它编译成:
    void set(){
    }
    void call(){
      printf("hello\n");
    }
  被允许这样做,是因为调用一个空函数指针是未定义的,因此编译器假定 set() 一定在 call() 之前被调用。在这种情况下,开发者即使忘了先 set,程序也不会因为一个 *NULL 操作而崩溃,直到另外的某人进行了一次调试版本的编译。
  结论是,这是个可修复的问题:如果你怀疑发生了什么像这样的奇怪东西,试着用 -O0(o 零,关优化)编译,这样编译器就不大可能执行任何的优化了。
  
  “可工作”的使用未定义行为的代码可能在编译器更新换代时失效
  “看起来能正常工作”的应用程序在升级 LLVM、或从 GCC 迁移到 LLVM 之后重新编译,然后坏掉的事情已经为广大人民群众所喜闻乐见了。LLVM 虽然本身也时不时地会有那么一两个 bug,嘿嘿(……),这种事通常还是因为应用程序中潜伏的、被新编译器暴露出来的 bug。这可能在种种情况下发生,例如:

  一、某个未初始化的变量以前(指以前版本的编译器)是零初始化的,现在它改到另外某个不为零的寄存器了。这通常是通过寄存器分配暴露出来。
  二、栈上的一个数组溢出,覆盖了一个有意义的变量而不是以前的某个死区域。这通常是因为编译器重新安排了栈帧的分配顺序、或者在让生命周期不重叠的变量共用存储区方面变得更为激进而暴露出来。
  重要而且吓人的事情,是了解几乎任何(此词重点)基于未定义行为的优化都可能在将来的任意时间被有 bug 的代码引发。内联、循环展开、内存提升和其它的优化做得越来越到位,而它们存在的一个重要原因就是给上面的二级优化暴露可乘之机。
  对我来说这很让人不爽。一方面是因为编译器最后不可避免地被喷,另一方面则因为那意味着大段的 C 代码成为等着炸人的地雷。事情甚至还会更早,因为——
  
  没有可靠的方法能确认大段的代码中是否含有未定义行为
  把刚才那个地雷放到更加更加差劲的地方的,是“没有那么一种方法,能确认一个大应用程序里是不是没有未定义行为、因此也不会在将来坏掉”这一事实。确实有很多能帮助查找某些 bug 的有用工具,但没有东西能百分之百保证你的代码将来不会坏掉。看看下面选项的优缺点:
  一、Valgrind 的 Memcheck(http://valgrind.org/info/tools.html)是一个梦幻般的查找所有未初始化变量之类内存问题的工具,但它太慢了、只能检测在生成的机器码中还残留的 bug(因此找不出被优化掉的那些,http://blog.regehr.org/archives/519),也不知道源代码是用 C 写的(因此找不出移位移过头、或者有符号整数溢出这种)。
  二、Clang 有个实验性的 -fcatch-undefined-behavior 模式,可以插入运行时检测的代码以查找移位移过头、一些简单的数组越界之类的错误。这个功能很受限,因为它减慢应用程序的运行速度,也不能查找解引用野指针的问题(Valgrind 就可以),但它可以查出别的重大 bug。Clang 也完全支持 -ftrapv 参数(别跟 -fwrapv 混了),此一参数导致有符号整数溢出时 CPU 自陷。(GCC 也有这个参数,不过以笔者的经验来看完全不靠谱。)下面是一个 -fcatch-undefined-behavior 的小示范:
   $ cat t.c
   int foo(int i){
     int x[2];
     x[i]=12;
     return x[i];
   }
  
   int main(){
     return foo(2);
   }
   $ clang t.c
   $ ./a.out
   $ clang t.c -fcatch-undefined-behavior
   $ ./a.out
   Illegal instruction
  三、编译器警告有助于找到一些这样的 bug,比如未初始化变量和简单的整数溢出。它有两个主要的限制:一是没有运行时的动态信息,二是为了不降低编译速度、检查必须匆匆掠过。
  四、Clang 静态分析器(http://clang-analyzer.llvm.org/)可以进行一个更深刻的检查以尝试找出错误(包括未定义行为的使用,如 *NULL)。你可以认为那是锦上添花版的编译器警告,因为它并不受通常警告只能发生在编译时的限制。静态分析器的主要缺点是:一没有运行时的动态信息,二是许多开发人员的正常流程里都没它啥事(虽然在 Xcode 3.2 及更高版本中它是被完美整合进来的)。
  五、LLVM 的 Klee 子项目(原链接貌似已失效?)使用符号分析对一段代码尝试路径覆盖、最后生成测试用例。不过这个小项目也很受限,因为在大规模的应用程序上运行是不现实的。
  六、尽管我从没用过,Chucky Ellison 和 Grigore Rosu 的 C 语义分析工具(http://code.google.com/p/c-semantics/)是个非常有趣的工具,可以找出几类错误(如顺序点违例)。这个工具仍是个研发阶段的原型,但可能对在(小型、自维护的)程序中查找 bug 很有用。建议阅读 John Regehr 的博文(http://blog.regehr.org/archives/523)以了解更多信息。
  
  最终,我们的工具箱里现在有了很多可供查找一些 bug 的工具,但没有一个好方法能证明一个应用程序中没有未定义行为。既然真实世界的应用程序中有许许多多的 bug、C 语言又在关键程序中被广泛使用的话,这就很吓人了。在最后一部分中,我将侧重于 Clang、探讨 C 编译器提供的各种对付未定义行为的选项。
  
  Chris Lattner 2011-05-14 12:33

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