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

我的任务是表达一些看似奇怪的C代码行为(在x86上运行)。 我可以很容易地完成其他所有事情,但是这个让我很困惑。

代码段1输出-2147483648

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

代码片段2不输出任何内容,并提供Floating point exception

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

我很清楚代码片段1( 1 + ~INT_MIN == INT_MIN )的结果的原因,但我不太明白整数除法-1如何生成FPE,也不能在我的Android手机上重现它(AArch64 ,海湾合作委员会7.2.0)。 代码2只输出与代码1相同,没有任何例外。 它是x86处理器的隐藏bugfunction吗?

该任务没有告诉任何其他内容(包括CPU架构),但由于整个课程基于桌面Linux发行版,您可以放心地假设它是一个现代的x86。


编辑 :我联系了我的朋友,他在Ubuntu 16.04(Intel Kaby Lake,GCC 6.3.0)上测试了代码。 结果与所指定的任何内容一致(代码1输出所述内容,代码2与FPE崩溃)。

这里有四件事:

  • gcc -O0行为解释了两个版本之间的差异。 (虽然clang -O0碰巧用idiv编译它们)。 为什么即使使用编译时常量操作数也能得到它。
  • x86 idiv错误行为与ARM上除法指令的行为
  • 如果整数数学导致信号被传递,POSIX要求它是SIGFPE: 在哪个平台上整数除以零会触发浮点exception? 但POSIX不需要捕获任何特定的整数操作。 (这就是允许x86和ARM不同的原因)。

    单Unix规范将SIGFPE定义为“错误的算术运算”。 它在浮点后以混乱的方式命名,但在FPU处于默认状态的普通系统中,只有整数数学会引发它。 在x86上,只有整数除法。 在MIPS上,编译器可以使用add而不是addu来进行带符号的数学运算,因此您可以获得有符号添加溢出的陷阱。 ( 即使对于签名 , gcc也使用addu ,但是未定义的行为检测器可能会使用add 。)

  • C未定义的行为规则(有符号溢出,特别是除法),它允许gcc发出可以在这种情况下捕获的代码。

没有选项的gcc与gcc -O0相同。

-O0减少编译时间并使调试产生预期结果 。 这是默认值。

这解释了两个版本之间的区别:

gcc -O0不仅不会尝试进行优化,而是主动去优化以使asm独立实现函数中的每个C语句。 这允许gdbjump命令安全地工作,让你跳转到函数内的另一行,并且就像你真正在C源中跳跃一样。

它也不能假设语句之间的变量值,因为您可以使用set b = 4更改变量。 这对于性能来说显然是灾难性的,这就是-O0代码运行速度比普通代码慢几倍的原因,以及为什么特别优化-O0是完全无意义的原因 。 它还使得-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); 

我把你的代码放在Godbolt 编译器资源管理器的函数中以获取这些语句的asm。

要评估a/bgcc -O0必须发出代码以从内存重新加载ab ,而不是对它们的值做任何假设。

但是使用int c = a / -1; ,你不能用调试器改变-1 ,所以gcc可以并且确实以与实现int c = -a;相同的方式实现该语句int c = -a; ,带有x86 neg eax或AArch64 neg w0, w0指令,由load(a)/ store(c)包围。 在ARM32上,它是一个rsb r3, r3, #0 (反向减法: r3 = 0 - r3 )。

但是,clang5.0 -O0不会进行优化。 它仍然使用idiv作为a / -1 ,因此两个版本都会在使用clang的x86上出错。 为什么gcc会“优化”? 请参阅在GCC中禁用所有优化选项 。 gcc总是通过内部表示进行转换,而-O0只是生成二进制文件所需的最小工作量。 它没有“愚蠢和文字”模式,试图尽可能地使asm像源一样。


x86 idiv vs. AArch64 idiv

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 

imul r32,r32不同imul r32,r32没有2操作数idiv没有被分红的上半部分输入。 无论如何,这并不重要; gcc只使用edx = eax符号位的副本,所以它实际上是32b / 32b => 32b商+余数。 正如英特尔手册中所述 , idiv引发了#DE:

  • 除数= 0
  • 签名结果(商)对于目的地来说太大了。

如果使用全范围的除数,则很容易发生溢出,例如对于int result = long long / int ,单个64b / 32b => 32b除法。 但gcc不能进行优化,因为它不允许生成错误的代码而不是遵循C整数提升规则并进行64位除法然后截断为int 。 即使在已知除数足够大以至于不能#DE情况下,它也不会优化

当进行32b / 32b除法(使用cdq )时,唯一可以溢出的输入是INT_MIN / -1 。 “正确”商是一个33位有符号整数,即带有前导零符号位的正0x80000000 ,使其成为正2的补码有符号整数。 由于这不适合eaxidiv会引发#DEexception。 然后内核提供SIGFPE

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] 

AFAICT,ARM硬件除法指令不会引发除以零或INT_MIN / -1的exception。 或者至少, 一些 ARM CPU没有。 在ARM OMAP3515处理器中除以零exception

AArch64 sdiv文档没有提到任何例外。

但是,整数除法的软件实现可能会提高: http : //infocenter.arm.com/help/index.jsp?topic = / com.arm.doc.faqs / ka4061.html 。 (默认情况下,gcc在ARM32上使用库调用进行除法,除非您设置了具有HW除法的-mcpu。)


C未定义的行为。

正如PSkocik所解释的那样 , INT_MIN / -1在C中是未定义的行为,就像所有有符号整数溢出一样。 这允许编译器在x86等机器上使用硬件除法指令,而无需检查特殊情况。 如果它不是故障,未知输入将需要运行时比较和分支检查,没有人希望C要求它。


