操作系统——全局描述符(六)

操作系统——全局描述符表(六)

2020-09-17 10:43:27 hawk


概述

  前面简单介绍了一下保护模式下相关的一些基础知识,下面会进一步对于保护模式做更为详细的分析和讲解。这篇博客主要讲述保护模式的重要组成部分——全局描述符表。


全局描述符表基础

  实际上,顾名思义,保护模式要比实模式相对更安全一些,其中一个重要的方面就是地址访问方面的。对于保护模式来说,内存段不在类似于实模式下简单地说用段寄存器加载一下段基址,即可直接使用。在保护模式下,需要加载很多额外的信息,需要提前就把定义好,才可以正常使用。书上有一个非常形象的例子——类似于家庭成员需要上户口一样,只有在户口簿上登记过的人才算合法。而只有在相关数据结构上登记过的内存段才是可以合法访问的内存段,而这个数据结构就是全局描述符表(Global Descriptor Table,GDT)

段描述符

  正如我们之前一直提到的,兼容性是计算机科学学科的优良传统。自然的,在实模式下访问内存的方式是“段基址:段内偏移地址”,则在保护模式下最好仍然也是这种形式。当然为了安全性,还需要额外添加一些属性位,然而寄存器位数并不足以支撑这些功能,导致直接使用寄存器实现“段基址:段内偏移地址”模式不是很现实。因此我们通过在内存中存储段基址、段内偏移地址和相关的属性位,从而在仍然采用“段基址:段内偏移地址”模式的基础上,添加额外的属性位来提高安全性。实际上这个数据结构就是段描述符,如下所示

 

 

 

   可以看到,实际上段描述符是连续的8字节,64位的数据结构。这里我们简单的对段描述符的每一个属性进行描述。

  1.  段基址,类似于实模式中的段基址,但是又有所不同——获取最终的地址的时候,不需要在进行移位了,直接与段内偏移进行加减即可。因为这里段基址一共占用了32位(分布在三个部分),而地址总线也就32位,自然不需要向8086CPU一样进行移位了。

  2.  段界限,其通过20bit来进行表示。实际上这个表示的是段边界的扩展最值。在保护模式下,段的拓展方向只有上下两种。对于数据段和代码段来说,其段的拓展方向是向上,即地址越来越高,此时的段界限用来表示段内偏移的最大值;对于栈段,段的拓展方向是向下,即地址越来越低,此时的段界限用来表示段内偏移的最小值。这里需要说明的是,段界限只是一个单位量,不过其单位要么是1字节,要么是4KB,这是由段描述符中的G位来标明的。即最终段的边界是此段界限值 * 单位,即最终段的大小要么是2 ^ 20 * 1B = 1MB,要么是2 ^ 20 * 4KB = 2 ^ 32 B = 4GB。即实际的段界限边界值=(描述符中段界限+1) * (段界限的粒度大小:4KB或1B)-1。

  3.  对于G来说,其表示段界限粒度大小。如果为0,则段界限粒度大小为1字节;如果为1,则粒度大小为4KB。

  4.  对于D/B来说,其用来指示有效地址(段内偏移地址)及操作数的大小,是为了兼容286CPU保护模式的16位。对于代码段来说,如果为0,表示指令中的有效地址和操作数都是16位,指令有效地址用IP寄存器;如果为1,表示指令中的有效地址和操作数都是32位,指令有效地址用EIP寄存器。对于栈段来说,如果为0,使用sp寄存器,栈的最大范围为16位;如果是1,使用esp寄存器,栈的最大范围为32位。这里说明一下,是否为代码段或者栈段(数据段,只不过ss指向该段)由后面S和TYPE共同决定。

  5.  对于L来说,用来设置是否为64位代码段。如果L为1,表示是64位代码段,否则表示32位代码段。

  6.  对于AVL来说,操作系统可以随意使用该位,没有专门的用途。

  7.  对于P来说,即Present,表明该段是否存在于内存中,由CPU进行检查该字段。如果P为1,则表明该段是存在于内存中;如果P为0,CPU将抛出异常,会转到相应的异常处理程序中,处理完后再将P置为1。

  8.  对于DPL来说,即Descriptor Privilege Level,描述符特权级。其将计算机按照权力划分为不同等级,每一种等级称为一个特权级。由于其由2bit进行标识,自然划分为0、1、2、3级特权。数字越小,则特权级越大。CPU由实模式进入保护模式后,特权级自动为0;操作系统处于最高的0特权级;用户程序通常处于3特权级,权限最小。

  9.  对于S来说,用来描述段是否为系统段。如果S为0表示为系统段;S为1表示为非系统段。对于系统段,在介绍TYPE时还会提及。

  10.  对于TYPE来说,共4bit,用来指定本段的类型。对于CPU来说,段大体可以分为两大类——系统段(凡是硬件运行所需要的东可以称之为系统)和数据段(凡是软件所需要的东西都称之为数据,包括代码),这是由上面的S决定的。而对于系统段来说,各种称之为门的便是系统段,也就是硬件系统所需要的种种结构。而Type则用来表示内存段或门的子类型,如下表所示

