在循环中的什么时候整数溢出变成未定义的行为?

时间:2016-10-07 10:12:30

标签: c++ c undefined-behavior integer-overflow

这是一个例子来说明我的问题,其中涉及一些我不能在这里发布的更复杂的代码。

#include <stdio.h>
int main()
{
    int a = 0;
    for (int i = 0; i < 3; i++)
    {
        printf("Hello\n");
        a = a + 1000000000;
    }
}

此程序在我的平台上包含未定义的行为,因为a将在第3次循环中溢出。

是否会使整个程序出现未定义的行为,或仅在溢出实际发生之后?编译器是否可能会发现a 溢出,因此可以声明整个循环未定义,并且即使它们都在溢出之前发生,也无需运行printfs?

(标记为C和C ++,即使它们不同,因为如果它们不同,我会对这两种语言的答案感兴趣。)

12 个答案:

答案 0 :(得分:106)

如果您对纯粹的理论答案感兴趣,C ++标准允许未定义的行为“时间旅行”:

  

[intro.execution]/5:   执行格式良好的程序的符合实现应产生相同的可观察行为   作为具有相同程序的抽象机的相应实例的可能执行之一   和相同的输入。但是,如果任何此类执行包含未定义的操作,则为International   标准不要求使用该输入执行该程序的实现(甚至不考虑第一个未定义操作之前的操作)

因此,如果您的程序包含未定义的行为,那么整个程序的行为是未定义的。

答案 1 :(得分:31)

首先,让我更正这个问题的标题:

未定义的行为不是(特定)执行领域。

未定义的行为会影响所有步骤:编译,链接,加载和执行。

要解决这个问题的一些例子,请记住,没有任何部分是详尽无遗的:

  • 编译器可以假设从不执行包含未定义行为的代码部分,因此假设导致它们的执行路径是死代码。除了Chris Lattner之外,请参阅What every C programmer should know about undefined behavior
  • 链接器可以假设在存在多个弱符号定义(由名称识别)的情况下,由于One Definition Rule
  • ,所有定义都是相同的
  • 加载器(如果你使用动态库)可以采用相同的方式,从而选择它找到的第一个符号;这通常是(ab)用于在Unix上使用LD_PRELOAD技巧拦截调用
  • 如果您使用悬空指针
  • ,执行可能会失败(SIGSEV)

这是关于未定义行为的如此可怕:它几乎不可能提前预测将发生什么样的确切行为,并且必须在每次更新工具链时重新审视此预测,底层操作系统,......

我建议由Michael Spencer(LLVM开发人员)观看此视频:CppCon 2016: My Little Optimizer: Undefined Behavior is Magic

答案 2 :(得分:28)

针对16位int积极优化C或C ++编译器将知道1000000000添加到int类型的行为是未定义

任何一个标准都允许做任何想要的事情 包括删除整个程序,留下int main(){}

但是更大的int呢?我不知道编译器是做了这个(我不是任何方式的C和C ++编译器设计专家),但我想某个时候一个针对32位的编译器{ {1}}或更高版本会发现循环是无限的(int不会更改)因此i最终会溢出。因此,它可以再次将输出优化为a。我在这里要说的是,随着编译器优化逐渐变得更具攻击性,越来越多的未定义行为结构以意想不到的方式表现出来。

由于您在循环体中写入标准输出,因此循环无限的事实本身并未定义。

答案 3 :(得分:11)

从技术上讲,在C ++标准下,如果程序包含未定义的行为,则整个程序even at compile time(在程序执行之前)的行为是未定义的。

实际上,因为编译器可能会假设(作为优化的一部分)不会发生溢出,所以至少循环的第三次迭代(假设32位机器)上的程序行为将是未定义的,虽然很可能在第三次迭代之前得到正确的结果。但是,由于整个程序的行为在技术上是未定义的,因此没有什么能阻止程序生成完全错误的输出(包括无输出),在执行期间的任何时刻在运行时崩溃,甚至无法完全编译(如未定义的行为延伸到编译时间。)

未定义的行为为编译器提供了更多优化空间,因为它们消除了对代码必须执行的操作的某些假设。在这样做时,依赖于涉及未定义行为的假设的程序不能保证按预期工作。因此,您不应该依赖于根据C ++标准被视为未定义的任何特定行为。

答案 4 :(得分:9)

为了理解为什么未定义的行为可以'time travel' as @TartanLlama adequately put it,让我们看一下“as-if&#39;规则:

  

1.9程序执行

     

1 本国际标准中的语义描述定义了一个   参数化非确定性抽象机。这个国际   标准对符合结构没有要求   实现。特别是,他们不需要复制或模仿   抽象机器的结构。相反,符合实施   需要模仿(仅)抽象的可观察行为   机器如下所述。

有了这个,我们可以将该程序视为一个黑盒子&#39;带输入和输出。输入可以是用户输入,文件和许多其他内容。输出是&#39;可观察到的行为&#39;在标准中提到。

标准仅定义输入和输出之间的映射,没有别的。它通过描述一个示例黑盒&#39;来实现这一点,但明确地说任何其他具有相同映射的黑盒子同样有效。这意味着黑匣子的内容无关紧要。

考虑到这一点,说未定义的行为在某个时刻发生是没有意义的。在黑盒子的示例实现中,我们可以说它发生的地点和时间,但实际黑盒子可能是完全不同的东西,所以我们不能说它何时何地发生。从理论上讲,编译器可以例如决定枚举所有可能的输入,并预先计算结果输出。然后在编译期间会发生未定义的行为。

未定义的行为是输入和输出之间不存在映射。程序可能对某些输入具有未定义的行为,但为其他输入定义了行为。那么输入和输出之间的映射就不​​完整了;存在没有输出映射的输入 问题中的程序对于任何输入都有未定义的行为,因此映射为空。

答案 5 :(得分:6)

假设int是32位,则在第三次迭代时会发生未定义的行为。因此,例如,如果循环只是有条件地可达,或者可以在第三次迭代之前有条件地终止,那么除非实际达到第三次迭代,否则将没有未定义的行为。但是,如果出现未定义的行为,程序的所有输出都是未定义的,包括相对于未定义行为调用的“过去”的输出。例如,在您的情况下,这意味着无法保证在输出中看到3条“Hello”消息。

答案 6 :(得分:6)

TartanLlama的回答是正确的。未定义的行为可以随时发生,即使在编译期间也是如此。这可能看起来很荒谬,但它是允许编译器做他们需要做的事情的关键特性。成为编译器并不总是那么容易。你必须每次都遵循规范所说的。然而,有时候证明特定行为正在发生可能是非常困难的。如果你还记得暂停问题,那么开发软件是相当简单的,你无法证明它在输入特定输入时是否完成或进入无限循环。

我们可以让编译器变得悲观,并且不断编译,担心下一条指令可能是问题之类的暂停问题之一,但这是不合理的。相反,我们给编译器一个通道:在这些“未定义的行为”主题上,它们不承担任何责任。未定义的行为包括所有行为,这些行为是如此巧妙,以至于我们无法将它们与真正令人讨厌的恶意停止问题等等分开。

有一个我喜欢发帖的例子,虽然我承认我失去了源头,所以我不得不解释。它来自特定版本的MySQL。在MySQL中,他们有一个循环缓冲区,里面装满了用户提供的数据。当然,他们想确保数据没有溢出缓冲区,所以他们检查了一下:

if (currentPtr + numberOfNewChars > endOfBufferPtr) { doOverflowLogic(); }

看起来很清醒。但是,如果numberOfNewChars真的很大,并且溢出怎么办?然后它环绕并变成一个小于endOfBufferPtr的指针,因此溢出逻辑永远不会被调用。所以他们在那之前添加了第二张支票:

if (currentPtr + numberOfNewChars < currentPtr) { detectWrapAround(); }

看起来你照顾了缓冲区溢出错误,对吧?但是,提交了一个错误,指出此缓冲区溢出特定版本的Debian!仔细调查显示,这个版本的Debian是第一个使用特别尖端版本的gcc。在这个版本的gcc上,编译器认识到currentPtr + numberOfNewChars可以永远是比currentPtr更小的指针,因为指针的溢出是未定义的行为!这足以让gcc优化整个检查,突然你没有防止缓冲区溢出,即使你编写代码来检查它!

