C语言 | 栈区空间初探
栈的定义
栈(stack)又名堆栈,堆栈是一个特定的存储区或寄存器,它的一端是固定的,另一端是浮动的 。对这个存储区存入的数据,是一种特殊的数据结构。所有的数据存入或取出,只能在浮动的一端(称栈顶)进行,严格按照“先进后出”的原则存取,位于其中间的元素,必须在其栈上部(后进栈者)诸元素逐个移出后才能取出。在内存储器(随机存储器)中开辟一个区域作为堆栈,叫软件堆栈;用寄存器构成的堆栈,叫硬件堆栈。
- 栈(操作系统):由操作系统自动分配释放 ,存放函数的参数值,局部变量的值等。其操作方式类似于数据结构中的栈。
- 堆(操作系统): 一般由程序员分配释放, 若程序员不释放,程序结束时可能由OS(操作系统)回收,分配方式倒是类似于链表。
本文目录:
栈的特点——先进后出
栈是一种一端受限的线性表,它只接受从同一端插入或删除,并且严格遵守先进后出的准则。什么意思呢,我们这样来理解,栈就是一个倒置的桶,这个桶有一定的空间,我们可以用这个桶来做装很多东西。在C语言中有着形如 int 类型占4个字节的空间,char 类型占1个字节空间等等的不同大小的变量类型。而我们在函数中定义一个变量 int a = 10;
,等于我们把一个名为 a 的占据4个字节空间的物体放入栈这个桶中,那么我们再来定义一个变量 char c = 'x'
,同样也放如栈中。那么我们模拟出栈的布局应是如下所示
我们可以看到,在栈中变量c在变量a的上方。因此,出栈的时候只能先出 c 变量再出 a 变量,而我们知道,a 变量是先于 c 变量入栈的,所以栈的特点为 先进后出 。
好了,关于栈的数据结构部分的讨论暂且先停下,下面我们要讨论的是内存中的栈区,而不是我们所说的数据结构栈。
函数内部的变量在栈区申请
我们说函数、全局变量、静态变量的虚拟地址在编译时就可确定,而在函数中使用的变量在运行时确定,函数形参在函数调用时确定。
那么这句话是什么意思呢?函数的入口地址、全局变量在编译阶段就确定的地址,这是编译链接的知识我们今天先不讨论,今天主要讨论一下栈区以及栈区的使用。
栈区空间分布
我们所说的栈区和平时我们用的数据结构中的栈还是有一些不同的。在内存中栈又叫堆栈,它分布在虚拟地址空间中,仅占一小部分。一个程序运行时拥有一个自己的虚拟地址空间,在32位计算机上为 2^32次方大小(4G)的一块内存空间。在这块空间中所有的地址都是逻辑地址,即虚拟的地址,在程序运行到哪一部分空间时把相应的内存页映射到真实的物理地址上。整个虚拟地址空间分布大致如下图所示
在定义变量之前,我们首先要知道,函数中使用的变量在栈上申请空间,至于原因我们下次在讨论。那么对于栈这种数据结构来说,它是由高地址向低地址生长的一种结构。像我们平时在 main函数或是普通的函数中定义的变量都是由栈区来进行管理的。下面进行几个实例以便于我们更加了解栈区的使用。
字符串在栈中申请空间的方式
编写如下C程序:
int main()
{
char str[] = { "hello world" };
char str2[10];
printf("%s \n",str);
printf("%s\n",str2);
return 0;
}
在 VS 2019中运行
我们在C源码中,给 str
赋值为“Hello World”,而 str2
没有进行赋值。
这里要说明一点,在函数内部会根据函数所用到的空间大小生成函数的栈帧,而后对其内存空间进行 0xcccc cccc
的初始化赋值。而'cc'
在中文编码下就是“烫”字符。有时候我们会说申请的局部变量(函数的作用域下)没有进行赋值其内容会是随机值。这么说其实也没错,原因很简单,在内存中的某个内存块上,无时无刻不伴随着大量程序的使用,而在程序使用过后就会在该内存块处留下一些数据,这些数据我们无法使用在我们看来就是随机值。而在 VS 编译器中为了防止随机值对程序运行结果造成干扰,就通过用初始化为 0xcccc cccc
的方式进行统一的初始化。而字符串的输出时靠字符串末尾的 \0
结束符来确定的,str2 ,中并没有该字符,因此在输出时一直顺着栈向高地址寻找,直到找到 str 中的 \0
结束符。
变量在栈中申请空间的方式
从上图我们也可以看到字符串在栈中是连续申请的。此外,变量、数组等也是在栈中连续申请的,请看下面的实例:
在Linux 上编写如下代码:
#include <stdio.h>
void main(void)
{
//整型变量
int a = 10;
int b = 10;
//字符型变量
char c = a;
char ch = b;
//数组型变量
int arr[10] = { 1,2,3,4,5,6,7,8,9,0 };
int d = 10;
// 输出源码
char str[] =" \
int a = 10;\n \
int b = 10;\n \
\n \
char c = a;\n \
char ch = b;\n \
\n \
int arr[10] = { 1,2,3,4,5,6,7,8,9,0 };\n \
int d = 10;\n";
printf("%s\n",str);
printf("&a = %x \t &b = %x\n",&a, &b);
printf("&c = %x \t &ch = %x\n",&c, &ch);
for(int i = 0; i < 10; ++i)
{
printf("&arr[%d] = %x \t arr[%d] = %d\n", i, &arr[i], i, arr[i]);
}
printf("---&arr[10] &arr[11] &arr[12] &arr[13] &arr[14] &arr[15] \n");
printf("---%x %x %x %x %x %x\n", &arr[10], &arr[11], &arr[12], &arr[13], &arr[14], &arr[15]);
exit(0);
}
在Linux 上编译运行 gcc -std=c99 -o test test.c
./test
其中 -std=c99 表示使用C99语法规则。
我们把输出结果复制到画图板上进行分析。另外,程序每次运行时,程序执行的起始地址不同,最终每次输出的结果也不同(如画图板上输出结果为重新运行的结果),但是这并我影响我们观察的结果。
分析:
如图所示,在标注出的绿色部分是申请的两个整型变量,根据栈的特性和申请的顺序,变量a在靠近高地址处,变量b在靠近低地址处。a、b都是整型变量,占4字节,所以在内存中因该是 e105a30---e105a33
为b变量占据的内存空间,e105a34---e105a37
为a变量占据的内存地址。
注:在图中仅标注出了起始地址。
对于 char 型字符变量 c 和 ch ,他们都只有一个字节的内存空间,但是由于内存对齐机制,他们两个一共占据了 e105a2c---e105a2f
4个字节。虽然如此,在使用时他们任然只使用一个字节。c 对应内存地址为 e105a2f
,ch 对应内存地址为 e105a2e
。
通过观察我们发现,数组中的 arr[11]、arr[12]、arr[13]的地址与我们定义的 ch 、b、a的首地址相同,这是怎么回事呢?
数组在栈中的排布
数组比较特殊,按照我们的理解,栈从高地址位向低地址为生长。按理来说,arr[0] 作为数组的首元素应该在高地址为最先被申请,而 arr[9] 为数组的末位元素理应在栈的低地址位被申请。然而,根据程序打印出的结果我们画出的栈内存布局显示,数组在栈中是顺着地址增长的方向排布的。😥其实,我们不妨想一想,在平时我们使用指针访问数组时 int *p = &arr[0]
,通过 p++(ps: 指针➕偏移实质上是,指针指向的地址+sizeof(指针类型) × n) 就能改变指针指向到下一元素,这恰恰可以证明数组在栈中是按照地址增长的顺序排布的(栈的低地址到高地址)。
关于数组在栈中申请空间的猜想:数组在栈区进行空间申请的时候,编译器因该是把数组作为一个整体来看待的,编译器向栈区申请 sizeof(arr) 个字节的空间,然后从该空间的起始位置开始赋值,arr[0]、arr[1]、……一直到数组的最后一个元素。
该数组从 e105a00
处到 e105a27
处,一共占据 4×10 = 40个字节(0x28),其中每一个元素占据四个字节,但是我们发现,在 e105a28---e105a2b
处有一个多出的内存块是我们没有申请的,也就是我们没有在代码中列出的变量。我们在代码中申请完 ch 字符变量后紧接着申请了数组,那么说是不是两个字符数组在字节对齐的时候进行了8字节对齐呢?我们口说无凭,编写一段代码来测试下吧。
探究数组在栈中的布局
编写如下程序:
先申请一个整型变量,紧接着申请一个字符变量和一个整型变量观察他们的地址变化;
再申请一个整型变量,紧接着申请两个字符变量和一个整型变量观察他们的地址变化;
#include <stdio.h>
int main()
{
int a;
char c;
int b;
printf("&a=%x &c=%x &b=%x \n",&a, &c, &b);
int aa;
char cc1;
char cc2;
int bb;
printf("&aa=%x &cc1=%x &cc2=%x &b=%x \n",&aa, &cc1, &cc2, &bb);
return 0;
}
在Linux 上编译运行,输出如下
地址分析:
- &a=dcb6aacc &c=dcb6aacb &b=dcb6aac4
a的首地址为dcb6aacc
,占据内存空间为:dcb6aacc---dcb6aadf
a的首地址为dcb6aacb
,占据内存空间为:dcb6aac8---dcb6aacb
4字节对齐
a的首地址为dcb6aac4
,占据内存空间为:dcb6aac4---dcb6aac7
- &aa=dcb6aac0 &cc1=dcb6aabf &cc2=dcb6aabe &b=dcb6aab8
aa 的首地址为dcb6aac0
,占据内存空间为:dcb6aac0---dcb6aac3
cc1的首地址为dcb6aabf
,cc2的首地址为dcb6aabe
占据内存空间为:dcb6aabc---dcb6aabf
4字节对齐
bb 的首地址为dcb6aab8
,占据内存空间为:dcb6aab8---dcb6aabb
从上面的结论我们已经看到,不论是一个 char、还是两个char,都是不满四字节向四字节内存对齐。(向后对齐原则,为了保证在该char 类型之后的申请的变量能够是2的整数倍地址,char变量内存对齐时与紧随其后的变量类型相关。该例中,如果bb的类型为double,那么cc1与cc2将向8字节内存对齐)。
关于栈中申请的数组末尾多出一块地址空间被占用的问题分析
可以看到不是因为 c 与 ch 内存对齐导致e105a28---e105a2b
内存块被占用,那么会不会是编译器自己生成的呢。因为处理数组类问题时常常会遇到越界访问的问题,通常这类问题会出现在使用 for( ; ;)
语句或是while()
语句中循环条件处理不当会造成,数组越界访问从而修改了其他数据的值。那么会不会是编译器或者数组本身申请的时候就加入了这种机制呢?
我们使用 sizeof 运算符时,得到的是整个数组的空间大小,与我们定义时完全一致。那么我暂且猜想,是编译器的一种安全机制,避免我们因操作不当而不小心修改了其他变量的值进行的一种维护手段。
注:此处为推论,暂未查得资料证实。欢迎评论补充,一起探讨学习。
一个小测试
在Linux 下编写如下程序进行测试
#include <stdio.h>
int main()
{
int i;
printf(" &i = %x\n",&i);
int arr[10];
for(i=0;i<=10;i++) // 越界了
{
arr[i] = 0;
printf(" %d %x\n",i,&arr[i]);
}
return 0;
}
可以看到如上程序在 i <= 10
处越界了。那么我们运行程序观察结果
我们发现即使程序越界了,arr[10] 已经超出了数组的范围,i 从0输出到 10。并且我们观察到 arr[10]的地址为203231c8
与 i 的地址 203231cc
相邻在一起。换句话说,如果我们继续向下修改内存中的值的话,很可能就会改变 i 的值,而 i 是我们的循环条件一旦改变将会产生不可预料的后果。那么,我们试着把 for(i=0;i<=10;i++)
的条件改变成 for(i=0;i<=11;i++)
试一试会发生什么效果。
修改循环条件,编译运行
程序变成了死循环,???,怎么成死循环了?
别急,让我们一步一步来分析:
每次循环的内部,都会做一件事 arr[i] = 0
,相对于前十次在数组内算是正常操作,而第十一次开始就已经属于越界操作了,但相对来说还是安全的,因为该处没有变量使用,而相对第十二次操作来说,直接修改了变量 i 的值,并且通过循环 i 的值会从 0 增加到 10,这就形成了一个死循环。
可能有人会问了(怎么又有人问了?谁问了?谁?谁?😜站出来),你一会用Windows 的VS 编译器,一会儿有转战 Linux 的gcc,你到底几个意思啊。
在这里不得不说一下VS是真的强大👍,在VS中编译的程序都进行了优化,有些变量的地址连它自己都不知道在哪儿(玩笑话😋),并且VS对一些错误的包容性很好,甚至是帮你处理掉了,所以在VS下进行试验可能会误导我们的判断。并且就拿上述代码来说在VS下运行,编译器直接就报错了,哪里会让你有机会死循环。所以说VS是真的香,搞得我现在一旦离开VS就不会编写程序了,最后BUG一堆不说还半天找不到问题原因😭。看来我还是太年轻、太依赖编译器了。
好了,让我们来看一下VS下的执行结果吧。
这里使用的 vs 2019,直接把错误弹出了。有编译器就是方便啊,虽然说平时使用经常报错感觉不太友好的亚子,但是这也说明了我们的编程基本功不到家啊。嗯嗯,要不以后试试手写代码😝。
栈区空间大小
好了,题外话不在多说了。现在终于搞懂变量在栈中是怎么存储的了,那么对于内存中的栈区而言,他的大小有多大呢?这个问题问的好(谁问了?谁…🔪🔪🔪)。
在Linux下可以通过 ulimit -s
查看栈的大小,可以看到默认有8m大小。
这个栈区大小也是可以通过实际代码测试出来的,通过递归函数的特点我们可以设计一种测试栈容量的程序。
#include <stdio.h>
void GetMem()
{
char mem[1024 * 1024]; // 1M
printf("1M %x \n", &mem[0]);
GetMem();
}
int main()
{
GetMem();
return 0;
}
测试结构如下:
我们可以看到在程序第七次申请空间结束后,第八次申请空间时发生了栈溢出。每次申请的空间大小为 1024×1024个字节大小,也就是 1M的空间大小。此外,调用函数本身也会有一定的函数栈帧开销。例如,在使用栈进行求斐波那契数列时,超过一定的范围就会致使栈溢出。因此,在平时的编程中,一定要慎用递归函数。
总结:
本次对内存栈区空间初探,主要通过实验的方式编写了一部分简单代码。通过对变量、字符串、数组、以及字符变量在栈区的内存地址分析,以及通过递归函数测试栈的容量等简单操作,对内存中的堆栈进行了一个初步的认识,主要总结为以下几点:
- 栈区的申请的变量均为连续空间
- 局部变量在栈中申请空间
- 字符串在栈中输出时如果没有结束字符会一直输出
C语言中字符串主要为字符数组 - 数组在栈区申请的空间,在最后一个元素的末尾空出一个内存单元
- 栈区的空间大小约为 8M,据说可以修改(我还不会😮)
- 使用递归函数会增加栈的开销
在探索栈区空间的过程中,涉及到了有关函数的栈帧的概念、函数栈帧的开辟、实参入栈、以及栈帧的回退等编译链接方面的问题。此外,还有关于计算机的虚拟地址空间分布、内存映射、以及内存分页机制等计算机基础方面的知识。
最后:推荐阅读《程序员的自我修养:链接、装载与库》。欢迎大家一起评论讨论互相学习。