上一章搭建了在进行操作系统开发时所需要的环境,这一章我们会第一次接触到汇编,并且写一个能在VMware虚拟机上运行的“Hello world”。
命名
开发操作系统,最重要的当然是给操作系统取个名字。
那么就叫KNOS好啦。 :diana_yeah:
我们按下电源键时,计算机在干什么?
学过计算机或者了解过计算机的同学可能会知道,程序运行前会把需要使用到的文件读入内存中,然后从内存中运行程序。至于为什么需要先将文件载入到内存,而非直接从存储设备中执行,主要有几点原因:
- 首先是存储设备的种类多、操作难以统一,现代操作系统的常用外部存储有:硬盘(固态硬盘、机械硬盘)、U盘、存储卡、光盘(部分有特殊要求的地方会用得比较多)等等。这些不同的设备都有各自不同的操作方式,所以如果是直接操作外部存储设备,就可能出现各种出现不兼容的情况;
- 其次,类似硬盘的存储设备都被称为“低速设备”,而内存的读写速度一般来说要比外部存储设备高几十甚至几百倍,所以使用内存能够大大提升程序运行效率;
- 最后是刚才所说的那些设备(硬盘、U盘之类的)都是属于外部存储设备,CPU没办法直接访问到这些设备,CPU如果要从外部存储设备中读取数据,需要向硬盘控制器之类的控制设备发送指令,然后由相关的控制器与存储设备所交互。
我们可以把上述步骤分成两个部分:
1) 从硬盘中读取所需文件存放到内存;
2) 将CPU的cs:ip寄存器指向程序的起始地址;
实际上,操作系统在启动前也是在执行这个类似的步骤。
谁来把系统从硬盘“搬”到内存中?
程序在运行时,是由操作系统的“某个模块”将其从硬盘加载到内存中的(当然,这个模块我们后续也会着手开发)。但是操作系统是谁来从硬盘加载到内存中的呢?
答案是BIOS。
BIOS(Base Input & Output System),翻译成中文大概是“基本输入输出系统”。顾名思义,该系统是负责进行最基本的输入输出工作的。
在计算机上电启动以后,首先会进行自检。很多计算机在刚按下电源键后,风扇会转的特别厉害,随后减弱,这个过程其实就是在自检。在比较老的计算机主板上面会有一个蜂鸣器,如果自检出现问题,则会按照相应的规则鸣响,不过现在的计算机基本上都没有预装蜂鸣器了,有些是只留了一个接口,可以自行安装,有些甚至连接口都取消了。
自检完成以后,BIOS就会开始进行它的工作。在启动系统之前,BIOS会根据设置来选择启动设备(也就是操作系统所在的存储设备)。完成启动设备的选择后,BIOS会读取存储设备的第0磁头的第0磁道的第1扇区,然后检查该扇区是否以0x55和0xaa两字节作为结尾。如果是以这两个字节作为结尾,则BIOS会认为这个扇区为引导扇区(Boot Sector),然后把这一个扇区的数据读入内存地址0x7c00处。(之所以是这个地址,而不是0x0000,是因为0号地址还有其他作用,后面会提到),然后CPU的cs:ip会指向这个地址,并开始执行这段程序。
Boot引导程序
上一节提到,计算机启动后会将系统盘的第一个扇区存到内存中,本文操作系统初期的启动盘介质是3.5英寸软盘,其一个扇区只有512字节(实际上正常情况下,大多存储设备的一个扇区都是512字节),其容量较小,没有办法存储操作系统的所有功能,所以我们只会在其中存储文件系统和一些用于加载真正的系统文件的程序,以及一些日志打印程序。这一段程序的主要作用就是引导操作系统的进一步工作,所以这一段程序就被成为Boot引导程序。
其他一些更复杂的操作(如载入系统文件到内存、初始化部分硬件设备等)就由Loader来进行,这一部分在后面会详细说明。
Hello world
接下来,让我们动手实现一段简单的引导程序,然后在VMware里面跑一下,先把效果给做出来。这一次我们的目标,就是在开机时屏幕上显示黑底白字的“Start booting KNOS”即可。
根据前面几节的描述,我们知道,大概操作分这么几步:编写代码->编译成二进制文件->将二进制文件写入镜像第1扇区->将镜像以软盘形式挂载到虚拟机->启动虚拟机。那么我们一步一步地来解决吧。
代码
首先是编写代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 | org 0x7c00 BaseOfStack equ 0x7c00 jmp short start ; 跳转到启动代码 ; 启动代码 start: ; 初始化 mov ax, cs mov ds, ax mov es, ax mov ss, ax mov sp, BaseOfStack ; 在屏幕上显示信息 ; 显示start booting KNOS ; 清屏(ah=0x06, al=0x00) mov ah, 06h ; int 10中断,使用0x06 ah功能号时,按照指定范围滚动窗口 mov al, 00h ; al为0时表示进行清屏操作 int 10h ; 设置光标位置 mov ah, 02h ; int 10h的02h功能号设定光标位置 mov dx, 0000h ; dh为列数,dl为行数 mov bh, 00h ; 页码 int 10h ; 显示start booting KNOS mov ah, 13h ; 显示字符串 mov al, 01h ; 完成显示后将光标移动至字符串末尾 mov bh, 00h ; 页码(第0页) mov bl, 0fh ; 字符属性(白色高亮,黑色背景不闪烁) mov dh, 00h ; 游标行号 mov dl, 00h ; 游标列号 mov cx, 18 ; 字符串长度 push ax mov ax, ds mov es, ax pop ax mov bp, StartBootMessage ; es:bp 指向字符串地址 int 10h ; 栈 StartBootMessage: db "Start booting KNOS" ; 填充至512字节,并设置启动扇区识别符 times 510 - ($ - $$) db 0 dw 0xaa55 |
简单介绍一下上面出现的代码内容,首先是org 0x7c00这个是一个伪指令,它表示这段程序处于内存0x7c00处,之前我们说过了,计算机上电以后,BIOS会将引导扇区复制到这个位置,所以我们必须使用org指令指明其所在位置,如果没有这条指令,编译器会认为程序的起始地址为0x0000,这样会影响到绝对地址寻址。
第二条指令为BaseOfStack equ 0x7c00,equ是一条赋值的指令,这里的意思就是让标识符BaseOfStack的值等于0x7c00,equ指令并不会给标识符分配内存空间,且标识符不能与其他符号同名(可以理解为变成语言的变量不能和保留字相同)。同时,标识符也不能被重新定义。代码里的BaseOfStack是为了给栈指针提供栈基址。
再往下就是jmp short start,jmp short指令用跳转,其跳转范围与short描述一致,即两个字节,这里的意思就是跳转到start:处执行(也就是下面我们要说的那条)。
再往下就进入了启动代码,启动代码的开始部分使用start:进行标识,紧接在这条命令之上就是跳转到这个位置的指令,那么可能有同学要问:“既然都挨在一起,为啥还要单独写一条指令来进行跳转呢,这不是脱裤子放屁吗? :bella_?: :bella_?: :bella_?: ”实际上,这一版的代码只是为了最快得到显示效果,在后续的迭代更新中,还会在这个扇区里构建FAT12文件系统,其部分内容就写在跳转指令和start标签之间,所以这里就先把跳转写上去了,反正早晚都要写的嘛。 :ava_zzz:
下面几行是初始化寄存器相关的代码,意思就是将cs寄存器的段基地址设置到ds、es、ss等寄存器中,最后再设置sp栈指针寄存器。
继续向下看,后面几段代码都是屏幕信息显示相关的内容了,简单来说就是设置各种参数,然后通过BIOS中断功能进行调用,各个参数和功能号在注释里写得都比较清楚了,如果还是有不懂的同学,可以去网上搜索相关资料。
需要单独介绍的可能就只有int 10h,首先在汇编语言当中,16进制数有两种表示方法,一种是0x0000(以0x开头),一种是0000h(以h结尾),当然,这两种表示方法上面的代码都有用到。这里的int指令表示调用中断功能。中断功能可以简单理解为BIOS中预置的一些函数功能,这些功能在实模式下可供我们直接使用。具体有关nasm的详细的内容,可以在这里找到。当然,也可以自行在网上搜索和阅读相关资料。
完成屏幕设置相关代码后,来到了StartBootMessage: db "Start booting KNOS"这条指令,其实严格来说,这是两条指令,或者说一个标签加一条伪指令,只是我把它们写到了一起,这一句的意思是在栈上使用一段内存控件来存放Start booting KNOS这个字符串内容,其中,每个字符会占用1个字节(DB中的B指的是Byte,即字节,也有其他长度的指令,后续用到再介绍)。然后StartBootMessage指向了这个字符串的起始地址,标签的用法和上面的start:是一样的。这一段字符串就是我们要显示的字符串。
最后是times 510 - ($ - $$) db 0和dw 0xaa55这两条指令。还记得我们之前说过,BIOS识别扇区是否为引导扇区,是以0x55和0xaa这两个字节为准吗,这一段代码就是来实现在510和511这两个字节处填写0x55和0xaa魔法数的。其中第一句表示使用0,以db(字节,8位)为单位填充0,直到第510字节,第二句是以dw(字,16位,用法上与db差不多只是宽度不同)填充0xaa55。这里之所以是0xaa55而非0x55aa,是因为intel的CPU使用小端存储,按照这样的顺序填充,在二进制文件中的顺序才是0x55、0xaa。
完成这些代码的编写后,将其保存为boot.asm,并保存在bootloader文件夹下。
编译
在介绍编译之前,我们要先了解一个非常好用的工具:make。
虽然我们现在只需要编译一个文件(boot.asm),直接使用nasm boot.asm指令即可,但是在系统逐渐完善的过程当中,不可能把所有代码全部写进一个文件里面。所以我们面临着一个问题:需要同时编译编译几十个甚至上百个文件,这个时候,我们就需要使用到make这个特别方便的自动化编译工具了。
这部分的具体内容我已经写到操作系统开发番外篇(1)——makefile中了,如果对make工具没有任何经验的同学可以去看看,会用make的就可以直接跳过了。
下面是makefile的内容,直接在/bootloader/文件夹下新建一个名叫Makefile的文件,然后将下面这些内容写入其中即可:
1 2 3 4 5 6 7 8 | # /bootloader/makefile # bootloader makefile INCLUDE = -I include # 默认编译指令 all: boot.bin # bootloader boot.bin : boot.asm nasm $< -o $@ $(INCLUDE) |
简单介绍一下,首先是注释部分,注释第一行表示当前文件的文件名,可以方便大家在阅读代码时快速定位到这个文件,后面在写其他代码也一样,会在第一行注释标明其位置。代码部分最开始声明了一个变量INCLUDE,这个变量是include文件夹的路径参数。这个文件夹暂时还没有,不过我们后续会用到,所以留一个位置,include文件夹里面保存了一些库、头文件一类需要导入的文件。
目前来说,一共就只有bootloader/boot.asm这一个文件需要编译,所以暂时只有boot.bin : boot.asm这一个规则。还有一处值得提起的是all: boot.bin,其指明了make工具的默认工作。
完成了第一版makefile的编写,我们先来尝试一下编译这个文件。进入wsl,切换目录到/bootloader/,然后输入以下命令:
1 | make |
可以看到控制台输出了所执行的命令nasm boot.asm -o boot.bin -I include,然后在/bootloader/目录下生成了一个boot.bin文件。这个就是之前的汇编程序编译成二进制的文件。
软盘镜像
我们可以观察一下这个文件属性的大小,刚好512B,正好是一个扇区的大小。但是我们没有办法直接把这个文件用在虚拟机上,因为它是只一个扇区的数据(可以看一下其中的内容,如下图),并非一个数据载体(光盘、硬盘、U盘等)的镜像,所以我们需要把这个文件里的内容写到一个软盘镜像文件里面。