《操作系统真象还原》读书笔记 第4章

0x1 保护模式基本概念

物理内存地址不能直接被程序访问,程序内部的地址(虚拟地址)需要被转换成物理地址后再去访问,程序对此一无所知。地址转换是由处理器和操作系统共同协作完成的,处理器在硬件上提供地址转换部件,操作系统提供转换过程中所需的页表。

0x1.1 实模式不是32位CPU,变成了16位

32位CPU具有保护模式和实模式两种运行模式,可以兼容实模式下的程序。兼容实模式,是指能够正确处理好实模式下的程序,并不是说在实模式下运行时就完全变成了纯16位的CPU。就像高中生做小学生的题,就意味着做题人从高中生变为了小学生。
实模式的运行环境时16位,保护模式运行环境32位。
当它以16位的实模式运行时,不是说CPU变成了纯粹的16位CPU(硬件本身不会变),32位CPU在16位的实模式下运行,虽说相当于更为强大的16位的CPU,但其本质可是32位的。当我们开机时,CPU一开始处于实模式下,它相当于逻辑上的纯粹CPU,它不知道自己可以使用32位寻址,只有在指定的标志位上设置为1时才,CPU才会“明白”自己原来是可以使用32位寻址的。

0x2 初见保护模式

0x2.1 保护模式之寄存器拓展

到了保护模式下CPU变成了32根地址总线,32根地址总线足够访问4GB的空间,所以没有必要按照老方式寻址了,为了满足4GB空间寻址,寄存器宽度也增加了一倍,从原来的2字节变为4字节32位。除了段寄存器仍然使用16位,其余通用寄存器都提升到32位。
寄存器要保持向下兼容,不会重新构造原来的基础设备而是在原有的寄存器基础上进行了拓展。经过拓展后的寄存器在原有名字上加了个e。如图所示。
在这里插入图片描述
高16位没办法单独使用,只能在用32位寄存器时才有可能使用到它们。
保护模式下还有一个大的变化就是段寄存器,段寄存器中要记录32位地址的数据段基址,16位肯定是装不下的,所以段基址都存储在一个数据结构中——全局描述符表。既然叫表,说明里面肯定存在表项,其中每个表项称为段描述符,其大小为64字节,用来描述各个内存段的起始地址、大小、权限等信息。该全局描述符表很大,所以放在内存中,由GDTR寄存器指向它就行。
这样,段寄存器中保存的就再也不是段基址了,里面保存的内容叫“选择子”,selector,说白了就是段描述符表的表项,就是个索引用的数,把全局描述符表当作数组来看的话,它就是数组的下标。
注意:
1)段描述符在内存中,访问内存对CPU来说比较慢,效率不高
2)段描述的格式很奇怪,一个数据要分三个地方存,所以CPU要把这些七零八落的数拼合成一个完整的数据也是要花时间的。
为了提高获取段信息的效率,对段寄存器采用了缓存技术,将段信息用一个寄存器来缓存,这就是段描述符缓冲寄存器(Descriptor Cache Registers)。对程序员而言它是不可见的。CPU每次将获取到的内存段信息,整理成“完整的、通顺、不蹩脚”的形式后,存入段描述符缓寄存冲器,以后每次访问相同的段时,就直接读取该段寄存器对应的段描述符缓冲寄存器。
另外,虽然段描述符缓冲寄存器时保护模式下的产物,但它也可以用在实模式下。也是为了避免重复计算浪费相应的时间。
既然是缓存,就一定要有个失效时间。段描述符缓冲寄存器的失效时间是多少?
其实这个时间并不固定,原则上,只要往段寄存器中赋值,CPU就会更新段描述符缓冲寄存器。例如,在保护模式下加载选择子(即使新选择子的值和之间段寄存器中老选择子相同),CPU就会重新访问全局描述符表,再将获取的段信息重新放回描述符缓冲寄存器,或在实模式下为段寄存器赋予段基址,无论是否与之前段基址相同,段基址左移4位后的结果就被送入段描述符缓冲寄存器。
下面列出三种段描述符缓冲寄存器结构。
在这里插入图片描述
保护模式下基址寄存器拓展到了所有通用寄存器,而不再是只限于bx,bp,变址寄存器也不再是si、di这两个。具体算法拓展图如下,作者给的很清楚。
在这里插入图片描述

0x2.2 保护模式之运行模式反转

