此前我们对操作系统中的分段、分页机制以及虚拟地址、逻辑地址、线性地址、物理地址进行了较为详细的介绍。 操作系统的内存管理 — 分段与分页、虚拟地址、逻辑地址、线性地址、物理地址
那么操作系统为什么要实现这一系列复杂的机制呢?上文提到的 GDT、LDT、IDT 又是什么呢?他们的结构是什么样的呢?保护模式中的“保护”体现在哪里呢? 本文就让我们来一探究竟。
此前的文章中,我们在系统启动时,通过触发 BIOS 中断,实现了在屏幕上显示出“Hello World my OS!”。 计算机是如何启动的?如何制作自己的操作系统
这段代码始终是运行在实地址模式中的,在实地址模式中,内存地址是通过 cs、ds 等 16 位段寄存器存储的段基址与 16 位段偏移共同计算出 20 位的物理地址的:
物理地址 = 段基址 * 16 + 段偏移
这样计算的原因是为了兼容 Intel 8086 CPU,8086 CPU 由若干个 16 位寄存器、16 位的数据总线以及 20 位的地址总线组成,通过段地址与段偏移组合成的 20 位地址提供了 1MB 内存空间的寻址能力。 但我们已经看到,在实地址模式下,应用程序可以读取、修改整个内存空间,这让 CPU 必须在一个进程完全运行结束后才能够进行内存的清理并加载新的程序,否则多个程序会在内存使用上发生冲突。 与此同时,程序没有权限控制,任何一个程序都可以直接通过中断机制与系统硬件交互,这显然是非常危险的。 在硬件与软件技术不断发展的同时,保护模式的软硬件设计便诞生了。
IA-32 CPU 80286 的诞生,提供了 32 位的地址总线,与一系列 32 位寄存器,这让 CPU 拥有了 4GB 内存空间的寻址能力。 然而此时,我们要在寻址过程中为每块内存加入内存的权限级别、读写执行控制等信息,因此我们需要将内存空间拆分为若干个块,并单独为每块内存设置权限控制等属性。 在这一思想下,32 位操作系统中的分段思想诞生了,他通过创建一个表,每个表项用来存储一个段基址与段界限的组合,从而实现一个内存段的定义,每个表项同时还定义了该内存段的若干属性,从而实现对这段内存的“保护”。
IA-32 CPU 中存在一个 48 位的分段描述符表寄存器 GDTR,他存储了 32 位的分段描述符表起始地址与 16 位的分段描述符表偏移量,因此只要读取分段描述符表寄存器,就可以在内存中找到对应的分段描述符表。 16 位段基址寄存器就用来在分段描述表中索引到具体的某个表项,从而定位到该表项指向的对应内存段。 然后,通过 32 位段偏移地址实现在内存段中内存地址的定位。 于是,最终通过段基址 + 段偏移地址的方式最终计算出了 32 位物理地址。
如上图所示,这个用来存储段基址、段界限、段属性的表被称为“内存描述符表”,每个表项被称为一个“描述符”,而 16 位段寄存器中存储的描述符索引值就被称为“段选择子”。
下面我们就来介绍一下上述用来给 32 位系统分段的内存结构 — 全局描述符表 GDT,操作系统全局定义了一个 GDT,用来将整个操作系统所使用的内存划分为多个段,GDT 存储了若干个被称为“描述符”的表项。 下图展示了每个表项的结构:
可以看到,全局描述符由段基址、段界限与属性共同构成,尽管由于历史原因,段基址被拆成了两部分,但每个描述符仍然通过段基址与段界限定义了一个内存段。
下图展示了 48 位全局描述符表寄存器 GDTR 的存储结构:
显而易见,GDTR 定义了全局描述符表 GDT 的起始地址与界限,由于 GDTR 中 GDT 界限为 16 位,因此 GDT 最大范围是 64KB,每一项是 8 字节,所以 GDT 中最多只能有 8192 个描述符。
如果要通过 8192 个描述符划分完整的 4GB 地址空间,那么每个描述符平均要描述 500KB 的段空间,显然,这样的大小粒度太粗了。 IA-32 CPU 在设计过程中也同样考虑到了这个问题,并设计了更细粒度的描述符表 — 局部描述符表 LDT。 局部描述符表是全局描述附表的下一级,在内存中,存在着很多个 LDT,每个 LDT 对应全局描述附表中的一个描述符,这个描述符描述了局部描述符表的起始地址与界限,而这个 LDT 中则对当前段区域进行了更细粒度的段拆分。 与 GDT 对应,每当操作系统需要使用某个 LDT 中的内存段时,都需要先将 LDT 的起始地址与界限载入到 LDTR 寄存器中。
下图展示了段选择子的存储结构:
上面已经提到,GDT 最多只能有 8192 个描述符,所以只需要 13 位即可索引全部描述符。 如图所示,段选择子是由 13 位描述符索引与两个属性字段构成的。 剩余 3 位分别是 TI 位与 RPL,TI 位为 0 表示当前索引的是 GDT,为 1 则表示当前索引的是 LDT。 RPL 是访问特权级,可以是 0、1、2 或 3,数字越小,特权级越大。
GDT、LDT 的第 5、6 字节拥有一系列属性,如下图所示:
如图所示,上图属性中的位的具体意义与取值如下:
如图所示,IA-32 CPU 将内存分为 4 个特权级,数字越大,级别越小。 他的出发点是为了保护核心代码和数据,让处于低级别的应用无法访问和修改高级别的内存。 这一原则是通过不同位置的三个字段来实现的。
他被存储在 cs 寄存器与 ss 寄存器的第 0 位和第 1 位上。 通常情况下,CPL 等于当前运行的代码所在内存段的特权级,每当程序发生跳转,处理器会自动改变 CPL 的值。
在附录中的 GDT 属性字段里,有另一个特权级字段 DPL。 他定义了 GDT/LDT 中对应对应内存段的特权级。 当代码要去访问一个内存段时,操作系统会将目标段的 DPL 与当前代码的 CPL 相比较,来决定当前代码是否可以访问目标段。 具体的比较规则是:
上面提到的 TSS、调用门等我们后续再进行介绍。
上面提到了段选择子中的特权级字段 RPL,它位于段选择子的第 0 位与第 1 位,当程序发生跳转,会比较 CPL 与 RPL,数值更大的会被更新到 cs、ds 的最低 0、1 位,成为新的 CPL,从而避免低特权级应用程序访问高特权级段内数据。
通过这篇文章的详细讲解,你是否已经对 IA-32 保护模式是如何实现的,以及“保护”指的是什么有了初步的了解呢?你是否已经对 32 位操作系统的分段机制的实现以及分段机制要解决的问题有了新的认识呢? 也许本文巨大的信息量让你有一些概念尚且无法完全吸收,如何去将这些知识应用于操作系统的代码中仍然是一个疑惑萦绕在你心头,别急,敬请期待下一篇文章 — 《进军保护模式》,用实际的源码带你彻底理解保护模式与分段机制。