Jun's Blog

汇编语言之保护模式

· Jun

保护模式与实模式

我们这里说的保护模式特指 IA-32 处理器上的32位保护模式。在保护模式下,所有的32位处理器都可以访问最多2^32字节,也就是4GB的内存。我们的段寄存器中保存的也不再是段地址,也不再需要左移与偏移量拼接。需要注意的是,32位处理器是兼容16位的实模式的,而在刚加电时,处理器默认也是跑在实模式下的,我们需要经过一番设置才能让他进入保护模式。

寄存器的变化

在16位处理器中,有8个通用寄存器,32位处理器在此基础上,拓展了这8个通用寄存器的长度,使之达到了32位。

  • EAX
  • EBX
  • ECX
  • EDX
  • ESI
  • EDI
  • EBP
  • ESP

为了兼容性,前四个寄存器的低16位就是16位寄存器,此时它的高16位没有用:

1
2
3
4
5
31   16 15    0
|      |  AX  | => EAX
|      |  BX  | => EBX
|      |  CX  | => ECX
|      |  DX  | => EDX

段的变化

因为32位处理器拥有32位的段寄存器,所以原则上它不需要分段就可以访问到所有地址。但是,IA-32 架构的处理器是基于分段模型的,它仍然需要通过段为单位访问内存。不过也可以指分一个段,段的基地址为0x00000000,段的长度为4GB, 这种情况下可以视为没有分段,也被称为平坦模式 (Flat mode)。

1
2
3
4
5
6
7
15   0
| CS | 描述符高速缓冲器 |
| SS | 描述符高速缓冲器 |
| DS | 描述符高速缓冲器 |
| ES | 描述符高速缓冲器 |
| FS | 描述符高速缓冲器 |
| GS | 描述符高速缓冲器 |

在32位模式下,传统的段寄存器保存的不再是16位的段基地址,而是段的选择子。严格来说,它的新名字叫做段选择器。每个段寄存器还包括一个不可见的部分,称为描述符高速缓冲器,里面保存着段的基地址和段的访问方法。注意这部分内容对程序员不可见,由处理器自动控制。

线性地址

在32位处理器访问内存时,同样需要在程序中给出段地址和偏移量。段的管理由处理器的段部件负责进行的,段部件将段地址和偏移地址相加,得到访问内存的地址。一般来说,段部件产生的地址就是物理地址。

然而,32位处理器还支持分页。当页功能开启时,段部件产生的地址就不是物理地址了,而成为线性地址 (Linear address)。线性地址还要经过页部件转换后才能得到真实的物理地址,这个下面会专门详细解释。

全局描述符表

上面我们说了段部件将段地址和偏移地址相加得到访问内存的地址,当然,实际情况要比这说的复杂的多。

在32位保护模式下,我们不能随心所欲地访问任何段。在此之前,我们必须记录下我们所拥有的所有段以及每个段的基本信息(能不能执行,被谁执行等)。而存放这些信息的地方,就被称为全局描述符表 (Global Descriptor Table), 简称为 GDT。注意 GDT 是需要我们人为定义在内存中的某个地方的,而且必须要在进入保护模式之前就定义好。可以想到,因为 GDT 不是处理器自动控制的,所以处理器需要一个寄存器来追踪它的基本信息,就像我们在实模式对段的处理一样。

为了做到这一点,处理器内部有一个48位的寄存器,称为全局描述符表寄存器 (GDTR)。该寄存器分为两个部分,高32位的线性地址用来保存 GDT 在内存中的起始地址,低16位用来保存表的大小(总字节数减去一,16位全为0表大小则为1)。因为表大小用16位来存储,所以表最多有2^16字节。表中每个项为8字节,故最多定义8192个描述符(段)。

上面说到了段描述符有8个字节,也就是2个双字,64位。这是它的高32位布局:

1
|端基地址31-24位|G|D/B|AVL|段界限19-16位|P|DPL|S|TYPE|端基地址23-16位|

这是它的低32位布局:

1
2
31            16 15           0
| 段基地址15-0位 | 段界限15-0位 |

可以看到段描述符中指定了32位的段基地址(段开始的地址)和20位的段界限。

下面给出段描述符中的其他信息含义:

  • G 粒度位。表示段界限的单位。为0以字节为单位,为1以4KB(页)为单位。
  • D/B 用来兼容16位保护程序,不做过多赘述。
  • AVL 可用位。由操作系统决定如何使用,没有特别的实际用途。
  • P 段存在位。用于指示段是否存在,某些情况下如段被置换到硬盘时该位则为0
  • S 描述符类型。该位为0时表示则是一个系统段,为1则表示是一个代码段或数据段
  • TYPE 用来指示该段的读写权限。

特权级

特权级是存在于描述符和段选择子中的一个数值。

Intel 处理器可以识别4个特权级别,越小的数值权限越大:

  • 0 一般用于操作系统的主体部分
  • 1 一般用于驱动程序
  • 2 一般用于驱动程序
  • 3 一般用于应用程序

