首页 > 代码库 > C/C++中变量的分配和在内存中的存储方式

C/C++中变量的分配和在内存中的存储方式

操作系统与C语言中的堆栈及其区别

CSDN


C/C++

一个由C/C++编译的程序占用的内存分为以下几个部分 
1. 栈区(stack)— 由编译器自动分配释放,存放函数的参数名,局部变量的名等。其操作方式类似于数据结构中的栈。 
2. 堆区(heap)— 由程序员分配释放,若程序员不释放,程序结束时可能由OS回收。注意它与数据结构中的堆是两回事,分配方式倒是类似于链表。 
3. 全局区(静态区)(static)—全局变量和静态变量的存储是放在一块的,初始化的全局变量和静态变量在一块区域, 未初始化的全局变量和未初始化的静态变量在相邻的另一块区域。程序结束后由系统释放。 
4. 文字常量区—常量字符串就是放在这里的,程序结束后由系统释放 。 
5. 程序代码区— 存放函数体的二进制代码。

例子程序

这是一个前辈写的,非常详细

  1. //main.cpp
  2. int a = 0;//全局初始化区
  3. char*p1;//全局未初始化区
  4. main()
  5. {
  6. int b;//栈
  7. char s[] = "abc";//栈
  8. char *p2;//栈
  9. char *p3 = "123456";// 123456\0 在常量区,p3在栈上。
  10. static int c = 0;//全局(静态)初始化区
  11. p1 = (char*)malloc(10);
  12. p2 = (char*)malloc(20);//分配得来的10和20字节的区域就在堆区。
  13. }
strcpy(p1, "123456"); //123456\0放在常量区,编译器可能会将它与p3所指向的"123456"优化成一个地方,这里是优化之后的效果,并不是标准要求

堆栈区别


  1. 内存分配方面:

堆:一般由程序员分配释放,若程序员不释放,程序结束时可能由OS回收。注意它与数据结构中的堆是两回事,分配方式是类似于链表。可能用到的关键字如下:newmallocdeletefree等等。

栈:由编译器(Compiler)自动分配释放,存放函数的参数值,局部变量的值等。其操作方式类似于数据结构中的栈。

2、申请方式方面:

堆:需要程序员自己申请,并指明大小。在cmalloc函数如p1 = (char *)malloc(10);在C++中用new运算符,但是注意p1p2本身是在栈中的。因为他们还是可以认为是局部变量。

栈:由系统自动分配。 例如,声明在函数中一个局部变量 int b;系统自动在栈中为b开辟空间。

  1. 系统响应方面:

堆:操作系统有一个记录空闲内存地址的链表,当系统收到程序的申请时,会遍历该链表,寻找第一个空间大于所申请空间的堆结点,然后将该结点从空闲结点链表中删除,并将该结点的空间分配给程序,另外,对于大多数系统,会在这块内存空间中的首地址处记录本次分配的大小,这样代码中的delete语句才能正确的释放本内存空间。另外由于找到的堆结点的大小不一定正好等于申请的大小,系统会自动的将多余的那部分重新放入空闲链表中。

栈:只要栈的剩余空间大于所申请空间,系统将为程序提供内存,否则将报异常提示栈溢出。

  1. 大小限制方面:

堆:是向高地址扩展的数据结构,是不连续的内存区域。这是由于系统是用链表来存储的空闲内存地址的,自然是不连续的,而链表的遍历方向是由低地址向高地址。堆的大小受限于计算机系统中有效的虚拟内存。由此可见,堆获得的空间比较灵活,也比较大。

栈:在Windows下, 栈是向低地址扩展的数据结构,是一块连续的内存的区域。这句话的意思是栈顶的地址和栈的最大容量是系统预先规定好的,在WINDOWS下,栈的大小是固定的(是一个编译时就确定的常数),如果申请的空间超过栈的剩余空间时,将提示overflow。因此,能从栈获得的空间较小。

  1. 效率方面:

堆:是由new分配的内存,一般速度比较慢,而且容易产生内存碎片,不过用起来最方便,另外,在WINDOWS下,最好的方式是用VirtualAlloc分配内存,他不是在堆,也不是在栈是直接在进程的地址空间中保留一快内存,虽然用起来最不方便。但是速度快,也最灵活。

栈:由系统自动分配,速度较快。但程序员是无法控制的。

  1. 存放内容方面:

堆:一般是在堆的头部用一个字节存放堆的大小。堆中的具体内容有程序员安排。

