0%

保护模式下的寻址

在汇编语言中,或者你有学习过诸如微机原理或计算机组成原理等课程的话,那么你很可能听说过实模式和保护模式的概念。他们到底是什么,有什么不同,又如何寻址?

在王爽的《汇编语言》最后,有关于Intel微处理器的三种工作模式的介绍。

继Intel 8086推出之后,Intel又推出了划时代的80386微处理器,它可以在实模式、保护模式和虚拟8086模式下工作,从那以后的微处理器都提供了这三种工作模式,直到现在。Intel系列微处理器的三种工作模式如下:

  • 实模式:工作模式相当于一个8086
  • 保护模式:提供支持多任务环境的工作方式,建立保护机制
  • 虚拟8086模式:可以从保护模式切换至其中的一种8086工作方式,这种方式的提供使用户可以方便的在保护模式下运行一个或多个8086程序

当我们的系统开机时,cpu首先工作在实模式下完成一些工作,之后跳入保护模式,为我们的系统提供多任务环境的支持。而当我们需要在保护模式的系统上运行实模式下的程序时(比如学习汇编时所用的DOS系统),我们就需要在当前的保护模式下弄一个“假”的实模式,这就是虚拟8086模式。

GDT和描述符

在实模式下(可以理解为工作在8086上时),我们的CPU是16位的,提供了16位的寄存器,16位数据总线,20位的地址总线,可寻址范围位1M。物理地址遵循下面的计算公式:

$$
物理地址 = 段地址 * 16 + 偏移地址
$$

其中的段地址和偏移地址都是16位的。

从80386开始,Intel家族的CPU进入了32位时代,这时候CPU有32位的地址总线,所以可寻址范围为4G。CPU同样拥有的是32位的寄存器,一个寄存器即可寻址4GB的空间。

在实模式下,我们采用段地址:偏移地址的寻址方式是因为我们只有16为的寄存器,单个寄存器的寻址范围达不到1MB,但现在我们拥有了32位的寄存器,单个寄存器的可寻址范围已经可以达到4GB了,那么是不是就不需要段寄存器了?答案是否定的。在保护模式下,地址仍然采用“段地址:偏移地址”的方式来表示,只是段的概念发生了根本性的变化

实模式下,段值(段地址的值)还是地址的一部分。在保护模式下,虽然段值仍然由原来的16位的cs、ds等寄存器表示,但是此时它们仅仅是一个索引,这些个索引指向一个数据结构的表项,表项中详细定义了一个段的起始地址、界限、属性等内容,这个数据结构,叫做GDT(其实还可能是LDT,我们先讨论大多数情况),GDT中的每一个表项,叫做描述符

寻址过程

我们在来看一下保护模式下的寻址过程。在此之前,有几点要说明:

  • GDT是一个数据结构,它是保存在内存中的,所以它应该有一个起始地址,它是一系列描述符的集合
  • GDT的起始地址由一个专门的寄存器来存放 – gdtr,gdtr寄存器是48位的,这个寄存器我们稍后在探讨
  • GDT中的每一个描述符描述一个段,其中包括段的起始地址(基址)等属性
  • 保护模式的偏移地址和实模式下的是相同的,只不过是32位

好了,下面有一张图,我们可以看着这张图过一遍保护模式下是如何寻址的。

  1. 寻址时,先找到gdtr寄存器,从中得到GDT的基址
  2. 有了GDT的基址,又有段寄存器中保存的索引,可以得到段寄存器“所指”的那个表项,既所指的那个描述符
  3. 得到了描述符,就可以从描述符中得到该描述符所描述的那个段的起始地址
  4. 有了段的起始地址,将偏移地址拿过来与之相加,便能得到最后的线性地址
  5. 有了线性地址(虚拟地址),经过变换,即可得到相应的物理地址

相信到这里,你已经对寻址过程有了个大概的了解,然后我们看看我们上面所未详细提及的东西

gdtr寄存器

