About Undefined Behavior[译文]
原文:blog.llvm.org/2011/05/what-every-c-programmer-should-know.html
人们偶尔会问为什么LLVM的汇编代码有时会在优化器打开时产生SIGTRAP信号。经过深入研究,他们发现Clang生成了“ud2”指令(假设x86代码)——和__builtin_trap()生成的一样。这里关系到若干问题,都围绕C代码的未定义行为和LLVM如何处理它展开。
本博文(作为包括三篇的一个系列的第一篇)试图解释其中的一些问题,以便使读者可以更好地了解为此作出的权衡和复杂性,也许还有C的一些阴暗面。这证实C不像许多有经验的C程序员(特别是注重底层的)认为的,是一种“高级汇编”,并且C++和Objective-C已经直接继承它的很多问题。
未定义行为
日语翻译:blog-ja.intransient.info/2011/05/c-13.html
LLVM IR和C语言都有“未定义行为”的概念。未定义行为是一个广泛的话题,包含很多细节。最好的介绍,我发现的最好的介绍是John Regehr的博文(blog.regehr.org/archives/213 )。这篇优秀的文章的大意是许多看似合理的东西在C中实际上存在未定义行为,这是一种常见的程序的错误来源。此外,任何未定义行为准许实现(编译器和运行时)产生这样的代码:格式化您的硬盘驱动器、做完全意想不到的事情,或者更糟(www.catb.org/jargon/html/N/nasal-demons.html )。我再次强烈建议阅读John的文章。
未定义行为在基于C的语言中存在,因为C的设计者希望它成为一个高效的低层次的编程语言。相比之下,像Java(和许多其他的“安全”的语言)已回避未定义行为,因为他们想要在实现之间的行为安全、可重现,并愿意为此牺牲性能。无论哪一个都不是“(应该)致力于的正确的目标”,如果你是一个C程序员,你真的应该明白未定义行为是什么。
在详细讨论之前,这里有必要简要提及,编译器需要做什么以使广泛的C应用程序取得良好的性能,因为没有灵丹妙药。在一个很高的层次上,编译器通过如下手段产生一个高性能的应用程序:a)良好的必需算法的实现,像寄存器分配,调度算法,等等。b)了解很多“招数”(如窥孔优化、循环变换等),并在有利时运用它们。c)善于消除不必要的抽象(如C中的宏导致的冗余、内联函数、消除C++临时对象等。以及d)不把任何东西搞砸。下面提到的优化可能听起来微不足道的,但实际上在一个关键循环仅节省一个周期就可以使一些编解码器的运行速率提高10%或节约10%的电源。
C中未定义行为的优势与例子
在进入未定义行为和作为一个C编译器使用时的LLVM的策略和行为的阴暗面之前,我想考虑一些未定义行为的具体用例,并讨论它们各自如何使性能比像Java那样的安全语言更好,是有帮助的。读者可以把它看作通过未定义行为“启用优化”或导致需要使每个用例被定义的“避免开销”。虽然在某些时候编译器优化器可以消除其中的某些开销,一般地(在所有情况下)这样做将需要解决停机问题和许多其它的“有趣的挑战”。
值得指出的还有,Clang和GCC都确定了C标准保留未定义的一些行为。我将描述的东西是按照标准和被这两个编译器的默认模式下都作为未定义的行为。
使用未初始化的变量:这被公认为在C程序中产生问题的一个源头,有很多工具来捕获这些:从编译器的警告到静态和动态分析。不要求所有的变量在进入作用域时被零初始化(像Java那样)可以提高性能。对于多数标量变量,零初始化会引起很小的开销,但对栈数组和malloc得到的内存会引发对存储的memset操作,这可能相当昂贵,特别是这些存储通常被完全覆写。
有符号整数溢出:若(例如)“int”类型的算术操作溢出,结果是未定义的。一个例子是“INT_MAX+1”不保证是INT_MIN。这种行为可以对某些代码启用特定类别的重要优化。例如,了解INT_MAX+1未定义允许优化“X+1>X”为“true”。允许乘法“不能”溢出(因为这样做将是未定义的)允许优化“X*2/2”为“X”。这些看起来像是微不足道的,因为这类操作通常被内联和宏展开。这允许的一个更重要的优化是对于“<=”循环,如下:
for (i = 0; i <= N; ++i) { ... }
在这个循环中,编译器可以假定该循环将迭代N+1次,如果“i”的溢出是未定义的,从而允许介入范围广泛的循环优化。另一方面,如果该变量被定义为溢出时回绕,那么编译器必须假设这个循环可能是无限的(当N是INT_MAX发生)——然后禁用这些重要的循环优化。这尤其影响64位平台,因为如此多的代码使用“int”作为循环变量。
值得注意的是,无符号溢出是保证定义为2的补(回绕),所以可以总是使用它们。使有符号整数溢出有定义的成本会使这些优化根本丢失(例如,一种常见症状是在64位的目标上的一堆按符号扩展)Clang和GCC都接受“-fwrapv”标志,这迫使编译器对有符号整数溢出视为有定义的(而不是INT_MIN除以-1)。
过大的移位量:
对一个uint32_t移32位或更多是未定义的。我的猜测是它起源于底层移位操作在不同的CPU上的不同:例如,X86截断32位移位量至5位(于是移32位相当于移0位),但PowerPC截断32位移位量至6位(于是移32位产生0)。由于这些硬件上的差异,其行为在C中是完全未定义的(因此,移32位在PowerPC上可以格式化你的硬盘驱动器,它*不*保证生产零)。消除这种未定义行为的成本是使编译器对变量移位产生一个额外的操作(类似一个“与”运算)为变量的变化,这将在通常的CPU上使代价翻倍。
解引用野指针和数组越界访问:
随机指针解引用(像NULL,指向已被free的内存,等等)和数组访问越界的特殊情况,是在C应用程序的常见错误(希望不用解释)。为了消除这种未定义行为的来源,每个数组访问需要检查范围,并且ABI(译注:应用程序二进制接口)将不得不改变,以确保任何指针算术操作伴随范围信息。这对许多数值和其它应用程序成本极高,同时破坏了与每一个现有的C库的二进制兼容性。
解引用一个NULL指针:
和流行的看法相反,解引用一个空指针在C中是未定义的。它并没有被定义为引起陷入,并且如果你mmap一个0页面,访问这个页面是未定义的。这在禁止解引用野指针的规则之外,使NULL指针作为哨位不可行。NULL解引用成为未定义使广泛的优化成为可能:作为对比,Java使编译器在任何对象指针解引用之间移动具有副作用的操作无效,因为它无法被优化器证明是非空的。这显著地不利于调度和其它优化。在基于C的语言中,NULL成为未定义的使大量简单标量优化能被暴露为宏展开和内联。
如果你使用的是基于LLVM的编译器,你可以解引用“volatile”空指针来获得崩溃,如果这就是你需要的,因为优化器一般不关心volatile的存取。目前没有标识使随机NULL指针读取能被作为有效的访问或使随机读取知道它们的指针是“允许为空”的。
违反类型规则:转换int*为float*并解引用(像存在一个“float”一样访问一个“int”)是未定义的。C要求这些种类的类型转换通过memcpy发生:使用指针转换不正确并导致结果未定义。这个规则是相当微妙的,我在这里不想详细讨论细节(对于char*有一个例外,向量有特殊的属性,联合造成改变,等等)。这种行为使称为“基于类型的别名分析”(TBAA)成为可能,被广泛用于编译器中的内存访问优化分析,并能显着提高生成的代码的性能。例如,这个规则允许clang优化此函数:
float *P;
void zero_array() {
int i;
for (i = 0; i < 10000; ++i)
P[i] = 0.0f;
}
为“memset(P, 0, 40000)”。这种优化也允许许多读取被提取出循环,消除公共子表达式等。这一类未定义的行为,可以通过传递-fno-strict-aliasing标志禁用,同时禁止此分析。传递此标志时,Clang被要求编译这个循环为10000个4字节存储(慢了若干倍),因为它得假定对任何改变P的值的存储都是有可能的,像如下的代码:
int main() {
P = (float*)&P; // 转换导致TBAA在zero_array中违例。
zero_array();
}
此类类型滥用是相当少见的,这就是为什么标准委员会决定,为了显著的性能提升,“合理”的类型转换可以导致预期外的结果。值得指出的是Java获取基于类型的优化同时没有这些缺陷,因为它在语言中根本没有不安全的指针转换。
无论如何,我希望这使你了解在C中未定义行为能使某些类别的优化成为可能。当然有许多其它种类,类似序列点违例如“foo(i, ++i)”,多线程程序的竞争条件,“restrict”违例,除以零,等等。
在我们的下一篇文章里,我们将讨论为什么C的未定义行为是一件非常可怕的事,如果性能不是你的唯一目标。在系列的最后一篇文章中,我们将谈论LLVM和Clang如何处理它。
第二部分:
在第一部分,我们讨论了是什么未定义行为,以及它如何允许 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 编译器提供的各种对付未定义行为的选项。
在第一部分中,我们对 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 未定义行为之后的论题,至少从一个编译器实现人员的角度来看。
这里有篇文章
http://blogs.msdn.com/b/oldnewthing/archive/2014/06/27/10537746.aspx