系统段 系统段类型 第3~0位 说明
第3位 第2位 第1位 第0位
未定义 0 0 0 0 保留
可用的80286 TSS 0 0 0 1 仅限286的任务状态段
LDT 0 0 1 0 局部描述符表
忙碌的80286 TSS 0 0 1 1 仅限286,表示当前任务忙碌
80286调用门 0 1 0 0 仅限286
任务门 0 1 0 1 在现代操作系统中很少用到
80286中断门 0 1 1 0 仅限286
80286陷阱门 0 1 1 1 仅限286
未定义 1 0 0 0 保留
可用的80386 TSS 1 0 0 1 386以上CPU的任务状态段
未定义 1 0 1 0 保留
忙碌的80386 TSS 1 0 1 1 386以上CPU,表明当前任务忙碌
80386调用门 1 1 0 0 386以上CPU的调用门
未定义 1 1 0 1 保留
80386中断门 1 1 1 0 386以上CPU的中断门
80386陷阱门 1 1 1 1 386以上CPU的陷阱门
对于非系统段,按代码段和数据段划分,这4位有不同的意义
非系统段 内存段类型 X(可执行) R(可读) C(一致性) A 说明
代码段 1 0 0 * 只执行代码段
1 1 0 * 可执行、可读代码段
1 0 1 * 可执行、一致性代码段
1 1 1 * 可读、可执行、一致性代码段
内存段类型 X(可执行) E(可写) W(向下拓展) A 说明
数据段 0 0 0 * 只读数据段
0 1 0 * 可读可写数据段
0 0 1 * 只读,向下扩展数据段
0 1 1 * 可读,可写,向下扩展数据段

 

  这里书上貌似有一些问题——E和W的位置颠倒了,至少在我实验的时候是相反的。这里稍微说一下门,其相当于入口,通往一段程序,可以先有一个大概影响即可,后面还会在进行分析。下面我们说明一下非系统段的相关信息,即A、C、X、R、W和E。对于A来说,即Accessed位,表示最近是否被访问,每当该段被PCU访问过后,CPU会自动将该位置为1,而新建段描述符时,会将该位置为0;对于C,即Conforming,表明是一致性代码段,即如果自己是转移的目标段,并且自己是一致性代码段,则自己的特权级一定要高于当前特权级,转移后的特权级不与自己的DPL为主,而是与转移前的低特权级一致;对于R,用来限制代码段的访问,也就是CPU仍然可以通过cs:ip来继续访问和执行这些指令,但是不能读取位于该代码段上的数据(如果代码和数据写在一起的话);对于W,表明该段是否可以被写入,该位为0表示可以不可以写入,否则可以写入;最后则是E,用来表示拓展方向,如果该位为1表示向下扩展,即地址越来越低,通常用于栈段。

  这里就简单的介绍完了段描述符,如果还想更深入的了解的话可以查找一下相关的资料,比如Intel手册。

 全局描述符表GDT、局部描述符表LDT和选择子selector

  正如前面介绍的,一个段描述符只能用来定义一个内存段。而操作系统中至少包含注入代码段、数据段等多个内存段,因此需要一个可以容纳多个段描述符的数据结构进行管理,即全局描述符表(Global Descriptor Table)。实际上,全局描述符表GDT相当于是描述符的数值,数组中的每个元素都是前面介绍的8字节的段描述符,而我们可以通过使用选择子中提供的下标进行索引段描述符。

  之所以称之为全局描述符表,因为多个程序都可以在里面定义自己的段描述符,是公用的。而正如前面介绍过的,单纯通过“段基址:段偏移”无法安全的访问内存,因此通过增加额外属性解决问题,而由于增加了额外的属性,不方便直接放入32位的寄存器中,因此将其放置在内存中。但是我们需要知道GDT在内存中的具体位置,因此通过专门的寄存器进行指定,即GDTR(GDT Register)。GDTR是一个48位的寄存器,结构如下所示

 

   其可以指定GDT在内存中的起始地址,同样保存这GDT的大小,即最大为2 ^ 16 = 64KB。而考虑到每一个段描述符大小为8字节,因此最多可以容纳64KB / 8B = 8K = 8192个段或门。当然,对于GDTR寄存器,其赋值不能使用简单的mov指令,CPU中专门为其设置了lgdt指令(Load GDT),其指令格式如下

