自制操作系统教程(持续更新)事创造键啊

虽然网上此类教程云集,虽然此类书籍很多,但是!

这些书籍有很多地方讲得不够细致(主要是代码有缺漏),有些对代码的更改甚至在书中了无痕迹。

而这才是我开启这篇教程的原因。

这篇教程之中,只要照着所有的操作做了一遍,以您OIer的水平,应当能够写出完整的操作系统!

本教程默认各位读者是会汇编的,或者说,至少应该能看懂汇编。

如果您使用的是Linux,我们只需要输入下面一行命令即可完成开发环境的配置:

sudoapt-getinstallnasmbuild-essentialqemu-system-x86

如果您使用的Linux中不含有apt系列包管理器,请使用您系统中的包管理器。

如果您使用的是Linux,但您的系统内没有包管理器,那么您可以去nasm官网、gcc官网和qemu官网下载源码,然后configure->make->sudomakeinstall。

如果您使用的是Windows,请去以下地方获取所需要的工具:

如果您使用的是macOS,那么请注意,系统内置的gcc会把文件编译成Mach-O格式,请通过Homebrew下载交叉编译器:

brewinstalli386-elf-binutils

brewinstalli386-elf-gcc

brewinstallqemu

以获取qemu。

在安装完之后,如果您使用的是Windows,请确保它们的路径位于PATH下!

除此之外便没什么重点了,不过,对于下文给出的工具名称默认以Windows为准,若您使用Linux,请去掉工具前缀,若您使用macOS,请将工具前缀中的i686改为i386!!!

对了,如果您使用的是Linux或macOS,请确保您在dd命令的后面加入conv=notrunc!!!

那么,开发环境配置正式结束,征程开始!

所谓引导扇区,其实就是一段可执行的代码而已,不过加入了一个小限制:编译后的总字节数不能超过512,同时扇区(一个512字节的连续区域,一般在磁盘里)最后两个字节必须是0x550xAA。

虽然现在看来这个限制并不怎样,但一到后面再回过头来,您将会发现这是一个非常恶心的限制。不过没关系,对于现在的我们来说,这个限制并不大。

那么我们的目标就是用一些功能往屏幕上输出信息。现在这个阶段,除了我们之外,还活着的也就一个BIOS了。万幸的是,BIOS提供了显示字符串的方法,具体用法如下:

向下列寄存器中依次存入:

AH=13h:输出信息

BH=页码(一般可以置0)

BL=属性(当al=0或1时才有用)

CX=字符串长度

(DH,DL):行和列

ES:BP:字符串地址

AL=输出方式

AL=0:仅含显示字符,字符属性(颜色等)位于BL中。显示后,光标位置不变。

AL=1:同AL=0,但显示后光标位置改变。

AL=2:字符串中含有显示字符和显示属性。显示后,光标位置不变。

AL=3:同AL=2,但显示后光标位置改变。

然后执行int10h。

寄存器可以近似理解为变量,这里面的AH、BH、BL、DH、DL这些都是寄存器。怎么操作它们呢?且看待会的代码。

这里面有个东西叫ES,它与其他寄存器不同,它是段寄存器。至于段寄存器是什么,ES:BP又是什么意思,且看下文说明。

那么此次我们要使用的就是AH=13hAL=01h的显示方法,即显示字符串后光标移动。

知道怎么显示字符串,主体部分的代码除了汇编的语法以外就没有理解障碍了。鉴于是第一段代码,我们还是来做一个阅读理解吧:

代码1-1最简单的引导扇区(boot.asm)

org07c00h;告诉编译器程序将装载至0x7c00处movax,csmovds,axmoves,ax;将dses设置为cs的值(因为此时字符串存在代码段内)callDispStr;显示字符函数jmp$;死循环DispStr:movax,BootMessagemovbp,ax;es前面设置过了,所以此处的bp就是串地址movcx,16;字符串长度movax,01301h;显示模式movbx,000ch;显示属性movdl,0;显示坐标(这里只设置列因为行固定是0)int10h;显示retBootMessage:db"Hello,OSworld!"times510-($-$$)db0db0x55,0xaa;确保最后两个字节是0x55AA汇编语言大小写不敏感,因此我们把所有的指令和寄存器都搞成了小写。汇编语言也不存在main函数,会从第一行开始顺次往下执行(当然如果遇到跳转会跳走,这个流程类似Python),因此我们也一行一行的看。

第一行,org07c00h,意义已经写在注释里,但是为什么要这么做?这是因为,按照硬件规程(这个词汇后面还会出现多次),BIOS在执行完自检等一系列操作以后,将执行位于0x7c00处的代码。07c00h,与0x7c00同义;同理,0(管你是啥)h和0x(管你是啥)也同义。这样,下面的代码才有被执行到的机会。由于它实际上不会产生任何机器码,因此它也被叫做伪指令。

下面的movax,cs,可以近似理解为ax=cs,这里的ax也是寄存器,cs也是寄存器,但这两者并不尽相同:ax被称为通用寄存器,顾名思义可以随便用;而cs则是段寄存器,段与内存有莫大的关系,如果乱动将导致内存操作不合预期,这个cs更是和code有关,乱动会导致执行出故障,因此除了某些必然更改的方法以外,它一般都是只读的。

接下来的两个mov本身,我想读者可以自己引申理解。这其中,ds和es也是段寄存器。段与内存有什么关系呢?在刚刚进入引导扇区的实模式下,我们认为一个段管理64KB内存。如果某个段寄存器的数值是x,那么从x*16开始的64KB就归它管,x本身则代表一个段。这样的寻址方法,用段寄存器:寻址寄存器来表示。或许有人就要问了:

唉,这不对啊,那两个段难道不会重合么?

好问题,两个段还真会重合。那么重合部分的内存归谁管呢?段寄存器里是哪个段,这个内存就归谁管。

至于这个寻址寄存器又是什么东西,由于我们不会在实模式待太久(我是不是听到了“还有其他模式?”),所以就先不解释了。

这里之所以要把ds和es用cs赋值,则又是因为这两者在BIOS执行期间可能还存着BIOS时期的段,如果不进行覆写,后面的int10h会觉得我要从BIOS的某处取字符串,实际则应该从执行代码的某处读字符串,而后者是由cs进行表示的。

然后callDispStr,这个可以近似理解为DispStr();。至于具体发生了什么,由于本节教程(甚至可能一直到很后面的教程)都没有用到,所以先不解释,用到了再说。

最后这个jmp$,相当于while(1);。但是需要注意,jmp并不是循环,它是一个跳转语句,和goto反而更为接近。$则表示这条指令的起始地址。这么一来,这条指令就相当于跳转到这条指令开始的位置,从而继续执行跳转,于是就起到了无限循环的作用。

然后是DispStr:,它既可以表示voidDispStr(),也可以干脆作为goto的标签名,从后面的介绍还可以知道,它还能表示更多的意思,就先不说了。

下面movax,BootMessage,相当于ax=BootMessage。这个BootMessage又是从什么地方来的?仔细观察发现,原来就在下面,BootMessage:db"Hello,OSworld!"。这个db也是个伪指令,作用是把后面的东西原样写进内存,不管它是一个数,一串数,或是一个字符串,只要它或它的每一个最小单元都在一个字节的范围内,就从头开始到最后,依次把这个数原样写在生成的文件里。大概相当于这样:

db0x55,0xaa->charsth[]={0x55,0xaa};

db"Hello,OSWorld!->charsth[]="Hello,OSWorld!"

db0x55->charsth[]={0x55}

这个db其实也是一系列伪指令里的一个,还有dw和dd,分别是把那个数组的类型改成了short和int。再往上还有更大尺度的,但是我们用不到。

把一个BootMessage:加在db前面,就相当于把这一串数组的名字给搞成了BootMessage。也就是说,

BootMessage:db"Hello,OSworld!"等价于charBootMessage[]="Hello,OSworld!"

因此这个mov代表的意思,就相当于是把BootMessage对应的内存地址赋值给了ax。

接下来movbp,ax,就是bp=ax。或许有人要问:

那么为什么不直接bp=BootMessage呢,转写成汇编就是movbp,BootMessage?这样难道不是效率更高、指令更少吗?

这是因为,有的寄存器不能直接使用内存地址和数字(我们统称这俩为立即数,意思是可以立即知道数值的数)赋值,比如段寄存器。虽然bp不在此列,但为了保险的需要,还是使用ax进行中转。

接下来就是按照要求,依次对这些寄存器进行写入了。先是movcx,16(cx=16),这是手动计算的下面字符串的长度;然后movax,01301h(ax=0x1301)、movbx,000ch(bx=0x000c),再之后是movdl,0设置在第0列显示。由注释可知,这是因为我们默认此时的dh是0的缘故。神奇的事情发生了,我们好像并没有对ah、al、bh、bl赋值!这又是为什么呢?

如果您的观察比较敏锐,那么就会发现,ah本该获得的0x13,被放在了ax的高8位;al本该获得的0x01,被放在了ax的低8位。难道说……?

接下来的int10h,相当于在调用库函数,上面的ah什么的都是参数。如果硬要类比,可能类似于这样:

sort(v.begin(),v.end(),cmp);,int10h就类似sort(只是角色,功能很不同),v.begin()、v.end()、cmp作为参数则和那些寄存器类似(当然后面知道其实也很不同)。

最后的ret,相当于returnax;。这个返回值怎么处置,最终是由调用方说了算。

下面的BootMessage那一行已解释过,再往下比较有意思,times510-($-$$)db0,这是在干什么,发刀乐么?

先说times。timesxxxaaa,相当于做xxx次aaa。times本身也就是个伪指令。一般times都与db系列的伪指令配合使用,和其他的联合使用的,我是没见过例子。

下面的$已经解释过,表示现在这个指令的起始内存地址,以此类推,其实伪指令的起始地址也可以用$表示;$$则比较复杂,不过在这个语境下,可以默认它是0。也就是说,写成times510-$db0也是没有问题的。

最后db0x55,0xAA,是为了顺应硬件规程的需要,“扇区最后两个字节必须是55AA”。一个扇区一共512个字节,所以先把最后这一句一直到510字节填充成0,然后写55AA,就能够保证这个二进制是一个符合硬件规程的扇区,从而能够被执行。

程序读完了,想必在这之前大家也写抄好了,我们该怎么运行呢?首先编译一下:

nasmboot.asm-oboot.bin对于Linux和macOS用户而言,只需要下面两行命令就可以完成软盘映像的创建与写入:

ddif=/dev/zeroof=a.imgbs=512count=2880ddif=boot.binof=a.imgbs=512count=1conv=notrunc如果您使用的是Windows,那么需要执行bximage。下面是使用bximage创建软盘映像的实例:

>bximage========================================================================bximageDiskImageCreationToolforBochs$Id:bximage.c,v1.342009/04/1409:45:22sshwartsExp$========================================================================DoyouwanttocreateafloppydiskimageoraharddiskimagePleasetypehdorfd.[hd]fdChoosethesizeoffloppydiskimagetocreate,inmegabytes.Pleasetype0.16,0.18,0.32,0.36,0.72,1.2,1.44,1.68,1.72,or2.88.[1.44]Iwillcreateafloppyimagewithheads=2sectorspertrack=18totalsectors=2880totalbytes=1474560WhatshouldInametheimage[a.img]Writing:[]Done.Iwrote1474560bytestoa.img.Thefollowinglineshouldappearinyourbochsrc:floppya:image="a.img",status=inserted(Thelineisstoredinyourwindowsclipborad,useCTRL-Vtopaste)Pressanykeytocontinue>硬盘镜像制作完成之后,我们再执行一条写入命令:

ddif=boot.binof=a.imgbs=512count=1注意,Windows下的dd不支持conv选项。

另外,如果您的boot.bin被报毒KillMBR,请不要惊慌,因为它就是一个MBR,因此被判为覆盖MBR的病毒非常正常,默认不做操作即可。

无论是上述哪种情况,在制作完成之后,直接执行一行命令来执行:

qemu-system-i386-fdaa.img如果您的执行结果如下图,那么恭喜您,您的引导扇区成功执行了!

(图1-1运行结果)

无论您使用的是哪种虚拟机,只要左上角出现Hello,OSworld!就算是成功。

前面我们花了极大的篇幅来写一个极简引导扇区的实现,但是本节相比之下就要短很多了,我们要在我们的软盘中创建FAT12文件系统,这样后续我们写入Loader和Kernel就要方便很多了。

一个磁盘中有没有文件系统,是依靠什么来进行标识的呢?一般而言,每一个文件系统都有特定的一个结构用来描述自己,无论是ext2的metadata块,还是FAT12/16/32在引导扇区中加入的BPB,都是一种对文件系统的标识。

BPB的具体结构如下图所示(实在懒得打列表了,干脆搬了一张网图):

(图2-1BPB的结构)

如诸位所见,FAT12文件系统头占用了汇编程序开头的64个字节。这下可用的空间又少了64字节(泪目)

不过它也带给我们一个好处,一般的FAT实现都认为只要有BPB就是有FAT文件系统(有的实现甚至不会管BPB),这样就可以用一些工具来方便地操作磁盘了。

那么我们就依照此结构写入一下这些结构吧:

代码2-1FAT12文件系统头(boot.asm)

总是困在小小的引导扇区之中,也不是长久之计,毕竟只有446个字节能给我们自由支配,而保护模式的栈动不动就512字节,一个引导扇区完全盛不下。所以我们有必要进入一个跳板模块,并在其中进行初始化工作,再进入内核。

这时候又该有人问了:

啊所以为什么不直接进内核呢?

emmm,事实上也有这种系统(比如haribote),但这样的一个缺点就是你的内核文件结构必须很简单甚至根本没有结构才行。

所以我们还是老老实实地跳入Loader再进内核吧,不过话说回来,我们现在连一个正经八百的Loader都还没有,不着急,我们马上创建一个:

代码3-1极简Loader(loader.asm)

org0100hmovax,0B800hmovgs,ax;将gs设置为0xB800,即文本模式下的显存地址movah,0Fh;显示属性,此处指白色moval,'L';待显示的字符mov[gs:((80*0+39)*2)],ax;直接写入显存jmp$;卡死在此处这个Loader的作用很简单,只是在屏幕第一行的正中央显示一个白色的“L”。不过,它还是需要一些解释的。

首先第一行想必不用解释,不管它加载到了什么段,都把它加载到0x100的偏移处。接下来两行让gs=0xB800,由第一节的知识可知,这个gs管的是从0xB8000开始的64KB,而这个位置恰好(虽然没那么大)是文本模式下的显存,这里但凡有风吹草动,都会被立即显示在屏幕上。

0Fh代表白色,'L'代表字符。把它们分别放在ah和al,组成的ax就是一个可以被显示的字符了。对于任意一个字符而言,都需要用高8位放颜色,低8位放字符本身,然后再进行显示。

最后一行出现了我们没见过的[],它是什么意思?我们先把这个[]去掉看看。gs:((80*0+39)*2),这像是一个坐标。确实如此,文本模式的显存横向为80字符,纵向为25字符。前面的80*0,代表第0行,同样,80*k就代表第\(k\)行(第0到\(k-1\)行一共有\(80k\)个字符)。后面的+39,自然就表示第\(39\)列。由于这一行前面\(k\)行一共有\(80k\)个字符,这一列前面\(n\)列又有\(n\)个字符,那么\((k,n)\)这个坐标,自然就对应\(80k+n\)这个位置(由此可见下标从0开始的好处)。

那么为什么这个坐标要乘\(2\)呢?这个问题的回答则更为简单,由上面的说明就知道,每一个字符在显存中占据\(2\)个字节,所以自然要乘\(2\)才能定位到显存中的偏移。

最后加上前面的gs:,我们就得到了这个字符将要被显示的内存地址。而加上这个中括号,就意味着往这个地址对应的内存里写入东西,这里是ax。至此,只要它被执行到,就可以在屏幕上显示一个白色L。

想要执行Loader,自然需要先把它读取到内存,然后跳转过去;而想要读取Loader,自然需要先找到它。

于是现在最主要的问题就变成了:我们应该怎样寻找Loader呢?

这个很简单,在根目录区中是一个一个一个32字节的文件结构,其中就包含文件名,我们在根目录区中查找即可。

依照FAT12文件系统的结构规定,根目录区排在FAT表和引导扇区后面,因此它的起始扇区是BPB_RsvdSecCnt+BPB_NumFATs*BPB_FATSz16=19号扇区;它的结束位置则是19+BPB_RootEntCnt*32/BPB_BytsPerSec=33号扇区。在第一节也曾提到,扇区是一个长度为512字节的结构,大多数时候位于磁盘中。不过它还有一个地位,那就是磁盘读写的最小单位。当我们说第某某扇区或者是某某号扇区时,默认它从0开始,也就是说引导扇区是第0个而非第1个扇区。

于是我们的思路便有了:从第19号扇区开始,依次读取每一个扇区,并在读到的扇区中查找LOADERBIN(loader.bin写入之后的文件名)。如果已经读到第34扇区而仍然没有找到LOADERBIN,那么就默认该磁盘内不存在loader。至于怎么找LOADERBIN,现在没有实现那么多高级算法的条件,只有一个小窍门:根目录区是从某某扇区开始的,而某某扇区的开始位置,一定是512的倍数,从而一定是32的倍数。那么,我们就只需要遍历开头11字节,若不等于LOADERBIN,则先指回开头,然后加32,就来到了下一个文件结构。由于某某扇区的开始位置是32的倍数,所有文件信息的开始位置也都是32的倍数,从而指回开头可以通过位运算实现:32=0b100000,所以只需要与上\(0\text{xffff}-(32-1)=0\text{xffe}0\),我们就回到了开头。

那么我们该怎么读取磁盘呢?事实上,BIOS也给我们提供了这个功能:

AH=02h,表示读取磁盘

AL:待读取扇区数

CH:起始扇区所在的柱面

DH:起始扇区所在的磁头

CL:起始扇区在柱面内的编号

DL:驱动器号

ES:BX:读入缓冲区的地址

然后执行int13h。

返回值:

FLAGS.CF=0:操作成功,AH=0,AL=成功读入的扇区总数

FLAGS.CF=1:操作失败,AH存放错误编码

这里又出现了一堆新名词,柱面、磁头,这又是什么?这是在物理上磁盘的存储结构,具体的结构不需要知道,你只需要知道,每一个磁盘有两面,分别对应上下两个磁头,编号为\(0\)和\(1\),而每一面上又被细细分为\(80\)个柱面(柱面也称磁道),而这里的扇区编号,则是把柱面再一次细分的结果,每一个柱面又被分为\(18\)个扇区。这一个柱面的18个扇区结束后,下一个扇区并不是紧邻着的相邻扇区,而是磁盘对面的那个柱面的第一个扇区。可能有些难理解,说白了,其实就是0磁头、0柱面、18扇区(第17扇区,牢记我们说第某某扇区时是从0开始的)的下一个扇区,并不是0磁头、1柱面、1扇区(第36扇区),而是1磁头、0柱面、1扇区(第18扇区);而1磁头、0柱面、18扇区的下一个扇区(第35扇区),才是0磁头、1柱面、1扇区(第36扇区)。

由于这种寻址方法太过具体,要给的参数太多,现在已经普遍弃用这种指定扇区的方法;由于用到柱面Cylinder、磁头Head和磁头内的扇区编号Sector,这种方法被称为CHS方式。现在一般采用直接指定总的扇区编号的方法,这个扇区编号又有一个名字叫做逻辑区块地址(LogicalBlockAddress),所以这种方法又被称为LBA方式。之所以现在突然提到这个,是为了给后面一个方便,以后就可以叫CHS、LBA了,更何况LBA这个概念我们后面还要用到。从上面的描述也可以大致猜出,为什么CHS里C排在H前面,实话说不查资料谁能想到啊。

还有一个更坑的点,CHS方式下的第一个扇区是\(0\)磁头、\(0\)柱面的第1扇区,但LBA方式下的第一个扇区编号是0。处理差一问题的痛苦回忆又开始回荡……(笑)

总之,我们现在最大的需求,又变成了把LBA方式下的扇区转换成CHS的形式。我们先从扇区找到柱面,然后从柱面找到磁头,这一流程大概是这样的:

首先,用LBA方式的扇区去除每磁道扇区数,这个东西写在了BPB里。前面定义BPB用的都是db、dw、dd,也就是存了一堆数组。其中,BPB_SecPerTrk表示每个磁道(其实就是柱面)有多少个扇区,它大概长这样:shortBPB_SecPerTrk[]={18};。所以,读取的时候也要读内存地址,也就是类似*BPB_SecPerTrk的东西。如果您有一定C语言储备,就知道它相当于BPB_SecPerTrk[0]。这样,商就对应柱面,余数就是这个扇区在柱面内的位置,CHS的S就已经到手了。由于CHS方式与LBA方式起始扇区的不同,这里需要给余数加1。

再然后,从柱面找磁头,由上面的描述可以推知,给柱面除以2,余数就是磁头,商就是对应的那个柱面。举个例子看看,第36扇区除以18,可以知道是第2个柱面(这个玩意也是从0开始),而它对应的磁头则在正面,隶属0磁头;第35扇区除以18,是第1个柱面,而它则在背面,隶属1磁头。这是因为沿着扇区号走下去时,磁头整体上呈一个0、1、0、1交替的态势(具体地说,是一段0、一段1这么交替下去的)。

这样,就可以从LBA中一个单独的扇区号,完整地推出CHS三个分量的值。那么,我们也就只需要一个LBA扇区号就行了,上面的BIOS调用中,CH、DH、CL可以归一。而驱动器号,则明明白白地写在BS_DrvNum这个数组里(它也是由db定义的),到时候从这个数组取值就行了,DL也可以不要。这样,就只剩下三个必要的参数:缓冲区ES:BX、读取扇区数AL以及起始扇区号。由于起始扇区号可能很大,我们把它分配给AX,原先读取扇区数的位置就随便挑个东西给了,就CL吧。

返回值中,错误编码我们并不需要,只需要保证FLAGS.CF的值为0就可以了。对此,我们可以执行一个jc跳转命令,它的作用是当FLAGS.CF为1时跳转。在这个案例里,我们让它多试几遍,不要因失败而放弃,每次让它在出错的时候跳转回读取循环的开头重新读入。

思路有了,读盘功能也有了,我们就开始写程序吧。首先在DispStr函数的后面加入一个读取扇区的函数ReadSector,它的作用上面已经讲过,从第ax号扇区开始,连续读取cl个扇区到es:bx。

代码3-2读取软盘的函数(boot.asm)

ReadSector:pushbpmovbp,spsubesp,2;空出两个字节存放待读扇区数(因为cl在调用BIOS时要用)movbyte[bp-2],clpushbx;这里临时用一下bxmovbl,[BPB_SecPerTrk]divbl;执行完后,ax将被除以bl(每磁道扇区数),运算结束后商位于al,余数位于ah,那么al代表的就是总磁道个数(下取整),ah代表的是剩余没除开的扇区数incah;+1表示起始扇区(这个才和BIOS中的起始扇区一个意思,是读入开始的第一个扇区)movcl,ah;按照BIOS标准置入clmovdh,al;用dh暂存位于哪个磁道shral,1;每个磁道两个磁头,除以2可得真正的柱面编号movch,al;按照BIOS标准置入chanddh,1;对磁道模2取余,可得位于哪个磁头,结果已经置入dhpopbx;将bx还原movdl,[BS_DrvNum];将驱动器号存入dl.GoOnReading:;万事俱备,只欠读取!movah,2;读盘moval,byte[bp-2];将之前存入的待读扇区数取出来int13h;执行读盘操作jc.GoOnReading;如发生错误就继续读,否则进入下面的流程addesp,2popbp;恢复堆栈ret这里出现了很多没有见过的东西,鉴于实在是有点多,所以我这里把它转写为类似C的程序:

代码3-2的转写(主体部分)

