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

243

我最近开始学习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循环越界了。我是故意这样做的,我只是想不通为什么在不同的操作系统和计算机上,行为会有所不同。


150
由于你超出了数组的范围,因此会发生未定义行为。未定义行为意味着任何事情都可能发生,包括似乎正常工作。因此,“代码永远不应该终止”并不是一个有效的期望。 - kaylum
38
好的,欢迎来到C语言。你的数组有10个元素,编号从0到9。 - Yetti99
14
你打破了代码,导致出现未定义的行为,这是有问题的代码。 - kaylum
50
整个意思就是未定义行为就是那样。你无法可靠地测试它并证明会发生某些定义的事情。在您的Windows机器上可能正在发生的情况是,变量i存储在array结束后的位置,并且您正在使用array [10] = 0;覆盖它。在同一平台上进行优化构建时,情况可能并非如此,可能将i存储在寄存器中,并且根本不引用内存中的它。 - paddy
46
因为不可预测性是未定义行为的基本属性。你需要明白这一点......绝对不能确定任何事情。 - paddy
显示剩余33条评论
14个回答

361
在我的运行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。 - JonCav
12
“它挂了”可能是因为写入 array[10] 破坏了堆栈帧。那么带或不带调试输出的代码之间有什么区别呢?如果从未需要 i 的地址,编译器可能会将 i 优化到寄存器中,从而改变堆栈上的内存布局... - Hagen von Eitzen
4
另一种选择是优化编译器完全删除数组,因为它对输出没有任何可观察的影响(在问题的原始代码中)。因此,生成的代码可以只打印该常量字符串十一次,然后打印常量大小,从而使溢出完全不可察觉。 - Holger
9
通常情况下,我会说你不需要更多地了解内存管理,而是要知道不要编写未定义的代码,具体来说,不要在数组末尾写入内容。 - T. Kiley
2
@T.Kiley,是的,我知道越界可能会导致意外行为,但我喜欢了解内存中发生的情况。 - JonCav
显示剩余10条评论

99

这个错误出现在以下代码之间:

int array[10],i;

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

array[i]=0;

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

通常情况下,所有栈变量都相邻排列。如果i位于array[10]写入的位置,则未定义行为将会重置i0,从而导致循环不终止。

要修复,请将循环条件更改为i < 10


6
吹毛求疵:市场上任何一个正常的操作系统都无法格式化硬盘,除非你作为root(或等效用户)运行。 - Kevin
26
@Kevin,当你触发UB时,你放弃了任何对于理智的主张。 - o11c
7
你的代码是否正确并不重要,操作系统都不会允许你这么做。 - Kevin
2
@Kevin 格式化硬盘的示例早在那之前就已经存在了。即使是当时的Unix(C的起源)也很乐意让你做这样的事情 - 即使今天,很多发行版也会允许你使用 rm -rf / 开始删除所有东西,即使你不是root,当然不会“格式化”整个驱动器,但仍会破坏你的所有数据。哎呀。 - Luaan
5
@Kevin,但未定义的行为可能会利用操作系统漏洞,然后提升自身权限以安装新的硬盘驱动程序,然后开始擦除磁盘。 - ratchet freak
显示剩余2条评论

38
在循环的最后一次运行中,您写入了 array [10],但数组中只有 10 个元素,从 0 到 9 编号。C 语言规范说这是“未定义行为”。实际上,这意味着您的程序将尝试写入紧接在内存中的 array 后面的大小为 int 的内存块。然后会发生什么取决于实际上存在于那里的内容,这不仅取决于操作系统,而更取决于编译器、编译器选项(如优化设置)、处理器架构、周围代码等。甚至可能因 地址空间随机化 而每次执行都不同(在这个玩具示例中可能不会发生,但在现实生活中确实会发生)。一些可能性包括:
  • 该位置未被使用。循环正常终止。
  • 该位置用于某个值恰好为0的事物。循环正常终止。
  • 该位置包含函数的返回地址。循环正常终止,但程序随后崩溃,因为它试图跳转到地址0。
  • 该位置包含变量i。循环永远不会终止,因为i会在0处重新开始。
  • 该位置包含其他某些变量。循环正常终止,但随后会发生“有趣”的事情。
  • 该位置是一个无效的内存地址,例如array刚好在虚拟内存页的末尾,并且下一页没有映射。
  • 鬼从你的鼻子里飞出来。幸运的是,大多数电脑都缺少必要的硬件。
