一个简单的32位多任务操作系统的实现


原创:monkeyfu  2003年3月21日  
适用对象:具有一定的C语言,汇编语言基础,对计算机系统结构有基本了解的操作系统爱好者


系统的启动过程:任何一台计算机,在开机后,它要做的第一件事情就是引导(Booting),通过引导,计算机为自身搭建好运行环境,为以后OS的启动与运行做好准备。首先,我们来看看一台计算机是如何引导自身的。在机器加电后,电源供电稳定后,电源会传给8284A时钟生成器一个“Power Good”低电位信号,随后8284A会输出有效的RESET信号,使CPU复位,这时CS:IP = FFFF:0000。CPU在这里执行一条jmp far addr类指令,跳转到实际BIOS映射代码的位置,开始执行BIOS代码。


上述是机器在加电后的启动过程,大家都知道计算机的启动是分为冷启动与热启动的,那么对于热启动,其过程又是怎样的呢?其实热启动只不过是将键盘中断程序置复位标志为1234h,然后再跳转到BIOS处执行,其主要是省去了在自检过程中对存储器的检测。


在跳转到BIOS后,首先会先关闭中断,然后开始自检(POST)工作,这个自检主要检测计算机最基本设备的运转状态。其主要包括对,CPU内部寄存器测试,BIOS芯片字节的检查,8237 DMA控制器测试,基本32K RAM检测等最基本内容。由于被检测设备在系统运行中的重要性,因此在此过程中,BIOS一旦检测到任何异常,都将判为致命性错误,系统将被停机。

通过上面的自检后,BIOS开始初始化8259可编程中断控制器,并设置BIOS的8个主要中断向量(int 10h—int 17h),然后初始化并测试CRT视频接口以及显示内存(对于热启动这一步将跳过),在确认正常后,执行其内部的显示卡标准驱动程序(注意这里的驱动跟安装操作系统下的驱动是不一样的),这段代码会存放在C0000h,其主要目的是初始化显示卡。然后BIOS会打印显卡信息。


接着 BIOS开始检查其他设备,其包括对8259中断控制器测试,8253定时器测试,键盘复位和卡键测试,扩展I/O测试,设置硬件中断向量,扩展RAM测试(这里的RAM测试会检测除0—32K以外的整个RAM空间,对于热启动同样也会跳过这一阶段),.然后BIOS会搜索其他设备的ROM,如果找到,则会执行它们。接着测试ROM-BASIC的字节检查,测试磁盘驱动器(如:FDC等),测试打印机端口和RS-232,并设置他们的地址。

然后打开NMI(不可屏蔽中断),最后就是调用Int 19h进行自举。这一阶段的自检如果发生错误,系统会判断其为一般性错误,并显示出相应的提示信息。在此过程中,BIOS会将检测收集到的数据保存在内存低1K--2K的区域,并将BIOS中断向量表,以及BIOS程序运行所需要的stack保存在内存低0K--1K的地方。


下面就是系统自举工作了,系统调用int 19h进行自举,寻找启动设备,如:软驱,硬盘,光驱等等。找到后系统读取启动设备的0号逻辑扇区(如是软盘就读取0面0道1扇区的整个内容),并将读取的内容放到内存地址为0000:7C00的地方。


当然,如果找不到启动设备,BIOS就会调用Int 18h,并给出相应的提示信息,然后进入ROM-BASIC。(有些机器会在等待一段时间后自动进入CMOS。如:很久以前海洋的AMD 386DX/40主板)至此,BIOS的引导程序结束,CPU开始执行0000:7C00处的代码。在这里需要说明一下的是,BIOS的引导程序是与操作系统无关的,但随后CPU开始执行的代码就开始与操作系统存在较大的相关性了,因此对于不同的操作系统,下面这一部分可能会存在着较大的不同。不过,从目的上来讲,它们是相同的,即都是为将要运行OS的内核(Kernel)作准备。


进入这一部分的首要工作就是执行启动设备的引导程序。硬盘与软盘的对于引导程序的存放结构是不同的。硬盘有一个叫做MBR(Master Boot Record)的扇区,系统会首先执行它,以判断那个分区是启动分区,并读入该分区的第一个扇区,并执行。并且在这个扇区中还存放着硬盘分区表(DPT),这个表的地位相当重要,因为它包含了

各个分区的诸如:分区类型,起始位置,结束位置等重要参数。下面我们来详细介绍一下MBR的结构。MBR的结构分为三部分,首先是可执行代码,占446个字节,然后是4个分区表,每个占16个字节,共64个字节,最后是签字AA55H。

下表列出了分区信息的详细内容:

偏移      长度      描述
0          字节      分区状态0:非活动分区,80h活动分区(可引导)
1          字节      分区起始头
2          字         分区起始扇区和起始柱
4          字节      分区类型
5          字节      分区中止头
6          字         分区中止扇区和中止柱
8          双字      分区起始绝对扇区
0Ch      双字       分区扇区数

然后我们开始介绍MBR中的可执行代码部分:

首先,程序会检测MBR的签字是否合法,即判断其最后字是否为AA55h.通过后,将自身移动到内存中的其他地方,以备将来在此装入引导分区的Boot扇区.然后,程序检查四个分区的分区状态,找出活动分区,并将该分区的Boot扇区读入到0000:7C00h处,并检查其签字是否合法,在通过后,程序跳转到0000:7C00h处执行,即将控制权交给活动分区的Boot程序;对于软盘则没有那么复杂,软盘的第一个扇区就是它的Boot区,系统自举时将直接将其读入到0000:7C00h处并执行。

-------------------------------

2.保护模式简述

最早的Intel系列的CPU只存在一种操作模式,即现在所说的实模式(Real Mode,以下简称RM)。在Intel推出80286之后,为了增强CPU的处理能力,同时也为了适应当时的软件开发需求,Intel提出了保护模式 (Protected Mode,以下简称PM),但在80286下的PM由于CPU本身设计的问题,并没有使其发挥出很大的功效。在80386推出之后,Intel完善了 CPU的设计形成了最终的IA-32架构,并提出了另一种模式系统管理模式(System Management Mode)。本章我们的讨论就围绕着这三种模式进行展开,并重点讨论PM.

首先,对这三种模式做一简单概述。

RM:此模式是主机在加电或复位后自动进入的模式,在此模式下其可以执行16位指令,并可以切换到PM或者SMM。

PM:在此模式下,CPU能够支持其自身的32位特性,使自身处于最高性能表现。
这些特性主要包括:
1. 最大可访问4GB内存空间。事实上,在RM下通过一些未公开的特性,也可以达到同样的效果,但其对于代码段 和堆栈空间却是无效的。况且后面的所有特性都是基于PM的,对RM没有效果。
2. 虚拟存储。处于PM下的CPU其内存管理单元(MMU)支持这项特性。前面我已经说到,在PM下CPU最大寻址空 间为4GB,而在实际中,我们并没有如此大的物理内存空间。因此通过MMU,可以将外存设备(如:硬盘)的一部分 空间模拟成物理内存进行使用。
3. 地址映射。即MMU可以在地址使用前对其进行转换,即所谓的映射。
4. 改进的分段机制。本文后面将对此进行重点论述。
5. 内存保护与任务保护。即在PM状态下,引入了权限机制。通过权限控制可以达到保护相关代码和数据的目的。
6. 改进的寻址模式。在RM下,只有常数,BX或BP,SI或DI可以用来形成地址,而在RM下可以通过任意寄存器进行寻 址,并且可以包含一个为2,4或8的比例因子。
7. 多任务支持。在PM下,CPU提供了特殊的机制能够进行快速的上下文切换。
SMM:该模式为操作系统实现特定平台指定的功能提供了一种有效的机制。

值得注意的是,在PM下,CPU允许在受保护的情况下,执行RM程序,这个特性被称为虚拟8086模式(Virtual-8086 Mode),但其本质上却不是真正的RM。
对于三种模式关系的形象解释可以通过下图来描绘:



正如上面所说的,只有在PM下,CPU才能充分发挥其自身的所有特性,而计算机在启动之后,默认的CPU操作模式却是RM。因此摆在我们面前的一个主要问题就是如何在RM与PM之间相互切换。那么如何在RM和PM之间相互切换呢。核心步骤其实很简单,只要改变CPU中的CR0寄存器中PE标志位的值,就可以实现。在PE=1时,CPU进入PM,而在PE = 0时,则进入RM.但这仅仅是整个切换过程中的一小部分,在进入保护模式之前我们还需要做很多事情,其中最关键的就是建立好一个被称为GDT的表。

在谈到GDT之前,我们先回顾一下,在RM中,内存中寻址的方式---段:偏移量。其中段(Segment)表明了一个基地址,其最大长度固定为64KB (FFFFH),即16bit数所能表示的最大数值。而偏移量(Offset),就是指在指定段内的位置。由此可见,通过段+偏移量这种表示方式,就可以表示出内存中的绝对地址。需要指出的是,在CPU实际处理过程中,CPU会将段寄存器的值左移动4位,再与偏移量相加,形成地址,放入20位的总线当中。

