程序员的自我修养--链接、装载与库笔记:内存

1. 程序的内存布局

现代的应用程序都运行在一个内存空间里,在32位的系统里,这个内存空间拥有4GB(2的32次方)的寻址能力。应用程序可以直接使用32位的地址进行寻址,这被称为平坦(flat)的内存模型。在平坦的内存模型中,整个内存是一个统一的地址空间,用户可以使用一个32位的指针访问任意内存位置。大多数操作系统都会将4GB的内存空间中的一部分挪给内核使用,应用程序无法直接访问这一段内存,这一部分内存地址被称为内核空间。Windows在默认情况下会将高地址的2GB空间分配给内核(也可配置为1GB),而Linux默认情况下将高地址的1GB空间分配给内核。

用户使用的剩下2GB或3GB的内存空间称为用户空间。在用户空间里,也有许多地址区间有特殊的地位,一般来讲,应用程序使用的内存空间里有如下默认”的区域

(1). 栈:用于维护函数调用的上下文,离开了栈函数调用就没法实现。栈通常在用户空间的最高地址处分配,通常有数兆字节的大小。

(2). 堆:用来容纳应用程序动态分配的内存区域,当程序使用malloc或new分配内存时,得到的内存来自堆里。堆通常存在于栈的下方(低地址方向),在某些时候,堆也可能没有固定统一的存储区域。堆一般比栈大很多,可以有几十至数百兆字节的容量。

(3). 可执行文件映像:这里存储着可执行文件在内存里的映像,由装载器在装载时将可执行文件的内存读取或映射到这里。

(4). 保留区:并不是一个单一的内存区域,而是对内存中受到保护而禁止访问的内存区域的总称,例如,大多数操作系统里,极小的地址通常都是不允许访问的,如NULL。通常C语言将无效指针赋值为0也是出于这个考虑,因为0地址上正常情况下不可能有有效的可访问数据。

2. 栈与调用惯例

什么是栈(stack):几乎每一个程序都使用了栈,没有栈就没有函数,没有局部变量,也就没有我们如今能够看见的所有的计算机语言。在经典的计算机科学中,栈被定义为一个特殊的容器,用户可以将数据压入栈中(入栈,push),也可以将已经压入栈中的数据弹出(出栈,pop),但栈这个容器必须遵守一条规则:先入栈的数据后出栈(First In Last Out, FIFO)。在计算机系统中,栈则是一个具有以上属性的动态内存区域。程序可以将数据压入栈中,也可以将数据从栈顶弹出。压栈操作使栈增大,而弹出操作使栈减少。在经典的操作系统里,栈总是向下增长的。压栈的操作使栈顶的地址减少,弹出的操作使栈顶地址增大。

栈保存了一个函数调用所需要的维护信息,这常常被称为堆栈帧(Stack Frame)或活动记录(Activate Record)。堆栈帧一般包括如下几方面内容:(1).函数的返回地址和参数;(2).临时变量:包括函数的非静态局部变量以及编译器自动生成的其它临时变量;(3).保存的上下文:包括在函数调用前后需要保持不变的寄存器。

在i386中,一个函数的活动记录用ebp和esp这两个寄存器划定范围。esp寄存器始终指向栈的顶部,同时也就是指向了当前函数的活动记录的顶部。而相对的,ebp寄存器指向了函数活动记录的一个固定位置,ebp寄存器又被称为帧指针(Frame Pointer)。

在VC下调试程序的时候,常常看到一些没有初始化的变量或内存区域的值是”烫”。之所以会出现烫”这么一个奇怪的字,就是因为Debug模式,将所有的分配出来的栈空间的每一个字节都初始化为0xCC。0xCCCC(即两个连续排列的0xCC)的汉字编码就是烫,所以0xCCCC如果被当作文本就是”烫”。将未初始化数据设置为0xCC的理由是这样可以有助于判断一个变量是否没有初始化。如果一个指针变量的值是0xCCCCCCCC,那么我们就可以基本相信这个指针没有经过初始化。当然这个信息仅供参考,编译器查未初始化变量的方法并不能以此为证据。有时编译器还会使用0xCDCDCDCD作为未初始化标记,此时我们就会看到汉字”屯屯”

钩子(Hook)技术:允许用户在某些时刻截获特定函数的调用。