我们的32位CPU既支持实模式,又支持保护模式。那么同一个汇编指令我们怎么确定它是在保护模式下执行的还是实模式下运行的?
解决这个问题前先回忆下指令格式
在这里插入图片描述
在这个格式种第3个字段用于指令寻址方式和操作数类型,在指令格式不变的情况下,为了兼容保护模式,一种方案是重新定义各种寻址方式、寄存器的编码。由于保护模式中的寻址方式和操作数类型同时模式下完全不同,故相应的编码也不同。比如在实模式下,用二进制010表示dx寄存器,在保护模式下的010就表示edx寄存器,对于硬件来说是完全不一样的,所以编译器必须明确操作对象是哪个。
在实模式下,指令和操作数都是16位,它可以使用32位的资源。同样在保护模式下,指令和操作数都是32位的,它也可以使用16位的资源。也就是说,在某模式下,可以使用另一模式的资源。
为了区分指令运行是在哪种模式下运行的,编译器提供了伪指令bits,用它来向编译器传达:我下面的指令都要编译成xx位的,因为我们编程者知道也希望自己的程序运行在xx模式下。比如在实模式下,运行的指令都是16位的,所以编译器要将代码编译成16位的指令。在实模式下准备好了保护模式所需的环境后,进入保护模式后的代码就应该是32位指令。也就是,同一段程序要经历两种模式,所以同一段程序中有两种模式的机器码。

bits 的指令格式是 [bits 16][bits 32]
[bits 16]是告诉编译器,下面的代码帮我编译成16位的机器码
[bits 32]是告诉编译器,下面的代码帮我编译成32位的机器码
范围是从当前[bits xx]标签一直到下一个[bits xx]标签

进入保护模式的代码并不统一。比如进入保护模式需要三个步骤。
1)打开 A20
2)加载gdt
3)将cr0的pe位置1
这三个步骤可以不顺序,也可以不连续,并且每个步骤又是由多个小步骤完成的,每个小步骤形式又是不固定的,组合多种多样。为了明确确定运行模式,使用bits伪指令是最简单的办法。
什么是反转前缀
在指令格式种有一个前缀字段,里面存放的是指令选项,比如指令重复前缀rep、段跨越前缀“段寄存器:”,还有操作数反转前缀0x66和寻址方式反转前缀0x67。
在不同模式下的操作,操作数和寻址方式都各有不同。
我们知道模式之间可以相互使用对方环境下的资源。比如,16位实模式下可以使用32位保护模式下的寄存器。如果要用另一模式下的操作数大小,需要在指令前添加指令前缀0x66,将当前指令模式临时转变为另一种模式。这就是反转的意义,不管当前模式是什么,总是转变成相反的运行模式。
比如,在指令中添加了0x66反转前缀后
假如当前运行模式是16位实模式,操作数大小变为32位。
假设当前运行模式是32位保护模式,操作数大小变为16位。
这个转换是临时的,只有在当前指令才有效。
在这里插入图片描述
寻址方式反转前缀0x67
操作数可以在模式间相互转换,那么寻址方式一样可以,只需要在它的指令前加上0x67反转前缀即可。
在这里插入图片描述

0x3 全局描述符表

到了保护模式下,内存段(如数据段、代码段等)不再是简单地用段寄存器加载下基址就能使用了,增加了很多信息,需要提前把段定义好才能使用。
全局描述符表(Global Descriptor Table,GDT)是保护模式下内存段的登记表,这是不同于实模式的特征。

0x3.1 段描述符

