实模式下,内存的访问是没有任何限制的,任何程序都能访问和修改任何内存地址,这就导致了实模式下的程序,甚至操作系统自己,都可能自身难保。于是,自8086的下一代产品80286起,保护模式诞生了;进一步的,自80386起,32位保护模式诞生了。

3.1 内存为什么要分段

在学习保护模式之前,需要先讨论一个问题:内存为什么要分段?

在实模式下,由于段寄存器只有16位,而地址线有20根,因此,段寄存器需要先左移4位,再与偏移地址相加,才能得到物理地址。这样看来,内存分段仅仅是一个补丁,用于解决地址线的数量比寄存器宽度更大这一问题,从而,一旦寄存器变得更宽,分段机制就不再需要了。

上述观点对不对呢?请看以下代码:

section text

    mov bx, helloStr

helloStr:
    db 'Hello'

如果上述代码是MBR的一部分,那么可以确定其加载地址是0x7c00。然而,如果上述代码是操作系统在运行过程中动态加载的一段程序,其加载地址又将如何确定呢?

最容易想到的方案是:硬编码每个程序的加载地址,并想办法告知操作系统这个地址(如记录在文件的头部);在汇编代码中,将vstart设定为这个地址。这种方案虽然简单,但有一个非常严重的缺点:各加载地址必须由人工预先安排好,且尽量不能重叠,一旦重叠,则受到重叠影响的程序一次只能加载一个,其他程序必须排队等待第一个程序的结束。

这种完全依赖硬编码的方案显然是不好的,于是,分段机制诞生了。分段机制的精妙之处在于:其将内存地址的确定分成了两个部分,一部分是偏移地址,由编译器负责。编译器只需要将程序中的地址计算为偏移量即可,无需关注这个程序被加载到哪里。另一部分是段基址,由操作系统负责。操作系统在加载一个程序时,只需要在内存中随便找一段能够容纳这个程序的内存,然后将程序加载到这段内存处,并将段基址设为这段内存的起点。此时,将段基址与偏移地址相加,就能确定内存地址了。

上述加载过程被称为重定位。编译器给代码计算偏移地址时,进行的是首次定位;操作系统通过填写段寄存器,再次影响了代码中的地址,便是重定位了。

综上,使用分段机制的主要目的是程序的重定位,解决地址线问题反而是一个附带的功能。也就是说,即使寄存器的宽度和地址线一样了,分段机制也依然有存在的意义。

3.2 保护模式的具体细节

保护模式,是具备内存保护功能的模式。想要保护一段内存,就需要设定这段内存的起点、长度、功能、权限等信息,然后,CPU就能根据这些信息对一段内存施加保护了。

3.2.1 段描述符

段描述符用于设定一段内存的各项信息。一个段描述符的大小为8字节,其结构如下图所示:

  • 第0\~15位是段限长的低16位。段限长的定义将在下文中描述
  • 第16\~39位是段基址的低24位
  • 第40\~43位是Type位,第44位是S位,这5位共同决定了当前段的类型。首先,如果S位为0,表示当前段是系统段;如果S位为1,表示当前段是非系统段。系统段是一类特殊用途的段,其不在本章中讨论;非系统段分为两种:代码段和数据段,其Type位的取值和含义如下:
Type位取值含义
0000数据段,只读,向上拓展
0001数据段,只读,向上拓展,已访问
0010数据段,可读写,向上拓展
0011数据段,可读写,向上拓展,已访问
0100数据段,只读,向下拓展
0101数据段,只读,向下拓展,已访问
0110数据段,可读写,向下拓展
0111数据段,可读写,向下拓展,已访问
1000代码段,仅执行
1001代码段,仅执行,已访问
1010代码段,可读,可执行
1011代码段,可读,可执行,已访问
1100依从代码段,仅执行
1101依从代码段,仅执行,已访问
1110依从代码段,可读,可执行
1111依从代码段,可读,可执行,已访问

观察上表可以发现:Type的第3位决定了当前段是代码段还是数据段;第2位决定了当前段是否被访问,这一位由CPU在访问该段时自动设为1,初始化时应将其设为0。访问位存在的意义将在下文中讨论。

