每个C程序员应该知道的未定义行为

本文探讨了C语言中的未定义行为如何在提高代码性能的同时,带来潜在的安全风险。通过解释特定案例,展示了未定义行为在优化过程中的作用及其带来的后果。文中还讨论了编译器在提供警告和处理未定义行为时所面临的挑战,以及Clang和LLVM在这一领域的应对策略。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

转载地址:http://blog.csdn.net/wuhui_gdnt/article/details/8736838

声明:请看到这篇文章的人务必去阅读原文,我转载的目的仅为了学习

作者:Chris Lattner

原作:http://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都从它直接继承了大量的问题。

未定义行为的介绍

LLVM IR(译注:IR即中间表示,immediate representation)及C编程语言具有“未定义行为”的概念。未定义行为是一个带有许多细微差别的广泛的话题。我见过最好的介绍是在John Regehr博客上的一篇帖子(译注:即“C、C++中未定义行为的指引”)。这篇优秀文章的简要内容是:在C中许多看起来合理的东西实际上有未定义行为,这是程序中bug的一个常见来源。除此之外,C中任何未定义行为给予实现(编译器与运行时)产生格式化你硬盘的代码的许可,执行完全意外的操作,或更糟。再次,我强烈推荐读一下John的论文

基于C的语言存在未定义行为,因为C的设计者希望它成为一个极其高效的低级编程语言。相反,像Java(及许多其它“安全”语言)的语言已经避开了未定义行为,因为他们希望实现之间安全及可重现的行为,并愿意牺牲性能来得到它。尽管两者都不是“要追求的正确目标”,如果你是一个C程序员,你确实应该理解未定义行为是什么。

在进入细节之前,值得提一下编译器从宽泛的C应用程序中获得好的性能的必要条件,因为没有魔弹。在非常高的层次,通过:a)做好基本的算法,像寄存器分配、调度等;b)知道许多、许多的“技巧”(比如窥孔优化,循环变换等),只要有利可图就应用它们;c)善于消除不必要的抽象(比如在C中由于宏导致的重复,函数内联,在C++中消除临时对象等);d)不要把事情弄砸;编译器产生高性能的应用程序。尽管下面的任一优化可能听上去无关紧要,事实证明仅节省一个关键循环的一个周期,可以使某些解码器快10%,或节省10%的功耗。

C中未定义行为的好处及例子

在进入未定义行为的黑暗面,及在用作一个C编译器时,LLVM的策略与行为前,我想考虑未定义行为的几个特定案例,并讨论每个如何实现比一个安全的语言更好的性能,比如Java,是有帮助的。你可以把这看做:由未定义行为类别“启用的优化”,或“避免”使每个情形有定义所要求的“开销”。尽管编译器优化器有时可以消除这些开销中的某些,要普遍这样做(对每个情形)将要求解决停机问题(thehalting problem)及许多其它“有趣的挑战”。

还值得指出Clang与GCC明确了C标准留作未定义的几个行为。我将描述标准未定义的行为,及在缺省模式下,这两个编译器处理作未定义行为的行为。

使用非初始化变量:这通常被认为是C程序中问题的来源,有许多工具捕捉它们:从编译器警告到静态及动态分析器。这通过在进入作用域时,不要求0初始化所有的变量(像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取消定义(因此在PowerPC上偏移32位可以格式化你的硬盘,它不保证产生0)。消除这个未定义行为的代价是:对变量偏移,编译器将必须产生一个额外的操作(像一个'and'),在普通的CPU上,这将使得它们代价加倍。

解引用野指针及数组越界访问:解引用随机指针(像NULL,指向已释放内存的指针等)及访问一个越界数组的特殊情形是C应用程序中一个常见的bug,但愿这无需解释。要消除这类未定义行为,每个数组访问必须检查边界,而且必须改变ABI以确保边界信息跟随任何受指针算术支配的指针。对许多数值及其它应用,这将是一个极高的代价,而且打破与现存C库的二进制兼容性。

解引用空指针:与普通的看法相反,在C中解引用一个空指针是未定义的。它不是定义为陷入,如果你mmap一个页面到0,它不是定义为访问该页面。这放弃了禁止解引用野指针并使用NULL作为一个哨兵的规则。NULL指针解引用被取消定义启动了广泛的优化:相反,Java使得编译器跨越任何对象指针解引用移动一个副作用操作无效,如果优化器不能证明该指针不是空。这严重地损害了调度及其它优化。在基于C的语言中,未定义NULL使大量简单的标量优化成为可能,这些优化由宏展开及内联发现。

如果正在使用基于LLVM的编译器,你可以解引用一个"volatile"空指针以得到一次崩溃,如果这是你所期望的,因为volatile保存与载入通常不会被优化器触及。
目前没有标记使任意NULL指针载入被处理作有效访问,或使随机载入知道它们的指针是“允许为空的”。

违反类型规则:把一个int*强制转换到float*并解引用它是未定义行为(访问"int",仿佛它是一个"float")。C要求这些类型的转换通过memcpy发生:使用指针强转是不正确的,会导致未定义的行为。这个规则是相当微妙的,在这里我不想深入细节(char*、具有特殊属性的向量、union改变等,是一个例外)。这个行为使一个称为“基于类型的别名分析(Type-BasedAlias Analysis,TABB)”的分析成为可能,在编译器中它被各种内存访问优化使用,并能显著提升生成代码的性能。
例如,该规则允许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;  // cast causes TBAA violation in zero_array.
    zero_array();
}

