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


240

我最近开始学习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中终止。

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

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


147
由于您要覆盖数组,因此会发生未定义的行为。未定义的行为意味着任何事情都可能发生,包括看起来可行。因此,“代码不应永不终止”不是有效的期望。
kaylum

37
恰好,欢迎C.你的阵列有10种元素- 0到9的编号
Yetti99

14
@JonCav您确实破坏了代码。您将得到未定义的行为,这是损坏的代码。
kaylum

50
好吧,总的来说,未定义的行为就是这样。您无法可靠地对其进行测试并证明会发生定义的事情。Windows机器中可能正在发生的事情是,该变量i在结束后立即存储array,并且您正在用覆盖它array[10]=0;。在同一平台上进行优化的构建中可能不是这种情况,后者可能存储i在寄存器中,而根本不会在内存中引用它。
水稻

46
因为不可预测性是未定义行为的基本属性。您需要了解这一点...绝对不能下注。
水稻

Answers:


356

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

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

您刚刚发现内存重载。您可以在此处了解更多信息:什么是“内存重载”?

当您分配时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; 
} 

6
嘿,谢谢!这确实解释了很多。在Windows中,它声明我如果从数组偏移10,而在CentOS和Ubuntu中,则为-1。奇怪的是,如果我将调试器代码注释掉,CentOS无法运行该代码(挂起),但是使用您的调试代码,它可以运行。到目前为止,C似乎是一种非常语言
X_x

12
例如,如果写入array[10]破坏堆栈框架,则@JonCav会“挂起” 。有或没有调试输出的代码之间有什么区别?如果i不再需要的地址,则编译器可以进行优化i。放入寄存器,从而更改堆栈上的内存布局...
Hagen von Eitzen

2
我不认为它正在挂起,我认为它处于无限循环中,因为它正在从内存中重新加载循环计数器(该计数器刚刚被清零array[10]=0。如果在启用优化的情况下编译代码,则可能不会发生这种情况。(因为C限制使用哪种类型的内存访问的别名规则必须假定可能与其他内存重叠。作为您从未使用过地址的局部变量,我认为编译器应该能够假定没有别名。数组的行为是不确定的。请始终尽量避免依赖
于此

4
另一种选择是优化的编译器完全删除数组,因为它没有可观察到的效果(在问题的原始代码中)。因此,生成的代码仅可以打印出该常量字符串11次,然后打印出常量大小,从而使溢出完全不明显。
Holger 2015年

9
@JonCav我一般说,你不要需要更多的了解内存管理和而不是简单地不知道写未定义代码,具体,不写过去的数组的末尾...
T.基利

98

该错误位于以下代码段之间:

int array[10],i;

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

array[i]=0;

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

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

要解决此问题,请将循环条件更改为i < 10


6
Nitpick:除非您以root(或同等身份)身份运行,否则您实际上无法在市场上任何合理的OS上格式化硬盘。
凯文(Kevin)

26
@Kevin调用UB时,您放弃任何理智的主张。
o11c

7
您的代码是否健全并不重要。操作系统不允许您这样做。
凯文

2
@Kevin格式化硬盘的示例早于这种情况而已。即使是当时的unix(C的起源)也很乐意允许您执行类似的操作-直到今天,rm -rf /即使您不是root用户,很多发行版仍可以使您高兴地开始删除所有内容。当然,“格式化”整个驱动器,但仍会破坏所有数据。哎哟。
罗安2015年

5
@Kevin但未定义的行为可以利用操作系统漏洞,然后提升自身以安装新的硬盘驱动器,然后开始清理驱动器。
棘轮怪胎2015年

38

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

  • 没有使用该位置。循环正常终止。
  • 该位置用于碰巧值为0的东西。循环正常终止。
  • 该位置包含函数的返回地址。循环正常终止,但随后程序崩溃,因为它试图跳转到地址0。
  • 该位置包含变量i。循环永远不会终止,因为i从0重新开始。
  • 该位置包含其他变量。循环正常终止,但随后发生“有趣”的事情。
  • 该位置是无效的内存地址,例如,因为array该地址恰好位于虚拟内存页面的末尾,并且未映射下一页。
  • 恶魔从你的鼻子里飞出来。幸运的是,大多数计算机都缺少必需的硬件。

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

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

.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位于数组的前面。i如果您碰巧要写入,则会覆盖array[-1]。如果更改array[i]=0array[9-i]=0,则使用这些特定的编译器选项将在此特定平台上出现无限循环。

现在,使用编译您的程序gcc -O1

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

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

为了使该示例更具说服力,让我们通过为编译器提供一些无法优化的内容来确保执行数组分配。一种简单的方法是使用另一个文件中的数组-由于要进行单独的编译,因此编译器不知道另一个文件中发生了什么(除非它在链接时进行了优化,有的gcc -O0还是gcc -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)如何对可执行程序生成的内容产生重大影响由编译器来做。编译器优化可以做很多事情,这些事情在调用未定义行为的程序上可能看起来并不直观。这就是为什么未定义的行为完全未定义的原因。当您在实际程序中偏离轨道如此微小时,即使对于有经验的程序员来说,也很难理解代码的作用与应执行的作用之间的关系。


25

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


对于数组:

int array[10]

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

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

array[10]在此处访问,将条件更改为i < 10


6
故意这样做还可能导致未定义的行为-编译器无法分辨!;-)
Toby Speight 2015年

1
只需使用宏将错误转换为警告即可:#define UNINTENDED_MISTAKE(EXP)printf(“警告:” #EXP“错误\ n”);
lkraider

1
我的意思是,如果您故意犯错,则最好也将其识别出来,并确保避免发生不确定的行为; D
lkraider15年

19

您有一个界限冲突,并且在非终止平台上,我相信您i在循环结束时无意中将其设置为零,从而使其重新开始。

array[10]是无效的; 它包含10个元素,array[0]通过array[9],并且array[10]是第11位。您的循环应编写为在之前 停止10,如下所示:

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

array[10]您的两个平台上i,土地是由实现定义的,并且有趣的是,它在土地上着陆,这些平台显然在之后arrayi设置为零,循环将永远继续。对于其他平台,i可能位于之前array,也array可能在其之后有一些填充。


我认为valgrind仍然可以捕获此错误,因为它仍然是有效的位置,但是ASAN可以。
o11c

13

你申报int array[10]的手段array有指数0,以9(总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++)

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]是因为你的初始化/声明语句。一些编译器将其解释为:

  • “为分配10个ints 块array[10]和另一个s int块。为了简化操作,请将它们彼此紧挨着。”
  • 与之前相同,但是将其移开一两个空间,所以array[10]没有指向i
  • 进行与以前相同的操作,但是分配iarray[-1](因为数组的索引不能为负,或者不应为负),或者在完全不同的位置进行分配,因为操作系统可以处理它,并且更安全。

