在循环中的哪一点整数溢出成为未定义行为?

At what point in the loop does integer overflow become undefined behavior?

本文关键字:整数 溢出 未定义 一点 循环      更新时间:2023-10-16

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

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

这个程序在我的平台上包含未定义的行为,因为a将在第三个循环时溢出。

这会使整个程序有未定义的行为,还是只有在溢出实际发生之后?编译器是否可以潜在地计算出a 溢出,因此它可以声明整个循环未定义,而不必运行printfs,即使它们都发生在溢出之前?

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

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

[intro.execution]/5:一个执行格式良好的程序的一致性实现应该产生相同的可观察行为作为抽象机的对应实例用同一程序执行的一种可能同样的输入。但是,如果任何此类执行包含未定义操作,则

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

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

未定义行为不属于执行领域。

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

一些例子来巩固这一点,记住没有一个章节是详尽的:

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

这就是关于未定义行为的可怕之处:几乎不可能提前预测准确的行为将会发生什么,并且这种预测必须在每次更新工具链,底层操作系统时重新访问…


我推荐观看Michael Spencer (LLVM开发人员)的这个视频:CppCon 2016: My Little Optimizer: Undefined Behavior is Magic。

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

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

但是更大的int s呢?我还不知道有哪个编译器能做到这一点(我不是C和c++编译器设计方面的专家),但我想有时候一个针对32位int或更高的编译器会发现循环是无限的(i不会改变)所以a最终会溢出。所以再一次,它可以优化输出到int main(){}。我在这里想说的是,随着编译器优化变得越来越积极,越来越多的未定义行为结构以意想不到的方式表现出来。

你的循环是无限的这个事实本身并不是没有定义的,因为你在循环体中写的是标准输出。

从技术上讲,在c++标准下,如果一个程序包含未定义行为,那么整个程序的行为,甚至在编译时(甚至在程序执行之前),都是未定义的。

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

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

要理解为什么未定义行为可以像@TartanLlama所说的那样"时间旅行",让我们来看看"as-if"规则:

1.9程序执行

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

有了这个,我们可以把程序看作一个有输入和输出的"黑盒"。输入可以是用户输入、文件和许多其他东西。输出是标准中提到的"可观察行为"。

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

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

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

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

TartanLlama的答案是正确的。未定义的行为可以在任何时候发生,甚至在编译期间。这可能看起来很荒谬,但它是允许编译器做它们需要做的事情的一个关键特性。做一名编译器并不总是那么容易。每次你都必须完全按照说明书的要求去做。然而,有时要证明某个特定的行为正在发生是极其困难的。如果您还记得暂停问题,那么开发无法证明当输入特定输入时它是否完成或进入无限循环的软件是相当微不足道的。

我们可以让编译器变得悲观,不断地在编译时担心下一条指令可能是这些暂停问题之一,但这是不合理的。相反,我们给编译器一个通行证:在这些"未定义的行为"主题上,它们被免除任何责任。未定义行为包括所有微妙的邪恶行为,以至于我们很难将它们与真正邪恶的停止问题和诸如此类的问题区分开来。

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

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

看起来很正常。但是,如果numberOfNewChars非常大,并且溢出怎么办?然后,它环绕并成为一个小于endOfBufferPtr的指针,因此溢出逻辑永远不会被调用。所以他们在前面加了第二个检查:

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

看起来你已经处理了缓冲区溢出错误,对吗?然而,提交了一个错误,指出这个缓冲区在特定版本的Debian上溢出!仔细的调查表明,这个版本的Debian是第一个使用特别先进的gcc版本的Debian。在这个版本的gcc中,编译器认识到currentPtr + numberOfNewChars 永远不能是小于currentPtr的指针,因为指针溢出是未定义的行为!这足以让gcc优化整个检查,突然之间,即使您编写了检查代码,也无法防止缓冲区溢出 !

这是规范行为。一切都是合法的(尽管据我所知,gcc在下一个版本中回滚了这个更改)。这不是我认为的直观行为,但如果您稍微发挥一下想象力,就会很容易看到这种情况的一个轻微变化如何成为编译器的一个暂停问题。因此,规范编写者将其称为"未定义行为",并声明编译器可以做任何它喜欢的事情。

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

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的数,乘以比例,就会得到0. 如果从内存中读取更多的刻度,它也可能变成一个无限循环然后某个东西意外地改变了它的值(在任何情况下)"scale"可以在不调用UB的情况下在循环中改变,编译器不会允许执行优化)。

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