在上面介绍段描述符的结构时我们可以看到,有一个叫 DPL (Descriptor Privilege Level) 的字段。它有2位组成,可以取值为00,01,10,11,刚好对应了4个特权级。对于数据段来说,DPL 决定了他们所应当访问的最低特权级别,也就是只有特权级小于它的程序才可以访问这个段。

代码段的特权级检查是很严格的。一般来说,控制转移只发生在两个特权级相同的代码段之间。不过,为了让特权级低的应用程序可以调用特权级高的程序,处理器也提供了处理办法:

  • 将高特权级的代码段定义为依从的。简单来说,如果一个段的段描述符中的 TYPE 字段中的 C 位为1,则这个段为依从的代码段,可以从特权级比他低的程序调用并进入。注意这也是有条件的,要求当前特权级 CPL 必须低于或和目标代码段描述符的 DPL 相同。注意,我们在转到依从的代码段后,不改变当前特权级 CPL,这也是为什么叫它依从的。

  • 门 (gate)。门是另一种的描述符,称为门描述符。事实上门的类型有好几种,我们这里只介绍调用门 (call gate)。所有的门描述符都是64位的,当然也包括调用门描述符。在调用门描述符中定义了目标过程所在代码段的选择子和段内偏移。要想通过调用门进行控制转移,可以用:

    • jmp far 指令。不改变当前特权级别。
    • call far 指令。改变当前特权级别。

分页

当同时运行的程序很多时,内存就有可能不够用了。这时,我们可以将一些段的描述符的 P 位清零,然后将其换出到硬盘中,这样我们就腾出了一些空间给其他程序使用。但是。段的长度是不确定的,如果换出的段长度太小则不够用,但如果换出的段过大,又会造成浪费。为了解决这个问题,我们引用了分页机制。

我们知道在此之前我们访问内存时都是使用“段基地址:偏移量”的方式,段部件会根据一定的规则构造出实际访问的地址。(这对实模式和保护模式都是成立的,只不过段部件构造的方式不同,保护模式下限制更多)而当我们引入了分页机制后,用段部件得到的内存就不再是真实的物理内存了,而是虚拟内存。

虚拟内存是真实内存的一层抽象,一层封装,并不真实存在也不能储存任何数据。在分页机制中,我们将真实内存分成了一定大小的块(一般是4KB)。举个具体的例子,假如我们需要分配一个段,首先我们会向虚拟内存请求一个连续的内存空间,接着虚拟内存就会在真实内存中寻找足够可用的空闲页,将要分配的内存拆开分别映射到各个页上。所以,虽然我们所看到的内存(虚拟内存)是连续的,但在实际的物理内存上它可能由多个不连续的页组成。而当我们找不到足够多的空闲页时,我们就会按照一定的规则将一些页置换到硬盘上,然后再使用。

可以看到,页面大小的选择至关重要。如果说页面大小过小,那么就可能需要分配很多页降低性能,但是如果页面太大,又会造成大量内存内部碎片。(我们将段映射到页上,即使再小的段也必须使用一个页,页中剩下的内容只能白白浪费)

页目录和页表

我们将各个段映射到一个或多个页面上,很显然我们需要一个表来保存这个映射关系。因为可访问的内存有4GB, 一个页为4KB, 所以共有2^20个页。我们需要在表中储存页的起始地址,所以一个页要4字节(32位地址),所以页表的总大小为4MB。这显然是一个很大的表需要很多页来储存。

所以,我们采取了结构化的方式,采用多级页表来解决这个问题。首先我们用一个页建立一个页目录,其中存放的是另一些页表的地址(32位地址所以每一项4字节共可以存放1024项)而在这些页表中,我们又可以存放1024个页面,这样我们便可以表示1024*1024共2^20个页,刚刚好!用高级编程语言来表示下这个关系:

1
2
3
4
5
6
7
8
// 页表
struct PageTable {
  std::array<Page, 1024> pages;
}
// 页目录
struct PageDirectory {
  std::array<PageTable, 1024> page_tables;
}

而为了记录页目录的信息,处理器中专门又有了一个控制寄存器 CR3, 也叫页目录基址寄存器 (Page Directory Base Register) 负责存放当前任务页目录的物理地址。每当任务发生切换时,这个寄存器就会被更新,指向新任务的页目录地址。

地址变换具体过程

假设有这样一条指令(段的起始地址为0x00800000):

1
mov edx, [0x1050]

正常情况下没有越界且具备所需权限时,段部件会产生0x00801050这个虚拟内存地址。页部件接着将这个地址分成3段:

1
2
| 页目录索引 | 页表索引 | 页内偏移 |
|0000000010|0000000001|000001010000|

这样我们就找到了它真实所在的页面和偏移量,并找到真实的物理地址了。

在上面的解释中,可以看出页目录和页表是建立在虚拟内存之上的,也只是普通的页,只不过被用于记录其他页的信息了!