极光城

写一个简单的操作系统

写一个简单的操作系统

Writing A Simple OS on i386

This is a simple operating system demo for i386cpu created from scratch.

The original purpose of starting this project is to use it as final homework of ZJU-advanced-OS lessons.

We finished it and think it’s fun.

Full Code

Docs Text

C:\Users\Deathy\AppData\Local\Temp\ksohtml\wps6927.tmp.jpgimage1

image2

基于Bochs的分时多任务调度器

陈思宇,陈璟洲,THACHAN SOPHANYOULY(尤利)

2017-08-20

摘 要

调度器可以看作是一个简单的操作系统,允许以周期性或单次方式来调用任务。从底层的角度看,调度器可以看作是一个由许多不同任务共享的定时器中断服务程序,因此,只需要初始化一个定时器,而且改变定时的时候通常只需要改变一个函数。此外,无论需要运行1个,10个还是多个不同的任务,通常都可以使用同一个调度器完成。随着计算机的发展,我们比较容易能够使用高级程序语言来实现调度器。那么使用一个低级程序语言像汇编语言能不能实现一个调度器。因此本次实验我们将要基于Bochs,一个模拟器,在8086的实模式以及80386的保护模式下利用汇编语言实现两个简单任务调度器。

关键词:Bochs,调度器,实模式,保护模式

目 录

第一章 绪论 4

1.1 虚拟计算机Bochs 4

1.2 Bochs的安装 4

1.3 Bochs的使用 5

第二章 实验主要内容 7

2.1 研究思路 7

2.2 研究过程与实现详解 8

2.3 可重入程序调度器 9

2.4 2级位置无关的内核加载器 14

2.5 2级加载的C语言内核 17

第三章 保护模式 26

3.1 描述符Descriptor 26

3.2 选择子Selector 28

3.3 80386寄存器结构 29

3.4 保护模式下的寻址 30

3.5 特级权 31

3.6 2级加载的保护模式下的调度器 33

第四章 总结 37

参考文献 38

第一章 绪论

1.1 虚拟计算机Bochs

即便没有听说过虚拟计算机,你至少应该听说过磁盘映像。如果经历过DOS时代,你可以就曾经用HD-COPY把一张软盘做成一个.IMG文件,或者把一个.IMG文件恢复成一张软盘。简单来讲,它相当于运行在计算机的小计算机。在介绍Bochs及其他工具之前,需要说明一点,这些工具并不是不可或缺的,介绍它们仅仅是为了提供一些可供选择的方法,用以搭建自己的工作环境。但是,这并不代表这一章就不重要,因为得心应手的工具不但可以愉悦身心,并且可以起到让工作事半功倍的功效。下面就从Bochs开始介绍。

image3我们先来看看Bochs是什么样子的,请看图1.1这一个屏幕截图。窗口的标题栏一行”Bochs x86 emulator”明白无误地告诉我们,这仅仅是个”emulator”——模拟器而已。在本文中我们把这种模拟器成为虚拟机,因为这个词使用得更广泛一些。不管是模拟还是虚拟,我们要的就是它,因为有了它我们不再需要频繁地重启计算机,即便程序有严重的问题,也丝毫伤害不到你的计算机。更加方便的是,可以用这个虚拟机来进行操作系统的调式。

图1.1,Linux中的Bochs

1.2 Bochs的安装

就像大部分软件一样,在不同的操作系统里面安装Bochs的过程是不同的,在Window中,最方便的方法就是从Bochs的官方网站获取安装程序来安装(安装时不妨将”DLX Linux Demo”选中,这样你可以参考它的配置文件)。在Linux中,不同的发行版(distribution)处理方法可能不同。比如,如果你用的是Debian GNU/Linux或其近亲(Ubuntu),可以使用这样的命令:

sudo apt-get install vgabios bochs bochs-x bximage

敲入这样一行命令,不一会儿就装好了。

很多Linux发行版都有自己的包管理机制,不如上面这行命令就使用了Debian的包管理命令,不过这样安装虽然省事,但有个缺点不得不说,就是默认安装的Bochs很可能是没有调式功能的,这显然不能满足我们的需要,所以最好的方法还是从源代码安装,源代码同样位于Bochs的官方网站,假设你下载的版本是2.3.5,那么安装过程差不多如下:

    tar vxzf bochs-2.3.5.tar.gz
    cd bochs-2.3.5
    ./configure ---enable-debuger ---enable-disasm
    make
    sudo make install

注意”./configure”之后的参数便是打开调式功能的开关。在安装过程中,如果遇到任何困难,不要惊慌,其官方网站有详细的安装说明。

1.3 Bochs的使用

