简体   繁体   English

为什么整数除以 -1(负一)会导致 FPE?

[英]Why does integer division by -1 (negative one) result in FPE?

I have an assignment of expaining some seemingly strange behaviors of C code (running on x86).我的任务是解释 C 代码(在 x86 上运行)的一些看似奇怪的行为。 I can easily complete everything else but this one has really confused me.我可以轻松完成其他所有事情,但这个让我很困惑。

Code snippet 1 outputs -2147483648代码片段 1 输出-2147483648

 int a = 0x80000000; int b = a / -1; printf("%d\\n", b);

Code snippet 2 outputs nothing, and gives a Floating point exception代码片段 2 不输出任何内容,并给出Floating point exception

 int a = 0x80000000; int b = -1; int c = a / b; printf("%d\\n", c);

I well know the reason for the result of Code Snippet 1 ( 1 + ~INT_MIN == INT_MIN ), but I can't quite understand how can integer division by -1 generate FPE, nor can I reproduce it on my Android phone (AArch64, GCC 7.2.0).我很清楚代码片段 1 ( 1 + ~INT_MIN == INT_MIN ) 结果的原因,但我不太明白整数除以 -1 如何生成 FPE,也不能在我的 Android 手机上重现它 (AArch64 , 海湾合作委员会 7.2.0)。 Code 2 just output the same as Code 1 without any exceptions.代码 2 的输出与代码 1 相同,没有任何异常。 Is it a hidden bug feature of x86 processor?它是 x86 处理器的隐藏错误功能吗?

The assignment didn't tell anything else (including CPU architecture), but since the whole course is based on a desktop Linux distro, you can safely assume it's a modern x86.作业没有说明任何其他内容(包括 CPU 架构),但由于整个课程基于桌面 Linux 发行版,您可以放心地假设它是现代 x86。


Edit : I contacted my friend and he tested the code on Ubuntu 16.04 (Intel Kaby Lake, GCC 6.3.0).编辑:我联系了我的朋友,他在 Ubuntu 16.04(Intel Kaby Lake,GCC 6.3.0)上测试了代码。 The result was consistent with whatever the assignment stated (Code 1 output the said thing and Code 2 crashed with FPE).结果与任何指定的分配一致(代码 1 输出所说的东西,代码 2 与 FPE 崩溃)。

There are four things going on here:这里发生了四件事:

  • gcc -O0 behaviour explains the difference between your two versions: idiv vs. neg . gcc -O0行为解释了您的两个版本之间的区别: idivneg (While clang -O0 happens to compile them both with idiv ). (虽然clang -O0恰好用idiv编译它们)。 And why you get this even with compile-time-constant operands.以及为什么即使使用编译时常量操作数也会得到这个。

  • x86 idiv faulting behaviour vs. behaviour of the division instruction on ARM x86 idiv故障行为与 ARM 上除法指令的行为

  • If integer math results in a signal being delivered, POSIX require it to be SIGFPE: On which platforms does integer divide by zero trigger a floating point exception?如果整数数学导致信号被传递,POSIX 要求它是 SIGFPE: 在哪些平台上整数除以零会触发浮点异常? But POSIX doesn't require trapping for any particular integer operation.但是,POSIX并不需要捕捉任何特定整数操作。 (This is why it's allowed for x86 and ARM to be different). (这就是为什么允许 x86 和 ARM 不同的原因)。

    The Single Unix Specification defines SIGFPE as "Erroneous arithmetic operation".单一 Unix 规范将 SIGFPE 定义为“错误的算术运算”。 It's confusingly named after floating point, but in a normal system with the FPU in its default state, only integer math will raise it.它以浮点命名令人困惑,但在 FPU 处于默认状态的正常系统中,只有整数数学会提高它。 On x86, only integer division.在 x86 上,只有整数除法。 On MIPS, a compiler could use add instead of addu for signed math, so you could get traps on signed add overflow.在 MIPS 上,编译器可以使用add而不是addu来进行有符号数学运算,因此您可能会在有符号加法溢出时遇到陷阱。 ( gcc uses addu even for signed , but an undefined-behaviour detector might use add .) gcc 甚至对 signed 使用addu ,但未定义行为检测器可能会使用add 。)

  • C Undefined Behaviour rules (signed overflow, and division specifically) which let gcc emit code which can trap in that case. C 未定义的行为规则(有符号溢出,特别是除法),它让 gcc 发出可以在这种情况下捕获的代码。


