10.4.3 Linux中内存管理的实现

32位机器上的每个Linux进程通常有3GB的虚拟地址空间,还有1GB留给其页表和其他内核数据。在用户态下运行时,内核的1GB是不可见的,但是当进程陷入到内核时是可以访问的。内核内存通常驻留在低端物理内存中,但是被映射到每个进程虚拟地址空间顶部的1GB中,在地址0xC0000000和0xFFFFFFFF(3~4GB)之间。当进程创建的时候,进程地址空间被创建,并且当发生一个exec系统调用时被重写。

为了允许多个进程共享物理内存,Linux监视物理内存的使用,在用户进程或者内核构件需要时分配更多的内存,把物理内存动态映射到不同进程的地址空间中去,把程序的可执行体、文件和其他状态信息移入移出内存来高效地利用平台资源并且保障程序执行的进展性。本章的剩余部分描述了在Linux内核中负责这些操作的各种机制的实现。

1.物理内存管理

广告:个人专属 VPN,独立 IP,无限流量,多机房切换,还可以屏蔽广告和恶意软件,每月最低仅 5 美元

在许多系统中由于异构硬件限制,并不是所有的物理内存都能被相同地对待,尤其是对于I/O和虚拟内存。Linux区分三种内存区域(zone):

1)ZONE_DMA:可以用来DMA操作的页。

2)ZONE_NORMAL:正常规则映射的页。

3)ZONE_HIGHMEM:高内存地址的页,并不永久性映射。

内存区域的确切边界和布局是硬件体系结构相关的。在x86硬件上,一些设备只能在起始的16MB地址空间进行DMA操作,因此ZONE_DMA就在0~16MB的范围内。此外,硬件也不能直接映射896MB以上的内存地址,因此ZONE_HIGHMEM就是高于该标记的任何地址。ZONE_NORMAL是介于其中的任何地址。因此在x86平台上,Linux地址空间的起始896MB是直接映射的,而内核地址空间的剩余128MB是用来访问高地址内存区域的。内核为每个内存区域维护一个zone数据结构,并且可以分别在三个区域上执行内存分配。

Linux的内存由三部分组成。前两部分是内核和内存映射,被“钉”在内存中(页面从来不换出)。内存的其他部分被划分成页框,每一个页框都可以包含一个代码、数据或者栈页面,一个页表页面,或者在空闲列表中。

内核维护内存的一个映射,该映射包含了所有系统物理内存使用情况的信息,比如区域、空闲页框等。如图10-15,这些信息是如下组织的。

首先,Linux维护一个页描述符数组,称为mem_map,其中页描述符是page类型的,而且系统当中的每个物理页框都有一个页描述符。每个页描述符都有个指针,在页面非空闲时指向它所属的地址空间,另有一对指针可以使得它跟其他描述符形成双向链表,来记录所有的空闲页框和一些其他的域。在图10-15中,页面150的页描述符包含一个到其所属地址空间的映射。页面70、页面80、页面200是空闲的,它们是被链接在一起的。页描述符的大小是32字节,因此整个mem_map消耗了不到1%的物理内存(对于4KB的页框)。

因为物理内存被分成区域,所以Linux为每个区域维护一个区域描述符。区域描述符包含了每个区域中内存利用情况的信息,例如活动和非活动页的数目,页面置换算法(本章后面介绍)所使用的高低水位,还有许多其他的域。

此外,区域描述符包含一个空闲区数组。该数组中的第i个元素标记了2i 个空闲页的第一个块的第一个页描述符。既然可能有多块2i 个空闲页,Linux使用页描述符的指针对把这些页面链接起来。这个信息在Linux的内存分配操作中使用。在图10-15中,free_area[0]标记所有仅由一个页框组成的物理内存空闲区,现在指向页面70,三个空闲区当中的第一个。其他大小为一个页面的空闲块也可通过页描述符中的链到达。

阅读 ‧ 电子书库
图 10-15 Linux内存表示

最后,Linux可以移植到NUMA体系结构(不同的内存地址有不同的访问时间),为了区分不同节点上的物理内存(同时避免跨节点分配数据结构),使用了一个节点描述符。每个节点描述符包含了内存使用的信息和该节点上的区域。在UMA平台上,Linux用一个节点描述符描述所有的内存。每个页描述符的最初一些位是用来指定该页框所属的节点和区域的。

为了使分页机制在32位和64位体系结构下高效工作,Linux采用了一个四级分页策略。这是一种最初在Alpha系统中使用的三级分页策略,在Linux 2.6.10之后加以扩展,并且从2.6.11版本以后使用的一个四级分页策略。每个虚拟地址划分成五个域,如图10-16。目录域是页目录的索引,每个进程都有一个私有的页目录。找到的值是指向其中一个下一级目录的一个指针,该目录也由虚拟地址的一个域索引。中级页目录表中的表项指向最终的页表,它是由虚拟地址的页表域索引的。页表的表项指向所需要的页面。在Pentium处理器(使用两级分页)上,每个页的上级和中级目录仅有一个表项,因此总目录项就可以有效地选择要使用的页表。类似地,在需要的时候可以使用三级分页,此时把上级目录域的大小设置为0就可以了。

阅读 ‧ 电子书库
图 10-16 Linux使用四级页表

物理内存可以用于多种目的。内核自身是完全“硬连线”的,它的任何一部分都不会换出。内存的其余部分可以作为用户页面、分页缓存和其他目的。页面缓存包含最近已读的或者由于未来有可能使用而预读的文件块,或者需要写回磁盘的文件块页面,例如那些被换出到磁盘的用户进程创建的页面。分页缓存并不是一个独立的缓存,而是那些不再需要的或者等待换出的用户页面集合。如果分页缓存当中的一个页面在被换出内存之前复用,它可以被快速收回。

此外,Linux支持动态加载模块,最常见的是设备驱动。它们可以是任意大小的并且必须分配一个连续的内核内存。这些需求的一个直接结果是,Linux用这样一种方式来管理物理内存使得它可以随意分配任意大小的内存片。它使用的算法就是伙伴算法,下面给予描述。

2.内存分配机制

Linux支持多种内存分配机制。分配物理内存页框的主要机制是页面分配器,它使用了著名的伙伴算法。

管理一块内存的基本思想如下。刚开始,内存由一块连续的片段组成,图10-17a的简单例子中是64个页面。当一个内存请求到达时,首先上舍入到2的幂,比如8个页面。然后整个内存块被分割成两半,如图b所示。因为这些片段还是太大了,较低的片段被再次二分(c),然后再二分(d)。现在我们有一块大小合适的内存,因此把它分配给请求者,如图d所示。

阅读 ‧ 电子书库
图 10-17 伙伴算法的操作

现在假定8个页面的第二个请求到达了。这个请求有(e)直接满足了。此时4个页面的第三个请求到达了。最小可用的块被分割(f),然后其一半被分配(g)。接下来,8页面的第二个块被释放(h)。最后,8页面的另一个块也被释放。因为刚刚释放的两个邻接的8页面块来自同一个16页面块,它们合并起来得到一个16页面的块(i)。

Linux用伙伴算法管理内存,同时有一些附加特性。它有个数组,其中的第一个元素是大小为1个单位的内存块列表的头部,第二个元素是大小为2个单位的内存块列表的头部,下一个是大小为4个单位的内存块列表的头部,以此类推。通过这种方法,任何2的幂次大小的块都可以快速找到。

这个算法导致了大量的内部碎片,因为如果想要65页面的块,必须要请求并且得到一个128页面的块。

为了缓解这个问题,Linux有另一个内存分配器,slab分配器。它使用伙伴算法获得内存块,但是之后从其中切出slab(更小的单元)并且分别进行管理。

