内存管理之堆和栈

时间:2021-09-22 19:48:33

一、内存

关于程序的运行,不得不提到内存方面的内容,那么首先就对一个进程虚拟地址空间的布局用一张图来看清楚

内存管理之堆和栈

这张图基于32位Linux系统,即起始地址为0x08048000,可以看到顺序为只读段(代码段等)、读写段(数据段、bss段等)、堆(向上即高地址扩展)、用于堆扩展的未使用空间、动态库的映射位置(0x40000000开始)、之后就是栈(向下即低地址扩展)以及用于栈扩展的未使用空间、最后是内核空间。

1、栈

其实前面的一张图中已经说过了栈与堆的大致特点,其中栈最大的不同就是高地址向低地址扩展,同时有esp指针一直指向栈顶(低地址),另外栈的原则是先入后出

栈保存了函数调用需要维护的信息,这称为堆栈帧或活动记录

内存管理之堆和栈
图中就是一个堆栈帧的实例图,即函数调用时会产生的一个在栈中保存的信息,其中使用ebp及esp两个寄存器来划定函数的活动记录。

esp始终指向栈顶部(esp指针一直指向栈顶(低地址),即当前的函数的活动记录的顶部,而ebp则固定不动,其指向的是调用函数前ebp的数据,于是在调用完成后,ebp会重新获得调用前的值。

至于一个堆栈帧的结构是这样的原因就是因为调用函数时所需要进行的一系列操作导致的:

1、将所有参数或一部分参数入栈
2、将当前指令的下一条指令地址入栈(返回地址)
3、跳转到函数体执行,在函数体开始执行时还需要完成一部分操作:ebp入栈,将ebp指向esp(栈顶),分配所需字节的临时空间,保存寄存器

其中第2和3步在汇编代码中就是由call指令完成的,而在i386中函数体的开头的一般形式中可以看出第2和3步执行后还需要完成的那一部分操作

1. push ebp    /*将old ebp入栈*/

2. mov ebp,esp    /*将ebp指向栈顶,经过上面的一步,此时esp即指向了old ebp的位置,因此完成将ebp寄存器指向old ebp的功能*/

3. sub esp,临时空间字节数    /*可选,根据局部变量的具体情况来定*/

4. push寄存器名    /*可选,即保存寄存器的值,对于多个寄存器来说可以重复多次*/

因此可以想象,实际的调用函数完成后的返回过程与之前相反:

1、将寄存器出栈,还原需要保存的寄存器值
2、将ebp的地址赋给esp,即回收临时空间
3、ebp出栈,还原ebp值
4、ret 执行原来保存的当前指令的下条指令

对应于具体的汇编代码就是这样

1. pop寄存器名    /*对应于上面的最后一步操作,有n个寄存器就需要出栈n次*/

2. mov esp,ebp    /*将esp指向ebp指向的位置,简单来说就是将栈顶设置在了old ebp的具体存储位置上,那么堆与局部变量的部分就属于了栈外,相当于回收了局部变量占用的空间*/

3. pop ebp    /*将ebp的值恢复*/

4. ret    /*位于ebp上面的部分就是返回地址,从而ret命令获取返回地址,并跳转到该位置上,于是完成了一个函数的调用结束返回的工作*/

对于上面调用一个函数的堆栈帧的过程大多数都是这样的情形,不过出现同时满足下面两种情况的话则不一定
1、函数声明为static(即函数仅能在此编译单元内访问)
2、函数在本编译单元中仅被直接调用,即没有任何函数指针指向过这个函数
这种情况下编译器能够确定在别的编译单元中不需要使用到这个函数,那么就可以随意修改这个函数的各个方面,其中也包括了进入与退出指令序列,从而达到优化的目的。

调用惯例

什么是调用惯例?简单来说就是调用方与被调用方约定好的一些规则,比方说函数参数的传递顺序和方式,栈的维护方式以及名字修饰等等。
一般来说C语言使用cdecl这个调用惯例,它是C语言默认的调用惯例,它的具体内容是:

1、参数传递方式:从右至左的顺序压参数入栈(对于一个foo(int m,int n )这样一个函数来说,参数入栈时的顺序就是先n后m)
2、出栈方:由
函数调用方将参数等出栈(因此在前面的函数调用收尾的代码中,并没有出现对于返回地址上面的参数部分的出栈工作,它是由调用方完成的,对于被调用函数来说对其进行任何操作)
3、名字修饰:直接在函数名称前加1个下划线”_”

2、堆与内存管理

光有栈对于面向过程的程序设计还远远不够:栈上的数据在函数返回时就会被释放掉,因此栈无法将数据传递至函数外部;而全局变量能够将数据传递至函数外部却无法动态产生,只能在编译时定义,缺乏表现力。那么就是唯一的选择了。

堆时常占据虚拟空间的绝大部分,程序可以在其中申请一块连续内存并*使用。



大家基本都知道如何申请一个堆空间,无非就是使用malloc函数,那么它到底是如何实现的呢? 
有一个很简单的预想方法,将进程的内存管理交给操作系统内核去做,那么就需要提供一个系统调用,可以让程序调用它申请内存。
的确这样的方法能够行得通,但是这样每次申请堆空间的时候都需要进行系统调用,而系统调用对性能方面的开销很大,如果对堆的操作比较频繁,必然会影响到程序的性能。
如何解决这个问题?其实很简单,对文件的读写时用到缓存的技术来解决性能的问题,对于堆空间的申请问题在我看来也是一样,提前向操作系统申请一块适当大小的堆空间由程序自己管理就行了。实际的运行中,管理堆空间分配的往往是程序的运行库。

这样对堆的分配就有了大概的了解了,运行库向操作系统“批发”了较大的堆空间,零售给程序使用,当全部使用完或者程序有大量内存需求时,再向操作系统“进货”,当然,一件货物不能卖两次,于是运行库就需要有算法来管理堆空间,避免一段空间连续分配,这就是堆的分配算法,一般来说比较简单的有空闲链表、位图以及对象池等,不过很多实际的运用中,堆的分配算法往往采用多种算法复合而成。

Linux进程堆管理

最后了解下Linux进程堆如何管理。

首先,Linux下进程堆管理提供了两种堆空间分配的方式,即两个系统调用:brk( )及mmap( )
这就是前文中说的“批发”堆空间的手段或方法,那么两种有什么区别呢?
1、bar( )
brk的实际作用实际上是设置进程数据段的结束地址,即它可以扩大或者缩小数据。想象一下,我们将数据段的大小增大,那么数据段多出来的那部分我们就能够使用了,将这块空间作为堆空间是最常见的做法。
另外,glibc中还有个sbrk函数,其实就是bar的包装,通过brk实现。

2、mmap( )
mmap的作用就是向OS申请一段虚拟地址空间,当然这块空间可以映射到某个文件,但是当它不将地址空间映射到某个文件时,我们就将这块空间称为
匿名空间
来看下mmap( )系统调用

1. void *mmap( void  *start,       /*申请空间的起始地址,为0时则OS自动挑选合适地址*/

2.            size_t  length,    /*申请空间的长度*/

3.            int  prot,              /*申请空间的权限(可读、可写或可执行)*/

4.            int  flags,            /*申请空间的映射类型(文件映射、匿名空间),可以看到这里就指明了两种映射类型,后一种即我们所说的可以实现批发堆空间的方式*/

5.            int  fd,                /*文件映射时指定文件描述符*/

6.            off_t  offset) ;    /*文件映射时指定文件偏移*/

那么我们基本了解了brk及mmap这两个申请内存空间的系统调用,最后来看看malloc如何处理用户的空间请求,基于前面的信息可以看出对于一定大小以内的空间申请时,是不会使用以上两个系统调用的。
实际上malloc对于小于128KB的请求,会在现有的堆空间根据堆分配算法分配一块空间并返回;大于128KB的请求则会使用mmap函数为其分配一块匿名空间,然后在这个匿名空间中为用户分配空间。
但是这里要注意,mmap是基于系统虚拟空间来申请函数的,申请空间的起始地址和大小必须是系统页的大小的整数倍,另外申请的空间大小不能超出空闲内存+空闲交换空间的总和。

前面说过实际情况下,堆分配算法往往采用多种算法合成,在glibc中就运用了这样的方法,malloc分配空间时不只是判断是否小于128KB,具体情况为:小于64B,采用对象池方法;大于512B采用最佳适配算法;大于64B但小于512B,根据情况选择最佳的折中策略,会在空闲链表、位图以及对象池几种算法中选择一种方法;最后,大于128KB则使用mmap机制直接向操作系统申请空间。