自制操作系统初体验

《30天自制操作系统》是我很久之前买的一本书,一直以来想跟着作者的脚步抽出30天时间踏踏实实做出一个操作系统(operating system, OS)出来。这几天心血来潮,突然想做点什么项目放到Github上,以备日后投简历之需。由于纸本在地下室懒得再去翻出来,因此从z-library上找了一本pdf下来看看,至于随书光盘文件大家可以去Github上找到汉化版(30天自制操作系统汉化版),虚拟机平台本文采用VMware 17。由于笔者假定读者有一定编程基础,所以不会像原书那样啰嗦着把知识点讲的很细很碎。接下来让我们看一看通过软盘启动、能在屏幕上打印"hello, world"字样的最简易操作系统是如何使用汇编语言实现的。

; hello-os
; TAB=4

; 标准FAT12格式软盘专用的代码 Standard FAT12 format floppy code

		DB		0xeb, 0x4e, 0x90
		DB		"HELLOIPL"		; 启动扇区名称(8字节)
		DW		512				; 每个扇区(sector)大小(必须512字节)
		DB		1				; 簇(cluster)大小(必须为1个扇区)
		DW		1				; FAT起始位置(一般为第一个扇区)
		DB		2				; FAT个数(必须为2)
		DW		224				; 根目录大小(一般为224项)
		DW		2880			; 该磁盘大小(必须为2880扇区1440*1024/512)
		DB		0xf0			; 磁盘类型(必须为0xf0)
		DW		9				; FAT的长度(必须是9扇区)
		DW		18				; 一个磁道(track)有几个扇区(必须为18)
		DW		2				; 磁头数(必须是2)
		DD		0				; 不使用分区,必须是0
		DD		2880			; 重写一次磁盘大小
		DB		0,0,0x29		; 意义不明(固定)
		DD		0xffffffff		; (可能是)卷标号码
		DB		"HELLO-OS   "	; 磁盘的名称(必须为11字节,不足填空格)
		DB		"FAT12   "		; 磁盘格式名称(必须是8字节,不足填空格)
		RESB	18				; 先空出18字节

; 程序主体

		DB		0xb8, 0x00, 0x00, 0x8e, 0xd0, 0xbc, 0x00, 0x7c
		DB		0x8e, 0xd8, 0x8e, 0xc0, 0xbe, 0x74, 0x7c, 0x8a
		DB		0x04, 0x83, 0xc6, 0x01, 0x3c, 0x00, 0x74, 0x09
		DB		0xb4, 0x0e, 0xbb, 0x0f, 0x00, 0xcd, 0x10, 0xeb
		DB		0xee, 0xf4, 0xeb, 0xfd

; 信息显示部分

		DB		0x0a, 0x0a		; 换行两次
		DB		"hello, world"
		DB		0x0a			; 换行
		DB		0

		RESB	0x1fe-$			; 填写0x00直到0x001fe

		DB		0x55, 0xaa

; 启动扇区以外部分输出

		DB		0xf0, 0xff, 0xff, 0x00, 0x00, 0x00, 0x00, 0x00
		RESB	4600
		DB		0xf0, 0xff, 0xff, 0x00, 0x00, 0x00, 0x00, 0x00
		RESB	1469432

