本文是 从零开始写个操作系统吧 的系列文章之一。

到目前为止,我们在 bootsect 中所使用的地址计算方式皆为 段地址 * 16 + 偏移地址 ,也就是 16 位实模式下的计算方式。但是接下来我们需要介绍另外一种完全不同的计算方式,即 32 位保护模式下的计算方式。

1. 32 位保护模式

16 位保护模式很好用,并且也很简单。既然如此,那么为什么那么我们还要使用 32 位保护模式呢?事实上,32 位保护模式主要有如下两个优势:

  • 扩大 CPU 的寻址范围
  • 提升安全性

32 位保护模式下的地址计算方式和 16 为实模式不一样,在 32 位保护模式下,真正的地址不再等于 段地址 * 16 + 偏移地址。在 32 位保护模式下,段寄存器中存储的地址不再叫段地址,而是叫 段选择子。段选择子包含如下三个部分的数据:

  • 请求特权级 RPL(Requested Privilege Level);
  • TI,TI=0 表示描述符在 GDT 中,TI=1 表示描述符在 LDT 中;
  • 索引值(Index),查询 GDT 的时候所使用的就是这部分的索引值;

段选择子的示意图

我们根据段选择子的索引去查询 全局描述符表,可以获得 段描述符,段描述符中存在着一个部分叫做 基地址,我们获取基地址之后再加上偏移地址,就可以获取到真正的内存中的地址了。以上步骤稍微有点复杂,不过复杂的部分只在段地址的计算的部分,偏移地址部分和 16 位实模式并没有任何区别,所以接下来我详细的介绍一下段地址的这一系列的转化的详情。这一系列步骤可以用下图来表示:

通过上面的观察可以发现,GDT(全局描述符表)在这转换之中承担了一个非常重要的角色,所以接下来我们就详细的了解一下这个表的结构。

2. GDT(全局描述符表)

GDT 在本质上是一个数组,而我们之前提到的段选择子就是这个数组的下标,我们可以通过段选择子去查阅这个数组从而得到数组中一个指定的段描述符。GDT 存在于内存中,CPU 通过一个名叫 gdtr 的寄存器保存 GDT 的位置信息,所以我们在设定好了 GDT 之后,要把 GDT 的位置信息保存在 gdtr 中。所以我们可以使用如下的公式来计算出段描述符的位置:

段描述符的位置 = 段选择子的索引 * 一个段描述符的长度 + gdtr 的值

OK,我们已经知道 GDT 是一个数组,并且也知道如何让 CPU 找到我们的 GDT 在内存中的位置了。下面我来详细介绍一下 GDT 中的每一项(即段描述符)会存储哪些东西,正是依靠这些存储的内容我们才能获取到比 16 位实模式更好的内存使用方式。

段描述符的大小为 8 个字节,主要包含了以下三个部分:

  • 基址(32 位),定义了这个段在物理内存的起始地址;
  • 段大小(20 位),定义了这个段的大小;
  • 其他各种标志位(12 位),包含了特权级以及读写权限等内容。

下面就是一个段描述符的图示:
段描述符

标志位包含了这个段的读写权限、特权级等等一系列重要的属性,各个标志位的具体含义以及相应数值的意义请自行查阅资料获取。

在上面我们接触到了 GDT、段选择子、段描述符等概念,如果理解起来稍有困难,下面这张图清晰地揭示了这些概念之间的关系

其中查阅 GDT,找到对应的段描述符的步骤就是使用了公式 段描述符的位置 = 段选择子 * 一个段描述符的长度 + gdtr的值 来完成的。

现在,你应该已经基本上明白了段选择子、GDT、段描述符这些概念之间的关系了,接下来我们就要进入 32 位保护模式的世界了,不过在进入之前,我们还需要先根据需要来初始化一个 GDT 以供 32 位保护模式下的 CPU 使用。

3. 初始化 GDT

GDT 可以存放在内存中的任意位置,不过设置好 GDT 之后需要把 GDT 在内存中的位置(大小为 32 位)和长度(大小为 16 位)保存在 gdtr 中,之后 CPU 就可以根据 gdtr 寄存器中的数据来寻找 GDT 在内存中的位置了。

