【问题标题】:Function optimized to infinite loop at 'gcc -O2'函数优化为“gcc -O2”处的无限循环
【发布时间】:2015-04-22 06:33:56
【问题描述】:

上下文
我的一位朋友问我以下谜题:

void fn(void)
{
  /* write something after this comment so that the program output is 10 */
  /* write something before this comment */
}

int main()
{
  int i = 5;
  fn();
  printf("%d\n", i);
  return 0;
}

我知道可以有多种解决方案,有些涉及宏,有些假设有关实现并违反 C。

我感兴趣的一个特殊解决方案是对堆栈做出某些假设并编写以下代码:(我理解这是未定义的行为,但在许多实现中可能会按预期工作)

void fn(void)
{
  /* write something after this comment so that the program output is 10 */
  int a[1] = {0};
  int j = 0;
  while(a[j] != 5) ++j;  /* Search stack until you find 5 */
  a[j] = 10;             /* Overwrite it with 10 */
  /* write something before this comment */
}

问题
该程序在 MSVC 和 gcc 中运行良好,无需优化。但是当我用gcc -O2 标志编译它或尝试ideone 时,它在函数fn 中无限循环。

我的观察
当我用gcc -Sgcc -S -O2 编译文件并进行比较时,它清楚地表明gcc 在函数fn 中保持了无限循环。

问题
我理解是因为代码调用了未定义的行为,不能将其称为错误。但是编译器为什么以及如何分析行为并在O2 处留下无限循环?


许多人评论说如果将某些变量更改为 volatile 会发生什么行为。预期的结果是:

  • 如果将ij 更改为volatile,程序行为保持不变。
  • 如果将数组a 设为volatile,则程序不会出现无限循环。
  • 此外,如果我应用以下补丁
-  int a[1] = {0};
+  int aa[1] = {0};
+  int *a = aa;

程序行为保持不变(无限循环)

如果我用gcc -O2 -fdump-tree-optimized 编译代码,我会得到以下中间文件:

;; Function fn (fn) (executed once)

Removing basic block 3
fn ()
{
<bb 2>:

<bb 3>:
  goto <bb 3>;

}



;; Function main (main) (executed once)

main ()
{
<bb 2>:
  fn ();

}
Invalid sum of incoming frequencies 0, should be 10000

这将验证在以下答案之后所做的断言。

【问题讨论】:

  • 解决这个难题的一个可能方法是将return;放在函数体中(注释write something before this comment之前),并在调用fn之前或之后放置i = 10;(这是在评论write something after this comment之后)。这可能是预期的解决方案,但我更喜欢你的策略。
  • void fn()printf("%d\n", 10); exit(0);没有UB。
  • @chux 我比我更喜欢#define fn() i=10
  • 我对替代解决方案不感兴趣。我想知道为什么用这个解决方案无限循环。
  • 这里描述和解释了一个非常相似的事情blogs.msdn.com/b/oldnewthing/archive/2014/06/27/10537746.aspx

标签: c gcc optimization undefined-behavior


【解决方案1】:

这是未定义的行为,因此编译器实际上可以做任何事情,我们可以在GCC pre-4.8 Breaks Broken SPEC 2006 Benchmarks 中找到类似的示例,其中gcc 采用未定义行为的循环并将其优化为:

L2:
    jmp .L2

文章说(强调我的):

当然这是一个无限循环。由于SATD()无条件 执行未定义的行为(它是类型 3 函数),any 翻译(或根本没有)是完全可以接受的行为 正确的 C 编译器。未定义的行为只是访问 d[16] 在退出循环之前。在 C99 中,创建指向的指针是合法的 一个元素在数组末尾之后的一个位置,但该指针 不得取消引用。同样,数组单元格过去一个元素 不能访问数组的末尾。

如果我们使用 godbolt 检查您的程序,我们会看到:

fn:
.L2:
    jmp .L2

优化器使用的逻辑大概是这样的:

  • a 的所有元素都初始化为零
  • a 在循环之前或循环内永远不会被修改
  • 所以a[j] != 5 始终为真 -> 无限循环
  • 由于无限,a[j] = 10; 无法访问,因此可以优化掉,aj 也可以,因为不再需要它们来确定循环条件。

这与文章中给出的情况类似:

int d[16];

分析以下循环:

for (dd=d[k=0]; k<16; dd=d[++k]) 

像这样:

