参考自《程序员的自我修养》
程序的内存布局
在学习内存布局之前,建议先了解一下程序是如何映射到内存中的
。现代的应用程序都运行在一个内存空间里。在32位的系统里,这个内存空间拥有4GB(2的32次方)的寻址能力。在平坦内存模型中,整个内存空间是一个统一的地址空间,用户可以使用一个指针访问任意内存位置。例如:
int *p = (int *)0x12345678;
++*p;
这段代码展示了如何直接读写指定地址的内存数据。但是,大多数操作系统的内存空间实际上是不平坦的,它们一般都会将系统内存分为两部分,一部分给内核使用一部分给应用程序使用。我们成为内核空间和用户空间。将用户空间和内核空间置于这种非对称访问机制下有很好的安全性,能有效抵御恶意用户的窥探,也能防止质量低劣的用户程序的侵害,从而使系统运行得更稳定可靠。
内核空间
内核空间是给内核使用的,应用程序无法访问这一空间。内核空间是为了让系统一部分核心软件独立于普通应用程序,运行在较高的特权级别上,它们驻留在被保护的内存空间上,拥有访问硬件设备的所有权限。
用户空间
用户空间是给应用程序使用的。用户空间的代码运行在较低的特权级别上,只能看到允许它们使用的部分系统资源,并且不能使用某些特定的系统功能,也不能直接访问内核空间和硬件设备,以及其他一些具体的使用限制。用户空间又可以将地址空间分成如下不同的区域:
- 栈:栈用于维护函数调用的上下文,离开了栈函数调用就没法实现。栈通常在用户空间的最高地址出分配,地址是由高往低走的。
- 堆:堆是用来容纳容纳应用程序动态分配的内存区域,当程序使用malloc或new分配内存时,得到的内存来自堆里。堆通常存在于栈的下方(低地址方向),在某些时候,堆可能没有固定统一的存储区域。堆一般比栈大很多。
- 可执行文件映像:这里存储着可执行文件在内存里的映像,由装载器在装载时将可执行文件的内存读取到这里。
可执行文件又可分为数据段和代码段。数据段有DATA段和BSS段组成。DATA是已初始化的数据段,占文件空间,也占物理内存;BSS未初始化的数据段,但其实并不包含数据,仅维护一个开始地址和结束地址,BSS段的数据默认都是零,所以在二进制可执行文件的文件头内,其实是不存在BSS段的,所以BSS不占文件空间,只占物理内存。代码段是存放程序代码的地方,同时也是只读的,比如我们的char *str = “hello world”; 就是存放在只读数据区,其实和代码段在一起,因此,在程序中我们不可以修改这个字符串。
-
保留区:保留区并不是单一的内存区域,而是内存中受到保护而禁止访问的内存区域的总称,例如,大多数操作系统里,极小的地址通常都是不允许访问的,如NULL。通常C语言将无效指针赋值为0也是出于这个考虑,因为0地址正常情况下不可能有有效的可访问数据。
以下是一个典型的进程内存布局:
内存布局.png
图中有一个前面没有提到的动态链接库映射区(dynamic libraries),这个区域用于映射装载的动态链接库。如果程序依赖其他共享库,共享库将被载入该空间。
图中的箭头可以看出栈是由高地址向低地址增长的,而堆是由低地址向高地址增长的。当它们大小不够用时,就会按上面的方法增长方向扩大自身尺寸,直到未使用空间被用完为止。
栈与函数调用
什么是栈
栈(stack)其实本身是一种数据结构,在经典计算机中,栈被定义为一个特殊的容器,是一个具有先进后出属性的动态内存区域。程序可以将数据压入栈中(入栈,push),也可以将已压入栈中的数据弹出(出栈,pop),但它必须遵循一个原则,那就是先进后出(First In Last Out,FILO)。栈的增长方向是向下增长,即由高地址向低地址方向。以下是一个栈的示例:
这里的栈底0xbfffffff,而esp寄存器标明了栈顶,地址为0xbffffff4。压栈会导致esp减小,出栈会导致esp增大。反过来说,改变esp的值,会改变栈空间的大小。
栈有什么作用
用于维护函数调用的上下文,离开了栈函数调用没法实现。栈中保存了一个函数调用所需要的维护信息,通常称为堆栈帧或活动记录。堆栈栈包括的内容:
- 函数的返回地址和参数
- 临时变量
- 保存的上下文,例如函数调用前后保持不变的寄存器。
函数调用过程
- 把所有的参数压入栈
- 把当前指令的下一条指令的地址压入栈中(函数的返回地址)
- 跳转到函数体执行
- 栈帧调整
具体包括:
1、保存当前栈帧状态值,已备后面恢复本栈帧时使用(EBP入栈)。
2、将当前栈帧切换到新栈帧(将ESP值装入EBP,更新栈帧底部)。
3、给新栈帧分配空间(把ESP减去所需空间的大小,抬高栈顶)。
函数的返回
函数返回的步骤如下:
- 保存返回值,通常将函数的返回值保存在寄存器EAX中。
- 弹出当前帧,恢复上一个栈帧。
具体包括:
1、在堆栈平衡的基础上,给ESP加上栈帧的大小,降低栈顶,回收当前栈帧的空间。
2、将当前栈帧底部保存的前栈帧EBP值弹入EBP寄存器,恢复出上一个栈帧。
3、将函数返回地址弹给EIP寄存器。
- 跳转:按照函数返回地址跳回母函数中继续执行。
堆与内存管理
什么是堆
堆内存是区别于栈区、全局数据区和代码区的另一个内存区域。堆允许程序在运行时动态地申请某个大小的内存空间。光有栈对于程序还远远不够,因为栈上的数据在函数返回的时候就会被释放掉,所以无法将数据传递至函数外部。而全局变量没有办法动态地产生,只能在编译的时候定义。在这种情况下,堆(Heap)是唯一的选择。
堆是一块巨大的内存空间,常常占据整个虚拟内存的绝大部分空间。在这片空间里,程序可以申请一块连续的内存,并自由地使用,这块内存在程序主动放弃之前都会一直保持有效。
内存分配
程序中使用malloc、new等内存分配函数获取内存即是从堆中分配内存。从堆中分配的内存需要程序员手动释放,如果不释放,而系统内存管理器又不自动回收这些堆内存的话(实现这一项功能的系统很少),那就一直被占用。如果一直申请堆内存,而不释放,内存会越来越少,很明显的结果是系统变慢或者申请不到新的堆内存。而过度的申请堆内存,会导致堆被压爆,结果是灾难性的。我们掌握堆内存的权柄就是返回的指针,一旦丢掉了指针,便无法在我们视野内释放它。这便是内存泄露。
堆的分配算法
-
空闲链表
空闲链表( Free List)的方法实际上就是把堆中各个空闲的块按照链表的方式连接起来,当用户请求一块空间时,可以遍历整个列表,直到找到合适大小的块并且将它拆分;当用户释放空间时将它合并到空闲链表中。 -
位图
针对空闲链表的弊端,另一种分配方式显得更加稳健。这种方式称为位图( Bitmap),其核心思想是将整个堆划分为大量的块( block),每个块的大小相同。当用户请求内存的时候,总是分配整数个块的空间给用户,第一个块我们称为已分配区域的头(Head),其余的称为己分配区域的主体(Body)。而我们可以使用一个整数数组来记录块的使用情况,由于每个块只有头/主体空闲三种状态,因此仅仅需要两位即可表示一个块,因此称为位图。 -
对象池
以上介绍的堆管理方法是最为基本的两种,实际上在一些场合,被分配对象的大小是较为固定的几个值,这时候我们可以针对这样的特征设计一个更为高效的堆算法,称为对象池。
对象池的思路很简单,如果每一次分配的空间大小都一样,那么就可以按照这个每次请求分配的大小作为一个单位,把整个堆空间划分为大量的小块,每次请求的时候只需要找到个小块就可以了。
对象池的管理方法可以采用空闲链表,也可以采用位图,与它们的区别仅仅在于它假定了每次请求的都是一个固定的大小,因此实现起来很容易。由于每次总是只请求一个单位的内存,因此请求得到满足的速度非常快,无须查找一个足够大的空间。
网友评论