调用惯例(Calling Convention):函数的调用方和被调用方对于函数如何调用需要有一个明确的约定,只有双方都遵守同样的约定,函数才能被正确地调用,这样的约定就称为调用惯例。一个调用惯例一般会规定如下几个方面的内容:

(1). 函数参数的传递顺序和方式:函数参数的传递有很多种方式,最常见的一种是通过栈传递。函数的调用方将参数压入栈中,函数自己再从栈中将参数取出。对于有多个参数的函数,调用惯例要规定函数调用方将参数压栈的顺序:是从左至右,还是从右至左。有些调用惯例还允许使用寄存器传递参数,以提高性能。

(2). 栈的维护方式:在函数将参数压栈之后,函数体会被调用,此后需要将被压入栈中的参数全部弹出,以使得栈在函数调用前后保持一致。这个弹出的工作可以由函数的调用方来完成,也可以由函数本身来完成。

(3). 名字修饰(Name-mangling)的策略:为了链接的时候对调用惯例进行区分,调用管理要对函数本身的名字进行修饰。不同的调用惯例有不同的名字修饰策略。事实上,在C语言里,存在着多个调用惯例,而默认的调用惯例是cdecl。任何一个没有显式指定调用惯例的函数都默认是cdecl惯例。_cdecl是非标准关键字,在不同的编译器里可能有不同的写法,例如在GCC里就不存在__cdecl这样的关键字,而是使用__attribute__((cdecl))。

下图介绍了几项C语言主要的调用惯例的内容:

此外,不少编译器还提供一种称为naked call的调用惯例,这种调用惯例用在特殊的场合,其特点是编译器不产生任何保护寄存器的代码,故称为naked call。对于C++语言,以上几种调用惯例的名字修饰策略都有所改变,因为C++支持函数重载以及命名空间和成员函数等等,因此实际上一个函数名可以对应多个函数定义,那么上面提到的名字修饰策略显然是无法区分各个不同同名函数定义的。所以C++自己有更加复杂的名字修饰策略。C++自己还有一种特殊的调用惯例,称为thiscall,专用于类成员函数的调用。其特点随编译器不同而不同,在VC里是this指针存放于ecx寄存器,参数从右到左压栈,而对于gcc,thiscall和cdecl完全一样,只是将this看做是函数的第一个参数。

函数返回值传递:除了参数的传递之外,函数与调用方的交互还有一个渠道就是返回值。如果返回值类型的尺寸太大,C语言在函数返回时会使用一个临时的栈上内存区域作为中转,结果返回值对象会被拷贝两次。因而不到万不得已,不要轻易返回大尺寸的对象函数传递大尺寸的返回值所使用的方法并不是可移植的,不同的编译器、不同的平台、不同的调用惯例甚至不同的编译参数都有权利采用不同的实现方法

在C++里返回一个对象的时候,对象要经过两次拷贝构造函数的调用才能够完成返回对象的传递。一次拷贝到栈上的临时对象里,另一次把临时对象拷贝到存储返回值的对象里。在某些编译器里,返回一个对象甚至要经过更多的步骤。这样带来的恶果就是返回一个较大对象会有非常多的额外开销。因此C++程序中都尽量避免返回对象。此外,为了减少返回对象的开销,C++提出了返回值优化(Return Value Optimization, RVO)这样的技术,可以将某些场合下的对象拷贝减少一次。

3. 堆与内存管理

什么是堆(Heap):光有栈对于面向过程的程序设计还远远不够,因为栈上的数据在函数返回的时候就会被释放掉,所以无法将数据传递至函数外部。而全局变量没有办法动态地产生,只能在编译时候定义,在很多情况下缺乏表现力。在这种情况下,堆是唯一的选择。堆是一块巨大的内存空间,常常占据整个虚拟空间的绝大部分。在这片空间里,程序可以请求一块连续内存,并自由地使用,这块内存在程序主动放弃之前都会一直保持有效

Linux进程堆管理:进程的地址空间中,除了可执行文件、共享库和栈之外,剩余的未分配的空间都可以被用来作为堆空间。Linux下提供了两种堆空间的分配方式,即两个系统调用:一个是brk()系统调用,另外一个是mmap()

