为什么/如何 gcc 在这个有符号溢出测试中编译未定义的行为,以便它适用于 x86 而不是 ARM64? [英] Why/how does gcc compile the undefined behaviour in this signed-overflow test so it works on x86 but not ARM64?

查看:25
本文介绍了为什么/如何 gcc 在这个有符号溢出测试中编译未定义的行为,以便它适用于 x86 而不是 ARM64?的处理方法,对大家解决问题具有一定的参考价值,需要的朋友们下面随着小编来一起学习吧!

问题描述

我正在自学 CSAPP,在运行断言测试时遇到一个奇怪的问题,得到了一个奇怪的结果.

我不知道从什么开始这个问题,所以让我先获取代码(文件名在评论中可见):

//文件:2.30.c//作者:iBugint tadd_ok(int x, int y) {如果 ((x ^ y) > > 31)返回 1;//一个正数和一个负整数相加总是没有问题的如果 (x <0)返回 (x + y)  0)返回 (x + y) >y;//x == 0返回 1;}

//文件:2.30-test.c//作者:iBug#include int tadd_ok(int x, int y);int main() {断言(大小(整数)== 4);断言(tadd_ok(0x7FFFFFFF,0x80000000)== 1);断言(tadd_ok(0x7FFFFFFF,0x7FFFFFFF)== 0);断言(tadd_ok(0x80000000,0x80000000)== 0);返回0;}

和命令:

gcc -o test -O0 -g3 -Wall -std=c11 2.30.c 2.30-test.c./测试

(旁注:命令行中没有任何 -O 选项,但由于它默认为级别 0,因此不应显式添加 -O0变化很大.)

以上两个命令在我的 Ubuntu VM(amd64、GCC 7.3.0)上运行良好,但其中一个断言在我的 Android 手机(AArch64 或 armv8-a、GCC 8.2.0)上失败

强>.

2.30-test.c:13: main: 断言 "tadd_ok(0x7FFFFFFFF, 0x7FFFFFFF) == 0" 失败

注意第一个断言通过了,所以 int 在平台上保证是 4 个字节.

所以我在手机上启动了 gdb 试图获得一些见解:

(gdb) l 2.30.c:11//文件:2.30.c2//作者:iBug34 int tadd_ok(int x, int y) {5 如果 ((x ^ y) > > 31)6 返回 1;//一个正数和一个负整数相加总是没有问题的7 如果 (x <0)8 返回 (x + y)  0)10 返回 (x + y) >y;(gdb) b 2.30.c:100x728 处的断点 1:文件 2.30.c,第 10 行.(gdb) r启动程序:/data/data/com.termux/files/home/CSAPP-2019/ch2/test警告:无法确定可用的硬件观察点数.警告:无法确定可用的硬件断点数.断点1,tadd_ok(x=2147483647,y=2147483647)在 2.30.c:1010 返回 (x + y) >y;(gdb) p x1 美元 = 2147483647(gdb) py2 美元 = 2147483647(gdb) p (x + y) >是3 美元 = 0(gdb) c继续.2.30-test.c:13: main: 断言 "tadd_ok(0x7FFFFFFFF, 0x7FFFFFFF) == 0" 失败程序收到信号 SIGABRT,中止.0x0000007fb7ca5928 中止 ()来自/system/lib64/libc.so(gdb) d 1(gdb) p tadd_ok(0x7FFFFFFF, 0x7FFFFFFF)4 美元 = 1(gdb)

正如你在 GDB 输出中看到的,结果非常不一致,因为到达了 2.30.c:10 上的 return 语句,返回值应该有已为 0,但该函数仍返回 1,导致断言失败.

请提供一个想法我在这里做错了什么.

<小时>

请尊重我的介绍.只是说它是 UB 而不涉及平台,尤其是 GDB 输出,不会有任何帮助.

解决方案

有符号溢出是 ISO C 中的未定义行为.您无法可靠地导致它并然后检查它是否发生.

在表达式 (x + y) >y;,编译器可以假设 x+y 不会溢出(因为那将是 UB).因此,它优化到检查 x >0.(是的,确实,即使在 -O0 处,gcc 也会这样做).

这个优化是 gcc8 的新功能.在 x86 和 AArch64 上是一样的;您必须在 AArch64 和 x86 上使用不同的 GCC 版本.(即使在 -O3,gcc7.x 和更早版本(故意?)错过了这个优化.clang7.0 也没有做到.他们实际上做了一个 32 位的加法和比较.他们也错过了优化 tadd_okreturn 1add 并检查溢出标志(V on ARM,OF on x86.Clang 的优化 asm 是 >>>31、OR 和一个 XOR 运算的有趣组合,但 -fwrapv 实际上改变了 asm所以它可能没有进行完整的溢出检查.)

你可以说 gcc8 破坏"了你的代码,但实际上它已经被破坏了,因为它是合法的/可移植的 ISO C.gcc8 刚刚揭示了这个事实.

<小时>

为了更清楚地看到它,让我们只将该表达式隔离到一个函数中.gcc -O0 无论如何都会单独编译每个语句,因此仅在 x<0 时运行的信息不会影响 -O0 代码-在您的 tadd_ok 函数中为这条语句生成代码.

//编译以添加和检查进位标志,或等效的int unsigned_overflow_test(无符号 x,无符号 y){返回 (x+y) >= y;//无符号溢出定义为包装.}//由于 UB 无法工作.int signed_overflow_expression(int x, int y) {返回 (x+y) >y;}

在带有 AArch64 的 Godbolt 编译器浏览器上GCC8.2 -O0 -fverbose-asm:

signed_overflow_expression:sub sp, sp, #16//,,//创建一个栈帧str w0, [sp, 12]//x, x//溢出参数str w1, [sp, 8]//y, y//序言结束//实现 return (x+y) > 的指令y;作为回报 x >0ldr w0, [sp, 12]//tmp94, xcmp w0, 0//tmp94,cset w0, gt//tmp95,//w0 = (x>0) ?1 : 0和 w0, w0, 255//_1, tmp93//冗余//结语添加 sp, sp, 16//,,退

GCC -ftree-dump-original-optimized 甚至会在完成此优化后将其 GIMPLE 转回类似 C 的代码(来自 Godbolt 链接):

<代码>;;函数 signed_overflow_expression (null);;由 -tree-original 启用{返回 x >0;}

不幸的是,即使使用 -Wall -Wextra -Wpedantic,也没有关于比较的警告.这不是微不足道正确的;它仍然取决于 x.

优化后的 asm 不出所料cmp w0, 0/cset w0, gt/ret.0xff 的 AND 是多余的.csetcsinc 的别名,使用零寄存器作为两个来源.所以它会产生0/1.对于其他寄存器,csinc的一般情况是任意2个寄存器的条件选择和递增.

无论如何,cset 相当于 AArch64 的 x86 setcc,用于将标志条件转换为寄存器中的 bool.

<小时>

如果您希望您的代码按编写的方式工作,您需要使用 -fwrapv 编译,使其在 -fwrapv 使 GCC 实现的 C 变体中具有明确定义的行为.默认是 -fstrict-overflow,就像 ISO C 标准一样.

如果您想在现代 C 语言中检查有符号溢出,您需要编写检测溢出的检查而实际上不会引起它.这更难、更烦人,而且编译器编写者和(某些)开发人员之间的争论点.他们争辩说,在为目标机器编译时,围绕未定义行为的语言规则并不意味着被用作无故破坏"代码的借口,因为它在 asm 中是有意义的.但是现代编译器大多只实现 ISO C(带有一些扩展和额外定义的行为),即使在编译目标体系结构时,如 x86 和 ARM,其中有符号整数没有填充(因此包装得很好),并且不会陷入溢出.

所以你可以说在那场战争中开枪了",gcc8.x 中的更改实际上破坏"了这样的不安全代码.:P

参见检测 C/C++ 中的签名溢出如何在没有未定义行为的情况下检查 C 中的有符号整数溢出?

<小时>

由于有符号和无符号加法在 2 的补码中是相同的二元运算,您可以也许只需将加法转换为 unsigned ,并且回退进行签名比较.这将使您的函数版本在正常"实现上是安全的:2 的补码,并且 unsignedint 之间的转换只是对相同位的重新解释.>

这不能有 UB,它只是不会给出关于补码或符号/幅度 C 实现的正确答案.

return (int)((unsigned)x + (unsigned)y) >y;

这将编译(使用 gcc8.2 -O3 for AArch64)

 add w0, w0, w1//x+ycmp w0, w1//x+y cmp ycset w0, gt退

<小时>

如果您将 int sum = x+y 作为与 return sum < 分开的 C 语句编写而成y,在禁用优化的情况下,这个 UB 对 gcc 不可见.但作为相同表达式的一部分,即使 gcc 使用默认的 -O0可以看到.

编译时可见的 UB 很糟糕.在这种情况下,只有特定范围的输入会产生 UB,因此编译器假定它不会发生.如果在执行路径上看到无条件 UB,优化编译器可以假设该路径永远不会发生.(在没有分支的函数中,它可以假设该函数从未被调用,并将其编译为一条非法指令.)参见 C++ 标准是否允许未初始化的 bool 使程序崩溃? 有关编译时可见的 UB 的更多信息.

(-O0 并不意味着没有优化",它意味着没有额外优化,除了在 asm for无论目标平台.@Basile Starynkevitch 在禁用 GCC 中的所有优化选项)

其他一些编译器可能会在禁用优化的情况下关闭大脑",并做一些更接近于将 C 音译为 asm 的事情,但是 gcc 不是 那样.例如,gcc 仍然使用乘法逆来除以 -O0 处的常数.(为什么 GCC 使用乘法在实现整数除法时被一个奇怪的数字?)所有其他 3 个主要的 x86 编译器(clang/ICC/MSVC)都使用 div.

I was self-studying CSAPP and got a strange result when I ran into a strange issue during the run of a assertion test.

I'm not sure what to start this question with, so let me get the code first (file name visible in comments):

// File: 2.30.c
// Author: iBug

int tadd_ok(int x, int y) {
    if ((x ^ y) >> 31)
        return 1;  // A positive number and a negative integer always add without problem
    if (x < 0)
        return (x + y) < y;
    if (x > 0)
        return (x + y) > y;
    // x == 0
    return 1;
}

// File: 2.30-test.c
// Author: iBug

#include <assert.h>

int tadd_ok(int x, int y);

int main() {
    assert(sizeof(int) == 4);

    assert(tadd_ok(0x7FFFFFFF, 0x80000000) == 1);
    assert(tadd_ok(0x7FFFFFFF, 0x7FFFFFFF) == 0);
    assert(tadd_ok(0x80000000, 0x80000000) == 0);
    return 0;
}

And commands:

gcc -o test -O0 -g3 -Wall -std=c11 2.30.c 2.30-test.c
./test

(Side note: There wasn't any -O option present in the command line, but as it defaults to level 0, explicitly adding -O0 shouldn't change much.)

The above two commands ran very well on my Ubuntu VM (amd64, GCC 7.3.0), but one of the assertions failed on my Android phone (AArch64 or armv8-a, GCC 8.2.0).

2.30-test.c:13: main: assertion "tadd_ok(0x7FFFFFFF, 0x7FFFFFFF) == 0" failed

Note that the first assertion passed, so int is guaranteed to be 4 bytes on the platforms.

So I fired up gdb on my phone trying to get some insights:

(gdb) l 2.30.c:1
1       // File: 2.30.c
2       // Author: iBug
3
4       int tadd_ok(int x, int y) {
5           if ((x ^ y) >> 31)
6               return 1;  // A positive number and a negative integer always add without problem
7           if (x < 0)
8               return (x + y) < y;
9           if (x > 0)
10              return (x + y) > y;
(gdb) b 2.30.c:10
Breakpoint 1 at 0x728: file 2.30.c, line 10.
(gdb) r
Starting program: /data/data/com.termux/files/home/CSAPP-2019/ch2/test
warning: Unable to determine the number of hardware watchpoints available.
warning: Unable to determine the number of hardware breakpoints available.

Breakpoint 1, tadd_ok (x=2147483647, y=2147483647)
    at 2.30.c:10
10              return (x + y) > y;
(gdb) p x
$1 = 2147483647
(gdb) p y
$2 = 2147483647
(gdb) p (x + y) > y
$3 = 0
(gdb) c
Continuing.
2.30-test.c:13: main: assertion "tadd_ok(0x7FFFFFFF, 0x7FFFFFFF) == 0" failed

Program received signal SIGABRT, Aborted.
0x0000007fb7ca5928 in abort ()
   from /system/lib64/libc.so
(gdb) d 1
(gdb) p tadd_ok(0x7FFFFFFF, 0x7FFFFFFF)
$4 = 1
(gdb)

As you see in the GDB output, the result is very inconsistent, as the return statement on 2.30.c:10 was reached, and the return value should have been 0, but the function still returns 1, making the assertion fail.

Kindly provide an idea what I'm getting wrong here.


Please respect what I have presented. Just saying it's UB without relating the platforms, especially GDB output, is not going to be any helpful.

解决方案

Signed overflow is Undefined Behaviour in ISO C. You can't reliably cause it and then check if it happened.

In the expression (x + y) > y;, the compiler is allowed to assume that x+y doesn't overflow (because that would be UB). Therefore, it optimizes down to checking x > 0. (Yes, really, gcc does this even at -O0).

This optimization is new in gcc8. It's the same on x86 and AArch64; you must have used different GCC versions on AArch64 and x86. (Even at -O3, gcc7.x and earlier (intentionally?) miss this optimization. clang7.0 doesn't do it either. They actually do a 32-bit add and compare. They also miss optimizing tadd_ok to return 1, or to add and checking the overflow flag (V on ARM, OF on x86). Clang's optimized asm is an interesting mix of >>31, OR and one XOR operation, but -fwrapv actually changes that asm so it's probably not doing a full overflow check.)

You could say that gcc8 "breaks" your code, but really it was already broken as far as being legal / portable ISO C. gcc8 just revealed that fact.


To see it more clearly, lets isolate just that expression into one function. gcc -O0 compiles each statement separately anyway, so the information that this only runs when x<0 doesn't affect the -O0 code-gen for this statement in your tadd_ok function.

// compiles to add and checking the carry flag, or equivalent
int unsigned_overflow_test(unsigned x, unsigned y) {
    return (x+y) >= y;    // unsigned overflow is well-defined as wrapping.
}

// doesn't work because of UB.
int signed_overflow_expression(int x, int y) {
    return (x+y) > y;
}

On the Godbolt compiler explorer with AArch64 GCC8.2 -O0 -fverbose-asm:

signed_overflow_expression:
    sub     sp, sp, #16       //,,      // make a stack fram
    str     w0, [sp, 12]      // x, x   // spill the args
    str     w1, [sp, 8]       // y, y
   // end of prologue

   // instructions that implement return (x+y) > y; as return  x > 0
    ldr     w0, [sp, 12]      // tmp94, x
    cmp     w0, 0     // tmp94,
    cset    w0, gt  // tmp95,                  // w0 = (x>0) ? 1 : 0
    and     w0, w0, 255       // _1, tmp93     // redundant

  // epilogue
    add     sp, sp, 16        //,,
    ret     

GCC -ftree-dump-original or -optimized will even turn its GIMPLE back into C-like code with this optimization done (from the Godbolt link):

;; Function signed_overflow_expression (null)
;; enabled by -tree-original

{
  return x > 0;
}

Unfortunately, even with -Wall -Wextra -Wpedantic, there's no warning about a the comparison. It's not trivially true; it still depends on x.

The optimized asm is unsurprisingly cmp w0, 0 / cset w0, gt / ret. The AND with 0xff is redundant. cset is an alias of csinc, using the zero-register as both sources. So it will produce 0 / 1. With other registers, the general case of csinc is a conditional select and increment of any 2 registers.

Anyway, cset is AArch64's equivalent of x86 setcc, for turning a flag condition into a bool in a register.


If you want your code to work as written, you'd need to compile with -fwrapv to make it well-defined behaviour in the variant of C that -fwrapv makes GCC implement. The default is -fstrict-overflow, like the ISO C standard.

If you want to check for signed overflow in modern C, you need to write checks that detect overflow without actually causing it. This is harder, annoying, and a point of contention between compiler writers and (some) developers. They argue that the language rules around undefined behaviour weren't meant to be used as an excuse to "gratuitously break" code when compiling for target machines where it would make sense in asm. But modern compilers mostly only implement ISO C (with some extensions and extra defined behaviour), even when compiling for target architectures like x86 and ARM where signed integers have no padding (and thus wrap just fine), and don't trap on overflow.

So you could say "shots fired" in that war, with the change in gcc8.x to actually "breaking" unsafe code like this. :P

See Detecting signed overflow in C/C++ and How to check for signed integer overflow in C without undefined behaviour?


Since signed and unsigned addition are the same binary operation in 2's complement, you could maybe just cast to unsigned for the add, and cast back for a signed compare. That would make a version of your function that's safe on "normal" implementations: 2's complement, and casting between unsigned and int is just a reinterpret of the same bits.

This can't have UB, it just won't give the right answer on one's complement or sign/magnitude C implementations.

return  (int)((unsigned)x + (unsigned)y) > y;

This compiles (with gcc8.2 -O3 for AArch64) to

    add     w0, w0, w1            // x+y
    cmp     w0, w1                // x+y  cmp  y
    cset    w0, gt
    ret


If you had written int sum = x+y as a separate C statement from return sum < y, this UB wouldn't be visible to gcc with optimization disabled. But as part of the same expression, even gcc with the default -O0 can see it.

Compile-time-visible UB is all kinds of bad. In this case, only certain ranges of inputs would produce UB, so the compiler assumes it doesn't happen. If unconditional UB is seen on a path of execution, an optimizing compiler can assume that path never happens. (In a function with no branching, it could assume the function is never called, and compile it to a single illegal instruction.) See Does the C++ standard allow for an uninitialized bool to crash a program? for more about compile-time-visible UB.

(-O0 doesn't mean "no optimization", it means no extra optimization besides what's already necessary to transform through gcc's internal representations on the way to asm for whatever target platform. @Basile Starynkevitch explains in Disable all optimization options in GCC)

Some other compilers may "turn their brains off" even more with optimization disabled, and do something closer to transliterating C into asm, but gcc is not like that. For example, gcc still uses a multiplicative inverse for integer division by a constant at -O0. (Why does GCC use multiplication by a strange number in implementing integer division?) All 3 other major x86 compilers (clang/ICC/MSVC) use div.

这篇关于为什么/如何 gcc 在这个有符号溢出测试中编译未定义的行为,以便它适用于 x86 而不是 ARM64?的文章就介绍到这了,希望我们推荐的答案对大家有所帮助,也希望大家多多支持IT屋!

查看全文
登录 关闭
扫码关注1秒登录
发送“验证码”获取 | 15天全站免登陆