x86 内存架构
在 x86 架构中,内存被划分成 3 种类型的地址:
· 逻辑地址 (logical address) 是存储位置的地址,它可能直接对应于一个物理位置,也可能不直接对应于一个物理位置。逻辑地址通常在请求控制器中的信息时使用。
· 线性地址 (linear address) (或称为平面地址空间)是从 0 开始进行寻址的内存。之后的每个字节都可顺序使用下一数字来引用(0、1、2、3 等),直到内存末尾为止。这就是大部分非 Intel CPU 的寻址方式。Intel® 架构使用了分段的地址空间,其中内存被划分成 64KB 的段,有一个段寄存器总是指向当前正在寻址的段的基址。这种架构中的 32 位模式被视为平面地址空间,不过它也使用了段。
· 物理地址 (physical address) 是使用物理地址总线中的位表示的地址。物理地址可能与逻辑地址不同,内存管理单元可以将逻辑地址转换成物理地址。
CPU 使用两种单元将逻辑地址转换成物理地址。第一种称为分段单元 (segmented unit),另外一种称为分页单元 (paging unit)。
段由两个元素构成:
· 基址 (base address) 包含某个物理内存位置的地址
· 长度值 (length value) 指定该段的长度
每个段都是一个 16 位的字段,称为段标识符 (segment identifier) 或段选择器 (segment selector)。x86 硬件包括几个可编程的寄存器,称为段寄存器 (segment register),段选择器保存于其中。这些寄存器为cs(代码段)、ds(数据段)和ss(堆栈段)。每个段标识符都代表一个使用 64 位(8 个字节)的段描述符 (segment descriptor) 表示的段。这些段描述符可以存储在一个 GDT(全局描述符表,global descriptor table)中,也可以存储在一个 LDT(本地描述符表,local descriptor table)中。每次将段选择器加载到段寄存器中时,对应的段描述符都会从内存加载到相匹配的不可编程 CPU 寄存器中。每个段描述符长 8 个字节,表示内存中的一个段。这些都存储到 LDT 或 GDT 中。段描述符条目中包含一个指针和一个 20 位的值(Limit 字段),前者指向由 Base 字段表示的相关段中的第一个字节,后者表示内存中段的大小。
段选择器包含以下内容:
· 一个 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。
Linux 中的段控制单元
在 Linux 中,所有的段寄存器都指向相同的段地址范围 —— 换言之,每个段寄存器都使用相同的线性地址。这使 Linux 所用的段描述符数量受限,从而可将所有描述符都保存在 GDT 之中。这种模型有两个优点:
· 当所有的进程都使用相同的段寄存器值时(当它们共享相同的线性地址空间时),内存管理更为简单。
· 在大部分架构上都可以实现可移植性。某些 RISC 处理器也可通过这种受限的方式支持分段。
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 个条目:
UP系统中只有一个GDT表,而在SMP系统中每个CPU有一个GDT表。所有GDT存放在cpu_gdt_table[]数组中,段的大小和指针存放在cpu_gdt_descr[]数组中。Linux的GDT布局如下图所示。它包含18个段描述符和14个Null、保留、未使用的段描述符。包括任务状态段TSS、用户和内核代码数据段、所有进程共享的局部描述段、高级电源管理使用的数据段APMBIOS data、即插即用设备代码数据段PNPBIOS、三个线程局部存储段TLS、第一个为null的段用于处理段描述符异常。
图4 Linux Global Descriptor Table
Linux启动时GDT段表的初始化
全局描述表GDT表的初始化分两个阶段:
- 第一个阶段在setup中完成,此处是为系统进入保护模式做准备,把内核代码段和数据段的两个段描述符初始化放在GDT表中,这只是一个并不完整的临时GDT表。
- 第二个阶段在arch/i386/kernel/head.S 文件中的startup_32()函数里,在这里加载head.s 文件中已经初始化的cpu_gdt_table描述表,该表有32项。
X86中的分页管理
x86 架构中指定分页的字段,这些字段有助于在 Linux 中实现分页功能。分页单元进入作为分段单元输出结果的线性字段,然后进一步将其划分成以下 3 个字段:
- Directory以 10 MSB 表示(Most Significant Bit,也就是二进制数字中值最大的位的位置 —— MSB 有时称为最左位)。
- Table以中间的 10 位表示。
- Offset以 12 LSB 表示。(Least Significant Bit,也就是二进制整数中给定单元值的位的位置,即确定这个数字是奇数还是偶数。LSB 有时称为最右位。这与数字权重最轻的数字类似,它是最右边位置处的数字。)
Intel的分页机制
图5 x86分页机制
Linux的三级分页模型
虽然 Linux 中的分页与普通的分页类似,但是 x86 架构引入了一种32位和64位通用的三级页表机制,包括:
- 页全局目录 (Page Global Directory),即 pgd,是多级页表的抽象最高层。每一级的页表都处理不同大小的内存 —— 这个全局目录可以处理 4 MB 的区域。每项都指向一个更小目录的低级表,因此 pgd 就是一个页表目录。当代码遍历这个结构时(有些驱动程序就要这样做),就称为是在“遍历”页表。
- 页中间目录 (Page Middle Directory),即 pmd,是页表的中间层。在 x86 架构上,pmd 在硬件中并不存在,但是在内核代码中它是与 pgd 合并在一起的。
- 页表条目 (Page Table Entry),即 pte,是页表的最低层,它直接处理页(参看PAGE_SIZE),该值包含某页的物理地址,还包含了说明该条目是否有效及相关页是否在物理内存中的位。
图6 Linux三级页表机制
为了支持大内存区域,Linux采用了这种三级分页机制。在不需要为大内存区域时,即可将 pmd 定义成“1”,返回两级分页机制。
分页级别是在编译时进行优化的,我们可以通过启用或禁用中间目录来启用两级和三级分页(使用相同的代码)。Intel 32 位处理器使用的是 pmd 分页,而 64 位处理器使用的是 pgd 分页。
每个进程都有自己的页目录和页表。为了引用一个包含实际用户数据的页框,操作系统(在 x86 架构上)首先将 pgd 加载到cr3寄存器中。Linux 将cr3寄存器的内容存储到 TSS 段中。此后只要在 CPU 上执行新进程,就从 TSS 段中将另外一个值加载到cr3寄存器中。从而使分页单元引用一组正确的页表。
pgd 表中的每一条目都指向一个页框,其中中包含了一组 pmd 条目;pdm 表中的每个条目又指向一个页框,其中包含一组 pte 条目;pde 表中的每个条目再指向一个页框,其中包含的是用户数据。如果正在查找的页已转出,那么就会在 pte 表中存储一个交换条目,(在缺页的情况下)以定位将哪个页框重新加载到内存中。
Linux 为内核代码和数据结构预留了几个页框。这些页永远不会被转出到磁盘上。从 0x0 到 0xc0000000 (PAGE_OFFSET)的线性地址可由用户代码和内核代码进行引用。从PAGE_OFFSET到 0xffffffff 的线性地址只能由内核代码进行访问。
这意味着在 4 GB 的内存空间中,只有 3 GB 可以用于用户应用程序。
Linux分页的启动
Linux 进程使用的分页机制包括两个阶段:
- 在启动时,系统为 8 MB 的物理内存设置页表。
- 然后,第二个阶段完成对其余所有物理地址的映射。
在启动阶段,startup_32()调用负责对分页机制进行初始化。这是在 arch/i386/kernel/head.S 文件中实现的。这 8 MB 的映射发生在PAGE_OFFSET之上的地址中。这种初始化是通过一个静态定义的编译时数组 (swapper_pg_dir) 开始的。在编译时它被放到一个特定的地址(0x00101000)。
这种操作为在代码中静态定义的两个页 —— pg0和pg1 —— 建立页表。这些页框的大小默认为 4 KB,除非我们设置了页大小扩展位(有关 PSE 的更多内容,请参阅 扩展分页 一节)。这个全局数组所指向的数据地址存储在cr3寄存器中,我认为这是为 Linux 进程设置分页单元的第一阶段。其余的页项是在第二阶段中完成的。
第二阶段由方法调用paging_init()来完成。
在 32 位的 x86 架构上,RAM 映射到PAGE_OFFSET和由 4GB 上限 (0xFFFFFFFF) 表示的地址之间。这意味着大约有 1 GB 的 RAM 可以在 Linux 启动时进行映射,这种操作是默认进行的。然而,如果有人设置了HIGHMEM_CONFIG,那么就可以将超过 1 GB 的内存映射到内核上 —— 切记这是一种临时的安排。可以通过调用kmap()实现。