我如何制作一个不会被优化掉的无限空循环?

nne*_*neo 141 c clang compiler-optimization language-lawyer

C11 标准似乎暗示不应优化带有常量控制表达式的迭代语句。我从这个答案中得到了我的建议,它特别引用了标准草案中的第 6.8.5 节:

其控制表达式不是常量表达式的迭代语句......可能会被实现假定为终止。

在该答案中,它提到while(1) ;不应进行优化之类的循环。

那么……为什么 Clang/LLVM 优化了下面的循环(用 编译cc -O2 -std=c11 test.c -o test)?

#include <stdio.h>

static void die() {
    while(1)
        ;
}

int main() {
    printf("begin\n");
    die();
    printf("unreachable\n");
}
Run Code Online (Sandbox Code Playgroud)

在我的机器上,这会打印出begin,然后在非法指令ud2放置在 之后的陷阱die()上崩溃在 Godbolt 上,我们可以看到调用puts.

让 Clang 输出无限循环是一项非常困难的任务-O2- 虽然我可以反复测试一个volatile变量,但这涉及到我不想要的内存读取。如果我做这样的事情:

#include <stdio.h>

static void die() {
    while(1)
        ;
}

int main() {
    printf("begin\n");
    volatile int x = 1;
    if(x)
        die();
    printf("unreachable\n");
}
Run Code Online (Sandbox Code Playgroud)

... Clang 打印begin后跟unreachable好像无限循环从未存在过一样。

你如何让 Clang 在优化打开的情况下输出一个正确的、无内存访问的无限循环?

Lun*_*din 83

C11 标准是这样说的,6.8.5/6:

控制表达式不是常量表达式的迭代语句,156)不执行输入/输出操作,不访问易失性对象,并且在其主体中不执行同步或原子操作,控制表达式,或(在 for语句)它的表达式 3,可以被实现假设终止。157)

这两个脚注不是规范性的,但提供了有用的信息:

156) 一个省略的控制表达式被一个非零常量替换,这是一个常量表达式。

157)这是为了允许编译器转换,例如即使在无法证明终止时也可以删除空循环。

在您的情况下,while(1)是一个非常清晰的常量表达式,因此实现可能不会假设它终止。这样的实现将无可救药地被破坏,因为“永远”循环是一种常见的编程结构。

然而,据我所知,循环后“无法访问的代码”会发生什么,并没有明确定义。但是,clang 的行为确实很奇怪。将机器代码与 gcc (x86) 进行比较:

海湾合作委员会 9.2 -O3 -std=c11 -pedantic-errors

.LC0:
        .string "begin"
main:
        sub     rsp, 8
        mov     edi, OFFSET FLAT:.LC0
        call    puts
.L2:
        jmp     .L2
Run Code Online (Sandbox Code Playgroud)

叮当 9.0.0 -O3 -std=c11 -pedantic-errors

main:                                   # @main
        push    rax
        mov     edi, offset .Lstr
        call    puts
.Lstr:
        .asciz  "begin"
Run Code Online (Sandbox Code Playgroud)

gcc 生成循环,clang 刚进入树林并以错误 255 退出。

我倾向于这种不合规的叮当行为。因为我试图像这样进一步扩展你的例子:

#include <stdio.h>
#include <setjmp.h>

static _Noreturn void die() {
    while(1)
        ;
}

int main(void) {
    jmp_buf buf;
    _Bool first = !setjmp(buf);

    printf("begin\n");
    if(first)
    {
      die();
      longjmp(buf, 1);
    }
    printf("unreachable\n");
}
Run Code Online (Sandbox Code Playgroud)

我添加了 C11_Noreturn试图帮助编译器进一步发展。应该很清楚,这个函数将挂断,仅从那个关键字。

setjmp将在第一次执行时返回 0,所以这个程序应该只是撞到while(1)那里并停在那里,只打印“开始”(假设 \n 刷新标准输出)。这发生在 gcc 中。

如果循环被简单地删除,它应该打印“begin”2 次,然后打印“unreachable”。然而,在clang(godbolt)上,它会在返回退出代码0之前打印“begin”1次然后“unreachable”。无论你怎么写,这都是完全错误的。

