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

GCC removes a bounds check in the right operand of &&, but not in the left operand, why?

提问人:cerveka2 提问时间:10/27/2023 最后编辑:cerveka2 更新时间:11/6/2023 访问量:4044

问:

我有以下 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

  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

正如你所看到的,程序集做到了!不!根据值 对变量进行任何检查。idxARRAY_LENGTH


我的问题

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

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

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

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

注意:如果我将条件的顺序交换为 ,则输出程序集包含对 的值的检查,如下所示:https://godbolt.org/z/fvbsTfr9Pidx < ARRAY_LENGTH && g_ptrArray[ idx ] != nullptridx

C++ 数组 C GCC 编译器优化

评论

34赞 Peter Cordes 10/27/2023
您刚刚访问以检查 NULL,编译器可以看到数组声明,因此它知道其大小。超过数组末尾将是 UB,因此编译器可以假设它不会发生。g_ptrArray[ idx ]idx
31赞 molbdnilo 10/27/2023
用它建立索引进行验证是没有意义的;当它出界时,已经太晚了。idx
3赞 Lundin 10/27/2023
什么是“C/C++”?您编译为 C 还是 C++?C 目前尚不支持 。请说明使用的确切编译器和编译器选项。nullptr
5赞 Peter Cordes 10/27/2023
@Lundin:公平地说,他们确实在C++模式下将 Godbolt 上的构建与 ARM GCC 12.2 联系起来。即将到来的 ISO C23 标准有 ,编译器已经支持(如果您在足够新的 GCC 中启用:godbolt.org/z/zxYK79TqP)。此外,C 和 C++ 在 UB 上彼此非常一致,允许编译器假设 UB 尚未发生,因为在这种情况下实际上对行为没有要求。nullptr-std=c2x
5赞 Peter Cordes 10/27/2023
@Lundin:它不是在那个代码生成中展开的。它“旋转”了循环并部分剥离了第一次迭代(在循环之前加载 ,并测试它是否为 null)。这是“循环反转”的一部分,重新排列一个循环,以便在底部有一个条件分支: 为什么循环总是编译为“do...而“风格(尾巴跳跃)?.所以 / 在 = 665 或更高时退出循环,因为它即将从 加载。(Clang 不会优化检查)-Osg_ptrArray[0]mov rsi, [rax]whilecmp rdx, 664ja doneig_arrayPtr[i+1]idx

答:

60赞 Marco Bonelli 10/27/2023 #1

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

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

即使是潜在的未定义行为也会做出类似的事情!

评论

28赞 chqrlie 10/27/2023
编译器的一个明显案例:编译器没有警告明显的程序员错误,而是静默地优化代码。我希望编译器的人能添加-Wdont-assume-the-programmer-is-always-right
7赞 Peter Cordes 10/27/2023
即使是潜在的未定义行为也会做出类似的事情!- 一种更简单的表达方式是:“编译器可以假设你的程序中没有UB”。(C++ 标准很清楚,这是追溯性的,如果没有可以避免 UB 的分支,则后续代码可能意味着早期代码的值范围。ISO C对此并不清楚:请参阅未定义的行为是否追溯性地意味着不能保证早期可见的副作用?其中比较了标准)
5赞 HolyBlackCat 10/27/2023
对不起,这主要是吹毛求疵,因为彼得说的是术语问题。“在适当的条件下,可以在运行时发生”我的意思是,在运行时的某个时刻,随着程序的继续,UB将不可避免地发生,这就是不利影响开始的时候。只要它仍然只是“潜力”,程序就不会中断;不可能注意到该条件已从循环中删除而不导致 UB。
10赞 Passer By 10/28/2023
@chqrlie 是的,这在代码审查中是显而易见的,但这就是我们拥有它们的原因。对于编译器来说,到处都存在冗余,特别是当函数是内联的或使用宏函数时(例如,不同函数中的边界检查是多余的)。当优化器开始工作时,它正在对中间表示进行操作,并且已经丢失了大部分使其显而易见的东西。
10赞 Holger 10/28/2023
@chqrlie你只是在质疑 C 和 C++ 的基本原理。它们是为“知道自己在做什么”或“比编译器更清楚”的假设开发人员而设计的。虽然我同意你关于设计理念的看法,但我认为在这种语言的上下文中讨论它没有意义,而存在替代语言。正如 Passer By 所说,代码可能不是以这种形式编写的。同时,编译器编写者会尝试对看起来像错误的情况进行警告。检查哪些编译器已经在这里发出警告会很有趣。
7赞 Lundin 10/27/2023 #2

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