内存段是一片内存区域,访问内存就要提供段基址,所以要有段基址属性。
为了限制程序访问内存的范围,还要对段大小进行约束,所以要有段界属性。同时还增加了一些约束条件。
这些用来描述内存段的属性,被放到一个称为段描述符的结构体中,这个结果专门用来描述一个内存段,该结构是8字节大小,这8个字节是连续不可拆分的。如下图
在这里插入图片描述
保护模式下地址总线是32位,段基址需要用32位来表示。
段界限表示段边界的扩展最值,即最大扩展到多少或最小扩展到多少。扩展方向只有上下两种。对于数据段和代码段,拓展方向是向上拓展的,即地址越来越高,此时的段界限用来表示段内偏移的最大值。对于栈段,段的拓展方向是向下拓展,即地址越来越低,此时段界限用来表示段内偏移的最小值。无论是向上还是向下拓展,段界限作用只是表示段边界、大小、范围。段界限用20个二级制位来表示。只不过此段界限只是个单位量,它的单位要么是字节,要么是4KB,这是由段描述符G位来表示的。最终段的边界是此段界值 * 单位,故段的大小要么是2的20次方等于1MB,要么是2的32次方等于4GB。
上面所说的1MB和4GB是范围,并不是具体的边界值。由于段界限只是个偏移量,是从0算起的,所以实际的段界限边界值 = (描述符中段界限+1)X (段界限粒度大小:4KB或者1)-1。
如果G位为0,表示段界限粒度大小为1字节,根据上面的公式,实际段界限=(描述符段界限+1)X1-1=描述符中段界限,段界限实际大小就等于描述符中段界限值。如果G位为1同理,只是将粒度大小换成4KB。
11 ~ 8位是TYPE字段,用来指定描述符的类型。在CPU眼里一个段描述符分为两大类,要么描述的是系统段,要么描述的是数据段。这是由段描述符S位决定的,用它指示是否是系统段。在CPU眼里,凡是硬件运行所需要的的东西都可以称之为系统,凡是软件(操作系统也属于软件,CPU眼中,它与用户程序无异)需要的东西都称之为数据。无论是代码,还是数据,甚至包括栈,他们都作为硬件的输入,都是给一硬件的数据而已,所以代码段在段描述符也属于数据端(非系统段)。S为0时表示系统段,S为1时表示数据段。type字段时要和S字段配合在一起才能确定段描述符的确切类型,只有S字段的值确定后,type字段才有意义。
什么是系统段
各种称为"门"的结构便是系统段,也就是硬件系统需要的结构,非软件使用的,如调用门、任务门。简而言之,门的意思就是入口,它通往一段程序。
type字段一共4为,用于表示内存段或门的子类型。说明见下图。
在这里插入图片描述
表中Accessed位,由CPU来设置,每次倍CPU访问后,CPU就将此位置置为1。创建一个新描述符是,应该将此位置置0。在调试时可以判断该描述符是否可用。
C表示一致性代码段,也称为依从代码段,Conforming。一致性代码段是指如果自己是转移的目标段,并且自己是一致性代码段,自己的特权级一定要高于当前特权级,转移后的特权级不与自己的DPL为主,而时与转移前的低特权级一致,也就是听从、依从赚一千的低特权级。C为1时则表示该段是一致性代码段,C为0时表示该段为非一致性代码段。
R表示可读,R为1表示可读,R为0表示不可读。这个属性一般用来限制代码段的访问。这个属性一般用来限制代码段的访问。如果指令执行过程中,CPU发现某些指令对R为0的段进行访问,如果使用段前缀CS来访问代码段,CPU将抛出异常。不可读代码段只是来限制代码指令的,并不是连CPU也不能看。CPU可以看任何位置的数据。
X表示该段是否可执行,EXecutable。我们所说的指令和数据,在CPU眼里是没有任何区别的,都是类似010101的二级制数据。所以要用type中的X位来标识出是否是可执行的代码。代码段是可执行的,即为1。而数据端是不可执行的,即X为0。
E是用来标识段的拓展方向的,Extend。E为0表示向上拓展,即地址越来越高。E为1表示向下拓展,地址越来越低。
W是指段是否可写,Writable。W为1表示可写,通常用于数据段。W为0表示不可写入,通常用于代码段。
描述符第12位是S字段,用来指出当前描述符是否是系统段。S为0表示系统段,S为1表示非系统段。
段描述符的第13 ~ 14位是DPL字段,Descriptor Privilege Level,即描述符特权级这个是保护模式提供的安全解决方案,将计算机世界按权力划分成不同等级,每种等级称为一种特权级。
由于段描述符用来描述一个内存段或一段代码的情况(若描述符类型为"门"),所以段描述符中的DPL是指所代表的内存段的特权级。
这两位能表示4种特权级,分别是0、1、2、3级特权,数字越小特权越大。特权级是保护模式下才有的东西(也是CPU运行在保护模式下的必须物,不是操作系统的必须物,操作系统只是利用了这种机制进行了一定的拓展,比如Windows操作系统只有0、3特权级,并没有使用所有的特权级)因为保护模式下的代码已经是操作系统的一部分,所以操作系统应该处于最高特权级0。用户程序通常处于3特权级,权限最小。某些指令只能在0特权级下运行,从而保证了安全。
段描述符第15位是P字段,Present,即段是否存在。如果段存在内存中,P为1,否则P为0。P字段是由CPU来检查的,如果为0,CPU将抛出异常,转到相应的异常处理程序,此异常处理程序是由我们自己来实现的,在异常处理完成后要将P置为1。也就是说,对于P字段,CPU只负责检查,我们程序员进行赋值。不过通常情况下段,段都是在内存中。当初CPU的设计是当内存不足时,可以将段描述符对应的内存段换出,也就是把不用的段直接换出到硬盘,待使用时再加载进来。现在即使内存不足时,也没有把整个段换出,现在都是平坦模式,一般情况下,段都要4GB大小,而且这些平坦的段都是公用的,换出去占空间也容易出问题。所以这是未开启分页时的解决方案,保护模式下有分页功能,可以按页(4KB)的单位来将内存换入换出。
段描述符的第20位为AVL字段,从名字上看它是AVaiLable,可用的。不过这个“可用的”是对用户来说的,也就是操作系统可以随意用此位。对硬件来说,它没有专门的用途,就当作是硬件给软件的馈赠吧。
段描述符的第21位为L字段,用来设置是否是64位代码段。L为1代表64位代码段,否则表示32位代码段。这目前属于保留位,在我们32位CPU下编程将其设置为0即可。
段描述符的第22位是D/B字段,用来指示有效地址(段内偏移地址)及操作数的大小,我们可能有这样的疑问,实模式已经是32位了的地址线和操作数了,难道操作数不是32位吗?其实这是为了兼容286的保护模式,286的保护模式下的操作数是16位。既然是指定“操作数”的大小,也就是对指令来说的,与指令相关的内存段是代码段和栈段,所以此字段是D或B。
对于代码段来说,此位是D位,若D位为0,表示指令中有效地址和操作数是16位,指令有效地址用IP寄存器。若D为1,表示指令中的有效地址及操作数是32位,指令有效地址用EIP寄存器。
对于栈段来说,此位是B位,用来指定操作数大小,此操作数涉及到的指针寄存器的选择及栈的地址上线。若B位0,使用的是sp寄存器,也就是栈的起始地址是16位寄存器的最大寻址范围,0xFFFF。若B为1,使用的是esp寄存器,也就是栈的起始地址是32为寄存器的最大寻址范围,0xFFFFFFFF。
段描述符的第23位是G字段,Granularity,粒度,用来指定段界限的单位大小。所以此位是用来配合段界限的,它与段界限一起来决定段的大小。若G为0,表示段界限的单位是1字节,这样段最大是2的20次方X1字节,即1MB。若G为1,表示段界限的单位是4KB,这样段的最大时2的20次方X4KB字节,即4GB。

