Linux 内存管理分析

1. 用户空间

通常 32 位 Linux 虚拟地址空间划分, 0-3GB为用户空间,3GB-4GB为内核空间。每个进程都有4GB的虚拟地址空间,其中0-3GB是自己私有的用户空间,最高的1GB是与所有进程共享的内核空间。Linux 进程的内存布局如下图所示:

进程虚拟地址空间

进程地址空间主要分为以下几部分:
代码段(Text): 程序代码在内存中的映射,存放函数体的二进制代码。
数据段(Data): 在程序运行初已经对变量进行初始化的数据。
BSS段(BSS): 在程序运行初未对变量进行初始化的数据。
栈(Stack): 存储局部,临时变量,函数调用时存储函数的返回指针,用于控制函数的调用和返回。在程序块开始时自动分配内存,结束时自动释放内存,其操作方式类似于数据结构中的栈。
堆(Heap): 存储动态内存分配,需要程序员手工分配,手工释放。

注意,以上所说的地址均为虚拟地址。进程虚拟地址到物理地址的转换会在下面"段页式存储管理"部分详细讲解。

2. 内核空间

在Linux中,内核空间是持续存在的,并且在所有进程中都映射到同样的物理内存,内核代码和数据总是可寻址的,随时准备处理中断和系统调用。

物理地址 = 逻辑地址 – 0xC0000000,这是内核地址空间3GB - 3GB+896MB的地址转换关系,说白了就是线性映射,偏移为0xC0000000。注意内核的虚拟地址在“高端”,但是它映射的物理内存地址在低端。

为什么只有3GB - 3GB+896MB是线性映射,而不是整个1GB都线性映射呢?假设按照这样简单的地址映射关系,那么内核地址空间访问为3GB-4GB,对应的物理内存范围就为0-1GB,即只能访问1GB物理内存。若机器中安装4G物理内存,那么内核就只能访问前1G物理内存,后面3G物理内存将会无法访问。为了解决这个问题,Linux引入了高端内存的概念。

高端内存的基本思想:借一段地址空间,建立临时地址映射,用完后释放。达到这段地址空间可以循环使用,访问所有物理内存的目的。

内核空间

Linux系统在初始化时,会根据实际的物理内存的大小,为每个物理页面创建一个page对象,所有的page对象构成一个mem_map数组。进而针对不同的用途,Linux内核将所有的物理页面划分到3类内存管理区中,如图,分别为ZONE_DMA,ZONE_NORMAL,ZONE_HIGHMEM。

Zone

ZONE_DMA的范围是0-16MB,该区域的物理页面专门供I/O设备的DMA使用。之所以需要单独管理DMA的物理页面,是因为DMA使用物理地址访问内存,不经过MMU,并且需要连续的缓冲区,所以为了能够提供物理上连续的缓冲区,必须从物理地址空间专门划分一段区域用于DMA。
ZONE_NORMAL的范围是16MB-896MB,该区域的物理页面是内核能够直接使用的。
ZONE_HIGHMEM的范围是896MB-4GB,该区域即为高端内存,内核不能直接使用。

3. 段页式存储管理

程序在执行时,传递给CPU的地址是逻辑地址。它由两部分组成,一部分是段选择符(比如cs和ds等段寄存器的值),另一部分是偏移量(比如eip寄存器的值)。逻辑地址必须经过段式映射转换为线性地址线性地址再经过页式映射转为物理地址,才能访问真正的物理内存。转换过程如下:

段页式存储管理

3.1 段式映射

逻辑地址是以"段寄存器:偏移地址"形式存在的。段寄存器是一个16位的寄存器, 其中第0和1位控制着将要访问段的特权级别。第2位说明是在GDT还是LDT中寻找地址,Linux程序里用的段描述符总是选择GDT。高13位作为一个索引值。

段寄存器

如下图所示,首先从GDTR寄存器中取出段描述符表(GDT)的首地址,通过段寄存器里的索引值,可以从段描述符表(GDT)里找到段的基址。 然后用基址加上段内的偏移量,就得到了对应的线性地址。

逻辑地址->线性地址

3.2 页式映射

内核把物理页作为内存管理的基本单位,页面大小为4KB,整个虚拟地址空间为4GB,则需要包含1M个页表项,这还只是一个进程,因为每个进程都有自己独立的页表,这样系统所有的内存都来存放页表项恐怕都不够。想象一下进程的虚拟地址空间,实际上大部分是空闲的,真正映射的区域几乎是汪洋大海中的小岛,因次我们可以考虑使用多级页表,可以减少页表内存使用量。Linux操作系统使用4级页表,4级页表分别为:页全局目录、页上级目录、页中间目录、页表

线性地址->物理地址

4. Buddy

Linux采用著名的伙伴系统(buddy system)算法来解决外碎片问题。把所有的空闲页框分组为11个块链表,每个块链表分别包含大小为1,2,4,8,16,32,64,128,256,512和1024个连续的页框。对1024个页框的最大请求对应着4MB大小的连续RAM块。每个块的第一个页框的物理地址是该块大小的整数倍。
伙伴算法是一种物理内存分配和回收的方法,物理内存所有空闲页都记录在BUDDY链表中。系统建立一个链表,链表中的每个元素代表一类大小的物理内存,分别为2的0次方、1次方、2次方...个页大小,对应4K、8K、16K...的内存,每一类大小的内存又有一个链表,表示目前可以分配的物理内存。例如现在仅存需要分配8K的物理内存,系统首先从8K那个链表中查询有无可分配的内存,若有直接分配;否则查找16K大小的链表,若有,首先将16K一分为二,将其中一个分配给进程,另一个插入8K的链表中,若无,继续查找32K,若有,首先把32K一分为二,其中一个16K大小的内存插入16K链表中,然后另一个16K继续一分为二,将其中一个插入8K的链表中,另一个分配给进程,以此类推。当内存释放时,查看相邻内存有无空闲,若存在两个联系的8K的空闲内存,直接合并成一个16K的内存,插入16K链表中。

Buddy 算法

采用伙伴算法分配内存时,每次至少分配一个页面。但当请求分配的内存大小为几十个字节或几百个字节时应该如何处理?如何在一个页面中分配小的内存区,小内存区的分配所产生的内碎片又如何解决?Linux采用Slab。

5. Slab

slab向buddy“批发”一些内存,加工切块以后“散卖”出去。
slab分配器主要的功能就是对频繁分配和释放的小对象提供高效的内存管理。它的核心思想是实现一个缓存池,分配对象的时候从缓存池中取,释放对象的时候再放入缓存池。slab分配器是基于对象类型进行内存管理的,每一种对象被划分为一类,例如索引节点对象是一类,进程描述符又是一类,等等。每当需要申请一个特定的对象时,就从相应的类中分配一个空白的对象出去;当这个对象被使用完毕时,就重新“插入”到相应的类中(其实并不存在插入的动作,仅仅是将该对象重新标记为空闲而已)。

Slab
  1. 首先要查看inode_cachep的slabs_partial链表,如果slabs_partial非空,就从中选中一个slab,返回一个指向已分配但未使用的inode结构的指针。完事之后,如果这个slab满了,就把它从slabs_partial中删除,插入到slabs_full中去,结束;

  2. 如果slabs_partial为空,也就是没有半满的slab,就会到slabs_empty中寻找。如果slabs_empty非空,就选中一个slab,返回一个指向已分配但未使用的inode结构的指针,然后将这个slab从slabs_empty中删除,插入到slabs_partial(或者slab_full)中去,结束;

  3. 如果slabs_empty也为空,那么没办法,cache内存已经不足,只能新创建一个slab了。

Slab分配器一直处于内核内存管理的核心地位,尽管如此,它还是拥有自身的缺点,最明显的两点就是复杂性和过多的管理数据造成的内存上的开销。针对这些问题,linux引入了slub分配器,

6. Slub

slub分配器保留了slab分配器的所有接口,实际上slub分配器的模型和slab分配的模型是基本一致的,只不过在一些地方进行了精简,这也使得slub分配器工作起来更为游刃有余。两者主要的区别如下:

  1. slab分配器为了增加分配速度,引入了一些管理数组,如slab管理区中的kmem_bufctl数组和紧随本地CPU结构后面的用来跟踪最热空闲对象的数组,这些结构虽然加快了分配对象的速度,但也增加了一定的复杂性,而且随着系统变得庞大,其对内存的开销也越明显,而slub分配器则完全摒弃了这些管理数据。
  2. slab分配器针对每个缓存,根据slab的状态划分了3个链表 full,partial和free。slub分配器做了简化,去掉了free链表,对于空闲的slab,slub分配器选择直接将其释放。
  3. slub分配器摒弃了slab分配器中的着色概念,在slab分配器中,由于颜色的个数有限,因此着色也无法完全解决slab之间的缓存行冲突问题,考虑到着色造成了内存上的浪费,slub分配器没有引入着色。
  4. 在NUMA架构的支持上,slub分配器也较slab分配器做了简化。

推荐阅读更多精彩内容