在PM中,对于段模式来讲,上面的寻址方式,在大部分上仍然是适用的。但由于PM是工作在32位下的,因此上面的各个值,也就都相应的变成了32位。与RM不同的是,在PM下,一个段的长度不再固定,其可以在CPU允许的规则下任意设置.并且CPU为段模式提供了保护机制,即增加了对自身的访问权限.因此在PM下,对于一个段,需要有三个变量给于描述,即基地址,段界限和访问权限.

事实上,CPU将这三个值保存为一个64位长的段描述符.但出于兼容性的考虑,Intel并没有将段寄存器改为64位可用--虽然,段寄存器在事实上确是64位,但对于程序来讲,高于16位的部分却是不可见的--因此,我们需要另一种方法去存放这些数据.Intel选择了将这些段描述符统统存入到一个全局数组中的方法,在访问段时,向相应的段寄存器填入该数组的下标值来实现间接引用。这个全局数组就称其为GDT(全局描述符表).由于GDT可以存放在内存中的任何位置,因此要引用它,就必须知道他的入口地址.Intel为我们提供了GDTR寄存器和LGDT指令.其中 GDTR寄存器存放的是GDT的入口地址(32位)和其界限(16位),共48位.这里的入口地址是一个线性地址,界限则是表的字节长度减一.可见该表最多可以长达64KB,存储8192条描述符号,而LGDT指令的作用就是将GDT装载到放入GDTR寄存器当中.

顾名思义,GDT是全局描述符,因此其在内存中存在且仅存在一个,并且它的存在对于所有的任务来讲,都是可见的.显然,这种做法对于多任务来讲是不易管理的.因此, Intel又引入了LDT(局部描述符表),该描述符与GDT不同之处在于,LDT在系统中可以有许多个,但每个任务只允许有一个LDT,且其只能该任务自身可见.其与GDT的主要关系在于,每一个LDT都会作为一个段,存入GDT中.由于CPU在任何时刻只能执行一个任务的代码,因此存储LDT所需要的寄存器也就只需要一个,Intel将其命名为LDTR,与GDT相同,Intel为装入LDT设置了LLDT指令。与GDT不同的是,LLDT指令的操作数却是一个16位的段选择子,即前面说到的要装入的LDT在GDT中的索引值。这里需要指出的是,LDT并不是必须的,你的程序可以选择使用,或者不使用它。

前面提到了一个新概念--段选择子。我们说段选择子是要引用段在GDT或LDT中的索引值,其实这种说法并不正确。因为段选择子除了含有索引值以外,它还包含了其他内容。  

段选择子的结构如下图:  



段选择子是一个16位的数据结构,其包含了三部分内容。其中,其高13位正是前面所说的索引值,TI用来指定是在GDT中索引,还是在LDT中索引(0 = GDT, 1 = LDT),RPL则是用来指明请求特权级的。

谈到这里,我们就已经阐明了在PM的段模式下,如何引用一个内存地址。首先,将段选择子装入相应的段寄存器中,然后CPU会自动根据段选择子找到相应的段描述符,并找出基地址,最后在加上偏移量,就得到了所需要的内存地址。

在本文开始的部分,我已经说过GDT是进入 PM所必需的数据结构,下面就详细的来讨论一下如何设置好GDT,并将其装入相应的寄存器.
首先必须注意的两点是:
1.GDT中的第一个描述符必须是空,即全为0。在程序中这个描述符不能用来进行内存访问,否则将产生General Protection异常。
2.由于GDT中的描述符都是64位长,因此为了让CPU的访问速度达到最快,需要将GDT的入口地址以8字节对齐,即放入8的倍数的位置.

下面,开始设置进入PM后的代码段和数据段的描述符.  

其格式如下:  



G – 粒度
D/B – 大小(0 = 16位段; 1 = 32位段)
D – 保留
AVL – 用户定义
P – 段是否存在
DPL – 描述符特权级

注意P位,这个位确定了段是否存在.这是什么意思呢.当该位被清除时,如果存在任务要访问这个段.那么CPU会产生一个错误,并会从外存(如:硬盘)中调入该段并再次尝试.当该位被清除时,描述符中的0到39位和48到63位能够包含任意值.你也可以用这些空间来存储该段在磁盘空间中的地址.  

还有就是A位,CPU会在对其所在段写入数据后,将该位置1,这样在做段的磁盘交换时,可以决定是否将该段写入磁盘.

下面要说的就是G位.你会发现,在描述符中段界限仅仅为20位.那么其如何能够设置成1MB到4GB之间的范围呢.这里G位其了重要的作用.当G位被清零时,界限域就是段的最大合法偏移.而如果G位被置成1,那么会把描述符中的段界限左移12位形成32位界限,再将低12位全部填1.这样,实际上就能够指定1MB以下的任意长度,和以4K到4GB为单位的长度.

