保护模式下的80386及其编程03:保护虚拟地址方式

目录

1 内存管理机制

1.1 概述

1.2 地址转换

1.2.1 转换什么地址?

1.2.2 段机制和分页机制

1.2.3 地址转换信息表

1.3 虚拟内存的概念

1.3.1 虚拟地址空间 / 线性地址空间 / 物理地址空间的大小

1.3.2 虚拟内存的实现机制

1.4 内存保护

1.4.1 隔离:任务间的保护

1.4.2 特权级:任务内的保护

1.4.3 组合保护与操作系统保护的实现

2 段机制

2.1 概述

2.1.1 段的参数与映射

2.1.2 段机制使用流程

2.2 段描述符

2.2.1 存储段描述符

2.2.2 系统段描述符

2.2.3 门描述符

2.3 段描述符表

2.3.1 概述

2.3.2 全局虚拟地址空间和局部虚拟地址空间

2.4 段选择子

2.4.1 段选择子格式

2.4.2 空选择子

2.5 段描述符投影寄存器

3 分页机制

3.1 概述

3.2 页表结构

3.2.1 单级页表结构

3.2.2 两级页表结构

3.3 页目录/页表项格式

3.3.1 P(Present)存在位

3.3.2 R/W(Read/Write)读写位

3.3.3 U/S(User/Supervisor)用户/系统位

3.3.4 A(Accessed)已访问位

3.3.5 D(Dirty)已写标志位

3.3.6 AVL(Available)程序可使用位

3.4 页属性与虚拟存储的实现

3.4.1 P位为虚拟存储提供关键属性

3.4.2 A位和D位协助虚拟存储的有效执行

3.5 页级保护

3.5.1 页级保护属性

3.5.2 组合保护属性

3.6 修改页表项的软件问题

3.6.1 有关TLB的问题

3.6.2 有关多处理器的问题

3.7 全局线性地址空间和局部线性地址空间

3.7.1 概述

3.7.2 全局线性地址空间

3.7.3 局部线性地址空间

4 处理器控制寄存器及系统段

4.1 处理器控制寄存器

4.1.1 CR0寄存器

4.1.2 CR1寄存器

4.1.3 CR2寄存器

4.1.4 CR3寄存器

4.2 段表基地址寄存器

4.2.1 GDTR与IDTR

4.2.2 LDTR与TR

4.3 任务状态段格式

4.3.1 概述

4.3.2 链接字段

4.3.3 高特权级栈指针

4.3.4 地址映射相关寄存器

4.3.5 寄存器保存区域

4.3.6 其他字段

5 特权级敏感指令

5.1 被赋予特权的指令

5.2 IO空间保护

5.2.1 IO敏感指令

5.2.2 IO空间保护机制

5.2.3 IO空间保护机制生效方式

5.2.4 IO许可位图

5.3 改变EFLAGS的指令

6 控制转移方法

6.1 同一特权级、同一任务的转移

6.1.1 一般转移规则

6.1.2 一致代码段

6.2 同一任务不同特权级的转移

6.2.1 概述

6.2.2 调用门(Call Gate)

6.2.3 栈切换


1 内存管理机制

1.1 概述

任何完整的内存管理系统都需要保护两个关键部分,

1. 内存保护

提供内存保护的目的,是要避免系统中的一个任务访问属于其他任务或属于操作系统的内存区域

2. 地址转换

地址转换不仅使操作系统可以灵活地将内存区域分配给各个任务,而且也是一种重要的保护机制

说明:内存管理和中断/异常管理属于80386硬件直接支持的操作系统特性,操作系统使用这些机制对计算机的资源(内存空间、执行时间以及外围设备)进行分配和保护。通过将这些资源分配给系统中的各个任务,并对这些资源进行保护,使得所有任务得以有效运行直至完成

从这里也可以看出,操作系统的设计是一个软硬件结合的过程

1.2 地址转换

1.2.1 转换什么地址?

1. 内存可以理解为一个存储字节的线性数组,其中每个字节占用一个唯一的地址,这是内存的物理地址

2. 程序在访问内存时,使用由(段选择子 + 偏移地址)构成的逻辑地址,逻辑地址也被称为虚拟地址

3. 逻辑地址并不直接用于寻址物理内存,而是需要地址转换机制将其映射为物理地址,也就是说完成逻辑地址到物理地址的转换

说明:由程序产生的每一个地址,无论是操作系统还是应用程序,使用的都是由(段选择子 + 偏移地址)构成的逻辑地址

1.2.2 段机制和分页机制

80386硬件提供的地址转换机制分为两级,即段机制和分页机制,他们构成的地址转换关系如下图所示,

1. 第一级使用段机制,将二维的虚拟地址转换为一维的线性地址

2. 第二级使用分页机制,将线性地址转换为物理地址

3. 在地址转换过程中,段机制是必选的,分页机制是可选的。如分页机制被禁用,则线性地址等于物理地址

说明:对于没有分段机制的体系结构(e.g. ARM,其实大多数体系结构都没有段机制),就没有虚拟地址到线性地址的转换,所以在MMU转换前的地址可以就称为虚拟地址或线性地址

1.2.3 地址转换信息表

1.2.3.1 地址转换信息表的概念

1. 无论是段机制还是分页机制,在进行地址转换时都需要一些转换信息与规则,我们这里称为地址转换信息表。地址转换信息表可以理解为一个转换函数,输入一个地址,根据规则输出另一个地址

2. 如果为每个字节的地址转换制定规则,地址转换信息表就过于庞大了,所以一般都是按一定的粒度将连续的内存映射成一个单元。这里的连续,既要求转换前地址的连续,也要求转换后地址的连续分段和分页就是采用不同的地址转换粒度

3. 一般将段机制使用的地址转换信息表称为段表,将分页机制使用的地址转换信息表称为页表

1.2.3.2 地址转换信息表的存储

1. 段表和页表最终都是要存储在物理内存中,才能供处理器使用

2. 段表存储在线性地址空间,如果使能分页机制,段表需要经过分页机制重定位到物理内存中,而段机制是不参与这个过程的。段机制只负责将虚拟地址转换为线性地址,并在线性地址中访问段表,而不会察觉分页机制已经将线性地址转换为物理地址,甚至不知道分页机制的存在

3. 页表存储在物理地址空间分页机制只负责将线性地址转换为物理地址,并且在物理地址中访问页表,并不知道虚拟地址空间的存在,甚至不知道段机制的存在

4. 段表和页表只允许操作系统访问,应用程序不能对其进行修改。而要实现这一点,就需要特权级的保护

5. 操作系统为每个任务维护一个不同的地址转换信息表,使得每个任务有各自的虚拟地址空间,从而实现了任务之间的隔离

1.2.3.3 地址转换信息表的保护

1. 首先说明一下问什么需要保护页表和段表

因为段表和页表属于核心数据,一旦被修改就修改了任务的虚拟地址空间,从而破坏了任务间的隔离(e.g. 假设一个任务可以修改段表和页表,则可以将自己的虚拟地址空间映射到任何物理地址空间中)

2. 有什么手段防止应用程序的随意修改

这就需要引入特权级的概念,让操作系统的指令运行在高特权级,应用程序的指令运行在低特权级。同时将段表和页表等核心数据的访问权限设置为高特权级,从而使得运行在低特权级的应用程序无法访问这些核心数据

1.3 虚拟内存的概念

1.3.1 虚拟地址空间 / 线性地址空间 / 物理地址空间的大小

要说明什么是虚拟内存以及为什么要引入虚拟内存,需要先说明地址转换机制涉及的3个地址空间及其大小

1.3.1.1 虚拟地址空间

要说明虚拟地址空间的大小,需要后续段描述符表和段选择子的相关知识,下面先说明此处需要用到的内容

1. 一个程序可以使用GDT和LDT两个段描述符表,每个描述符表最多2^13个表项

2. 段选择子的组成如下,其中的TI索引GDT和LDT中的一个,描述符索引字段索引一个描述符表中的2^13个表项

