由于历史原因,X86 处理器刚启动时处于实模式状态只能访问 1 MB 空间,进入保护模式后才可以访问 32 位地址空间。因此 XV6 和 Linux 或其他 X86 上的操作系统一样,都必须经历从实地址启动并且换到保护模式的转变。
本小节讨论 X86 处理器内存管理单元 MMU(Memory Management Unit)提供的实模式和保护模式下的寻址方式,而具体的设置以及从实模式到保护模式(及分页)转换的细节,请参见前面 XV6 内核启动过程的分析。
首先给读者讲述 X86 处理器模式以及相应的寻址方式。
XV6 内核启动时将会先后经历 X86 处理器的实地址模式、分段的保护模式、段页式的保护模式三个阶段。地址模式的转换是通过修改控制寄存器 %cr0
来完成的,其中涉及 PE 和 PG 两位,分别对应于进入保护模式和开启分页机制,具体组合如下表:
PG | PE | 方式 |
---|---|---|
0 | 0 | 实模式,8080 操作 |
0 | 1 | 保护模式,但不允许分页 |
1 | 0 | 出错 |
1 | 1 | 允许分页的保护模式 |
其中 00 编码对应的是实地址模式, 01 编码的是分段保护模式,11 编码的是段页式保护模式, 10 编码属于错误(没有对应的模式)。
进入保护模式后,X86 处理器启动了分段管理机制,甚至还可以开启分页管理机制,因此具有逻辑地址、线性地址和物理地址这三个概念。
在 X86 处理器的 MMU 中,进程虚存空间使用的是逻辑地址,逻辑地址经过分段单元 (segmentation unit)转换为线性地址,然后线性地址再经过分页单元(paging unit)的硬件转 换成物理地址。这样就不能笼统地使用虚地址来表示程序地址,而必须区分 “逻辑地址” 和 “线性地址” 的区别。前者是 “分段 + 段内偏移” 的二维地址,而线性地址则是一维的地址,这也是称为 “线性” 的原因。
如果不开启分页,则线性地址直接用做物理地址。而启动了分页机制,则线性地址还需要经过页表转换才能变成物理地址。X86 的分页机制采用多级页表,需要经过多次转换才能完成线性地址到物理地址的转换。
我们对不同地址空间所使用的术语,编程语言中使用的地址(例如指针、函数地址等)称为逻辑地址,经过分段机制后形成的是线性地址。如果没有启动分页,则线性地址可以用作物理地址去访问物理内存。如果开启分页机制,则线性地址还需要经过分页后才形成物理地址。
在 XV6 的启动过程中,我们先后经历实地址模式、保护模式(不分页)、保护模式(分页、 大页模式)和保护模式(分页、4 KB 页)共四种模式。
X86 在实地址模式下时,地址表示成 “段:偏移”(segment : offset)的形式,其中的 segment 是该段的基地址,对于段基址的偏移部分被存在 offset 里面。逻辑地址到线性地址的变换方式: segment << 4 + offset
。每个地址自动关联到一个段,例如代码关联到代码段寄存器 CS,数据 则关联到数据段寄存器 DS,堆栈操作关联到堆栈段寄存器 SS,因此程序中出现的显式地址实际上只有 offset。
实地址比较简单,程序发出的地址都是有相关联的段寄存器。例如访问指令时由 CS:IP
决定指令的地址 , 如果 CS=0x1000
且 IP=0x0055
, 则对应指令的物理地址为 0x10000+0x0055=0x10055
。对于数据访问的地址形成过程也是类似的,只是使用不同的相关联的段寄存器而已。如果是堆栈则使用 SS 作为段寄存器,如果是其他数据则使用 DS/ES/GS/HS 等作为段寄存器。
下面仍是以 CS:IP
取指令的地址形成为例,来说明保护模式下的物理地址形成过程。但是需要注意和区分,在处于刚启动的实模式下时,段寄存器 CS/DS/ES/SS 的内容是直接作为段基地址的 segment 部分。一旦开启保护模式,则 CS/DS/ES/SS 段寄存器是作为一个间接信息(索引),称为 “段选择子”(后面简称选择子),在全局描述符表 GDT 或局部描述符表 LDT 里指定一个具体的项,后者才具有段的地址起点的信息(以及访问权限等信息)。
如果在保护模式下 CS=0x1000 且 IP=0x0055,那么 CS 的 0x1000 当作选择子在全局描述符表或局部描述符表中选择一个 “段描述符”,假设所取出的段描述符基地址 Base=0x7fff3000
, 则线性地址为 0x7fff3000+0x0055=0x7fff3055
。此时可支持的物理内存空间从原来的 1 MB 提升到了 4 GB(2^32
)。
从上面的例子可以看出,每个逻辑地址由 16 位的段选择符 + 32 位的偏移量组成,其中段是实现逻辑地址到线性地址转换机制的基础。
在保护方式下,段的特征有以下三个:段基址,段限长,段属性。这三个特征存储在段描述符(segment descriptor)之中,用以实现从逻辑地址到线性地址的转换。我们使用段选择子 (CS/DS/ES/SS/HS/GS 之一)来定位段描述符在这个表中的位置。
下面我们来学习段选择子和段描述符(表)的知识。
实模式下的 6 个 16 位段寄存器 CS、 DS、 ES、 FS、 GS 和 SS,在保护模式下叫做段选择子。在保护模式下,尽管访问内存时也需要指定一个段,但段选择器的内容不再是逻辑段地址,而是段描述符在描述符表中的索引号。在保护模式下访问一个段时,传送到段选择器的是段选择符,也叫段选择子。
位 | 名称 | 说明 |
---|---|---|
15~3 | Index | 索引(0~8192) |
2 | TI | Table Indicator(0=GDT,1=LDT) |
1~0 | RPL | Requested Privilege |
段选择子由三部分组成,共 16 bit,第一部分是描述符的索引号,用来在描述符表中选择一个段描述符。TI 是描述符表指示器, TI=0 时,表示描述符在 GDT 中; TI =1 时,描述符在 LDT 中。RPL 是请求特权级,表示给出当前选择子对应的 “程序 / 数据” 的特权级别。每个程序都有特权级别。
可以看出段选择子只是一个索引,而真正描述段的信息的是段描述符,下面立刻对其进行分析。
段的描述符有两大类,一个是用来指出数据或代码段的用途,另一种是系统管理用的,用于指出局部描述符表 LDT、任务状态段 TSS、4 种门(中断门 / 陷阱门 / 调用门 / 系统门)。这些描述符构成三种表:GDT、LDT、IDT。下面逐个进行分析讨论。
段描述符
段描述符长 64 位,用于描述一个段的起始地址、长度和属性三种信息。 分别用 2 个 32 位表示。
第一个 32 位(31~0)信息如下:
31~24 | 23 | 22 | 21 | 20 | 19~16 | 15 | 14~13 | 12 | 11~8 | 23~16 |
---|---|---|---|---|---|---|---|---|---|---|
BASE | G | D/B | L | AVL | LIMIT | P | DPL | S | TYPE | BASE |
第二个 32 位(63~32)信息如下:
31~16 | 15~0 |
---|---|
BASE | LIMIT |
BASE
段基址规定线性地址空间中段的开始地址,使用段描述符的第一个 32 bit 数据的 16~31 位和第二个 32 bit 数据的 0~7、24~31 bit。因为基地址长度与寻址地址的长度相同,所以段基地址可以是 0~4 GB 范围内的任意地址,而不像实地址方式下规定的边界必须被 16 整除(左移 4 位)。不过,还是建议应当选取那些 16 字节对齐的地址。尽管对于 Intel 处理器来说,允许不对齐的地址,但是,对齐能够使程序在访问代码和数据时的性能最大化。
LIMIT
段界限规定段的大小。在保护模式下,段界限用 20 位表示,而且段界限可以是以字节为单位或以 4 K 字节为单位。偏移量是从 0 开始递增,段界限决定了偏移量的最大值。对于向下扩展的段,如堆栈段来说,段界限决定了偏移量的最小值。
标志位分散在多处:
G 位是粒度位,用于解释段界限的含义。当 G 位是 0 时,段界限以字节为单位。此时,段的扩展范围是从 1 字节到 1 兆字节,因为描述符中的界限值是 20 位 的。相反,如果该位是 1,那么,段界限是以 4 KB 为单位的。这样,段的扩展范围是从 4 KB 到 4 GB。
P 是段存在位( Segment Present)。 P 位用于指示描述符所对应的段是否存在。 一般来说,描述符所指示的段都位于内存中。但是,当内存空间紧张时,有可能只是建立了描述符,对应的内存空间并不存在,这时,就应当把描述符的 P 位清零,表示段并不存在(或在磁盘中)。P 位是由处理器负责检查的。每当通过描述符访问内存中的段时,如果 P 位是 0,处理器就会产生一个异常中断。
DPL 记录描述符的特权级;
D/B 位是 “默认的操作数大小”( Default Operation Size)或者 “默认的堆栈指针大小”,又或者 “上部边界” 标志。设立该标志位,主要是为了能够在 32 位处理器上兼容运行 16 位保护模式的程序。D=0 表示指令中的偏移地址或者操作数是 16 位的; D=1, 指示 32 位的偏移地址或者操作数。
举个例子来说, 如果代码段描述符的 D 位是 0,那么,当处理器在这个段上执行时,将使用 16 位的指令指针寄存器 IP 来取指令,否则使用 32 位的 EIP。
对于堆栈段来说,该位被叫做 B 位,用于在进行隐式的堆栈操作时,是使用 SP 寄存器还是 ESP 寄存器。
L 位是 64 位代码段标志,保留此位给 64 位处理器使用。目前,我们将此位置 0 即可。
AVL :available and reserved bit,通常为 0。
DPL:表示描述符的特权级( Descriptor Privilege Level, DPL)。这两位用于指定段的特权级。共有 4 种处理器支持的特权级别,分别是 0、 1、 2、 3,其中 0 是最高特权级别, 3 是最低特权级别。刚进入保护模式时执行的代码具有最高特权级 0(可以看成是从处理器那里继承来的),这些代码通常都是操作系统代码,因此它的特权级别最高。每当操作系统加载一个用户程序时,它通常都会指定一个稍低的特权级,比如 3 特权级。不同特权级别的程序是互相隔离的,其互访是严格限制的,而且有些处理器指令(特权指令)只能由 0 特权级的程序来执行,为的就是安全。这里再次点明了为何叫保护模式。
S :S = 1 表示该描述符指向的是代码段或数据段;S=0 表示系统段(TSS、LDT)和门描述符。
TYPE 字段共 4 位,用于指示描述符的子类型,或者说是类别。对于数据段来说, 这 4 位分别是 X、 E、 W、 A 位;而对于代码段来说,这 4 位 则分别是 X、 C、 R、 A 位。
S=1
(代码段或数据段)且 TYPE<8
时,为数据段描述符。数据段都是可读的,但不 一定可写。
十进制值 | X | E | W | A | 数据段 |
---|---|---|---|---|---|
0 | 0 | 0 | 0 | 0 | 只读 |
1 | 0 | 0 | 0 | 1 | 只读,已访问 |
2 | 0 | 0 | 1 | 0 | 读写 |
3 | 0 | 0 | 1 | 1 | 读写、已访问 |
4 | 0 | 1 | 0 | 0 | 只读、向下扩展 |
5 | 0 | 1 | 0 | 1 | 只读、向下扩展、已访问 |
6 | 0 | 1 | 1 | 0 | 读写、向下拓展 |
7 | 0 | 1 | 1 | 1 | 读写、向下拓展、已访问 |
S=1 且 TYPE>=8
时,为代码段描述符。代码段都是可执行的,一定不可写,不一定可读。
十进制值 | X | C | R | A | 代码段 |
---|---|---|---|---|---|
8 | 1 | 0 | 0 | 0 | 只执行 |
9 | 1 | 0 | 0 | 1 | 只执行、已访问 |
10 | 1 | 0 | 1 | 0 | 执行、可读 |
11 | 1 | 0 | 1 | 1 | 执行、可读、已访问 |
12 | 1 | 1 | 0 | 0 | 只执行、一致 |
13 | 1 | 1 | 0 | 1 | 只执行、一致、已访问 |
14 | 1 | 1 | 1 | 0 | 执行、可读、一致 |
15 | 1 | 1 | 1 | 1 | 执行、可读、一致、已访问 |
S=0 时,描述符可能为 TSS、LDT 和 4 种门描述符,也就是该描述符不是用来指出代码或数据,而是还有一些特殊用途的管理数据。虽然 TSS 和任务门都和 X86 的硬件任务概念相关,但是需要区分前者是处理器现场等信息记录,后者是任务切换的入口机制。
十进制 | 11 | 10 | 9 | 8 | 说明 |
---|---|---|---|---|---|
0 | 0 | 0 | 0 | 0 | 保留 |
1 | 0 | 0 | 0 | 1 | 16 位 TSS |
2 | 0 | 0 | 1 | 0 | LDT |
3 | 0 | 0 | 1 | 1 | 16 位 TSS(忙) |
4 | 0 | 1 | 0 | 0 | 16 位调用门(Interrupt Gate) |
5 | 0 | 1 | 0 | 1 | 任务门(Task Gate) |
6 | 0 | 1 | 1 | 0 | 16 位中断门(Interrupt Gate) |
7 | 0 | 1 | 1 | 1 | 16 位陷阱门(Trap Gate) |
8 | 1 | 0 | 0 | 0 | 保留 |
9 | 1 | 0 | 0 | 1 | 32 位 TSS |
10 | 1 | 0 | 1 | 0 | 保留 |
11 | 1 | 0 | 1 | 1 | 32 位 TSS(忙) |
12 | 1 | 1 | 0 | 0 | 32 位调用门 |
13 | 1 | 1 | 0 | 1 | 保留 |
14 | 1 | 1 | 1 | 0 | 32 位中断门 |
15 | 1 | 1 | 1 | 1 | 32 位陷阱门 |
X 表示是否可以执行( executable)。数据段总是不可执行的,X=0;代码段总是可以执行的,因此,X=1。
对于数据段来说, E 位指示段的扩展方向。 E=0 是向上扩展的,也就是向高地址方向扩展的,是普通的数据段; E=1 是向下扩展的,也就是向低地址方向扩展的,通常是堆栈段。
W 位指示段的读写属性,或者说段是否可写,W=0 的段是不允许写入的,否则会引发处理器异常中断;W=1 的段是可以正常写入的。
对于代码段来说,C 位指示段是否为特权级依从的(Conforming)。 C=0 表示非依从的代码段,这样的代码段可以从与它特权级相同的代码段调用,或者通过门调用; C=1 表示允许从低特权级的程序转移到该段执行。
R 位指示代码段是否允许读出。代码段总是可以执行的,但是,为了防止程序被破坏,它是不能写入的。至于是否有读出的可能,由 R 位指定。 R=0 表示不能读出,如果企图去读一个 R=0 的代码段,会引发处理器异常中断;如果 R=1,则代码段是可以读出的,即可以把这个段的内容当成 ROM 一样使用。
也许有人会问,既然代码段是不可读的,那处理器怎么从里面取指令执行呢?事实上, 这里的 R 属性并非用来限制处理器, 而是用来限制程序和指令的行为。
数据段和代码段的 A 位是已访问位,用于指示它所指向的段最近是否被访问过。 在描述符创建的时候,应该清零。之后,每当该段被访问时,处理器自动将该位置 1。
GDT / LDT
每个段都需要一个描述符,因此在内存中开辟出一段空间,用户存放这些描述符。在这段空间里,所有的描述符都是挨在一起集中存放的,这就构成一个描述符表。描述符表的长度可变,最多可以包含 8 K(8192)个这样的描述符。这是因为段选择子是 16 位的,其中低位的 3 bit 用来作标志位,余下 13 bit 只能索引 8192 个项。
X86 有 GDT 和 LDT 两种描述符表,它们可以形成两层的层次关系。但是系统中只有一个 GDTR 和一个 LTDR,因此在任何一个时刻,只有一个 GDT 和一个 LDT 生效。
其实 LDT 本质上和 GDT 是差不多的,区别在于全局(Global)可见和局部(Local)可见;LDT 表存放在 LDT 类型的段之中,此时 GDT 必须含有 LDT 的段描述符; LDT 本身是一个段,而 GDT 不是。
查找 GDT 在线性地址中的基地址,需要借助 GDTR 寄存器;而查找 LDT 相应基地址,需要的是 GDT 中的段描述符。访问 LDT 需要使用段选择符,为了减少访问 LDT 时候的段转换次 数,LDT 的段选择符、段基址、段限长都要放在 LDTR 寄存器之中。
对于操作系统来说,每个系统必须定义一个 GDT,用于系统中的所有任务和程序。可选择性定义若干个 LDT。GDT 本身不是一个段,而是线性地址空间(区别于页表寄存器,页表寄存器记录的是页表起始物理地址)的一个数据结构;GDT 的线性基地址和长度必须加载进 GDTR 之中。因为每个描述符长度是 8,所以 GDT 的基地址最好进行 8 字节对齐。
GDT 的线性基地址在 GDTR 中,又因为每个描述符占 8 字节,因此描述符在表内的偏移地址是索引号乘以 8。当处理器在执行任何改变段选择器的指令时(比如 pop
、mov
、jmp far
、call far
、iret
、retf
),就将指令中提供的索引号乘以 8 作为偏移地址,同 GDTR 中提供的线性基地址相加,以访问 GDT。如果没有发现什么问题(比如超出了 GDT 的界限), 就自动将找到的描述符加载到不可见的描述符高速缓存部分。加载的内容包括段的线性基地址、段界限和段的访问属性。此后,每当有访问内存的指令时,就不再访问 GDT 中的描述符,直接用当前段选择子对应的段寄存器描述符高速缓存器提供线性基地址。
LDTR 记录局部描述符表的起始位置,与 GDTR 不同,LDTR 的内容是一个段选择子。由于 LDT 本身同样是一段内存,也是一个段,所以它也有个描述符描述它,这个描述符就存储在 GDT 中,对应这个描述符也会有一个选择子,LDTR 装载的就是这样一个选择子。LDTR 可以在程序中随时改变,通过使用 lldt
指令。
总结起来,LDT 是一个段,段基址也是在 GDT 中记录,而 GDT 的基地址在 GDTR,LDTR 记录的是 LDT 在 GDT 中的索引。
中断描述符表 IDT
2 KB,最多 256 个中断描述符,里面可以是各种类型的门。
描述符寄存器:(GDTR 和 LDTR)
GDTR:共 48 位,高 32 位为全局描述符表 GDT 的基址,低 16 位存放 GDT 限长。
LDTR:共 16 位,高 13 位存放 LDT 在 GDT 中的索引。也就是说 LDTR 实际上和 CS / DS / SS / ES 等段寄存器本质上是一样的。