在Windows上,您观察到编译器决定将变量i放置在数组后面的内存中,因此array[10] = 0最终被赋值给了i。在Ubuntu和CentOS上,编译器没有将i放置在那里。几乎所有C实现都会将局部变量分组存储在内存堆栈中,但有一个重要的例外:一些局部变量可以完全放置在寄存器中。即使变量在堆栈上,变量的顺序也由编译器确定,它可能不仅取决于源文件中的顺序,还取决于它们的类型(为了避免浪费内存以对齐约束留下空隙),它们的名称,以及编译器内部数据结构中使用的某个哈希值等。
如果你想知道编译器做了什么,你可以让它显示汇编代码。噢,还要学会解读汇编代码(比写汇编代码容易)。对于GCC(以及其他一些编译器,尤其是在Unix世界中),使用选项-S生成汇编代码而不是二进制代码。例如,这是使用AMD64上的GCC编译循环时产生的汇编片段,使用优化选项-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的调用),都可以对编译器生成的可执行程序产生显著影响。编译器优化可以做很多看似不符合直觉的事情,特别是在调用未定义行为的程序时。这就是为什么未定义行为完全没有被定义的原因。在现实世界的程序中,即使对于有经验的程序员来说,稍微偏离轨道,也很难理解代码执行与应该执行的关系。

25

与Java不同,C语言不会进行数组边界检查,即没有ArrayIndexOutOfBoundsException异常,确保数组索引的有效性是程序员的责任。故意这样做会导致未定义的行为,可能发生任何事情。


对于一个数组:

int array[10]

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

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

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


6
即使是无意中这样做,也会导致未定义的行为 - 编译器无法分辨!;-) - Toby Speight
1
只需使用宏将您的错误转换为警告:#define UNINTENDED_MISTAKE(EXP) printf("警告:" #EXP " 错误\n"); - lkraider
1
我的意思是,如果你故意犯错,那么最好将其标识为这样,并使其安全以避免未定义的行为; D - lkraider

19

您出现了越界错误,并且在非终止平台上,我认为您意外地在循环结束时将 i 设为零,以便它重新开始。

array[10] 是无效的;它包含了 10 个元素,从 array[0]array[9],而 array[10] 则是第 11 个。您应该编写循环,在达到 10 之前停止,如下所示:

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

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


我认为Valgrind无法捕获此问题,因为它仍然是一个有效的位置,但ASAN可以。 - o11c

12

你声明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++)

7
array[10]处没有定义,如前所述,会产生未定义行为。想象一下:

我的购物车里有10件物品,它们是:

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

cart[10]没有定义,在一些编译器中可能会导致越界异常。但是,许多编译器显然不会。这个显而易见的第11件物品实际上没有在购物车里。第11件物品指向一个我称之为“幽灵物品”的东西。它从来没有存在过,但是它确实存在过。

为什么一些编译器会给i赋值为array[10]array[11]甚至是array[-1],取决于你的初始化/声明语句。有些编译器将其解释为:

  • “为array[10]分配10个int块和另一个int块。为了方便起见,将它们放在一起。”
  • 与前者相同,但是将其移动一两个空格,以便array[10]不指向i
  • 做与之前相同的事情,但是将i分配到array[-1](因为数组的索引不能或不应该是负数),或者将其分配到完全不同的地方,因为操作系统可以处理它,而且它更加安全。

有些编译器希望速度更快,有些编译器更喜欢安全。这全部取决于上下文。例如,如果我正在开发古老的BREW操作系统的应用程序(基本手机的操作系统),那么它不会关心安全性。如果我正在为iPhone 6开发,那么无论如何都可以运行得很快,因此我需要强调安全性。(说真的,你读过苹果的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编译器不会检查数组边界。如果你引用了属于进程外的内存地址,就有可能出现段错误(segmentation fault)。但是,局部变量是分配在栈上的,根据内存分配方式,数组(array[10])后面的区域可能属于进程的内存段,因此不会触发段错误陷阱,这似乎就是你遇到的情况。正如其他人所指出的,这是C语言中未定义行为,你的代码可能会被视为不稳定。因此,在学习C语言时,最好养成检查数组边界的习惯。


4
除了内存可能被布局以至于试图写入 a[10] 实际上覆盖 i 的可能性外,还可能出现优化编译器确定循环测试不能通过访问不存在的数组元素 a[10] 而达到 i 大于十的值时。由于尝试访问该元素将是未定义的行为,因此编译器在此之后对程序可能执行什么操作没有任何义务。具体来说,由于编译器没有义务在可能大于十的任何情况下生成检查循环索引的代码,它也没有义务完全生成检查代码;它可以假设 <= 10 的测试始终为真。请注意,即使代码读取 a[10] 而不是写入它,这也是正确的。

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接