因此,一个程序最多可以索引(2 * 2^13 = 2^14)个段,而每个段的最大长度是4GB。因此,一个程序的虚拟地址空间最大为(2^14 * 4GB = 2^14 * 4 * 2^30 = 2^46B = 64TB

1.3.1.2 线性地址空间

1. 线性地址空间的大小由处理器位数决定

2. 80386为32位处理器,因此线性地址空间大小为4GB

3. 线性地址空间的大小与实际使用的物理内存大小无关

说明:在有段机制的X86体系结构中,应用程序使用的是虚拟地址空间(段选择子 + 偏移地址)。在没有段机制的体系结构中,应用程序使用的就是线性地址空间

1.3.1.3 物理地址空间

1. 物理地址空间的大小由处理器的地址线数量决定

2. 80386有32根地址线,因此物理地址空间大小为4GB

3. 虽然80386物理地址空间为4GB,但是实际使用的物理内存可能小于4GB,这有两方面的原因,

物理地址空间并不是全部给内存使用的,比如BIOS也会占用物理地址空间,还有一些设备会将设备内的ROM或RAM映射到物理地址空间中操作

② 物理地址空间中预留给内存使用的部分,表示可使用的最大物理内存容量,系统中实际安装的物理内存可以小于该容量

说明:物理地址扩展(Physical Address Extension,PAE)技术

① Intel处理器从80386到Pentium使用的地址线均为32位,因此物理地址空间为4GB。但是正如上文所述,实际可用的物理内存小于4GB

② 为了增加可使用的物理内存容量,Intel从Pentium Pro开始,在处理器仍然是32位的基础上,将地址线从32位增加到36位,因此物理地址空间增加到64GB

虽然PAE技术扩展了物理地址空间,但是由于处理器仍然是32位的,所以并没有扩展线性地址空间。要根治这个问题,还是需要使用64位处理器

从中可以看出,虚拟地址空间和线性 / 物理地址空间的大小存在巨大差异。应用程序使用的是虚拟地址空间,而应用程序的开发者希望在开发过程中不要受到实际物理内存大小的束缚,这样才能开发通用的应用程序,因此就提出了虚拟内存的概念

1.3.2 虚拟内存的实现机制

虚拟内存的实现由内存管理机制硬盘存储器支持,核心是在程序运行的任何时刻,只将虚拟地址空间中的一小部分映射到物理内存中,其余部分则存储在硬盘上。这种方法的可行性,依赖程序访问内存的局部性

其中内存管理机制以如下方式工作,

1. 将实际驻留在物理内存中的那部分虚拟内存标记为有效,并建立虚拟内存到物理内存的映射;其余未加载到物理内存的虚拟内存则标记为无效

2. 当程序访问尚未映射的虚拟内存时,会因为没有有效映射信息而引起异常(缺页异常)。操作系统在该异常的处理过程中,将磁盘上的内存加载到物理内存,并建立映射关系。当异常返回后,会再次执行原先触发异常的指令,由于此时映射已建立,所以可以正常访问

3. 操作系统通过地址转换机制收集驻留在物理内存中的虚拟内存的使用统计信息,以决定在物理内存紧张时,将哪些部分存储回硬盘

说明:在80386上,分页机制是支持虚拟内存的最佳选择

1.4 内存保护

80386支持两种主要的内存保护类型,

1. 通过给每个任务分配不同的虚拟地址空间,使任务之间完全隔离

2. 任务内的保护机制,保护操作系统的内存段以及特殊的处理器寄存器不被应用程序访问

1.4.1 隔离:任务间的保护

1. 在80386上,通过赋予每个任务不同的地址转换信息表将每个任务放置在不同的虚拟地址空间,从而实现任务间的隔离

由于每个任务有自己独立的段表及页表,操作系统可以为他们指定不同的映射规则,将不同任务对同一虚拟地址的访问映射到不同的物理地址

2. 当处理器进行任务切换时,需要进行段表和页表的切换。任务切换了,任务所在的虚拟地址空也随之切换

1.4.2 特权级:任务内的保护

1. 80386定义了4个特权级(0特权级最高,1特权级最低),在一个任务内,用来限制对任务中段的访问

这里其实包含了2层含义,

特权级主要在任务内起作用,这主要是因为已经通过不同的段表和页表将任务的虚拟地址空间隔离,在一个任务内无法访问其他任务的虚拟地址空间

特权级以段为单位生效

2. 每个内存段都与一个特权级相联系,他用于控制只有拥有足够特权级的程序,才可以对相应的段进行访问

这里就自然涉及一个特权级比较的问题,此处访问对象的特权级就是与每个内存段的特权级,而访问主体的特权级则是当前CS段寄存器中的最后2位,也就是CPL(Current Privilege Level,当前代码段特权级)

3. 当一个程序试图访问一个内存段时,处理器就将CPL和要访问的段的特权级进行比较,以决定是否允许这一访问。只有当CPL <= 访问对象的特权级时,才允许访问;否则访问是非法的,此时处理器会产生一个异常,向操作系统报告这一违例操作

说明1:特权级控制访问示例

以上图为例,当前执行的程序位于特权级1,因此CPL = 1,所以可以访问特权级1和特权级3的内存段,但是不能访问特权级0的内存段

说明2:特权级的典型用法

① 特权级0:操作系统核心

② 特权级1:操作系统的其余部分(e.g. 设备驱动)

③ 特权级2:预留特权级,可供中间软件使用

④ 特权级3:应用程序

但是在实际操作系统中,会简化用法,例如Linux就只使用了特权级0(运行操作系统内核,包括设备驱动)和特权级3(运行应用程序)

说明3:任务栈的特权级

① 一个任务在每个特权级有各自独立的栈段

② 当任务从一个特权级切换到另一个特权级执行时,任务使用的栈也需要切换到与目标特权级相同的栈

1.4.3 组合保护与操作系统保护的实现

1.4.3.1 操作系统保护目标

1. 保护操作系统不被应用程序破坏

这里主要的目标是使得应用程序不能随意访问操作系统所在的内存段,也不能直接跳转到操作系统执行

2. 所有应用程序可以共享操作系统提供的功能

这里主要是指需要提供一种手段,使得应用程序可以合法地跳转到操作系统执行。所谓使用操作系统提供的功能,是通过执行操作系统提供的函数实现的

1.4.3.2 使用隔离进行保护

1. 使用隔离方式保护操作系统,就是将操作系统存储在一个单独的任务中,有自己的虚拟地址空间

2. 这种方式可以保护操作系统不被应用程序破坏,但是不便于应用程序调用操作系统提供的功能,同时也不便于操作系统管理所有资源

1.4.3.3 使用特权级进行保护

1. 使用特权级保护操作系统,就是操作系统存储在任务的虚拟地址空间中,之后通过特权级确保任务无法访问操作系统所在的内存段

3. 为了让所有任务能够共享操作系统提供的功能,需要将操作系统所在的内存映射到所有任务的虚拟地址空间中。这就自然引出了任务的全局地址空间和局部地址空间的概念

1.4.3.4 全局地址空间和局部地址空间

1. 全局地址空间

① 将操作系统存储在虚拟地址空间的一个公共区域

② 每个任务按此区域分配一个同样的虚拟地址空间,并且映射到同样的物理地址空间。各个任务共用的这部分虚拟地址空间,就被称为全局地址空间

2. 局部地址空间

① 仅由一个任务占有,不被任何其他任务共享的虚拟地址部分,被称为局部地址空间

② 局部地址空间包含的代码和数据是任务私有的,需要与系统中的其他任务隔离

说明1:全局地址空间与局部地址空间实例

上图为80386 + Linux的虚拟地址空间布局,任务的虚拟地址空间共4GB(更准确的说是线性地址空间),其中,

① 0 ~ 3GB为用户空间,每个进程私有,与其他进程隔离

② 3 ~ 4GB为内核空间,由系统中的所有进程共享

说明2:Linux内核High memory Zone概述

① 按上图的线性地址空间布局,内核线性地址空间只有1GB,因此可映射的物理内存最多也只有1GB。如果实际的物理内存超过1GB,则Linux内核无法对所有内存进行线性映射管理

② 此时Linux内核不会将1GB线性地址空间全部进行线性映射,而是只映射最多896MB物理内存,预留了最高端的128MB虚拟地址空间给IO设备和启动用途。而这里超出896MB之外的物理内存,就是所谓的High memory zone

1.4.3.5 使用组合保护

最终对操作系统的保护通过组合保护实现,其中,

1. 局部地址空间是隔离的,体现在段表和页表上,就是不同任务的局部地址空间有各自的映射规则

2. 全局地址空间是共享的,体现在段表和页表上,就是不同任务的全局地址空间所占据的虚拟地址空间是相同的,而且也使用相同的映射规则

但是全局地址空间所在的内存段被设置为高特权级,局部地址空间所在的内存段被设置为低特权级,从而实现对操作系统的保护

说明:映射规则在实现中,就体现为段表表项和页表表项。因此,

① 所谓局部地址空间有各自的映射规则,就是局部地址空间使用的表项不同

② 所谓全局地址空间使用相同的映射规则,就是全局地址空间使用的表项相同

2 段机制

2.1 概述

2.1.1 段的参数与映射

段是实现虚拟地址到线性地址转换的基础,每个段由3个参数进行定义,

1. 段的基地址(Base Address)

表示段在线性地址空间中的起始地址

② 虚拟地址中的偏移量就是相对于段基地址的偏移量

2. 段的界限(Limit)

表示段的容量

② 虚拟地址中的偏移量范围受到段界限的限制

3. 段的属性(Attributes)

表示段的特性

② 包括段是否可读写、是否可执行,以及段的特权级等

根据上述参数,段机制就可以将虚拟地址空间中的段映射到线性地址空间中。再次强调一下,段是存在于虚拟地址空间中的

说明:关于段界限的合法性检查

① 界限检查

如果访问内存时,虚拟地址中的偏移量超过段界限,将触发异常

② 权限检查

对一个段进行段的属性所不允许的操作,也将触发异常

2.1.2 段机制使用流程

2.1.2.1 段的描述

1. 段使用段描述符进行描述

2. 上文提到的段的基地址、界限和权限就记录在段描述符中

3. 段描述符本质上是一个存储在线性地址空间中的数据结构,处理器硬件规定了他的格式,软件依据该格式进行构造

2.1.2.2 段的登记

1. 段描述符存储在段描述符表

2. 段描述符表是一个段描述符数组

2.1.2.3 段的引用

1. 使用段选择子来引用一个段

2. 段选择子指定了引用的段描述符的位置,从而标识一个段

下面就分别说明流程中涉及的段描述符、段描述符表和段选择子

2.2 段描述符

1. 段描述符按类型可以分为存储段描述符、系统段描述符和门描述符

2. 在80386中,他们的长度均为8B

3. 不同类型的段描述符有相似之处,处理器通过段描述符中的关键比特位来判断段描述符类型

2.2.1 存储段描述符

2.2.1.1 DT位描述符类型位

1. 存储段是存放由程序直接进行访问的代码及数据的段

2. 当段描述符属性字段的DT = 1,表示该段为存储段;当DT = 0,表示该段为系统段或门

3. 存储段描述符本质上是描述一段线性地址空间中的内存

2.2.1.2 基地址(Base

1. 基地址标识了段在线性地址空间中的起始地址,长度为32位

2. 因为基地址长度和处理器长度相同,所以任何一个段都可以从32位线性地址空间中的任何一个字节地址开始

2.2.1.3 G位粒度(Granularity)位

1. G位是界限粒度属性

2. 当G = 0,表示界限粒度为字节;当G = 1,表示界限粒度为4KB

3. G位只影响段界限的粒度,对段的基地址没有影响,基地址总是以字节为粒度

2.2.1.4 界限(Limit

1. 之所以要有G位粒度位,是因为在段描述符中,段界限只有20位

2. 当G = 0,20位段界限的最大值为1MB,增量为1B;当G = 1,20位段界限的最大值为4GB,增量为4KB

3. 这里的增量,是指20位段界限值加1,实际的段界限增加多少

2.2.1.5 D

D位对于不同类型的段有不同的含义,总体上是为了处理与80286的兼容性。在80386软件中,D位总应该为1

1. 对于可执行段

① D位指定指令中默认地址和默认操作数的长度

② D = 1,表示指令使用的默认值是32位地址,32位或8位操作数,这是80386的正常使用情况

③ D = 0,表示指令使用的默认值是16位地址,16位或8位操作数,这是为了与80286系统兼容

④ 使用指令前缀可以进行默认地址和默认操作数长度的反转

2. 对于向下扩展的段

① D位指定段的上部边界

② D = 1,表示段的上部边界为4GB(0xFFFFFFFF,32位)

③ D = 0,表示段的上部边界为64KB(0xFFFF,16位),这是为了与80286兼容

3. 对于由SS寄存器寻址的段,也就是栈段

① D位确定隐式栈访问时使用的寄存器

② D = 1,表示隐式栈访问指令(e.g. push / pop / call指令)使用32位的ESP寄存器

② D = 0,表示隐式栈访问指令使用16位的SP寄存器

2.2.1.6 P位存在(Present)位

1. P = 1,表示当前段描述符对地址转换是有效的

2. P = 0,表示当前段描述符无效,使用该描述符会引起异常

2.2.1.7 AVL软件可利用(Available)位

1. AVL位是软件可使用的位,通常由操作系统使用

2. Intel确保后续对AVL位使用的兼容性

2.2.1.8 DPL描述符特权级

DPL共2位,用于定义与段相关联的特权级

2.2.1.9 Type类型字段

存储段描述符支持的类型如下,

1. X(eXecutable)执行位

① X位表示段是否可执行

② 数据段不可执行,X = 0;代码段可执行,X = 1

2. E(Expand)扩展位

① 用于不可执行段,表示段的扩展方向

② E = 0,向上(高地址方向)扩展;E = 1,向下(低地址方向)扩展

3. W(Writable)可写位

① 用于不可执行段,表示段是否可写(不可执行段总是可读的)

② W = 0,不允许写入,如写入将触发异常;W = 1,允许写入

4. C(Conforming)依从位

① 用于可执行段,表示段是否是特权级依从的

② 关于依从代码段的相关内容,详见下文分析

5. R(Readable)可读位

① 用于可执行段,表示代码段是否允许读出(为了防止程序被破坏,代码段总是不可写的)

② R = 0,不允许读出,如读出将触发异常;R = 1,允许读出

③ R位限制的不是处理器读取指令的行为,而是限制程序像访问数据段一样访问代码段的内容。一个典型的例子,就是使用mov指令 + "CS:"段超越前缀读取代码段中的内容

6. A(Accessed)已访问位

① 当使用指令将段描述符相应的段选择子加载到段寄存器时,表示段描述符已由80386访问,此时处理器会将A位置为1

② 操作系统可以访问该位,对该位的清零也由操作系统复杂。通过定期监视该位的状态,就可以统计出该段的使用频率

说明:段扩展方向位对段界限计算的影响

向下扩展段的界限计算与向上扩展段是相反的,当把段的界限视为4GB,

① 对于向上扩展段,[0, limit]是合法的偏移量,[limit + 1, 4GB - 1]是非法的偏移量

② 对于向下扩展段,[0, limit]是非法的偏移量,[limit + 1, 4GB - 1]是合法的偏移量

2.2.2 系统段描述符

2.2.2.1 概述

1. 系统段是80386段机制使用的一种特殊的段

2. 系统段描述符本质上也是描述一片线性地址空间,只是这段空间由处理器以特定方式使用

下面说明系统段描述符中与存储段描述符不同的字段,

2.2.2.2 D

1. 存储段描述符中的D位,在系统段描述符中不使用

2. 上图中标为X

2.2.2.3 Type类型字段

系统段描述符的类型字段也是4位,但是编码及含义与存储段描述符完全不同,他标识了系统段和各种门的类型

2.2.3 门描述符

2.2.3.1 概述

1. 门描述符不是用来描述一个段,而是包含一个指针

2. 此处包含的48位全指针,即(16位段选择子 + 32位偏移量)

2.2.3.2 Dword Count字段

1. Dword Count字段仅对调用门有效

2. 在通过调用门调用另一个过程时,有时需要传递一些参数给被调用的过程,这些参数一般存储在栈中。如果由于调用门的使用引起特权级的切换,从而又导致不同特权级栈的切换,则需要将参数从一个栈拷贝到另一个栈中

3. Dword Count字段就是用于给出要拷贝的双字(4B)参数的数量

说明1:调用门使用场景示例

① 假设当前执行的代码特权级为3,需要通过调用门调用一个特权级为0的过程,并且有参数通过栈进行传递

② 此时在调用端,需要将参数压入栈中,同时在构造调用门时,需要在Dword Count字段指定要拷贝的4B长度参数个数

③ 在通过调用门进行控制转移时,处理器会根据Dword Count字段将参数从特权级为3的栈拷贝到特权级为0的栈

说明2:如果使用寄存器进行传参,则可以省略上面的参数拷贝过程,但是在80386处理器中,通用寄存器的数量是不多的

2.3 段描述符表

2.3.1 概述

1. 段描述符表包括全局段描述符表(GDT)和局部段描述符表(LDT)

2. 段描述符表本质上是一段线性地址空间,但是属于系统段

更准确地说,因为LDT可以有多个,所以实现为一个系统段;而GDT只有一个,所以直接使用GDTR寄存器指向,而没有再实现为一个系统段

3. 段描述符表只能由操作系统进行访问,以避免应用程序修改地址转换信息,因此需要将段描述符表存储在高特权级数据段中

2.3.2 全局虚拟地址空间和局部虚拟地址空间

2.3.2.1 全局虚拟地址空间

1. 对于一个任务而言,虚拟地址空间的一半由GDT进行映射,共2^13个段,构成全局虚拟地址空间所谓全局,是指所有任务都可以访问,而访问的方式就是向段寄存器中加载相应的段选择子

2. 在系统中,所有任务共享的段通过GDT进行映射,一般包括含有操作系统的段及系统中所有任务的LDT段(还有所有任务的TSS段),这些LDT(和TSS)段可视为属于操作系统的数据

2.3.2.2 局部虚拟地址空间

1. 虚拟地址空间的另一半由LDT进行映射,也是2^13个段,构成局部虚拟地址空间

所谓局部,是指每个任务可以有自己的LDT,不同任务相互隔离,每个任务只能访问属于自己的LDT中的段

2. LDT作为一个系统段,也是需要描述和登记的。描述就是使用系统段描述符,而登记就是登记在GDT中的。而任务自己的代码段和数据段,则是登记在任务自己的LDT中

说明1:这里再次强调一下,虚拟地址空间就是由段组成的,而段机制的作用,是将虚拟地址空间中的段映射到线性地址空间中

说明2:LDT的指向与切换

① 由于系统中有多个LDT,因此引入LDTR寄存器指向当前任务使用的LDT

② 当任务切换时,LDTR寄存器指向的LDT也需要随之切换

2.4 段选择子

2.4.1 段选择子格式

段选择子用于标识GDTLDT中的一个段,可以将段选择子视为段的名字,段选择子的构成如下,

1. 描述符表指示器TI(Table Indicator)

TI = 0,表示描述符在GDT中;TI = 0,表示描述符在LDT中

2. 描述符索引(Index)

共13位,正好最多可以索引8192个描述符,与GDT & LDT的最大表项数匹配。或者从另一个方面说,是Index的位数决定了GDT & LDT的最大表项数

② Index的值为描述符在描述符表中以表项为单位的偏移量

3. 请求特权级RPL(Request Privilege Level)

表示实际对象访问请求者的特权级,这也是一个软硬件结合的确权机制,详情可参考X86汇编语言从实模式到保护模式16:特权级和特权级保护 chapter 3.2

说明:当段选择子中的TI位为1时,只能索引LDTR寄存器当前指向的LDT,无法访问其他任务的LDT,这就实现了任务间虚拟地址空间的隔离

2.4.2 空选择子

1. 16位全为0的选择子被称为空选择子,此时Index / TI / RPL字段均为0,因此索引到的是GDT的第0个表项

2. 由于空选择子在内存访问中有特殊的作用(详见下文),因此GDT需要预留出第0个表项,不在此处登记可用的描述符

2.5 段描述符投影寄存器

1. 处理器对于每一次的处理器访问,都会根据段描述符中设置的界限值进行合法性检查,而段描述符是存储在GDT或LDT中的,最终也是存储在物理内存中的。如果每次合法性检查都需要访问一次内存,会严重影响效率

2. 为解决上述问题,处理器为每个段寄存器提供了相应的描述符投影寄存器(也就教材翻译为描述符高速缓存,这个就是从功能的层面进行翻译,原文为shadow)

当程序将段选择子成功加载到段寄存器之后,处理器会将该段选择子索引的段描述符信息存储在描述符投影寄存器中,在后续的合法性检查中直接使用其中的内容,从而避免访问内存

3. 段寄存器对程序员是可见的,与之相联系的描述符投影寄存器对程序员是不可见的

说明1:加载段选择子的合法性检查

除了上文中提到的内存访问合法性检查,还有一个加载段选择子时的合法性检查。就是在将段选择子加载到段寄存器时,处理器会判断当前执行的程序是否有权限加载该段选择子,也就是是否有权限访问该选择子指向的段

只有当权限检查通过时,才会将段选择子加载到段寄存器中,同时更新相应的描述符投影寄存器的内容;否则上述内容不变,并且产生异常

说明2:对段描述符的修改需要及时同步到描述符投影寄存器中

描述符投影寄存器中的内容是描述符内容的拷贝,因此操作系统需要保证对描述符的修改要及时反映在描述符投影寄存器中。否则可能操作系统已经改变了一个段的基地址和界限,但是描述符投影寄存器中存储的仍是修改前的值

② 一种简单的处理方法,是在改变了描述符表中的任意一个描述符之后,重新装入所有6个段寄存器,从而使描述符表中的最新信息加载到描述符投影寄存器中

3 分页机制

3.1 概述

1. 分页机制在段机制之后进行操作,将段机制生成的线性地址进一步转换为物理地址

分页机制的使能是可设置的,如果禁用分页机制,则直接将段机制生成的线性地址作为物理地址使用

2. 段机制管理的是大小可变的存储器块;而分页机制管理的是固定大小的存储块,称之为

80386使用4KB大小的页,且在4KB的边界上对齐,即每一页的起始地址都能被4KB整除

3. 分页机制将整个线性地址空间和整个物理地址空间均看成由页组成,在线性地址空间中的任何一页,可以映射为物理地址空间中的任何一页

3.2 页表结构

3.2.1 单级页表结构

1. 由于80386使用4KB大小的页,因此将4GB的线性地址空间划分为2^20个页

2. 因为每个页的起始地址都是4KB对齐的,所以线性地址的低12位不加改变地经过分页机制直接作为物理地址的低12;实现映射的转换函数,只需要将线性地址的高20位转换为物理地址的高20

3. 如果使用单级页表实现映射转换函数,可以将单级页表看作一个包含2^20个成员的数组,线性地址的高20位形成对数组的索引,而索引的结果是对应物理页的高20位地址

4. 每个页表项为32位,其中20位用于存储物理页地址的高20位,剩下的12位可以用来存储页的属性。整个单级页表的大小为4 * 2^20 = 4MB,而且是需要连续的4MB物理内存

说明:这里再次强调一下,页表总是存储在物理地址空间中的

3.2.2 两级页表结构

单级页表的主要问题,就是每个页表需要占用4MB连续的物理内存。为了避免如此大量的内存消耗,引入了多级页表,其中80386硬件支持两级页表

3.2.2.1 两级页表的构成

下面说明两级页表的构成,这里理解的关键是页目录和页表也是一个数组,因此可以从下面4个维度去理解,

① 数组存储在哪里

② 数组的起始地址在哪里

③ 由谁索引数组

④ 数组成员的含义

1. 两级页表的第一级称作页目录,

① 页目录存储在一个4KB页中

② 页目录的物理基地址存储在CR3寄存器中

③ 使用线性地址的最高10位索引页目录

④ 页目录的成员是页目录项,每个页目录项指向一个页表(本质上也是一个4KB页,只是处理器将其解释为页表),页目录项中包含页表的物理基地址

⑤ 每个页目录项为4B,因此一个页目录可以指向(4096 / 4 = 1024)个页表

2. 两级页表的第二级称作页表,

① 页表也存储在一个4KB页中

② 页表的物理基地址记录在指向他的页目录项中

③ 使用线性地址的中间10位索引页表

④ 页表的成员是页表项,每个页表项指向一个4KB页,页表项中包含物理页的物理基地址

⑤ 每个页表项为4B,因此一个页表可以指向(4096 / 4 = 1024)个物理页

3.2.2.2 页表项 / 页表 / 页目录项 / 页目录映射的内存大小

自底向上说明每层映射的内存大小,

1. 每个页表项指向一个物理页,因此映射4KB内存

2. 每个页表指向1024个物理页,因此映射(1024 * 4KB = 4MB)内存

3. 每个页目录项指向一个页表,因此和页表一样,映射4MB内存

4. 每个页目录指向1024个页表,因此映射(1024 * 4MB = 4GB)内存

3.2.2.3 使用两级页表如何减少内存消耗

这个问题需要从2个方面理解,

1. 内存连续性要求

① 存储单级页表需要4MB连续内存

两级页表可以离散地存储在物理内存的任意页中,因此对连续内存的要求就降低到4KB

2. 内存使用总量

① 如果将两级页表填满,则共有1个页目录和1024个页表,总共需要(4KB + 4MB),其实比单级页表还多用4KB

② 但是在两级页表中,对于线性地址空间中不存在或者不使用的部分不必分配页表。而任何一个实际运行的程序所使用的线性地址空间都远小于4GB,所以页表所需内存就远小于4MB

③ 之所以单级页表不能根据程序对线性地址空间的使用来动态分配页表,也是和程序的线性地址空间布局有关(这点一般是操作系统约定的)。以Linux为例,进程的地址空间两头实中间空,因此如果使用单级页表,就必须占用4MB内存

3.3 页目录/页表项格式

80386中的页目录项和页表项格式相同,共4B,其中高20位存储页表/页的物理地址的高20位,低12位存储页表/页的属性

说明:图中内容为0的位,是Intel为今后开发的处理器预留的位,在使用中这些位必须设置为0,以保证与后续处理器的兼容性

3.3.1 PPresent)存在位

1. P = 1,表示表项对地址转换有效;P = 0,表示表项对地址转换无效

2. 在页转换期间,无论是遇到无效页目录项还是页表项,都会产生异常,虚拟内存即依靠该异常实现

3. 当P = 0,表项中的其余各位可供软件使用,80386不解释P = 0的表项中的任何其他位

3.3.2 R/WRead/Write)读写位

1. 对于页表项,当R/W = 1,表示对该页表项映射的页可进行读 / 写 / 执行;当R/W = 0,表示对该页表项映射的页可读可执行,但是不能进行写操作

2. 对于页目录项,R/W位属性应用于该页目录项映射的所有页

3. RW位并不总是起作用的,当处理器处于特权模式(即0 / 1 / 2特权级),RW位被忽略

3.3.3 U/SUser/Supervisor)用户/系统位

