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,据说可以修改(我还不会)
- 使用递归函数会增加栈的开销
在探索栈区空间的过程中,涉及到了有关函数的栈帧的概念、函数栈帧的开辟、实参入栈、以及栈帧的回退等编译链接方面的问题。此外,还有关于计算机的虚拟地址空间分布、内存映射、以及内存分页机制等计算机基础方面的知识。
最后:推荐阅读《程序员的自我修养:链接、装载与库》。欢迎大家一起评论讨论互相学习。
C语言 | 栈区空间初探的更多相关文章
- 0xC0000005;Access Violation(栈区空间很宝贵, linux上栈区空间默认为8M,vc6下默认栈空间大小为1M)
写C/C++程序最怕出现这样的提示了,还好是在调试环境下显示出来的,在非调试状态就直接崩溃退出. 从上述汇编代码发现在取内存地址 eax+38h 的值时出错, 那说明这个地址非法呗, 不能访问, 一般 ...
- R语言绘制空间热力图
先上图 R语言的REmap包拥有非常强大的空间热力图以及空间迁移图功能,里面内置了国内外诸多城市坐标数据,使用起来方便快捷. 开始 首先安装相关包 install_packages("dev ...
- Go语言并发机制初探
Go 语言相比Java等一个很大的优势就是可以方便地编写并发程序.Go 语言内置了 goroutine 机制,使用goroutine可以快速地开发并发程序, 更好的利用多核处理器资源.这篇文章学习 g ...
- c语言—栈区,堆区,全局区,文字常量区,程序代码区 详解
转:http://www.cnblogs.com/xiaowenhui/p/4669684.html 一.预备知识—程序的内存分配 一个由C/C++编译的程序占用的内存分为以下几个部分1.栈区(sta ...
- 嵌入式C语言4.1 C语言内存空间的使用-指针
指针:就是内存资源的地址.门牌号的代名词 假如你所在的城市是一个内存(存储器),如果找到你家,就是通过你的家庭住址(指针)寻找,而你家里的摆设面积之类的就是内存的内容(指针指向的内容). 指针变量:存 ...
- C语言数组空间的初始化详解
数组空间的初始化就是为每一个标签地址赋值.按照标签逐一处理.如果我们需要为每一个内存赋值,假如有一个int a[100];我们就需要用下标为100个int类型的空间赋值.这样的工作量是非常大的,我们就 ...
- R语言dplyr包初探
昨天学了一下R语言dplyr包,处理数据框还是很好用的.记录一下免得我忘记了... 先写一篇入门的,以后有空再写一篇详细的用法. #dplyr learning library(dplyr) #fil ...
- Go语言程序结构分析初探
每一种编程语言都有自己的语法.结构以及自己的风格,这也是每种语言展现各自魅力及众不同的地方.Go也不例外,它简单而优雅,与此同时使用起来也很有趣.在本文中,我们将讨论以下几点: Go程序结构 如何运行 ...
- 嵌入式C语言4.4 C语言内存空间的使用-多级指针
多级指针 int **p; 存访地址的地址空间
随机推荐
- C# init用法
init是什么意思? init就 modreq([System.Runtime]System.Runtime.CompilerServices.IsExternalInit) 类型的缩写 modreq ...
- Java:输入输出、格式化输出
1.输出 都在System.out模块下,常用方法有: print:输出: println:输出并换行: printf:格式化输出: 2.格式化输出 格式化输出的方法是System.out.print ...
- pandas模块篇(之三)
今日内容概要 目标:将Pandas尽量结束 如何读取外部excel文件数据到DataFrame中 针对DataFrame的常用数据操作 索引与切片 操作DataFrame的字段名称 时间对象序列操作 ...
- java复习面向对象(二)
java复习面向对象(二) 1.static关键字 举例图片 静态变量 使用static修饰的成员变量是静态变量 如果一个成员变量使用了关键字static,那么这个变量不属于对象自己,而属于所在的类多 ...
- JZ-038-二叉树的深度
二叉树的深度 题目描述 输入一棵二叉树,求该树的深度.从根结点到叶结点依次经过的结点(含根.叶结点)形成树的一条路径,最长路径的长度为树的深度. 题目链接: 二叉树的深度 代码 /** * 标题:二叉 ...
- linux Wireshark图解TCP三次握手与四次挥手
Linux Wireshark图解TCP三次握手与四次挥手 原文章链接:Wireshark图解TCP三次握手与四次挥手 文章内容丰富 值得学习
- CF587F&CF547E题解
这两道题好像啊 贡献一种使用SAM和ACAM草两道题的方法 下面假装有 \(O(\sum |S|=m)=O(n)\). 你看看,这CF换过多少个出题人啦?换汤不换药啦!其实这两道题是同一个人出的 CF ...
- ffmpeg修改视频文件的分辨率
在本文中,我们将展示如何调整任何视频文件的大小. 这种方法是在Linux系统(几乎任何发行版)中调整视频文件大小的最佳方法之一,也是Windows和Mac用户的绝佳替代方案. 更改视频文件的分辨率将是 ...
- vue3-码一下组件的v-model
RT,码一下组件的v-model,知识点太多,没听懂...,后面学完回来再仔细看看
- npm vue路由配置
npm vue路由 复习:1.README.md文件:保存如何使用的命令 (1) npm install:拷项目时可以不拷node_modules文件,运行该命令时,会自动下载node_mod ...