实模式到保护模式


在不知道保护模式之前,让我们先看一段代码,如果没有接触过保护模式,会一头雾水,不过没关系,在这种好奇心的驱使下,会学的更高效。

; ==========================================
; pmtest1.asm
; 编译方法:nasm pmtest1.asm -o pmtest1.com
; ==========================================

%include	"pm.inc"	; 常量, 宏, 以及一些说明


org	07c00h
	jmp	LABEL_BEGIN



[SECTION .gdt]
; GDT
;                                         段基址,      段界限     , 属性

LABEL_GDT:		Descriptor	       0,                0, 0     		; 空描述符
LABEL_DESC_CODE32:	Descriptor	       0, SegCode32Len - 1, DA_C + DA_32	; 非一致代码段, 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
; END of [SECTION .gdt]


[SECTION .s16]
[BITS	16]
LABEL_BEGIN:

	mov	ax, cs
	mov	ds, ax
	mov	es, ax
	mov	ss, ax
	mov	sp, 0100h

	; 初始化 32 位代码段描述符
	; 旨在将LABEL_SEG_CODE32设为保护模式的基址
	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

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



	; 加载 GDTR
	lgdt	[GdtPtr] ; 将GdtPtr指示的6字节加载到寄存器gdtr,其中32为GDT基地址,16为GDT界限

	; 关中断

	cli

	; 打开地址线A20,即第20号地址位(从0开始),超过了1MB,开机时默认关闭
	; 只有打开才能寻1MB以外的空间
	in	al, 92h
	or	al, 00000010b
	out	92h, al

	; 准备切换到保护模式
	; 将寄存器cr0的第0号位置设为1,相当于打开了保护模式的开关
	mov	eax, cr0
	or	eax, 1
	mov	cr0, eax

	; 真正进入保护模式
	jmp	dword SelectorCode32:0	; 执行这一句会把 SelectorCode32 装入 cs, 并跳转到 Code32Selector:0  处
; END of [SECTION .s16]


[SECTION .s32]; 32 位代码段. 由实模式跳入.
[BITS	32]
LABEL_SEG_CODE32:

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

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

	; 到此停止
	jmp	$
	
SegCode32Len	equ	$ - LABEL_SEG_CODE32
; END of [SECTION .s32]

这段代码会将OS从实模式跳转到保护模式,然后在屏幕的第10行第0列输出黑底红字的P,让我们先运行下看看效果,这里使用的工具为:Ubuntu虚拟机 + NASM + Bochs + FreeDos。

最基础的,可以将上如代码编译为pmtest1.bin然后直接写入Boot Sector(引导扇区),这样做很方便,可以直接运行,不需要FreeDos。但是,有个很明显的缺点,就是空间有限,限制为512B,一旦代码量大了起来就不行了,所以不是长久之计。

解决上述问题有两个方法,一个是写一个Boot Sector,然后是它读取我们写的程序并运行,即引导我们写的OS内核,不过难度较大。第二个方法就是借助别的东西,比如DOS,我们把程序编译成COM文件,然后让DOS来执行它,这里,我们使用FreeDos来完成这个任务。

首先去Bochs官方下载一个FreeDos,主要用到其中的a.img。解压后,将a.img复制到工作目录,更名为freedos.img

bximage生成一个软盘镜像,起名为pm.img

新建文件bochsrc,内容如下,重点为floppya、floppyb和boot:

megs:32

romimage:file=$BXSHARE/BIOS-bochs-latest

vgaromimage:file=$BXSHARE/VGABIOS-lgpl-latest

floppya:1_44=freedos.img,status=inserted
floppyb:1_44=pm.img,status=inserted
#软盘映射
boot:a
#硬盘
#boot:disk

#分配HD为60M,根据分配硬盘大小,cylinders, heads, spt会变化
#ata0: enabled=1, ioaddr1=0x1f0, ioaddr2=0x3f0, irq=14
#ata0-master: type=disk, mode=flat, path="boot.img", cylinders=121, heads=16, spt=63

log:bochsout.txt

使用bochs -f bochsrc启动Bochs,待FreeDos启动完毕后格式化B盘:

这里说下为什么要用Ubuntu,因为Linux的挂载(mount)操作可以将宿主目录和Dos中的镜像关联起来,轻易实现将目标文件复制到Dos运行的镜像中。首先,编译出.COM文件,然后将其复制到虚拟软盘pm.img上:

nasm pmtest1.asm -o pmtest1.com
sudo mount -o loop pm.img /mnt/floppy
sudo cp pmtest1.com /mnt/floppy/
sudo umount /mnt/floppy

然后到FreeDos中,执行命令:B:\pmtest1.com,就运行起来了,如图所示,显示一个红色的p。

好了,演示完毕,接下来开始介绍各部分代码的含义,由此了解保护模式,不过更重要的,是要先了解保护模式的一些历史。

初始保护模式

保护模式重在“保护”二字,顾名思义,其主要提供了安全上的服务,那它到底安全在哪?保护了什么?这个还在要从8086的16位寄存器时开始说起。

在Intel 8086中,CPU是16位的,它有着16位的寄存器、16位的数据总线以及20位的地址总线,共1MB的寻址空间,由于数据总线是少于地址总线的,所以物理地址是又段:偏移组成的,两者都是16位。

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

上述模式就是我们后来所称的实模式,它的弊端是很大的:

1> 操作系统和用户程序属于同一特权级,平起平坐,没有区别;

2> 用户程序引用的地址直接为物理地址,即逻辑地址=物理地址,指哪打哪,没有限制很不安全;

3> 用户程序可以自由设定段基址,在1MB空间中随意访问,不安全;

4> 16位寄存器只能访问64KB,所以当访问的地址跨越64KB时,需要更改段基址,很麻烦;

5> 一次只能运行一个程序,无法充分利用计算机资源;

6> 最大的弊端,空间只有1MB,这在20年前就已经不够用了;

其中,(1)(2)(3)是安全方面的缺陷,没有安全可言的CPU是注定不可依赖的,它从本质上决定了程序乃至操作系统的数据有被随意删改的风险。(4)(5)是使用方面的缺陷,这个再20年前似乎还能忍受,但是随着计算需求的提升必会被淘汰。最硬伤的就是(6),1MB完全不够用,因此急需更高的寻址空间。

从80286开始,Intel的CPU进入32位时代,32位CPU具有两种运行模式,分别为实模式保护模式,可以兼容8086时代的16位运行环境。实际上,在8086时代,16位CPU根本没有实模式的概念!当时的人用的只有16位CPU,他们从没想过自己习惯的模式还要重新命名。直到CPU发展到了32位,新的运行模式和之前不大相同,但不管怎么发展,都一定要满足一个原则:兼容

也就是说,32位CPU具有两种模式,一种是为了克服8086运行模式弊端的新模式,即它自己的运行模式;另一种就是为了兼容8086运行模式而存在的,实模式。为了凸显前者的优势,将其称为保护模式。再强调一遍:

实模式是32CPU时才提出的概念,旨在兼容8086的16位运行模式

实模式的运行环境为16位,保护模式为32位。但是要注意的是,当PC运行在实模式下时,CPU依然是32位,硬件本身不会变!相当于高中生去做初中生的题,当它处在16位的运行模式时,依然可以处理32位的操作数,因为它在硬件层面上是32位的。也即:

实模式指32位CPU运行在16位模式下的状态,但CPU本身是32位的,依然可以处理32位操作数

了解完其基础概念后,来看看保护模式究竟从哪些方面得到了进化。

寄存器扩展

无论PC怎么发展,兼容都是最基本的要求,包括寄存器、访存方式、指令格式等的兼容。CPU发展到了32位后,地址总线和数据总线也发展到了32位,即寻址空间夸大到了4GB,一次可以处理的操作数升至32位。4GB的空间如果还用曾经的段:偏移法来寻址的话,段基址要左移16位,这显然很不合适。

最关键的,从设计思想上将,段基址本身就应该是某个内存段的起始地址,它不应该在应用前先做个处理。在原来的16位模式下,由于数据总线小于地址总线,所以迫不得已预先将段基址*16,才产生了这种蹩脚的方式。现在,数据总线和地址总线一样宽了,就可从根本上解决这个问题——扩展寄存器到32位。