在看到 d[++k] 时,允许假设增加的值 k 在数组范围内,因为否则未定义的行为 发生。对于这里的代码,GCC 可以推断出 k 在 0..15 范围内。 稍后,当 GCC 看到 k

也许一个有趣的次要点是无限循环是否被认为是可观察的行为(w.r.t. to the as-if rule),这会影响无限循环是否也可以被优化掉。从C Compilers Disprove Fermat’s Last Theorem我们可以看出,在C11之前至少还有一些解释的空间:

许多知识渊博的人(包括我)读到这篇文章时说, 不得更改程序的终止行为。显然有些 编译器作者不同意,否则不相信这很重要。这 理性的人不同意这种解释的事实似乎 表示 C 标准存在缺陷。

C11 增加了对 6.8.5 部分的说明迭代语句 并在 this answer 中有更详细的介绍。

【讨论】:

  • 我希望标准能够为各种形式的未定义行为定义一些规范模型。许多程序可以从不允许完全不受约束的 UB 的行为模型中受益,但会允许许多有用的优化。一个相当典型的模型会说,可能存在地址,如果写入或读取,可能会导致其他内存内容被任意重写(读取触发的地址在现实世界中几乎是未知的),并且编译器可以安排变量,以便越界数组访问会命中这些地址。在这样的模式下……
  • ...在d[k++] 之后省略边界检查可以在as-if 规则下得到证明。如果代码希望指定一个模型,其中任何地址的任何读取都会产生一个值(某些硬件平台为真),或者任何地址的任何读取都必须产生一个值或以实现定义的方式捕获的模型,这将排除随后的代码执行(许多硬件平台都是如此),省略绑定检查会改变可观察的行为。
【解决方案2】:

在优化版本中,编译器决定了几件事:

  1. 数组a 在该测试之前不会改变。
  2. 数组a 不包含5

因此,我们可以将代码改写为:

void fn(void) {
  int a[1] = {0};
  int j = 0;
  while(true) ++j;
  a[j] = 10;
}

现在,我们可以做出进一步的决定:

  1. while 循环之后的所有代码都是死代码(无法访问)。
  2. j 已写入但从未读取。这样我们就可以摆脱它了。
  3. a 永远不会被阅读。

此时,您的代码已简化为:

void fn(void) {
  int a[1] = {0};
  while(true);
}

我们可以注意到a 现在永远不会被读取,所以让我们也摆脱它:

void fn(void) {
  while(true);
}

现在,未优化的代码:

在未优化的生成代码中,数组将保留在内存中。你会在运行时真正地走它。一旦你走过数组的末端,它之后可能会有一个可读的5

这就是为什么未优化的版本有时不会崩溃和烧毁的原因。

【讨论】:

    【解决方案3】:

    如果循环确实被优化为无限循环,这可能是由于静态代码分析发现您的数组是

    1. 不是volatile

    2. 仅包含0

    3. 永远不会被写入

    因此它不可能包含数字5。这意味着无限循环。

    即使没有这样做,您的方法也很容易失败。例如,某些编译器可能会优化您的代码而不使您的循环无限,但会将i 的内容填充到寄存器中,使其无法从堆栈中获取。

    顺便说一句,我敢打赌你朋友的实际预期是这样的:

    void fn(void)
    {
      /* write something after this comment so that the program output is 10 */
      printf("10\n"); /* Output 10 */
      while(1); /* Endless loop, function won't return, i won't be output */
      /* write something before this comment */
    }
    

    或者这个(如果包含stdlib.h):

    void fn(void)
    {
      /* write something after this comment so that the program output is 10 */
      printf("10\n"); /* Output 10 */
      exit(0); /* Exit gracefully */
      /* write something before this comment */
    }
    

    【讨论】:

    • } int main() { /* do whatever */ #define main this_is_not_main
    • 您的第一个解决方案实际上可能不会输出任何内容:您需要在 while 循环之前进行 fflush。
    • @EmilJeřábek:嗯,你为什么这么认为?我的印象是printf 在每个换行符上都会自动刷新...
    • 否,这取决于标准输出的当前缓冲模式,可以显式设置,也可以具有实现定义的默认值。在实践中,我看到的是 stdout 在连接到终端时以行缓冲开始,否则完全缓冲,但 YMMV。
    猜你喜欢
    • 2020-03-08
    • 2015-05-13
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 2015-12-15
    • 1970-01-01
    • 1970-01-01
    相关资源
    最近更新 更多