0x3.2 全局描述符表GDT、局部描述符表LDT及选择子

一个段描述符只用来定义(描述)一个内存段。代码段要占用一个段描述符、数据端和栈段等,多个内存段也要各自占用一个描述符,这些段描述符放在全局段描述符表,就是GDT(Global Desriptor Table)。全局描述符表GDT相当于是描述符数组,数组中每个元素都是8字节的描述符。可以用选择子中提供的下标在GDT中索引描述符。
将该表称为全局描述符表,其全局性体现在多个程序都可以在里面定义自己的段描述符,是公用的,全局描述符表在内存里,需要用专门的寄存器指定它,CPU才知道他在哪里。这个专门的寄存器就是GDTR,即GDT Register。专门用来存储GDT的内存地址及大小。GDTR是个48位的寄存器,如下图。
在这里插入图片描述
对此寄存器的访问不能用mov gdtr,有专门的指令为它进行初始化,这就是lgdt指令。虽然我们是为了进入保护模式才用到该指令对GDTR寄存器初始化,但实际上,此指令在保护模式下也能执行。进入保护模式需要有GDT,但进入保护模式后,还可以重新更换GDT加载。在保护模式下重新更换GDT的原因是实模式下只能访问1MB空间,所以GDT只能位于1MB之间。根据操作系统的真实情况,有可能把GDT放在其他内存位置,所以在进入保护模式后,访问内存空间突破了1MB,可以将GDT放在合适的位置重新加载进来。
lgdt的格式是:lgdt 48位内存数据
这48位内存划分为两部分,其中前16位是GDT以字节为单位的界限值。后32位是GDT的起始地址。由于GDT界限大小是16位2进制,其表示范围是2的16次方等于65536字节。每个描述符大小是8字节,故GDT中最多可容纳的描述符是65536/8=8192个,即GDT中最多容纳8192个段或门。
段描述符与内存之间的关系如下图。
在这里插入图片描述
有了段描述符和段描述符表,接下来该引出段选择子的概念。
段寄存器CS、DS、ES、FS、GS、SS,在实模式下,段中存储的是段基址,即内存段的起始地址。而在保护模式下时,由于段基址已经存入在段描述符中,所以段寄存器中再存放段基址是没有意义的,在段寄存器中存储的是一个叫选择子的东西——selector。选择子“基本上”是个索引值,这里说的是基本上,其中还有其他属性。用此索引值在段描述符表中索引相应的段描述符,这样,便在段描述符中得到了内存段的起始地址和段界限值等相关信息。
在这里插入图片描述
由于段寄存器是16位,所以选择子也是16位,在其低2位即第0 ~ 1位,用来存储RPL,即请求特权级,可以表示0、1、2、3四种特权级。在选择子的第2位是TI位,即Table Indicator,用来表示训责子实在GDT中,还是LDT中索引描述符。TI位0表示在GDT中,TI为1表示在LDT中索引描述符。
由于选择子的索引值部分是13位,即2的13次方是8192,故最多可以索引8192个段,这和GDT中最多定义8192个描述符是吻合的。
选择子的作用主要是确定段描述符,确定描述符的目的,一是为了特权级、界限等安全考虑,最主要还是为了确定段基址。
注意GDT的第0个段描述符是不可用的,原因是定义在GDT中的段描述符是要用选择子来访问的,如果使用的选择子忘记初始化,选择子的值便会是0,这边会访问到第0个段描述符。为了避免出现这种因忘记初始化选择子而选择到第0个描述符,处理器将会发出异常。
局部段描述符表,叫做LDT,Local Descriptor Table,它是CPU厂商为在硬件一级原生支持多任务而创造的表,按照CPU的设想,一个任务对应一个LDT,其实在操作系统中很少用LDT的,作者书中的LDT也没有用到。
CPU厂商建议每个任务的私有内存段都存到自己的段描述符表中,该表就是LDT即每个任务都有自己的LDT,随着任务的切换,也要切换任务的LDT。LDT位于内存中,其他地址需要先被加载到某个寄存器后,CPU才能使用LDT,该寄存器是LDTR,即LDT Register。同样也有专门的指令用于载入LDT,即lldt。以后每切换任务时,都要用lldt指令重新加载任务的私有内存段。
回顾之前的type字段,LDT段类型属于系统段。LDT虽然是个表,但也是一片内存区域,所以也需要描述符在GDT中先注册。段描述符是需要选择子去访问的。
lldt指令格式lldt 16位寄存器/16位内存
无论是寄存器,还是内存,其内容一定是一个选择子,该选择子用来在GDT中索引LDT的段描述符。
在LDT被加载到ldtr寄存器后,之后再访问某个段时,选择子中的TI位若为1,就会用该选择子中的高13位在ldtr寄存器所指向的LDT中去索引相应段描述符。
LDT中的段描述符和GDT中的一样,与GDT中不同的是LDT中的第0个段描述符是可以使用的。因为提交的选择子中的TI位,TI位用于指定是GDT,还是LDT,TI为1则表示在LDT中索引段描述符,即TI为1必然是经过显式初始化的结果,完全排除了忘记初始化的可能。