image4上面有提到软盘,那么软盘究竟是什么?既然计算机都可以”虚拟”,软盘当然也可以。在刚刚装好的Bochs组件中,就有一个工具叫做bximage,它不但可以生成虚拟软盘,还能生成虚拟硬盘,我们也称它们为磁盘映像。创建一个软盘映像的过程如图1.2所示:

图1.2,bximage,创建一个软盘映像

在上面只有一个地方没有使用默认值,就是被问到创建硬盘还是软盘映像的时候,就输入了”fd”。

完成这一步骤之后,当前目录下就多了一个a.img,这便是软盘映像了。所谓映像者可以理解为原始设备的逐字节复制,也就是说,软盘的第M个字节对应映像文件的第M个字节。

现在我们已经有了”计算机”,也有了”软盘”,是时候将引导扇区写进软盘了。可以使用dd命令:

    dd if=boot.bin of=a.img bs=512 count=1 conv=notrunc

注意这里多用了一个参数”conv=notrunc”,如果不用它的话软盘映像文件a.img会被截断(truncated),因为boot.bin比a.img要小。

image5现在一切准备就绪,只差一个Bochs的配置文件。为什么要有配置文件呢?因为我们需要告诉Bochs,希望我们的虚拟机是什么样子的。比如,内存多大、硬盘映像和软盘映像都是哪些文件等内容。图1.3就是一个Linux下的典型配置文件例子。

图1.3,bochsrc示例

可以看到,这个配置文件本来就不长,除去注释之后内容更少了,而且很容易理解,字面上稍微不容易理解的只有romimage和vgaromimage,它们指定的文件对应的其实就是机器的BIOS和VGA BIOS,操作时需要确保它们的路径是正确的,不然过一会儿虚拟机启动时可能会被提示”couldn’t open ROM image file”。除了之外还要注意floppya一项,它指定我们使用哪个文件作为软盘映像。现在一切准备就绪,是时候启动了,输入命令:

    bochs --f bochsrc

一个回车,结果就如图1.1所示。

第二章 实验主要内容

不借助任何外部代码,不借助《30天教你从头写XXX》类书籍,不借助现有OS的工程思路,不借助网络上博客的”讲解”,进行如下工作:

  1. 阅读英特尔CPU产品说明书,掌握CPU工作方式、指令的详尽功能;

  2. 阅读BIOS产品说明书,全面掌握BIOS的功能,掌握使用要点与调用方法;

  3. 阅读GAS软件产品说明书,全面掌握GAS的汇编语法与工程使用;

  4. 阅读NASM软件产品说明书,全面掌握NASM的汇编语法与工程使用;

  5. 阅读各平台ABI说明书,选择性地大致掌握主流二进制文件的结构;

  6. 编写能够实现输出的HELLOWORLD程序。

  7. 编写能对简单可重入客户程序进行调度的调度器。

  8. 编写2级加载的内核加载器。

  9. 编写能实现位置无关运行的2级内核加载器。

  10. 编写2级加载的C语言内核,并实现调度、维护,提供简单的系统调用与服务。

  11. 编写2级加载的保护模式下的调度器。

如有雷同,绝非巧合。因为工程上的雷同大都因为框架的限制与规定。

2.1 研究思路

容易想出来的模式有两种:

1,FIFO模式(管道调度)

2,仲裁模式(事件调度)

我们采用明显更为适合的仲裁模式。

CPU上电基本环境为0x7c00开始运行,cs=0x0000 ip=0x7c00。内存结构如下图所示,共计1MB。A20地址线未打开,如果访存超1MB,则会被环回0地址。

image6

2.2 研究过程与实现详解

image7

实现打印与光标控制功能,直接覆盖到bios信息上面。首先展示我们研究过程中使用的编译方法,如下图所示。

因为该项目对编译的需求比较复杂,总结起来包括:

  1. 调用不同的汇编编译器处理S源代码

  2. 处理并抽象汇编器的一大堆参数,保存多种汇编方案

  3. 读写镜像文件

  4. 打包与保存,简单的版本管理

  5. 处理C语言的编译生成、编译器参数

  6. 处理链接器的参数

  7. 处理ABI格式并进行重构拼接

  8. 保存、显示功能提示笔记和帮助

image8

第一个项目实现时,Makefile功能还很简单,只用了一小部分功能。在此实验之后的研究中,Makefile的功能逐渐被填充增加,以满足研发过程中出现过的乱七八糟的需求。

图2.1,Hello,World的运行结果(红色)

主要代码:

../../Desktop/hello1src.jpgimage9

首先将地址翻译的预处理指针定位到7c00位置,设置代码段与数据段相同,便于索引数据。

