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

我最近开始学习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 <= 11时,代码只在运行Ubuntu的笔记本电脑上终止。它永远不会在CentOS和Windows中终止。

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

编辑:我知道for循环越界了。我是故意这么做的。我只是不明白在不同的操作系统和计算机上,这种行为是如何不同的。

23193 次浏览

与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]从内存中访问未分配的位置,这将导致未定义的行为. c错误。未定义行为意味着您的程序将以不确定的方式运行,因此它可以在每次执行时给出不同的输出。

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

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

将循环010意味着11时间。因此,当i = 10时,它将溢出缓冲区并导致未定义的行为

所以试试这个:

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

或者,

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

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

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

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

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

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

在我运行Ubuntu 14.04的笔记本电脑上,这段代码不会中断它的运行直至完成。在我学校运行CentOS 6.6的电脑上,它也运行得很好。在Windows 8.1上,循环永远不会终止。

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

您刚刚发现了内存踩踏。你可以在这里阅读更多关于它的信息:什么是“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实际上是一个数组元素,数组[10],就像在堆栈上看到的那样。因为你已经允许索引使数组[10]= 0,循环索引i永远不会超过10。让它for(i=0; i<10; i+=1)

i++是K& R所称的“坏风格”。它增加了i的大小,而不是1。i++是指针数学,I +=1是代数。虽然这取决于编译器,但对于可移植性来说,这不是一个好的约定。

在应该是循环的最后一次运行中,写入array[10],但数组中只有10个元素,从0到9。C语言规范说这是“未定义的行为”。在实践中,这意味着你的程序将尝试写入内存中紧邻array的__abc1大小的内存块。接下来会发生什么,实际上取决于那里发生了什么,而这不仅取决于操作系统,更取决于编译器、编译器选项(比如优化设置)、处理器架构、周围的代码等等。它甚至可以在不同的执行中有所不同,例如,由于地址空间随机化(可能不在这个玩具示例中,但在现实生活中确实会发生)。一些可能性包括:

  • 这个地点没有被使用。循环正常终止。
  • 该位置用于某个值恰好为0的东西。循环正常终止。
  • 该位置包含函数的返回地址。循环正常终止,但随后程序崩溃,因为它试图跳转到地址0。
  • 该位置包含变量i。循环永远不会终止,因为i在0处重新启动。
  • 位置包含一些其他变量。循环正常结束,但随后会发生“有趣的”事情。
  • 该位置是一个无效的内存地址,例如,因为array恰好在虚拟内存页的末尾,而下一页没有映射。
  • 魔鬼会从你鼻子里飞出来。幸运的是,大多数计算机都没有必要的硬件。

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

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

.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),可以对编译器生成的可执行程序产生重大影响。编译器优化可以做很多在调用未定义行为的程序上看起来不直观的事情。这就是为什么未定义的行为完全没有定义。在实际的程序中,当您稍微偏离轨道时,即使是有经验的程序员,也很难理解代码所做的和它应该做的之间的关系。

除了内存布局的可能性之外,试图写入a[10]实际上会覆盖i,优化编译器也可能会确定,如果代码未首先访问不存在的数组元素a[10],则i值大于10时无法到达循环测试。

由于试图访问该元素将是未定义的行为,因此编译器对程序在此之后可能做的事情没有义务。更具体地说,因为编译器没有义务在任何情况下生成代码来检查循环索引是否大于10,所以它根本没有义务生成代码来检查它;相反,它可以假设<=10测试总是会产生true。注意,即使代码读取而不是写入a[10],这也是正确的。

当你遍历i==9时,你把0赋给实际上位于通过数组的“数组项”,所以你重写了一些其他数据。最可能的情况是,你覆盖了i变量,它位于a[]之后。这样你就可以简单地i变量重置为零并重新启动循环。

如果你在循环中输出i,你自己就会发现:

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

而不仅仅是

      printf("test \n");

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

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

我的购物车里有10样东西。它们是:

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

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

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

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

一些编译器希望运行得更快,而一些编译器更喜欢安全。这一切都与环境有关。例如,如果我正在为古老的BREW操作系统(基本手机的操作系统)开发一款应用程序,它就不会关心安全性。如果我开发的是iPhone 6,那么它无论如何都能运行得很快,所以我需要强调安全性。(说真的,你读过苹果的应用商店指南吗,或者读过Swift和Swift 2.0的开发吗?)

错误是在部分数组[10]w/c也是I的地址(int数组[10],I;) 当数组[10]被设置为0时,I将为0 w/c重置整个循环和 导致无限循环。 如果数组[10]在0-10之间,就会出现无限循环。正确的循环应该是(i = 0;I <10;我+ +){…} int数组[10],我; For (i = 0;I <=10;我+ +) array[我]= 0;< / p >

我将建议一些我在上面没有发现的东西:

赋值数组[i] = 20;

我想这应该会终止所有的代码..(如果你保持i<=10或ll)

如果运行此程序,您可以确定这里指定的答案已经是正确的[与内存踩脚有关的答案为ex。]