这里发生了四件事:
gcc -O0
行为解释了您的两个版本之间的区别:idiv
与neg
. (虽然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
完全是胡说八道。由于所有的存储/重新加载,甚至缺乏最明显的优化,它还使-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/b
,gcc -O0
必须发出代码以重新加载a
和b
从内存中加载,并且不对它们的值做任何假设。
但是,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
并没有做这种优化。它仍然使用idiv
for 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 的补码有符号整数。由于这不适合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]
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 博客文章)。