未定义的行为是否具有追溯性,这意味着不能保证早期可见的副作用?

Does undefined behaviour retroactively mean that earlier visible side-effects aren't guaranteed?

提问人:Peter Cordes 提问时间:9/21/2023 最后编辑:Peter Cordes 更新时间:10/30/2023 访问量:294

问:

在 C++ 中,编译器可以假设不会发生 UB,从而影响执行路径中的行为(甚至是明显的副作用,如 I/O),如果我正确理解了措辞,这些路径会遇到 UB,但还没有。

C是否要求在抽象机器遇到UB之前“正确”执行程序,直到最后一个可见的副作用?编译器似乎以这种方式运行,但在 C++ 模式和 C 模式下都是这样做的,因此它可能只是一个错过的优化或故意选择,以减少“程序员对程序员的敌意”。

ISO C 标准是否允许这样的优化?(编译者可能出于各种原因合理地选择不这样做,包括在不错误编译任何其他情况下实现的困难,或“实现质量”因素。


ISO C++ 标准在这一点上相当明确

这个问题(主要)是关于C的,但C++至少是一个有趣的比较点,因为UB的概念在两种语言中至少是相似的。我在 ISO C 中没有看到任何类似的明确语言,因此提出了这个问题。

ISO C++ [intro.abstract]/5 是这样说的(并且至少从 C++11 开始,可能更早):

执行格式良好的程序的符合要求的实现应产生与具有相同程序和相同输入的抽象机器的相应实例的可能执行之一相同的可观察行为。但是,如果任何此类执行包含未定义的操作,则本文档不要求使用该输入执行该程序的实现(甚至不要求在第一个未定义操作之前执行的操作)。

我认为对使用该输入执行该程序的实现没有要求的预期含义是,即使是在抽象机器遇到 UB 之前排序的可见副作用(例如访问或 I/O,包括无缓冲)也不需要发生。volatilefprintf(stderr, ...)

“用该输入执行该程序”的措辞是在谈论整个程序,从其执行开始。(有些人谈论“时间旅行”,但这实际上是一个问题,比如后来的代码允许值范围假设(例如非 null),这些假设会影响编译时决策中的早期分支,正如其他人在之前的 SO 问题中所说的那样。编译器可以假设整个程序的执行不会遇到 UB。

真实编译器行为的测试用例

我试图让编译器进行我想知道的优化。这将非常明确地表明,根据编译器开发人员对标准的解释,它是允许的。(除非它实际上是编译器错误。但到目前为止,我尝试过的所有方法都表明编译器保留了可见的副作用。

我只尝试过访问(不是或或其他),假设优化器应该更容易看到和理解。对非内联函数的调用通常是优化器的黑盒,除非它们是基于函数名称的特殊情况,例如一些非常重要的函数,例如.此外,假设对 I/O 函数的调用可能会永久阻塞,甚至可能中止,因此在以后的代码中永远不会遇到 UB。volatileputcharstd::cout<<printfmemcpy

实际上,我只尝试过商店,而不是阅读。编译器可能会出于某种原因以不同的方式处理这个问题,尽管您希望不会。volatilevolatile

编译器确实假设访问不会捕获,例如,它们在它们周围执行死存储消除(Godbolt)。因此,加载或存储不应阻止优化器看到此执行路径中的 UB 将发生。(更新:这可能没有我想象的那么多,因为如果它确实捕获了这个程序中的信号处理程序,ISO C 和 C++ 都说只有变量才会在信号处理程序中具有其“预期”值。因此,仍然允许在可能发出信号然后恢复或不恢复的事物中消除非全局的死存储。但它仍然表明,访问被认为不太奇怪。volatilevolatilevolatile sig_atomic_tvolatilevolatile

前面的一些示例(例如导致时间旅行的未定义行为)围绕着 if/else 示例展开,其中 UB 将在一侧遇到,因此编译器可以假设另一侧被占用。

但这些在执行路径上没有明显的副作用,这肯定会导致UB,只是在另一条路径上。此示例确实具有:

volatile int sink;     // same code-gen with plain   int sink;
void foo(int *p) {
    if (p)         // null pointer check *could* be deleted due to unconditional deref later.
        sink = 1;      // but GCC / clang / MSVC don't

    *p = 2;
}

GCC13 和 clang16 对 x86-64 的编译方式相同(使用 )。(Godbolt:我正在编译,告诉他们将其视为C++。也是 MSVC19.37,但 arg 在 RCX 而不是 RDI 中。-O3-xc++p

foo(int*):
        test    rdi, rdi
        je      .LBB0_2                      #  if (!p)  goto .LBB0_2, skipping the if body
        mov     dword ptr [rip + sink], 1    # then fall-through, rejoining the other path
.LBB0_2:
        mov     dword ptr [rdi], 2
        ret

用作循环条件时,MSVC 的代码生成是相同的,除了 而不是 。GCC 和 Clang 进行尾部复制,制作两个块,每个块都以 a 结尾,第一个是 just,第二个是两个存储。(这很有趣,因为 clang 编译为零指令,但通过尾部复制,它会创建一个块,其中它被证明是空的,但仍然发出实际的存储指令。if(!p)jnejeret*p=2;*(int*)0p

如果我们 放在 之前,空指针检查确实会被删除。(在 Godbolt 链接中:编译为 2 个无条件存储。*p = 2;if()baz()

即使使用非易失性(带或)也不会发生“预期”优化,这一事实可能表明编译器通常试图避免追溯效应 作为避免在达到 UB 之前改变可见副作用的一种方式。或者它可能只是告诉我们编译器不够激进,无法演示我的观点。在非 UB 情况下发明存储是一种棘手的线程安全违规行为,因此我可以想象编译器对此持谨慎态度。-xc++-xc

至少对于非商店来说,一些成功的例子是:volatile

volatile int sink;
void bar_nv(int *p) {
    /*volatile*/ int sink2;
    if (p) {
        sink = 3;    // volatile
    }else{
        sink2 = 4;   // non-volatile
        *p = 4;  // reachable only with p == NULL, so compilers can assume it's *not* reached.  Only clang takes advantage
    }
}

Clang16 ,编译为 C 或 C++。(与仍然分支的 GCC 不同)。-O3

bar_nv(int*):
        mov     dword ptr [rip + sink], 3
        ret

这优化了包含非易失性副作用的整个分支。sink2

如果我们也使 ,那么它会分支,并且仍然会执行在从函数末尾掉落之前存储在该执行路径中的可见副作用(实际上不是取消引用,已知在函数的那一侧为 null)。请参阅 Godbolt 链接。sink2volatilesink2pifbar_v

我正在玩的另一个案例:https://godbolt.org/z/vjqeb59TG 在 if/else 的两侧放置 derefs,导致与 vs. 类似的结果。*pbar_nvbar_v

因此,我无法让编译器优化执行路径中的不稳定副作用,即使在 C++ 中也肯定会导致 UB。但这并不能证明ISO C++标准不允许这样做。(我仍然有点好奇这是否是故意的,或者是否存在发生这种优化的情况。

在不实际对 null-deref 进行错误的情况下执行可见的副作用是不同的:null-deref 是 UB,因此不能保证任何事情,甚至不能保证实际错误。它是 UB,因此任何事情都可能发生,包括什么都不做或做随机 I/O。


早期的问答(主要是我找到C++问题,而不是C):

  • 这个问题的动机是最近与@user541686的问答评论中的讨论,他声称即使是C++措辞也不允许编译器在达到未定义的操作之前忽略可见的副作用(尤其是访问)。在后来的讨论中,他们可能已经将论点缩小到这样一种说法,即这种优化是不可能的,因为 I/O 可能会永远出错或阻塞,从而无法真正达到未定义的操作。但是我能够证明 GCC 和 clang 确实假设操作不会出错,或者至少它们不会陷入该程序中可以观察其他全局变量状态的其他代码。printfvolatilevolatile

    所以我认为他们对 C++ 的看法是错误的,但发现 ISO C 至少可以解释为在未定义的操作实际发生之前需要所有可见的副作用。(这就是编译器对 C 和 C++ 的实际作用。但这很常见,还是通常被解释为不需要这样做?

  • 导致时间旅行的未定义行为 - ,询问 Raymond Chen 的文章未定义的行为可能导致时间旅行。该示例在遇到 UB 的执行路径中没有任何明显的副作用,因此假定早期分支不会到达该示例。关于这个问题的答案描述了编译器被允许假设 UB 是不可访问的,但在这种情况下,它并没有讨论省略在未定义操作之前可能发生的可见副作用。

  • C++ 最早的未定义行为可以表现出来是什么?- ,大多数答案都同意程序的整个执行是未定义的,而不仅仅是在达到 UB 之后。

  • 是否有任何障碍是时间旅行未定义行为可能无法跨越的?- 这个问题的 版本,具有类似的试金石测试。仅在评论中回答,但意见是不能保证会发生可见的副作用。

  • 如果程序的一部分表现出未定义的行为,它会影响程序的其余部分吗?- hacks 的回答引用了 C 标准 (n1570-3.4.3 (P2)) 关于 UB 的后果,然后毫无理由地断言它适用于整个程序。从 C 标准中的措辞和 IDK 中看,这并不明显,如果还有其他相关内容的话。Bathsheba的回答是“矛盾的是,在此之前运行的语句的行为也是未定义的”,但没有具体说明这是在谈论C还是C++或两者兼而有之,也没有引用任何标准来支持它。

  • 具有未定义行为且从未实际执行的表达式是否会使程序出错? 问题,但@supercat发布了一个 答案说

    一旦程序进入没有定义事件序列的状态,C 编译器就可以执行任何它喜欢的事情,这将允许程序避免在将来的某个时间点调用未定义的行为

    他们没有引用标准来支持这一点,但他们评论了另一个问题:

    不要使用术语“一旦未定义的行为发生”,而是使用“一旦建立了使未定义行为不可避免的条件”。C 标准中的语言可能旨在使 Undefined Behavior 相对于其他代码不排序,但被一些编译器编写者解释为暗示它应该受时间或因果关系定律的约束。

    因此,听起来 C 不像 C++ 那样明确地缺乏对将遇到 UB 的执行要求。ISO C 标准中具体采用哪种语言,以及对它的这种解释的论据是什么,假设这实际上是编译器编写者的想法,但仍然选择不让他们的编译器沿着已经走向 UB 的路径优化掉可见的副作用。

    (@supercat 值得注意的是,现代 C 和 C++ 基于无 UB 假设的积极优化错过了标准原始作者的意图。特别是当这包括有符号整数溢出或比较不相关的指针时,这些指针在我们正在编译的机器上的 asm 中不是问题。这当然不是很好,但是将循环中的变量提升为指针宽度对于64位机器来说是一个相当重要的优化,所以很明显有理由开始走这条路,这让现代C和C++对程序员充满了地雷。int

在这个问题中,我要问的是书面的 ISO C 标准允许什么,无论是明确的还是根据任何普遍同意的解释。特别是这是否比编译器在我的测试用例中实际所做的更宽松。我不是在争论真正的编译器是否应该进一步优化;不这样做似乎是合理的。

C++ C GCC 语言 Lawyer 编译器 - 优化 undefined-behavior

评论

2赞 Jesper Juhl 9/21/2023
“未定义的行为是否追溯性地意味着不能保证早期可见的副作用?”——根据我的经验,是的。
1赞 Peter Cordes 9/21/2023
@RichardCritten:是的,我在问题中链接了这一点。它引用了 C++ 标准。我问的是 C 标准。(以及它与 C++ 有何不同,但也许我应该取消标记 C++,因为我相当有信心我对 C++ 标准语言的解释是正确的并且得到广泛认可。现已编辑。似乎人们普遍认为答案是肯定的,匹配 C++ 这是有道理的,但问题是 ISO C 标准的语言本身有什么理由?C++ 是否更清楚地说明了 C 在实践中已经存在的情况?
1赞 Peter Cordes 9/21/2023
@tadman:我试图将问题本身缩小到ISO C中是否允许,因此使用了[语言律师]标签。主流观点似乎是肯定的,但哪种措辞可以证明这一点?关于实际编译器行为的部分主要是为了表明答案在实践中并不明显,并将我展示的示例与其他示例区分开来。(我避免实际询问相关问题,例如GCC或Clang开发人员是否故意不在C++中进行此优化,因为他们需要在C中避免它,以及为什么即使使用非易失性,他们也错过了它......
2赞 user17732522 9/21/2023
WG14 文件日志上有一篇关于这个问题的相对较新的论文,根据其中包含的脚注,这是 UB 研究小组的结果:open-std.org/jtc1/sc22/wg14/www/docs/n3128.pdf
2赞 Peter Cordes 9/22/2023
@JesperJuhl:很公平,完全可以理解。我的实际问题或多或少得到了 open-std.org/jtc1/sc22/wg14/www/docs/n3128.pdf 的回答——编译器目前允许追溯效应,但它们在实践中不会影响可见的副作用,除了访问,因为在实践中 I/O 是由编译器无法假设甚至会返回的不透明库函数完成的。这就是为什么我觉得你的报告很有趣,因为它与 n3128 的期望相矛盾。可能是编译器错误,或者对 I/O 函数没有错误做出有效 (?) 假设。volatile

答:

0赞 supercat 10/30/2023 #1

除其他事项外,该标准旨在允许实现任意交错处理步骤的字符串,这些步骤与它们自己的字符串之外的操作没有可观察到的排序关系。如果一个没有指定可观察到副作用的操作紧跟在一些其他有副作用的操作之后,则编译器可能会在前面的操作之前对没有副作用的操作进行重新排序。如果尝试执行该操作触发了意想不到的副作用,则这种副作用可能会在“先前”已记录副作用的其他操作之前显现出来。

然而,该标准中的任何内容都不会禁止实现以不仅与代码执行顺序不一致的方式处理代码,甚至与正常的因果律不一致。给定如下序列:

unsigned test(unsigned x, unsigned mask)
{
  unsigned i=1;
  while((i & mask) != x)
    i*=3;
  if (x < someValue)
    doSomething(x);
  return i;
}

循环中的任何内容似乎都无法影响 的值,或者因此影响以下语句的行为,但是如果 clang 或 gcc 编译器可以显示上述函数的返回值永远不会被使用,并且大于 的最大可能值,他们可能会决定同时消除循环和条件检查的代码, 更改函数,使其无条件传递给 。xifsomeValuemaskxdoSomething()

评论

0赞 chqrlie 10/30/2023
+1.下面是一个更简单的示例:int fun(int a, int b) { int q; printf("a\n"); q = a / b; printf("b\n"); return q; }
0赞 supercat 10/30/2023
@chqrlie:你的例子可以说明许多人认为相对可取且不令人惊讶的交错。我的观点是要说明,clang 和 gcc 不受因果依赖的普通定律的约束,因此可能会以将最小惊讶原则抛到窗外的方式转换代码。
1赞 Peter Cordes 10/30/2023
我希望答案能指出ISO C标准中的一些特定措辞,并解释它如何证明你在第一段中描述的内容是合理的,即以C++明确放弃对早期行为的任何保证的方式解释它。但我认为它非常接近理解 ISO C 解读背后的思维过程,正如当前行为 open-std.org/jtc1/sc22/wg14/www/docs/n3128.pdf 中所述。
1赞 Peter Cordes 10/30/2023
我不否认目前的情况不是很好,但当前的 ISO C 标准确实将这个循环归类为 UB。编译器采用它并运行它......就像一把剪刀。这很糟糕,但与这个问题是一个单独的问题。
1赞 supercat 10/31/2023
@PeterCordes:此外,我的主要观点是,有一项一般规定允许将具有公认副作用的行为重新排序,而不是没有公认副作用的行动,而根据假设规则允许这样做的唯一方法是将任何可能具有未识别副作用的行为归类为UB。无限循环条款只是一般规则的一种应用,该规则非常侧重于允许时间旅行。