为什么/如何在此签名溢出测试中gcc编译未定义的行为,以便它可以在x86上运行但不能在ARM64上运行?

时间:2019-02-04 04:20:13

标签: c gcc x86-64 integer-overflow arm64

我正在自学CSAPP,在断言测试过程中遇到一个奇怪的问题时得到了一个奇怪的结果。

我不确定从什么开始这个问题,所以让我先获取代码(文件名在注释中可见):

// 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;
}

和命令:

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

(附带说明:命令行中没有任何-O选项,但由于它默认为级别0,因此显式添加-O0不会有太大变化。)

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

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

请注意,第一个声明已传递,因此保证int在平台上为4个字节。

因此,我在手机上启动了gdb,试图获得一些见解:

(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)

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

请提供一个我在这里出错的想法。


请尊重我的陈述。只是说不涉及平台,特别是GDB输出的UB,将无济于事。

6 个答案:

答案 0 :(得分:7)

签名溢出是ISO C中的未定义行为。您不能可靠地引起它,然后然后检查它是否发生。

在表达式(x + y) > y;中,允许编译器假定x+y没有溢出(因为它将是UB)。因此,它会优化到检查x > 0(是的,实际上,即使在-O0,gcc也会这样做)。

此优化是gcc8中的新增功能。在x86和AArch64上相同;您必须在AArch64和x86上使用了不同的GCC版本。 (甚至在-O3,gcc7.x和更早的版本(有意?)都错过了此优化。clang7.0也没有做到这一点。他们实际上进行了32位加法和比较。他们也错过了优化{{1 }}到tadd_okreturn 1并检查溢出标志(在ARM上为add,在x86上为V)Clang的优化asm是{{1 }},OR和一个XOR操作,但是OF实际上会更改该asm,因此它可能没有进行完整的溢出检查。)

您可以说gcc8“破坏了”您的代码,但实际上它已经被合法/可移植的ISO C破坏了。gcc8刚刚揭示了这一事实。


为更清楚地看到它,让我们仅将该表达式隔离为一个函数。 >>31仍将分别编译每个语句,因此该信息仅在-fwrapv不影响您的gcc -O0函数中该语句的x<0代码生成时运行。 / p>

-O0

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

tadd_ok

GCC // 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; } 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 甚至可以通过优化(通过Godbolt链接)将其GIMPLE转换回类似C的代码:

-ftree-dump-original

不幸的是,即使使用-optimized,也没有关于比较的警告。这不是简单真实的;仍然取决于;; Function signed_overflow_expression (null) ;; enabled by -tree-original { return x > 0; }

毫无疑问,优化后的asm是-Wall -Wextra -Wpedantic / x / cmp w0, 0cset w0, gt的AND是多余的。 cset is an alias of csinc,同时使用零寄存器作为两个源。因此它将产生0 /1。对于其他寄存器,ret的一般情况是有条件地选择并递增任意2个寄存器。

无论如何,0xff与x86 csinc的AArch64等效,用于将标志条件转换为寄存器中的cset


如果您希望代码按编写的方式工作,则需要compile with -fwrapv 使其在setcc成为GCC的C变体中进行明确定义的行为实行。默认值为bool,类似于ISO C标准。

如果要在现代C语言中检查带符号的溢出,则需要编写检测而不实际引起溢出的检查。 这更困难,烦人,并且编译器作者和(某些)开发人员之间的争论点。他们争辩说,围绕未定义行为的语言规则并不是要作为在为目标机器编译在asm中有意义的目标机器时“无偿破坏”代码的借口。但是,即使对于x86和ARM之类的目标体系结构(其中带符号的整数都没有填充(因此可以很好地包装))并且不会陷入溢出的情况下,现代编译器大多仅实现ISO C(具有一些扩展和额外定义的行为)。

因此,您可以在那场战争中说“开枪”,将gcc8.x更改为实际上是“破坏”这样的不安全代码。 :P

请参见Detecting signed overflow in C/C++ How to check for signed integer overflow in C without undefined behaviour?


由于有符号和无符号加法是2的补码中的相同二进制运算,因此您可以可以仅将其强制转换为-fwrapv签名的比较。那将使您的函数版本在“常规”实现上安全:2的补码,并且在-fstrict-overflowunsigned之间进行强制转换只是对相同位的重新解释。

这不能有UB,只是不能给人的补码或符号/幅度C实现提供正确的答案。

unsigned

这会编译(对于AArch64,使用gcc8.2 -O3)

int

如果您将return (int)((unsigned)x + (unsigned)y) > y; 作为 add w0, w0, w1 // x+y cmp w0, w1 // x+y cmp y cset w0, gt ret 的单独C语句编写,则禁用优化功能的gcc将看不到该UB。表达式,即使具有默认int sum = x+y的{​​{1}}也可以看到它。

对编译时可见的UB有很多坏处。在这种情况下,只有特定范围的输入会产生UB,因此编译器假定它不会发生。如果在执行路径上看到无条件的UB,则优化的编译器可以假定该路径永远不会发生。 (在没有分支的函数中,它可能会假定该函数从未被调用过,并将其编译为一条非法指令。)有关编译时可见的UB的更多信息,请参见Does the C++ standard allow for an uninitialized bool to crash a program?

({return sum < y并不意味着“没有优化”,它意味着没有额外优化,除了在任何目标平台上通过gcc的内部表示形式转换为asm的方式所必需的之外。 @Basile Starynkevitch在解释  Disable all optimization options in GCC

在禁用优化的情况下,其他一些编译器可能会“更多地绞尽脑汁”,并做一些更接近将C音译为asm的事情,但是gcc并不是 那样。例如,gcc仍使用gcc常数的整数形式的乘法逆。 (Why does GCC use multiplication by a strange number in implementing integer division?。所有其他3个主要的x86编译器(clang / ICC / MSVC)都使用-O0

答案 1 :(得分:5)

有符号整数的溢出将调用undefined behavior。您不能通过将两个数字相加并检查它们是否以某种方式环绕来检查溢出情况。当您可能在x86 / x64系统上无法使用此功能时,并不能保证其他人的行为相同。

可以进行的操作是与INT_MAXINT_MIN一起进行检查的一些算法。

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 INT_MIN - x < y;
    if (x > 0)
        return INT_MAX - x > y;
    // x == 0
    return 1;
}

表达式INT_MAX - x > y在算术上等同于INT_MAX > x + y,但可以防止溢出。同样,INT_MIN - x < y在算术上等同于INT_MIN < x + y,但可以防止溢出。

编辑:

如果要强制定义有符号整数溢出,可以对-fwrapv选项使用gcc。但是,最好完全避免溢出。

答案 2 :(得分:1)

您已经被告知,您正在调用未定义的行为。没有为C中的带符号整数定义溢出。编译器理解,第二个和第三个if语句未按带符号整数定义,因此编译器决定,无论采用哪种分支,都不能在定义良好的程序中发生。因此,整个功能tadd_ok会折叠成一个return 1

禁用优化无关紧要:这些if语句调用未定义的行为是在优化器开始工作很久之前确定的。

启用调试信息的生成也没关系,因为这不会改变代码的生成方式(它只是为解释二进制转储和进程状态的工具添加注释)。

最后但并非最不重要的一点是,当您使GDB打印语句(x+y)>y的结果时,它会在C编译范围之外执行此操作,只是使用“在金属上运行”指令。在C之后,并不是唯一编译为二进制的语言。而且,尽管在C中未定义带符号整数下溢,但在某些不同的语言中可能会很好地定义它;并且您可能还希望能够在此类程序上使用GDB。当将p (x+y)>y的输出与C语句(x+y)>yxysigned int的C语句进行比较时,您将桔子与苹果进行了比较;他们是完全不同的东西。

答案 3 :(得分:1)

我知道您要求使用UB以外的其他功能,但是即使您使用的是-O0,也恐怕是导致您遇到此问题的原因。让我们看一下生成的程序集。

我已简化了此功能以隔离UB:

int tadd_ok(int x, int y) {
    if (x > 0)
        return (x + y) > y;

    return 1;
}

为AArch64(-O0 -x c -march=armv8-a)生成的输出:

tadd_ok:
        sub     sp, sp, #16
        str     w0, [sp, 12]
        str     w1, [sp, 8]
        ldr     w0, [sp, 12]
        cmp     w0, 0
        ble     .L2           ; if (x <= 0) goto return stmt
        ldr     w0, [sp, 12]  ; here we are runnig (x + y) > y branch
        cmp     w0, 0         ; x is compared to zero
        cset    w0, gt        ; return value is set to (x > 0)
        and     w0, w0, 255
        b       .L3
.L2:
        mov     w0, 1
.L3:
        add     sp, sp, 16
        ret

请记住,由于不允许带符号的整数溢出,因此表达式(x + y)始终大于y,除非x <= 0。 GCC知道此之前优化程序已启动,因此它将(x + y) > y替换为x > 0

即使进行了相同的检查,似乎也忘记了这一点-没有启用优化的副作用。

您可以使用以下代码替换上面的C代码:

int tadd_ok(int x, int y) {
    if (x > 0)
        return x > 0;

    return 1;
}

输出不变:

tadd_ok:
        sub     sp, sp, #16
        str     w0, [sp, 12]
        str     w1, [sp, 8]
        ldr     w0, [sp, 12]
        cmp     w0, 0
        ble     .L2
        ldr     w0, [sp, 12]
        cmp     w0, 0
        cset    w0, gt
        and     w0, w0, 255
        b       .L3
.L2:
        mov     w0, 1
.L3:
        add     sp, sp, 16
        ret

使用上面的代码,很明显优化器将对其进行处理:

tadd_ok:
        mov     w0, 1
        ret

您使用的其他选项不会更改任何内容,平台也没关系,因为不会生成任何附加说明。

对于GDB:它通过在调试程序中使用编译器生成的相同代码执行复杂表达式来运行复杂表达式,因此输出不会有所不同。因此,评估tadd_ok(0x7FFFFFFF, 0x7FFFFFFF)运行相同的代码。

答案 4 :(得分:1)

我想补充一点,在GCC中,有一种简单的方法可以处理带溢出的签名加法并进行定义。您可以使用https://gcc.gnu.org/onlinedocs/gcc/Integer-Overflow-Builtins.html中记录的内建函数来执行已定义为环绕的已签名操作(add,sub,mul),它将告诉您该操作是否溢出。

bool __builtin_add_overflow(type1 a, type2 b, type3 *res)

例如,您可以像这样重写函数:

int tadd_ok(int x, int y) {
    int result;
    return !__builtin_add_overflow(x, y, &result);
    // result now contains (int)((unsigned int)x + (unsigned int)y)
}

答案 5 :(得分:0)

有符号整数溢出是根据C标准的未定义行为,这与保证可环绕的无符号溢出不同。

尝试使用最新的GCC x86-64和-O3将代码放在Godbolt上。它被优化为:

mov eax, 1
ret 

可以接受。我以为ARM64发出了等效的指令序列,但是我不知道这种体系结构,不能仅凭外观就能确定。