VGABIOS将功能共享内存映射到显存上,主板BIOS再将显存映射到主存上,并根据VGA型号在内存中写入控制程序。这里利用的是10h号中断的2h偏移功能包装了一个DisplayString函数。

2.3 可重入程序调度器

这次编写的调度器尝试调度两个简单的打印程序,一个打印A,另一个打印B,两者交替运行。程序运行效果如图2.2所示。

图2.2,调度两个简单的打印程序

image10

image11

程序代码说明:

基于上一份代码主要的更改就是”中断劫持”。我们的思路是通过时钟中断唤醒调度程序。修改中断服务程序的返回地址,使得中断结束后,返回到一个我们指定的位置。

我们要劫持的中断号是1c,该中断是由08h中断触发的软中断,08h是由8259级联片触发的硬件中断,8259从晶体振荡钟获得时钟触发的电气脉冲。

鉴于每个中断向量占4字节,其中低2子节为IP,高2子节为CS,我们将1ch乘4获得要修改的内存首地址,将该处内容改写为我们的调度器地址即可完成劫持。

image12 图2.3展示的是两个被调度的客户程序。

图2.3,两个被调度的客户程序

之后,我们实现了调度器的逻辑,最初有两种考虑,分别为2状态调度与4状态调度,两种状态调度模式如下:

  1. 状态1:应该运行main1

  2. 状态2:应该运行main2

  3. 永远在1,2之间切换跳转。

四种状态调度模式如下:

  1. 状态1:应该启动main1

  2. 状态2:main1被中断,启动main2

  3. 状态3:main2被中断,还原main1

  4. 状态4:main1被中断,还原main2

  5. 稳定在3,4之间切换跳转。

image13我们最终选择了四种状态的调度逻辑,代码如下。

当中断触发,进入调度器后,首先保护父过程的栈底地址ebp,将上一次的状态从内存读取到ax中。之后将ax与0-3比较,实现C语言中switch的功能。由于INTEL芯片中断的行为如下:

  1. 查看int偏移值是否合法
  2. 检查栈够6byte
  3. push(eflags[0:15]); 2bytes
  4. 关中断,清trap,清AC
  5. push(CS) 2bytes
  6. push(IP) 2bytes

故在状态的处理中,会将上次程序的flag、IP从栈底后16byte处读取出来,因为最初main被中断时,其地址保存在0x08中断的ebp+0 到 ebp+5的位置上,换算到第二次调用,就是6*2+size(ebp)=16。根据此地址,可以从此处取出上一次的状态,并写入想要的状态。

比较完成后,将新的状态号写入ax,跳转到结束保存位置”savestate”,该位置的代码会将ax写回内存,退出中断,返回到指定位置。

image14

同时,我们在这一阶段也实现了对CRT硬件的基本控制。首先是屏幕清除函数,代码如下。

它是用屏幕翻滚中断来实现的,当翻滚为0时,则清屏,光标位置不变。这里我们发现用双头方式定义默认参数构造函数非常方便,算是汇编编程领悟到的一个小trick。这个Scroll功能行为其实比较不完善,因为其会破坏eax与ebp、ebx寄存器,所以必须要预先保存。之后我们从内存位置取出行列值,发送命令让int10-06写入显存。

image15

每次写入显存后,我们都要更新光标的位置,让它指向下一个字符块,如果到达行尾,则光标应该向下一行,然后回车;如果整个屏幕都已写满,则应该向上滚动屏幕,然后写在CRT最后一行,这样才能实现平时我们所熟悉的字符界面。函数cursor_deal如下。

我们的CRT是标准设备,大小为80x25。初始光标在(0,0)位置,每次写完成后,我们都要计算光标下一个可写的坐标,并保存该坐标到内存。必要时,会滚动屏幕。

下面的函数实现了打印单个字符到显示器的功能,通过修改参数,也可以实现打印多彩色字符。

image16

这里我们也使用了双头函数来包装默认功能与详细功能的不同入口。之后我们编写了光标设置函数。该函数接收行列参数来放置光标。代码如下所示。

image17

在实验的过程中,我们发现这个简单的调度器当陷入到我们自己编写的”系统调用”函数中时,如果被中断切出,则会因为已经使用的栈空间而造成内存泄漏。所以,我们在此确定了一个非抢断的实现方案,使得陷入API后进程不得被抢断。打印的服务函数如下。

image18

该函数在进入时关中断,离开时开中断,避免被调度而泄漏内存。

2.4 2级位置无关的内核加载器

因为BIOS的规定与限制,首次读盘只能加载0柱面0磁头1扇区512byte的内容,这对于一个稍微复杂一点的启动程序来说已是远远不够的,所以实现从其他存储介质中读取代码复制到内存中运行是不可避免的。因此,我们计划将bootloader写入软驱的第一个扇区,再编写一个小程序放到硬盘中等待被bootloader加载。

