我有以下 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
:
g_ptrArray[ idx ] != nullptr
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
:
注意:如果我将条件顺序交换为
idx < ARRAY_LENGTH && g_ptrArray[ idx ] != nullptr
,则输出程序集包含对 idx
值的检查,如下所示:https://godbolt.org/z/fvbsTfr9P。
越界访问数组是未定义的行为,因此编译器可以假设它永远不会在
&&
表达式的 LHS 中发生。然后跳过一些环节(优化),注意到由于 ARRAY_LENGTH
是数组的长度,所以 RHS 条件必须成立(否则 UB 会出现在 LHS 中)。这就是你看到的结果。
正确的检查是
idx < ARRAY_LENGTH && g_ptrArray[idx] != nullptr
。
即使是潜在的未定义行为也可能会做出这样令人讨厌的事情!
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;
}
}
这里指针充当“中间人”。它是具有外部链接的文件范围变量,因此可能随时更改。编译器不能再假设它始终指向
g_ptrArray
。现在,&&
的左操作数可以成为明确定义的访问。因此,gcc 现在向汇编程序添加了越界检查:
cmp QWORD PTR [rdx+rax*8], 0
je .L6
cmp eax, 666
je .L6