brk()的作用实际上就是设置进程数据段的结束地址,即它可以扩大或者缩小数据段(Linux下数据段和BSS合并在一起统称数据段)。如果我们将数据段的结束地址向高地址移动,那么扩大的那部分空间就可以被我们使用,把这块空间拿来作为堆空间是最常见的做法之一。Glibc中还有一个函数叫sbrk,它的功能与brk类似,只不过参数和返回值略有不同。sbrk以一个增量(Increment)作为参数,即需要增加(负数为减少)的空间大小,返回值是增加(或减少)后数据段结束地址,这个函数实际上是对brk系统调用的包装,它是通过brk()实现的。

mmap()的作用和Windows系统下的VirtualAlloc很相似,它的作用就是向操作系统申请一段虚拟地址空间,当然这块虚拟地址空间可以映射到某个文件(这也是这个系统调用的最初的作用),当它不将地址空间映射到某个文件时,我们又称这块空间为匿名(Anonymous)空间,匿名空间就可以拿来作为堆空间。它的声明如下:mmap的前两个参数分别用于指定需要申请的空间的起始地址和长度,如果起始地址设置为0,那么Linux系统会自动挑选合适的起始地址。prot/flags这两个参数用于设置申请的空间的权限(可读、可写、可执行)以及映射类型(文件映射、匿名空间等),最后两个参数是用于文件映射时指定文件描述符和文件偏移的。

void *mmap(void *addr, size_t length, int prot, int flags, int fd, off_t offset);

Glibc的malloc函数是这样处理用户的空间请求的:对于小于128KB的请求来说,它会在现有的堆空间里面,按照堆分配算法为它分配一块空间并返回;对于大于128KB的请求来说,它会使用mmap()函数为它分配一块匿名空间,然后在这个匿名空间中为用户分配空间。

由于mmap()函数与VirtualAlloc()类似,它们都是系统虚拟空间申请函数,它们申请的空间的起始地址和大小都必须是系统页的大小的整数倍,对于字节数很小的请求如果也使用mmap的话,无疑是会浪费大量的空间的。

Windows进程堆管理:Windows的进程将地址空间分配给了各种EXE、DLL文件、堆、栈。每个线程的栈都是独立的,所以一个进程中有多少个线程,就应该有多少个对应的栈,对于Windows来说,每个线程默认的栈大小是1MB,在线程启动时,系统会为它在进程地址空间中分配相应的空间作为栈,线程栈的大小可以由创建线程时CreateThread的参数指定。在分配完上面这些地址以后,Windows的进程地址空间已经是支离破碎了。当程序向系统申请堆空间时,只好从这些剩下的还没有被占用的地址上分配。Windows系统提供了一个API叫做VirtualAlloc(),用来向系统申请空间,它与Linux下的mmap非常相似。实际上VirtualAlloc()申请的空间不一定只用于堆,它仅仅是向系统预留了一块虚拟地址,应用程序可以按照需要随意使用。在使用VirtualAlloc()函数申请空间时,系统要求空间大小必须为页的整数倍,即对于x86系统来说,必须是4096字节的整数倍。Windows中,堆管理器(Heap Manager)提供了一套与堆相关的API可以用来创建、分配、释放和销毁堆空间:HeapCreate,创建一个堆;HeapAlloc,在一个堆里分配内存;HeapFree,释放已经分配的内存;HeapDestroy,销毁一个堆。

堆管理器实际上存在于Windows的两个位置:一份是位于NTDLL.DLL中,这个DLL是Windows操作系统用户层的最底层DLL,它负责Windows子系统DLL与Windows内核之间的接口,所有用户程序、运行时库和子系统的堆分配都是使用这部分的代码;而在Windows内核Ntoskrnl.exe中,还存在一份类似的堆管理器,它负责Windows内核中的堆空间分配(内核堆和用户的堆不是同一个),Windows内核、内核组件、驱动程序使用堆时用到的都是这份堆分配代码,内核堆管理器的接口都由RtlHeap开头。

每个进程在创建时都会有一个默认堆,这个堆在进程启动时创建,并且直到进程结束都一直存在。默认堆的大小为1MB,不过我们可以通过链接器的/HEAP参数指定可执行文件的默认堆大小,这样系统在创建进程时就会按照可执行文件所指定的大小创建默认堆。当然1MB的堆空间对很多程序来说是不够用的,如果用户申请的空间超过1MB,堆管理器就会扩展堆的大小,它会通过VirtualAlloc向系统申请更多的空间。

