4. 进入保护模式
4.1 为什么要进入保护模式?
PC的微处理器有三种运行模式:
·实模式,微处理器的默认模式,提供与8086相同的功能。也因此,在这个模式下,不能对大于1MB的内存地址进行寻址。
·保护模式能够发掘微处理器的更高性能。在这个模式下能够进行4G内存地址空间的寻址并提供了对多任务和多用户系统的实现的支持。
·虚拟8086模式是一个能让为8086编写的程序运行于多用户系统的模式。
我们的目标是实现一个多用户,多任务并能够寻址整个内存的内核,这就必须把处理器切换到保护模式。这在编程方面有很大影响。首先,保护模式下的寻址机制(下面将详述),不同于实模式下的(我们一直用到现在的)。部分因为这,不可能再依赖于BIOS的例程去访问外部设备,且所有的驱动程序需要重写。
4.2 如何从实模式切换到保护模式
从实模式切换到保护模式很简单,只要满足将寄存器CR0的bit0位置1。
mov eax,cr0
or ax,1
mov cr0,eax ; PE位置1 (CR0)
然而尽管这样做足以改变模式,可是一旦保护模式建立,这并不能让一个程序持续运行。
在改变模式前,一些结构体必须被正确的初始化。这些结构体使得我们能够正确的在内存中寻址,它们将会在下面的部分被详细介绍。
4.3 保护模式下的内存寻址
4.3.1 不同类型的地址
对于程序员来说有三种地址:逻辑地址,线性地址和物理地址。
逻辑地址,由一个段选择子和一个偏移组成,它直接被程序员操作当程序员想要访问一个特定的内存区域时。选择子指向一块特定大小的内存区域(一个内存段),偏移是这块内存区域中相对于其基地址的一个位移量。分段部件将逻辑地址转变为一个32位的线性地址。分页部件将线性地址转变为物理地址。如果分页没有被启用,线性地址便等同于物理地址。
一开始,我们只用分段机制。分页机制更加难以实现。此外,我们可以不使用虚拟内存,它并不是必需的。
下面的示意图说明了保护模式下寻址的原理:

4.3.2 分段机制
内存段由这些64bits的结构体来描述,称为段描述符,它们被存储在一个叫做GDT(Global Descriptor Table)的表里。一个描述符明确指出一个段在内存中的的开始地址,结束地址,段属性(代码段,数据段,堆栈段,等等)以及其他信息。

为了在一个特定的内存区域寻址,必须指明处于什么样的段中。选择子,由程序员直接使用,或许可以认为它是指向GDT中段描述符的指针。段描述符提供段的开始地址(段基址)和结束地址(段界限)。线性地址通过段基址和偏移相加获得。下面的示意图描述了这个机制:

4.3.3 段描述符
下面的示意图显示了一个段描述符的结构:

base(基地址),32位,是内存中段的开始线性地址。
limite(段界限),16位,定义段的长度。如果G位置0,段界限表现为字节,否则,表现为4k页的数量。
type(段类型)定义了段的类型,例如代码段,数据段以及堆栈段。
S位置1即为段描述符,置0即为系统段描述符(一种特殊的描述符,我们以后会见到)。
DPL指明段的权限级别。0级别对应于超级用户模式。
P位用来确定段是否存在于物理内存中。如果存在则置1。
D/B位指定指令和操作数长度。如果指定为32bits的话,置1。
AVL位可以自由使用(译者:建议保留)。
下面的示意图描述了代码段描述符和数据段描述符的模型:


我们注意到G位被置为1(界限表现为页),代码是32的(D/B位置1),P位置1(段在内存中存在),权限级别为0(超级用户模式),S位置为1指明是一个段描述符,type的值表明我们处理的是代码段(更详细的可以查看Intel手册)。
为了能够在整个内存寻址,段基址必须是0x0,使得段从内存的开始处开始,其段界限必须是0xFFFFF且粒度位(即G位)置1使其大小为4G。
4.4 在什么时候进入保护模式
在启动引导的时候有几个时机可以切换到保护模式:引导扇区程序执行的时候或者当内核执行的时候。
原则上在内核未运行前切换到保护模式这种方式更加简单。这样内核就只需要用仅能在保护模式下运行的32位指令编写。如果是让内核来执行切换将会加大代码额外的复杂度:内核将必须由16位和32位代码两部分组成。
由于这个原因,我们在引导扇区代码执行到保护模式的切换。
4.5 切换到保护模式的引导程序
'bootsect.asm' 文件:
%define BASE 0x100 ; 0x0100:0x0 = 0x1000
%define KSIZE 1
[BITS 16]
[ORG 0x0]
jmp start
%include "UTIL.INC"
start:
mov [bootdrv],dl ; recuparation de l'unite de boot
; initialisation des segments en 0x07C0
mov ax,0x07C0
mov ds,ax
mov es,ax
mov ax,0x8000 ; stack en 0xFFFF
mov ss,ax
mov sp, 0xf000
; affiche un msg
mov si,msgDebut
call afficher
; charger le noyau
xor ax,ax
int 0x13
push es
mov ax,BASE
mov es,ax
mov bx,0
mov ah,2
mov al,KSIZE
mov ch,0
mov cl,2
mov dh,0
mov dl,[bootdrv]
int 0x13
pop es
; initialisation du pointeur sur la GDT
mov ax,gdtend ; calcule la limite de GDT
mov bx,gdt
sub ax,bx
mov word [gdtptr],ax
xor eax,eax ; calcule l'adresse lineaire de GDT
xor ebx,ebx
mov ax,ds
mov ecx,eax
shl ecx,4
mov bx,gdt
add ecx,ebx
mov dword [gdtptr+2],ecx
; passage en modep
cli
lgdt [gdtptr] ; charge la gdt
mov eax,cr0
or ax,1
mov cr0,eax ; PE mis a 1 (CR0)
jmp next
next:
mov ax,0x10 ; segment de donne
mov ds,ax
mov fs,ax
mov gs,ax
mov es,ax
mov ss,ax
mov esp,0x9F000
jmp dword 0x8:0x1000 ; reinitialise le segment de code
;--------------------------------------------------------------------
bootdrv: db 0
msgDebut db "Chargement du kernel",13,10,0
;--------------------------------------------------------------------
gdt:
db 0,0,0,0,0,0,0,0
gdt_cs:
db 0xFF,0xFF,0x0,0x0,0x0,10011011b,11011111b,0x0
gdt_ds:
db 0xFF,0xFF,0x0,0x0,0x0,10010011b,11011111b,0x0
gdtend:
;--------------------------------------------------------------------
gdtptr:
dw 0 ; limite
dd 0 ; base
;--------------------------------------------------------------------
;; NOP jusqu'a 510
times 510-($-$$) db 144
dw 0xAA55
这个引导扇区程序跟以前看到的很相似。
我们将外部引导设备的编号存进一个变量,接着初始化与代码段和数据段相关的寄存器,然后显示一条消息。接下来,把内核加载到内存地址0x1000处。
在切换到保护模式之前,必须先初始化GDT,使得在进入新的模式之后没有寻址上的问题。GDT应当包含代码段,数据段和堆栈段的描述符。下面的代码声明并初始化了GDT:
gdt:
db 0,0,0,0,0,0,0,0
gdt_cs:
db 0xFF,0xFF,0x0,0x0,0x0,10011011b,11011111b,0x0
gdt_ds:
db 0xFF,0xFF,0x0,0x0,0x0,10010011b,11011111b,0x0
gdtend:
标号'gdt:'是指向全局描述符表开头的指针。每个描述符为8字节。表头的8字节不使用(我并不知道准确的原因,但是386处理器手册很清楚的指明:第一个描述符不能使用)。第二个是代码段描述符,用标号 'gdt_cs:'指向它,这样同时使得代码更清晰。
不同的描述符被初始化以便代码段描述符和数据段描述符能够在整个内存寻址。这里的只是个范例,它们的寻址空间在内存中是混合叠加的。
下面的图表显示了代码段和数据段描述符被初始化成能够寻址整个内存空间:


