为什么在使用GCC的x86编译器时整数溢出会导致无限循环?

139

以下代码在GCC上会进入无限循环:

#include <iostream>
using namespace std;

int main(){
    int i = 0x10000000;

    int c = 0;
    do{
        c++;
        i += i;
        cout << i << endl;
    }while (i > 0);

    cout << c << endl;
    return 0;
}

所以问题在于:有符号整数溢出在技术上是未定义行为。但是在x86架构的GCC中,使用x86整数指令实现整数算术运算-在溢出时会进行包装。

因此,我本来期望它会在溢出时进行包装-尽管它是未定义行为。但这显然不是情况。那么我错过了什么?

我使用以下方式编译:

~/Desktop$ g++ main.cpp -O2

GCC 输出:

~/Desktop$ ./a.out
536870912
1073741824
-2147483648
0
0
0

... (infinite loop)

在关闭优化时,没有无限循环,输出是正确的。Visual Studio也正确编译了这个程序,并给出了以下结果:

正确输出:

~/Desktop$ g++ main.cpp
~/Desktop$ ./a.out
536870912
1073741824
-2147483648
3

以下是其他一些变体:

i *= 2;   //  Also fails and goes into infinite loop.
i <<= 1;  //  This seems okay. It does not enter infinite loop.

以下是所有相关版本信息:

~/Desktop$ g++ -v
Using built-in specs.
COLLECT_GCC=g++
COLLECT_LTO_WRAPPER=/usr/lib/x86_64-linux-gnu/gcc/x86_64-linux-gnu/4.5.2/lto-wrapper
Target: x86_64-linux-gnu
Configured with: ..

...

Thread model: posix
gcc version 4.5.2 (Ubuntu/Linaro 4.5.2-8ubuntu4) 
~/Desktop$ 

问题是:这是GCC的一个bug吗?还是我误解了GCC处理整数运算的方式?

*我也打上了C标签,因为我认为这个bug会在C中复现。(我还没有验证过。)

编辑:

这是循环的汇编代码:(如果我识别正确的话)

.L5:
addl    %ebp, %ebp
movl    $_ZSt4cout, %edi
movl    %ebp, %esi
.cfi_offset 3, -40
call    _ZNSolsEi
movq    %rax, %rbx
movq    (%rax), %rax
movq    -24(%rax), %rax
movq    240(%rbx,%rax), %r13
testq   %r13, %r13
je  .L10
cmpb    $0, 56(%r13)
je  .L3
movzbl  67(%r13), %eax
.L4:
movsbl  %al, %esi
movq    %rbx, %rdi
addl    $1, %r12d
call    _ZNSo3putEc
movq    %rax, %rdi
call    _ZNSo5flushEv
cmpl    $3, %r12d
jne .L5

在C/C ++中,有符号整数溢出是未定义的行为(无符号整数操作是模$2^w$,其中$w$是字长)。 - vonbrand
13
你说这严格来说是未定义行为,并问这是否是未定义行为。对我来说,这不是一个真正的问题。 - Johannes Schaub - litb
12
谢谢你的评论。可能是我用词不当。我会尽力澄清问题,以赢得你的支持(并相应地编辑问题)。基本上,我知道这是未定义行为。但我也知道,在x86架构上,GCC使用x86整数指令——会在溢出时进行包装。因此,我预期它会包装,尽管这是UB。然而,它没有包装,这让我感到困惑。因此有了这个问题。 - Mysticial
6个回答

192

当标准规定行为未定义时,它就是这样。什么都可能发生。"什么都可能发生" 包括 "通常整数会被截断,但偶尔会发生奇怪的事情"。

是的,在 x86 CPU 上,整数通常以您预期的方式被截断。这是其中的一个例外情况。编译器假设您不会引起未定义的行为,并优化掉了循环测试。如果您真的想要环绕操作,可以在编译时通过向 g++gcc 传递 -fwrapv 来获得定义良好的(二进制补码)溢出语义,但这可能会影响性能。


2
有没有警告选项可以尝试检测意外的无限循环? - Jeff Burdges
6
我发现在这里提到了-Wunsafe-loop-optimizations:https://dev59.com/questions/4HA85IYBdhLWcg3wBOtO。 - Jeff Burdges
1
“是的,在x86 CPU上,整数通常以您期望的方式进行包装。” 这是错误的。 但这很微妙。 据我回忆,它们可能会在溢出时陷入,但我们在这里谈论的不是这个,而且我从未见过它被执行。 除此之外,暂且不考虑x86 BCD操作(在C ++中不允许表示),x86整数操作始终包装,因为它们是二进制补码。 您将g ++错误(或极其不切实际和无意义)的优化误认为是x86整数运算的属性。 - Cheers and hth. - Alf
6
@Cheersandhth.-Alf,当我说“在x86 CPU上”时,我的意思是“当你使用C编译器为x86 CPU开发时”。难道我真的需要这么明确地表达吗?很显然,如果你使用汇编语言进行开发,那么关于整数溢出的语义是非常明确定义的,所以我之前谈论的编译器和GCC都是无关紧要的。 - bdonlan
1
@supercat:这里没有足够的空间来讨论“可以有相当大的优化优势”。有一些博客文章试图深入探讨这个问题。通常它们被写成只是为了表明这一点,但我总是发现这些断言和结论通常不成立,这是一种迷恋盲目的事情。我的印象是,这里有一些(少数)轻微的优化机会。但是,尽管有符号溢出的普遍假设使这些机会成为可能,但实际上并不需要作为一般性假设。 - Cheers and hth. - Alf
显示剩余13条评论

22

简单来说,未定义行为 - 特别是在开启优化 (-O2) 的情况下 - 意味着任何事情都有可能发生。

如果没有开启 -O2 开关,你的代码会按照你所期望的方式工作。

顺便提一下,使用 icl 和 tcc 稍微好些,但你不能依赖这些东西......

根据这里的说法,gcc 优化实际上利用了带符号整数溢出。这意味着“错误”是有意设计的。


编译器会选择无限循环作为未定义行为,这有点糟糕。 - Inverse
29
我不同意。如果你的代码存在未定义行为,最好祈祷它能进入无限循环,这样更容易检测到问题。 - Dennis
1
我的意思是,如果编译器正在积极寻找UB,为什么不插入异常而不是试图超级优化损坏的代码呢? - Inverse
19
编译器不会主动寻找未定义的行为,它假设这种情况不存在。这样可以让编译器对代码进行优化。例如,它不会计算for (j = i; j < i + 10; ++j) ++k;,而是直接设置k = 10,因为只要没有有符号溢出发生,这个条件就总是成立。 - Dennis
1
@Inverse 编译器没有“优化”任何东西。是你在代码中编写了循环,而不是编译器发明它。 - Lightness Races in Orbit
@Inverse:与超现代编译器相比,这里展示的只是小巫见大巫。像这里展示的行为可能会在处理整数类型时使用比变量大小更大的寄存器的平台上“自然”发生(例如,在具有32位int的x64平台上)。相比之下,超现代编译器有时会假设代码达到了一个点,必须发生某些事情以避免UB,即使没有其他基础来支持这种假设。 - supercat

15

需要注意的是C++程序是针对C++抽象机器编写的(通常通过硬件指令来模拟)。你编译为x86架构并不影响它产生未定义行为的事实。

编译器可以利用未定义行为以改进其优化,例如在此示例中,通过从循环中移除条件来进行优化。除了机器码必须能够在执行时产生C++抽象机器要求的结果外,C++级别构造和x86级别的机器码构造之间没有可靠或有用的映射关系。


4
i += i;

// 溢出是未定义的。

使用-fwrapv是正确的。 -fwrapv


4
请注意,未定义行为就是未定义的。这意味着任何事情都可能发生。在实践中(例如在这种情况下),编译器可以自由地假设它不会被调用,并且如果这可以使代码更快/更小,那么它可以随意进行操作。对于不应运行的代码会发生什么,任何人都无法预测。它将取决于周围的代码(根据这个,编译器可以生成不同的代码),使用的变量/常量,编译器标志等等。哦,编译器可能会更新并以不同的方式编写相同的代码,或者您可能会获得另一个具有不同代码生成视图的编译器。甚至可以获得不同的机器,即使是在相同架构线的另一个型号也可能有其自己的未定义行为(查找未定义操作码,一些富有创造力的程序员发现,在一些早期的机器上有时确实会执行有用的操作...)。不存在“编译器在未定义行为上给出确定行为”的情况。有些地方是实现定义的,您应该能够指望编译器保持一致的行为。

1
是的,我非常清楚未定义行为是什么。但是当您了解特定环境下语言的某些方面是如何实现的时,您可以预期看到某些类型的UB而不是其他类型。我知道GCC将整数算术实现为x86整数算术-在溢出时进行包装。因此,我假设行为是这样的。我没有预料到GCC会像bdonlan所回答的那样做其他事情。 - Mysticial
9
错误。发生的情况是GCC可以假设您不会调用未定义的行为,因此它只是发出代码,就好像它不可能发生一样。如果确实发生了这种情况,将执行执行您要求而没有未定义行为的指令,结果就是CPU所做的事情。也就是说,在x86上进行x86操作。如果是另一个处理器,它可能会执行完全不同的操作。或者编译器足够聪明,能够发现您正在调用未定义行为并开始nethack游戏(是的,某些古老版本的gcc确实是这样做的)。 - vonbrand
5
我相信你误读了我的评论。我说:“我没想到会发生什么” - 这就是我首先提出问题的原因。我没有料到GCC会耍什么花招。 - Mysticial

0
即使编译器规定整数溢出必须被视为“非关键”形式的未定义行为(如Annex L所定义),在缺乏更具体行为的特定平台承诺的情况下,整数溢出的结果应至少被视为“部分不确定值”。根据这样的规则,添加1073741824 + 1073741824可以任意地被认为产生2147483648或-2147483648或与2147483648 mod 4294967296同余的任何其他值,并且通过加法获得的值可以任意地被认为是与0 mod 4294967296同余的任何值。
允许溢出产生“部分不确定值”的规则足以遵守Annex L的字眼和精神,但不会阻止编译器进行与无限制未定义行为相同的普遍有用的推断。它将防止编译器进行一些虚假的“优化”,这些优化在许多情况下主要的影响是要求程序员向代码中添加额外的混乱,其唯一目的是防止这种“优化”;是否这是一件好事取决于一个人的观点。

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