深入理解Linux内核-第3版 第二章 内存寻址 内核2.6.11 强调:本章出现参见其他章节的地方不做深究,留到看到对应章节时深究

本章介绍寻址技术。值得庆幸的是,操作系统自身不必完全了解物理内存;如今的微处理器包含的硬件线路使内存管理既高效又健壮,所以编程错误就不会对该程序之外的内存产生非法访问。

作为本书的一部分,本章将详细描述80x86微处理器怎样进行芯片级的内存寻址,Linux又是如何利用寻址硬件的。我们希望当你学习内存寻址技术在Linux最流行的硬件平台上的详细实现方法时,既能够更好地理解分页单元的一般原理,又能更好地研究内存寻址技术在其他平台,上是如何实现的。

关于内存管理有三章,这是其中的第一章;还有第八章,讨论内核怎样给自己分配主存;以及第九章,考虑怎样给进程分配线性地址。

一、内存地址

程序员偶尔会引用内存地址(memoryaddress)作为访问内存单元内容的一种方式,但是,当使用80x86微处理器时,我们必须区分以下三种不同的地址:

  • 逻辑地址(logical address)
    包含在机器语言指令中用来指定一个操作数或一条指令的地址。这种寻址方式在,80x86著名的分段结构中表现得尤为具体,它促使MS-DOS或Windows程序员把程序分成若千段。每一个逻辑地址都由一个段(segment)和偏移量(offset或displacement)组成,偏移量指明了丛段开始的地方到实际地址之间的距离。

  • 线性地址(linear address )(也称虛拟地址virtual address)
    是一个32位无符号整数,可以用来表示高达4GB的地址,也就是,高达4 294 967296个内存单元。线性地址通常用十六进制数字表示,值的范围从0x0000000到0xffffffff.

  • 物理地址(physical address )
    用于内存芯片级内存单元寻址。它们与从微处理器的地址引脚发送到内存总线上的电信号相对应。物理地址由32位或36位无符号整数表示。

内存控制单元(MMU)通过一种称为分段单元(segmentation unit)的硬件电路把一个逻辑地址转换成线性地址;
接着,第二个称为分页单元(pagingunit)的硬件电路把线性地址转换成一个物理地址(见图2-1)。
在这里插入图片描述

在多处理器系统中,所有CPU都共享同一内存;这意味着RAM芯片可以由独立的CPU并发地访问。因为在RAM芯片上的读或写操作必须串行地执行,因此一种所谓内存仲裁器(memory arbiter)的硬件电路插在总线和每个RAM芯片之间。其作用是如果某个RAM芯片空闲,就准予一个CPU访问,如果该芯片忙于为另一个处理器提出的请求服务,就延迟这个CPU的访问。(应该是为了协同和避免死锁)
即使在单处理器上也使用内存仲裁器,因为单处理器系统中包含一个叫做DMA控制器的特殊处理器,而DMA控制器与CPU并发操作[参见第十三章“直接内存访问(DMA)” 一节”]。在多处理器系统的情况下,因为仲裁器有多个输入端口,所以其结构更加复杂。例如,双Pentium在每个芯片的入口维持一个两端口仲裁器,并在试图使用公用总线前请求两个CPU交换同步信息。从编程观点看,因为仲裁器由硬件电路管理,因此它是隐藏的。

二、硬件中的分段

从80286 模型开始,Intel 微处理器以两种不同的方式执行地址转换,这两种方式分别称为实模式(real mode)和保护模式(protected mode)。我们将从下一节开始描述保护模式下的地址转换。实模式存在的主要原因是要维持处理器与早期模型兼容,并让操作系统自举(参阅附录一中针对实模式的简短描述)。

1、段选择符和段寄存器

一个逻辑地址由两部分组成∶一个段标识符和一个指定段内相对地址的移量。段标识符是一个16位长的字段,称为段选择符(Segment Selector)如图2-2所示,而偏移量是一个32位长的字段。我们将在本章"快速访问段描述符"一节描述段选择符字段。
在这里插入图片描述
为了快速方便地找到段选择符,处理器提供段寄存器,段寄存器的唯一目的是存放段选择符。这些段寄存器称为cs,ss,ds,es,fs和gs。尽管只有6个段寄存器,但程序可以把同一个段寄存器用于不同的目的,方法是先将其值保存在内存中,用完后再恢复。

6个寄存器中3个有专门的用途∶

  • cs 代码段寄存器,指向包含程序指令的段。
  • ss 栈段寄存器,指向包含当前程序栈的段。
  • ds 数据段寄存器,指向包含静态数据或者全局数据段。

其他3个段寄存器作一般用途,可以指向任意的数据段。

cs寄存器还有一个很重要的功能;它含有一个两位的字段,用以指明CPU的当前特权级(Current Privilege Level,CPL)。值为0代表最高优先级,而值为3代表最低优先级。Linux 只用0级和3级,分别称之为内核态和用户态。

2、段描述符

每个段由一个8字节的段描述符(Segment Descriptor)表示,它描述了段的特征。段描述符放在全局描述符表(Global DescriptorTable,GDT)或局部描述符表(LocalDescriptor Table,LDT)中。

通常只定义一个GDT,而每个进程除了存放在GDT中的段之外如果还需要创建附加的段,就可以有自己的LDT。GDT在主存中的地址和大小存放在gdtr控制寄存器中,当前正被使用的LDT地址和大小放在1dtr控制寄有器中。图2-3阐明了段描述符的格式;表2-1解释了图中各个字段的含义
在这里插入图片描述
在这里插入图片描述

有几种不同类型的段以及和它们对应的的段描述符。下面列出了Linux中被广泛采用的类型∶

  • 代码段描述符
    表示这个段描述符代表一个代码段,它可以放在GDT或LDT中。该描述符置S标志为1(非系统段)。
  • 数据段描述符
    表示这个段描述符代表一个数据段,它可以放在GDT或LDT中。该描述符置S标志为1。栈段是通过一般的数据段实现的。
  • 任务状态段描述符(TSSD)
    表示这个段描述符代表一个任务状态段(Task State Segment,TSS),也就是说这个段用于保存处理器寄存器的内容(参见第三章中的"任务状态段"一节)。它只能出现在GDT中。根据相应的进程是否正在CPU上运行,其Type字段的值分别为11或9。这个描述符的 S标志置为0。
  • 局部描述符表描述符(LDTD)
    表示这个段描述符代表一个包含LDT的段,它只出现在GDT中。相应的Type字段的值为2,S标志置为0。下一节说明80x86处理器如何决定一个段描述符是存放在GDT中还是存放在进程的LDT中。

3、快速访问段描述符

我们回忆一下∶逻辑地址由16位段选择符和32位偏移量组成,段寄存器仅仅存放段选择符。

为了加速逻辑地址到线性地址的转换,80x86处理器提供一种附加的非编程的寄存器(一个不能被程序员所设置的寄存器),供6个可编程的段寄存器使用。每一个非编程的寄存器含有8个字节的段描述符(在前一节已讲述),由相应的段寄存器中的段选择符来指定。每当一个段选择符被装入段寄存器时,相应的段描述符就由内存装入到对应的非编程CPU寄存器。从那时起,针对那个段的逻辑地址转换就可以不访问主存中的GDT或LDT,处理器只需直接引用存放段描述符的CPU寄存器即可。仅当段寄存器的内容改变时,才有必要访问GDT或LDT(参见图2-4)。
在这里插入图片描述
表2-2描述了任意段选择符所包含的3个字段。

在这里插入图片描述
由于一个段描述符是8字节长,因此它在GDT或LDT内的相对地址是由段选择符的最高13位的值乘以8得到的。
例如∶如果GDT在0x00020000(这个值保存在gdtr寄存器中),且由段选择符所指定的索引号为2,
那么相应的段描述符地址是0x00020000 +(2 × 8),或 0x00020010。

GDT的第一项总是设为0。这就确保空段选择符的逻辑地址会被认为是无效的,因此引起一个处理器异常。能够保存在GDT中的段描述符的最大数目是8191,即213-1。

分段单元
图2-5详细显示了一个逻辑地址是怎样转换成相应的线性地址的。分段单元(segmentationunit)执行以下操作∶

  • 先检查段选择符的TI字段,以决定段描述符保存在哪一个描述符表中。T工字段指明描述符是在GDT中(在这种情况下,分段单元从gdtr寄存器中得到GDT的线性基地址)还是在激活的LDT中(在这种情况下,分段单元从1dtr寄存器中得到LDT的线性基地址)

  • 从段选择符的index字段计算段描述符的地址,index字段的值乘以8(一个段描述符的大小),这个结果与gdtr或1dtr寄存器中的内容相加。

  • 把逻辑地址的偏移量与段描述符 Base 字段的值相加就得到了线性地址。

在这里插入图片描述
请注意,有了与段寄存器相关的不可编程寄存器,只有当段寄存器的内容被改变时才需要执行前两个操作。
在这里插入图片描述

三、Linux中的分段

从前面的段描述符的各字段含义,可以找到段描述符用来,记录某段的大小,用途,访问权限,此段在内存还是磁盘