有些编译器希望运行更快,而有些编译器则更喜欢安全性。这都是关于上下文的。例如,如果我正在为古老的BREW操作系统(基本电话的操作系统)开发应用程序,则它不会在意安全性。如果我正在开发iPhone 6,那么无论如何它都可以快速运行,因此我需要强调安全性。(严重的是,您是否阅读过Apple的App Store指南,或已阅读Swift和Swift 2.0的开发?)


注意:我键入了列表,所以它变为“ 0、1、2、3、4、5、6、7、8、9”,但是SO的标记语言固定了我的有序列表的位置。
DDPWNAGE

6

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

int array[10],i;

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

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


5

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


4

除了可能会安排内存以a[10]实际写入的尝试覆盖之外i,优化编译器还可能会确定,i如果没有先访问代码的代码,则循环测试无法达到大于10 的值。不存在的数组元素a[10]

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


3

当你迭代过去i==9分配了零到实际所在的“阵列项” 过去的数组,所以你overwritnig一些其他数据。您很可能会覆盖i位于之后的变量a[]。这样,您只需i变量重置为零,然后重新启动循环即可。

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

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

而不只是

      printf("test \n");

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


0

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


0

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

尝试分配array [i] = 20;

我猜这应该在任何地方终止代码。(假设您保持i <= 10或ll)

如果运行成功,则可以确定在此指定的答案已经是正确的了[与内存占用一个答案有关的答案。


-9

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

正如K&R所称,i ++是“坏风格”。它以i的大小(而不是1)递增i。i ++用于指针数学,而i + = 1用于代数。尽管这取决于编译器,但对于可移植性而言并不是一个好的约定。


5
-1完全错误。变量i不是数组的元素a[10],编译器没有义务甚至不建议在数组之后 立即将其放到堆栈上,a[]也可以将其放在数组之前,也可以用一些额外的空间分隔开。它甚至可以分配到主存储器之外,例如在CPU寄存器中。++对于指针而不是整数也是不正确的。完全错误的是“ i ++将i乘以i的大小” –请阅读语言定义中的运算符描述!
2015年

这就是为什么它可以在某些平台上运行而不是在其他平台上运行的原因。这是为什么它在Windows上永远循环的唯一逻辑解释。对于I ++,它是指针数学而不是整数。阅读圣经...“ C编程语言”。由Kernigan和Ritche撰写,如果您想要我有亲笔签名的副本,并且自1981
。– SkipBerne 2015年

1
通过OP阅读源代码并找到变量的声明i-它是int类型的。它是一个整数,不是指针;一个整数,用作。的索引array
2015年

1
我做到了,这就是为什么我像我一样发表评论。也许您应该意识到,除非编译器包括堆栈检查,在这种情况下,当I = 10实际在某些编译中引用数组索引且位于堆栈区域范围内时,堆栈引用就没有关系。编译器无法修复愚蠢的问题。编译可能会像看起来那样进行修补,但是对c编程语言的纯解释将不支持该约定,并且正如OP所说的那样会导致不可移植的结果。
SkipBerne 2015年

@SkipBerne:请考虑删除您的答案,然后再给您“更多的负面评价”。
彼得·瓦尔加
By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.