评论

2赞 Davislor 10/28/2023
“C 标准 (C17 6.5.6 §8) 规定,我们不能在数组之外进行指针算术”。你知道这一点,但有一个例外:我们可以计算一个指针超过数组末尾的一个元素,并将其与数组中的指针进行比较。也就是说,对于介于 和 之间是有效的。p < &array[0] + ARRAY_SIZEp&array[0]endp
4赞 Davislor 10/28/2023
@supercat 这是不正确的。该标准明确允许:“如果指针操作数和结果都指向同一数组对象的元素,或者指向数组对象的最后一个元素,则计算不应产生溢出;否则,行为是未定义的。
7赞 Ben Voigt 10/28/2023
@supercat:没有人会和你争论实现可以用UB做什么。Davidslor 正确地指出,这里不是指针算术是 UB,而是左值到右值的转换(或 C 的等价物)。 即使 ,也没问题,但是(假设它是在评估的上下文中而不是 的操作数)不是。g_ptrArray + idxidx == ARRAY_LENGTH*(g_ptrArray + idx)&
3赞 Toby Speight 10/28/2023
略显迂腐:在C记忆模型中不能“随时”更改。但它肯定可以在初始化和调用之间发生变化,这是与此相关的。I_might_change_at_any_timetest2()
3赞 Davislor 10/29/2023
@supercat 正如我之前告诉过你的那样,我不想再花更多的时间在这个讨论上了。
5赞 chqrlie 10/28/2023 #3

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

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


恕我直言,这种*优化*反常的原因在于缺乏对不明显优化的警告。

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

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

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

评论

