x86 保护模式浅析(二)

走进保护模式

A20 地址线

在本文中,要完全解释 A20 地址线的作用以及原理是不太现实的。对于我们程序员而言,我们只需要了解一些大致的概念。简单来说,因为考虑到向下兼容性,A20 地址线在默认情况下是禁用的。于是,我们得启用它,这样我们就可以访问全部内存,这对于 CPU 在保护模式下正常工作是十分重要的

至于如何启用 A20 地址线,OSDev 上面列举了很多方法,可以参考链接:https://wiki.osdev.org/A20
这里,我们列举比较简单的一种方法,在大多数情况下是可正常使用的(对于一些老主板,需要主板CMOS设置中启用快速 A20 支持 )

  • 快速 A20 门(Fast A20 Gate)
in al, 92h    ; 从 92h 端口读一字节
or al, 2      ; 置字节的第 2 位为 1
out 92h, al   ; 写回 92h 端口

上述的 Fast A20 Gate 方式只需要 3 条指令就可以启用 A20 地址线,当执行完这 3 条指令后,CPU 就有能力访问全部内存了

关闭中断(包括 NMI 在内)

保护模式下,CPU 的中断机制与实模式是不相同的。BIOS 中断、中断向量表都不再适用于保护模式,在进入保护模式之前,需要先使用 CLI 指令以关闭中断

cli    ; 关闭中断

设置 GDTR 寄存器

在上一篇文章中,我们讲到 GDT、段选择子的概念,在进入保护模式前,首先要正确地设置 GDTR 寄存器,确保 GDT 的设置是有效的

lgdt m48    ; 48 位(6字节)向量,低字表示 GDT 界限(字节大小减1),高双字表示 GDT 头部的线性地址

设置控制寄存器的PE位

CPU 内部的 0 号控制寄存器(CR0)中的最低位即是PE(Protection Enable,保护生效)位,将其置为 1 时,CPU 将工作在保护模式下,汇编代码可以像下面这样写

mov eax, cr0
or al, 1      ; 置 PE 位为 1
mov cr0, eax

命运的跳转(jmp dword 方式)

jmp dword 段选择子偏移:跳转到的地址

其中,段选择子偏移就是对应段选择子(一般是代码段选择子)相对于GDT头部的偏移字节数,而跳转到的地址,即是在段选择子的段界限内的地址。因为 PE 位的设置,现在 CPU 在保护模式下,所以得用段选择子和线性地址的方式去访问内存

至于为什么要 jmp dword,是因为现在 CPU 还在 16 位机器代码上执行跳转,但是跳转的地址却是用 32 位保护模式的方式寻址的。jmp dword 能够使 CPU 的流水线清空,并串行化执行跳转后的 32 位机器代码,这样 CPU 就能在保护模式下正常运行。

后续

光说不练假把式,也许你还是只有一些笼统的概念。下一篇文章,笔者将用整个实例来演示并穿插解释一段引导扇区程序是如何一步步进入保护模式的。感谢阅读本文!

x86 保护模式浅析(一)

实模式下的内存访问

实模式下,CPU通过段寄存器(16位)和偏移地址(16位)来访问物理地址,这个过程可以抽象地简述成以下步骤:
1、将段寄存器中存放的地址,左移4位(乘以2的4次方,即乘以16)得到基址(为了方便理解暂且这么叫吧)
2、将上一步得到的基址加上偏移地址,得到真实的物理地址

在上面的步骤中,我们不难看出的这几点特性:

  • 在实模式下,当段寄存器为 0 时,物理地址所能表示的最大范围是 0 ~ FFFFF,即 1MB 连续内存空间
  • 在实模式下,无论段寄存器为何值(无论基址为何值),CPU 总是只能访问 1MB 的连续内存空间
  • 在实模式下,段寄存器的作用更像是确定一个访问起点,偏移地址是用于基于这个起点开始计算距离,最终确定内存位置

初探 GDT

x86 保护模式使得 CPU 不再局限于 1MB 的内存空间,使其寻址能力达到了 4GB(后文会解释原因),同时它提供了更多优良的特性。废话少说,我们可以先来了解一下以下概念。

段选择子(Selector)

要进入保护模式,CPU 必须要设置一些必要的参数,这个工作由程序员完成。程序员通过设置全局描述符表(简称 GDT),使得 CPU 从中获取必要的参数,这样 CPU 才能顺利进入保护模式。全局描述符表(GDT)一般由多个段选择子(Selector)组成。下面笔者使用类 C 伪代码表示单个段选择子的相关结构,并给出解释

  • 单个全局描述符表的表项(即段选择子)的数据结构(从低字节到高字节排列):
