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

来源:互联网 发布:网络小票机怎么连接 编辑:程序博客网 时间:2024/06/04 18:49

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

作者:Chris Lattner

原作:http://blog.llvm.org/2011/05/what-every-c-programmer-should-know_21.html

在本系列的第一部分,我们回顾在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中未定义行为背后的某些问题。

原创粉丝点击