您应该在循环的最后一次运行中写入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]=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; /*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
)如何对可执行程序生成的内容产生重大影响由编译器来做。编译器优化可以做很多事情,这些事情在调用未定义行为的程序上可能看起来并不直观。这就是为什么未定义的行为完全未定义的原因。当您在实际程序中偏离轨道如此微小时,即使对于有经验的程序员来说,也很难理解代码的作用与应执行的作用之间的关系。