我们曾想尝试直接从硬盘中读取第一扇区作为bootloader,读取之后的十个扇区作为mini-kernel,但是我们经历数次挫折后发现,由于bochs已知的BUG,从HD中读首扇区后再次读取时常会发生错误,而软盘则不会;另外考虑到我们应该实践练习一下对多种存储设备进行读写,所以最终选择了这种复杂的组合。

这次编写的代码中,bootloader的主干逻辑如下所示。

image19

首先清空屏幕,读取mini-kernel到0x7e00位置,然后跳转到新加载的代码处运行。

一般来说,编译代码时必须制定一个代码起始的默认地址,这个数值是必须指定的,用来给所有的label、call、jmp或内存访问编址。对于bootsection,我们根据BIOS说明书已知其会被复制到0x7c00位置,所以我们可以显式用org指定。但是对于客户程序或者可能被升级的自写代码来说,编写者并不知道自己的代码可能被bootloader/kernel加载到什么位置上去,所以操作系统必须支持把任意编写的代码正确运行的功能,也就是PIC/PIE(Position Independent Code/ Position Independent Executable)。

我们根据阅读INTEL说明书地址翻译部分学习的知识,认为段地址+偏移值的机制很好的支持了这种特性,因为地址的翻译是基于eip计算的逻辑地址,也称偏移值。我们的设想是将代码加载到16B对齐的位置,改变CS值,使得地址翻译正确进行。这样,客户的程序只需要从0编址就可以了。

这里的机制比较复杂,有一些误区,比如PIE机制可能被人误解为程序中不出现绝对的内存寻址,比如gcc的编译选项fpie。实际上,任何程序都不能避免使用绝对值来表示地址,例如C语言的函数指针,必须是关于CS的偏移值。如果CS不改变的话,翻译一定会出错。所以即使在gcc中选了fpie,也无法保证这代码就是理论上地址无关的。地址无关代码的支持是编译器与内核加载器紧密联系才能实现的特性。我们没有阅读gcc或linux的工程代码,不过我们猜想他们用的PIE机制应该和我们探索的极为相似。

接下来我们将主体框架拆分讲解,首先是读取硬盘的代码。代码如下所示。

image20

image21

之后,我们把打印相关的代码统统移到mini-kernel里,这使得bootloader的大小减为原先的二分之一。Mini-kernel的主干代码如下所示。

该部分代码是依照段首0x00为准编译的,具有通用性,因为编译器生成的obj都是从0编址。代码在逻辑上首先关中断,然后不管之前的cs/ds如何。统一将代码段数据段扩展数据段都设置为代码段。然后调用mini-kernel里的清屏函数,如果位置无关工作正常的话,屏幕上的BIOS信息将被清除。紧接着调用光标设置与字符打印函数打印一个’C’字母,如果工作正常,就会在屏幕第一个字符块位置打印出该字符。该程序结果如图2.4。

image22图2.4,调用光标设置与字符打印函数打印一个’C’字母

可见我们设计的PIE机制工作正确。

2.5 2级加载的C语言内核

在这里我们的设计是让bootloader尽量小,装载完毕后直接跳转。而把中断处理、进程调度、系统服务等代码全都放进mini-kernel里面用C实现。这里的关键点有三个,分别是符号位置、代码格式、opcode格式 。

image23

符号位置指的是我们如何定位终端服务函数,这样才能在bootloader中劫持中断。因为在之后的工作中我们会需要将内核改为保护模式下执行,所以必须在bootloader中设置中断。这时我们就需要详细知晓交叉编译常常遇到的ABI格式转换问题也就是代码格式,同时,原ABI中有很多没有用的结构,比如图2.5所示的ELF格式中,头部有巨大的标志结构,数据稀疏度达到了98%。而bin格式则无稀疏数据。

image26

像上图2.6这样的结构我们也应该按照ABI来剪裁,本次研究中使用的机器是darwin Mach-O 格式,该格式与linux系统采用的ELF(Executable and Linkable Format)格式类似,如图2.7所示。

image30

通过阅读苹果的ABI说明书,我们编写了工具解析该格式,以达到定位首函数、定位中端服务函数并裁减二进制文件的目的。最后我们遇到了opcode问题,为了代码的清晰、结构的明确,我们需要链接多个文件,bin格式虽然紧凑但无法链接,所以不能使用bin格式,而在使用其他可链接格式的过程中,gcc与unix-cc编译C语言代码时虽然指定了相应的参数,生成了i386代码,但是却无法在机器上正确运行。我们通过反汇编发现,指令中立即数部分被延长了,如图2.8所示。

图2.8,反汇编的结果