一个进程中能够分配给堆用的空间不是连续的。所以当一个堆的空间已经无法再扩展时,我们必须创建一个新的堆。但是这一切都不需要用户操作,因为运行库的malloc函数已经解决了这一切,它实际上是对Heapxxxx系列函数的包装,当一个堆空间不够时,它会在进程中创建额外的堆。所以进程中可能存在多个堆,但是一个进程中一次性能够分配的最大的堆空间取决于最大的那个堆。

一块连续的虚拟地址空间有可能是若干个不连续的物理页拼凑而成的

堆分配算法:如何管理一大块连续的内存空间,能够按照需求分配、释放其中的空间,这就是堆分配的算法。堆的分配算法有很多种:

(1). 空闲链表(Free List):实际上就是把堆中各个空闲的块按照链表的方式连接起来,当用户请求一块空间时,可以遍历整个列表,直到找到合适大小的块并且将它拆分;当用户释放空间时将它合并到空闲链表中。空闲链表是这样一种结构,在堆里的每一个空闲空间的开头(或结尾)有一个头(header),头结构里记录了上一个(prev)和下一个(next)空闲块的地址,也就是说,所有的空闲块形成了一个链表。

这样的空闲链表实现尽管简单,但在释放空间的时候,给定一个已分配块的指针,堆无法确定这个块的大小。一个简单的解决方法是当用户请求k个字节空间的时候,我们实际分配k+4个字节,这4个字节用于存储该分配的大小,即k+4。这样释放该内存的时候只要看看这4个字节的值,就能知道该内存块的大小,然后将其插入到空闲链表里就可以了。当然这仅仅是最简单的一种分配策略,这样的思路存在很大问题。例如,一旦链表被破坏,或者记录长度的那4字节被破坏,整个堆就无法正常工作,而这些数据恰恰很容易被越界读写所接触到。

(2). 位图:针对空闲链表的弊端,另一种分配方式显得更加稳健。这种方式称为位图(Bitmap),其核心思想是将这个堆划分为大量的块(block),每个块的大小相同。当用户请求内存的时候,总是分配整数个块的空间给用户,第一个块我们称为已分配区域的头(Head),其余的称为已分配区域的主体(Body)。而我们可以使用一个整数数组来记录块的使用情况,由于每个块只有头/主体/空闲三种状态,因此仅仅需要两位即可表示一个块,因此称为位图。优点:速度快,由于整个堆的空闲信息存储在一个数组内,因此访问该数组时cache容易命中;稳定性好,为了避免用户越界读写破坏数据,我们只需简单地备份一下位图接口,而且即使部分数据被破坏,也不会导致整个堆无法工作;块不需要额外信息,易于管理。缺点:分配内存的时候容易产生碎片;如果堆很大,或者设定的一个块很小(这样可以减少碎片),那么位图将会很大,可能失去cache命中率高的优势,而且也会浪费一定的空间,针对这种情况,我们可以使用多级的位图。

(3). 对象池:在一些场合,被分配对象的大小是较为固定的几个值,这时候可以针对这一特征设计一个更为高效的堆算法,称为对象池。对象池的思路很简单,如果每一次分配的空间大小都一样,那么就可以按照这个每次请求分配的大小作为一个单位,把整个堆空间划分为大量的小块,每次请求的时候只需要找到一个小块就可以了。对象池的管理方法可以采用空闲链表,也可以采用位图,与它们的区别仅仅在于它假定了每次请求的都是一个固定的大小,因此实现起来很容易。由于每次总是只请求一个单位的内存,因此请求得到满足的速度非常快,无须查找一个足够大的空间。

实际上很多现实应用中,堆的分配算法往往是采取多种算法符合而成的。比如对于Glibc来说,它对于小于64字节的空间申请是采用类似于对象池的方法;而对于大于512字节的空间申请采用的是最佳适配算法;对于大于64字节而小于512字节的,它会根据情况采取上述方法中的最佳折中策略;对于大于128KB的申请,它会使用mmap机制直接向操作系统申请空间。

GitHubhttps://github.com/fengbingchun/Messy_Test

发布了718 篇原创文章 · 获赞 1131 · 访问量 609万+

猜你喜欢

转载自blog.csdn.net/fengbingchun/article/details/101780432