lgdt 48位内存数据

 

  这条指令既可以在实模式下会自行,同样也可以在保护模式下执行。对于在实模式下执行时理所当然的,因为在实模式下切换到保护模式中,需要构建GDT,自然使用到这个命令;而对于保护模式下可以执行的原因其实也很简单——实模式下只能访问低端1MB的内存空间,自然初始化的GDT处于低端1MB的内存空间中;而进入保护模式后,可以访问更多的内存空间,自然可以将GDT放置在其他更合适的位置上,所以可以通过指令lgdt指令重新更换gdt。

  下面则是如何使用GDT——即段的选择子。在保护模式下,段寄存器CS、DS、ES、FS、GS和SS存入的是选择子(selector),基本可以理解为GDT的索引值(还包括一些其他的信息),然后通过GDTR和选择子,基本就可以获取对应的段描述符,从而获取内存段的起始地址和段界限值等相关信息。由于段寄存器是16位,则选择子也是16位,其结构如下所示

 

   其低两位用来存储RPL,即请求特权级(0、1、2、3特权级),这里可以简单理解为请求者的当前特权级;第2位TI,即Table Indicator,用来表示是在GDT中还是LDT中进行索引描述符,当TI为0表示在GDT中进行索引,TI为1表示在LDT中进行索引(后面会讲到LDT)。而高3~15位则是描述符的索引值,用来进行索引,由于其是13位,所以其可以索引的个数即为2 ^ 13 = 8192,和前面GDT中的段描述符的最大个数是一致的。这里需要额外说明一下,实际上GDT中的第0个段描述符是不可用的,原因也比较简单,用来避免出现忘记初始化选择子(如果忘记初始化选择子,则一般选择子为0,则索引值自然就为0),如果选择子选到了第0个描述符,则处理器将发出异常。

  这里在稍微说明一下保护模式下如何进行地址的访问——首先通过段寄存器获取对应的选择子的值,从中获取TI和描述符索引值,然后根据TI和对应的寄存器找到相关的表项,然后根据描述符索引值获取对应的描述符,然后从描述符中即可获取段基址。然后将段基址和段偏移寄存器直接相加,即可获取最后的地址。

  当然,根据TI,实际上还存在有LDT(Local Descriptor Table),按照CPU的设想,一个任务对应一个LDT。而LDT也位于内存当中,也是一块内存,因此同样需要首先在GDT中进行注册,然后通过选择子进行获取相关的地址,这里LDT实际上是系统段(可以看一下前面GDT中TYPE字段的表)。类似于GDT,同样有LDTR,即LDT Register指向对应的LDT;同样使用指令lldt(Load LDT)初始化LDTR,但是不同于lgdt,其指令如下所示

lldt 16位内存数据/16位寄存器

 

  可以看到,其数据大小不同于lgdt,因为其存储的是GDT的选择子。除此之外,LDT中第0个段描述符是可用的——因为如果要索引LDT的话,其TI位会被设置为1,这也就表明选择子是被初始化过的,因此无需通过第0个段描述符不可用来避免选择子的未初始化。

A20地址线

  前面的实模式下,寄存器都是16位的,地址线都是20位的。对于8086等CPU,对于访问地址是很普通的——就是通过“段基址:段偏移”进行访问即可,由于其分段访问基址,实际上可以访问的地址会超过内存空间,但是由于地址线只有20位,因此相当于自动去掉最高位,表现形式也就是回绕。但是对于80286等后续发展的CPU来说,其地址线不仅仅是20位,会出现24位甚至32位,但是还需要使用CPU的实模式,如果不加处理的进行工作的话,内存地址可能会出现问题——访问0x100000时,对于8086CPU来说,会回绕到0;但是对于目前的CPU来说,其至少有24位地址线,则不会进行回绕,会访问到对应的物理内存上去。

  因此实际上目前是通过A20Gate进行兼容8086的。如果A20Gate被打开,则不会进行地址回绕;如果A20Gate被禁止,则会采用8086CPU类似的地址回绕。因此如果我们需要从实模式下转换为保护模式,需要打开A20Gate,其方法也很简单,通过与相关的IO接口进行交互即可,代码如下所示

in    al, 0x92
or    al, 0000_0010b  ;(or  al, 0x1)
out    0x92, al

CR0寄存器

  实际上从实模式转换到保护模式的话,除了构建GDT,打开A20地址线以外,我们还需要通过设置一些寄存器来设置CPU的工作模式。

  实际上,除了段寄存器、通用寄存器以外,还有一些额外的寄存器支持计算机的运行,比如之前提到的GDTR、LDRT等,还有控制寄存器CRx。这里从实模式转换到保护模式,需要用到控制寄存器CR0。实际上,控制寄存器是CPU的窗口,既可以用来展示CPU的内部状态,也可用于控制CPU的运行基址。而对于CR0控制寄存器,其第0位,即PE位,Protection Enable,此位用于开启CPU的保护模式,是保护模式的开关。因此我们在进行相关的设置后,只需要将CR0控制器的PE位置为1,即可最终开启保护模式,相关代码如下所示

