What Every C Programmer Should Know About Undefined Behavior #2/3

原作者 Chris Lattner 发表于 2011 年 5 月 14 日(星期六),原文链接

在本系列文章的 第一篇 中,我们讨论了未定义行为的概念, 以及它如何允许 C 和 C++ 编译器产出比 “安全” 语言更高性能的应用程序。 本文则讨论 C 语言到底有多么 “不安全”, 解释未定义行为可能导致的一些非常令人吃惊的结果。在 第三篇 中, 我们会讨论友好的编译器可以如何消除这些意外结果,尽管标准并不要求。 我喜欢将本文概括为 为什么未定义行为对于 C 程序员来说通常是可怕和糟糕的事情 。:-)

编译优化之间的相互作用导致令人吃惊的结果

现代编译器的代码优化器包含许多优化操作,它们按特定顺序运行, 有时会反复迭代运行,运行顺序还会随着编译器的发展而变化 (例如当新的编译器版本发布时)。另外,不同编译器的代码优化器也相当不同。 由于优化是分阶段进行的,某些问题会因为前一轮的优化突然体现出来。

请阅读下面这个愚蠢的例子(从 Linux 内核中一个可利用的 bug 简化), 从而证实以上结论:

void contains_null_check(int *P) {
  int dead = *P;
  if (P == 0)
    return;
  *P = 4;
}

在本例中,代码“显然”地检查了空指针。如果编译器恰巧在“冗余空指针检查消除” (RNCE)过程之前先进行“死码消除”(DCE)过程,则代码会按以下两步演化:

void contains_null_check_after_DCE(int *P) {
  [DEL: //int dead = *P; :DEL]      // 被优化器删除。
  if (P == 0)
    return;
  *P = 4;
}

之后:

void contains_null_check_after_DCE_and_RNCE(int *P) {
  if (P == 0)   // 空指针检查不是冗余的,保留。
    return;
  *P = 4;
}

然而,如果优化器的组织结构不同,它可以在 DCE 之前首先运行 RNCE。 这会给出以下两步的结果:

void contains_null_check_after_RNCE(int *P) {
  int dead = *P;
  if (false)  // P 在之前已经解引用过,不可能为空
    return;
  *P = 4;
}

然后死码消除过程运行:

void contains_null_check_after_RNCE_and_DCE(int *P) {
  [DEL: //int dead = *P; :DEL]
  [DEL: //if (false) :DEL]
  [DEL: //  return; :DEL]
  *P = 4
}

对于许多(讲道理的!)程序员而言,从这个函数中删除空指针检查让他们很吃惊 (他们可能还会为编译器提交一个 bug 报告 :)。然而,根据语言标准, contains_null_check_after_DCE_and_RNCEcontains_null_check_after_RNCE_and_DCE 作为 contains_null_check 的优化形式,都是绝对正确的,而且这两项优化对于一些应用程序的性能都很关键。

虽然上面的代码已经特意人为简化成了一个简单的例子, 但这种事情在使用内联函数时总是发生:函数内联往往会展示出许多二次优化的机会。 这意味着,如果优化器决定内联一个函数,则一些局部优化可能会介入, 从而改变代码的行为。根据语言标准,这是绝对正确的,而且在实践中, 这些优化对性能十分重要。

未定义行为和安全性水火不相容

C 家族语言已经被用于编写一些关键的安全性代码,例如内核、setuid 守护进程、 网络浏览器等。这些代码需要经受恶意输入的考验, 其中的 bug 可能导致各种各样的可利用的安全漏洞。

人们广泛引述 C 语言 “阅读代码时相对容易理解其行为的优点”。 然而,未定义行为却彻底摧毁了这一性质。不管怎么说,多数程序员还是会认为 上面的 contains_null_check 会进行空指针检查。这个例子并不可怕 (如果它跳过了空指针检查,结果可能导致存储时发生崩溃,这相对容易调试), 但实际上有很多 看上去很合理 的 C 代码片段其实是完全错误的。 这个问题已经坑过很多项目(包括 Linux 内核、OpenSSL 和 glibc 等), 甚至导致 CERT 针对 GCC 发了一个 安全缺陷警告 (虽然我个人认为, 所有广泛应用的 C 语言优化编译器都存在这个“缺陷”,不仅是 GCC)。

让我们再看一个例子。考虑下面“仔细编写”的 C 代码:

void process_something(int size) {
  // Catch integer overflow.
  if (size > size+1)
    abort();
  ...
  // Error checking from this code elided.
  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 时能够利用的 bug 。 让我们考虑这件事有多么糟糕:代码审核员读了代码, 会合理地认为代码已经进行了正确的溢出检查。测试人员找不出问题, 除非他们针对可能出错的执行路径进行了专门的测试。这段“安全的” 代码似乎正常工作,直到有人发现这个缺陷。说来说去, 这是一类令人吃惊且十分恐怖的 bug 。幸运的是它很容易修复: 只要使用 size == INT_MAX 或者类似的判断方法即可。

我们已经论证,整数溢出在许多情况下都会产生安全问题。 即使你在使用完整定义的整数代数(使用 -fwrapv 或者使用无符号整数), 也有可能碰到一类 完全不同 的整数溢出 bug 。 幸运的是这类 bug 在代码中容易辨认,有经验的安全审核人员能够注意到这种问题。

调试优化过的代码可能毫无意义

有些人(比如那些喜欢阅读生成的机器码的底层嵌入式程序员) 会在开发的全过程中启用优化。因为在开发过程中程序 经常 有 bug , 这些人最后会发现,很多令人吃惊的优化会在运行时导致难以调试的行为。 例如,如果在第一篇文章的 zero_array 函数中,忘记了 i=0, 那么编译器可以完全丢弃循环(将 zero_array 的函数体编译成 return;), 因为函数体中使用了未初始化的变量。

还有另一件有趣的,在一些人使用(全局)函数指针时带来麻烦的事情。 一个简化的例子如下:

static void (*FP)() = 0;
static void impl() {
  printf("hello\n");
}
void set() {
  FP = impl;
}
void call() {
  FP();
}

但 Clang 把它优化成:

void set() {}
void call() {
  printf("hello\n");
}

这是合法的,因为通过空指针调用函数是未定义行为,所以可以假设 set() 一定会在 call() 之前被调用。此时,如果开发者忘了调用 set(), 他的代码不会因为空指针解引用而崩溃, 结果代码会在其他人以调试模式进行编译时突然出现问题。

这个例子是可以修复的:如果你感觉类似这样的怪事正在发生,试着用 -O0 编译,此时编译器几乎完全不会进行优化。

“正常工作”的使用未定义行为的代码可能在更换或升级编译器时“停止工作”

我们已经看到,很多“看似正常工作”的应用程序在使用新版 LLVM 编译时, 或从 GCC 迁移到 LLVM 时,突然停止工作。尽管 LLVM 本身确实有一两个 bug :-), 以上现象最常见的原因仍然是应用中遗留的 bug 刚刚被编译器挖掘出来。 许多情况下都会发生这种事,比如:

  1. 一个未初始化的变量,在之前因为运气好,恰好被初始化成 0 , 而现在它和某个不是 0 的变量共享寄存器。这通常是因为寄存器分配算法的变化。
  2. 栈上一个数组的溢出突然开始覆盖重要变量,而不是像以前一样覆盖没用的数据。 这在编译器重新安排堆栈结构时会发生, 有时编译器甚至会激进地为生存期不重叠的变量值复用同一块栈空间。

我们必须注意一个重要而恐怖的事实, 即 任何 基于未定义行为的优化都可能在未来的任何时间触发代码中的 bug 。 内联、循环展开、内存提升和其他优化都会变得更强大, 它们存在的一项重要原因就是如同我们上面描述的那样,展示二次优化的可能性。

对于我来说,这很令人沮丧,部分原因是编译器不可避免地被人甩锅, 但另一方面也是因为这意味着巨大的 C 程序代码变成了随时会爆炸的地雷, 甚至比地雷还危险,因为 …

没有办法确定大型代码库是否有未定义行为

未定义行为像一颗埋在非常糟糕的位置的地雷,事实是, 没有好的办法 能确定大型应用程序是否没有未定义行为,因此不会在未来突然故障。 有许多有用的工具能够帮助找到 一些 bug , 但谁也不能完全保证你的代码不会在未来突然停止工作。让我们了解一下这些工具, 以及它们的优势和弱点:

1. Valgrind memcheck 工具是寻找未初始化变量等各种内存相关 bug 的绝妙工具。然而,Valgrind 的应用受到一些问题的限制,包括它很慢, 它只能发现在生成的机器码中仍然存在的 bug (即 [不能找到优化器已经移除的问题][5b]),甚至不知道程序是 C 语言编写的 (所以不能找到移位超过整数位数或带符号整数溢出这类问题)。

2. Clang 有一个实验性质的 -fcatch-undefined-behavior 模式, 它插入运行时检查,从而检查类似移位超过位数、某些简单的数组越界等问题。 它的应用主要受限于它会拖慢程序运行速度,而且不能帮你寻找野指针解引用 (而 Valgrind 可以),但它能找到其他重要的 bug 。Clang 也完全支持 -ftrapv 选项(不要与 -fwrapv 混淆),该选项使得带符号整数溢出在运行时陷入陷阱 (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

3. 编译器警告消息能够找到若干类型的 bug , 包括未初始化的变量和简单的整数溢出 bug 。然而它有两大局限性:

  1. 它完全没有关于代码如何执行的动态信息,
  2. 它的速度必须非常快,因为它执行的任何分析都会拖慢编译过程。

4. Clang 静态分析器 对源代码进行更加深入的分析,以寻找 bug (包括对未定义行为的使用,如空指针解引用)。 你可以认为它提供了增强版的编译警告, 因为它并不像常规的编译警告那样受到编译速度的限制。 它的主要局限性是,1) 完全没有关于程序如何执行的动态信息, 2) 许多开发者没有将它集成到自己的常规工作流程中(尽管它已经很好地集成到了 Xcode 3.2 和更新版本中)。

5. LLVM “Klee” 项目 采用符号分析法,对一小段代码“尝试所有可能路径” 以寻找 bug ,并 产生一个测试用例 。这是一个很好的项目, 但在大型应用程序上进行这种分析并不现实。

6. 尽管我没有尝试过,Chunck Ellision 和 Grigore Rosu 编写的 C-Semantics 是一个很有趣的工具,似乎能够找到某些特定类型的 bug (如违反 sequence point 规则的情况)。它仍然是一个研究原型, 但在寻找(小的,自包含的)程序中的 bug 时很有用。 我建议阅读 John Regehr 关于它的文章 了解更多信息。

综上所述,我们的工具箱中有很多工具,它们能找到一些 bug , 然而都不能证明一个应用程序绝对没有未定义行为。 再考虑到真实世界的程序总是有不少 bug ,而且 C 语言被广泛用于关键的程序, 这是很恐怖的。在我们的 最后一篇文章 中, 我们会讨论 C 编译器处理未定义行为的方法,特别是 Clang 处理它们的方法。

Chris Lattner 发表于 12:33 PM

Chris Lattner
Chris Lattner
Senior Director and Distinguished Engineer, TensorFlow Infrastructure and Technologies