因为intel指令集是变长指令,上述地址的翻译错误导致了后续代码空间顺序紊乱。我们阅读gcc与cc的说明书后,根据其声称的编写boot代码时所需设置再次进行试验,发现没有任何效果,代码依然不是i386-generic,而是i386-long模式。经过一系列的失败和探索,我们发现只能将编译过程手动分解为前端、汇编、链接、后处理,四步操作,并在其中采用大量的参数、配合自写脚本,才能使得cc\gas\ld输出正确的结果。我们最终探索得到的步骤可在Makefile中体现。简而言之抽取其中四条命令和部分参数,其步骤可大致理解为:

  1. nasm –f bin
  2. cc -m16 $(csrc) -S -O0 -fPIC –ffreestanding
  3. Asm(“.code16 \n\t”);
  4. as -arch i386 $(target).s -o $(target).o -O0

image31编译的过程输出如图2.9所示。

图2.9,编译过程

image32相比之前几次探究,这次bootloader的主要改变是放弃劫持1ch,转而劫持其父硬件中断08h,绑定到mini-kernel中的int08函数上。代码如下。

image33另一处不同,是对堆栈的处理,之前我们一直让堆栈处于BIOS执行完的默认状态,但是我们发现,如果在远跳转后依然保留堆栈为0000:ffff尺度的话,会因为字符串类操作与c语言传参操作占堆栈大而溢出到0x7e00以上的代码空间,所以我们在这里重新设置了对战的大小,代码如下。

image34下面将介绍说明C语言实现的mini-kernel。首先是文件头,如下所示。

这里定义了C语言一些默认的符号,定义了进程池的大小,TTY尺寸,与进程的状态标志。之后我们定义了进程描述符,结构如下所示。

image35

进程描述符内主要存储了寄存器信息、id、进程状态、进程主函数与堆栈保留空间。Bar变量的作用是检测与防止堆栈读写问题导致溢出,使得系统能对该异常做出反应。

image36主要的示例过程与工具函数如下所示。

image37功能分别为:中断伺服、系统idle零号进程、客户进程、清屏调用、光标调用、字符串求长、非安全栈显示字符、安全显示字符、打印字符串、自旋睡眠、获得即时esp、开始调度API、加入任务、任务切换、非栈安全设置光标。除此外还有许多辅助函数,细节繁杂就不一一解释了,只挑出其中比较重要的任务切换相关代码说明。

主函数设置好段,清理堆栈,然后加入两个进程开始调度。如以上的代码。

image38image39以上代码是中断处理函数,首先将当前的通用寄存器都压栈,再将堆栈指针都压栈,然后压入cs、ip后调用保存上下文函数,上下文保存完毕后,清理堆栈保证无任何泄漏,再进入进程切换。下面首先展示上下文保护函数,如下。

该函数依据gcc说明书所描述的C语言标准传参过程,从右到左依次弹出,获得参数,保存到任务描述符中,并调用MOVSB指令保存当前任务从栈底到栈顶的所有信息到任务描述符中。

image40该函数执行完毕后,int08将会调用switch_proc函数。该函数上半部分形式如下面的代码。

首先switch_proc会选择一个程序来调度,在此处可以实现任意的调度算法或逻辑,本次实验中采取了便于解释2个进程关系的处理方式,即:只有idle进程时启动另外一个ready进程,idle进入paused状态,当两个进程都已启动时,进行公平时间片调度,被切换掉的进程设置为paused,新运行的进程设置为running。

紧接着是通用的处理过程,主要关注如何恢复现场。首先检测被还原的进程所占堆栈式否超过了128Btye上限,如果超出了就截断,否则继续。这是为了防止客户在编写程序时出现错误而无限制地毁坏堆栈,伤及代码;另一方面也是为了限制管理开销。同时,我们也可以看到,为了防止调度嵌套,我们只在控制转移之前向20h端口发送”看门狗”喂狗信号,允许再次产生中断,这个做法在后面一半代码中也有体现。

image41接下来展示的是switch_proc的后半部分代码。

这部分代码的作用是将保存在任务描述符中的寄存器、堆栈指针恢复到对应硬件,再将堆栈写回内存,最后恢复标志位寄存器、恢复cs、ip。

因为我们要恢复所有寄存器,而总是需要额外寄存器作为中转器,这就像玩华容道一样比较难搞,所以我们将要恢复的内容依次写到堆栈里,然后逐个弹出。注意,这个堆栈的位置不是随意指定的,否则客户程序栈内存恢复的过程中会破坏我们的寄存器临时栈,所以我们将这个位置显式地写在旧esp的后面。旧的esp存在esi中。