我在这里找不到任何声明未定义行为的案例,所以我认为这是 clang 中的一个错误。无论如何,这种行为使 clang 100% 对嵌入式系统等程序毫无用处,在这些程序中,您必须能够依靠挂起程序的永恒循环(在等待看门狗等时)。

  • 我不同意*“这是一个非常清晰的常量表达式,因此实现可能不会假定它终止”*。这确实涉及到挑剔的语言律师,但“6.8.5/6”的形式是“如果(这些)那么你可以假设(这个)*”。这并不意味着*如果没有(这些),您可能不会假设(这个)*。它只是在满足条件时的规范,而不是在不满足条件时的规范,您可以在标准范围内做任何您想做的事情。如果没有可观察到的东西...... (16认同)
  • @kabanus 引用的部分是一个特例。如果不是(特殊情况),请像​​平常一样对代码进行评估和排序。如果您继续阅读同一章,则会按照为每个迭代语句指定的方式计算控制表达式(“按照语义指定”),但引用的特殊情况除外。它遵循与任何值计算的评估相同的规则,这是有序且明确定义的。 (8认同)
  • “这样的实现将无可救药地被破坏,因为‘永远’循环是一种常见的编程结构。” — 我理解这种情绪,但这个论点是有缺陷的,因为它可以同样地应用于 C++,但优化这个循环的 C++ 编译器不会被破坏,而是一致的。 (3认同)
  • 我同意,但你不会对 `int z=3; 感到惊讶。整数y=2;整数x=1;printf("%d %d\n", x, z);` 程序集中没有 `2`,因此在空无用的意义上,`x` 没有分配在 `y` 之后,而是在 `z` 之后,因为优化。因此,从您的最后一句话开始,我们遵循常规规则,假设暂停(因为我们没有受到更好的限制),并留在最终的“无法访问”打印中。现在,我们优化掉那个无用的语句(因为我们不知道更好)。 (2认同)
  • @kabanus:C 语义由源代码决定。优化器可能已经删除了将值 2 实际写入“y”的内存地址,但这并没有从源代码中删除逻辑语句。 (2认同)
  • @MSalters 我的一条评论已被删除,但感谢您的意见 - 我同意。我的评论所说的是,我认为这是争论的核心 - 在我们允许优化哪些语义方面,“while(1);”与“int y = 2;”语句相同,即使他们的逻辑仍然存在于源头。从 n1528 开始,我的印象是它们可能是相同的,但由于比我更有经验的人正在以相反的方式争论,而且这显然是一个官方错误,因此超出了关于标准中的措辞是否明确的哲学辩论,这个论点变得毫无意义。 (2认同)

P__*_*J__ 60

您需要插入一个可能导致副作用的表达式。

最简单的解决方案:

static void die() {
    while(1)
       __asm("");
}
Run Code Online (Sandbox Code Playgroud)

Godbolt 链接

  • 然而,并没有解释 clang 为何会出现问题。 (23认同)
  • 只需说“这是 clang 中的错误”就足够了。不过,在我大喊“bug”之前,我想先在这里尝试一些事情。 (4认同)
  • 幸运的是,GCC 是开源的,我可以编写一个编译器来优化您的示例。无论现在还是将来,我都可以为您提出的任何示例这样做。 (4认同)
  • @ThomasWeller:GCC 开发人员不会接受优化此循环的补丁;它将违反记录=保证的行为。请参阅我之前的评论: `asm("")` 隐式地是 `asm volatile("");`,因此 asm 语句必须运行与抽象机中一样多的次数 https://gcc.gnu.org/ onlinedocs/gcc/Basic-Asm.html。(请注意,包含任何内存或寄存器的副作用是*不*安全的;如果您想读取或写入从 C 访问过的内存,则需要带有“内存”破坏器的扩展汇编语言。基本汇编语言只是对于 `asm("mfence")` 或 `cli` 之类的东西是安全的。) (4认同)
  • @Lundin 我不知道这是否是一个错误。在这种情况下,标准在技术上并不精确 (3认同)
  • @nneonneo:GNU C Basic asm 语句隐式是“易失性”的,就像没有输出操作数的扩展 Asm 语句一样。如果您编写了 `asm("" : "=r"(dummy));` 并且没有使用 `dummy` 结果,那么它*将会*被优化掉。您需要“asm 易失性”来告诉编译器存在副作用(或读取不断变化的输入,如 rdtsc)*以及*生成输出的直接影响。所以是的,副作用无法被优化掉,但关键是编译器是否假设存在副作用!https://gcc.gnu.org/onlinedocs/gcc/Extended-Asm.html#Volatile (2认同)
  • 是的,如果您将分配给“易失性 int 接收器;”作为副作用,那么优化“您现在和将来想到的任何示例”中的循环的编译器甚至必须违反 ISO C 规则。到那时,没有人关心编译器的可移植性,甚至不再是 C 编译器。 (2认同)

