和一个段有关的信息需要 8 个字节来描述,所以称为段描述符(Segment Descriptor),每个段都需要一个描述符。为了存放这些描述符,需要在内存中开辟出一段空间。在这段空间里,所有的描述符都是挨在一起,集中存放的,这就构成一个描述符表,最主要的描述符表是全局描述符表(Global Descriptor Table,GDT)。
为了跟踪全局描述符表,处理器内部有一个 48 位的寄存器,称为全局描述符表寄存器(GDTR)。该寄存器分为两部分,分别是 32 位的线性地址和 16 位的边界。32 位的处理器具有 32 根地址线,可以访问的地址范围是 0x00000000 到0xFFFFFFFF,共 232字节的内存,即 4GB 内存。所以,GDTR的 32 位线性基地址部分保存的是全局描述符表在内存中的起始线性地址,16 位边界部分保存的是全局描述符表的边界(界限),其在数值上等于表的大小(总字节数)减一。
因为 GDT 的界限是 16 位的,所以,该表最大是 216 字节,也就是 65536 字节(64KB)。又因为一个描述符占 8 字节,故最多可以定义 8192 个描述符。
由于在实模式下只能访问 1MB 的内存,故 GDT 通常都定义在 1MB 以下的内存范围中。当然,允许在进入保护模式之后换个位置重新定义 GDT。
先是确定 GDT 的起始线性地址,并初始化了一个双字0x00007e00,我们决定从这个地方开始创建全局描述符表(GDT)。在实模式下,主引导程序的加载位置是 0x0000:0x7c00,也就是物理地址 0x07c00。因为现在的地址是 32 位的,所以它现在对应着物理地址 0x00007c00。主引导扇区程序共 512(0x200)字节,所以,我们决定把 GDT 设在主引导程序之后,也就是物理地址 0x00007e00 处。因为 GDT 最大可以为 64KB,所以,理论上,它的尺寸可以扩展到物理地址 0x00017dff 处。
相应地,因为堆栈指针寄存器 SP 被初始化为0x7c00,和 CS 一样,堆栈段寄存器 SS 被初始化为0x0000,而且堆栈是向下扩展的,所以,从 0x00007c00往下的区域是实际上可用的堆栈区域。只不过,该区域包含了很多 BIOS 数据,包括实模式下的中断向量表。
每个表述符在GDT中占8个字节,也就是2个双子,或者说是64位。
在 32 位保护模式下,段地址是 32 位的线性地址,如果未开启分页功能,该线性地址就是物理地址。
处理器规定,GDT 中的第一个描述符必须是空描述符,或者叫哑描述符或 NULL 描述符。在初始状态下(计算机启动之后),GDTR 的基地址被初始化为0x00000000;界限值为 0xFFFF。
;创建0#描述符,它是空描述符,这是处理器的要求
mov dword [bx+0x00],0x00
mov dword [bx+0x04],0x00
;创建#1描述符,保护模式下的代码段描述符
;线性基地址为 0x00007C00。
段界限为 0x001FF,粒度为字节(G=0)。该段的长度为 512 字节。
属于存储器的段(S=1)。
这是一个 32 位的段(D=1)。
该段目前位于内存中(P=1)。
段的特权级为 0(DPL=00)。
这是一个只能执行的代码段(TYPE=1000)。
mov dword [bx+0x08],0x7c0001ff
mov dword [bx+0x0c],0x00409800
;创建#2描述符,保护模式下的数据段描述符(文本模式下的显示缓冲区)
线性基地址为 0x000B8000。
段界限为 0x0FFFF,粒度为字节(G=0)。即,该段的长度为 64KB。
属于存储器的段(S=1)。
这是一个 32 位的段(D=1)。
该段目前位于内存中(P=1)。
段的特权级为 0(DPL=00)。
这是一个可读可写、向上扩展的数据段(TYPE=0010)。
mov dword [bx+0x10],0x8000ffff
mov dword [bx+0x14],0x0040920b
;创建#3描述符,保护模式下的堆栈段描述符
线性基地址为 0x00000000。
段界限为 0x07A00,粒度为字节(G=0)。
属于存储器的段(S=1)。
这是一个 32 位的段(D=1)。
该段目前位于内存中(P=1)。
段的特权级为 0(DPL=00)。
这是一个可读可写、向下扩展的数据段,即堆栈段(TYPE=0010)。
mov dword [bx+0x18],0x00007a00
mov dword [bx+0x1c],0x00409600
;初始化描述符表寄存器GDTR
mov word [cs: gdt_size+0x7c00],31 ;描述符表的界限(总字节数减一)
lgdt [cs: gdt_size+0x7c00] ;指向一个包含了 48 位(6 字节)数据的内存区域。
;在 16 位模式下,该地址是 16 位的;在 32 位模式下,该地址是 32 位的。该指令在实模式和保护模
式下都可以执行。
8086只有20根地址线,只能访问1MB的内存,到了80386有32根地址线,这里就会出现一个问题,在8086时代,很多程序都会利用20位地址回绕特性(当物理地址超过0xFFFFF就会回绕到0x00000),而到了80286以后,由于地址线加多了,这个进位不会被丢弃,所以就会引发很多问题。
Intel想了一个方法,他们在80286和80386在A20处使用一个与门控制,并且把这个与门的控制阀门放在键盘上,端口号是0x60,向这个端口写入数据的时候,如果这个第一位是1,那么键盘控制器通向与门的输出就是1,与门的输出决定于A20是0还是1(在实模式下,只要强制与门的输出为0,那么实模式的回绕特性将会被保留)。
这种方法非常麻烦,后来到了80486,这个问题被得到简化。在80486以后,处理器本身就有了A20M#引脚(A20 Mask,A20屏蔽),这个引脚低电平有效。在ICH上,有一个用于兼容老式设备的端口0x92,第7-2位保留,第0位叫做INIT_NOW,用于初始化处理器,当它从0到1过渡的,ICH会使INIT#引脚电平变为低电平有效,并保持至少16个PCI时钟周期,也就是说,如果向0x92写入1,那么就会让处理器复位,导致计算机强制重启。
当INIT_NOW从0到1,ALT_A20_GATE将会被置为1,这就是说,计算机启动的时候,第21个根引线是自动启用的(但是A20#M是仅用于单处理器系统,多核系统一般是不用的)。现在基本都是USB设备了。
要开启保护模式,除了加载GDT,打开A20还不够,我们必须还要对CR0开关进行操作,CR0也是一个处理器内部的控制寄存器(Control Register,RD)。这样的控制器还有CR1,CR2,CR3等。CR0是一个32位的寄存器,他的第一位(0位)是保护模式允许位(Protection Enable,PE),如果把这个位置为1,那么处理器将会进入保护模式,按保护模式的规则开始运行。在保护模式下,实模式下的中断向量表不再适用,且我们不能再使用BIOS中断,这就是为什么我们之前要把中断关掉的原因。
在32位处理器下的实模式下,首先如果处理器要引用一个段(也就是执行将段地址传到段寄存器的指令),处理器会自动将段地址左移4位,然后传到描述符高速缓存器,这以后,就一直使用描述符高速缓存器的内容作为段地址。只要不改变段寄存器DS的内容,以后每次访问内存都直接使用DS描述符高速缓存器中的内容,在实模式下段寄存器只能传送16位的逻辑地址。(这个时候处理器不会把他看成是段的选择子),处理器也只能访问1MB的内存。
在32位处理器下的保护模式下,传入段寄存器的内容不再是逻辑地址,而是段的选择子,所谓段的选择子,其实就是段描述符在描述符表(GDT,LDT) 的索引号。
第一部分(0 ~ 1)是RPL特权级,表示给出当前选择该选择子的那个程序的特权级,第二部分是TI(2)(Table Indicator),当TI=0,表示描述表在GDT中;当TI=1,表示描述符在LDT中。第三部分(3~15)是描述符索引号,这个部分是只有13位的,正好和213=8192个描述符对应。
GDT的线性基地址在GDTR中,每个描述符占用8个字节,党处理器在执行改变段选择器的指令的时候,就将指令中的索引号乘以8得到偏移地址,和GDTR中的线性地址相加,以此访问GDT,处理器会根据GDT的界限以及特权级检查,如果没有问题,那么处理器就会将在对应描述符的内容的一部分(线性基地址,段界限和段的访问属性)加载到高速缓存中。此后,每当有访问内存的指令,就不会再访问GDT的描述符,而是直接用当前段寄存器的高速缓存的内容提供线性基地址。,访问代码段遗失一样如此访问的(EIP+高速缓存中的线性基地址)。
在进入保护模式之前的最后一个步骤,就是要清空流水线,因为在实模式下,高速缓存器也被用来直接访问内存,但是这些内容在保护模式下是无效的;并且,在进入保护模式之前,已经有很多指令进入流水线了,在实模式下他们都是按照16位操作数或者16位地址长度编译的,即使用bits32编译的指令,进入保护模式之后,因为CS的描述符高速缓存中还有实模式残留的内容,可能会导致指令执行结果不正确,并且乱序执行得到的中间结果也是无效的,所以我们必须在进入保护模式之前把CS,SS,DS,ES,FS和GS的内容,包括段选择器和描述符高速缓存器的内容清除。
建议的做法就是在设置了CR0的PE位后,立马使用直接远转移指令jmp,当处理器遇到jmp时,一般会清空流水线,并且串行化执行。不仅如此,CS还会被重新加载,描述符高速缓存器的内容会被刷新。
当然也可以使用dword来描述偏移地址,这样的话flush对应标号有所不同(因为偏移地址和段的选择子的长度变了,变成32位,不加dword这两个长度都是16位),但是不影响执行。
需要注意的是,在保护模式下,不允许直接用mov指令改变段寄存器CS的内容,企图这样操作会引发无效操作码的异常中断。
在跳转指令之前,处理器虽然进入了保护模式,但是,这个时候描述符高速缓存器的内容没有被刷新,但是处理器任然是可以继续执行下去的,因为检查描述符是否有效,通常是在加载段寄存器(选择器),并刷新描述符高速缓存器的时候进行的,比如jmp 0x0008:flush这条指令,而对于数据段来说,是加载段选择子的时候,比如mov ds,cx,但是现在因为是刚进入保护模式,描述符的很多位,是在实模式下都是无效的。
;文件说明:硬盘主引导扇区代码
;设置堆栈段和栈指针
mov ax,cs
mov ss,ax
mov sp,0x7c00
;计算GDT所在的逻辑段地址
mov ax,[cs:gdt_base+0x7c00] ;低16位
mov dx,[cs:gdt_base+0x7c00+0x02] ;高16位
mov bx,16
div bx
mov ds,ax ;令DS指向该段以进行操作
mov bx,dx ;段内起始偏移地址
;创建0#描述符,它是空描述符,这是处理器的要求
mov dword [bx+0x00],0x00
mov dword [bx+0x04],0x00
;创建#1描述符,保护模式下的代码段描述符
mov dword [bx+0x08],0x7c0001ff
mov dword [bx+0x0c],0x00409800
;创建#2描述符,保护模式下的数据段描述符(文本模式下的显示缓冲区)
mov dword [bx+0x10],0x8000ffff
mov dword [bx+0x14],0x0040920b
;创建#3描述符,保护模式下的堆栈段描述符
mov dword [bx+0x18],0x00007a00
mov dword [bx+0x1c],0x00409600
;初始化描述符表寄存器GDTR
mov word [cs: gdt_size+0x7c00],31 ;描述符表的界限(总字节数减一)
lgdt [cs: gdt_size+0x7c00]
in al,0x92 ;南桥芯片内的端口
or al,0000_0010B
out 0x92,al ;打开A20
cli ;保护模式下中断机制尚未建立,应
;禁止中断
mov eax,cr0
or eax,1
mov cr0,eax ;设置PE位
;以下进入保护模式... ...
jmp dword 0x0008:flush ;16位的描述符选择子:32位偏移
;清流水线并串行化处理器
[bits 32]
flush:
mov cx,00000000000_10_000B ;加载数据段选择子(0x10)
mov ds,cx
;以下在屏幕上显示"Protect mode OK."
mov byte [0x00],'P'
mov byte [0x02],'r'
mov byte [0x04],'o'
mov byte [0x06],'t'
mov byte [0x08],'e'
mov byte [0x0a],'c'
mov byte [0x0c],'t'
mov byte [0x0e],' '
mov byte [0x10],'m'
mov byte [0x12],'o'
mov byte [0x14],'d'
mov byte [0x16],'e'
mov byte [0x18],' '
mov byte [0x1a],'O'
mov byte [0x1c],'K'
;以下用简单的示例来帮助阐述32位保护模式下的堆栈操作
mov cx,00000000000_11_000B ;加载堆栈段选择子
mov ss,cx
mov esp,0x7c00
mov ebp,esp ;保存堆栈指针
push byte '.' ;压入立即数(字节)
sub ebp,4
cmp ebp,esp ;判断压入立即数时,ESP是否减4
jnz ghalt
pop eax
mov [0x1e],al ;显示句点
ghalt:
hlt ;已经禁止中断,将不会被唤醒
;-------------------------------------------------------------------------------
gdt_size dw 0
gdt_base dd 0x00007e00 ;GDT的物理地址
times 510-($-$$) db 0
db 0x55,0xaa