80x86 处理器提供相应的硬件电路辅助内存管理,Linux使用这些硬件。

Intel 微处理器以两种不同的方式执行地址转换,称为实模式和保护模式。实模式的存在主要是为了保持处理器与旧型号的兼容性并让操作系统能够引导。
逻辑地址由两部分组成:段标识符和段内偏移量。段标识符是一个称为段选择器的 16 位字段,而偏移量是一个 32 位字段。

为了便于快速检索段选择器,处理器提供了段寄存器,其唯一目的是保存段选择器。这些寄存器为 cs、ss、ds、es、fs 和 gs。虽然只有六个,但程序可以通过将相同的段寄存器保存在内存中,然后在以后恢复它来为不同的目的重复使用相同的段寄存器。
其余三个分段寄存器是通用的,可以引用任意数据段。
cs 寄存器还有另一个重要功能:它包含一个 2 位字段,用于指定 CPU 的当前特权级别 (CPL)。0 表示最高特权级别,而 3 表示最低特权级别。 Linux 仅使用级别 0 和 3,分别称为内核模式和用户模式。
每个段由 8 字节的段描述符说明特征。段描述符存储在全局描述符表 (GDT) 或局部描述符表 (LDT) 中。
通常只定义一个 GDT,而如果每个进程需要创建除了存储在 GDT 中的段之外的其他段,则允许每个进程拥有自己的 LDT。 GDT 在主存中的地址和大小包含在 gdtr 控制寄存器中,而当前使用的 LDT 的地址和大小包含在 ldtr 控制寄存器中。
| 字段 | 说明 |
|---|---|
| Base | 段的首字节线性地址 |
| G | 粒度标志:如果复位(等于0),则段大小以字节表示;否则,表示为以4096字节的倍数表示 |
| Limit | 记录段中最后一个内存单元的偏移量,从而绑定段长度。当 G 设置为0时,段的大小可能在1字节到1 MB之间变化;否则,它可能在4 KB和4 GB之间变化 |
| S | 如果复位,则该段是存储关键数据结构(如局部描述符表)的系统段;否则,它是普通代码或数据段 |
| Type | 描述段类型及其访问权限 |
| DPL | 描述符权限级别:用于限制对段的访问。它表示访问段所请求的最小 CPU 特权级别。因此,只有当 CPL 为0时(即在内核模式下),才能访问DPL设置为0的段,而DPL设置为3的段可以使用每个 CPL 值访问 |
| P | 段存在标志:如果段当前未存储在内存中,则等于0。Linux 总是将此标志(位47)设置为1,因为它从不将整个段交换到磁盘 |
| D or B | 根据段包含的是代码还是数据,称为D或B。在这两种情况下,其含义略有不同,但如果用作段偏移量的地址为32位长,则基本上会置位(等于1),如果地址为16位长,则会复位 |
| AVL | 可能会被操作系统使用,Linux不使用它 |
有几种类型的段,因此有几种类型的段描述符。下面的列表显示了Linux中广泛使用的类型。

回顾一下,逻辑地址由一个16位段选择器和一个32位偏移量组成,而段寄存器只存储段选择器。
为了加速逻辑地址到线性地址的翻译,80x86 处理器提供了额外的非可编程寄存器用于保存 8 字节的段描述符,这些段描述符由存储在相应段寄存器中的段选择器指定。每当段选择器被加载到段寄存器时,相应的段描述符就会从内存中加载到匹配的非可编程寄存器。这样一来,段的逻辑地址翻译可以不需要访问内存中的 GDT 和 LDT,而仅访问寄存器。只有当段寄存器的内容改变时才需要访问 GDT 和 LDT。

段选择器字段说明:
| 字段 | 说明 |
|---|---|
| index | 标识段描述符在 GDT 或 LDT 中的索引 |
| TI | 指定段描述符是包含在 GDT(TI=0)中还是包含在 LDT(TI=1)中 |
| RPL | 请求者权限级别:将相应的段选择器加载到 cs 寄存器时 CPU 的当前权限级别;它还可用于在访问数据段时选择性地削弱处理器权限级别 |
GDT 的第一个条目始终为0。这保证了段选择器为空的逻辑地址将被视为无效,从而导致处理器异常。GDT 中可存储的最大段描述符数量为8191(即 2 13 − 1 2^{13}-1 213−1)。

Linux 以非常有限的方式使用分段。这是因为,分段和分页在某种程度上冗余了,因为两者都可以将进程的物理地址分离:分段可以为不同的进程分配不同的线性地址空间,而分页可以将相同的线性地址空间映射到不同的物理地址空间。Linux 选择分页而不是分段的原因如下:
所有在用户模式下运行的 Linux 进程都使用相同的一对段来处理指令和数据。这些段分别为用户代码段和用户数据段。类似地,在内核模式下运行的所有 Linux 进程都使用相同的一对段来处理指令和数据:它们分别为内核代码段和内核数据段。下表展示了这四个关键段的段描述符。
| Segment | Base | G | Limit | S | Type | DPL | D/B | P |
|---|---|---|---|---|---|---|---|---|
| user code | 0x00000000 | 1 | 0xfffff | 1 | 10 | 3 | 1 | 1 |
| user data | 0x00000000 | 1 | 0xfffff | 1 | 2 | 3 | 1 | 1 |
| kernel code | 0x00000000 | 1 | 0xfffff | 1 | 10 | 0 | 1 | 1 |
| kernel data | 0x00000000 | 1 | 0xfffff | 1 | 2 | 0 | 1 | 1 |
注意,这些段的线性地址都从 0 开始,并达到 2 32 – 1 2^{32} –1 232–1 的寻址限制。这意味着所有进程,无论是用户模式还是内核模式,都可以使用相同的逻辑地址。(笔者注:同一进程的代码段和数据段不能使用相同的逻辑地址,即 Offset 不能相同,因为一个进程只用一组页表,无论是代码还是数据都会通过同一个页目录翻译,链接器在执行重定位时会安排好一切。)
相应的段选择器分别由宏__USER_CS、__USER_DS、__KERNEL_CS 和 __KERNEL_DS定义。例如,为了寻址内核代码段,内核只需将 __KERNEL_CS 的值加载到 cs 寄存器中。
让所有段都从 0x00000000 开始的另一个重要结果是,在 Linux 中,逻辑地址与线性地址一致。也就是说,一个逻辑地址的 offset 字段的值总是与对应的线性地址的值一致。
如前所述,CPU 的当前特权级别(CPL)指出处理器是处于用户模式还是内核模式,并由存储在 cs 寄存器中的段选择器的 RPL 字段指定。当 CPL 改变时,ds 和 ss 寄存器的内容也要发生相应改变。
当保存指向指令或数据结构的指针时,内核不需要存储逻辑地址中的段选择器,因为 ss 寄存器包含当前的段选择器。例如,当内核调用一个函数时,它执行一条 call 指令,只需要给出逻辑地址的偏移量;段选择器被隐式选择为 cs 寄存器。因为只有一种内核模式下可执行的段,即__KERNEL_CS标识的代码段,所以只要 CPU 切换到内核模式,将__KERNEL_CS加载到 cs 中就足够了。同样的参数适用于指向内核数据结构的指针(隐式使用 ds 寄存器),以及指向用户数据结构的指针(内核显式使用es 寄存器)。
除了刚刚描述的四个段之外,Linux 还使用了一些其他专门的段。
在单处理器系统中只有一个 GDT,而在多处理器系统中,系统中的每个 CPU 都有一个 GDT。所有 GDT 都存储在cpu_gdt_table数组中,而 GDT 的地址和大小(在初始化 gdtr 寄存器时使用)存储在 cpu_gdt_descr数组中。
每个 GDT 包括 18 个段描述符和 14 个空的、未使用的或保留的条目。故意插入未使用的条目,可以将通常一起访问的段描述符保存在同一缓存行(cache line)中。
每个 GDT 中包含的 18 个段描述符指向以下段:
init_tss数组中;特别是,第 n 个 CPU 的 TSS 描述符的 Base 字段指向init_tss数组的第 n 个元素。 G(粒度)标志复位,而 Limit 字段设置为0xeb,因为 TSS 长度为 236 字节。 Type 字段设置为 9 或 11(可用的 32 位 TSS),并且 DPL 设置为 0,因为不允许用户模式下的进程访问 TSS。set_thread_area()和get_thread_area()系统调用分别为正在执行的进程创建和释放一个 TLS 段。
如前所述,系统中的每个处理器都有一份 GDT 副本。 GDT 的所有副本都存储相同的条目,除了少数情况。首先,每个处理器都有自己的 TSS 段,因此相应的 GDT 的条目不同。此外,GDT 中的一些条目可能取决于 CPU 正在执行的进程(LDT 和 TLS 段描述符)。最后,在某些情况下,处理器可能会临时修改其 GDT 副本中的条目;例如,当调用 APM 的 BIOS 过程时,就会发生这种情况。
大多数 Linux 用户模式应用程序不使用局部描述符表,因此内核定义了一个由大多数进程共享的默认 LDT。默认局部描述符表存储在default_ldt数组中。它包括五个条目,但其中只有两个被内核有效使用:iBCS 可执行文件的调用门和 Solaris/x86 可执行文件的调用门。调用门是 80x86 微处理器提供的一种机制,用于在调用预定义函数的同时改变 CPU 的特权级别。
但是,在某些情况下,进程可能需要设置自己的 LDT。事实证明,这对于执行面向段的 Microsoft Windows 应用程序的应用程序(例如 Wine)很有用。 modify_ldt()系统调用让进程执行此操作。
任何由modify_ldt()创建的自定义 LDT 也需要自己的段。当处理器开始执行具有自定义 LDT 的进程时,特定 CPU 的 GDT 中的 LDT 条目会相应更改。
用户模式应用程序也可以通过modify_ldt()分配新的段;然而,内核从不使用这些段,并且它不必跟踪相应的段描述符,因为它们包含在进程的自定义 LDT 中。
分页单元将线性地址转换为物理地址。该单元中的一项关键任务是根据线性地址的访问权限检查请求的访问类型。如果内存访问无效,则会产生缺页故障(Page Fault)异常。
分页单元认为所有 RAM 都被划分为固定长度的 page frames(有时称为物理页)。每个 page frames 包含一个页——也就是说,page frames 的长度与页的长度一致。page frame 是内存的组成部分,因此它是一个存储区域。区分页和 page frame 很重要;前者只是一个数据块,可以存储在任何 page frame 或磁盘上。
将线性地址映射到物理地址的数据结构称为页表;它们存储在主内存中,并且必须在启用分页单元之前由内核正确初始化。
从 80386 开始,所有 80x86 处理器都支持分页;它通过设置名为 cr0 的控制寄存器的 PG 标志来启用。当 PG = 0 时,线性地址被解释为物理地址。
从 80386 开始,Intel 处理器的分页单元处理 4KB 的页。线性地址的 32 位分为三个字段:
线性地址的翻译分两步完成,每一步都基于一种翻译表。第一个翻译表称为页目录,第二个称为页表。这种两级方案的目的是减少每个进程页表所需的内存。如果使用简单的一级页表,将需要 2 20 2^{20} 220 个条目来表示每个进程的页表,即使进程不使用该范围内的所有地址。两级方案通过只为进程实际使用的那些虚拟内存区域申请页表来减少内存。
每个活动进程都必须有一个分配给它的页目录。但是,不需要一次为进程的所有页表分配内存;只有当进程需要它时,才为页表分配内存会更有效。
正在使用的页目录的物理地址存储在名为 cr3 的控制寄存器中。线性地址中的 Director 字段决定了页目录中指向正确页表的条目。反过来,地址的 Table 字段确定页表中的条目,该条目指向包含该页的 page frame 的物理地址。 Offset 字段决定了 page frame 内的相对位置。因为它是 12 位长,所以每页由 4096 字节的数据组成。

Diretor 和 Table 字段都是 10 位长,因此页目录和页表最多可以包含 1024 个条目。因此,页目录最多可以寻址
1024
×
1024
×
4096
=
2
32
1024 × 1024 × 4096=2^{32}
1024×1024×4096=232 个内存单元。
页目录和页表的条目具有相同的结构。每个条目包括以下字段:
可以看出,一个页表条目需要4字节表示,因此一个页可以存储的页表条目数量为4096 / 4 = 1024。
从 Pentium 模型开始,80x86 微处理器引入了扩展分页,它允许分页大小为 4 MB 而不是 4 KB。扩展分页用于将大的连续线性地址范围转换为相应的物理地址范围;在种情况下,内核可以不使用中间页表,从而节省内存并保留 TLB 条目。

如上一节所述,通过设置页目录条目的页大小标志来启用扩展分页。在这种情况下,分页单元将线性地址的 32 位分为两个字段:
扩展分页的页目录条目与普通分页相同,除了:
扩展分页与常规分页并存,它通过设置 cr4 寄存器的 PSE 标志来启用。
分页单元使用与分段单元不同的保护方案。虽然 80x86 处理器允许一个段有四种可能的特权级别,但只有两个特权级别与页和页表相关联,因为特权由前面“常规分页”部分中提到的用户/主管标志控制。当此标志为 0 时,只有当 CPL 小于 3 时才能寻址页(这意味着处理器处于内核模式时)。当标志为 1 时,页总是可以被寻址。
此外,与段相关联的三种访问权限(读、写和执行)不同,只有两种访问权限(读和写)与页相关联。如果一个页目录或页表项的读/写标志等于0,则只能读取对应的页表或页;否则可以读写。
64 位处理器的所有硬件分页系统都使用额外的分页级别。使用的级别数取决于处理器的类型。下表总结了一些 Linux 支持的 64 位平台使用的硬件分页系统的主要特征。