0x3.2 打开 A20 地址线

为了让“段基址:段内偏移”策略继续可用,CPU采取的做法是将超过1MB的部分自动绕回到0地址,继续从0地址开始映射。相当于把地址对1MB求模。超过1MB多余出来的内存被称为高端内存区HMA。
这种地址绕回的做法需要通过两种情况分别讨论:
对于只有20位地址线的CPU,不需要任何操作便能自动实现地址绕回。
地址(Address)线从0开始编号,在8086/8088中,只有20位地址线,即A0~A19。20位地址线表示的内存是2的20次方,最大为1MB,即0x0 ~ 0xFFFFF。若内存超过1MB,是需要第21根地址线支持的。所以,若地址位进到1MB以上,如0x100000,由于没有第21根地址线,相当于丢掉了进位1,变成了0x00000。如下图
在这里插入图片描述
当其他有更多地址总线的时候,因为CPU可以访问更多的内存,所以不会产生地址回滚。这种情况下的解决方案就是对第21根地址线进行操作。
如果开启了第21根线A20,则可以正常访问超过1MB的数据。
如果关闭了A20,则物理上就相当于没有21根线往后的所有总线,因为为了迎合CPU,地址线都是串口顺序输入输出。所以A20线断了后面的线也就访问不到了。打开A20Gate的方式很简单,将端口0x92的第1位置置1就可以了,作者给出了以下三个步骤

in al,0x92
or al,0000_0010B
out 0x92,al

0x3.3 保护模式的开关,CR0 寄存器 PE 位

CRx系列寄存器属于控制寄存器一类。控制寄存器是CPU的窗口,既可以用来展示CPU内部的状态,也可以用于控制CPU的运行机制。我们需要用到的是CR0寄存器的第0位,即PE位,Protection Enable,此位用于启动保护模式,是保护模式的开关。打开此位后,CPU才能真正的进入保护模式,这是进入保护模式三步中的最后一步。CR0寄存器如下图。
在这里插入图片描述
作者为了照顾我这种有强迫症的同学还特意列出所有字段含义,真的用心了。
在这里插入图片描述
PE为0表示在实模式下运行,PE为1表示在保护模式下运行。所以,我们的任务是将此位置设置为1。

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