更多关于UB的后果:

启用优化后 ,编译器可以假设a/b运行时a/bb仍然具有其设置值。 然后它可以看到程序具有未定义的行为,因此可以做任何想做的事情。 gcc选择像从-INT_MIN那样生成-INT_MIN

在2的补数系统中,最负数是它自己的负数。 对于2的补码,这是一个令人讨厌的角落情况,因为这意味着abs(x)仍然可以是负数。 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; } 

使用gcc6.3 -O3编译为x86-64

 x86_fault: mov eax, -2147483648 ret 

clang5.0 -O3编译为(即使使用-Wall -Wextra`也没有警告):

 x86_fault: ret 

未定义的行为确实是完全未定义的。 编译器可以做任何他们想做的事情,包括在函数入口处返回eax垃圾,或者加载NULL指针和非法指令。 例如,对于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 

你的-O0情况并没有让编译器在编译时看到UB,所以你得到了“预期的”asm输出。

另请参阅每个C程序员应该知道的关于未定义行为的内容 (Basile链接的相同LLVM博客文章)。

如果符合以下情况,则在二进制补码中的符号int是不确定的:

  1. 除数为零,或者
  2. 被除数是INT_MIN (= = 0x80000000如果intint32_t ),除数是-1 (在二进制补码中, -INT_MIN > INT_MAX ,这会导致整数溢出,这是C中未定义的行为)

https://www.securecoding.cert.org建议在检查此类边缘情况的函数中包装整数运算)

由于您通过违反规则2来调用未定义的行为,因此任何事情都可能发生,并且在发生这种情况时,您平台上的这一特定事件恰好是由处理器生成的FPE信号。

对于未定义的行为,可能会发生非常糟糕的事情,有时它们确实会发生。

你的问题在C中没有意义(读UB上的Lattner )。 但是你可以获得汇编代码(例如由gcc -O -fverbose-asm -S )并关注机器代码行为。

在具有Linux整数溢出的x86-64上(以及整数除零,IIRC)给出SIGFPE信号。 见信号(7)

BTW,在PowerPC上整数除以零被传言在机器级别给出-1(但是一些C编译器会生成额外的代码来测试这种情况)。

您的问题中的代码是C中未定义的行为。生成的汇编代码具有一些已定义的行为(取决于ISA和处理器)。

(这项任务是为了让你阅读更多关于UB的内容,特别是Lattner的博客 ,你绝对应该阅读)

在x86上,如果你实际使用 idiv操作除了 (对于常量参数实际上并不是必需的,甚至对于变量 – 已知为常量,但无论如何都发生了), INT_MIN / -1是其中一个例子。导致#DE(除错误)。 这是一个特殊的情况,商是超出范围,一般来说这是可能的,因为idiv除以除数的超宽分红,所以许多组合导致溢出 – 但INT_MIN / -1是唯一不是a的情况div-by-0,您通常可以从更高级别的语言访问,因为它们通常不会暴露超宽分红function。

Linux烦人地将#DE映射到SIGFPE,这可能会让第一次处理它的人感到困惑。

两种情况都很奇怪,因为第一种情况是将-2147483648除以-1并且应该给出2147483648 ,而不是你收到的结果。

0x80000000不是32位体系结构中的有效int数,表示二进制补码中的数字。 如果你计算它的负值,你会再次得到它,因为它在零附近没有相反的数字。 当你使用有符号整数进行算术运算时,它适用于整数加法和减法(总是要小心,因为当你向某些int添加最大值时很容易溢出),但是你不能安全地使用它来乘以或除。 所以在这种情况下,您正在调用未定义的行为 。 您总是在使用有符号整数的溢出时调用未定义的行为(或实现定义的行为,这类似但不相同),因为实现它的实现差别很大。

我将尝试解释可能发生的事情(没有信任),因为编译器可以自由地做任何事情,或者什么都不做。

具体地说,以二进制补码表示的0x80000000

 1000_0000_0000_0000_0000_0000_0000 

如果我们补充这个数字,我们得到(首先补充所有位,然后加一个)

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

令人惊讶的是相同的数字….你有一个溢出(这个数字没有对应的正值,因为我们在改变符号时溢出)然后你取出符号位,掩盖与

 1000_0000_0000_0000_0000_0000_0000 & 0111_1111_1111_1111_1111_1111_1111 => 0000_0000_0000_0000_0000_0000_0000 

这是你用作除数的数字,导致除零除外。

但正如我之前所说,这是您的系统上可能发生的事情,但不确定,因为标准说这是未定义的行为 ,因此,您可以从您的计算机/编译器获得任何不同的行为。

注1

就编译器而言,标准没有说明必须实现的int的有效范围(标准在二进制补码架构中通常不包括0x8000...000 ), 0x800...000的正确行为0x800...000两个补码架构中的0x800...000应该是,因为它具有该类型整数的最大绝对值,在将数字除以它时给出0的结果。 但是硬件实现通常不允许除以这样的数字(因为它们中的许多甚至没有实现有符号整数除法,而是从无符号除法中模拟它,所以很多只是简单地提取符号并进行无符号除法)这需要一个在划分之前检查,并且标准显示未定义的行为 ,允许实现自由地避免这种检查,并且禁止除以该数字。 他们只需选择整数范围从0x8000...0010xffff...fff ,然后从0x000..00000x7fff...ffff ,不允许将值0x8000...0000视为无效。