mov     eax, cr0
or     eax, 0x00000001
mov    cr0, eax

实验

  虽然前面一直在完善MBR程序,但是实际上在上一节MBR成功将loader加载入内存后,MBR就已经完成了其历史使命。因此这里我们从实模式进入到保护模式的任务就交给了loader程序。因此,结合前面所讲的,下面我们将通过完成GDT的构建与载入、打开A20地址线、最后设置CR0寄存器,从而最终完成进入保护模式。仓库链接如下所示

  虽然上面说MBR已经完成了历史使命,但是这里还是需要修改一下其内容——其读取磁盘的大小,因为上一次实验最后的loader程序功能比较简单,所以程序大小很小,只需要mbr读取磁盘上一个扇区的内容即可完成loader程序的读入,从而完成loader的加载。之后的loader需要完成从实模式进入保护模式,自然需要较多的代码进行实现,因此这里需要修改一下其读入的扇区数,这里直接修改为5扇区,其余没有任何的改变,就不再放源代码了。

  下面我们提前进行一些宏定义——这样子有利于我们在loader程序中更注重代码逻辑而非实现细节,宏定义我们按照前面的规则,仍然定义在include/boot.inc中,源代码如下所示

;-------------------------- loader 和 kernel---------------------
LOADER_BASE_ADDR equ 0x700
LOADER_START_SECTOR equ 0x1



;-----------------------------GDT描述符属性----------------------
;    下面的所有相关的性质主要描述GDT的高32位,格式如下所示
;    00000000_0_0_0_0_0000_0_00_0_0000_00000000b
;    段基址8_G1_D/B1_L1_AVL1_段界限4_P1_DPL2_S1_TYPE4_段基址8


GDT_DES equ 00000000_0_0_0_0_0000_0_00_0_0000_00000000b                            ;段描述符


GDT_DES_G_4K equ 00000000_1_0_0_0_0000_0_00_0_0000_00000000b                        ;段界限粒度为4K
GDT_DES_G_1B equ GDT_DES                                        ;段界限粒度为1B


GDT_DES_D_16 equ GDT_DES                                        ;代码段,有效地址和操作数16位,ip寄存器
GDT_DES_D_32 equ 00000000_0_1_0_0_0000_0_00_0_0000_00000000b                        ;代码段,有效地址和操作数32位,eip寄存器


GDT_DES_B_16 equ GDT_DES_D_16                                        ;栈段,sp寄存器
GDT_DES_B_32 equ GDT_DES_D_32                                        ;栈段,esp寄存器


GDT_DES_L_32 equ GDT_DES                                        ;32位代码段
GDT_DES_L_64 equ 00000000_0_0_1_0_0000_0_00_0_0000_00000000b                        ;64位代码段


GDT_DES_AVL equ GDT_DES                                            ;这个属性没有实际意义,但作为组成部分必须赋值



GDT_DES_UNPRESENT equ GDT_DES                                        ;段未存在于内存中
GDT_DES_PRESENT equ 00000000_0_0_0_0_0000_1_00_0_0000_00000000b                        ;段存在于内存中


GDT_DES_DPL_0 equ GDT_DES                                        ;0特权级
GDT_DES_DPL_1 equ 00000000_0_0_0_0_0000_0_01_0_0000_00000000b                        ;1特权级
GDT_DES_DPL_2 equ 00000000_0_0_0_0_0000_0_10_0_0000_00000000b                        ;2特权级
GDT_DES_DPL_3 equ 00000000_0_0_0_0_0000_0_11_0_0000_00000000b                        ;3特权级


GDT_DES_UNSYSTEM equ 00000000_0_0_0_0_0000_0_00_1_0000_00000000b                    ;非系统段
GDT_DES_SYSTEM equ GDT_DES                                        ;系统段


GDT_DES_TYPE_CODE_X equ 00000000_0_0_0_0_0000_0_00_0_1000_00000000b                    ;代码段具有可执行性
GDT_DES_TYPE_CODE_R equ 00000000_0_0_0_0_0000_0_00_0_0100_00000000b                    ;代码段具有可读性
GDT_DES_TYPE_CODE_C equ 00000000_0_0_0_0_0000_0_00_0_0010_00000000b                    ;代码段具有一致性


GDT_DES_TYPE_DATA_W equ 00000000_0_0_0_0_0000_0_00_0_0010_00000000b                    ;数据段具有可写性
GDT_DES_TYPE_DATA_E equ 00000000_0_0_0_0_0000_0_00_0_0100_00000000b                    ;代码段具有向下扩展性


;代码段的段描述符的高32位:其4k对齐;指令中有效地址和操作数为32位
;32位代码段;存在于内存中;位于0特权级;非系统段;仅可执行代码段
;采用平坦模式,段基址为0,段界限为0xfffff

