如何创建一个不会被优化掉的无限空循环?

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");
}

在我的机器上,打印出 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");
}

... Clang 打印 begin后面跟着 unreachable就好像无限循环不存在一样。

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

33120 次浏览

我已经确信这只是一个普通的老虫子。我把我的测试留在下面,特别是参考标准委员会的讨论,因为我之前有一些理由。


我认为这是一个未定义行为,Clang 只有一个实现。GCC 确实如您所期望的那样工作,只优化了 unreachable打印语句,但是留下了循环。Clang 在组合内联和确定它可以用循环做什么时,做出的决定有些奇怪。

这种行为非常奇怪——它删除了最后的打印,所以“看到”了无限循环,但是也删除了循环。

据我所知,情况更糟。移除内联我们得到:

die: # @die
.LBB0_1: # =>This Inner Loop Header: Depth=1
jmp .LBB0_1
main: # @main
push rax
mov edi, offset .Lstr
call puts
.Lstr:
.asciz "begin"

因此,函数被创建,调用被优化。这比预期的更有弹性:

#include <stdio.h>


void die(int x) {
while(x);
}


int main() {
printf("begin\n");
die(1);
printf("unreachable\n");
}

结果导致函数非常不优化的组装,但函数调用再次被优化了! 更糟糕的是:

void die(x) {
while(x++);
}


int main() {
printf("begin\n");
die(1);
printf("unreachable\n");
}

我做了一堆其他的测试,添加一个局部变量并增加它,传递一个指针,使用 goto等等。.在这一点上,我会放弃。如果你一定要用叮当

static void die() {
int volatile x = 1;
while(x);
}

完成任务。它不擅长优化(很明显) ,最后只剩下冗余的 printf了。至少程序没有停止。也许海湾合作委员会?

附录

经过与 David 的讨论,我得出结论,标准并没有说“如果条件是常量,则不能假定循环终止”。因此,在标准下没有可观察到的行为(如标准中定义的) ,我认为只有一致性——如果编译器正在优化一个循环,因为它假定它终止,它不应该优化以下语句。

如果我没看错的话,这些未定义行为就是海克 N1528

这样做的一个主要问题是,它允许代码在可能不终止的循环中移动

从这里我认为它只能下放到什么我们 想要(预期?)的讨论而不是被允许的。

该循环没有任何副作用,因此可以优化出来。

这个循环实际上是一个无限次的零工作单元迭代。这在数学和逻辑上都是未定义的,标准并没有说如果一个实现可以在零时间内完成,那么它是否允许完成无限多的事情。克朗的解释是完全合理的,把无穷大乘以零看作是零而不是无穷大。标准并没有说如果循环中的所有工作实际上都完成了,那么无限循环是否可以结束。

编译器被允许优化标准中定义的任何不可观察的行为。包括执行时间。不需要保留这样一个事实,即循环如果不优化,将需要无限长的时间。允许将其更改为更短的运行时间ーー实际上,这是大多数优化的要点。你的循环被优化了。

即使 Clang 天真地翻译了代码,您也可以想象一个优化的 CPU,它可以用前一次迭代所花费时间的一半来完成每次迭代。这将在有限的时间内完成无限循环。这样一个优化的 CPU 是否违反了标准?如果一个优化的 CPU 过于擅长优化,那么它就会违反标准,这种说法似乎很荒谬。编译器也是如此。

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

最简单的解决办法是:

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

Godbolt 链接

C11标准是6.8.5/6:

其控制表达式不是常量表达式的迭代语句 不执行任何输入/输出操作,不访问易失性对象,并执行 no 控制表达式或(在 for 语句的情况下)其表达式 -3的同步或原子操作,可以由实现假定为 终止。 < sup > 157)

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

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

157)这是为了允许编译器转换,如删除空循环,即使当 终止不能被证明。

在您的例子中,while(1)是一个非常清晰的常量表达式,因此实现可能假定 没有终止。由于“永久”循环是一种常见的编程构造,这样的实现将无可救药地中断。

然而,据我所知,循环之后“无法访问的代码”会发生什么情况,并没有得到很好的定义。然而,叮当的确表现得很奇怪。将机器代码与 gcc (x86)进行比较:

