11

我的任务是解释 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);

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

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


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

4

5 回答 5

19

这里发生了四件事:

  • gcc -O0行为解释了您的两个版本之间的区别:idivneg. (虽然clang -O0碰巧用 编译它们idiv)。以及为什么即使使用编译时常量操作数也会得到这个。

  • x86idiv错误行为与 ARM 上除法指令的行为

  • 如果整数数学导致信号被传递,POSIX 要求它是 SIGFPE:在哪些平台上整数除以零会触发浮点异常? 但是 POSIX不需要捕获任何特定的整数运算。(这就是为什么允许 x86 和 ARM 有所不同的原因)。

    单一 Unix 规范将 SIGFPE 定义为“错误的算术运算”。它以浮点数命名令人困惑,但在 FPU 处于默认状态的正常系统中,只有整数数学会引发它。在 x86 上,只有整数除法。在 MIPS 上,编译器可以使用add而不是addu用于有符号数学,因此您可能会在有符号添加溢出时遇到陷阱。(gccaddu甚至用于 signed,但未定义行为检测器可能会使用add。)

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


没有选项的 gcc 与gcc -O0.

-O0 减少编译时间,使调试产生预期的结果。这是默认设置。

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

不仅不gcc -O0尝试优化,它还积极反优化以使 asm 独立实现函数中的每个 C 语句。这允许gdb'sjump命令安全地工作,让您跳转到函数中的不同行,并表现得就像您真的在 C 源代码中跳转一样。 为什么clang用-O0(对于这个简单的浮点总和)产生效率低下的asm?解释更多关于如何以及为什么-O0编译它的方式。

它也不能假设语句之间的变量值,因为您可以使用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);

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

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

但是int c = a / -1;您不能-1使用调试器更改,因此 gcc 可以并且确实以相同的方式实现该语句int c = -a;,使用 x86neg eax或 AArch64neg w0, w0指令,由 load(a)/store(c) 包围。在 ARM32 上,它是一个rsb r3, r3, #0(reverse-subtract: r3 = 0 - r3)。

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


x86idiv与 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

与 不同,没有没有被除数上半部分输入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引发#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]

ARM 硬件除法指令不会引发除以零或INT_MIN/-1溢出的异常。Nate Eldredge 评论道:

完整的 ARM 体系结构参考手册指出 UDIV 或 SDIV 在除以零时仅返回零作为结果,“没有任何迹象表明发生了除零”(Armv8-A 版本中的 C3.4.8)。没有异常也没有标志——如果你想捕捉除以零,你必须写一个明确的测试。同样,有符号的除以INT_MIN返回-1没有INT_MIN溢出指示。

AArch64sdiv文档没有提到任何例外。

但是,整数除法的软件实现可能会引发: http://infocenter.arm.com/help/index.jsp?topic=/com.arm.doc.faqs/ ka4061.html。(默认情况下,gcc 在 ARM32 上使用库调用进行除法,除非您设置了具有硬件除法的 -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;
}

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 输出。

另请参阅What Every C Programmer Should Know About Undefined Behavior(Basile 链接的同一篇 LLVM 博客文章)。

于 2017-09-23T13:18:54.573 回答
6

如果满足以下条件,则二进制补码中的有符号int除法未定义:

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

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

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

于 2017-09-23T10:00:16.823 回答
2

在 x86 上,如果您通过实际使用idiv操作进行除法(这对于常量参数来说不是必需的,甚至对于已知为常量的变量也不是,但无论如何都会发生),INT_MIN / -1是导致 #DE 的情况之一(除法错误)。这确实是商超出范围的特殊情况,通常这是可能的,因为idiv将超宽除数除以除数,所以许多组合会导致溢出 - 但这INT_MIN / -1是唯一不是 div-by-0 的情况您通常可以从更高级别的语言访问,因为它们通常不公开超宽红利功能。

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

于 2017-09-23T10:14:31.387 回答
2

With undefined behavior very bad things could happen, and sometimes they do happen.

Your question has no sense in C (read Lattner on UB). But you could get the assembler code (e.g. produced by gcc -O -fverbose-asm -S) and care about machine code behavior.

On x86-64 with Linux integer overflow (and also integer division by zero, IIRC) gives a SIGFPE signal. See signal(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).

The code in your question is undefined behavior in C. The generated assembler code has some defined behavior (depends upon the ISA and processor).

(the assignment is done to make you read more about UB, notably Lattner 's blog, which you should absolutely read)

于 2017-09-23T09:50:58.593 回答
-2

这两种情况都很奇怪,因为第一种情况是除以应该给出-2147483648,而不是你收到的结果。除以(作为乘法)应该将被除数的符号变为正数。但是没有这样的正数(这就是引起UB的原因)-12147483648-1int

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

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

具体而言,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

这是您用作股息的数字。

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

您获得的不同结果可能是第一个操作由编译器完成的结果,而第二个操作是由程序本身完成的。在第一种情况下,您正在分配0x8000_0000给变量,而在第二种情况下,您正在计算程序中的值。这两种情况都是未定义的行为,您会看到它发生在您的眼前。

#注意 1

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

于 2017-09-25T09:19:17.237 回答