voidReadSector(shortax,shortcl,short*es:bx){save(cl);//在栈里保存cl,但这个和存bx有很大不同,待会再说push_to_stack(bx);//暂存bxbl=BPB_SecPerTrk[0];//18shortquot=ax/bl,remain=ax%bl;//quot->商,是从0开始位于第几个柱面;remain->余数,是柱面内第几个扇区ah=remain,al=quot;//divbl的效果就是这样ah++;//incahcl=ah;//cl:起始扇区在柱面内编号,已获得dh=al;//dh:从0开始的柱面号al>>=1;//shral,1,此时的al为柱面号ch=al;//ch:柱面号,已获得dh&=1;//dh:磁头号,已获得//至此LBA格式的ax已经成功转换为CHS格式的cl、ch和dhpop_from_stack(bx);//还原bxdl=BS_DrvNum[0];//获取驱动器号do{ah=2;//ah=0x02,读盘al=load();//读取先前保存的clINT(0x13,ah,al,ch,dh,cl,dl,es:bx);}while(flags.cf);}什么inc啦,shr啦,and啦,到底什么意思都已经讲明白了。下面那个jc,我们也把它表示成了do-while的形式。

那么现在,需要解释清楚的就是几点:1、开头结尾的pushbp、movbp,sp和结尾的popbp是在干什么;2、这个cl到底存哪去了;3、这个.GoOnReading带.是在干什么(虽然我觉得有这个问题的不会多)。

我们从易到难吧。先说最后一点,这实际上是nasm的私货,这种东西不能单独存在,必须长成类似这样:

xxx:.xxx:aaa:才行。只要现在的代码还在最上面那个xxx:之下,访问下面那个.xxx就可以直接用.xxx的形式,比如mov、jmp、call都行;但一旦到了下面那个aaa:的下面,就不能再这么做了,如果还想访问上面那个.xxx,必须通过xxx.xxx的方式。或许有人会有疑问:

如果我在aaa下面再定义一个.xxx呢?

那自然是毫无问题,aaa下面的代码访问.xxx,访问的就是aaa下面定义的那个,而非xxx下面定义的那个。这个东西就类似在别的什么高级编程语言里的私有属性,因此有个名字叫本地标签。不过目前知道就行了,具体用处没有体现。

接下来来解决1和2,这俩其实是同一个问题。pushbp和movbp,sp是C语言函数默认带有的两条指令,表示函数开始,所谓的栈帧也就是这个东西。而最后的popbp,自然是反过来的操作,表示函数结束,退出栈帧。

接下来的movbyte[bp-2],cl,bp-2处此时是个什么地方呢?注意在存完栈帧以后,立刻执行了subesp,2(esp-=2)的操作,而bp则相当于还没减时候的sp,bp-2自然就是现在的sp。

说白了,这一番操作其实就相当于:pushcl,而已。只不过为了对称,一般有push必有pop,除非返回,而这个位置后面还要用到多次,不能pop,因此最开头为了对称起见(笑)也就没有用push。这样一来,cl和bx其实类似,都是被暂存在栈上了,只是bx只被用到一次,很快就pop掉了,但cl被用到多次,一直到最后的addesp,2才相当于把它pop了出去。

好了,ReadSector就解释完了,不知道大家明白没有(笑),我们继续吧。

下一步,我们定义几个常量,它们的作用是增加可读性,毕竟满篇写死的根目录大小14之类的,很难让人看懂。

代码3-3放在开头的常量定义(boot.asm)

BaseOfStackequ07c00h;栈的基址BaseOfLoaderequ09000h;Loader的基址OffsetOfLoaderequ0100h;Loader的偏移RootDirSectorsequ14;根目录大小SectorNoOfRootDirectoryequ19;根目录起始扇区常量过后还有变量,我们在这个程序中将要用到的变量也不少,它们将被放置在DispStr函数的前面。

代码3-4放在中间的变量定义(boot.asm)

wRootDirSizeForLoopdwRootDirSectors;查找loader的循环中将会用到wSectorNodw0;用于保存当前扇区数bOdddb0;这个其实是下一节的东西,不过先放在这也不是不行LoaderFileNamedb"LOADERBIN",0;loader的文件名MessageLengthequ9;下面是三条小消息,此变量用于保存其长度,事实上在内存中它们的排序类似于二维数组BootMessage:db"Booting";此处定义之后就可以删除原先定义的BootMessage字符串了Message1db"Ready.";显示已准备好Message2db"NoLOADER";显示没有LoaderBootMessage改过之后,DispStr也做了微调,现在可以用dh传递消息编号来打印了:

代码3-5改进后的DispStr(boot.asm)

DispStr:movax,MessageLengthmuldh;将ax乘以dh后,结果仍置入ax(事实上远比此复杂,此处先解释到这里)addax,BootMessage;找到给定的消息movbp,ax;先给定偏移movax,dsmoves,ax;以防万一,重新设置esmovcx,MessageLength;字符串长度movax,01301h;ah=13h,显示字符的同时光标移位movbx,0007h;黑底灰字movdl,0;第0行,前面指定的dh不变,所以给定第几条消息就打印到第几行int10h;显示字符ret或许有人看不懂这个DispStr最开头的三行代码在干什么,把它和那一堆变量转写成C会更好理解一点:

代码3-5的转写

#defineMessageLength9charBootMessage[MessageLength][3]={"Booting","Ready.","NoLOADER"};voidDispStr(){bp=BootMessage[dh];//...下略}也就是说,上面的三个Message在内存中的排布实际上就是一个二维数组,而movax,MessageLength和muldh的操作相当于在找它的第dh行。

为什么用dh当参数呢?重新翻阅第一节可以知道,这样还顺便指定了行数,确实是一条妙计。

一切准备工作均已办妥,下面我们开始主循环吧……且慢,我们还有一点点预备知识要补充,下面是int13h的另一种用途。

AH=00h:复位磁盘驱动器

DL=驱动器号

FLAGS.CF=0:操作成功

FLAGS.CF=1:操作失败,AH=错误代码

这里我们直接假定FLAGS.CF为0,不做任何判断了。下面便是主体代码:

代码3-6查找Loader的代码主体(boot.asm)

后面的代码讲的就不会再像第一节和这一节这么详细了,大部分的解读都在注释,所以一定要善用哦。

如果直接按照上文的方法,先nasm后dd,一顿操作猛如虎的话,那么运行结果应该是这样的:

(图3-1直接运行的效果)

第三行将会出现一个NoLOADER的标识,虽然不符合预期(应该没有任何输出才对),但这也正好说明了我们的主循环在工作。

那么下面我们的工作就是把Loader写入磁盘了,不过您可能会发现,我们甚至都没有编译Loader,没事,马上编译一下:

nasmloader.asm-oloader.bin虽然得到了loader.bin,但我们的写入工作在此处就有两个分支了。如果您使用的是Linux或macOS,请使用下列命令将loader.bin写入磁盘:

mkdirfloppysudomount-oloopa.img./floppy/cploader.bin./floppy/-vsudoumount./floppy/rmdirfloppy在Windows下我们则需要这样:

edimgimgin:a.imgcopyfrom:loader.binto:@:imgout:a.img无论用什么方式,只要您成功把Loader写入了磁盘,便无大碍。总之,写入之后的运行结果是这样的:

如果您的运行结果与之相符,那么您就可以进入下一节的学习,我们将要加载我们的Loader,并跳入其中,这样,我们的可支配空间就从0.5KB扩张到了63KB,足有126倍的提升。64KB是一个段的大小,我们的Loader就活在一个段里;至于还有1KB则是被org0100h给吃了。

在执行流到达LABEL_FILENAME_FOUND时,此时的di应当正好位于Loader所在的文件块中。因此,我们可以通过这个方法获得Loader的起始扇区。

至于怎么获得,这就与那个32字节文件块的结构有关。

typedefstructFILEINFO{uint8_tname[8],ext[3];uint8_ttype,reserved[10];uint16_ttime,date,clustno;uint32_tsize;}__attribute__((packed))fileinfo_t;这个结构体就是对文件块的描述,后面我们还会见到它的。其中的clustno是它起始的簇,一个簇对应一个扇区。

从簇号转化到扇区号要怎么办呢?这就不得不提到FAT12文件系统的结构了。以下叙述默认下标从0开始。

FAT12文件系统在磁盘中是这样的:第0个扇区,是引导扇区,接下来是两块大小为9扇区的FAT表,再往下是14个扇区的根目录区,剩下的部分都是数据区。

数据区的每一个扇区,都叫做一个簇。数据区的第0个扇区,是第2个簇。这个时候或许有人要问了:

那么第0个簇和第1个簇去哪里了?

它们被FAT表给暴力强占了。

FAT表和数据区不是彼此独立的吗,怎么会发生这种事情?

然而,不知道因为什么,前两个本该对应0号簇和1号簇的项,分别存储的是坏簇标记FF0和结束标记FFF。因此,可以使用的第一个簇也就变成了第2个。这两个簇不能使用,又不能真空出两个扇区来啥也不干,所以干脆把数据区的第0个扇区(也就是第33扇区)当成第2号簇。

既然这堆簇排成了一个链表,自然需要知道第一个簇在什么地方,而这个值就保存在文件信息块fileinfo_t的clustno成员中,偏移量为\(26\)。

获得第一个簇以后之后我们便可以做几件事:读取第一个扇区,查找FAT,读入下一个扇区,直至所有扇区都被读完。

不难发现我们需要多次查找FAT,所以我们干脆把查找FAT的过程也包装一下,我们将使用ax存储待查询的簇号,查询结果也放入ax中。

请把下面的代码放到ReadSector之后:

代码4-1读取FAT项的函数(boot.asm)

于是缺德微软就脑子短路没有选择跳过FAT12直接发明FAT16

于是微软就搞出了一套“压缩”方法(说是压缩,每一个FAT项还是占一个字节半,其实没有任何优化),把两个FAT项硬挤在三个字节里,具体而言是长这样的:

这样就搞得很恶心,FAT12要考虑的细节有一半都来自这个破算法。比如,由于每两个FAT项占三个字节,所以极端情况下会出现某个FAT项的低八位在扇区\(a\),高四位在扇区\(a+1\),所以读取磁盘时,一次要读两个扇区,读到之后还得费尽心思转换。

不过,上面的代码中,使用了非常巧妙的方法辗转腾挪,最终只用了五行代码就完成了转换,我们到时候再说。

说的有点远,我们从第一行开始看。开局存了三个寄存器es、bx和ax,这是因为读取磁盘要用es和bx,而设置新缓冲区要用ax,所以都得存一下。

接下来这几行,把Loader前面4KB(0x100*16=4096=4KB)的位置当做缓冲区,然后还原ax。其实选什么地方当缓冲区并没有什么特别的规定,基本上是想放哪放哪,这里使用Loader的开头作为基准只是为了方便。

还原ax以后,由于每两个FAT项占三个字节,所以先给它乘3找到对应的两个FAT项。由于ax可能过大,再乘一个bx有爆掉16位的危险(其实算一算就知道根本不可能),因此CPU会把乘积的低16位放在ax,高16位放在dx。注释里使用dx:ax,算是一种惯用法,表示高16位和低16位是这两个寄存器,与es:bx这种寻址意义不同,需要注意一下。

那么问题来了,你现在找到了两个一共占三字节的FAT项,它们可是缠在一起的,你怎么知道你要找的那个项被塞在了哪两个字节里呢?

我们能直接使用的变量,都是以字节(char)为最小单位。想要访问它的第几位,就需要用位运算来处理。同理,内存处理的最小单位也是字节,低于一个字节的都要用位运算来提取。由此就引发了一个问题:高于一个字节的东西怎么在内存里储存呢?比如这有个两字节的东西:0xAA55,它放在内存里长什么样呢?

对此,不同的CPU有不同的方法,其中最流行的,是小端(intel采用这种模式)和大端。还有一些更为复杂的,什么网络序之类的,在此不提。把数按从高字节到低字节的顺序排列,一般的十六进制数都是天然按这种方法排列的,比如:0x12345678,它的高字节就是0x12,低字节就是0x78;如果按字节从高到低的顺序顺次写入内存,就叫大端,反之就是小端。

比如我要把0x12345678存储到0x100开头的四个字节。先把数按照从高到低字节顺序排列:0x12、0x34、0x56、0x78。大端按字节从高到低顺序写入内存,也就是0x100处存0x12,0x101处存0x34,0x102处存0x56,0x103处存0x78。小端则反过来,0x100处存0x78,0x101处存0x56,以此类推。

这两种排列方式孰优孰劣,我们还真不好判断。不过,用这种视角重新回看上面提到的FAT的“压缩”,或许你瞬间就能发现其不对劲之处:按照小端来解释,bcfade不仅不抽象,反而刚好是defabc的表示!也就是说,微软的这种编码反而很自然,FAT表变成了一个项正好1.5字节的数组。

这样一来,我要找第\(k\)个FAT项,只需给\(k\)乘1.5即可,这一过程又要被拆成乘3再除以2。如果想要找奇数项,和找偶数项实现上略有差别,因此用了cmpdx,0的判断(总算说回到代码了)。顺便一提,div指令如果发现你在试图除以一个16位数,将会把dx:ax当作被除数,商仍放在ax,余数放在dx。

这一下可扯得太太太太太远了,我们说回来。在判断奇偶的时候,使用了一个bOdd变量,它是在上一节被定义的。最终,执行流都会进入LABEL_EVEN。

LABEL_EVEN一上来把dx清零,这是为了避免已经没有用的余数影响接下来的除法。然后,把此时的ax再除以512,和刚才一样,商放在ax中表示距离FAT开头多少个扇区,余数放在dx中表示距离扇区开头的偏移。接下来要读取磁盘,由于dx被改变,需要暂存一下。接下来把ax加上第一个FAT起始位置的扇区号,得到它在磁盘中的真正位置,把cl设成2表示要读两个扇区。从上面的说明中可以知道这是为什么,如果这么快就忘了罚你从头再看一遍。

读完两个扇区以后把dx弹出来,然后加到bx上,此时的bx和原本一样,应该是0,所以此时addbx,dx就相当于movbx,dx。至于为什么要挪到bx上,是因为bx可以用来访问内存而dx不可以。接着,从es:bx,也就是读到的数据里拿到两个字节的FAT项,我们只需要其中的1.5字节,所以需要进行一些小小的处理。

接下来的五行,堪称是这一整段程序最巧妙的五行,充分利用了intel是小端的特性。

我们来手动模拟一下。我想要取第\(2k+1\)个FAT项,则要把它乘1.5,变成第\(3k+1\)字节(小数部分已舍去)开头的两个字节。同理,若要读取第\(2k\)个FAT项,则最终会搞到第\(3k\)个字节开头的两个字节。abc放在低位,是第\(2k\)个项,对应第\(3k\)个字节开头;def放在高位,是第\(2k+1\)个项,对应第\(3k+1\)个字节开头。我们来读两个字节看看,abc变成了fabc,def变成了defc。那么,对于奇数项而言,首先要右移四位;之后是奇偶项统一的操作,取低12位,这样就搞到了我们想要的FAT项。

代码里的五行,也正是这个逻辑。先判断是不是奇数,是奇数就右移四位,随后统一取低12位。

最后返回的时候,按照C调用约定默认ax是返回值,这里虽然写的是汇编无所谓,但是ax是参数,考虑到频繁调用,把ax当返回值自有其方便之处在。

这样一来,总算就把上面那个鬼函数讲完了。

从代码中也能看到,我们的常量喜加一,把下面的代码放到SectorNoOfRootDirectory后面:

代码4-2新常量的定义(boot.asm)

SectorNoOfFAT1equ1;第一个FAT表的开始扇区DeltaSectorNoequ17;由于前两个簇不用,所以SectorNoOfRootDirectory要-2再加上根目录区大小和簇号才能得到真正的扇区号,故把SectorNoOfRootDirectory-2封装成一个常量(17)可以看到,除了上文已经出现的常量以外,还定义了一个DeltaSectorNo,其作用已经在注释中阐明。

现在是时候加载并跳入Loader了:

代码4-3加载并跳入Loader(boot.asm)

接下来先读取扇区,然后从栈里弹出之前存的首簇号,用它来查找FAT项。如果是0xfff,则说明文件结束,进入LABEL_FILE_LOADED文件加载成功的分支;否则,存储现在的FAT项(待会接着查),这个FAT项同时也是当前簇,所以把它也转换成扇区号,准备进行下一轮读取;bx也向后移动一个扇区,然后开始读取下一个扇区的内容。

加载成功以后,自然是直接jmp进去。这里用的jmpxxx:xxx,同时修改代码段和下一条要执行的指令,就相当于进入了Loader里去了。前一个xxx是代码段的值,后一个xxx是下一条要执行的指令,它实际上也是一个寄存器,叫做EIP,平时只通过jmp、ret、call之类的语句修改。

下面就是编译运行了,如果成功的话,就会执行Loader的指令,在屏幕第一行正中央显示一个白色的L。运行结果如下:

(图4-1成功进入Loader)

屏幕第一行正中间出现了一个白色的L,我们成功了!这意味着我们摆脱了引导扇区的束缚,进入了Loader的广阔天地!

在进入保护模式之前,我们最后休整一下。首先用下列代码清屏,它位于movsp,BaseOfStack和xorah,ah之间:

代码4-4清屏(boot.asm)

movax,0600h;AH=06h:向上滚屏,AL=00h:清空窗口movbx,0700h;空白区域缺省属性movcx,0;左上:(0,0)movdx,0184fh;右下:(80,25)int10h;执行movdh,0callDispStr;Booting下面的代码用于在加载Loader之前打印Ready.

代码4-5打印Ready.(boot.asm)

LABEL_FILE_LOADED:movdh,1;打印第1条消息(Ready.)callDispStrjmpBaseOfLoader:OffsetOfLoader;跳入Loader!下图是运行结果:

(图4-2整理屏幕)

那么最后我们贴一下现在引导扇区的完整代码:

代码4-6完整的引导扇区(boot.asm)

代码5-2新版Loader(loader.asm)

引导扇区开头的部分也做了一点修改,因为FAT12的部分已经抽离出来了:

代码5-3引导扇区开头部分(boot.asm)

jmpshortLABEL_STARTnop;BS_JMPBoot由于要三个字节而jmp到LABEL_START只有两个字节所以加一个nop%include"fat12hdr.inc"LABEL_START:运行结果如下:

(图5-1不存在Kernel时的运行情况)

屏幕中出现了一行NoKERNEL,这是理所应当的,因为我们甚至连一个最简单的内核都没有写,马上来写一个:

代码5-4极简内核程序(kernel.asm)

[section.text]global_start_start:;此处假设gs仍指向显存movah,0Fhmoval,'K'mov[gs:((80*1+39)*2)],ax;第1行正中央,白色Kjmp$;死循环这里好像出现了很多我们之前的极简Loader没有的东西,这个global是什么,section.text又是什么东西,为什么一上来还要定义一个_start?

说实话,其实这些都和现在无关,完全是为了以后的考虑。前四节(包括这一节)我们一直在使用汇编,但更多的时候,我们为了方便理解甚至会使用C语言转写。如果未来能使用C语言,会不会方便得多?只是可惜,如果为了方便,继续使用纯二进制的话,写C恐怕会十分复杂,而且不一定能够成功(说多了都是泪.jpg)。

因此,我们为内核引入了一种可执行文件格式(当然不是我自己写的,我还没那个本事),叫做ELF,全称不想写,目前广泛应用于Linux以及自制操作系统中(题外话:现在的自制操作系统可执行文件基本都是ELF,少数使用PE,也就是微软家exe文件的格式,自创格式的几乎没有)。

既然有Linux撑腰,想要用它自然十分容易,在一开头就下载了i686-elf-tools-windows.zip(或者i386-elf-gccformac,Linux自己的gcc编译出来就是ELF),用它包办编译和链接即可。使用下面的命令,即可轻松编译出一个ELF来(mac用户把i686改成i386,linux用户去掉i686-elf,链接选项加上-melf_i386)。

nasm-felf-okernel.okernel.asmi686-elf-ld-s-okernel.binkernel.o写入的命令也要改一下:

edimgimgin:a.imgcopyfrom:loader.binto:@:copyfrom:kernel.binto:@:imgout:a.img这样就把kernel.bin也给写入到磁盘里来了。

唉唉唉,别想避重就轻,你还没解释那堆东西到底是什么玩意呢。

uhh,好吧。global_start和_start:是给链接器看的,以这种方式告诉链接器,ELF程序从这里开始执行(ELF程序的默认入口点都是_start,这是一个约定。或许有人会问:“那main是什么?难道不重要吗?”其实还真的不重要,看看第23节没准就能获得解答)。section.text是给ld看的,这样ld就会知道“哦,下面的部分都是代码而不是数据”,从而正确设置ELF。至于为什么能把section放进中括号这种取址用的东西里,据说是一部分伪指令的特性,带与不带中括号有一些奇妙的不同;不过在这篇教程的语境下,可以认为它们是一样的。

屏幕第四行出现了Ready.,意味着我们的内核已经被成功读入了,下面我们进入保护模式吧。在保护模式中我们只做两件事:重新放置内核并进入内核,也就是下一节的内容。

首先来说一下,什么是保护模式?一般而言,我们认为只要有GDT、cs是GDT选择子、cr0寄存器的PE位是1的时候,当前CPU就处于保护模式。至于GDT和cr0是什么,将在接下来阐明。保护模式分为16位和32位两种,不过16位保护模式非常少见(也不是不可以,只要设置16位代码段和数据段就可以了,一个flag的事),后文除非特别指明,默认保护模式是32位的。

进入保护模式总共分为6步:

1.准备GDT

2.加载GDT(lgdt)

3.关中断

4.打开A20地址线

5.将cr0的第0位置1(PE位)

6.通过一个jmp指令进入32位代码段

这其中又出现了很多生词,A20是啥,中断又是什么?再加上上面挖的坑,接下来我们一块填了。

首先是A20,它是一个什么东西呢?在曾经的CPU里,一共有20条地址线,编号为A0~A19,这样就可以访问到共计2^20=1MB的内存。但是,后来内存大了,20根地址线不够用了,到了80286时期,又涨到24根,这就衍生出了兼容性的问题(你看,又是兼容):早期的CPU对于超过1MB的内存会重新指回0x00,比如访问FFFF:FFFF并不会访问到预想中的0x10FFEF,而是会指回0xFFEF去。这又来了五根地址线,不就麻烦了么?

intel遂采取一种笨办法,既然多出来这一点会带来问题,那我找个地方,把新来的A20一关,不就行了么?你设置的地址是0x100000,但A20一关,实际上相当于不管你第20位是多少,通通把它当成0,于是1MB又变回了0x000000,这就暴力地兼容了以往把内存指回去的方案。80286还是16位,最大还是0x10FFEF的内存,所以关一个A20就够了;但80386以后加了32位,从而可以访问4GB内存,A21~A31根本没人管,但A20却还是默认关着,只有第20位受伤的世界打成了。如果直接进入32位模式而不去打开A20,那就相当于12MB、34MB、5~6MB等内存空间完全无法访问,因为这一位CPU不管,所以为了访问到全部内存,必须把A20打开。

唯一的问题就是把A20放在哪呢?请欣赏:兼容性问题的终极解决方案,键盘控制器——这里可谓人杰地灵,既要管理键盘,又要管理鼠标,甚至可以用键盘重启电脑,总之不差你一个A20。于是,intel就随便扒了一个键盘的空余引脚,用来控制A20。这么搞唯一的问题就是它实在太慢了,于是又衍生出更多打开A20的方案,包括但不限于使用int15h的扩展,以及访问其他端口等。我们使用的是0x92端口法,这个端口内的数值,第二位是1,则表示开启A20。

GDT的表项就没有这么简单了,它被称为描述符。下图是一个描述符结构的简图(节选自《Orange'S:一个操作系统的实现》):

DA_32EQU4000hDA_LIMIT_4KEQU8000hDA_DPL0EQU00hDA_DPL1EQU20hDA_DPL2EQU40hDA_DPL3EQU60hDA_DREQU90hDA_DRWEQU92hDA_DRWAEQU93hDA_CEQU98hDA_CREQU9AhDA_CCOEQU9ChDA_CCOREQU9EhDA_LDTEQU82hDA_TaskGateEQU85hDA_386TSSEQU89hDA_386CGateEQU8ChDA_386IGateEQU8EhDA_386TGateEQU8FhSA_RPL0EQU0SA_RPL1EQU1SA_RPL2EQU2SA_RPL3EQU3SA_TIGEQU0SA_TILEQU4PG_PEQU1PG_RWREQU0PG_RWWEQU2PG_USSEQU0PG_USUEQU4%macroDescriptor3dw%2&0FFFFhdw%1&0FFFFhdb(%1>>16)&0FFhdw((%2>>8)&0F00h)|(%3&0F0FFh)db(%1>>24)&0FFh%endmacro%macroGate4dw(%2&0FFFFh)dw%1dw(%3&1Fh)|((%4<<8)&0FF00h)dw((%2>>16)&0FFFFh)%endmacro上面用了一堆equ的语法的部分都是硬件规程。equ本质上相当于C++里的#define,即:#defineDA_320x4000之类的。(nasm里也有%define,但是用得好像很少,都被equ和%macro给包了)除此之外,唯一需要解释的可能就是Descriptor这一块了(Gate宏根本没有用到,所以也就不管它)。

从下面的代码可知,Descriptor的用法是:Descriptorxxx,xxx,xxx。再由前文可以知道,文本模式显存基址是0xb8000,与显存段一对比,显然第一个参数是段基址。第三个参数全是各种DA_混合在一块,显然是段属性,也就是GDT描述符结构那个图里,BYTE6和BYTE5去掉段界限的那一部分。而剩下的第二个参数,也就只能是段界限了。用这个宏最大的好处,无疑是简化了描述符的定义,看看其他的教程和书里是怎么定义描述符的就知道了,他们还在硬凹数位的时候,我们已经用上如此方便的宏了……(笑)不过这个宏也不是笔者的劳动成果,如此自夸怕是不太好。(前六节内容均基于《Orange'S:一个操作系统的实现》,有能力支持原作喵。至少就前六节而言,相当于这本书的二创了。)

这个宏怎么就能定义出一个描述符呢?先得解释这个奇怪的语法。这个东西是汇编里的宏,和C语言中的#define非常相似。第一行的%macro表示宏开始,Descriptor为宏名,4为接收参数数量,接收的参数从%1开始逐渐递增表示。

接下来这一部分,一直到%endmacro为止,就是宏的本体了,里面是纯粹的位运算。最后是一个%endmacro,表示宏结束。这里的宏就是纯粹的文本替换,也就是说,Descriptor0,0,0会被替换为:

dw0&0FFFFhdw0&0FFFFhdb(0>>16)&0FFhdw((0>>8)&0F00h)|(0&0F0FFh)db(0>>24)&0FFh什么,汇编居然有这么方便的位运算?那第四节shr、and半天在干什么呢?

事实上,只有在编译期间可以被计算的量,才能够用上这么方便的东西,具体而言,有且只有常数和标签对应的地址是可以在编译期立即知道的。你要是想对一个寄存器做这些,没门,用x86指令去;对内存,更没门,这块地方都不知道是不是归内存管(有的外设会在内存里开辟一段空间来,驱动程序通过读写这段内存与外设交互),哪能随便让你算了。

好了,话说回来,我们来看看这五行都在干什么。

首先写入两个字节的段界限低16位(%2是第二个参数表段界限),然后是两个字节的段基址低16位(%1是第一个参数表段基址),再往下是一个字节的段基址第16-23位。与上面的图对照,正好是BYTE0~BYTE4的内容。

接下来的BYTE5到BYTE6,用了一个dw来写入。首先把段界限右移8位,把原来第16~19位的位置变成第8~11位,也就是在BYTE5~BYTE6中它实际在的位置,然后用与运算把除了这四位以外的部分都设置成0。后面则是把第三个参数里,把段界限占领的部分变成0,最后把两个部分或在一起,拼成一个完整的BYTE5~BYTE6。最后是段基址的高8位,写在BYTE7。于是,这些位运算就这样把原来的三个参数拼成了内存里8字节的描述符。

下一步就是具体解释一下这个段寄存器里的值与GDT描述符之间的关系。事实上,这个段值也被称为选择子,下面是选择子的结构简图(同样节选自《Orange'S》):

(图5-4选择子结构)

当TI和RPL均为0时,不难发现,此时的整个选择子就是它对应的描述符的偏移(一个GDT占8字节。事实上也正是因为一个GDT占8字节,intel才敢在低三位塞点私货)。这两个小部分的作用后面还会提及,到第22节我们再揭晓。

那么下一个部分自然就是lgdt了,我们需要把下面的结构写入gdtr寄存器:

(图5-5gdtr结构)

这个也不难理解,我们只需要按照上图中的结构写入就可以了。唯一需要注意的是这一段内存会在保护模式下被访问,所以写汇编时有16位意义下段的相对地址,要被转化为原来的段基址乘以16再加上相对地址的绝对地址。

下一步就是关中断了。中断的具体内容我们放到后面第9、10节解释,此处我们只需要知道对于这个东西的处理保护模式另有安排,因此为了以后的重新设置,此处暂时关闭。

最后便是cr0,它属于控制寄存器(ControlRegister),共有四个(cr0+cr2~4)。下面是cr0的结构:

(图5-6cr0结构)

可以看到,cr0的最低位就是PE位,它的含义是:当它为1时,进入保护模式,当它为0时,为实模式。

那么以上部分我们就阐述清楚了,如果您不明白的话,看下面的代码大致就能明白了,它们在实际开发中位于LABEL_START之前:

代码5-6GDT表结构(loader.asm)

LABEL_GDT:Descriptor0,0,0;占位用描述符LABEL_DESC_FLAT_C:Descriptor0,0fffffh,DA_C|DA_32|DA_LIMIT_4K;32位代码段,平坦内存LABEL_DESC_FLAT_RW:Descriptor0,0fffffh,DA_DRW|DA_32|DA_LIMIT_4K;32位数据段,平坦内存LABEL_DESC_VIDEO:Descriptor0B8000h,0ffffh,DA_DRW|DA_DPL3;文本模式显存,后面用不到了GdtLenequ$-LABEL_GDT;GDT的长度GdtPtrdwGdtLen-1;gdtr寄存器,先放置长度ddBaseOfLoaderPhyAddr+LABEL_GDT;保护模式使用线性地址,因此需要加上程序装载位置的物理地址(BaseOfLoaderPhyAddr)SelectorFlatCequLABEL_DESC_FLAT_C-LABEL_GDT;代码段选择子SelectorFlatRWequLABEL_DESC_FLAT_RW-LABEL_GDT;数据段选择子SelectorVideoequLABEL_DESC_VIDEO-LABEL_GDT+SA_RPL3;文本模式显存选择子上述代码定义了gdt的同时,也定义了gdtr和选择子。不过需要注意的是,这其中我们用到了BaseOfLoaderPhyAddr,它的定义如下:

代码5-7新常量(load.inc)

BaseOfLoaderequ09000h;Loader的基址OffsetOfLoaderequ0100h;Loader的偏移BaseOfLoaderPhyAddrequBaseOfLoader*10h;Loader被装载到的物理地址BaseOfKernelFileequ08000h;Kernel的基址OffsetOfKernelFileequ0h;Kernel的偏移由于把BaseOfLoader和OffsetOfLoader也给搬进来了,boot.asm中的这一部分就可以删除了。因此,引导扇区和loader的前面几行也应当相应做出更改:

代码5-8引导扇区头部(boot.asm)

org07c00h;告诉编译器程序将装载至0x7c00处BaseOfStackequ07c00h;栈的基址jmpshortLABEL_STARTnop;BS_JMPBoot由于要三个字节而jmp到LABEL_START只有两个字节所以加一个nop%include"fat12hdr.inc"%include"load.inc"代码5-9Loader头部(loader.asm)

org0100h;告诉编译器程序将装载至0x100处BaseOfStackequ0100h;栈的基址jmpLABEL_START%include"fat12hdr.inc"%include"load.inc"%include"pm.inc"经过一番整理,虽然简化了一点代码,但别忘了我们最原始的目标仍没达成。下面我们首先创建32位代码段,它位于KillMotor之后。

代码5-1032位代码段(loader.asm)

[section.s32]align32[bits32]LABEL_PM_START:movax,SelectorVideo;按照保护模式的规矩来movgs,ax;把选择子装入gsmovah,0Fhmoval,'P'mov[gs:((80*0+39)*2)],ax;这一部分写入显存是通用的jmp$开头又是之前没有解释,糊弄过去的section。除了.text、.data这种有特殊意义的名字以外,剩下的名字都只是一种分割的表示,并没有实际的意义。下面的align32和bits32,则是先设置内存按32位模式对齐,然后告知nasm“已进入32位模式,以下指令请按照32位进行解读”。接下来在第0行正中央显示一个P,并没有什么太大的改变,只是gs由实模式的0B800h变成了保护模式的SelectorVideo。时刻记住,这样CPU会去查找GDT的段,并使用GDT的段基址来进行相对地址的访问。

下列代码用于进入保护模式。

代码5-11进入保护模式(loader.asm)

LABEL_FILE_LOADED:callKillMotor;关闭软驱马达movdh,1;"Ready."callDispStrlgdt[GdtPtr];下面开始进入保护模式cli;关中断inal,92h;使用A20快速门开启A20oral,00000010bout92h,almoveax,cr0oreax,1;置位PE位movcr0,eaxjmpdwordSelectorFlatC:(BaseOfLoaderPhyAddr+LABEL_PM_START);真正进入保护模式无非是按照上文的流程完整地做了一遍。重复一下,若一段内存在保护模式下被访问,则原来16位意义下段的相对地址,要被转化为原来的段基址乘以16再加上相对地址的绝对地址。所以,这里要给LABEL_PM_START加上BaseOfLoaderPhyAddr,后者是BaseOfLoader乘16的封装。

编译运行后,如果一切正常的话,运行结果应如下图:

(图5-7运行结果)

我们看到了白色的字母P,这说明我们已经进入了保护模式。如果您还是不放心,可以把jmp$换成int0,如果您的QEMU窗口中的文字开始无限变换,那么就说明我们成功进入了保护模式。

进入保护模式之后我们的目标只有一个,那就是随之跳入内核。不过,既然我们的内核是有格式的(ELF),我们需要先分析一下ELF格式到底长什么样子,如下图所示:

(图6-1ELF文件结构)

下面就是对ProgramHeader和ELF头的描述:

代码6-1ProgramHeader

#defineEI_NIDENT16typedefstruct{unsignedchare_ident[EI_NIDENT];//ELF特征标Elf32_Halfe_type;//文件类型Elf32_Halfe_machine;//运行至少需要的体系结构Elf32_Worde_version;//文件版本Elf32_Addre_entry;//程序的入口点Elf32_Offe_phoff;//ProgramHeader表的偏移Elf32_Offe_shoff;//SectionHeader表的偏移Elf32_Worde_flags;//对于32位系统为0Elf32_Halfe_ehsize;//ELFHeader的大小,单位字节Elf32_Halfe_phentsize;//ProgramHeader的大小Elf32_Halfe_phnum;//ProgramHeader的数量Elf32_Halfe_shentsize;//SectionHeader的大小Elf32_Halfe_shnum;//SectionHeader的数量Elf32_Halfe_shstrndx;//包含Section名称的字符串表位于哪一项}Elf32_Ehdr;其中的所有数据类型(Elf32_Word、Elf32_Off和Elf32_Addr)均为大小为4、对齐也为4的无符号类型,而Word为大整数,Off为偏移,Addr为地址。至于Elf32_Half(unsignedchar大家肯定很熟悉就不算了),它代表一个无符号中等大小整数,大小和对齐均为2字节。

由上图可知,给ELF头的地址加上e_phoff的偏移,后面就是ProgramHeader的数组,直接分段复制即可。

对于ELF的研究就到此为止,后续的细节我们在代码当中说明……但此时还有一个小问题,下图是目前的kernel.bin的样子。

(图6-2目前的kernel.bin)

我用蓝色标出来的位置,根据计算不难发现是e_entry,它已经位于0x8000000(128MB)以外(具体的数值为0x8048060,读者不妨自行验证),但根据我们的默认设置,我们的内存大小只有128MB。另一方面来讲我们可以通过分页来调低这个位置,但它的具体位置也是不可控的。

那么我们就只剩下一条路了:手动更改e_entry的值。事实上,这个过程只需要修改一下编译命令:

nasm-felf-okernel.okernel.asmi686-elf-ld-s-Ttext0x100000-okernel.binkernel.o我们把它的入口点定在了0x100000,因为这里刚好是1MB,可以避开前面错综复杂的势力。

说了这么半天,我们到底如何重新放置内核?根据前面的分析,我们只需要重复执行与下列C语句相同的指令即可:

代码6-3我们的目标

memcpy(p_vaddr,BaseOfKernelFilePhyAddr+p_offset,p_filesz);这时候我们忽然惊奇地发现,我们还没有内存拷贝用的函数,而且连保护模式下的堆栈都没有,甚至对各种段寄存器的处理都欠佳。不要紧,马上修改:

代码6-4修整保护模式(loader.asm)

[section.s32]align32[bits32]LABEL_PM_START:movax,SelectorVideo;按照保护模式的规矩来movgs,ax;把选择子装入gsmovax,SelectorFlatRW;数据段movds,axmoves,axmovfs,axmovss,axmovesp,TopOfStack;cs的设定已在之前的远跳转中完成jmp$MemCpy:;ds:参数2==>es:参数1,大小:参数3pushebpmovebp,esp;保存ebp和esp的值pushesipushedipushecx;暂存这三个,要用movedi,[ebp+8];[esp+4]==>第一个参数,目标内存区movesi,[ebp+12];[esp+8]==>第二个参数,源内存区movecx,[ebp+16];[esp+12]==>第三个参数,拷贝的字节大小.1:cmpecx,0;if(ecx==0)jz.2;goto.2;moval,[ds:esi];从源内存区中获取一个值incesi;源内存区地址+1movbyte[es:edi],al;将该值写入目标内存incedi;目标内存区地址+1dececx;拷贝字节数大小-1jmp.1;重复执行.2:moveax,[ebp+8];目标内存区作为返回值popecx;以下代码恢复堆栈popedipopesimovesp,ebppopebpret[section.data1]StackSpace:times1024db0;栈暂且先给1KBTopOfStackequ$-StackSpace;栈顶下面便是本节最后的工作了。首先我们重新放置内核:

代码6-5重新放置内核(loader.asm)

其中又有很多新的常量:

代码6-6新常量(load.inc)

BaseOfLoaderequ09000h;Loader的基址OffsetOfLoaderequ0100h;Loader的偏移BaseOfLoaderPhyAddrequBaseOfLoader*10h;Loader被装载到的物理地址BaseOfKernelFileequ08000h;Kernel的基址OffsetOfKernelFileequ0h;Kernel的偏移BaseOfKernelFilePhyAddrequBaseOfKernelFile*10h;Kernel被装载到的物理地址KernelEntryPointPhyAddrequ0x100000;Kernel入口点,一定要与编译命令一致!!!可能有聪明的读者就要问了:

所以为啥不Init完直接进呢???

有点仪式感(bushi),你看之前进Loader,一点仪式感没有,平平淡淡地就进了(

好那么我们最后重视一下这仪式感吧,下面是进入内核的远跳转,请用它代替jmp$:

代码6-7跳入内核(loader.asm)

jmpSelectorFlatC:KernelEntryPointPhyAddr运行结果如图:

运行地非常成功,这不仅代表着我们可以让汇编仅起辅助作用,更是我们的操作系统的一个重要成果。

但是我既没有说后面不用汇编,也没有说Loader的工作到此结束,事实上后面我们可能还要再对Loader进行一次大改。

那么我们就暂时维持着Kernel现在的样子,进入下一节的内容。

经过了六节不长不短的征程,我们总算是来到了内核之中。

首先,我们要让kernel获取很多东西的控制权,比如gdt,比如esp。这一部分肯定是要用到汇编的,但主体已经是C。

因此,我们要把kernel更改一下:

代码7-1内核改版(kernel.asm)

[section.bss];这里,为栈准备空间StackSpaceresb2*1024;2KB的栈,大概够用?StackTop:;栈顶位置[section.text]externkernel_main;kernel_main是C部分的主函数global_start;真正的入口点_start:movesp,StackTop;先把栈移动过来cli;以防万一,再关闭一次中断(前面进保护模式已经关闭过一次)callkernel_main;进入kernel_mainjmp$;从kernel_main回来了(一般不会发生),悬停然后呢?然后告诉大家一个好消息,我们可以开始用C啦!(鼓掌)但是坏消息是,这里的C不能用标准库(因为某些原因),所以我们只能自力更生了。

所以,我们应当先把基础设施搭建起来,在这里我指的是基本的整数类型。虽然整数类型可以直接用,但unsignedint之流毕竟还是太长了。

所以,新建common.h,我们要开始定义了。

代码7-2基础设施(common.h)

#ifndefCOMMON_H#defineCOMMON_Htypedefunsignedintuint32_t;typedefintint32_t;typedefunsignedshortuint16_t;typedefshortint16_t;typedefunsignedcharuint8_t;typedefcharint8_t;typedefint8_tbool;#definetrue1#definefalse0voidoutb(uint16_tport,uint8_tvalue);voidoutw(uint16_tport,uint16_tvalue);uint8_tinb(uint16_tport);uint16_tinw(uint16_tport);#defineNULL((void*)0)#endif这里除了定义了整数类型、布尔类型和NULL外,还有四个I/O端口操作函数。正如我们在平常写app时一样,新建common.c,我们来添加实现:

代码7-3端口操作(common.c)

#include"common.h"voidoutb(uint16_tport,uint8_tvalue){asmvolatile("outb%1,%0"::"dN"(port),"a"(value));//相当于outvalue,port}voidoutw(uint16_tport,uint16_tvalue){asmvolatile("outw%1,%0"::"dN"(port),"a"(value));//相当于outvalue,port}uint8_tinb(uint16_tport){uint8_tret;asmvolatile("inb%1,%0":"=a"(ret):"dN"(port));//相当于inval,port;returnval;returnret;}uint16_tinw(uint16_tport){uint16_tret;asmvolatile("inw%1,%0":"=a"(ret):"dN"(port));//相当于inval,port;returnval;returnret;}怎么样,看上去很不好懂是不是?这玩意叫做内联汇编,这么复杂的用法只此一次,后面哪怕用也不会这么复杂了。至于它具体的用法,可自行百度,在此略过不提。

(说白了其实就是我也看不懂这坨史,只好把它们抄下来)

虽然它们定义起来很麻烦,但用还是很好用的,我们很快就会看到。

接下来,看看本节的标题,我们继续向着实现打印函数的目标前进。

代码7-4打印函数头文件(monitor.h)

#ifndef_MONITOR_H_#define_MONITOR_H_#include"common.h"voidmonitor_put(charc);//打印字符voidmonitor_clear();//清屏voidmonitor_write(char*s);//打印字符串voidmonitor_write_hex(uint32_thex);//打印十六进制数voidmonitor_write_dec(uint32_tdec);//打印十进制数#endif从名字和注释上看,应该还是挺好懂的吧。这里同时提供十六进制打印和十进制打印,十六进制对于地址等情况十分便利,而对于我们这些用惯了十进制的人而言,打印十进制会更有亲和力。

接下来,我们将实施“四步走”战略,逐步完成打印函数的实现。

第一步:移动光标

我们之前操作光标,用的都是int10h。现在进入了保护模式,int10h不能用了,怎么办?

换一个角度来想,光标是在显示器上跳动的,所以显示器必然有调整光标的方法。猜对啦,我们正是要操纵显卡来移动光标。

新建monitor.c,加入如下定义:

代码7-5基本定义与光标移动(monitor.c)

第二步:滚屏操作

在平常用shell的时候,当光标到了最后一行,我们还要按enter,那么shell内部的文字将自动滚动。这个过程我们称为滚屏。

显然,如果我们自己的OS在打印时也能自动滚屏就好了。其实,实现滚屏并不太难:

代码7-6滚屏(monitor.c)

//文本控制台共80列,25行(纵列竖行),因此当y坐标不低于25时就要滚屏了staticvoidscroll()//滚屏{uint8_tattributeByte=(0<<4)|(15&0x0F);//黑底白字uint16_tblank=0x20|(attributeByte<<8);//0x20->空格这个字,attributeByte<<8->属性位if(cursor_y>=25)//控制台共25行,超过即滚屏{inti;for(i=0*80;i<24*80;i++)video_memory[i]=video_memory[i+80];//前24行用下一行覆盖for(i=24*80;i<25*80;i++)video_memory[i]=blank;//第25行用空格覆盖cursor_y=24;//光标设置回24行}}这样,只要调用scroll,显示器就会自动判断是否需要滚屏;如果需要滚屏,则立即执行滚屏,但这一过程并不会重新设置光标位置。

第三步:打印单个字符、打印字符串、清屏

打印字符串无非是不断重复打印单个字符的过程,因此这一步的重点还是在打印字符上。

打印字符本身并不难,难的是随之而来的各种判断,比如对各种转义字符的处理,对不可见字符(也就是在ASCII里,但我们根本看不见的字,比如换行其实是一个单独的字符,但我们看不见,只能看见渲染时候分行了)的处理,等等。

总之,下面就是打印单个字符的函数。

代码7-7打印单个字符(monitor.c)

location=video_memory+(cursor_y*80+cursor_x);//当前光标处就是写入字符位置*location=c|attribute;//低8位:字符本体,高8位:属性,黑底白字接下来便是打印字符串,它不过是对打印字符的简单重复:

代码7-8打印字符串(monitor.c)

voidmonitor_write(char*s){for(;*s;s++)monitor_put(*s);//遍历字符串直到结尾,输出每一个字符}这一步还剩下最后一个任务,实现清屏。说白了,清屏不过就是把全屏都打印上空格,然后把光标放到左上角而已。

代码7-9清屏(monitor.c)

voidmonitor_clear(){uint8_tattributeByte=(0<<4)|(15&0x0F);//黑底白字uint16_tblank=0x20|(attributeByte<<8);//0x20->空格这个字,attributeByte<<8->属性位for(inti=0;i<80*25;i++)video_memory[i]=blank;//全部打印为空格cursor_x=0;cursor_y=0;move_cursor();//光标置于左上角}至此,最基本的打印函数已经成型。其实这里已经可以测试了,但还有两个函数,我们总不能放着不管。

第四步:输出整数

这一步我们要更进一步,在基础打印函数的基础上实现十六进制和十进制数的输出。我们从易到难,从十进制数开始。

OIer基本都知道,在OI中,有一套东西,叫做快读快写。而现在,没有cout,没有printf,还想输出十进制数,快写正好可以胜任。

在这里,我们使用最简单的一版快写——递归版,它的代码并不长,仅有三行:

代码7-10十进制数打印(monitor.c)

voidmonitor_write_dec(uint32_tdec){intupper=dec/10,rest=dec%10;if(upper)monitor_write_dec(upper);monitor_put(rest+'0');}还是挺好懂的吧,先输出高位,再把最后一位输出出来。

十六进制相比十进制要难上一点,因为我们希望在输出十六进制的时候有一个0x前缀,这样就不能直接用递归了(不过硬要用递归也可以,写起来肯定比循环短)。

代码7-11十六进制打印(monitor.c)

voidmonitor_write_hex(uint32_thex){charbuf[20];//32位最多0xffffffff,20个都多了char*p=buf;//用于写入的指针charch;//当前十六进制字符inti,flag=0;//i->循环变量,flag->前导0是否结束*p++='0';*p++='x';//先存一个0xif(hex==0)*p++='0';//如果是0,直接0x0结束else{for(i=28;i>=0;i-=4){//每次4位,0xF=0b1111ch=(hex>>i)&0xF;//0~9,A~F//28的原因是多留一点后路(if(flag||ch>0){//跳过前导0flag=1;//没有前导0就把flag设为1,这样后面再有0也不会忽略ch+='0';//0~9=>'0'~'9'if(ch>'9'){ch+=7;//'A'-'9'=7}*p++=ch;//写入}}}*p='\0';//结束符monitor_write(buf);}具体如上,配合注释还是比较好懂的。至此,我们的“四步走”战略胜利完成。

最后的最后,自然是功能测试。新建main.c,如此这般:

#include"monitor.h"voidkernel_main()//kernel.asm会跳转到这里{monitor_clear();//先清屏monitor_write("Hello,kernelworld!\n");//验证write_hex和write_dec,由于没有printf,这一步十分烦人monitor_write_hex(0x114514);monitor_write("=");monitor_write_dec(0x114514);monitor_write("\n");//悬停while(1);}虽然前面我们一直在写C,但是忽略了一个问题,那就是怎么编译的问题。如果操作正确,在第0节您应该下载了i686-elf-tools(或者linux的gcc),如此这般编译:

i686-elf-gcc-c-O0-fno-builtin-fno-stack-protector-omonitor.omonitor.c这是编译monitor.c的示例。现在总的编译命令太长了,总共有这么多:

nasmboot.asm-oboot.binnasmloader.asm-oloader.bini686-elf-gcc-c-O0-fno-builtin-fno-stack-protector-omonitor.omonitor.ci686-elf-gcc-c-O0-fno-builtin-fno-stack-protector-ocommon.ocommon.ci686-elf-gcc-c-O0-fno-builtin-fno-stack-protector-omain.omain.cnasm-felf-okernel.okernel.asmi686-elf-ld-s-Ttext0x100000-okernel.binkernel.ocommon.omonitor.omain.oedimgimgin:a.imgcopyfrom:loader.binto:@:copyfrom:kernel.binto:@:imgout:a.imgqemu-system-i386-fdaa.img足足9条,随着文件越来越多它还会水涨船高,下一节我们来解决一下这个问题。

不过在此之前,我们还是要看看我们成果如何。把上面的那坨命令粘贴到命令行,QEMU窗口应如下图:

好,成功运行!

本来想继续写代码的,但上一节的9条编译命令还是有些让人发怵:以后文件还会越来越多,难道就任由它这么发展下去?

况且,现在我们的根目录长这样:

各个部分堆在一起,杂乱无章,我们还是应该先整理一下根目录再说。

按照不同功能和部分的划分,我们把它这样分割:

这样一分就很舒服了,但是编译命令也由此变成了彻底的地狱。下面就让我们引入在Linux下十分常见的自动编译工具——Makefile。

经常在Linux下装软件的朋友应该都知道,有的时候部分app只提供源代码,这样就只能按下面的三部曲安装:

./configuremakesudomakeinstall这三步是什么意思呢?第一步./configure,生成Makefile;第二步make,用make工具调用Makefile编译;第三步sudomakeinstall,用make工具调用Makefile安装。

Makefile的实际应用比较复杂,我们这里只讲最简单的部分。一个Makefile是由多个块组成的,每个块的结构如下:

result:whatyouneed[TAB]command需要注意的是,每个块的command部分必须以TAB开头,而在cnblogs编辑器里打不出TAB(会被自动替换为空格),因此只能用[TAB]来提示一下了,望大家谅解。

举个例子,如果我们想要把kernel/monitor.c编译为out/monitor.o,我们应该怎样写出一个块呢?答案是这样:

out/monitor.o:kernel/monitor.c[TAB]i686-elf-gcc-Iinclude-c-O0-fno-builtin-fno-stack-protector-oout/monitor.okernel/monitor.c新出现了-Iinclude的选项,这是因为所有的头文件都在include文件夹下,需要这样才能被gcc识别。

诶诶且慢,要是每个文件都要这么来一下,那不还是没有解决问题么?

GNU那帮人其实早就替我们想好啦,我们只需要先写好这么一个模板,然后进行一个替换:

out/%.o:kernel/%.c[TAB]i686-elf-gcc-Iinclude-c-O0-fno-builtin-fno-stack-protector-oout/$*.okernel/$*.c这段代码与上面的Makefile并没有什么不同,只是把command中的monitor变成了$*,把第一行的monitor变成了%而已。但这样一改,Makefile就会对所有你要求编译的程序进行编译啦。

好,下面我们就继续进行对汇编的操作,其实和处理C几乎完全相同:

out/%.o:kernel/%.asm[TAB]nasm-felf-oout/$*.okernel/$*.asmout/%.bin:boot/%.asm[TAB]nasm-Iboot/include-oout/$*.binboot/$*.asm注意到boot.bin和loader.bin由汇编直接编译,所以我们也把它放在这了。添加-Iboot/include的原因和添加-Iinclude的原因相同,这里不多说了。

下面还剩下最后几步。首先,眼尖的读者可能已经发现了,在几段之前有这样一句话:

但这样一改,Makefile就会对所有你要求编译的程序进行编译啦。

那Makefile怎么知道我们要编译哪些程序呢?分两种方案:

第一种,命令行指定。通过makexxx.o或makexxx.bin,即可编译对应的文件。但如果这样,就又回到之前的问题了。

第二种,可以在一个块的whatyouneed部分指定,然后makeresult。这样,在makeresult的时候,make会发现whatyouneed还不存在,于是就会自动编译了。

看来第二种比较适合我们。不过,我们还没有链接kernel.bin,所以可以先拿它试试手:

out/kernel.bin:$(OBJS)[TAB]i686-elf-ld-s-Ttext0x100000-oout/kernel.bin$(OBJS)这里新出现了$(OBJS),它实际上就是Makefile里的变量。在Makefile的开头添加一行:

OBJS=out/kernel.oout/common.oout/monitor.oout/main.o以后我们再增加新文件,就只需要在这里加一个out/xxx.o,比之前可方便多了。

下面我们来进行测试,在命令行里输入makeout/kernel.bin:

只一下,kernel.bin便编译完成了。Makefile的确方便哪(笑)。

但下面就是难点了:写盘操作在Windows和Linux下完全不一致。而想要判断当前操作系统是什么,并不简单,这正是Makefile的局限性。(网上方法大都依赖uname,但Windows没有uname,所以会报错退出;所有依赖报错的方法会直接导致make终止,因此只能用其他语言的其他方法,如Python的os.name。)

没办法,由于笔者是Windows机,所以我只好用Windows的方法写盘了。

a.img:out/boot.binout/loader.binout/kernel.bin[TAB]ddif=out/boot.binof=a.imgbs=512count=1[TAB]edimgimgin:a.imgcopyfrom:out/loader.binto:@:copyfrom:out/kernel.binto:@:imgout:a.img或许之前没提,command部分可以有多条命令哦。

现在,makea.img,效果如下:

我们最后再加一条命令,makerun,用于一步到位运行操作系统。

run:a.img[TAB]qemu-system-i386-fdaa.img执行makerun,效果如图:

呼,经过一整节的整理,我们总算是有了一个可靠的自动编译系统。下面,我们就继续回到coding之中。

早在第5节,笔者其实就已经说过GDT到底是个什么东西了。但是,当时说得不够明确,语焉不详,因此在这里重新说一遍。

自8086时代以来,内存一直是一个PC上必不可少的物件。而在8086的时代,intel的大叔们拍着胸脯说:“内存绝不会超过1MB!”

然而,哪怕在当时,16位的寄存器最多也只能寻址64KB。于是,intel的大叔们想出了一种绝妙的方法,再加一组16位寄存器,叫做段寄存器,也就是ds、es、fs、gs、ss,这样在寻址时,给段寄存器乘16,再加上原本的地址,就有了64KB*16+64KB=1088KB的寻址空间,比1MB刚刚超过一点。剩下的64KB,intel的大叔们选择让它们指回0~64KB,完美!

进入32位之后,由32位寄存器来寻址,寻址空间可达4GB,再这么维持下去就不够用了。同时,32位模式又称“保护”模式,现有的方法也不足以进行“保护”,这就迫切地需要对段进行改革。

改革的具体方法如下。首先是段寄存器,它们不再是乘以16的这么一个代表,而是一个选择子,结构如下:

其中的TI和RPL正是这种改革引入的新东西,后面还要讲到,在这里不多说。多说几句的是剩下的12位,它代表的是描述符索引。何为描述符?GDT全称GlobalDescriptorTable(全局描述符表),其实就是GDT的表项。

好,段寄存器改革完毕了,但段本身也要进行改革,它不能再只代表一段连续的内存了。事实上,为了尽力压缩空间,intel的大叔们还是花了相当的功夫的,但最后也就形成了一种十分畸形的结构:

所谓前人挖坑,后人兼容,屎山大都是这么堆起来的,这种结构一直保存到现在的64位(笑)……不说别的了,我们来考虑些更加现实的问题。

早在Loader的阶段,我们已经设置过GDT,不过它的样子大家恐怕都已经忘完了吧。所以我们需要把GDT移到内核来控制。

GDT还有另外一个作用,那就是IDT需要依赖GDT提供的代码段选择子进行设置,所以必须先设置GDT才能设置IDT。

那么,我们开始吧。依照上面的结构,新建gdtidt.h,定义GDT描述符如下:

代码9-1GDT描述符(include/gdtidt.h)

structgdt_entry_struct{uint16_tlimit_low;//BYTE0~1uint16_tbase_low;//BYTE2~3uint8_tbase_mid;//BYTE4uint8_taccess_right;//BYTE5,P|DPL|S|TYPE(1|2|1|4)uint8_tlimit_high;//BYTE6,G|D/B|0|AVL|limit_high(1|1|1|1|4)uint8_tbase_high;//BYTE7}__attribute__((packed));typedefstructgdt_entry_structgdt_entry_t;由于C语言编译器的对齐机制,如果什么都不做,会导致GDT的表项与硬件不符,因此需要加入__attribute__((packed))禁用对齐功能。下面那个typedef仅仅是为了看着方便。

CPU如何知道GDT的更改呢?这需要通过一个汇编指令:lgdt[addr],它可以从addr处读取六个字节作为新的GDTR寄存器,从而告知CPU新的GDT位置。

GDTR的结构在前图5-5中有过标明,这里再放一遍:

(图9-3gdtr结构)

以下是C语言定义的GDTR结构:

代码9-2GDT描述符(include/gdtidt.h)

structgdt_ptr_struct{uint16_tlimit;uint32_tbase;}__attribute__((packed));typedefstructgdt_ptr_structgdt_ptr_t;出于同样的理由,我们使用了__attribute__((packed))。

#include"common.h"#include"gdtidt.h"externvoidgdt_flush(uint32_t);gdt_entry_tgdt_entries[4096];gdt_ptr_tgdt_ptr;紧接着是写入GDT表项的函数如下:

代码9-4写入GDT表项(kernel/gdtidt.c)

接下来,我们来初始化整个GDT表,同样位于gdtidt.c:

代码9-5初始化GDT(kernel/gdtidt.c)

staticvoidinit_gdt(){gdt_ptr.limit=sizeof(gdt_entry_t)*4096-1;//GDT总共4096个描述符,但我们总共只用到3个gdt_ptr.base=(uint32_t)&gdt_entries;//基地址gdt_set_gate(0,0,0,0);//占位用NULL段gdt_set_gate(1,0,0xFFFFFFFF,0x409A);//32位代码段gdt_set_gate(2,0,0xFFFFFFFF,0x4092);//32位数据段gdt_flush((uint32_t)&gdt_ptr);//刷新gdt}voidinit_gdtidt(){init_gdt();//目前只有gdt}这个0x409A、0x4092就纯靠死记硬背了,硬件规程如此。

最后是这个gdt_flush,代码如下:

代码9-6刷新GDT(lib/nasmfunc.asm)

[globalgdt_flush]gdt_flush:moveax,[esp+4];根据C编译器约定,C语言传入的第一个参数位于内存esp+4处,第二个位于esp+8处,以此类推,第n个位于esp+n*4处lgdt[eax];加载gdt并重新设置;接下来重新设置各段movax,0x10movds,axmoves,axmovfs,axmovgs,axmovss,ax;所有数据段均使用2号数据段jmp0x08:.flush;利用farjmp重置代码段为1号代码段并刷新流水线.flush:ret;完成注释里提到了一个“编译器约定”,这个约定是传参数时候的约定;而在函数调用上,硬件本身也有一些规定,在这里一并说一说。

无论是在哪种模式下,但凡调用函数,基本都涉及一个返回的问题。既然要返回,我就得时时刻刻知道要返回到什么位置,返回地址这个东西就得保存,保在什么地方呢?intel方面选择了esp的位置,也就是当前栈顶就是待返回的位置。

接下来一个自然的想法,就是把函数有关的东西全都放在栈里。由于32位模式地址可达4字节,因此下一个可用的位置是esp+4,这个参数又可以占用4字节,下一个可用的位置就是esp+8,以此类推。事实上,这正是gcc传参使用的模式(其实也可以指定gcc使用寄存器,__attribute__((regparm(xxx)))其中xxx表示使用寄存器传参的个数)。

需要注意的是,返回的流程实际上就是jmp[esp](默认栈平衡由被调用方保证,因此在最后栈顶应该回归到返回地址处)这么一个流程(当然还包括一些细节操作实际上比这复杂),所以只要随便找个地方写一个地址,然后把那个地方设成栈顶再调用ret,即使根本没有调用函数,一样可以起到“从函数返回”的效果。

这样的操作有什么用呢?在同一个地址段当然没什么用,这样做甚至有些多余(完全可以直接jmp)。这样做真正的用途,是在后面第22节的启动应用程序,以及64位模式下的时候,操作系统层级的farjmp(直接jmp到其他地址段,使用例:jmpnew_cs:new_ip)/farcall(直接call其他地址段的函数,使用例:callnew_cs:new_eip)被禁用,只能使用这样的方式来代替直接跳转。同样地,farcall需要对应farret来返回,而farjmp则和普通jmp一样一般不考虑返回。

那么这种farjmp/farcall与普通的jmp/call有什么区别呢?首先无疑是同时改变了cs的值,而cs除开这两种方法外,就只剩下farret一种改变方法了。硬说还有什么区别,也就只剩下栈了。farjmp对栈倒是没什么改动,farcall则会在栈里同时存一下几样东西:

esp->返回时eip;esp-4->返回时cs;esp-8->返回时esp;esp-12->返回时ss。

而在farret的时候,也就会把这四样东西从栈里弹出,最后进行一个相当于farjmp的操作,把cs和eip变“回去”。按照上面ret的道理,同样可以在栈里提前push好这四样东西,然后执行一个farret(这个直接retf就行)同时设置好这四个寄存器的同时更改执行流。

扯得有点远了,稍微往回收一收。在接下来的操作应该都不难懂,最难以理解的地方也就是lgdt[eax]了。这是因为我们传入的是gdt_ptr结构体的地址,需要加一个[gdt_ptr]来获得它对应内存里的具体数值。再往下直接mov和上面解释过的farjmp应该没什么需要说的,由于GDT发生改动,所以需要重新设置段寄存器的值。

由于新增了一个文件夹,在这里顺便更新一下Makefile:

代码9-7现在的Makefile(Makefile)

OBJS=out/kernel.oout/common.oout/monitor.oout/main.oout/gdtidt.oout/nasmfunc.oout/%.o:kernel/%.ci686-elf-gcc-c-Iinclude-O0-fno-builtin-fno-stack-protector-oout/$*.okernel/$*.cout/%.o:kernel/%.asmnasm-felf-oout/$*.okernel/$*.asmout/%.o:lib/%.ci686-elf-gcc-c-Iinclude-O0-fno-builtin-fno-stack-protector-oout/$*.olib/$*.cout/%.o:lib/%.asmnasm-felf-oout/$*.olib/$*.asmout/%.bin:boot/%.asmnasm-Iboot/include-oout/$*.binboot/$*.asmout/kernel.bin:$(OBJS)i686-elf-ld-s-Ttext0x100000-oout/kernel.bin$(OBJS)a.img:out/boot.binout/loader.binout/kernel.binddif=out/boot.binof=a.imgbs=512count=1edimgimgin:a.imgcopyfrom:out/loader.binto:@:copyfrom:out/kernel.binto:@:imgout:a.imgrun:a.imgqemu-system-i386-fdaa.img同样,使用macOS/Linux跟随本教程学习的需要自行更改i686-elf-gcc、i686-elf-ld以及对a.img进行写入的行为,所有的内容均在第0、1、8节有所介绍,在此不多赘述。

接下来,修改main.c用于测试现在的GDT是否有效:

代码9-8对重设GDT的测试(kernel/main.c)

#include"monitor.h"#include"gdtidt.h"voidkernel_main()//kernel.asm会跳转到这里{init_gdtidt();monitor_clear();//先清屏monitor_write("Hello,kernelworld!\n");//验证write_hex和write_dec,由于没有printf,这一步十分烦人monitor_write_hex(0x114514);monitor_write("=");monitor_write_dec(0x114514);monitor_write("\n");//悬停while(1);}编译,运行,效果仍应如图8-6所示。若您的qemu文字开始无限变换,请检查您的代码是否在运输途中出现了一些问题(?)

好了,一刻都没有为GDT的更改如此平淡而哀悼,立刻赶到现场的是——IDT!

事实上,前面提到的qemu内部文字的无限变换是底层CPU无限重启的现象,而造成它无限重启的根本原因则是找不到对应的异常处理程序。因此,现在的当务之急是为所有异常设置对应的异常处理程序,这就需要IDT了。

如果说重新设置GDT的原因是它位于Loader内,极不可控,那么重设IDT的原因就是,现在的IDT根本就是啥都没有。

与GDT相同,IDT的每一个表项也叫做描述符,不过为了与GDT的描述符区分,一般称IDT的表项为中断描述符。由于中断描述符结构极其简单,此处不贴图。

与GDT类似,让CPU知道IDT在哪的方法是用lidt指令设置一个IDTR寄存器,其结构与GDTR寄存器完全一致。

中断描述符与IDTR寄存器结构定义如下:

代码9-9IDT表项与IDTR(include/gdtidt.h)

由于没有烦人的一坨坨,IDT表项的设置十分简单:

代码9-10设置中断描述符、初始化IDT(kernel/gdtidt.c)

externvoid*intr_table[48];staticvoididt_set_gate(uint8_tnum,uint32_toffset,uint16_tsel,uint8_tflags){idt_entries[num].offset_low=offset&0xFFFF;idt_entries[num].selector=sel;idt_entries[num].dw_count=0;idt_entries[num].access_right=flags;idt_entries[num].offset_high=(offset>>16)&0xFFFF;}staticvoidinit_idt(){idt_ptr.limit=sizeof(idt_entry_t)*256-1;idt_ptr.base=(uint32_t)&idt_entries;memset(&idt_entries,0,sizeof(idt_entry_t)*256);for(inti=0;i<32;i++){idt_set_gate(i,(uint32_t)intr_table[i],0x08,0x8E);}idt_flush((uint32_t)&idt_ptr);}这里构建了一个intr_table,到时候会在汇编里使用奇妙的小手段构建这个数组,现在先忘掉它吧,当它不存在。至于具体的设置,0x08表示内核代码段,0x8E的含义无需了解,但是只有这样设置才能正确设置异常处理程序。

代码9-11idt_flush(lib/nasmfunc.asm)

[globalidt_flush]idt_flush:moveax,[esp+4]lidt[eax]ret接下来,就是对这32个异常处理程序进行编写了。其实它们当中的相当一部分都是重复的。具体而言,先是要对中断环境进行保存,使CPU知道异常发生时的基本错误信息;然后,是调用对应的高层异常处理程序,这一部分可以用C语言完成。

因此,我们可以写出一个模糊的异常处理程序框架:

代码9-12模糊框架(无文件)

%macroISR1[globalisr%1]isr%1:push%1;使处理程序知道异常号码jmpisr_common_stub;通用部分%endmacro这个%macro在第五节已经出现过,如果忘了罚你重读。

这个宏的展开比较有意思。例如,ISR0展开后为:

[globalisr0]isr0:push0jmpisr_common_stub大概如此,汇编里的宏比#define简单一些,没有#和##之类的奇怪东西,想拼接直接写在后面就可以了。

其实,这一部分离真正的框架已经相当近了。之所以不完全正确,是因为有的异常有错误码,而有的异常没有,我们需要让栈中的结构保持统一。这就需要我们在没有错误码的异常中压入一个假的错误码。

查询资料可知,第8、10~14、17、21号异常有错误码,其余异常无错误码,我们需要对其他的异常进行特别关照。

综上,我们得出的基本框架如下:

代码9-13真实框架?(kernel/interrupt.asm)

%macroISR_ERRCODE1[globalisr%1]isr%1:push%1;使处理程序知道异常号码jmpisr_common_stub;通用部分%endmacro%macroISR_NOERRCODE1[globalisr%1]isr%1:pushbyte0;异常错误码是四个字节,这里只push一个字节原因未知push%1;使处理程序知道异常号码jmpisr_common_stub;通用部分%endmacroISR_NOERRCODE0ISR_NOERRCODE1ISR_NOERRCODE2ISR_NOERRCODE3ISR_NOERRCODE4ISR_NOERRCODE5ISR_NOERRCODE6ISR_NOERRCODE7ISR_ERRCODE8ISR_NOERRCODE9ISR_ERRCODE10ISR_ERRCODE11ISR_ERRCODE12ISR_ERRCODE13ISR_ERRCODE14ISR_NOERRCODE15ISR_NOERRCODE16ISR_ERRCODE17ISR_NOERRCODE18ISR_NOERRCODE19ISR_NOERRCODE20ISR_ERRCODE21ISR_NOERRCODE22ISR_NOERRCODE23ISR_NOERRCODE24ISR_NOERRCODE25ISR_NOERRCODE26ISR_NOERRCODE27ISR_NOERRCODE28ISR_NOERRCODE29ISR_NOERRCODE30ISR_NOERRCODE31现在我们再来考虑构建intr_table的事。难道是要靠dd这一堆isr函数么?事实上,通过利用section的特性,可以轻易做到这一点:

代码9-14真实框架(kernel/interrupt.asm)

section.dataglobalintr_tableintr_table:%macroISR_ERRCODE1section.textisr%1:push%1;使处理程序知道异常号码jmpisr_common_stub;通用部分section.dataddisr%1%endmacro%macroISR_NOERRCODE1section.textisr%1:pushbyte0;异常错误码是四个字节,这里只push一个字节原因未知push%1;使处理程序知道异常号码jmpisr_common_stub;通用部分section.dataddisr%1%endmacro这是个什么原理?事实上,通过划分section,nasm就知道“哦,这些是代码,这些是数据,要分开存放”。于是,代码会按出现顺序合并,数据也会按出现顺序合并,相当于是编译器帮我们代劳了分开程序主体和程序位置的工作。这样,在intr_table中就是纯净的函数了。

接下来,是isr_common_stub。这个东西写起来不麻烦,无非是保存和还原中断环境而已:

代码9-15异常处理公共部分(kernel/interrupt.asm)

section.text[externisr_handler];将会在isr.c中被定义;通用中断处理程序isr_common_stub:pusha;存储所有寄存器movax,dspusheax;存储dsmovax,0x10;将内核数据段赋值给各段movds,axmoves,axmovfs,axmovgs,axcallisr_handler;调用C语言处理函数popeax;恢复各段movds,axmoves,axmovfs,axmovgs,axpopa;弹出所有寄存器addesp,8;弹出错误码和中断IDiret;从中断返回这个消掉了,又出现一个isr_handler,真是麻烦。不过这场打地鼠的游戏也要迎来收尾了,而且另一个好消息是,这个东西是用C语言写的,代码如下:

代码9-16真正的异常处理部分(kernel/isr.c)

#include"monitor.h"#include"isr.h"voidisr_handler(registers_tregs){asm("cli");monitor_write("receivedinterrupt:");monitor_write_dec(regs.int_no);monitor_put('\n');while(1);}这里为什么要cli和while(1);呢?一般出现异常时已经无可挽回,因此直接悬停在处理程序里即可。cli防止下一节要设置的外部中断来烦人。

代码9-17registers_t的定义(kernel/isr.h)

#ifndef_ISR_H_#define_ISR_H_#include"common.h"typedefstructregisters{uint32_tds;uint32_tedi,esi,ebp,esp,ebx,edx,ecx,eax;uint32_tint_no,err_code;uint32_teip,cs,eflags,user_esp,ss;}registers_t;#endifgdtidt.c的开头也要作修改:

代码9-18新版gdtidt开头,替换至gdt_set_gate之前(kernel/gdtidt.c)

#include"common.h"#include"gdtidt.h"externvoidgdt_flush(uint32_t);externvoididt_flush(uint32_t);gdt_entry_tgdt_entries[4096];gdt_ptr_tgdt_ptr;idt_entry_tidt_entries[256];idt_ptr_tidt_ptr;注意到init_idt中用到了memset,为此将后续会用到的字符串/内存操作函数统一copy进来,组合成lib/string.c:

代码9-19字符串操作函数(lib/string.c)

#include"common.h"void*memset(void*dst_,uint8_tvalue,uint32_tsize){uint8_t*dst=(uint8_t*)dst_;while(size-->0)*dst++=value;returndst_;}void*memcpy(void*dst_,constvoid*src_,uint32_tsize){uint8_t*dst=dst_;constuint8_t*src=src_;while(size-->0)*dst++=*src++;return(void*)src_;}intmemcmp(constvoid*a_,constvoid*b_,uint32_tsize){constchar*a=a_;constchar*b=b_;while(size-->0){if(*a!=*b)return*a>*b1:-1;a++,b++;}return0;}char*strcpy(char*dst_,constchar*src_){char*r=dst_;while((*dst_++=*src_++));returnr;}uint32_tstrlen(constchar*str){constchar*p=str;while(*p++);returnp-str-1;}int8_tstrcmp(constchar*a,constchar*b){while(*a&&*a==*b)a++,b++;return*a<*b-1:*a>*b;}char*strchr(constchar*str,constuint8_tch){while(*str){if(*str==ch)return(char*)str;str++;}returnNULL;}代码9-20头文件(include/string.h)

#ifndef_STRING_H_#define_STRING_H_void*memset(void*dst_,uint8_tvalue,uint32_tsize);void*memcpy(void*dst_,constvoid*src_,uint32_tsize);intmemcmp(constvoid*a_,constvoid*b_,uint32_tsize);char*strcpy(char*dst_,constchar*src_);uint32_tstrlen(constchar*str);int8_tstrcmp(constchar*a,constchar*b);char*strchr(constchar*str,constuint8_tch);#endif最后,在common.h中加入#include"string.h",在init_gdtidt中加入一行init_idt(),并在kernel_main中,在while(1);之前加入一行asm("ud2");,在Makefile的OBJS变量中加入out/string.oout/isr.oout/interrupt.o。

若上述所有操作全部正确无误,那么编译运行后效果应如下图:

(图9-4运行效果)

尽管我们只测试了一个ud2异常,即6号异常,但我们足以相信,整个IDT对于异常已经设置无误了。

第9节中,我们设置的异常是一种内部的中断。而本节,我们将要接收来自外部设备的中断。

话说回来,我们前面一直提到中断,到底什么是中断?字面意思上讲,就是你正在持续的工作被突然打断。发挥联想记忆,可以知道,中断实际上就是在操作系统正常运行的过程中,让它被迫接收的信号。

扯得有点多,往回收收。由于0-31号IDT已经归给了异常,现在又有16个外设中断信号,那么最自然的想法,就是把它们放置在32-47号IDT。

什么?你说电脑明明有一堆外设,中断号为什么这么少?仔细想想就会发现,如果所有的外设都给CPU发中断,那CPU不仅分辨不出来谁是谁,更是要炸了。因此,在x86框架下,所有的中断会被汇集到一个叫做8259A的芯片,它还有另一个名字,叫做可编程中断控制器(PIC),当然,目前已经被淘汰了。实际操作中,由PIC分辨每一个外设,并发送两个字节(0xCD外设编号)给CPU,从而使得CPU自动执行对应外设的中断处理程序。

好了,原理大致如此,我们开始。这么一看,中断处理和IDT也脱不了干系,先对16个外设中断信号对应的IDT进行设置,以下是新的init_idt:

代码10-1设置外设中断信号对应的中断描述符(kernel/gdtidt.c)

staticvoidinit_idt(){idt_ptr.limit=sizeof(idt_entry_t)*256-1;idt_ptr.base=(uint32_t)&idt_entries;memset(&idt_entries,0,sizeof(idt_entry_t)*256);for(inti=0;i<32+16;i++){idt_set_gate(i,(uint32_t)intr_table[i],0x08,0x8E);}idt_flush((uint32_t)&idt_ptr);}找不同环节,你能发现哪里做了修改吗?其实就是在32后面加了16,因为总共有16个外设中断信号嘛。

interrupt.asm中的代码几乎与异常时如出一辙,这些东西要放在isr_common_stub之前:

代码10-2外设中断信号的实现(kernel/interrupt.asm)

section.data%macroIRQ1section.textirq%1:clipushbyte0push%1jmpirq_common_stubsection.dataddirq%1%endmacroIRQ32IRQ33IRQ34IRQ35IRQ36IRQ37IRQ38IRQ39IRQ40IRQ41IRQ42IRQ43IRQ44IRQ45IRQ46IRQ47section.text[externirq_handler];通用中断处理程序irq_common_stub:pusha;存储所有寄存器movax,dspusheax;存储dsmovax,0x10;将内核数据段赋值给各段movds,axmoves,axmovfs,axmovgs,axcallirq_handler;调用C语言处理函数popeax;恢复各段movds,axmoves,axmovfs,axmovgs,axpopa;弹出所有寄存器addesp,8;弹出错误码和中断IDiret;从中断返回结果IRQ宏里还是一样的小把戏,只是调用的宏换了个名字。最后是irq_handler,放上来看看:

代码10-3中断处理程序的C语言接口(kernel/isr.c)

voidirq_handler(registers_tregs){monitor_write("receivedirq:");monitor_write_dec(regs.int_no);monitor_put('\n');}甚至完全一致,除了删掉了关闭中断和无限悬停的部分。

最后,由于kernel.asm中关闭了外部中断,在此处需要重新打开。因此,需要在main.c中把上一节测试用的asm("ud2");替换为asm("sti");。

完整main.c如下:

代码10-4测试用(kernel/main.c)

嗯?8号中断?这不是异常吗?这是怎么回事??

我当初做到这里的时候,一度怀疑人生,在代码中查询到底是哪里出了问题,最终没有结果(笑)。后来查阅资料发现,8号异常不会随便出现,当CPU找不到对应异常的处理程序,但是有8号异常处理程序时,才会调用8号异常处理程序(顺便说一下,如果没有8号处理程序,结果自然就是重启啦)。最终我才得以确定,程序本身并没有问题。

那么,这个神秘的8号异常是哪里来的呢?

解铃还须系铃人。我们鼓捣了半天,偏偏把最重要的PIC给忘了!而16位模式下,PIC默认时钟中断为对应的是8号而不是我们新设定的32号,由于我们没管PIC,所以它还是16位的状态,此时出现了时钟中断,PIC自然就会给CPU发送8号中断!

重设PIC也是非常古老、非常屎山也是非常定式的操作,由于涉及到硬件,这里不多解说。总之只要添加这8行代码,就没有问题(把它们添加在init_idt中的memset之前):

代码10-5重设PIC(kernel/gdtidt.c)

出现了receivedirq:32,说明我们重设PIC成功了,耶!但是仔细一想,谁家时钟只会滴答一次?那么为什么我们的时钟中断只发了一次就没有了?

这是因为,PIC非常忙,不止有这一个外设要管,鬼知道你这边完事没有。因此,我们需要向PIC发信号说“处理完毕啦”,这个信号被称作EOI。

我们在irq_handler中加入EOI的发送:

代码10-6发送EOI(kernel/isr.c)

voidirq_handler(registers_tregs){if(regs.int_no>=0x28)outb(0xA0,0x20);//给从片发EOIoutb(0x20,0x20);//给主片发EOImonitor_write("receivedirq:");monitor_write_dec(regs.int_no);monitor_put('\n');}这里的从片主片又是什么东西呢?虽然总共有16个外设信号,但是一个PIC总共只有8条向外输出的线,只好搞两个PIC,一主一从,两个PIC通过两个外设中断互相交换信息。所以,其实有两个外设中断是没有用的。

这下总行了吧?编译,运行,效果如图:

至此,我们成功实现了对IRQ的接收。不过,我们对外设中断的要求更苛刻一点——能不能让接收方自己决定怎么处置外设中断呢?

这一部分完全在软件层级,可以用C语言来完成。

首先,定义自定义中断处理程序函数:

代码10-7开始自定义中断处理程序(kernel/isr.h)

#defineIRQ032#defineIRQ133#defineIRQ234#defineIRQ335#defineIRQ436#defineIRQ537#defineIRQ638#defineIRQ739#defineIRQ840#defineIRQ941#defineIRQ1042#defineIRQ1143#defineIRQ1244#defineIRQ1345#defineIRQ1446#defineIRQ1547typedefvoid(*isr_t)(registers_t*);voidregister_interrupt_handler(uint8_tn,isr_thandler);下面已经添加了注册函数了,在isr.c中加入一行:

代码10-8自定义中断处理程序列表(kernel/isr.c)

staticisr_tinterrupt_handlers[256];由于isr_t是函数指针,因此可以用是否为NULL判断是否存在自定义中断处理程序。这是新版的irq_handler:

代码10-9将中断信号分发给自定义处理程序,以及注册函数(kernel/isr.c)

voidirq_handler(registers_tregs){if(regs.int_no>=0x28)outb(0xA0,0x20);//中断号>=40,来自从片,发送EOI给从片outb(0x20,0x20);//发送EOI给主片if(interrupt_handlers[regs.int_no]){isr_thandler=interrupt_handlers[regs.int_no];//有自定义处理程序,调用之handler(®s);//传入寄存器}}voidregister_interrupt_handler(uint8_tn,isr_thandler){interrupt_handlers[n]=handler;}为避免regs在传值中出现不必要的拷贝,这里选择使用指针形式向自定义中断处理程序传入寄存器。

现在再编译运行,应该恢复到图8-6的状态了,一片祥和。

那么,接下来就是暴风雨了,我们来自定义一个时钟中断处理程序。

首先,对于时钟中断目前的频率,我们只知道PIT内部的时钟频率为1193180Hz(什么b数),对于具体的频率,我们是一无所知的。不过,这个值可以更改,具体方法为:

上述逻辑并不复杂。为了管理时钟,我们新建一个timer.c,其具体代码依照上面逻辑可如下写出:

代码10-11时钟管理程序(kernel/timer.c)

#ifndef_TIMER_H_#define_TIMER_H_#include"common.h"voidinit_timer(uint32_tfreq);#endif我们不仅增加了init_timer,而且还注册了时钟中断的处理函数。目前它只是打印一下当前的ticks,后面会对它进行更改。

最后,在kernel_main中加入一行init_timer(50);,在Makefile的OBJS变量后面追加timer.o,编译,运行,效果如图:

我们看到了不断增加的ticks,这是一个极好的现象,说明我们对时钟的设置和对IRQ自定义处理程序的设置都成功了。

虽然说时钟中断往后的最合理的主题就是多任务,但从目录可以看出来,多任务是下一节的内容,本节我们首先实现一个极其简单的内存管理系统。

什么是内存管理?内存管理,就是管理内存(什么废话文学)。直接解释其含义有点困难,不过,内存的分配和释放,就是内存管理的主要部分。

本节内容大部分参考自《30天自制操作系统》,有原书的建议结合原书交叉参考,毕竟我这个写出来的东西和人家原书肯定是比不了的。

好了,我们开始吧。首先,既然要管理内存,必然要知道内存总共有多大。在BIOS中,有非常多的方法来做到(均基于int15h,根据ax的值为0xe820、0xe801和0x66分别有不同的行为),但是现在已经到了保护模式,没法用BIOS了,怎么办?

换个思路想:32位下内存最多为4GB,如果往没有内存的地方写入一些字节,再读出来的时候,不管长什么样,肯定不会是写入时候的样子。所以,我们只需要指定一个开头和结尾,对这一段区域的所有内存进行试写,如果遇到了边界,那么直接退出,并报告边界值即可。

看上去很美好,但intel的设计更为前卫,为了增加访问内存的效率,486之后的intelcpu加入了缓存功能。在缓存中读写自然是没有意义的,因此首先要检测是否在486以上,如果是,那么就要把缓存关掉。

因此,我们新建memory.c,简单写一下内存检测的部分。

代码11-1内存检测(kernel/memory.c)

在这里用到了对eflags和cr0进行操作的四个汇编函数,代码如下:

代码11-2操作eflags和cr0的汇编(lib/nasmfunc.asm)

[globalload_eflags]load_eflags:pushfd;eflags寄存器只能用pushfd/popfd操作,将eflags入栈/将栈中内容弹入eflagspopeax;eax=eflags;ret;returneax;[globalstore_eflags]store_eflags:moveax,[esp+4];获取参数pusheaxpopfd;eflags=eax;ret[globalload_cr0]load_cr0:moveax,cr0;cr0只能和eax之间movret;returncr0;[globalstore_cr0]store_cr0:moveax,[esp+4];获取参数movcr0,eax;赋值cr0ret程序写好了,怎么测试呢?看看这样行不行:

代码11-3init_memory(kernel/memory.c)

voidinit_memory(){uint32_tmemtotal=memtest(0x00400000,0xbfffffff);//检测4MB~3GB范围内的内存monitor_write("memory");monitor_write_dec(memtotal/1024/1024);monitor_write("MB\n");//以MB形式打印出来}代码11-4头文件(include/memory.h)

#ifndef_MEMORY_H_#define_MEMORY_H_#include"common.h"voidinit_memory();#endif代码11-5测试用main(kernel/main.c)

#include"monitor.h"#include"gdtidt.h"#include"memory.h"#include"timer.h"voidkernel_main()//kernel.asm会跳转到这里{monitor_clear();//先清屏init_gdtidt();init_timer(50);init_memory();monitor_write("Hello,kernelworld!\n");//验证write_hex和write_dec,由于没有printf,这一步十分烦人monitor_write_hex(0x114514);monitor_write("=");monitor_write_dec(0x114514);monitor_write("\n");//asm("sti");//悬停while(1);}编译,运行,效果如图所示:

我们的检测程序报告共有128MB内存,这与QEMU的默认设置相符。如果读者不放心,可以自行在qemu的参数中加入-m参数指定内存大小,其中memsize以MB为单位。

检测完了,下面就该正式进行管理了。我目前看到的所有教程中,大致可将内存管理方案分为三种:位图型,表格型以及混合型。

位图型,是指用位图的方式来管理内存。位图其实就是一个字符数组,每一个数组的每一位分别代表一个管理单元(通常为4KB),若要分配连续多个4KB的内存,则需要操控位图的单独位来实现。这种方法不仅说起来麻烦,写起来也麻烦,因此不考虑了。

表格型就非常好理解了,就是把可用内存信息放在一个一个的表项中,每一个项的内容包括起始地址、内存大小等信息。在这里为了偷懒,就只包括这两项信息了。事实上,以UEFI为基础的64位操作系统内核中,离不开与这种表格的交道(在此不详谈了,更何况64位的基本属于混合型)。

混合型比这两种还要复杂,也不多考虑了。

综上,我们最终选择了表格型的方式进行管理。如果硬要写一段代码的话,大致是这样的:

代码11-6表格型内存管理方案示例(无文件)

好了,我们开始吧。首先把上面的表项依样画葫芦抄下来:

代码11-7表格型内存管理数据结构的定义(include/memory.h)

#defineMEMMAN_FREES4090typedefstructFREEINFO{uint32_taddr,size;}freeinfo_t;typedefstructMEMMAN{intfrees;freeinfo_tfree[MEMMAN_FREES];}memman_t;紧接着,是初始化、总数据和分配的代码,由于十分简单,合并为同一个部分:

代码11-8表格初始化、表格总数据和内存分配(kernel/memory.c)

staticvoidmemman_init(memman_t*man){man->frees=0;}staticuint32_tmemman_total(memman_t*man){uint32_ti,t=0;for(i=0;ifrees;i++)t+=man->free[i].size;//剩余内存总和returnt;}staticuint32_tmemman_alloc(memman_t*man,uint32_tsize){uint32_ti,a;for(i=0;man->frees;i++){if(man->free[i].size>=size){//找到了足够的内存a=man->free[i].addr;man->free[i].addr+=size;//addr后移,因为原来的addr被使用了man->free[i].size-=size;//size也要减掉if(man->free[i].size==0){//这一条size被分配完了man->frees--;//减一条freesfor(;ifrees;i++){man->free[i]=man->free[i+1];//各free前移}}returna;//返回}}return0;//无可用空间}内存分配和总数据统计就不多解释了,分配的操作大部分都已经解释过,也不多说。

接下来是内存释放,这一部分比较复杂。

代码11-9内存释放(kernel/memory.c)

首先,稍微分析一下就会发现,在表格中的所有表项,必然以基地址为键呈升序排列(也就是说,越往后的项,基地址也越大)。正因如此,第3~8行的判断才得以顺利进行。

第926行,是当前释放的这一段内存与前后进行合并的判断。第2835行,如果无法与前面的合并,则要进行与后面的内存合并的判断。如果不这样判断,会出现下面的情况:

内存表项0:起始地址0x400000,大小3KB内存表项1:起始地址0x401000,大小4KB当释放从0x400c00开始的1KB时,如果不进行判断,那么如果后续要分配5KB内存,便无从下手。然而,这三段内存(表项0、表项1、刚释放)实际上是以0x400000为起始、共计8KB的连续内存空间,完全可以分配5KB内存。如果都合并不了,只好单独创建一个内存块插入在这之间。如果已经完全没有地方,只好返回-1报错了。

好了,到此为止,我们仅用了不到200行代码,就完成了段式内存管理的实现——吧。在此之前,我们要对段式内存管理进行一个基本的封装。

首先,现在的内存释放需要指定大小,这实在是非常不方便的一个因素。因此,我们需要开辟出一定的内存空间,供内存释放时读取大小使用。

具体而言,封装后的kmalloc和kfree如下:

代码11-10最终封装内存管理(kernel/memory.c)

void*kmalloc(uint32_tsize){uint32_taddr;memman_t*memman=(memman_t*)MEMMAN_ADDR;addr=memman_alloc(memman,size+16);//多分配16字节memset((void*)addr,0,size+16);char*p=(char*)addr;if(p){*((int*)p)=size;p+=16;}return(void*)p;}voidkfree(void*p){char*q=(char*)p;intsize=0;if(q){q-=16;size=*((int*)q);}memman_t*memman=(memman_t*)MEMMAN_ADDR;memman_free(memman,(uint32_t)q,size+16);p=NULL;return;}代码11-11MEMMAN_ADDR的定义(include/memory.h)

#defineMEMMAN_ADDR0x003c0000这一部分涉及到相当晦涩的指针操作,简单解释一下。

在kmalloc中,首先从一个固定的地址(0x3c0000)读出一个memman_t来,然后分配size字节的内存。注意这里还多分配了16个字节,这是干什么用的呢?

众所周知,free是不需要知道这段内存的大小的,我们希望kfree也是一样。所以,我们便需要在这段内存的一开头把大小存起来。这也就是第351行在干的事情:把p转化成int指针,再向它这个地址处写入大小,最后把指针后移16把大小这一块空过去。

同理,在kfree中,先从地址最开头读出大小,然后从同一个memman_t处把内存释放。

最后的最后,由于kmalloc和kfree都指着这块地的memman_t呢,我们需要在init_memory中初始化memman。代码如下:

代码11-12初始化memman(kernel/memory.c)

voidinit_memory(){uint32_tmemtotal=memtest(0x00400000,0xbfffffff);memman_t*memman=(memman_t*)MEMMAN_ADDR;memman_init(memman);memman_free(memman,0x400000,memtotal-0x400000);}同样删去了打印,因为用不到了。

内存管理到此结束,我们还真验证不了它能不能用,不过,很快,我们会转入另一个更具挑战性的课题——多任务。

注:与上一篇类似,本节同样有参考《30天自制操作系统》,但不像上一节一样没有原创的东西(kmalloc和kfree的代码在29.3节中有)。

多任务,顾名思义,就是多个任务同时进行。在计算机中,这是一个非常重要的概念,否则这篇教程甚至写不出来(我需要一边打字一边写代码,显然需要两个一块开)。当然在现实生活中,不推荐使用多任务。

在intelx86cpu中,任务切换的核心是任务状态段(TSS),这一部分完全是intel硬件提供的。由于TSS是一个段,在实际使用时,需要把这个段注册到GDT中。

由于效率较低,在Linux等更为现代的操作系统中已经废弃了这种方法,但初学而言,还是用原生自带的比较好。

TSS总共有16位、32位和64位三种版本,我们来看看32位版的TSS长什么样:

代码12-1TSS32(include/mtask.h)

#ifndef_MTASK_H_#define_MTASK_H_#include"common.h"typedefstructTSS32{uint32_tbacklink,esp0,ss0,esp1,ss1,esp2,ss2,cr3;uint32_teip,eflags,eax,ecx,edx,ebx,esp,ebp,esi,edi;uint32_tes,cs,ss,ds,fs,gs;uint32_tldtr,iomap;}tss32_t;#endifTSS32结构体的第二、三行,是任务切换中会随时更改的寄存器。任务切换发生时,会把当时寄存器的值存入TSS。

代码12-2表示任务的结构体(include/mtask.h)

typedefstructTASK{uint32_tsel;int32_tflags;tss32_ttss;}task_t;目前,它只有两个属性:sel和flags。sel代表它对应的TSS的选择子,flags代表它的标志,如是否使用过、是否在运行等。

接下来,我们来实现一个控制任务的结构体。由于注册的任务和实际的任务可能不一致,这需要两个task_t数组,由于较为复杂,打包为一个结构体:

代码12-3任务控制结构体(include/mtask.h)

#defineMAX_TASKS1000#defineTASK_GDT03typedefstructTASKCTL{intrunning,now;task_t*tasks[MAX_TASKS];task_ttasks0[MAX_TASKS];}taskctl_t;running和now代表正在运行的任务数量和当前运行的任务编号,tasks是实际运行任务的数组,tasks0是任务注册时进入的数组。这样一来,我们只需要一个taskctl_t,就可以引用到所有这些控制任务的变量了。

TASK_GDT0表示从第多少号GDT开始分配给TSS使用。

首先,是初始化多任务环境的函数task_init。首先它会初始化taskctl,在执行完后,当前执行流将被当成一个任务来对待,这样做的目的是方便管理。

代码12-4初始化多任务环境(kernel/mtask.c)

#include"mtask.h"#include"gdtidt.h"#include"memory.h"#include"isr.h"externvoidload_tr(int);externvoidfarjmp(int,int);taskctl_t*taskctl;task_t*task_init(){task_t*task;taskctl=(taskctl_t*)kmalloc(sizeof(taskctl_t));for(inti=0;itasks0[i].flags=0;taskctl->tasks0[i].sel=(TASK_GDT0+i)*8;gdt_set_gate(TASK_GDT0+i,(int)&taskctl->tasks0[i].tss,103,0x89);//硬性规定,0x89代表TSS,103是因为TSS共26个uint32_t组成,总计104字节,因规程减1变为103}task=task_alloc();task->flags=2;taskctl->running=1;taskctl->now=0;taskctl->tasks[0]=task;load_tr(task->sel);//向CPU报告当前task->sel对应的任务为正在运行的任务returntask;}这之中最难懂的大概就是倒数第三行的load_tr了吧。调用task_init的应该是kernel_main,而kernel_main此时还没有任务形态,需要用load_tr来使得CPU认识到这是正在运行的任务。

代码12-5load_tr(lib/nasmfunc.asm)

[globalload_tr]load_tr:ltr[esp+4]ret在这之中,用到了task_alloc,它是分配一个任务用的函数。先从tasks0中找到空项,然后进行一些初始化工作,最后返回一个崭新的任务。

代码12-6分配任务用task_alloc(kernel/mtask.c)

task_t*task_alloc(){task_t*task;for(inti=0;itasks0[i].flags==0){task=&taskctl->tasks0[i];task->flags=1;task->tss.eflags=0x00000202;task->tss.eax=task->tss.ecx=task->tss.edx=task->tss.ebx=0;task->tss.ebp=task->tss.esi=task->tss.edi=0;task->tss.es=task->tss.ds=task->tss.fs=task->tss.gs=0;task->tss.ldtr=0;task->tss.iomap=0x40000000;returntask;}}returnNULL;}eflags的这个数值表示新任务默认开启中断。iomap本意是让应用程序能够执行in/out指令所设置,但我们不需要让它执行这些指令,否则到时候执行个应用程序,键盘也不发信号了,时钟也不响了,就连电脑都重启了,所以把它设置成0x40000000(事实上只要大于等于103就可以)表示:当作为应用程序时,不需要执行in/out指令。至于CPU怎么判断你是不是一个应用程序,第22节再说。

接下来是task_run,使一个任务开始运行。实际上只是把这个任务加入了tasks数组而已。

代码12-7运行任务用task_run(kernel/mtask.c)

voidtask_run(task_t*task){task->flags=2;taskctl->tasks[taskctl->running]=task;taskctl->running++;}接下来是task_switch,真正执行任务切换的部分。不过,我们好像还没有具体讲究竟是怎么任务切换的,我们现在来简单说一下。

其实非常简单,只需要用farjmp就可以了。当执行一个远跳转(就是我们之前用过的jmpxxx:xxx)时,CPU会检查对应的段是否是代码段,如果不是,就退而求其次检查是不是TSS。如果是TSS,就会先把当前任务的全部寄存器存到它的TSS里,然后自动读取TSS中的全部寄存器,这之中包括下一步执行哪里的eip,从而恢复断点,继续执行。

代码12-8farjmp(lib/nasmfunc.asm)

[globalfarjmp]farjmp:jmpfar[esp+4]ret在实际运用中,应在C中如此调用:farjmp(eip,cs)。eip为下一步执行哪里的寄存器,如果跳的是TSS,那就必须写0;cs为跳入的代码段选择子,在这里是TSS。

为什么一定要这样呢?这和我们使用了jmpfar[addr]来进行远跳转有关。你只需要知道,在这种情况下,[addr]的位置必须写EIP,[addr+4]的位置必须写cs。

这样一来,task_switch就十分简单了。

代码12-9任务切换(kernel/mtask.c)

voidtask_switch(){if(taskctl->running>=2){//显然,至少得有两个任务才能切换taskctl->now++;//下一个任务if(taskctl->now==taskctl->running){//到结尾了taskctl->now=0;//转换为第一个}farjmp(0,taskctl->tasks[taskctl->now]->sel);//跳入任务对应的TSS}}结合注释应该不难理解……我是第几次说这句话了?

最后是task_now,返回当前任务,后续会频繁用到。

代码12-10返回当前任务(kernel/mtask.c)

task_t*task_now(){returntaskctl->tasks[taskctl->now];}至此,我们已经基本完成了一个可用的任务处理框架。但是还有最后一个问题:谁来控制任务切换的进行呢?

因此,进入timer.c,删除tick变量和所有对tick变量的操作,修改timer_callback如下:

代码12-11新版时钟中断回调(kernel/timer.c)

#include"mtask.h"staticvoidtimer_callback(registers_t*regs){task_switch();//每出现一次时钟中断,切换一次任务}首先,进入到main.c,添加一个创建内核任务的函数。由于代码量大且(将会)频繁用到,做一个小小的封装。

代码12-12创建内核任务(kernel/main.c)

#include"mtask.h"task_t*create_kernel_task(void*entry){task_t*new_task;new_task=task_alloc();new_task->tss.esp=(uint32_t)kmalloc(64*1024)+64*1024-4;new_task->tss.eip=(int)entry;new_task->tss.es=new_task->tss.ss=new_task->tss.ds=new_task->tss.fs=new_task->tss.gs=2*8;new_task->tss.cs=1*8;returnnew_task;}然后是新任务的主体task_b_main,目前它还没啥大作用。

代码12-13新任务主体task_b_main(kernel/main.c)

voidtask_b_main(){while(1)monitor_put('B');//重复打印B}最后是新版kernel_main:

代码12-14最新内核主函数(kernel/main.c)

voidkernel_main()//kernel.asm会跳转到这里{monitor_clear();//先清屏init_gdtidt();init_memory();init_timer(100);asm("sti");task_t*task_a=task_init();task_t*task_b=create_kernel_task(task_b_main);task_run(task_b);//此时kernel_main已经成为task_a的一部分while(1)monitor_put('A');}首先我们将时钟中断发生频率改为每0.1s发生一次,然后是创建、运行任务的代码,应该不难理解。

代码12-16include/mtask.h

我们看到了交错的A和B,这是个好现象。那么我们的多任务到此结束……

……那是不可能的。我们还有一些内容没有完成。我们还要实现Linux中exit和waitpid的功能。这一部分地基打好了,我们后面的应用程序才能更好地运行起来。

那么,我们再次开始。首先,exit是有返回值的,我们需要在某一个地方存一下返回值。思来想去,最合适的地方还是在TASK结构体中,在mtask.h中添加这样一个定义:

typedefstructexit_retval{intpid,val;}exit_retval_t;typedefstructTASK{uint32_tsel;int32_tflags;exit_retval_tmy_retval;tss32_ttss;}task_t;删去原本的TASK定义,替换为上面这一段。由于exit的返回值可以是任何一个东西,因此特意添加了一个pid变量,用来确认是否退出。当然这个变量可以换成随便一个东西,这里用pid只是一种用法。

那么,一个任务的pid是什么呢?pid自然是一个id,是一个任务的另一个身份证。一般而言,它是一个单独的数,表示它在一个任务数组或者什么地方的索引。

在这里,由于我们的全局数组是taskctl->tasks0,因此,一个任务的pid就是它在tasks0中的索引。看起来从一个任务找pid是一个O(n)的操作,但是注意task_init中的这行代码:

taskctl->tasks0[i].sel=(TASK_GDT0+i)*8;倒推回去,就可以得到:一个任务对应的pid为task->sel/8-TASK_GDT0。这是一个重要的结论,我们把它写成单独的函数task_pid:

代码12-18从任务找pid(kernel/mtask.c)

inttask_pid(task_t*task){returntask->sel/8-TASK_GDT0;}下面就是正式的exit代码了。exit必然意味着一个任务执行的终止,这也就意味着它将会被从tasks删除,如果正在执行这个任务,那么还要进行切换。因此,我们先单独写一个删除任务的函数task_remove:

代码12-19从tasks中删除任务(kernel/mtask.c)

voidtask_remove(task_t*task){boolneed_switch=false;//是否要进行切换?inti;if(task->flags==2){//此任务正在运行,如果不运行那就根本不在tasks里,什么都不用干if(task==task_now()){//是当前任务need_switch=true;//待会还得润}for(i=0;irunning;i++){if(taskctl->tasks[i]==task)break;//在tasks中找到当前任务}taskctl->running--;//运行任务数量减1if(inow)taskctl->now--;//如果now在这个任务的后面,那now也要前移一个(因为这个任务要删了,后面的要填上来,会整体前移一个)for(;irunning;i++){taskctl->tasks[i]=taskctl->tasks[i+1];//整体前移,不必多说}if(need_switch){//需要切换if(taskctl->now>=taskctl->running){taskctl->now=0;//now超限,重置为0}farjmp(0,task_now()->sel);//跳入到现在的当前任务中}}}task_remove比较长,因此给了详尽的注释。基本上就是一堆善后工作需要做,核心部分只有中间的三行整体前移。

为什么添加了一个need_switch的变量呢?因为如果在最上面的if那就切换,那下面的整体前移就根本执行不到,这样就没有删除的作用了。

有了task_remove,task_exit就非常简单了:

代码12-20任务自动退出(kernel/mtask.c)

voidtask_exit(intvalue){task_t*cur=task_now();//当前任务cur->my_retval.pid=task_pid(cur);//pid变为当前任务的pidcur->my_retval.val=value;//val为此时的值task_remove(cur);//删除当前任务cur->flags=4;//返回值还没人收,暂时还不能释放这个块为可用(0)}接下来是task_wait,等待指定pid的进程执行exit退出。

代码12-21等待任务退出(kernel/mtask.c)

inttask_wait(intpid){task_t*task=&taskctl->tasks0[pid];//找出对应的taskwhile(task->my_retval.pid==-1);//若没有返回值就一直等着task->flags=0;//释放为可用returntask->my_retval.val;//拿到返回值}注意,由于此处是判断pid是否为-1来判断任务是否为退出,应当在初始化任务的时候(即task_alloc中)加上对pid的设定如下:

代码12-22初始化my_retval(kernel/mtask.c)

接下来是测试用例,直接把完整版main.c端上来:

代码12-23测试用例(kernel/main.c)

好了,多任务到此为止已经可以结束了。下面我们来加速冲刺,进入到人机交互的第一个里程碑——键盘驱动。

首先是一个好消息,键盘在PIC里是有外设编号的(希望大家还记得PIC,否则建议复习第10节),按我们的设定,编号为33。并不是所有的外设都在PIC里自带编号,像网卡啊、声卡这些,都是没有自带编号的。

那么既然如此,新建drivers/keyboard.c,我们来写一个最简单的键盘驱动:

代码13-1最简单的键盘驱动(drivers/keyboard.c)

#ifndef_KEYBOARD_H_#define_KEYBOARD_H_voidinit_keyboard();#endif添加了新目录,照例放一下Makefile:

代码13-3如今的Makefile(Makefile)

按下第一个按键后,出现了一个*,这是非常好的现象。但是,我们发现,后续无论再怎么按键,都完全没有任何作用,屏幕上不再有新的星号出现。

这是为什么呢?查阅资料我们发现,这是键盘控制器(8042、8048)干的好事。当按键被按下时,键盘处理器将根据对应的键产生一个或多个对应的代码,我们称之为扫描码,这个或这些扫描码随即被依次写入到键盘控制器自带的缓冲区中。在写入完后,键盘控制器会立即发送一个中断信号。然而,如果内核在收到中断后不读出这个缓冲区里的扫描码,键盘就会卡死。

键盘控制器的缓冲区端口号为0x60,我们只需要用inb(0x60)就可以读出键盘缓冲区中的扫描码。因此,修改keyboard_handler,我们来看看读出的扫描码长什么样:

代码13-4新版键盘驱动(drivers/keyboard.c)

在本图中,依次按下了shift、a、a、shift、a、lctrl、lctrl、alt、win这几个键。我们发现,虽然一共只按了9个键,但产生了20个扫描码。这是因为,扫描码不是单独出现,而是成对出现的,按下时产生一组,松开时产生一组。

那么,我们怎么知道每个按键对应的是哪个扫描码呢?一个可实践的方法是,按照上面的顺序依次分别按下,观察屏幕上扫描码的变化。不过,这对写代码解析扫描码是没有帮助的。

还有另外一个办法,就是依次按下键盘上的每一个键,看看它对应的扫描码,然后记录到一个数组或者其他什么地方。不过,这样做实在太过耗时,前人栽树,后人乘凉,我们选择直接把这个数组抄下来:

代码13-5从扫描码到每一个键的对应关系(drivers/keymap.c)

这一段数组中出现了非常多的宏,诸如NR_SCAN_CODES、MAP_COLS、ESC等等。数组中的宏是每一个键的唯一标识,在保证唯一性的情况下,读者可以任意指定;而剩下的NR_SCAN_CODES和MAP_COLS则分别为0x7f和3。需要注意的是,keymap里的索引是按下时的扫描码,而非抬起时的扫描码,实际编程时需要留意一下。

在本教程中使用的一个keyboard.h的示例如下:

在Makefile的OBJS中追加一个keymap.o,由于我们尚未开始解析扫描码,所以这一部分没有变化。

接下来,为了存储获得到的扫描码,我们来做一个存储扫描码用的数据结构。显然,先按下的键需要先被处理,所以我们选择做一个队列。

代码13-7FIFO队列的实现(lib/fifo.c)

#ifndef_FIFO_H_#define_FIFO_H_#include"common.h"typedefstructFIFO{uint32_t*buf;intp,q,size,free,flags;}fifo_t;#defineFIFO_FLAGS_OVERRUN1voidfifo_init(fifo_t*fifo,intsize,uint32_t*buf);intfifo_put(fifo_t*fifo,uint32_tdata);intfifo_get(fifo_t*fifo);intfifo_status(fifo_t*fifo);#endif在Makefile的OBJS中追加out/fifo.o,编译运行,效果仍应不变,因为这个队列我们也还没开始用。

具体实践中如何使用这样一个队列呢?我们先来到keyboard.c,创建一个存储扫描码用的keyfifo:

代码13-9创建keyfifo(drivers/keyboard.c)

#include"isr.h"#include"keyboard.h"#include"fifo.h"fifo_tkeyfifo;uint32_tkeybuf[32];externuint32_tkeymap[];voidkeyboard_handler(registers_t*regs){monitor_write_hex(inb(0x60));}voidinit_keyboard(){fifo_init(&keyfifo,32,keybuf);register_interrupt_handler(IRQ1,keyboard_handler);}然后在keyboard_handler中,我们存储扫描码到keyfifo:

代码13-10存入扫描码(drivers/keyboard.c)

staticuint8_tget_scancode(){uint8_tscancode;asm("cli");scancode=fifo_get(&keyfifo);asm("sti");returnscancode;}staticvoidkeyboard_read(){if(fifo_status(&keyfifo)>0){uint8_tscancode=get_scancode();monitor_write_hex(scancode);}}voidkeyboard_handler(registers_t*regs){fifo_put(&keyfifo,inb(KB_DATA));keyboard_read();}我们同时还新建了keyboard_read和get_scancode两个函数,未来我们对键盘数据的处理将主要在keyboard_read当中进行。

编译,运行,效果仍应不变,因为我们还没有开始处理扫描码。事不过三,我们马上就开始处理工作。

代码13-11初步处理扫描码(drivers/keyboard.c)

staticvoidkeyboard_read(){uint8_tscancode;intmake;if(fifo_status(&keyfifo)>0){scancode=get_scancode();if(scancode==0xE1){//特殊开头,暂不做处理}elseif(scancode==0xE0){//特殊开头,暂不做处理}else{make=(scancode&FLAG_BREAKtrue:false);if(make){charkey=keymap[(scancode&0x7f)*MAP_COLS];monitor_put(key);}}}}FLAG_BREAK在之前的keyboard.h中已有定义,是0x80。多启动几次按几个键会发现,除了一部分产生多个扫描码的键以外,每次按下的扫描码比抬起的扫描码少0x80。因此只需要探测0x80是否存在,就可以确定现在的这个键是被按下还是被抬起,选择一个处理即可。

这就是else中第一行的作用,这里选择的是被抬起时进行判断。scancode&0x7f可以取得对应的被按下时的扫描码,从而作为keymap的索引获得对应的键。

我们看到了后面的abc123,说明我们的键盘驱动已经初步完成。本节的篇幅已经够长了,下一节我们将继续写键盘驱动,做出一个基本的处理框架。

欢迎回来,我们继续键盘驱动的旅途。

首先,是三个最基本的东西:shift、alt和ctrl。这三个东西我们完全没有处理,特别是shift,导致我们现在任何一个大写字母都打不出来。

修改一下keyboard.c,先在最开头写下这几个全局变量,记录shift、alt、ctrl的状态:

代码14-1shift、alt、ctrl的状态(drivers/keyboard.c)

staticintcode_with_E0=0;staticintshift_l;staticintshift_r;staticintalt_l;staticintalt_r;staticintctrl_l;staticintctrl_r;staticintcaps_lock;staticintnum_lock;staticintscroll_lock;staticintcolumn;然后是现在的keyboard_read:

代码14-2带shift的扫描码解析(drivers/keyboard.c)

staticvoidkeyboard_read(){uint8_tscancode;intmake;uint32_tkey=0;uint32_t*keyrow;if(fifo_status(&keyfifo)>0){scancode=get_scancode();if(scancode==0xE1){//特殊开头,暂不做处理}elseif(scancode==0xE0){code_with_E0=1;}else{make=scancode&FLAG_BREAKfalse:true;keyrow=&keymap[(scancode&0x7f)*MAP_COLS];column=0;if(shift_l||shift_r){column=1;}if(code_with_E0){column=2;code_with_E0=0;}key=keyrow[column];switch(key){caseSHIFT_L:shift_l=make;key=0;break;caseSHIFT_R:shift_r=make;key=0;break;caseCTRL_L:ctrl_l=make;key=0;break;caseCTRL_R:ctrl_r=make;key=0;break;caseALT_L:alt_l=make;key=0;break;caseALT_R:alt_r=make;key=0;break;default:if(!make)key=0;break;}if(key)monitor_put(key);}}}在实现shift的同时,我们过滤了ctrl和alt,判断是否释放也改为了判断是否按下,同时会在适当的时候忽略key的值。

编译,运行,按下shift和不按shift分别输入abc123,效果如图所示:

现在的keyboard_read对按键的处理仅限于打印,为了匹配以后更加复杂的需求,我们单独创建一个in_process用来处理不同的按键。

把if(key)monitor_put(key)替换为:

代码14-3对按键进行编码(drivers/keyboard.c)

if(make){key|=shift_lFLAG_SHIFT_L:0;key|=shift_rFLAG_SHIFT_R:0;key|=alt_lFLAG_ALT_L:0;key|=alt_rFLAG_ALT_R:0;key|=ctrl_lFLAG_CTRL_L:0;key|=ctrl_rFLAG_CTRL_R:0;in_process(key);}这里相当于对key进行了编码,同时将当时所有的按键状态编码了进去。

然后是in_process,把它放在keyboard_read之前。

代码14-4in_process(drivers/keyboard.c)

staticvoidin_process(uint32_tkey){if(!(key&FLAG_EXT)){monitor_put(key&0xFF);}}编译,运行,效果仍应如图14-1所示。

下面是对回车、退格和tab行为的单独处理,我们只需要找出ENTER、BACKSPACE以及TAB,改为打印\n、\b以及\t。

代码14-5回车、退格与TAB(drivers/keyboard.c)

staticvoidin_process(uint32_tkey){if(!(key&FLAG_EXT)){monitor_put(key&0xFF);}else{intraw_key=key&MASK_RAW;switch(raw_key){caseENTER:monitor_put('\n');break;caseBACKSPACE:monitor_put('\b');break;caseTAB:monitor_put('\t');break;}}}编译,运行,待task_c输出后连按数次退格,效果如下:

我们发现,光标虽然成功后移,但字符都还在。这是在monitor_put中对\b的判断中应当处理的,找到monitor_put中的第一个判断,我们来做一个专项修改:

代码14-6对退格键的修改(kernel/monitor.c)

if(c==0x08&&cursor_x)//退格,且光标不在某行开始处{cursor_x--;//直接把光标向后移一格video_memory[cursor_y*80+cursor_x]=0x20|(attributeByte<<8);//空格}再次编译,运行,待task_c输出信息后,按若干次退格并按下\n、\t,效果如下:

由此即可证明,对这三个键的处理可以暂告一段落。乘胜追击,我们来处理键盘上有指示灯的三个键:CapsLock、NumLock和ScrollLock(什么?Fn?这玩意真的在PS/2键盘上?)。

对键盘指示灯的操控需要借助我们之前遇到的0x60端口,也要借助键盘控制器的另一个端口——0x64。

如何设置键盘指示灯的情况呢?非常简单,大致分为如下几步:

好了,在我们前面设置shift、ctrl和alt状态的时候已经增加了这三个lock指示灯的变量,我们来针对上面的四步分别写程序。

首先,我们来创建一个设置LED灯状态的函数,实际使用时只需要往caps_lock、num_lock和scroll_lock三个变量中写指示灯状态即可(请把它们放在get_scancode之前)。

代码14-7设置LED状态(drivers/keyboard.c)

staticvoidkb_wait(){uint8_tkb_stat;do{kb_stat=inb(KB_CMD);//KB_CMD:0x64}while(kb_stat&0x02);}staticvoidkb_ack(){uint8_tkb_data;do{kb_data=inb(KB_DATA);//KB_DATA:0x60}while(kb_data!=KB_ACK);//KB_ACK:0xFA}staticvoidset_leds(){uint8_tled_status=(caps_lock<<2)|(num_lock<<1)|scroll_lock;kb_wait();outb(KB_DATA,LED_CODE);//LED_CODE:0xEDkb_ack();kb_wait();outb(KB_DATA,led_status);kb_ack();}其中的KB_CMD、KB_DATA、KB_ACK以及LED_CODE已经定义在keyboard.h之中了。

接下来我们对init_keyboard略作修改,初始化LED灯的状态。

代码14-8新版init_keyboard(drivers/keyboard.c)

voidinit_keyboard(){fifo_init(&keyfifo,32,keybuf);shift_l=shift_r=0;alt_l=alt_r=0;ctrl_l=ctrl_r=0;caps_lock=0;num_lock=1;scroll_lock=0;set_leds();register_interrupt_handler(IRQ1,keyboard_handler);}之所以设置num_lock为1,是因为小键盘的数字功能一般比方向功能常用。不过,设置了也还没有用,我们紧接着对caps_lock和num_lock的状态作出判断。

我们首先添加当按下这三个键时更改状态和LED灯状态的处理,然后是添加了CapsLock按下时的实际功能。

代码14-9LED灯状态的变换以及CapsLock的实际功能(drivers/keyboard.c)

编译,运行,等待task_c输出完成后,依次按下:

ENTER、CapsLock、a、b、c、1、2、3、CapsLock、a、b、c、1、2、3、CapsLock、Shift+A、A,

效果如下:

最后,是对NumLock的处理,请用下面这一长串替换掉keyboard_read中if(make)的分支:

代码14-10NumLock(drivers/keyboard.c)

编译,运行,依次按下小键盘上的:

Enter、7、8、9、4、5、6、1、2、3、+、-、*、/、.、Enter、Enter、0、0、0、0、0、0、0、0、Enter、Enter,

然后是不在小键盘上的上下左右方向键,按下Enter,再按下不在小键盘上的上下左右方向键,效果如下:

前面小键盘的测试部分倒是符合预期,但是为什么上下左右方向键会输出一个8呢(有的机型甚至会输出8246)?

通过回到第13节开篇的状态打印扫描码我们发现,原来是qemu对PS/2键盘的模拟出了点故障,将0xE0这一本该放在前面的字节放在了后面,导致我们的键盘驱动先接收到要打印小键盘的8(没错,方向键和小键盘上的键扫描码相同,只是后面跟了个0xE0),然后接收到0xE0,0xE0就被后续的方向键所匹配了(有的甚至都匹配不上)。对此我们有几个解决方案:要么干脆摆烂直接不管,要么做一个补丁。

但是,经过进一步的测试,我们发现不同版本的QEMU有不同的模拟逻辑,有的QEMU甚至直接不区分方向键和小键盘的方向键,那这个补丁自然没法打,所以就此开摆!

最后,我们把in_process中的打印字符改为向特定的FIFO中放入字符,由kernel_main或者别的什么地方从这里面读取。

代码14-11in_process最终版(drivers/keyboard.c)

staticvoidin_process(uint32_tkey){if(!(key&FLAG_EXT)){fifo_put(&decoded_key,key&0xFF);}else{intraw_key=key&MASK_RAW;switch(raw_key){caseENTER:fifo_put(&decoded_key,'\n');break;caseBACKSPACE:fifo_put(&decoded_key,'\b');break;caseTAB:fifo_put(&decoded_key,'\t');break;}}}在文件开头添加两行fifo_tdecoded_key;以及uint32_tdkey_buf[32];,在init_keyboard中加入一行fifo_init(&decoded_key,32,dkey_buf);,我们的键盘驱动就此完结。

最新版的测试用main.c完整版如下:

代码14-12键盘驱动最终测试(kernel/main.c)

我们看到,现在的OS启动时屏幕一片空旷,它成了一个完完全全的打字机。这也算是我们人机交互的初步成果了。

如果你做过32位的Linux开发,那就比较好说了,如果没有做过也无所谓。

首先,我们得想一想:程序是如何调用系统的功能的呢?在C语言中,或许只是一个函数调用,那么在底层,它长什么样呢?

在32位的Linux中,它的底层是这样的:往eax、ebx这些寄存器里填好参数,然后执行int80h。这看起来很好用,我们也来抄一下。

首先,我们来在IDT里创建一个0x80编号的中断描述符。

代码15-10x80号中断描述符(kernel/gdtidt.c)

externvoidgdt_flush(uint32_t);externvoididt_flush(uint32_t);externvoidsyscall_handler();//这里是新增的和上面设置第15号中断的代码相对比,我们发现在最后一个参数处有些奇怪,为什么要|0x60呢?事实上,|0x60的意思就是说,这个中断是给应用程序用的。可是我们目前还没有应用程序,因此只能让操作系统代为测试了。

接下来我们来编写syscall_handler:

代码15-3系统调用入口(kernel/interrupt.asm)

[externsyscall_manager][globalsyscall_handler]syscall_handler:sti;CPU在执行int指令时默认关闭中断,我们只是来用一下系统功能,所以把中断打开pushad;用于返回值的pushadpushad;用于给syscall_manager传值的pushadcallsyscall_manageraddesp,32;把给syscall_manager传值的pushad部分跳过popad;把希望系统调用后的寄存器情况pop出来iretd;由于是int指令,所以用iretd返回接着,在kernel目录下创建syscall.c,我们来实现syscall_manager:

代码15-4系统调用分发(kernel/syscall.c)

#include"common.h"#include"syscall.h"voidsyscall_manager(intedi,intesi,intebp,intesp,intebx,intedx,intecx,inteax)//这里的参数顺序是pushad的倒序,不可更改{typedefint(*syscall_t)(int,int,int,int,int);//这里面只有五个寄存器勉强可以算正常用,所以只有五个参数//(&eax+1)[7]=((syscall_t)syscall_table[eax])(ebx,ecx,edx,edi,esi);//把下面的代码压缩成上面一行是这样的syscall_tsyscall_fn=(syscall_t)syscall_table[eax];//从syscall_table中拿到第eax个函数intret=syscall_fn(ebx,ecx,edx,edi,esi);//调用并获取返回值//感谢编译器,即使给多了参数,被调用的函数也会把它们忽略掉int*save_reg=&eax+1;//进入用于返回值的pushadsave_reg[7]=ret;//第7个寄存器为eax,函数返回时默认将eax作为返回值}这里的syscall_table定义在syscall.h中,它长这样:

代码15-5系统调用函数表(include/syscall.h)

#ifndef_SYSCALL_H_#define_SYSCALL_H_typedefvoid*syscall_func_t;syscall_func_tsyscall_table[]={};#endif里面目前还没有任何一个函数。我们之所以采用这样一个系统调用表的方式,是因为这样便于扩展,我们只需要写好函数,然后加到数组里即可。

那么,我们现在来试试这个新框架。在syscall.c的下方,我们创建一个sys_getpid:

代码15-6系统调用sys_getpid(kernel/syscall.c)

intsys_getpid(){returntask_pid(task_now());}添加到syscall_table:

代码15-7新系统调用表(include/syscall.h)

intsys_getpid();syscall_func_tsyscall_table[]={sys_getpid,};新建kernel/syscall_impl.asm,给getpid加个包装:

代码15-8系统调用的包装(kernel/syscall_impl.asm)

[globalgetpid]getpid:moveax,0int80hret在Makefile中,给OBJS变量加上out/syscall.oout/syscall_impl.o,理论上现在已经可以调用getpid了。

我们来做一个小小的测试。在kernel_main中加入这三行(放在task_init调用的后面):

代码15-9getpid测试(kernel/main.c)

monitor_write("kernel_mainpid:");monitor_write_dec(getpid());monitor_put('\n');编译,运行,效果如下:

getpid返回了0。这有可能有两个原因,是int80h的调用失败了,所以getpid返回的是调用时的那个0,还是真的返回了kernel_main对应的那个任务的pid也就是0呢?

再创建一个任务,我们来实地验证一下:

代码15-10task_b打赢复活赛(kernel/main.c)

voidtask_b_main(){monitor_write("task_bpid:");monitor_write_dec(getpid());monitor_put('\n');task_exit(0);}//以下两行语句添加在kernel_main中task_init调用后task_t*task_b=create_kernel_task(task_b_main);task_run(task_b);再次编译运行,效果如下:

至此,我们已经初步完成了系统调用的框架。后续如果有需要,我们再对大框架进行修改。以后只要添加一个系统调用xxx,对应的处理函数就叫sys_xxx,这是我们后面的一个约定。

现在的篇幅略微有些短了(bushi),我们来实现一个printf吧。毕竟从内存管理开始,我们就在忍受着交替的monitor_write、monitor_write_hex、monitor_write_dec,如果到了下一节的shell我们还在用这些,那这个画面……

所以,实现一个printf势在必行。之所以拖到现在,是因为前面的篇幅都被排满了。

那么,我们开始。printf分为两个部分:print和f。看起来print简单一点,我们就先做print吧。

在Linux中,输出用的函数归根到底是write系统调用。我们照葫芦画瓢,也实现一个write系统调用。不过在Linux上,write是用来写文件的,第一个参数代表对应的文件描述符(第18节会详细讲解这是个什么东西)。只要传入1,那么Linux就会认为你在往标准输出写入。这个功能好,我也这么干。

那么,sys_write的具体内容如下:

代码15-11只支持到标准输出的write(kernel/syscall.c)

intsys_write(intfd,constvoid*msg,intlen){if(fd==1){char*s=(char*)msg;for(inti=0;i

#ifndef_SYSCALL_H_#define_SYSCALL_H_typedefvoid*syscall_func_t;intsys_getpid();intsys_write(int,constvoid*,int);syscall_func_tsyscall_table[]={sys_getpid,sys_write,};#endif接下来添加对应的包装:

代码15-13write的包装(kernel/syscall_impl.asm)

[globalwrite]write:pushebxmoveax,1movebx,[esp+8]movecx,[esp+12]movedx,[esp+16]int80hpopebxret按照C编译器约定,ebx不能随便用,所以这里push又pop了一下。那么,参数的位置也就要相应顺延,从esp+4、esp+8、esp+12都加了4。

好了,我们来测试一下write:

代码15-14write测试(kernel/main.c)

voidtask_b_main(){write(1,"task_bpid:",strlen("task_bpid:"));monitor_write_dec(getpid());write(1,"\n",2);task_exit(0);}编译,运行,效果仍应如图15-2所示。现在,我们已经有了print的系统调用,该实现f了。

或许有人会说,你这个write比monitor_write需要的参数还要多,有什么好处可言吗?你说得对,但是write是系统调用,未来可以给应用程序用,但是monitor_write并不行。

怎样实现这个f呢?这个f背后的内容非常庞大,我们不写那么多,只支持%d、%x、%c以及%s。如果只支持打印的话,功能有点少,顺便再支持一个sprintf。涉及到sprintf,那就必然存在要把整数转换成字符串的问题。

输出十进制和十六进制整数我们已有先例,但是那都是输出到屏幕上了,我们总不可能从屏幕里再收集一遍。所以我们只好写一个单独的函数了。

查找资料发现,在Windows下,对应的整数转字符串函数为itoa,原型是char*itoa(intnum,char*ptr,intradix)。我们不需要这样一个返回值,但我们又需要写入char*。这是因为char*本身是一个字符串,在别的作用域修改char*就需要char*的指针,也就是char**。

最终,我们决定把itoa写成:voiditoa(uint32_tnum,char**ptr_addr,intradix)。它的实现也没有那么难:

代码15-15itoa(lib/printf.c)

#include"common.h"staticvoiditoa(uint32_tnum,char**buf_ptr_addr,intradix){uint32_tm=num%radix;//最低位uint32_ti=num/radix;//最高位if(i)itoa(i,buf_ptr_addr,radix);//先把高位化为字符串if(m<10){//处理最低位*((*buf_ptr_addr)++)=m+'0';//0~9,直接加0}else{*((*buf_ptr_addr)++)=m-10+'A';//10~15,10~15->0~5->A~F}}接下来我们来思考一个问题:printf接收的参数并没有数量上的限定,它哪来的那么大能耐接收无穷无尽的参数呢?这就用到了C语言一个不那么鲜为人知的特性:可变参数包。

那么这四个东西是怎么实现的呢?我们找到了mingw中对应的头文件,位于mingw文件夹下/lib/gcc/mingw32/9.2.0/include/stdarg.h(不同版本mingw可能变化),特此复制粘贴供诸位参考。请看VCR:

代码15-16va_list有关函数的实现(无文件)

#defineva_start(v,l) __builtin_va_start(v,l)#defineva_end(v) __builtin_va_end(v)#defineva_arg(v,l) __builtin_va_arg(v,l)#if!defined(__STRICT_ANSI__)||__STDC_VERSION__+0>=199900L\||__cplusplus+0>=201103L#defineva_copy(d,s) __builtin_va_copy(d,s)//C99以上或C++11以上或添加-ansi选项时提供#endif#define__va_copy(d,s) __builtin_va_copy(d,s)原来是编译器内置的实现,那没事了。在i686-elf-tools的类似路径下,我们也找到了这样的一段代码,看来我们的gcc也是支持这几个东西的。

有编译器内置实现我们就不管了,新建include/stdarg.h,我们这就开抄:

代码15-17include/stdarg.h

#ifndef_STDARG_H_#define_STDARG_H_typedefchar*va_list;//我也不知道va_list是什么类型,先给个char*挂着,反正用不到#defineva_start(v,l) __builtin_va_start(v,l)#defineva_end(v) __builtin_va_end(v)#defineva_arg(v,l) __builtin_va_arg(v,l)#defineva_copy(d,s) __builtin_va_copy(d,s)#endif好了,现在我们已经有了处理可变参数包的手段了,我们来写一个printf:

代码15-18不能格式化的printf(lib/printf.c)

#include"stdarg.h"//在开头添加,因为用到了va_list以及操纵va_list的这些东西intvsprintf(char*buf,constchar*fmt,va_listap){return114514;}intsprintf(char*buf,constchar*fmt,...){va_listap;va_start(ap,fmt);intret=vsprintf(buf,fmt,ap);va_end(ap);returnret;}intvprintf(constchar*fmt,va_listap){charbuf[1024]={0};//理论上够了intret=vsprintf(buf,fmt,ap);write(1,buf,ret);returnret;}intprintf(constchar*fmt,...){va_listap;va_start(ap,fmt);intret=vprintf(fmt,ap);va_end(ap);returnret;}经过层层踢皮球,最终sprintf、vprintf和printf参数处理的重任都落到了vsprintf的头上。由于我们只支持%s、%c、%d和%x,我们也就不用多麻烦地处理%后面那一坨,直接用一个switch即可。

我们先来列一下基本框架:

代码15-19vsprintf的基本框架(lib/printf.c)

intvsprintf(char*buf,constchar*fmt,va_listap){char*buf_ptr=buf;//不动原来的buf,原来的buf可能还用得着constchar*index_ptr=fmt;//不动原来的fmt,但这个好像真用不着charindex_char=*index_ptr;//fmt串中的当前字符int32_targ_int;//可能会出现的int参数char*arg_str;//可能会出现的char*参数while(index_char){//没到fmt的结尾if(index_char!='%'){//不是%*(buf_ptr++)=index_char;//直接复制到bufindex_char=*(++index_ptr);//自动更新到下一个字符continue;//跳过后续对于%的判断}index_char=*(++index_ptr);//先把%跳过去switch(index_char){//对现在的index_char进行判断case's':case'c':case'x':case'd':default:break;}index_char=*(++index_ptr);//再把%后面的scxd跳过去}returnstrlen(buf);//返回做完后buf的长度}基本上就是这样,对代码的解释都在注释里了。

下面我们着重对index_char的判断进行讲解,实际上也并不多。

首先从%s和%c开始。大致思路是这样的:获取对应的参数->写入buf_ptr。

代码15-20%s、%c(lib/printf.c)

switch(index_char){//对现在的index_char进行判断case's':arg_str=va_arg(ap,char*);//获取char*参数strcpy(buf_ptr,arg_str);//直接strcpy进buf_ptrbuf_ptr+=strlen(arg_str);//buf_ptr直接跳到arg_str结尾,正好在arg_str结尾的\0处break;case'c':*(buf_ptr++)=va_arg(ap,int);//把获取到的char参数直接写进buf_ptrbreak;case'x':case'd':default:break;}之所以%c那里没有用va_arg(ap,char)获取char类型的参数,是因为这样会报警告,原因未知。

下面的%x和%d逻辑类似,因为有itoa十分简单。

代码15-21%x、%d(lib/printf.c)

case'x':arg_int=va_arg(ap,int);//获取int参数itoa(arg_int,&buf_ptr,16);//itoa早在设计时就可以修改buf_ptr,这样就直接写到buf_ptr里了,还自动跳到数末尾break;case'd':arg_int=va_arg(ap,int);//获取int参数if(arg_int<0){//给负数前面加个符号arg_int=-arg_int;//先转负为正*(buf_ptr++)='-';//然后加负号}itoa(arg_int,&buf_ptr,10);//itoa早在设计时就可以修改buf_ptr,这样就直接写到buf_ptr里了,还自动跳到数末尾break;现在我们的printf就已经写完了,在Makefile的OBJS最后加入一个out/printf.o,准备进行测试。

代码15-22现在的task_b_main(kernel/main.c)

voidtask_b_main(){printf("task_b%s%d%c","pid:",getpid(),'\n');task_exit(0);}编译,运行,效果仍应如图15-2所示。至此,我们的printf顺利完成。

最后,我们再开发一个内核专用的printk,它直接调用monitor_write,省略了write的中间步骤。

代码15-23printk(lib/kstdio.c)

#include"stdio.h"#include"monitor.h"intprintk(constchar*fmt,...){va_listap;va_start(ap,fmt);charbuf[1024]={0};intret=vsprintf(buf,fmt,ap);va_end(ap);monitor_write(buf);returnret;}代码15-24include/stdio.h

#ifndef_STDIO_H_#define_STDIO_H_#include"common.h"#include"stdarg.h"intvsprintf(char*buf,constchar*fmt,va_listap);intsprintf(char*buf,constchar*fmt,...);intvprintf(constchar*fmt,va_listap);intprintf(constchar*fmt,...);intprintk(constchar*fmt,...);//forkerneluse#endif在Makefile的OBJS处添加out/kstdio.o,由于测试代码未变更,暂时不需要编译运行。

好了,本节到此为止就结束了,下一节我们开始做更好的人机交互——也就是shell。

和前面几节相比,这一节应该会轻松很多,因为shell离用户层更近,也就更贴合日常开发时的代码习惯,再也不用去管什么硬件规程了——不过也就欢快这一节,下面两节又是硬菜了。

我们希望我们的shell能够很方便地移植成用户程序,所以我们要保证shell中调用的函数最终都是应用程序能直接用的东西,包括系统调用和string.h里的那一坨。

作为一个shell,读取键盘输入是必要的,但我们目前还没有读取键盘输入的系统调用。

啊这个不是非常简单吗,键盘输入就是标准输入,读标准输入用scanf不就行了?

你说得对,但是把一个scanf说明白写明白已经抵得上我至少一节的篇幅了。所以我们还是得到Linux里去想办法。

经过查阅资料,我们发现,归根结底,在Linux中,得到键盘输入的函数是read。只要给第一个参数传0,read就会默认你要读键盘输入。而在Linux中read时,只要没有回车,read就不会返回。

我们的read不需要那么智能,有一个键返回一个就够了。来到kernel/syscall.c,我们来写sys_read:

代码16-1read系统调用的背后(kernel/syscall.c)

#include"fifo.h"//加在开头externfifo_tdecoded_key;//加在开头//省略中间的syscall_manager、sys_getpid和sys_writeintsys_read(intfd,void*buf,intcount){intret=-1;if(fd==0){//如果是标准输入char*buffer=(char*)buf;//先转成char*uint32_tbytes_read=0;//读了多少个while(bytes_read

代码16-2read的实现(kernel/syscall_impl.asm)

[globalread]read:pushebxmoveax,2movebx,[esp+8]movecx,[esp+12]movedx,[esp+16]int80hpopebxret目前我们输出字符串需要依靠printf,但是printf("%s\n")我们要频繁用到,这又实在是太长了。

因此,我们把lib/printf.c改名为lib/stdio.c,并封装了两个最基本的东西,puts和putchar:

代码16-3puts和putchar(lib/stdio.c)

新建一个kernel/shell.c,我们正式开始写shell。先搭一个最基本的脚手架吧:

代码16-4脚手架(kernel/shell.c)

#ifndef_SHELL_H_#define_SHELL_H_#include"common.h"#defineMAX_CMD_LEN100#defineMAX_ARG_NR30voidshell();#endif在Makefile的OBJS中添加out/shell.o,编译运行,自然是什么都没有,因为我们根本就没有运行shell的入口。

在kernel_main中创建一个新任务用来执行shell:

代码16-6shell任务(kernel/main.c)

#include"shell.h"//添加在开头voidkernel_main()//kernel.asm会跳转到这里{monitor_clear();init_gdtidt();init_memory();init_timer(100);init_keyboard();asm("sti");task_t*task_a=task_init();task_t*task_b=create_kernel_task(task_b_main);task_t*task_shell=create_kernel_task(shell);task_run(task_b);task_run(task_shell);monitor_write("kernel_mainpid:");monitor_write_dec(getpid());monitor_put('\n');while(1){if(fifo_status(&decoded_key)>0){//monitor_put(fifo_get(&decoded_key));}}}我们注释掉了最后的monitor_put,这是因为我们已经有了shell(即使只是个脚手架),不再需要这么低级的人机交互了。

现在再次编译,运行,效果如下:

现在我们就得到了一个shell,一个输入什么都不会返回的shell。

task_b_main已经结束其历史使命,可以删掉了。现在的main.c就精简成了这个样子:

代码16-7如今的kernel/main.c

#include"monitor.h"#include"gdtidt.h"#include"isr.h"#include"timer.h"#include"memory.h"#include"mtask.h"#include"keyboard.h"#include"shell.h"task_t*create_kernel_task(void*entry){task_t*new_task;new_task=task_alloc();new_task->tss.esp=(uint32_t)kmalloc(64*1024)+64*1024-4;new_task->tss.eip=(int)entry;new_task->tss.es=new_task->tss.ss=new_task->tss.ds=new_task->tss.fs=new_task->tss.gs=2*8;new_task->tss.cs=1*8;returnnew_task;}voidkernel_main()//kernel.asm会跳转到这里{monitor_clear();init_gdtidt();init_memory();init_timer(100);init_keyboard();asm("sti");task_t*task_a=task_init();task_t*task_shell=create_kernel_task(shell);task_run(task_shell);while(1);}有种回到了第12节的错觉呢?

下面我们来做对命令的解析,这一部分比较好想。

代码16-8命令解析cmd_parse(kernel/shell.c)

staticintcmd_parse(char*cmd_str,char**argv,chartoken){intarg_idx=0;while(arg_idxMAX_ARG_NR)return-1;//参数太多,超过上限了argc++;//argc增一,如果最后一个字符是空格时不提前退出,argc会错误地被多加1}returnargc;}代码的详细解释请参见注释,写的已经很详尽了。我们的cmd_parse支持自己传入分隔符,顺便还支持了一下引号。

下面是新版的shell本体:

代码16-9新版shell(kernel/shell.c)

现在,我们的shell已经支持用空格分割参数,并且支持把引号括起来的部分当成整体。只有一个引号我没有测试,理论上会一直延伸到命令末尾。

最后,是命令的执行,这一部分我们单开一个cmd_execute来做:

代码16-10命令执行(kernel/shell.c)

voidcmd_ver(intargc,char**argv){puts("TutorialOSIndev");}voidcmd_execute(intargc,char**argv){if(!strcmp("ver",argv[0])){cmd_ver(argc,argv);}else{printf("shell:badcommand:%s\n",argv[0]);}}目前而言,我们只支持一个ver就足够了。

用cmd_execute(argc,argv);//执行替换for(inti=0;i

shell就做到这里,下面两节我们来吃一盘硬菜:文件系统。(想当年,我被文件系统卡了整整一年半,令人感叹)

什么是文件系统呢?简而言之,文件系统就是管理文件的系统。当我们谈及对文件的操作的时候,文件系统是动态的,我们可以与它交互;当我们谈及文件系统的磁盘结构之类的东西的时候,文件系统又是静态的,它的每一个字节都摆在那里,随你可看。

本节我们先不着急实现文件系统,以及介绍那个怪怪的FAT16到底是个什么东西。我们先来完善一下基础设施建设,写一下硬盘驱动以及RTC(Real-TimeClock,实时时钟)。

当然,我们先挑软柿子捏,从RTC开始实现。

与键盘类似,RTC也是外部设备,需要使用in/out指令从对应的端口来读取数据。这之中,端口0x70是索引寄存器,用来告诉RTC你要读什么数据;端口0x71是数据寄存器,你想读的RTC数据就从这里读出。查阅资料可知,当前时刻的世纪、年、月、日、时、分、秒分别对应着索引0x32、0x9、0x8、0x7、0x4、0x2和0x0。需要注意的是,从RTC读出的数据使用8421BCD编码,需要手动转换为十进制;具体而言,是将读出的数据的高4位当作十位,低4位当作个位。还有一点需要注意,在读取完后,需要向0x70端口发送0x80,表示读取完成。

好了,我们就用上面一段话完整地描述了RTC的实现,相当简单吧?那么,开工。

首先,创建include/cmos.h,我们来把上面的这一堆常数写在一个地方:

#ifndef_CMOS_H_#define_CMOS_H_#include"common.h"#defineCMOS_INDEX0x70#defineCMOS_DATA0x71#defineCMOS_CUR_SEC0x0#defineCMOS_CUR_MIN0x2#defineCMOS_CUR_HOUR0x4#defineCMOS_CUR_DAY0x7#defineCMOS_CUR_MON0x8#defineCMOS_CUR_YEAR0x9#defineCMOS_CUR_CEN0x32#definebcd2hex(n)(((n>>4)*10)+(n&0xf))typedefstruct{intyear,month,day,hour,min,sec;}current_time_t;#endif不仅定义了这些常量,还在最后添加了bcd2hex和一个结构体类型,这纯粹是为了后续方便。

然后,由于RTC是外设,我们在drivers目录下添加cmos.c,来写真正操作RTC的代码:

代码17-2读取RTC(drivers/cmos.c)

#include"cmos.h"staticuint8_tread_cmos(uint8_tp){uint8_tdata;outb(CMOS_INDEX,p);data=inb(CMOS_DATA);outb(CMOS_INDEX,0x80);returndata;}voidget_current_time(current_time_t*ctime){ctime->year=bcd2hex(read_cmos(CMOS_CUR_CEN))*100+bcd2hex(read_cmos(CMOS_CUR_YEAR));ctime->month=bcd2hex(read_cmos(CMOS_CUR_MON));ctime->day=bcd2hex(read_cmos(CMOS_CUR_DAY));ctime->hour=bcd2hex(read_cmos(CMOS_CUR_HOUR));ctime->min=bcd2hex(read_cmos(CMOS_CUR_MIN));ctime->sec=bcd2hex(read_cmos(CMOS_CUR_SEC));}总共20行,我们就实现了对RTC的读取。那么让我们进入kernel/main.c添加测试代码看看效果:

代码17-3测试RTC(drivers/cmos.c)

voidkernel_main()//kernel.asm会跳转到这里{monitor_clear();init_gdtidt();init_memory();init_timer(100);init_keyboard();asm("sti");task_t*task_a=task_init();task_t*task_shell=create_kernel_task(shell);//task_run(task_shell);current_time_tctime;get_current_time(&ctime);printk("%d/%d/%d%d:%d:%d",ctime.year,ctime.month,ctime.day,ctime.hour,ctime.min,ctime.sec);while(1);}我们注释掉了开始运行task_shell的这行代码,因为在这三节里都用不上它。

为了与现实相符合,我们选择手动调节RTC的输出,让它加上8小时。

代码17-4手动加上8小时(drivers/cmos.c)

同样还需要注意的是,为了应对不同虚拟机间的不同模拟情况,这里使用了宏定义NEED_UTC_8,当定义这个宏时就会自动增加给RTC添加8小时的处理,否则就是代码17-2的样子。

如果你始终选择用QEMU进行模拟,记得在include/cmos.h中加入一行#defineNEED_UTC_8。

ok,那么RTC就这样被我们轻松拿下。然后是下一个据点:硬盘驱动。

硬盘,显然也是外部设备,如果要真正详细地去实现硬盘,那足够写出这个OS现在的代码三分之一的代码量的驱动来(osdev上的IDE驱动有749行)。不过,只是读取和写入的话,实际上有捷径可走,无需像osdev上一样费劲绕道PCI,只需要几个简单的端口操作即可。

以上的部分就是我们需要用到的部分,其中的Secondary一列代表第二块硬盘,可以不管,反正到最后只需要操作第一块硬盘。

想要读写一块硬盘的一个扇区,大概操作是这样的:

1.等待可能存在的上一个硬盘操作完成。2.通过向0x1f2~0x1f6端口写入适当数据,告知硬盘需要操作的扇区编号及个数。3.向0x1f7端口写入0x20(代表读)或者0x30(代表写)。4.等待硬盘操作完成。5.从0x1f0端口读出数据或向0x1f0端口写入数据,一次两个字节。

看上去比较简单,但是有一些具体的技术细节需要注意,还是直接看代码吧:

代码17-5硬盘驱动:等待上一个硬盘操作完成,指定操作扇区(drivers/hd.c)

#include"common.h"//等待磁盘,直到它就绪staticvoidwait_disk_ready(){while(1){uint8_tdata=inb(0x1f7);//输入时,0x1f7端口为主硬盘状态寄存器if((data&0x88)==0x08){//第7位:硬盘忙,第3位:硬盘已经准备好//提取第7位和第3位,判断是否为0x08,即硬盘不忙且已准备好return;//等完了}}}//选择要操作扇区staticvoidselect_sector(intlba){//第一步:向0x1f2端口指定要读取扇区数//输出时,0x1f2端口为操作扇区数outb(0x1f2,1);//第二步:存入写入地址//0x1f3~0x1f5:LBA的低中高8位//0x1f6:REG_DEVICE,Drive|Head|LBA(24~27位)//在实际操作中,只有一个硬盘,Drive|Head=0xe0outb(0x1f3,lba);outb(0x1f4,lba>>8);outb(0x1f5,lba>>16);outb(0x1f6,(((lba>>24)&0x0f)|0xe0));}以上两个函数便是我前面提到过的“具体细节”,有关说明已经写在注释中了。

或许有人要问:

那你为什么一次只操作一个扇区呢?一次操作多个扇区不好吗?

你说得对,但是,由于QEMU的问题(这是第几遍出现了),一次操作多个扇区会莫名其妙卡住,所以只好一次操作一个扇区了。

既然技术细节已经填充上,单独读取和写入一个扇区的函数也就可以写了:

代码17-6硬盘驱动:读取和写入一个扇区(drivers/hd.c)

读写多个扇区就是对读写单个扇区的简单重复:

代码17-7硬盘驱动:连续读写多个扇区(drivers/hd.c)

//读取硬盘staticvoidread_disk(intlba,intsec_cnt,uint32_tbuffer){for(inti=0;i

代码17-8硬盘驱动:最终暴露的接口(drivers/hd.c)

//包装voidhd_read(intlba,intsec_cnt,void*buffer){read_disk(lba,sec_cnt,(uint32_t)buffer);}voidhd_write(intlba,intsec_cnt,void*buffer){write_disk(lba,sec_cnt,(uint32_t)buffer);}好,硬盘驱动到此结束,但是怎么测试呢?显然,这时并不存在一个虚拟硬盘。

(注:由于此程序目前名义上正在重构,所以请以old文件夹中的内容为准。当然实际上重构已经停滞了。)

本次测试需要用到的程序为ftimgcreate和ftformat,确认这两个程序是否都已存在且可供调用:

如果在命令行输入ftimgcreate与ftformat后,输出如上两图所示(或类似),则说明这两个程序配置相当成功;否则,请检查是否把这两个程序放在了正确的地方。

在命令行中执行这两条命令:

若无返回消息,则说明成功。现在hd.img就是一个有数据的虚拟硬盘了。

在main.c中将kernel_main修改如下:

代码17-9测试硬盘用kernel_main(kernel/main.c)

voidkernel_main()//kernel.asm会跳转到这里{monitor_clear();init_gdtidt();init_memory();init_timer(100);init_keyboard();asm("sti");task_t*task_a=task_init();task_t*task_shell=create_kernel_task(shell);//task_run(task_shell);charfirst_sect[512]={0};hd_read(0,1,first_sect);printk(first_sect);while(1);}实际上就是读取第一个扇区的内容。

什么都没有输出,这是因为我们还没有在QEMU上挂载这个虚拟硬盘,修改Makefile中的run指令如下:

代码17-10Makefile中的run(Makefile)

run:a.img qemu-system-i386-fdaa.img-hdahd.img-boota在挂载硬盘的同时指定从软盘启动,因为硬盘里根本啥都没有,从硬盘启动就废了。

在去掉不可打印字符后,输出与硬盘内真实数据一致。由于FTFORMAT后紧跟着就是00,所以后面的问号没有输出。总之,可以认为我们的硬盘驱动已经正常工作了。

那么,实现FAT16的基建已经基本铺好,下面的工作就是了解什么是FAT16,然后动手实践了。

什么是FAT16文件系统呢?这就涉及到一段比较长的科学历史,总之,FAT16文件系统是由微软公司自主研发的一款……(后面忘了)

FAT16文件系统由以下几个部分组成:引导扇区、FAT表、根目录区以及数据区。其中,引导扇区就是单独的一个扇区;FAT表共两份,互为备份,各占32个扇区;根目录区占32个扇区;数据区占据剩余部分。FAT表和数据区我们放在下一节来讲,本节我们只处理引导扇区和根目录区。

引导扇区的结构和前面的图2-1完全一致,在这里重新放一遍:

它对应的代码如代码18-1所示:

代码18-1FAT16引导扇区结构(include/file.h)

typedefstructFAT_BPB_HEADER{unsignedcharBS_jmpBoot[3];unsignedcharBS_OEMName[8];unsignedshortBPB_BytsPerSec;unsignedcharBPB_SecPerClust;unsignedshortBPB_RsvdSecCnt;unsignedcharBPB_NumFATs;unsignedshortBPB_RootEntCnt;unsignedshortBPB_TotSec16;unsignedcharBPB_Media;unsignedshortBPB_FATSz16;unsignedshortBPB_SecPerTrk;unsignedshortBPB_NumHeads;unsignedintBPB_HiddSec;unsignedintBPB_TotSec32;unsignedcharBS_DrvNum;unsignedcharBS_Reserved1;unsignedcharBS_BootSig;unsignedintBS_VolID;unsignedcharBS_VolLab[11];unsignedcharBS_FileSysType[8];unsignedcharBS_BootCode[448];unsignedshortBS_BootEndSig;}__attribute__((packed))bpb_hdr_t;想要创建一个FAT16文件系统,需要把BPB的内容依照上面的格式填入,同时还要初始化FAT表——在目前的语境下,相当于向两个扇区处分别写入4个字节,具体是什么后面再说。

1.等待上一步可能存在的硬盘操作完成。2.向0x1f6寄存器写入0x00,0x1f7寄存器写入0xec。3.等待硬盘操作完成。4.从0x1f0寄存器读取512字节的硬盘信息。5.从硬盘信息中收集硬盘总扇区数。

详细代码如代码18-2:

代码18-2获取硬盘扇区数(drivers/hd.c)

staticinthd_size_cache=0;intget_hd_sects(){if(hd_size_cache)returnhd_size_cache;while(inb(0x1f7)&0x80);//等硬盘不忙了再发送命令,具体意义见wait_disk_readyoutw(0x1f6,0x00);outw(0x1f7,0xec);//IDENTIFY命令wait_disk_ready();uint16_t*hdinfo=(uint16_t*)kmalloc(512);char*buffer=(char*)hdinfo;for(inti=0;i<256;i++){//每次硬盘会发送2个字节数据uint16_tdata=inw(0x1f0);*((uint16_t*)buffer)=data;//存入bufbuffer+=2;}intsectors=((int)hdinfo[61]<<16)+hdinfo[60];kfree(hd_info);return(hd_size_cache=sectors);}由于硬盘操作可能比较耗时,这里存了一个hd_size_cache,在第一次调用后就直接引用这里面的数据而不再向硬盘发命令了。由于用到了kmalloc,记得在开头添加#include"memory.h"。

那么,新建fs目录,并新建fat16.c和file.c,由于出现了新目录,所以贴一下新Makefile:

代码18-3新Makefile(Makefile)

格式化文件系统也是相↑当↓公↑式→的操作,所以直接在下面贴代码了,具体细节会在代码里标注出来。

代码18-4创建FAT16文件系统(fs/fat16.c)

按照上面的方法,应该就可以格式化出一个FAT16文件系统了。下面我们进行测试。

首先,在命令行输入ftimgcreatehd.img-thd-size80,重新创建虚拟硬盘hd.img:

然后,调用ftlshd.img-l,确认hd.img中不存在FAT16文件系统:

在main.c中添加fat16_format_hd(),编译,运行,等待10秒后,再次ftlshd.img-l,确认文件系统已经存在:

文件系统已经成功创建,说明我们的格式化函数已经完成。接下来,就可以开始进行创建文件和打开文件的操作了。

目前而言,创建文件和打开文件都只需要操作根目录区即可完成。根目录区中,一个文件对应的信息为32个字节,具体代码如下所示:

代码18-6根目录区中的文件信息(include/file.h)

那么,首先要考虑的就是怎样把一个文件名转化为一个合法的8.3文件名。在转化时,要求文件名除了大小写字母、数字外,其他字符都将被替换为下划线,小写字母将会自动转为大写字母,并且在第一个字节为0xe5时要自动替换为0x05。这样的工作十分繁杂,我们选择直接修改myfattools中的lfn2sfn函数(有开源抄就是好):

代码18-7文件名转8.3(fs/fat16.c)

在此之前,我们先来读取一下根目录区的所有文件练练手。如果你忘了根目录区的大小和起点的话,没有关系,file.h的宏定义已经定义好了:

代码18-8读取根目录所有文件read_dir_entries(drivers/fat16.c)

//读取根目录目录项fileinfo_t*read_dir_entries(int*dir_ents){fileinfo_t*root_dir=(fileinfo_t*)kmalloc(ROOT_DIR_SECTORS*SECTOR_SIZE);hd_read(ROOT_DIR_START_LBA,ROOT_DIR_SECTORS,root_dir);//将根目录的所有扇区全部读入inti;for(i=0;i

代码18-9read_dir_entries测试(kernel/main.c)

intentries;fileinfo_t*root_dir=read_dir_entries(&entries);//用这两行替换掉fat16_format_hd();作为测试,我们来新建一个文件ilovehon.kai(没什么别的意思,名字你可以随便换,但必须遵循上面提到的8.3文件名规则),并填充512个A和512个B(同样只是测试,内容也可以随便换):

用ftcopy命令将文件写入虚拟硬盘hd.img:

扩写上面的测试代码:

代码18-10一个啥都没有的ls(kernel/main.c)

for(inti=0;i

注意到,将ilovehon.kai手工转化为8.3文件名也为ILOVEHONKAI,因此可知read_dir_entries实现成功。

下面就可以正式开始创建文件的操作了。想要创建一个文件,和格式化出一个文件系统是类似的,只需要把fileinfo_t结构体当中的各个成员分别填写好就可以。

目前来看,我们总共需要填入的东西里已经有些可以完成或忽略:填入name和ext的过程已经由lfn2sfn实现了;而type只需要填上0x20,reserved、clustno和size都设置为0即可。那么,就只剩下time和date了。

微软对time和date的编码如下:

time:低5位为秒,中6位为分,高5位为时;

date:低5位为日,中4位为月,高7位为年。其中,年份要减去1980,意义不明。

代码18-11创建文件(fs/fat16.c)

最后是打开文件,我们只需要根据文件名找到对应的fileinfo_t返回就可以了。

代码18-12打开文件(fs/fat16.c)

//打开文件intfat16_open_file(fileinfo_t*finfo,char*filename){charsfn[20]={0};intret=lfn2sfn(filename,sfn);//将原文件名转换为8.3if(ret)return-1;//转换失败,不用打开了intentries;fileinfo_t*root_dir=read_dir_entries(&entries);//读取所有目录项intfile_index=entries;//filename对应文件的索引for(inti=0;i

将刚才写的测试read_dir_entries的代码替换为:

代码18-13创建文件测试(kernel/main.c)

printk("createstatus:%d\n",fat16_create_file(NULL,"iloveado.fai"));编译,运行,效果应如图所示:

在命令行中使用ftls工具,确认文件已经成功创建:

好了,那么这一节作为我们实现FAT16的第一战,显然效果非常成功。下一节我们来实现文件的读取、写入和删除,从而为后续的包装打好地基。

在实现文件的读取、写入和删除之前,首先还需要了解两个在上一节被刻意忽略掉的概念:FAT表以及簇。

数据区你远看它是一整块,但是近看它被分割成了一个个的扇区,每一个扇区都还有另一个名字,这就是簇。而FAT表,就是簇的索引,每一个FAT项的位置实际上都是一个簇的编号,简称簇号。在FAT16文件系统中,一个FAT项占据16位,这也是这个文件系统名字的由来。

每一个FAT项所在的位置都对应着一个簇号,而这个FAT项中存放的数据,则是这个文件数据的下一个簇的所在位置。这个逻辑有点像链表:首先从文件的clustno属性获取第一个簇的位置,然后读取第一个簇的簇号所在的FAT项获取下一个簇的位置,以此类推。据规定,在FAT16文件系统中,若一个簇号对应的FAT项的值大于等于0xFFF8,那么说明文件结束。一般而言,大多数实现都采用0xffff作为文件结束标志。

然而,为了软件识别的需要,微软官方直接把前两个FAT项砍了,并规定:数据区的第一个簇的簇号为2,往后依次类推。这意味着,在读写簇内容的时候还需要手动减2,才能读到正确的扇区。

那么,我们来写一个读写FAT项的函数。虽说没有类,做不到模拟数组操作,但是能接近还是接近一下:

代码19-1读写FAT项(fs/fat16.c)

接下来就是给出簇号,读写对应的簇的函数了:

代码19-2读写一个簇(fs/fat16.c)

//读取第n个cluststaticvoidread_nth_clust(uint16_tn,void*clust){hd_read(n+SECTOR_CLUSTER_BALANCE,1,clust);}//写入第n个cluststaticvoidwrite_nth_clust(uint16_tn,constvoid*clust){hd_write(n+SECTOR_CLUSTER_BALANCE,1,(void*)clust);}其中SECTOR_CLUSTER_BALANCE定义于include/file.h,其值为DATA_START_LBA-2。具体原因,是因为簇号要减去2才是数据区中的扇区编号,所以在把簇号加上数据区以找到对应扇区的同时,还要再减去2以找到正确的位置。

有了读取FAT项和读取一个簇的手段,实现读取文件几乎是水到渠成的,其具体操作如下:

1.根据打开的fileinfo_t找到第一个簇号。2.读取第一个簇到缓冲区。3.读取该簇号对应的FAT项,找到该文件下一个簇的簇号。4.若该FAT项大于等于0xfff8,则文件结束,终止循环。5.假装下一个簇是第一个簇,重复2~5。

将上面的思路化为代码,就得到了:

代码19-3读取文件(fs/fat16.c)

//读取文件,当然要有素质地一次读整个文件啦intfat16_read_file(fileinfo_t*finfo,void*buf){uint16_tclustno=finfo->clustno;//finfo中记录的第一个簇号char*clust=(char*)kmalloc(512);//单独给簇分配一个缓冲区,直接往buf里写也行do{read_nth_clust(clustno,clust);//将该簇号对应的簇读取进来memcpy(buf,clust,512);//拷贝入bufbuf+=512;//buf后推一个扇区clustno=get_nth_fat(clustno);//获取下一个簇号if(clustno>=0xFFF8)break;//文件结束,退出循环}while(1);kfree(clust);//读完了,释放临时缓冲区return0;//返回}如你所见,这个读取文件的函数非常之短,甚至比前面的创建和打开还短,只有15行,和读写FAT的单个函数差不多长。这就是FAT16的简单之处。

作为测试,上一节我们写入了文件ilovehon.kai,现在是时候同时对打开文件和读取文件进行一次测试了。替换掉上一节的创建文件测试代码,编写测试代码如下:

代码19-4读取测试(fs/fat16.c)

一刻也没有为读取文件的迅速结束而哀悼,立刻赶到战场的是——删除文件!

想要删除一个文件,并不需要把文件的所有内容都随机01啦、设成0或1啦这些,非常简单,你只需要让这个文件无法被找到就可以了。

这里总算可以填上前面挖的一个坑了:

并且在第一个字节为0xe5时要自动替换为0x05

这是为什么呢?正是因为在FAT文件系统中,第一个字节为0xe5的文件被视为“已经删除”,所以才要特意和谐一下。

那么,既然这样就相当于在根目录区里消失了,数据区的簇又可以赖着,一个文件所剩的资源就只有FAT项了。事实上,在删除一个文件时,它所在的FAT项也要全部设置为0。

只要注意这两点,那么实现删除文件也就相当简单:

代码19-5删除文件(fs/fat16.c)

下面是测试环节。我们在上一节测试创建文件时创建了一个iloveado.fai文件,现在我们来删除它。

代码19-6删除文件测试(kernel/main.c)

fileinfo_tfinfo;intstatus=fat16_delete_file("iloveado.fai");printk("deletestatus:%d\n",status);编译,运行,效果如下图所示:

在命令行中调用ftls,确认删除成功:

现在,本节最简单的两个操作——读取和删除,已经完成,我们来进攻最后一个据点——写入。只要写入文件完成,后面就都是软件上的事了。

为了简单起见,在写入文件时,只支持将整个文件全部覆盖。相信一些开发经验比较丰富的读者已经要说了:

可是我用过fseek/lseek,可以在任意位置进行写入呀。

为了简单起见,这些东西我们可以用纯软件来实现,就不麻烦FAT16的底层实现了。

实现写入文件主要的问题在于要处理的问题太多,包括但不限于:

对于上面的几个东西,我们来分步解决。这一部分代码的注释非常重要,请认真阅读(?)

首先,针对第一次写入需要分配首簇号的问题,我们添加了一个判断:

代码19-7写入文件(1)——为第一次写入的空文件分配簇号(fs/fat16.c)

//写入文件,为简单起见相当于覆盖了intfat16_write_file(fileinfo_t*finfo,constvoid*buf,uint32_tsize){uint16_tclustno=finfo->clustno,next_clustno;//从已有首簇号开始if(finfo->size==0&&finfo->clustno==0){//没有首簇号clustno=2;//从第2个簇开始分配while(1){if(get_nth_fat(clustno)==0){//当前簇空闲finfo->clustno=clustno;//分配break;//已找到空闲簇号}clustno++;//继续寻找下一个簇}}finfo->size=size;//更新大小再然后,是写入的主体部分,这里要处理的问题比较复杂。

代码19-8写入文件(2)——写入文件主体(fs/fat16.c)

intwrite_sects=(size+511)/512;//确认要写入的扇区总数,这里向上舍入while(write_sects){//只要还要写write_nth_clust(clustno,buf);//将当前buf的512字节写入对应簇中write_sects--;//要写入扇区总数-1buf+=512;//buf后移一个扇区next_clustno=get_nth_fat(clustno);//寻找下一个簇if(next_clustno==0||next_clustno>=0xfff8){//当前簇不可用next_clustno=clustno+1;//从下一个簇开始while(1){if(get_nth_fat(next_clustno)==0){//这个簇是可用的set_nth_fat(clustno,next_clustno);//将这个簇当成下一个簇链接上去break;}elsenext_clustno++;//否则,只好继续了}}clustno=next_clustno;//将下一个簇看做当前簇}最后,是收尾的部分。

代码19-9写入文件(3)——扫尾(fs/fat16.c)

好了,最后还是测试环节。

代码19-10写入测试(kernel/main.c)

fileinfo_tfinfo;intstatus=fat16_create_file(&finfo,"iloveado.fai");printk("createstatus:%d\n",status);char*buf=(char*)kmalloc(512);strcpy(buf,"IloveADanceOfFireandIce!");status=fat16_write_file(&finfo,buf,strlen(buf));printk("writestatus:%d\n",status);编译,运行,效果如下:

更换上面的测试代码为:

代码19-11写入测试II(kernel/main.c)

fileinfo_tfinfo;intstatus=fat16_open_file(&finfo,"iloveado.fai");printk("openstatus:%d\n",status);char*buf=(char*)kmalloc(512);status=fat16_read_file(&finfo,buf);printk("readstatus:%d\nfilecontent:%s\n",status,buf);再次编译运行,效果如下:

至此,我们已经彻底完成了FAT16的底层实现,下一章,我们来彻底完成文件系统的制作,实现用户可以使用的一套系统调用。

本节是实现FAT16的最后一个小节,我们将实现一套可供用户使用的系统调用,包括open、read、write、unlink以及lseek。有了这五个函数,我们就可以任意地进行文件读写以及对文件进行删除和创建。

所以,最终,经过包装,我们创建了一个file_t结构体,用于表示一个抽象的文件的概念。

代码20-1文件的底层抽象(include/file.h)

typedefenumFILE_TYPE{FT_USABLE,FT_REGULAR,FT_UNKNOWN}file_type_t;typedefenumoflags{O_RDONLY,O_WRONLY,O_RDWR,O_CREAT=4}oflags_t;typedefstructFILE_STRUCT{void*handle;void*buffer;intpos;intsize;intopen_cnt;file_type_ttype;oflags_tflags;}file_t;由于完全不打算实现目录,所以在file_type_t里,只有FT_REGULAR一种正常值,剩下两个一个用于判断文件是否已被占用,另一个则没什么用。

至于O_RDONLY这些,则是一个简单的判断读写的小机制,这样可以创建只读以及只写的文件,虽然这没什么用吧。

再往下的file_t可以说是十分灵活,我们甚至没有限制handle必须是fileinfo_t,你往里面塞什么牛鬼蛇神都行,只要你能在后面的read这些地方圆回来。

这个buffer具体的用处到后面read和write时再讲。

下面的pos则是代表文件的读写位置,lseek移动的就是它。

最后的open_cnt暂时没用,大概到了下一节或者最后一节才会有用。

下面是一些小小的改动:实际上,打开文件的操作是由任务执行的,所以至少在任务的层面,应该对文件给予一些支持。

代码20-2新版task_t结构体(include/mtask.h)

#defineMAX_FILE_OPEN_PER_TASK32typedefstructTASK{uint32_tsel;int32_tflags;exit_retval_tmy_retval;intfd_table[MAX_FILE_OPEN_PER_TASK];//heretss32_ttss;}task_t;与Linux0.01不同的是,这里并未直接存储file_t,而是存储了一个int,它代表对应的file_t在一个文件表中的索引。这么做的目的是节省空间,以及可能带来的更高氨醛性——毕竟理论上可以顺着malloc(虽然还没实现)找到task_t来给文件一锅端了(确信)。

这个数组需要在task_alloc时进行初始化:

代码20-3初始化任务中的文件描述符表(kernel/mtask.c)

task->fd_table[0]=0;//标准输入,占位task->fd_table[1]=1;//标准输出,占位task->fd_table[2]=2;//标准错误,占位for(inti=3;ifd_table[i]=-1;//其余文件均可用}然后,在fs/file.c中,我们来添加这个文件表:

代码20-4创建文件表(fs/file.c)

#include"file.h"#include"mtask.h"#include"memory.h"staticfile_tfile_table[MAX_FILE_NUM];然后我们需要提供一个/一组把fileinfo_t转换为file_t并安装到当前任务当中的函数,这由下面的install_to_global和install_to_local实现:

代码20-5将fileinfo_t安装到任务(fs/file.c)

代码20-6open:打开文件、创建文件(fs/file.c)

代码20-7read、write:读写文件(fs/fat16.c)

同时,改完这里以后,原先kernel/syscall.c中的sys_read和sys_write就可以删除了。

扩容用的krealloc写在了kernel/memory.c中:

代码20-8krealloc(kernel/memory.c)

void*krealloc(void*buffer,intsize){void*res=NULL;if(!buffer)returnkmalloc(size);//buffer为NULL,则realloc相当于mallocif(!size){//size为NULL,则realloc相当于freekfree(buffer);returnNULL;}//否则实现扩容res=kmalloc(size);//分配新的缓冲区memcpy(res,buffer,size);//将原缓冲区内容复制过去kfree(buffer);//释放原缓冲区returnres;//返回新缓冲区}接下来是关闭文件用的sys_close,基本上就是对文件使用资源的释放。

代码20-9close:关闭文件(fs/file.c)

intsys_close(intfd){intret=-1;//返回值if(fd>2){//的确是被打开的文件task_t*task=task_now();//获取当前任务uint32_tglobal_fd=task->fd_table[fd];//获取对应文件表索引task->fd_table[fd]=-1;//释放文件描述符file_t*cfile=&file_table[global_fd];//获取对应文件kfree(cfile->buffer);//释放缓冲区kfree(cfile->handle);//install_to_global中使用kmalloc分配fileinfo指针cfile->type=FT_USABLE;//设置type为可用return0;//关闭完成}returnret;//否则返回-1}移动读写指针用的sys_lseek纯属软件操作,sys_unlink则只是fat16_delete_file套皮,这里一并放上来。

代码20-10lseek、unlink:最后一个部分(fs/file.c)

intsys_lseek(intfd,intoffset,uint8_twhence){if(fd<3)return-1;//不是被打开的文件,返回if(whence<1||whence>3)return-1;//whence只能为123,分别对应SET、CUR、END,返回task_t*task=task_now();//获取当前任务file_t*cfile=&file_table[task->fd_table[fd]];//获取fd对应的文件fileinfo_t*fhandle=(fileinfo_t*)cfile->handle;//文件实际上对应的fileinfointsize=fhandle->size;//获取大小,总归是有用的intnew_pos=0;//新的文件位置switch(whence){caseSEEK_SET://SEEK_SET就是纯设置new_pos=offset;//直接设置break;caseSEEK_CUR://从当前位置算起移动offset位置new_pos=cfile->pos+offset;//用当前pos加上offsetbreak;caseSEEK_END://从结束位置算起移动offset位置new_pos=size+offset;//用大小加上offsetbreak;}if(new_pos<0||new_pos>size-1)return-1;//如果新的位置超出文件,返回-1cfile->pos=new_pos;//设置新位置returnnew_pos;//返回新位置}intsys_unlink(constchar*filename){returnfat16_delete_file((char*)filename);//直接套皮,不多说}好,那么到此为止,历时四节,我们的FAT16文件系统实现的征程到此结束!鼓掌!

或许有人会觉得:这看上去也不难嘛……那不妨自己查询资料写一个试试哦(

再往下几节,我们来实现应用程序的执行,彻底结束这个破烂不堪的操作系统教程。

本来是想写应用程序的,但是吧,这个问题吧,它这个这个,略微有一些难度,知道吧,所以说先挑比较简单的写。

引导扇区比较好改,先从引导扇区开始吧。和软盘版的引导扇区相比,主要要修改的部分有以下几点:

其余的部分均可保持不变。

我们先进入项目根目录,然后新建boot.asm和loader.asm,loader.asm我们仍旧选择使用第3-4节使用的白板Loader:

代码21-1白板Loader(loader.asm)

org0100hmovax,0B800hmovgs,ax;将gs设置为0xB800,即文本模式下的显存地址movah,0Fh;显示属性,此处指白色moval,'L';待显示的字符mov[gs:((80*0+39)*2)],ax;直接写入显存jmp$;卡死在此处将白板Loader用ftcopy命令写入硬盘:

在boot.asm中粘贴原先软盘版的boot.asm的所有内容,并将load.inc和pm.inc一并复制到根目录,然后就可以开始修改了。

首先来修改boot.asm中获取FAT项的部分:

代码21-2硬盘版GetFATEntry(boot.asm)

GetFATEntry:;返回第ax个簇的值pushespushbxpushax;都会用到,push一下movax,BaseOfLoadersubax,0100hmoves,axpopaxmovbx,2mulbx;每一个FAT项是两字节,给ax乘2就是偏移LABEL_GET_FAT_ENTRY:;将ax变为扇区号xordx,dxmovbx,[BPB_BytsPerSec]divbx;dx=ax%512,ax/=512pushdx;保存dx的值movbx,0;es:bx已指定addax,SectorNoOfFAT1;对应扇区号movcl,1;一次读一个扇区即可callReadSector;直接读入;bx到bx+512处为读进扇区popdxaddbx,dx;加上偏移movax,[es:bx];读取,那么这里就是了LABEL_GET_FAT_ENTRY_OK:;胜利执行popbxpopes;恢复堆栈ret修改的部分主要有:乘1.5的部分变成了乘2;读取的扇区数由两个降到一个;删掉了FAT12时期对FAT解压缩的处理。

读取扇区的部分则直接仿着四节前的那个硬盘驱动写就行了:

代码21-3硬盘版ReadSector(boot.asm)

ReadSector:;读硬盘扇区;从第eax号扇区开始,读取cl个扇区至es:bxpushesipushdipushespushbxmovesi,eaxmovdi,cx;备份ax,cx;读硬盘第一步:设置要读取扇区数movdx,0x1f2moval,cloutdx,almoveax,esi;恢复ax;第二步:写入扇区号movdx,0x1f3outdx,al;LBA7~0位,写入0x1f3movcl,8shreax,cl;LBA15~8位,写入0x1f4movdx,0x1f4outdx,alshreax,clmovdx,0x1f5outdx,al;LBA23~16位,写入0x1f5shreax,clandal,0x0f;LBA27~24位oral,0xe0;表示当前硬盘movdx,0x1f6;写入0x1f6outdx,al;第三步:0x1f7写入0x20,表示读movdx,0x1f7moval,0x20outdx,al;第四步:检测硬盘状态.not_ready:nopinal,dx;读入硬盘状态andal,0x88;分离第4位,第7位cmpal,0x08;硬盘不忙且已准备好jnz.not_ready;不满足,继续等待;第五步:将数据从0x1f0端口读出movax,di;di为要读扇区数,共需读di*512/2次movdx,256muldxmovcx,axmovdx,0x1f0.go_on_read:inax,dxmov[es:bx],axaddbx,2loop.go_on_read;结束popbxpopespopdipopesiret这里需要注意,ReadSector调用前后会修改bx、di和esi,如果自己写的话要注意备份。

由于换了FAT16,boot.asm开头的%include"fat12hdr.inc"也要同步更换为%include"fat16hdr.inc",这里面的内容对照着格式化函数和file.h很容易写出:

代码21-5硬盘版主循环(boot.asm)

cmpax,0FFFFh;这里!原本是0FFF,但FAT16的文件结束时FFFF,所以这里要修改jzLABEL_FILE_LOADED;若此项=0FFFF,代表文件结束,直接跳入Loaderpushax;重新存储FAT号,但此时的FAT号已经是下一个FAT了至此,硬盘版引导扇区修改完成,完整代码如下:

代码21-6硬盘引导扇区-完整版(boot.asm)

对于Loader,在进行完上述修改以后,把LABEL_FILE_LOADED中的callKillMotor以及KillMotor函数一并删除即可,这里不多赘述,贴一遍完整代码:

代码21-7硬盘版Loader-完整版(loader.asm)

Makefile也要进行修改,从此以后不再生成a.img了,而是生成hd.img:

代码21-8新版Makefile(Makefile)

hd.img:out/boot.binout/loader.binout/kernel.bin ftimgcreatehd.img-thd-size80 ftformathd.img-thd-ffat16 ftcopyout/loader.bin-to-imghd.img ftcopyout/kernel.bin-to-imghd.img ddif=out/boot.binof=hd.imgbs=512count=1run:hd.img qemu-system-i386-hdahd.img由于现在每次编译都会重新创建硬盘镜像,所以之前的写入测试文件iloveado.fai将不复存在,那就返璞归真,用一行简单的打印证明我们进入了内核吧:

代码21-9kernel/main.c

voidkernel_main()//kernel.asm会跳转到这里{monitor_clear();init_gdtidt();init_memory();init_timer(100);init_keyboard();asm("sti");task_t*task_a=task_init();task_t*task_shell=create_kernel_task(shell);//task_run(task_shell);printk("Hello,HDBoot!");while(1);}(shell:请你认真的看一看我……我至今为止有被用过哪怕一次么?)

使用makedefault全部重新编译,运行,效果如下图:

终于,在整整21节之后,我们并不是很紧地跟上了时代潮流,将软盘扔进了历史的垃圾堆,事实证明这是颇不具有里程碑意义的一件不是很大的事。

这一节本来是想放在最后写的,耐不住老有人催,所以提前写了,应用程序什么的就放在下一节吧!

想要加载一个应用程序,实际上是相当简单的,我们迅速来做一个简单的示例。

首先,创建新文件test_app.asm,内容如下:

代码22-1测试应用程序(test_app.asm)

ud2这个东西本来应该在前面几节讲异常的时候提的,它可以手动触发一个6号异常,到时候只需要看是否触发就行了。

使用nasm命令将它编译为一个二进制程序:

生成了仅两个字节的test_app.bin,使用ftcopy命令将它写入虚拟硬盘hd.img:

修改kernel_main中测试代码如下:

代码22-2执行应用程序(kernel/main.c)

intfd=sys_open("test_app.bin",O_RDWR);//打开应用程序文件test_app.binchar*buf=(char*)kmalloc(512);//分配一个扇区当缓冲区intret=sys_read(fd,buf,512);//读取512字节的空间printk("readstatus:%d\n",ret);//返回读取状态asm("jmp%0"::"m"(buf));//然后直接跳入buf开始执行里面的代码kfree(buf);//释放缓冲区(虽然如果成了理论上执行不到这)编译,运行,效果如下图所示:

好了,既然我们的第一个应用程序已经成功执行,已经达到了本节标题的进度,所以本节到此结束,下一节……

乐了,你看这可能吗?那包不可能的,我们本节真正的任务其实总共有两个:

1.实现任务创建,现在的应用程序执行会直接把原来的任务顶号,这样的话多任务就跟没实现一样;

2.实现基本的保护措施,现在的应用程序随随便便就能惊动CPU让它爆异常,这实在是非常脆弱的,哪怕爆也只能爆一般保护性异常让OS做处理。

我们从易到难,从实现任务创建开始。在Linux中,创建任务通常使用的是fork函数(当然也有别的函数比如vfork,这里不讨论),作用是复制一个当前的任务,不过由于我们使用TSS而非PCB,fork函数非常难实现。那么,就只能选择使用微软风格的CreateProcess,后面写成create_process:

代码22-3应用程序任务创建及执行API

intcreate_process(constchar*app_name,constchar*cmdline,constchar*work_dir);//返回新任务的PID由于我们没有实现目录,第三个参数只能填/。

这个系统调用怎么实现呢?希望大家都还没有忘掉第15节创建新系统调用的方法(笑)。首先实现一个对应的sys_create_process,然后从汇编里把参数传过去。

代码22-4系统调用表(include/syscall.h)

intsys_create_process(constchar*app_name,constchar*cmdline,constchar*work_dir);//...syscall_func_tsyscall_table[]={sys_getpid,sys_write,sys_read,sys_create_process,//这里新增了一个函数};由于create_process共有三个参数,因此和read、write一样,用ebx、ecx、edx三个寄存器进行传参,所以抄一遍上面两个系统调用,然后改一下系统调用号即可:

代码22-5create_process的实现(伪)(kernel/syscall_impl.asm)

[globalcreate_process]create_process:pushebxmoveax,3movebx,[esp+8]movecx,[esp+12]movedx,[esp+16]int80hpopebxret对于sys_create_process,我们需要一个单独的文件,毕竟这算是另一个主题——应用程序执行里面的东西。新建kernel/exec.c,我们来考虑考虑怎么写这个东西。

创建新任务我们是有方法的,直接调用那个create_kernel_task就行了。但是这个新任务要怎么知道执行哪个应用呢?有没有什么办法让这个任务接收到参数呢?

这个任务在本质上也就是一个函数而已。而函数的传参,依靠的是esp+4、esp+8之类的特殊地址。那么,我们只需要先把esp减去一个特定的值,空出三个参数的量来,然后把三个参数写进那个内存里,这样就可以在新任务中读到了。

由于在任务中自己操作自己比在别的任务中操作这个任务要更为简单,所以在sys_create_process中我们只进行创建任务的工作。

代码22-6sys_create_process的实现(kernel/exec.c)

三个参数一共对应12的栈偏移,三个参数就被顺次放在esp+4、esp+8、esp+12的地方。最后返回了新任务的PID,这是因为它肯定会被用到,不能让调用的啥也不知道。

这样,app_entry应该就可以成功接收到参数了:

代码22-7是新任务哦(kernel/exec.c)

voidapp_entry(constchar*app_name,constchar*cmdline,constchar*work_dir){puts(app_name);puts(cmdline);puts(work_dir);while(1);}在kernel_main中添加一行create_process("test_app.bin","nothing","/"),然后编译运行,效果如下:

好了,上面两点要求里的第一点——任务创建,就这样做完了。下面该考虑实现保护功能的事了。

intel的cpu一共可分为四个特权级(可以类似理解为权限),按照0~3标号为ring0、ring1、ring2、ring3。其中中间两个不常用,前后两个常简称为r0和r3。r0是默认的特权级,是给操作系统内核用的;而r3则是给用户使用的特权级。为了实现保护,我们需要进入r3特权级,然后再考虑执行的事。

怎么进入r3特权级呢?这就不得不提到很早以前,大概十几节以前,GDT描述符及选择子的结构图,再贴一遍(上为描述符,下为选择子):

这其中的DPL和RPL就是特权级有关的东西了。把一个段的DPL设为0-3,表示这个段的特权级;而把一个选择子的RPL设为0-3,表示这个选择子的特权级。由于程序执行的是代码段,所以代码段选择子的特权级,就是现在的特权级(CPL)。显然,由于选择子是描述符的代言人,DPL与其选择子的RPL应当一致。想要进入r3,也就是更改CPL,只需要先创建一个DPL=3的代码段,然后想办法进去就可以了。

这个代码段放在哪呢?GDT里?那自然不行,应用程序访问应用程序的代码段合情合理,但这个程序访问那个程序的段就不合理了。

intel自然也考虑到了这个问题,在设计TSS时,搞了一个叫做ldtr的成员。使用联想记忆法,GDTR、IDTR都对应GDT、IDT,难道LDTR对应一个叫LDT的东西吗?

诶,还真是!GDT全称是GlobalDescriptorTable,这个LDT则与之对应,是LocalDescriptorTable。每一个LDT的结构,都与GDT完全一致,只是表项可以省略。在选择子的结构图中,可以看到有一个TI位,它为1则表示当前段在LDT中,否则表示当前段在GDT中。

在执行任务切换时,intel会自动加载LDT,所以这一部分就不需要我们来管了。现在唯一的问题就是:CPU怎么知道你这个LDT在哪里呢?对此,intel采取了一套与TSS类似的方案,那就是把LDT放到GDT里(?)。实际上,TSS的ldtr成员对应的正是这个任务的LDT在GDT中对应的那个段的选择子。

在任务结构体中新增一个成员ldt:

代码22-8LDT真正存放的位置(include/mtask.h)

#include"gdtidt.h"//省略tss32_t,exit_retval_t,MAX_FILE_OPEN_PER_PROCtypedefstructTASK{uint32_tsel;int32_tflags;exit_retval_tmy_retval;intfd_table[MAX_FILE_OPEN_PER_TASK];gdt_entry_tldt[2];tss32_ttss;}task_t;接着在task_init中,把所有任务的LDT注册到GDT并初始化LDTR:

代码22-9初始化LDT以及LDTR(kernel/mtask.c)

for(inti=0;itasks0[i].flags=0;taskctl->tasks0[i].sel=(TASK_GDT0+i)*8;taskctl->tasks0[i].tss.ldtr=(TASK_GDT0+MAX_TASKS+i)*8;gdt_set_gate(TASK_GDT0+i,(int)&taskctl->tasks0[i].tss,103,0x89);//硬性规定,0x89代表TSS,103是因为TSS共26个uint32_t组成,总计104字节,因规程减1变为103gdt_set_gate(TASK_GDT0+MAX_TASKS+i,(int)&taskctl->tasks0[i].ldt,15,0x82);//0x82代表LDT,两个GDT表项共计16字节}现在有了LDT,该想办法进入r3了。或许有的读者会就此想当然:

改变cs和eip?这不是一个farjmp/farcall就可以做到了吗?

然而,intel实际上不允许使用farjmp/farcall从r0跳到r3(甚至到64位以后直接把这俩玩意ban了)。当然,办法总比困难多,还可以用far-ret和iretd:系统调用本质上还是中断,而系统调用执行时是r0权限,返回时是r3权限,所以从中断返回的这一步,intel是不加限制的。far-ret同理,可能会有一些比较古早的系统使用farcall来进行系统调用。

现在只是初始化了LDT这个表,它的表项都还没初始化,保持着一开始的样子。现在执行二进制的应用程序,代码段的大小就是文件大小,因此还需要把文件读进来:

代码22-10读入应用程序(kernel/exec.c)

#include"file.h"#include"memory.h"//...voidapp_entry(constchar*app_name,constchar*cmdline,constchar*work_dir){intfd=sys_open((char*)app_name,O_RDONLY);intsize=sys_lseek(fd,-1,SEEK_END)+1;sys_lseek(fd,0,SEEK_SET);char*buf=(char*)kmalloc(size+5);sys_read(fd,buf,size);while(1);}这里使用了一种常见的手法,先调用lseek把读写指针设置到结尾,利用它返回新位置的特性得到文件大小,再用lseek把读写指针设置回开头,最后一次读取整个文件。由于在lseek中对超出size-1的位置不予承认,这里需要先把指针指向size-1处,最后再把1加回来得到文件大小。至于为什么要对文件名进行强转,是因为如果不这样gcc会报警告很烦。

由于LDT的表项与GDT的表项完全一致,所以复制粘贴了一个ldt_set_gate:

代码22-11设置LDT表项的函数(kernel/exec.c)

voidldt_set_gate(int32_tnum,uint32_tbase,uint32_tlimit,uint16_tar){task_t*task=task_now();if(limit>0xfffff){//段上限超过1MBar|=0x8000;//ar的第15位(将被当作limit_high中的G位)设为1limit/=0x1000;//段上限缩小为原来的1/4096,G位表示段上限为实际的4KB}//base部分没有其他的奇怪东西混杂,很好说task->ldt[num].base_low=base&0xFFFF;//低16位task->ldt[num].base_mid=(base>>16)&0xFF;//中间8位task->ldt[num].base_high=(base>>24)&0xFF;//高8位//limit部分混了一坨ar进来,略微复杂task->ldt[num].limit_low=limit&0xFFFF;//低16位task->ldt[num].limit_high=((limit>>16)&0x0F)|((ar>>8)&0xF0);//现在的limit最多为0xfffff,所以最高位只剩4位作为低4位,高4位自然被ar的高12位挤占task->ldt[num].access_right=ar&0xFF;//ar部分只能存低4位了}LDT的代码段应该是整个文件,那数据段呢?由于纯二进制文件结构的特殊性,我们也认为是整个文件(纯二进制的代码和数据是混在一起的,具体怎么样由程序本身来决定)。

代码22-12设置应用程序代码段、数据段(kernel/exec.c)

voidapp_entry(constchar*app_name,constchar*cmdline,constchar*work_dir){intfd=sys_open((char*)app_name,O_RDONLY);intsize=sys_lseek(fd,-1,SEEK_END)+1;sys_lseek(fd,0,SEEK_SET);char*buf=(char*)kmalloc(size+5);sys_read(fd,buf,size);ldt_set_gate(0,(int)buf,size-1,0x409a|0x60);//hereldt_set_gate(1,(int)buf,size-1,0x4092|0x60);//herewhile(1);}在最后两处或上0x60,实际上相当于把DPL设置成了3。

那么最后一步,就是启动了。这启动可不能乱启动,在任务切换的时候,CPU会观察你要跳到哪个层级,如果你在r3而想要跳回r0,那么它的栈指针esp会从这个任务的TSS中的esp0成员来读取,ss堆栈段也是一样。因此在程序中,还需要对这两个东西进行设置。我们使用一个单独的汇编函数start_app来处理这些事:

代码22-13应用程序启动之前(lib/nasmfunc.asm)

运行成功了!我们成功进入了r3用户特权级,这意味着现在操作系统已经在保护之下。不信邪的各位可以把测试代码改成int21h,应该能看到触发了13号,也就是一般保护性异常。

需要注意的是,由于每次重新编译都会清空硬盘,所以需要手动写入test_app.bin。

不过,光能运行程序还不够,还有两件事情要办:第一,确认它可以实现系统调用;第二,把这东西接入shell当中。

怎么实现系统调用呢?这个好办,我们程序里怎么用的这就怎么用。至于用什么,简单输出一个字符串,用write系统调用就可以。

write系统调用:eax=1ebx=fdecx=bufedx=size

代码22-14使用系统调用输出字符(test_app.asm)

moveax,1movebx,1movecx,stringmovedx,strlenint80hjmp$string:db"Hello,World!",0x0A,0x00strlenequ$-string编译应用程序并用ftcopy命令写入磁盘,效果如下:

唉,你怎么似了??看来实现应用程序还没有那么简单(苦笑),这一节还有很长的路要走。

我们来仔细阅读现在的系统调用处理程序syscall_handler:

代码22-15现在的syscall_handler

[externsyscall_manager][globalsyscall_handler]syscall_handler:stipushadpushadcallsyscall_manageraddesp,32popadiretd我们发现,此时所有的段全都是用户时r3时期的段,而内核处理系统调用的东西都在r0,当然读不到。这就引发了一个矛盾:想要让用户程序执行系统调用,必须加载内核r0的段,但是这样一来就又把r3段中要显示的东西给丢了。

总之,切换到r0目前来看更为必要,那么该怎么换呢?由于cs已经是r0代码段了,所以直接赋值内核数据段选择子就可以了。

代码22-16新版syscall_handler(kernel/interrupt.asm)

[externsyscall_manager][globalsyscall_handler]syscall_handler:stipushdspushespushadpushadmovax,0x10;新增movds,ax;新增moves,ax;新增callsyscall_manageraddesp,32popadpopespopdsiretd现在再编译运行,并手动更新test_app.bin,效果可能是这样的:

虽然说并没有输出HelloWorld,但是至少输出了点东西了,这至少说明我们的系统调用已经成功执行。接下来就该处理输出的东西和实际不一样这件事了。

这个问题怎么解决呢?考虑到实际上它访问的地址是这个程序对应的任务LDT内的地址,所以只要把LDT基址加在这个地址上,大概就没问题了。

由于现在的系统调用采取在数组里找函数的方式,所以没法单独给一个参数加LDT基址。测试需要,我们给目前write在使用的ecx寄存器加上LDT基址。怎么加呢?其实找个地方把buf存一下就好了(笑)。

鉴于执行系统调用前后其实是同一个任务,所以这个东西放在任务结构体里会比较方便。

代码22-17任务数据段基址(include/mtask.h)

typedefstructTASK{uint32_tsel;int32_tflags;exit_retval_tmy_retval;intfd_table[MAX_FILE_OPEN_PER_TASK];gdt_entry_tldt[2];intds_base;//新增tss32_ttss;}task_t;在app_entry中更新它:

代码22-18执行应用时更新数据段基址(kernel/exec.c)

voidapp_entry(constchar*app_name,constchar*cmdline,constchar*work_dir){intfd=sys_open((char*)app_name,O_RDONLY);intsize=sys_lseek(fd,-1,SEEK_END)+1;sys_lseek(fd,0,SEEK_SET);char*buf=(char*)kmalloc(size+5);sys_read(fd,buf,size);task_now()->ds_base=(int)buf;//这里是新增的ldt_set_gate(0,(int)buf,size-1,0x409a|0x60);ldt_set_gate(1,(int)buf,size-1,0x4092|0x60);start_app(0,0*8+4,0,1*8+4,&(task_now()->tss.esp0));while(1);}现在存是存完了,问题是怎么加到地址上?每一个系统调用都是现场从函数表里取的,不能单独处理。这里为了测试需要,不管三七二十一,直接加到ecx上(目前所有的系统调用都用了ecx传地址,但是create_process其实应该都加):

代码22-19处理地址偏移问题(临时)

voidsyscall_manager(intedi,intesi,intebp,intesp,intebx,intedx,intecx,inteax){intds_base=task_now()->ds_base;typedefint(*syscall_t)(int,int,int,int,int);//(&eax+1)[7]=((syscall_t)syscall_table[eax])(ebx,ecx,edx,edi,esi);syscall_tsyscall_fn=(syscall_t)syscall_table[eax];intret=syscall_fn(ebx,ecx+ds_base,edx,edi,esi);int*save_reg=&eax+1;save_reg[7]=ret;}这下应该处理完成了。再次编译,运行,效果如下:

至此,纯二进制应用程序应该已经可以完整执行了。没想到单是这样篇幅就已经快要爆炸了,那么集成到shell的问题就只好下一节再办了。

还是在下一节,我们会用C写一些简单的小程序来跑。

或许有人问了,那么最后一节干什么呢?先卖个关子哦。

终于要结束啦(超大声)

本节先来处理上一节的历史遗留问题,上一节给我们留下了一个巨大的烂摊子:

系统调用中有关应用程序基址偏移的部分需要对不同的系统调用具体问题具体分析,这意味着把那个优美的系统调用表拆成一坨屎一样的switch-case。执行应用程序还没有集成到shell。事实上这个功能看上去容易,其实也略有复杂,还有两个系统调用(waitpid和exit)没有实现。

首先我们来把系统调用表拆掉。其实这个东西命不该绝,拆了也会让代码变得很丑,但是为了应用程序的执行,我们也只好挥泪斩马谡,对系统调用表高唱seeyouagain。现在的syscall.h长这样:

代码23-1想你了系统调用表(include/syscall.h)

代码23-2想你了系统调用表-实现版(kernel/syscall.c)

voidsyscall_manager(intedi,intesi,intebp,intesp,intebx,intedx,intecx,inteax){intds_base=task_now()->ds_base;intret=0;switch(eax){//从这里开始case0:ret=sys_getpid();break;case1:ret=sys_write(ebx,(char*)ecx+ds_base,edx);break;case2:ret=sys_read(ebx,(char*)ecx+ds_base,edx);break;case3:ret=sys_create_process((constchar*)ebx+ds_base,(constchar*)ecx+ds_base,(constchar*)edx+ds_base);break;}//到这里结束int*save_reg=&eax+1;save_reg[7]=ret;}和原来相比简直丑的不止一点半点。不过对于程序来说最重要的还是能不能跑,这点美学上的牺牲可以不管。

在接入到shell之前,我们还得实现点系统调用。首先是文件系统的全套,其次是waitpid和exit。还好,这些东西的底层实现我们都已经有了:

代码23-3系统调用大爆炸(kernel/syscall.c)

代码23-4系统调用高层实现(kernel/syscall_impl.asm)

[globalopen]open:pushebxmoveax,3movebx,[esp+8]movecx,[esp+12]int80hpopebxret[globalclose]close:pushebxmoveax,4movebx,[esp+8]int80hpopebxret[globallseek]lseek:pushebxmoveax,5movebx,[esp+8]movecx,[esp+12]movedx,[esp+16]int80hpopebxret[globalunlink]unlink:pushebxmoveax,6movebx,[esp+8]int80hpopebxret[globalcreate_process]create_process:pushebxmoveax,7movebx,[esp+8]movecx,[esp+12]movedx,[esp+16]int80hpopebxret[globalwaitpid]waitpid:pushebxmoveax,8movebx,[esp+8]int80hpopebxret[globalexit]exit:pushebxmoveax,9movebx,[esp+8]int80hpopebxret原来的create_process就可以删除了。

现在,我们终于具备了把应用程序执行集成到shell当中的条件,是时候开搞了。

首先,在sys_create_process中,我们对应用程序是否存在不加任何判断,如果文件不存在的话,拖到app_entry再处理就晚了。因此,在创建任务之前,我们先试图打开文件以判断它是否存在:

代码23-5文件存在吗?(kernel/exec.c)

intsys_create_process(constchar*app_name,constchar*cmdline,constchar*work_dir){intfd=sys_open((char*)app_name,O_RDONLY);if(fd==-1)return-1;sys_close(fd);//下略}当文件不存在时,sys_create_process返回-1。

然后,我们需要在内核主程序中解放shell,自从第16节起就被封存的shell终于派上用场了:

代码23-6内核主程序之终(kernel/main.c)

voidkernel_main()//kernel.asm会跳转到这里{monitor_clear();init_gdtidt();init_memory();init_timer(100);init_keyboard();asm("sti");task_t*task_a=task_init();task_t*task_shell=create_kernel_task(shell);task_run(task_shell);task_exit(0);}在启动了shell任务以后,内核主程序旋即退出,并使用0的返回值报告正常。内核主程序以后大概还会再改最后一次,事了拂衣去,深藏身与名(泪目)

实现命令执行的函数位于cmd_execute,因此需要在cmd_execute中启动应用程序。哪些是应用程序呢?我们认为只要不是内部命令的就都是应用程序(笑)。

代码23-7应用程序执行框架(kernel/shell.c)

voidcmd_execute(intargc,char**argv){if(!strcmp("ver",argv[0])){cmd_ver(argc,argv);}else{intexist;intret=try_to_run_external(argv[0],&exist);if(!exist){printf("shell:`%s`isnotrecognizedasaninternalorexternalcommandorexecutablefile.\n",argv[0]);}elseif(ret){printf("shell:app`%s`exitedabnormally,retval:%d(0x%x).\n",argv[0],ret,ret);}}}由于应用程序可能返回任何返回值,所以这里必须使用两个返回值,因此使用传统的指针双返回值法,传一个指针进去表示文件是否存在。如果不存在,自然要报错,这个报错是从Windowscmd里抄的;否则,如果返回值不为0,我们也报一个错,说明应用程序异常退出。

接下来的try_to_run_external自然就是实现应用程序执行的核心逻辑了:

代码23-8应用程序执行(kernel/shell.c)

代码23-9TutorialOS系统调用列表(include/unistd.h)

#ifndef_UNISTD_H_#define_UNISTD_H_intopen(char*filename,uint32_tflags);intwrite(intfd,constvoid*msg,intlen);intread(intfd,void*buf,intcount);intclose(intfd);intlseek(intfd,intoffset,uint8_twhence);intunlink(constchar*filename);intwaitpid(intpid);intexit(intret);intcreate_process(constchar*app_name,constchar*cmdline,constchar*work_dir);#endif在shell.h中包含unistd.h即可。

最后就是应用程序这边,要使用新的exit系统调用退出:

代码23-10应用程序(test_app.asm)

bits32moveax,1movebx,1movecx,stringmovedx,strlenint80hmoveax,9movebx,114514int80hjmp$string:db"Hello,World!",0x0A,0x00strlenequ$-string之所以加上bits32,是因为我们测试用的返回值(114514)超过16位最大值(65536),所以标记一下使用32位寄存器,把数字也看成32位的。

编译运行,并把test_app.bin写入硬盘,效果如下:

可以看到,loader.bin的执行虽然被拦下,但是程序却在异常处理程序中卡死了,没有把控制权交回到shell。如今已经有了多任务,我们只需在isr.c中结束当前任务即可:

代码23-11发生异常时强制结束应用程序(kernel/isr.c)

#include"mtask.h"//中略voidisr_handler(registers_tregs){asm("cli");monitor_write("receivedinterrupt:");monitor_write_dec(regs.int_no);monitor_put('\n');task_exit(-1);//强制退出}由于在任务结束后会强制切换回shell,从而重新开启中断,所以最上面的asm("cli")不用处理。

现在再试图运行loader.bin,应该就会把控制权交还给内核了:

至此,我们终于解决完了上一节留下的烂摊子。kernel.bin没被拦下是因为它是有格式的,还没来得及执行到指令就已经不知道在执行什么东西了,从而导致了它的卡死。正好我们本节的任务——C语言应用程序还没开始,就顺其自然,解析kernel.bin的文件格式——ELF。重回第6节既视感(

重提一下ELF文件的结构:

代码23-12ProgramHeader(include/elf.h)

#defineEI_NIDENT16typedefstruct{unsignedchare_ident[EI_NIDENT];//ELF特征标Elf32_Halfe_type;//文件类型Elf32_Halfe_machine;//运行至少需要的体系结构Elf32_Worde_version;//文件版本Elf32_Addre_entry;//程序的入口点Elf32_Offe_phoff;//ProgramHeader表的偏移Elf32_Offe_shoff;//SectionHeader表的偏移Elf32_Worde_flags;//对于32位系统为0Elf32_Halfe_ehsize;//ELFHeader的大小,单位字节Elf32_Halfe_phentsize;//ProgramHeader的大小Elf32_Halfe_phnum;//ProgramHeader的数量Elf32_Halfe_shentsize;//SectionHeader的大小Elf32_Halfe_shnum;//SectionHeader的数量Elf32_Halfe_shstrndx;//包含Section名称的字符串表位于哪一项}Elf32_Ehdr;其中数据类型Elf32_Word、Elf32_Off和Elf32_Addr均为大小为4、对齐也为4的无符号类型,而Word为大整数,Off为偏移,Addr为地址。Half则顾名思义,是前面这些类型的一半,也就是2个字节这么大。因此,在文件开头添加这样的类型定义:

代码23-14类型定义(include/elf.h)

(严格来讲其实要做的远比这个要多,什么动态链接、调试符号之类的都要解析SectionHeader,但是我们只做最基本的执行的话就不强求了)

那么,我们就来快速地解析一下ELF文件。首先新建一个kernel/elf.c:

代码23-15准备开始解析ELF(kernel/elf.c)

#include"elf.h"#definemin(a,b)((a)<(b)(a):(b))#definemax(a,b)((a)<(b)(b):(a))这里定义了两个一看就懂的宏max和min,个人认为不用解释。

接下来我们就开始准备加载ELF了。出于简单的需要,我们把可执行程序的入口点定在0x00处;由于这个要求完全做不到,链接器就会自己把整个代码段的开始位置定在这里,然后把入口点略微往后推一点点。既然这样,鉴于这个程序如果直接加载到内存,将位于1MB以内,而这一块内存我们根本就不想管,所以我们要另行分配一个缓冲区作为ELF解析后的存放地。

那么,知道这个ELF在被解析后一共多大就尤为重要了。事实上,这个过程可以在被解析之前进行,只需要遍历每一个ProgramHeader,同时更新加载首地址最小值与末地址最大值,最后减一下就可以了:

代码23-16获取ELF被加载后的范围(kernel/elf.c)

staticvoidcalc_load_range(Elf32_Ehdr*ehdr,uint32_t*first,uint32_t*last){Elf32_Phdr*phdr=(Elf32_Phdr*)((uint32_t)ehdr+ehdr->e_phoff);//第一个programheader地址*first=0xffffffff;//UINT32最大值*last=0;//UINT32最小值for(uint16_ti=0;ie_phnum;i++){//遍历每一个programheaderif(phdr[i].p_type!=PT_LOAD)continue;//只关心LOAD段*first=min(*first,phdr[i].p_vaddr);*last=max(*last,phdr[i].p_vaddr+phdr[i].p_memsz);//每一个programheader首尾取最值}}在ELF头中放着的e_phoff代表第一个ProgramHeader的相对ELF头的偏移,加上ELF头的地址,就得到了第一个ProgramHeader的地址。由前面的结构图可以知道,所有的ProgramHeader是连续的,因此可以被视为一个数组,其长度则由ELF头的e_phnum定义。在这么多ProgramHeader中,只有类型为PT_LOAD(其值为1,在include/elf.h中定义)才可以加载,因此我们也就只管这些。这里还是使用经典的指针法进行多值返回。

在获取范围以后就可以分配缓冲区了。假设现在已经分配好了缓冲区,我们要把每个ProgramHeader所对应的程序复制到正确的位置去,这就需要知道它们的大小和方向位置。位置比较容易,ProgramHeader的p_offset存的就是相对ELF头的位置;大小却有p_memsz和p_filesz两个值,采信哪个呢?由于我们是要从文件里加载,所以采用p_filesz的值,至于可能多出来的部分,那就只能填0了。

代码23-17复制ELF的各个ProgramHeader(kernel/elf.c)

staticvoidcopy_load_segments(Elf32_Ehdr*ehdr,char*buf){Elf32_Phdr*phdr=(Elf32_Phdr*)((uint32_t)ehdr+ehdr->e_phoff);//第一个programheader地址for(uint16_ti=0;ie_phnum;i++){//遍历每一个programheaderif(phdr[i].p_type!=PT_LOAD)continue;//只关心LOAD段uint32_tsegm_in_file=(uint32_t)ehdr+phdr[i].p_offset;//段在文件中的位置memcpy(buf+phdr[i].p_vaddr,(void*)segm_in_file,phdr[i].p_filesz);//将文件中大小的部分copy过去uint32_tremain_bytes=phdr[i].p_memsz-phdr[i].p_filesz;//两者之差memset(buf+(phdr[i].p_vaddr+phdr[i].p_filesz),0,remain_bytes);//赋值为0}}最后便是融合到一起去的整体包装,它会首先检测ELF格式是否正确,如果错误会返回-1,否则返回ELF的入口点,也就是开始执行的位置:

代码23-18加载ELF(kernel/elf.c)

intload_elf(Elf32_Ehdr*ehdr,char**buf,uint32_t*first,uint32_t*last){if(memcmp(ehdr->e_ident,"\177ELF\1\1\1",7))return-1;//魔数不对,不予执行calc_load_range(ehdr,first,last);//计算加载位移*buf=(char*)kmalloc(*last-*first+5);//用算得的大小分配内存copy_load_segments(ehdr,*buf);//把ELFreturnehdr->e_entry;}ELF头的作用不必多说,这里之所以用两重指针,是因为我们要修改单重指针buf的值。在实际使用时只要传一个指针进来就行了,不管这个指针长什么样子。

这样一来,在buf中存着的就是二进制一样的机器码了,理论上可以直接启动。事实上也的确如此,在exec.c中只需修改几行代码,就可以让一个ELF跑起来了:

代码23-19启动ELF(kernel/exec.c)

voidapp_entry(constchar*app_name,constchar*cmdline,constchar*work_dir){//...上略...char*code;//存放代码的缓冲区intentry=load_elf((Elf32_Ehdr*)buf,&code,&first,&last);//buf是文件读进来的那个缓冲区,code是存实际代码的if(entry==-1)task_exit(-1);//解析失败,直接exit(-1)//注意:以下代码非常不安全,仅供参考;不过目前我也没有找到更优的解//坑比intel在访问[esp+xxx]的地址时用的是ds,ss完全成了摆设,所以栈和数据必须放在一个段里,于是就炸了char*ds=(char*)kmalloc(last-first+4*1024*1024+5);//新分配一个数据段,为原来大小+4MB+5memcpy(ds,code,last-first);//把代码复制过来,也就包含了必须要用的数据task_now()->ds_base=(int)ds;//数据段基址,与下面一致ldt_set_gate(0,(int)code,last-first-1,0x409a|0x60);ldt_set_gate(1,(int)ds,last-first+4*1024*1024-1,0x4092|0x60);//大小也多了4MBstart_app(entry,0*8+4,last-first+4*1024*1024-4,1*8+4,&(task_now()->tss.esp0));//把栈顶设为4MB-4while(1);}坑比intel在访问[esp+xxx]的地址时用的是ds,ss完全成了摆设,所以栈和数据必须放在一个段里,于是就炸了(重复一遍)。

现在已经可以执行ELF了,我们就写一个C应用作为测试吧。新建apps文件夹,我们就写一个最简单的HelloWorld:

代码23-20Hello,ELFWorld!(apps/test_c.c)

#includeintmain(){printf("Hello,World!");return0;}不知道看见这样的程序勾起了你什么回忆呢,总之我是有种回到了刚学C语言时的感觉……不煽情了,现在仔细回顾一下这个程序,有什么问题吗?

看似完美无缺,实际上问题非常严重。或许你曾对main函数的返回值谁来接收有疑问,当时的回答是操作系统。现在我们就是操作系统,那这个main的返回值,是不是也得接收一下?

况且在第五节中提过,ELF程序的真正入口并不是main,而是_start,main只是一个普通的函数而已。因此,我们需要定义一个_start。

这个_start还是相当好写的,让应用程序接收参数是下一节的话题,不考虑参数的话,只需要调用main,然后用exit结束应用程序即可:

代码23-21简单的入口点(apps/start.c)

intmain();void_start(){exit(main());}接下来怎么编译呢?直接gcc?那可不行,我们的“标准库”和Linux还是不一样的,得链接上我们的标准库才行。

对Makefile这么修改一下:

代码23-22新的Makefile(Makefile)

LIBC_OBJECTS=out/syscall_impl.oout/stdio.oout/string.oout/%.bin:apps/%.asm nasmapps/$*.asm-oout/$*.o-felf i686-elf-ld-s-Ttext0x0-oout/$*.binout/$*.oout/tulibc.a:$(LIBC_OBJECTS) i686-elf-arrcsout/tulibc.a$(LIBC_OBJECTS)out/%.bin:apps/%.capps/start.cout/tulibc.a i686-elf-gcc-c-Iincludeapps/start.c-oout/start.o-fno-builtin i686-elf-gcc-c-Iincludeapps/$*.c-oout/$*.o-fno-builtin i686-elf-ld-s-Ttext0x0-oout/$*.binout/$*.oout/start.oout/tulibc.a这里先用了ar,把我们的“标准库”——stdio.o(printf,sprintf,vprintf,vsprintf)、string.o(mem系列和str系列)以及syscall_impl.o(系统调用的实现部分)打成了一个库tulibc.a,取TUtorialosLIBC的意思。libc则是一种描述标准库的通用简写。然后,分别编译start.c和应用程序,最后把应用程序本体、start.o和tulibc.a链接在一起,并设定入口点,形成可以让TutorialOS执行的应用程序。

当然,我们也是支持用汇编来编写应用程序的,这个流程就比较简洁,因为没有main的特殊包袱,直接编译链接即可。

下面编译硬盘映像的部分,我们也做了修改。

代码23-23新的Makefile(续)(Makefile)

APPS=out/test_c.bin#中略hd.img:out/boot.binout/loader.binout/kernel.bin$(APPS) ftimgcreatehd.img-thd-size80 ftformathd.img-thd-ffat16 ftcopyout/loader.bin-to-imghd.img ftcopyout/kernel.bin-to-imghd.img ftcopyout/test_c.bin-to-imghd.img ddif=out/boot.binof=hd.imgbs=512count=1在whatyouneed的部分,我们新添加了一个APPS变量,它代表我们需要编译的所有应用,目前只有一个test_c.bin。编译出来以后,我们在下面的命令中进行写入。

现在应该就可以开始运行了。编译,运行,效果如下:

这一节实在是太长了,到此为止吧。下一节我们来支持malloc,同时为应用程序传参,然后就可以正式结束啦!提前完结撒花.jpg

欢迎回来。我们本节的任务十分明确:支持malloc,同时为应用程序传参。

不过,在一开始我们先来点“轻松”的。或许在第16节和第22节的时候,有的读者会有这样的疑问:

你shell明明集成在内核当中,为什么还要费事去系统调用呢?

所以我们今天的第一个surprise,就是把shell从内核当中给剥离出去,做成一个单独的app。没有什么原因,只是因为这样泰裤辣!(逃

上一节已经初步支持了C语言应用程序,而我们的shell一不用传参,二来在一番微操之下规避了malloc,因此可以直接放在这个框架里。

首先来把现在的shell改造成一个应用程序一样的东西:

代码24-1现在的shell(apps/shell.c)

下面引入了一堆头文件,其中的unistd.h是上一节所造,stdio.h早已有之,剩下的stdint.h、stdbool.h以及stddef.h是从common.h里分离出来的产物:

代码24-2三个头文件(include/stdint.h、include/stdbool.h、include/stddef.h)

#ifndef_STDINT_H_#define_STDINT_H_typedefunsignedintuint32_t;typedefintint32_t;typedefunsignedshortuint16_t;typedefshortint16_t;typedefunsignedcharuint8_t;typedefcharint8_t;#endif#ifndef_STDBOOL_H_#define_STDBOOL_H_typedef_Boolbool;#definetrue1#definefalse0#endif#ifndef_STDDEF_H_#define_STDDEF_H_#defineNULL((void*)0)#endif如你所见,这三个头文件基本上全都很短小,什么安全保护措施也没加,毕竟纯玩玩也用不到,到时候从什么地方copy一个就行了(bushi)。

接下来在kernel_main启动shell的部分也要修改:

代码24-3启动shell(kernel/main.c)

voidkernel_main()//kernel.asm会跳转到这里{monitor_clear();init_gdtidt();init_memory();init_timer(100);init_keyboard();asm("sti");task_init();sys_create_process("shell.bin","","/");task_exit(0);}task_a变量从头到尾没有被用到,因此就删了。下面的sys_create_process实质上开启了一个新任务执行shell.bin。最后,调用task_exit(0)退出当前任务,于是操作系统就进入后台,而主要是ring3用户层的shell在起交互作用了。

在Makefile的APPS中加入out/shell.bin,OBJS中删除out/shell.o,完成最后的交接。shell的地位甚至因此还提升了(?)

最后当然是编译运行啦:

执行内部命令还是应用程序都没问题,不过按理来说也算理所应当吧,到最后也没做多少修改(笑)。

热身完成,筋骨也活动得差不多了,也该回顾一下上一节的两大目标:实现malloc以及给应用程序传参。

说到底,这其实是同一个问题:如果应用程序能直接访问操作系统的内存不就好了?这样可以直接使用kmalloc、kfree,传参随便写个系统调用也就可以做到了。可是应用程序只能访问应用程序段自己的地址,这是出于安全的考虑:如果应用程序能访问操作系统的内存,那不就能随便破坏了吗。

所以,现在的问题就变成了:要由操作系统提供一段位于应用程序数据段内的内存,接下来就可以让应用程序自治,通过系统调用等等手段从这里获取内存。

对C语言有些了解的读者应该知道,malloc实际上是从一个叫“堆”的地方获取内存的;它并不是直接的系统调用,真正用于向操作系统申请内存的系统调用是brk、sbrk和mmap。mmap的本意是将文件内容映射到内存当中,与普通的读取不同的是,对映射后内存的修改会立刻同步到文件;而通过使用一些特殊文件,就可以实现凭空申请内存的效果。

然而,实现一个mmap对我们来说太过困难,准备一个特殊文件也不在现有的框架之内,因此就算了。接下来的brk和sbrk,一看名字就知道是一对函数,查阅linuxmanual知道它们操控着一个叫做programbreak的玩意。手册上说它是什么数据段的终止,这是什么不知道;不过使用这两个函数可以修改这个位置,从而给数据段里凭空多出内存来,这就是为我们所用的内存了。

brk是直接设置programbreak,我还得自己维护上一个位置才能申请,相比之下,还是直接使用sbrk更加直接,它的参数是增量,返回的是旧的programbreak的位置,原型如下:

void*sbrk(intincr);和malloc一对比,是不是看着很接近?更棒的是,incr还可以是负数,相当于在释放用完的内存;也就是说,用sbrk一个函数就可以实现内存的分配和释放,接下来就只是管理的事了。

那么我们最终的问题,就变成了两个:

先从第一个开始吧。既然所谓的programbreak表示数据段的终止,我们先来实现一个可以用来扩张数据段的函数。不过说是扩张,到头来其实也还是删掉旧的创建新的。

内核的数据段已经占满了4GB,给内核实现扩张毫无意义。因此,我们给任务添加一个is_user标签,表示是不是应用程序:

代码24-4添加新标签(include/mtask.h、kernel/mtask.c、kernel/exec.c)

typedefstructTASK{uint32_tsel;int32_tflags;exit_retval_tmy_retval;intfd_table[MAX_FILE_OPEN_PER_TASK];gdt_entry_tldt[2];intds_base;boolis_user;//heretss32_ttss;}task_t;for(inti=3;ifd_table[i]=-1;}task->is_user=false;//herereturntask;voidapp_entry(constchar*app_name,constchar*cmdline,constchar*work_dir){intfd=sys_open((char*)app_name,O_RDONLY);intsize=sys_lseek(fd,-1,SEEK_END)+1;sys_lseek(fd,0,SEEK_SET);char*buf=(char*)kmalloc(size+5);sys_read(fd,buf,size);intfirst,last;char*code;intentry=load_elf((Elf32_Ehdr*)buf,&code,&first,&last);if(entry==-1)task_exit(-1);char*ds=(char*)kmalloc(last-first+4*1024*1024+5);memcpy(ds,code,last-first);task_now()->is_user=true;//heretask_now()->ds_base=(int)ds;ldt_set_gate(0,(int)code,last-first-1,0x409a|0x60);ldt_set_gate(1,(int)ds,last-first+4*1024*1024+1*1024*1024-1,0x4092|0x60);start_app(entry,0*8+4,last-first+4*1024*1024-4,1*8+4,&(task_now()->tss.esp0));while(1);}接下来就可以实现给用户扩张数据段的函数了:

代码24-5扩张数据段(kernel/exec.c)

(图24-2GDT描述符结构)

以及它的代码表示:

structgdt_entry_struct{uint16_tlimit_low;//BYTE0~1uint16_tbase_low;//BYTE2~3uint8_tbase_mid;//BYTE4uint8_taccess_right;//BYTE5,P|DPL|S|TYPE(1|2|1|4)uint8_tlimit_high;//BYTE6,G|D/B|0|AVL|limit_high(1|1|1|1|4)uint8_tbase_high;//BYTE7}__attribute__((packed));typedefstructgdt_entry_structgdt_entry_t;按照这个结构再去看上面的代码,拼base是显而易见的,拼limit则涉及到一个G位的问题:G位位于limit_high的最高位,当它为1时,代表整个limit代表的是一个以4KB为单位的段(说白了就是要给limit乘上4096)。拼完以后由于limit加1才是size,所以再把1给加上。

接下来重新分配一段新的数据段,把旧的东西全都复制过去,唯一的变化就是大小变大了。旧的数据段留着也没有用,既然前面初始化是用kmalloc初始化的ds,新的段也使用kmalloc分配,所以可以安全地使用kfree把内存释放掉。最后调用ldt_set_gate把数据段换成新的,同时更新task里的ds_base,这样如假包换,应用程序毫无感知。

接下来就是实现sbrk了。上面的programbreak说是数据段结尾,但如果老是更新数据段的话,内存也吃不消,速度也会慢上一点(不过不仔细看的话,大概是看不出来的)。所以我们先临时开1MB缓冲区,这1MB用完了再扩展至少32KB,这样也许会把占用搞小一点(心虚)。

因此,存programbreak不仅要存它现在的位置,还要存给它的缓冲区在哪里结束,这样才可以扩展数据段。

代码24-6实现sbrk(1)——创建programbreak(include/mtask.h)

typedefstructTASK{uint32_tsel;int32_tflags;exit_retval_tmy_retval;intfd_table[MAX_FILE_OPEN_PER_TASK];gdt_entry_tldt[2];intds_base;boolis_user;void*brk_start,*brk_end;//heretss32_ttss;}task_t;接下来在app_entry中初始化它:

代码24-7实现sbrk(2)——初始化programbreak(kernel/exec.c)

voidapp_entry(constchar*app_name,constchar*cmdline,constchar*work_dir){intfd=sys_open((char*)app_name,O_RDONLY);intsize=sys_lseek(fd,-1,SEEK_END)+1;sys_lseek(fd,0,SEEK_SET);char*buf=(char*)kmalloc(size+5);sys_read(fd,buf,size);intfirst,last;char*code;intentry=load_elf((Elf32_Ehdr*)buf,&code,&first,&last);if(entry==-1)task_exit(-1);char*ds=(char*)kmalloc(last-first+4*1024*1024+1*1024*1024-5);memcpy(ds,code,last-first);task_now()->is_user=true;//这一块就是给用户用的task_now()->brk_start=(void*)last-first+4*1024*1024;task_now()->brk_end=(void*)last-first+5*1024*1024-1;task_now()->ds_base=(int)ds;//设置ds基址ldt_set_gate(0,(int)code,last-first-1,0x409a|0x60);ldt_set_gate(1,(int)ds,last-first+4*1024*1024+1*1024*1024-1,0x4092|0x60);start_app(entry,0*8+4,last-first+4*1024*1024-4,1*8+4,&(task_now()->tss.esp0));while(1);}最后就是sbrk的本体了,为了偷懒也放在了kernel/exec.c下面(虽然放这里好像不大好?):

代码24-8实现sbrk(3)——操控programbreak(kernel/exec.c)

void*sys_sbrk(intincr){task_t*task=task_now();if(task->is_user){//是应用程序if(task->brk_start+incr>task->brk_end){//如果超出已有缓冲区expand_user_segment(incr+32*1024);//再多扩展32KBtask->brk_end+=incr+32*1024;//由于扩展了32KB,同步将brk_end移到现在的数据段结尾}void*ret=task->brk_start;//旧的programbreaktask->brk_start+=incr;//直接添加就完事了returnret;//返回之}returnNULL;//非用户不允许使用sbrk}到现在为止,就实现了最基本的向内核申请内存的函数。接下来把它搞成一个系统调用,sbrk就可以使用了:

代码24-9实现sbrk(4)——添加系统调用(include/syscall.h、kernel/syscall.c、kernel/syscall_impl.asm)

#ifndef_SYSCALL_H_#define_SYSCALL_H_//上略...//exec.cvoid*sys_sbrk(intincr);#endifswitch(eax){//上略...case10:ret=(int)sys_sbrk(ebx);break;}//下略...[globalsbrk]sbrk:pushebxmoveax,10movebx,[esp+8]int80hpopebxret加了十个系统调用了,相信大家也应该大致熟悉了添加系统调用的流程了吧:首先实现系统调用本身,然后在syscall.c的switch-case里新加一个分支,最后用汇编仿照格式写一个实现,没有参数(getpid)、一个参数(一堆不列举)、两个参数(open)、三个参数(一堆不列举)的系统调用目前都有了。

下面执行第二步,用sbrk实现malloc。这个网上教程有一大堆,你干脆直接移植ptmalloc都行,这里我选择了一种最简单但同时大概也是最不稳定跑在CoolPotOS上成功造成了114514次异常的一种。

新建lib/malloc.c,这就是我们malloc的实现。

我们的堆实质上是一块一块的内存碎片,这些碎片采用链表的方式来组织,以下是每一个链表的节点:

代码24-10串联可用内存的链表节点(lib/malloc.c)

#include#includetypedefcharALIGN[16];typedefunionheader{struct{uint32_tsize;uint32_tis_free;unionheader*next;}s;ALIGNstub;}header_t;staticheader_t*head,*tail;ALIGN纯粹是用来对齐的类型,据传给搞成16字节对齐的地址能够使CPU更高效。里面的s成员才是会真正用到的部分,三个成员干什么的一看就明白:size是碎片大小,is_free是可用与否,next是下一个节点。

接下来我们来找一个能够盛下待分配内存的节点。这个过程很简单,顺着链表找下去就完了。

代码24-11寻找能盛下待分配内存的节点(lib/malloc.c)

//寻找一个符合条件的指定大小的空闲内存块staticheader_t*get_free_block(uint32_tsize){header_t*curr=head;//从头开始while(curr){if(curr->s.is_free&&curr->s.size>=size)returncurr;//空闲,并且大小也满足条件,直接返回curr=curr->s.next;//下一位}returnNULL;//找不到}然后就可以开始实现malloc了。先把代码放在这里,后面再慢慢解说。

代码24-12实现malloc(lib/malloc.c)

接下来讨论没找到的情况,这时再去找操作系统使用sbrk申请内存。由于使用了header+1,内存块的开头应该是一个header_t,要给加上这一片内存。

接下来if(block==(void*)-1)是在干什么呢?按照标准规定,当sbrk失败时应当返回-1,但我们的sbrk不会失败,就导致这一行没有用了。

然后就是把这个内存块初始化,连到链表里并返回。由于只能直接知道链表末尾的位置,把它串联到末尾。最后返回header+1跳过刚刚构造的header_t结构。

malloc完了,紧接着实现free,基本上差不多简单:

代码24-13实现free(lib/malloc.c)

voidfree(void*block){header_t*header,*tmp;if(!block)return;//free(NULL),有什么用捏header=(header_t*)block-1;//减去一个header_t的大小,刚好指向header_tif((char*)block+header->s.size==sbrk(0)){//正好在堆末尾if(head==tail)head=tail=NULL;//只有一个内存块,全部清空else{//遍历整个内存块链表,找到对应的内存块,并把它从链表中删除tmp=head;while(tmp){//如果内存在堆末尾,那这个块肯定也在链表末尾if(tmp->s.next==tail){//下一个就是原本末尾tmp->s.next=NULL;//踢掉tail=tmp;//末尾位置顶替}tmp=tmp->s.next;//下一个}}//释放这一块内存sbrk(0-sizeof(header_t)-header->s.size);return;}//否则,设置为freeheader->s.is_free=1;}首先判断block是不是NULL,然后把block减去一个header的大小,拿到对应的header。如果这个块正好在堆末尾,那就涉及到把这段内存归还给操作系统的事情;否则,直接把属性设置成free就可以为前面的get_free_block所用。

中间的大if就是在向操作系统归还内存,首先判断能不能归还,只要当前的这个内存正好抵着现在的programbreak,那就可以把这段内存归还。首先判断是不是只有这一个内存块,如果是的话直接清空整个链表即可;否则,由于链表中的内存块按分配先后顺序排列,那么在堆末尾的内存块,一定也在链表末尾。所以,这里直接遍历整个链表,在即将到达末尾的时候把末尾内存块踢出链表,并同时更新现在的末尾位置。最后,就可以释放掉这个内存块对应大小的内存,以及这个内存块本身占据的内存。以免你忘了,sbrk可以使用正数分配、负数释放。而使用sbrk(0),则相当于返回现在的programbreak,因为它的行为相当于给原programbreak加0再返回旧的。

好了,一个简单的malloc/free就已经实现了,居然连100行都不到,应该很简单吧。

有了malloc打底(事实上有sbrk就够了),给应用程序传参也就不是什么难事,malloc就等着写完应用程序传参再测吧。

既然给应用程序传参是通过函数的参数,我们就操作新应用程序的栈。或许有人就要问了:

既然shell里解析出了argc和argv,为什么不直接传,反而传的是cmdline呢?

这是因为,argc固然好传,但argv并不好传。相比之下cmdline好传得多,只需要分配一块内存,把cmdline复制进去,然后往栈里写一个指向新内存的指针即可。

这也正是我们所要做的,请看下面的代码:

代码24-14向应用程序传入cmdline(kernel/exec.c)

//接下来把cmdline传给app,解析工作由CRT完成//这样我就不用管怎么把一个char**放到栈里了((((intnew_esp=last-first+4*1024*1024-4;intprev_brk=sys_sbrk(strlen(cmdline)+5);//分配cmdline这么长的内存,反正也输入不了1MB长的命令strcpy((char*)(ds+prev_brk),cmdline);//sys_sbrk使用相对地址,要转换成以ds为基址的绝对地址需要加上ds*((int*)(ds+new_esp))=(int)prev_brk;//把prev_brk的地址写进栈里,这个位置可以被_start访问new_esp-=4;//esp后移一个dword//中略start_app(entry,0*8+4,new_esp,1*8+4,&(task_now()->tss.esp0));sys_sbrk返回的是相对ds_base的地址,所以下面的操作都要手动加上它。接下来手动存了一下新的esp,这是为了化简程序。然后直接调用sbrk而不是malloc分配空间,没有中间商赚差价,牢记sbrk返回的是调用之前的programbreak,所以此时的prev_brk正好就是新内存的起点。

接下来调用strcpy,向新内存写入cmdline,为什么加ds前面已经说了。最后往栈里写入已经指向这片区域的prev_brk,并同时把栈后移4位,这样让esp+4指向prev_brk,后面就可以从这里读出cmdline。

这边写完了,从C里读就更简单了,先从shell里偷一个cmd_parse,然后如此修改_start:

代码24-15能够接收参数的(apps/start.c)

#include#include#defineMAX_ARG_NR30staticchar*argv[MAX_ARG_NR]={NULL};//argv,字面意思intmain(intargc,char**argv);staticintcmd_parse(char*cmd_str,char**argv,chartoken){//shell.c里有自己抄去难道这个还要我给你写吗)))}void_start(char*cmdline){intargc=cmd_parse(cmdline,argv,'');exit(main(argc,argv));}看上去复杂了不少,不过没什么需要注意的,基本上都挺直接吧。

哦对了,cmd_parse会修改cmd_str的内容,所以在shell里要备份一下cmd_line,然后把备份的传进create_process:

代码24-16shell里杂七杂八的小修改(apps/shell.c)

staticcharcmd_line[MAX_CMD_LEN]={0};//输入命令行的内容staticcharcmd_line_back[MAX_CMD_LEN]={0};//这一行是新加的staticchar*argv[MAX_ARG_NR]={NULL};//argv,字面意思inttry_to_run_external(char*name,int*exist){intret=create_process(name,cmd_line_back,"/");//这里经过修改*exist=false;if(ret==-1){//略过添加.bin的处理ret=create_process(new_name,cmd_line_back,"/");//这里经过修改if(ret==-1)return-1;}*exist=true;ret=waitpid(ret);returnret;}现在,应用程序应该就已经可以接收参数了,可是拿什么来测试呢?根据我的经验,自己实现的东西都不能够说明问题,所以我们直接移植一个小程序玩玩吧!

添加了新的应用程序,Makefile也要修改,在APPS中添加一个out/c4.bin,并在hd.img里写入:

代码24-17写入hd.img的内容(Makefile)

hd.img:out/boot.binout/loader.binout/kernel.bin$(APPS) ftimgcreatehd.img-thd-size80 ftformathd.img-thd-ffat16 ftcopyout/loader.bin-to-imghd.img ftcopyout/kernel.bin-to-imghd.img ftcopyout/test_c.bin-to-imghd.img ftcopyout/test2.bin-to-imghd.img ftcopyout/shell.bin-to-imghd.img ftcopyout/c4.bin-to-imghd.img ftcopyapps/test_c.c-to-imghd.img ftcopyapps/c4.c-to-imghd.img ddif=out/boot.binof=hd.imgbs=512count=1我们不仅添加了c4.bin,还添加了test_c.c和c4.c。因为c4其实是一个小型的C解释器,可以直接执行C代码,但它是移植来的,这里不多做讲解。总之,既然能执行C代码,就得有C代码,c4.c和test_c.c就是两个规模大和规模小的测试文件。

现在终于可以编译运行,效果应如下图:

首先执行了编译的原生test_c,然后用c4执行源代码test_c.c,都没有问题;后来又先用c4自己运行自己c4.c,然后再执行test_c.c,再往下甚至又多套了一层,也是毫无问题。当然了,执行速度肯定是顺次往下越来越慢。c4里也用到了malloc,一石二鸟,这说明我们做的工作都成功了!

忽然想起我们分配的资源来,之前在exit的时候都没有妥善释放,但经过测试,exit的时候释放会出现莫名其妙的问题,所以只能放在wait里释放了,不知道这算不算一种我们OS特有的僵尸进程……(笑)

代码24-18释放任务所占资源(kernel/mtask.c)

inttask_wait(intpid){task_t*task=&taskctl->tasks0[pid];//找出对应的taskwhile(task->my_retval.pid==-1);//若没有返回值就一直等着task->flags=0;//释放为可用//总算把你等死了,释放该任务所占资源for(inti=3;ifd_table[i]!=-1)sys_close(task->fd_table[i]);//关闭所有打开的文件}//该任务malloc的所有东西都在数据段里,所以释放了数据段就相当于全释放了if(task->is_user)kfree((void*)task->ds_base);//释放数据段returntask->my_retval.val;//拿到返回值}再编译运行一遍,当然是毫无问题啦。这意味着我们的OS之旅终于可以暂告一段落了。

不过告一段落是一方面,怎么总感觉差点东西?这个work_dir参数加了怎么没有用到呢?既然ftcopy支持目录为什么我们不支持呢?嗯……

本来以为这一节就可以结束教程了,没想到还留了这一个瑕疵,就当是一个小尾巴吧。下一节我们将在原有文件系统的基础上进行修改,实现目录,或许还有一些其他的高级玩意哦。

THE END
1.通过Windows11操作系统电脑和应用感受AI的强大功能体验Microsoft Windows 11 的最新功能。了解最新的 Windows 操作系统如何为你提供更多工作、娱乐和创作方式。https://www.microsoft.com/zh-cn/windows
2.操作系统原理与实践教程完整指南简介:《计算机操作系统教程》PPT格式的教育资源详细介绍了操作系统的基础理论和应用实践。教程内容覆盖操作系统的核心组成部分,包括定义、类型、作用、发展历史以及基本功能。此外,还包括用户界面、进程管理、存储管理、设备管理和文件管理的深入讲解,最后重点介绍了Windows98中文版的系统架构与配置。学习者通过这七个章节将https://blog.csdn.net/weixin_33750664/article/details/142172809
3.操作系统怎么自学教程系统安装操作系统怎么自学教程 要自学操作系统,请按照以下步骤进行:奠定基础:学习计算机体系结构和汇编语言,阅读操作系统教科书。选择编程语言:c/c++、rust 或 python。实践实践实践:创建迷你操作系统、贡献开源项目和搭建虚拟机。深入学习:了解并发编程、文件系统和虚拟内存。项目和应用开发:根据特定需求开发嵌入式操作系统或优化https://m.php.cn/faq/1010652.html
4.操作系统教程Windows教程分享操作系统教程,Windows 教程,Windows7使用教程,系统安装教程,U盘安装系统教程,Win7优化教程,Win10优化教程,系统重装教程等优秀电脑系统及软件使用教程。http://www.winwin7.com/JC/
5.操作系统教程:初学者入门指南操作系统教程为您全面解析操作系统的基础、分类与作用,深入探讨常见操作系统的安装步骤和基础操作,提供文件与文件夹管理、系统设置与个性化、快捷键与操作技巧的实用指南,以及应用程序与软件安装方法。教程还覆盖了系统安全与维护,包括数据备份与恢复、病毒防护与防火墙设置,以及进阶操作如虚拟机与多系统共存、桌面环境与主题https://www.imooc.com/article/354968
6.计算机操作系统教程操作系统是计算机系统的核心系统软件,负责控制和管理整个系统,使之协调工作。本书不仅全面讲述了操作系统的基本概念、原理和方法,还清楚地展现了当代操作系统的本质和特点。全书分为9章,由浅入深地介绍了操作系统引论、进程管理、处理机调度、存储管理、设备管理、文件管理、Linux操作系统的实例与分析、网络和分布式操作https://hitpress.hit.edu.cn/2017/1213/c9161a196787/pagem.psp
7.操作系统教程ISBN:9787561154113 出版社:大连理工大学出版社 出版年:2010 操作系统教程 作者:陈向群 ISBN:7301035098 出版社:北京大学出版社 出版年:2001 操作系统教程 作者:陆松年 ISBN:9787121103353 出版社:电子工业出版社 出版年:2010 操作系统教程 作者:王素华 ISBN:7115058156 出版社:人民邮电出版社 出版年:1995 问https://www.las.ac.cn/front/book/detail?id=8862b058eca6af5649bd077dbc7dae78
8.操作系统实用教程(Linux版)第1章操作系统概述在线免费阅读看操作系统实用教程(Linux版)第1章 操作系统概述最新章节, /*p.sgc-1 {text-番茄小说网下载番茄小说免费阅读全文。https://fanqienovel.com/reader/7112746248448248839
9.操作系统基础教程《操作系统基础教程》是2007年中国电力出版社出版的图书,作者是(美)戴维斯。内容简介 如今,无论是技术还是非技术领域的职业,都必定与计算机及其操作系统之间有一定的联系,并相互影响。本书提供了对操作系统原理的介绍,适合于有一定教育背景的初级用户。在第六版中,本书继续着重于计算机操作系统及网络的使用(而非https://baike.baidu.com/item/%E6%93%8D%E4%BD%9C%E7%B3%BB%E7%BB%9F%E5%9F%BA%E7%A1%80%E6%95%99%E7%A8%8B/6229722
10.《计算机操作系统教程》(第3版).pdf脱机批处理 图1.2 早期脱机批处理模型 14 计算机操作系统教程(第3 版) 图1.3 监督程序管理下的解题过程 1.2.3 多道程序系统 图1.4 程序工作示例 计算机操作系统教程(第3 版) 15 1.2.4 分时操作系统 1.2.5 实时操作系统 1.2.6 通用操作系统 1.2.7 操作系统的进一步发展 1.3 操作系统的基本类型 1.3.1 批https://max.book118.com/html/2019/0404/5120302134002023.shtm
11.计算机操作系统教程(第3版)电子素材.pdf目录第 1 章 绪论 13 1 .1 操作系统概念 13 1 .2 操作系统的历史13 1.2.1 手工操作阶段 13 1.2.2 早期批处理13 1 .2.3 多道程序系统 14 1.2.4 分时操作系统 15 1.2.5 实时操作系统https://m.renrendoc.com/paper/307138829.html
12.计算机操作系统教程(第5版)(豆瓣)作者:张尧学/任炬/卢军 出版社:清华大学出版社 ISBN:9787302608912 豆瓣评分 目前无人评价 评价: 写笔记 写书评 加入购书单 分享到 推荐 我要写书评 计算机操作系统教程(第5版)的书评 ···(全部 0 条) 论坛· ··· 在这本书的论坛里发言 Chen豆瓣 10https://book.douban.com/isbn/978-7-302-60891-2/
13.win10系统教程windows10使用技巧win10系统升级方法口袋装机网站提供windows10系统安装方法和win10系统使用教程,包括win10系统常见故障修复、win10关闭自动更新方法以及win10系统升级windows11电脑系统教程和官方激活方式.https://www.koudaipe.com/view
14.2025年计算机考研专业课《计算机操作系统》(第四版)视频计算机考研专业课汤小丹版计算机操作系统(第四版)最新版的内容 系统学习操作系统知识,完善自己的知识体系。 建立整体知识框架,强化知识点掌握; 重点知识梳理,科学高效的备考; 课程简介: 计算机专业考研408必考操作系统课程,但有些同学大学时没有听懂,导致考试失分较多,自己看书,教程太厚、真题太难。怎么花最少的时间顺https://edu.51cto.com/course/3253.html
15.硬盘安装操作系统详细图文教程系统安装操作系统安装操作系统一般有三种,它们分别是:光盘安装法、u盘安装法、硬盘安装法。和前两种安装法相比,硬盘安装无需借助任何辅助设备,即可在硬盘中安装操作系统。本文就将为大家分享一篇硬盘安装操作系统图文教程,希望对大家有所帮助 GPT4.0+Midjourney绘画+国内大模型 会员永久免费使用! https://www.jb51.net/os/192619.html
16.什么是XP系统?windowsXP教程系统之家一键重装软件下载 永久免费的Windows 系统重装工具 立即下载,安装Windows 系统 立即下载 查看视频教程 Windows 7 旗舰版下载 微软经典Windows操作系统,办公一族得力助手 立即下载,安装Windows7 立即下载 查看视频教程 Windows10专业版下载 办公主流Windows 操作系统,让工作更稳定 https://www.163987.com/windowsxp/63156.html
17.老毛桃视频教程,系统安装教程,系统操作教程老毛桃官网的各种视频教程,包含u盘启动盘制作教程、使用u盘重装系统教程、各种系统的操作教程。https://www.laomaotao.net/video/
18.操作系统原理视频教程浙江大学徐宗元教程简介: 操作系统是计算机系统的核心软件,具备内容庞杂、涉及面广的特点。课程将操作系统组织成一个逻辑清晰的整体并提炼了并发、共享的主线;讲述了进程概念、支持多进程运行必需的机制、系统资源管理的策略与方法;以流行的操作系统为实例,剖析其特点和实现技术,理论与实际有机结合、相互印证..查看详细 http://v.dxsbb.com/jisuanji/1836/
19.《刺客信条中国》图文攻略全关卡100%同步图文攻略《刺客信条编年史:中国》这款刺客信条系列的游戏终于来到的我们中国,备受期待,这款游戏的主角也是大家最为熟悉的刺客大师艾吉奥的徒弟,并且还是一个女刺客,相信很多玩家已经被游戏的设定深深的吸引了,下面小编就为大家带来刺客信条中国的图文攻略,本攻略为全章节100%完美同步、全剧情、全宝箱碎片收集以及系统详细教程,希望https://www.gamersky.com/handbook/201504/569092.shtml
20.免重装:MBR转GPT分区表教程免重装:MBR转GPT分区表教程 前不久我们在《硬件大讲堂:MBR和GPT分区表的那些事儿》一文中介绍了操作系统引导以及MBR和GPT分区表的知识,在UEFI GPT大范围普及前,虽然很多用户的平台都已经可以享用新技术带来的优势(诸如显著优化了系统启动速度等),但是绝大多数用户并没有很好的利用它,依然选择了传统的Legacy MBRhttps://news.mydrivers.com/1/504/504096.htm
21.64位操作系统怎么安装CAD2006版本?大家好,我是小溜,在我们日常工作中使用“CAD软件”会遇到各种各样的问题,不管是新手还是高手都会遇到自己不知道的问题,比如“64位操作系统怎么安装CAD2006版本?”,下面小编就讲解一下怎么解决该问题吧,此教程共分为8个步骤,小编用的是联想天逸台式电脑,电脑操作系统版本以Win7为例,希望能帮助到各位小伙伴! https://zixue.3d66.com/article/details_103687.html