其中使用的编程语言是NASM(实际上是NASK,原书作者改进版NASM,但大体语法一致)。可以看到,分号(;)代表注释,进而这段代码所有部分的功能便可以一目了然了。最显眼的部分当属一大段"标准FAT12格式软盘专用的代码",原书中是希望大家将系统镜像刻录到软盘里再进行引导从而启动自制操作系统的,这段代码会告诉计算机如何解析、识别软盘中的数据:哪里是启动扇区、它叫什么名字、扇区大小如何等等。众所周知,全世界只有日本还在使用古早的软盘和传真机(但据他们所说打算在2025年用光先前所有的软盘)。如果我们希望通过其他方式引导操作系统,就需要找到其他文件系统格式对应的引导代码(如FAT32、NTFS等)。这种文件格式告诉了硬件中数据的组织方式,也就是我们在格式化时经常能看见的选项。引导代码的种类一定要和系统文件所在的硬件相一致,否则无法正确读取硬件中的系统文件。
DBDWDD都是写入数据的汇编语句,"B"意味着字节(byte),"W"意味着字(word),"D"意味着双字(double-word),三者前的"D"意思是数据(data)。可见,引导代码相当于填写一份表格,我们要做的是规规矩矩按自己需求填写表格,而不要花时间去思考为什么表格要设计成这样,否则就会进入钻牛角尖的死胡同。
此处的程序主体其实就是系统主体,这里系统只会执行“输出”这一行为,而输出的信息则在后续部分。其中我们发现了一个美元符($),它的作用是标记当前的数据位置。而RESB的作用是预留字节(reserve byte),也就是写若干个0来填补剩余空间。为什么一直要填写到0x001fe呢?说明计算机只允许我们填写数据到这个位置。可如果我需要输出的远不止这些信息呢?那你只能刷新屏幕,再输出其他的信息。总之,你一次性输出的信息必须是有限的!
启动扇区以外部分的输出并不是我们需要关心的问题,因为大量的空间都是0,或者你可以说最后这段代码将软盘分隔成了两个部分,分隔标志即0xf0, 0xff, 0xff, 0x00, 0x00, 0x00, 0x00, 0x00
写完这段代码后,我们需要使用原书光盘中附赠的nask.exe进行编译,它的位置在一个名为tolset文件夹下的z_tools文件夹里。

在该文件的所在目录里,用这个程序编译我们的操作系统:

..\tolset\z_tools\nask.exe helloos.nas helloos.img

其中helloos.nas是我们的汇编文件名,而helloos.img是编译后的系统镜像。一方面,你可以把它刻录到软盘里(如果你有软盘和烧录机的话),另一方面,你可以使用VMware运行该系统。
使用VMware创建新的虚拟机,一直选到最后一个选项,选择“自定义硬件”,找到“添加”,选择“软盘驱动器”,软盘镜像选择我们刚刚编译好的镜像文件。




之后直接启动,稍作等待后就可以看到"hello, world"字样。

现在,你成功使用汇编语言在硬件级别实现了你的“程序逻辑”了。

清楚一点,再清楚一点

刚刚的代码可以正常运行,但很多地方都是直接写入的字节,除了空白扇区(最后一段代码)我们可以理解为什么直接写入字节(否则要亲自输入十几万个0x00)。我们希望真正看到这些非零字节的作用,因此请看以下代码:

; hello-os
; TAB=4

		ORG		0x7c00			; 指明程序装载地址

; 标准FAT12格式软盘专用的代码 Stand FAT12 format floppy code

		JMP		entry
		DB		0x90
		DB		"HELLOIPL"		; 启动扇区名称(8字节)
		DW		512				; 每个扇区(sector)大小(必须512字节)
		DB		1				; 簇(cluster)大小(必须为1个扇区)
		DW		1				; FAT起始位置(一般为第一个扇区)
		DB		2				; FAT个数(必须为2)
		DW		224				; 根目录大小(一般为224项)
		DW		2880			; 该磁盘大小(必须为2880扇区1440*1024/512)
		DB		0xf0			; 磁盘类型(必须为0xf0)
		DW		9				; FAT的长度(必??9扇区)
		DW		18				; 一个磁道(track)有几个扇区(必须为18)
		DW		2				; 磁头数(必??2)
		DD		0				; 不使用分区,必须是0
		DD		2880			; 重写一次磁盘大小
		DB		0,0,0x29		; 意义不明(固定)
		DD		0xffffffff		; (可能是)卷标号码
		DB		"HELLO-OS   "	; 磁盘的名称(必须为11字?,不足填空格)
		DB		"FAT12   "		; 磁盘格式名称(必??8字?,不足填空格)
		RESB	18				; 先空出18字节

; 程序主体

entry:
		MOV		AX,0			; 初始化寄存器
		MOV		SS,AX
		MOV		SP,0x7c00
		MOV		DS,AX
		MOV		ES,AX

		MOV		SI,msg
putloop:
		MOV		AL,[SI]
		ADD		SI,1			; 给SI加1
		CMP		AL,0
		JE		fin
		MOV		AH,0x0e			; 显示一个文字
		MOV		BX,15			; 指定字符颜色
		INT		0x10			; 调用显卡BIOS
		JMP		putloop
fin:
		HLT						; 让CPU停止,等待指令
		JMP		fin				; 无限循环

msg:
		DB		0x0a, 0x0a		; 换行两次
		DB		"hello, world"
		DB		0x0a			; 换行
		DB		0

这次和上次不一样,起手就是一个ORG指令,它代表原点、起始点(origin),标明了从0x7c00处开始加载下述代码。接下来就是一个JMP指令,即跳转(jump)到"entry"标签的位置。往下浏览,可以发现我们的程序被分成了若干段:entry(入口)、putloop(输出循环)、fin(终止)、msg(message, 信息)。我们称之为"代码段"(segment)。
第一个陌生的指令是MOV,即移动(move),但实际上它是复制的意思,例如MOV AX, 0就是将0复制到AX中。可是AX是什么呢?
AX是一种寄存器(register),是CPU内部的储存单元。如果把CPU比作人脑,那么寄存器就是我们大脑内部有记忆功能的神经元。你可能会疑惑,一般的储存单元不是硬盘或者U盘吗?事实是这样的,寄存器作为CPU内部的储存单元,运算速度极快,就像人脑做口算(例如1+1=2)。但是遇到大规模数据时(例如9位数乘9位数),我们只能在纸上列竖式计算。列竖式的过程中我们同样会不断执行九九乘法表的逻辑,只是每次处理的数据很小。换句话讲,我们每次处理的数据都是寄存器里的数据,而把9位数写在纸上就相当于把数据存在硬盘,而处理小数据相当于把截取的数据从硬盘转移到寄存器。
CPU中有如下寄存器:AX(累加寄存器, accumulator)、CX(计数寄存器, counter)、DX(数据寄存器, data)、BX(基址寄存器, base)、SI(源数据索引, source index)、SS(栈段寄存器,stack segment)、SP(栈指针, source point)、DS(数据段, data segment)、ES(扩展段, extra segment)等等。前四个带后缀X(即扩展extend)的寄存器实际上是16位寄存器,可以分为两个8位寄存器:高位(H,high)和低位(L,low)。也就是说AX可以分为AH、AL,CX可以分为CH、CL,其他两个以此类推。但是其他寄存器不可以这样,因此如果我们想把SI里的数据分成高位和低位,只能先MOV AX, SI,再从AH或AL里提取数据。
从entry里可以看出,我们把AX、SS、DS、ES寄存器初始化为了0,而把SP初始化成了0x7c00,使得此时栈指针指向的恰好是系统载入的位置。最后,我们将SI初始化成了msg标签所在的地址。当我们试图进入SI时,此时相当于进入msg。
按顺序执行完entry后,我们进入putloop代码段。我们首先看到的是MOV AL, [SI],为什么有一对方括号?!方括号的意思是将其中的值理解为地址,进而读取对应地址的值,有点像C/C++中的寻址运算符(&)。也就是说,我们把msg的第一个字符读取到了AL中。之后做一次指针加法ADD SI, 1,将地址向前移动一位。随后开始比较(compare, CMP),假如相等的话则跳转(JE, jump if equal)到fin代码段。如果不相等的话,则开始准备输出这个字符。注意,输出字符时,AH内要放入0x0e表示要输出字符,同时在BX中指定字符颜色。一切准备就绪后,再调用INT,即系统中断(interrupt, INT),最终回到putloop一开始的地方。其中,INT 0x10调用了BIOS中的输出函数。BIOS是引导输入输出系统的简称,定义了很多在引导阶段实用的函数,我们在下一节读入扇区时还会用到其他函数。
从这里我们可以管中窥豹一番:只要实现了比较和跳转,实际上这个程序就实现了判断和循环。
fin代码段本质是一个死循环,但是多出来一个HLT指令,即停止(halt)。它会让CPU停止工作,但如果外界有信号的话,CPU就会恢复工作。这是为了防止CPU空转,反复执行死循环直到效率接近100%。
将这段代码按之前的方式编译,再放入到VMware中运行,可以发现效果完全一样!

