linux内存管理

1.内存管理概述


  虚拟地址又叫线性地址。linux没有采用分段机制,所以逻辑地址和虚拟地址(线性地址)是一个概念。内核的虚拟地址和物理地址,大部分只差一个线性偏移量。用户空间的虚拟地址和物理地址则采用了多级页表进行映射,但仍称之为线性地址。

2.进程内存空间


  毫无疑问,所有进程都必须占用一定数量的内存,它或是用来存放从磁盘载入的程序代码,或是存放取自用户输入的数据等等。不过进程对这些内存的管理方式因内存用途不一而不尽相同,有些内存是事先静态分配和统一回收的,而有些却是按需要动态分配和回收的。

  Linux操作系统采用虚拟内存管理技术,使得每个进程都有各自互不干涉的进程地址空间,该空间是块大小为4G的线性虚拟空间,用户所看到和接触到的都是该虚拟地址,无法看到实际的物理内存地址。利用这种虚拟地址不但能起到保护操作系统的效果(用户不能直接访问物理内存),而且更重要的是,用户程序可使用比实际物理内存更大的地址空间。

  4G的进程地址空间被人为的分为两个部分——用户空间与内核空间。用户空间从0到3G(0xC0000000),内核空间占据3G到4G。用户进程通常情况下只能访问用户空间的虚拟地址,不能访问内核空间虚拟地址。只有用户进程进行系统调用(代表用户进程在内核态执行)等时刻可以访问到内核空间。

  每当进程切换,用户空间就会跟着变化;而内核空间是由内核负责映射,它并不会跟着进程改变,是固定的。内核空间地址有自己对应的页表init_mm.pgd),用户进程各自有不同的页表。

  对任何一个普通进程来讲,它都会涉及到5种不同的数据段:

    [1]代码段:代码段是用来存放可执行文件的操作指令,也就是说是它是可执行程序在内存中的镜像。代码段需要防止在运行时被非法修改,所以只准许读取操作,而不允许写入(修改)操作——它是不可写的。

    [2]已初始化数据段(.data):数据段用来存放可执行文件中已初始化全局变量,换句话说就是存放程序静态分配的变量和全局变量。

    [3]未初始化数据段(.bss):BSS段包含了程序中未初始化的全局变量,在内存中bss段全部置零。

    [4]堆(heap):堆是用于存放进程运行中动态分配的内存段。当进程调用malloc等函数分配内存时,新分配的内存就被动态添加到堆上(堆被扩张);当利用free等函数释放内存时,被释放的内存从堆中被剔除(堆被缩减)。

    [5]栈:栈是用户存放程序临时创建的局部变量,也就是说我们函数括弧“{}”中定义的变量(但不包括static声明的变量,static意味着在数据段中存放变量)。除此以外,在函数被调用时,其参数也会被压入发起调用的进程栈中,并且待到调用结束后,函数的返回值也会被存放回栈中。由于栈的先进先出特点,所以栈特别方便用来保存/恢复调用现场。从这个意义上讲,我们可以把堆栈看成一个寄存、交换临时数据的内存区。

    

