今天我和朋友讨论了几个小时的“编译器优化”,我认为有时候编译器优化可能会引入错误或至少是不期而至的行为。
我的朋友完全不同意,说“编译器由聪明的人建造并做聪明的事情”,因此它永远不会出错。
他一点也没说服我,但我必须承认我缺乏现实生活中的例子来加强我的观点。
谁是对的呢?如果我是对的,你是否有任何真实的例子,可以证明编译器优化会在生成的软件中产生错误?如果我错了,那么我应该停止编程并学习钓鱼吗?
今天我和朋友讨论了几个小时的“编译器优化”,我认为有时候编译器优化可能会引入错误或至少是不期而至的行为。
我的朋友完全不同意,说“编译器由聪明的人建造并做聪明的事情”,因此它永远不会出错。
他一点也没说服我,但我必须承认我缺乏现实生活中的例子来加强我的观点。
谁是对的呢?如果我是对的,你是否有任何真实的例子,可以证明编译器优化会在生成的软件中产生错误?如果我错了,那么我应该停止编程并学习钓鱼吗?
编译器优化可能会引入错误或不良行为。这就是为什么你可以关闭它们的原因。
一个例子:编译器可以优化对内存位置的读/写访问,比如消除重复的读或写,或重新排列某些操作。如果所涉及的内存位置只由单个线程使用并且实际上是内存,那么这可能没问题。但是,如果内存位置是硬件设备IO寄存器,则重新排序或消除写入可能是完全错误的。在这种情况下,通常必须编写代码,知道编译器可能会"优化"它,因此知道天真的方法不起作用。
更新:正如Adam Robinson在评论中指出的那样,我上面描述的场景更像是编程错误而不是优化器错误。但我试图说明的重点是,一些程序在与一些优化结合使用时可能会引入程序中的错误,而这些程序在其他方面是正确的,而这些优化在其他方面是有效的。在某些情况下,语言规范说"你必须以这种方式做,因为这些类型的优化可能发生,你的程序将失败",在这种情况下,这是代码中的错误。但有时编译器具有(通常是可选的)优化功能,可以生成不正确的代码,因为编译器试图过度优化代码或无法检测到该优化不合适。在这种情况下,程序员必须知道何时可以安全地打开相关的优化。
另一个例子: Linux内核存在一个漏洞,在测试指针是否为空之前,可能会对潜在的NULL指针进行解引用。然而,在某些情况下,将内存映射到地址零是可能的,因此允许解引用成功。编译器在注意到指针被解引用后,假定它不能为NULL,然后稍后删除了空测试和该分支中的所有代码。这导致代码中引入了安全漏洞,因为函数将继续使用包含攻击者提供的数据的无效指针。对于指针合法为空且内存未映射到地址零的情况,内核仍然会像以前一样发生OOPS。因此,在优化之前,代码包含一个错误;在优化之后,代码包含两个错误,其中一个允许本地root利用。 CERT有一份名为“危险的优化和因果关系丧失”的演示文稿,由Robert C. Seacord撰写,列出了许多在程序中引入(或暴露)错误的优化。它讨论了可能的各种优化类型,从“执行硬件所做的”到“捕获所有可能的未定义行为”到“执行任何未被禁止的操作”。Checking for overflow
// fails because the overflow test gets removed
if (ptr + len < ptr || ptr + len > max) return EINVAL;
Using overflow artithmetic at all:
// The compiler optimizes this to an infinite loop
for (i = 1; i > 0; i += i) ++j;
Clearing memory of sensitive information:
// the compiler can remove these "useless writes"
memset(password_buffer, 0, sizeof(password_buffer));
这就是为什么大多数编译器提供了关闭(或打开)优化的标志。您的程序是否编写时考虑到整数可能会溢出?那么您应该关闭溢出优化,因为它们可能会引入错误。您的程序是否严格避免别名指针?那么您可以打开假设指针从未别名的优化。您的程序是否尝试清除内存以避免泄漏信息?哦,在这种情况下,您就没那么幸运了:您要么需要关闭死代码删除,要么需要预先知道您的编译器将消除您的“死”代码,并使用一些解决方法。
struct sock *sk = tun->sk;
这一行对tun
进行解引用操作却没有进行NULL检查引起的。其次,C++标准规定NULL是指向无对象的指针,不能进行解引用操作。基于此,编译器优化代码是完全合法且符合标准的。 - Hadi Brais当通过禁用优化来解决bug时,大多数情况下仍是您的问题。
我负责一个商业应用程序,主要使用C++编写 - 最初是VC5,早期移植到VC6,现在成功移植到VC2008。在过去的10年中,代码已经增长到了100万行以上。
在这段时间里,我确认了一个代码生成bug,只有在启用了激进的优化时才会出现。
那么我为什么会抱怨呢?因为在同样的时间内,有许多bug使我怀疑编译器 - 但最终证明是我对C++标准理解不足。标准为编译器可以或不可以利用的优化提供了空间。
多年来在不同的论坛上,我看到了很多指责编译器的帖子,最终发现是原始代码中的错误。毫无疑问,其中许多都是需要深入理解标准中使用的概念的隐蔽bug,但它们仍然是源代码中的错误。
我为什么这么晚才回复:在确认实际上是编译器的错误之前,请停止指责编译器。
编译器(和运行时)优化确实可能会引入不需要的行为,但至少只有在依赖未指定的行为(或者确实是对明确定义的行为做出了错误假设)时才会发生。
当然,除此之外,编译器也可能存在错误。其中一些可能与优化有关,并且其影响可能非常微妙 - 实际上它们很可能是这样的,因为明显的错误更容易被修复。
假设你将JIT视为编译器,我曾经在发布版本的.NET JIT和Hotspot JVM中都看到过错误(不幸的是,我目前没有详细信息),这些错误在特别奇怪的情况下是可以重现的。它们是否是由于特定的优化而导致的,我不知道。
将其他文章合并:
编译器偶尔会有其代码中的错误,像大多数软件一样。 "聪明人"的论点与此完全无关,因为由聪明人构建的NASA卫星和其他应用程序也存在漏洞。执行优化的编码与不执行优化的编码是不同的,因此如果错误恰好出现在优化器中,则您的优化代码可能包含错误,而非优化代码则不会。
正如Shiny and New先生所指出的那样,仅就并发和/或时间问题而言,相对幼稚的代码可以在没有优化的情况下运行得令人满意,但在进行优化后却失败了,因为这可能会改变执行的时序。你可以将这样的问题归咎于源代码,但如果只在优化时才会表现出来,有些人可能会责备优化。
我从未听说过或使用过一个编译器,其指令不能改变程序的行为。通常这是一件好事,但它需要你阅读手册。
而且,我最近遇到了一个情况,其中一个编译器指令“消除”了一个错误。当然,这个错误仍然存在,但我有一个临时解决方法,直到我正确修复程序。
可以。一个很好的例子是双重检查锁定模式(double-checked locking pattern)。在C++中,没有安全的方式实现双重检查锁定模式,因为编译器可以重新排列指令,这在单线程系统中是有意义的,但在多线程系统中不是。完整的讨论可以在http://www.aristeia.com/Papers/DDJ_Jul_Aug_2004_revised.pdf找到。
std::atomic
。在C++11之前,无锁原子操作和自行实现的锁定总是高度依赖于对编译器工作方式的许多假设(并且通常需要内联汇编)以及对volatile
的滥用。 - Peter CordesFunction foo( const type & tp );
被称为:
foo( foo2() );
foo2()
返回一个 type
类的对象;
在 GCC 中容易崩溃,因为在这种情况下对象没有分配在堆栈上,但是 VS 做了一些优化来解决这个问题,所以它可能会工作。
这可能吗?在主要产品中不太可能,但当然是有可能的。编译器优化生成的代码;无论代码来自何处(你编写它或某些东西生成它),它都可能包含错误。
别名现象可能会对某些优化造成问题,这就是为什么编译器有一个选项来禁用这些优化。来自Wikipedia:
为了以可预测的方式启用这种优化,C编程语言的ISO标准(包括其更新的C99版本)规定,除了一些例外情况外,不同类型的指针引用同一内存位置是非法的。这个规则被称为“严格别名”,可以显著提高性能[citation needed],但已知会破坏一些本来有效的代码。一些软件项目有意违反了C99标准的这一部分。例如,Python 2.x这样做是为了实现引用计数,[1]并需要更改Python 3中的基本对象结构来启用这种优化。Linux内核这样做是因为严格别名会导致内联代码优化出现问题。[2]在这种情况下,使用gcc编译时会调用选项-fno-strict-aliasing,以防止产生不希望或无效的优化,从而产生不正确的代码。