栈:在函数调用时第一个进栈的是主函数中后的下一条指令(函数调用语句的下一条可执行语句)的地址然后是函数的各个参数,在大多数的C编译器中,参数是由右往左入栈,然后是函数中的局部变量。 注意: 静态变量是不入栈的。当本次函数调用结束后,局部变量先出栈,然后是参数,最后栈顶指针指向最开始存的地址,也就是主函数中的下一条指令,程序由该点继续运行。

  1. 存取效率方面:

堆:char *s1 = "Hellow Word";是在编译时就确定的;

栈:char s1[] = "Hellow Word";是在运行时赋值的;用数组比用指针速度要快一些,因为指针在底层汇编中需要用edx寄存器中转一下,而数组在栈上直接读取。

堆栈原理


申请方式

stack:由系统自动分配。 例如,声明在函数中一个局部变量int b; 系统自动在栈中为b开辟空间 
heap:需要程序员自己申请,并指明大小,在cmalloc函数。如p1 = (char *)malloc(10)
C++中用new运算符。如p2 = new char[20];//(char *)malloc(10);但是注意p1p2本身是在栈中的。

申请响应

栈:只要栈的剩余空间大于所申请空间,系统将为程序提供内存,否则将报异常提示栈溢出。 
堆:首先应该知道操作系统有一个记录空闲内存地址的链表,当系统收到程序的申请时,会遍历该链表,寻找第一个空间大于所申请空间的堆结点,然后将该结点从空闲结点链表中删除,并将该结点的空间分配给程序,另外,对于大多数系统,会在这块内存空间中的首地址处记录本次分配的大小,这样,代码中的delete语句才能正确的释放本内存空间。另外,由于找到的堆结点的大小不一定正好等于申请的大小,系统会自动的将多余的那部分重新放入空闲链表中。

申请限制

栈:在Windows下,栈是向低地址扩展的数据结构,是一块连续的内存的区域。这句话的意思是栈顶的地址和栈的最大容量是系统预先规定好的,在WINDOWS下,栈的大小是2M(也有的说是1M,总之是一个编译时就确定的常数),如果申请的空间超过栈的剩余空间时,将提示overflow。因此,能从栈获得的空间较小。 
堆:堆是向高地址扩展的数据结构,是不连续的内存区域。这是由于系统是用链表来存储的空闲内存地址的,自然是不连续的,而链表的遍历方向是由低地址向高地址。堆的大小受限于计算机系统中有效的虚拟内存。由此可见,堆获得的空间比较灵活,也比较大。

存取比较

char s1[] = "aaaaaaaaaaaaaaa";
char *s2 = "bbbbbbbbbbbbbbbbb";

aaaaaaaaaaa是在运行时刻赋值的;而bbbbbbbbbbb是在编译时就确定的;

但是,在以后的存取中,在栈上的数组比指针所指向的字符串(例如堆)快。 
比如:

  1. #include
  2. void main()
  3. {
  4. char a = 1;
  5. char c[] = "1234567890";
  6. char *p ="1234567890";
  7. a = c[1];
  8. a = p[1];
  9. return;
  10. }

对应的汇编代码

  1. 10: a = c[1];
  2. 00401067 8A 4D F1 mov cl,byte ptr [ebp-0Fh]
  3. 0040106A 88 4D FC mov byte ptr [ebp-4],cl
  4. 11: a = p[1];
  5. 0040106D 8B 55 EC mov edx,dword ptr [ebp-14h]
  6. 00401070 8A 42 01 mov al,byte ptr [edx+1]
  7. 00401073 88 45 FC mov byte ptr [ebp-4],al

第一种在读取时直接就把字符串中的元素读到寄存器cl中,而第二种则要先把指针值读到edx中,再根据edx读取字符,显然慢了。

小结

堆和栈的区别可以用如下的比喻来看出: 
使用栈就象我们去饭馆里吃饭,只管点菜(发出申请)、付钱、和吃(使用),吃饱了就走,不必理会切菜、洗菜等准备工作和洗碗、刷锅等扫尾工作,他的好处是快捷,但是自由度小。 
使用堆就象是自己动手做喜欢吃的菜肴,比较麻烦,但是比较符合自己的口味,而且自由度大。

堆栈是一种存储部件,即数据的写入跟读出不需要提供地址,而是根据写入的顺序决定读出的顺序。 
形象来说,栈就是一条流水线,而流水线中加工的就是方法的主要程序,在分配栈时,由于程序是自上而下顺序执行,就将程序指令一条一条压入栈中,就像流水线一样。而堆上站着的就是工作人员,他们加工流水线中的商品,由程序员分配:何时加工,如何加工。而我们通常使用new运算符为对象在堆上分配内存(java),堆上寻找对象的任务交给句柄,而栈中由栈指针管理