Arn*_*ion 58

其他答案已经涵盖了使用内联汇编语言或其他副作用使 Clang 发出无限循环的方法。我只是想确认这确实是一个编译器错误。具体来说,这是一个长期存在的 LLVM 错误——它将 C++ 概念“没有副作用的所有循环必须终止”应用于不应该终止的语言,例如 C。该错误最终在 LLVM 12 中得到修复。

例如,Rust 编程语言也允许无限循环并使用 LLVM 作为后端,它也有同样的问题。

LLVM 12 添加了一个mustprogress前端可以省略的属性,以指示函数何时不一定返回,并且更新了 clang 12 以解决这个问题。您可以看到您的示例使用 clang 12.0.0正确编译使用 clang 11.0.1则没有

  • 没有什么能比得上一个十多年前的错误的味道了……有多个建议的修复和补丁……但仍然没有得到修复。 (8认同)
  • 它已被“修复”,因为 LLVM 添加了“副作用”操作(在 2017 年),并期望前端自行决定将该操作插入到循环中。LLVM 必须选择*一些*默认的 for 循环,而且它碰巧有意或无意地选择了与 C++ 行为一致的循环。当然,还有一些优化工作需要完成,例如将连续的“副作用”操作合并为一个。(这就是阻止 Rust 前端使用它的原因。)因此,在此基础上,错误出现在前端 (clang) 中,它不会在循环中插入操作。 (5认同)
  • @IanKemp:对于他们来说,现在修复该错误需要承认他们花了十年的时间来修复该错误。最好抱有希望,希望标准会改变以证明他们的行为合理。当然,即使标准确实发生了变化,这仍然不能证明他们的行为是合理的,除非在那些认为标准的变化表明标准早期的行为要求是一个缺陷、应该追溯纠正的人看来。 (4认同)
  • 该讨论可能属于 LLVM / clang 邮件列表。FWIW 添加该操作的 LLVM 提交也确实教授了一些关于它的优化过程。此外,Rust 尝试在每个函数的开头插入“副作用”操作,但没有看到任何运行时性能回归。唯一的问题是*编译时间*回归,显然是由于缺乏连续操作的融合,就像我在之前的评论中提到的那样。 (2认同)
  • 然而,他们花了 15(!)年的时间才修复这样的重大错误,这真是可悲。我现在永远(双关语)失去了对这个编译器的信任。由于这个 bug 不被认为是严重的或优先修复的,所以 clang 似乎是由一群 PC 程序员维护的。在过去的二十年中,每一个犯了使用此编译器错误的裸机嵌入式系统都可能是一颗定时炸弹,因为所有此类系统都依赖于 main() 中的永恒循环。 (2认同)

Pet*_*des 34

这是一个 Clang 错误

... 内联包含无限循环的函数时。当while(1);直接出现在 main 中时,行为是不同的,这对我来说很臭。

有关摘要和链接,请参阅@Arnavion 的回答。这个答案的其余部分是在我确认这是一个错误之前写的,更不用说一个已知的错误了。


回答标题问题:如何制作一个不会被优化掉的无限空循环?? -在 Clang 3.9 及更高版本中
创建die()一个宏,而不是一个函数来解决这个错误。(早期的 Clang 版本要么保持循环,要么call使用无限循环向函数的非内联版本发出 a。)即使print;while(1);print;函数内联到调用者(Godbolt)中,这似乎也是安全的。 -std=gnu11vs.-std=gnu99不会改变任何东西。

如果您只关心 GNU C,循环内的P__J____asm__("");也可以工作,并且不应该损害任何理解它的编译器对任何周围代码的优化。GNU C Basic asm 语句是隐式的volatile,因此这被视为一个可见的副作用,它必须像在 C 抽象机器中那样多次“执行”。(是的,Clang 实现了 C 的 GNU 方言,如 GCC 手册所述。)


