Linux使用的是单一整体式结构(Monolithic),其中定义了一组原语或系统调用以实现操作系统的服务,例如在几个模块中以超级模式运行的进程管理、并发控制和内存管理服务。尽管出于兼容性考虑,Linux依然将段控制单元模型(segment control unit model)保持一种符号表示,但实际上已经很少使用这种模型了。
与内存管理有关的主要问题有:
* 虚拟内存的管理,这是介于应用程序请求与物理内存之间的一个逻辑层。
* 物理内存的管理。
* 内核虚拟内存的管理/内核内存分配器,这是一个用来满足对内存的请求的组件。这种对内存的请求可能来自于内核,也可能来自于用户。
* 虚拟地址空间的管理。
* 交换和缓存。
1. 分段模型概述
在x86架构中,内存被划分成3种类型的地址:
逻辑地址(logical address)是存储位置的地址,它可能直接对应于一个物理位置,也可能不直接对应于一个物理位置。逻辑地址通常在请求控制器中的信息时使用。
线性地址(linear address)(或称为平面地址空间)是从0开始进行寻址的内存。之后的每个字节都可顺序使用下一数字来引用(0、1、2、3 等),直到内存末尾为止。这就是大部分非Intel CPU的寻址方式。Intel®架构使用了分段的地址空间,其中内存被划分成64KB的段,有一个段寄存器总是指向当前正在寻址的段的基址。这种架构中的32位模式被视为平面地址空间,不过它也使用了段。
物理地址(physical address)是使用物理地址总线中的位表示的地址。物理地址可能与逻辑地址不同,内存管理单元可以将逻辑地址转换成物理地址。
CPU使用两种单元将逻辑地址转换成物理地址。第一种称为分段单元(segmented unit),另外一种称为分页单元(paging unit)。 图1表示转换地址空间使用的两种单元。
图1
分段模型背后的基本思想是将内存分段管理。从本质上来说,每个段就是自己的地址空间。段由两个元素构成:基址(base address)包含某个物理内存位置的地址;长度值(length value)指定该段的长度。分段地址还包括两个组件,即段选择器(segment selector)和段内偏移量(offset into the segment)。段选择器指定了要使用的段(即基址和长度值),而段内偏移量组件则指定了实际内存位置相对于基址的偏移量。实际内存位置的物理地址就是这个基址值与偏移量之和。如果偏移量超过了段的长度,系统就会生成一个保护违例错误。上述内容可小结如下:分段单元可以表示成 -> 段: 偏移量 模型,也也可表示成 -> 段标识符: 偏移量。
每个段都是一个16位的字段,称为段标识符(segment identifier)或段选择器(segment selector)。x86硬件包括几个可编程的寄存器,称为段寄存器(segment register),段选择器保存于其中。这些寄存器为 cs(代码段)、ds(数据段)和 ss(堆栈段)。每个段标识符都代表一个使用 64 位(8 个字节)的段描述符 (segment descriptor) 表示的段。这些段描述符可以存储在一个 GDT(全局描述符表,global descriptor table)中,也可以存储在一个 LDT(本地描述符表,local descriptor table)中。图2表示段描述符和段寄存器的相互关系。
图2
每次将段选择器加载到段寄存器中时,对应的段描述符都会从内存加载到相匹配的不可编程CPU寄存器中。每个段描述符长8个字节,表示内存中的一个段。这些都存储到LDT或GDT中。段描述符条目中包含一个指针和一个20位的值(Limit字段),前者指向由Base字段表示的相关段中的第一个字节,后者表示内存中段的大小。其他某些字段还包含一些特殊属性,例如优先级和段的类型(cs 或 ds)。段的类型是由一个4位的Type字段表示的。由于我们使用了不可编程寄存器,因此在将逻辑地址转换成线性地址时不引用GDT或LDT。这样可以加快内存地址的转换速度。
段选择器包含以下内容:
* 一个13位的索引,用来标识GDT或LDT中包含的对应段描述符条目。
* TI(Table Indicator)标志指定段描述符是在GDT中还是在LDT中,如果该值是0,段描述符就在GDT中;如果该值是1,段描述符就在LDT中。
* RPL(request privilege level)定义了在将对应的段选择器加载到段寄存器中时CPU的当前特权级别。
由于一个段描述符的大小是8个字节,因此它在GDT或LDT中的相对地址可以这样计算:段选择器的高13位乘以8。例如,如果GDT存储在地址0x00020000处,而段选择器的Index域是2,那么对应的段描述符的地址就等于(2*8) + 0x00020000。GDT中可以存储的段描述符的总数等于 (2^13 - 1),即8191。图3展示了从逻辑地址获得线性地址。
图3
Linux对这个模型稍微进行了修改。我注意到Linux以一种受限的方法来使用这种分段模型(主要是出于兼容性方面的考虑)。在Linux中,所有的段寄存器都指向相同的段地址范围。换言之,每个段寄存器都使用相同的线性地址。这使Linux所用的段描述符数量受限,从而可将所有描述符都保存在GDT之中。这种模型有两个优点:一是当所有的进程都使用相同的段寄存器值时(当它们共享相同的线性地址空间时),内存管理更为简单。二是在大部分架构上都可以实现可移植性。某些RISC处理器也可通过这种受限的方式支持分段。图4展示了对模型的修改(段寄存器指向相同的地址集)。
图4
Linux使用以下段描述符:内核代码段、内核数据段、用户代码段、用户数据段、TSS段、默认LDT段。下面详细介绍这些段寄存器。
GDT中的内核代码段(kernel code segment)描述符中的值如下:
* Base = 0x00000000
* Limit = 0xffffffff(2^32 -1) = 4GB
* G(粒度标志)= 1,表示段的大小是以页为单位表示的
* S = 1,表示普通代码或数据段
* Type = 0xa,表示可以读取或执行的代码段
* DPL值 = 0,表示内核模式
与这个段相关的线性地址是4 GB,S = 1和type = 0xa表示代码段。选择器在cs寄存器中。Linux 中用来访问这个段选择器的宏是_KERNEL_CS。
内核数据段(kernel data segment)描述符的值与内核代码段的值类似,惟一不同的就是Type字段值为 2。这表示此段为数据段,选择器存储在ds寄存器中。Linux中用来访问这个段选择器的宏是_KERNEL_DS。
用户代码段(user code segment)由处于用户模式中的所有进程共享。存储在GDT中的对应段描述符的值如下:
* Base = 0x00000000
* Limit = 0xffffffff
* G = 1
* S = 1
* Type = 0xa,表示可以读取和执行的代码段
* DPL = 3,表示用户模式
在Linux中,我们可以通过_USER_CS宏来访问此段选择器。
在用户数据段(user data segment)描述符中,惟一不同的字段就是Type,它被设置为2,表示将此数据段定义为可读取和写入。Linux中用来访问此段选择器的宏是_USER_DS。
除了这些段描述符之外,GDT还包含了另外两个用于每个创建的进程的段描述符:TSS和LDT段。每个TSS段(TSS segment)描述符都代表一个不同的进程。TSS中保存了每个CPU的硬件上下文信息,它有助于有效地切换上下文。例如,在U->K模式的切换中,x86 CPU就是从TSS中获取内核模式堆栈的地址。每个进程都有自己在 GDT 中存储的对应进程的 TSS 描述符。这些描述符的值如下:
* Base = &tss(对应进程描述符的TSS字段的地址;例如 &tss_struct)这是在Linux内核的schedule.h文件中定义的
* Limit = 0xeb(TSS段的大小是236字节)
* Type = 9或11
* DPL = 0。用户模式不能访问TSS。G标志被清除
所有进程共享默认LDT段。默认情况下,其中会包含一个空的段描述符。这个默认LDT段描述符存储在GDT中。Linux所生成的LDT的大小是24个字节。默认有3个条目:LDT[0] = 空;LDT[1] = 用户代码段;LDT[2] = 用户数据/堆栈段描述符。
为了计算GDT中最多可以存储多少条目,必须先理解NR_TASKS,这个变量决定了Linux可支持的并发进程数,内核源代码中的默认值是512,最多允许有256个到同一实例的并发连接。GDT中可存储的条目总数可通过以下公式确定:
GDT中的条目数 = 12 + 2 * NR_TASKS。
正如前所述,GDT可以保存的条目数 = 2^13 -1 = 8192。在这8192个段描述符中,Linux要使用6个段描述符,另外还有4个描述符将用于APM特性(高级电源管理特性),在GDT中还有4个条目保留未用。因此,GDT中的条目数等于8192 - 14,也就是8180。
任何情况下,GDT中的条目数8180,因此:2 * NR_TASKS = 8180,NR_TASKS = 8180/2 = 4090。为什么使用2 * NR_TASKS?因为对于所创建的每个进程,都不仅要加载一个TSS描述符用来维护上下文切换的内容,另外还要加载一个LDT描述符。这种 x86架构中进程数量的限制Linux 2.2中的一个组件,但自2.4版的内核开始,这个问题已经不存在了,部分原因是使用了硬件上下文切换(这不可避免地要使用TSS),并将其替换为进程切换。
2、分页模型概述
分页单元负责将线性地址转换成物理地址(请参见图 1)。线性地址会被分组成页的形式。这些线性地址实际上都是连续的。分页单元将这些连续的内存映射成对应的连续物理地址范围(称为页框)。注意,分页单元会直观地将RAM划分成固定大小的页框。正因如此,分页具有以下优点:为一个页定义的访问权限中保存了构成该页的整组线性地址的权限;页的大小等于页框的大小。
将这些页映射成页框的数据结构称为页表(page table)。页表存储在主存储器中,可由内核在启用分页单元之前对其进行恰当的初始化。图5展示了页表映射到页框的情况。
图5
在Linux中,分页单元的使用多于分段单元。前面介绍Linux分段模型时已提到,每个分段描述符都使用相同的地址集进行线性寻址,从而尽可能降低使用分段单元将逻辑地址转换成线性地址的需要。通过更多地使用分页单元而非分段单元,Linux可以极大地促进内存管理及其在不同硬件平台之间的可移植性。
下面让我们来介绍一下用于在x86架构中指定分页的字段,这些字段有助于在Linux中实现分页功能。分页单元进入作为分段单元输出结果的线性字段,然后进一步将其划分成以下3个字段:
* Directory 以10 MSB表示(Most Significant Bit,也就是二进制数字中值最大的位的位置 —— MSB 有时称为最左位)。
* Table 以中间的10位表示。
* Offset 以12 LSB表示。(Least Significant Bit,也就是二进制整数中给定单元值的位的位置,即确定这个数字是奇数还是偶数。LSB有时称为最右位。这与数字权重最轻的数字类似,它是最右边位置处的数字。)
线性地址到对应物理位置的转换的过程包含两个步骤。第一步使用了一个称为页目录 (Page Directory) 的转换表(从页目录转换成页表),第二步使用了一个称为页表 (Page Table) 的转换表(即页表加偏移量再加页框)。图6展示了此过程。
图6
开始时,首先将页目录的物理地址加载到cr3寄存器中。线性地址中的Directory字段确定页目录中指向恰当的页表条目。Table字段中的地址确定包含页的页框物理地址所在页表中的条目。Offset字段确定了页框中的相对位置。由于Offset字段为12位,因此每个页中都包含有4 KB数据。
下面小结物理地址的计算:
(1) cr3 + Page Directory (10 MSB) = 指向 table_base
(2) table_base + Page Table (10 中间位) = 指向 page_base
(3) page_base + Offset = 物理地址 (获得页框)
由于Page Directory字段和Page Table段都是10位,因此其可寻址上限为 1024*1024 KB,Offset可寻址的范围最大为2^12(4096 字节)。因此,页目录的可寻址上限为1024*1024*4096(等于2^32个内存单元,即4 GB)。因此在x86架构上,总可寻址上限是4 GB。
对于扩展分页,则是通过删除页表转换表实现的;此后线性地址的划分即可在页目录 (10 MSB) 和偏移量(22 LSB)之间完成了。22 LSB构成了页框的4 MB边界(2^22)。扩展分页可以与普通的分页模型一起使用,并可用于将大型的连续线性地址映射为对应的物理地址。操作系统中删除页表以提供扩展页表。这可以通过设置PSE(page size extension)实现。36位的PSE扩展了36位的物理地址,可以支持4 MB页,同时维护一个4字节的页目录条目,这样就可以提供一种对超过4 GB的物理内存进行寻址的方法,而不需要对操作系统进行太大的修改。这种方法对于按需分页来说具有一些实际的限制。
虽然Linux中的分页模型与普通的分页类似,但是 x86 架构引入了一种三级页表机制,包括:
* 页全局目录 (Page Global Directory),即pgd,是多级页表的抽象最高层。每一级的页表都处理不同大小的内存 —— 这个全局目录可以处理4 MB的区域。每项都指向一个更小目录的低级表,因此pgd就是一个页表目录。当代码遍历这个结构时(有些驱动程序就要这样做),就称为是在“遍历”页表。
* 页中间目录 (Page Middle Directory),pmd,是页表的中间层。在x86架构上,pmd在硬件中并不存在,但是在内核代码中它是与pgd合并在一起的。
* 页表条目 (Page Table Entry),即pte,是页表的最低层,它直接处理页(参看PAGE_SIZE),该值包含某页的物理地址,还包含了说明该条目是否有效及相关页是否在物理内存中的位。
为了支持大内存区域,Linux也采用了这种三级分页机制。在不需要为大内存区域时,即可将pmd定义成“1”,返回两级分页机制。注意分页级别是在编译时进行优化的,我们可以通过启用或禁用中间目录来启用两级和三级分页(使用相同的代码)。32位处理器使用的是pmd分页,而64位处理器使用的是pgd分页。图7说明三级分页的情况。
图7
如您所知,在64位处理器中,21 MSB保留未用;13 LSB由页面偏移量表示;其余的30位分为10位用于页表,10位用于页全局目录,10位用于页中间目录。我们可以从架构中看到,实际上使用了43位进行寻址。因此在64位处理器中,可以有效使用的内存是2的43次方。
每个进程都有自己的页目录和页表。为了引用一个包含实际用户数据的页框,操作系统(在x86架构上)首先将pgd加载到cr3寄存器中。Linux将cr3寄存器的内容存储到TSS段中。此后只要在CPU上执行新进程,就从TSS段中将另外一个值加载到cr3寄存器中。从而使分页单元引用一组正确的页表。pgd表中的每一条目都指向一个页框,其中中包含了一组pmd条目;pmd表中的每个条目又指向一个页框,其中包含一组pte条目;pde表中的每个条目再指向一个页框,其中包含的是用户数据。如果正在查找的页已转出,那么就会在pte表中存储一个交换条目,(在缺页的情况下)以定位将哪个页框重新加载到内存中。图8说明我们连续为各级页表添加偏移量来映射对应的页框条目。我们通过进入作为分段单元输出的线性地址,再划分该地址来获得偏移量。要将线性地址划分成对应的每个页表元素,需要在内核中使用不同的宏。这里不详细介绍这些宏,下面我们通过图8来简单看一下线性地址的划分方式。
图8
Linux为内核代码和数据结构预留了几个页框。这些页永远不会被转出到磁盘上。从0x0到0xc0000000(PAGE_OFFSET)的线性地址可由用户代码和内核代码进行引用。从PAGE_OFFSET到0xffffffff的线性地址只能由内核代码进行访问。这意味着在4 GB 的内存空间中,只有3 GB可以用于用户应用程序。
Linux进程启用分页机制包括两个阶段:在启动时,系统为8 MB的物理内存设置页表。然后,第二个阶段完成对其余物理地址的映射。在启动阶段,startup_32()调用(是32位内核的入口函数,也称为进程0)负责对分页机制进行初始化。这是在arch/x86/kernel/head_32.S文件中实现的。这8 MB的映射发生在PAGE_OFFSET之上的地址中。这种初始化是通过一个静态定义的编译时数组 (swapper_pg_dir) 开始的。在编译时它被放到一个特定的地址(0x00101000)。这种操作为在代码中静态定义的两个页即pg0和pg1建立页表。这些页框的大小默认为4KB,除非我们设置了页大小扩展位(即扩展分页)。这个全局数组所指向的数据地址存储在cr3寄存器中,这是为Linux进程设置分页单元的第一阶段。其余的页项是在第二阶段中完成的。第二阶段由方法调用paging_init()来完成。
在32位的x86架构上,RAM映射到PAGE_OFFSET和由4GB上限 (0xFFFFFFFF) 表示的地址之间。这意味着大约有1 GB的RAM可以在Linux启动时进行映射,这种操作是默认进行的。然而,如果有人设置了HIGHMEM_CONFIG,那么就可以将超过1 GB的内存映射到内核上,切记这是一种临时的安排。可以通过调用kmap()实现。
上面已经展示了(32位架构上的) Linux内核按照3:1的比率来划分虚拟内存:3 GB的虚拟内存用于用户空间,1 GB的内存用于内核空间。内核代码及其数据结构都必须位于这1 GB的地址空间中,但是对于此地址空间而言,更大的消费者是物理地址的虚拟映射。之所以出现这种问题,是因为若一段内存没有映射到自己的地址空间中,那么内核就不能操作这段内存。因此,内核可以处理的最大内存总量就是可以映射到内核的虚拟地址空间减去需要映射到内核代码本身上的空间。结果,一个基于x86的Linux系统最大可以使用略低于1 GB的物理内存。
为了迎合大量用户的需要,支持更多内存、提高性能,并建立一种独立于架构的内存描述方法,Linux内存模型就必须进行改进。为了实现这些目标,新模型将内存划分成分配给每个CPU的空间。每个空间都称为一个节点;每个节点都被划分成一些区域。区域(表示内存中的范围)可以进一步划分为以下类型:
ZONE_DMA(0-16 MB):包含ISA/PCI设备需要的低端物理内存区域中的内存范围。
ZONE_NORMAL:用户空间可用的正常内存。
ZONE_HIGHMEM:由内核直接映射到高端范围的物理内存。只能由内核访问,用户空间访问不到。所有的内核操作都只能使用这个内存区域来进行,因此这是对性能至关重要的区域。
节点的概念在内核中是使用struct pglist_data结构来实现的。区域是使用struct zone_struct结构来描述的。物理页框是使用struct page结构来表示的,所有这些struct都保存在全局结构数组struct mem_map 中,这个数组存储在 ZONE_NORMAL的开头。节点、区域和页框之间的基本关系如图9所示。
图9
当实现了对Pentium II的虚拟内存扩展的支持(在32位系统上使用PAE——Physical Address Extension——可以访问64 GB的内存)和对4 GB的物理内存(同样是在32位系统上)的支持时,高端内存区域就会出现在内核内存管理中了。这是在x86和SPARC平台上引用的一个概念。通常这4 GB的内存可以通过使用kmap()将ZONE_HIGHMEM映射到ZONE_NORMAL来进行访问。请注意在32位的架构上使用超过16 GB的内存是不明智的,即使启用了PAE也是如此。PAE是Intel提供的内存地址扩展机制,它通过在宿主操作系统中使用Address Windowing Extensions API为应用程序提供支持,从而让处理器将可以用来寻址物理内存的位数从32位扩展为36位。
这个物理内存区域的管理是通过一个 区域分配器(zone allocator)实现的。它负责将内存划分为很多区域;它可以将每个区域作为一个分配单元使用。每个特定的分配请求都利用了一组区域,内核可以从这些位置按照从高到低的顺序来进行分配。例如:
* 对于某个用户页面的请求可以首先从“普通”区域中来满足(ZONE_NORMAL);
* 如果失败,就从ZONE_HIGHMEM开始尝试;
* 如果这也失败了,就从ZONE_DMA开始尝试。
这种分配的区域列表依次包括ZONE_NORMAL、ZONE_HIGHMEM和ZONE_DMA区域。另一方面,对于DMA页的请求可能只能从DMA区域中得到满足,因此这种请求的区域列表就只包含DMA区域。