这种类型的类型滥用是相当罕见的,这是为什么标准委员会决定,对“合理的”类型转换,值得用意外的结果换取显著的性能提升。值得指出Java获得基于类型优化的好处,而没有这些坏处,因为在该语言中完全没有不安全的指针转换。

总之,我希望这给你一个概念,某些类型的优化由C中的未定义行为启动。当然还有许多其它类型,包括像"foo(i, ++i)"的顺序点违反,在多线程程序中的竞争条件,违反'restrict',除0等。

在我们下一贴中,我们将讨论为什么C中的未定义行为是一件相当可怕的事,如果性能不是你的唯一目标。在我们该系列的最后一贴中,我们将讨论LLVM与Clang如何处理它。

===================================================================================

在我们系列的第一部分,我们讨论了未定义行为是什么,及它如何允许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) {
    <del>//int dead = *P;</del>     // 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) {
    <del>//int dead = *P;
    //if(false)
    //    return;</del>
    *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

===================================================================================

在本系列的第一部分,我们回顾在C中的未定义行为,并展示了某些案例,它们允许C比“安全”语言性能更高。在第二部分,我们浏览了由这引起的令人惊讶的bug,及许多程序员关于C某些普遍的误解。在本文中,我们看一下编译器在提供关于这些陷阱的警告时所面临的挑战,并讨论LLVM及Clang提供来辅助性能提升,同时消除一些惊诧的某些特性及工具。

在基于未定义行为优化时,为什么你不能警告

人们通常问,当编译器利用未定义行为进行优化时,为什么它不能产生警告,因为任何这样的情形可能实际上是用户代码中的bug。这个做法的挑战是:1)它很可能产生太多的警告,而变得不可用——因为在没有bug时,这些优化始终生效,2)仅当人们需要它们的时候产生这些警告,真的很难处理,3)我们没有好的方式来(向用户)表达一系列优化如何组合来暴露优化机会。让我们依次考虑它们:

使得这真正有用,“确实困难”

让我们看一个例子:尽管无效类型转换bug经常被基于类型的别名分析暴露出来,在优化"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),其次,编译器是高度分层的,其中尝试“把P的一个载入提取出该循环”的优化器不知道TBAA是解析指针别名查询的分析。是的,这是这篇文章“写编译器的兄弟的抱怨”部分,但它确实是一个困难的问题。

仅当人们希望它们时产生这些警告是困难的

Clang为未定义行为简单及明显的情形实现了许多警告,比如越界偏移,像"x <<421"。你可能认为这是简单且明显的事,但事实证明这是困难的,因为人们不希望得到在死代码中未定义行为的警告(参考theduplicates)。

死代码可以有几种形式:在传递一个常量时,以一个有趣方式展开的宏,我们甚至抱怨我们在要求switch语句的控制流分析证明那些case不可到达的情形下警告。C中switch语句不一定被正确地组织这个事实也无济于事。

在Clang中对此的解决方案是一个持续增长的、处理“运行时行为”警告的基础设施,连同裁剪出这些警告的代码,因此如果我们在后面发现这个块不可到达,它们不会被报告。可是这是一场与程序员的军备竞赛,因为总有我们不期望的惯用语法,在前端这样做意味着它不能捕捉人们希望它捕捉的所有情形。

解释暴露一个机会的一系列优化

如果前端有产生好的警告的挑战,取而代之,可能我们可以从优化器产生它们!这里产生一个有用警告最大的问题是数据跟踪。一个编译器优化器包括了数以十计的优化遍,每个遍会改变代码,因为它涉及到规范化代码或(期望)使得代码运行得更快。如果内联器决定内联一个函数,比如这可能暴露另一个机会优化掉一个"X*2/2"