根据定义,未定义的行为是一个灰色地带。你根本无法预测它会做什么或不会做什么——这就是"未定义行为"的含义

自古以来,程序员总是试图从未定义的情况中挽救定义的残余。他们有一些很想用的代码,但结果是没有定义的,所以他们试图争辩:"我知道它没有定义,但它肯定会在最坏的情况下做这个或那个;它永远不会做。"有时这些观点或多或少是对的,但通常都是错的。随着编译器变得越来越聪明(或者,有些人可能会说,越来越狡猾),问题的边界也在不断变化。

所以说真的,如果你想编写保证工作的代码,并且能长时间工作,只有一个选择:不惜一切代价避免未定义的行为。的确,如果你涉足其中,它会回来困扰你。

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

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

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

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

我们可以从缺陷报告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++中,这种方法似乎更宽松,并且无论实现是否可以静态地证明它,都会暗示程序具有未定义的行为。

我们有[介绍]。[摘要]p5表示:

一个符合规范的实现执行一个格式良好的程序,应产生与具有相同程序和相同输入的抽象机器的相应实例的可能执行之一相同的可观察行为。然而,如果任何这样的执行包含未定义操作,本文档对使用该输入执行该程序的实现没有要求(甚至对于第一个未定义操作之前的操作也没有要求)。

上面的答案是一个错误的(但常见的)误解:

未定义行为是一个运行时属性*。它不能"时间旅行"!

某些操作(根据标准)被定义为具有副作用并且不能被优化掉。执行I/O或访问volatile变量的操作属于这一类。

然而,有一个警告:UB可以任何行为,包括撤消先前操作的行为。在某些情况下,这与优化早期代码的结果类似。

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

一个符合规范的实现执行一个格式良好的程序,应产生与具有相同程序和相同输入的抽象机器的相应实例的可能执行之一相同的可观察行为。
然而,如果任何这样的执行包含未定义的操作,本国际标准的实现执行带有该输入的程序没有要求(甚至对于第一个未定义操作之前的操作)。

是的,引号确实表示"甚至不涉及第一个未定义操作之前的操作",但请注意,这是专门针对执行的代码,而不仅仅是编译。
毕竟,没有实际到达的未定义行为不会做任何事情,并且对于实际到达包含UB的行,必须首先执行它前面的代码!

所以,是的,一旦UB执行,之前操作的任何效果都变得未定义。但在此之前,程序的执行是定义良好的。

但是,请注意,所有导致这种情况的程序执行都可以优化为等效的程序,包括执行先前操作但随后取消其效果的程序。因此,前面的代码可以优化掉,只要这样做等同于取消的效果;否则,它不能。请看下面的例子。

*注意:这是不是与编译时发生的UB不一致。如果编译器确实可以证明UB代码将对所有输入始终执行,那么UB可以扩展到编译时间。然而,这需要知道所有前面的代码最终返回,这是一个强烈的要求。同样,请参见下面的示例/解释。


要使这一点具体化,请注意以下代码必须打印foo并等待您的输入,而不管它后面有任何未定义的行为:
printf("foo");
getchar();
*(char*)1 = 1;

但是,也请注意,不能保证foo在UB发生后仍将留在屏幕上,或者您键入的字符将不再在输入缓冲区中;这两种操作都可以"撤销",这与UB的"时间旅行"有类似的效果。

如果getchar()行不在那里,那么对于优化掉的行是合法的,当且仅当与输出foo然后"取消"它区分不清

两者是否不可区分将完全取决于的实现(即在你的编译器和标准库)。例如,在等待另一个程序读取输出时,您的printf 可以在这里阻塞线程吗?还是会马上回来?

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

  • 如果它可以在这里立即返回,那么我们知道它必须返回,因此优化它与执行它然后取消它的效果完全没有区别。

当然,由于编译器知道特定版本的printf允许什么行为,因此它可以相应地进行优化,因此printf可能在某些情况下被优化出来,而在其他情况下则没有。但是,再一次,理由是这将与UB取消之前的操作无法区分,而不是先前的代码由于UB而"中毒"。