在循环的最后一次运行中,您写入了
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
cltq
movl $0, -48(%rbp,%rax,4)
movl $.LC0, %edi
call puts
addl $1, -52(%rbp)
.L2:
cmpl $10, -52(%rbp)
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 -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;
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
的调用),都可以对编译器生成的可执行程序产生显著影响。编译器优化可以做很多看似不符合直觉的事情,特别是在调用未定义行为的程序时。这就是为什么未定义行为完全没有被定义的原因。在现实世界的程序中,即使对于有经验的程序员来说,稍微偏离轨道,也很难理解代码执行与应该执行的关系。
i
存储在array
结束后的位置,并且您正在使用array [10] = 0;
覆盖它。在同一平台上进行优化构建时,情况可能并非如此,可能将i
存储在寄存器中,并且根本不引用内存中的它。 - paddy