1. 对于页表项,当U/S = 1,表示该页表项映射的页可以由任何特权级下执行的程序访问;当U/S = 0,表示该页表项映射的页只能由特权模式(即0 / 1 / 2特权级)下执行的程序访问

2. 对于页目录项,U/S位属性应用于该页目录项映射的所有页

3.3.4 AAccessed)已访问位

1. 对于页表项,当对该页表项映射的页进行任何访问时,由处理器将A位置1

2. 对于页目录项,当该页目录项映射的任一页进行任何访问时,由处理器将A位置1

3. 处理器不会清除A位,一般由操作系统清除。操作系统为获取页使用情况的统计信息,可以周期性地清除A位

3.3.5 DDirty)已写标志位

1. 对于页表项,当对该页表项映射的页进行写操作时,由处理器将D位置1

2. 处理器不修改页目录项的D位

3.3.6 AVLAvailable)程序可使用位

AVL字段供软件使用,处理器不会对其进行修改

说明:在《X86汇编语言:从实模式到保护模式》中的页目录项和页表项中,有与Cache和TLB相关的属性。可见在80386处理器中并没有这些属性,而是由后续的处理器引入,而使用的就是80386中的预留位

3.4 页属性与虚拟存储的实现

3.4.1 P位为虚拟存储提供关键属性