这是规范行为。一切都是合法的(虽然从我听到的,gcc在下一个版本中回滚了这个变化)。这不是我认为的直觉行为,但如果你稍微扩展想象力,很容易看出这种情况的轻微变体如何成为编译器的暂停问题。因此,规范编写者将其定义为“未定义行为”并声明编译器可以做任何令人满意的事情。

答案 7 :(得分:4)

根据定义,未定义的行为是灰色区域。你根本无法预测它会做什么或不会做什么 - 这就是“未定义的行为”意味着什么

自远古以来,程序员一直试图从未定义的情况中挽救定义的残余。他们有一些他们真正想要使用的代码,但结果证明是未定义的,所以他们试图争辩说:“我知道它是未定义的,但在最坏的情况下,肯定会这样或那样;它永远不会这样做< EM>那“。有时候这些论点或多或少是正确的 - 但往往是错误的。随着编译器越来越聪明(或者有些人可能会说,偷偷摸摸和偷偷摸摸),问题的界限也在不断变化。

所以,真的,如果你想编写保证可以工作的代码,而且这种代码将继续工作很长时间,那么只有一个选择:不惜一切代价避免未定义的行为。实际上,如果你涉足它,它会回来困扰你。

答案 8 :(得分:4)

除了理论答案之外,一个实际的观察是,很长一段时间,编译器已经对循环应用了各种变换,以减少在其中完成的工作量。例如,给定:

for (int i=0; i<n; i++)
  foo[i] = i*scale;

编译器可能会将其转换为:

int temp = 0;
for (int i=0; i<n; i++)
{
  foo[i] = temp;
  temp+=scale;
}

因此,每次循环迭代都会保存乘法。一种额外的优化形式,编译器适应不同程度的攻击性, 会把它变成:

if (n > 0)
{
  int temp1 = n*scale;
  int *temp2 = foo;
  do
  {
    temp1 -= scale;
    *temp2++ = temp1;
  } while(temp1);
}

即使在溢出时发生无声环绕的机器上,如果发生故障也可能出现故障 有一些数字小于n,当乘以比例时,会产生 如果从内存中读取比例更多,它也可能变成无限循环 而不是一次,一些东西意外地改变了它的价值(无论如何在哪里 “scale”可以在不调用UB的情况下改变中间循环,编译器不会 允许执行优化。)

虽然大多数此类优化在两个情况下都不会有任何问题 短无符号类型相乘以产生介于INT_MAX + 1之间的值 和UINT_MAX,gcc有一些在循环中这样的乘法的情况 可能导致循环提前退出。我没有注意到这种行为 来自生成代码中的比较指令,但在案例中可以观察到 编译器使用溢出来推断循环最多可以执行的位置 4次或更少次;在某些情况下,它不会默认生成警告 输入将导致UB而其他人不会,即使其推论导致 要忽略的循环的上限。

答案 9 :(得分:1)

你的例子没有考虑的一件事是优化。 a在循环中设置但从未使用过,优化器可以解决此问题。因此,优化者完全抛弃a是合法的,在这种情况下,所有未定义的行为都会像boojum的受害者一样消失。

然而,当然这本身是未定义的,因为优化是未定义的。 :)

答案 10 :(得分:0)

由于这个问题是双重标记的C和C ++,因此我将尝试同时解决这两个问题。 C和C ++在这里采用不同的方法。

在C中,实现必须能够证明将调用未定义的行为,以便将整个程序视为具有未定义的行为。在OP的示例中,编译器证明这一点似乎微不足道,因此就好像整个程序都未定义一样。

我们可以从Defect Report 109看到这一点,问题的症结所在是:

  

但是,如果C标准认识到“未定义值”的单独存在(其纯粹的创建不涉及完全“未定义的行为”),则进行编译器测试的人员可以编写如下测试用例,他/她还可以期望(或可能要求)一个符合标准的实现,至少应编译该代码(并可能还使其执行)而不会出现“失败”。

int array1[5];
int array2[5];
int *p1 = &array1[0];
int *p2 = &array2[0];

int foo()
{
int i;
i = (p1 > p2); /* Must this be "successfully translated"? */
1/0; /* Must this be "successfully translated"? */
return 0;
}
     

因此,最重要的问题是:上面的代码是否必须“成功翻译”(意味着什么)? (请参阅第5.1.1.3节中的脚注。)

,回复为:

  

