搬运-操作系统开发:编写开机引导
操作系统开发:编写开机引导
操作系统是用来管理与协调硬件工作的,开发一款操作系统有利于理解底层的运转逻辑,本篇内容主要用来理解操作系统是如何启动的,又是如何加载磁盘中的内核的,该系列文章参考各类底层书籍,通过自己的理解并加以叙述,让内容变得更加简单,一目了然,即可学到知识又能提高自己的表述能力。
该系列文章是在学习《操作系统真相还原》时通过阅读后简化并适当描述整理的学习笔记,首先,致敬作者郑刚博士,在读本书时能深刻的感觉到作者写书时一丝不苟的态度,书很厚写的,讲解细致幽默,很能让人愿意继续读下去,同时也不得不佩服作者计算机底层功力的深厚。
BOIS是如何苏醒的
BIOS 基本输入输出系统,BIOS 代码所做的工作是一成不变的,所以他是被固化到ROM中的一块只读区域中,在开机时此ROM会被映射到低端1MB
内存的顶部,原因是系统在开启时默认是实地址模式(该模式最大寻址范围0-fffff
),所以其寻址范围也就被限制在了0xF0000-x0xFFFFF
区域中,这64KB
的内存就是BIOS的执行代码.
在开机的一瞬间,CPU的CS:IP寄存器会被强制初始化为0xF000:0xFFF0
,在实地址模式下该地址需要乘以16也就是左移四位加上偏移地址得到,于是0xF000:0xFFF0
就等效于0xFFFF0
此处的地址距离0xFFFFF
只有16个字节的空间,里面存放着一条jmp far f000:e05b = fe05b
的汇编指令,该指令将跳转到真正的BIOS开始的位置.
接着BIOS将会通过自身的代码对硬件进行自检测,在初始化硬件后,则开始向内存0x000-0x3ff
中初始化数据结构以及拷贝中断向量表,紧接着BIOS将会通过调用int 19h
中断,此中断用以检测计算机中的硬盘,如果检测到0盘0道1扇区末尾的两个字节是0x55,0xaa
则认为此扇区确实存在,于是就会将此区域中的内容,加载到内存0x7c00
的位置,并通过一条jmp far 0:0x7c00h
的指令跳转到该位置执行,这样BIOS就将CPU控制权交给了MBR了,而BIOS将会再次睡去.
MBR继续执行引导
如上提到过的0x7c00
就是MBR代码的开始位置,之所以是0x7C00
是因为,DOS中要求最小内存是32KB
而MBR大小必须是512
字节,所以选择32kB
中的最后1KB
的位置最为合适(32KB(0x8000)-1KB(0x400)=>0x7c00
),这就是0x7C00
的由来,同时还需要保证第510-511
字节必须为0x55,0xaa
才可以,这就需要在末尾部分自动补齐两字节的填充.
简单的引导MBR的代码如下,首先我们需要先初始化每个段寄存器DS,ES,SS,FS,SP然后通过调用两次int 0x10
中断对命令行进行置空操作,最后通过mov ax,01301h
也就是13号中断,打印出字符串.
我们直接将其保存为mbr.asm
文件,通过Nasm汇编器编译为二进制文件,然后再通过dd
命令写入到一个镜像文件中,具体编译流程如下,这里需要下载好Windows版本的dd
命令.
由于我们使用的模拟器是Bochs x86所以,在制作好镜像后,需要在编写一个虚拟机配置文件,该配置文件命名为mbr.src
其内部需要定义好虚拟机的类型,启动方式,镜像位置等基本参数,一个简易版定义语句如下.
启动时可以直接调用bochsdbg -q -f mbr.src
命令,使用调试模式运行,并通过语句vb sp:0x7c00
在开头下断点,使用c
命令可运行到MBR代码处,单步n
执行,即可输出一段话,标志着MBR已经成功被加载.
让MBR直接驱动显卡
如上代码,我们通过调用BIOS提供的int 0x10
中断来实现打印字符操作,但我们在后期必须要借助显卡来输出图像,而显卡是外部设备,必须通过总线来操作。
由于CPU使用的信号是TTL电平,而外部设备都是机械设备,故他们不会使用该电平驱动,这就导致CPU与硬件设备没有办法实现沟通,硬件工程师们提供的方法是,在这两者之间架起一座桥,也就是在CPU和外设之间加上一层IO接口,该接口的作用就是实现CPU和外设之间相互做协调转换。
其次外部设备的种类也是多种多样的,其输出的信号可能是数字信号,也可能是模拟信号,而我们的CPU只能处理数字信号,数字信号需要经过数模转换器<D/A>
成模拟量才能送到外设来驱动硬件工作,模拟量也同样需要经过模数转换器<A/D>
转换成数字量才能被CPU直接处理,所以接口电路中需要包括A/D
转换器和D/A
转换器。
转换后的数字信号,会经过总线进行传递,总线的别名是BUS,之所以叫做BUS是因为其是公共线路,所有硬件设备都会走此线路,但同一时刻,CPU只能和一个IO接口(寄存器/端口)通信,当有多个IO接口同时想和CPU通信时,那么IO仲裁模块会对其进行竞争与选优,仲裁模块固化到,输入输出控制中心(ICH)也就是南桥芯片上的。
多数情况下,南桥和北桥是成对出现的,南桥主要负责连接PCI,PCI-Express,AGP等低速设备,而北桥则用于链接高速设备,如内存等。
IO接口都是串行口,其在设计之初就是负责与CPU进行通信的,我们想要与CPU通信,其实是向这些接口中写入数据,同时为了区别CPU中的寄存器,所以把IO接口叫做端口,某些外设可以通过内存映射来访问,即把某些端口映射到指定内存中,访问某个内存区域就相当于访问了指定的端口。
由于显卡的起始地址为0xb800
向该地址写入数据即可回显在显示器上,如下代码是一个简单的填充过程。
编译并运行这段代码,由于使用的是显卡输出,所以在输出色彩上,我们的选择余地更多了。
如上代码中需要注意,偶数行gs:0x04
代表的是输出数据,奇数行gs:0x05
则代表颜色背景色,如果需要实现循环输出,那么我们除需要考虑循环条件外,还应把基数偶数行也考虑进来。
让MBR直接驱动硬盘
既然显卡中存在端口可以被操作,那么硬盘也同样存在,硬盘控制器属于IO接口,如果想让硬盘工作,我们需要通过读写硬盘控制器上的端口,此处的端口指的就是硬盘控制器上的寄存器组。
- 硬盘控制器中的端口可被分为两种,最主要的是 Command Block registers 组中的寄存器
- Command Block registers 用于向硬盘驱动器写入命令字或者从硬盘控制器获得硬盘状态
- Control Block registers 用于控制硬盘工作状态
一般硬盘中的一个通道包括两片硬盘,其中0
为主盘,1
为从盘,硬盘控制器中的主要寄存器如下,其中主盘所对应的通道为Primary,后面的那个Secondary则是从盘通道,主从盘调用中断号完全不同:
- DATA 寄存器主要负责管理数据,相当于数据的门,作用是读取或写入数据
- 读硬盘时: 硬盘准备好数据后,硬盘控制器将其放在内部的缓冲区中,不断读此寄存器便是读出缓冲区中的全部数据。
- 写硬盘时: 把数据源源不断地输送到此端口,数据便被存入缓冲区里,硬盘控制器发现这个缓冲区中有数据了,便将此处的数据写入相应的扇区中。
- ERROR/FEATURES 由于环境不同用途不同,所以两个寄存器名字指的是同一个
- 读硬盘时: 端口
0x171
或0x1F1
的寄存器名字叫Error寄存器,若读取失败,里面存储的是失败状态信息,并且0x1F2
端口中存储未读的扇区数。 - 写硬盘时: 就变成feauture寄存器,用于写命令的参数,有些命令需要指定额外参数,这些参数就写在 Feature 寄存器中。
- SectorCount 寄存器用来指定待读取或待写入的扇区数
- 硬盘每完成1个扇区,就会将此寄存器的值减1,如果中间失败了,此寄存器中的值便是尚未完成的扇区。
- LBA 逻辑块地址,解决了磁盘在柱面磁头扇区上寻址的麻烦(CHS),寻址时不用再考虑扇区所在的物理结构,当今LBA有两种,一种是LBA28最大支持
128GB
的寻址,另外一种是LBA48,最大支持128PB
寻址. - LBA寄存器,有三种不同的形式:
LBA low
、LBA mid
、LBA high
LBA low
寄存器用来存储28位地址的第0-7位LBA mid
寄存器用来存储第8-15位LBA high
寄存器存储第16-23位
- Device 寄存器是个杂项,宽度
8
位,此寄存器的低4
位用来存储LBA地址的第24-27
位- 第
4
位用来指定通道上的主盘或从盘 - 第
6
位用来设置是否启用LBA方式 - 第
5
位和第7
位是固定为1
的,称为MBS位
- 第
- Status 状态寄存器,控制端口
0x1F7
或0x177
,它是8
位宽度寄存器,用来给出硬盘的状态信息- 第
0
位是ERR位,为1
表示命令出错,具体原因可见error寄存器。 - 第
3
位是data request位,为1
表示硬盘已经把数据准备好了机现在可以把数据读出来。 - 第
6
位是DRDY,表示硬盘就绪表示硬盘检测正常,可以继续执行一些其他命令。 - 第
7
位是BSY位,表示硬盘是否繁忙,为1
表示硬盘正忙。
- 第
- 注释: 状态位与Error寄存器一样,在写硬盘时寄存器就变成Command,此寄存器用来存储让硬盘执行的命令,只要把命令写进此寄存器,硬盘就开始工作。
由于MBR受制于只能容纳512
字节大小的数据,没法为内核准备好环境,更没法将内核成功加载到内存并运行,此时我们需要让MBR实现从硬盘加载Loader程序到内存,加载完成后再将接力棒交给Loader继续运行,MBR加载磁盘代码如下。
至此虽然输出效果与在MBR中直接操作显卡输出结果一致,但本质是不同的,此处代码中MBR主要负责从硬盘中的第3
个扇区中读入Loader加载器到内存,并将CPU指针指向它,后期的输出纯粹是Loader加载器所为。
实模式切入保护模式
保护模式最早出现在80286
系列处理器中,之所以会出现保护模式是因为实地址模式中存在以下问题:
- 实模式下操作系统与用户程序属于同一特权级
R0
,无法区分系统程序与用户程序。 - 用户程序引用的地址都是指向真实的物理地址,所以逻辑地址就等于物理地址。
- 用户程序可以直接修改段基址,当访问超过
64KB
的内存区域时需要手动切换段基址。 - 共
20
条地址线最大可用1MB
内存,且一次只能运行1
个程序,无法充分利用计算资源。
为了克服内存访问限制,CPU厂商则开发出保护模式,在保护模式下物理地址不能被程序直接访问,在访问时需要将虚拟地址转换为物理地址再去访问,而对于程序而言这一系列操作都是透明的。
这个地址转换过程是由操作系统与处理器共同协作完成的,处理器在硬件上提供地址转换部件,操作系统提供转换过程中所需要的页表。
实模式与保护模式
相对于实地址模式,保护模式对寄存器进行了一定的扩展,CPU扩展为32
位后,其地址总线和数据总线也变为32
位,寻址空间达到了4GB
,为了让一个寄存器可以访问到4GB
空间,需要将寄存器宽度提升到32
位。
除段寄存器外,通用寄存器、指令指针寄存器、标志寄存器都由原来的16
位扩展到了32
位,段寄存器16
位就够用了。
相对于实地址模式,保护模式大大提高了对内存段的保护能力,GDT全局描述符就是对特定内存段属性进行描述的数据结构,该数据结构中的每一个表项称为段描述符,大小为64
字节,用来描述各个内存段的起始地址、大小、权限等信息。
由于全局描述符表GDT很大,所以默认将其放在了内存中,由GDTR寄存器指向它,GDTR是个48
位的寄存器,通常使用lgdtr
指令操作,控制该寄存器。
这样,段寄存器中保存的再也不是段基址了,里面保存的内容叫 段选择子(selector) 该选择子其实就是个数,用这个数来索引全局描述符表中的段描述符,如果把全局描述符表当成数组,那么选择子就是数组的下标。
GDT全局描述符表
全局描述符表GDT是保护模式下内存段的登记表,这是不同于实地址模式下的显著特征。
局部描述符表LDT是CPU厂商为了在硬件层面支持多任务的一个表,当今操作系统不使用。
在实地址模式下,寻址是按照[段基址+段内偏移]
的形式进行,而在保护模式下为了保证兼容性,其也必须遵循这一规范。
在实地址模式下,访问内存时只要将段基址加载到段寄存器中,再结合偏移地址就行,段寄存器太小了,只能存储 16
位的信息,甚至连 20
位地址都要借助左移 4
位来实现。
而进入到保护模式,各个寄存器都提升到了32
位,且还需对特定的内存段增加一些额外的安全属性,那么将这些属性放在内存中是最好的选择。
- 之所以需要增加全局描述符表,并为每个段增加段描述符,是因为实模式下存在以下问题。
- 实模式下的用户程序可以破坏存储代码的内存区域,所以要添加个内存段类型属性来阻止这种行为。
- 实模式下的用户程序和操作系统是同一级别的,所以要添加个特权级属性来区分用户程序和系统。
- 内存段是一片内存区域,访问内存就要提供段基址,所以要有段基址属性。
- 为了限制程序访问内存的范围,还要对段大小进行约束,所以要有段界限属性。
段描述符: 一个段描述符只用来描述一个内存段的属性,这些描述符被依次排列在GDT中,GDT全局描述符表相当于描述符数组,数组中每个元素都是一个描述符,每个描述符大小是8
字节,分为高32
位与低32
位,即两个四字节,GDT中最多可容纳的描述符数量是65536/8=8192
个,即 GDT 中可容纳 8192
个段或门。
如下,段描述符结构示意参考,以及每个字段的大体含义。
- 段界限:第
0-15
位与16-19
位共同构成段界限,表示段的边界,大小,范围,段界限用20
个二进制位来表示。 - 段基址:第
0-7
是段基址的16-23
位,第24-31
位是段基址的高32
位,加上段描述符低32
位中的段基址0-15
位,就构成了32
位的基地址。 - Type字段:第
8-11
位是type字段,共占用4
位,用于表示内存段或调用门的子类型。 - S字段:第
12
位是S字段,用于指示是否为系统段,为0
是系统段,为1
是数据段,通常与Type字段配合使用。 - DPL字段:第
13-14
位是DPL,即描述符特权级,通常是指所代表的内存段的特权级。 - Present字段:第
15
位是P字段,标志着指定段是否存在,如果段存在于内存中,P为1
否则为0
。 - AVaiLable字段:第
20
位是AVL字段,无用途,可随意使用此位。 - L字段:第
21
位是L字段,用于设置是否是64
位代码段,L为1
表示64
位代码段,为0
则为32
位。 - D/B字段:第
22
位是DB字段,用来指示有效地址(段内偏移地址)及操作数的大小。 - Granularity字段:第
23
位是G字段,用来指定段界限的单位大小,若G为0
表示段界限的单位是1
字节,若G为1
表示段界限的单位是4KB
。
段选择子: 保护模式下段寄存器中存储的就是段选择子,选择子是一个索引值,用此索引值在段描述符表中索引相应的段描述符,这样,便可以在段描述符中得到了内存段的起始地址和段界限值等相关信息。
如下,段选择子结构示意参考,以及每个字段的大体含义。
由于段寄存器是16
位,所以选择子也是16
位,每一个选择子都会被分为3
块。
- RPL字段:第
0-1
位,用来存储RPL(请求特权级) 通常为0、1、2、3
四种特权级。 - TI字段:第
2
位,用来指示选择子是在GDT还是LDT中索引描述符,为0
在GDT中,为1
在LDT中。 - 描述符索引:第
3-15
位是描述符的索引值,此值主要用于在GDT中索引符合条件的段描述符。
选择子的作用主要是确定段描述符,确定描述符的目的,一是为了特权级、界限等安全考虑,最主要的还是要确定段的基地址。
由于保护模式下段寄存器中已经默认是选择子了,在寻址时直接用选择子对应的[段描述符中的段基址+段内偏移地址]
就是要访问的内存地址。
A20Gate地址回绕线
地址回绕线是为了兼容8086实模式而增加的,在实模式下地址线只有20
条,寻址空间只能是1MB(0x00000 - 0xFFFFF)
如果超出1MB
的寻址范围,那么在默认开启地址回绕的CPU上,会自动将超出1MB
的部分回绕到0
地址处,继续从0
地址处开始映射,地址回绕如下图。
对于只有20
位地址线的8086系列CPU而言,A20
地址线默认是开启的,不需要任何操作即可实现地址回绕,但80286有 24
条地址线,即A0-A23
,也就是说 A20
地址线是开启的,如果访问0x100000-0x10FFEF
之间的内存,系统将直接访问这块物理内存,并不会像8086那样回绕到0
,反之如果是关闭的,则访问超出0x00000 - 0xfffff
的地址范围后会自动回绕到0
处。
如果A20Gate被打开,当访问到
0x100000-0x10FFEF
之间的地址时,CPU将真正访问这块物理内存。
如果A20Gate被禁止,当访问0x100000-0x10FFEF
之间的地址时,CPU将采用8086的地址回绕。
我们想进入保护模式,就需要突破第20
条地址线(A20
)去访问更大的内存空间。而这一切,只有关闭了地址回绕才能实现,而关闭地址回绕,就需要打开A20Gate,打开A20Gate地址线只需要将端口0x92
的第1
位置1
即可。
CR0控制寄存器
想要进入保护模式还差最后一步,通过控制CR系列寄存器切换CPU模式,CR寄存器是CPU的控制窗口,即可用来查询CPU的内部状态,也可用于直接控制CPU的运行机制,切入保护模式最需要关注的是CR0寄存器中的PE位。
如下图是完整的CR0寄存器,以及重要寄存器解释:
- 保护允许位PE (Protedted Enable):
0
位用于启动保护模式,如果PE置1
则保护模式启动,PE置0
则实模式启用。 - 监控协处理位MP (Moniter coprocessor):
1
位与3
位配合,当TS=1
时操作码WAIT
是否产生一个协处理器不能使用的出错信号。 - 任务转换位TS (Task Switch):
3
位当一个任务转换完成之后,自动将它置1
,随着TS=1
就不能使用协处理器。 - 模拟协处理器位EM (Emulate coprocessor):
2
位如果EM=1
则不能使用协处理器,如果EM=0
则允许使用协处理器。 - 微处理器扩展类型位ET (Processor Extension Type):
4
位保存着处理器扩展类型的信息,如果ET=0
使用287协处理器,ET=1
使用387浮点协处理器。 - 写保护位WP:
16
位这一位置0
就可以禁用写保护,置1
则可恢复写保护。 - 分页允许位PE (Paging Enable):
31
位表示芯片上的分页部件是否允许工作。
正式切入保护模式
在保护模式中,内存段都是平坦模式,也就是整个内存都在一个段内,进入保护模式之前我们需要手动在内存中构建出GDT及其内部的描述符,GDT只是一片内存区域,里面每隔8
字节即是一个段描述符,GDT结构如下。
GDT中的每个描述符简单介绍.
- GDT_BASE:构建GDT的起始地址(此位是
0
位,不可用,所以直接填充为全0
即可) - CODE_DESC/DATA_STACK_DESC/VIDEO_DESC:代码段描述符,数据段和栈段描述符,显存描述符
- GDT_SIZE/GDT_LIMIT:计算出GDT大小**,GDT_LIMIT得到段界限,为后续加载GDT**做准备.
- SELECTOR_CODE/SELECTOR_DATA/SELECTOR_VIDEO:分别构建代码段,数据段,显存段的选择子.
gdt_ptr
:定义GDT指针,此指针是lgdt
加载GDT到gdtr
寄存器用的.
接下来就是进入保护模式,进入保护模式需要三步:
- 打开A20
- 加载GDT
- CR0第
0
位置1
代码如下。
最后还需要使用jmp SELECTOR_CODE:p_mode_start
指令来实现刷新流水线。
流水线是CPU为了提高执行效率而发展起来的加速技术,通常执行指令需要经过取指令,译码,执行指令,等操作,而运用流水线技术则将当前指令及其后面的几条指令同时放在流水线中重叠执行。
由于实模式是16
位的,而保护模式是32
位,在切换时必须要清空当前流水线上面所有的16
位指令集,以及错误的段属性,只有这样才能保证后面的32
位指令能够被正确的执行。
此时我们既要改变代码段描述符缓冲寄存器的值,又要清空以前的流水线,使用JMP
指令则可以达到这两种效果,JMP
指令在执行无条件跳转时会自动的将所有段寄存器初始化并清空当前流水线上的指令集。