提问人:cerveka2 提问时间:10/27/2023 最后编辑:cerveka2 更新时间:11/6/2023 访问量:4044
GCC 删除了 && 右操作数中的边界检查,但左操作数中没有,为什么?
GCC removes a bounds check in the right operand of &&, but not in the left operand, why?
问:
我有以下 C/C++ 代码片段:
#define ARRAY_LENGTH 666
int g_sum = 0;
extern int *g_ptrArray[ ARRAY_LENGTH ];
void test()
{
unsigned int idx = 0;
// either enable or disable the check "idx < ARRAY_LENGTH" in the while loop
while( g_ptrArray[ idx ] != nullptr /* && idx < ARRAY_LENGTH */ )
{
g_sum += *g_ptrArray[ idx ];
++idx;
}
return;
}
当我在 12.2.0 版中使用 GCC 编译器编译上述代码时,有两种情况的选项:-Os
- while 循环条件是
g_ptrArray[ idx ] != nullptr
- while 循环条件是
g_ptrArray[ idx ] != nullptr && idx < ARRAY_LENGTH
我得到以下程序集:
test():
ldr r2, .L4
ldr r1, .L4+4
.L2:
ldr r3, [r2], #4
cbnz r3, .L3
bx lr
.L3:
ldr r3, [r3]
ldr r0, [r1]
add r3, r3, r0
str r3, [r1]
b .L2
.L4:
.word g_ptrArray
.word .LANCHOR0
g_sum:
.space 4
正如你所看到的,程序集做到了!不!根据值 对变量进行任何检查。idx
ARRAY_LENGTH
我的问题
这怎么可能?
编译器如何为这两种情况生成完全相同的程序集,并忽略代码中存在的条件?向我解释规则或过程,编译器如何得出结论,它可以完全删除条件。idx < ARRAY_LENGTH
编译器资源管理器中显示的输出程序集(请参阅两个程序集是否相同):
while 条件为:
g_ptrArray[ idx ] != nullptr
while 条件为:
g_ptrArray[ idx ] != nullptr && idx < ARRAY_LENGTH
注意:如果我将条件的顺序交换为 ,则输出程序集包含对 的值的检查,如下所示:https://godbolt.org/z/fvbsTfr9P。idx < ARRAY_LENGTH && g_ptrArray[ idx ] != nullptr
idx
答:
越界访问数组是未定义的行为,因此编译器可以假定它永远不会在表达式的 LHS 中发生。然后,它跳过箍(优化)以注意到,由于是数组的长度,因此 RHS 条件必须成立(否则 UB 将在 LHS 中出现)。因此,你会看到结果。&&
ARRAY_LENGTH
正确的检查是 。这将避免在 RHS 上出现任何未定义行为的可能性,因为必须首先评估 LHS,除非 LHS 为 true(在 C 和 C++ 中保证运算符以这种方式行事),否则不会评估 RHS。idx < ARRAY_LENGTH && g_ptrArray[idx] != nullptr
&&
即使是潜在的未定义行为也会做出类似的事情!
评论
-Wdont-assume-the-programmer-is-always-right
C 标准 (C17, 6.5.6, §8) 规定,我们不能在数组外部进行指针算术运算,也不能在数组之外访问它——这样做是未定义的行为,任何事情都可能发生。
因此,严格来说,数组越界检查是多余的,因为您的循环条件为“在数组中发现空指针时停止”。如果是越界访问,您可以调用未定义的行为,因此理论上程序此时会被烘烤。因此,没有必要评估 的正确操作数。(您可能知道,有严格的从左到右评估。编译器可以假定访问始终位于已知大小的数组内。g_ptrArray[ idx ]
&&
&&
我们可以通过添加一些使编译器无法预测代码的东西来使编译器重新符合要求:
int** I_might_change_at_any_time = g_ptrArray;
void test2()
{
unsigned int idx = 0;
// check for idx value is NOT present in code
while( I_might_change_at_any_time[ idx ] != nullptr && idx < ARRAY_LENGTH)
{
g_sum += *g_ptrArray[ idx ];
++idx;
}
}
在这里,指针充当“中间人”。它是一个具有外部链接的文件范围变量,因此它可能随时更改。编译器不能再假定它总是指向 。现在,左操作数可以成为定义良好的访问。因此,gcc 现在将越界检查添加到汇编程序中:g_ptrArray
&&
cmp QWORD PTR [rdx+rax*8], 0
je .L6
cmp eax, 666
je .L6
评论
p < &array[0] + ARRAY_SIZE
p
&array[0]
endp
g_ptrArray + idx
idx == ARRAY_LENGTH
*(g_ptrArray + idx)
&
I_might_change_at_any_time
test2()
解释:正如 Marco Bonelli 所记录的那样,编译器假设编写第一个测试的程序员知道此代码已定义行为,因此它假定该代码在适当的范围内,即:。因此,下一个测试是冗余的,因此可以省略代码。g_ptrArray[idx] != nullptr
idx
idx < ARRAY_LENGTH
&& idx < ARRAY_LENGTH
idx
< ARRAY_LENGTH
目前尚不清楚这种范围分析发生在哪里,以及编译器是否也可以警告程序员 ,它标记潜在编程错误的方式 或redundant test elided
if (a = b) ...
a = b << 1 + 2;
恕我直言,这种*优化*反常的原因在于缺乏对不明显优化的警告。
与编译器不同,程序员也是人,会犯错误。即使是 10 倍的程序员有时也会犯愚蠢的错误,编译器不应该假设程序员永远是对的,除非他们明确地吹嘘它,比如if ((a = b)) ...
测试的顺序不正确,在代码审查中应该很明显。相反,如果假定是合法的,那么冗余的事实对于代码的人类读者来说并不明显。编译器假设程序员知道可以执行,因此假设它在适当的范围内,并推断第二个测试是多余的。这是反常的:如果程序员足够精明,正确地假设 idx 总是在适当的范围内,他们肯定不会编写冗余测试。相反,如果他们犯了错误并以错误的顺序编写了测试,那么标记冗余代码将有助于修复明显的错误。g_ptrArray[idx] != nullptr
idx < ARRAY_LENGTH
idx < ARRAY_LENGTH
g_ptrArray[idx] != nullptr
g_ptrArray[idx] != nullptr
idx
当编译器变得足够聪明,可以检测到这种冗余时,这种级别的分析应该使程序员受益,并有助于检测编程错误,而不是使调试变得比现在更难。
评论
gcc -fsanitize=undefined
g_ptrArray[idx] != nullptr
a[x]
unsigned > 0
我想强调的是,由于其他答案没有,数组查找和边界测试的执行顺序并不重要。如果你写过
bool in_bounds = idx < ARRAY_LENGTH;
if ( g_ptrArray[ idx ] != nullptr && in_bounds ) { ... }
然后,边界测试仍可能被删除,即使它是在数组查找之前排序的。即使你做了一个 ,GCC 仍然可以放弃测试并向其写入一个常量。in_bounds
volatile bool
true
重要的是代码是否执行。如果它总是执行,如上所述,那么即使在早期的使用中,也可以假定它在边界内(当然,如果没有对它的干预赋值)。边界检查不会被丢弃,因为 的短路行为意味着并不总是运行。g_ptrArray[ idx ]
idx
( idx < ARRAY_LENGTH && g_ptrArray[ idx ] != nullptr )
&&
g_ptrArray[ idx ]
在编写 C 标准时,C 实现至少有三种不同的处理方式,例如:
extern int a[5];
int x=a[i];
在超出范围 0..4 的情况下:i
一些实现使用抽象模型,该模型将以一种完全不可知的方式添加到 的地址中,而与生成的地址是否在 中无关,并从该地址执行加载,无论发生什么后果。(脚注1)
i
a
a
某些实现会尝试捕获范围 0..4 之外的访问。
某些实现通常与 #1 中的行为相同,但如果在同一函数中对 的访问之前和之后都是对 的访问,并且没有证据表明访问 之间的任何内容都可能是对存储的访问,则编译器可能会合并对 的访问。
a[i]
b[0]
b[0]
b[0]
这些方法中的每一种对某些任务都有优点,而对其他任务有缺点。该标准并没有试图暗示所有编译器都应该使用相同的方法,而是选择允许实现在它们之间进行选择,或任何其他可能有用的方法,包括尚未发明的方法,无论他们认为合适的方法如何,假设实现将选择对目标程序员最有用的方法。该标准通过将 #1 和 #3 归类为未定义行为情况来做到这一点。(脚注2)。
像 gcc 和 clang 这样的编译器是为任务而设计的,这些任务可以通过识别 并删除仅在标准没有施加任何要求的情况下才相关的代码,并且不会从其他处理中受益,例如说编译器可以在闲暇时通过读取存储来处理此类数组读取,无论结果如何,或者以无副作用的方式产生任意值。这种处理方式就是你在这个例子中看到的。
脚注 1:虽然 C 可能不提供任何强制任何特定对象紧随其后分配存储的方法,但其他语言确实提供了对布局的控制,并且如果和某些其他对象被强制连续存储,则对 的访问将是对 的访问。a
a
extern int b[5];
a[5]
b[0]
脚注 2:某些方法(如 #3)与将行为分类为“实现定义”不兼容。由于“假设”规则要求程序行为的任何可观察方面都不受优化的影响,除非在归类为未定义行为的场景中,并且由于使用方法 #3 的实现中的行为越界访问将受到优化的影响,因此标准有必要将此类访问归类为未定义的行为。
这绝不是意味着允许以使方法 #1 有用的方式分配对象地址的实现不应该继续支持该方法,也不是说以这种方式使用数组访问语法对于允许精确控制内存布局的实现来说不是一个好方法,以允许程序员利用这种控制。
尽管该标准在其严格符合 C 程序的定义中明确规定,此类程序不得执行任何以调用未定义行为为特征的操作,但其对“符合 C 程序”的定义却没有这样的要求,并且它明确指出,短语“X 应为 Y”的意思不多于或少于当 X 不是 Y 时执行构造将调用未定义行为 [暗示此类执行将被禁止在严格符合的 C 程序中,但在符合 C 程序中则不然,有些人认为该标准不承认任何类别的一致性,而许多约束不适用这些类别。
评论
g_ptrArray[ idx ]
if
idx
ARRAY_LENGTH
idx
评论
g_ptrArray[ idx ]
idx
idx
nullptr
nullptr
-std=c2x
-Os
g_ptrArray[0]
mov rsi, [rax]
while
cmp rdx, 664
ja done
i
g_arrayPtr[i+1]
idx