GCC 删除了 && 右操作数中的边界检查,但没有删除左操作数中的边界检查,为什么?

cer*_*ka2 32 c c++ arrays gcc compiler-optimization

我有以下 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;
}
Run Code Online (Sandbox Code Playgroud)

当我使用版本 12.2.0 中的 GCC 编译器编译上述代码时,并选择-Os两种情况:

  1. while 循环条件是g_ptrArray[ idx ] != nullptr
  2. 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
Run Code Online (Sandbox Code Playgroud)

正如您所看到的,程序集确实如此!不!idx根据值对变量进行任何检查ARRAY_LENGTH


我的问题

这怎么可能?编译器如何为这两种情况生成完全相同的程序集,并忽略idx < ARRAY_LENGTH代码中存在的条件?向我解释一下规则或过程,编译器如何得出可以完全删除条件的结论。

编译器资源管理器中显示的输出程序集(看到两个程序集是相同的):

  1. while 条件是g_ptrArray[ idx ] != nullptr

  2. while 条件是g_ptrArray[ idx ] != nullptr && idx < ARRAY_LENGTH

注意:如果我将条件的顺序交换为idx < ARRAY_LENGTH && g_ptrArray[ idx ] != nullptr,则输出程序集包含对 的值的检查,idx如您在此处看到的:https: //godbolt.org/z/fvbsTfr9P

Mar*_*lli 60

越界访问数组是未定义的行为,因此编译器可以假设它永远不会在表达式的 LHS 中发生&&。然后跳过一些环节(优化),注意到由于ARRAY_LENGTH是数组的长度,RHS 条件必须成立(否则 UB 会出现在 LHS 中)。因此你看到的结果。

正确的检查是idx < ARRAY_LENGTH && g_ptrArray[idx] != nullptr. 这将避免 RHS 上任何未定义行为的可能性,因为必须首先评估 LHS,并且除非 LHS 为 true,否则不会&&评估 RHS(在 C 和 C++ 中,保证运算符以这种方式运行)。

即使是潜在的未定义行为也可能会做出这样令人讨厌的事情!

  • 编译器反常的一个明显例子:编译器没有警告明显的程序员错误,而是默默地优化代码。我希望编译器人员添加“-Wdont-assume-the-programmer-is-always-right” (29认同)
  • @chqrlie 是的,这在代码审查中很明显,但这就是我们拥有它们的原因。对于编译器来说,到处都存在冗余,特别是当函数被内联或使用宏函数时(例如,不同函数中的边界检查是冗余的)。当优化器开始工作时,它正在对中间表示进行操作,并且已经丢失了大部分显而易见的内容。 (10认同)
  • @chqrlie 你只是在质疑 C 和 C++ 的基本原理。它们是为假设的开发人员而设计的,“他们知道自己在做什么”或“比编译器更了解”。虽然我同意您关于设计理念的看法,但我认为在存在替代语言的情况下在这种语言的背景下讨论它没有意义。正如路人所说,代码可能不是按字面意思写的。与此同时,编译器编写者尝试对看似错误的情况实施警告。检查哪些编译器已经在此处发出警告会很有趣。 (10认同)
  • *甚至潜在的未定义行为也可能会造成类似的令人讨厌的事情!* - 更简单的表达方式是:“*编译器可以假设您的程序中没有 UB*”。(C++ 标准明确表示这是追溯性的,如果没有可以避免 UB 的分支,后面的代码可以暗示早期代码的值范围。ISO C 对此并不清楚:请参阅[未定义的行为追溯意味着不能保证早期可见的副作用?](/sf/ask/5400226191/)比较标准) (7认同)
  • 抱歉,这主要是一个挑剔,正如彼得所说的术语问题。*“在适当的条件下可以在运行时发生”*我的意思是,在运行时的某个时刻,随着程序的继续,UB将不可避免地发生,那就是不利影响开始出现的时候。只要它仍然只是“潜在的”,程序不会中断;不可能在不引起 UB 的情况下注意到条件已从循环中删除。 (5认同)
  • 在这种情况下,UB 必然已经发生*因为“idx &lt; ARRAY_LENGTH”可以通过“idx”访问,从而使比较为假*。这只是如何描述事物的术语问题;我认为; 这个概念看起来比较简单。如果你有像 `if (x &lt; 0) *ptr = 1;` arr[x] = 2;` 这样的代码,那么事情会变得更加棘手,其中 `arr` 是一个实际的数组,而不仅仅是一个指针,因此编译器可以确定负指数将超出范围,因此 UB。 (2认同)
  • @chqrlie “显而易见”究竟如何?您是否希望编译器对所有可能的可消除计算发出警告?如果不是,您能否*准确地*描述什么时候是错误,什么时候是故意的? (2认同)
  • @chqrlie,也许你可以帮助实现这一目标?我非常确定 GCC 维护者会欢迎有助于改进发出的诊断的补丁,如果它们不会产生误报并且不会过多损害其他所需的属性(例如正确代码的编译速度和编译输出的质量) 。 (2认同)

Lun*_*din 7

C 标准 (C17 6.5.6 \xc2\xa78) 规定我们不能在数组之外进行指针算术,也不能在数组之外访问它 - 这样做是未定义的行为,任何事情都可能发生。

\n

因此,严格来说,数组越界检查是多余的,因为循环条件为“在数组中发现空指针时停止”。如果g_ptrArray[ idx ]是越界访问,您将调用未定义的行为,因此理论上该程序此时会被烘烤。因此无需计算 的正确操作数&&。(你可能知道,&&具有严格的从左到右评估。)编译器可以假设访问始终位于已知大小的数组内。

\n

我们可以通过添加一些使编译器无法预测代码的内容来使编译器恢复正常:

\n
int** I_might_change_at_any_time = g_ptrArray;\nvoid test2()\n{\n    unsigned int idx = 0;\n     \n\n    // check for idx value is NOT present in code\n    while( I_might_change_at_any_time[ idx ] != nullptr && idx < ARRAY_LENGTH)\n    {\n        g_sum += *g_ptrArray[ idx ];\n        ++idx;\n    }\n}\n
Run Code Online (Sandbox Code Playgroud)\n

这里指针充当“中间人”。它是一个具有外部链接的文件范围变量,因此它可能随时更改。编译器不能再假设它始终指向g_ptrArray. 现在, 的左操作数可以&&成为明确定义的访问。因此 gcc 现在向汇编器添加了越界检查:

\n
        cmp     QWORD PTR [rdx+rax*8], 0\n        je      .L6\n        cmp     eax, 666\n        je      .L6\n
Run Code Online (Sandbox Code Playgroud)\n

  • @supercat:没有人与你争论 UB 的实现可能会做什么。Davidslor 正确地指出,这里的 UB 不是指针算术,而是左值到右值的转换(或 C 的等效转换)。即使当“idx == ARRAY_LENGTH”时,“g_ptrArray + idx”也可以,但“*(g_ptrArray + idx)”(假设它位于计算上下文中,而不是“&amp;”的操作数)则不然。 (7认同)
  • @supercat 那是不正确的。标准特别允许:“如果指针操作数和结果都指向同一数组对象的元素,**或超过数组对象的最后一个元素**,则求值不应产生溢出;**否则,**行为是未定义的。” (4认同)
  • 有点迂腐:`I_might_change_at_any_time` 不能在 C 内存模型中“随时”更改。但它肯定可以在初始化和调用“test2()”之间发生变化,这就是这里相关的。 (3认同)
  • @supercat 正如我之前告诉过你的,我不想再花时间在这个讨论上了。 (3认同)
  • “C 标准 (C17 6.5.6 §8) 规定我们不能在数组之外进行指针算术”。您知道这一点,但有一个例外:我们可以计算超出数组末尾一个元素的指针,并将其与数组中的指针进行比较。也就是说,对于“&amp;array[0]”和“endp”之间的“p”,“p &lt; &amp;array[0] + ARRAY_SIZE”是有效的。 (2认同)

chq*_*lie 5

解释:正如Marco Bonelli所记录的,编译器假设编写第一个测试的程序员g_ptrArray[idx] != nullptr知道该代码已定义行为,因此它假设idx在适当的范围内,即:idx < ARRAY_LENGTH。由此可见,下一个测试&& idx < ARRAY_LENGTH是冗余idx< ARRAY_LENGTH,因此可以省略该代码。

目前尚不清楚这种范围分析发生在哪里,也不清楚编译器是否也可以警告程序员 a ,它标记或redundant test elided中潜在编程错误的方式if (a = b) ...a = b << 1 + 2;


恕我直言,这种“优化”反常的原因是缺乏对不明显优化的警告。

与编译器不同,程序员是人,也会犯错误。即使是 10 倍的程序员有时也会犯愚蠢的错误,编译器不应该假设程序员总是对的,除非他们明确地吹嘘这一点,如if ((a = b)) ...

测试顺序不正确g_ptrArray[idx] != nullptridx < ARRAY_LENGTH在代码审查中应该很明显。相反,如果假设合法,idx < ARRAY_LENGTH则冗余的事实对于代码的人类读者来说并不明显。g_ptrArray[idx] != nullptr编译器假设程序员知道g_ptrArray[idx] != nullptr可以执行该操作,因此假设它idx在适当的范围内,并推断第二个测试是多余的。这是反常的:如果程序员足够精明,能够正确地假设 idx 始终在正确的范围内,那么他们肯定不会编写多余的测试。相反,如果他们犯了错误并以错误的顺序编写了测试,那么标记冗余代码将有助于修复明显的错误。

当编译器变得足够聪明,能够检测到这种冗余时,这种级别的分析应该对程序员有利,并有助于检测编程错误,而不是使调试变得比现在更困难。

  • 希望像 `gcc -fsanitize=undefined` (或者 Valgrind?)这样的工具能够捕获 `g_ptrArray[idx] != nullptr` 中的越界读取。但前提是您有一个所有指针都非空的测试用例,因此您必须已经在循环终止中寻找可能的错误才能捕获它。关于缺乏警告的观点很好。对我来说,这种优化*确实*看起来很明显,但你是对的,我一开始就不会这样写(至少不是故意的),因为我知道你需要在使用它们之前检查它们全部。 (2认同)