对于数据段来说,只读还是可读写很好理解;那什么叫"向上拓展/向下拓展"呢?这里的拓展方向,是用来配合段限长使用的。如果是向上拓展的段,就需要满足这个公式:段基址 + 段限长 == 段的最后一个可用地址。也就是说,段限长在数值上等于段的大小减1。对于向下拓展的段,其计算方式十分难理解,我们也不使用该功能,所以就不讨论了。

对于代码段来说,可读,可执行都很好理解;依从代码段这个概念超出了本章的讨论范围,我们也不使用该功能,所以就不讨论了。

  • 第45\~46位是DPL位,即描述符特权级(Descriptor Privilege Level,DPL)位。特权级这个概念不在本章中讨论,目前读者只需要知道:特权级一共有4级,分别是0\~3级。0特权级权限最高,用于操作系统;3特权级权限最低,用于普通程序。中间的两级在我们的操作系统中不使用。所以,现在应使用0特权级
  • 第47位是P位,即存在(Present)位。P位为1时,表示段描述符存在;P位为0时,表示段描述符不存在。P位看似多此一举,段描述符写了就是存在,没写就是不存在,为什么还要再标注一下?原来,这一位是配合Type中的访问位一起使用的。设想:现在有一个GDT,里面有一些段描述符,此时内存突然不够用了,怎么办呢?于是,操作系统可以遍历GDT,找到一个虽然存在(P位为1),但没有被使用(访问位为0)的段,将这个段保存到硬盘上,这样,其内存就空出来了。然而这毕竟是临时方案,段并没有真正消失,所以不能将段描述符从GDT中删除,而是应将其P位置0,表示"这个段曾经存在过,但现在暂时不存在了"。另一方面,当CPU加载一个P位为0的段时,会引发一个异常以通知操作系统,于是操作系统又将这个段恢复。由此可见,P位和Type字段中的访问位,是给CPU与操作系统在内存不足时使用的。不过,我们的操作系统并不实现这一机制,故所有段描述符中的P位始终为1
  • 第48\~51位是段限长的高8位
  • 第52位是AVL位,即可用(Available)位。这一位是CPU送给操作系统的,CPU保证不会使用该位。在我们的操作系统中,也不使用该位
  • 第53位是L位,这一位保留给64位硬件使用,需要将其置0
  • 第54位是D/B位,这一位用于兼容80286。当D/B位为0时,当前段工作在16位模式下;当D/B位为1时,当前段工作在32位模式下
  • 第55位是G位,即粒度(Granularity)位。G位影响段限长的含义:当G位为0时,段限长表示的就是字节数;当G位为1时,段限长需要乘0x1000。所以,当G位为1时,段限长能够达到32位
  • 第56\~63位是段基址的高8位

观察段描述符的结构,可以发现一个不合理之处:段基址为什么是拆成两部分的?

原来,段描述符在80286中就有了,而80286只有24根地址线,所以,它的段描述符是这样的:

因此,出于兼容性的考虑,80386及其后续产品的段描述符,就只能设计成如今这个样子了。

3.2.2 全局描述符表

全局描述符表(Global Descriptor Table,GDT)是一段连续的内存,其中存放的是段描述符。

CPU要求:GDT的第0个表项必须存在且为0,可用的表项从第1项开始。这样做的意义将在后续章节中讨论。

3.2.3 全局描述符表寄存器

GDT准备好后,需要将其信息通知给CPU。具体来说,CPU需要知道这两件事:

  1. GDT在哪里?
  2. GDT有多长?

于是,CPU要求使用一段48位的内存来存储这两个信息。其中,低16位存储GDT的长度,高32位存储GDT的起始地址。与段限长一样,GDT的长度在数值上等于GDT的大小减1。

这段48位的内存准备好后,需要使用lgdt [...]指令加载GDT,加载后的GDT存放在全局描述符表寄存器(Global Descriptor Table Register,GDTR)中,这是CPU内部的一个专用寄存器。

sgdt [...]指令的功能与lgdt [...]指令相反,其可将GDTR存放到指定的内存中。

3.2.4 段选择子

在实模式下,段寄存器用于存放段基址;在保护模式下,段的各种信息都存放在段描述符中,段寄存器中存放的是段选择子(Segment Selector)。段选择子是一个16位的数值,结构如下:

RPL,即请求特权级(Requested Privilege Level,RPL),这也是一个特权级概念,目前,将其置0即可。

TI位用于标识局部描述符表(Local Descriptor Table,LDT),我们的操作系统并不使用LDT,故此位置0。

