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

jco*_*der 85 c c++ integer-overflow undefined-behavior

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

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

这个程序在我的平台上包含未定义的行为,因为它a会在第3个循环中溢出.

这是否会使整个程序具有未定义的行为,或者仅在溢出实际发生之后?编译器是否可能a 会解决溢出问题,因此它可以声明整个循环未定义,并且不会打扰运行printfs,即使它们都在溢出之前发生?

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

Tar*_*ama 106

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

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

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

  • @KeithThompson:但是,`sneeze()`函数本身在类'Demon`(其中鼻子类是子类)的任何东西上都是未定义的,无论如何都要使整个东西变成圆形. (4认同)
  • @Crashworks这就是为什么Linux是用*编译为*unportable*C的原因(即C的超集,它需要特定的编译器具有特定的选择,例如-fno-strict-aliasing) (3认同)
  • @usr我希望它定义如果`printf`没有返回,但是如果`printf`将要返回,那么未定义的行为可能会在调用`printf`之前引起问题.因此,时间旅行.`printf("Hello \n");`然后下一行编译为`undoPrintf(); launchNuclearMissiles();` (3认同)

Mat*_* M. 31

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

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

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

一些例子来强调这一点,请记住,没有任何部分是详尽无遗的:

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

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


我推荐观看Michael Spencer(LLVM开发人员)观看此视频:CppCon 2016:我的小优化器:未定义的行为是魔术.

  • @jcoder:这里有一个重要的逃脱点.不允许编译器猜测输入数据.只要至少有一个未发生未定义行为的输入,编译器必须确保此特定输入仍然生成正确的输出.关于危险优化的所有可怕谈论仅适用于_unavoidable_ UB.实际上,如果您使用`argc`作为循环计数,则`argc = 1`的情况不会产生UB,编译器将被强制处理. (8认同)
  • @jcoder:如果`f(good);`做某事X和`f(坏);`调用未定义的行为,那么只调用`f(good);`的程序保证做X,但是`f(好); f(坏);`不保证做X. (4认同)
  • @Hurkyl更有趣的是,如果你的代码是`if(foo)f(good); 否则f(坏);`,一个聪明的编译器将丢弃比较并产生一个无条件的`foo(好)`. (4认同)
  • 这让我很担心.在我的真实代码中,它很复杂,但我*可能*有一个总会溢出的情况.我并不关心这一点,但我担心"正确"代码也会受此影响.显然我需要解决它,但修复需要理解:) (3认同)

Bat*_*eba 28

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

它是由两种标准允许做任何它想做这可能包括整个程序的缺失,留下int main(){}.

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

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

  • @jimifiki在标准中.C++ 14(N4140)1.3.24"udnefined behavior =本国际标准没有要求的行为." 加上一个冗长的说明,详细说明.但重点是,不是"声明"的行为是未定义的,而是*程序的行为.*这意味着只要UB由标准中的规则(或缺少规则)触发,标准不再适用于作为整体的程序*.*所以*程序的任何*部分都可以按照它想要的方式运行. (8认同)
  • 第一个陈述是错误的.如果`int`是16位,则添加将在`long`中发生(因为文字操作数具有类型`long`),其中它被很好地定义,然后通过实现定义的转换转换回`int`. (5认同)
  • 为什么16位?我想OP正在寻找32位有符号溢出. (4认同)
  • 即使在未定义的行为出现之前,标准是否允许它做任何想做的事情?这说明了什么? (3认同)
  • @usr`printf`的行为由标准定义为始终返回 (2认同)

bwD*_*aco 11

从技术上讲,在C++标准下,如果程序包含未定义的行为,则整个程序的行为即使在编译时(在程序执行之前)也是未定义的.

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

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


ala*_*ain 9

为了理解为什么未定义的行为能够"时间旅行",因为@TartanLlama充分说明了这一点,让我们来看看"as-if"规则:

1.9程序执行

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

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

标准只定义了输入和输出之间的映射,没有别的.它通过描述"示例黑盒子"来做到这一点,但明确说明具有相同映射的任何其他黑盒子同样有效.这意味着黑匣子的内容无关紧要.

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

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


R..*_*R.. 6

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


Cor*_*ica 6

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

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

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

if (currentPtr + numberOfNewChars > endOfBufferPtr) { doOverflowLogic(); }
Run Code Online (Sandbox Code Playgroud)

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

if (currentPtr + numberOfNewChars < currentPtr) { detectWrapAround(); }
Run Code Online (Sandbox Code Playgroud)

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

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