Gcc 9.2 -O3 -std=c11 -pedantic-errors

.LC0:
.string "begin"
main:
sub     rsp, 8
mov     edi, OFFSET FLAT:.LC0
call    puts
.L2:
jmp     .L2

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

main:                                   # @main
push    rax
mov     edi, offset .Lstr
call    puts
.Lstr:
.asciz  "begin"

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");
}

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

在第一次执行时,setjmp将返回0,所以这个程序应该直接撞到 while(1)并停在那里,只打印“ start”(假设 n 刷新 stdout)。GCC 就是这样。

如果循环被简单地删除,它应该打印“开始”2次,然后打印“无法到达”。然而,在 clang (Godbolt)上,它在返回退出代码0之前输出“ start”1次,然后输出“ unreach”。不管你怎么说,这都是错误的。

我在这里找不到任何未定义行为,所以我认为这是一种“ clang”语中的错误。无论如何,这种行为使得叮当100% 对于嵌入式系统这样的程序没有用处,在嵌入式系统中,您必须能够依赖挂起程序的永久循环(同时等待看门狗等)。

我将扮演魔鬼的拥护者,并且认为这个标准并没有明确禁止编译器优化出一个无限循环。

其控制表达式不是常数的迭代语句 表达式(156)不执行输入/输出操作 访问易失性对象,并且不执行同步或原子操作 操作,控制表达式,或者(在 语句)的表达式 -3,可由实现假定 终止

让我们来解析一下: 一个满足特定条件的迭代语句可以被假定为终止:

if (satisfiesCriteriaForTerminatingEh(a_loop))
if (whatever_reason_or_just_because_you_feel_like_it)
assumeTerminates(a_loop);

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

do { } while(0)while(0){}是所有的迭代语句(循环) ,它们不满足允许编译器随意假设它们终止的条件,但它们显然确实终止了。

但是编译器能够优化 while(1){}吗?

5.1.2.3 p4 说:

在抽象机器中,所有表达式都按照 实际的实现不需要计算 如果它能推断出它的值没有被使用,并且没有 产生所需的副作用(包括调用 函数或访问易失性对象)。

这里提到的是表达方式,而不是陈述,所以它不是100% 令人信服,但它肯定允许这样的呼叫:

void loop(void){ loop(); }


int main()
{
loop();
}

有趣的是,Clang 确实跳过了,而 GCC 没有

这是 Clang 虫

当嵌入一个包含无限循环的函数时。当 while(1);直接出现在主机上时,这种行为是不同的,我觉得这种行为很有问题。

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


回答题目问题: 如何创建一个不会被优化掉的无限空循环??-
使 die()成为一个宏,而不是一个 函数,以便在 Clang 3.9及更高版本中解决这个 bug。(早期的 Clang 版本要么将 保持循环或发出一个 call转换为具有无限循环的函数的非内联版本。)即使 print;while(1);print;函数内联到 它的调用程序(戈德博尔特)中,这似乎也是安全的。-std=gnu11-std=gnu99不会改变任何事情。

如果您只关心 GNU C,那么循环中的 P _ _ J _ _ 的 __asm__("");也可以工作,并且不会影响任何编译器对周围代码的优化。GNU C Basic asm 语句是 隐式 volatile,所以这算是一个可见的副作用,它必须像在 C 抽象机器中那样多次“执行”。(是的,Clang 实现了 GCC 手册中记录的 C 的 GNU 方言。)


有些人认为,优化掉一个空的无限循环可能是合法的。我不同意 1,但是即使我们接受它,对于 Clang 来说,在循环无法访问之后假设语句是不合法的,和让执行从函数的末尾落到下一个函数中,或者进入随机指令解码的垃圾中。