2赞 Peter Cordes 10/29/2023
希望像(或者也许是 Valgrind?)这样的工具能够捕捉到越界读取。但前提是你有一个测试用例,其中所有指针都是非空的,所以你必须已经在循环终止中寻找可能的错误才能捕获它。关于缺乏警告的好点。对我来说,这种优化似乎很明显,但你是对的,我一开始就不会这样写(至少不是故意的),因为我知道你需要在使用它们之前检查它们。gcc -fsanitize=undefinedg_ptrArray[idx] != nullptr
0赞 supercat 10/30/2023
@PeterCordes:只有当程序接收到为产生相关极端情况而精心设计的输入时,这些工具才会有所帮助。如果一个函数的行为是“如果 x 小于 5,则产生 a[x],否则产生一个没有副作用的任意值”,即使对于恶意输入,也能满足应用程序要求,那么一个简单返回的函数可以由编译器的一个版本转换为 100% 满足应用程序要求的机器代码, 但是后来的编译器可能需要编写代码的效率较低......a[x]
0赞 supercat 10/30/2023
...以满足应用要求。
1赞 Peter Cordes 10/30/2023
@MSalters:我其实很喜欢这个答案的第二部分;在阅读这篇文章之前,我很难理解那些不希望他们的编译器优化他们有缺陷的代码的人的观点。比如,如果你这样写,编译器当然会进行优化,这就是编译器的用途。但是关于缺乏警告的观点,以及没有人会这样写(如果他们知道自己在做什么)的观点是很好的;其他一些语言坑洼确实有警告,比如 always-true 循环条件,比如unsigned > 0
1赞 Peter Cordes 10/30/2023
@MSalters:(但相反,真正的代码可能不是这样的。经过一些内联和简化后,它在逻辑上可能是等价的,如果我们确实想要一个边界检查来优化,因为我们知道这个数组是以 null 结尾的,并且在运行一些迭代器增量函数之前通过无条件取消引用来向编译器表达它。这可能是编译器开发人员在实现此类优化时所考虑的那种情况。例如,与优化 C++ 中的“安全”容器有关,这些容器对每次访问进行边界检查。
4赞 benrg 10/29/2023 #4

我想强调的是,由于其他答案没有,数组查找和边界测试的执行顺序并不重要。如果你写过

    bool in_bounds = idx < ARRAY_LENGTH;
    if ( g_ptrArray[ idx ] != nullptr && in_bounds ) { ... }

然后,边界测试仍可能被删除,即使它是在数组查找之前排序的。即使你做了一个 ,GCC 仍然可以放弃测试并向其写入一个常量。in_boundsvolatile booltrue

重要的是代码是否执行。如果它总是执行,如上所述,那么即使在早期的使用中,也可以假定它在边界内(当然,如果没有对它的干预赋值)。边界检查不会被丢弃,因为 的短路行为意味着并不总是运行。g_ptrArray[ idx ]idx( idx < ARRAY_LENGTH && g_ptrArray[ idx ] != nullptr )&&g_ptrArray[ idx ]

-2赞 supercat 10/30/2023 #5

在编写 C 标准时,C 实现至少有三种不同的处理方式,例如:

extern int a[5];
int x=a[i];

在超出范围 0..4 的情况下:i

  1. 一些实现使用抽象模型,该模型将以一种完全不可知的方式添加到 的地址中,而与生成的地址是否在 中无关,并从该地址执行加载,无论发生什么后果。(脚注1)iaa

  2. 某些实现会尝试捕获范围 0..4 之外的访问。

  3. 某些实现通常与 #1 中的行为相同,但如果在同一函数中对 的访问之前和之后都是对 的访问,并且没有证据表明访问 之间的任何内容都可能是对存储的访问,则编译器可能会合并对 的访问。a[i]b[0]b[0]b[0]

这些方法中的每一种对某些任务都有优点,而对其他任务有缺点。该标准并没有试图暗示所有编译器都应该使用相同的方法,而是选择允许实现在它们之间进行选择,或任何其他可能有用的方法,包括尚未发明的方法,无论他们认为合适的方法如何,假设实现将选择对目标程序员最有用的方法。该标准通过将 #1 和 #3 归类为未定义行为情况来做到这一点。(脚注2)。

像 gcc 和 clang 这样的编译器是为任务而设计的,这些任务可以通过识别 并删除仅在标准没有施加任何要求的情况下才相关的代码,并且不会从其他处理中受益,例如说编译器可以在闲暇时通过读取存储来处理此类数组读取,无论结果如何,或者以无副作用的方式产生任意值。这种处理方式就是你在这个例子中看到的。

脚注 1:虽然 C 可能不提供任何强制任何特定对象紧随其后分配存储的方法,但其他语言确实提供了对布局的控制,并且如果和某些其他对象被强制连续存储,则对 的访问将是对 的访问。aaextern int b[5];a[5]b[0]

脚注 2:某些方法(如 #3)与将行为分类为“实现定义”不兼容。由于“假设”规则要求程序行为的任何可观察方面都不受优化的影响,除非在归类为未定义行为的场景中,并且由于使用方法 #3 的实现中的行为越界访问将受到优化的影响,因此标准有必要将此类访问归类为未定义的行为。

这绝不是意味着允许以使方法 #1 有用的方式分配对象地址的实现不应该继续支持该方法,也不是说以这种方式使用数组访问语法对于允许精确控制内存布局的实现来说不是一个好方法,以允许程序员利用这种控制。

尽管该标准在其严格符合 C 程序的定义中明确规定,此类程序不得执行任何以调用未定义行为为特征的操作,但其对“符合 C 程序”的定义却没有这样的要求,并且它明确指出,短语“X 应为 Y”的意思不多于或少于当 X 不是 Y 时执行构造将调用未定义行为 [暗示此类执行将被禁止在严格符合的 C 程序中,但在符合 C 程序中则不然,有些人认为该标准不承认任何类别的一致性,而许多约束不适用这些类别。

评论

4赞 MSalters 10/30/2023
脚注 1 具有误导性。当时现实世界中最坏的情况是内存映射硬件。越界读取几乎可以做任何事情。从实际内存中返回一个值是最良性的结果。
3赞 MSalters 10/30/2023
脚注2也具有误导性。as-if 规则不要求这样做。as-if 规则只要求优化编译器根据标准和实现定义行为的抽象规则编译源代码。没有任何规则规定优化编译器必须以某种方式匹配非优化编译器。具体而言,未指定行为可以并且将会因优化而有很大差异(越界读取不是未指定行为 - 请参阅前面的评论)
2赞 user3840170 10/30/2023
这些咆哮实际上都没有回答这里提出的问题。
0赞 supercat 10/30/2023
@MSalters:我对脚注 1 的观点是,在某些情况下,程序员会知道计算出的地址是有意义的,即使编译器不能。主要段落文本“无论将发生什么后果”都考虑到了内存映射 I/O 的可能性。
0赞 supercat 10/30/2023
@user3840170:编译器正在利用标准对评估的松懈来生成代码,这些代码会跳过 在会或更大的情况下。该标准规定,实施可能会选择对 施加约束,有些选择这样做,但这种做法很难普遍;例如,选择使用K&R行为(上面的#1)不是缺陷。N1570第4节“一致性”第2段指出,该标准使用短语“X应为Y”作为“标准仅在X为Y时行使管辖权”的简写。g_ptrArray[ idx ]ifidxARRAY_LENGTHidx