80x86微处理器中的分段鼓励程序员把他们的程序化分成逻辑上相关的实体,例如子程序或者全局与局部数据区。然而,Linux 以非常有限的方式使用分段。实际上,分段和分页在某种程度上有点多余,因为它们都可以划分进程的物理地址空间;分段可以给每一个进程分配不同的线性地址空间,而分页可以把同一线性地址空间映射到不同的物理空间。与分段相比,Linux 更喜欢使用分页方式,因为∶

  • 当所有进程使用相同的段寄存器值时,内存管理变得更简单,也就是说它们能共享同样的一组线性地址。
  • Linux设计目标之一是可以把它移植到绝大多数流行的处理器平台上。然而,RISC体系结构对分段的支持很有限。

2.6版的Linux 只有在80x86结构下才需要使用分段。

运行在用户态的所有Linux进程都使用一对相同的段来对指令和数据寻址。这两个段就是所谓的用户代码段和用户数据段。类似地,运行在内核态的所有Linux进程都使用一对相同的段对指令和数据寻址∶它们分别叫做内核代码段和内核数据段。表2-3显示了这四个重要段的段描述符字段的值。
在这里插入图片描述
相应的段选择符由宏__USER_CS,__USER_DS,__KERNEL_CS,和__KERNEL_DS分别定义。例如,为了对内核代码段寻址,内核只需要把___KERNEL__CS宏产生的值装进cs段寄存器即可。

注意,与段相关的线性地址从0开始,达到232-1的寻址限长。
这就意味着在用户态或内核态下的所有进程可以使用相同的逻辑地址。
所有段都从 0x00000000开始,这可以得出另一个重要结论,那就是在Linux下逻辑地址与线性地址是一致的,即逻辑地址的偏移量字段的值与相应的线性地址的值总是一致的。

如前所述,CPU的当前特权级(CPL)反映了进程是在用户态还是内核态,并由存放在cs寄存器中的段选择符的RPL字段指定。只要当前特权级被改变,一些段寄存器必须相应地更新。例如,当CPL=3时(用户态),ds寄存器必须含有用户数据段的段选择符,而当 CPL=0时,ds 寄存器必须含有内核数据段的段选择符。

类似的情况也出现在ss 寄存器中。当CPL为3时,它必须指向一个用户数据段中的用户栈,而当CPL为0时,它必须指向内核数据段中的一个内核栈。当从用户态切换到内核态时,Linux 总是确保 ss寄存器装有内核数据段的段选择符。

当对指向指令或者数据结构的指针进行保存时,内核根本不需要为其设置逻辑地址的段选择符,因为cs寄存器就含有当前的段选择符。例如,当内核调用一个函数时,它执行一条cal1汇编语言指令,该指令仅指定其逻辑地址的偏移量部分,而段选择符不用设置,它已经隐含在cs寄存器中了。因为"在内核态执行"的段只有一种,叫做代码段,由宏___KERNEL_CS定义,所以只要当CPU切换到内核态时将___KERNEL_CS装载进cs 就足够了。同样的道理也适用于指向内核数据结构的指针(隐含地使用ds寄存器)以及指向用户数据结构的指针(内核显式地使用es 寄存器)。

除了刚才描述的4个段以外,Linux还使用了其他几个专门的段。我们将在下一节讲述Linux GDT的时候介绍它们。

1、Linux存放段描述符的GDT

看下图感觉:全局描述符表和局部描述符表都存在全局地址空间,即描述符在全局地址空间,全局描述符表描述全局,局部描述符表描述局部。
在这里插入图片描述

在单处理器系统中只有一个GDT,而在多处理器系统中每个CPU对应一个GDT。所有的GDT都存放在cpu_gdt_table数组中,而所有GDT的地址和它们的大小(当初始化gdtr寄存器时使用)被存放在cpu_gdt_descr数组中。

/* 在汇编和C混合编程中,在GNU ARM编译环境下,汇编程序中要使用.global伪操作声明汇编程序为全局的函数,
* 即可被外部函数调用,同时C程序中要使用extern声明要调用的汇编语言程序。
* C:\Users\李杰\Desktop\linux-2.6.11.1\linux-2.6.11.1\arch\i386\kernel\head.S
*/
.globl cpu_gdt_descr

/* C:\Users\李杰\Desktop\linux-2.6.11.1\linux-2.6.11.1\arch\x86_64\kernel\head.S*/
.globl cpu_gdt_descr

/* C:\Users\李杰\Desktop\linux-2.6.11.1\linux-2.6.11.1\arch\x86_64\kernel\setup64.c
* struct desc_ptr {
* 	unsigned short size;
* 	unsigned long address;
*   } __attribute__((packed)) ;
*/
extern struct desc_ptr cpu_gdt_descr[];

/* C:\Users\李杰\Desktop\linux-2.6.11.1\linux-2.6.11.1\include\asm-i386\desc.h*/
extern struct Xgt_desc_struct idt_descr, cpu_gdt_descr[NR_CPUS];

如果你到源代码索引中查看,可以看到这些符号都在文件arch/i386/kernelhead.S中被定义。
本书中的每一个宏、函数和其他符号都列有源代码索引,所以能在源代码中很方便地找到它们。

图2-6是GDT的布局示意图。每个GDT包含18个段描述符和14个空的,未使用的,或保留的项。插入未使用的项的目的是为了使经常一起访问的描述符能够处于同一个32字节的硬件高速缓存行中(参见本章后面"硬件高速缓存"一节)。

每一个GDT中包含的18个段描述符指向下列的段∶

  • 用户态和内核态下的代码段和数据段共 4个(参见前面一节)。

  • 任务状态段(TSS),每个处理器有1个。每个TSS相应的线性地址空间都是内核数据段相应线性地址空间的一个小子集。所有的任务状态段都顺序地存放在init_tss数组中;值得特别说明的是,第n个CPU的TSS描述符的Base字段指向ini_tss数组的第n个元素。G(粒度)标志被清0,而Limit字段置为0xeb,因为TSS段是236字节长。Type字段置为9或11(可用的32位TSS),且DPL置为0,因为不允许用户态下的进程访问TSS段。在第三章"任务状态段"一节你可以找到Linux是如何使用TSS的细节。
    在这里插入图片描述

  • 1个包括缺省(缺省,即系统默认状态)局部描述符表的段,这个段通常是被所有进程共享的段(参见下一节)。

  • 3个局部线程存储(Thread-Local Storage,TLS)段;这种机制允许多线程应用程序使用最多3个局部于线程的数据段。系统调用set_thread_area()和get_thread_area()分别为正在执行的进程创建和撤消一个TLS段。

  • 高级电源管理(AMP)相关的3个段;由于BIOS代码使用段,所以Linux APM驱动程序调用BIOS函数来获取或者设置APM设备的状态时,就可以使用自定义的代码段和数据段。

  • 与支持即插即用(PnP)功能的BIOS服务程序相关的5个段∶在前一种情况下,就像前述与AMP相关的3个段的情况一样,由于BIOS例程使用段,所以当Linux的PnP设备驱动程序调用BIOS函数来检测PnP设备使用的资源时,就可以使用自定义的代码段和数据段。

  • 被内核用来处理"双重错误"(译注1)异常的特殊TSS段(参见第四章的"异常"一节)
    译注1∶ 处理一个异常时可能会引发另一个异常,在这种情况下产生双重错误。

如前所述,系统中每个处理器都有一个GDT副本。除少数几种情况以外,所有GDT的副本都存放相同的表项。
首先,每个处理器都有它自己的TSS段,因此其对应的 GDT项不同。
其次,GDT中只有少数项可能依赖于CPU正在执行的进程(LDT和TLS段描述符)。
最后,在某些情况下,处理器可能临时修改GDT副本里的某个项;例如,当调用APM的BIOS例程时就会发生这种情况。

2、Linux存放段描述符的LDT

/* C:\Users\李杰\Desktop\linux-2.6.11.1\linux-2.6.11.1\include\asm-x86_64\desc.h 
* 这是每个进程都会得到的ldt,除非我们需要其他东西。                               
* struct desc_struct {
* 	unsigned long a,b;
* };
*/
extern struct desc_struct default_ldt[];

/*C:\Users\李杰\Desktop\linux-2.6.11.1\linux-2.6.11.1\arch\i386\kernel\traps.c*/
struct desc_struct default_ldt[] = { { 0, 0 }, { 0, 0 }, { 0, 0 },
		{ 0, 0 }, { 0, 0 } };

大多数用户态下的Linux程序不使用局部描述符表,这样内核就定义了一个缺省的LDT供大多数进程共享。缺省的局部描述符表存放在default_ldt数组中。它包含5个项,但内核仅仅有效地使用了其中的两个项∶用于iBCS执行文件的调用门和Solaris/x86可执行文件的调用门(参见第二十章的"执行域"一节)。调用门是80x86微处理器提供的一种机制,用于在调用预定义函数时改变CPU的特权级,由于我们不会再更深入地讨论它们,所以请参考Intel文档以获取更多详情。

在某些情况下,进程仍然需要创建自己的局部描述符表。这对有些应用程序很有用,像Wine那样的程序,它们执行面向段的微软Windows应用程序。modify_1ldt()系统调用允许进程创建自己的局部描述符表。

任何被modify_1dt()创建的自定义局部描述符表仍然需要它自己的段。当处理器开始执行拥有自定义局部描述符表的进程时,该CPU的GDT副本中的LDT表项相应地就被修改了。

用户态下的程序同样也利用modify_1dt()来分配新的段,但内核却从不使用这些段,它也不需要了解相应的段描述符,因为这些段描述符被包含在进程自定义的局部描述符表中了。

四、硬件中的分页

分页单元(paging unit)把线性地址转换成物理地址。其中的一个关键任务是把所请求的访问类型与线性地址的访问权限相比较,如果这次内存访问是无效的,就产生一个缺页异常(参见第四章和第八章)。

为了效率起见,线性地址被分成以固定长度为单位的组,称为页(page)。页内部连续的线性地址被映射到连续的物理地址中。这样,内核可以指定一个页的物理地址和其存取权限,而不用指定页所包含的全部线性地址的存取权限。我们遵循通常习惯,使用术语"页"既指一组线性地址,又指包含在这组地址中的数据。

下两图是《linux0.11完全注释》的图,我个人看图后认为:
进程A的地址空间是虚拟地址空间,进程B的地址空间也是虚拟地址空间。
页表映射一段物理地址

在这里插入图片描述
Linux0.11中:实模式(real mode)+ 分段机制 => 保护模式(protected mode)。
从分段机制的GDT、LDT表,得到进程虚拟地址的起点线性地址,进程地址空间固定64MB

在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

关于四个图的上两图的更多详情有另一篇我有文章

分页单元把所有的RAM分成固定长度的页框(page frame)(有时叫做物理页)。每一个页框包含一个页(page),也就是说一个页框的长度与一个页的长度一致。页框是主存的一部分,因此也是一个存储区域。区分一页和一个页框是很重要的,前者只是一个数据块,可以存放在任何页框或磁盘中。

把线性地址映射到物理地址的数据结构称为页表(page table)。页表存放在主存中,并在启用分页单元之前必须由内核对页表进行适当的初始化。

从80386开始,所有的80x86处理器都支持分页,它通过设置cr0寄存器的 PG标志启用。当 PG=0时,线性地址就被解释成物理地址。

1、常规分页

从80386起,Intel处理器的分页单元处理4KB的页。
32位的线性地址被分成3个域∶

  • Directory(目录)
    最高10位
  • Table(页表)
    中间10位
  • Offset(偏移量)
    最低12位

线性地址的转换分两步完成,每一步都基于一种转换表,第一种转换表称为页目录表(page directory),第二种转换表称为页表(page table)(注1)。
注1∶ 在接下来的讨论中,小写的"page table"表示保存线性地址和物理地址之间映射的页,而利用"Page Table"表示在上层页表中的页。

使用这种二级模式的目的在于减少每个进程页表所需RAM的数量。如果使用简单的一级页表,那将需要高达220个表项(也就是,在每项4个字节时,需要4MB RAM)来表示每个进程的页表(如果进程使用全部4GB线性地址空间),即使一个进程并不使用那个范围内的所有地址。二级模式通过只为进程实际使用的那些虚拟内存区请求页表来减少内存容量。

每个活动进程必须有一个分配给它的页目录。不过,没有必要马上为进程的所有页表都分配RAM。只有在进程实际需要一个页表时才给该页表分配RAM会更为有效率。

正在使用的页目录的物理地址存放在控制寄存器cr3中。线性地址内的Directory字段决定页目录中的目录项,而目录项指向适当的页表。地址的Table字段依次又决定页表项,而表项含有页所在页框的物理地址。Offset字段决定页框内的相对位置(见图2-7)。由于它是12位长,故每一页含有4096字节的数据。
在这里插入图片描述
irectory字段和Table字段都是10位长,因此页目录和页表都可以多达1024项。那么一个页目录可以寻址到高达1024× 1024×4096=232个存储单元(以8位二进制作为一个存储单元,也就是一个字节),这和你对32位地址所期望的一样。

页目录项和页表项有同样的结构,每项都包含下面的字段;

  • Present标志(present adj.存在的)
    如果被置为1,所指的页(或页表)就在主存中;如果该标志为0,则这一页不在主存中,此时这个表项剩余的位可由操作系统用于自己的目的。如果执行一个地址转换所需的页表项或页目录项中 Present标志被清0,那么分页单元就把该线性地址存放在控制寄存器cr2中,并产生14号异常:缺页异常。(我们将在第十七章中看到Linux如何使用这个字段。)

  • 包含页框物理地址最高20位的字段
    由于每一个页框有4KB的容量,它的物理地址必须是4096的倍数,因此物理地址的最低12位总是为0。如果这个字段指向一个页目录,相应的页框就含有一个页表;如果它指向一个页表,相应的页框就含有一页数据。

  • Accessed标志(Access n.存取,访问)
    每当分页单元对相应页框进行寻址时就设置这个标志。当选中的页被交换出去时,这一标志就可以由操作系统使用。分页单元从来不重置这个标志,而是必须由操作系统去做。

  • DirtY 标志(dirty adj. 肮脏的)
    只应用于页表项中。每当对一个页框进行写操作时就设置这个标志。与Accessed标志一样,当选中的页被交换出去时,这一标志就可以由操作系统使用。分页单元从来不重置这个标志,而是必须由操作系统去做。

  • Read/Write标志
    含有页或页表的存取权限(Read/Write 或Read)(参阅本章后面"硬件保护方案"一节)。

  • User/Supervisor标志(Supervisor n.管理者)
    含有访问页或页表所需的特权级(参见后面的"硬件保护方案"一节)。

  • PCD 和 PWT标志
    控制硬件高速缓存处理页或页表的方式(参见本章后面"硬件高速缓存"一节)。

  • Page Size 标志
    只应用于页目录项。如果设置为1,则页目录项指的是2MB或4MB的页框(参见下一节)。

  • Global标志(global adj.全局的)
    只应用于页表项。这个标志是在Pentium Pro中引入的,用来防止常用页从TLB(译注2)高速缓存中刷新出去【参阅本章后面"转换后援缓冲器(TLB)"一节】。只有在cr4寄存器的页全局启用(Page Global Enable,PGE)标志置位时这个标志才起作用。

2、扩展分页

从Pentium模型开始,80x86微处理器引入了扩展分页(extended paging),它允许页框大小为4MB而不是4KB(见图2-8)。扩展分页用于把大段连续的线性地址转换成相应的物理地址,在这些情况下,内核可以不用中间页表进行地址转换,从而节省内存并保留TLB项【参阅"转换后援缓冲器(LTB)"一节】。
在这里插入图片描述
正如前面所述,通过设置页目录项的Page Size标志启用扩展分页功能。在这种情况下,分页单元把32位线性地址分成两个字段∶

  • Directory:最高10位
  • offser:其余22位
  • 扩展分页和正常分页的页目录项基本相同,除了∶
    1)Page Size标志必须被设置。
    2)20位物理地址字段只有最高10位是有意义的。这是因为每一个物理地址都是在以4MB为边界的地方开始的,故这个地址的最低 22位为0。

通过设置cr4处理器寄存器的 PSE标志能使扩展分页与常规分页共存。

3、硬件保护方案

分页单元和分段单元的保护方案不同。尽管80x86处理器允许一个段使用4种可能的特权级别,但与页和页表相关的特权级只有两个,因为特权由前面"常规分页"一节中所提到的User/Supervisor标志所控制。若这个标志为0,只有当CPL小于3(这意味着对于Linux而言,处理器处于内核态)时才能对页寻址;若该标志为1,则总能对页寻址。

此外,与段的3种存取权限(读,写,执行)不同的是,页的存取权限只有两种(读,写)。如果页目录项或页表项的Read/write标志等于0,说明相应的页表或页是只读的,否则是可读写的(注2)。

4、常规分页举例

这个简单的例子将有助于阐明常规分页是如何工作的。
我们假定内核已给一个正在运行的进程分配的线性地址空间范围是0x20000000到 0x2003ffff(注3)。这个空间正好由64页组成。我们不必关心包含这些页的页框的物理地址,事实上,其中的一些页甚至可能不在主存中。我们只关注页表项中剩余的字段。

让我们从分配给进程的线性地址的最高10位(分页单元解释为Directory字段)开始。这两个地址都以2开头后面跟着0,因此高10位有相同的值,即0x080或十进制的128。因此,这两个地址的Directory字段都指向进程页目录的第129项。相应的目录项中必须包含分配给该进程的页表的物理地址(见图2-9)。如果没有给这个进程分配其它的线性地址,则页目录的其余1023项都填为0。

中间10位的值(即 Table字段的值)范围从0到 0x03f,或十进制的从0到63。因而只有页表的前64个表项是有意义的,其余 960个表项都填0。

假设进程需要读线性地址 0x20021406中的字节。这个地址由分页单元按下面的方法处理∶

  • 1.Directory字段的0x80用于选择页目录的第 0x80目录项,此目录项指向和该进程
    的页相关的页表。
  • 2.Table字段 0x21用于选择页表的第0x21表项,此表项指向包含所需页的页框。
  • 3.最后,Offet字段 0x406用于在目标页框中读偏移量为0x406中的字节。

在这里插入图片描述
如果页表第0x21表项的Present标志为0,则此页就不在主存中;在这种情况下,分页单元在线性地址转换的同时产生一个缺页异常。无论何时,当进程试图访问限定在0x20000000到0x2003ffff范围之外的线性地址时,都将产生一个缺页异常,因为这些页表项都填充了0,尤其是它们的 Present标志都被清0。

5、物理地址扩展(PAE)分页机制

处理器所支持的RAM容量受连接到地址总线上的地址管脚数限制。
早期Intel处理器从80386到Pentium使用32位物理地址。从理论上讲,这样的系统上可以安装高达4GB的RAM;而实际上,由于用户进程线性地址空间的需要,内核不能直接对1GB以上的RAM进行寻址,我们将会在后面"Linux中的分页"一节中看到这一点。

然而,大型服务器需要大于4GB的RAM来同时运行数以千计的进程,近几年这对Intel造成了压力,所以必须扩展32位80x86结构所支持的RAM容量。

Intel通过在它的处理器上把管脚数从32增加到36已经满足了这些需求。从Pentium Pro开始,Intel所有处理器现在寻址能力达236=64GB。不过,只有引入一种新的分页机制把32位线性地址转换为36位物理地址才能使用所增加的物理地址。

从Pentium Pro 处理器开始,
Intel引入一种叫做物理地址扩展(Physical AddressExtension,PAE)的机制。
另外一种叫做页大小扩展【Page Size Extension (PSE-36)】的机制在Pentium I处理器中引入,但是Linux并没有采用这种机制,因而我们在本书中不做进一步讨论。

通过设置cr4控制寄存器中的物理地址扩展标志激活物理地址扩展。页目录项中的页大小标志PS 启用大尺寸页(在PAE启用时为2MB)。

intel为了支持PAE已经改变了分页机制。

  • 64GB(236Btye)的RAM被分为224个页框,页表项的物理地址字段从20位扩展到了24位。因为PAE页表项必须包含12个标志位(在前面"常规分页"一节已描述)和24个物理地址位,总数之和为36,页表项大小从32位变为64位增加了一倍。(64为应该只用了12个标志位+24个物理地址位)结果,一个4KB的页表包含512个表项而不是1024个表项。

  • 引入一个叫做页目录指针表(Page Directory Pointer Table,PDPT)的页表新级别,它由4个64位表项组成。

  • cr3控制寄存器包含一个27位的页目录指针表(PDPT)基地址字段。因为PDPT存放在RAM的前4GB中,并在32字节(25)的倍数上对齐,因此27位足以表示页目录指针表的基地址。

  • 当把线性地址映射到4KB的页时(页目录项中的PS标志清0),32位线性地址按下列方式解释∶
    cr3:指向一个PDPT
    位31-30:指向PDPT中4个项中的一个
    位29-27:指向页目录中512个项中的一个
    位20-12:指向页表中512项中的一个
    位11-0:4KB页中的偏移量

下图绿色寄存器,黑色内存,自己画的:
在这里插入图片描述

  • 当把线性地址映射到2MB(221Byte)的页时(页目录项中的PS标志置为1),32位线性地址
    按下列方式解释∶
    cr3:指向一个PDPT
    位31-30:指向PDPT中4个项中的一个
    位29-21:指向页目录中512个项中的一个
    位20-0:2MB页中的偏移量

总之,一旦cr3被设置,就可能寻址高达4GB RAM。如果我们希望对更多的RAM寻址,就必须在cr3中放置一个新值,或改变PDPT的内容。然而,使用PAE的主要问题是线性地址仍然是32位长。这就迫使内核编程人员用同一线性地址映射不同的RAM区。在后面的"当RAM大于4096MB时的最终内核页表"一节中,我们将描述启用PAE时Linux如何初始化页表。很明显,PAE并没有扩大进程的线性地址空间,因为它只处理物理地址。此外,只有内核能够修改进程的页表,所以在用户态下运行的进程不能使用大于4GB的物理地址空间。另一方面,PAE允许内核使用容量高达64GB的RAM,从而显著增加了系统中的进程数量。

6、64位系统中的分页

我们在前面几节已经看到,32位微处理器普遍采用两级分页(注4)。然而两级分页并不适用于采用64 位系统的计算机。让我们用一种思维实验来解释为什么;

只分两级页目录和页表的项数太大,项数最多可达226
首先假设一个大小为4KB的标准页。因为1KB覆盖21个地址的范围,4KB覆盖212个地址,所以offset字段是12位。这样线性地址就剩下52位分配给Table和Directory字段。如果我们现在决定仅仅使用64位中的48位来寻址(这个限制仍然使我们自在地拥有256TB的寻址空间!),剩下的48-12=36位将被分配给Table和Directory字段。如果我们现在决定为两个字段各预留18位,那么每个进程的页目录和页表都含有218个项,即超过256000个项。

由于这个原因,所有64位处理器的硬件分页系统都使用了额外的分页级别。使用的级别数量取决于处理器的类型。表2-4总结了一些Linux所支持64位平台使用的硬件分页系统的主要特征。对于与平台名称相关的硬件的简要描述请参见第一章的"硬件的依赖性"一节。
在这里插入图片描述
在这里插入图片描述
稍后我们将会在本章的"Linux中的分页"一节看到,Linux成功地提供了一种通用分页模型,它适合于绝大多数所支持的硬件分页系统。

7、硬件高速缓存

当今的微处理器时钟频率接近几个 GHz,而动态RAM(DRAM)芯片的存取时间是时钟周期的数百倍。这意味着,当从RAM中取操作数或向RAM中存放结果这样的指令执行时,CPU可能等待很长时间。

为了缩小CPU和RAM之间的速度不匹配,引入了硬件高速缓存内存(hardware cachememory)。硬件高速缓存基于著名的局部性原理(localiry principle),该原理既适用程序结构和也适用数据结构。这表明由于程序的循环结构及相关数组可以组织成线性数组,最近最常用的相邻地址在最近的将来又被用到的可能性极大。因此,引入小而快的内存来存放最近最常使用的代码和数据变得很有意义。为此,80x86体系结构中引人了一个叫行(line)的新单位。行由几十个连续的字节组成,它们以脉冲突发模式(burst mode)在慢速DRAM和快速的用来实现高速缓存的片上静态RAM(SRAM)之间传送,用来实现高速缓存。

高速缓存再被细分为行的子集。在一种极端的情况下,高速缓存可以是直接映射的(direct mapped),这时主存中的一个行总是存放在高速缓存中完全相同的位置。在另一种极端情况下,高速缓存是充分关联的(fullyassociative),这意味着主存中的任意一个行可以存放在高速缓存中的任意位置。但是大多数高速缓存在某种程度上是N-路组关联的(N-way set associative),意味着主存中的任意一个行可以存放在高速缓存N行中的任意一行中。例如,内存中的一个行可以存放到一个2路组关联高速缓存两个不同的行中。

如图2-10所示,高速缓存单元插在分页单元和主内存之间。它包含一个硬件高速缓存内存(hardware cache memory)和一个高速缓存控制器(cache controller)。高速缓存内存 存放 内存中真正的行。(个人同理:高速缓存磁盘 存放 磁盘中真正的块。)高速缓存控制器存放一个表项数组,每个表项对应高速缓存内存中的一个行。每个表项有一个标签(rag)和描述高速缓存行状态的几个标志(flag)。这个标签由一些位组成,这些位让高速缓存控制器能够辨别由这个行当前所映射的内存单元。这种内存物理地址通常分为3组∶最高几位对应标签,中间几位对应高速缓存控制器的子集索引,最低几位对应行内的偏移量
在这里插入图片描述
当访问一个RAM存储单元时,CPU从物理地址中提取出子集的索引号并把子集中所有行的标签与物理地址的高几位相比较。如果发现某一个行的标签与这个物理地址的高位相同,则CPU命中一个高速缓存(cache hit);否则,高速缓存没有命中(cache miss)。

当命中一个高速缓存时,高速缓存控制器进行不同的操作,具体取决于存取类型。对于读操作,控制器从高速缓存行中选择数据并送到CPU寄存器;不需要访问RAM因而节约了CPU时间,因此,高速缓存系统起到了其应有的作用。对于写操作,控制器可能采用以下两个基本策略之一,分别称之为通写(write-through)和回写(write-back)。在通写中,控制器总是既写RAM也写高速缓存行,为了提高写操作的效率关闭高速缓存。回写方式只更新高速缓存行,不改变RAM的内容,提供了更快的功效。当然,回写结束以后,RAM最终必须被更新。只有当CPU执行一条要求刷新高速缓存表项的指令时,或者当一个FLUSH硬件信号产生时(通常在高速缓存不命中之后),高速缓存控制器才把高速缓存行写回到RAM中。

当高速缓存没有命中时、高速缓存行被写回到内存中,如果有必要的话,把正确的行从RAM中取出放到高速缓存的表项中。

多处理器系统的每一个处理器都有一个单独的硬件高速缓存,因此它们需要额外的硬件电路用于保持高速缓存内容的同步如图2-11所示,每个CPU都有自己的本地硬件高速缓存。但是,现在更新变得更耗时;只要一个CPU修改了它的硬件高速缓存,它就必须检查同样的数据是否包含在其他的硬件高速缓存中;如果是,它必须通知其他CPU用适当的值对其更新。常把这种活动叫做高速缓存侦听(cache snooping)。值得庆幸的是,所有这一切都在硬件级处理,内核无需关心。
在这里插入图片描述
高速缓存技术正在快速向前发展。
例如,第一代Pentium芯片包含一颗称为L1-cache的片上高速缓存。近期的芯片又包含另外的容量更大、速度较慢,称之为L2-cache,L3-cache等的片上高速缓存。多级高速缓存之间的一致性是由硬件实现的。Linux忽略这些硬件细节并假定只有一个单独的高速缓存。

处理器的cr0寄存器的CD标志位用来启用或禁用高速缓存电路。这个寄存器中的NW标志指明高速缓存是使用通写还是回写策略。

Pentium处理器高速缓存的另一个有趣的特点是,让操作系统把不同的高速缓存管理策略与每一个页框相关联。为此,每一个页目录项和每一个页表项都包含两个标志:

  • PCD(Page Cache Disablt)标志指明当访问包含在这个页框中的数据时,高速缓存功能必须被启用还是禁用。
  • PWT(page Write-Through)标志指明当把数据写到页框时,必须使用的策略是回写策略还是通写策略。Linux

清除了所有页目录项和页表项中的PCD 和PWT标志;结果是∶对于所有的页框都启用高速缓存,对于写操作总是采用回写策略。

8、转换后援缓冲器(TLB)

除了通用硬件高速缓存之外,80x86处理器还包含了另一个称为转换后援缓冲器或TLB(Translation Lookaside Buffer)的高速缓存用于加快线性地址的转换。当一个线性地址被第一次使用时,通过慢速访问RAM中的页表计算出相应的物理地址。同时,物理地址被存放在一个TLB表项(TLB entry)中,以便以后对同一个线性地址的引用可以快速地得到转换。

在多处理系统中,每个CPU都有自己的TLB,这叫做该CPU的本地TLB。与硬件高速缓存相反,TLB中的对应项不必同步,这是因为运行在现有CPU上的进程可以使同一线性地址与不同的物理地址发生联系。

当CPU的cr3控制寄存器被修改时,硬件自动使本地TLB中的所有项都无效,这是因为新的一组页表被启用而TLB指向的是旧数据。

五、Linux中的分页

Linux采用了一种同时适用于32位和64位系统的普通分页模型。正像前面"64位系统中的分页"一节所解释的那样,两级页表对32位系统来说已经足够了,但64 位系统需要更多数量的分页级别。直到2.6.10版本,Linux 采用三级分页的模型。从2.6.11版本开始,采用了四级分页模型(注5)。图2-12中展示的4种页表分别被为∶

  • 页全局目录(Page Global Directory)
  • 页上级目录(Page Upper Directory)
  • 页中间目录(Page Middle Directory)
  • 页表(Page Table)

页全局目录包含若干页上级目录的地址,页上级目录又依次包含若干页中间目录的地址,而页中间目录又包含若干页表的地址。每一个页表项指向一个页框。线性地址因此被分成五个部分。图2-12没有显示位数,因为每一部分的大小与具体的计算机体系结构有关

对于没有启用物理地址扩展的32位系统,两级页表已经足够了。Linux通过使"页上级目录"位和"页中间目录"位全为0,从根本上取消了页上级目录和页中间目录字段。不过,页上级目录和页中间目录在指针序列中的位置被保留,以便同样的代码在32位系统和64位系统下都能使用。内核为页上级目录和页中间目录保留了一个位置,这是通过把它们的页目录项数设置为1,并把这两个目录项映射到页全局目录的一个适当的目录项而实现的。
在这里插入图片描述
启用了物理地址扩展的32位系统使用了三级页表。Linux的页全局目录对应80x86的页目录指针表(PDPT),取消了页上级目录,页中间目录对应80x86的页目录,Linux的页表对应80x86的页表。

最后,64位系统使用三级还是四级分页取决于硬件对线性地址的位的划分(见表2-4)。

Linux的进程处理很大程度上依赖于分页。事实上,线性地址到物理地址的自动转换使下面的设计目标变得可行∶

  • 给每一个进程分配一块不同的物理地址空间,这确保了可以有效地防止寻址错误。

  • 区别页(即一组数据)和页框(即主存中的物理地址)之不同。这就允许存放在某个页框中的一个页,然后保存到磁盘上,以后重新装入这同一页时又可以被装在不同的页框中。这就是虚拟内存机制的基本要素(参见第十七章)。

在本章剩余的部分,为了具体起见,我们将涉及 80x86处理器使用的分页机制。

我们将在第九章看到,每一个进程有它自已的页全局目录和自己的页表集。当发生进程切换时(参见第三章"进程切换"一节),Linux把cr3 控制寄存器的内容保存在前一个执行进程的描述符中,然后把下一个要执行进程的描述符的值装入cr3寄存器中。因此,当新进程重新开始在CPU上执行时,分页单元指向一组正确的页表。

把线性地址映射到物理地址虽然有点复杂,但现在已经成了一种机械式的任务。本章下面的几节中列举了一些比较单调乏味的函数和宏,它们检索内核为了查找地址和管理表格所需的信息;其中大多数函数只有一两行。也许现在你就想跳过这部分,但是知道这些函数和宏的功能是非常有用的,因为在贯穿本书的讨论中你会经常看到它们。

1、线性地址字段

下列宏简化了页表处理∶

  • 1.PAGE_SHIPT
    指定Offset字段的位数;当用于80x86 处理器时,它产生的值为12。由于页内所有地址都必须能放到Offset字段中,因此80x86系统的页的大小是212=4096字节。PAGE_SHIFT的值为12可以看作以2为底的页大小的对数。这个宏由 PAGE_SIZE使用以返回页的大小。
    最后,PAGE_MASK宏产生的值为 0xfffff000,用以屏蔽Offset 字段的所有位。

  • 2.PMD_SHIFT
    指定线性地址的Offset字段和Table(页表)字段的总位数;
    换句话说,是页中间目录项可以映射的区域大小的对数。计算机存储的都是二进制:映射的区域大小=2N,对数N=log2映射的区域大小
    PMD_SIZE宏用于计算由页中间目录的一个单独表项所映射的区域大小,也就是一个页表的大小。
    PMD_MASK宏用于屏蔽 Offset字段与Table 字段的所有位。
    1 )
    当PAE被禁用时,PMD_SHIFT产生的值为22(来自Offset的12位加上来自Table的10位),
    PMD_SIZE产生的值为2PMD_SHIFTByte=222Byte=4 MB,PMD_MASK产生的值为0xffc00000。
    2 )
    相反,当PAE被激活时,PMD_SHIFT产生的值为21(来自Offset的12位加上来自Table的9位),
    PMD_SIZE产生的值为2PMD_SHIFTByte=221Byte=2 MB,PMD_MASK 产生的值为0xffe00000。
    3 )
    大型页不使用最后一级页表,所以产生大型页尺寸的LARGE_PAGE_SIZE宏等于PMD_SIZE(2PMD_SHIFT),而在大型页地址中用于屏蔽 Offset字段和Table字段的所有位的LARGE_PAGE_MASK 宏,就等于PMD_MASK。

  • 3.PUD_SHIFT
    确定页上级目录项能映射的区域大小的对数。
    PUD_SIZE宏用于计算页全局目录中的一个单独表项所能映射的区域大小。
    PUD_MASK宏用于屏蔽Offset字段、Table字段、Middle Air字段( 书好像错了这里我去掉了Upper Air字段)的所有位。
    在80x86处理器上,PUD_SHIFT总是等价于PMD_SHIFT,而PUD_SIZE则等于4MB 或2MB。

  • 4.PGDIR_SHIFT
    确定页全局目录项能映射的区域大小的对数。
    PGDIR_SIZE宏用于计算页全局目录中一个单独表项所能映射区域的大小。
    PGDIR_MASK宏用于屏蔽Offset字段、Table字段、Middle Air字段和 Upper Air字段的所有位。
    1 )
    当PAE被禁止时,PGDIR_SHIFT产生的值为22(与PMD_SHIFT和PUD_SHIFT产生的值相同),
    PGDIR_SIZE 产生的值为2PGDIR_SHIFTByte=222Byte=4 MB,以及 PGDIR_MASK产生的值为0xffc00000。
    2 )
    相反,当PAE被激活时,PGDIR_SHIFT产生的值为30(12位Offset加9位Table再加9位 Middle Air),PGDIR_SIZE 产生的值为2PGDIR_SHIFTByte=230Byte=1 GB以及 PGDIR_MASK产生的值为0xc0000000。

  • 5.PTRS_PER_PTE,PTRS_PER_PMD,PTRS_PER_PUD以及PTRS_PER_PGD
    用于计算页表、页中间目录、页上级目录和页全局目录表中表项的个数。
    当PAE被禁止时,它们产生的值分别为1024(210),1,1和1024。
    当PAE被激活时,产生的值分别为512(29)、512,1和4。

在这里插入图片描述

2、页表处理

te_t、pmd_t、pud_t和pgd_t分别描述页表项、页中间目录项、页上级目录和页全局目录项的格式。
当PAE被激活时它们都是64位的数据类型,否则都是32位数据类型。
pgprot_t是另一个64位(PAE激活时)或32位(PAE禁用时)的数据类型,它表示与一个单独表项相关的保护标志。

五个类型转换宏(__pte、__pmd、___pud、___pgd和__pgprot)把一个无符号整数转换成所需的类型。
另外的五个类型转换宏(pte_val、pmd_va1、pud_val、pgd_val和pgprot_val)执行相反的转换,即把上面提到的四种特殊的类型转换成一个无符号整数。

内核还提供了许多宏和函数用于读或修改页表表项∶

  • 如果相应的表项值为0,那么,宏pte_none、pmd_none、pud_none和pgd_none产生的值为1,否则产生的值为0。

  • 宏pte_clear、pmd_clear、pud_clear和pgd_clear清除相应页表的一个表项,由此禁止进程使用由该页表项映射的线性地址。ptep_get_and_clear()函数清除一个页表项并返回前一个值。

  • set_pte、set_pmd、set_pud和set_pgd向一个页表项中写入指定的值。
    set_pte_atomic与 set_pte的作用相同,但是当PAE被激活时它同样能保证64位的值被原子地写入。

  • 如果 a 和b 两个页表项指向同一页并且指定相同的访问优先级,那么
    pte_same(a,b)返回1,否则返回0。

  • 如果页中间目录项e指向一个大型页(2MB或4MB),那么pmd_large(e)返回1,否则返回0。

宏pmd_bad由函数使用并通过输入参数传递来检查页中间目录项。如果目录项指向一个不能使用的页表,也就是说,如果至少出现以下条件中的一个,则这个宏产生的值为1;

  • 页不在主存中(Present 标志被清除)。
  • 页只允许读访问(Read/write标志被清除)。
  • Acessed或者Dirty位被清除(对于每个现有的页表,Linux 总是强制设置这些标志)。

pud_bad宏和pgd_bad宏总是产生0。没有定义pte_bad宏,因为页表项引用一个不在主存中的页、一个不可写的页或一个根本无法访问的页都是合法的。

如果一个页表项的 Present标志或者 Page Size标志等于1,则pte_present宏产生的值为1,否则为0。

前面讲过页表项的 Page Size标志对微处理器的分页单元来讲没有意义,然而,对于当前在主存中却又没有读、写或执行权限的页,内核将其Present和 Page Size分别标记为0和1(不在主存+页目录项指的是2MB或4MB的页框)。这样,任何试图对此类页的访问都会引起一个缺页异常,因为页的Present标志被清0,而内核可以通过检查 Page Size的值来检测到产生异常并不是因为缺页。

如果相应表项的Present 标志等于1,也就是说,如果对应的页或页表被载入主存。pmd_present宏产生的值为1。pud_present宏和 pgd_present宏产生的值总是1。

表2-5中列出的函数用来查询页表项中任意一个标志的当前值;除了pte_file()外,其他函数只有在pte_present返回1的时候,才能正常返回页表项中任意一个标志。
在这里插入图片描述
表2-6列出的另一组函数用于设置页表项中各标志的值
在这里插入图片描述
现在,我们来讨论表2-7中列出的宏,它们把一个页地址和一组保护标志组合成页表项,或者执行相反的操作,从一个页表项中提取出页地址。请注意这其中的一些宏对页的引用是通过"页描述符"的线性地址(参见第八章"页描述符"一节),而不是通过该页本身的线性地址。
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
这里罗列最后一组函数来简化页表项的创建和撤消。

当使用两级页表时,创建或删除一个页中间目录项是不重要的。如本节前部分所述,页中间目录仅含有一个指向下属页表的目录项。所以,页中间目录项只是页全局目录中的一项而已。然而当处理页表时,创建一个页表项可能很复杂,因为包含页表项的那个页表可能就不存在。在这样的情况下,有必要分配一个新页框,把它填写为0,并把这个表项入。

如果PAE被激活,内核使用三级页表。当内核创建一个新的页全局目录时,同时也分配四个相应的页中间目录;只有当父页全局目录被释放时,这四个页中间目录才得以释放。当使用两级或三级分页时,页上级目录项总是被映射为页全局目录中的一个单独项。与以往一样,表2-8中列出的函数描述是针对80x86体系结构的。
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

3、物理内存布局

在初始化阶段,内核必须建立一个物理地址映射来指定哪些物理地址范围对内核可用而哪些不可用(或者因为它们映射硬件设备I/O的共享内存,或者因为相应的页框含有BIOS数据)。

内核将下列页框记为保留∶

  • 在不可用的物理地址范围内的页框。
  • 含有内核代码和已初始化的数据结构的页框。

保留页框中的页绝不能被动态分配或交换到磁盘上。

一般来说,Linux内核安装在RAM中从物理地址 0x00100000开始的地方,也就是说,从第二个MB开始。所需页框总数依赖于内核的配置方案∶典型的配置所得到的内核可以被安装在小于3MB的RAM中。

为什么内核没有安装在RAM第一个MB开始的地方?因为PC体系结构有几个独特的地方必须考虑到。例如∶

  • 页框0由BIOS使用,存放加电自检(Power-On Self-Test,POST)期间检查到的系统硬件配置。因此,很多膝上型电脑的BIOS甚至在系统初始化后还将数据写到该页框。

  • 物理地址从0x000a0000到 0x000fffff的范围通常留给BIOS例程,并且映射ISA图形卡上的内部内存。这个区域就是所有IBM兼容PC上从640KB 到1MB之间著名的∶物理地址存在但被保留,对应的页框不能由操作系统使用。

  • 第一个MB内的其他页框可能由特定计算机模型保留。例如,IBM Thinkpnd把0xa0 页框映射到0x9f 页框。

在启动过程的早期阶段(参看附录一),内核询问BIOS并了解物理内存的大小。
在新近的计算机中,内核也调用BIOS过程建立一组物理地址范围和其对应的内存类型。
随后,内核执行machine_specific_memory_setup()函数,该函数建立物理地址映射(见表2-9中的例子)。
当然,如果这张表是可获取的,那是内核在BIOS列表的基础上构建的;
否则,内核按保守的缺省设置构建这张表∶从0x9f(LOWMEMSIZE())到0x100(HIGH_MEMORY)号的所有页框都标记为保留。

在这里插入图片描述在这里插入图片描述
表2-9显示了具有128MB RAM计算机的典型配置。
从0x07ff0000到0x07ff2fff的物理地址范围中存有加电自检(POST)阶段由BIOS写入的系统硬件设备信息;在初始化阶段,内核把这些信息拷贝到一个合适的内核数据结构中,然后认为这些页框是可用的。
相反,从0x07ff3000到0x07ffffff的物理地址范围被映射到硬件设备的ROM芯片
从0xfff0000开始的物理地址范围标记为保留,因为它由硬件映射到BIOS的ROM芯片(参见附录一)。
注意BIOS也许并不提供一些物理地址范围的信息(在上述表中,范围是0x000a0000到0x000effff)。为安全可靠起见,Linux假定这样的范围是不可用的

内核可能不会见到BIOS报告的所有物理内存∶例如,如果未使用PAE支持来编译,即使有更大的物理内存可供使用,内核也只能寻址4GB大小的RAM。setup_memory()函数在machine_specific_memory_setup()执行后被调用∶它分析物理内存区域表并初始化一些变量来描述内核的物理内存布局,这些变量如表2-10所示。
在这里插入图片描述
为了避免把内核装入一组不连续的页框里,Linux更愿跳过RAM的第一个MB。明确地说,Linux 用 PC 体系结构未保留的页框来动态存放所分配的页。
图2-13显示Linux怎样填充前3MB的RAM。我们假设内核需要小于3MB的RAM。
在这里插入图片描述
符号_text对应于物理地址 0x00100000,表示内核代码第一个字节的地址。内核代码的结束位置由另外一个类似的符号_etext表示。内核数据分为两组:初始化过的数据的和没有初始化的数据。初始化过的数据在_etext 后开始,在_edata处结束。紧接着是未初始化的数据并以__end结束。

图中出现的符号并没有在Linux 源代码中定义,它们是编译内核时产生的(注6)。
注6∶ 你可以在System.map文件中找到这些符号的线性地址,System.map是编译内核以后所创建的。

4、进程页表

进程的线性地址空间分成两部分∶

  • 从0x00000000到0xbfffffff的线性地址,无论进程运行在用户态还是内核态都可以寻址。
  • 0xc0000000到 0xffffffff的线性地址,只有内核态的进程才能寻址。

当进程运行在用户态时,它产生的线性地址小于0xc0 000 000;
当进程运行在内核态时,它执行内核代码,所产生的地址大于等于0xc0000000。但是,在某些情况下,内核为了检索或存放数据必须访问用户态线性地址空间。

宏PAGE_OFFSET产生的值是0xc0000000,这就是进程在线性地址空间中的偏移量,也是内核生存空间的开始之处。在本书中,我们常常直接引用0xc0000000这个数。

页全局目录的第一部分表项映射的线性地址小于0xc0000000(在PAE未启用时是前768项,PAE启用时是前3项),具体大小依赖于特定进程。相反,剩余的表项对所有进程来说都应该是相同的,它们等于主内核页全局目录的相应表项(参见下一节)。

5、内核页表

内核维持着一组自己使用的页表,驻留在所谓的主内核页全局目录(master kernel PageGlobal Directory)中。系统初始化后、这组页表还从未被任何进程或任何内核线程直接使用;更确切地说,主内核页全局目录的最高目录项部分作为参考模型,为系统中每个普通进程对应的页全局目录项提供参考模型。

我们在第八章"非连续内存区的线性地址"一节将会解释,内核如何确保对主内核页全局目录的修改能传递到由进程实际使用的页全局目录中。

我们现在描述内核如何初始化自己的页表。这个过程分为两个阶段。事实上,内核映像刚刚被装入内存后,CPU仍然运行于实模式,所以分页功能没有被启用。

第一个阶段,内核创建一个有限的地址空间,包括内核的代码段和数据段、初始页表和用于存放动态数据结构的共128KB大小的空间。这个最小限度的地址空间仅够将内核装入RAM和对其初始化的核心数据结构。

第二个阶段,内核充分利用剩余的RAM并适当地建立分页表。下一节解释这个方案是怎样实施的。

6、临时内核页表

临时页全局目录是在内核编译过程中静态地初始化的,而临时页表是由 startup_32()汇编语言函数(定义于arch/386/kernel/head.S)初始化的。我们不再过多提及页上级目录和页中间目录,因为它们相当于页全局目录项。在这个阶段PAE支持并未激活。

临时页全局目录放在swapper_pg_dir变量中。临时页表在pg0变量处开始存放,紧接在内核未初始化的数据段(图2-13中的_end符号)后面。为简单起见,我们假设内核使用的段、临时页表和128KB的内存范围能容纳于RAM前8MB空间里。为了映射RAM前8MB的空间,需要用到两个页表。

分页第一个阶段的目标是允许在实模式下和保护模式下都能很容易地对这8MB寻址。因此,内核必须创建一个映射,把从0x00000000到0x007fffff的线性地址和从0xc0000000到 0xc07fffff的线性地址映射到从 0x0000000到0x007fffff的物理地址。换句话说,内核在初始化的第一阶段,可以通过与物理地址相同的线性地址或者通过从0xc0000000开始的8MB线性地址对RAM的前8MB(223Byte=(7fffff+1)Byte)进行寻址。

内核通过把swapper_pg_dir临时页全局目录所有项都填充为0来创建期望的映射,不过,0、1、0x300(十进制768)和0x301(十进制769)这四项除外;(因为0x7ff fff=2*222-1,所以从0xc0000 000到0xc07ff fff间是两个页表所能映射的动作范围)后两项包含了从0xc0000 000到0xc07ff fff间的所有线性地址。0、1、0x300和0x301按以下方式初始化∶

  • 0项和0x300项的地址字段置为pg0的物理地址,而1项和0x301项的地址字段置为紧随pg0后的页框的物理地址。

  • 把这四个项中的Present、Read/Write和User/Supervisor标志置位。

  • 把这四个项中的Accessed、Dirty、PCD、PWD和Page Size标志清0。

汇编语言函数startup_32()也启用分页单元,通过向cr3控制寄存器装入swapper_pg_dir的地址及设置cr0控制寄存器的PG标志来达到这一目的。下面是等价的代码片段∶
(从80386开始,所有的80x86处理器都支持分页,它通过设置cr0寄存器的 PG标志启用。当 PG=0时,线性地址就被解释成物理地址。)

//下两句汇编的C语言表达:cr3=swapper_pg_dir-0xcO000000
movl $swapper_pg_dir-0xcO000000,%eax
moy1 %eax,%cr3 /*设置页表指针……*/

//下三句汇编的C语言表达:cr0=cr0|(1<<31)
mov1 %cr0,%eax
or1 $0x80000000,%eax
mov1 %eax,%cr0 /*……设置分页(PG)位*/

7、当 RAM 小于896MB时的最终内核页表

由内核页表所提供的最终映射必须把从0xc0000000开始的线性地址转化为从0开始的物理地址。

宏___pa用于把从 PAGE_OFFSET开始的线性地址转换成相应的物理地址,而宏__va做相反的转化。

主内核页全局目录仍然保存在swapper_pg_dir变量中。它由paging_init()函数初始化。该函数进行如下操作∶

  • 1.调用 pagetable_init()适当地建立页表项。
  • 2.把 swapper_pg_dir的物理地址写入 cr3 控制寄存器中。
  • 3.如果CPU支持PAE并且如果内核编译时支持PAE,则将cr4控制寄存器的PAE标志置位。
  • 4.调用__flush_tlb_all()使TLB的所有项无效。

pagetable_init()执行的操作既依赖于现有RAM的容量,也依赖于CPU模型。让我们从最简单的情况开始。我们的计算机有小于896MB(注7)的RAM,32位物理地址足以对所有可用RAM进行寻址,因而没有必要激活PAE机制【参见前面"物理地址扩展(PAE)分页机制"一节】。

swapper_pg_dir 页全局目录由如下等价的循环重新初始化∶

pgd = swapper_pg_dir+ pgd_index(PAGE_OFFSET);       /* 768*/
phys_addr = 0x00000000;
while (phys_addr <(max_low_pfn* PAGE_SIZE)){
        pmd = one_md_table_init(pgd);             /*返回pgd*/
        set_pmd(pmd,__pmd(phys_addr | pgprot_val(_ _pgprot(0xle3))));
        /*0xle3 == Present,Accessed,Dirty,Read/Write, Page Size,Global */
        phys_addr += PrRS_PER_PTE* PAGE_SIZE;      /*0x400000*/
        ++pgd;
}

我们假定CPU是支持4MB页和"全局(global)"TLB表项的最新80x86微处理器。注意如果页全局目录项对应的是 0xc000 0000(即3072MB)之上的线性地址,则把所有这些项的User/Supervisor标志清0,由此拒绝用户态进程访问内核地址空间。还要注意Page size被置位使得内核可以通过使用大型页来对RAM进行寻址(参见本章先前的"扩展分页"一节)。

由 startup_32()函数创建的物理内存前8MB的恒等映射用来完成内核的初始化阶段。当这种映射不再必要时,内核调用 zap_low_mappings()函数清除对应的页表项。

实际上,这种描述并未说明全部事实。我们将在后面"固定映射的线性地址"一节看到,内核也调整与"固定映射的线性地址"对应的页表项。

8、当 RAM 大小在 896MB和 4096MB 之间时的最终内核页表

在这种情况下,并不把RAM全部映射到内核地址空间。Linux在初始化阶段可以做的最好的事是把一个具有896MB的RAM窗口(window)映射到内核线性地址空间。如果一个程序需要对现有RAM的其余部分寻址,那就必须把某些其他的线性地址间隔映射到所需的RAM。这意味着修改某些页表项的值。我们将在第八章讨论这种动态重映射是如何进行的。

内核使用与前一种情况相同的代码来初始化页全局目录。

9、当 RAM大于 4096MB时的最终内核页表

现在让我们考虑RAM大于4GB计算机的内核页表初始化;更确切地说,我们处理以下发生的情况∶

CPU模型支持物理地址扩展(PAE)
RAM容量大于4GB
内核以PAE支持来编译

尽管PAE处理36 位物理地址,但是线性地址依然是32位地址。如前所述,Linux映射一个896 MB的RAM窗口到内核线性地址空间;剩余RAM留着不映射,并由动态重映射来处理,第八章将对此进行描述。与前一种情况的主要差异是使用三级分页模型,因此页全局目录按以下循环代码来初始化∶

pgd_idx = pgd_index(PAGE_OFFSET);                      /* 3 */
for(i=0;i<pgd_idx;1+)
        set pgd(swapper_Dg_dir + i,- pgd(__pa(empty_zero_page)+0x001));
        /*0×001 ==Present */
pgd = swapper_pg_dir + pgd_idx;
phys_addr = 0x00000000;
for (;i<PTRS_PER_RGD;++i,++pgd){
        prmd =(pmd_t*)alloc_bootmem_low_pages(PAGE_SIZE);
        set_pgd(pgd,__pgd(__pa(pmd)I 0x001));             /*0x001 == Present */
        if(phys_addr < max_low_pfn* PAGE_SIZE)
                for(j=0;j< PTRS_PER_PMD                  /*512*/
                        && phys_addr <max_low_pfn*PAGE_SIZE;++j)(
                set_pmd(pmd,__pmd(phy8_addr |
                                   pgprot_val(__pgprot(0xle3))));
                /* 0x1le3 == Present,Accessed,Dirty,Read/Write,Page Size,Global */
                phys_addr += PTRS_PER_PTE* PAGE_SIZE;  /* 0x20000 */
        }
}
swapper_pg_dir[0]= swapper_pg_dir[pgd_idx];

页全局目录中的前三项与用户线性地址空间相对应,内核用一个空页(empty_zero_page)的地址对这三项进行初始化。第四项用页中间目录(pmd)的地址初始化,该页中间目录是通过调用alloc_bootmem_low_pages()分配的。页中间目录中的前448项(有512项,但后64项留给非连续内存分配;参见第八章的"非连续内存区管理"一节)用RAM前896MB的物理地址填充。

注意,支持PAE的所有CPU模型也支持大型2MB页和全局页。正如前一种情况一样,只要可能,Linux 使用大型页来减少页表数。

然后页全局目录的第四项被拷贝到第一项中,这样好为线性地址空间的前896MB中的低物理内存映射作镜像。为了完成对SMP(对称多处理)系统的初始化,这个映射是必需的;当这个映射不再必要时,内核通过调用zap_low_mappings()函数来清除对应的页表项,正如先前的情况一样。

10、固定映射的线性地址

我们看到内核线性地址第四个GB的初始部分映射系统的物理内存。但是,至少128MB的线性地址总是留作他用,因为内核使用这些线性地址实现非连续内存分配和固定映射的线性地址。

非连续内存分配仅仅是动态分配和释放内存页的一种特殊方式,将在第八章"非连续内存区的线性地址"一节描述。本节我们集中讨论固定映射的线性地址。

固定映射的线性地址(fix-mapped linear address)基本上是一种类似于 0xffffc000这样的常量线性地址,其对应的物理地址不必等于线性地址减去0xc000000,而是可以以任意方式建立。因此,每个固定映射的线性地址都映射一个物理内存的页框。我们将会在后面的章节看到,内核使用固定映射的线性地址来代替指针变量,因为这些指针变量的值从不改变。

