1.x86 内存架构和Linux的分段管理
x86 内存架构
在
x86
架构中,内存被划分成
3
种类型的地址:
·
逻辑地址
(logical address)
是存储位置的地址,它可能直接对应于一个物理位置,也可能不直接对应于一个物理位置。逻辑地址通常在请求控制器中的信息时使用。
·
线性地址
(linear address)
(或称为
平面地址空间)是从
0
开始进行寻址的内存。之后的每个字节都可顺序使用下一数字来引用(
0
、
1
、
2
、
3
等),直到内存末尾为止。这就是大部分非
Intel CPU
的寻址方式。
Intel®
架构使用了分段的地址空间,其中内存被划分成
64KB
的段,有一个段寄存器总是指向当前正在寻址的段的基址。这种架构中的
32
位模式被视为平面地址空间,不过它也使用了段。
·
物理地址
(physical address)
是使用物理地址总线中的位表示的地址。物理地址可能与逻辑地址不同,内存管理单元可以将逻辑地址转换成物理地址。
CPU
使用两种单元将逻辑地址转换成物理地址。第一种称为分段单元
(segmented unit)
,另外一种称为分页单元
(paging unit)
。
图
2.
转换地址空间使用的两种单元
段由两个元素构成:
·
基址
(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
。
图
3.
从逻辑地址获得线性地址
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项。
2.Linux的三级分页管理
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()
实现。