GDT_DES_CODE_HIGH_4B equ ((0x00 << 24) | GDT_DES_G_4K | GDT_DES_D_32 | GDT_DES_L_32 | GDT_DES_AVL | (0xf << 16) | \
GDT_DES_PRESENT | GDT_DES_DPL_0 | GDT_DES_UNSYSTEM | GDT_DES_TYPE_CODE_X | 0x00)                        



;数据段的段描述符的高32位:其4k对齐;如果为栈段,采用esp寄存器;
;设置32位代码段(以防万一,不用的话设置也没有影响);存在于内存;段界限为0xfffff;位于0特权级
;非系统段;可读、可写数据段,会向上拓展
;采用平坦模式,段基址为0,段界限为0xfffff
GDT_DES_DATA_HIGH_4B equ ((0x00 << 24) | GDT_DES_G_4K | GDT_DES_B_32 | GDT_DES_L_32 | GDT_DES_AVL | (0xf << 16) | \
GDT_DES_PRESENT | GDT_DES_DPL_0 | GDT_DES_UNSYSTEM | GDT_DES_TYPE_DATA_W | 0x00)                        
                                                    


;显存段的段描述符的高32位:其4k对齐;如果为栈段,采用esp寄存器;
;设置32位代码段(以防万一,不用的话设置也没有影响);存在于内存;段界限为7;位于0特权级
;非系统段;可读、可写数据段,会向上扩展
;这里没有采用平坦模式,根据实模式1MB内存布局,段基址为0xb8000,段界限为0x7 = (32KB - 1) / 4KB
GDT_DES_VIDEO_HIGH_4B equ ((0x00 << 24) | GDT_DES_G_4K | GDT_DES_B_32 | GDT_DES_L_32 | GDT_DES_AVL | (0x0 << 16) | \
GDT_DES_PRESENT | GDT_DES_DPL_0 | GDT_DES_UNSYSTEM | GDT_DES_TYPE_DATA_W | 0x0b)                        
                                                    


;----------------------选择子属性--------------------------------------------
;    下面的所有相关的性质主要描述选择子,格式如下所示
;    0000000000000_0_00b
;    索引13_TI1_RPL2

GDT_SECT equ 0000000000000_0_00b                                    ;选择子格式

GDT_SECT_RPL_0 equ GDT_SECT                                        ;RPL为0特权级
GDT_SECT_RPL_1 equ 0000000000000_0_01b                                    ;RPL为1特权级
GDT_SECT_RPL_2 equ 0000000000000_0_01b                                    ;RPL为2特权级
GDT_SECT_RPL_3 equ 0000000000000_0_01b                                    ;RPL为3特权级

GDT_SECT_TI_GDT equ GDT_SECT                                        ;在GDT中进行索引
GDT_SECT_TI_LDT equ 0000000000000_1_00b                                    ;在LDT中进行索引

 

  这里说明一下,别看代码比较多,实际上大部分就是将GDT的段描述符的每一个部分对应的数值使用宏进行表示,然后最后通过这些宏的组合完成对于代码段、数据段或者显存段的段描述符的高32位的表示。所以你也可以不使用这些宏,直接用32bit的二进制数来表示,但是那样的话可读性就非常差了。下面则是最后的loader的源代码,如下所示

;    这里实现简单的loader,其讲系统由实模式进入保护模式
;    最后loader仍然会输出相关的字符串,然后进行悬停,方便进行观察
;------------------------------------------------------------------------

%include "boot.inc"
;    类似于C语言的宏定义
;--------------------------------------------------------------------------------
;    这个文件的主要定义如下所示
;    LOADER_BASE_ADDR equ 0x700
;    LOADER_START_SECTOR equ 0x1
;    GDT和GDT的选择子的宏定义


SECTION    LOADER    vstart=LOADER_BASE_ADDR         ;这个地址表示将起始地址设置为LOADER_BASE_ADDR——因为MBR会将loader程序加载到LOADER_BASE_ADDR处

LOADER_STACK_TOP equ LOADER_BASE_ADDR        ;这里提前说明一下,实际上loader的起始栈顶是LOADER_BASE_ADDR

    jmp    LOADER_START            ;16位实模式相对近转移

;-----------------这部分内容用来构建GDT结构,由于栈向下生长,因此不会破坏GDT--------------------------------
;    db: data byte,         1字节
;    dw: data word,         2字节
;    dd: data double-word,    4字节
;    dq: data quarter-word,    8字节


GDT_BASE:
    dd 0x00000000, 0x00000000        ;前面分析过了,GDT的第0个段描述符无法使用,因此直接置为0即可