gdtr是一个48位的寄存器,其中保存了GDT的基地址和界限(或者说GDT的长度),高32位为GDT的基地址,低16位为界限。还记得保护模式中的段寄存器也是16位的吗,它们和gdtr中的界限是对应的啊。

描述符

GDT中的每个描述符占8个字节,其结构如下

我们可以不用管其中的属性,仅看段基址和段界限。是不是和上面的寻址联系上了呢。

你可能会问,问什么段基址和段界限都被分开了,却不放在一起?这主要还是历史遗留问题,我们就不在探讨了。

代码

光看理论终究还是水中月,我们看一段简单的代码实际体会一下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
[SECTION .gdt]
; GDT
; 段基址, 段界限 , 属性
LABEL_GDT: Descriptor 0, 0, 0 ; 空描述符
LABEL_DESC_CODE32: Descriptor 0, SegCode32Len - 1, DA_C + DA_32; 非一致代码段
LABEL_DESC_VIDEO: Descriptor 0B8000h, 0ffffh, DA_DRW ; 显存首地址
; GDT 结束

GdtLen equ $ - LABEL_GDT ; GDT长度
GdtPtr dw GdtLen - 1 ; GDT界限
dd 0 ; GDT基地址

; GDT 选择子
SelectorCode32 equ LABEL_DESC_CODE32 - LABEL_GDT
SelectorVideo equ LABEL_DESC_VIDEO - LABEL_GDT

上面的代码中,我们定义了一个角.gdt的段,其中前三个LABLE_xxx后是我们用一个叫Descriptor宏定义了三个选择子,其中的数值并不一定正确,因为我们只是定义了,还并没有初始化。 Descriptor的作用是将段基址、段界限和属性放在一个选择子中相应的位置,其定义在文章末尾,感兴趣的话可以看下。

GdtPtr是不是和gdtr中所放的内容一样呢?没错,当我们在实模式进入保护模式之前,我们需要将GdtPtr的值加载到gdtr寄存器:使用指令lgdt [GdtPtr]

那最后两个GDT选择子又是什么呢?好像是描述符相对于GDT基地址的偏移,其实并不全对,它稍稍复杂一些,如下图所示。

其中TI和RPL是选择子的一些属性,剩下的高13位表示的是描述符在描述符表的位置,即GDT中第几个描述符

最后,我们看一下如何使用上面的东西吧

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
[SECTION .s32]; 32 位代码段. 
[BITS 32]

LABEL_SEG_CODE32:
mov ax, SelectorVideo
mov gs, ax ; 视频段选择子(目的)

mov edi, (80 * 11 + 79) * 2 ; 屏幕第 11 行, 第 79 列。
mov ah, 0Ch ; 0000: 黑底 1100: 红字
mov al, 'P'
mov [gs:edi], ax

; 到此停止
jmp $

SegCode32Len equ $ - LABEL_SEG_CODE32

上述代码将一个字母P显示在屏幕上。gs中保存的是显存的选择子,edi为偏移地址,然后使用mov [gs:edi], ax将ax的内容写入到地址为gs所指的描述符中的段基址+edi的内存处,由于这里写入的是显存,所以将会将一个字母P显示在屏幕上。

Descriptor宏的定义如下

1
2
3
4
5
6
7
8
9
10
11
; usage: Descriptor Base, Limit, Attr
; Base: dd
; Limit: dd (low 20 bits available)
; Attr: dw (lower 4 bits of higher byte are always 0)
%macro Descriptor 3
dw %2 & 0FFFFh ; 段界限1
dw %1 & 0FFFFh ; 段基址1
db (%1 >> 16) & 0FFh ; 段基址2
dw ((%2 >> 8) & 0F00h) | (%3 & 0F0FFh) ; 属性1 + 段界限2 + 属性2
db (%1 >> 24) & 0FFh ; 段基址3
%endmacro ; 共 8 字节

参考:

  • 《汇编语言》 王爽
  • 《一个操作系统的实现》 于渊