我们注意到这些段的基地址被设置为0,段界限被设置为0xFFFFF(由于G位被置为1,这里的数字指的是页的数目)。
从头看这个程序。
程序在显示了一条消息以及将内核加载到内存地址0x1000后。接下来就开始进行实模式到保护模式的转换了!
首先禁止中断。这是必须的,因为当系统的寻址方式改变,中断调用的例程不再有效,它们需要重新编程。用指令'cli'禁止中断。
cli
代码段和数据段描述符已经定义好并且GDT已经被正确的初始化。接下来必须告诉处理器GDT已准备好。这时候轮到寄存器GDTR发挥作用了,寄存器GDTR有6个字节长,它包含了GDT在内存中的基地址以及GDT的界限。
在我们的例子中,'gdtptr'是一个指针,它指向一个包含要装载到寄存器GDTR的内容的结构体。结构体'gdtptr'在文件的末尾声明并初始化:
gdtptr:
dw 0 ; limite
dd 0 ; base
接下来用GDT的基地址和界限来初始化这个结构:
; initialisation du pointeur sur la GDT
mov ax,gdtend ; calcule la limite de GDT
mov bx,gdt
sub ax,bx
mov word [gdtptr],ax
xor eax,eax ; calcule l'adresse lineaire de GDT
xor ebx,ebx
mov ax,ds
mov ecx,eax
shl ecx,4
mov bx,gdt
add ecx,ebx
mov dword [gdtptr+2],ecx
接下来用指令'lgdt'将结构加载到寄存器GDTR:
lgdt [gdtptr] ; charge la gdt
一旦寄存器GDTR被初始化,我们就能够进入保护模式。
切换到保护模式相当简单,只要修改寄存器CR0的bit 0(置1):
; passage en modep
mov eax,cr0
or ax,1
mov cr0,eax ; PE mis a 1 (CR0)
注意,切换到保护模式的处理器还剩下一个棘手的难题要解决:预置代码段及数据段选择子。
接下来的一条指令对于正确地清空处理器缓存是必要的:
jmp next
next:
接下来,我们打算预置数据段选择子。寄存器DS,ES,FS,GS是特殊的,它们默认是用来装载数据段的选择子(处理器的架构使得使用多个数据段成为可能,但在我们的例子中,我们让其指向相同的内存段)。
为了指向一个数据段,选择子必须指向GDT内相应的描述符。将其设置为对应描述符在表内的偏移。在我们的例子中,这个偏移是16字节(16进制为0x10)。将这些选择子初始化为这个值:
mov ax,0x10 ; segment de donne
mov ds,ax
mov fs,ax
mov gs,ax
mov es,ax
接下来初始化堆栈段寄存器及堆栈指针:
mov ss,ax
mov esp,0x9F000
随后的代码运行位于物理内存'0x1000'处的内核代码。这些指令是必要的,它们不仅能运行内核代码,而且能正确的设置代码段选择子并指向正确的描述符(GDT表内偏移0x8处)以及初始化指令指针:
jmp dword 0x8:0x1000
就到这里了!引导扇区代码已经完成。再看看为数不多的内核代码吧……
4.6 一个简单的内核
4.6.1 代码
[BITS 32]
[ORG 0x1000]
; Affichage d'un message par ecriture dans la RAM video
mov byte [0xB8000],'H'
mov byte [0xB8001],0x57
mov byte [0xB8002],'E'
mov byte [0xB8003],0x57
mov byte [0xB8004],'L'
mov byte [0xB8005],0x57
mov byte [0xB8006],'L'
mov byte [0xB8007],0x57
mov byte [0xB8008],'O'
mov byte [0xB8009],0x57
end:
jmp end
这个内核显示一条消息后接着进入死循环。在这个时候,BIOS的那些能够显示字符到屏幕的例程已经不能使用。要想在屏幕上显示消息,我们只有自己编写相应的显示例程。
4.6.2 显示一些东西到屏幕上
显存被映射到物理内存地址0xB8000。我们能够直接操作从这个地址开始的一系列字节来显示一些消息。
显示屏幕由25行,80列组成。屏幕上的每个字符在内存中由2个字节来描述,一个字节包括显示字符的ASCII码,随后的字节包含其属性(颜色,闪烁……)。

下面的示意图显示了一个字符的属性如何编码:

下面的代码在屏幕的左上方显示了一个白色前景色,品红色背景色的字符'H'。
mov byte [0xB8000],'H'
mov byte [0xB8001],0x57
接下来,我们在屏幕的左上方显示一个单词'HELLO'。下面是bochs运行得到的: