文章目录
  1. 程序的内存布局
  2. 栈与调用惯例
    1. 调用惯例
    2. 函数返回值的传递
  3. 堆与内存管理
    1. Linux进程堆管理
    2. 堆分配算法

程序的内存布局

结合上一章介绍的动态链接,再来看看进程的32位内存地址空间的布局。

 

栈与调用惯例

栈用于维护函数调用的上下文,栈通常从用户空间最高地址开始,栈从高地址向低地址增长。

在x86架构下,esp寄存器始终指向栈顶。栈保存了每个函数调用所需要维护的信息,维护的每个函数调用的信息称为一个活动记录。ebp寄存器指向当前活动记录的一个固定位置。如下是一个典型的活动记录示意图。

 

ebp寄存器始终指向当前活动记录的old EBP,ebp-4是函数的返回地址、ebp-8、ebp-12等等是函数的参数的地址。

x86架构下标准的函数从进入到退出序列如下。

1
2
3
4
5
6
7
8
9
10
11
12
13
push ebp
mov ebp, esp
sub esp, x
push reg1
...
push regn
[Function instructions]
pop regn
...
pop reg1
mov esp, ebp
pop ebp
ret

调用惯例

函数调用者传递的参数需要被函数正确地理解,如使用寄存器还是栈传参、参数压栈顺序为从左到右还是从右到左。为此,函数的调用方与与其自身需要有一个明确的约定,只有双方同时遵守同样的约定,函数才能正确的执行。这样的约定称为调用惯例

调用惯例通常会规定如下几个方面的内容。

  • 参数的传递顺序与方式;
  • 栈的维护方式;被压入栈中的参数被函数弹出还是被函数调用者弹出;
  • 名字修饰策略;如在函数名前下划线;

C语言默认使用cdecl调用惯例:从右到左的顺序压参数入栈、栈由函数调用者维护、函数名修饰为在函数名前加下划线。

函数返回值的传递

一般来说,函数将其返回值存入eax寄存器,函数调用者访问eax寄存器获取调用的函数的返回值。但是在x86架构下,eax只有4个字节,如果返回值多余4个字节,但是没有超过8个字节,则使用eax与edx联合返回;那么,如果返回值特别长,远远大于8字节,该怎么传递返回值。

解决办法是(被调用)函数在栈上为返回值开辟一段空间,然后将返回值复制到栈上,然后返回返回值的地址,调用者再根据返回的地址复制返回值到其内部的变量。此时,一个大尺寸的返回值会被复制两次,因此在程序编写过程中极其不建议返回大尺寸的值。

堆与内存管理

用来容纳应用程序动态分配的内存区域,程序使用malloc或new分配的内存就来自堆。进程申请内存空间需要使用系统调用,而如果程序对堆的操作比较频繁,那么就要频繁地调用开销很大的系统调用,为此,进程会向操作系统申请一块适当大小的内存作为堆空间,然后由进程自行管理这块内存空间,具体来说,是由程序的运行库向操作系统申请堆空间并管理堆空间的分配。

Linux进程堆管理

Linux系统提供了两种堆空间的申请方式,即两个系统调用:brk与mmap;brk的功能是设置进程数据段的结束地址Program break;

 

Program break向高地址移动,扩大的内存空间作为堆空间。mmap的功能是向操作系统申请一段虚拟地址空间。

堆分配算法

堆分配问题其实就是如何管理一大块连续的内存空间,可以按照需求分配、释放其中的空间。

空闲链表:将堆中的空闲空间使用双向链表组织起来;当用户请求空间时,遍历整个链表,直到找到合适大小的空闲块并将其拆分。当用户释放空间时将其加入链表或链表的某个空闲块中。这种堆分配算法实现简单,但是不稳定。

位图:将整个堆空间划分为大量的块,当用户请求内存时,算法会分配整数个块给用户,分配的第一个块称为头,其余的称为主体;可以使用一个数组来记录块的分配情况——空闲/主体/头,因此每2比特就可以表示一个块的状态。因此叫做位图。位图具有速度块、稳定性好的特点;但是容易产生内碎片。

对象池:由于在某些情况下,用户会频繁地申请几个较为固定长度的内存,针对这种情况,堆分配算法可以将这些固定长度的内存作为单位,每次用户申请这些固定长度的内存时只需要找到一个对应长度的单位即可。对象池的管理可以使用空闲链表,也可以使用位图。

实际应用的堆分配算法是上述多种分配算法的组合。