整数溢出是否会导致内存破坏而产生未定义行为?

51

我最近读到,C和C++中的有符号整数溢出会导致未定义行为:

如果在表达式求值过程中,结果在其类型的可表示值范围内没有数学定义或不在该类型的可表示值范围内,则行为是未定义的。

我目前正在尝试理解此处未定义行为的原因。我认为未定义行为发生在此处,是因为整数在变得太大而无法适应基础类型时开始操纵其周围的内存。

因此,我决定在Visual Studio 2015中编写一个小测试程序来使用以下代码测试该理论:

#include <stdio.h>
#include <limits.h>

struct TestStruct
{
    char pad1[50];
    int testVal;
    char pad2[50];
};

int main()
{
    TestStruct test;
    memset(&test, 0, sizeof(test));

    for (test.testVal = 0; ; test.testVal++)
    {
        if (test.testVal == INT_MAX)
            printf("Overflowing\r\n");
    }

    return 0;
}

我在这里使用了一个结构来防止在调试模式下出现任何 Visual Studio 的保护措施,例如堆栈变量的临时填充等。

这个无限循环应该会导致 test.testVal 的多次溢出,事实上确实如此,但除了溢出本身外没有任何后果。

在运行溢出测试时,我查看了内存转储,结果如下(test.testVal 的内存地址为 0x001CFAFC):

0x001CFAE5  00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
0x001CFAFC  94 53 ca d8 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00

整数溢出的内存转储

如您所见,持续溢出的int周围的内存仍然“未受损”。我使用类似的输出进行了多次测试。从未有任何关于溢出int周围内存的损坏。

这里发生了什么?为什么变量test.testVal周围的内存没有受到破坏?这如何导致未定义的行为?

我正在尝试理解我的错误,以及为什么在整数溢出期间没有发生内存破坏。


37
你希望得到一个“未定义”的行为定义?明确告诉你没有合理的期望可以持有,所以这种行为不可能与任何你可以期望的东西有所区别。 - Kerrek SB
8
整数溢出不会影响相邻的内存。 - sashoalm
9
@NathanOliver,对于推理未定义行为并没有什么害处。我个人认为这是一个非常有用的练习。 - SergeyA
7
@Olaf UB有一个理由,我正在努力理解它。这张图片并没有包含问题的关键部分,而是为了图形化地展示我的测试结果。图片中的所有内容,包括使用的代码,都已经以明文方式发布。 - Vinz
21
在我看来,对这个问题进行负评是完全错误的。提问者实际上展现了一种非常健康的渴望去理解,而不是盲目地跟随。 - SergeyA
显示剩余35条评论
6个回答

78

您误解了未定义行为的原因。原因不是整数周围的内存损坏 - 它将始终占用整数占用的相同大小 - 而是底层算术运算。

由于有符号整数不必编码为二进制补码,因此无法针对溢出情况提供具体指导。不同的编码或CPU行为可能会导致不同的溢出结果,包括例如程序由于陷阱而被杀死。

就像所有未定义行为一样,即使硬件在其算术中使用二进制补码并定义了溢出规则,编译器也不受它们的约束。例如,长期以来,GCC优化掉了任何只在2的补码环境下成立的检查。例如,if (x > x + 1) f()会从优化代码中删除,因为有符号溢出是未定义行为,意味着它从不发生(从编译器的角度看,程序从不包含产生未定义行为的代码),这意味着x永远不能大于x + 1


5
@SergeyA 一点没错!我试图理解 UB 的原因,并猜测这可能是由于溢出期间发生的内存损坏所致。现在我知道它有算术背景了 :) 再次感谢,我认为那些反对票并没有什么影响……我不会删除这个问题,因为它可能对像我一样思考的其他人有所帮助 :) - Vinz
2
@JonTrauntvein:C++ 的设计不仅仅是为了现代架构。 - Martin York
15
一些数字信号处理器支持锁存算术。将1加到最大值,最大值仍然保持不变。这样,溢出漏洞就不会导致你的导弹偏离预定方向180度。 - brian beuning
2
@Vinzenz:请注意,C语言的特定实现(例如MSVC)可能定义了有符号整数溢出时会发生什么(即保证使用2补数字进行正确行为,因为这是底层硬件所支持的)。编写依赖于此的代码即使对于x86也不安全:一些编译器(如gcc和clang)利用UB进行更多优化。例如,在一个具有int循环计数器索引数组的循环中,编译器可以在每次迭代中跳过从32b到64b的符号扩展。 - Peter Cordes
4
是的,这对于多种未定义行为都是正确的。问题在于你的回答有点暗示了UB的后果是有限制的。它似乎意味着在C有符号整数上进行算术运算会在2补码硬件上使用2补码,但是像gcc和clang这样的优化编译器是不会这样做的。我认为这是一个非常重要的观点,否则人们会倾向于依赖有符号溢出,因为他们知道目标是2补码硬件。感谢更新。 - Peter Cordes
显示剩余9条评论

29
标准的作者没有定义整数溢出,因为一些硬件平台可能以不可预测的方式触发陷阱(可能包括随机代码执行和随后的内存损坏)。尽管在C89标准发布时,已经有了使用可预测的静默环绕溢出处理的二进制补码硬件标准(在我检查过的许多可重编程微型计算机架构中,都是这样),但标准的作者不想阻止任何人在旧设备上生产C实现。
在实现了普遍使用的二进制补码静默环绕语义的情况下,像以下的代码:
int test(int x)
{
  int temp = (x==INT_MAX);
  if (x+1 <= 23) temp+=2;
  return temp;
}

当传入INT_MAX的值时,该函数将100%可靠地返回3,因为将1添加到INT_MAX会产生INT_MIN,后者当然小于23。
在1990年代,编译器利用整数溢出未定义行为的事实,而不是被定义为二进制补码环绕,以启用各种优化,这意味着溢出的计算的确切结果可能不可预测,但不依赖于确切结果的行为方面将保持正常。给定上述代码的1990年代编译器可能会将其视为添加1到INT_MAX会产生一个数值比INT_MAX大一的值,从而导致函数返回1而不是3,或者它可能像旧编译器一样,产生3。请注意,在上述代码中,这种处理可以在许多平台上节省一条指令,因为(x + 1 <= 23)等价于(x <= 22)。编译器可能无法一致选择1或3,但生成的代码除了产生这些值之一外,不会执行任何其他操作。
然而,自那时以来,编译器越来越时髦地使用标准在整数溢出情况下未强加任何要求的失败(这种失败由硬件的存在而得到激励,其中后果可能真正不可预测)来证明在溢出情况下使编译器启动代码完全脱轨是合理的。现代编译器可能会注意到程序将在x == INT_MAX时调用未定义行为,因此得出结论该函数永远不会传递该值。如果从另一个翻译单元调用上述函数并且x == INT_MAX,则它可能返回0或2;如果从同一翻译单元中调用,则效果可能更加奇怪,因为编译器会将其关于x的推断扩展回调用者。
关于溢出是否会导致内存损坏,在某些旧硬件上可能会有。在运行在现代硬件上的旧编译器上,它不会。对于超现代编译器,溢出会否定时间和因果关系的结构,因此一切皆有可能。在计算x + 1的溢出中,可能会有效地破坏先前针对INT_MAX的比较所看到的x的值,使其表现为内存中x的值已被破坏。此外,这种编译器行为通常会删除将防止其他类型的内存损坏的条件逻辑,从而允许发生任意内存损坏。

2
编译器出现问题的一个原因是用户在咒骂编译器时并不总是意识到,编译器并没有假设你会故意写出带有未定义行为的代码,并期望编译器会做出明智的处理。相反,编译器是基于这样的假设编写的:如果它看到上面的代码,那么很可能是某种边缘情况的结果,比如INT_MAX是宏的结果,因此它应该将其优化为特殊情况。如果你将该代码中的INT_MAX更改回不荒谬的值,它就会停止优化。 - Steve Jessop
@SteveJessop:许多程序可以容忍几乎任何形式的溢出行为,只要满足两个约束条件:(1)整数运算除了尝试除以零之外没有副作用;(2)将有符号加法、乘法或按位操作的N位结果转换为N位或更小的无符号类型将产生与使用无符号数学执行操作时相同的结果。C89的作者指出,大多数编译器都遵守这两个保证,并且对于短无符号类型的有符号提升选择部分基于该行为。 - supercat
@SteveJessop: 完全偏离常规的行为是否能够实现有益的优化,以抵消函数在其它职责中执行廉价计算(其结果有时会有意义,有时则没有),以及处理这种情况的效率低下?如果在使用结果的情况下从不发生溢出,在结果被忽略的情况下要求代码防止溢出是否有任何有用的目的呢? - supercat
5
@SteveJessop: 我认为一个基本问题是,有些人已经产生了疯狂的想法,即C标准旨在描述关于优质实现的所有重要内容。如果我们意识到(1)在一个良好的实现中,抽象机器通常会从运行它的真实执行平台继承特性和保证;(2)不同类型的程序可以容忍真实和抽象平台之间不同程度的分歧;(3)定义一个“有选择地符合”的程序类别将具有巨大的价值... - supercat
5
@SteveJessop:...不需要在每个平台上进行编译,但需要在每个符合条件的平台上正确运行(相反,一个符合条件的平台不需要运行大部分选择性符合的程序,但需要拒绝任何它无法满足需求的选择性符合程序)。目前,“符合性”被定义得太宽泛,以至于基本上没有意义,“严格符合性”又被定义得如此严格,以至于几乎没有真实世界的任务可以使用严格符合性代码完成。 - supercat
显示剩余10条评论

5

未定义行为是未定义的。它可能会使您的程序崩溃。它也可能什么都不做。它也可能正好做您想要的事情。它可能会唤起鼻子恶魔。它可能会删除所有文件。当编译器遇到未定义行为时,它可以自由地发出任何代码(或根本不发出代码)。

任何未定义行为的实例都导致整个程序变得不确定 - 不仅是未定义的操作,因此编译器可以对程序的任何部分都做任何它想做的事情。包括时间旅行:未定义的行为可能导致时间旅行(除其他外,但时间旅行最炫酷)

有关未定义行为的许多答案和博客文章,但以下是我最喜欢的。如果您想了解更多信息,请阅读它们:


3
很好的复制粘贴...虽然我完全理解“未定义”的定义,但我试图理解UB的原因,正如您可以从@SergeyA的答案中看到的那样,它是相当明确定义的。 - Vinz
3
你能否找到任何证据表明,在2005年之前,使用二进制补码静默环绕硬件时溢出会产生除返回无意义结果之外的副作用?我鄙视这种说法,即程序员从未合理地期望微型计算机编译器遵守行为约定,而这些约定在大型机或小型机上并不一致支持,但据我所知,这些约定已经被微型计算机编译器绝对一致地支持。 - supercat

5
除了深奥的优化结果外,即使是你天真地期望一个非优化编译器生成的代码,你也必须考虑其他问题。
  • 即使你知道体系结构是二进制补码(或其他),溢出操作可能不会按预期设置标志,因此像 if(a + b < 0) 这样的语句可能会执行错误的分支:给定两个较大的正数,当它们相加时会溢出,结果是负数,但实际上加法指令可能并没有设置负数标志)

  • 一个多步操作可能在比 sizeof(int) 更宽的寄存器中进行,而在每一步中都没有被截断,因此像 (x << 5) >> 5 这样的表达式可能不会像你假设的那样截断左侧的五个位。

  • 乘除运算可能使用一个辅助寄存器来存储产品和被除数的额外位。如果乘法“不能”溢出,则编译器可以自由地假设辅助寄存器为零(或对于负产品为-1),并在除法之前不重置它。因此,像 x * y / z 这样的表达式可能使用比预期更宽的中间乘积。

其中一些听起来像是额外的精度,但这是不被期望、无法预测或依赖的额外精度,并且违反了你的心理模型,“每个操作接受 N 位二进制补码操作数,并返回结果的最低 N 位以供下一个操作使用”。


如果编译的目标平台上,add 指令不能准确地根据结果设置符号标志位,编译器会知道这一点,并使用单独的测试/比较指令来产生正确的结果(假设 gcc -fwrapv 使有符号溢出具有定义的包装语义)。C 编译器不仅仅生成类似源代码的汇编代码;它们还要确保生成的代码与源代码完全相同,除非 UB 允许它们进行优化(例如,在循环计数器每次迭代索引时不重新执行符号扩展)。 - Peter Cordes
1
@Peter Cordes - 这些事情都不是晦涩难懂的,它们完全是编写与等效C代码含义相对应的自然汇编代码的自然结果。-fwrapv本身就是一个晦涩难懂的选项,它所做的事情并不仅仅是“禁用优化”。源代码实际上没有你所断言的语义。 - Random832
所以你在谈论 gcc -O0(即 -fno-strict-overflow,但不是 -fwrapv)? 你确定这些吗?我的意思是,如果添加操作没有以有用的方式设置符号标志,则必须正确编译 f((unsigned)a + (unsigned)b < (unsigned)INT_MAX),并且需要单独进行比较。我认为编译器除了通过优化将其消除之外,不可能出错于相同分支的有符号版本。 - Peter Cordes
我特别考虑x86。我编写的无符号比较将编译为add eax,ebx / jns。我选择INT_MAX作为截止点是有原因的:因为它允许编译器检查标志位以检测其MSB设置的无符号结果。是的,我刚刚测试了一下,我是正确的:gcc使用add/js,因为add根据结果设置所有标志。另请参阅http://teaching.idallen.com/dat2343/10f/notes/040_overflow.txt。无论如何,我们不是在检查溢出,而是在检查结果>或<0。 - Peter Cordes
在这种情况下,它设置了符号标志,但也设置了溢出标志,并且有符号比较将使用jge,而不是jns。我所提到的“负标志”实际上是SF^OF,而不是SF。 - Random832
显示剩余3条评论

5
整数溢出行为在C++标准中没有定义。这意味着任何C++的实现都可以自由地做任何它喜欢的事情。
实际上,这意味着:无论对于实现者来说最方便的是什么。而且由于大多数实现者将int视为二进制补码值,所以现在最常见的实现方式是认为两个正数的溢出和是一个负数,它与真实结果有某种关系。这是一个错误的答案,但它是被标准允许的,因为标准允许任何事情。
有人认为整数溢出应该像整数除以零一样被视为错误。'86架构甚至有INTO指令来引发溢出异常。在某些时候,这个观点可能会得到足够的支持,使其进入主流编译器,此时整数溢出可能会导致崩溃。这也符合C++标准,允许实现做任何事情。
您可以想象一种架构,其中数字以小端方式表示为以 null 结尾的字符串,并使用零字节表示“数字结束”。通过逐字节相加直到达到零字节来进行加法运算。在这样的架构中,整数溢出可能会用1覆盖尾随的零,使结果看起来远远更长,并有可能损坏未来的数据。这也符合C ++标准。
最后,正如其他回复所指出的那样,大量代码生成和优化取决于编译器推理其生成的代码及其执行方式的能力。在整数溢出的情况下,编译器完全可以(a)生成添加大正数时产生负结果的加法代码,并(b)利用添加大正数产生正结果的知识来通知其代码生成。因此例如:
if (a+b>0) x=a+b;

如果编译器知道ab都是正数,就可能不必执行测试,而是无条件地将a加到b中,并将结果放入x。在补码机器上,这可能导致负值被放入x中,似乎违反了代码的意图。这完全符合标准。


1
实际上有相当数量的应用程序,其中捕获溢出或静默地产生任意值而没有副作用都是可以接受的;不幸的是,超现代 UB 已经远远超出了这个范畴。如果程序员可以依赖于溢出具有受限制的后果,那么能够接受这些后果的代码可能比必须不惜一切代价防止溢出的代码更有效率,但在现代编译器上,仅仅测试 (a+b > 0) 的行为就可以任意地 并且是事后 改变 ab 的值。这就是让人感到害怕的地方。 - supercat

3

int 表示的值是未定义的。与你认为的不同,内存中没有“溢出”。


谢谢,我明白现在这与内存损坏无关 :) - Vinz
2
事情比那更糟。编译器可能会基于有符号溢出从未发生的假设进行优化(例如,i+1 > i始终为真)。这可能导致除单个变量外的其他事物具有未定义的值。 - Peter Cordes
@PeterCordes:你是否同意我对20世纪90年代编译器行为的描述——像(i+1 > i)这样的表达式在i==INT_MAX时可能会任意产生0或产生1,但这是唯一的两种可能的行为?在我看来,允许该表达式任意产生0或1,但说((int)(i+1) > i)必须执行包装计算,在许多情况下比要求编译器始终使用包装或要求程序员在代码需要保持所有输入值的情况下显式转换值更有效率... - supercat
但是,在那些计算行为是否出现包装的情况下并不重要的情况下,编译器可能会在循环之外计算"k-j",然后将其与"i"进行比较。例如,如果表达式是"i+j>k",而"j"和"k"是循环不变量,编译器可能能够计算出循环之外的"k-j",然后将"i"与其进行比较,但是如果程序员使用无符号数学来防止溢出,则无法这样做。 - supercat
1
@PeterCordes:您所描述的目的可以通过一个内在的机制来实现,如果右值超出其类型的范围,则该机制将设置溢出标志。这样的东西只在极少数情况下才是必需的;让程序员在这些情况下指定它,可以使在更常见的情况下提高性能,其中所需要的只是总体上的“在这个大计算过程中出了什么问题”? - supercat
显示剩余10条评论

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