LLVM 项目博客

LLVM 项目新闻和来自战壕的细节

每个 C 程序员都应该了解的关于未定义行为 #2/3

在我们系列的 第一部分 中,我们讨论了什么是未定义行为,以及它如何允许 C 和 C++ 编译器生成比“安全”语言更高性能的应用程序。这篇文章讨论了“不安全”的 C 语言到底有多不安全,解释了未定义行为可能导致的一些非常令人惊讶的效果。在 第 3 部分 中,我们将讨论友好的编译器可以做些什么来减轻一些令人惊讶的结果,即使它们没有义务这样做。

我喜欢称之为“为什么未定义行为对于 C 程序员来说往往是一件可怕的事情”。:-)


翻译版本:日语西班牙语>。

相互作用的编译器优化导致意想不到的结果


一个现代的编译器优化器包含许多按照特定顺序运行的优化,有时会迭代,并且随着编译器的发展而改变(例如,发布了新版本)。此外,不同的编译器通常具有截然不同的优化器。由于优化是在不同的阶段运行的,因此由于先前的优化改变了代码,可能会出现突发性影响。

让我们看一个简单的例子(简化自 Linux 内核中发现的一个可利用的漏洞)来使之更加具体

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

在这个例子中,代码“明显地”检查了空指针。如果编译器碰巧在“冗余空检查消除”阶段之前运行“死代码消除”,那么我们会看到代码在这两个步骤中发生变化

void contains_null_check_after_DCE(int *P) {
//int dead = *P; // deleted by the optimizer.
if (P == 0)
return;
*P = 4;
}

然后

void contains_null_check_after_DCE_and_RNCE(int *P) {
if (P == 0) // Null check not redundant, and is kept.
return;
*P = 4;
}

但是,如果优化器碰巧结构不同,它可能会在 DCE 之前运行 RNCE。这将给我们这两个步骤

void contains_null_check_after_RNCE(int *P) {
int dead = *P;
if (false) // P was dereferenced by this point, so it can't be null
return;
*P = 4;
}

然后死代码消除运行

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

对许多(合理的!)程序员来说,从该函数中删除空检查会非常令人惊讶(他们可能还会向编译器提交一个错误报告:)。但是,根据标准,“contains_null_check_after_DCE_and_RNCE”和“contains_null_check_after_RNCE_and_DCE”都是“contains_null_check”的完全有效的优化形式,并且这两个优化都对各种应用程序的性能至关重要。

虽然这是一个故意简单且人为的例子,但这种事情经常发生在内联中:内联函数通常会暴露许多次级优化机会。这意味着,如果优化器决定内联函数,各种局部优化可能会启动,从而改变代码的行为。这完全符合标准,并且在实践中对性能至关重要。

未定义行为和安全并不兼容


C 语言家族用于编写各种安全关键代码,例如内核、setuid 守护程序、网络浏览器等等。此代码暴露于恶意输入,并且错误会导致各种可利用的安全问题。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 足够大以容纳从文件中读取的数据(因为需要添加一个空终止符字节),如果出现整数溢出错误,则退出。但是,这正是我们之前 给出的例子,其中编译器被允许(有效地)优化掉检查。这意味着编译器完全有可能将这段代码转换为

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(可能是磁盘上文件的尺寸)时,这很可能是一个可利用的漏洞。让我们考虑一下这有多糟糕:代码审核人员会非常合理地认为正在进行适当的溢出检查。测试代码的人会发现没有问题,除非他们专门测试了该错误路径。安全代码似乎正常工作,直到有人利用了漏洞。总而言之,这是一类令人惊讶且非常可怕的错误。幸运的是,在这种情况下,修复很简单:只需使用“size == INT_MAX”或类似方法。

事实证明,整数溢出由于多种原因而成为安全问题。即使你正在使用完全定义的整数运算(通过使用-fwrapv或使用无符号整数),也存在另一类 整数溢出错误。幸运的是,这类错误在代码中可见,并且经验丰富的安全审核人员通常会意识到这个问题。


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

有些人(例如,喜欢查看生成的机器代码的低级嵌入式程序员)在优化开启的情况下进行所有开发。由于代码在开发时经常出现错误,因此这些人最终会看到大量的令人惊讶的优化,这些优化会导致运行时难以调试的行为。例如,在第一篇文章中的“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()。在这种情况下,开发人员忘记调用“set”,没有因空指针解引用而崩溃,并且当其他人进行调试构建时,他们的代码崩溃了。