假定要在进入PM后,使代码段和数据段能够访问全部线性空间,于是可将GDT设置为:

gdt        dd    00000000h, 00000000h ;空
gdt.Code32 dd    0000ffffh, 00cf9a00h ;代码段 读/执行 4GB空间 基地址 = 0 粒度 = 4096,386
gdt.Data32 dd    0000ffffh, 00cf9200h ;数据段 读/写 4GB空间 基地址 = 0 粒度 = 4096,386  

这里你会发现在GDT中不同的描述符指向了同一块内存空间.这在系统中是允许的.在实际应用中,这也是经常要使用到的,例如:操作系统可以 将一个可执行文件装入数据段,然后再从同一位置开始执行.  

在设置好GDT以后,需要将其装入相应的寄存器中.前面说过GDTR寄存器包含两段内容,因此我们需要先算出GDT的绝对物理地址.
GDTR的具体内容
gdtr dw gdtr - gdt – 1;界限
    dd gdt;前面GDT的地址  

实现代码如下:
mov eax, ds
shl eax, 4
add [gdtr+2], eax ; 生成绝对物理地址
lgdt [gdtr]          ; 将gdtr装入寄存器  

到此,就完成了进入保护模式的最主要工作,可以进入PM模式了.  

实现代码如下:
mov eax, cr0
or al, 1
mov cr0, eax ; 修改CR0寄存器,置PE = 1
jmp dword 8:_premain32 ; 8为选择子  

你可能会问为什么要在代码的最后添加一个jmp语句.这是因为,我们必须要清除CPU的指令预取队列(流水线),并以此来设置CS段寄存器. 不过这仅仅是其一,还有一个重要的原因就是,我前面谈到的那个段寄存器大于16位的不可见部分.需要指出的是,Intel对于这一点是未公开的,因此我下面对该问题的讨论仅仅是由推断得出来的。事实上,当我们执行一条装载CS寄存器的指令时,操作数被装入了寄存器的可见部分,而CPU会自动根据操作数,去设置其不可见部分.CS段寄存器所处的状态与当前在哪个模式下并无关系。 在刚刚进PM后,CS仍然认为当前处于16位段,即当前的地址仍是16位地址。因此,必须通过装载一条32位指令去切换到32位段,这也就是jmp在这里的意义.

虽然程序已经进入了PM,但需要做的事情还远没有做完,因为我们还没有配置IDT,即中断描述符表,而要理解这个表又要牵涉到许多内容,因此,我将在后面的文章中详细介绍,这里就不多谈了。

Intel 之所以将这个模式起名为保护模式,其来源就在于特权保护。在PM下,每一个任务都拥有自己的特权级(PL)。Intel将其分为四个级别,由零到三。数字越低级别则越高。例如:PL3级的程序对于一些特定的指令,如:HLT,LGDT,LLDT等,没有执行的权限,并且其也不允许访问拥有高特权级程序的数据。  

在PM下,I/O的访问同样也受到了特权保护。在EFLAGS中存在一个IOPL域。这个域的值决定了能够执行I/O操作的最低权限。例如,当IOPL为3的时候,表明所有特权级的程序都能够执行I/O操作。这个域的值仅允许PL0级的程序进行修改,其他级的程序修改无效。  

同样,数据访问在PM下也是受到保护的。当数据段寄存器要被加载时,CPU会将该段的描述符特权级(DPL)与一个被称为有效特权级(EPL)的数值进行比较,如果DPL不小于EPL,则允许装载寄存器,否则将会产生一个错误。这里的EPL就是选择子的RPL和程序当前特权级(CPL)的数值较大的那一个。

对于堆栈,则有些不同,访问SS寄存器,其DPL要求必须和CPL相等。

关于保护模式,本篇文章就介绍到的这里。对于保护模式的其他重要特性,如:分页操作,多任务处理等,由于内容很多,几乎每一块内容都能当成一个专题来讲,因此我将在以后的文章中对此进行详细讨论。

从功能上来讲软盘与硬盘的Boot区是相同的,其任务都是将OS的内核(Kernel)读入到内存并执行。但具体来看,由于绝大多数OS的Kernel是以文件形式存放在磁盘上的,要读取它就要涉及到对文件系统的操作,这使得它们在实现上又是很不相同的。因此,对于Boot区的分析我们将放在后面的内容中具体介绍。

原文地址:https://www.cnblogs.com/smwikipedia/p/1364198.html