3.进程内存管理


  很明显,进程内存管理的对象进程使用的虚拟内存区域,为了方便管理,虚拟空间被划分为许多大小可变的(但必须是4096的倍数)内存区域,这些区域的划分原则是“将访问属性一致的地址空间存放在一起”,所谓访问属性在这里无非指的是“可读、可写、可执行等”。

  要查看某个进程占用的内存区域,可以使用命令cat /proc/<pid>/maps获得。

  vm_area_struct是描述进程地址空间的基本管理单元,对于一个进程来说往往需要多个内存区域来描述它的虚拟空间,vm_area_struct结构是以链表形式链接,不过为了方便查找,内核又以红黑树(以前的内核使用平衡树)的形式组织内存区域,以便降低搜索耗时。并存的两种组织形式,并非冗余:链表用于需要遍历全部节点的时候用,而红黑树适用于在地址空间中定位特定内存区域的时候。内核为了内存区域上的各种不同操作都能获得高性能,所以同时使用了这两种数据结构。

  下图反映了进程地址空间的管理模型:

  

  创建进程fork()、程序载入execve()、映射文件mmap()、动态内存分配malloc()/brk()等进程相关操作都需要分配内存给进程。不过这时进程申请和获得的还不是物理内存,而是虚拟内存。进程对虚拟内存的分配最终都会归结到do_mmap()函数上来(brk调用被单独以系统调用实现,不用do_mmap()),内核使用do_mmap()函数创建一个新的线性地址区间,但是说该函数创建了一个新vm_area_struct并不非常准确,因为如果创建的地址区间和一个已经存在的地址区间相邻,并且它们具有相同的访问权限的话,那么两个区间将合并为一个。如果不能合并,那么就确实需要创建一个新的vm_area_struct了,但无论哪种情况,do_mmap()函数都会将一个地址区间加入到进程的地址空间中,无论是扩展已存在的内存区域还是创建一个新的区域。

  同样,释放一个内存区域应使用函数do_ummap(),它会销毁对应的内存区域。

  进程所能直接操作的地址都为虚拟地址。当进程需要内存时,从内核获得的仅仅是虚拟的内存区域,而不是实际的物理地址,获得的仅仅是对一个新的线性地址区间的使用权。实际的物理内存只有当进程真的去访问新获取的虚拟地址时,才会由“请求页机制”产生“缺页”异常,从而进入分配实际页面的例程。该异常是虚拟内存机制赖以存在的基本保证——它会告诉内核去真正为进程分配物理页,并建立对应的页表,这之后虚拟地址才实实在在地映射到了系统的物理内存上。(当然,如果页被换出到磁盘,也会产生缺页异常,不过这时不用再建立页表了)。

  总之,当访问的进程虚拟内存并未真正分配页面时,该操作便被调用来分配实际的物理页,并为该页建立页表项。

  虽然应用程序操作的对象是映射到物理内存之上的虚拟内存,但是处理器直接操作的却是物理内存。所以当应用程序访问一个虚拟地址时,首先必须将虚拟地址转化成物理地址,然后处理器才能解析地址访问请求。地址的转换工作需要通过查询页表才能完成,概括地讲,地址转换需要将虚拟地址分段,使每段虚地址都作为一个索引指向页表,而页表项则指向下一级别的页表或者指向最终的物理页面。

  每个进程都有自己的页表。进程描述符的pgd域指向的就是进程的页全局目录。

4.物理内存管理


  Linux内核管理物理内存是通过分页机制实现的,它将整个内存划分成无数个4k(在i386体系结构中)大小的页,从而分配和回收内存的基本单位便是内存页了。利用分页管理有助于灵活分配内存地址,因为分配时不必要求必须有大块的连续内存。虽然如此,但是实际上系统使用内存时还是倾向于分配连续的内存块,因为分配连续内存时,页表不需要更改,因此能降低TLB的刷新率(频繁刷新会在很大程度上降低访问速度)。

  内核分配物理页面时为了尽量减少不连续情况,采用了buddy算法来管理空闲页面。内核中分配空闲页面的基本函数是基于buddy的get_free_page/get_free_pages,它们或是分配1页或是分配指定的页面(2、4、8…512页)。

  注意:get_free_page是在内核中分配内存,不同于malloc在用户空间中分配,malloc利用堆动态分配,实际上是调用brk()系统调用,该调用的作用是扩大或缩小进程堆空间(它会修改进程的brk域)。如果现有的内存区域不够容纳堆空间,则会以页面大小的倍数为单位,扩张或收缩对应的内存区域,但brk值并非以页面大小为倍数修改,而是按实际请求修改。因此Malloc在用户空间分配内存可以以字节为单位分配,但内核在内部仍然会是以页为单位分配的。

  由get_free_page函数所分配的连续内存都陷于物理映射区域,所以它们返回的内核虚拟地址和实际物理地址仅仅是相差一个偏移量(PAGE_OFFSET),你可以很方便的将其转化为物理内存地址,同时内核也提供了virt_to_phys()函数将内核虚拟空间中的物理映射区地址转化为物理地址。

  伙伴关系也好、slab技术也好,从内存管理理论角度而言目的基本是一致的,它们都是为了防止“分片”。slab分配器使得一个页面内包含的众多小块内存可独立被分配使用,避免了内部分片,节约了空闲内存。伙伴关系把内存块按大小分组管理,一定程度上减轻了外部分片的危害,因为页框分配不在盲目,而是按照大小依次有序进行,不过伙伴关系只是减轻了外部分片,但并未彻底消除。

  【1】内部碎片和外部碎片

    内部分片是说系统为了满足一小段内存区(连续)的需要,不得不分配了一大区域连续内存给它,从而造成了空间浪费;

    外部分片是指系统虽有足够的内存,但却是分散的碎片,无法满足对大块“连续内存”的需求。

  【2】buddy算法

    伙伴算法(Buddy system)把所有的空闲页框分为11个块链表,每块链表中分布包含特定的连续页框地址空间,比如第0个块链表包含大小为2^0个连续的页框,第1个块链表中,每个链表元素包含2个页框大小的连续地址空间,….,第10个块链表中,每个链表元素代表4M的连续地址空间。每个链表中元素的个数在系统初始化时决定,在执行过程中,动态变化。

    伙伴算法每次只能分配2的幂次个页框的空间,比如一次分配1页,2页,4页,8页,…,1024页(2^10)等等,每页大小一般为4K,因此,伙伴算法最多一次能够分配4M(1024*4K)的内存空间。

    通常X86系统上MAX_ORDER是11,限制了最大分配的连续物理内存页为1024页,即4M。

    初始阶段,每个链表中元素的个数是固定的,假如MAX_ORDER是11,则此时可能第10个块里面都是4M的块链表,其他块的链表为空,当分配内存的时候就向下拆分,当释放的时候就向上合并,因此这种方式可以有效的保持一定量的连续物理内存块,方便某些情况申请到大块的物理内存。

    因此,伙伴算法的优点就是,较好的解决了外部碎片问题。不过伙伴关系只是减轻了外部分片,但并未彻底消除。

    同时导致的问题:一个连续的内存中仅仅一个页面被占用,导致整块内存区都不能合并;会导致内部碎片,例如需要9K大小时,必须分配16K的内存空间,但实际只用到9K。

  【3】slab

    以页为最小单位分配内存对于内核管理系统中的物理内存来说的确比较方便,但内核自身最常使用的内存却往往是很小(远远小于一页)的内存块——比如存放文件描述符、进程描述符、虚拟内存区域描述符等行为所需的内存都不足一页。 为了满足内核对这种小内存块的需要,Linux系统采用了一种被称为slab分配器的技术。Slab分配器的核心思想就是“存储池”的运用。内存片段(小块内存)被看作对象,当被使用完后,并不直接释放而是被缓存到“存储池”里,留做下次使用,这无疑避免了频繁创建与销毁对象所带来的额外负载。

    slab建立在页面和buddy算法基础之上,每个Slab都是从buddy分配的连续的物理页面。slab中的对象分配和销毁使用kmem_cache_alloc与kmem_cache_free。

    linux在slab基础上构造了一个通用的内存分配器,即kmalloc函数,系统在内存初始化的时候就预先建立一组预定义大小的kmen_cache,从32字节开始,按2的幂逐步递增,用户调用kmalloc进行内存分配时,就是从kmem_cache中找到第一个比size大的kmem_cache。因此用kmalloc申请50个字节时,其实是从size-64的slab中进行分配的。

  【4】vmalloc

    kmalloc函数基于slab分配的内存对象和get_free_page/get_free_pages申请的内存,最后都是基于buddy得到的连续的物理内存,通过buddy分配的内存由MAX_ORDER限制,因此不能分配超大内存。因此内核提供了vmalloc函数用来分配超大内存。

    函数vmalloc返回一个连续的虚拟地址的内存块,但是物理块可能是不连续的。

    相比kmalloc来说,vmalloc需要建立专门的页表项,对不连续的物理内存进行映射,因此分配效率要低。所以一般用kmalloc进行内存分配,只有分配超大内存时才使用vmalloc。

  总结:最底层的物理内存分配是基于buddy算法的get_free_page/get_free_pages,之后是基于buddy算法的slab分配内存kmem_cache_alloc与kmem_cache_free,在上面就是基于slab的kmalloc函数。

                                          之后是基于buddy算法的vmalloc。

  【5】多级页表结构

    进程访问的对象都是虚拟地址,CPU通过MMU把虚拟地址转为物理地址访问真正的物理内存。虚拟内存和物理内存的映射关系保存在页表中。使用多级页表可以节约地址转换需要的空间。

    为什么不分级的页表就做不到节约内存呢?这是保存在主存中的页表承担的职责是将虚拟地址翻译成物理地址,所以页表一定要覆盖全部虚拟地址空间,不分级的页表就需要有1M个页表项来映射,而二级页表则最少只需要1K个页表项。

    目前是四层页表结构:页全局目录、页上级目录、页中间目录、页表项。进程地址描述符中记录的mm->pgd就是当前进程的全局页目录的起始地址。

    问题:虚拟空间上,每个页面为4K,因此4G的内存需要有1M个页表项(4GB / 4KB = 1M)。如果使用二级页表,一级页表映射4MB,二级页表映射4Kb,则需要1K + 1K * 1K = 1.1M,占用空间反而大了,这什么原因?

       这是因为一级页表肯定存在,但是二级页表可以不存在,因为如果一级的页表没有用到,就不用创建其对应的二级页表了,可以在需要的时候创建,即一级页表覆盖到了全部虚拟地址空间,二级页表在需要时创建。

       同理,多级页表可以更加节约空间,同时多级页表也是典型的时间换空间的例子,动态创建二级页表、调入和调出二级页表都是需要花费额外时间的,远没有不分级的页表来的直接;

 

 

 

 

 

 

 

posted on 2019-03-04 14:38  能量星星  阅读(289)  评论(0编辑  收藏  举报

导航