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

iBu*_*Bug 8 c x86 gcc x86-64 floating-point-exceptions

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

代码段1输出 -2147483648

int a = 0x80000000;
int b = a / -1;
printf("%d\n", b);
Run Code Online (Sandbox Code Playgroud)

代码片段2没有输出任何内容,并给出了一个 Floating point exception

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

我很清楚Code Snippet 1(1 + ~INT_MIN == INT_MIN)的结果的原因,但是我不太明白整数除以-1如何生成FPE,也不能在我的Android手机(AArch64,GCC 7.2.0)上重现它.代码2只输出与代码1相同,没有任何例外.它是x86处理器的隐藏bug功能吗?

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


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

Pet*_*des 12

这里有四件事:

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

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

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

gcc没有选项是一样的gcc -O0.

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

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

不仅不gcc -O0尝试优化,它还主动去优化以使asm独立地实现函数内的每个C语句.这样gdb的jump命令安全工作,让你跳转到函数中不同的线路和行为像你真的在C源跳来跳去.

它也不能假设语句之间的变量值,因为你可以用变量来改变变量set b = 4.这对性能来说显然是灾难性的,这就是为什么-O0代码比普通代码运行慢几倍,为什么特别优化-O0是完全无意义的原因.由于所有存储/重新加载,甚至缺乏最明显的优化,它也使得-O0asm输出真的很嘈杂,人类难以阅读.

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);
Run Code Online (Sandbox Code Playgroud)

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

要评估a/b,gcc -O0必须发出代码来重新加载a和b从内存中,而不是对它们的值做任何假设.

但有int c = a / -1;,你不能改变-1一个调试器,以使gcc可以和确实实现这一声明将实施同样的方式int c = -a;,用在x86 neg eax或AArch64 neg w0, w0指令,通过负载(A)/存储(C)包围.在ARM32上,它是一个rsb r3, r3, #0(反向减法:) r3 = 0 - r3.

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


x86 idiv与AArch64 sdiv:

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
Run Code Online (Sandbox Code Playgroud)

不同的是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除法(with cdq)时,唯一可以溢出的输入是INT_MIN / -1."正确"商是一个33位有符号整数,即0x80000000带有前导零符号位的正数,使其成为正2的补码有符号整数.由于这不适合eax,idiv引发#DE异常.然后内核提供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]
Run Code Online (Sandbox Code Playgroud)

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

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/b运行.然后它可以看到程序具有未定义的行为,因此可以做任何想做的事情.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;
}
Run Code Online (Sandbox Code Playgroud)

使用gcc6.3 -O3for x86-64 编译到此

x86_fault:
    mov     eax, -2147483648
    ret
Run Code Online (Sandbox Code Playgroud)

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

x86_fault:
    ret
Run Code Online (Sandbox Code Playgroud)

未定义的行为确实是完全未定义的.编译器可以做任何他们想做的事情,包括返回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
Run Code Online (Sandbox Code Playgroud)

您的情况-O0并没有让编译器在编译时看到UB,因此您得到了"预期的"asm输出.

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

  • 为什么这会被贬低?我做得太长了吗?我不想假设很多初学知识,但这使得它成为一个很长的答案:/ (2认同)
  • 我检查了汇编输出,当我看到 `a/-1;` 被编译成 `negl %eax` 而 `a/b` 被编译成 `cltd; 时,我立即知道发生了什么。idivl %ebx`。感谢您的详细解释和耐心! (2认同)
  • 完整的 ARM 架构参考手册指出,UDIV 或 SDIV 在除以零时,只是返回零作为结果,“没有任何迹象表明发生了除零”(Armv8-A 版本中的 C3.4.8)。没有例外,也没有标志 - 如果你想捕获被零除的情况,你必须编写一个显式的测试。同样,“INT_MIN”除以“-1”的有符号除法会返回“INT_MIN”,但不会指示溢出。 (2认同)

PSk*_*cik 6

int如果符合以下条件,则在两个补码中的签名除法是不确定的

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

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

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


归档时间:

查看次数:

1188 次

最近记录:

8 年,11 月 前