1. 对于已经与物理页建立映射的线性地址空间中的页,对应页表项P位被置为1,通过页表项可以得到对应的物理地址

2. 对于没有与物理页建立映射的线性地址空间中的页,对应页表项P位被置为0,如果程序访问这些线性地址空间中的页,将会产生异常

3. 对于这种异常(缺页异常,page fault),需要区分2种情况,

线性地址是操作系统不支持的地址

对于这种情况,因为程序访问了无效或非法的地址,操作系统应该将其终止

线性地址对应的页存储在磁盘上,而不是在物理内存中

对于这种情况,该无效地址实际上是请求操作系统的虚拟存储管理系统,将存储在磁盘上的页传送到物理内存中,并填充页表项建立映射,使得该线性地址可以被访问

4. 页目录项中的P位,也可以起到类似的功能,

① 通过缺页异常,可以使操作系统按需分配页表,即只分配覆盖实际使用的线性地址范围的页表。这也是使用两级页表可以减少页表内存消耗的原因

② 可以将暂时不使用的页表存储在磁盘中(页表本质上也是一个物理页),并且在需要时在缺页异常处理中恢复,这样可以最大限度地减少存储页表所需的物理内存

说明1:当缺页异常发生时,操作系统需要有一种机制用于判断触发异常的地址合法性,只有在地址合法时,才会进行从磁盘加载数据到内存的操作

