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

Why does this for loop exit on some platforms and not on others?

我最近开始学C,正在考一个class,科目是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 <= 11 时,代码仅在我的笔记本电脑 运行 Ubuntu 上终止。它永远不会在 CentOS 和 Windows 中终止。

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

编辑:我知道 for 循环越界了。我是故意的我只是无法弄清楚不同操作系统和计算机之间的行为有何不同。

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


对于数组:

int array[10]

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

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

这里访问array[10],把条件改成i < 10

错误存在于这些代码段之间:

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 < 10

由于您创建了一个大小为 10 的数组,for 循环条件应如下所示:

int array[10],i;

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

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

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

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

将循环010意味着11次。因此,当 i = 10 时,它将溢出缓冲区并导致 Undefined Behavior.

所以试试这个:

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

或者,

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

您违反了边界,并且在非终止平台上,我相信您在循环结束时无意中将 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 可能在其后有一些填充。

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

On my laptop running Ubuntu 14.04, this code does not break it runs to completion. On my school's computer running CentOS 6.6, it also runs fine. On Windows 8.1, the loop never terminates.

What is more strange is when I edit the conditional of the for loop to: i <= 11, the code only terminates on my laptop running Ubuntu. CentOS and Windows never terminates.

您刚刚发现内存踩踏。您可以在这里阅读更多相关信息: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; 
} 

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

i++,正如 K&R 所说的那样,是 'bad style'。它按 i 的大小递增 i,而不是 1。i++ 用于指针数学,i+=1 用于代数。虽然这取决于编译器,但它不是可移植性的良好约定。

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

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

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

如果您想了解您的编译器决定做什么,您可以告诉它向您显示汇编代码。哦,学习破译汇编程序(这比编写汇编程序容易)。使用 GCC(和其他一些编译器,尤其是在 Unix 世界中),传递选项 -S 以生成汇编代码而不是二进制代码。例如,下面是使用优化选项 -O0(无优化)在 amd64 上使用 GCC 编译循环的汇编程序片段,并手动添加注释:

.L3:
    movl    -52(%rbp), %eax           ; load i to register eax
    cltq
    movl    [=10=], -48(%rbp,%rax,4)      ; set array[i] to 0
    movl    $.LC0, %edi
    call    puts                      ; printf of a constant string was optimized to puts
    addl    , -52(%rbp)             ; add 1 to i
.L2:
    cmpl    , -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    , %ebx
.L3:
    movl    $.LC0, %edi
    call    puts
    subl    , %ebx
    jne     .L3

更短了!编译器不仅拒绝为 i 分配堆栈位置——它只存储在寄存器 ebx 中——而且它也懒得为 array 分配任何内存,或者生成代码来设置其元素,因为它注意到曾经使用过 none 个元素。

为了让这个例子更有说服力,让我们通过为编译器提供它无法优化的东西来确保数组赋值是执行的。一种简单的方法是使用另一个文件中的数组——由于单独编译,编译器不知道另一个文件中发生了什么(除非它在 ​​link 时间优化,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    [=15=], (%rbx)
    movl    $.LC0, %edi
    call    puts
    addq    , %rbx
    cmpq    %rbp, %rbx
    jne     .L3

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

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

除了内存布局可能导致写入 a[10] 的尝试实际上覆盖了 i 之外,优化编译器也可能确定循环测试不能在没有代码首先访问不存在的数组元素 a[10].

的情况下达到大于 10 的值 i

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

当你迭代过去 i==9 时,你将零分配给 'array items',它实际上位于 数组 之后,所以你覆盖了一些其他数据.您很可能覆盖了位于 a[] 之后的 i 变量。这样,您只需 i 变量重置为零 ,从而重新启动循环。

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

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

而不仅仅是

      printf("test \n");

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

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

我的购物车里有 10 件商品。他们是:

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

cart[10] 未定义,在某些编译器中可能会出现越界异常。但是,很多显然不是。明显的第 11 件商品 实际上不在购物车中。 第 11 件商品指向的是我要称呼的 "poltergeist item." 它从未存在过,但它在那里。

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

  • "Allocate 10 blocks of ints for array[10] and another int block. to make it easier, put them right next to each other."
  • 与以前相同,但将它移开 space 或两个距离,这样 array[10] 就不会指向 i
  • 和以前一样,但是在array[-1]分配i(因为数组的索引不能,也不应该是负数),或者完全分配不同的地方,因为 OS 可以处理它,而且 更安全。

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

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

我会推荐一些我在上面找不到的东西:

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

我想这应该会在所有地方终止代码..(假设你保持 i<=10 或 ll)

如果这个运行,你可以确定这里指定的答案已经是正确的[与内存踩踏相关的答案例如。]