首页 > 解决方案 > 为什么/如何 gcc 编译此签名溢出测试中的未定义行为,使其适用于 x86 但不适用于 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 or 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 输出中看到的那样,结果非常不一致,因为到达了returnon 语句2.30.c:10,并且返回值应该是 0,但函数仍然返回 1,导致断言失败。

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


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

标签: cgccundefined-behaviorinteger-overflowarm64

解决方案


有符号溢出是 ISO C 中的未定义行为。您不能可靠地导致它,然后检查它是否发生。

在表达式(x + y) > y;中,允许编译器假设x+y不会溢出(因为那将是 UB)。因此,它优化到检查x > 0 (是的,真的,gcc 甚至在-O0)。

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

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


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

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

在带有 AArch64 GCC8.2 的 Godbolt 编译器资源管理器上-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-optimized什至会将其 GIMPLE 转换回类似 C 的代码并完成此优化(来自 Godbolt 链接):

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

{
  return x > 0;
}

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

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

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


如果您希望您的代码按照编写的方式工作,则需要编译-fwrapv以使其在-fwrapv使 GCC 实现的 C 变体中具有良好定义的行为。默认值为-fstrict-overflow,类似于 ISO C 标准。

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

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

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


由于有符号和无符号加法在 2 的补码中是相同的二进制运算,因此您可以只转换unsigned为 add,然后转换回符号比较。这将使您的函数版本在“正常”实现上是安全的:2 的补码,以及在unsignedand之间的转换int只是对相同位的重新解释。

这不能有 UB,它只是不会在补码或符号/大小 C 实现上给出正确的答案。

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

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

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

如果您已将其编写int sum = x+y为单独的 C 语句return sum < y,则在禁用优化的情况下,gcc 将看不到此 UB。 但是作为同一个表达式的一部分,即gcc使用默认-O0也可以看到它。

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

-O0并不意味着“没有优化”,它意味着除了在任何目标平台的 asm 的过程中通过 gcc 的内部表示进行转换已经有必要之外,没有额外的优化。@Basile Starynkevitch 在 禁用 GCC 中的所有优化选项中解释)

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


推荐阅读