GDT_CODE:
    dd 0x0000ffff, GDT_DES_CODE_HIGH_4B    ;这是GDT的第1个段描述符,代码段。由于采用了平坦模式,因此段基址设置为0, 段界限设置为0xfffff

GDT_DATA_STACK:
    dd 0x0000ffff, GDT_DES_DATA_HIGH_4B    ;这是GDT的第2个段描述符,数据(栈)段。由于采用了平坦模式,因此断机制设置为0,段界限设置为0xfffff
                        ;这里需要说明一下,这里纯数据和栈公用一个段,且该段向上扩展。但是栈的方向和段的拓展方向并没有关系。
                        ;段的拓展方向仅仅是用来约束段偏移的,即向上拓展的话,则[base_add, base_add + offset]是对应的段
                        ;向下拓展的话,则[base_add, base_add + offset]非该段,其余都是段描述符对应的段

GDT_VIDEO:
    dd 0x8000_0007, GDT_DES_VIDEO_HIGH_4B    ;这是GDT的第3个段描述符,显存数据段。未采用平坦模式,段基址为0xb8000,段大小为32KB,即段界限为0x7
                        

;-----------------下面定义一下GDT的选择子的--------------------------------
GDT_SECT_CODE equ ((0x0001 << 3) | GDT_SECT_TI_GDT | GDT_SECT_RPL_0)        ;描述符索引值为0x1;在GDT中索引;请求权限为0特权级
GDT_SECT_DATASTACK equ ((0x0002 << 3) | GDT_SECT_TI_GDT | GDT_SECT_RPL_0)    ;描述符索引值为0x2;在GDT中索引;请求权限为0特权级
GDT_SECT_VIDEO equ ((0x0003 << 3) | GDT_SECT_TI_GDT | GDT_SECT_RPL_0)        ;描述符索引值为0x3;在GDT中索引;请求权限为0特权级



;-----------------这里则是GDTR相关的信息,用来构造GDTR的值--------------------------------
GDT_SIZE equ ($ - GDT_BASE)                            ;当前内存中GDT的大小
GDT_LIMIT equ (GDT_SIZE - 1)                            ;GDTR中的值,由于其从0开始,需要减一,类似于段界限

times 60 dq 0                                    ;在预留60个段描述符的空间


GDT_PTR:
    dw GDT_LIMIT
    dd GDT_BASE                                ;低2个字节是GDT的界限,高4个字节是GDT内存起始地址




LOADER_START:
;    向1MB内存中的文本模式的显示适配器区域写入数据
;------------------------------------------------------------------------
;    每个字符2字节,其低字节为字符对应的ASCII码,高字节为字符的属性
;    由于其为背景蓝色,前景色浅品红色,不闪烁,其高字节值为 00011101b
;------------------------------------------------------------------------

    mov    cx, 0x0
    mov byte    al, [format]        ;初始化计数器cx,。由于前面已经设置了ds段寄存器为0,该指令相当于将字符属性字节读入ax寄存器中

    LOOP:
        mov    di, cx

        mov byte    dl, [di + loaderMsg];这里通过变址寻址访问内存,由于前面设置了ds段寄存器为0,这里直接获取字符串中的对应字符
        sub    dl, 0
        jz    LOOPEND            ;判断字符串是否结束。有条件跳转,因此仅仅修改段偏移地址,由于cs始终为0,自然跳转到LOOPEND对应的位置

        add    di, di
    add    di, 160            ;由于VGA模式为80 * 25,即一行80个字符,每一个字符2字节,如果输出在终端的第2行,则需要从80 * 2 = 160的偏移开始

        mov byte    [es:di], dl        ;这里通过变址寻址访问内存

        add    di, 1
        mov byte    [es:di], al        ;这里通过变址寻址访问内存
        
    add    cx, 1
           jmp near    LOOP            ;无条件相对近跳转,会重新跳转到LOOP处执行循环

    LOOPEND:
;------------------------------------------------------------------------
;    我们将上面的指令分析一下
;    可以看到,对于内存寻址来说,这里通过直接寻址进行寻址
;    我们每一次输入两个字节信息,其中低字节是上面分析的字符的属性
;    高字节是字符对应的ascii码,从而完成了内存的写入。



;------------------------准备进入保护模式-----------------------------------
;    1.    将GDT装载入GDTR中
    lgdt    [GDT_PTR]


;    2.    打开A20Gate
    mov    dx, 0x92
    in    al, dx
    or    al, 0000_0010b
    out    dx, al


;    3.    修改CR0寄存器
    mov    eax, cr0
    or    eax, 0x00000001
    mov    cr0, eax


;----------------------------------这里需要通过无条件跳转来刷新流水线,否则会出错,需要特别注意一下----------------
    jmp GDT_SECT_CODE:PROTECTION_MODE_START                        ;绝对地址远调用





;-------------------------------------下面是观察用的保护模式下的代码,用来确认成功进入保护模式---------------
[bits 32]
PROTECTION_MODE_START:

    mov    ax, GDT_SECT_DATASTACK
    mov    ds, ax
    mov    gs, ax
    mov    ss, ax                ;初始化各个段寄存器,将其都指向GDT_SECT_DATASTACK段描述符对应的段

    mov    esp, LOADER_STACK_TOP
    mov    ax, GDT_SECT_VIDEO
    mov    es, ax                ;这里将es设置为GDT_VIDEO段描述符对应的段,即显存段,那里没有使用平坦模式,访问显存仍然类似于实模式




;    在保护模式下向文本模式的显示适配器区域写入数据
;------------------------------------------------------------------------
;    每个字符2字节,其低字节为字符对应的ASCII码,高字节为字符的属性
;    由于其为背景蓝色,前景色浅品红色,不闪烁,其高字节值为 00011101b
;------------------------------------------------------------------------

    mov    cx, 0x0
    mov byte    al, [format]        ;初始化计数器cx,。由于前面已经设置了ds段寄存器为0,该指令相当于将字符属性字节读入ax寄存器中

    LOOP2:
        mov    di, cx

        mov byte    dl, [di + protectionMsg];这里通过变址寻址访问内存,由于前面设置了ds段寄存器为0,这里直接获取字符串中的对应字符
        sub    dl, 0
        jz    LOOPEND2            ;判断字符串是否结束。有条件跳转,因此仅仅修改段偏移地址,由于cs始终为0,自然跳转到LOOPEND对应的位置

        add    di, di
    add    di, 320            ;由于VGA模式为80 * 25,即一行80个字符,每一个字符2字节,如果输出在终端的第3行,则需要从80 * 4 = 320的偏移开始

        mov byte    [es:di], dl        ;这里通过变址寻址访问内存

        add    di, 1
        mov byte    [es:di], al        ;这里通过变址寻址访问内存
        
    add    cx, 1
           jmp near    LOOP2        ;无条件相对近跳转,会重新跳转到LOOP处执行循环

    LOOPEND2:
;------------------------------------------------------------------------
;    我们将上面的指令分析一下
;    可以看到,对于内存寻址来说,这里通过直接寻址进行寻址
;    我们每一次输入两个字节信息,其中低字节是上面分析的字符的属性
;    高字节是字符对应的ascii码,从而完成了内存的写入。




;    下面进行循环,确保程序悬停在该处,从而观察输出
;------------------------------------------------------------------------

    jmp    $            ;无条件相对近跳转,会重新跳转到LOOP处执行循环

;------------------------------------------------------------------------
;    我们将上面的指令分析一下
;    $表示当前行的地址,这样子相当于始终执行这一行指令,从而使程序悬停




;    下面进行常量设置
;------------------------------------------------------------------------
    loaderMsg db "Hawk's LOADER", 0;        即伪操作指令,表示每一个元素大小为1字节, 并且在结尾为\x00表明字符串结束
    protectionMsg db "Now in PROTECTION mode", 0;        即伪操作指令,表示每一个元素大小为1字节, 并且在结尾为\x00表明字符串结束
    format db 00011101b;            这里是显存中的字符属性,表明其为背景蓝色,前景色浅品红色,不闪烁   
    times (2560 - ($ - $$)) db 0            ;使用0填充至5个扇区

 

  这个源代码稍稍复杂了一些。实际上大体上可以分为两部分——实模式下的保护模式环境的配置和保护模式下的验证这两部分。对于实模式下的保护模式环境的配置,这部分也是代码和数据混合放置在一起的,第一个jmp LOADER_START指令就是为了跳过前面的数据部分,从而直接到相关的代码部分。而前面的数据部分也很简单,就是单纯的排列出GDT,里面包含3个设置好的段描述符(代码段,数据-栈段,以及显存段),以及预留的60个段描述符的位置;除此之外,还有GDTR的值,因为GDT Register同样是从内存中载入数据,因此这里准备好带载入GDTR中的值。而代码部分就是前面分析过的三大块——载入GDT,打开A20Gate以及设置CR0寄存器。这样子就算基本完成了从实模式进入保护模式的操作。注意的是,这后面紧跟了一个无条件跳转,并且就跳转到自己的下一条指令,看似没有作用,实际上缺了这条无条件跳转,程序就不会正常执行,这个在后面分析。

  上面跳转之后就进入到了保护模式下的验证部分,这部分代码实际上就是简单的向显存中输入数据并显示。这里实际上就是简单的验证了能否通过选择子正确选择对应的段并进行正常的读写,这里我们在后面给出截图。这里需要说明一下几点

  1.  书上好像有一些问题——对于GDT中的段描述符的TYPE的数据段的W和E,也就是可写性质和向下扩展,这两个位放反了(或者可能我理解错了)。大家可以注意一下这一点,因为我在实验的时候这两点困扰了我很久,然后突然尝试了一下更换了这两位,才最终出现正常现象。

  2.  在完成实模式向保护模式的转换后,即完成CR0寄存器的置位后,需要立马进行一个无条件跳转,用来刷新流水线,否则可能会出错。这个原因后面再提及,下面我们放出相关的实验步骤,这里还需要说明一下,这里之后的环境我更改为了bochs(qemu不知道为什么,读取磁盘的时候好像会有一些问题,可能是设置的不太正确),其配置教程前面已经给过链接了,这里在给一下。相关的配置文件如下所示