固定映射的线性地址概念上类似于对RAM前896MB映射的线性地址。不过,固定映射的线性地址可以映射任何物理地址,而由第4GB初始部分的线性地址所建立的映射是线性的(线性地址X映射物理地址X-PAGE_OFFSET)。

就指针变量而言,固定映射的线性地址更有效。事实上,间接引用一个指针变量比间接引用一个立即常量地址要多一次内存访问。此外,在间接引用一个指针变量之前对其值进行检查是一个良好的编程习惯;相反,对一个常量线性地址的检查则是没有必要的。

每个固定映射的线性地址都由定义于enum Eixed addresses数据结构中的整型素引来表示∶

enum fixed_addresses{
        FIX_HOLE,
        FIX_VSYsCALL,
        FIX_APIC_BASE,
        FIX_IO_APIC_BASE_0,
        [..]
        __end_of_fixed_addresses
}

每个固定映射的线性地址都存放在线性地址第四个GB的末端。fix_to_virt()函数计算从给定索引开始的常量线性地址

inline unsigned long fix_to_virt(const unsigned int idx>
{
        if (idx >=__end_of_fixed_addresses)
                __this_fixmap_doe8_not_exist();
        return (0xfffff000UL - (idx << PAGE_SHIFT));
}

让我们假定某个内核函数调用fix_to_virt(FIX_IO_APIC_BASE_0)。因为该函数声明为"inline",所以C编译程序不调用fix_to_virt(),而是仅仅把它的代码插入到调用函数中。此外,运行时从不对这个索引值执行检查。事实上,FIX_IO_APIC_BASE_0是个等于3的常量,因此编译程序可以去掉if语句,因为它的条件在编译时为假。相反,如果条件为真,或者fix_to_virt()的参数不是一个常量,则编译程序在连接阶段产生一个错误,因为符号this_Eixmap_does_not_exist在别处没有定义。最后,编译程序计算0xfffff000-(3<<PAGE_SHIFT),并用常量线性地址0xffffc000代替fix_to_virt()函数调用。

为了把一个物理地址与固定映射的线性地址关联起来,内核使用set_fixmap(idx,phys)和set_fixmap_nocache(idx,phys)宏。这两个函数都把fix_to_virt(idx)线性地址对应的一个页表项初始化为物理地址 phys;不过,第二个函数也把页表项的PCD标志置位,因此,当访问这个页框中的数据时禁用硬件高速缓存(参见本章前面"硬件高速缓存"一节)。反过来,clear_fixmap(idx)用来撤消固定映射线性地址idx和物理地址之间的连接。

11、处理硬件高速缓存 和 TLB(转换后援缓冲器)

内存寻址的最后一个主题是关于内核如何使用硬件高速缓存来达到最佳效果。硬件高速缓存和转换后援缓冲器(TLB)在提高现代计算机体系结构的性能上扮演着重要角色。内核开发者采用一些技术来减少高速缓存和TLB的未命中次数。

12、处理硬件高速缓存

如前所述,硬件高速缓存是通过高速缓存行(cache line)寻址的。L1_CACHE_BYTES宏产生以字节为单位的高速缓存行的大小。在早于Pentium 4的Intel模型中,这个宏产生的值为32;在Pentium 4上,它产生的值为128。

为了使高速缓存的命中率达到最优化,内核在下列决策中考虑体系结构∶

  • 一个数据结构中最常使用的字段放在该数据结构内的低偏移部分,以便它们能够处于高速缓存的同一行中。

  • 当为一大组数据结构分配空间时,内核试图把它们都存放在内存中,以便所有高速缓存行按同一方式使用。80x86 微处理器自动处理高速缓存的同步,所以应用于这种处理器的Linux内核并不处理任何硬件高速缓存的刷新。不过内核却为不能同步高速缓存的处理器提供了高速缓存刷新接口。

13、处理TLB(转换后援缓冲器)

处理器不能自动同步它们自己的TLB高速缓存,因为决定线性地址和物理地址之间映射何时不再有效的是内核,而不是硬件。
Linux 2.6提供了几种在合适时机应当运用的TLB刷新方法,这取决于页表更换的类型(见表2-11)。
在这里插入图片描述
尽管普通Linux内核提供了丰富的TLB方法,但通常每个微处理器都提供了更受限制的一组使TLB无效的汇编语言指令。在这个方面,一个更为灵活的硬件平台就是 Sun的UItraSPARC。与之相比,Intel微处理器只提供了两种使TLB无效的技术∶

  • 在向cr3寄存器写入值时所有Pentium处理器自动刷新相对于非全局页的TLB表项。

  • 在Pentium Pro及以后的处理器中,invlpg汇编语言指令使映射指定线性地址的单个TLB表项无效。

表2-12列出了采用这种硬件技术的Linux 宏;这些宏是实现独立于系统的方法(表2-11)的基本要素。
在这里插入图片描述

注意表2-12中没有flush_Elbpgtables方法∶在80x86系统中,当页表与父页表解除链接时什么也不需要做,所以实现这个方法的函数为空。

独立于体系结构的使TLB无效的方法非常简单地扩展到了多处理器系统上。在一个CPU上运行的函数发送一个处理器间中断(参见第四章的"处理器间中断处理")给其他的CPU来强制它们执行适当的函数使TLB无效。

一般来说,任何进程切换都会暗示着更换活动页表集。相对于过期页表,本地TLB表项必须被刷新;这个过程在内核把新的页全局目录的地址写入cr3控制寄存器时会自动完成。不过内核在下列情况下将避免TLB被刷新

  • 当两个使用相同页表集的普通进程之间执行进程切换时(参见第七章的"schedule()函数"一节)。

  • 当在一个普通进程和一个内核线程间执行进程切换时。事实上,我们将在第九章的"内核线程的内存描述符"一节看到,内核线程并不拥有自己的页表集;更确切地说,它们使用刚在 CPU 上执行过的普通进程的页表集。

除了进程切换以外,还有其他几种情况下内核需要刷新TLB中的一些表项。例如,当内核为某个用户态进程分配页框并将它的物理地址存入页表项时,它必须刷新与相应线性地址对应的任何本地TLB表项。在多处理器系统中,如果有多个CPU在使用相同的页表集,那么内核还必须刷新这些CPU上使用相同页表集的 TLB表项。

为了避免多处理器系统上无用的TLB刷新,内核使用一种叫做懒惰TLB(lazy TLB)模式的技术。其基本思想是,如果几个CPU正在使用相同的页表,而且必须对这些CPU上的一个TLB表项刷新,那么,在某些情况下,正在运行内核线程的那些CPU上的刷新就可以延迟。

事实上,每个内核线程并不拥有自己的页表集;更确切地说,它使用一个普通进程的页表集。不过,没有必要使一个用户态线性地址对应的TLB表项无效,因为用户态线程不访问内核态地址空间(注8)。

当某个CPU开始运行一个内核线程时,内核把它置为懒惰TLB模式。当发出清除TLB表项的请求时,处于懒惰TLB模式的每个CPU都不刷新相应的表项;但是,CPU记住它的当前进程正运行在一组页表上,而这组页表的TLB表项对用户态地址是无效的。只要处于懒惰TLB 模式的CPU用一个不同的页表集切换到一个普通进程,硬件就自动刷新TLB表项,同时内核把CPU设置为非懒惰TLB模式。然而,如果处于懒惰TLB模式的CPU切换到的进程与刚才运行的内核线程拥有相同的页表集,那么,任何使TLB无效的延迟操作必须由内核有效地实施;这种使TLB无效的"懒惰"操作可以通过刷新CPU的所有非全局TLB项来有效地获取。

为了实现懒惰TLB模式,需要一些额外的数据结构。cpu_tlbstate变量是一个具有NR_CPUs个结构的静态数组(这个宏的默认值是32;它代表了系统中CPU的最大数量),这个结构有两个字段,一个是指向当前进程内存描述符的active__mm字段(参见第九章),一个是具有两个状态值的state字段∶TLBSTATE_OK(非懒惰TLB模式)或TLBSTATE_LAZY(懒惰TLB模式)。此外,每个内存描述符中包含一个cpu_vm_mask字段,该字段存放的是CPU(这些CPU将要接收与TLB刷新相关的处理器间中断)下标;只有当内存描述符属于当前运行的一个进程时这个字段才有意义。

当一个CPU开始执行内核线程时,内核把该CPU的cpu_tlbstate元素的state字段置为TLBSTATE_LAZY;此外,活动(active)内存描述符的cpu_vm_mask字段存放系统中所有CPU(包括进入懒惰TLB模式的CPU)的下标。对于与给定页表集相关的所有CPU的TLB表项,当另外一个CPU想使这些表项无效时,该CPU就把一个处理器间中断发送给下标处于对应内存描述符的 cpu_vm_mask字段中的那CPU。

当CPU 接受到一个与TLB刷新相关的处理器间中断,并验证它影响了其当前进程的页表集时,它就检查它的cputlbstate元素的state字段是否等于TLBSTATE_LAZY;如果等于,内核就拒绝使TLB表项无效,并从内存描述符的 cpu_vm_mask字段删除该CPU下标。这有两种结果∶

  • 只要CPU还处于懒惰TLB模式,它将不接受其他与TLB刷新相关的处理器间中断。

  • 如果CPU 切换到另一个进程,而这个进程与刚被替换的内核线程使用相同的页表集,那么内核调用__flush_tlb()使该CPU的所有非全局TLB表项无效。

在这里插入图片描述

  • 1
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值