(这对于 Clang + + 来说是标准兼容的(但仍然不是非常有用) ; 没有任何副作用的无限循环是 C + + 中的 UB,而不是 C。
在 C 中是 while (1) ; 的未定义行为吗?UB 允许编译器为执行路径上肯定会遇到 UB 的代码发出基本上任何内容。循环中的 asm语句可以避免 C + + 的这种 UB。但实际上,作为 C + + 的 Clang 编译并不会去除常量表达式的无限空循环,除非在内联时,就像 C 编译时一样


手动内嵌 while(1);改变了 Clang 编译它的方式: 无限循环存在于空间中。这就是我们对规则的期望——律师 POV。

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

在 Godbolt 编译器浏览器 上,将 Clang 9.0-O3编译为 C (-xc) ,用于 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"

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

有效的选项是

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

崩溃或其他继续没有打印“不可到达”显然不是没有问题的 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 中会发生什么?)。

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

或者对于没有任何节能空闲特性的硬件,这可能是唯一的实现。(直到21世纪初,我认为这在 x86上并不罕见。虽然 hlt指令确实存在,但是如果 IDK 在 CPU 开始进入低功耗空闲状态之前节省了大量的电力,那么它就是 IDK。)

以下内容似乎对我有用:

#include <stdio.h>


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


int main(void) {
printf("begin\n");
die();
printf("unreachable\n");
}

Godbolt

显式地告诉 Clang 不要优化某个函数,这会导致按预期发出无限循环。希望有一种方法可以选择性地禁用特定的优化,而不是像那样把它们都关掉。不过,Clang 仍然拒绝发出第二个 printf的代码。为了迫使它这样做,我不得不进一步修改 main中的代码:

volatile int x = 0;
if (x == 0)
die();

看起来您需要禁用对无限循环函数的优化,然后确保有条件地调用无限循环。在现实世界中,后者几乎总是如此。

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

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

LLVM 12添加了一个 mustprogress属性,前端可以忽略这个属性,以指示函数什么时候不一定返回,而 clang 12则进行了更新,以解决这个问题。您可以看到,您的示例正确编译了 与叮当12.0,而没有编译 与叮当11.0。1

郑重声明,Clang 在 goto上也有不当行为:

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


int main() {
int x; printf("begin\n");
die();
printf("unreachable\n");
}

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

main: # @main
push rax
mov edi, offset .Lstr
call puts
.Lstr:
.asciz "begin"

我看不出有什么方法可以解读 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

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

一个符合规范的实现可能,而且许多实际的实现确实会对一个程序可以执行多长时间或者执行多少指令施加任意的限制,如果这些限制被违反,或者——根据“似”规则——如果它确定它们将不可避免地被违反,那么它就会以任意的方式行事。如果一个实施项目能够成功地处理至少一个名义上达到 N15705.2.4.1中列出的所有限制而没有达到任何翻译限制的项目,那么限制的存在、限制被记录的程度以及超过限制的影响都是标准管辖范围之外的实施质量问题。

我认为标准的意图很清楚,编译器不应该假定一个没有副作用的 while(1) {}循环或者 break语句将会终止。与一些人可能认为的相反,标准的作者并没有邀请编译器作者愚蠢或迟钝。一个符合规范的实现可以有效地决定终止任何程序,如果不被打断的话,它会执行比宇宙中原子还多的无副作用指令,但是一个高质量的实现不应该基于任何关于终止的假设来执行这样的行动,而是基于这样做可能是有用的,并且不会(不像 Clang 的行为)比无用更糟糕。

这似乎是 Clang 编译器中的一个错误。如果对 die()函数没有任何强制要求成为一个静态函数,那么就去掉 static,使它成为 inline:

#include <stdio.h>


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


int main(void) {
printf("begin\n");
die();
printf("unreachable\n");
}

在使用 Clang 编译器编译时,它能够正常工作,而且也是可移植的。

编译器资源管理器(Godbolt.org) -Clang 9.0.0 -O3 -std=c11 -pedantic-errors

main:                                   # @main
push    rax
mov     edi, offset .Lstr
call    puts
.LBB0_1:                                # =>This Inner Loop Header: Depth=1
jmp     .LBB0_1
.Lstr:
.asciz  "begin"

如果情况不是这样的话,我很抱歉,但是我偶然发现了这篇文章,我知道,因为我多年来使用 Gentoo Linux发行版,如果你想让编译器不优化你的代码,你应该使用 (零)。

我很好奇。我编译并运行了上面的代码,循环确实无限期地进行了下去。它是用克朗9编译的:

cc -O0 -std=c11 test.c -o test