寄存器要保持向下兼容,不能推翻之前的方案重来,因此要在原有16位的基础上向高位扩展16位,延申到32位,新的寄存器用前缀e表示扩展,即eax、ebx等等,如图:

可以看到,通用寄存器、标志寄存器、指令指针寄存器都扩展到了32位,但是段寄存器没有,仍然只有32位,这是因为段寄存器16位就够用了,这个后面会解释到。

上图中,寄存器的低16位是为了兼容实模式的,在任何模式下都可以单独使用,但是高16位不行,必须在引用32位寄存器时才能用到他们。

之前提到过,在80286之后,引入了保护模式的概念,大大提高了安全性,其中很重要的一部分,就是对内存段的描述。它改变了段基址的实质,引入了GRT(Global Descriptor Table)的概念。

GDT

保护模式下,偏移地址和实模式是一样的,但是段基址不再是简单的一个地址了。之前说过,实模式下段基址可以任意设定,随意访问空间内的任何地方,很不安全。因此为了更加安全,要给它加一些约束条件,这些约束条件就是对内存段的描述信息。由于信息太多了,所以专门找了个数据结构——全局描述符表,即GDT。该表很大,故放在了内存中,由GDTR寄存器指向它的首址,这个寄存器有6字节。

既然是表,那一定有表项,每一个表项就是一个段描述符,大小为8字节,用来描述一个内存段的段基址、段界限和段属性等等。注意,表项就是描述符,而不是描述符的索引。不过这里不会详细介绍,只浅谈一下即可,后面会专门开一篇博客专门介绍GDT和描述符。

这样,段寄存器中保存的再也不是段基址了,里面保存的内容叫选择子,selector。实际上,这个选择子就是个,用这个数来索引GDT中的段描述符。把全局描述符当成数组,选择子就像数组下标一样。

1> 段描述符是在内存中的,访问会很耗时

2> 段描述符的格式很奇怪,CPU把这些七零八落的数拼凑起来也会耗时

既然访问内存中的段描述符很耗时间,那CPU如何提高效率呢?使用缓存。在80286的保护模式中,为了提高获取段信息的效率,对段寄存器率先应用了缓存技术,将段信息用一个寄存器来缓存,这就是段描述符缓冲寄存器。对程序员而言它是不可见的。CPU每次访问描述符并获取到内存段信息后,会将其存入上述缓冲寄存器中,下次访问相同段时,直接从中取就行了。

另外,虽然段描述符缓冲寄存器是保护模式下的产物,但是它也可以用在实模式下。在16为的实模式下,段基址左移4位这个操作也是耗时的。因此,CPU会将段基址左移后的结果存在上述寄存器中,以后每次引用这个段时,就直接走缓冲寄存器,直到换段,也就是段寄存器被重新赋值。

在进入代码解释之前按,先简单概括下描述符各属性的含义:

P位:存在位,P=1表示段在内存中存在;P=0表示在内存中不存在。

DPL:描述符特权级(Privilege Level),可以是0123,数字越小特权级越大。

S位:指明描述符是数据段/代码段(S=1)还是系统段/门描述符(S=0).

TYPE:描述符类型,详情见下表:

其中,当S=1时,TYPE<8对应数据段,>=8对应代码段;当S=0时,TYPE<8对应系统段,>=8对应门描述符。

G位:段界限粒度(Granularity)位。G=0时段界限为字节;G=1时段界限粒度为4KB。

D/B位:这一位比较复杂,分三种情况:

  • 在可执行代码段描述符中,这一位叫做D位。D=1时,在默认情况下指令使用32位地址以及32位或8位操作数;D=0时,在默认情况下使用16位地址以及16位或8位操作数;
  • 在向下扩展数据段的描述符中,这一位叫做B位。B=1时,段的上部界限为4GB;B=0时,段的上部界限为64KB;
  • 在描述堆栈段(由ss寄存器指向的段)的描述符中,这一位叫做B位。B=1时,隐式的堆栈访问指令(如push、pop和call)使用32位堆栈指针寄存器esp;D=0时,隐式的堆栈访问指令(如push、pop和call)使用16位堆栈指针寄存器sp;

AVL位:保留位:可以被系统软件使用。

代码解释

好了,知道保护模式和GDT的相关基础后,可以开始通过解释文初的代码来进一步学习保护模式了。

代码一共分为三个部分,即三个段:[SECTION .gdt]、[SECTION .s16]、[SECTION .s32]。顾名思义,第一部分定义了GDT,第二部分为16位环境下运行的代码,即实模式,第三部分为32位环境下运行的代码,即保护模式。我们一个一个看。

[SECTION .gdt]

这个部分定义了一个数组,每一个元素都是一个Descriptor,很明显,这个数组就是GDT。其中,Descriptor是一个宏,这个宏用比较自动化的方法把段基址、段界限和段属性放入一个描述符中合适的位置,这个宏在pm.inc中,内容如下:

; 描述符
; 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				(2 字节)
	dw	%1 & 0FFFFh				; 段基址 1				(2 字节)
	db	(%1 >> 16) & 0FFh			; 段基址 2				(1 字节)
	dw	((%2 >> 8) & 0F00h) | (%3 & 0F0FFh)	; 属性 1 + 段界限 2 + 属性 2		(2 字节)
	db	(%1 >> 24) & 0FFh			; 段基址 3				(1 字节)
%endmacro ; 共 8 字节

代码的GDT中共有3个描述符,为方便起见,这里分别简称为DESC_DUMMY、DESC_CODE32和DESC_VIDEO。其中DESC_VIDEO的段基址是0B800h,这个地址是显存的起始地址,故该描述符指向了显存。

段基址和段界限都好说,来看下段属性。DESC_CODE32的属性为DA_C + DA_32,DESC_VIDEO的属性为DA_DRW。这些值都定义在pm.inc中:

;---------------------------------------------------------------
; 描述符类型值说明
; 其中:
;       DA_  : Descriptor Attribute
;       D    : 数据段
;       C    : 代码段
;       S    : 系统段
;       R    : 只读
;       RW   : 读写
;       A    : 已访问
;       其它 : 可按照字面意思理解
;---------------------------------------------------------------
DA_32		EQU	4000h	; 32 位段

DA_DPL0		EQU	  00h	; DPL = 0
DA_DPL1		EQU	  20h	; DPL = 1
DA_DPL2		EQU	  40h	; DPL = 2
DA_DPL3		EQU	  60h	; DPL = 3
;---------------------------------------------------------------
; 存储段描述符类型值说明
;---------------------------------------------------------------
DA_DR		EQU	90h	; 存在的只读数据段类型值
DA_DRW		EQU	92h	; 存在的可读写数据段属性值
DA_DRWA		EQU	93h	; 存在的已访问可读写数据段类型值
DA_C		EQU	98h	; 存在的只执行代码段属性值
DA_CR		EQU	9Ah	; 存在的可执行可读代码段属性值
DA_CCO		EQU	9Ch	; 存在的只执行一致代码段属性值
DA_CCOR		EQU	9Eh	; 存在的可执行可读一致代码段属性值
;---------------------------------------------------------------
; 系统段描述符类型值说明
;---------------------------------------------------------------
DA_LDT		EQU	  82h	; 局部描述符表段类型值
DA_TaskGate	EQU	  85h	; 任务门类型值
DA_386TSS	EQU	  89h	; 可用 386 任务状态段类型值
DA_386CGate	EQU	  8Ch	; 386 调用门类型值
DA_386IGate	EQU	  8Eh	; 386 中断门类型值
DA_386TGate	EQU	  8Fh	; 386 陷阱门类型值
;---------------------------------------------------------------

DA_C + DA_32的二进制为0100 0000 1001 1000,更具描述符TYPE的含义可知该内存段为存在的只执行的32位代码段,DPL为0。同理可知,DESC_VIDEO段为可读写数据段

定义完GDT后,用变量GdtLen存储GDT的长度,结构变量GdtPtr存储GDT的长度和基地址。

随后,开始存储后两个描述符的选择子,可以看到,SelectorCode32SelectorVideo分别存储了DESC_CODE32和DESC_VIDEO相对于GDT首址的偏移,这个数就是他们各自的选择子。实际上,选择子不止是一个偏移,它的结构要稍微复杂一些,这里不详细介绍。