以Linux 2.4.0内核为例(arch/i386/mm/fault.c),通过VMA(Virtual Memory Area)机制来检查地址的合法性。VMA的相关内容可参考Linux操作系统原理与应用04:内存管理 chapter 2

说明2:如果经过上面的检查,触发缺页异常的地址是合法的,那么就需要将相应的数据从磁盘加载到物理内存(这是一个很复杂的话题),并填充页表建立映射

而有了触发缺页异常的线性地址,就知道了该填充哪些页目录项和页表项,步骤如下,

① 要填充的页目录项物理地址 = 页目录物理基地址 + 线性地址高10位 * 4

页目录项可能已经填充过,此时就只需要填充下一级的页表项

② 要填充的页表项物理地址 = 页表物理基地址 + 线性地址中间10位 * 4

此处的页表物理基地址,就包含在上级的页目录项中

3.4.2 A位和D位协助虚拟存储的有效执行

1. 通过周期性地检测和清除所有的A位,操作系统可以确定哪些页在最近一段时间未被访问。当内存资源紧张时,操作系统可以选择这些最近未被访问的页,并将他们换出到磁盘上

2. 当一个页从磁盘上读入时,D位被置为0。如果虚拟存储系统需要将该页写回磁盘,若该页的D位仍然为0,即没有被写入过,则此时不需要实际的磁盘写操作,只要简单放弃内存中的页即可

说明:此处判断内存中的页是否被写过,完全依靠页表项中的D位,而不是页的内容。假设页中的内存被写入多次并恰好使得内容未被改变,但是由于D位会被置为1,仍然需要写磁盘的操作

3.5 页级保护

3.5.1 页级保护属性

1. 页级保护属性主要是U/S位和R/W位对页访问权限的控制

2. 页级保护在段级保护之后生效,如果段级权限检查就已经失败,则不会进入页级权限检查

3. 在页级权限检查中只有两种特权级,即超级特权级(0 / 1 / 2特权级)和用户特权级级(3特权级)

3.5.2 组合保护属性

如上文所述,有些属性在页目录项和页表项中均可设置,此时会按"逻辑与"操作来得到组合保护属性,因此具有相对于两个级别更严格的限制

说明:因为有了页级保护,而且Linux操作系统只使用0特权级和3特权级,所以Linux操作系统通过平坦模型绕过了段机制,只对段机制进行了非常有限的使用

在Linux内核中建立了内核代码段、内核数据段、用户代码段和用户数据段4个覆盖完整4GB线性地址空间的段描述符(arch/i386/kernel/head.S)

3.6 修改页表项的软件问题

3.6.1 有关TLB的问题

1. 为避免在每次内存访问时查询两级页表,80386处理器硬件将最近使用的线性地址到物理地址的转换关系缓存在TLB中。每次访问内存时,处理器先在TLB中查找,只有当查找miss时,才访问两级页表

2. 但是TLB中缓存的数据和两级页表中数据的相关性,并不是由80386处理器进行维护的。简单来说,就是操作系统对页表的修改不会自动同步到TLB

3. 为了确保TLB与页表数据的一致性,需要操作系统在修改页表后刷新TLB。而刷新TLB可以通过加载控制寄存器CR3实现,代码如下,

mov eax, cr3

mov cr3, eax

说明:如果是修改P位为0的页目录项或页表项,则不需要刷新TLB,因为P位为0的项本身就不会存入TLB

对于将页目录项或页表项的P位从0修改为1的操作,也是不需要刷新TLB的,原因是一样的。这就表示在从磁盘上读入一页使其存在时,不必刷新TLB

3.6.2 有关多处理器的问题

1. 在一个多处理器系统中,必须注意在一个处理器中执行的程序,是否会修改其他处理器正在访问的页表

2. 在80386处理器中,每当要更新页表项时,可以使用Lock前缀锁住总线,从而使用不可分割的读-修改-写流程完成修改

在修改一个可能有其他处理器使用的页表之前,最好使用一条加锁的AND指令在一个不可分割的操作中将P位清零。然后根据需要进行修改,并将P位置为1

3. 当修改页表项时,必须及时通知(一般使用核间中断)系统中已经将该表项缓存在TLB中的所有处理器,以便他们invalid缓存的表项

3.7 全局线性地址空间和局部线性地址空间

3.7.1 概述

1. 首先需要强调的是,与段机制不同,将任务的页表划分为全局页表和局部页表,不是硬件上的要求

段机制是在硬件层面设置了GDT和LDT,从而实现了全局虚拟地址空间和局部虚拟地址空间的划分

2. 有了全局页表和局部页表之后,自然就有了全局线性地址空间局部线性地址空间的划分

3.7.2 全局线性地址空间

全局页表按如下方式实现在每个任务中,

1. 在每个任务的线性地址空间中,划分相同的区域作为线性地址空间中的全局部分

以80386 + Linux为例,将线性地址空间中的3 ~ 4GB作为内核空间。这种区域的划分,完全是操作系统软件决定的

2. 对于每个任务线性地址空间中的全局部分,使用相同的映射规则映射到相同的物理地址。这里存储相同映射规则的页表,就是全局页表。而全局页表指向的物理页,就称作全局页

在80386 + Linux中,全局页表有一个更熟悉的名字,就是内核页表

3. 每个任务有自己的页目录,页目录中与全局线性地址空间对应的页目录项指向全局页表

以80386 + Linux为例,由于地址空间中的3 ~ 4GB作为内核空间,因此将页目录的第768 ~ 1023项指向全局页表

4. 在上图中,不同的任务通过共享第二级页表及其指向的全局页,实现了全局线性地址空间。这样做有如下3个优点,

① 只需要分配一组第二级页表映射全局页,这些页表由所有任务共享,这就极大地减少了存储页表所需的内存