struct gdt_entry {    // 单个段选择子,也就是全局描述符表的每一个表项的结构
    WORD low_limit;        // 段界限的  0 ~ 15 位
    WORD low_base;         // 段基址的  0 ~ 15 位
    BYTE high_base;        // 段基址的 16 ~ 23 位
    BYTE access_byte;      // 访问控制(一字节)(下文有展开说明)
    HALF_BYTE high_limit;  // 段界限的 16 ~ 19 位(段界限共20位)
    HALF_BYTE flags;       // 标志(半字节)(下文有展开说明)
    BYTE high_high_base;   // 段基址的 24 ~ 31 位(段基址共32位)
}selector; // 段选择子
  • 访问控制字节(access_byte)(从低位到高位排列)
BYTE access_byte {  // 没办法,理解性地,暂且这么定义吧
    _bit AC;  // 被访问标志位:一般置为 0,当该段被访问时,CPU将其置为 1
    _bit RW;  // 读写标志位:若是代码段,则此位表示该段是否可读;若是数据段,则此位表示该段是否可写
    _bit DC;  // 方向/顺应标志位:若是代码段,则此位表示是否可以从相等或较低的特权级开始执行(否则只能被“特权级标志”中描述的特权级所执行);若是数据段,则此位表示该段是否是向下(向低地址方向)增长的
    _bit Ex;  // 可执行标志位:此位表示该段是否可被执行,若置 0 则是数据段(不可执行)
    _bit S;   // 描述符类型标志位:置 1 表示该段是代码段或数据段,置 0 表示该段是系统段(暂时先置 1 吧)
    _double_bit Privl; // 特权级标志双位:双位可置 0 ~ 3,表示该段所具有特权级(ring0 ~ ring3)
    _bit Pr;  // 存在标志位:应当永远置 1,表示该选择子(Selector)有效
};
  • 标志位半字节(flags)(从低位到高位排列)
HALF_BYTE flags {  // 没办法,理解性地,暂且这么定义吧
    _bit zero;  // 一般置为 0
    _bit zero;  // 一般置为 0
    _bit Sz;    // 宽度标志位:此位表示选择子是否定义的是 32 位保护模式(置 0 表示选择子定义了 16 位保护模式)
    _bit Gr;    // 粒度标志位:此位置 1 表示段界限粒度为页(4KB),置 0 表示段界限粒度为字节(1Byte)
};

在保护模式下,段已经不同于实模式中的段。实际上,更多地是访问方式、起到的作用发生了改变。这可能需要读者自己去循序渐进地体会。下文将会讲到全局描述符表的结构以及加载方式,段选择子的使用方式

全局描述符表(Global Descriptor Table)

我们从前述中得知,段选择子就是全局描述符表的表项,也就是说 GDT 的每一个表项都是 8 字节的段选择子。段选择子中定义了段的基址、段的界限、还有一些描述性的信息。因为系统中一般将用到多个段,所以用线性的方式去存放这些段的选择子,于是构成了 GDT。我们可以先用类 C 的伪代码来定义 GDT 的数据结构

  • GDT(全局描述符表)(从低字节到高字节)
struct gdt {
    gdt_entry selector_null;  // 第 1 个选择子(全局描述符表项)(填充为 0)
    gdt_entry selector1;      // 第 2 个选择子(全局描述符表项)
    gdt_entry selector2;      // 第 3 个选择子(全局描述符表项)
    /* ...... */
};

GDT 的一个段选择子(第一个表项),必须全部填充为 0,这是系统约定俗成

看到这里,你应该已经对 GDT 的结构豁然开朗了。下文我们将介绍如何加载 GDT,为初探 x86 保护模式的难点画上句号

加载 GDT

假设我们在内存的某个位置定义了一个包含多个段选择子的 GDT,它包含我们进入保护模式后,CPU 在保护模式正常运行的相关信息。在我们进入保护模式之前需要让 CPU 加载这个 GDT,现在,我们有两样法宝,一个是 LGDT 机器指令、另外一个是 GDTR 寄存器。

顾名思义,LGDT 指令能够将 GDT 载入到 GDTR 寄存器中,这样 CPU 就“得到”了在保护模式下正常运作的相关信息。问题是,具体该如何做到。我们先来了解 GDTR 寄存器的结构,如图所示

Size 即是GDT所占总字节数减 1 的值(边界值),Offset 即是 GDT 头部的线性地址(即绝对地址)

不难看出,GDT 中最多能容纳 0x10000 / 8 = 8192 个段选择子。话说回来,我们可以使用 LGDT 指令从指定内存位置读取这个 48 位(6字节)的 GDT 向量到 GDTR 寄存器中。这样,CPU 就将 GDT 的相关信息“牢记在心”

后续

下一篇文章,笔者将讲述如何为真正进入保护模式扫清障碍,有关 A20 地址线、PE 标志位的设置、以及段选择子的用法、保护模式下的内存寻址方式。感谢阅读本文!