一、软盘
软盘(Floppy Disk)是个人计算机(PC)中最早使用的可移介质。软盘的读写是通过软盘驱动器完成的。软盘驱动器设计能接收可移动式软盘,目前常用的就是容量为1.44MB的3.5英寸软盘。
软盘存取速度慢,容量也小,但可装可卸、携带方便。作为一种可移贮存方法,它是用于那些需要被物理移动的小文件的理想选择。
软盘在早期计算机上必备的一个硬件,也是计算机上面最早使用的可移介质。软盘英文缩写是FIoppy Disk,它作为一种可移储存硬件适用于一些需要被物理移动的小文件,软盘的读写是用过软驱也就是软盘驱动器来完成的。
1、容量
一个软盘,其实也就是一个单磁盘的硬盘,它拥有80个柱面(0-79),2个磁头(正反面,0-1),18个扇区(1-18),每个扇区有512字节的存储位置,所以,一张软盘的大小就是:
80(磁道) x 18(扇区) x 512bytes(扇区的大小) x 2(双面) = 1440 x 1024bytes = 1440KB = 1.44MB
柱面的英文是Cylindrical Surface,简写为C,磁头的英文是Head,简写为H,扇区的英文是Sector,简写为S,所以比如我们要表示45柱面1磁头6扇区就应当记做C45-H1-S6。
软盘并没有MBR,由于容量小也没有分区,整个软盘就是一个大的逻辑分区,从软盘启动电脑时,是从软盘的第0面,第0磁道的第0扇区开始的,每次读取一个扇区。如果这个扇区的最后两个字节是0xaa55,这里就叫做启动扇区,所以只要在这个扇区填入必要的指令,并在最后两个字节填入0xaa55,这个软盘就是启动盘了。
2、启动盘格式
根据BIOS的设计,如果BIOS被设定为软盘启动的话,计算机会去读取软盘中的数据,首先会读取软盘的前77个字节,来判断软盘的类型,在FAT12格式的软盘中对前77个字节是这样规定的:
开始地址 | 结束地址 | 占用字节 | 作用 |
---|---|---|---|
0x00 | 0x02 | 3 | 程序的第一条指令 |
0x03 | 0x0a | 8 | 启动区名称 |
0x0b | 0x0c | 2 | 每个扇区的字节数 |
0x0d | 0x0d | 1 | 每簇的扇区数 |
0x0e | 0x0f | 2 | FAT起始位置 |
0x10 | 0x10 | 1 | FAT的个数 |
0x11 | 0x12 | 2 | 根目录大小 |
0x13 | 0x14 | 2 | 磁盘大小 |
0x15 | 0x15 | 1 | 磁盘种类代码 |
0x15 | 0x17 | 2 | FAT长度 |
0x18 | 0x19 | 2 | 每磁道的扇区数 |
0x1a | 0x1b | 2 | 磁头数 |
0x1c | 0x1f | 4 | 分区个数 |
0x20 | 0x23 | 4 | 磁盘大小 |
0x24 | 0x26 | 3 | 固定数据 |
0x27 | 0x27 | 1 | 卷标 |
0x28 | 0x32 | 11 | 磁盘名称 |
0x33 | 0x3a | 8 | 格式名称 |
0x3b | 0x4c | 18 | 空行 |
所以只需要按照这种方式来填写,计算机就会以FAT12的形式来识别这张软盘。然后,计算机会检查这张软盘的第510(0x1fe)和第511(0x1ff)这两个字节的数据,如果这两个字节的数据是0x55和0xaa,就把该软盘的前512字节作为启动盘,然后执行0x00-0x02这三个字节(也可能只有前两个字节)的机器指令。
如果510和511字节不是0x55和0xaa,就会认为这张软盘仅仅是数据盘而并非启动盘,BIOS就会进行其他的动作(寻找其他启动盘或者进入待机状态)。所以,制作启动盘的第一步,就是把前77个字节按照规定写好,并且把510和511号位置数据写成55和aa,最后再在前3个字节的位置写上需要执行的命令(一般是一个跳转指令,然后运行其他位置的指令)就OK了。
二、软盘启动
1、启动盘书写
为了得到一个真正用于启动计算机的软盘,我们可以按照以下格式,这里指令和数据共同存储,数据和指令的混合书写:
org 0x7c00 ; 指定程序的装载位置
jmp entry ; 转到entry标签下(7c4d)
db 0x90
db "HELLOIPL"
dw 512
db 1
dw 1
db 2
dw 224
dw 2880
db 0xf0
dw 9
dw 18
dw 2
dd 0
dd 2880
db 0, 0, 0x29
db 0xffffffff
db "HELLO-OS "
db "FAT12 "
resb 18
entry:
mov ax, 0
mov ds, ax
mov si, msg
putloop:
mov al, [si]
add si, 1
cmp al, 0
je fin
mov ah, 0x0e
mov bx, 15
int 0x10
jmp putloop
fin:
hlt
jmp fin
msg:
db 0x0d, 0x0a
db "Hello World!"
db 0x0d, 0x0a, 0x00
resb 0x7dfe-0x7c7b
db 0x55, 0xaa
第1行表示该程序在内存中的加载位置,它不会被编译成机器码,而是让系统将其加载至内存指定位置,由于计算机硬件的原因,规定启动区就只能放到0x7c00-0x7dff这512字节的位置,这也就是为什么我们的启动区部分只能写512字节。
第2行是一个跳转指令,因为紧接着的都是一些数据,这些数据是不能当做指令来执行的,所以这里做一次跳转,跳转到0x7c50处,entry是标号,为对应指令的汇编地址。
第3行的这个数据其实是个废弃的,因为jmp指令只占2个字节,留这一个字节是因为,如果你写的是一个3字节的指令,那就必须用到这一个字节了,所以现在的情况,写什么数据都随便。
23行和24行是为了给ds寄存器赋值为0,由于CPU电路设计的问题,只能通过通用寄存器或者内存单元对段寄存器赋值,我们必须通过ax间接给ds赋值。
内存分段:
由于Intel 8086中,CPU拥有16位寻址线(虽然现在使用的80386的32位CPU共有32根地址线,但是在实模式下仍然只能使用16位),寻址范围太小,于是添加了段的概念,使用20位寻址,可以支持1MB($2^{20}$B)的内存空间。(即用两个16位寄存器表示20位地址)
内存地址——段地址«4 + 偏移地址
我们现在无论是数据还是指令,都是位于前0x0200(512)个字节,所以段地址只能为0。在默认情况下,段地址由段寄存器ds提供,所以我们需要给ds赋值为0,确保数据可以正常读取。
第25行,把0x7c6a赋值给si寄存器。si寄存器是源变址寄存器,这是变址寻址方式需要使用的。
变址寻址:
变址寻址方式——我们给出一个基址,这个基址可能是某一段数据的开头,或者是某一段指令的第一条的地址,然后以这个为基础依次向下读取(或执行)。由于我们在41-43行存放了要输出的数据,而这些数据的地址起始就是0x7c6a(msg),所以,要把这个值赋值给si,方便接下来使用。
文字输出:
27-35行实现的是文字的输出。
第34行,int是调用中断指令,这里调用的是BIOS中断,所谓的中断,简单解释一下。CPU、内存等这些速度都非常快,但是,在他们执行过程中不可避免的会等待类似于键盘鼠标的输入以及显示器打印机等的输出,这些设备的速度相比而言就非常慢了,所以,如果CPU一直在等待这些事情,那么它等于说有很长时间都不能工作,因而有了中断机制。
CPU不会因为需要输入或者输出就直接停下来等待,而是当设备需要进行输入或输出时,给CPU发出一个信号,CPU会停下手中的工作转来处理,处理完之后继续进行自己的工作;换句话说,CPU不需要实时监控,而是在需要他的时候“中断”一下,所以就把这种机制叫做中断机制。
CPU需要让BIOS调用显卡,然后把对应的内容打印到屏幕上,这一过程对于CPU来说就是对它的中断。而要进行中断,自然也就需要相应的数据传递:CPU先把要处理的数据放到相对应的寄存器上,然后其他的设备(BIOS)再读取寄存器中的数据,进行对应的工作,然后再返回数据到寄存器,CPU再通过寄存器得到返回数据(可以理解成函数调用,利用寄存器传递参数)。
这里就是调用了0x10号中断,这个中断需要BIOS响应,当ah(ax的高8位)是0xe的时候,BIOS会进行字符打印处理,它会调用显卡,把al(ax的低8位)所表示的字符打印在屏幕上,与此同时,bx中的内容决定了文字的颜色。(ah中的值可以视为命令,根据命令读取寄存器并进行相对应的操作)
28行利用si的值进行寻址(ds+si),把对应内存地址处的数据赋值给al;
32-33行给寄存器ah,bx赋值,然后34行调用0x10号中断,就可以在屏幕上打印出一个字符了。
但是,我们要的不是只打印一个字符,是要打印一系列的字符(字符串),所以,就需要一个循环,而有循环就需要一个循环的结束条件,35行的跳转指令制造一个循环,而30和31行就是判断是否跳出循环。这里是将字符和0去比较,这样的话,就会把msg标签下的内容作为数据,一个字符一个字符打印到屏幕上,直到找到'\0'
字符为止。
后面的fin标签下就制造了一个死循环:
hlt命令是让CPU待机,这样减少能耗,是一个好的编程习惯,由于之后我们不做什么事情了,所以就让CPU一直待机就可以了。
我们已经成功的把一台什么都没有的裸机运行起来了,还输出了一行字。由于一开始只能加载512字节,所以这512字节里的程序一般都用来加载磁盘,然后转到别的程序,基本上起了一个加载和引导的作用,我们把这一段内容叫做ipl。
2、完成ipl
上次完成了启动盘,我们已经成功把启动区加载到了内存中,当然了,这512字节的空间肯定是不可能够我们写一个完整的操作系统的,所以,我们操作系统的其他部分肯定得在别的地方写,那么,ipl的任务就应该是把软盘这1440KB的内容全部加载到内存中。
不过这里有一个相当严重的问题,就是我们在实模式下,只有20位寻址空间,换句话说,我们只能访问1MB的内存,所以暂时还没办法把所有的数据都放到内存中,我们先只放10个柱面的内容到内存中。
交给操作系统的内存部分是0x8000到0x9fbff,不过,虽然ipl的部分事先被加载到了0x7c00的位置,但它仍然作为软盘数据的一部分,这部分的数据在以后是有可能会用到的,因此,我们还是把0x8000到0x81ff的内存空间(1KB)留给它,把剩下的内容从0x8200开始储存。
我们按照扇区-磁头-柱面的顺序变更来读取软盘(也就是C0-H0-S1, C0-H0-S2, ……C0-H0-S18, C0-H1-S1, …… C0-H1-S18, C1-H0-S1, ……这样的顺序)一直读到C9-H1-S18。下面是修改后的ipl:
org 0x7c00 ;载入0x7c00的内存位置
jmp entry
data: ;数据段
db 0x90
db "HELLOIPL"
dw 512
db 1
dw 1
db 2
dw 224
dw 2880
db 0xf0
dw 9
dw 18
dw 2
dd 0
dd 2880
db 0, 0, 0x29
db 0xffffffff
db "HELLO-OS "
db "FAT12 "
resb 18
entry:
mov ax, 0
mov ss, ax ; ss = 0
mov sp, 0x7c00 ; sp = 0x7c00
mov ds, sp ; ds = 0x7c00
mov ax, 0x0820
mov es, ax ; 缓冲地址段地址 es = 0x0820
mov ch, 0 ; 柱面号
mov dh, 0 ; 磁头号
mov cl, 2 ; 扇区号
readloop:
mov si, 0
retry:
mov ah, 0x02; ah = 0x2
mov al, 1 ; 处理1个扇区
mov bx, 0 ; 缓冲地址基址
mov dl, 0 ; 驱动器号
int 0x13 ; 0x13号中断,ah=0x2时读盘,内容读取到es:bx中
jnc succeed ; 如果进位符是1表示读取错误,jnc表示进位符为0
add si, 1
cmp si, 5
jae error
mov ah, 0
mov dl, 0
int 0x13 ; 0x13号中断 ah=0, dl=0时系统复位
jmp retry
next:
mov ax, es
add ax, 0x20
mov es, ax
add cl, 1
cmp cl, 18
jbe readloop
mov cl, 1
add dh, 1
cmp dh, 2
jb readloop
mov dh, 0
add ch, 1
cmp ch, 10
jb readloop
mov [0xff0], ch
jmp 0xc200
succeed:
mov si, msg_succeed
jmp putloop
error:
mov si,msg_error
putloop:
mov al, [si]
add si, 1
cmp al, 0
je next
mov ah, 0x0e
mov bx, 15
int 0x10
jmp putloop
msg_error:
db 0x0d, 0x0a
db "load error"
db 0x0a, 0
msg_succeed:
db 0x0d, 0x0a
db "load success "
db 0x0a, 0
resb 0x7dfe-$
db 0x55, 0xaa
24-27行是寄存器的初始化,ss和sp这两个寄存器在本程序里还用不到,只不过写上合适的值方便以后添加程序。
29-30行初始化了缓冲地址寄存器es,这是因为我们要设置数据的起点,由于我们要把数据加载到0x8200位置,所以数据的起点就是这里,段寄存器就指向这里。
13号BIOS中断时有关软盘操作的,当al为1时进行读盘操作,将会把dl表示的驱动器中的内容存放到[es:bx]中;当al为0时进行重新读盘操作。
36-49行尝试读盘,si寄存器存放的是尝试读盘的次数,所以在35行初始化为0,当尝试5次都失败时将放弃读取,输出错误信息,而读取成功后,就会跳转到next标签下。
50-64行通过不断循环36-49行的指令,把10个扇区的数据全部载入内存中。
65行向内存0xff0处保存了一个数据,这个数据是目前读取的扇区数,这也是为了以后拓展这个程序用的,可以方便地知道之前加载了多少数据。
执行到66行时,程序应当将10个扇区都加载完毕了,跳转到0xc200处,没错,0xc200已经超出ipl的范围了,这就是给操作系统的一个引子,我们就应当把操作系统加载到这个位置,由于ipl之前已经把这些内容都加载到内存中了。
操作系统内容:
所以,接下来就可以直接执行操作系统的内容了,接下来我们来写操作系统,输出一行文字就好。
org 0xc200
osEntry:
mov ax, 0
mov ds, ax
mov si, msg
putloop:
mov al, [si]
add si, 1
cmp al, 0
je fin
mov ah, 0xe
mov bx, 15
int 0x10
jmp putloop
fin:
hlt
jmp fin
msg:
db 0x0d, 0x0a
db "Hello World!"
db 0x0d, 0x0a, 0
在输出了一系列的加载成功以后,又输出了Hello World。
ipl的使命基本就是这样,接下来的任务就要转向我们的操作系统内核了。在操作系统加载后需要进入保护模式,以便利用32位寻址空间,访问4GB的内存。
三、操作系统内核
由于在实模式下能够使用的内存实在太少,而且对硬件也而是一种浪费,因此,我们要写一个32位内核的操作系统,所以,就要先进入保护模式。
但是,由于在保护模式下不能调用BIOS,因此,要在引入保护模式之前,把可能需要用到的信息保存在内存中。我们指定了一些位置来存放相对应的信息,主要是关于屏幕的信息以及键盘指示灯的信息,代码如下:
CYLS EQU 0x0ff0
LEDS EQU 0x0ff1
VMODE EQU 0x0ff2
SCRNX EQU 0x0ff4
SCRNY EQU 0x0ff6
VRAM EQU 0x0ff8
org 0xc200 ; 这里的org并不是真的可以决定在内存中的加载位置(由ipl决定),而是为了让编译器可以算出正确的标签所代表的内存位置
mov al, 0x13 ; 320×200×8位色
mov ah, 0x00
int 0x10 ; 0x10号中断,ah=0时为VGA显卡图形模式
mov byte[VMODE], 8
mov word[SCRNX], 320
mov word[SCRNY], 200
mov dword[VRAM], 0x000a0000
mov ah, 0x02
int 0x16 ; ah=0x02时获取键盘led灯状态,保存在al中
mov [LEDS], al
分段:
在实模式下,段的设置是以16字节为一段,因为DS寄存器存储的数据表示段,DS:BX就会把DS的值左移四位再加上BX的值,得到最终地址,所以段地址最小单位就是16字节。
而在保护模式下,寻址空间为4GB($2^{32}$),仅仅利用一个16位寄存器是无法表示出来的,所以,将内存划分为了8192($2^{13}$)个段(段寄存器的高13位,第14位为0时访问GDT,为1是访问LDT,后两位则是特权选择器),每个段有512个字节,而段寄存器中的值对应每个段的段号,每一个段号对应一个32位的内存地址。
全局描述表GDT:
这样一个对应关系就需要一张表来储存,所以我们就需要8192×8B=64KB的内存空间,这部分数据就被称为全局描述表GDT(global descriptor table),CPU中会有一个专门的寄存器来存放GDT的地址,这个寄存器就是GDTR。
一般来说,GDT被设定在主引导扇区之后,也就是0x7c00+512=0x7e00的位置,大小最大为64KB。
CPU通过GDTR找到GDT,就可以确认当前段寄存器中段号的具体地址,再与实际的偏移地址相加,便可以计算出物理地址。
段寄存器-> GDTR -> GDT -> 物理段地址
GDTR是一个48位(6B)寄存器,给它赋值时必须从一个内存地址开始的连续6个字节给其赋值,指令是LGDT
,其低16位是段边界,即段描述表的大小-1(字节为单位),高32位表示在内存中的起始地址。
由于段边界有16位,所以最多可以使用$2^{16} = 64KB$的内存空间,最多可以定义8192个段描述符。
在GDT中,操作系统为每一个用户程序分配一个段描述符,每一个段描述符需要8B的空间,这8B的信息用于描述对应的一个段。
段描述符的格式如图所示,指定了32位的段起始地址(可以为4GB中的任意地址,但一般16B对齐),以及20位的段边界;
G为段边界的粒度,G=0以B为单位,即段的大小最大可以为$2^{20}B = 1MB$;G=1以4KB为单位,即段的大小最大可以为$2^{20}\times 4KB = 4GB$;
S用于指定描述符类型,S=0表示其为系统段,S=1表示其为代码段/数据段;
DPL为描述符的特权级,依次划分为0、1、2、3,0为最高级别,3为最低级别;不同特权级别的程序是相互隔离的,互相访问严格受限(当前程序只能由同等级或更高等级的程序访问);
P为段存在位,P=0表示该段不存在;
D/B位表示默认操作数大小/默认堆栈指针大小/上部边界标志,对于代码段,该位表示操作数为16或32位;对于堆栈段,该位表示使用SP或者ESP;
L为64位代码段标志,留给64位处理器使用;
AVL为软件可用位,一般不使用;
TYPE为描述符子类型,对数据段来说,为XEWA四位,对代码段来说,是XCRA四位。具体说明看下表:
GDT中第一个段描述符为空描述符NULL;
GDT的设立是为了实现保护,操作系统为每一个用户程序分配一个段描述符,用户程序需要通过自身的段描述符访问内存中特定的段。
中断向量表IDT:
中断向量表IDT(interrupt descriptor table),和GDT类似,它是用来记录中断的。
x86结构的CPU最多支持256种中断,因此,IDT的大小也应该是256×8B=2KB。当发生了中断时,CPU将根据IDTR的值找到IDT,然后再根据中断号找到对应的中断处理函数,执行对应的指令。
可编程中断控制器PIC:
可编程中断控制器PIC(Programmable Interrupt Controller),是用来处理中断的,CPU使用了中断机制,虽然不用自己去持续监听外部设备,但总是需要一个设备来做这件事情,这就是PIC。
PIC原本是一个独立的芯片,后来被集成在CPU中,通常情况下CPU中会有2个PIC,每个PIC有8个控制端和一个输出端,一个PIC直接连接CPU,另外一个将输出端接入刚才PIC的2号输入口,这样CPU就可以接受15个中断消息。
常用中断表
使用int命令时对应的中断类型表: