整数溢出是否会因内存损坏而导致未定义的行为?

Vin*_*inz 47 c c++ x86 integer-overflow undefined-behavior

我最近读到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;
}
Run Code Online (Sandbox Code Playgroud)

我在这里使用了一个结构来防止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
Run Code Online (Sandbox Code Playgroud)

带内存转储的溢出整数

如你所见,int周围的内存不断溢出仍然"完好无损".我用类似的输出测试了几次.从来没有任何关于溢出的int损坏的内存.

这里发生了什么?为什么变量周围的内存没有受损test.testVal?这怎么会导致未定义的行为?

我试图理解我的错误以及为什么在整数溢出期间没有内存损坏.

Ser*_*eyA 76

你误解了未定义行为的原因.原因不是整数周围的内存损坏 - 它总是占据整数占用的相同大小 - 而是基础算术.

由于有符号整数不需要以2的补码进行编码,因此无法具体指导它们溢出时会发生什么.不同的编码或CPU行为可能导致不同的溢出结果,包括例如由于陷阱导致的程序杀死.

与所有未定义的行为一样,即使您的硬件对其算法使用2的补码并定义了溢出规则,编译器也不受它们的约束.例如,很长一段时间,GCC优化了任何只能在二进制补码环境中实现的检查.例如,if (x > x + 1) f()将从优化代码中删除,因为签名溢出是未定义的行为,这意味着它永远不会发生(从编译器的视图来看,程序永远不会包含产生未定义行为的代码),意味着x永远不会超过x + 1.

  • @JonTrauntvein有些DSP支持锁存算法.将1加到最大值将保持最大值.这样一个溢出的错误不会导致你的导弹走向所需方向的180°. (14认同)
  • 是的,对于多种UB来说都是如此.问题是你的回答意味着UB的后果有限.这似乎意味着对C签名整数的算术将是2的补码硬件上的2的补码,对于像gcc和clang那样积极优化的编译器来说,这不是真的*.我认为这是非常重要的一点,否则人们会想要依赖已签名的溢出,因为他们知道他们的目标是2的补充硬件.感谢更新. (4认同)
  • @SergeyA完全正确!我试图了解UB的原因并猜测这是因为溢出期间发生了内存损坏.现在我知道它有算术背景:)再次感谢你,我不认为downvotes伤害太多...我不会删除这个问题,因为它可能对其他人的思考就像我做的那样有帮助:) (3认同)
  • @JonTrauntvein:C++不仅适用于现代架构. (2认同)
  • @Vinzenz:注意C的特定实现(如MSVC)*可以*定义当有符号整数溢出时会发生什么(即保证2的补码整数的正确行为,因为这是底层硬件支持的.)编写依赖于此的代码即使对于x86也不安全:一些编译器(如gcc和clang)[标记UB的优势以优化更多](http://blog.llvm.org/2011/05/what-every-c-programmer-should- know.html).例如,在一个带有`int`循环计数器索引数组的循环中,编译器可以在每次迭代时跳过从32b到64b的符号扩展. (2认同)

sup*_*cat 29

标准的作者保留了未定义的整数溢出,因为某些硬件平台可能会陷入其后果可能无法预测的方式(可能包括随机代码执行和随之而来的内存损坏).尽管有预测的无声环绕溢出处理二进制补码的硬件是相当多建立由C89标准发布的时间标准(许多可重新编程的微机体系结构的,我检查,零使用别的)标准的作者不希望阻止任何人在旧机器上生成C实现.

在实现普通的二进制补充静默环绕语义的实现上,代码就像

int test(int x)
{
  int temp = (x==INT_MAX);
  if (x+1 <= 23) temp+=2;
  return temp;
}
Run Code Online (Sandbox Code Playgroud)

当100%可靠时,在传递INT_MAX值时返回3,因为向INT_MAX添加1将产生INT_MIN,当然小于23.

在20世纪90年代,编译器使用了这样的事实:整数溢出是未定义的行为,而不是被定义为二进制补码包装,以实现各种优化,这意味着溢出的计算的确切结果将是不可预测的,但是行为方面没有取决于确切的结果将留在轨道上.给定上述代码的20世纪90年代的编译器可能会对它进行处理,好像在INT_MAX中添加1会产生一个大于INT_MAX的值,从而导致函数返回1而不是3,或者它可能像旧的编译器一样,产生3.在上面的代码中,这样的处理可以在许多平台上保存指令,因为(x + 1 <= 23)将等于(x <= 22).编译器在选择1或3时可能不一致,但生成的代码除了产生其中一个值之外不会执行任何操作.

然而,从那以后,对于编译器而言,使用标准的失败对于程序行为的任何要求变得更加时髦,以防整数溢出(由硬件的存在导致的失败,其后果可能是真正不可预测的)以证明编译器的合理性.在溢出的情况下完全脱离轨道启动代码.现代编译器可能会注意到,如果x == INT_MAX,程序将调用未定义的行为,从而得出该函数永远不会传递该值的结论.如果函数永远不会传递该值,则可以省略与INT_MAX的比较.如果从x == INT_MAX的另一个翻译单元调用上述函数,则它可能因此返回0或2; 如果从同一个翻译单元中调用,效果可能会更奇怪,因为编译器会将关于x的推断扩展回调用者.

关于溢出是否会导致内存损坏,在某些旧硬件上可能会有.在现代硬件上运行的旧编译器上,它不会.在超现代编译器中,溢出否定了时间和因果关系的结构,所以所有的赌注都是关闭的.x + 1评估中的溢出可以有效地破坏先前与INT_MAX的比较所看到的x的值,使其表现得好像内存中的x值已被破坏.此外,这种编译器行为通常会删除会阻止其他类型的内存损坏的条件逻辑,从而允许发生任意内存损坏.

  • @SteveJessop:我认为一个基本问题是有些人已经疯狂地认为C标准旨在描述质量实现的所有重要内容.如果一个人认识到(1)在一个好的实现中,抽象机器通常会从运行它的真实执行平台继承特性和保证; (2)不同类型的程序可以容忍真实平台和抽象平台之间不同程度的分歧; (3)具有一定范围的"选择性符合"程序具有巨大的价值...... (5认同)
  • @SteveJessop:...不需要在每个平台上编译,但是需要在他们编译的每个兼容平台上正确运行(相反,不需要兼容平台来运行大部分符合选择性的程序,但是要求拒绝任何符合选择性的程序,这些程序的要求无法满足.就像现在一样,"一致性"定义得如此宽松,以至于基本上没有意义,"严格一致性"的定义非常严格,以至于很少有真实世界的任务可以用严格符合的代码来完成. (5认同)
  • 离线的一个原因是用户在他们的编译器上发誓并不总是欣赏的是编译器不是假设您有意使用UB编写代码而期望编译器将编写的做一些明智的事.相反,它的编写假设如果它看到上面的代码那么它可能是某种边缘情况的结果,就像'INT_MAX`是宏的结果,所以它*应该*优化它作为一个特殊情况.如果您将该代码中的"INT_MAX"更改回非愚蠢的东西,它将停止优化. (2认同)

Jes*_*uhl 5

未定义的行为未定义.它可能会使您的程序崩溃.它可能什么都不做.它可能完全符合您的预期.它可能会召唤鼻子恶魔.它可能会删除您的所有文件.当遇到未定义的行为时,编译器可以自由地发出它喜欢的任何代码(或者根本不发出代码).

任何未定义行为的实例都会导致整个程序未定义 - 不仅仅是未定义的操作,因此编译器可以对程序的任何部分执行任何操作.包括时间旅行:未定义的行为可能导致时间旅行(除其他外,但时间旅行是最有趣的).

有许多关于未定义行为的答案和博客文章,但以下是我的最爱.如果您想了解有关该主题的更多信息,我建议您阅读它们.

  • 很好的复制粘贴...虽然我完全理解"未定义"的定义,但我试图理解UB的原因,这是一个相当好的定义,你可以看到@Sergey的回答 (3认同)
  • 你能找到任何证据表明两个补充的静音环绕硬件有溢出的问题,除了在2005年左右之前返回无意义的结果之外还有副作用吗?我鄙视这样一种说法,即程序员期望微型计算机编译器能够维护大型机或小型计算机不能持续支持的行为惯例,但据我所知,微机编译器绝对一致支持这种惯例. (2认同)

Ran*_*832 5

除了深奥的优化结果之外,您还必须考虑其他问题,即使您天真地希望生成非优化编译器的代码也是如此.

  • 即使您知道该体系结构是二进制补码(或其他),溢出操作可能不会按预期设置标志,因此类似的语句if(a + b < 0)可能会采用错误的分支:给定两个大的正数,因此当它们加在一起时会溢出并且结果,所以二元补充纯粹主义者声称,是否定的,但加法指令实际上可能没有设置负面标志)

  • 多步操作可能发生在比sizeof(int)更宽的寄存器中,而不是在每一步都被截断,因此表达式(x << 5) >> 5可能不会像你想象的那样切断左边五位.

  • 乘法和除法运算可以使用辅助寄存器来获得产品和股息中的额外位.如果乘法"不能"溢出,编译器可以自由地假设辅助寄存器为零(或负产品为-1)并且在分割之前不重置它.所以表达式x * y / z可能会使用比预期更广泛的中间产品.

其中一些听起来像额外的准确性,但它的超精确性是预期的,无法预测或依赖,并且违反了你的心理模型"每个操作接受N位二进制补码操作数并返回最不重要的N下一次操作的结果位"


nug*_*gae 5

C++标准未定义整数溢出行为.这意味着C++的任何实现都可以随意做任何事情.

在实践中,这意味着:对于实现者来说最方便的是什么.并且由于大多数实现者将int二进制补码视为二进制值,现在最常见的实现是两个正数的溢出和是一个负数,它与真实结果有一定关系.这是一个错误的答案,标准允许这样做,因为标准允许任何内容.

有一种说法认为整数溢出应该被视为一个错误,就像整数除零一样.'86架构甚至具有INTO在溢出时引发异常的指令.在某些时候,该论点可能会获得足够的权重,使其成为主流编译器,此时整数溢出可能会导致崩溃.这也符合C++标准,它允许实现做任何事情.

您可以想象一种架构,其中数字以小端方式表示为以空字符结尾的字符串,零字节表示"数字结束".可以通过逐字节添加来完成添加,直到达到零字节.在这样的体系结构中,整数溢出可能会用一个覆盖尾随零,从而使得结果看起来更远,更长并且可能在将来破坏数据.这也符合C++标准.

最后,正如其他一些回复中所指出的,大量的代码生成和优化取决于编译器对其生成的代码及其执行方式的推理.在整数溢出的情况下,编译器完全合法(a)生成用于添加的代码,其在添加大的正数时给出否定结果;以及(b)通过添加大的正数来知道其代码生成.给出了积极的结果.因此,例如

if (a+b>0) x=a+b;
Run Code Online (Sandbox Code Playgroud)

可能,如果编译器知道这两个ab是积极的,没有刻意去进行测试,而是无条件地添加ab和结果放入x.在二进制补码机器上,这可能会导致负值x,显然违反了代码的意图.这完全符合标准.