严肃的引导代码

“乘骐骥以驰骋兮,来吾道夫先路。”——屈原《离骚》
事实上,我们执行的并不是操作系统,而是引导代码。换句话说,就像是用既定幻灯片代替网络游戏一样。引导代码的作用是把硬盘中的操作系统录入到内存中。接下来,我们就要开发严肃的引导代码,把硬件中存储的系统文件读入内存并执行。然而,鉴于目前我们根本没有真正的操作系统,我们今天只需要写出一个会“读”的引导代码就算大功告成了!
查阅资料(https://wiki.osdev.org/BIOS)可以发现INT 0x10指的是输出字符,而INT 0x13则是内存操作函数。

原书中给出了一个网址,现在已经无法访问了,取而代之的是Wiki OSDev。但作者还是给出了他当时在页面中看到的内容:

可见,如果我们要读盘,需要使用AH=0x02。此外,如果操作过程有错误,BIOS会反映到进位寄存器FLAGS.CF中。假设我们需要判断是否发生了错误,就需要JC运算符,即进位则跳转(jump if carry)。
为了更直观地理解读取软盘的过程,我们给出软盘的解剖图:

我们先来看看读一个扇区的代码是什么样子的。

mov ax, 0x0820
mov es, ax
mov ch, 0  ; 0号柱面
mov dh, 0  ; 0号磁头(正面)
mov cl, 2  ; 2号扇区
mov ah, 0x02   ;  读盘模式
mov al, 1  ; 一次读1个扇区
mov bx, 0
mov dl, 0x00  ; 第一个驱动器/A驱动器(VMWare设置出现过)
int 0x13  ; 执行读盘操作
jc error  ; 错误处理

这段代码通过磁头读取了软盘上第0个柱面、第2个扇区的内容。由于软盘本身不可靠,可能在读取中确实会随机出现一两次错误,为了防止程序因非硬件错误而中断,我们需要再写一个重试代码段。

        MOV		AX, 0x0820
		MOV		ES, AX
		MOV		CH, 0  ; 0号柱面
		MOV		DH, 0  ; 0号磁头(正面)
		MOV		CL, 2  ; 2号扇区
		MOV		SI, 0  ; 记录失败次数的寄存器(本来应该用CX,但是CL和CH已经被占用了)
retry:
		MOV		AH, 0x02
		MOV		AL, 1
		MOV		BX, 0
		MOV 	DL, 0x00
		INT  	0x13
		JNC  	fin
		ADD  	SI, 1  ; jnc没有执行,则说明有错误发生了!
		CMP  	SI, 5
		JAE  	error  ;  如果si中的错误次数大于等于5,则跳转到error代码段
		MOV  	AH, 0x00  ; 重置
		MOV  	DL, 0x00  ; 重置
		INT  	0x13
		JMP 	retry  ; 循环

完整代码如下

点击查看完整代码
; hello-os
; TAB=4
		ORG		0x7c00			; 指明程序装载地址
; 标准FAT12格式软盘专用的代码 Stand FAT12 format floppy code
		JMP		entry
		DB		0x90
		DB		"HELLOIPL"		; 启动扇区名称(8字节)
		DW		512				; 每个扇区(sector)大小(必须512字节)
		DB		1				; 簇(cluster)大小(必须为1个扇区)
		DW		1				; FAT起始位置(一般为第一个扇区)
		DB		2				; FAT个数(必须为2)
		DW		224				; 根目录大小(一般为224项)
		DW		2880			; 该磁盘大小(必须为2880扇区1440*1024/512)
		DB		0xf0			; 磁盘类型(必须为0xf0)
		DW		9				; FAT的长度(必??9扇区)
		DW		18				; 一个磁道(track)有几个扇区(必须为18)
		DW		2				; 磁头数(必??2)
		DD		0				; 不使用分区,必须是0
		DD		2880			; 重写一次磁盘大小
		DB		0,0,0x29		; 意义不明(固定)
		DD		0xffffffff		; (可能是)卷标号码
		DB		"HELLO-OS   "	; 磁盘的名称(必须为11字?,不足填空格)
		DB		"FAT12   "		; 磁盘格式名称(必??8字?,不足填空格)
		RESB	18				; 先空出18字节
; 程序主体
entry:
		MOV		AX,0			; 初始化寄存器
		MOV		SS,AX
		MOV		SP,0x7c00
		MOV		DS,AX
		MOV		AX, 0x0820
		MOV		ES, AX
		MOV		CH, 0  ; 0号柱面
		MOV		DH, 0  ; 0号磁头(正面)
		MOV		CL, 2  ; 2号扇区
		MOV		SI, 0  ; 记录失败次数的寄存器(本来应该用CX,但是CL和CH已经被占用了)
retry:
		MOV		AH, 0x02
		MOV		AL, 1
		MOV		BX, 0
		MOV 	DL, 0x00
		INT  	0x13
		JNC  	ok
		ADD  	SI, 1  ; jnc没有执行,则说明有错误发生了!
		CMP  	SI, 5
		JAE  	error  ;  如果si中的错误次数大于等于5,则跳转到error代码段
		MOV  	AH, 0x00  ; 重置
		MOV  	DL, 0x00  ; 重置
		INT  	0x13
		JMP 	retry  ; 循环
fin:
		HLT						; 让CPU停止,等待指令
		JMP		fin				; 无限循环
ok:
	MOV SI, ok_msg	
	JMP putloop
error:
	MOV SI, err_msg
	JMP putloop
putloop:
		MOV AL, [SI]
		ADD SI, 1
		CMP AL, 0
		JE fin
		MOV AH, 0x0e
		MOV BX, 15
		INT 0x10
		JMP putloop
ok_msg:
		DB "Data reading finished! Congratulations!", 0
err_msg:
		DB "An error happened! Please check your code.", 0
相较于先前代码,笔者添加了ok_msg和err_msg来显式地告诉用户执行情况如何。编译运行后可以看到以下输出:

下面,我们需要读一个完整的柱面,即18个扇区。这很简单,只需要加一个判断语句和循环语句,不停地增加CL的值。

		MOV		AX, 0x0820
		MOV		ES, AX
		MOV		CH, 0  ; 0号柱面
		MOV		DH, 0  ; 0号磁头(正面)
		MOV		CL, 2  ; 2号扇区
readloop:
		MOV		SI, 0  ; 记录失败次数的寄存器(本来应该用CX,但是CL和CH已经被占用了)
retry:
		MOV		AH, 0x02
		MOV		AL, 1
		MOV		BX, 0
		MOV 	DL, 0x00
		INT  	0x13
		JNC  	next
		ADD  	SI, 1  ; jnc没有执行,则说明有错误发生了!
		CMP  	SI, 5
		JAE  	error  ;  如果si中的错误次数大于等于5,则跳转到error代码段
		MOV  	AH, 0x00  ; 重置
		MOV  	DL, 0x00  ; 重置
		INT  	0x13
		JMP 	retry  ; 循环
next:
		MOV		AX,ES			; 把内存地址后移0x200(512/16十六进制转换)
		ADD		AX,0x0020
		MOV		ES,AX			; ADD ES,0x020因为没有ADD ES,只能通过AX进行
		ADD		CL, 1
		CMP		CL, 18
		JBE		readloop
		JMP		ok

接下来我们来读10个扇区,即不停地增加CH的值。此时,只需要对next代码段略作修改。

next:
		MOV		AX,ES			; 把内存地址后移0x200(512/16十六进制转换)
		ADD		AX,0x0020
		MOV		ES,AX			; ADD ES,0x020因为没有ADD ES,只能通过AX进行
		ADD		CL, 1
		CMP		CL, 18
		JBE		readloop
	    MOV     CL, 0
        ADD     CH, 1
        CMP     CH, 10
        JBE     readloop

虽然能正常运行,但这是不对的!因为我们还没有读另一个磁头(磁头有正反之分)!正确的代码如下。

next:
		MOV		AX,ES			; 把内存地址后移0x200(512/16十六进制转换)
		ADD		AX,0x0020
		MOV		ES,AX			; ADD ES,0x020因为没有ADD ES,只能通过AX进行
		ADD		CL, 1
		CMP		CL, 18
		JBE		readloop
        MOV     CL, 0
        ADD     DH, 1
        CMP     DH, 2
        JB      readloop
	    MOV     DH, 0
        ADD     CH, 1
        CMP     CH, 10
        JBE     readloop
        JMP     ok

编译运行后得到

怎么回事?哪里出错了?答案是CL并不是从0而是从1开始的,CL的范围是1,2,...,18。将MOV CL, 0修改为MOV CL, 1即可!编译运行,大功告成!

点击查看完整代码
; hello-os
; TAB=4
		ORG		0x7c00			; 指明程序装载地址
; 标准FAT12格式软盘专用的代码 Stand FAT12 format floppy code
		JMP		entry
		DB		0x90
		DB		"HELLOIPL"		; 启动扇区名称(8字节)
		DW		512				; 每个扇区(sector)大小(必须512字节)
		DB		1				; 簇(cluster)大小(必须为1个扇区)
		DW		1				; FAT起始位置(一般为第一个扇区)
		DB		2				; FAT个数(必须为2)
		DW		224				; 根目录大小(一般为224项)
		DW		2880			; 该磁盘大小(必须为2880扇区1440*1024/512)
		DB		0xf0			; 磁盘类型(必须为0xf0)
		DW		9				; FAT的长度(必??9扇区)
		DW		18				; 一个磁道(track)有几个扇区(必须为18)
		DW		2				; 磁头数(必??2)
		DD		0				; 不使用分区,必须是0
		DD		2880			; 重写一次磁盘大小
		DB		0,0,0x29		; 意义不明(固定)
		DD		0xffffffff		; (可能是)卷标号码
		DB		"HELLO-OS   "	; 磁盘的名称(必须为11字?,不足填空格)
		DB		"FAT12   "		; 磁盘格式名称(必??8字?,不足填空格)
		RESB	18				; 先空出18字节
; 程序主体
entry:
		MOV		AX,0			; 初始化寄存器
		MOV		SS,AX
		MOV		SP,0x7c00
		MOV		DS,AX
		MOV		AX, 0x0820
		MOV		ES, AX
		MOV		CH, 0  ; 0号柱面
		MOV		DH, 0  ; 0号磁头(正面)
		MOV		CL, 2  ; 2号扇区
readloop:
		MOV		SI, 0  ; 记录失败次数的寄存器(本来应该用CX,但是CL和CH已经被占用了)
retry:
		MOV		AH, 0x02
		MOV		AL, 1
		MOV		BX, 0
		MOV 	DL, 0x00
		INT  	0x13
		JNC  	next
		ADD  	SI, 1  ; jnc没有执行,则说明有错误发生了!
		CMP  	SI, 5
		JAE  	error  ;  如果si中的错误次数大于等于5,则跳转到error代码段
		MOV  	AH, 0x00  ; 重置
		MOV  	DL, 0x00  ; 重置
		INT  	0x13
		JMP 	retry  ; 循环
next:
		MOV		AX,ES			; 把内存地址后移0x200(512/16十六进制转换)
		ADD		AX,0x0020
		MOV		ES,AX			; ADD ES,0x020因为没有ADD 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
        JBE     readloop
        JMP     ok
fin:
		HLT						; 让CPU停止,等待指令
		JMP		fin				; 无限循环
ok:
	MOV SI, ok_msg	
	JMP putloop
error:
	MOV SI, err_msg
	JMP putloop
putloop:
		MOV AL, [SI]
		ADD SI, 1
		CMP AL, 0
		JE fin
		MOV AH, 0x0e
		MOV BX, 15
		INT 0x10
		JMP putloop
ok_msg:
		DB "Reading 10 cylinders finished! Congratulations!", 0
err_msg:
		DB "An error happened! Please check your code.", 0
今天的学习就到这里,明天我们开始学习如何利用引导代码加载操作系统并引入C语言!
posted on 2025-02-05 17:56  溴锑锑跃迁  阅读(62)  评论(0编辑  收藏  举报