数据结构GdtPtr是用来记录GDT界限和GDT基址的。前文提到过,GDTR寄存器要指向GDT的首址,先来看看这个寄存器的结构:

可以看到,这个寄存器和我们定义的GdtPtr结构是一样的,实际上,定义GdtPtr的目的就是将其赋值给GDTR

[SECTION .s32]

这个部分为保护模式下要执行的代码,目的很简单,在指定位置打印一个红色的P。首先,把显存的内存段也就是描述符DESC_VIDEO的选择子写入段寄存器GS:

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

接着,把想要显示的位置写入edi,然后通过[gs:edi]要显示的东西写入显存的对应位置中:

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

保护模式运行的代码到此为止,使用一个死循环来停在此处即可。可以看到,这部分并不难。核心目的就是把字符写入显存对应的位置,不过由于保护模式用描述符来表示各个内存段,因此需要借助GDT来完成。

[SECTION .s16]

我们知道,当PC启动时,首先进入的是实模式,只有1MB的寻址空间,要转入保护模式,就需要另做一些操作,比如打开某些开关,然后jmp到保护模式,[SECTION .s16]就是在实模式中做这些事的。

在将cs、ds、es、ss相统一之后,开始初始化保护模式代码段描述符。因为最后要跳到保护模式所在的内存段(代码段),因此需要一个描述符来存储它,就是DESC_CODE32,不过由于描述符结构比较蹩脚,所以得分步来赋值。

; 初始化 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

初始化完该描述符后,开始加载GDT,即将GDT的首址和界限写入寄存器GDTR,期间要借助数据结构GdtPtr

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

; 加载 GDTR
lgdt	[GdtPtr]

最后一行的lgdt的作用是将以GdtPtr为首址的6字节加载到寄存器GDTR中。

接下来就是关中断,因为保护模式下中断处理的机制是不同的,不关掉会出错误:

; 关中断
cli

再下面几行的作用就是打开A20地址线,这里简单介绍下什么时候A20地址线。在8086中,地址线只有20位,空间为1MB,那么如果试图访问1MB之外的空间呢?系统并不会发生异常,而是回卷,重新从0地址开始。但是到了80286时,空间变成了4GB,访问1MB之外不再需要回卷了,这就造成了和旧模式的不兼容,怎么办呢?IBM想出一个办法,使用8042键盘控制器来控制第20号(从0开始)地址位,这就是A20地址线,如果不被打开,默认为0,地址会回卷。也即,A20是程序或OS能够访问到1MB之外的空间,可以认为是进入保护模式的第一步。代码通过操作端口92h来打开它:

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

接下来,离最后的jmp就只差一步了,那就是打开保护模式的开关:

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

上述代码很简单,就是把cr0寄存器的第0位设1。寄存器cr0的第0位是PE位,此位为0时,CPU运行于实模式,为1时,CPU运行于保护模式。因此,这一步打开了进入保护模式的开关。

此时,系统已经进入了保护模式,但是cs仍然是实模式的值,即依然停留在实模式的代码处。因此,需要将其更改为保护模式的代码段,那就是jmp。通过之前定义的DESC_CODE32的选择子SelectorCode32,来执行保护模式处的代码:

; 真正进入保护模式
	jmp	dword SelectorCode32:0	; 执行这一句会把 SelectorCode32 装入 cs, 并跳转到 Code32Selector:0  处

总结

至此,成功进入了保护模式,下面总结一下进入保护模式的主要步骤:

1> 准备GDT;

2> 用lgdt加载gdtr;

3> 打开A20;

4> 置cr0的PE位,使系统处于保护模式;

5> 跳转,进入保护模式;


文章作者: SrcMiLe
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 SrcMiLe !
评论
 上一篇
下一篇 
Unicode和UTF-8 Unicode和UTF-8
都知道Unicode编码,但我经常把它和UTF-8弄混,都是囫囵吞枣式的理解,基础不扎实。今天突然想搞清楚它俩的前世今生缘,想着连这都不知道也太Low了,就去查了查资料,发现比我想象的要复杂。
2022-02-15 SrcMiLe
  目录