1.程序的内存布局
首先需要明确应用程序使用的内存空间里有四个“默认”区域:
栈 | 用于维护函数调用的上下文(向低地址增长) |
堆 | 用来容纳应用程序动态分配的区域(向高地址增长) |
可执行文件映像 | 由装载器在装载时将可执行文件的内存读取或映射到这里 |
保留区 | 是对内存中收到保护而禁止访问的内存区域的总称 |
此外还有一个区域需要了解——动态链接库映射区:用于映射装载的动态链接库。
注:
程序出现“段错误”或者“非法操作,该内存地址不能read/write”的错误信息原因:是由于非法指针解引用造成的错误。一种情况是:某些地址一开始并没有映射到实际的物理内存,应用程序必须事先请求将这些地址映射到实际的物理地址。如:程序员将指针设置为NULL,之后并没有给它一个合理的值就开始使用指针,因此会报错。另外一种情况是:指针指向一个不允许读或写的内存地址,但是程序却试图利用指针来读或写该地址。如:一个指针没有初始化,这个指针所指向的地址是随机的,可能指向不允许读或写的地址,之后程序员就使用这个指针,然后就会报错。
2.栈与调用惯例
2.1与栈相关的基本概念
(1)堆栈帧的概念
栈保存了一个函数调用所需要的维护信息,这常常被称为堆栈帧,堆栈帧一般包括如下几方面内容:
①函数的返回地址和参数
②临时变量
③保存的上下文
(2)调用函数时栈的变化
函数调用过程:
①把所有或一部分参数压入栈中,如果有其他参数没有入栈,就使用某些特定的寄存器传递
②把当前指令的下一条指令的地址压入栈中
③跳转到函数体执行
push ebp
move ebp,esp:ebp=esp
(可选)sub esp,xxx:在栈上分配xxx字节的临时空间
(可选) push xxx:如果有必要,保存名为xxx寄存器(可重复多个)
函数调用结束过程:
(可选)pop xxx:恢复保存过的寄存器
mov esp,ebp:恢复esp同时回收局部变量空间
pop ebp:从栈中恢复保存的ebp的值
ret:从栈中取得返回地址,并跳转到该位置。
如图:
2.2调用惯例
一般调用管理会包含如下几方面的内容:
①函数参数的传递顺序和方式
②栈的维护方式
③名字的修饰策略
2.3函数返回值传递
函数将返回值存储在eax中,返回后函数的调用方读取eax,对于大于四字节的返回值,eax存储的是需要返回内容的地址,具体思路如下:
①首先main函数在栈上额外开辟一片空间,并将这块空间的一部分作为传递返回值的临时对象,这里称为temp.
②将temp对象的地址作为隐藏参数传递给return_test函数
③return_test函数将数据拷贝给temp对象,并将temp对象的地址用eax传出
④return_test返回之后,main函数将eax指向的temp对象的内容拷贝给n
如图:
3.堆与内存管理
3.1堆的定义
堆是一块巨大的内存空间,常常占据整个虚拟空间的绝大部分,在这片空间里,程序可通过系统调用请求一块连续空间,然后自己管理并自由使用,这块内存在程序主动放弃之前都会一直保持有效。
3.2Linux进程堆管理
堆空间:进程的地址空间中,除了可执行文件、共享库和栈之外,剩余的未分配的空间都可以被用来作为堆栈空间。
linux下进程堆管理有两种系统调用:
(1)brk系统调用
brk()的作用实际上就是设置进程数据段的结束地址。
它可以扩大或者缩小数据段。如果我们将数据段的结束地址向高地址移动,那么扩大的那部分空间就可以被使用,把这块空间拿来作为对空间是最为常见的作法之一。
(2)mmap系统调用
mmap()的作用和windows系统下的VirtualAlloc很相似,它的作用就是向操作系统申请一段虚拟地址空间,这块虚拟地址空间可以映射到某个文件,当它不将地址空间映射到某个地址空间时,那么这块空间称为匿名空间,匿名空间就可以拿来作为堆空间。
声明如下:
void *mmap
{
void *start,//指定需要申请的空间的起始地址
size_t length,//申请的空间的长度
int prot,//设置申请的空间权限(可读可写可执行)
int flags,//设置映射类型(文件映射、匿名空间等)
int fd,//文件映射时指定文件描述符
off_t offset//文件映射是指定文件偏移的
}
注:malloc到底一次能够申请的最大空间是多少?
在有共享库的情况下,留给堆可以用的空间有两处:一处是从BSS段结束到0X40000000,即大约1GB不到的空间;另外一处是从共享库到栈的这块空间,大约是2GB不到。这两块空间大小都取决于栈、共享库的大小和数量。于是可以估算到malloc最大的申请空间大约是2GB不到,但是有实验结论得出大约为2.9GB,实际上两个结论都是没有错误的,造成这种差异是因为linux的内核版本造成的,此外还有系统的资源限制、物理内存和交换空间的综合等因素都会影响malloc的最大空间大小。
3.3Windows进程堆管理——VirtualAlloc()函数
VirtualAlloc向操作系统一次性申请大量空间,并且系统要求空间大小必须为页的整数倍,然后依据需要分配算法给程序。分配算法的时间位于堆管理器,对管理器提供了一套与堆相关的API可以用来创建、分配、释放和销毁堆空间:
HeapCreate:创建一个堆空间,它会向操作系统批发一块内存空间(通过VirtualAlloc()实现)
HeapAlloc:在一个堆里分配一块小的空间并返回给用户,如果堆空间不足的话,还会通过VirtualAlloc向操作系统申请更多的内存直到操作系统内没有空间可以分配为止
HeapFree:释放已经分配的内存
HeapDestroy:摧毁一个堆
注:
(1)堆管理器位于Windows的两个位置:一个是NTDLL.DLL种,这个DLL是Windows操作系统用户层的最底层DLL,它负责Windows子系统DLL和Windows内核之间的接口。另一个在Windows内核Ntoskrnl.exe种,它负责Windows内核中的堆空间分配。
(2)当堆空间不够时,它会在进程中创建额外的堆,所以进程中可能存在多个堆,但是一个进程中一次性能过够分配的最大的堆空间取决于最大的那个堆。
(3)Q&A
①可以重复释放两次堆里面的同一片内存吗?
不能。几乎所有的堆实现里面,都会在重复释放同一片堆里的内存时产生错误。原因是:内存的释放不是清零,而是将这块内存标记为未使用。计算机上到处有类似的设计,比如:删除一个文件,是把对应的促标记为未分配,重复释放内存报错的原因是由于这些内存已经标记为未分配,就不再有记录了,所以再次释放标准库就会丢出异常。
②堆总是向上增长的吗?
不是。unix系统它使用类似于brk的方法来分配空间,而brk的增长方向是向上的,但是windows下,大部分堆使用HeapCreate产生,它则不遵照向上增长的规律。
③调用malloc会不会最后调用到系统调用或者API
如果堆的空间足够分配,就不会进行系统调用,如果堆的剩余空间不足以分配,就会进行系统调用来向操作系统申请更大的空间。
④malloc申请的内存,进程结束以后还会不会存在?
malloc申请的内存,进程结束后,也会倍收回。
⑤malloc申请的空间是不是连续的?
如果“空间”指的是虚拟空间的话,就是连续的,如果是物理空间的话,不一定连续,因为一块连续的虚拟地址空间可能是若干个不连续的物理空间页拼凑而成的。
3.4堆分配算法
(1)空闲链表
思想:
把堆中的各个空闲块通过链表进行连接,当需要申请一块空间的时候,就遍历链表寻找到合适大小的块并将它拆分,当用户释放空间的时候又将它合并到空闲链表中。
优点:
实现简单。
缺点:
健壮性不好,比如链表倍破坏或者记录分配块大小的四字节被破坏,整个堆都无法正常工作。
(2)位图
思想:
将整个堆划分为大量的块,每个块的大小相同。当用户请求内存的时候,总是分配整数个块的空间给用户,第一个块称为已分配区域的头,其余的称为已分配区域的主体。然后使用一个整数数组来记录块的使用情况,由于每个块只有头/主体/空闲三种状态,因此只需要两位即可表示一个块,因此称为位图。
优点:
速度快:整个堆的空闲信息存储在一个数组中,因此访问数组时容易命中。
稳定性好:即使部分数据被破坏,也不会导致整个堆无法工作。
缺点:
容易产生内存碎片
如果堆很大或者设定一个块很小,那么位图将会很大,可能失去cache命中率高的优势,也会浪费一定的空间。
(3)对象池
如果每一次分配的空间大小都一样,那么就可以按照这个每次请求分配的大小作为一个单位,把整个堆空间划分为大量的小块,每次请求的时候只需要找到一个小块就可以了。
优点:
实现起来容易,请求得到满足的速度非常快。