0x3.4 进入保护模式

保护模式是在我们之前写的加载器中进入的也就是loader.bin中进入,除了源程序loader.S要更新,还需要更新2个相关文件。
第一个是mbr.S,由于loader.bin超过了512字节,所以我们要把mbr.S加载loader.bin的读入扇区数目增大。
在这里插入图片描述
除了修改mbr.S的读取扇区数外,还需要修改包含文件boot.inc中的配置信息,修改内容如下

;--------------------- loader 和 kernel---------------------

LOADER_BASE_ADDR equ 0x900
LOADER_START_SECTOR equ 0x2

;--------------------  gdt 描述符属性  ----------------------
DESC_G_4K         equ 1_00000000000000000000000b
DESC_D_32         equ  1_0000000000000000000000b
DESC_L            equ   0_000000000000000000000b		;64位代码标记,此处标记为0便可
DESC_AVL          equ    0_00000000000000000000b
DESC_LIMIT_CODE2  equ     1111_0000000000000000b
DESC_LIMIT_DATA2  equ     DESC_LIMIT_CODE2
DESC_LIMIT_VIDEO2 equ      0000_000000000000000b		;CPU不用此位,暂置为0
DESC_P			  equ         1_000000000000000b
DESC_DPL_0        equ          00_0000000000000b
DESC_DPL_1		  equ          01_0000000000000b
DESC_DPL_2        equ		   10_0000000000000b
DESC_DPL_3        equ          11_0000000000000b
DESC_S_CODE		  equ            1_000000000000b
DESC_S_DATA       equ            DESC_S_CODE
DESC_S_sys        equ            0_000000000000b
DESC_TYPE_CODE    equ             1000_00000000b		;x=1,c=0,r=0,a=0 代码段是可执行的,非依从的,不可读的,已访问位a清0.  
DESC_TYPE_DATA    equ             0010_00000000b		;x=0,e=0,w=1,a=0 数据段是不可执行的,向上扩展的,可写的,已访问位a清0.

DESC_CODE_HIGH4 equ (0x00 << 24) + DESC_G_4K + DESC_D_32 + DESC_L + DESC_AVL + DESC_LIMIT_CODE2 + DESC_P + DESC_DPL_0 + DESC_S_CODE + DESC_TYPE_CODE + 0x00
DESC_DATA_HIGH4 equ (0x00 << 24) + DESC_G_4K + DESC_D_32 + DESC_L + DESC_AVL + DESC_LIMIT_DATA2 + DESC_P + DESC_DPL_0 + DESC_S_DATA + DESC_TYPE_DATA + 0x00
DESC_VIDEO_HIGH4 equ (0x00 << 24) + DESC_G_4K + DESC_D_32 + DESC_L + DESC_AVL + DESC_LIMIT_VIDEO2 + DESC_P + DESC_DPL_0 + DESC_S_DATA + DESC_TYPE_DATA + 0x0b

;--------------   选择子属性  ---------------
RPL0  equ   00b
RPL1  equ   01b
RPL2  equ   10b
RPL3  equ   11b
TI_GDT	 equ   000b
TI_LDT	 equ   100b

修改完相关文件后接下来就是实际代码进入保护模式

%include "boot.inc"
section loader vstart=LOADER_BASE_ADDR
LOADER_STACK_TOP equ LOADER_BASE_ADDR
jmp loader_start

;构建gdt以及内部的描述符
GDT_BASE: dd 0x00000000
		  dd 0x00000000
CODE_DESC: dd 0x0000FFFF
		   dd DESC_DATA_HITH4
DATA_STACK_DESC: dd 0x0000FFFF
				 dd DESC_DATA_HIGH4

VIDEO_DESC: dd 0x80000007;limit=(0xbffff-0xb8000)/4k=0x7
			dd DESC_VIDEO_HIGH4;此时dpl为0
GDT_SIZE equ $-GDT_BASE
GDT_LIMIT equ GDT_SIZE-1
times dq 60		;此处预留60个描述符空位
SELECTOR_CODE equ (0x0001<<3)+TI_GDT+RPL0	;相当于(CODE_DESC-GDT_BASE)/8+TI_GDT+RPL0
SELECTOR_DATA equ (0x0002<<3)+TI_GDT+RPL0
SELECTOR_VIDEO equ (0x0003<<3)+TI_GDT+RPL0

;以下是gdt的指针,前2字节是gdt界限,后4字节是gdt起始地址
gdt_ptr dw GDT_LIMIT
		dd GDT_BASE