尽管我已经给出了相对简单且独立的例子来展示这些优化,它们介入的大多数情形源自宏具现、内联及其它编译器执行的抽象消除活动的代码中。事实是人们通常不会直接写得这么傻。至于警告,这意味着为了把问题转述会用户代码,警告将必须确切重构编译器是如何得到它从事的中间代码。我们需要能够这样说:

warning:after 3 levels of inlining (potentially across files with Link TimeOptimization), some common subexpression elimination, after hoisting this thingout of a loop and proving that these 13 pointers don't alias, we found a casewhere you're doing something undefined. This could either be because there is abug in your code, or because you have macros and inlining and the invalid codeis dynamically unreachable but we can't prove that it is dead.

不幸,我们完全没有产生这的内部追踪设施,即使我们有,编译器没有一个足够的好的用户接口来向程序员表达它。

最终,未定义行为对优化器是有价值的,因为它宣称“这个操作是无效的——你可以假定它永不发生”。在像"*P"的情形中,这给优化器推理P不会是NULL的能力。在像"*NULL"的情形中(比如,在某些常量传播及内联后),这使得优化器了解这个代码一定不可到达。这里重要的技巧是,因为不能解决停机问题,编译器不知道代码是否真的死了(正如C标准宣称它必须),或者它是否是一个bug,暴露于一系列(可能漫长的)优化后。因为通常没有好的方式区分两者,几乎所有产生的警告将是假阳性的(噪声)。

Clang处理未定义行为的做法

考虑到在设计未定义行为时我们所处的尴尬境况,你可能想知道Clang与LLVM正在做什么来尝试改善这个境况。我已经提到了几个:Clang静态分析Klee项目,及-fcatch-undefined-behavior标记是侦测这些bug的某些类别的有用的工具。问题是,它们不像编译器那样得到广泛使用,因此任何我们可以直接在编译器中完成的工作,能提供比在这些工具中更多的友好性。但也要提醒,编译器受没有动态信息的限制,并局限于不能消耗太多编译时间。

Clang的改进各色代码的第一步是打开比其它编译器默认打开多得多的警告。虽然有些开发者是规矩的,使用(比如)"-Wall -Wextra"编译,许多人不知道这些标记或懒得传入它们。默认打开更多警告在更多时间里捉到更多的bug。

第二步是,Clang对许多类别的、在代码中显而易见的未定义行为产生警告(包括解引用空指针,越界偏移等)以捕捉某些常见的错误。上面提到某些注意事项,但在实践中这些看起来工作得很好。

第三步是,LLVM优化器通常遵循比允许少得多的未定义行为的自由度。虽然标准宣称任何未定义行为的实例对程序有完全不受限制的作用,但这不是可兹利用的、特别有用或开发者友好的行为。相反。LLVM优化器在几个不同的方式来处理这些优化(链接描述了LLVM IR的规则,不是C,抱歉!):

1.     某些未定义行为的情形被悄悄地转换为隐含陷入操作,如果有好的方式这样做。例如,使用Clang,这个C++函数:

int *foo(long x) {
    return new int[x];
}

编译为这个X86-64机器码:

__Z3fool:
        movl $4, %ecx
        movq %rdi, %rax
        mulq %rcx
        movq $-1, %rdi        # Set the size to -1 on overflow
        cmovnoq %rax, %rdi    # Which causes 'new' to throw std::bad_alloc
        jmp __Znam

而不是GCC产生的代码:

__Z3fool:
        salq $2, %rdi
        jmp __Znam             # Security bug on overflow!

这里的区别是,我们已经决定耗费几个周期来防止会导致缓存溢出与漏洞利用的、严重的整数溢出 bug(操作符new通常相当昂贵,因此这个开销难以觉察)。GCC的弟兄至少自2005年就知道这,但在写本文时尚未修正。

2.     使用未定义值的算术被认为产生一个未定义的值,而不是产生未定义的行为。区别是,未定义的值不会格式化你的硬盘或产生其它意外的影响。在该算术对该未定义值任何可能的实例产生相同输出比特位的情形下,出现一个有用的细化。例如,优化器假定"undef& 1"的结果在高位是0,仅把低位处理作未定义。这意味着((undef & 1) >> 1)在LLVM中被定义为0,不是未定义。

