上下文
我被一位朋友问到了以下谜题:
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 -S
vs gcc -S -O2
编译文件并进行比较时,它清楚地显示gcc
在函数fn
中保持无限循环。
问题
我理解,因为代码调用未定义的行为,不能称之为bug。但是为什么以及如何编译器分析行为并在O2
留下无限循环?
如果将某些变量更改为volatile,许多人会评论该行为。预期的结果是:
i
或j
更改为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
这将验证以下答案后的断言。
答案 0 :(得分:50)
这是未定义的行为,因此编译器可以真正做任何事情,我们可以在GCC pre-4.8 Breaks Broken SPEC 2006 Benchmarks中找到类似的示例,其中gcc
采用具有未定义行为的循环并将其优化为:
L2:
jmp .L2
文章说(强调我的):
当然这是一个无限循环。由于SATD()无条件 执行未定义的行为(它是类型3函数),任何 翻译(或根本没有)是一个完全可以接受的行为 正确的C编译器。未定义的行为只是访问d [16] 在退出循环之前。在C99 中创建指针是合法的 一个元素位于数组末尾的位置,但该指针 不得解除引用。同样,数组单元格过去一个元素 不得访问数组的末尾。
如果我们使用godbolt审核您的计划,我们会看到:
fn:
.L2:
jmp .L2
优化器使用的逻辑可能是这样的:
a
的所有元素都已初始化为零a
永远不会在循环之前或之内修改a[j] != 5
总是如此 - &gt;无限循环a[j] = 10;
无法访问,因此可以优化,a
和j
也可以,因为不再需要它们来确定循环条件。< / LI>
这与给出的文章中的情况类似:
int d[16];
分析以下循环:
for (dd=d[k=0]; k<16; dd=d[++k])
像这样:
在看到d [++ k]时,允许假设增加的值 k是在数组边界内,因为否则是未定义的行为 发生。对于此处的代码,GCC可以推断出k在0..15的范围内。 稍后,当GCC看到k <16时,它对自己说:“啊哈 - 那个 表达式总是正确的,所以我们有一个无限循环。“
也许一个有趣的次要观点是,无限循环是否被认为是可观察的行为( w.r.t。到as-if规则),这会影响是否也可以优化无限循环。我们可以从C Compilers Disprove Fermat’s Last Theorem看到,在C11之前至少有一些解释空间:
许多知识渊博的人(包括我)都是这样说的 不得更改程序的终止行为。显然有些 编译器编写者不同意,或者不相信它很重要。该 事实上,合理的人们对解释的看法不一致 表明C标准存在缺陷。
C11 添加了对6.8.5
迭代声明部分的说明,并在this answer中有更详细的介绍。
答案 1 :(得分:20)
在优化版本中,编译器决定了一些事情:
a
不会更改。a
不包含5
。因此,我们可以将代码重写为:
void fn(void) {
int a[1] = {0};
int j = 0;
while(true) ++j;
a[j] = 10;
}
现在,我们可以做出进一步的决定:
j
已写入,但从未阅读过。所以我们可以摆脱它。a
永远不会被阅读。此时,您的代码已缩减为:
void fn(void) {
int a[1] = {0};
while(true);
}
我们可以注意到a
现在从未被读过,所以我们也要把它除去:
void fn(void) {
while(true);
}
在未经优化的生成代码中,数组将保留在内存中。而且你会在运行时完成它。一旦你走过数组末尾,就有可能会有一个5
可读。
这就是未经优化的版本有时不会崩溃和烧毁的原因。
答案 2 :(得分:7)
如果循环 被优化为无限循环,可能是由于静态代码分析看到你的数组是
不是volatile
仅包含0
永远不会被写入
因此它不可能包含数字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 */
}