#Bochs运行中使用的内存,设置为32M
megs: 32
 
#设置真实机器的BIOS和VGA BIOS
#修改成你们对应的地址
romimage: file=/home/hawk/Desktop/bochs/share/bochs/BIOS-bochs-latest
vgaromimage: file=/home/hawk/Desktop/bochs/share/bochs/VGABIOS-lgpl-latest
#设置Bochs所使用的磁盘
#设置启动盘符
boot: disk

#设置日志文件的输出
#log: bochs.out

#开启或关闭某些功能,修改成你们对应的地址
mouse: enabled=0
keyboard_mapping: enabled=1, map=/home/hawk/Desktop/bochs/share/bochs/keymaps/x11-pc-us.map

#硬盘设置
ata0: enabled=1, ioaddr1=0x1f0, ioaddr2=0x3f0, irq=14
ata0-slave: type=disk, path="hawk.img", mode=flat, cylinders=121, heads=16, spt=63

 

  我们首先正常编译MBR程序和loader程序,指令如下所示

nasm -I include/ -o mbr.bin mbr.S
nasm -I include/ -o loader.bin loader.S

dd if=mbr.bin of=hawk.img bs=512 count=1 conv=notrunc
d if=loader.bin of=hawk.img bs=512 seek=1 count=5 conv=notrunc

 

  结果如图所示

 

 

  下面我们启动bochs虚拟机,命令如下所示

 

 

 

   结果如图所示

 

   然后我们在观察一下GDTR和相关的寄存器,结果如图所示

 

   可以看到,es对应的base确实为0xb8000,也就是文本模式显存的起始地址处,则进入保护模式成功。另外说明一下,这里面的段界限(limit)的单位是字节,而我们之前代码中设置的段界限粒度都是4KB,所以需要相应的进行转换,这个需要明确一下,我做实验的时候一直以为bochs显示错误。。。

  最后,我们来分析一下前面提到的无条件远程调用问题。这里就稍微说明一下问题即可。

  我们知道,一方面,段描述符位于内存中,访问内存对于CPU来说是非常慢的动作,因此CPU中使用段描述符缓冲寄存器缓冲段描述符,而且其兼容实模式,其仅仅在更改段寄存器值的时候才会进行更新。而我们在从实模式转换进入保护模式的过程中,段描述符缓冲寄存器还是实模式下的内容,其部分段属性不对应,因此这对保护模式来说会造成错误。另一方面,CPU为了提高效率,采用了流水线技术、分支预测技术等,也就是同一时刻会同时执行不同指令的不同阶段,这会导致什么问题呢——我们简单的将CPU执行指令分为取指,译码、执行等步骤,则如果刚刚执行完CR寄存器的置位,从实模式进入了保护模式的话,此时同一时刻下几条指令已经被送上流水线了,而之后进行译码等步骤的时候,是按照保护模式进行的,然而此时段描述符寄存器还没有进行更新,其段属性中的D/B位中的D被设置为0,从而CPU进行处理的时候操作数都是16位,然而我们已经进行入了32位的保护模式,后面执行的很可能都是32位下的代码了,这样子很可能会导致译码失败。

  综上可以知道,现在问题的关键是在刚刚结束模式转换后,必须立即要更新代码段描述符缓冲寄存器的值(否则寻找下一条指令会用到代码段描述符缓冲寄存器的值,则会出现错误),又要清空流水线(否则可能因为代码段描述符缓冲寄存器错误值而进行错误译码,从而导致错误)。这里就是用远程转移进行完成。我们注意到,第一点,我们将这个代码仍然处于[bits 16]下进行编译的。一方面,其操作数等仍然是16位,则不会出现上面的译码失败的问题;另一方面,如果进行无条件远程转移,则CPU会清空流水线(分支预测大概率错的),这样子确保了后面的指令一定是在保护模式下进行的。另外,如果是远程转移的话,其会修改代码段寄存器的值,从而完成对于代码段描述符缓冲寄存器的更新,从而避免了后面因为代码段描述符缓冲寄存器仍处于实模式而导致的错误。

posted @ 2020-09-18 23:18  hawkJW  阅读(805)  评论(0编辑  收藏  举报