首先,我们利用嵌入式汇编从任务描述符中恢复ip、flags、edi、esi,然后恢复eax/ebx/ecx/edx,然后取出ebp。此时寄存器都已经在esp栈顶存放了,我们再依次将数据复制到旧的程序栈中。接着,将各个寄存器的值通过pop重新赋给寄存器,完成恢复。

在喂”看门狗”开中断后,我们将返回ip、cs、flag写到栈顶,利用正常的retl跳转到原程序运行的位置,至此完成调度。这种完全保存栈行为的切换方式能够调度任何程序,包括线性时无关或任意时相关程序(也可理解为可重入、不可重入程序)。此处有趣的一点是,int08函数进入switch_proc函数后是不会返回的,int08函数也不会返回,因为我们模拟了正常时间中断的行为后,伪造了一个正常函数调用的现场利用retl跳转到其他位置去了。

image42Idle程序打印字母”I”,task程序打印字母”O”,实现效果如图2.10所示。

图2.10,实现效果

成功进行了bootloader加载、minikernel加载、调度、保存、切换等一系列事件驱动调度器的功能。

第三章 保护模式

32位数据线和32位地址线,支持存储器分段和分页管理,支持多任务,可以进行快速的任务切换和保护任务环境,在分页的基础上实现了虚拟存储器,包含4个特权级和完善的特权检查机制。

描述符可用于描述多种对象:存储段、任务状态段、调用门、任务门、中断门、陷阱门和LDT;80386将描述符组织成线性表,称为描述符表,每个描述符表本身形成一个特殊的内存数据段,最多可以包含8192个描述符,该段由操作系统维护、由处理器中的存储管理单元硬件MMU访问;整个系统中全局描述符GDT和中断描述符表IDT只有一张,局部描述符表LDT可以有若干张,每个任务一张。

GDT:含有操作系统所使用的代码段、数据段和堆栈段的描述符,在任务切换时,并不切换GDT,通过GDT可以使各任务都需要的段能够被共享;LDT:每个任务的局部描述符表LDT都含有该任务自己的代码段、数据段和数据段的描述符。也包含该任务使用的一些门描述符。随着任务的切换,系统当前的局部描述符LDT也随之切换。

3.1 描述符Descriptor

image43

P存在位:1代表描述符所描述的段在内存中;0该描述符进行内存访问会引起异常。

DPL描述符特权级,2位,规定了所描述的段特权级,用于特权检查,决定该段可否访问。

DT描述符类型:1代表存储段描述符,0代表系统段描述符和门描述符。

TYPE:说明存储段描述符所描述的存储段的具体属性:

image44 

image45

image46

G段界限粒度:针对段界限有效,段基址总是以字节为单位(无效);0代表粒度为字节,1代表粒度为4K字节。

D位可用于描述可执行段、向下扩展数据段、或由SS寄存器寻址的段(通常是堆栈段)。在描述可执行段的描述符中,D位决定了指令使用的地址及操作数所默认的大小。

  1. D=1表示默认情况下指令使用32位地址及32位或8位操作数,这样的代码段也称为32位代码段;
  2. D=0 表示默认情况下,使用16位地址及16位或8位操作数,这样的代码段也称为16位代码段,它与80286兼容。可以使用地址大小前缀和操作数大小前缀分别改变默认的地址或操作数的大小。 

在向下扩展数据段的描述符中,D位决定段的上部边界。

  1. D=1表示段的上部界限为4G;
  2. D=0表示段的上部界限为64K,这是为了与80286兼容。

在描述由SS寄存器寻址的段描述符中,D位决定隐式的堆栈访问指令(如PUSH和POP指令)使用何种堆栈指针寄存器。

  1. D=1表示使用32位堆栈指针寄存器ESP;
  2. D=0表示使用16位堆栈指针寄存器SP,这与80286兼容。 

AVL软件可利用位:Undefined

门描述符image47

Dword Count:从调用者堆栈中将参数复制到被调用者堆栈中,参数个数由调用门中该项指定,为0代表不会复制参数包括中断门,调用门,陷阱门,任务们。

3.2 选择子Selector

image48

3.3 80386寄存器结构

4个32位通用寄存器:EAX,EBX,ECX,EDX;4个32位地址寄存器ESP,EBP,EDI,ESI;32位指令指针寄存器EIP;32位标志寄存器EFALGS;6个16位段寄存器CS,DS,ES,SS,FS,GS;4个32位控制寄存器CR0,CR1,CR2,CR3;4个系统地址寄存器:GDTR(48),IDTR(48),LDTR(16),TR(16)。

image49
GDT和IDT的基地址分别存放在GDTR和IDTR中,而各个LDT的基地址存放在GDT的LDT描述符中,LDTR是当前所使用的LDT描述符的选择子;GDTR和IDTR长48位,高32位为基地址,低16位为界限;任务状态段寄存器TR包含指示描述当前任务的任务状态段的描述符的选择子,从而规定了当前任务的状态段。