从段选择子的结构可以看出,在保护模式下,段寄存器起到的作用是在GDT中选择一个段描述符,然后从中取得段基址,再与偏移地址相加,就得到了物理地址。

3.3 分段机制与平坦模型

在实模式下,寄存器只能存放16位的偏移地址,故一个段最大为64K。想要访问全部的1M内存,就需要换段。例如,想要访问从0xb8000开始的显存,就需要先将段寄存器设为0xb800。这样不停的换段是一件很麻烦的事。

当寄存器升级到32位以后,单一寄存器已经能够访问全部的32位内存地址。于是,可以将段基址设为0,段限长设为最大,G位设为1,这样一来,整个4G内存就成了一个超长的段。这样的内存模型就称为平坦模型。

然而,分段机制存在的意义,并不是由于寄存器宽度不够,而是可以实现重定位,但平坦模型已经彻底不具备重定位能力了。这个问题将在后续章节中讨论。

3.4 进入保护模式的步骤

想要进入保护模式,需要依次进行以下操作。

3.4.1 加载GDT

保护模式的核心就在于GDT。其需要先准备好,然后通过lgdt [...]指令加载。

3.4.2 打开A20地址线

8086有20根地址线,这些地址线依次被称为A0\~A19地址线。地址线的数量是可以被编程技巧利用的,具体来说,程序可以故意让内存地址溢出,使其回滚到0。而当地址线增多时,这一技巧就不管用了。所以,出于兼容性的考虑,第21根地址线,即A20地址线上安装了一个开关。当A20地址线断开时,加法器会在A20地址线处将进位丢掉,从而模拟了只有20根地址线时的效果。32位保护模式不需要这个功能,所以需要打开A20地址线。

打开A20地址线的具体操作如下:

in al, 0x92
or al, 0x2
out 0x92, al

这里使用的inout指令是操作系统中比较常用的指令。在CPU中,有的外部设备被映射到内存地址上,如显存;更多的外部设备,如硬盘,显卡,键盘等,使用一套独立的地址进行访问,这套地址被称为IO端口。不同的外部设备被分配了不同的IO端口号。inout指令用于对IO端口进行读写。

这两个指令的语法比较严格,只能使用以下格式中的一种:

in al, 8位立即数
in al, dx
in ax, 8位立即数
in ax, dx

out 8位立即数, al
out dx, al
out 8位立即数, ax
out dx, ax

也就是说,必须使用AL或AX存放数据,具体使用哪个取决于端口的要求;且端口号必须存放在DX中,或使用一个8位立即数(如上面的0x92)。

3.4.3 设定CR0寄存器

CR0寄存器是控制寄存器系列中的一个,其用于控制CPU的多项运行模式。保护模式的开关位于CR0的第0位。CR0是一个32位寄存器,其不能像通用寄存器那样随意使用,而只能与32位通用寄存器互相传送。因此,需要使用以下代码来启动保护模式:

mov eax, cr0
or eax, 0x1
mov cr0, eax

3.5 进入保护模式

请看本章代码3/Mbr.s

第31\~38行,定义了GDT以及用于lgdt指令的GDTR。在GDT中,定义了一个空描述符,一个仅执行的代码段描述符,以及一个可读写的数据段描述符。因此,代码段选择子是1 << 3,数据段选择子是2 << 3

第3\~11行,完成了进入保护模式的三个步骤。至此,保护模式已经启动。

进入保护模式后,需要将6个段寄存器中的值修改为段选择子。

第13行,使用远跳转指令修改CS。这个跳转指令同时具备两个功能:

  1. 修改CS。由于代码段描述符中的D/B位是1,所以CPU现在工作在32位模式下
  2. 远跳转指令会清空指令译码器缓存,迫使其重新译码。这正是这里需要的,因为先前的译码器工作在16位模式下,不能对32位的指令正确译码

第15行,将编译模式声明为32位。[bits 32]是nasm提供的伪指令,在默认情况下,nasm的编译模式是16位,使用这个伪指令后,nasm就会切换到32位编译模式。bits伪指令的生效范围直至下一个bits伪指令或文件结尾。

第19\~24行,将剩余的5个数据段寄存器中的值修改为数据段选择子。

第26\~27行,通过直接访问显存的方式在屏幕左上角打印一个6,这在实模式下是做不到的。


樱雨楼
26 声望1 粉丝

Stay Gold