3.     动态执行一个未定义操作(比如一个有符号整数溢出)的算术产生一个逻辑trap值,它毒死任何基于它的计算,但这不会摧毁你整个的程序。这意味着该未定义操作的逻辑下游可能受到影响,但你整个的程序不会毁坏。这是为什么优化器最终删除,比如使用未初始化值的代码的原因。

向空指针保存及通过空指针调用被转化为一个__builtin_trap()调用(它转化为一个陷入指令,像x86上的"ud2")。这些都发生在优化后的代码中(作为其它转换的结果,像内联及常量传播),并且我们习惯只删除包含它们的块,因为它们是“明显不可到达的”。
尽管(从一个迂腐的语言律师的角度)这是千真万确的,我们很快认识到人们偶尔会解引用空指针,并且允许代码执行进入下一个函数使得理解该问题非常困难。从性能角度,展示这些最重要的方面是挤压下游代码。为此,clang把这些转换为一个运行时陷入:如果实际上动态到达这些之一,程序立即停止并可被调试。这样做的缺点是,通过拥有这些操作及控制其判断的条件,我们稍微膨胀了代码。

4.     当程序员的意思是明白无疑时(比如当P是float的一个指针时,执行"*(int*)P"的代码),优化器确实花费了一些功夫来“做正确的事”。这有助于许多常见的情形,但实际上你不需要依赖这,有许多没有向你的代码应用一长串的转换,而你可能认为是“明白无疑”的例子。

5.     没有落入这些类别的优化,比如在第一部分中的zero_array及set/call例子如描述那样优化,悄悄地,没有任何指示给用户。我们这样做因为我们没有任何有用的东西可说,(多臭虫的)真实世界的代码被这些优化破坏是非常罕见的。

我们可以做的一个主要的改进领域是关于trap插入。我认为添加一个(缺省关闭)一旦产生一条trap指令,使优化器给出警告的警告标记是有趣的。对于某些代码库,这将极其嘈杂,但对于其它会是有用的。这里第一个限定因素是使得优化器产生警告的基础设施行为:除非调试信息打开,它没有有用的源代码位置信息(不过这可以修正)。

另一个更重要的限定因素是,警告将没有任何“追踪”信息,能够解释一个操作是展开一个循环三次,通过四层函数调用内联它的结果。最好我们能够指出原始操作的文件/行/列,在大多数无关紧要的情形中这将是有用的,但在其它情形中很可能极为混淆。无论如何,这并非是我们优先考虑的实现,因为a)它不太可能给出好的体验,b)我们将不会默认打开它,c)实现工作量大。

使用更安全的C方言(及其它选项)

如果你不在乎“最终性能”,一个最后选项是使用各种编译器标记来启用消除这些未定义行为的C方言。例如,使用-fwrapv标记消除由有符号整数溢出导致的未定义行为(不过,注意到它没有消除可能的整数溢出安全漏洞)。标记-fno-strict-aliasing禁止基于类型的别名分析,因此你可以忽略这些类型规则。如果有需求,我们可以向Clang添加一个隐含0初始化所有局部变量的标记,一个在带有一个可变偏移量的每个偏移前插入一个"and"操作的标记等。不幸,没有易驾驭的方法从C完全消除未定义行为,而不打破ABI并而且破坏其性能。其它问题是,你不再编写C,你在编写一个类似,但不可移植的C方言。

如果你不想以不可移植的C方言编写代码,那么标记-ftrapv-fcatch-undefined-behavior (连同前面提到的其它工具)是你武库中追踪这些类型bug的有用的武器。在你调试构建中启用它们是及早找出相关bug的一个极好的方式。如果你正在构建安全关键应用,这些标记还可用于产品代码中。尽管它们不保证它们会找出所有的bug,它们确实找出bug的一个有用的子集。

最后,这里真正的问题是,C就不是一个“安全的”语言,而且(尽管其成功及流行)许多人没有真正理解这个语言如何工作。在1989年标准化之前数十年的演化中,C从作为一个“是PDP汇编上一个薄层的低级系统编程语言”迁移为一个“低级系统编程语言,尝试通过出人意料的方式提供像样的性能”。一方面,这些C“欺诈”几乎总能工作,而且代码因此通常性能更好(在某些情形下,是高得多的性能)。另一方面,C欺诈的地方往往是最出乎人们意外的,而且通常在最坏的时候罢工。

有时以非常意外的方式,C表现远不止是一个可移植的汇编器。我希望至少从一个编译器实现者的角度,这个讨论有助于解释C中未定义行为背后的某些问题。


评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值