要点是这是一个可修复的问题:如果你怀疑发生了类似这样的奇怪问题,请尝试在 -O0 级别进行构建,在这种情况下,编译器不太可能进行任何优化。


使用未定义行为的“工作”代码可能会随着编译器的演进或更改而“崩溃”

我们已经看到了很多案例,其中“似乎正在工作”的应用程序在使用更新的 LLVM 进行构建时,或者在将应用程序从 GCC 迁移到 LLVM 时突然崩溃。虽然 LLVM 偶尔也会出现一两个错误:-),但这通常是因为应用程序中存在的潜在错误,现在这些错误被编译器暴露出来了。这可以通过各种方式发生,以下是两个例子

1. 一个未初始化的变量,该变量“之前”由于运气而被零初始化,现在它共享另一个非零寄存器。这通常由寄存器分配更改暴露出来。

2. 栈上的数组溢出,它开始覆盖一个实际重要的变量,而不是一个死变量。当编译器重新排列它在栈上打包内容的方式,或者在共享具有非重叠生命周期的值的栈空间方面变得更加激进时,就会暴露出来。

重要的是要认识到,基于未定义行为的几乎任何优化都可以在将来的任何时间开始触发有问题的代码。内联、循环展开、内存提升和其他优化将不断改进,它们存在的一个重要原因是暴露像上面这样的次级优化。

对我来说,这让人非常不满意,部分原因是编译器不可避免地被指责,但也因为这意味着大量的 C 代码就像地雷,随时可能爆炸。更糟糕的是,因为...

没有可靠的方法来确定大型代码库是否包含未定义行为

更糟糕的是,没有好办法来确定大型应用程序是否没有未定义行为,因此不会在将来发生故障。有很多有用的工具可以帮助找到一些错误,但没有任何工具可以完全保证你的代码不会在将来发生故障。让我们看看这些选项,以及它们的优缺点

1. Valgrindmemcheck 工具 是一种非常棒的工具,可以找到各种未初始化的变量和其他内存错误。Valgrind 的局限性在于它非常慢,它只能找到仍然存在于生成的机器代码中的错误(因此它 无法找到优化器删除的内容),并且它不知道源语言是 C(因此它无法找到移位超出范围或有符号整数溢出错误)。

2. Clang 有一个实验性的-fcatch-undefined-behavior模式,它插入运行时检查以查找违反行为,例如移位量超出范围、一些简单的数组越界错误等等。它的局限性在于它会减慢应用程序的运行时,并且它无法帮助你解决随机指针解引用(如 Valgrind 可以做到的),但它可以找到其他重要的错误。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. 编译器警告消息对于查找这类错误的一些类别很有用,例如未初始化的变量和简单的整数溢出错误。它有两个主要局限性:1) 它没有关于代码执行时的动态信息,以及 2) 它必须运行得非常快,因为任何分析都会减慢编译时间。

4. Clang 静态分析器 执行更深入的分析,试图找到错误(包括使用未定义行为,例如空指针解引用)。你可以把它想象成生成增强型的编译器警告消息,因为它不受普通警告的编译时限制。静态分析器的主要缺点是 1) 它没有关于程序运行时的动态信息,以及 2) 它没有集成到许多开发人员的正常工作流程中(尽管它与 Xcode 3.2 及更高版本 的集成非常棒)。

5. LLVM 的 "Klee" 子项目 使用符号分析来 "尝试代码中所有可能的路径",以发现代码中的错误,并 **生成测试用例**。这是一个很棒的小项目,其主要限制在于无法在大型应用程序上实际运行。

6. 虽然我从未尝试过,但 C-Semantics 工具 由 Chucky Ellison 和 Grigore Rosu 开发,这是一个非常有趣的工具,它显然可以发现某些类型的错误(例如序列点违规)。它仍然是一个研究原型,但可能有助于发现(小型且自包含)程序中的错误。我建议阅读 John Regehr 关于它的文章 以了解更多信息。

最终结果是,我们有许多工具可以用来发现一些错误,但没有好的方法来证明应用程序没有未定义的行为。鉴于现实世界应用程序中存在大量错误,以及 C 被用于广泛的关键应用程序,这非常可怕。在我们 最后一篇文章 中,我探讨了 C 编译器在处理未定义行为时各种选项,重点关注 Clang

- Chris Lattner