i386 CPU中的页式存储管理的基本思路是:通过页面目录和页面表分两个层次实现从线性地址到物理地址的映射。这种映射模式在大多数情况下可以节省页面表所占用的空间。因为大多是进程不会用到整个虚存空间,在虚存空间中通常都留有很大的空洞。采用两层的方式,只要一个目录项所对应的那部分空间是个空洞,就可以把该目录项设置成空,从而省下了与之对应的页面表(1024个页面描述项)。当地址的宽度为32位时,两层映射机制比较有效也比较合理。但是,当地址的宽度大于32位时,两层映射显得不尽合理,不够有效了。
linux内核的设计要考虑到各种不同CPU上的实现,还要考虑到64位上的实现,所以不能仅仅针对i386结构来设计它的映射机制,而要以一种假象的、虚拟的CPU和mmu(内存管理单元)为基础,设计出一种通用的模型,再把它分别落实到各种具体的CPU上。因此,linux内核的映射机制设计成三层,在页面目录和页面表中间增设了一层中间目录。在代码中,页面目录称为PGD,中间目录称为PMD,而页面表则称为PT。PT中的表项则称为PTE,PTE是 page table entry 的缩写。PGD、PMD和PT三者均为数组。相应的,在逻辑上也把线性地址从高位到低位划分成4个位段,各占若干位,分别用作在目录PGD中的下标、中间目录PMD中的下标、页面表中的下标以及物理页面内的位移。这样,对线性地址的映射就分成如图所示的4步:
具体一点说,对于CPU发出的线性地址,虚拟的linux内存管理单元如下四步完成从线性地址到物理地址的映射:
- 用线性地址中最高的那一个位段作为下标在PGD中找到相应的表项,该表项指向相应的中间目录PMD。
- 用线性地址中的第二个位段作为下标在此PMD中找到相应的表项,该表项指向相应页面表。
- 用线性地址中的第三个位段作为下标在页面表中找到相应的表项PTE,该表项中存放的就是指向物理页面的指针。
- 线性地址中最后位段为物理页面内的相对位移量,将此位移量与目标物理页面的起始地址相加便得到相应的物理地址。
但是,这个虚拟的映射模型必须落实到具体CPU和MMU的物理映射机制。就以i386来说,CPU实际上不是按三层而是按两层的模型进行地址映射的。这就需要将虚拟的三层映射落实到具体的两层映射,跳过中间的PMD层次。另一方面,从Pentium pro开始,Intel引入了物理地址扩充功能PAE,允许将地址宽度从32位提高到36位,并且在硬件上支持三层映射模型。这样,在Pentium pro以及以后的CPU上,只要将CPU的内存管理设置成PAE模式,就能使虚存的映射变成三层模式。
那么,具体对于i386结构的CPU,linux内核是怎样实现这种映射机制的呢?首先让我们来看include/asm-i386/pgtable.h中的一段定义:
#if CONFIG_X86_PAE
# include <asm/pgtable-3level.h>
#else
# include <asm/pgtable-2level.h>
#endif
根据在编译linux内核之前的系统配置(config)过程中的选择,编译的时候会把目录include/asm符号链接到具体CPU专用的文件目录。对于i386 CPU,该目录被符号链接到include/asm-i386。同时,在配置系统时还有一个选择项是关于PAE的,如果所用的CPU是Pentium pro或以上时,并且决定采用36位地址,则在编译时选择项CONFIG_X86_PAE为1,否则为0。根据此项选择,编译时从pgtable-3level.h或pgtable-2level.h中二选一,前者用于36位地址的三层映射,而后者则用于32位地址的二层映射。这里,我们将集中讨论32位地址的二层映射。在弄清了32位地址的二层映射以后,读者可以自行阅读有关36位地址的三层映射的代码。
文件pgtable-2level.h中定义了二层映射中PGD和PMD的基本结构:
/*
* traditional i386 two-level paging structure:
*/
#define PGDIR_SHIFT 22
#define PTRS_PER_PGD 1024
/*
* the i386 is two-level, so we don't really have any
* PMD directory physically.
*/
#define PMD_SHIFT 22
#define PTRS_PER_PMD 1
#define PTRS_PER_PTE 1024
这里PGDIR_SHIFT表示线性地址中PGD下标位段的起始位置,文件中将其定义为22,也即bit22(第23位)。由于PGD是线性地址中最高的位段,所以该位段是从第23位到第32位,一共是10位。在pgtable.h中定义了另一个常数PGDIR_SIZE
#define PGDIR_SIZE (1UL << PGDIR_SHIFT)
也就是说,PGD中每一个表项所代表的空间(并不是PGD本身所占的空间)大小是2^22。同时,pgtable-2level.h中定义了PTRS_PER_PGD,也就是每个PGD表种指针的个数位1024。显然,这是与线性地址中PGD位段的长度(10位)相符的,因为2^10=1024。这两个常数值的定义完全是针对i386 CPU及其MMU的,因为非PAE模式的i386 MMU用线性地址中的最高10位作为目录中的下标,而且目录的大小为1024。不过,在32位的系统中每个指针的大小4个字节,所以PGD表的大小为4KB。
对PMD的定义就很有意思了。PMD_SHIFT也定义为22,与PGD_SHIFT相同,表示PMD位段的长度为0,一个PMD表项所代表的空间与PGD表项所代表的空间时一样大的。而PMD表中指针的个数PTRS_PER_PGD则定义为1,表示每个PMD表中只有一个表项。同样,这也是针对i386 CPU及其MMU而定义的,因为要将linux逻辑上的三层映射模型落实到i386结构物理上的二层映射,就要从线性地址逻辑上的4个虚拟位段中把PMD抽出,使它的长度为0,所以逻辑上的PMD表的大小就成为1(2^0=1)。
这样,上述4步映射过程对于内核(软件)和i386 MMU就成为:
- 内核为MMU设置好映射目录PGD,MMU用线性地址中最高的那一个位段(10位)作为下标在PGD中找到相应的表项。该表项逻辑上指向一个中间目录PMD,但是物理上直接指向相应的页面表,MMU并不知道PMD的存在。
- PMD只是逻辑上存在,即对内核软件在概念上存在,但是表中只有一个表项,而所谓的映射就是保持原值不变,现在一转手却指向页面表了。
- 内核为MMU设置好了所有的页面表,MMU用线性地址中的PT位段作为下标在相应页面表中找到相应的表项PTE,该表项中存放的就是指向物理页面的指针。
- 线性地址中的最后位段为物理页面内的相对位移量,MMU将此位移量与目标物理页面的起始地址相加便得到相应的物理地址。
这样,逻辑上的三层映射对于i386 CPU和MMU就变成了二层映射,把中间目录PMD这一层跳过了,但是软件的结构却还保持着三层映射的框架。
具体的映射因空间的性质而异,但是后面我们会看到(除用来模拟80286的vm86模式外),其段式映射基地址总是0,所以线性地址与虚拟地址总是一致的。在以后的讨论中,我们常常对二者不加区分。
32位地址意味着4G字节的虚存空间,linux内核将这4G字节的空间划分成两部分。将最高的1G字节(从虚地址0xC0000000至0xFFFFFFFF),用于内核本身,称为系统空间。而将较低的3G字节(从虚地址0x0至0xBFFFFFFF),用作各个进程的用户空间。这样,理论上每个进程可以使用的用户空间都是3G字节。当然,实际的空间大小受到物理存储器(包括内存以及磁盘交换区或交换文件)大小的限制。虽然各个进程拥有其自己的3G字节的用户空间,系统空间却由所有的进程共享。每当一个进程通过系统调用进入了内核,该进程就在共享的系统空间中运行,不再有其自己的独立空间。从具体进程的角度看,则每个进程都拥有4G字节的虚存空间,较低的3G字节为自己的用户空间,最高的1G字节则为所有进程以及内核共享的系统空间,如图:
虽然系统空间占据了每个虚存空间中最高的1G字节,在物理的内存中却总是从最低的地址(0)开始。所以,对于内核来说,其地址的映射是很简单的线性映射,0xC0000000就是两者之间的位移量。因此,在代码中将此位移称为PAGE_OFFSET而定义于文件page.h中:
/*
* This handles the memory map.. We could make this a config
* option, but too many people screw it up, and too few need
* it.
*
* A __PAGE_OFFSET of 0xC0000000 means that the kernel has
* a virtual address space of one gigabyte, which limits the
* amount of physical memory you can use to about 950MB.
*
* If you want more physical memory than this then see the CONFIG_HIGHMEM4G
* and CONFIG_HIGHMEM64G options in the kernel configuration.
*/
#define __PAGE_OFFSET (0xC0000000)
.......
#define PAGE_OFFSET ((unsigned long)__PAGE_OFFSET)
#define __pa(x) ((unsigned long)(x)-PAGE_OFFSET)
#define __va(x) ((void *)((unsigned long)(x)+PAGE_OFFSET))
也就是说:对于系统空间而言,给定一个虚地址x,其物理地址是从x中减去PAGE_OFFSET;相应的,给定一个物理地址x,其虚地址就是x+PAGE_OFFSET。
同时,PAGE_OFFSET也代表着用户空间的上限,所以常数TASK_SIZE就是通过它定义的:
/*
* User space process size: 3GB (default).
*/
#define TASK_SIZE (PAGE_OFFSET)
这是因为在讨论一个用户进程的大小时,并不包括此进程在系统空间中共享的资源。
当然,CPU并不是通过这里所说的计算方法进行地址映射的,__pa()只是为内核代码中当需要知道与一个虚地址对应的物理地址时提供方便。例如,在切换进程的时候要将寄存器CR3设置成指向新进程的页面目录PGD,而该目录的起始地址在内核代码中是虚地址,但CR3所需要的是物理地址,这时候就要用到__pa()了。这行语句在文件mmu_context.h中:
/* Re-load page tables */
asm volatile("movl %0,%%cr3": :"r" (__pa(next->pgd)));
这是一行汇编代码,说的就是将next->pgd,即下一个进程的页面目录起始地址,通过__pa()转换成物理地址(存放在某个寄存器),然后用mov指令将其写入寄存器CR3。经过这条指令以后,CR3就指向新进程next的页面目录表PGD了。
前面讲过,每个进程的局部段描述表LDT都作为一个独立的段而存在,在全局段描述表GDT中要有一个表项指向这个段的起始地址,并说明该段的长度以及其他一些参数。除此之外,每个进程还有一个TSS结构(任务状态段)也是一样。(关于TSS以后还会加以讨论)所以,每个进程都要在全局段描述表GDT中占据两个表项。那么,GDT的容量有多大呢?段寄存器中用作GDT表下标的位段宽度为13位,所以GDT中可以由8192个描述项。除一些系统的开销(例如GDT中的第二项和第三项分别用于内核的代码段和数据段,第4项和第5项永远用于当前进程的代码段和数据段,第1项永远是0,等等)以外,尚有8180个表项可供使用,所以理论上系统中最大进程数量是4090。