Linux 采用了一种通用的分页模型,它同时适用于 32 位和 64 位架构。正如前面“64 位架构的分页”部分所述,两级分页对于 32 位架构就足够了,而 64 位架构需要更多级的分页。直到 2.6.10 版本,Linux 分页模型由三级组成。从 2.6.11 版本开始,采用了四级分页模型。

对于没有物理地址扩展的 32 位架构,两级分页就足够了。 Linux 基本上去除了 Page Upper Directory 和 Page Middle Directory 字段,因为它们都是 0。但是, Page Upper Directory 和 Page Middle Directory 在指针序列中的位置被保留,以便相同的代码可以在 32 位和 64 位体系结构上工作。内核通过将其中的条目数设置为 1 并将这两个条目映射到 Page Global Directory 的正确条目来为 Page Upper Directory 和 Page Middle Directory 保留一个位置。
对于启用了物理地址扩展的 32 位架构,使用了三级分页。 Linux 的 Page Global Directory 对应 80x86 的 Page Directory Pointer Table,Page Upper Directory被去掉了,Page Middle Directory 对应80x86 的 Page Directory,Linux 的 Page Table 对应 80x86 的 Page Table。
每个进程都有自己的页全局目录和自己的一组页表。当发生进程切换时,Linux 将 cr3 控制寄存器保存在先前正在执行的进程的描述符中,然后将存储在下一个要执行进程的描述符中的值加载到 cr3。因此,当新进程在 CPU 上恢复执行时,分页单元会引用正确的页表集。
以下宏简化了页表处理:
PAGE_SHIFT可以被认为是页大小的以 2 为底的对数。 PAGE_SIZE使用此宏来返回页大小。最后,PAGE_MASK宏产生值0xfffff000并用于屏蔽 Offset 字段。PMD_SIZE宏计算由 Page Middle Directory 的单个条目映射的区域(即一个页表能够映射的区域)大小。 PMD_MASK宏用于屏蔽 Offset 和 Table 字段。PMD_SHIFT产生值 22(12 来自 Offset 加上 10 来自 Table),PMD_SIZE产生
2
22
2^{22}
222 或 4 MB,PMD_MASK产生0xffc00000。相反,当启用 PAE 时,PMD_SHIFT产生值 21(12 来自 Offset 加上 9 来自 Table),PMD_SIZE产生
2
21
2^{21}
221 或 2 MB,PMD_MASK产生 0xffe00000。LARGE_PAGE_SIZE等于PMD_SIZE(
2
P
M
D
_
S
H
I
F
T
2^{PMD\_SHIFT}
2PMD_SHIFT),而 LARGE_PAGE_MASK用于屏蔽在大页地址中的 Offset 和 Table 字段的,等于PMD_MASK。PUD_SIZE宏计算由 Page Upper Directory 的单个条目映射的区域大小。 PUD_MASK宏用于屏蔽 Offset、Table、Middle Air字段。在 80x86 处理器上,PUD_SHIFT始终等于PMD_SHIFT,PUD_SIZE等于 4 MB 或 2 MB。PGDIR_SIZE 宏计算由 Page Global Directory 的单个条目映射的区域的大小。 PGDIR_MASK宏用于屏蔽 Offset、Table、Middle Air 和 Upper Air 字段。PGDIR_SHIFT产生值 22(与PMD_SHIFT和PUD_SHIFT产生的值相同),PGDIR_SIZE产生
2
22
2^{22}
222 或 4 MB,PGDIR_MASK产生0xffc00000。相反,当启用 PAE 时,PGDIR_SHIFT产生值 30(12 来自 Offset 加上 9 来自 Table 加上 9 来自 Middle Air),PGDIR_SIZE产生
2
30
2^{30}
230 或 1 GB,而PGDIR_MASK产生0xc0000000。pte_t、pmd_t、pud_t和pgd_t分别描述了页表、Page Middle Directory 、Page Upper Directory 和Page Global Directory 条目的格式。启用 PAE 时它们是 64 位数据类型,否则它们是 32 位数据类型。 pgprot_t是另一种 64 位(启用 PAE)或 32 位(禁用 PAE)数据类型,表示与单个条目关联的保护标志。
五个类型转换宏——__pte、__pmd、__pud、__pgd和__pgprot——将一个无符号整数转换为所需的类型。其他五个类型转换宏——pte_val、pmd_val、pud_val、pgd_val和pgprot_val——执行从前面提到的四种特殊类型之一到无符号整数的反向转换。
内核还提供了几个宏和函数来读取或修改页表条目:
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 位值。pte_same(a,b)返回 1,否则返回 0。pmd_large(e)返回 1,否则返回 0。函数使用pmd_bad宏来检查作为输入参数传递的页中间目录条目。如果条目指向错误的页表,则它产生值 1——也就是说,如果至少满足以下条件之一:
pud_bad和pgd_bad宏总是产生 0。没有定义pte_bad宏,因为页表条目引用主存储器中不存在、不可写或根本不可访问的页面是合法的。
如果页表条目的存在标志或页大小标志等于 1,则pte_present宏产生值 1,否则产生值 0。回想一下,页表条目中的页大小标志对于微处理器的分页单元没有意义;然而,对于内存中存在但没有读取、写入或执行权限的页,内核将存在标志置为 0,页大小标志置为 1。这样,任何对此类页面的访问都会因为存在标志被复位而触发缺页故障异常,并且内核可以通过检查页大小的值来检测出故障不是由于缺页造成的。
如果相应条目的存在标志等于 1,则pmd_present宏产生值 1——也就是说,如果相应的页或页表被加载到主存储器中。 pud_present和 pgd_present宏总是产生值 1。
表中列出的函数查询页表条目中包含的任何标志的当前值;除了pte_file(),这些函数仅在pte_present返回 1 的页表条目上正常工作。
| 函数名 | 描述 |
|---|---|
| pte_user() | 读取用户/主管标志 |
| pte_read() | 读取用户/主管标志(80 × 86 处理器上的页面无法防止读取) |
| pte_write() | 读取读/写标志 |
| pte_exec() | 读取用户/主管标志(80x86 处理器上的页面无法防止代码执行) |
| pte_dirty() | 读取脏标志 |
| pte_young() | 读取已访问标志 |
| pte_file() | 读取脏标志(当存在标志被复位并设置脏标志时,该页属于非线性磁盘文件映射) |
物理内存中有些区域由具体机器的BIOS使用,操作系统从0x100000到0x2fffff加载代码、数据。下图笔者认为有误,0x100应与_text对齐,0x2ff应在_end之前。

临时页全局目录保存在swapper_pg_dir变量中,临时页表保存在pg0开始处,紧邻着操作系统未初始化数据段。
简单起见,假设内核映像、临时页表、动态数据结构占8M,则需要2个页表来映射,目标是将进程视角中0xc0000000到0xc07fffff的地址(内核所在区域)映射为物理地址。(则页全局目录需要768个条目映射0xc0000000以下的地址)
毫无疑问,swapper_pg_dir的线性地址大于0xc0000000。
内核初始化swapper_pg_dir:
0和0x300的地址字段设置为pg0的物理地址(即临时页表的起始地址)。由上面的图可知,page frame0x2ff装载了内核未初始化的数据,紧跟着就是临时页表,因此这里是0x300。而条目1和0x301的地址字段设置为pg0后面的 page frame 的起始物理地址(对应前面说过的,2个页表)。这里的巧妙在于,对于线性地址0xc0000000到0xc03fffff,按照翻译步骤,在页全局目录表中按索引查找条目时,地址高10位为11 0000 0000,刚好对应条目0x300,它指向的就是第一个页表;而对于0xc0400000到0xc07fffff,地址高10位为11 0000 0001,刚好对应条目0x301,它指向的就是第二个页表。
为了设置页全局目录表,通过startup_32()来完成:
movl $swapper_pg_dir - 0xc0000000, %eax
movl %eax, %cr3 ;全局目录表的物理起始地址
movl %cr0, %eax
orl $0x80000000, %eax
movl %eax, %cr0 ;开启分页(设置PG)
最终的内核全局目录表仍用swapper_pg_dir存储。
页全局目录表的重新初始化由paging_init()完成,循环设置条目:
pgd = swapper_pg_dir + pgd_index(PAGE_OFFSET); //条目0x300
phys_addr = 0x00000000; //内核区域线性地址映射到从0开始的物理地址上
while (phys_addr < (max_low_pfn * PAGE_SIZE)) {
pmd = one_md_table_init(pgd); //返回 pgd 自身
set_pmd(pmd, __pmd(phys_addr | pgprot_val(__pgprot(0x1e3)))); //设置条目内容
phys_addr += PTRS_PER_PTE * PAGE_SIZE; //开启了大页
++pgd;
}
在这种情况下,内核线性地址空间不能完全映射到 RAM。 Linux 在初始化阶段可以做的最好的事情是将大小为 896 MB 的 RAM 窗口映射到内核线性地址空间。如果程序需要寻址现有 RAM 的其他部分,则必须将一些其他线性地址间隔映射到所需的 RAM。这意味着更改某些页表条目的值。
为了初始化页面全局目录,内核使用与前一种情况相同的代码。