栈的定义

栈(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,据说可以修改(我还不会)
  • 使用递归函数会增加栈的开销

在探索栈区空间的过程中,涉及到了有关函数的栈帧的概念、函数栈帧的开辟、实参入栈、以及栈帧的回退等编译链接方面的问题。此外,还有关于计算机的虚拟地址空间分布、内存映射、以及内存分页机制等计算机基础方面的知识。

最后:推荐阅读《程序员的自我修养:链接、装载与库》。欢迎大家一起评论讨论互相学习。

C语言 | 栈区空间初探的更多相关文章

  1. 0xC0000005;Access Violation(栈区空间很宝贵, linux上栈区空间默认为8M,vc6下默认栈空间大小为1M)

    写C/C++程序最怕出现这样的提示了,还好是在调试环境下显示出来的,在非调试状态就直接崩溃退出. 从上述汇编代码发现在取内存地址 eax+38h 的值时出错, 那说明这个地址非法呗, 不能访问, 一般 ...

  2. R语言绘制空间热力图

    先上图 R语言的REmap包拥有非常强大的空间热力图以及空间迁移图功能,里面内置了国内外诸多城市坐标数据,使用起来方便快捷. 开始 首先安装相关包 install_packages("dev ...

  3. Go语言并发机制初探

    Go 语言相比Java等一个很大的优势就是可以方便地编写并发程序.Go 语言内置了 goroutine 机制,使用goroutine可以快速地开发并发程序, 更好的利用多核处理器资源.这篇文章学习 g ...

  4. c语言—栈区,堆区,全局区,文字常量区,程序代码区 详解

    转:http://www.cnblogs.com/xiaowenhui/p/4669684.html 一.预备知识—程序的内存分配 一个由C/C++编译的程序占用的内存分为以下几个部分1.栈区(sta ...

  5. 嵌入式C语言4.1 C语言内存空间的使用-指针

    指针:就是内存资源的地址.门牌号的代名词 假如你所在的城市是一个内存(存储器),如果找到你家,就是通过你的家庭住址(指针)寻找,而你家里的摆设面积之类的就是内存的内容(指针指向的内容). 指针变量:存 ...

  6. C语言数组空间的初始化详解

    数组空间的初始化就是为每一个标签地址赋值.按照标签逐一处理.如果我们需要为每一个内存赋值,假如有一个int a[100];我们就需要用下标为100个int类型的空间赋值.这样的工作量是非常大的,我们就 ...

  7. R语言dplyr包初探

    昨天学了一下R语言dplyr包,处理数据框还是很好用的.记录一下免得我忘记了... 先写一篇入门的,以后有空再写一篇详细的用法. #dplyr learning library(dplyr) #fil ...

  8. Go语言程序结构分析初探

    每一种编程语言都有自己的语法.结构以及自己的风格,这也是每种语言展现各自魅力及众不同的地方.Go也不例外,它简单而优雅,与此同时使用起来也很有趣.在本文中,我们将讨论以下几点: Go程序结构 如何运行 ...

  9. 嵌入式C语言4.4 C语言内存空间的使用-多级指针

    多级指针 int **p; 存访地址的地址空间

随机推荐

  1. 每日一算法之two sum

    题目如下:首先准备一个数组,[1,2,8,4,9]  然后输入一个6,找出数组两项之和为6的两个下标. 啥也不想,马上上代码,这个太简单了, static int[] twoSum(int[] num ...

  2. 基于消息队列(RabbitMQ)实现延迟任务

    一.序言 延迟任务应用广泛,延迟任务典型应用场景有订单超时自动取消:支付回调重试.其中订单超时取消具有幂等性属性,无需考虑重复消费问题:支付回调重试需要考虑重复消费问题. 延迟任务具有如下特点:在未来 ...

  3. 【转载】深入浅出SQL Server中的死锁

    essay from:http://www.cnblogs.com/CareySon/archive/2012/09/19/2693555.html 简介 死锁的本质是一种僵持状态,是多个主体对于资源 ...

  4. 分页PHP

    <?php//1.连接数据库$link = mysqli_connect('127.0.0.1','root','root','1906');//2.设置字符集mysqli_set_charse ...

  5. nginx配置负载均衡分发服务器笔记

    记录学习搭建nginx负载均衡分发服务器的过程笔记 1.服务器IP:192.168.31.202(当前需要搭建nginx负载均衡分发的服务器)安装好nginx 2.在服务器IP:192.168.31. ...

  6. hive从入门到放弃(二)——DDL数据定义

    前一篇文章,介绍了什么是 hive,以及 hive 的架构.数据类型,没看的可以点击阅读:hive从入门到放弃(一)--初识hive 今天讲一下 hive 的 DDL 数据定义 创建数据库 CREAT ...

  7. 《shader入门精要》13.2再谈运动模糊中片元着色器的世界坐标的计算

    具体在书p275页 这里为啥需要除D.w呢. 首先我们得到的NDC的坐标是已经归一化的,但是CurrenViewProjectionMatrix的作用,是把世界空间转化为尚未归一化的裁剪空间. 这里看 ...

  8. 宏参数(Arguments)的扩展

    宏分为两种,一种是 object-like 宏,比如: #define STR "Hello, World!" 另一种是 function-like 宏,比如: #define M ...

  9. 谈谈 Kubernetes Operator

    简介 你可能听过Kubernetes中Operator的概念,Operator可以帮助我们扩展Kubernetes功能,包括管理任何有状态应用程序.我们看到了它被用于有状态基础设施应用程序的许多可能性 ...

  10. 《手把手教你》系列基础篇(八十一)-java+ selenium自动化测试-框架设计基础-TestNG如何暂停执行一些case(详解教程)

    1.简介 在实际测试过程中,我们经常会遇到这样的情况,开发由于某些原因导致一些模块进度延后,而你的自动化测试脚本已经提前完成,这样就会有部分模块测试,有部分模块不能进行测试.这就需要我们暂时不让一些t ...