2 继续开发
昨天我们还没有详细地讲解helloos.nas中的注释部分,其中要掌握程序核心之前的内容和启动区以外的内容,需要具备软盘方面的一些具体知识,而这在以后我们还会讲到,所以这两部分暂时先保留。
这样一来,尚未讲解清楚的就只有程序核心部分了,那么我们下面就把它改写成更简单易懂的形式吧。先把projects/02_day中的helloos3复制到tolset中,然后打开其中的helloos.nas文件。这个文件太长了,我们节选一部分来讲解。
helloos.nas节选
; hello-os ; TAB=4 ORG 0x7c00 ; 指明程序的装载地址 ; 以下的记述用于标准FAT12格式的软盘 JMP entry DB 0x90 ——-(中略)——- ; 程序核心 entry: MOV AX,0 ; 初始化寄存器 MOV SS, AX MOV SP,0x7c00 MOV DS, AX MOV ES, AX MOV SI, msg putloop: MOV AL, [SI] ADD SI,1 ; 给SI加1 CMP AL,0 JE fin MOV AH,0x0e ; 显示一个文字 MOV BX,15 ; 指定字符颜色 INT 0x10 ; 调用显卡BIOS JMP putloop fin: HLT ; 让CPU停止,等待指令 JMP fin ; 无限循环 msg: DB 0x0a, 0x0a ; 换行2次 DB "hello, world" DB 0x0a ; 换行 DB 0
这段程序里有很多新指令,我们从上到下依次来看看。
■■■■■
首先是ORG指令。这个指令会告诉nask,在开始执行的时候,把这些机器语言指令装载到内存中的哪个地址。如果没有它,有几个指令就不能被正确地翻译和执行。另外,有了这条指令的话,美元符($)的含义也随之变化,它不再是指输出文件的第几个字节,而是代表将要读入的内存地址。
ORG指令来源于英文“origin”,意思是“源头、起点”。它会告诉nask,程序要从指定的这个地址开始,也就是要把程序装载到内存中的指定地址。这里指定的地址是0x7c00,至于指定它的原因我们会在后文(本节末尾)详述。
下一个是JMP指令,它相当于C语言的goto语句,来源于英文的jump,意思是“跳转”。简单吧!
再下面是“entry:”,这是标签的声明,用于指定JMP指令的跳转目的地等。这与C语言很像。entry这个词是“入口”的意思。
■■■■■
然后我们来看看MOV指令。MOV指令应该是最常用的指令了,即便在这段程序里,MOV指令的使用次数也仅次于DB指令。这个指令的功能非常简单,即赋值。虽然简单,但笔者认为,只要完全掌握了MOV指令,也就理解了汇编语言的一大半。所以,我们在这里详细地讲解一下这个指令。
“MOV AX,0”,相当于“AX=0; ”这样一个赋值语句。同样,“MOV SS, AX”就相当于“SS=AX; ”。或许有人会问:“这个AX和SS是什么东西?”这个问题我们待会儿再回答。
MOV命令源自英文“move”,意思是“移动”。“赋值”与“移动”虽然有些相似,但毕竟还是不同的。一般说来,如果我们把一个东西移走了,它原来所占用的位置就会空出来。但是,在执行了“MOV SS, AX”语句之后,AX并没有变“空”,还保留着原来的值不变。所以这实际上是“赋值”,而不是“移动”。如果用“COPY”指令来打比方,理解起来就简单多了。至于为什么成了MOV指令,笔者也搞不明白。
■■■■■
现在来说说AX和SS。CPU里有一种名为寄存器的存储电路,在机器语言中就相当于变量的功能。具有代表性的寄存器有以下8个。各个寄存器本来都是有名字的,但现在知道这些名字的机会已经不多了,所以在这里顺便介绍一下。
AX——accumulator,累加寄存器
CX——counter,计数寄存器
DX——data,数据寄存器
BX——base,基址寄存器
SP——stack pointer,栈指针寄存器
BP——base pointer,基址指针寄存器
SI——source index,源变址寄存器
DI——destination index,目的变址寄存器
这些寄存器全都是16位寄存器,因此可以存储16位的二进制数。虽然它们都有上面这种正式名称,但在平常使用的时候,人们往往用简单的英文字母来代替,称它们为“AX寄存器”、“SI寄存器”等。
其实寄存器的全名还是很能说明它本来的意义的。比如在这8个寄存器中,不管使用哪一个,差不多都能进行同样的计算,但如果都用AX来进行各种运算的话,程序就可以写得很简洁。
“ADD CX,0x1234”编译成81 C1 34 12,是一个4字节的命令。
而 “ADD AX,0x1234”编译成05 34 12,是一个3字节的命令。
从上面例子可以看出,这里所说的“程序可以写得简洁”是指“用机器语言写程序”的情况,从汇编语言的源代码上是看不到这些区别的。如果我们不懂机器语言,就会有很多地方难以理解。
再说说别的寄存器,CX是为方便计数而设计的,BX则适合作为计算内存地址的基点。其他的寄存器也各有优点。
关于AX、CX、 DX、 BX这几个寄存器名字的由来,虽然我们找不到缩写为X的单词,但这个X表示扩展(extend)的意思。之所以说扩展是因为在这之前CPU的寄存器都是8位的,而现在一下变成了16位,扩展了一倍,所以发明者在原来寄存器的名字后面加了个X,意思是说“扩张了一倍,了不起吧!”。大家可能注意到了这几个寄存器的排列顺序,它并不遵循名称的字母顺序。没错,其实这是按照机器语言中寄存器的编号顺序排列的,可不是笔者随手瞎写的哦。
这8个寄存器全部合起来也才只有16个字节。换句话说,就算我们把这8个寄存器都用上,CPU也只能存储区区16个字节。
另一方面,CPU中还有8个8位寄存器。
AL——累加寄存器低位(accumulator low)
CL——计数寄存器低位(counter low)
DL——数据寄存器低位(data low)
BL——基址寄存器低位(base low)
AH——累加寄存器高位(accumulator high)
CH——计数寄存器高位(counter high)
DH——数据寄存器高位(data high)
BH——基址寄存器高位(base high)
名字看起来有点像,其实这是有原因的:AX寄存器共有16位,其中0位到7位的低8位称为AL,而8位到15位的高8位称为AH。所以,如果以为“再加上这8个8位寄存器,CPU就又可以多保存8个字节了”就大错特错了,CPU还是那个CPU,依然只能存储区区16个字节。CPU的存储能力实在是太有限了。
那BP、SP、SI、DI怎么没分为“L”和“H”呢?能这么想,就说明大家已经做到举一反三了,但可惜的是这几个寄存器不能分为“L”和“H”。如果无论如何都要分别取高位或低位数据的话,就必须先用“MOV, AX, SI”将SI的值赋到AX中去,然后再用AL、AH来取值。这貌似是英特尔(Intel)的设计人员的思维模式。
“喂,我家的电脑是32位的,可不是16位。这样就能以32位为单位来处理数据了吧?那32位的寄存器在哪儿呀?”大家可能会有这样的疑问,下面笔者就来回答这个问题。
EAX, ECX, EDX, EBX, ESP, EBP, ESI, EDI
这些就是32位寄存器。这次的程序虽然没有用到它们,但如果想用也是完全可以使用的。在16位寄存器的名字前面加上一个E就是32位寄存器的名字了。这个字母E其实还是来源于“Extend”(扩展)这个词。在当时主流为16位的时代里,能扩展到32位算是个飞跃了。虽说EAX是个32位寄存器,但其实跟前面一样,它有一部分是与AX共用的,32位中的低16位就是AX,而高16位既没有名字,也没有寄存器编号。也就是说,虽然我们可以把EAX作为2个16位寄存器来用,但只有低16位用起来方便;如果我们要用高16位的话,就需要使用移位命令,把高16位移到低16位后才能用。
这么说来,就是32位的CPU也只能存储区区32字节,存储能力还真是小得可怜。
有的读者用的电脑可能是64位的,但我们这次不使用64位模式,所以这里也就不再赘述了。
关于寄存器本来笔者就想介绍到这儿,但是突然想起来,还有一个段寄存器(segment register),所以在这里一并给大家介绍一下吧。这些段寄存器都是16位寄存器。
ES——附加段寄存器(extra segment)
CS——代码段寄存器(code segment)
SS——栈段寄存器(stack segment)
DS——数据段寄存器(data segment)
FS——没有名称(segment part 2)
GS——没有名称(segment part 3)
关于段寄存器的具体内容,我们保留到明天再详细讲解。现在,我们暂时先在这些寄存器里放上0就可以了。
好,到这里寄存器已经讲得差不多了。
■■■■■
那么接下来我们继续看程序,下一个看不懂的语句应该是“MOV SI, msg”吧。MOV是赋值,意思是SI=msg,而msg是下面将会出现的标号。“把标号赋值给寄存器?这到底是怎么回事?”为了理解这个谜团,我们先回到JMP指令。
前面我们已经看到了“JMP entry”这个指令,其实把它写成“JMP 0x7c50”也完全没有问题。本来JMP指令的基本形式就是跳转到指定的内存地址,因此这个指令就是让CPU去执行内存地址0x7c50的程序。
之所以可以用“JMP entry”来代替“JMP 0x7c50”,是因为entry就是0x7c50。在汇编语言中,所有标号都仅仅是单纯的数字。每个标号对应的数字,是由汇编语言编译器根据ORG指令计算出来的。编译器计算出的“标号的地方对应的内存地址”就是那个标号的值。
所以,如果我们在这个程序中写了“MOV AX, entry”,那它就会把0x7c50代入到AX寄存器里,我们代入到AX寄存器中的就是这个简单的数字。大家可不要以为写在“entry”下面的程序也都被储存了,这是不可能的。
那么“MOV SI, msg”会怎么样呢?由于在这里msg的地址是0x7c74,所以这个指令就是把0x7c74代入到SI寄存器中去。
■■■■■
下面我们来看“MOV AL, [SI]”。如果这个命令是“MOV AL, SI”的话,不用多说大家也都能明白它的意思,可这里用方括号把SI括了起来。如果在汇编语言中出现这个方括号,寄存器所代表的意思就完全不一样了。
这个记号代表“内存”。如果大家自己组装过电脑,就知道所谓“内存”,指的是256MB或512MB的那个零件。
内存
到现在为止,内存这个词我们已经使用了很多次了,可一直都还没有正式讲解过,那内存到底是什么呢?简单地用一句话来概括,它就是一个超大规模的存储单元“住宅区”。用“住宅区”来比喻内存再合适不过了,它能充分体现出存储单元紧密、整齐地排列在一起的样子。英语中memory是“记忆”的意思,这里我们把它译成“内存”。
通过对寄存器的讲解,现在大家都知道了CPU的存储能力很差,如果我们想让CPU处理大量信息,就必须给它另外准备一套用于存储的电路。因为即便是32位的CPU,把所有普通的寄存器都加在一起,最多也只能存储32个字节的数据。就算把段寄存器也全部用上,也才只有44字节。这么小的存储空间,就连启动电脑所必需的启动区数据都放不下。
现在大家已经知道了存储单元的必要性,那么我们下面就讲内存。内存并不在CPU的内部,而是在CPU的外面。所以对于CPU来说,内存实际上是外部存储器。这点很重要,就是说CPU要通过自己的一部分管脚(引线)向内存发送电信号,告诉内存说:“喂,把5678号地址的数据通过我的管脚传过来(严格说来,CPU和内存之间还有称为芯片(chipset)的控制单元)! ”CPU向内存读写数据时,就是这样进行信息交换的。
CPU与内存之间的电信号交换,并不仅仅是为了存取数据。因为从根本上讲,程序本身也是保存在内存里的。程序一般都大于44字节,不可能保存在寄存器中,所以规定程序必须放在内存里。CPU在执行机器语言时,必须从内存一个命令一个命令地读取程序,顺序执行。
内存虽然如此重要,但它的位置却离CPU相当远。就算是只有10厘米左右的距离吧,可这与CPU中的半导体相比已经非常遥远了。所以,当CPU向内存请求数据或者输出数据的时候,内存需要花很长时间才能够完整无误地实现CPU的要求(CPU运行速度极快,所以即使在10厘米这么短的距离内传送电信号,所花的时间都不容忽视)。所以,虽然内存比寄存器的存储能力大很多个数量级,但使用内存时速度很慢。CPU访问内存的速度比访问寄存器慢很多倍,记住这一点,我们才能开发出执行速度快的程序来。
■■■■■
基础知识我们讲完了,下面再回到汇编语言。MOV指令的数据传送源和传送目的地不仅可以是寄存器或常数,也可以是内存地址。这个时候,我们就使用方括号([ ])来表示内存地址。另外,BYTE、WORD、DWORD等英文词也都是汇编语言的保留字,下面举个例子吧。
MOV BYTE [678],123
这个指令是要用内存的“678”号地址来保存“123”这个数值。虽然指令里有数字,看起来像那么回事,但实际上内存和CPU一样,根本就没有什么数值的概念。所谓的“678”,不过就是一大串开(ON)或者关(OFF)的电信号而已。当内存收到这一串信号时,电路中的某8个存储单元就会响应,这8个存储单元会记住代表“123”的开(ON)或关(OFF)的电信号。为什么是8位呢?这是因为指令里指定了“BYTE”。同样,我们还可以写成:
MOV WORD [678],123
在这种情况下,内存地址中的678号和旁边的679号都会做出反应,一共是16位。这时,123被解释成一个16位的数值,也就是0000000001111011,低位的01111011保存在678号,高位的00000000保存在旁边的679号。
像这样在汇编语言里指定内存地址时,要用下面这种方式来写:
数据大小 [地址]
这是一个固定的组合。如果我们指定“数据大小”为BYTE,那么使用的存储单元就只是地址所指定的字节。如果我们指定“数据大小”为WORD,则相邻的一个字节也会成为这个指令的操作对象。如果指定为DWORD,则与WORD相邻的两个字节,也都成为这个指令的操作对象(共4个字节)。这里所说的相邻,指的是地址增加方向的相邻。
至于内存地址的指定方法,我们不仅可以使用常数,还可以用寄存器。比如“BYTE [SI]”、“WORD [BX]”等等。如果SI中保存的是987的话,“BYTE [SI]”就会被解释成“BYTE [987]”,即指定地址为987的内存。
虽然我们可以用寄存器来指定内存地址,但可作此用途的寄存器非常有限,只有BX、BP、SI、DI这几个。剩下的AX、CX、DX、SP不能用来指定内存地址,这是因为CPU没有处理这种指令的电路,或者说没有表示这种处理的机器语言。没有对应的机器语言当然也就不能进行这样的处理了,如果有意见的话,就写邮件找英特尔的大叔们吧(笑)。笔者没有勇气找英特尔的大叔们抱怨,所以想把DX内存里的内容赋值给AL的时候,就会这样写:
MOV BX, DX MOV AL, BYTE [BX]
■■■■■
根据以上说明我们知道可以用下面这个指令将SI地址的1字节内容读入到AL。
MOV AL, BYTE [SI]
可是MOV指令有一个规则,那就是源数据和目的数据必须位数相同。也就是说,能向AL里代入的就只有BYTE,这样一来就可以省略BYTE,即可以写成:
MOV AL, [SI]
哦,这样就与程序中的写法一样了。现在总算把这个指令解释清楚了,所以这个指令的意思就是“把SI地址的1个字节的内容读入AL中”。
■■■■■
ADD是加法指令。若以C语言的形式改写“ADD SI,1”的话,就是SI=SI+1。“add”的英文原语意为“加”。
CMP是比较指令。或许有人想,比较指令是干什么的呢?简单说来,它是if语句的一部分。譬如C语言会有这种语句:
if(a==3){ 处理;}
即对a和3进行比较,将其翻译成机器语言时,必须先写“CMP a,3”,告诉CPU比较的对象,然后下一步再写“如果二者相等,需要做什么”。
这里是“CMP AL,0”,意思就是将AL中的值与0进行比较。这个指令源自英文中的compare,意为“比较”。
JE是条件跳转指令中之一。所谓条件跳转指令,就是根据比较的结果决定跳转或不跳转。就JE指令而言,如果比较结果相等,则跳转到指定的地址;而如果比较结果不等,则不跳转,继续执行下一条指令。因此,
CMP AL, 0 JE fin
这两条指令,就相当于:
if (AL == 0) { goto fin; }
这条指令源自于英文“jump if equal”,意思是如果相等就跳转。顺便说一句,fin是个标号,它表示“结束”(finish)的意思,笔者经常使用。
■■■■■
INT是软件中断指令。如果现在就讲中断机制的话,肯定会让人头昏脑胀的,所以我们暂时先把它看作一个函数调用吧。这个指令源自英文“interrupt”,是“中途打断”的意思。
电脑里有个名为BIOS的程序,出厂时就组装在电脑主板上的ROM单元里。电脑厂家在BIOS中预先写入了操作系统开发人员经常会用到的一些程序,非常方便。BIOS是英文“basic input output system”的缩写,直译过来就是“基本输入输出系统(程序)”。
最近的BIOS功能非常多,甚至包括了电脑的设定画面,不过它的本质正如其名,就是为操作系统开发人员准备的各种函数的集合。而INT就是用来调用这些函数的指令。INT的后面是个数字,使用不同的数字可以调用不同的函数。这次我们调用的是0x10(即16)号函数,它的功能是控制显卡。
比如我们现在想要显示文字,先假设一次只显示一个字,那么具体怎么做才能知道这个功能的使用方法呢?
首先,既然是要显示文字,就应该看跟显卡有关的函数。这么看来,INT 0x10好像有点关系,于是在上面网页上搜索,然后就能找到以下内容(网页的原文为日语)。
显示一个字符
❏ AH=0x0e;
❏ AL=character code;
❏ BH=0;
❏ BL=color code;
❏ 返回值:无
❏ 注:beep、退格(back space)、CR、LF都会被当做控制字符处理
所以,如果大家按照这里所写的步骤,往寄存器里代入各种值,再调用INT 0x10,就能顺利地在屏幕上显示一个字符出来。
■■■■■
最后一个新出现的指令是HLT。这个指令很少用,会让它在第2天的内容里就登台亮相的,估计全世界就只有笔者了。不过由于笔者对它的偏好,就让笔者在这里多说两句吧(笑)。
HLT是让CPU停止动作的指令,不过并不是彻底地停止(如果要彻底停止CPU的动作,只能切断电源),而是让CPU进入待机状态。只要外部发生变化,比如按下键盘,或是移动鼠标,CPU就会醒过来,继续执行程序。说到这,请大家再仔细看看这个程序,我们会发现其实不管有没有HLT指令,JMP fin都是无限循环,不写HLT指令也可以。所以很少有人一开始就向初学者介绍HLT指令,因为这样只会让话变得很长。
然而笔者讨厌让CPU毫无意义地空转。如果没有HLT指令,CPU就会不停地全力去执行JMP指令,这会使CPU的负荷达到100%,非常费电。这多浪费呀。我们仅仅加上一个HLT指令,就能让CPU基本处于睡眠状态,可以省很多电。什么都不干,还要耗费那么多电,这就是浪费。即便是初学者,最好也要一开始就养成待机时使用HLT指令的习惯。或者说,恰恰应该在初学阶段,就养成这样的好习惯。这样既节能环保,又节约电费,或许还能延长电脑的使用寿命呢。
对了,HLT指令源自英文“halt”,意思是“停止”。
■■■■■
说了这么多,终于把这个程序从头到尾都讲完了。总结一下就是这样的:
用C语言改写后的helloos.nas程序节选
entry: AX = 0; SS = AX; SP = 0x7c00; DS = AX; ES = AX; SI = msg; putloop: AL = BYTE [SI]; SI = SI + 1; if (AL == 0) { goto fin; } AH = 0x0e; BX = 15; INT 0x10; goto putloop; fin: HLT; goto fin;
就是有了这个程序,我们才能够把msg里写的数据,一个字符一个字符地显示出来,并且数据变成0以后,HLT指令就会让程序进入无限循环。“hello, world”就是这样显示出来的。
■■■■■
对了,我们还没有说ORG的0x7c00是怎么回事呢。ORG指令本身刚才已经讲过,就不再重复了,但这个0x7c00又是从哪儿冒出来的呢?换成1234是不是就不行啊?嗯,还真是不行,我们要是把它换成1234的话,程序马上就不动了。
大家所用的电脑里配置的,大概都是64MB,甚至512MB这样非常大的内存。那是不是这些内存我们想怎么用就能怎么用呢?也不是这样的。比如说,内存的0号地址,也就是最开始的部分,是BIOS程序用来实现各种不同功能的地方,如果我们随便使用的话,就会与BIOS发生冲突,结果不只是BIOS会出错,而且我们的程序也肯定会问题百出。另外,在内存的0xf0000号地址附近,还存放着BIOS程序本身,那里我们也不能使用。
内存里还有其他不少地方也是不能用的,所以我们作为操作系统开发者,不得不注意这一点。在我们作为一般用户使用Windows或Linux时,不用想这些麻烦事,因为操作系统已经都处理好了,而现在,我们成了操作系统开发者,就需要为用户来考虑这些问题了。只用语言文字来讲解内存哪个部分不能用的话,不够清楚直观,所以还是要画张地图。
有一句话可是非常重要的,一定不能漏掉。
0x00007c00-0x00007dff :启动区内容的装载地址
程序中ORG指令的值就是这个数字。而且正是因为我们使用的是这个同样的数字,所以程序才能正常运行。
看到这,大家可能会问:“为什么是0x7c00呢? 0x7000不是更简单、好记吗?”其实笔者也是这么想的,不过没办法,当初规定的就是0x7c00。做出这个规定的应该是IBM的大叔们,不过估计他们现在都成爷爷了。
一旦有了规定,人们就会以此为前提开发各种操作系统,因此以后就算有人说“现在地址变成0x7000-0x71ff了,请大家跟着改一下”,也只是空口号,不可能实现。因为硬要这么做的话,那现有的操作系统就必须全部加以改造才能在这台新电脑上运行,这样的电脑兼容性不好,根本就卖不出去。
今后也许大家还会提出很多疑问:“为什么是这样呢?”这些都是当年IBM和英特尔的大叔们规定的。如果非要深究的话,我们倒是也能找到一些当时时代背景下的原因,不过要把这些都说清楚的话,这本书恐怕还要再加厚一倍,所以关于这些问题我们就不过多解释了。