了解了一些与 GDT 相关的概念之后,我们开始在内存中手动的创建一个 GDT,并且使用 lgdt 指令把 GDT 的属性信息(即位置和长度信息)保存在 gdtr 中。下面就是一个我们创建 GDT 并把其信息保存在 gdt 中的汇编实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
[org 0x7c00]

lgdt [gdt_descriptor] ; 把 gdt 的位置保存到 gdtr 中

cli ; 屏蔽中断

; 把 cr0 的最低位置为 1,开启 32 位保护模式
mov eax, cr0
or eax, 0x1
mov cr0, eax

; jmp CODE_SEG:init_pm ; 进行一次远跳转来刷新 CPU 缓存,存在问题

; 初始化段寄存器的值并设置栈的位置
init_pm:
mov ax, DATA_SEG
mov ds, ax
mov ss, ax
mov es, ax
mov fs, ax
mov gs, ax
mov ebp, 0x90000 ; 现在栈顶指向 0x90000
mov esp, ebp
call BEGIN_PM ; 开始执行保护模式的代码

BEGIN_PM:
mov ebx, STRING ; 被打印字符的地址
mov edx, VIDEO_MEMORY ; 显存的初始地址
mov ah, WHITE_ON_BLACK ; 设置文字的颜色

call print_string_pm
jmp $

print_string_pm:
mov al, [ebx]
cmp al, 0
je done
mov [edx], ax ; al 为字符,ah 为颜色,修改显存对应位置的值即可显示字符

add ebx, 1 ; 字符的位置加一
add edx, 2 ; 显存的地址加二
jmp print_string_pm

done:
ret

VIDEO_MEMORY equ 0xb8000 ; 显存位置
WHITE_ON_BLACK equ 0x0f ; 背景色的控制

STRING db 'We are in protected mode!', 0 ; 打印的字符

; ******** GDT 开始的标记 ********
gdt_start:
; GDT 的第一项必须为 0
gdt_null:
dd 0x0 ; dd,4 个字节
dd 0x0
; 代码段描述符,一段很机械的的定义,参考 Intel 手册即可
gdt_code:
; base=0x0, limit=0xfffff ,
; 1st flags: (present)1 (privilege)00 (descriptor type)1 -> 1001b
; type flags: (code)1 (conforming)0 (readable)1 (accessed)0 -> 1010b
; 2nd flags: (granularity)1 (32-bit default)1 (64-bit seg)0 (AVL)0 -> 1100b
dw 0xffff ; Limit (bits 0-15)
dw 0x0 ; Base (bits 0-15)
db 0x0 ; Base (bits 16-23)
db 10011010b ; 1st flags , type flags
db 11001111b ; 2nd flags , Limit (bits 16-19)
db 0x0 ; Base (bits 24-31)
; 数据段描述符,一段很机械的的定义,参考 Intel 手册即可
gdt_data:
; Same as code segment except for the type flags:
; type flags: (code)0 (expand down)0 (writable)1 (accessed)0 -> 0010b
dw 0xffff ; Limit (bits 0-15)
dw 0x0 ; Base (bits 0-15)
db 0x0 ; Base (bits 16-23)
db 10010010b ; 1st flags , type flags
db 11001111b ; 2nd flags , Limit (bits 16-19)
db 0x0 ; Base (bits 24-31)
; 在这里放置一个标记方便我们计算 gdt 的长度
gdt_end:

; GDT descriptior
gdt_descriptor:
dw gdt_end - gdt_start - 1 ; Size of our GDT, always less one of the true size
dd gdt_start ; Start address of our GDT

; 把描述符的位置通过常量的值保存下来
CODE_SEG equ gdt_code - gdt_start
DATA_SEG equ gdt_data - gdt_start

times 510-($-$$) db 0
dw 0xaa55

汇编如上的代码并运行,能够得到如下的结果,红线中的字符就是我们通过改变显存中的数据从而打印出来的:

到这里,你大概已经对 32 位保护模式有了一定的了解了,如果想要更加详细的对此进行了解,强烈推荐阅读 Linux 内核完全剖析 的第四章:80X86 保护模式及其编程。

4. 总结

这这篇文章中,我们了解到了 32 位保护模式和 GDT 的概念,以及如何通过设置 GDT 来帮助我们切换到 32 位保护模式。