作者:Chris Lattner
原作:http://blog.llvm.org/2011/05/what-every-c-programmer-should-know_14.html
在我们系列的第一部分,我们讨论了未定义行为是什么,及它如何允许C及C++编译器产生比“安全”语言更高性能的应用程序。本帖讨论C实际上怎样“不安全”,解释未定义行为可以导致的某些非常令人惊奇的效果。在第3部分,我们讨论友好的编译器可以做什么来减轻一些惊诧,即使它们没有被要求。
我倾向于称之为“为什么未定义行为对于C程序员通常是一个吓人的、可怕的事”。J
相互影响的编译器优化导致意外的结果
一个现代的编译器优化器包含许多以特定次序运行,有时迭代,的优化,并随着编译器的演进(即新发布的出现)而变化。同样,不同的编译器通常具有实质上不同的优化器。因为优化运行在不同的阶段,意外的结果会因为之前的优化改变了代码而发生。
让我们看一下一个愚蠢的例子(简化自在Linux内核中发现的一个可利用的漏洞)使之更加具体:
void contains_null_check(int *P) {
int dead = *P;
if (P == 0)
return;
*P = 4;
}
在这个例子中,代码“明确地”检查空指针。如果编译器恰好在“重复空指针检查消除(Redundant NullCheck Elimination)”遍之前运行“死代码消除(DeadCode Elimination)”,那么我们会看到代码以这两步演化:
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;
}
对于许多(通情达理的!)程序员,从这个函数删除空指针检查是非常意外的(他们可能会把它报告为编译器的bug)。不过,根据标准"contains_null_check_after_DCE_and_RNCE"与"contains_null_check_after_RNCE_and_DCE"都是完全有效的优化形式,并且对于各种应用的性能,涉及的这两个优化都是重要的。
尽管这是一个故意简单及做作的例子,这种事情始终与内联一起发生:内联一个函数通常暴露若干次要的优化机会。这意味着如果优化器决定内联一个函数,各种局部优化可以介入,它们改变了代码的行为。根据标准这完全合法,而且在实践中对性能是重要的。
未定义行为与安全性不能兼容
C族编程语言用于编写范围广阔的安全关键代码,比如内核,setuid守护进程,web浏览器等等。这个代码被暴露给有敌意的输入,bug会导致各种有机可乘的安全问题。C一个广为援引的好处是:在阅读代码时,相对容易地理解发生了什么。
不过,未定义行为剥夺了这个属性。毕竟,大多数程序员会认为
"contains_null_check"会进行上面的一个空指针检查。尽管这个案例不是太出人意料(如果传入一个空指针,代码可能在储存处崩溃,这相对容易调试),有许多各种看上去非常合理、却完全非法的C片段。这个问题制约了许多项目(包括Linux Kernel,OpenSSL,glibc等),甚至使得CERT对GCC发出一个漏洞通知(vulnerability note)(虽然我个人相信所有广泛使用的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,这些兄弟最终发现可以导致运行时难以调试行为的,令人惊讶的优化的数目不成比例地多。例如,在第一篇文章的"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. 在栈上的一个数组溢出,它开始破坏一个目前非常重要的变量,而不是已经死去的对象。在编译器重新安排在栈上封包时,或对生命期不重合的变量共享栈空间方面变得更为激进时,这被暴露出来。
要认识的重要及可怕的事是:几乎任何基于未定义行为的优化在未来的任何时候会开始触发错误代码。内联,循环展开,内存提领及其它优化将越来越好,它们存在的一个重要原因是暴露像上面那样的初级优化。
对我而言,这是非常不令人满意的,部分因为编译器不可避免地最终受到指责,还因为这意味着庞大的C代码是等待爆发的地雷。这甚至更糟,因为……
没有可靠的方式来确定一个大块代码是否包含未定义行为
使得地雷变得更糟得多的是:没有好的方式来确定一个大规模应用程序没有未定义行为,因此在将来不容易出问题,这个事实。有许多有用的工具可以帮助找出某些bug,但没有什么能完全保证你的代码在将来不会出问题。让我们看一下这些选项,连同它们的长处与缺点:
1. Valgrind memcheck tool是一个找出各种未初始化变量及其它内存bug的奇妙的方式。Valgrind是局限的,因为它相当慢,它仅可以找出仍然存在于生成的机器码中的bug(因此它不能找出优化器删除的东西),并且不知道源语言是C(因此它不能找出越界偏移或有符号整数溢出bug)。
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的某些类型,像未初始化变量及简单的整数溢出,是有好处的。它有两个主要的局限:1)在执行时它没有代码的动态信息,2)它必须运行得非常快,因为执行的任何分析延长了编译时间。
4. ClangStatic Analyzer执行一个要深入得多的分析,以尝试找出bug(包括未定义行为的使用,像空指针解引用)。你可以把它想象为产生加大马力的编译器警告消息,因为它不受普通警告编译时间约束的限制。这个静态分析器的主要缺点是:1)在运行时,它没有关于你程序的动态信息,而且2)对于许多开发者,它没有整合入普通的工作流(虽然其整合入Xcode3.2及后版本是不可思议的)。
5. LLVM"Klee"子项目在一段代码中使用符号化分析“尝试每个可能的路径”来查找该代码中的bug,并且它产生一个测试用例。它是一个很棒的小项目,主要受到不能实际运行在大规模应用上的限制。
6. 尽管我从未尝试它,ChuckyEllison与Grigore Rosu的C-Semanticstool是一个非常有趣的工具,它显然能找到某些类型的bug(比如顺序点违反)。它仍然是一个研究原型,但可以用于在(小的、自包含)程序中查找bug。我建议阅读John Regehr关于此的帖子。
最终结果是,我们有许多工具来查找某些bug,但没有好的方法来证明一个应用程序没有未定义行为。考虑到在真实世界应用程序中有大量的bug,并且C广泛用于关键的应用程序,这相当可怕。在我们最后的文章里,我着眼于在处理未定义行为时C编译器所具有的各种选项,特别关注Clang。