因为内核频繁地创建和撤销一定类型的对象(如task_struct),它使用了对象缓存。这些缓存由指向一个或多个slab的指针组成,而slab可以存储大量相同类型的对象。每个slab要么是满的,要么是部分满的,要么是空的。

例如,当内核需要分配一个新的进程描述符(一个新的task_struct)的时候,它在task结构的对象缓存中寻找,首先试图找一个部分满的slab并且在那里分配一个新的task_struct对象。如果没有这样的slab可用,就在空闲slab列表中查找。最后,如果必要,它会分配一个新的slab,把新的task结构放在那里,同时把该slab连接到task结构对象缓存中。在内核地址空间分配连续的内存区域的kmalloc内核服务,实际上就是建立在slab和对象缓存接口之上的。

第三个内存分配器vmalloc也是可用的,并且用于那些仅仅需要虚拟地址空间连续的请求。实际上,这一点对于大部分内存分配是成立的。一个例外是设备,它位于内存总线和内存管理单元的另一端,因此并不理解虚拟地址。然而,vmalloc的使用导致一些性能的损失,主要用于分配大量连续虚拟地址空间,例如动态插入内核模块。所有这些内存分配器都是继承自System V中的那些分配器。

3.虚拟地址空间表示

虚拟地址空间被分割成同构连续页面对齐的区域。也就是说,每个区域由一系列连续的具有相同保护和分页属性的页面组成。代码段和映射文件就是区(area)的例子(见图10-15)。在虚拟地址空间的区之间可以有空隙。所有对这些空隙的引用都会导致一个严重的页面故障。页大小是确定的,例如Pentium是4KB而Alpha是8KB。Pentium支持4MB的页框,Linux可以支持4MB的大页框。而且,在PAE(物理地址扩展)模式下,2MB的页大小是支持的。在一些32位机器上常用PAE来增加进程地址空间,使之超过4GB。

在内核中,每个区是用vm_area_struct项来描述的。一个进程的所有vm_area_struct用一个链表链接在一起,并且按照虚拟地址排序以便可以找到所有的页面。当这个链表太长时(多于32项),就创建一个树来加速搜索。vm_area_struct项列出了该区的属性。这些属性包括:保护模式(如,只读或者可读可写)、是否固定在内存中(不可换出)、朝向哪个方向生长(数据段向上长,栈段向下长)。

vm_area_struct也记录该区是私有的还是跟一个或多个其他进程共享的。fork之后,Linux为子进程复制一份区链表,但是让父子进程指向相同的页表。区被标记为可读可写,但是页面却被标记为只读。如果任何一个进程试图写页面,就会产生一个保护故障,此时内核发现该内存区逻辑上是可写的,但是页面却不是,因此它把该页面的一个副本给当前进程同时标记为可读可写。这个机制就说明了写时复制是如何实现的。

vm_area_struct也记录该区是否在磁盘上有备份存储,如果有,在什么地方。代码段把可执行二进制文件作为备份存储,内存映射文件把磁盘文件作为备份存储。其他区,如栈,直到它们不得不被换出,否则没有备份存储被分配。

一个顶层内存描述符mm_struct收集属于一个地址空间的所有虚拟内存区相关的信息,还有关于不同段(代码,数据,栈)和用户共享地址空间的信息等。一个地址空间的所有vm_area_struct元素可以通过内存描述符用两种方式访问。首先,它们是按照虚拟地址顺序组织在链表中的。这种方式的有用之处是:当所有的虚拟地址区需要被访问时,或者当内核查找分配一个指定大小的虚拟内存区域时。此外,vm_area_struct项目被组织成二叉“红黑”树(一种为了快速查找而优化的数据结构)。这种方法用于访问一个指定的虚拟内存地址。为了能够用这两种方法访问进程地址空间的元素,Linux为每个进程使用了更多的状态,但是却允许不同的内核操作来使用这些访问方法,这对进程而言更加高效。