有些人认为优化掉一个空的无限循环可能是合法的。我不同意1,但即使我们接受这一点,就不能成为合法的Clang的承担循环之后的语句是不可达,并让执行脱落功能的结束到下一个功能,或者进入垃圾解码为随机指令。

(这将符合 Clang++ 的标准(但仍然不是很有用);没有任何副作用的无限循环是 C++ 中的 UB,但不是 C。
是 while(1);C 中的未定义行为? UB 使编译器基本上可以发出任何内容对于肯定会遇到 UB 的执行路径上的代码。asm循环中的语句将避免 C++ 的 UB。但实际上,Clang 编译为 C++ 不会删除常量表达式无限空循环,除非内联时,与 when 相同编译为 C。)


手动内联会while(1);改变 Clang 的编译方式:asm 中存在无限循环。 这是我们对规则律师 POV 的期望。

#include <stdio.h>
int main() {
    printf("begin\n");
    while(1);
    //infloop_nonconst(1);
    //infloop();
    printf("unreachable\n");
}
Run Code Online (Sandbox Code Playgroud)

在 Godbolt 编译器资源管理器上,Clang 9.0 -O3 编译为 C ( -xc) for x86-64:

main:                                   # @main
        push    rax                       # re-align the stack by 16
        mov     edi, offset .Lstr         # non-PIE executable can use 32-bit absolute addresses
        call    puts
.LBB3_1:                                # =>This Inner Loop Header: Depth=1
        jmp     .LBB3_1                   # infinite loop


.section .rodata
 ...
.Lstr:
        .asciz  "begin"
Run Code Online (Sandbox Code Playgroud)

具有相同选项的同一个编译器编译 amain调用infloop() { while(1); }相同的 first puts,但在那之后停止发出指令main。因此,正如我所说,执行只是从函数的末尾开始,进入下一个函数(但堆栈未对齐以用于函数入口,因此它甚至不是有效的尾调用)。

有效的选择是

  • 发出label: jmp label无限循环
  • 或者(如果我们接受可以删除无限循环)发出另一个调用以打印第二个字符串,然后return 0main.

崩溃或以其他方式继续而不打印“无法访问”显然不适用于 C11 实现,除非有我没有注意到的 UB。


脚注 1:

作为记录,我同意@Lundin 的回答,该回答引用了证据标准,即 C11 不允许假设常量表达式无限循环终止,即使它们为空(无 I/O、易失性、同步或其他可见的副作用)。

这是一组条件,可以将循环编译为普通 CPU的空 asm 循环。(即使源中的主体不是空的,在循环运行时,没有数据竞争 UB 的其他线程或信号处理程序也无法看到对变量的赋值。因此,如果需要,符合要求的实现可以删除此类循环主体到。然后就留下了是否可以删除循环本身的问题。ISO C11 明确说不。)

鉴于 C11 将这种情况作为一种实现不能假设循环终止(并且它不是 UB)的情况,很明显他们打算在运行时出现循环。以 CPU 为目标的实现,其执行模型不能在有限时间内完成无限量的工作,没有理由删除空的常量无限循环。甚至一般来说,确切的措辞是关于它们是否可以“假设终止”。如果循环无法终止,则意味着无法访问后面的代码,无论对数学和无穷大提出什么论点,以及在某个假设的机器上完成无限量的工作需要多长时间。

此外,Clang 不仅仅是符合 ISO C 的 DeathStation 9000,它还旨在用于现实世界的低级系统编程,包括内核和嵌入式内容。 因此,无论您是否接受有关 C11允许删除 的论点while(1);,Clang 想要真正这样做是没有意义的。如果你写while(1);,那可能不是意外。删除意外结束的循环(使用运行时变量控制表达式)可能很有用,编译器这样做是有意义的。

您很少想一直旋转直到下一个中​​断,但是如果您用 C 编写它,那绝对是您期望发生的事情。(以及在 GCC 和 Clang会发生什么,当无限循环在包装函数内时 Clang 除外)。

例如,在原始 OS 内核中,当调度程序没有要运行的任务时,它可能会运行空闲任务。第一个实现可能是while(1);.