补充


接触过编程的人都知道,高级语言都能通过变量名来访问内存中的数据。那么这些变量在内存中是如何存放的呢?程序又是如何使用这些变量的呢?下面就会对此进行深入的讨论。下文中的C语言代码如没有特别声明,默认都使用VC编译的release版。

首先,来了解一下 C 语言的变量是如何在内存分部的。C语言有全局变量(Global)、本地变量(Local),静态变量(Static)、寄存器变量(Regeister)。每种变量都有不同的分配方式。先来看下面这段代码:

  1. #include <stdio.h>
  2. int g1=0, g2=0, g3=0;
  3. int main()
  4. {
  5. static int s1=0, s2=0, s3=0;
  6. int v1=0, v2=0, v3=0;
  7. //打印出各个变量的内存地址
  8. printf("0x%08x\n",&v1); //打印各本地变量的内存地址
  9. printf("0x%08x\n",&v2);
  10. printf("0x%08x\n\n",&v3);
  11. printf("0x%08x\n",&g1); //打印各全局变量的内存地址
  12. printf("0x%08x\n",&g2);
  13. printf("0x%08x\n\n",&g3);
  14. printf("0x%08x\n",&s1); //打印各静态变量的内存地址
  15. printf("0x%08x\n",&s2);
  16. printf("0x%08x\n\n",&s3);
  17. return 0;
  18. }

编译后的执行结果是:

  1. 0x0012ff78
  2. 0x0012ff7c
  3. 0x0012ff80
  4. 0x004068d0
  5. 0x004068d4
  6. 0x004068d8
  7. 0x004068dc
  8. 0x004068e0
  9. 0x004068e4

输出的结果就是变量的内存地址。其中v1,v2,v3是本地变量,g1,g2,g3是全局变量,s1,s2,s3是静态变量。你可以看到这些变量在内存是连续分布的,但是本地变量和全局变量分配的内存地址差了十万八千里,而全局变量和静态变量分配的内存是连续的。这是因为本地变量和全局/静态变量是分配在不同类型的内存区域中的结果。对于一个进程的内存空间而言,可以在逻辑上分成3个部份:代码区,静态数据区和动态数据区。动态数据区一般就是“堆栈”。“栈(stack)”和“堆(heap)”是两种不同的动态数据区,栈是一种线性结构,堆是一种链式结构。进程的每个线程都有私有的“栈”,所以每个线程虽然代码一样,但本地变量的数据都是互不干扰。一个堆栈可以通过“基地址”和“栈顶”地址来描述。全局变量和静态变量分配在静态数据区,本地变量分配在动态数据区,即堆栈中。程序通过堆栈的基地址和偏移量来访问本地变量。

├———————┤低端内存区域 
│ …… │ 
├———————┤ 
│ 动态数据区 │ 
├———————┤ 
│ …… │ 
├———————┤ 
│ 代码区 │ 
├———————┤ 
│ 静态数据区 │ 
├———————┤ 
│ …… │ 
├———————┤高端内存区域

堆栈是一个先进后出的数据结构,栈顶地址总是小于等于栈的基地址(向下增长)。我们可以先了解一下函数调用的过程,以便对堆栈在程序中的作用有更深入的了解。不同的语言有不同的函数调用规定,这些因素有参数的压入规则和堆栈的平衡。windows API的调用规则和ANSI C的函数调用规则是不一样的,前者由被调函数调整堆栈,后者由调用者调整堆栈。两者通过__stdcall__cdecl前缀区分。先看下面这段代码:

  1. #include <stdio.h>
  2. void __stdcall func(int param1,int param2,int param3)
  3. {
  4. int var1=param1;
  5. int var2=param2;
  6. int var3=param3;
  7. printf("0x%08x\n",?m1); //打印出各个变量的内存地址
  8. printf("0x%08x\n",?m2);
  9. printf("0x%08x\n\n",?m3);
  10. printf("0x%08x\n",&var1);
  11. printf("0x%08x\n",&var2);
  12. printf("0x%08x\n\n",&var3);
  13. return;
  14. }
  15. int main()
  16. {
  17. func(1,2,3);
  18. return 0;
  19. }

编译后的执行结果是:

  1. 0x0012ff78
  2. 0x0012ff7c
  3. 0x0012ff80
  4. 0x0012ff68
  5. 0x0012ff6c
  6. 0x0012ff70

├———————┤<—函数执行时的栈顶(ESP)、低端内存区域 
│ …… │ 
├———————┤ 
│ var 1 │ 
├———————┤ 
│ var 2 │ 
├———————┤ 
│ var 3 │ 
├———————┤ 
│ RET │ 
├———————┤<—“__cdecl”函数返回后的栈顶(ESP) 
│ parameter 1 │ 
├———————┤ 
│ parameter 2 │ 
├———————┤ 
│ parameter 3 │ 
├———————┤<—“__stdcall”函数返回后的栈顶(ESP) 
│ …… │ 
├———————┤<—栈底(基地址 EBP)、高端内存区域

上图就是函数调用过程中堆栈的样子了。三个参数以从右到左的次序压入堆栈,先压param3,再压param2,最后压入param1;然后压入函数的返回地址(RET),接着跳转到函数地址接着执行。

聪明的读者看到这里,差不多就明白缓冲溢出的原理了。先来看下面的代码:

  1. #include <stdio.h>
  2. #include <string.h>
  3. void __stdcall func()
  4. {
  5. char lpBuff[8]="\0";
  6. strcat(lpBuff,"AAAAAAAAAAA");
  7. return;
  8. }
  9. int main()
  10. {
  11. func();
  12. return 0;
  13. }

编译后执行一下回怎么样?哈,0x00414141指令引用的0x00000000内存。该内存不能为read。非法操作喽!"41"就是A的16进制的ASCII码了,那明显就是strcat这句出的问题了。lpBuff的大小只有8字节,算进结尾的\0,那strcat最多只能写入7个A,但程序实际写入了11个A外加1个\0。再来看看上面那幅图,多出来的4个字节正好覆盖了RET的所在的内存空间,导致函数返回到一个错误的内存地址,执行了错误的指令。如果能精心构造这个字符串,使它分成三部分,前一部份仅仅是填充的无意义数据以达到溢出的目的,接着是一个覆盖RET的数据,紧接着是一段shell code,那只要着个RET地址能指向这段shellcode的第一个指令,那函数返回时就能执行shell code了。但是软件的不同版本和不同的运行环境都可能影响这段shell code在内存中的位置,那么要构造这个RET是十分困难的。一般都在RETshellcode之间填充大量的NOP指令,使得exploit有更强的通用性。

├———————┤<—低端内存区域 
│ …… │ 
├———————┤<—由exploit填入数据的开始 
│ │ 
│ buffer │<—填入无用的数据 
│ │ 
├———————┤ 
│ RET │<—指向shellcode,或NOP指令的范围 
├———————┤ 
│ NOP │ 
│ …… │<—填入的NOP指令,是RET可指向的范围 
│ NOP │ 
├———————┤ 
│ │ 
│ shellcode │ 
│ │ 
├———————┤<—由exploit填入数据的结束 
│ …… │ 
├———————┤<—高端内存区域

windows下的动态数据除了可存放在栈中,还可以存放在堆中。了解C++的朋友都知道,C++可以使用new关键字来动态分配内存。来看下面的C++代码:

  1. #include <stdio.h>
  2. #include <iostream.h>
  3. #include <windows.h>
  4. void func()
  5. {
  6. char *buffer=new char[128];
  7. char bufflocal[128];
  8. static char buffstatic[128];
  9. printf("0x%08x\n",buffer); //打印堆中变量的内存地址
  10. printf("0x%08x\n",bufflocal); //打印本地变量的内存地址
  11. printf("0x%08x\n",buffstatic); //打印静态变量的内存地址
  12. }
  13. void main()
  14. {
  15. func();
  16. return;
  17. }

程序执行结果为:

  1. 0x004107d0
  2. 0x0012ff04
  3. 0x004068c0

可以发现用new关键字分配的内存即不在栈中,也不在静态数据区。VC编译器是通过windows下的“堆(heap)”来实现new关键字的内存动态分配。

什么是常见的堆性能问题?

以下是您使用堆时会遇到的最常见问题:

分配操作造成的速度减慢。光分配就耗费很长时间。最可能导致运行速度减慢原因是空闲列表没有块,所以运行时分配程序代码会耗费周期寻找较大的空闲块,或从后端分配程序分配新块。

释放操作造成的速度减慢。释放操作耗费较多周期,主要是启用了收集操作。收集期间,每个释放操作“查找”它的相邻块,取出它们并构造成较大块,然后再把此较大块插入空闲列表。在查找期间,内存可能会随机碰到,从而导致高速缓存不能命中,性能降低。