C标准使用术语“不确定值”而非“不确定值”。使用不确定的有价物品会导致不确定的行为。   条款5.1.1.3的脚注指出,只要有效的程序仍能正确翻译,实现就可以自由生成任何数量的诊断信息。   如果在需要常量表达式的情况下出现其后遗忘将导致不确定行为的表达式,则包含的程序并不严格符合要求。此外,如果给定程序的每次可能执行都会导致不确定行为,给定的程序不严格符合。   一个合格的实现一定不能仅仅因为某个程序的某些可能执行会导致不确定的行为而翻译一个严格合格的程序。由于可能永远不会调用foo,因此必须通过符合标准的实现成功翻译给出的示例。

在C ++中,该方法似乎更宽松,并且建议程序具有不确定的行为,而不管实现是否可以静态证明它。

我们有[intro.abstrac]p5,其中说:

  

执行格式正确的程序的一致实现应产生与具有相同程序和相同输入的抽象机相应实例的可能执行之一相同的可观察行为。   但是,如果任何此类执行包含未定义的操作,则本文档对使用该输入执行该程序的实现没有任何要求(即使对于第一个未定义的操作之前的操作也没有要求)。

答案 11 :(得分:-1)

最重要的答案是错误的(但很常见的)误解:

未定义的行为是运行时属性*。它不能&#34;时间旅行&#34;!

某些操作(通过标准)定义为副作用,无法进行优化。执行I / O或访问volatile变量的操作属于此类别。

然而,有一点需要注意:UB可以任何行为,包括 撤消 以前的操作行为。在某些情况下,这可能会对优化早期代码产生类似的影响。

事实上,这与最高答案(强调我的)中的引用一致:

  

执行格式良好的程序的符合实现应该产生与具有相同程序和相同输入的抽象机的相应实例的可能执行之一相同的可观察行为。
  但是,如果任何此类执行包含未定义的操作,则此国际标准不对执行执行该输入的程序提出任何要求(甚至不考虑第一个之前的操作)未定义的操作)。

是的,这个引用 &#34;甚至没有关于第一个未定义操作&#34; 之前的操作,但请注意这是关于代码的这是执行,而不仅仅是编译 毕竟,未实际达到的未定义行为不会做任何事情,并且对于实际到达包含UB的行,其前面的代码必须先执行!

是的,一旦执行UB ,以前操作的任何效果都将变为未定义。但在此之前,程序的执行是明确定义的。

但是,请注意,导致这种情况发生的程序的所有执行都可以针对等效的程序进行优化,包括执行先前操作然后取消其效果的任何程序。因此,前面的代码可以被优化掉每当这样做相当于它们的效果被撤消;否则,它不能。请参阅下面的示例。

*注意: UB occurring at compile time不一致。如果编译器确实可以证明UB代码始终对所有输入执行,那么UB可以延伸到编译时。但是,这需要知道所有以前的代码最终返回,这是一个强烈的要求。再次,请参阅下面的示例/解释。

为了具体说明,请注意以下代码必须打印foo并等待您的输入,无论其后面是否有任何未定义的行为:

printf("foo");
getchar();
*(char*)1 = 1;

但是,请注意,无法保证在UB发生后foo将保留在屏幕上,或者您键入的字符将不再位于输入缓冲区中;这两个操作都可以“撤消”,这对UB&#34;时间旅行&#34;具有类似的效果。

如果getchar()行不在那里,那么合法地将要优化的行当且仅当那将是 无法区分与输出foo然后&#34;取消操作&#34;它。

这两者是否无法区分将完全取决于完全(即在您的编译器和标准库上)。例如,在等待另一个程序读取输出时,您的printf 阻止您的线程吗?或者会立即返回?

  • 如果它可以阻塞,那么另一个程序可以拒绝读取其完整输出,并且它可能永远不会返回,因此UB可能永远不会实际发生。

  • 如果它可以立即返回,那么我们知道它必须返回,因此优化它完全无法区分执行它然后取消它的效果。

当然,由于编译器知道其特定版本的printf允许哪些行为是允许的,因此可以相应地进行优化,因此printf可能会在某些情况下优化而不是其他情况。但是,再一次,理由是,这与UB以前未执行的操作无法区分,以前的代码是&#34;中毒&#34;因为UB。