c - 为什么/如何 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 输出中看到的那样,结果非常不一致,因为到达了return
on 语句2.30.c:10
,并且返回值应该是 0,但函数仍然返回 1,导致断言失败。
请提供一个想法,我在这里做错了什么。
请尊重我介绍的内容。只是说它是 UB 而不涉及平台,尤其是 GDB 输出,不会有任何帮助。
解决方案
有符号溢出是 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_ok
到return 1
,或到add
和检查溢出标志(V
在 ARM 上,OF
在 x86 上)。Clang 的优化 asm 是>>31
, OR 和一个 XOR 操作的有趣组合,但-fwrapv
实际上更改了 asm,因此它可能没有进行完整的溢出检查。)
你可以说 gcc8 “破坏”了你的代码,但实际上它已经被破坏了,因为它是合法/可移植的 ISO C。gcc8 只是揭示了这个事实。
为了更清楚地看到它,让我们将该表达式隔离到一个函数中。 无论如何都会单独编译每个语句,因此仅在不影响函数中此语句的代码生成gcc -O0
时才运行的信息。x<0
-O0
tadd_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 个寄存器的增量。
无论如何,cset
AArch64 相当于 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 的补码,以及在unsigned
and之间的转换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
.
推荐阅读
- monitoring - 如何在 grafana 中显示服务器下拉列表
- angular - 从服务器获取数据并将其添加到局部变量以供将来在 json 中使用
- javascript - 类型错误:button.classList 未定义
- node.js - GET http://localhost:3000/ 404(未找到),实际上是在调用 POST 但得到 GET
- sql-server - 如何将 SQL Server 数据库放在 DVD 上
- javascript - Fetch API 无法加载 URL 方案对于 CORS 请求必须是“http”或“https”
- java - 运行 Jar 时出现 NoClassDefFoundError
- image-processing - 根据图片从数据库中查找相似图片
- html - 背景图像自动调整大小
- imdb - 如何将 imdb id 转换为 tmdb id?