堆竞争造成的速度减慢。当两个或多个线程同时访问数据,而且一个线程继续进行之前必须等待另一个线程完成时就发生竞争。竞争总是导致麻烦;这也是目前多处理器系统遇到的最大问题。当大量使用内存块的应用程序或 DLL 以多线程方式运行(或运行于多处理器系统上)时将导致速度减慢。单一锁定的使用—常用的解决方案—意味着使用堆的所有操作是序列化的。当等待锁定时序列化会引起线程切换上下文。可以想象交叉路口闪烁的红灯处走走停停导致的速度减慢。 
竞争通常会导致线程和进程的上下文切换。上下文切换的开销是很大的,但开销更大的是数据从处理器高速缓存中丢失,以及后来线程复活时的数据重建。

堆破坏造成的速度减慢。造成堆破坏的原因是应用程序对堆块的不正确使用。通常情形包括释放已释放的堆块或使用已释放的堆块,以及块的越界重写等明显问题。

频繁的分配和重分配造成的速度减慢。这是使用脚本语言时非常普遍的现象。如字符串被反复分配,随重分配增长和释放。不要这样做,如果可能,尽量分配大字符串和使用缓冲区。另一种方法就是尽量少用连接操作。 
竞争是在分配和释放操作中导致速度减慢的问题。理想情况下,希望使用没有竞争和快速分配/释放的堆。可惜,现在还没有这样的通用堆,也许将来会有。

在所有的服务器系统中, 堆锁定实在是个大瓶颈。处理器数越多,竞争就越会恶化。

尽量减少堆的使用

现在您明白使用堆时存在的问题了,难道您不想拥有能解决这些问题的超级魔棒吗?我可希望有。但没有魔法能使堆运行加快—因此不要期望在产品出货之前的最后一星期能够大为改观。如果提前规划堆策略,情况将会大大好转。调整使用堆的方法,减少对堆的操作是提高性能的良方。

如何减少使用堆操作?通过利用数据结构内的位置可减少堆操作的次数。请考虑下列实例:

  1. struct ObjectA {
  2. // objectA 的数据
  3. }
  4. struct ObjectB {
  5. // objectB 的数据
  6. }
  7. // 同时使用 objectA 和 objectB
  8. //
  9. // 使用指针
  10. //
  11. struct ObjectB {
  12. struct ObjectA * pObjA;
  13. // objectB 的数据
  14. }
  15. //
  16. // 使用嵌入
  17. //
  18. struct ObjectB {
  19. struct ObjectA pObjA;
  20. // objectB 的数据
  21. }
  22. //
  23. // 集合 – 在另一对象内使用 objectA 和 objectB
  24. //
  25. struct ObjectX {
  26. struct ObjectA objA;
  27. struct ObjectB objB;
  28. }

避免使用指针关联两个数据结构。如果使用指针关联两个数据结构,前面实例中的对象 A 和 B 将被分别分配和释放。这会增加额外开销—我们要避免这种做法。

把带指针的子对象嵌入父对象。当对象中有指针时,则意味着对象中有动态元素(百分之八十)和没有引用的新位置。嵌入增加了位置从而减少了进一步分配/释放的需求。这将提高应用程序的性能。

合并小对象形成大对象(聚合)。聚合减少分配和释放的块的数量。如果有几个开发者,各自开发设计的不同部分,则最终会有许多小对象需要合并。集成的挑战就是要找到正确的聚合边界。

内联缓冲区能够满足百分之八十的需要。个别情况下,需要内存缓冲区来保存字符串/二进制数据,但事先不知道总字节数。估计并内联一个大小能满足百分之八十需要的缓冲区。对剩余的百分之二十,可以分配一个新的缓冲区和指向这个缓冲区的指针。这样,就减少分配和释放调用并增加数据的位置空间,从根本上提高代码的性能。

在块中分配对象(块化)。块化是以组的方式一次分配多个对象的方法。如果对列表的项连续跟踪,例如对一个 {名称,值} 对的列表,有两种选择:选择一是为每一个“名称-值”对分配一个节点;选择二是分配一个能容纳(如五个)“名称-值”对的结构。例如,一般情况下,如果存储四对,就可减少节点的数量,如果需要额外的空间数量,则使用附加的链表指针。 
块化是友好的处理器高速缓存,特别是对于 L1-高速缓存,因为它提供了增加的位置 —不用说对于块分配,很多数据块会在同一个虚拟页中。

C/C++中变量的分配和在内存中的存储方式