② 如果全局页的状态改变,只需要更新一个页表项(全局页表中的页表项),就可以在所有任务中反映状态的变化

③ 如果一个全局页被换出到磁盘或者被换入内存,也只需要更新一个页表项

3.7.3 局部线性地址空间

1. 线性地址空间中余下的部分,局部于每一个不同的任务

以80386 + Linux为例,将线性地址空间中的0 ~ 3GB作为用户空间

2. 通过为每个任务建立不同的页表,使得不同的地址映射规则作用于线性地址空间的局部范围。这里存储每个任务不同映射规则的页表,就是局部页表。而局部页表指向的物理页,就称作局部页

在80386 + Linux中,局部页表也被称为用户页表

3. 在每个任务的页目录中,与局部线性地址空间对应的页目录项指向局部页表,而且这些局部页表是按需分配

以80386 + Linux为例,由于地址空间中的0 ~ 3GB作为用户空间,因此将页目录的第0 ~ 767项指向局部页表

说明1:全局页表和局部页表的使用

① 与段一样,操作系统的代码、数据及段描述符表,应存储在全局页中,并通过全局页表映射

② 仅属于一个任务的代码和数据应存储在局部页中,并通过局部页表映射

③ 段表及页表的建立,最好使得由GDT映射的段存储在全局页中,由LDT映射的段存储在局部于任务的页中(Linux 0.11内核就使用了这种方法,而后续的Linux内核则使用平坦模型绕过了段机制)

说明2:对于一张页表,最多映射4MB内存。而4MB内存在线性地址空间的位置是浮动的,由指向他的页目录项决定

从线性地址的划分角度来看,一张页表的内容,只是影响了线性地址的中间10位

说明3:80386 + Linux线性地址空间划分实例

由于80386 + Linux使用平坦模型绕过段机制,所以下图中的虚拟地址空间是指线性地址空间

每个进程都有自己的页目录,从而拥有4GB线性地址空,其中,

① 0 ~ 3GB为用户空间,局部于每个任务。取出对应的线性地址的最高10位,也就是页目录的第0(0x0)~ 767(0x2FF)项

② 3 ~ 4GB为内核空间,为所有进程共享。取出对应的线性地址的最高10位,也就是页目录的第768(0x300)~ 1023(0x3FF)项

说明4:线性地址空间的切换

① 由于每个任务都有自己的页目录,因此在任务切换时,也需要随之切换任务的页目录。切换的方法就是修改CR3控制寄存器记录新任务的页目录物理基地址

② 切换了页目录,就是切换了线性地址空间,也就切换了线性地址空间到物理地址空间的映射关系

③ 需要注意的是,在切换页目录时,线性地址空间的全局部分并没有切换,也就是内核空间并没有切换。这就使得所有任务都有了共享的内核空间

说明5:这种将内核空间映射到所有任务的全局线性地址空间的做法还有一个好处,就是当执行流在一个任务中从用户态切换到内核态时,不需要切换页表。因为每个任务都映射了内核空间,而且陷入到内核态之后,也有了访问内核的权限

不得不说这种任务间隔离 + 任务内特权级保护的方式,实在是太妙了

4 处理器控制寄存器及系统段

4.1 处理器控制寄存器

80386有CR0 ~ CR3共4个控制寄存器,控制寄存器只能由特权级为0的程序访问,如果外层程序试图写控制寄存器,则会产生异常

4.1.1 CR0寄存器

CR0主要控制保护模式与分页机制的使能,同时还控制80387浮点协处理器的操作

