为啥这个 for 循环在某些平台上退出而不在其他平台上退出?

Posted

技术标签:

【中文标题】为啥这个 for 循环在某些平台上退出而不在其他平台上退出?【英文标题】:Why does this for loop exit on some platforms and not on others?为什么这个 for 循环在某些平台上退出而不在其他平台上退出? 【发布时间】:2015-09-10 01:42:54 【问题描述】:

我最近开始学习 C,我正在上一门以 C 为主题的课程。我目前正在玩循环,并且遇到了一些我不知道如何解释的奇怪行为。

#include <stdio.h>

int main()

  int array[10],i;

  for (i = 0; i <=10 ; i++)
  
    array[i]=0; /*code should never terminate*/
    printf("test \n");

  
  printf("%d \n", sizeof(array)/sizeof(int));
  return 0;

在我运行 Ubuntu 14.04 的笔记本电脑上,此代码不会中断。它运行到完成。在我学校运行 CentOS 6.6 的计算机上,它也运行良好。在 Windows 8.1 上,循环永远不会终止。

更奇怪的是,当我将for 循环的条件编辑为:i &lt;= 11 时,代码只会在我运行 Ubuntu 的笔记本电脑上终止。它永远不会在 CentOS 和 Windows 中终止。

谁能解释内存中发生了什么以及为什么运行相同代码的不同操作系统会产生不同的结果?

编辑:我知道 for 循环超出范围。我是故意的。我只是无法弄清楚不同操作系统和计算机的行为如何不同。

【问题讨论】:

由于您超出了数组,因此会发生未定义的行为。未定义的行为意味着任何事情都可能发生,包括它看起来有效。因此,“代码永远不应该终止”不是一个有效的期望。 没错,欢迎使用 C。您的数组有 10 个元素 - 编号为 0 到 9。 @JonCav 你确实破坏了代码。你得到未定义的行为,这是错误的代码。 嗯,重点是未定义的行为正是如此。你不能可靠地测试它并证明定义的事情会发生。您的 Windows 机器中可能发生的情况是,变量 i 存储在 array 的末尾之后,而您正在用 array[10]=0; 覆盖它。在同一平台上的优化构建中可能不是这种情况,它可能将i 存储在寄存器中,并且根本不会在内存中引用它。 因为不可预测性是未定义行为的基本属性。你需要明白这一点......绝对所有的赌注都没有。 【参考方案1】:

在我运行 Ubuntu 14.04 的笔记本电脑上,此代码不会破坏它运行完成。在我学校运行 CentOS 6.6 的计算机上,它也运行良好。在 Windows 8.1 上,循环永远不会终止。

更奇怪的是,当我将for 循环的条件编辑为:i &lt;= 11 时,代码只会在我运行 Ubuntu 的笔记本电脑上终止。 CentOS 和 Windows 永远不会终止。

您刚刚发现了内存跺脚。你可以在这里阅读更多信息:What is a “memory stomp”?

当您分配int array[10],i; 时,这些变量会进入内存(具体来说,它们是在堆栈上分配的,堆栈是与函数关联的内存块)。 array[]i 在内存中可能是相邻的。似乎在 Windows 8.1 上,i 位于array[10]。在 CentOS 上,i 位于 array[11]。而在 Ubuntu 上,它不在任何位置(也许在 array[-1]?)。

尝试将这些调试语句添加到您的代码中。您应该注意到,在迭代 10 或 11 中,array[i] 指向 i

#include <stdio.h>
 
int main() 
 
  int array[10],i; 
 
  printf ("array: %p, &i: %p\n", array, &i); 
  printf ("i is offset %d from array\n", &i - array);

  for (i = 0; i <=11 ; i++) 
   
    printf ("%d: Writing 0 to address %p\n", i, &array[i]); 
    array[i]=0; /*code should never terminate*/ 
   
  return 0; 
 

【讨论】:

嘿,谢谢!这确实解释了很多。在 Windows 中,它声明 i 如果从数组偏移 10,而在 CentOS 和 Ubuntu 中,它是 -1。更奇怪的是,如果我将您的调试器代码注释掉,CentOS 无法运行该代码(它挂起),但使用您的调试代码它运行。到目前为止,C 似乎是一门非常语言 X_x @JonCav “它挂起”可能会发生,例如,如果写入 array[10] 会破坏堆栈帧。有或没有调试输出的代码有什么区别?如果永远不需要i 的地址,编译器可以优化i。到一个寄存器中,从而改变堆栈上的内存布局...... 我不认为它挂起,我认为它处于无限循环中,因为它正在从内存中重新加载循环计数器(刚刚被 array[10]=0 清零。如果你编译你的代码优化,这可能不会发生。(因为 C 具有别名规则,这些规则限制了必须假定哪些类型的内存访问可能与其他内存重叠。作为一个你永远不会获取地址的局部变量,我认为编译器应该能够假设没有任何别名。无论如何,写出数组的末尾是未定义的行为。总是尽量避免依赖它。 另一种选择是优化编译器完全删除数组,因为它没有可观察到的效果(在问题的原始代码中)。因此,生成的代码可能只打印出该常量字符串 11 次,然后打印常量大小,从而使溢出完全不明显。 @JonCav 我会说一般你不需要需要了解更多关于内存管理的知识,而只是知道不要编写未定义的代码,特别是不要写过去数组的结尾...【参考方案2】:

错误位于这些代码之间:

int array[10],i;

for (i = 0; i <=10 ; i++)

array[i]=0;

由于 array 只有 10 个元素,所以在最后一次迭代中 array[10] = 0; 是缓冲区溢出。缓冲区溢出是未定义的行为,这意味着它们可能会格式化您的硬盘驱动器或导致恶魔飞出您的鼻子。

所有堆栈变量彼此相邻布置是相当普遍的。如果i 位于array[10] 写入的位置,则UB 会将i 重置为0,从而导致未终止的循环。

要修复,请将循环条件更改为i &lt; 10

【讨论】:

Nitpick:您实际上无法在市场上任何健全的操作系统上格式化硬盘驱动器,除非您以 root(或等效)身份运行。 @Kevin 当你调用 UB 时,你放弃了任何理智的要求。 你的代码是否合理并不重要。操作系统不会让你这样做。 @Kevin 格式化您的硬盘驱动器的例子早在这种情况下就出现了。即使是当时的 Unix(C 的起源)也很乐意让你做这样的事情——即使在今天,很多发行版也会很乐意让你开始使用 rm -rf / 删除所有内容,即使你不是root,当然不是“格式化”整个驱动器,但仍然会破坏所有数据。哎哟。 @Kevin 但未定义的行为可以利用操作系统漏洞,然后提升自身以安装新的硬盘驱动程序,然后开始清理驱动器。【参考方案3】:

在循环的最后一次运行中,您写信给array[10],但数组中只有 10 个元素,编号从 0 到 9。C 语言规范说这是“未定义的行为”。这实际上意味着您的程序将尝试写入位于内存中array 之后的int 大小的内存块。然后会发生什么取决于实际上存在什么,这不仅取决于操作系统,还取决于编译器、编译器选项(例如优化设置)、处理器架构、周围代码等。它甚至可能因执行而异,例如由于address space randomization(可能不在这个玩具示例中,但它确实发生在现实生活中)。一些可能性包括:

该位置未被使用。循环正常终止。 该位置用于恰好值为 0 的内容。循环正常终止。 该位置包含函数的返回地址。循环正常终止,但随后程序崩溃,因为它试图跳转到地址 0。 该位置包含变量i。循环永远不会终止,因为i 在 0 处重新开始。 该位置包含一些其他变量。循环正常终止,但随后发生了“有趣”的事情。 该位置是无效的内存地址,例如因为array 正好位于虚拟内存页面的末尾,并且没有映射下一页。 Demons fly out of your nose。幸运的是,大多数计算机都缺乏必要的硬件。

您在 Windows 上观察到的是,编译器决定将变量 i 紧跟在内存中的数组之后,因此 array[10] = 0 最终分配给了 i。在 Ubuntu 和 CentOS 上,编译器没有将 i 放在那里。几乎所有的 C 实现都在内存中对局部变量进行分组,在 memory stack 上,有一个主要例外:一些局部变量可以完全放在 registers 中。即使变量在堆栈上,变量的顺序也是由编译器决定的,它可能不仅取决于源文件中的顺序,还取决于它们的类型(以避免将内存浪费在会留下漏洞的对齐约束上) ,关于他们的名字,关于编译器内部数据结构中使用的一些哈希值,等等。

如果你想知道你的编译器决定做什么,你可以告诉它向你展示汇编代码。哦,学习破译汇编程序(这比编写它更容易)。使用 GCC(和其他一些编译器,尤其是在 Unix 世界中),传递选项 -S 以生成汇编代码而不是二进制文件。例如,这里是使用 GCC 在 amd64 上使用优化选项 -O0(无优化)编译的循环的汇编器 sn-p,手动添加了 cmets:

.L3:
    movl    -52(%rbp), %eax           ; load i to register eax
    cltq
    movl    $0, -48(%rbp,%rax,4)      ; set array[i] to 0
    movl    $.LC0, %edi
    call    puts                      ; printf of a constant string was optimized to puts
    addl    $1, -52(%rbp)             ; add 1 to i
.L2:
    cmpl    $10, -52(%rbp)            ; compare i to 10
    jle     .L3

这里变量i在栈顶以下52字节,而数组从栈顶以下48字节开始。所以这个编译器恰好在数组之前放置了i;如果你碰巧写信给array[-1],你会覆盖i。如果您将array[i]=0 更改为array[9-i]=0,您将在具有这些特定编译器选项的特定平台上获得无限循环。

现在让我们用gcc -O1 编译你的程序。

    movl    $11, %ebx
.L3:
    movl    $.LC0, %edi
    call    puts
    subl    $1, %ebx
    jne     .L3

那更短了!编译器不仅拒绝为i 分配堆栈位置——它只存储在寄存器ebx 中——而且它没有费心为array 分配任何内存,或者生成代码来设置它的元素,因为它注意到没有使用过任何元素。

为了让这个例子更能说明问题,让我们确保通过为编译器提供一些它无法优化掉的东西来执行数组赋值。一个简单的方法是使用另一个文件中的数组——因为单独编译,编译器不知道另一个文件中发生了什么(除非它在链接时优化,gcc -O0gcc -O1 不会)。创建一个源文件use_array.c 包含

void use_array(int *array) 

并将您的源代码更改为

#include <stdio.h>
void use_array(int *array);

int main()

  int array[10],i;

  for (i = 0; i <=10 ; i++)
  
    array[i]=0; /*code should never terminate*/
    printf("test \n");

  
  printf("%zd \n", sizeof(array)/sizeof(int));
  use_array(array);
  return 0;

编译

gcc -c use_array.c
gcc -O1 -S -o with_use_array1.c with_use_array.c use_array.o

这次的汇编代码如下所示:

    movq    %rsp, %rbx
    leaq    44(%rsp), %rbp
.L3:
    movl    $0, (%rbx)
    movl    $.LC0, %edi
    call    puts
    addq    $4, %rbx
    cmpq    %rbp, %rbx
    jne     .L3

现在数组在堆栈上,距顶部 44 个字节。 i 呢?它不会出现在任何地方!但是循环计数器保存在寄存器rbx 中。不完全是i,而是array[i] 的地址。编译器已决定,由于从未直接使用 i 的值,因此在每次循环运行期间执行算术来计算存储 0 的位置是没有意义的。相反,该地址是循环变量,确定边界的算法部分在编译时执行(将 11 次迭代乘以每个数组元素 4 个字节得到 44),部分在运行时执行,但在循环开始之前一劳永逸(进行减法得到初始值)。

即使在这个非常简单的示例中,我们也看到了如何更改编译器选项(打开优化)或更改一些次要内容(array[i]array[9-i]),甚至更改一些明显不相关的内容(添加对 use_array 的调用) 可以对编译器生成的可执行程序的作用产生重大影响。 编译器优化可以做很多在调用未定义行为的程序上可能看起来不直观的事情。这就是为什么未定义的行为完全未定义的原因。当你稍微偏离轨道时,在现实世界的程序中,即使对于有经验的程序员来说,也很难理解代码所做的事情和应该做的事情之间的关系。

【讨论】:

【参考方案4】:

与 Java 不同,C 不进行数组边界检查,即没有 ArrayIndexOutOfBoundsException,确保数组索引有效的工作留给了程序员。故意这样做会导致未定义的行为,任何事情都可能发生。


对于数组:

int array[10]

索引仅在09 范围内有效。但是,您正在尝试:

for (i = 0; i <=10 ; i++)

在此处访问array[10],将条件改为i &lt; 10

【讨论】:

不故意这样做也会导致未定义的行为 - 编译器无法判断! ;-) 只需使用宏将错误转换为警告:#define UNINTENDED_MISTAKE(EXP) printf("Warning: " #EXP "error\n"); 我的意思是,如果你是故意犯错,你不妨将其识别出来,并确保避免未定义的行为;D【参考方案5】:

您违反了边界,并且在非终止平台上,我相信您在循环结束时无意中将i 设置为零,因此它会重新开始。

array[10] 无效;它包含 10 个元素,array[0]array[9]array[10] 是第 11 个。你的循环应该写成停止before10,如下:

for (i = 0; i < 10; i++)

array[10] 所在的位置是实现定义的,有趣的是,在您的两个平台上,它位于 i,这些平台显然直接位于 array 之后。 i 设置为零,循环将永远继续。对于您的其他平台,i 可能位于array 之前,或者array 之后可能有一些填充。

【讨论】:

我不认为 valgrind 可以捕捉到这个,因为它仍然是一个有效的位置,但 ASAN 可以。【参考方案6】:

您声明int array[10] 意味着array 具有索引09(它可以容纳的总10 整数元素)。但是下面的循环,

for (i = 0; i <=10 ; i++)

0 循环到10 意味着11 时间。因此,当i = 10 时,它会溢出缓冲区并导致Undefined Behavior。

所以试试这个:

for (i = 0; i < 10 ; i++)

或者,

for (i = 0; i <= 9 ; i++)

【讨论】:

【参考方案7】:

它在array[10] 处未定义,并提供如前所述的未定义行为。这样想:

我的购物车中有 10 件商品。它们是:

0:一盒麦片 1:面包 2:牛奶 3:馅饼 4:鸡蛋 5:蛋糕 6:2升苏打水 7:沙拉 8:汉堡 9:冰淇淋

cart[10] 未定义,在某些编译器中可能会给出越界异常。但是,很多显然没有。明显的第 11 项是实际上不在购物车中的项。第 11 项指向,我将称之为“poltergeist 项”。它从来不存在,但它就在那里。

为什么有些编译器给i 一个索引array[10]array[11] 甚至array[-1] 是因为你的初始化/声明语句。一些编译器将其解释为:

“为array[10] 分配10 个ints 块和另一个int 块。为方便起见,将它们并排放置。” 和以前一样,但是将它移开一两个空格,这样array[10] 就不会指向i。 和以前一样,但在array[-1] 分配i(因为数组的索引不能或不应该是负数),或者将它分配在完全不同的位置,因为操作系统可以处理它,它更安全。

一些编译器希望事情进展得更快,而一些编译器更喜欢安全。这一切都与上下文有关。例如,如果我正在为古老的 BREW OS(基本手机的操作系统)开发应用程序,它不会关心安全性。如果我正在为 iPhone 6 开发,那么无论如何它都可以运行得很快,所以我需要强调安全性。 (说真的,你读过 Apple 的 App Store Guidelines,或者读过 Swift 和 Swift 2.0 的开发吗?)

【讨论】:

注意:我输入的列表是“0, 1, 2, 3, 4, 5, 6, 7, 8, 9”,但是 SO 的标记语言固定了我的有序列表的位置.【参考方案8】:

既然你创建了一个大小为 10 的数组,那么 for 循环条件应该如下:

int array[10],i;

for (i = 0; i <10 ; i++)

目前您正尝试使用array[10] 从内存中访问未分配的位置,这导致了未定义的行为。未定义的行为意味着您的程序将以未确定的方式运行,因此它可以在每次执行时给出不同的输出。

【讨论】:

【参考方案9】:

嗯,C 编译器传统上不检查边界。如果您引用不“属于”您的进程的位置,则可能会出现分段错误。但是,局部变量是在堆栈上分配的,根据分配内存的方式,数组之外的区域 (array[10]) 可能属于进程的内存段。因此,不会引发分段错误陷阱,这就是您似乎所经历的。正如其他人指出的那样,这是 C 中未定义的行为,您的代码可能被认为是不稳定的。由于您正在学习 C,因此最好养成检查代码边界的习惯。

【讨论】:

【参考方案10】:

除了可能会布置内存以便尝试写入a[10] 实际上会覆盖i 之外,优化编译器也可能会确定循环测试无法通过值为i 大于十,没有代码首先访问不存在的数组元素a[10]

由于访问该元素的尝试将是未定义的行为,因此编译器对该点之后程序可能执行的操作没有任何义务。更具体地说,由于编译器在任何情况下都没有义务生成代码来检查循环索引,如果它可能大于 10,那么它根本没有义务生成代码来检查它;相反,它可以假设&lt;=10 测试将始终为真。请注意,即使代码读取 a[10] 而不是写入它也是如此。

【讨论】:

【参考方案11】:

当您遍历i==9 时,您将零分配给实际位于数组之后 的“数组项”,因此您覆盖了其他一些数据。您很可能会覆盖位于a[] 之后的i 变量。这样您只需i 变量重置为零,然后重新启动循环。

如果你在循环中打印i,你会发现自己:

      printf("test i=%d\n", i);

而不仅仅是

      printf("test \n");

当然,结果很大程度上取决于变量的内存分配,而这又取决于编译器及其设置,因此通常是未定义的行为——这就是为什么在不同的机器上产生不同或不同的结果操作系统或不同的编译器可能会有所不同。

【讨论】:

【参考方案12】:

错误在数组 [10] 部分 w/c 也是 i 的地址 (int array[10],i;)。 当 array[10] 设置为 0 时 i 将为 0 w/c 重置整个循环并 导致无限循环。 如果 array[10] 在 0-10 之间会有无限循环。正确的循环应该是 for (i = 0; i

【讨论】:

【参考方案13】:

我会建议一些我在上面找不到的东西:

尝试分配数组[i] = 20;

我想这应该在任何地方终止代码..(假设你保持 i

如果运行,您可以确定此处指定的答案已经是正确的[与记忆跺脚有关的答案,例如]

【讨论】:

【参考方案14】:

这里有两个问题。 int i 实际上是一个数组元素,array[10],如堆栈中所示。因为您已经允许索引实际上使 array[10] = 0,所以循环索引 i 永远不会超过 10。使其成为for(i=0; i&lt;10; i+=1)

i++ 就像K&R 所说的那样,是“糟糕的风格”。它以 i 的大小而不是 1 递增 i。i++ 用于指针数学,i+=1 用于代数。虽然这取决于编译器,但它不是一个很好的可移植性约定。

【讨论】:

-1 完全错误。变量 i 是 NOTan 数组元素 a[10],编译器没有义务甚至建议将其立即放入堆栈之后 a[] - 它也可以位于数组之前,或用一些额外的空间分隔。它甚至可以分配在主存储器之外,例如在 CPU 寄存器中。 ++ 用于指针而不是整数也是不正确的。完全错误的是“i++ 将 i 增加 i 的大小”——阅读语言定义中的运算符描述! 这就是为什么它适用于某些平台而不适用于其他平台的原因。这是为什么它在 Windows 上永远循环的唯一合乎逻辑的解释。关于 I++,它是指针数学而不是整数。阅读圣经……“C 编程语言”。由 Kernigan 和 Ritche 撰写,如果你想要我有一份亲笔签名的副本,并且自 1981 年以来一直在用 c 编程。 通过OP阅读源码,发现变量i的声明——它是int类型。它是一个整数,而不是指针;一个整数,用作array,.的索引。 我做到了,这就是我发表评论的原因。也许您应该意识到,除非编译器包括堆栈检查,并且在这种情况下,当 I=10 时堆栈引用实际上会引用,在某些编译中,数组索引并且在堆栈区域的范围内,这并不重要。编译器无法修复愚蠢。 compiles 可能会像这个一样进行修复,但是对 c 编程语言的纯粹解释将不支持这种约定,并且正如 OP 所说的那样会导致不可移植的结果。 @SkipBerne:考虑删除您的答案,然后您将获得更多负面分数。

以上是关于为啥这个 for 循环在某些平台上退出而不在其他平台上退出?的主要内容,如果未能解决你的问题,请参考以下文章

为啥 R 对象不在函数或“for”循环中打印?

为啥这个for循环不执行?

为啥调用嵌套批处理文件而不在行前添加“调用”退出父批处理文件?

为啥 const 在 JavaScript 的某些 for 循环中起作用?

c语言一个死循环中为啥执行完一个功能函数就退出了?

为啥 SharpDevelop 上这个使用“for”循环的小 C# 程序不起作用?