在循环中,整数溢出何时变为未定义行为?

86

这是一个示例,用来说明我的问题,其中涉及了一些更加复杂的代码,我无法在这里发布。

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

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

这是否意味着整个程序都有未定义行为,还是只有在溢出实际发生后才有未定义行为?编译器是否可以预测到 a 将会溢出,从而声明整个循环未定义,并且没有运行 printfs 即使它们在溢出之前全部发生了?

(标记了 C 和 C++,尽管它们不同,因为如果它们不同,我将对两种语言的答案感兴趣。)


7
编译器能否判断出 a 除了计算自身外并没有被使用,然后简单地将 a 删除呢? - Support Ukraine
12
你可能会喜欢CppCon今年的讲座《我的小优化器:未定义行为是如何运作的》My Little Optimizer: Undefined Behaviour is Magic。这个讲座主要介绍了编译器基于未定义行为可以进行哪些优化。 - TartanLlama
2
https://dev59.com/x2w15IYBdhLWcg3wWqf-#6665635 - Bo Persson
3
请参阅 https://blogs.msdn.microsoft.com/oldnewthing/20140627-00/?p=633。 - Eric Lippert
2
"一个足够先进的编译器和一个对手是无法区分的。" - Kyle Strand
显示剩余9条评论
12个回答

108
如果您只是对一个纯理论性的答案感兴趣,那么C++标准允许未定义的行为“时间旅行”:
【intro.execution】/5:执行一份格式良好的程序的符合实现应该生成与相同程序和相同输入的抽象机器的相应实例之一具有相同的可观察行为。然而,如果任何这样的执行包含未定义的操作,则本国际标准对于使用该输入执行此程序的实现(甚至是在第一个未定义操作之前的操作)不会提出要求。
因此,如果您的程序包含未定义的行为,则您的整个程序的行为都是未定义的。

4
然而,sneeze()函数本身在类Demon上是未定义的(其中鼻子变体是一个子类),这使得整个过程无限循环。 - Sebastian Lenartowicz
1
但是 printf 可能不会返回,因此前两轮被定义为必须完成,因为在它们完成之前,不清楚是否会出现 UB。请参见 https://dev59.com/0GAg5IYBdhLWcg3w0Noz - usr
1
这就是为什么编译器在发现 Linux 内核中的引导代码依赖于未定义行为时可以在技术上发出“nop”的原因:http://blog.regehr.org/archives/761 - Crashworks
3
这就是为什么Linux采用不可移植的C语言编写和编译的原因(即C语言的超集,需要使用特定选项的编译器,例如-fno-strict-aliasing)。 - user253751
3
如果printf函数不返回的话,情况是被定义的。但如果printf函数将要返回并且在此之前出现了未定义的行为,会引起问题。因此,就像时间旅行一样。先执行printf("Hello\n");,然后下一行代码编译成了undoPrintf(); launchNuclearMissiles();。请注意,这里只翻译内容,不提供其他解释或额外信息。 - user253751
显示剩余11条评论

32

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

未定义行为并不仅限于执行领域。

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

以下是一些例子,需要注意的是,并非穷尽所有情况:

  • 编译器可以假定包含未定义行为的代码段永远不会被执行,因此可以假定导致它们的执行路径都是死代码。请参见Chris Lattner所写的What every C programmer should know about undefined behavior
  • 在存在多个弱符号(按名称识别)的多个定义时,链接器可以假定所有定义都相同,这要归功于One Definition Rule
  • 加载程序(如果使用动态库)可以做出同样的假设,从而选择找到的第一个符号;这通常用于Unix上使用LD_PRELOAD技巧拦截调用。
  • 如果使用悬空指针,则执行可能会失败(SIGSEV)

这就是未定义行为的可怕之处:几乎不可能预测将发生什么确切的行为,而且每次更新工具链、底层操作系统等都必须重新考虑这个预测


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


3
这是让我担忧的事情。在我的真实代码中,它很复杂,但我可能会遇到一种情况,它总是会溢出。我并不真正关心那个,但我担心“正确”的代码也会受到影响。显然我需要修复它,但修复需要理解 :) - jcoder
8
@jcoder: 这里有一个重要的逃脱口。编译器不允许猜测输入数据。只要存在至少一种输入,Undefined Behavior 不会发生,编译器必须确保该特定输入仍然产生正确的输出。关于危险优化的可怕谈话仅适用于无法避免的 UB。实际上,如果您将argc作为循环计数使用,则argc=1的情况不会产生 UB,编译器将被迫处理它。 - MSalters
@jcoder:在这种情况下,这不是死代码。然而,编译器可能足够聪明,可以推断出 i 不能增加超过 N 次,因此其值是有界的。 - Matthieu M.
4
如果f(good);执行某项操作X,而f(bad);会导致未定义的行为,那么仅调用f(good);的程序保证会执行X,但是f(good); f(bad);并不能保证会执行X。 - user1084944
4
更有趣的是,如果你的代码是if(foo) f(good); else f(bad);,一款智能编译器将会抛弃比较操作并生成无条件的f(good) - John Dvorak
显示剩余2条评论

28
一款针对 16 位的 int 类型进行积极优化的 C 或 C++ 编译器会“知道”将 1000000000 加到一个 int 类型上的行为是“未定义”的。
根据标准,编译器可以执行任何可能的操作,包括删除整个程序,留下 int main(){}
但是那么对于更大的 int 呢?我不知道是否有这样的编译器(我并不是 C 和 C++ 编译器设计方面的专家),但我想在某个时候,一款针对 32 位或更高位的 int 的编译器可能会发现循环是无限的(i 不变),因此 a 最终会溢出。因此,它可以将输出优化为 int main(){}。我试图说明的重点是随着编译器优化变得越来越积极,越来越多的未定义行为构造以意想不到的方式显现出来。
循环是无限的这一事实本身并非未定义,因为您在循环体中写入了标准输出。

3
标准是否允许在未定义行为产生之前随意执行任何操作?这个规定在哪里说明了? - jimifiki
4
为什么是16位?我猜OP正在寻找32位有符号溢出。 - Support Ukraine
8
在标准 C++14(N4140)的1.3.24中,“未定义行为=国际标准不强制要求的行为。”另外还有一个详细说明的注释。但重点是,未定义行为不是“语句”的行为,而是程序的行为。这意味着,只要标准中的规则(或缺乏规则)触发了UB,标准就不再适用于整个程序。因此,程序的任何部分都可以按其意愿运行。 - Angew is no longer proud of SO
5
第一个声明是错误的。如果“int”是16位的,那么加法将在“long”中进行(因为文字操作数具有“long”类型),这是良好定义的,然后通过实现定义的转换转换回“int”。 - R.. GitHub STOP HELPING ICE
2
@usr printf 的行为是由标准定义的,它总是返回一个值。 - M.M
显示剩余5条评论

12

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

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

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


如果UB部分在if(false) {}范围内怎么办?由于编译器假定所有分支都包含 ~明确定义的逻辑部分,因此基于错误的假设运行,这是否会破坏整个程序? - mlvljr
1
标准对未定义行为没有任何要求,因此从理论上讲,它确实会破坏整个程序。然而,在实践中,任何优化编译器都很可能只是删除死代码,因此它可能不会对执行产生影响。但是,您仍然不应该依赖这种行为。 - bwDraco
好的,谢谢 :) - mlvljr

10
为了理解未定义行为的'时间旅行' as @TartanLlama 充分表达的原因,让我们看一下“as-if”规则:

1.9 程序执行

1本国际标准中的语义描述定义了一个参数化的非确定性抽象机。本国际标准不对符合实现的结构提出任何要求。特别地,它们不需要复制或模拟抽象机的结构。相反,符合实现仅需要模拟下面所述的抽象机的可观察行为。

有了这个,我们可以将程序视为具有输入和输出的“黑盒子”。输入可以是用户输入、文件和许多其他东西。输出是标准中提到的“可观察行为”。
标准只定义了输入和输出之间的映射,除此之外没有其他东西。它通过描述一个“示例黑盒子”来做到这一点,但明确表示具有相同映射的任何其他黑盒子都是同样有效的。这意味着黑盒子的内容是无关紧要的。
考虑到这一点,说未定义行为发生在某个特定时刻是没有意义的。在黑盒子的示例实现中,我们可以指出它发生的地点和时间,但实际的黑盒子可能完全不同,因此我们不能再指出它发生的地点和时间了。理论上,编译器可以决定枚举所有可能的输入,并预先计算出结果。那么未定义行为就会发生在编译期间。
未定义行为是输入和输出之间不存在映射的情况。一个程序对于某些输入可能具有未定义行为,但对于其他输入则具有定义行为。然后,输入和输出之间的映射仅仅是不完整的;存在输入,其中没有映射到输出。
问题中的程序对于任何输入都具有未定义行为,因此映射为空。

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

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在下一个版本中回滚了这个变化)。这不是我认为直观的行为,但是如果你稍微想象一下,就可以很容易地看出这种情况的一个轻微变体可能会成为编译器的停机问题。正因为如此,规范撰写者将其视为“未定义行为”,并声明编译器可以随心所欲地执行任何操作。

我并不认为编译器表现得好像在超出“int”范围的类型上执行有符号算术运算时特别令人惊讶,尤其是考虑到即使在x86上进行直接代码生成时,有时这样做比截断中间结果更有效率。更令人惊讶的是当溢出影响其他计算时,即使代码将两个uint16_t值的乘积存储到uint32_t中,gcc也可能会发生这种情况--这种操作在非消毒构建中应该没有任何合理的原因表现出意外行为。 - supercat
当然,正确的检查应该是 if(numberOfNewChars > endOfBufferPtr - currentPtr),前提是numberOfNewChars永远不会是负数,而currentPtr始终指向缓冲区内的某个位置,你甚至不需要荒谬的“环绕”检查。(我认为你提供的代码在循环缓冲区中没有任何希望工作 - 你在释义中省略了必要的内容,所以我也忽略了这种情况) - Random832
@Random832,我确实省略了很多内容。我试图引用更大的上下文,但由于我丢失了我的来源,我发现重新解释上下文会让我陷入更多麻烦,所以我把它省略了。我真的需要找到那个该死的错误报告,这样我才能正确地引用它。这确实是一个强有力的例子,说明你可能认为你以某种方式编写了代码,但它却完全不同。 - Cort Ammon
这是我对未定义行为最大的问题。它有时会使编写正确代码变得不可能,当编译器检测到它时,默认情况下不会告诉您已触发未定义行为。在这种情况下,用户只想进行算术运算 - 无论是否使用指针 - 而他们所有编写安全代码的努力都被撤销了。至少应该有一种方法来注释代码部分,以表示-没有花哨的优化。 C / C ++在过多关键领域中使用,以允许这种危险情况继续支持优化,这是不可取的。 - John McGrath

4

按定义,未定义行为是一个灰色地带。你根本无法预测它会做什么或不会做什么 -- 这就是“未定义行为”的意思。

自古以来,程序员一直试图从一个未定义的情况中挽救出一些定义性的残留物。他们有一些他们真正想使用的代码,但结果被证明是未定义的,所以他们试图辩论:“我知道这是未定义的,但最坏的情况下,它将做这个或那个;它永远不会做那个。” 有时候这些论点或多或少是正确的 - 但通常是错误的。随着编译器变得越来越聪明 (或者有些人可能会说,越来越狡猾),问题的边界也在不断改变。

因此,如果你想编写保证工作并长期工作的代码,只有一个选择:不惜一切代价避免未定义的行为。确实,如果你涉足其中,它将回来困扰你。


然而,问题在于...编译器可以利用未定义的行为进行优化,但它们通常不会告诉你。因此,如果我们有一个很棒的工具来避免无论如何都要避免做X,为什么编译器不能给出警告,以便您可以修复它呢? - Jason S

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的数字,当它乘以比例时将产生0,则可能会出现故障。 如果从内存中读取了多次“scale”并且某些东西意外地改变了其值的情况下,“它”也可能变成无限循环(在任何情况下,“scale”可以在中间更改而不会引发UB,编译器将不允许执行优化)。
虽然大多数这样的优化在将两个短无符号类型相乘以产生介于INT_MAX + 1和UINT_MAX之间的值的情况下不会有任何问题,但gcc在某些情况下,这样的循环内乘法可能会导致循环过早退出。 我没有注意到从生成的代码中产生比较指令的这种行为,但在编译器使用溢出推断循环最多只能执行4次或更少的循环中,可以观察到这种行为; 即使它的推论导致忽略循环的上限,在某些输入会导致UB而其他输入不会的情况下,它默认不生成警告。

1
你的例子没有考虑优化。在循环中设置了a,但从未使用过,优化器可以发现这一点。因此,优化器可以完全丢弃a,在这种情况下所有未定义的行为都会消失,就像布旧姆的受害者一样。
然而,当然,这本身也是未定义的,因为优化是未定义的。 :)

1
在确定行为是否未定义时,没有理由考虑优化。 - Keith Thompson
2
程序表现出我们所期望的行为,并不意味着未定义的行为“消失”了。这种行为仍然是未定义的,你只是依靠运气。程序的行为可以根据编译器选项而改变的事实,强烈表明该行为是未定义的。 - Jordan Melo
@JordanMelo 由于之前的许多回答都讨论了优化问题(而且 OP 特别提到了这一点),我提到了优化的一个特性,而之前没有任何一个回答涉及到它。 我还指出,即使优化可以消除它,依赖优化以任何特定方式工作仍然是未定义的。 当然,我不建议这样做! :) - Graham
@KeithThompson 当然可以,但OP特别问到了优化及其对他所在平台上的未定义行为的影响。具体的行为可能会因为优化而消失。不过正如我在答案中所说的,未定义性将不会改变。 - Graham

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接