Featured image of post x86 从实模式到保护模式

x86 从实模式到保护模式

字数: 2478

当计算机启动,CPU 处于实模式下,实模式下只有 1MB 的寻址,而且没有任何硬件级的存储器保护。在实模式,段寄存器直接存放段基址,程序可以任意访问任何物理地址。

在 80286 处理器之后,引入了保护模式。保护模式提升了 CPU 寻址能力,带来了全局描述符表(GDT)。段寄存器保存的数据不再是内存物理地址,而是 GDT 选择子,使得内存访问更安全。

从实模式切换到保护模式要做好多事情:初始化 GDT 表、选择子、开启 A20 地址线、改写 cr0 寄存器……
现在一步步去切换保护模式:

GDT(Global Descriptor Table)

昨天下午近现代史纲要课结束后就去看于渊的这本操作系统书,GDT 就是我的一道拦路虎。一直到晚上才简单弄明白 GDT 是啥,如何使用。

GDT 是 x86 下的二进制数据结构,它向 CPU 描述内存段。GDT 保存了段基址、段界限和段属性。

GDTR

GDTR 是 CPU 的一个寄存器,其指向 GDT 表。在 nasm 汇编中,用:

1
lgdt [GdtPtr]

向 GDTR 写入 GDT 表地址。
之后获得 GDT 表的数据就通过 GDTR 的偏移量获得。

GDT 结构

GDT条目结构(来源:WikiPeida)

GDT 条目是 8 字节的数据结构。
由于历史因素的积累,为了向后兼容。GDT 的条目略显凌乱。
可以弹道在段基址被拆分成了两部分,在高地址还有一部分。这就是为了向后兼容做出的妥协。

在于渊一书中,通过如下宏来实现 GDT 条目的自动生成:

1
2
3
4
5
6
7
%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 字节

现在详细解析下这个宏。这个宏我看了一个多小时才大概理解具体操作,都是位运算神力。

1
%macro Descriptor 3

这行主要是定义宏名称,确定参数。
后面 %1 %2 %3 就分别指代 3 个参数。
各参数代表:

  • %1 段基址 32 位:内存的起始地址
  • %2 段界限 20 位:内存长度
  • %3 段属性 12 位:权限、类型……
1
dw	%2 & 0FFFFh				; 段界限1

生成段界限的低 16 位。
dw 表示两字节数据,%2 & 0xFFFFh 表示只保留段界限的 0 ~ 15 位。
作用:填充 Descriptor 第 0 ~ 1 字节。

1
dw	%1 & 0FFFFh				; 段基址1

和上一块大致一样,其作用填充 Descriptor 第 2 ~ 3 字节。保存段基址的 0 ~ 15 位。

1
db	(%1 >> 16) & 0FFh			; 段基址2

db 表示一字节数据,(%1>>16)&0FFh 将段基址右移 16 位,让 16 ~ 23 位移到最低位取 1 字节填充 Descriptor 的第 4 字节位。

1
dw	((%2 >> 8) & 0F00h) | (%3 & 0F0FFh)	; 属性1 + 段界限2 + 属性2

(%2 >> 8) & 0F00h 取出段界限的 16 ~ 19 位(高 4 位)。
(%3 & 0F0FFh) 过滤出段属性的有效位。通过按位或(|) 把两段数据拼在一起。填充 Descriptor 的第 5~6 字节。

1
db	(%1 >> 24) & 0FFh			; 段基址3

把段基址右移 24 位,让 24~31 位移到最低位。填充描述符的第 7 字节。

1
%endmacro ; 共 8 字节

结束宏。


而调用则就简单了。一般是在 .gdt 段下调用:

1
2
3
4
5
6
7
[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 结束

后面还需要一些参数:

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

GDT 选择子(Selector)

在 GDT 下面有这么一段数据:

1
2
3
4
; GDT 选择子
SelectorCode32		equ	LABEL_DESC_CODE32	- LABEL_GDT
SelectorVideo		equ	LABEL_DESC_VIDEO	- LABEL_GDT
; END of [SECTION .gdt]

大致可以理解为描述符相对于 GDT 基址的偏移,但实际上它的低 3 位是有特殊用途的:

  • 0~1 位 RPL 权限位(内核/用户)
  • 2 位 TI (GDT/LDT) 选表
  • 3 ~ 15 位 描述符索引。
  • 直接用该段地址减去 GDT 基址就可以在不带上 RPL/TI 这两个特殊位的情况下得到 Selector。

在保护模式下,CPU 的段寄存器存储的是选择子的地址。通过选择子访问 GDT 得到段基址而不是直接存储物理地址更加安全,这也是保护模式很重要的一种机制。

初始化一些数据

现在我们 BIOS 刚自检完,还处于实模式下,此时 CPU 为 16 位。现在要先运行一些初始化代码,一步步走上正轨:

1
2
3
4
5
mov	ax, cs
mov	ds, ax
mov	es, ax
mov	ss, ax
mov	sp, 0100h

前面四行代码让 cs = ds = es = ss,保证寻址统一。
最后设置栈指针为 0x100。
一个经典的 16 位实模式初始化。
接下来初始化 32 位代码段描述符:

1
2
3
4
5
6
7
8
9
; 初始化 32 位代码段描述符
	xor	eax, eax
	mov	ax, cs
	shl	eax, 4
	add	eax, LABEL_SEG_CODE32
	mov	word [LABEL_DESC_CODE32 + 2], ax
	shr	eax, 16
	mov	byte [LABEL_DESC_CODE32 + 4], al
	mov	byte [LABEL_DESC_CODE32 + 7], ah

实模式物理地址公式:$物理地址=段值\times16+偏移$ LABEL_DESC_CODE32 存储的是偏移地址。cs 存储的是段地址,这样左移 4 位就是 * 16。
ax, al, ah 就是 eax 的嵌套。通过这样对真实地址进行分段写入 LABEL_SEG_CODE32 这样就得到了 GDT 代码段的数据。

加载 GDTR

1
2
3
4
5
6
	; 为加载 GDTR 作准备
	xor	eax, eax
	mov	ax, ds
	shl	eax, 4
	add	eax, LABEL_GDT		; eax <- gdt 基地址
	mov	dword [GdtPtr + 2], eax	; [GdtPtr + 2] <- gdt 基地址

这段计算 GDT 在内存中的真实地址。首先清空 eax 寄存器,之后将 ds(数据段寄存器) 放入 ax 然后段地址 x16 左移位,然后放入 GDT 的地址到 eax。
最后将 eax 存储的 GDT 的地址放入 GdtPtr 中。

为什么放入 [GdtPtr + 2]? 因为 GDTR 的格式中, 0~1 字节放 GDT 界限, 2 ~ 5 字节放 GDT 的 32 位基地址。所以要 + 2。

最后一步就是通过 lgdt 指令将 GdtPtr 的数据放入 GDTR 寄存器。

1
lgdt	[GdtPtr]

关闭中断

为了防止在进入保护模式的过程中因为突发的中断打断导致异常,要关闭中断再进入下一步操作。

1
cli

打开 A20 地址线

A20 是第 21 根地址线,控制 1MB 以上内存能否访问。进入保护模式前要开启 A20。实际上也是向下兼容的问题。

1
2
3
4
; 打开地址线A20
in	al, 92h
or	al, 00000010b
out	92h, al

in al, 92h 从端口 0x92 读 1 个字节到 al 端口 0x92 是主板上快速 A20 控制端口

or al, 00000010b 把第一位置 1,这个位是 A20 使能开关。

out 92h, al 把修改后的值写回端口。
这样 A20 就被打开了。

打开 cr0 中保护模式开关

cr0 是 CPU 总模式的开关,其的第 0 位 PE 位控制 PCU 处在实模式还是保护模式。
PE = 0 实模式
PE = 1 保护模式

1
2
3
4
; 准备切换到保护模式
mov	eax, cr0
or	eax, 1
mov	cr0, eax

通过 eax 寄存器改写 cr0。
or eax, 1 把第 0 位置为 1。

此时,CPU 就进入了保护模式,一切保护模式下的硬件支持都生效了。只差最后一步跳转进入到 32 位代码段。

跳转到 32 位代码段

1
	jmp	dword SelectorCode32:0	; 执行这一句会把 SelectorCode32 装入 cs,

这行在于渊的书中被称为:“历史性的 jmp”。
将跳转到描述符 LABEL_DESC_CODE32 对于段的首地址,进入保护模式。

保护模式下

在保护模式下,能做什么呢?最简单的还是打印字符:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
[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
; END of [SECTION .s32]

往显存写了一个字符。

参考资料

  1. 自己动手写操作系统 于渊 电子工业出版社
  2. WikiPeida:保护模式
  3. WikiPeida:全局描述符表
  4. OSDev.org:Global Descriptor Table