10.1 程序的内存布局
现代的应用程序都运行在一个内存空间里面,在32位的系统里,这个内存空间拥有4GB的寻址能力。相对于16位时代 i386 的段地址 加 段内偏移的寻址模式,
如今的应用程序可以直接使用 32 位的地址进行寻址,这被称为平坦(flat) 的内存模型。在平坦的内存模型中,整个内存是一个统一的地址空间,用户可以使用
一个 32 位的指针访问任意内存位置。如,
int *p = (int *) 0x12345678;
++*p;
这段代码展示了如何直接读写指定地址的内存数据。不过,尽管当今的内存空间号称是平坦的,但实际上内存仍然存在不同的地址区间上有着不同的地位。例如,
大多数操作系统都会将 4GB 的内存空间中的一部分挪给内核使用,应用程序无法直接访问这段内存,这一部分内存地址空间被称为内核空间。Windows 下默认情况
会将高地址的 2GB 空间分配给内核(也可以配置为 1GB),而 Linux 默认情况下将高地址的 1GB空间分配给内存。
用户使用的剩下的 2GB 或者 3GB 的内存空间称为用户空间。在用户空间里,也有许多地址区间的特殊的地位,一般来说,应用程序使用的内存空间里面有如下
"默认"的区域。
1.栈:栈用于维护函数调用的上下文,离开了栈,函数调用就没法实现了。栈通常在用户空间的最高地址处分配,通常有数M字节的大小。
2.堆:堆是用来容纳应用程序哦动态分配的内存区域,当程序使用 malloc 或 new 分配内存时,得到的内存来自堆里。堆通常存在于栈的下发(低地址方向),在
某些时候,堆也可能没有固定统一的存储区域。堆一般比栈大很多,可以有几十至数百 M 的容量。
3.可执行文件印象:这里存储着可执行文件在内存里的映像,由装载器在装载时将可执行文件的内存读取或映射到这里。
4.保留区:保留区并不是一个单一的内存区域,而是对内存中收到保护而禁止访问的内存区域的总称。例如,大多数操作系统里,极小的地址通常是不允许访问的,如
NULL。通常 C 语言将无效指针赋值为0也是处于这个考虑,因为0地址上正常情况下不可能有有效访问的数据。
5.动态链接库映射区:这个区域用于映射装载的动态链接库。在 Linux 下,如果可执行文件依赖其他共享库,那么系统就会为它在从 0x40000000 开始的地址分配相应
的空间,并将共享库载入到该空间。
栈向低地址增长,堆向高地址增长,当栈或者堆现有的大小不够用时,它将按照图中的增长方向扩大自己的尺寸,直到预留空间被用完为止。
Q : 写程序经常出现 '段错误' 或者 '非法操作', 该内存地址不能 read/write
A : 这是典型的非法指针解引用造成的错误。当指针指向一个不允许读或者写的内存地址,而程序却试图利用指针来读或者写该地址的时候,就会出现这个错误。在 Linux
或 Windows 的内存布局中,有些地址是始终不能读写的,例如 0 地址。还有些地址是一开始不允许读写,应用程序必须事先请求这些地址的读写权,或者一开始并没有
映射到实际的物理内存,应用程序必须事先请求将这些地址映射到实际的物理地址,之后才能够自由的写这片内存。当一个指针指向这些区域的时候,对它指向的内存进行
读写就会引发错误。造成这样原因有2种:
1.程序员将指针初始化为 NULL, 之后却没有给它一个合理的值就开始使用指针。
2.程序员没有初始化栈上的指针,指针的值是一个随机数,之后就直接开始使用指针。// 危害比较大,值随机的
10.2 栈与调用惯例
10.2.1 什么是栈
在经典的计算机科学中,栈被定义为一个特殊的容器,用户可以将数据压入栈中(入栈,push),也可以将已经压入栈中的数据弹出(出栈,pop),
但栈这个容器必须遵守一条规则:先入栈的数据后出栈(FIFO)。
在计算机系统中,栈则是一个具有以上属性的动态内存区域。程序可以将数据压入栈中,也可以将数据从栈顶弹出。压栈是的栈增大,而弹出操作
使得栈减小。
在经典的操作系统中,栈总是向下增长的。在 i386 下,栈顶由称为 esp 的寄存器进行定位。压栈的操作使栈顶的地址减小,弹出的操作使得
栈顶的地址增大。
栈在程序运行中具有举足轻重的地位。最重要的,栈保存了一个函数调用所需要的维护信息,这常常被称为堆栈帧(stack frame) 或活动记录(activate record)。
堆栈帧一般包括以下几个内容:
1.函数的返回地址和参数
2.临时变量:包括函数的非静态局部变量以及编译器自动生成的其他临时变量
3.保存的上下文:包括函数调用前后需要保持不变的寄存器
在 i386 中,一个函数的活动记录用 ebp 和 esp 这2个寄存器划定范围。esp 寄存器始终指向栈的顶部,同时也就指向了当前函数的活动记录的顶部。而相对的,
ebp 寄存器指向了函数活动记录的一个固定位置,ebp 寄存器又被称为帧指针。
在参数之后的数据(包括参数)即是当前函数的活动记录,ebp固定在图中所示的位置,不随这个函数的执行而变化。相反的,esp始终指向栈顶,因此随着函数的执行,
esp会不断的变化。固定不变的 ebp 可以用来定位函数活动记录中的各个数据。在 ebp 之前首先是这个函数的返回地址,它的地址是 ebp-4,再往前是压入栈中的参数,
它们的地址分别是 ebp-8,ebp-12 等,视参数数量和大小而定。 ebp 所直接指向的数据是调用该函数之前 ebp 的值,这样在函数返回的时候,ebp 可以通过读取这个值
恢复到调用之前的值。之所以函数的活动记录会形成这样的结构,是因为函数调用本身是如此书写的:一个 i386 下的函数总是这样调用的:
1.把所有或一部分参数压入栈中,如果有其他参数没有入栈,那么使用某些特定的寄存器传递。
2.把当前指令的下一条指令的地址压入栈中
3.跳转到函数体执行
其中第2步和第3步由指令 call 一起执行。跳转到函数体之后即开始执行函数,而 i386 函数体的 '标准' 开头是这样的:
1.push ebp :把 ebp 压入栈中(称为 old ebp)
2.mov ebp, esp; ebp = esp (这时 ebp 执行栈顶,而此时栈顶就是 old ebp)
3.【可选】 sub esp,XXX : 在栈上分配 XXX 字节的临时空间
4.【可选】 push XXX : 如有必要,保存名为 XXX 寄存器(可重复多个)
把 ebp 压入栈中,是位了在函数返回的时候便于恢复以前的 ebp 的值。而之所以可能要保持一些寄存器,在于编译器可能要求某些寄存器在调用前后保持不变,那么
函数就可以在调用开始时将这些寄存器的值压入栈中,在结束后取出。不难想象,在函数返回时,所进行的 '标准' 结尾与 '标准' 开头正好相反:
1.【可选】 pop XXX : 如有必要,恢复保持过的寄存器(可重复多个)
2. mov esp,ebp : 恢复 esp 同时回收局部变量空间
3. pop ebp : 从栈中恢复保持的 ebp 的值
4.ret : 从栈中取得返回地址,并跳转到该位置
gcc 有个 -fomit-frame-pointer 参数可以取消帧指针,即不使用任何帧指针,而是通过 esp 直接计算变量的位置。这么做的好处是可以多出一个 ebp 寄存器
供使用,但坏处很多。
10.2.2 调用惯例
函数的调用方和被调用方对函数如何调用有着统一的理解,例如它们双方都一致的认同函数的参数是按照某个固定的方式压入栈内。如果不这样的话,函数都无法运行。
int foo(int n, float m)
{
int a = 0, b= 0;
...
}
如果函数的调用方在传递参数时,先压入参数n,再压入参数m,而 foo 函数却认为其调用方应该先压入参数m,后压入参数n,那么不难想象 foo 内部的 m 和 n 的值将会
被交换。再者,如果函数的调用方决定利用寄存器传递参数,而函数本身却仍然以为参数是通过栈传递,那么显然函数无法获取正确的参数。因此,毫无疑问函数的调用方和被
调用方对函数如何调用必须有一个明确的约定,只有双方都遵守同样的约定,函数才能被正确的调用,这样的约定就称为调用惯例。一个调用惯例会规定如下几方面的内容:
1.函数参数的传递顺序和方式
函数参数的传递有很多方式,最常见的一种是通过栈传递。函数的调用方将参数压入栈中,函数自己再从栈中将参数取出。对于有多个参数的函数,调用惯例要规定函数
调用方将参数压栈的顺序:是从左至右,还是从右至左。有些调用惯例还允许使用寄存器传递参数,以提高性能。
2.栈维护的方式
在函数将参数压栈之后,函数体会被调用,此后需要将被压入栈中的参数全部弹出来,以使得栈在函数调用前后保持一致。这个弹出的工作可以由函数的调用方来完成,也
可以由函数本身来完成。
3.名字修饰策略
为了链接的时候对调用惯例进行区分,调用管理要对函数本身的名字进行修饰。不同的调用惯例有不同的名字修饰策略。
事实上,在 C 语言里,存在多个调用惯例,而默认的调用惯例是 cdecl。任何一个没有显示指定调用惯例的函数都是默认的 cdecl 惯例。对于函数 foo 的声明,它的
完成形式是: int _cdecl foo(int n, float m)
cdecl 这个调用惯例是 C 语言默认的调用惯例,它的内容如下:
参数传递 出栈方 名字修饰
从右至左的顺序压参数入栈 函数调用方 直接在函数名称前面加一个下划线
因此 foo 被修饰之后就变为 _foo。在调用 foo 的时候,按照 cdecl 的参数传递方式,具体堆栈操作如下:
1.将 m 压入栈
2.将 n 压入栈
3.调用 _foo,此步骤又分为2步:
a) 将返回地址(即调用 _foo 之后的下一条指令的地址)压入栈;
b) 跳转到 _foo 执行。
对于不同的编译器,由于分配局部变量和保存寄存器的策略不同,这个结果可能有出入。
除了 cdecl 调用惯例之后,还存在很多别的调用惯例,例如 stdcall, fastcall 等。
10.2.3 函数返回值传递
除了参数传递之外,函数与调用方的交互还有一个渠道就是返回值。我们发现 exa 是传递返回值的通道。函数将返回值存储在 eax 中,返回后函数的调用方再读取 eax。
但是 eax 本身只有4个字节,那么大于4个字节的返回值是如何传递的呢。
对于返回5~8 字节对象的情况,几乎所有的调用惯例都是采用 eax 和 edx 联合返回的方式进行的。其中 eax 存储返回值要低 4 字节,而 edx 存储返回值要高 1 ~ 4
字节。而对于超过8个字节的返回类型。。
声名狼藉的 c++ 返回对象:
在 C++ 里返回一个对象的时候,对象要经过2次拷贝构造函数的调用,才能够返回对象的传递。1次拷贝到栈上的临时对象里,另外一个把临时对象拷贝存储返回值的对象里。
这样带来的恶果就是返回一个较大对象会有非常多的额外开销,因此在 C++ 程序中尽量避免返回对象。
10.3 堆与内存管理
相对于栈而言,堆这片内存面临一个稍微复杂的行为模式:在任意时刻,程序可能发出请求,要么申请一段内存,要么释放
已经申请过的内存,而且申请的大小从几个字节到数 GB 都是有可能的。
10.3.1 什么是堆
光有栈对于面向过程的程序设计还远远不够的,因为栈上的数据在函数返回的时候就会被释放掉,所以无法将数据传递至函数外部。
而全局变量没有办法动态的产生,只能在编译的时候定义,有很多情况下缺乏表现力。在这种情况下,堆是唯一的选择。
堆是一块巨大的内存空间,常常占据整个虚拟空间的绝大部分。在这片空间里面,程序可以请求一块连续内存,并自由的使用,这块
内存在程序主动放弃之前都会一直保持有效。
int main()
{
char *p = (char*)malloc(1000);
free(p);
}
那么 malloc 到底怎么实现的呢?有一种做法是,把进程的内存管理交给操作系统内核去做,既然内核管理着进程的空间地址,那么如果
它提供一个系统调用,可以让程序使用这个系统调用申请内存,不就可以了。但实际上这样做的性能比较差,因为每次程序申请或者释放堆
空间都需要进行系统调用。我们知道系统调用的性能开销是很大的,当程序对堆的操作比较频繁的时候,这样做的结果是会严重影响程序的性能
的。比较好的做法是程序像操作系统申请一块适当大小的堆空间,然后由程序自己管理这块空间,而具体而言,管理着堆空间分配的往往是程序
的运行库。
运行库相当于向操作系统 '批发' 了一大块比较大的堆空间,然后 '零售' 给程序用。当全部 '售完' 或程序有大量的内存需求的时候,再
根据实际需求向操作系统 '进货'。当然运行库在向程序零售堆空间时,必须管理它批发来的堆空间,不能把同一块地址出售2次,导致地址的冲突。
于是运行库需要一个算法来管理堆空间,这个算法就是堆的分配算法。不过在了解具体的分配算法之前,我们先看看运行库是如何向操作系统批发
内存的。
10.3.2 Linux 进程堆管理
进程的地址空间中,除了可执行文件,共享库和栈之外,剩余的都是未分配的空间都可以用来作为堆空间。Linux 下的进程堆管理稍微有些复杂,因为
它提供了2种堆空间分配的方式,即2个系统调用:一个是 brk() 系统调用,一个是 mmap()。
1.int brk(void* end_data_segment);
brk() 的作用实际上就是设置进程数据段的结束地址,即它可以扩大或者缩小数据段(Linux 下数据段和 bss 合并在一起成为数据段)。如果我们将数据段的
结束地址向高地址移动,那么扩大的那部分空间就可以被我们使用,把这块空间拿来作为堆空间是最常见的做法之一。glibc 中还有一个函数叫做 sbrk,它的功能
与 brk 类似,只不过参数和返回值略微不同。sbrk 以一个增量作为参数,即需要增加(负数为减少)的空间大小,返回值是增加(或减少)后数据段结束的地址,这个
函数实际上是对 brk 系统调用的包装,它是通过 brk() 实现的。
2.mmap()的作用和 Windows 系统下的 VirtualAlloc 很相似,它的作用就是向操作系统申请一段虚拟地址空间,当然这块虚拟地址空间可以映射到某个文件(这也是
系统调用的最初作用),当它不将地址空间映射到某个文件时,我们又称这块空间为匿名空间,匿名空间就可以拿来作为堆空间。它的声明如下:
void* mmap(void* start, size_t length, int prot, int flags,int fd, off_t offset);
mmap 的前2个参数分别用于指定需要申请的空间的起始地址和长度,如果其实地址没设置为0,那么Linux系统会自动挑选合适的起始地址。 prot/flags 这2个参数用于
设置申请的空间的权限(rwx)以及映射类型(文件映射,匿名空间等),最后2个参数用于文件映射时指定的文件描述符和文件便宜,这里我们不关心。
glibc 的 malloc 函数是这样处理用户的空间请求的:对于小于128KB的请求来说,它会在现有的堆空间里面,按照算法为它分配一块空间并返回;对于大于 128KB 的请求
来说,它会使用 mmap() 函数为它分配一块匿名空间,然后在这个匿名空间中为用户分配空间。
由于 mmap() 函数和 VirtualAlloc 类似,它们都是系统虚拟空间申请函数,它们申请的空间的起始地址和大小都必须是系统页的整倍数,对于字节数很小的请求如果也使用
mmap() 的话,无疑是会浪费大量的空间的,所以上述的做法仅仅是演示而已,不具有实用性。
malloc 到底一次性能够申请的最大空间是多少?我们知道在有共享库的情况下,留给堆可以用的空间还有2处。第一处就是从 bss 段结束到 0x40000000,大于1GB不到的空间;
第二处是从共享库到栈的这段空间,大约是2GB不到。这2块空间的大小都取决于栈,共享库的大小和数量。于是可以估算到 malloc 最大的身躯空间大约是 2GB 不到。
还要其他因素会影响 malloc 的最大空间大小,比如系统的资源限制,物理内存和交换空间的总和等。mmap 申请匿名空间时,系统会为它在内存或交换空间中预留地址,但是申请
的空间大小不能超出空闲内存 + 空闲交换空间的总和。
10.3.3 Windows 进程堆管理
每个线程的栈都是独立的,所以一个进程中有多少个线程,就应该有多少个对应的栈。对于 Windows 来说,每个线程默认的栈大小是 1MB,在线程启动的时候,系统会为它在进程
地址空间中分配相应的空间作为栈,线程栈的大小可以由创建线程时 CreateThread 的参数指定。
堆管理器提供了一套与堆相关的 API 可以用来创建,分配,释放和销毁堆空间:
1.HeapCreate:创建一个堆
2.HeapAlloc:在一个堆里分配内存
3.HeapFree:释放已经分配的内存
4.HeapDestroy:销毁一个堆
每个进程在创建时都会有一个默认堆,这个堆在进程启动时创建,并且直到进程结束都一直存在。默认堆的大小为 1MB,不过我们可以通过链接器的 /HEAP 参数指定可执行文件的默认
堆大小,这样系统在创建进程时就会按照可执行文件所指定的大小创建默认堆。
一个进程中能够分配给堆用的空间不是连续的。所以当一个堆的空间无法再扩展时,我们必须创建一个新的堆。但是这一切都不需要用户操作,因为运行库的 malloc 函数已经解决了这
一切,它实际上是对 HeapXXXX 系统函数的包装,当一个堆空间不够时,它会在进程中创建额外的堆。
所以进程中可能存在多个堆,但是在一个进程中一次性能够分配的最大的堆空间取决于最大的那个堆。
Q : 我可以重复释放2次堆里的同一片内存吗
A : 不能。重复释放同一片堆里的内存时产生错误。
Q : 堆总数向上增长吗
A : 不是。
Q : malloc 申请的内存,进程结束后还会继续存在吗
A : 不会存在。当进程结束后,所有与进程相关的资源,包括进程的地址空间,物理内存,打开的文件,网络链接等都被称作系统关闭或者回收了。
Q : malloc 申请的空间是不是连续的?
A : 如果空间指的是 虚拟空间的话,那么答案是连续的;如果空间指的是 物理空间的话,则不一定是连续的。因为一块连续的虚拟地址空间有可能
是由若干个不连续的物理页拼凑而成的。
10.3.4 堆分配算法
1.空闲链表
实际上就是把堆中各个空闲的块按照链表的方式连接起来,当用户申请一块空间时,可以遍历整个列表,直到找到合适大小的块并且拆分它;当用户
释放空间时将它合并到空闲链表中。
缺点:一旦链表被破坏,或者记录长度的那4个字节被破坏,整个堆就无法正常工作了。
2.位图
其核心思想是将整个堆划分为大量的块(block),每个块的大小相同。当用户请求内存的时候,总数分配整个块给的空间给用户,第一个块我们称为已
分配区域的头(head),其他的称为已经分配区域的主题(body)。而我们可以使用一个整数数组来记录块的使用情况,由于每个块只有头/主体/空间 3种
状态,因此仅仅需要2位极客表示一个块,因此称为位图。
优点:
1.速度快 : 由于整个堆的空闲信息存储在一个数组里,因此访问该数组时 cache 很容易命中
2.稳定性好 : 为了避免用户越界读写破坏数据,我们只需简单的备份下位图即可。而且即使数据部分被破坏,也不会导致整个堆无法工作。
3.块不需要额外信息,易于管理
缺点:
1.分配内存的时候容易产生碎片
2.如果堆很大,或者设定的一个块很小(那么位图将会很大),可能失去 cache 命中率高的优势,也会浪费一定的空间。针对这种情况,我们可以使用多级位图。
3.对象池
实际上的一些场合,被分配对象的大小是较为固定的几个值,这时候我们可以针对这样的特征设计一个更为高效的堆算法,称为对象池。
对象池的思路很简单,如果每一次分配的空间大小都一样,那么久可以按照这个每次请求分配的大小作为一个单位,把整个堆空间划分为大量的小块,每次请求的时候
只需要找到一个小块就可以了。
对象池的管理方式可以采用空闲链表,也可以采用位图,与它们的区别仅仅在于它假定了每次请求都是一个固定的大小,因此实现起来容易。
实际上很多实现应用中,堆的分配算法往往是采用多种算法复合而成的。比如 对于 glibc 来说,它对于小于 64 字节的空间申请采用类似于对象池的方法;而对于
大于 512 字节的空间申请采用的是最佳适配算法;大于64而小于512字节的,它会根据情况采用上述的最佳折中策略;对于大于 128KB 的申请,它会使用 mmap 机制
直接向操作系统申请空间。
10.1 程序的内存布局
10.2 栈与调用惯例
10.3 堆与内存管理