gcc with no options is the same as gcc -O0 .没有选项的 gcc 与gcc -O0相同。

-O0 Reduce compilation time and make debugging produce the expected results . -O0减少编译时间并使调试产生预期的结果 This is the default.这是默认设置。

This explains the difference between your two versions:这解释了您的两个版本之间的区别:

Not only does gcc -O0 not try to optimize, it actively de-optimizes to make asm that independently implements each C statement within a function. gcc -O0不仅不尝试优化,还主动去优化,使 asm 独立实现函数内的每个 C 语句。 This allows gdb 's jump command to work safely, letting you jump to a different line within the function and act like you're really jumping around in the C source.这允许gdbjump命令安全地工作,让您跳转到函数内的不同行,并表现得就像您真的在 C 源代码中跳转一样。 Why does clang produce inefficient asm with -O0 (for this simple floating point sum)? 为什么 clang 使用 -O0 产生低效的 asm(对于这个简单的浮点和)? explains more about how and why -O0 compiles the way it does.解释了更多关于-O0如何以及为什么编译它的方式。

It also can't assume anything about variable values between statements, because you can change variables with set b = 4 .它也不能对语句之间的变量值做任何假设,因为您可以使用set b = 4更改变量。 This is obviously catastrophically bad for performance, which is why -O0 code runs several times slower than normal code, and why optimizing for -O0 specifically is total nonsense .这显然对性能来说是灾难性的,这就是为什么-O0代码运行速度比普通代码慢几倍,以及为什么专门针对-O0优化完全是胡说八道 It also makes -O0 asm output really noisy and hard for a human to read , because of all the storing/reloading, and lack of even the most obvious optimizations.它还使-O0 asm 输出非常嘈杂且难以让人阅读,因为所有的存储/重新加载,甚至缺乏最明显的优化。

int a = 0x80000000;
int b = -1;
  // debugger can stop here on a breakpoint and modify b.
int c = a / b;        // a and b have to be treated as runtime variables, not constants.
printf("%d\n", c);

I put your code inside functions on the Godbolt compiler explorer to get the asm for those statements.我将您的代码放在Godbolt 编译器资源管理上的函数中以获取这些语句的 asm。

To evaluate a/b , gcc -O0 has to emit code to reload a and b from memory, and not make any assumptions about their value.要评估a/bgcc -O0必须发出代码以从内存中重新加载ab ,并且不对它们的值做出任何假设。

But with int c = a / -1;但是int c = a / -1; , you can't change the -1 with a debugger , so gcc can and does implement that statement the same way it would implement int c = -a; ,您不能使用调试器更改-1 ,因此 gcc 可以并且确实以与实现int c = -a;相同的方式实现该语句int c = -a; , with an x86 neg eax or AArch64 neg w0, w0 instruction, surrounded by a load(a)/store(c). , 带有 x86 neg eax或 AArch64 neg w0, w0指令,被 load(a)/store(c) 包围。 On ARM32, it's a rsb r3, r3, #0 (reverse-subtract: r3 = 0 - r3 ).在 ARM32 上,它是一个rsb r3, r3, #0 (反向减法: r3 = 0 - r3 )。

However, clang5.0 -O0 doesn't do that optimization.但是, clang5.0 -O0不会进行该优化。 It still uses idiv for a / -1 , so both versions will fault on x86 with clang.它仍然将idiv用于a / -1 ,因此两个版本都会在 x86 上使用 clang 出错。 Why does gcc "optimize" at all?为什么 gcc 会“优化”? See Disable all optimization options in GCC .请参阅禁用 GCC 中的所有优化选项 gcc always transforms through an internal representation, and -O0 is just the minimum amount of work needed to produce a binary. gcc 总是通过内部表示进行转换,而 -O0 只是生成二进制文件所需的最少工作量。 It doesn't have a "dumb and literal" mode that tries to make the asm as much like the source as possible.它没有试图使 asm 尽可能像源代码的“愚蠢和文字”模式。


x86 idiv vs. AArch64 sdiv : 86 idiv与AArch64 sdiv

x86-64: x86-64:

    # int c = a / b  from x86_fault()
    mov     eax, DWORD PTR [rbp-4]
    cdq                                 # dividend sign-extended into edx:eax
    idiv    DWORD PTR [rbp-8]           # divisor from memory
    mov     DWORD PTR [rbp-12], eax     # store quotient

Unlike imul r32,r32 , there's no 2-operand idiv that doesn't have a dividend upper-half input.imul r32,r32不同,没有没有被除数上半部分输入的 2 操作数idiv Anyway, not that it matters;无论如何,这并不重要; gcc is only using it with edx = copies of the sign bit in eax , so it's really doing a 32b / 32b => 32b quotient + remainder. gcc 仅将它与edx = eax符号位的副本一起使用,因此它实际上是在执行 32b / 32b => 32b 商 + 余数。 As documented in Intel's manual , idiv raises #DE on:如英特尔手册中所述,idiv 在以下idiv引发 #DE:

  • divisor = 0除数 = 0
  • The signed result (quotient) is too large for the destination.签名结果(商)对于目标来说太大了。

Overflow can easily happen if you use the full range of divisors, eg for int result = long long / int with a single 64b / 32b => 32b division.如果您使用全范围的除数,则很容易发生溢出,例如,对于int result = long long / int ,只有一个 64b / 32b => 32b 除法。 But gcc can't do that optimization because it's not allowed to make code that would fault instead of following the C integer promotion rules and doing a 64-bit division and then truncating to int .但是 gcc 无法进行这种优化,因为不允许编写会出错的代码,而不是遵循 C 整数提升规则并进行 64 位除法然后截断为int It also doesn't optimize even in cases where the divisor is known to be large enough that it couldn't #DE 即使在已知除数足够大以至于不能#DE情况下,它也不会优化

When doing 32b / 32b division (with cdq ), the only input that can overflow is INT_MIN / -1 .在进行 32b / 32b 除法(使用cdq )时,唯一可能溢出的输入是INT_MIN / -1 The "correct" quotient is a 33-bit signed integer, ie positive 0x80000000 with a leading-zero sign bit to make it a positive 2's complement signed integer. “正确”商是一个 33 位有符号整数,即正0x80000000带有前导零符号位,使其成为正 2 的补符号整数。 Since this doesn't fit in eax , idiv raises a #DE exception.由于这不适合eax ,因此idiv会引发#DE异常。 The kernel then delivers SIGFPE .然后内核提供SIGFPE

AArch64: AArch64:

    # int c = a / b  from x86_fault()  (which doesn't fault on AArch64)
    ldr     w1, [sp, 12]
    ldr     w0, [sp, 8]          # 32-bit loads into 32-bit registers
    sdiv    w0, w1, w0           # 32 / 32 => 32 bit signed division
    str     w0, [sp, 4]

ARM hardware division instructions don't raise exceptions for divide by zero or for INT_MIN/-1 overflow. ARM 硬件除法指令不会引发除以零或INT_MIN/-1溢出的异常。 Nate Eldredge commented:内特·埃尔德雷奇评论道:

The full ARM architecture reference manual states that UDIV or SDIV, when dividing by zero, simply return zero as the result, "without any indication that the division by zero occurred" (C3.4.8 in the Armv8-A version).完整的 ARM 架构参考手册指出,UDIV 或 SDIV 在被零除时,只会返回零作为结果,“没有任何迹象表明发生了被零除”(Armv8-A 版本中的 C3.4.8)。 No exceptions and no flags - if you want to catch divide by zero, you have to write an explicit test.没有例外也没有标志——如果你想捕捉除以零,你必须编写一个显式测试。 Likewise, signed divide of INT_MIN by -1 returns INT_MIN with no indication of the overflow.同样, INT_MIN除以-1符号除法返回INT_MIN而不指示溢出。

AArch64 sdiv documentation doesn't mention any exceptions. AArch64 sdiv文档没有提到任何例外。

However, software implementations of integer division may raise: http://infocenter.arm.com/help/index.jsp?topic=/com.arm.doc.faqs/ka4061.html .但是,整数除法的软件实现可能会引发: http : //infocenter.arm.com/help/index.jsp?topic=/com.arm.doc.faqs/ka4061.html (gcc uses a library call for division on ARM32 by default, unless you set a -mcpu that has HW division.) (默认情况下,gcc 在 ARM32 上使用库调用进行除法,除非您设置了具有硬件除法的 -mcpu。)


C Undefined Behaviour. C 未定义的行为。

As PSkocik explains , INT_MIN / -1 is undefined behaviour in C, like all signed integer overflow.正如PSkocik 解释的那样INT_MIN / -1是 C 中未定义的行为,就像所有有符号整数溢出一样。 This allows compilers to use hardware division instructions on machines like x86 without checking for that special case.这允许编译器在 x86 等机器上使用硬件除法指令,而无需检查这种特殊情况。 If it had to not fault, unknown inputs would require run-time compare-and branch checks, and nobody wants C to require that.如果它没有故障,未知输入将需要运行时比较和分支检查,而没有人希望 C 要求这样做。


More about the consequences of UB:更多关于UB的后果:

With optimization enabled , the compiler can assume that a and b still have their set values when a/b runs.启用优化,编译器可以假定ab仍然有其设定值时, a/b运行。 It can then see the program has undefined behaviour, and thus can do whatever it wants.然后它可以看到程序有未定义的行为,因此可以做任何它想做的事情。 gcc chooses to produce INT_MIN like it would from -INT_MIN . GCC选择生产INT_MIN像它会从-INT_MIN

On a 2's complement system, the most-negative number is its own negative.在 2 的补码系统中,最负数是它自己的负数。 This is a nasty corner-case for 2's complement, because it means abs(x) can still be negative.对于 2 的补码来说,这是一个令人讨厌的极端情况,因为这意味着abs(x)仍然可以是负数。 https://en.wikipedia.org/wiki/Two%27s_complement#Most_negative_number https://en.wikipedia.org/wiki/Two%27s_complement#Most_negative_number

int x86_fault() {
    int a = 0x80000000;
    int b = -1;
    int c = a / b;
    return c;
}

compile to this with gcc6.3 -O3 for x86-64gcc6.3 -O3 for x86-64 编译到这个

x86_fault:
    mov     eax, -2147483648
    ret

but clang5.0 -O3 compiles to (with no warning even with -Wall -Wextra`):但是clang5.0 -O3编译为(即使使用 -Wall -Wextra` 也没有警告):

x86_fault:
    ret

Undefined Behaviour really is totally undefined.未定义行为真的是完全未定义的。 Compilers can do whatever they feel like, including returning whatever garbage was in eax on function entry, or loading a NULL pointer and an illegal instruction.编译器可以做任何他们想做的事情,包括在函数入口返回eax任何垃圾,或者加载 NULL 指针和非法指令。 eg with gcc6.3 -O3 for x86-64:例如,对于 x86-64,使用 gcc6.3 -O3:

int *local_address(int a) {
    return &a;
}

local_address:
    xor     eax, eax     # return 0
    ret

void foo() {
    int *p = local_address(4);
    *p = 2;
}

 foo:
   mov     DWORD PTR ds:0, 0     # store immediate 0 into absolute address 0
   ud2                           # illegal instruction

Your case with -O0 didn't let the compilers see the UB at compile time, so you got the "expected" asm output.您使用-O0情况没有让编译器在编译时看到 UB,因此您获得了“预期的”asm 输出。

See also What Every C Programmer Should Know About Undefined Behavior (the same LLVM blog post that Basile linked).另请参阅What Every C Programmer should Know About Undefined Behavior (Basile 链接的同一篇 LLVM 博客文章)。

Signed int division in two's complement is undefined if:在以下情况下,二进制补码中的有符号int除法未定义:

  1. the divisor is zero, OR除数为零,或
  2. the dividend is INT_MIN (== 0x80000000 if int is int32_t ) and the divisor is -1 (in two's complement, -INT_MIN > INT_MAX , which causes integer overflow, which is undefined behavior in C)被除数是INT_MIN (== 0x80000000如果intint32_t ),除数是-1 (在二进制补码中, -INT_MIN > INT_MAX ,这会导致整数溢出,这是 C 中未定义的行为)

( https://www.securecoding.cert.org recommends wrapping integer operations in functions that check for such edge cases) https://www.securecoding.cert.org建议将整数运算包装在检查此类边缘情况的函数中)

Since you're invoking undefined behavior by breaking rule 2, anything can happen, and as it happens, this particular anything on your platform happens to be an FPE signal being generated by your processor.由于您通过违反规则 2 来调用未定义的行为,因此任何事情都可能发生,并且当它发生时,您平台上的特定任何事情恰好是您的处理器生成的 FPE 信号。

With undefined behavior very bad things could happen, and sometimes they do happen.对于未定义的行为,可能会发生非常糟糕的事情,有时它们确实会发生。

Your question has no sense in C (read Lattner on UB ).你的问题在 C 中没有意义(阅读Lattner on UB )。 But you could get the assembler code (eg produced by gcc -O -fverbose-asm -S ) and care about machine code behavior.但是您可以获得汇编代码(例如由gcc -O -fverbose-asm -S )并关心机器代码行为。

On x86-64 with Linux integer overflow (and also integer division by zero, IIRC) gives a SIGFPE signal.在带有 Linux 整数溢出(以及整数除以零,IIRC)的 x86-64 上,会给出SIGFPE信号。 See signal(7)信号(7)

BTW, on PowerPC integer division by zero is rumored to give -1 at the machine level (but some C compilers generate extra code to test that case).顺便说一句,传闻在 PowerPC 整数除以零在机器级别给出 -1(但一些 C 编译器会生成额外的代码来测试这种情况)。

The code in your question is undefined behavior in C. The generated assembler code has some defined behavior (depends upon the ISA and processor).您问题中的代码是 C 中的未定义行为。生成的汇编代码具有一些已定义的行为(取决于ISA和处理器)。

(the assignment is done to make you read more about UB, notably Lattner 's blog , which you should absolutely read) (完成作业是为了让您阅读更多关于 UB 的信息,尤其是Lattner 的博客,您绝对应该阅读)

On x86 if you divide by actually using the idiv operation (which is not really necessary for constant arguments, not even for variables-known-to-be-constant, but it happened anyway), INT_MIN / -1 is one of the cases that results in #DE (divide error).在 x86 上,如果您通过实际使用idiv操作进行除法(这对于常量参数并不是真正必需的,甚至对于已知为常量的变量也不是必需的,但它无论如何都会发生), INT_MIN / -1是以下情况之一导致#DE(除法错误)。 It's really a special case of the quotient being out of range, in general that is possible because idiv divides an extra-wide dividend by the divisor, so many combinations cause overflow - but INT_MIN / -1 is the only case that isn't a div-by-0 that you can normally access from higher level languages since they typically do not expose the extra-wide-dividend capabilities.这确实是商超出范围的特殊情况,通常这是可能的,因为idiv将超宽被除数除以除数,如此多的组合会导致溢出 - 但INT_MIN / -1是唯一不是您通常可以从更高级别的语言访问的 div-by-0,因为它们通常不公开超宽红利功能。

Linux annoyingly maps the #DE to SIGFPE, which has probably confused everyone who dealt with it the first time. Linux 令人讨厌地将 #DE 映射到 SIGFPE,这可能让第一次处理它的每个人都感到困惑。

Both cases are weird, as the first consists in dividing -2147483648 by -1 and should give 2147483648 , and not the result you are receiving.这两种情况都很奇怪,因为第一种情况是将-2147483648除以-1并且应该给出2147483648 ,而不是您收到的结果。 The division by -1 (as the multiplication) should change the sign of the dividend to become a positive number.除以-1 (作为乘法)应该将被除数的符号更改为正数。 But there's no such positive number in int (this is what raises UB)但是int没有这样的正数(这就是引起 UB 的原因)

0x80000000 is not a valid int number in a 32 bit architecture (as stated in the standard) that represents numbers in two's complement. 0x80000000在 32 位体系结构(如标准中所述)中不是有效的int数字,它表示二进制补码中的数字。 If you calculate its negative value, you'll get again to it, as it has no opposite number around zero.如果你计算它的负值,你会再次得到它,因为它在零附近没有相反的数字。 When you do arithmetic with signed integers, it works well for integer addition and substraction (always with care, as you are quite easy to overflow, when you add the largest value to some int) but you cannot safely use it to multiply or divide.当您使用有符号整数进行算术运算时,它适用于整数加法和减法(始终要小心,因为当您将最大值添加到某个 int 时很容易溢出),但您不能安全地使用它进行乘法或除法。 So in this case, you are invoking Undefined Behaviour .因此,在这种情况下,您正在调用Undefined Behavior You always invoke undefined behaviour (or implementation defined behaviour, which is similar, but not the same) on overflow with signed integers, as implementations vary widely in implementing that.您总是在带符号整数溢出时调用未定义的行为(或实现定义的行为,它们相似但不相同),因为实现方式在实现时差异很大。

I'll try to explain what can be happening (with no trustness), as the compiler is free to do anything, or nothing at all.我将尝试解释可能发生的事情(没有信任),因为编译器可以自由地做任何事情,或者什么都不做。

Concretely, 0x80000000 as represented in two's complement is具体来说,用二进制补码表示的0x80000000

1000_0000_0000_0000_0000_0000_0000

if we complement this number, we get (first complement all bits, then add one)如果我们补充这个数字,我们得到(首先补充所有位,然后加一个)

0111_1111_1111_1111_1111_1111_1111 + 1 =>
1000_0000_0000_0000_0000_0000_0000  !!!  the same original number.

suprisingly the same number.... You had an overflow (there's no counterpart positive value to this number, as we overflown when changing sign) then you take out the sign bit, masking with令人惊讶的是相同的数字......你有一个溢出(这个数字没有对应的正值,因为我们在改变符号时溢出)然后你取出符号位,用

1000_0000_0000_0000_0000_0000_0000 &
0111_1111_1111_1111_1111_1111_1111 =>
0000_0000_0000_0000_0000_0000_0000

which is the number you use as dividend.这是您用作股息的数字。

But as I said before, this is what can be happening on your system, but not sure, as the standard says this is Undefined behaviour and, as so, you can get whatever different behaviour from your computer/compiler.但正如我之前所说,这就是您的系统上可能发生的情况,但不确定,因为标准说这是未定义的行为,因此,您可以从计算机/编译器获得任何不同的行为。

The different results you are obtaining are probably the result of the first operation being done by the compiler, while the second one is done by the program itself.您获得的不同结果可能是第一个操作由编译器完成的结果,而第二个操作由程序本身完成。 In the first case you are assigning 0x8000_0000 to the variable, while in the second you are calculating the value in the program .在第一种情况下,您将0x8000_0000分配给变量,而在第二种情况下,您正在计算程序中的值 Both cases are undefined behaviour and you are seeing it happening in front your eyes.这两种情况都是未定义的行为,您会看到它发生在您的眼前。

#NOTE 1 #注意 1

As the compiler is concerned, and the standard doesn't say anything about the valid ranges of int that must be implemented (the standard doesn't include normally 0x8000...000 in two's complement architectures) the correct behaviour of 0x800...000 in two's complement architectures should be, as it has the largest absolute value for an integer of that type, to give a result of 0 when dividing a number by it.就编译器而言,标准没有说明必须实现的int的有效范围(标准通常不包括二进制补码架构中的0x8000...0000x800...000二进制补码体系结构中的0x800...000应该是,因为它具有该类型整数的最大绝对值,因此在除以一个数字时给出的结果为0 But hardware implementations normally don't allow to divide by such a number (as many of them doesn't even implement signed integer division, but simulate it from unsigned division, so many simply extract the signs and do an unsigned division) That requires a check before division, and as the standard says Undefined behaviour , implementations are allowed to freely avoid such a check, and disallow dividing by that number.但是硬件实现通常不允许除以这样的数字(因为它们中的许多甚至没有实现有符号整数除法,而是从无符号除法中模拟它,所以许多只是提取符号并进行无符号除法)这需要一个在除法之前检查,正如标准所说的Undefined behavior ,允许实现自由地避免这种检查,并且不允许除以该数字。 They simply select the integer range to go from 0x8000...001 to 0xffff...fff , and then from 0x000..0000 to 0x7fff...ffff , disallowing the value 0x8000...0000 as invalid.他们只是选择整数范围从0x8000...0010xffff...fff ,然后从0x000..00000x7fff...ffff ,不允许值0x8000...0000无效。

声明:本站的技术帖子网页,遵循CC BY-SA 4.0协议,如果您需要转载,请注明本站网址或者原文地址。任何问题请咨询:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM