这种编译器优化不一致是否完全由未定义的行为来解释



在前几天我和几个同事的讨论中,我用C++拼凑了一段代码来说明内存访问违规。

在很长一段时间几乎完全使用垃圾收集语言之后,我目前正在慢慢恢复C++,我想,我的失联表明,因为我对我的短程序表现出的行为感到非常困惑。

有问题的代码是这样的:

#include <iostream>
using std::cout;
using std::endl;
struct A
{
    int value;
};
void f()
{
    A* pa;    // Uninitialized pointer
    cout<< pa << endl;
    pa->value = 42;    // Writing via an uninitialized pointer
}
int main(int argc, char** argv)
{   
    f();
    cout<< "Returned to main()" << endl;
    return 0;
}

我在 Ubuntu 15.04 上使用 GCC 4.9.2 编译了它-O2并设置了编译器标志。我在运行它时的期望是,当我的评论表示为"通过未初始化的指针写入"的行被执行时,它会崩溃。

然而,与

我的期望相反,该程序成功地运行到最后,产生了以下输出:

0
Returned to main()

我用 -O0 标志重新编译了代码(以禁用所有优化(并再次运行该程序。这一次,行为如我所料:

0
Segmentation fault

(嗯,几乎:我没想到指针被初始化为 0。基于这个观察,我假设当使用-O2集合进行编译时,致命指令被优化了。这是有道理的,因为在违规行设置pa->value后,没有进一步的代码访问该,因此,据推测,编译器确定删除它不会修改程序的可观察行为。

我多次重现了这一点,每次程序在没有优化的情况下编译时都会崩溃,并且在使用 -O2 编译时奇迹般地工作。

当我在f()的正文末尾添加一行输出pa->value时,我的假设得到了进一步的证实:

cout<< pa->value << endl;

正如预期的那样,有了这一行,无论编译它的优化级别如何,程序都会持续崩溃。

如果我到目前为止的假设是正确的,这一切都是有道理的。但是,我的理解有些中断的地方是,如果我将代码从f()体直接移动到main(),如下所示:

int main(int argc, char** argv)
{   
    A* pa;
    cout<< pa << endl;
    pa->value = 42;
    cout<< pa->value << endl;
    return 0;
}

禁用优化后,该程序会崩溃,正如预期的那样。但是,使用 -O2 ,程序可以成功运行到最后并产生以下输出:

0
42

这对我来说毫无意义。

这个答案提到"取消引用尚未明确初始化的指针",这正是我正在做的事情,作为C++中未定义行为的来源之一。

那么,与f()中的代码相比,优化影响main()代码的方式上的这种差异,完全可以用我的程序包含UB来解释,因此编译器在技术上可以自由地"发疯",还是在main()中的代码优化方式之间存在一些我不知道的根本差异, 与其他例程中的代码相比?

您的程序具有未定义的行为。这意味着任何事情都可能发生。该程序根本不在C++标准中。你不应该带着任何期望进入。

人们常说,未定义的行为可能会"发射导弹"或"导致恶魔从你的鼻子里飞出来",以加强这一点。后者更牵强,但前者是可行的,想象一下你的代码在核发射场,而狂野的指针恰好写了一段内存,开始了全球温度战争。

编写未知指针一直是可能产生未知后果的事情。 更令人讨厌的是当前流行的一种哲学,它建议编译器应该假设程序永远不会收到导致UB的输入,因此应该优化任何代码,如果此类测试不会阻止UB发生,则应该测试此类输入。

因此,例如,给定:

uint32_t hey(uint16_t x, uint16_t y)
{
  if (x < 60000)
    launch_missiles();
  else
    return x*y;
}
void wow(uint16_t x)
{
  return hey(x,40000);
}

32 位编译器可以合法地将wow替换为无条件调用 launch_missiles不考虑x的值,因为x"不可能"大于 53687(任何超出该值的值都会导致 x*y 的计算溢出。 尽管C89的作者指出,那个时代的大多数编译器都会在上述情况下计算出正确的结果,因为该标准没有对编译器施加任何要求,超现代哲学认为编译器假设程序永远不会收到需要依赖这些东西的输入是"更有效的"。

相关内容

最新更新