美文网首页
《程序员的自我修养》读书笔记——内存

《程序员的自我修养》读书笔记——内存

作者: 纸简书生 | 来源:发表于2018-04-10 11:45 被阅读77次

    前面几篇文章都是关于静态库,动态库相关的介绍。平时开发中接触并不是很多。从这篇开始进入和工作中关系比较大的部分,库和运行库。第一篇就从内存开始介绍。

    本文导图


    内存布局

    现代应用程序都运行在一个内存空间里面,在32系统里面这个内存空间拥有4GB的寻址能力。16位时代是用段地址加偏移地址的寻址模式用尽不用了。如今的应用程序直接使用32位的地址进行寻址。这种方式叫做平坦的内存模型。整个内存是一个统一的地址空间,用户可以使用一个32位指针访问任何一个内存位置。

    int *p=(int *)0x12345678;
    ++*p;
    

    如上可以直接读写指定内存的数据。但是真真的情况下并不是这么平坦的。因为大多数操作系统会把内存空间的一部分给内核使用,应用程序时无法访问的,对应的内存空间也就叫做内核空间。相对内核空间,应用程序能够访问到的就是永恒空间。一般而言,在用户空间,很多区域有特殊含义。一个应用程序有如下默认的区域:

    • 栈:用于维护函数调用的上下文,通常是在用户空间高地址除分配,有数兆字节大小
    • 堆:堆用来容纳应用程序动态分配的内存区域。比如使用malloc、new分配内存的时候。堆通常位于栈的下方(低地址方向),并且堆一般没有统一的区域,而且范围也比栈大很多。
    • 可执行文件映像:存储可执行文件在内存中的映像。之前介绍过,由装载器在装载的时候将可执行文件的内存读取或映射到这里。
    • 保留区:不是一个单一的内存区域,而是对内存中受保护而禁止访问内存区域的总称。比如大多数操作系统绩效的地址通常不允许访问比如NULL,因为0地址正常情况不可能有有效的可访问数据。

    如下图是Linux一个进程的内存布局:


    注意还有一个动态链接库映射区,这个区域用于映射装载的动态链接库,比如在Linux下,如果有其他共享库链接,那么系统就会从0x4000 0000开始地址分配。

    栈最大的特点就是先进后出。在数据结构中,栈被定义为一个特殊的容器,可以压栈和出栈。在计算机系统中,栈则是一个具有数据结构中栈的属性的一个动态内存区域。

    栈总是向下增长的,栈顶由称为esp(有些也叫sp)寄存器定位。


    栈保存了一个函数调用所需要的维护信息,被称作为堆栈帧或者活动记录,堆栈帧一般有如下内容:
    • 函数的返回地址和参数
    • 临时变量,包括函数的非晶体局部变量及编译器自动生成的其他临时变量
    • 保存的上下文,包括在函数调用前后保持不变的寄存器

    在i386中,栈帧用ebp和esp这链两个寄存器划定范围。esp指向栈顶,ebp指向栈帧的一个固定位置,ebp又叫做帧指针。

    比如:


    在参数之后的数据就是函数的活动记录,ebp固定在其中一个位置,ebp不会随着函数的执行变化而变化,esp指向栈顶,会随着函数的执行变化而变化。ebp用来定位函数活动记录的各个数据。

    ebp之前就是函数返回地址(ebp-4),再往前就是压入栈的参数 ,地址分配是ebp-8、ebp-12,根据参数大小不同而定。

    ebp指向的数据是调用函数前的ebp的值,这样函数返回的时候ebp可以通过这个值恢复到调用前的值。

    i386函数调用过程:

    • 把所有或者一部分参数压入栈中,如果有些参数没有入栈则使用特定的寄存器保存。——参数
    • 把当前指令的下一条指令地址压入栈中。——返回地址
    • 跳转到函数体执行——上一步和这一步由指令call一起执行
    • push ebp;将ebp压入栈中——old ebp
    • mov ebp,esp ebp =esp——这个时候ebp指向栈顶,恰好这个时候栈顶就是old ebp
    • sub esp,xxx——在栈上分配xxx字节的临时空间
    • push xxx——如果有必要,保存名为xxx寄存器的内容
    • pop xxx——将栈顶数据恢复到xxx寄存器
    • mov esp,ebp——恢复esp,回收栈局部变量空间
    • pop ebp——从栈中恢复保存的ebp的值
    • ret ——从栈中取回返回地址,并跳转到该位置。

    一个例子:

    调用惯例(一种调用方和被调用的约定)

    如果函数调用方决定利用寄存器传递参数,而函数本身却以栈的方式传递,那么函数无法获取正确的参数。调用方和被调用方对于函数如何调用有一个明确的约定,只有双方遵守同样的约定,函数才能被正确调用,这样的约定被称作调用惯例。

    • 参数传递顺序和方式
    • 栈的维护方式
    • 名字修饰的策略

    c语言中使用的cdel惯例:


    看一个例子:


    • 虚线表示指令执行后的栈状态
    • 实线表示程序的跳转状态

    最好按照执行过程走一遍,弄清楚整个流程。

    函数返回值传递

    函数返回值存储在eax中,返回后函数的调用方再读取eax。但是exa只有4个字节,那么如何返回大数据的返回值呢。

    几乎所有的调用惯例都是采用eax和edx联合返回的方式进行。exa存储低1-4字节,edx存储高1-4字节。

    可以通过反汇编来分析eax和edx

    源代码:


    分析结果:


    如果返回值的尺寸太大,C语言在函数返回时会使用一个临时的栈上内存区域作为中转,结果返回值的对象会被拷贝两次,所以不到万不得已不要轻易返回大尺寸的对象。

    堆的内存管理比栈更为复杂,在任意时刻程序都可能发出请求,要么申请一段内存,要么释放一段已经申请过的内存。

    栈在函数返回时数据就会被清空,所以无法将数据传递至函数外部,而全局变量没有办法动态产生,只能在编译的时候定义,所以堆是唯一的选择。

    堆是一块巨大的空间,占有虚拟内存很大一部分。程序可以在堆上主动申请一块连续的内存,直到程序主动放弃。

    int main(){
      char* p=(char *)malloc(1000);
      //p指针指向1000大小的数组
      free(p);
    }
    

    malloc一种简单的实现是通过操作系统内核去完成的,把进程的内存管理给操作系统内核,系统提供一个系统调用。但是这样性能比较差,因为程序申请释放堆的操作比较频繁,每次都需要在用户太和内核态切换会消耗非常大的开销。比较好的做法就是把程序向操作系统申请一块适当大小的堆空间,然后由程序自己管理这块空间,往往是通过运行库来管理的。

    运行库充当的而是像操作系统批发一块较大堆空间角色,然后零售给程序使用。当全部使用完或者程序有大量内存需求的时候,运行库再向操作系统进货。通过堆分配算法来保证分配的内存不会出现冲突。

    Linux进程堆管理

    Linux 提供了两种堆空间的分配方式(两种系统调用),一种是brk系统调用,一种是mmap系统调用。brk原型int brk(void* end_data_segment)

    brk

    brk作用实际上就是设置进程数据段的结束地址,扩大 或者缩小数据端,如果数据段结束地址向高地址移动那么扩大的部分就可以被使用。另一个和brk类似的是sbrk,只不过sbrk传入的是需要增加空间的大小(负数就是减小),返回时值数据段的结束为止,其实就是对brk的一次包装。

    mmap

    作用是向操作系统申请一段虚拟地址空间,这块虚拟地址空间可以映射到某个文件,当不再映射到某个文件的时候,又叫这个空间为匿名空间——虚拟内存和磁盘交换

    声明如下:


    字段 含义
    start 申请空间的起始地址
    length 申请空间的大小,和start决定虚拟空间
    prot 设置申请空间的权限,可读、可写、可执行
    flags 映射类型,比如文件映射、匿名空间等
    fd 文件描述符
    offset 文件偏移

    malloc

    glibc中是通过malloc申请空间的,小于128kb的请求来讲会在现有的堆空间里面,安装堆分配算法分配一块空间并返回;如果大于128kb的请求会通过mmap申请一块匿名空间,然后在匿名空间中为用户分配空间。

    申请空间的其实地址和代销都必须是系统页大小的整数倍。所以对于字节很小的请求用mmap的话,会非常浪费。

    Windows进程堆管理

    关于Windows的内容这里暂时先省略,有感兴趣的同学可以自己去看一看。

    值得注意的几个问题

    • 一个堆里面的空间,如果被多次释放,那么会在重复释放的时候产生错误
    • 调用malloc首先会从系统分配给应用功能的堆里面去取,如果不够用才会通过系统调用再申请空间
    • malloc申请的空间在进程结束后都不会存在了,不仅内存如此。凡是和进程相关的资源,比如地址空间,物理内存,打开的文件,网络链接等都会被系统回收
    • malloc申请的空间在虚拟空间是连续的,在对应的物理空间不一定连续

    堆分配算法

    如何管理一大块连续的内存空间,能够按照需求 分配和释放,这就是堆分配算法。堆分配算法有很多种,这里介绍简单的几种

    空闲链表

    空闲链表就是把堆中各个空闲的块通过链表的方式联系起来,当前用申请一块空间的时候 ,可以遍历整个链表,直到找到合适大小的块,并将它拆分,当用户释放空间的时候将它合并到空闲链表中。

    当找到了足够容纳请求的一个空闲快,会把块分为两个部分,一部分为程序申请的,一部分是没有用到剩下的的(申请的和块大小不一定完全一样)。下图表示申请了一块和空闲块2相等堆后的状态


    在释放的时候,需要知道这个块的大小,那就在后面多加4个字节,用于存储分配的大小。这样在释放的时候就知道大小了。

    位图

    核心思想就是把整个堆分为大量的块,每个块的大小相同,当用户申请的时候,总是分配给整数个块给用户。第一个块称为分配区域的头,其余的我主体。那么所有的块只有三种状态头(H)、主体(B)、空闲(F)。所有可用两个二进制就能表示出来。


    其中11表示H,10表示主体,00表示空闲
    如上图,这个堆分配3个片内存,分别有2、4、1块。对应的位图是

    • 速度快:整个堆空闲信息存储在一个数组内,隐藏访问数组时cache容易命中。
    • 稳定性好
    • 快不需要额外的信息
    • 但是分配内存的时候容易造成随便,比如分配300字节,实际分配了3个快,也就是384.

    对象池

    上面两种是最为基本的。被分配对象大小是较为固定的几个值。对象池是一个对于这种场景更为高效的算法。

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

    核心在于与上面两种的区别是假定每次请求的都是一个固定的大小,因此实现起来容易。

    实际中堆分配算法往往是结合起来的,比如glilbc,对于64字节空念的申请,采用的是类似于对象池的方式,对于大于512字节的采用最佳适配算法,对于大于128KB的,会直接使用mmap机制直接向操作系统申请。

    小结

    这一节总体看来不是特别复杂,因为这一层平时接触比较多。相对于编译连接,静态、动态库之类的要简单很多。

    相关文章

      网友评论

          本文标题:《程序员的自我修养》读书笔记——内存

          本文链接:https://www.haomeiwen.com/subject/swmghftx.html