或者对于没有任何省电空闲功能的硬件,这可能是唯一的实现。(直到 2000 年代初,我认为这在 x86 上并不少见。虽然该hlt指令确实存在,但 IDK 如果在 CPU 开始处于低功耗空闲状态之前节省了大量电量。)

  • https://bugs.llvm.org/show_bug.cgi?id=965 这个错误看起来相关,但我不确定这就是我们在这里看到的。 (2认同)
  • @JeffLearman 最近成为主流,那么?不管怎样,gcc 严格别名的惨败只发生在 C99 引入之后,而且它的新版本在遇到严格别名违规时似乎也不再发疯了。尽管如此,每当我使用它时我仍然持怀疑态度。至于 clang,最新版本显然在永恒循环方面完全被破坏了,因此它不能用于嵌入式系统。 (2认同)

jon*_*njo 15

只是为了记录,Clang 也行为不端goto

static void die() {
nasty:
    goto nasty;
}

int main() {
    int x; printf("begin\n");
    die();
    printf("unreachable\n");
}
Run Code Online (Sandbox Code Playgroud)

它产生与问题相同的输出,即:

main: # @main
  push rax
  mov edi, offset .Lstr
  call puts
.Lstr:
  .asciz "begin"
Run Code Online (Sandbox Code Playgroud)

我看不到 C11 中允许的任何方式来阅读它,它只说:

6.8.6.1(2)goto语句导致无条件跳转到封闭函数中以命名标签为前缀的语句。

由于goto不是“迭代声明”(6.8.5 列表whiledofor),没有任何关于特殊“假设终止”的放纵适用,但是您想阅读它们。

每个原始问题的 Godbolt 链接编译器是 x86-64 Clang 9.0.0,标志是 -g -o output.s -mllvm --x86-asm-syntax=intel -S --gcc-toolchain=/opt/compiler-explorer/gcc-9.2.0 -fcolor-diagnostics -fno-crash-diagnostics -O2 -std=c11 example.c

使用 x86-64 GCC 9.2 等其他版本,您将获得非常完美的效果:

.LC0:
  .string "begin"
main:
  sub rsp, 8
  mov edi, OFFSET FLAT:.LC0
  call puts
.L2:
  jmp .L2
Run Code Online (Sandbox Code Playgroud)

标志: -g -o output.s -masm=intel -S -fdiagnostics-color=always -O2 -std=c11 example.c

  • @supercat 感谢您的评论...为什么超出翻译限制除了使翻译阶段失败并拒绝执行之外还会做任何其他事情?另外:“**5.1.1.3 诊断** 如果预处理翻译单元或翻译单元包含违反**任何语法规则或约束** ...,则符合要求的实现应产生...诊断消息...”。我看不出执行阶段的错误行为如何能够符合。 (2认同)

PSk*_*cik 6

我将扮演魔鬼的拥护者并争辩说标准没有明确禁止编译器优化无限循环。

控制表达式不是常量表达式的迭代语句,156)不执行输入/输出操作,不访问易失性对象,并且在其主体中不执行同步或原子操作,控制表达式,或(在 for语句)它的表达式 3,可以被实现假定为终止。157)

我们来解析一下。可以假设满足某些标准的迭代语句终止:

if (satisfiesCriteriaForTerminatingEh(a_loop)) 
    if (whatever_reason_or_just_because_you_feel_like_it)
         assumeTerminates(a_loop);
Run Code Online (Sandbox Code Playgroud)

这并没有说明如果不满足标准会发生什么,并且假设循环可能会终止,只要遵守标准的其他规则,即使这样也不会明确禁止。

do { } while(0)或者while(0){}毕竟是不满足条件的迭代语句(循环),这些条件允许编译器随心所欲地假设它们终止,但它们显然确实终止了。

但是编译器可以优化while(1){}吗?

5.1.2.3p4说:

在抽象机中,所有表达式都按照语义指定的方式进行评估。如果一个实际的实现可以推断出它的值未被使用并且没有产生所需的副作用(包括由调用函数或访问易失性对象引起的任何副作用),则它不需要计算表达式的一部分。

这提到了表达式,而不是语句,所以它不是 100% 令人信服的,但它肯定允许这样的调用:

void loop(void){ loop(); }

int main()
{
    loop();
}
Run Code Online (Sandbox Code Playgroud)

被跳过。有趣的是,clang 确实跳过了它,而 gcc 没有

  • +1尽管在我看来“执行无限期挂起而没有任何输出”是“副作用”的任何定义中的“副作用”,它是有意义的并且除了真空中的标准之外也是有用的,这有助于解释对某人来说有意义的心态。 (3认同)