luojinbao 2013-05-24
怎样写一个简单的操作系统?(原文标题:How to write asimple operating system)
目录
简介
必备知识
计算机启动
汇编入门
第一个操作系统
进阶
简介
本文主要介绍怎样编写和构建您的第一个,基于x86汇编语言的操作系统。它解释了计算机开机的基本过程,一些基本的汇编语言,以及怎样进一步提升自己这方面的技术。最终编写的操作系统将是非常小的一个程序(仅仅是一个系统引导程序),并且只有非常少的功能,但它是您在这方面进一步进行研究和探索的一个起点。
在您阅读了本文之后,如果您想更进一步在这方面进行探索并扩展您的能力,您可以继续看一下Mike OS(译注:http://mikeos.berlios.de/)项目,这是一个更大更完备的x86汇编语言操作系统。
必备知识
编程经验是必不可少的。如果你已经使用一些高级语言像PHP和JAVA之类的做过一些开发,那再好不过,但是,你最好还要具备一些更底层语言的知识,像C之类的,特别是对一些内存和指针的问题比较熟悉。
在本文中,我们将使用Linux操作系统来作开发平台,当然,在Windows上面进行操作系统开发也是可以是的,但是显然在Linux上面开发更加的简单,因为你需要点击几下鼠标敲击几个命令就可以获得一套完整的开发工具。在Linux上面制作软盘和CD – ROM也更方便,你不需要安装一些繁琐的驱动程序。
现在安装Linux是非常容易的,如果你不想在你的计算机上面安装双系统,你可以把Ubuntu(译注:Ubuntu是Linux操作系统中的一个)安装在VMware或者VirtualBox上面,进入Ubuntu之后,只需要在命令行窗口键入下面的命令,就可以获得本文所需要的全部工具,简单把:
sudo apt-get install build-essential qemu nasm
通过这个命令你可以获得开发工具(编译器等等),QEMU PC仿真器和NASM的汇编器等等,汇编器能把汇编语言转换原生的机器码而组成可执行文件。
计算机启动
如果你正在为一个x86系统(这是最好的选择,因为有大量的文档可以参考)的计算机写操作系统,你需要理解计算机启动过程的基本知识,不过幸运的是,你现在不需要去了解图形驱动程序和网络协议等等复杂的部分,因此你可以专注于最本质的地方。
当计算机通电之后,它最开始执行的是BIOS(基本输入/输出系统)程序,它本质上是一个内置在系统中的微型操作系统。BIOS执行一些基本的硬件检测(如内存检查等),并且绘制一些特殊的图形(如DELL的LOGO)或者打印一些诊断文本到屏幕上。做完这些之后,它开始从某个可以找到的媒介上加载你的操作系统。然后大部分的计算机会跳转到硬盘驱动器并开始执行主引导区(MBR)的代码,主引导区是指一个硬盘驱动器最开始的512个字节的部分。有些计算机会尝试在一个软盘(启动扇区)或者CD – ROM上找到可执行代码。
计算机具体会去哪里寻找引导程序,依赖于引导顺序-你可以在BIOS的选项屏幕上明确的指定它。BIOS从选中的媒介(译注:硬盘,软盘,CD - ROM)中加载512字节到内存中,然后开始执行它。这就是(传说中的)引导程序,这个小程序然后加载操作系统内核或一个更大一些的引导程序(例如,Linux系统下的GRUB / LILO)。为了告诉操作系统它是一个引导扇区,512字节的引导程序在最后面有两个特殊的数字作标记,我们稍后将介绍它。
在计算机启动、引导的时候,有一个有趣的地方。在以前,基本上所有的计算机都配有一个软盘驱动器,因此BIOS配置的是从软盘驱动器启动,然而,现在的大部分的个人电脑都没有软盘驱动器,而是配备了一个CD – ROM,为了满足这种需要,专门开发了一个hack(译注:a hack直译不知道怎么翻译,大概就是类似外挂一样的意思,干预引导程序,呵呵)程序。当计算机从CD - ROM启动的时候,它可以模拟一个软盘出来,BIOS将从CD – ROM驱动器上面读取一个数据块并加载,然后执行它,就好像它是一个软盘一样。这对于操作系统开发者来说是非常(译注:原文用了incredibly,表示非常非常有用的,呃)有用的,因为我们可以只制作一个引导我们的操作系统的软盘,但是依然可以引导只有CD – ROM设备的机器。(相对来说,软盘是比较容易使用和操作的,而CD - ROM的文件系统则显然要复杂得多)。
因此总的来说,启动过程如下:
1、打开电源,计算机启动然后开始执行BIOS代码。
2、BIOS程序寻找软盘或硬盘驱动器等多种媒介(译注:可以在BIOS中设定寻找顺序)。
3、BIOS将从指定的媒介中加载512字节的引导扇区,然后开始执行它。
4、引导扇区然后再去加载操作系统本身,或者更加复杂的引导程序。
对于Mike OS,我们写了一个512字节的引导程序,并将它制作成一个软盘映像文件(虚拟软盘)。对于只有CD – ROM的驱动器,我们可以把该映像文件拷贝到CD上。不过无论使用哪种方式,BIOS都将正常加载它,就好像它是一个软盘一样,并开始执行它。之后我们就可以控制整个系统了!
汇编入门
现代操作系统大部分都是使用C或者C++编写,因为这对于可移植性和代码维护来说是至关重要的,但是这不是免费的午餐,在处理上就增加了一个更加复杂的层次。编写您的第一个操作系统,建议您最好是使用汇编语言,在Mike OS中也是使用的汇编语言,虽然汇编语言显得冗余和不可移植,但是您不用去担心编译器和链接器,这是它的优点。另外,此外,你需要一点汇编代码去启动任何操作系统。
汇编语言(或俗称的“汇编”)是表示CPU执行指令的一种文本化方式。例如,一条表示在CPU中移动数据的指令用二进制表示可能11001001 01101110,这种表示方法非常令人难以记忆(译注:简直是发狂的)。汇编语言使用一些助记符,如mov ax, 30来代替这些指令。汇编指令直接与机器码CPU指令相关联,我们就不用再关心那些看起来毫无意义的二进制数字。
跟大多数的编程语言一样,汇编语言也是有序的指令流。你可以在不同的指令位置进行跳转,也可以设置子程序或者函数,但是它比C#之类的程序要小得多。使用汇编,你无法给出一个打印“Hello World”到屏幕的指令,因为CPU根本没有屏幕这样一个概念!相反,你可以直接操作内存,控制RAM(译注:随机存取存储器),在它们上面进行算术运算并把结果放到正确的位置。听起来很疯狂么?但是汇编并不是很难掌握,虽然在一开始你会觉得有点陌生和不可理解。
在汇编语言层次,并没有高级语言中类似变量这样的一些抽象的概念。你所能做的就是设置Registers(译注:寄存器)的值,Registers是内置在CPU中的高速存储设备。你可以把数据存放在Registers上面并且执行计算。在16位模式下,这些寄存器只可以存储0到65535之间的数字。下面是一个典型的X86 CPU的基本寄存器列表:
AX, BX, CX, DX | 通用数据寄存器,可以用于存储正在使用的数据。譬如,你可以使用AX存储键盘上刚刚按下的字符;使用CX作为一个循环计数器。(注:16位寄存器可以被分割为8位寄存器使用,比如,AH/AL,BH/BL等等) |
SI, DI | 源操作数和目的操作数寄存器。用于指向内存中的某个地址来获取和存储数据。 |
SP | 堆栈寄存器(稍后再解释)。 |
IP(sometimes CP) | 指令寄存器,存放着即将要执行的下一条指令的内存地址,当一条指令执行结束后,指令寄存器进行自增(译注:不是增加1,而应该是自增下一条指令的长度)以便于指向接下来的指令地址。你可以改变指令寄存器的内容使得代码逻辑跳转执行(译注:一般不能直接改动,而是通过call和ret之类的指令间接改动)。 |
(译注:32位系统寄存器的位数增加到32位,相应的名称叫EAX, EBX, ECX, EDX等等)
因此你可以像使用变量一样用这些寄存器来存储数据,只不过它们在数据大小和用途上比较固定。有一类比较特殊的寄存器,叫做段寄存器,这主要是因为旧的计算机系统的限制,内存的处理被限制在一个64K的叫做段的块上。这是一个非常麻烦而混乱的问题,不过幸运的是,你现在不用担心,因为目前你即将编写操作系统远远小于一千字节,在Mike OS里面,我们把程序局限在一个64K的段里面,这样我们就不必去招惹麻烦的段寄存器了。
堆栈是从主存储器上面专门开辟的一块区域,用来存储临时信息。之所以叫着栈是因为一个数字堆积在另一个数字是上面,很形象的一种称呼。你可以想象一下,如果你有一个品客(译注:国际著名薯片品牌)的包装筒,如果你往里面顺序放入一张扑克牌,一个iPod Shuffle和一个啤酒杯垫子,那么你再把它们拿出来的时候就是完全相反的顺序了(先是啤酒杯垫子,然后是ipod shuffle,最后是扑克牌)。这跟数字也是一样的,如果你把数字5,7和15顺序压入堆栈,那么你弹出这些数字的时候顺序就刚好相反了,先是数字15,然后是数字7,最后是15。在汇编里面,你可以把寄存器的值压到堆栈上,处理完某些事情后在把它们从堆栈上弹回到寄存器中,这个主要用于当你想使用某些寄存器去干别的事情的时候,而你又不想破坏现在寄存器里面的值,那么你可以把寄存器里面的值压入堆栈,等处理完其他事情后再从堆栈上把值弹回寄存器中。
计算机的内存可以看作一个线性的空间,就像一个个连续的鸽子笼一样,它的范围从0开始直到你所安装的内存的最大值(现代计算机的内存高达数百万字节)。例如,你可能怎在使用浏览器来查看内存中53634246字节的一个文档文件,但是我们人类的计数是基于10的幂的(10,100,1000等等,也就是十进制),而计算机计数则是基于2的幂的(因为计算机使用2进制更好)。为了能更好的描述数字,我们使用16(基于16的幂)进制,可以对照下面的表格来理解:
10进制 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | |
16进制 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | A | B | C | D | E | F | 10 | 11 | 12 | 13 | 14 |
如图中表格所示,我们平时都是使用的十进制计数系统,用0-9计数,十六进制计数系统使用0-F进行计数,对于没有接触过进制的人来说,可能有点困惑,不过没关系,很快你就能学会它。在汇编语言里面,我们通过在数字字符后面加上‘h’表示一个十六进制的数字,比如0Ah就表示十进制的10(你也可以通过加一个0x前缀的方式来表示十六进制,例如0x0A)。
让我们先来看看几条常用的汇编指令,主要是数据传送指令,比较指令和数学计算指令等等,它们将是构建你的操作系统的基石。总共大概有数百条汇编指令,不过你不需要全部记住它们,熟悉常用指令就可以了,因为90%的时间我们都是在使用其中的极少数指令。
mov | 数据传送指令,从寄存器或者内存中移动数据到另一个地方。例如:mov ax, 30表示把数据30传送到寄存器ax中。要获取内存中的数据,如果它被某个寄存器指向,你可以使用方括号。例如,如果bx中的数据是80,那么mov ax, [bx]的意思就是,获取内存地址为80的处的数据,并把它传送到ax寄存器中。你也可以使用这条指令在两个寄存器之间传送数据,譬如:mov bx, cx。 |
add/sub | 数据加减指令,add ax, FFh表示加上一个FFh(十六进制数,表示十进制的255)到ax寄存器中,sub指令的使用也是一样的,sub dx, 50。 |
cmp | 比较指令。用来比较一个寄存器和一个数字,例如,cmp cx, 12的意思是将cx中的数据和12进行比较。根据比较的结果,更新CPU的标志寄存器(译注:标志寄存器是用来存储最后一次运算结果的状态的特殊寄存器)。在这个例子里面,如果12大于cx中存储的值,比较结果是一个负数,那么标志寄存器的符号标志位就会标识为负,我们会在下面的指令中用到这种信息…… |
jmp/jg/jl … | 跳转指令。跳转到代码的另一个地方。jmp label是无条件跳转指令,能跳转到我们定义的标签位置,也就是label。跳转指令中使用更多的是条件跳转指令,它根据之前的指令设置在状态寄存器中值,有条件的进行跳转。例如:如果一条cmp指令执行完成后,确定寄存器比与它比较的数值要小,你可以使用指令jl label(如果小于则跳转到标签label),类似,jge label的意思是,如果cmp左边的值比右边和它进行比较的数值大或者等于则跳转到label。 |
int | 中断指令。中断程序的执行并且跳转到内存中的一个指定的地方。操作系统触发一个中断有点类似高级语言中的子程序。例如,在MS-DOS里面,21h中断表示调用一个DOS的服务(例如打开一个文件)。通常,在ax寄存器中存入一个数值,然后调用中断等待结果(结果也是通过寄存器传回)。如果你是完全从头写一个操作系统,你可以使用int 10h,int 13h,int 14h或int 16h等等指令来调用BIOS的中断,可以完成打印字符串,从一个磁盘读取一个扇区等等任务。 |
我们看看这些指令的更多一些细节,思考如下的汇编代码片断:
mov bx, 1000h
mov ax, [bx]
cmp ax, 50
jge label
label:
mov ax, 10
我们来看看这几条指令,第一条指令,把1000h传送到寄存器bx中。然后,第二条指令是把bx寄存器指向的内存单元的数据传送到ax寄存器中,如果我们仅仅使用mov ax, bx,那么它的意思就是简单的把数据1000h传送到ax寄存器中,但是在bx上使用了方括号之后,意义就变化了,不是简单的把数据从bx传送到ax了,而是传送bx所指向的内存的内容到ax寄存器中。具体到这条指令,bx中是1000h,那么它的意思就是说把内存地址1000h中的数据传送到ax寄存器中。
因此,如果内存地址1000h中存放的数据是37,那么执行完上面的第二条指令之后,ax中存放的数据将是37,接下来,将是使用cmp指令对ax中的数据和50(十进制数50,注意,并没有h后缀)进行比较,之后的jge指令将作用在cmp的比较结果上,因为cmp指令执行之后,会设置标志寄存器的某些标志位,指令jge label的意思就是说,如果之前的比较结果是大于或者等于则跳转到label标签的位置继续执行,因此,如果ax寄存器中的数据不比50小的话,程序就跳转到label标签处执行,否则,程序继续执行“…”部分的代码。
最后一点:您可以使用db(字节定义)标识将数据插入到程序中。与数据库程序中插入数据(定义字节)指令。例如,定义一个字符串,可以使用一段以0结尾的连续字节来表示。如下所示:
mylabel: db 'Message here', 0
在汇编代码中,我们就可以知道一个以0结尾的字符串能通过mylabel: position找到。我们还可以设置单个字节的值,然后在其他地方像一个变量一样使用它,看下面的代码:
foo: db 0
现在foo:在代码中指向单字节,在这种情况下,Mike OS将是可写的,因为整个操作系统都被拷贝到了内存中。因此,你可以使用下面的指令:
mov byte al, [foo]
这条指令的意思是把foo指向的一个字节传送到al寄存器中。
这就是X86体系汇编语言的基本要点,已经足够让你开始后面的学习了。当你编写一个操作系统的时候,随着你的进展,你需要学习更多的汇编知识和其他知识,你可以参考Resources部分的辅导资料(译注:没有找到所谓的辅导资料)。
第一个操作系统
现在,你已经可以开始编写您的第一个操作系统内核了!当然,这将是一个极其精简的部分,仅仅是一个512字节的引导扇区,就跟之前叙述的一样,但是,它是您进行进一步探索的一个起点。复制粘贴下面的代码到一个名为myfirst.asm的文件中,并把它保存到你的home目录——这就是你的第一个操作系统的源代码。O(∩_∩)o
BITS 16
start:
mov ax,07C0h; Set up 4Kstack space after this bootloader
add ax,288; (4096 + 512) / 16bytes per paragraph
mov ss, ax
mov sp, 4096
mov ax, 07C0h;Set data segment to where we're loaded
mov ds, ax
mov si,text_string; Put string position into SI
callprint_string; Call our string-printing routine
jmp$;Jump here - infinite loop!
text_string db'This is my cool new OS!', 0
print_string:;Routine: output string in SI to screen
mov ah,0Eh;int 10h 'print char' function
.repeat:
lodsb;Get character from string
cmp al, 0
je.done;If char is zero, end of string
int10h;Otherwise, print it
jmp .repeat
.done:
ret
times510-($-$$) db 0; Pad remainder of bootsector with 0s
dw0xAA55; The standard PCboot signature
让我们一步步的来分析这段代码。BITS 16这条并不是一个x86系统的指令,这里仅仅是告诉NASM汇编器,接下来我们将工作在16位模式。NASM将把下面的指令翻译成原生的x86二进制代码。然后是一个start:标签,在文件的开头,这个标签不是必须的,不过是一个好的编码习惯,分号(;)用来进行代码注释,里面可以写任何你觉得对代码注解有用的东西,它不会被执行。
接下来的6行代码对我们来说并不是特别的有意义,这几行代码用来设置段寄存器的值,以便于堆栈指针(SP)能方便的知道我们的堆栈数据在哪儿,我们的数据段又在哪个位置。之前提到过,段是老的16位系统遗留下来一种混乱恶心的管理内存的东西,不过我们这里仅仅是设置段寄存器的值,然后你就忘了它吧(代码中引用的07C0H是BIOS加载我们的代码的等效段地址,因此我们从这个地方开始)。
接下来的代码开始有意思起来,mov si, text_string这行的意思是,把text_string的地址复制到si寄存器中。然后是一条call指令,call指令类似于BASIC语言中的Go Sub或者C语言中函数调用。call指令含义是:跳转到到指定的代码块执行,当任务完成后再回到call指令的下一条指令继续执行。
对于call指令,代码是怎么做到跳转执行之后又正常返回的?当我们使用一个call指令的时候CPU增加IP(指令指针)寄存器的值,并把该值压到栈上。你可以回顾一下之前对栈的解释,栈是一种后进先出(译注:传说中的LIFO)的内存存储结构。所有的事情在最开始通过设置堆栈指针(SP)和堆栈段指针(SS)的值做好了,为栈专门开辟了一块空间,因此你能把临时数据存放在这个上面而不会覆盖我们本身的代码。
所以,call print_string的意思就是说,跳转到print_string分支去执行,并且把call指令的下一条指令的地址压入堆栈中,当从分支返回后,再把该地址从堆栈上弹出并从该地址处的指令接着执行。运行到了print_string分支之后,这个分支的代码使用BIOS输出文本到屏幕上面。首先把0Eh传送到ah寄存器里面(ax寄存器的高8位),然后lodsb指令从si寄存器指向的地址处取出一个字节存放到al寄存器(ax寄存器的低8位)里面,接下来将使用cmp指令判断取出来的这个指令是否为0,如果等于0,说明已经是字符串的末尾,将退出打印(跳转到.done标签)。
如果取出来的值不是0,那么将通过int 10h指令触发中断进入BIOS,在BIOS里面,将会读取ah寄存器的值,之前设置的是0Eh,这个值对于BIOS来说就是打印al寄存器中的字符到屏幕上的意思。因此BIOS首先打印我们的字符串里面的第一个字符,然后从中断返回,代码跳转到.repeat标签,然后重复上面的过程,lodsb指令继续从si寄存器(每次该指令会把si寄存器增加1)指向的地址处取得一个字节放入al寄存器,判断是否为0并决定做什么事情。print_sting分支最后的ret指令的意思是:这里的工作已经做完啦,可以回去了,o(∩_∩)o,然后程序会返回到我们调用该分支代码的地方,并弹出堆栈上的值到IP寄存器中(译注:之前不是说过把call指令的下一条指令存放到了堆栈中么)。
在这个单独的分支里面有一个循环语句。在代码里面,text_string标识符的旁边是一个字符流,是通过上面提到过的db指令插入到我们的操作系统中的。这段文本包含在一对单引号(’)里面,就是告诉NASM这里面不是代码,并且以一个0结尾,标识文本的结束,在print_string分支里面作为字符串的结束的判断标志。
重新叙述一下基本要点:首先是设置段寄存器,因此操作系统就知道了堆栈的位置和可执行代码的起始位置。然后我们把si寄存器指向我们系统中的一个二进制串,然后调用一个字符串打印子程。这个子程通过si寄存器指向的字符指针扫描整个二进制串,直到遇到0,然后程序回到调用这个子程的下一句代码继续执行。jmp $这句的意思跳转到同一行(在NSAM里面“$”的意思是表示当前代码行)。这样就设置了一个无限循环,因此在信息被打印到屏幕上之后我们的系统将不再执行下面的代码(译注:因为一直在这里死循环)。
最后的两行代码比较的有意思,就我们的计算机来说,一个合法有效的磁盘引导扇区,必须是精确的512字节,并且以AAh和55h结尾(引导扇区的签名)。因此times 510-($-$$) db 0这句的意思是说,填充我们的二进制目标文件直到大小达到510个字节。然后第二句dw 0xAA55的意思是使用dw(定义一个word,两个字节)指令填充之前说的引导扇区的签名。510+2不是刚好512字节了么,一个以引导扇区的签名结尾的,正确大小的启动文件就此诞生,呵呵。
下面我们来编译我们的新操作系统,在命令行窗口中,进入你的home目录,输入如下的命令:
nasm -f bin -o myfirst.bin myfirst.asm
用这个命令,我们把文本代码汇编成了原始的机器码二进制文件。命令参数–f bin是告诉NASM,我们需要的是一个纯的二进制文件(而不是一个复杂的linux下的可执行文件——总之我们希望这个文件越简单纯粹越好)。-o myfirst.bin部分是告诉汇编器生成一个名为myfirst.bin的二进制文件。
现在我们需要一个虚拟软盘映像来存放我们的引导程序,把mikeos.flp文件从Mike OS包目下的disk_images/文件夹中拷贝到你的home目录下,并且改名为myfirst.flp,然后输入下面的命令:
dd status=noxfer conv=notrunc if=myfirst.binof=myfirst.flp
使用‘dd’命令把我们的内核文件直接复制到软件映像的第一个扇区。做完这些之后,我们就可以使用QEMU PC模拟器用下面的命令进行引导:
qemu -fda myfirst.flp
瞧!你的操作系统将在虚拟机里面启动起来了。如果你真的想在一台个人计算机上使用它,你可以把软盘映像写到一个真正的软盘上去,并设置为从它启动,或者生成一个CD - ROM的ISO映像。如果你使用后一种方法,你需要创建一个新的目录cdiso,并把myfirst.flp放到这个文件夹下面。然后在你的home目录下输入如下命令:
mkisofs -o myfirst.iso -b myfirst.flp cdiso/
执行之后将生成一个CD – ROM的ISO映像文件,名字叫myfirst.iso。这是使用之前那个可引导的虚拟软盘映像生成的。现在你可以把这个ISO文件刻录到你的CD-R里面去,并且用它来引导你的计算机。(要注意的是你要直接把它刻录为一个iso映像,而不是把它复制到光盘上面)
接下来,如果你想改进自己的操作系统——浏览一下Mike OS的源代码你也许能获得一些灵感。需要记住的是,引导程序被限制在512个字节,如果你想做更多的事情,你需要让你的引导程序从磁盘上装入一个单独的文件,然后执行它,Mike OS中就是这么做的。
进阶
现在你已经有了一个非常简单的引导加载程序。下一步呢?这里给出一些建议:
1、增加更多的子程序——在你的内核中已经有了print_string子程序,你可以添加子程序获得字符串或者是移动鼠标等等。关于实现这些的方法,你可以在网上搜索一下BIOS的调用。
2、加载文件——引导程序被限制在512字节,因此你没有太多发挥的余地。不过你可以用引导程序把磁盘上后续的一些扇区加载到内存(RAM)里面,并跳转到该处继续执行。或者你研究一下软盘驱动器上使用的文件系统FAT12,然后再实现它。(可以看一下Mike OS里面的一个实现,文件位置:source/bootload/bootload.asm)
3、加入某个项目——本文档是由Mike OS的首席开发Mike Saunders撰写的,Mike OS是一个简单的基于x86汇编语言的16位操作系统,一旦你完全理解了这里介绍的一些概念,你就可以深入代码进行挖掘并为它增加功能。查看系统的开发手册的链接(译注:这个项目的地址http://mikeos.berlios.de/),可以获得更多的信息,你也可以加入这个项目的邮件组。