3.4 保护模式下的寻址

段选择子装入CS,CS描述符高速缓存寄存器装入代码段描述符,包括首地址、界限、属性(选择子装入段寄存器选择器,在GDT中选择对应段描述符,将该描述符装入对应段寄存器的描述符高速缓存寄存器)。

image50

指令执行过程

  1. CS描述符高速缓存寄存器中基地址+EIP(偏移),形成32位地址,到此地址取得命令。
  2. EIP=EIP+所指指令的长度。
  3. 执行指令(个别指令执行的时候修改CS描述符高速缓存寄存器,EIP),跳到①继续执行。

3.5 特级权

image52

CPL:当前执行的程序或任务的特权级,存储在CS和SS的第0位和第一位;通常情况下,CPL等于代码所在的段的特权级,当程序进行转移到不同特权级的代码段时,处理器改变CPL(当处理器访问一个与CPL特权级不同的一致代码段时,CPL不变)。

DPL:段或者门特权级。数据段的DPL规定了可以访问此段的最低特权级;非一致代码段(不使用调用门)的DPL规定访问此段的特权级,CPL必须等于目标访问段的DPL,RPL小于DPL;调用门的DPL规定了当前执行的程序或任务可以访问此调用门的最低特权级;一致代码段和通过调用门访问的非一致代码段DPL规定了访问此段的最高的特权级;TSS的DPL规定了可以访问此TSS的最低特权级。

RPL:段选择子的第0位和第1位。处理器通过检查RPL和CPL,同DPL进行比较,来确认一个访问段或门的请求是否合法,其中RPL较CPL起决定作用

Jmp指令直接跳转到目的地址,不影响堆栈;Call指令分长调用和短调用,会影响堆栈。要注意这两种指令长跳转和短跳转时对段寄存器和EIP(偏移量)的影响。

call调用门也是一个长调用,但是涉及堆栈切换时,堆栈已经发生变化,intel将堆栈A的诸多内容复制到堆栈B(参数、调用者SS、ESP、CS、EIP),当需要获取不同堆栈的SS和ESP时,使用TSS的数据结构。

image53
如果有出错码的话,出错码在最后压栈;从中断返回必须使用iretd,返回时同时改变eflags。

3.6 2级加载的保护模式下的调度器

pm.inc是宏定义文件,用于在boot.s中定义描述符和门描述符,因此在boot.s中直接使用宏定义对要使用的描述符和门描述符进行定义。Boot.s汇编语言程序文件,是在保护模式下实现的两个进程间的调度。这两个进程是用fen别在屏幕上打印A字符和B字符,进程切换的时候,屏幕上打印不同的字符。

pm.inc代码如下:

    ;----------------------------------------------------------------------------
    ; 描述符类型值说明
    ; 其中:
    ; DA_ : Descriptor Attribute
    ; D : 数据段
    ; C : 代码段
    ; S : 系统段
    ; R : 只读
    ; RW : 读写
    ;\-\-\-\-\-\-\-\-\-\-\-\-\-\-\-\-\-\-\-\-\-\-\-\-\-\-\-\-\-\-\-\-\-\-\-\-\-\-\-\-\-\-\-\-\-\-\-\-\-\-\-\-\-\-\-\-\-\-\-\-\-\-\-\-\-\-\-\-\-\-\-\-\-\-\--
    ; A : 已访问
    ; 其它 : 可按照字面意思理解
    ; G D 0 AVL 0 0 0 0 P DPL(2) DT TYPE(4)
    ;----------------------------------------------------------------------------
    DA_32 EQU 4000h ; 32 位段 0100 0000 0000 0000
    DA_DPL0 EQU 00h ; DPL = 0 0000 0000
    DA_DPL1 EQU 20h ; DPL = 1 0010 0000
    DA_DPL2 EQU 40h ; DPL = 2 0100 0000
    DA_DPL3 EQU 60h ; DPL = 3 0110 0000
    ;----------------------------------------------------------------------------
    ; 存储段描述符类型值说明
    ;----------------------------------------------------------------------------
    DA_DR EQU 90h ; 存在的只读数据段类型值 1001 0000
    DA_DRW EQU 92h ; 存在的可读写数据段属性值 1001 0010
    DA_DRWA EQU 93h ; 存在的已访问可读写数据段类型值 1001 0011
    DA_C EQU 98h ; 存在的只执行代码段属性值 1001 1000
    DA_CR EQU 9Ah ; 存在的可执行可读代码段属性值 1001 1010
    DA_CCO EQU 9Ch ; 存在的只执行一致代码段属性值 1001 1100
    DA_CCOR EQU 9Eh ; 存在的可执行可读一致代码段属性值 1001 1110
    ;----------------------------------------------------------------------------
    ; 系统段描述符类型值说明
    ;----------------------------------------------------------------------------
    DA_LDT EQU 82h ; 局部描述符表段类型值 1000 0010
    DA_TaskGate EQU 85h ; 任务门类型值 1000 0101
    DA_386TSS EQU 89h ; 可用 386 任务状态段类型值 1000 1001
    DA_386CGate EQU 8Ch ; 386 调用门类型值 1000 1100
    DA_386IGate EQU 8Eh ; 386 中断门类型值 1000 1110
    DA_386TGate EQU 8Fh ; 386 陷阱门类型值 1000 1111
    ;----------------------------------------------------------------------------
    ;----------------------------------------------------------------------------
    ; 选择子类型值说明
    ; 其中:
    ; SA_ : Selector Attribute
    SA_RPL0 EQU 0 ;  00
    SA_RPL1 EQU 1 ;  RPL 01
    SA_RPL2 EQU 2 ;  10
    SA_RPL3 EQU 3 ;  11
    SA_TIG EQU 0 ; TI 0000
    SA_TIL EQU 4 ;  0100
    ;----------------------------------------------------------------------------
    ;  ------------------------------------------------------------------------------------------------------
    ;
    ; 描述符
    ; usage: Descriptor Base, Limit, Attr
    ; Base: dd

    ; Limit: dd (low 20 bits available)低二十位可用
    ; Attr: dw (lower 4 bits of higher byte are always 0)高字节的低四位始终为0
    %macro Descriptor 3 ;段界限为低地址 1代表Base 2代表Limit 3代表属性
    dw %2 & 0FFFFh ; 段界限 1 (2 字节)
    dw %1 & 0FFFFh ; 段首地址 1 (2 字节)
    db (%1 >> 16) & 0FFh ; 段首地址 2 (1 字节)
    dw ((%2 >> 8) & 0F00h) | (%3 & 0F0FFh) ; 属性 1 + 段界限 2 + 属性 2 ;(2 字节)
    db (%1 >> 24) & 0FFh ; 段首地址 3 (1 字节)
    %endmacro ;  8 字节
    ;
    ; 
    ; usage: Gate Selector, Offset, DCount, Attr
    ; Selector: dw
    ; Offset: dd
    ; DCount: db
    ; Attr: db
    %macro Gate 4 ;1代表Selector 2代表Offset 3代表DCount 4代表Attr
    dw (%2 & 0FFFFh) ; 偏移 1 (2 字节)
    dw %1 ; 选择子 (2 字节)
    dw (%3 & 1Fh) | ((%4 << 8) & 0FF00h) ; 属性 (2 字节)
    dw ((%2 >> 16) & 0FFFFh) ; 偏移 2 (2 字节)
    %endmacro ;  8 字节

在boot.s中的GDT和IDT代码如下 image54

boot.s进入保护模式的实现代码: image55

结果显示如图2.2。

第四章 总结

本次实验已经成功地,基于Bochs在两个不同模式下分别是8086的实模式和80386的保护模式下,实现了两个简单任务的调度器。在实现过程中有对比较底层的内核结构和使用进行详细的描述。因为本次实验对编译的需求比较复杂,所以我们除了实现汇编的编写还有编写了C语言,两个语言结合地使用就是为了读者更加容易理解。

参考文献

[X86 WIKIBOOK]https://en.wikibooks.org/wiki/X86_Assembly

[IntelAsm]http://www.logix.cz/michal/doc/i386

[NASM备忘lmu]http://cs.lmu.edu/~ray/notes/nasmtutorial/ 

[Nasm Manuall]http://www.nasm.us/doc/nasmdoc0.html

[NASM 数据定义]http://www.nasm.us/doc/nasmdoc3.html

[NASM]https://www.tutorialspoint.com/assembly_programming/assembly_system_calls.htm

[GAS]http://csiflabs.cs.ucdavis.edu/~ssdavis/50/att-syntax.htm

[Gas Doc]https://sourceware.org/binutils/docs-2.16/as/index.html

[macOS]http://orangejuiceliberationfront.com/intel-assembler-on-mac-os-x/

[INT table]https://en.wikibooks.org/wiki/X86_Assembly/Advanced_Interrupts

[Inline Assembly]http://www.ibm.com/developerworks/library/l-ia/index.html

[InlineAssemble][http://www.ibiblio.org/gferg/ldp/GCC-Inline-Assembly-HOWTO.html]

Optimization]http://www.agner.org/optimize/

[Apple ABI Function Call Guide] [link]

PDF