整数溢出导致未定义的行为,因为内存损坏?

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

如果在expression式评估过程中,结果不是math定义的,或者不在其types的可表示值范围内,则行为是未定义的。

我目前正试图了解这里未定义行为的原因。 我以为未定义的行为发生在这里,因为当它变得太大而不适合底层types时,整数开始操作内存。

所以我决定在Visual Studio 2015中编写一个testing程序,用下面的代码来testing这个理论:

#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在debugging模式下的任何保护性问题,比如临时填充栈variables等等。 无限循环应该会引起test.testVal多次溢出,而且确实是这样,除了溢出本身之外没有其他任何后果。

我在运行溢出testing时看到了内存转储,结果如下( 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内存仍然是“完好无损”的。 我用相似的输出testing了几次。 从来没有任何内存周围溢出int损坏。

这里发生了什么? 为什么variablestest.testVal的内存没有受到损害? 这怎么会导致未定义的行为?

我想了解我的错误,以及为什么在整数溢出期间没有执行内存损坏。

你误解了未定义行为的原因。 原因不是整数周围的内存破坏 – 它将总是占用整数占据的相同大小 – 而是底层算术。

由于带符号的整数不需要用2的补码来编码,所以当它们溢出时将不会有什么特别的指导。 不同的编码或CPU行为会导致不同的溢出结果,例如,由于陷阱而导致程序死机。

和所有未定义的行为一样,即使你的硬件使用2的补码来进行算术运算,并且已经定义了溢出规则,编译器也不受它们的束缚。 例如,很长一段时间,GCC对任何只在2的补码环境下才能实现的检查进行优化。 例如, if (x > x + 1) f()将从优化代码中移除,因为有符号溢出是未定义的行为,意味着它永远不会发生(从编译器的angular度来看,程序从不包含产生未定义行为的代码)永远不会大于x + 1

标准的作者左整数溢出未定义,因为一些硬件平台可能陷入后果可能不可预知(可能包括随机代码执行和随之而来的内存损坏)的方式。 尽pipe在C89标准发布之前(尽pipe我已经研究过许多可重新编程的微型计算机架构,零其他任何东西都没有使用),但是具有可预测的无提示溢出处理function的二进制补充硬件几乎已经成为标准了。不想阻止任何人在旧机器上生成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。

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

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

关于溢出是否会导致内存损坏,可能在一些旧的硬件上。 在现代硬件上运行的较老的编译器,它不会。 在超现代的编译器中,溢出否定了时间和因果关系,所以所有的赌注都没有了。 在x + 1的评估中的溢出可以有效地破坏之前与INT_MAX比较已经看到的x的值,使得其performance得好像存储器中x的值已被破坏。 此外,这样的编译器行为通常会去除条件逻辑,以防止其他types的内存损坏,从而允许发生任意的内存损坏。

未定义的行为是未定义的。 它可能会导致程序崩溃。 它可能什么也不做。 它可能正是你所期望的。 它可能召唤鼻魔。 它可能会删除您的所有文件。 当遇到未定义的行为时,编译器可以自由地发出任何需要的代码(或者根本没有)。

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

关于未定义的行为有很多答案和博客文章,但以下是我的最爱。 如果你想了解更多的话题,我build议你阅读。

  • C和C ++中未定义行为的指南,第1部分
  • 每个C程序员应该知道的关于未定义的行为#1/3

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

  • 即使你知道架构是二进制补码(或其他),溢出的操作可能不会按预期设置标志,所以像if(a + b < 0)这样的语句可能采用错误的分支:给定两个大的正数,如此当它们加在一起时就溢出了,所以这个补充纯粹主义者声称是负的,但是加法指令实际上可能不会设置负旗)

  • 多步操作可能发生在比sizeof(int)更宽的寄存器中,而不会在每一步被截断,因此像(x << 5) >> 5这样的expression式可能不会像您一样截断左边的五位假设他们会。

  • 乘法和除法运算可以使用一个二级寄存器来存储产品中的额外位和分红。 如果乘法“不能”溢出,编译器可以自由地假定二级寄存器为零(或负数乘积为-1),并且不会在分频之前将其复位。 所以像x * y / z这样的expression式可能会比预期的使用更广泛的中间产品。

其中的一些听起来像额外的准确性,但它是额外的准确性,不是预期的,不能预测也不能依赖的,违背了你的心智模型,“每个操作接受N位二进制补码操作数,并返回最不重要的N下一个操作的结果位“

整数溢出行为不是由C ++标准定义的。 这意味着任何C ++的实现都可以自由地做任何事情。

实际上这意味着:对于实现者来说最为方便。 而且由于大多数实现者将int视为二进制补码值,所以现在最常见的实现方式是说,两个正数的溢出总和是一个与真实结果有一定关系的负数。 这是一个错误的答案 ,这是标准允许的,因为标准允许任何事情。

有一个说法,说整数溢出应该被视为一个错误 ,就像整数零除。 '86架构甚至有INTO指令在溢出时引发exception。 在某些时候,参数可能会获得足够的权重,使其成为主stream编译器,此时整数溢出可能会导致崩溃。 这也符合C ++标准,允许执行任何事情。

你可以想象一个架构,在这个架构中,数字以little-endian方式表示为空字符结尾的string,零字节表示“数字结尾”。 可以通过逐字节地添加直到达到零字节来进行加法。 在这样的体系结构中,整数溢出可能会用一个零来覆盖结尾的零,使得结果看起来远远更长,并且可能在将来破坏数据。 这也符合C ++标准。

最后,正如其他一些答复所指出的那样,大量的代码生成和优化依赖于编译器对其生成的代码以及它将如何执行的推理。 在整数溢出的情况下,编译器(a)生成加法代码是完全合法的,当添加大的正数时给出负面的结果,并且(b)以知道增加大的正数来通知其代码生成给出了积极的结果。 因此,例如

 if (a+b>0) x=a+b; 

可能的话,如果编译器知道ab都是正数,不用费心去执行一个testing,但是无条件的把a加到b并把结果放到x 。 在一个二进制补码机器上,这可能会导致x的负值,显然违反了代码的意图。 这完全符合标准。

这是不明确的int值表示什么值。 记忆中没有像你想象的那样“溢出”。