一、程序的内存布局
重点关注dynamic libraries,它用于映射装载的动态链接库。
二、栈与调用惯例
1. 栈
栈保存了函数调用所需要的维护信息,通常被称为栈帧(stack frame)或活动记录(active record)。栈帧通常包含以下信息:
- 函数的返回地址和参数
- 临时变量:函数内的非静态变量及编译器自动生成的临时变量
- 保存的上下文:包括在函数调用前后需要保持不变的寄存器
在i386中,一个栈帧用esp和ebp两个寄存器划定范围:esp指向栈帧顶部,ebp指向调用该函数前的ebp的值,这样就可以在函数返回时,ebp可以读取这个值来恢复到调用前的值。
疑问:由于栈增长方向从高地址到低地址,因此,ebp指向的位置是不是应该在Old EBP和保存的寄存器之间才对?
之所以函数的活动记录会形成如上结构,是因为函数调用本身是如此书写的,以i386以例,函数调用总是:
- 把所有或部分参数压入栈,如果有其他参数没有入栈,那么使用某些特定的寄存器传递
- 把当前指令的下一条指令的地址压入栈
- 跳到函数体执行
以上步骤1和2,与图10-4的活动记录的参数和返回地址对应。步骤2和3由call指令一起执行,步骤3中的函数体的标准开头为:
- ebp入栈,对应10-4的Old EBP
- esp的值赋给ebp
- 分配空间,并将寄存器值保存到已分配空间,对应10-4中的保存的寄存器
2. 调用惯例
调用方与被调用方要遵循约定的规则,这种规则就叫做调用惯例。规定涉及:
- 函数参数的入栈顺序
- 函数调用结束后由谁负责将数据弹出栈
- 函数名称修饰符
对c++而言,上述的名字修饰显然不能满足重载和命名空间的需求,因此c++有更加复杂的名字修饰策略。
此外,c++还用特殊的调用惯例:thiscall,专门用于成员函数的调用。其特点随编译器不同而不同:
- VC里,this指针存放于ecx寄存器,参数由右至左入栈
- gcc里,thiscall与cdecl完全一样,只是将this看作函数的第一个参数
3. 函数返回值传递
这里不举例,只给出结论,有兴趣的可以翻阅P299
- (0,4]字节的返回值,由eax传递:即被调函数将返回结果存入eax中,调用函数读取eax内容得到返回值
- (4,8]字节的返回值,由eax和edx联合返回:eax存放低4字节,edx存放高4字节
- 8以上字节的返回值,eax存储返回值起始地址:栈上分配返回值所需字节的空间,将空间起始地址赋值给eax
三、堆与内存管理
malloc/new是如何实现堆空间分配的呢?
- 方法1:把进程的内存管理交给操作系统的内核去做,既然内核管理着进程的地址空间,那么如果它提供一个系统调用,可以让程序使用这个系统调用申请内存,不就可以了吗?当然这是理论上可行的方法,但实际上性能很差,因为每次程序申请或释放堆空间都需要进行系统调用,而系统调用的性能开销很大(开销大的原因详见《程序员的自我修养 - 系统调用与API》),当程序频繁的申请或释放堆空间时,将会严重影响程序的性能。
- 方法2:程序向操作系统申请一块适当大小的堆空间,然后由程序自己管理这块空间。具体而言,管理着堆空间分配的往往是程序的运行库。运行库相当于向操作系统“批发”了一块较大的堆空间,然后“零售”给程序用。当使用“售完”或程序有大量的内存需求时,再根据需求向操作系统“进货”。当然运行库向程序“零售”堆空间时,必须管理“批发”来的空间,这类管理算法叫堆空间分配算法。
根据堆空间管理的顺序:批发 - 零售,先讲解批发的原理。
堆批发原理
1. linux进程堆管理
1.1 brk / sbrk
- brk实际上通过将数据段(data与bss段统称为数据段)的结束地址往高地址移动,使得扩大的那部分数据段的空间可被当作堆空间使用。(疑问:通过增大数据段空间来增大可用堆空间?感觉很别扭!)
- sbrk以增量作为参数,即需要增加或减少的空间大小,返回值是增加或减少后数据段的结束地址,它实际上是对brk的包装。
1.2 mmap
mmap与windows的VirtualAlloc相似,它的作用是向操作系统申请一段虚拟地址空间,这段空间可以映射到某个文件,当它不被映射到文件时称为匿名空间,而匿名空间可以被当作堆空间。
但mmap申请的空间的起始地址和空间大小都必须是系统页大小的整数倍,对于字节很小的请求无疑是一种浪费。
glibc的malloc函数是这样处理用户请求的:
- 对于小于128KB的请求来说,它会在现有堆空间里按照堆空间分配算法分配一块空间并返回
- 对于大于128KB的请求来说,它会先通过mmap申请匿名空间,再从匿名空间中分配空间并返回
最后,分析一下32位系统中,可分配的堆空间大小。
从图10-1中观察到(注意:kernel version为2.4.x),dynamic libraries从0x40000000开始往高地址增长,使得实际可申请的最大堆空间只有2GB,而在2.6版本的内核中,dynamic libraries已经被挪到靠近栈空间的位置,即位于0xbf******附近。
除了空间布局以外,还有诸多因素可以影响到malloc最大空间大小,如系统的资源限制(ulimit),物理内存和交换空间的总和等。mmap申请的匿名空间的大小不可能超过物理内存和交换空间的总和!!!
2. windows进程堆管理
要分析堆管理,必须先搞清楚windows进程空间地址分布,如下:
简单解释一下上图为何会有那么多栈。
我们知道,每个线程的栈都是独立的,所以一个进程可以有多个线程,就对应多个栈。
windows用VirtualAlloc来向操作系统申请空间(不仅仅用作堆,可视需求而定),与mmap一样,它申请到的空间的起始地址及大小都必须是系统页大小的整数倍。为了提高堆的使用效率,windows提供了堆管理器,它对应一组API,用于创建堆、在堆内分配内存、释放已分配的内存及销毁堆。
从图10-14可看出,进程地址空间较为零碎,因此可分配的最大堆空间取决于最大连续空间的大小,图中对应的最大堆为heap5。
堆零售原理(堆空间分配算法)
在运行库完成了从操作系统批发堆空间的动作后,就需要根据用户程序的需求分配已批发到的空间。下面就简单介绍运行库是如何管理并分配堆空间的。
1. 空闲链表法
用双向链表将所有空间链接串连起来,同时若用户申请了K个字节的内存大小,则分配K+4个字节的空间,额外的4个字节位于空间的起始位置,用来指示K的大小。
2. 位图法
将堆划分个固定大小的块,每块大小相同。当用户请求内存时,总是分配整数块的空间,第一个块称为已分配区域的头(head),其余称为分配区域的主体(body)。
(H : Head B : Body F : Free)
3. 对象池
在实际应用中,被分配的堆对象的大小是较为固定的几个值,这时候就可以针对这样的特征设计一个更为高效的堆算法,称为对象池。
对象池的思路很简单,如果每次分配的空间大小都一样(假设为K字节),那么就可以以此空间大小作为一个单位,把整个空间划分为大量的K字节大小的块,每次请求时只需要找到一个小块就可以。
对象池的管理方法可以是空闲链表,也可以用位图。
以上介绍了三种堆空间分配算法,实际应用时采用复合算法。比如对于glibc来说
- 对于小于64字节的空间申请,采用类似对象池的算法
- 对于大于512字节的空间申请方法,采用最佳适配算法
- 对于大于64字节小于512字节的空间申请,采用上述的最佳折衷策略(所以到底是怎么个折衷法)?
- 对于大于128KB的申请,使用mmap机制