loadermsg db '2 loader in real.'
loader_start:
;---------------------------------------------------------
;INT 0x10	功能号:0x13	功能描述符:打印字符串
;---------------------------------------------------------
;输入:
;AH 子功能号=13H
;BH = 页码
;BL = 属性(若AL=00H或01H)
;CX = 字符串长度
;(DH,DL)=坐标(行,列)
;ES:BP=字符串地址
;AL=显示输出方式
;0——字符串中只含显示字符,其显示属性在BL中。显示后,光标位置不变
;1——字符串中只含显示字符,其显示属性在BL中。显示后,光标位置改变
;2——字符串中只含显示字符和显示属性。显示后,光标位置不变。
;3——字符串中只含显示字符和显示属性。显示后,光标位置改变。
;无返回值
mov sp,LOADER_BASS_ADDR
mov bp,loadermsg		;ES:BP=字符串地址
mov cx,17				;CX=字符串长度
mov ax,0x1301			;AH=13,AL=01h
mov bx,0x001f			;页号为0(BH=0)蓝底分红子(BL=1fh)
mov dx,0x1800
int 0x10

;---------------------准备进入保护模式-------------------------
;1 打开A20
;2 加载gdt
;3 将cr0的pe位置1

;-----------------------打开A20--------------------------
in al,0x92
mov or al,0000_0010B
out 0x92,al
;-----------------------加载GDT--------------------------
lgdt [gdt_ptr]

;----------------------cr0 第 0 位置 1-------------------
mov eax,cr0
or eax,0x00000001
mov cr0,eax

jmp dword SELECTOR_CODE:p_mode_start		;刷新流水线

[bits 32]
p_mode_start:
	mov ax,SELECTOR_DATA
	mov ds,ax
	mov es,ax
	mov ss,ax
	mov esp,LOADER_STACK_TOP
	mov ax,SELECTOR_VIDEO
	mov gs,ax
	mov byte [gs:160],'P'
	jmp $

将mbr.S和loader.S重新编译成2进制文件,并且覆盖原来的扇区位置。注意这里loader.bin编译后为615个字节,需要2个扇区大小,写入磁盘时要给count赋值为2,不要忘了。
在这里插入图片描述
我们观察下各个寄存器的值,首先看gdt。我们代码中设置的段描述符的属性都已经成功设置进入了段寄存器中,第0个段选择子用来排错所有位均为0;第2个是代码段,我们采用了平坦模式,将整个4GB空间都设置成代码段,属性可执行非依从;数据段也是平坦模式,属性可读可写。注意,这里栈段和数据段用的是一个段描述符,所以堆栈也是向上拓展的;最后一个是显存段,因为我们要在保护模式下显示信息,但这里没有再采用平坦模式,因为文本显存的空间是0xb8000 ~ 0xbffff。直接通过段描述符将这段范围规定好。
在这里插入图片描述
查看PE位,我们可以看到CR0的PE位被设置为1,此后就是保护模式的天下了
在这里插入图片描述
还记得我们最后进入保护模式前还要刷新下流水线吗?
流水线式CPU为提高效率采取的一种工作方式,CPU当前指令及后面几条指令同时放在流水线中重叠执行。由于在实模式下时,按照16位指令格式来译码,而进入保护模式的loader.bin是既包含16位指令,又有32位指令的,所以CPU按照32位译码16位指令就会出错。解决这个问题的方法就是用无条件跳转指令进行流水线刷新(《x86从实模式到保护模式》中提到过,CPU被设计成,每次执行跳转指令就会清空当前执行机器码的流水线进行重新加载,大家有兴趣可以看下)。
刷新完流水线后就可以按照32位方式对各个寄存器重新初始化了,作者代码中直接使用了之前构造好的段选择子进行初始化。
万事俱备,看下程序执行的效果。
在这里插入图片描述
在这里插入图片描述

0x3.5 使用远跳指令清空流水线,更新段描述符缓冲寄存器

以下指令我们刚刚使用过,是一个标准的远跳指令。

jmp dowrd SELECTOR_CODE:p_mode_start

首先我们要明确,段描述符缓冲寄存器是CPU为了提升访问内存速度要使用的缓冲寄存器,无论实模式还是保护模式都要使用它来提升地址访问速度。不重新引用一个段时,它是不会主动更新的。无论实模式还是保护模式下CPU都以段描述符缓冲寄存器的内容为主。
实模式进入到保护模式时,由于段描述符缓冲寄存器的内容仅仅是实模式下的20位的段基址,很多属性位都是错误的值,这对于保护模式必定会造成错误,所以需要马上更新下段描述符缓冲寄存器,也就是想办法往相应段寄存器中加载段选择子。
其次,流水线中指令译码错误。
在默认情况下,如果未使用bits伪指令来设置运行环境,编译器就将代码按照16位实模式编译。进入保护模式前,SRAM中都是它预测的,将来要执行的16位指令,所以进入32位保护模式下需要使用jmp指令无条件刷新流水,重新加载32位译码出的指令。

