《程序员的自我修养》读书笔记7

来源:互联网 发布:王宝强 日本 知乎 编辑:程序博客网 时间:2024/05/07 18:37

内存

一、程序的内存布局

        现代的应用程序都运行在一个内存空间里,比如在32位系统里,这个内存空间拥有4GB的寻址能力,应用程序可以直接使用32位的地址进行寻址,这被称为平坦(flat)的内存模型。

       对32位系统来讲,内存的寻址能力是4GB,其中一部分会分给内核使用,比如Windows下默认会将高地址的2GB空间分配给内核(也可以配置为1GB),而Linux默认情况下将高地址的1GB空间分配给内核。剩下的2GB或3GB的内存空间称为用户空间。

       用户空间可以划分为:

       栈:维护函数调用的上下文, 向低地址增长

       堆:动态分配区域, 向高地址增长

       可执行文件镜像:存储可执行文件在内存中的映像

      保留区:内存中受保护而禁止访问的区域总称。


二、关于栈

1、堆栈帧的内容

     栈保存了一个函数调用所需要的维护信息,通常被称为堆栈帧(Stack Frame)或活动记录(Activate Record)。

     堆栈帧中保存的内容:

      函数的返回地址和参数; 

      临时变量:包括函数的非静态局部变量以及编译器自动生成的其他临时变量

      保存的上下文:包括在函数调用前后需要保持不变的寄存器


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

      i386下函数的调用过程:

      ** 把所有或一部分参数压入栈;

      ** 把当前指令的下一条指令的地址压入栈,并且跳转到函数体执行

      **接下来是函数体的执行过程:

          ** push ebp :把ebp压入栈中(称为old ebp)

          ** mov  ebp, eso:ebp = esp( 这时ebp指向栈顶,而此时栈顶就是old ebp);

         [ 可选 ] ** sub esp, XXX:在栈上分配XXX字节的临时空间

         [ 可选 ] ** push XXX:如有必要,保存名为XXX寄存器(可重复多个)

         **执行函数指令

         [ 可选 ] ** pop XXX :如有必要,恢复保存过的寄存器(可重复多个)

         ** mov esp,ebp :恢复ESP同时回收局部变量空间

         ** pop  ebp: 从栈中恢复保存的ebp的值

         ** ret :从栈中取得返回地址,并跳转到该位置。


下面分析一下foo函数的反汇编:

 int  foo()

{

       retrun 123;

}


foo函数汇编分析代码如下:


2、调用惯例

        函数参数的传递顺序和方式;(从左至右压栈,还是从右至左?)

        栈的维护方式;(出栈方是函数调用方?还是函数本身)

       名字修饰(Name-mapping)的策略()

       

        注:cdecl调用惯例是C语言默认的调用惯例:从右至左压入参数,出栈方:函数调用方, 名字修饰:函数名前加1个下划线


        几种主要的函数调用的内容:



3、函数返回值传递

         对于4字节大小的返回值,通过eax寄存器返回,对于5~8字节的对象,是采用eax和edx联合返回的方式进行的,其中eax存储返回值的低4字节,而edx存储返回值的高1~4字节,超过8字节的返回类型,也是通过eax寄存器传出,只不过eax存储的是数据块的指针,指针指向栈中的一块内存。

        下面分析一下:数据块作为函数返回值是怎样传递的

typedef struct big_thing{char buf[128];} big_thing;big_thing return_test(){big_thing b;b.buf[0] = 0;return b;}int main(){big_thing n = return_test();}
以上代码关于返回值的传递过程如下:

       ** 首先main函数在栈上额外开辟一个空间,并将这块空间的一部分作为传递返回值的临时对象,这里称为temp;

       ** 将temp对象的地址作为隐藏参数传递给return_test函数;

       ** return_test函数将数据拷贝给temp对象,并将temp对象的地址用eax传出;

       ** return_test返回之后,main函数将eax指向的temp对象的内容拷贝给n;

上述过程的伪代码表示如下:

void return_test( void *temp ){    big_thing b;    b.buf[0] = 0;    memcpy( temp, &b, sizeof(big_thing) ); //第一次拷贝    eax = temp;}int main(){    big_thing temp;    big_thing n;    return_test( &temp );    memcpy( &n, eax, sizeof(big_thing));   //第二次拷贝}

从上面可以看出,c语言在函数返回时会使用一个临时的栈上的内存区域作为中转,结果返回值对象会被拷贝两次,因此不到万不得已,不要轻易返回大尺寸的对象。对于返回值是c++对象的情况,也是如此,也会产生两次拷贝。

       注:为了减少返回对象的开销,c++提出了返回值优化(Return Value Optimization, RVO)技术,可以将某些场合下的对象拷贝减少1次,例如:

cpp_obj return_test(){    return cpp_obj;}
      在这个例子中,构造一个cpp_obj对象会调用一次cpp_obj的构造函数,在返回这个对象时,还会调用cpp_obj的拷贝构造函数,C++的返回值优化可以将这两步合并,直接将对象构造在传出时使用的临时对象上,因此可以减少一次复制过程。


三、堆与内存管理

       堆是一块巨大的内存空间,常常占据整个虚拟空间的绝大部分,在这片空间里,程序可以请求一块连续内存并自由使用。

       malloc是怎样实现的呢?

       程序会向操作系统申请一块适当大小的堆空间,然后由程序自己管理这块空间,而具体来讲,管理堆空间分配的往往是程序的运行库,运行库相当于是向操作系统”批发“了一块较大的堆空间,然后”零售“给程序用,当全部”售完“或程序有大量的内存需求时,再根据实际需求向操作系统”进货“。堆分配算法负责管理堆空间。

     

四、堆分配算法

       1、空闲链表(Free list)

        空闲链表的方法实际上就是把堆中各个空闲的块按照链表的方式连接起来,当用户请求一块空间时,可以遍历整个列表,直到找到合适大小的块并且将它拆分,当用户释放空间时将它合并到空闲链表中。

       这是最简单的一种分配策略,这种思路存在很多问题,比如一旦链表被破坏,或者记录长度的那4字节被破坏,整个堆就无法正常使用。

       2、位图(Bitmap)

       相对于空闲链表,”位图“的分配方式更加稳健,其核心思想是将整个堆划分为大量的块(block),每一块的大小相同。当用户请求内存的时候,总是分配整数个块的空间给用户,第一个块称为以分配区域的头(Head),其余的称为已分配区域的主体(Body)。可以使用一个整数数组来记录块的使用情况,由于每个块只有头/主体/空闲三种状态,因此仅仅需要两位即可表示一个块,因此称为位图。

       位图方式的优缺点:

      优点是:速度快、稳定性好、块不要额外信息,易于管理;

      缺点是:分配内存的时候易产生碎片(将块减小可以减少碎片),此外如果堆很大,或者设定一个块很小,那么位图将会很大,可能失去cache命中率高的优势,而且也会浪费一定的空间(针对这种情况可以使用多级的位图),

      3、对象池

      对象池的思路很简单,如果每一次分配的空间大小都一样,那么就可以按照这个每次请求分配的大小作为一个单位,把整个堆空间划分为大量的小块,每次请求的时候只需要找到一个小块就可以了。

       对象池的管理方法可使采用空闲链表,也可以采用位图,与它们的区别仅仅在于它假定了每次请求的都是一个固定的大小,因此实现起来很容易,由于每次总是只请求一个单位的内存,因此请求得到满足的速度非常快,无需查找一个足够大的空间。

       

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

0 0