4.1.1.1 PE位(Protection Enable

PE位用于控制段机制,

① 当PE = 1,处理器工作在保护模式,保护模式的段机制生效

② 当PE = 0,处理器工作在实模式,等同于8086

4.1.1.2 PG位(PaGing

PG位用于控制分页机制,

① 当PG = 1,启用分页机制,使能从线性地址到物理地址的转换

② 当PG = 0,禁用分页机制,段机制产生的线性地址,直接作为物理地址使用

说明1:PE位与PG位的组合设置

可见PG = 1 && PE = 0的组合是非法的(也就是说,分页机制只能在保护模式下启用),使用该组合设置CR0时,会触发通用保护异常

说明2:修改PE位注意事项

在改变PE位之后,程序必须立即执行一条jmp指令,以刷新以不正确的方式已经预取的指令

② 如果是将PE位由0设置为1,那么在设置CR0之前,处理器按照实模式的方式预取指令并进行译码,因此在进入保护模式后需要清除

说明3:修改PG位注意事项

① PG位的修改将使系统启用或禁用分页机制,因此只有当所执行程序的代码和至少有一部分数据在线性地址空间和物理地址空间具有相同的地址的情况下,才能改变PG

② 如果是将PG位由0设置为1,此时程序是在禁用分页机制的情况下加载的,那么在设置CR0之前,处理器是按照线性地址预取指令的,必须确保这部分代码在使能分页之后仍能正确运行

③ 为了达到上述目的,在开启分页时,一般都需要先建立恒等映射进行过渡。关于恒等映射,可参考X86汇编语言从实模式到保护模式19:分页和动态页面分配 chapter 3.1

说明4:CR0中的MP / EM / TS / EM位用于控制80387浮点协处理器操作,本文不做详细说明,贴一张截图

4.1.2 CR1寄存器

1. 80386未对CR1进行定义

2. 在指令中使用CR1将触发无效指令操作异常

4.1.3 CR2寄存器

1. 当发生页异常时,处理器将引起异常的线性地址保存在CR2中

2. 操作系统中的页异常处理程序可以检查CR2的内容,从而查出是线性地址空间中的哪一页引起本次异常

以80386 + Linux为例,在处理页异常的do_page_fault函数中,首先就是从CR2中读取触发异常的线性地址

4.1.4 CR3寄存器

1. CR3用于存储页目录的物理基地址

2. 由于页目录是页对齐的,所以CR3中只有高20位有效,低12位保留未使用

3. 向CR3中加载新值时,低12位必须为0;而保存CR3中的值时,低12位被忽略

4. 使用mov cr3, reg指令向CR3加载新值时,会产生刷新TLB的副作用

5. 当CR0中的PG = 0时,也可以向CR3中加载新值,这就是进行分页机制的初始化,要在开启分页之间部署好页目录和页表

6. CR3在任务切换时也会随之切换,但是如果CR3切换前后的值相同,则处理器不会刷新TLB。因此当任务共享页表时有较快的执行速度

4.2 段表基地址寄存器

GDTR / IDTR / LDTR / TR是系统中非常重要的特殊段基地址寄存器,这些段包含对段机制非常重要的表格,下面按不同的构造方式进行说明

需要注意的是,这些段表基地址寄存器指向的对象,都存储在线性地址空间,是在线性地址空间中的连续区域

4.2.1 GDTRIDTR

之所以将GDTR与IDTR合并说明,是因为GDT(全局描述符表)和IDT(中断描述符表)的部署与索引方式类似

4.2.1.1 GDTRGDT

1. 系统中生效的GDT只有一个,但是GDT本身不能由GDT中的描述符进行定义,因此直接通过GDTR持有GDT的线性基地址和界限值的方式进行指向

2. GDTR共48位,其中32位为线性基地址,16位为段界限

3. 由于GDTR指向GDT,因此GDTR确定了线性地址空间中段表结构的"根"

4. lgdt指令用于将GDT表的线性基地址和界限值加载到GDTR中,指令格式如下,

lgdt m48

这里m48表示该指令的操作数是一个48位的内存区域,且要求低16位为GDT界限值,高32位是GDT线性基地址

说明1:LDT段和TSS段的描述符都存储在GDT中,并且通过段选择子进行引用

说明2:lgdt指令在实模式和保护模式下均可执行,对于m48这个内存地址的有效地址(EA)的给出方式,在16位实模式下,EA是16位的;在32位保护模式下,EA是32位的

其实lgdt指令必须支持在实模式下执行,因为在使能保护模式之前,必须先设置好GDT与GDTR

4.2.1.2 IDTRIDT

1. 与GDT类似,系统中生效的IDT也只有一个。理论上IDT可以作为一个段存储在GDT中,但是这样在异常和中断处理中会多引入一级访问。因此在实现中,直接通过IDTR持有IDT的线性基地址和界限值的方式进行指向

2. IDTR也是48位,布局与GDTR相同

3. lidt指令用于将IDT表的线性基地址和界限值加载到IDTR中,指令格式如下,

lidt m48

在m48指向的内存中,需要按如下布局存储IDT表界限和线性地址

说明:和lgdt指令一样,lidt指令也可以在实模式下执行,以便于在进入保护模式之前就做好与中断相关的准备

4.2.2 LDTRTR

1. 由于LDT段和TSS段均存储在GDT中,可以通过段选择子进行索引。因此LDTR和TR寄存器中只需要存储相应的段选择子,而无需存储段的线性基地址和界限值

2. LDTR寄存器包含当前任务的LDT段选择子

3. 可以用一个空选择子装入LDTR,这表示当前任务没有LDT。如果此时程序通过LDT进行寻址,则会产生异常

4. TR寄存器包含当前任务的TSS段选择子

5. 为加速对LDT和TSS段的访问,LDTR和TR寄存器均包含对程序员不可见的投影寄存器

说明1:多任务系统中GDTR / LDTR / TR寄存器的指向关系如下图所示,此时的当前任务为任务2

说明2:GDT / IDT / LDT / TSS段都在线性地址空间中定义的,因此可以由分页机制进行重定位。而且在内存紧张时,不活动任务的LDT和TSS段也可以以页为单位交换到磁盘上

由于GDT和IDT由所有任务访问,所以这2个段应该常驻在内存中

说明3:程序如何操作GDT / IDT / LDT / TSS段

① 由于上述系统段是需要程序来构建的(一般是操作系统来构建),因此程序有操作上述系统段的需求

② 程序需要使用别名段来访问这些段,也就是定义一个涵盖这些系统段的存储段,然后将存储段的选择子加载到段寄存器中进行访问

③ 程序不能将LDT和TSS段的段选择子直接加载到段寄存器中进行访问,此时会报出异常。段寄存器只能加载存储段的段选择子

4.3 任务状态段格式

4.3.1 概述

1. TSS段可以理解为任务执行状态的快照,通过在TSS段中保存任务寄存器状态的完整镜像,可以实现任务的挂起与恢复

① 当任务被挂起时,将当前处理器中各寄存器的值写入TSS段的相应字段

② 当任务恢复执行时,将保存在TSS段中的值恢复到处理器各寄存器,从而重新建立任务的状态,使任务可以继续运行

2. 80386处理器定义了TSS段前104B(0x68)的格式,在此硬件定义的区域之上,操作系统还可以存储与任务相关的附加信息

说明1:什么是任务的执行状态?

任务的执行状态,就是任务执行时处理器寄存器的值(包括通用寄存器、状态寄存器和系统寄存器),因此保存了这些寄存器的值,也就保存了任务的执行状态

说明2:在实际的操作系统中,因为需要的时钟周期太长,并没有使用TSS段进行任务切换,而是普遍使用内核栈进行任务切换

tips:Linux 0.11内核中使用TSS段进行任务切换,但是后续版本不再使用

4.3.2 链接字段

4.3.2.1 概述

1. 链接字段LINK在TSS段中的偏移量为0,长度为32位,其中16位存储链接任务的TSS段选择子,高16位未使用

2. 链接字段与EFLAGS寄存器中的NT位配合,将由CALL指令或中断挂起的任务的TSS段链接起来。此处回顾一下EFLAGS寄存器中NT位的含义

4.3.2.2 任务TSS段的链接与恢复

1. 当通过CALL指令或中断触发任务切换时,处理器会进行如下操作,

① 将被挂起任务的TSS段选择子,保存在切换目标任务TSS段的链接字段,用于后续恢复

② 将EFLAGS寄存器的NT位置为1,标识切换目标任务TSS段的链接字段有效

③ 如果在切换目标任务中再次通过CALL指令或中断触发任务切换,则重复上述操作,从而构成如下图所示的TSS段链接链

2. 当使用iret指令进行返回时,由于EFLAGS寄存器的NT位为1,会是的iret指令不是进行中断返回,而是沿着TSS段的链接字段恢复到链上的前一个任务

4.3.3 高特权级栈指针

1. 80386处理器在每个特权级使用各自的栈,从而避免由于共享栈而产生的保护问题

2. TSS段中共有3个栈指针,他们都是48位全指针,分别指向特权级0 ~ 2的栈顶。当在任务中发生向高特权级的转移时(e.g. 触发系统调用),处理器从TSS段选择目标特权级的栈指针加载到SSESP寄存器,从而切换到高特权级栈

与此同时,会将转移前低特权级栈的全指针保存在高特权级栈中,如下图所示,

3. 80386处理器会从TSS段中读取高特权级栈指针,但是不会向这一区域写入。因此向高特权级转移时,总是将高特权级栈初始化为同样的栈指针。也就是每次向高特权级转移时,高特权级栈总是一个空栈

说明1:为什么TSS段中没有存储特权级3的栈?

因为特权级3是最低的特权级,向高特权级的转移不会以特权级3为目标

说明2:需要注意的是,在发生特权级切换时,任务并没有切换。这里发生的,是从局部地址空间向全局地址空间的转移

如果任务在转移到高特权级后又发生任务切换,那么此时的高特权级栈指针会被保存在TSS段寄存器保存区域的SS和ESP字段

4.3.4 地址映射相关寄存器

1. 通过保存和恢复LDT段选择子,可以改变局部虚拟地址空间中虚拟地址到线性地址的转换规则,从而实现不同任务局部虚拟地址空间的隔离与切换

2. 通过保存和恢复CR3寄存器,可以改变线性地址空间到物理地址空间的转换规则,从而实现不同任务页表的切换

3. 处理器也不会向上述字段进行写入操作,因此如果任务修改了LDTR或CR3,则必须在当前正在执行的任务中将新值存储在TSS段中

4.3.5 寄存器保存区域

TSS段中的寄存器保存区域用于保存通用寄存器、状态寄存器和段寄存器的状态

4.3.6 其他字段

1. TSS段中的T位用于设置任务切换调试属性,如果将T位置为1,则通过TSS段切换到该任务时,将触发调试异常

2. TSS段中的IO许可位图用于定义可由TSS段对应任务寻址的IO端口地址,IO许可位图本身是TSS段中的附加字段

5 特权级敏感指令

5.1 被赋予特权的指令

1. 被赋予特权的指令只能在特权级0执行,如果在其他特权级执行这些指令,则会产生异常。80386的的特权指令如下,

① 加载GDTR / IDTR / LDRT / LTR / MSW寄存器的指令是特权指令,但是保存这些寄存器值的指令不是特权指令(e.g. sgdt指令将GDTR中的值存储到内存中)

② 加载和保存控制寄存器和调试寄存器的指令都是特权指令

2. 通过对上述保护模式相关的寄存器访问进行限制,保证了保护模式的完整性

5.2 IO空间保护

5.2.1 IO敏感指令

80386处理器的IO敏感指令如下,对IO敏感指令的执行,由IO空间保护机制控制

5.2.2 IO空间保护机制

80386处理器中有两种机制控制对IO地址空间的访问,

1. EFLAGS寄存器中的IOPL字段

定义可执行所有IO相关指令及访问所有IO地址空间的最低特权级

2. TSS段中的IO许可位图

定义IO地址空间中的哪些地址可以由在任何特权级执行的程序进行访问

5.2.3 IO空间保护机制生效方式

1. 首先生效的是EFLAGS寄存器中的IOPL字段,

① 如果CPL <= IOPL字段,则可以执行所有IO敏感指令,访问所有IO地址空间

② 如果CPL > IOPL字段,则执行CLI和STI指令将产生异常;而访问IO地址,还需要查询IO许可位图

2. 在CPL < IOPL字段的情况下访问IO地址时,需要查询IO许可位图

① 如果IO许可位图允许访问该IO地址,则可以访问

② 如果IO许可位图标记该IO地址为不可访问,则产生异常

说明:IO许可位图设置实例

① 应用程序一般运行在特权级0,但是可以为其定义特定的IO许可位图,从而提升效率。例如游戏程序可以定义允许访问操纵手柄的IO许可位图

② 在实际操作系统的实现中,为了简化实现,并没有遵循X86体系结构的构想。操作系统内核与设备驱动均运行在特权级0;用户程序均运行在特权级3,且不允许访问硬件端口

5.2.4 IO许可位图

1. 80386处理器的IO地址空间共有2^16(64K)个端口,因此端口号长度为2B

2. IO许可位图定义了与TSS段相对应的任务,在64K个端口中,哪些地址可以由在任何特权级执行的程序访问

3. 在TSS段中可以存储一个最多64KB的位串,位串中的每一位对应一个IO字节地址。其中,位0对应地址0,位1对应地址1,位N对应地址N

4. 位图中为0的位,表示对应的IO地址可以由在任何特权级执行的程序访问;位图中为1的位,表示对应的IO地址只能在CPL <= IOPL时访问

5. 并不需要在IO许可位图中设置所有IO地址空间,只要根据任务需要,设置从0开始到所需的IO地址空间即可。但长度必须是8位的整数倍,也就是以字节为单位

说明1:IO端口多位检查

① 80386处理器的IO地址空间按字节进行寻址,但是端口读写可以是多字节的,每条指令一次最多可访问连续的4B

② 对于一次连续访问端口多字节的指令,需要对IO许可位图中的多个位进行检查,必须所有字节都允许访问时,指令才能执行

③ 对于跨字节的IO地址(e.g. 访问端口0xFF和0x100),在检查时处理器需要读取并检查IO许可位图中的2B。但是由于指令一次最多只能访问4B,所以处理器也只需检查IO许可位图中的2B

④ 为使访问位图尽可能快速进行,80386处理器无论是否需要,总是读取位图中的2B

⑤ 由于处理器每次都是从IO许可位图中读取2B,为避免在位图映射的最高IO地址处发生问题,在包含有效映射信息的位图中,在最后一个位图字节之后,TSS段界限范围之前,必须另加一个内容为全1的字节

说明2:IO许可位图的存储

① 由于TSS段中存储IO许可位图偏移量的字段长度为2B,因此IO许可位图可以存储在TSS段的第一个64KB字节内的任何位置,并且可以是8位的整数倍的任意长度

这里要注意,TSS段本质上也是一段线性地址空间,在对应的TSS段描述符中也有线性基地址和段界限

② 映射所有2^16个IO地址需要(64K / 8 = 8KB)位图,在这种情况下,IO许可位图的起始偏移量必须小于(64KB - 8KB = 56KB)

当然,并不是所有任务都需要映射所有IO地址空间

③ IO许可位图的存储还受到TSS段界限的限制,如果包含IO许可位图,他必须是TSS段的一部分

5.3 改变EFLAGS的指令

对EFLAGS寄存器中某些字段的修改,随执行这些指令所在特权级的不同,有不同的执行结果。在EFLAGS中对VM / IOPL / IF位的处理,不同于对ELFAGS中其他字段的处理

1. VM和IOPL位只能由在特权级0执行的程序修改

2. IF位只能由相对于IOPL同级或更高特权级执行的程序修改

说明1:IRET / STI / CLI / POPF指令可用来修改EFLAGS中的这些字段

说明2:如果在不具备相应特权级的程序中,使用POPFIRET指令修改上述字段,并不会产生异常,试图要修改的字段也不会被修改

6 控制转移方法

6.1 同一特权级、同一任务的转移

6.1.1 一般转移规则

1. 最简单的段间转移,是使用JMP / CALL / RET指令,将控制转移到同一任务中同一特权级的代码段,也就是CPL = 跳转目标代码段的DPL

2. 在任何情况下都不允许通过JMP / CALL指令及各种机制,从高特权级代码段跳转到低特权级代码段执行;只能通过RET / IRET指令从高特权级代码段返回低特权级代码段

3. 在将新的代码段选择子装入CS段寄存器时,需要进行检测,

① 首先检测段选择子的有效性,主要是该段选择子索引的段描述符是否存在

② 如果对应的代码段描述符存在,则对该描述符进行一系列检测,比如是否有权限转移以及转移的偏移量是否越界

③ 如果段描述符检测也通过,则将段描述符加载到CS投影寄存器,将段选择子加载到CS段寄存器,将新的偏移量加载到EIP寄存器

6.1.2 一致代码段

1. 一致代码段是一种特殊的代码段,用于支持为在多个特权级执行的程序提供共享例程,而不改变执行的特权级

这也conforming被翻译为"一致"或"依从"的由来,即执行时特权级不是由代码段DPL决定,而是与转移前的CPL一致,或依从于转移前的CPL

2. 将控制转移到一致代码段,要求CPL >= 依从代码段DPL,即只能从低特权级转移到高特权级代码,只是在执行时保持CPL不变

根据上述规定,特权级3的程序可以转移到任何特权级的一致代码段,而特权级0的程序只允许转移到DPL = 0的一致代码段(这个时候也就没必要使用一致代码段了)

说明:在实际操作系统中,一致代码段并不常用

6.2 同一任务不同特权级的转移

6.2.1 概述

1. 同一任务在不同特权级间转移的必要性

处理器需要支持从低特权级向高特权级的调用,以及向低特权级返回,这样应用程序才可以调用操作系统提供的例程,以获取必要的系统服务(e.g. 内存分配和文件访问)

2. 从低特权级向高特权级转移的操作需要加以控制,以确保保护机制的完整性。因此只允许通过体系结构与操作系统规定的入口点进行转移,例如下面要说明的调用门

3. 操作系统必须控制向高特权级转移入口点的段及偏移量,这点主要通过构造合理的门描述符实现

说明1:处理器不支持从高特权级向低特权级的调用,以及向高特权级返回

说明2:在很少数的情况下,操作系统必须从低特权级转移到高特权级,此时只能通过构造从高特权级返回低特权级的场景实现

最典型的情况,就是操作系统在启动完成后,0号进程的创建,以Linux 0.11为例,

① 在main函数完成操作系统初始化之后,调用move_to_user_mode返回用户态,使其成为0号进程

② move_to_user_mode函数构造异常返回场景,之后通过iret指令返回权级执行

6.2.2 调用门(Call Gate

6.2.2.1 调用门转移过程

1. 调用门提供了从低特权级访问高特权级例程所需的机制,调用门是一个特殊的描述符类型,包含目标地址段及偏移量的48位全指针

2. 调用门描述符需要登记在GDT和LDT中(一般是登记在GDT中),之后通过选择子进行索引。当低特权级程序使用CALL / JMP指令使用调用门进行控制转移时,需要指定调用门对应的选择子

此时CALL / JMP指令中需要携带4B偏移量,但是不会生效,而是会被调用门描述符中的偏移量替代

3. 当处理器发现CALL / JMP指令的跳转目标是调用门时,会使用门描述符中的全指针进行控制转移

说明:调用门的一个重要特性就是他对于主调程序是透明的,主调程序可以使用标准的段间CALL指令转移到不同的段,而不用区分在调用中给出的选择子是指向存储器段,还是指向调用门描述符

6.2.2.2 调用门特权级检查

1. 门级检查

只有当CPL和RPL <= 门描述符DPL时,程序才可以访问调用门

2. 段级检查

① 段级检查对比目标代码段的DPL和CPL,忽略RPL

② 需要CPL >= 目标代码段DPL,也就是控制只能从低特权级转移到高特权级

③ 如果使用JMP指令使用调用门,由于不会改变CPL,因此成功跳转的条件如下,

  • 如果目标代码段是一致的
CPL >= 目标代码段描述符的DPL
  • 如果目标代码段是非一致的
CPL = 目标代码段描述符的DPL

说明:在主流操作系统中,并没有使用调用门机制向用户程序提供服务,而是使用系统调用机制(本质是使用异常机制)。在80386处理器中,就是使用int 0x80中断

6.2.3 栈切换

1. 当处理器通过调用门或异常等机制将控制从低特权级转移到高特权级时,所使用的栈也必须随着切换,规则就是必须使用和CPL相同特权级的栈

2. 而栈切换时所使用的栈指针就保存在TSS段中

3. 以通过CALL指令 + 调用门将控制从低特权级转移到高特权级为例,处理器会从TSS中选择适当的栈指针设置SS段寄存器和ESP寄存器,同时会保存如下内容,以便后续返回

① 低特权级栈的全指针

② 根据调用门描述符,将参数从低特权级栈拷贝到高特权级栈

③ 低特权级返回地址的全指针

说明1:向低特权级返回时的栈操作

对于上述通过CALL指令 + 调用门进行的特权级转移,可以通过RET指令返回,在返回过程中注意事项如下,

① 可以使用ret 16指令返回,以便将压栈的参数弹出,此时会同时处理高低特权级栈

② 通过将保存在高特权级栈中的返回地址全指针出栈到CS段寄存器和EIP寄存器,可以实现控制流返回到低特权级

③ 通过将保存在高特权级栈中的低特权级栈全指针出栈到SS段寄存器和ESP寄存器,可以切换回低特权级栈

说明2:防止保护空洞

在低特权级程序恢复执行之前,处理器会检查数据段寄存器DS / ES / FS / GS,以保证寻址他们寻址的段在低特权级是可以访问的。如果数据段寄存器寻址的段在低特权级不可访问(e.g. 在从特权级0返回特权级3时,DS段寄存器指向DPL为0的数据段),则用一个空选择子装入段寄存器,以避免在返回时发生保护空洞

② 之所以要进行检查,是因为对段访问特权级的检查只在加载段寄存器时进行,后续对该段的访问不再进行特权级检查(当然,仍然会进行段界限检查)。因此,如果从高特权级返回时,数据段寄存器中包含指向高特权级数据段的选择子,则低特权级程序可以直接访问这些高特权级数据段

③ 对于一个完善的调用门程序或者异常处理程序,一般会在程序入口处保存数据段寄存器,并且在出口处进行返回,这样就不会出现上述的问题

可以看出体系结构对系统的安全性进行了最后的保险

猜你喜欢

转载自blog.csdn.net/chenchengwudi/article/details/124494458