0x4 保护模式之内存段的保护

0x4.1 向段寄存器加载段选择子时的保护

当引用一个内存段时,实际上就是往段寄存器中加载个段选择子,为了避免非法引用内存段的情况,在这时候,处理器会在以下方面做出检查。
勾线根据选择子的值验证段描述符是否超越界限。
选择子的高13位时段描述索引值,第0 ~ 1位是RPL,第2位是TI位。首先处理器得保证段选择子是正确的,判断标准是选择子的索引值一定要小于等于段描述符表(GDT或LDT)中段描述符个数。像数组下标一样,不能越界。也就是说,段描述符最后1字节一定要在段描述符表的界限地址之内。每个段描述符的大小是8字节,所以在往段寄存器中加载段选择子时,处理器要求段选择子的索引值要满足以下表达式:

描述符表基地址+选择子中的索引值*8+7<=描述符表基地址+描述符表界限值

段描述符中还有一个type字段,用来表示段的类型,也就是不同的段有不同的作用。在段选择自检查过后,就要检查段的类型了。
检查原则大致如下:
1)只有具备可执行属性的段(代码段)才能加载到CS寄存器里
2)只具备执行属性的段(代码段)不允许被加载到除CS外的段寄存器中
3)只有具备可写属性的段(数据段)才能被加载到SS寄存器中
4)至少具备可读属性的段才能加载到DS、ES、FS、GS寄存器中
在这里插入图片描述
检查完type后,还会再检查描述符中的P位来确认内存段是否存在,如果P位存在,P=1,这时候就可以将选择子载入寄存器了,同时段描述符缓冲寄存器也会更新为选择子对应的段描述符内容,随后处理器将段描述符中的A位置设置为1,表示已经访问过了。如果P位为0,表示该内存段不存在,不存在的原因可能是由于内存不足,操作系统将该段移出内存转储到硬盘,这时候处理器会抛出异常,自动转去执行相应的异常处理程序,异常处理程序将段从硬盘加载到内存后并将P置为1,随后返回。CPU继续执行刚才的操作,判断P位。
上述涉及到的P位,其值由软件(通常是操作系统)来设置,由CPU来检查。A位由CPU来设置。

0x4.2 代码段和数据段的保护

对于代码段和数据段来说,CPU每访问一个地址,都要确认该地址不能超过其所在内存段的范围。
实际段界限值为:

(描述符中段界限+1*(段界限的粒度大小:4KB或者1-1

对于G位为1的4k(0x1000)中以0为起始的最后一字节。所以此充实的意义是以0为起始的段偏移量,即段界限。
实际的段界大小,是段内最后一个可访问的有效地址。由于有了段界的限制,我们给CPU提交的每一个内存地址,无论是指令地址还是数据的地址,CPU都要帮我们检查地址的有效性。首先地址指向的数据是有宽度的,CPU要保证该数据一定要落在段内,不能骑在段边界上。
访问内存时只需要:

EIP中的偏移地址+指令长度-1<=实际段界限大小

如果未小于,则指令未完整的落在段内,CPU会抛出异常。
在这里插入图片描述

0x4.3 栈段保护

虽然段描述符中type的e位用来表示段的拓展方向,但它和别的描述符属性一样,仅仅时用来描述段的性质,即使e等于1向下拓展,依然可以引用不断向上递增的内存地址,即使e等于0向上拓展,也依然可以引用不断向下递减的内存地址。栈顶指针[e]wp的值逐渐降低,这是push指令的作用与描述符是否向下拓展无关,也就是说,数据段就可以用作栈。
CPU对数据段的检查,其中一项就是看地址是否超越界限。如果向上拓展的数据段用作栈,拿CPU将按照上一节提到的的数据段方式检查改段。如果向下拓展的段做栈就会出现以下检测机制
1)对于向上拓展的段,实际段界限是段内可以访问的最后一个字节。
2)对于向下拓展的段,实际段界限是段内不可以访问的第一个字节。

实际段界限+1<=esp-操作数大小<=0xFFFFFFFF
发布了30 篇原创文章 · 获赞 5 · 访问量 1929

猜你喜欢

转载自blog.csdn.net/AlexSmoker/article/details/104124071