x86指令种类繁多,数量庞大,在这一节我们将会学习x86指令的分类,并分析其中最为基础的一部分指令。通常一个指令系统主要包括这几类指令。运算指令,比如加、减、乘、除这样的算术运算,以及与、或、非这样的逻辑运算。还有传送类指令,比如把数据从存储器送到通用寄存器,或者从通用寄存器送到I/O接口等等。有了这两类指令,计算机就可以从外界获取数据,并在内部完成运算,最后将结果输出到外界。但是如果你想编制比较复杂的程序,例如像高级语言当中if else这样的语句,或者是for while这样的循环语句,那就需要用到转移类指令,另外还需要有一些对CPU进行控制的指令。那无论是哪一类指令,我们首先要关心的就是它究竟改变了什么。例如一条加法指令,它会改变通用寄存器的内容,或者有可能改变标志位,再有是改变存储器单元的内容,或者改变外设端口的内容,还有可能改变指令指针以及其他的情况。那我们在学习到新的指令的时候,一定要认真地想清楚这条指令究竟改变了哪些地方,又对后续的指令会产生什么样的影响。
现在我们就通过一个示例程序来讲解几个常用的指令。这个程序的目的是进行两个数的求和运算,这两个数比较大,可能有很多个字节,第一个数存放在2000H开始的存储器空间中,第二个数存放在3000H开始的存储器空间中。而且我们希望这个程序有一定的灵活性,可以适应不同长度的数。这两个数的长度存放在2500H这个字节单元里。那我们就可以假设存储器单元中存放的数的情况是这样的,从2000H开始,若干个字节存放在第一个数,3000H开始的若干个字节存放在第二个数,而2500H地址对应的这个字节则告诉我们这两个数有多长。从这里我们可以看出是16进制的12H,也就是十进制的18。现在这个程序已经编写完毕,那我们就来一起逐条指令地分析这个程序。首先我们要用到的是传送类指令,传送类指令的作用是把数据或者地址传送到寄存器或者存储器单元中。这些是x86指令系统当中常用的传送类指令,那我们就来看一看第一条MOV指令。MOV指令带两个操作数,第一个是目的操作数,第二个是源操作数。这条指令所做的操作,就是将源操作数中的内容传送到目的操作数中。看似简单,但实际上这条指令的格式有非常丰富的变化。例如这条MOV指令就是将40这个数送到EBX寄存器当中,那对于它的源操作数来说,就是使用了直接给出操作数的方式,这个操作数的值就会体现在指令编码中,CPU在从存储器取址的时候,就会将40这个数作为指令编码的一部分取回来,然后就可以直接将这个数送到EBX寄存器中了。第二条MOV指令是将BL寄存器的内容传送到AL寄存器中,那对于源操作数来说,这里给出的是存放操作数的寄存器的名称。第三条MOV指令则是将1000H所指向的存储器单元中的内容取出,传送到ECX寄存器中。这里源操作数则是给出了存放操作数的存储器的地址。第四条MOV指令是将AX寄存器中的内容传送到存储器的某个单元,这个单元的地址是存放在DI寄存器中,所以在执行这条指令的过程中,CPU需要先从DI寄存器当中取出一个数,把这个数作为访问存储器的地址,再从AX当中取出一个数,作为访问存储器的数据,再执行写存储器的操作。因此对于这个目的操作数,是给出了一个寄存器的名称,而这个寄存器当中存放了操作数的存储器地址。最后是一个更复杂的情况,这里给出的是存放操作数的存储器地址的寄存方法。这里WORD PTR这个关键词所表明的意思是这个内存地址指向的是长度为1个字的内存单元,也就是两个字节。那我们要把01H这个数存放到这个内存单元当中去,而计算这个内存单元地址的过程是这样的。CPU要从SI寄存器当中取出一个数,并将它乘以2,然后从BX寄存器当中取出一个数,二者相加,还要再加上200H这个数。在完成了这么多次运算之后,我们才能得到这个存储器的地址,然后才能发起存储器写的操作,将01H这个数送到指定的内存单元当中去。
这几个例子我们可以看出,x86提供了非常丰富的访问存储器的方法,这为编写程序带来了很大的便利,但这也让CPU的设计变得非常地复杂。那我们再来看看MOV指令的编码,这就是一条MOV指令,它有3个字节。第一行是这3个字节的含义,第二行是我们举的一个例子的具体的编码,那这个编码实际上是MOV AX ,10EEH这条指令。我们可以发现第2个字节和第3个字节就是这条指令当中的这个立即数10EE。而第1个字节的最后3个比特是指定了寄存器的编号,000代表是AX,而前面1011则是代表了这个类型的MOV指令。因此CPU取回这条指令编码就知道是将后面这两个字节的内容写入到AX这个寄存器当中。我们再来看另一条MOV指令。这条MOV指令执行的是一个存储器到寄存器的传送,这个存储器的地址是由BX寄存器的内容和立即数1004H相加得到的,那我们在指令编码当中,也能找到1004这个立即数,还有在寄存器这个位域所对应的能看到011,这是BX寄存器的编号。那这条MOV指令比上面这条要复杂一些,所以它是4个字节的。从这里我们也可以看出,x86指令系统是一种变长的指令,它可以根据需要设定指令编码的长度,这样就比较灵活。但是从另一个方面来看,这对CPU取指令的操作会带来很多的麻烦。因为CPU在取到这条指令之前,它无法判断这条指令究竟由几个字节组成,取得少了,那指令编码不全,无法执行;取得太多,又会浪费时间,还会多占用CPU内部的空间。这就是变长指令的不利之处。
那我们还是回到这个程序的例子,前三条都是MOV指令,第一条是将2500H这个内存地址中的内容传送到CL寄存器中,这个内存地址当中保存的是我们要运算的数的长度。第二条是将2000H这个立即数传送到SI寄存器中。第三条是将3000H这个立即数传送到DI寄存器中。这样SI和DI这两个寄存器就分别保存了我们要计算的这两个数的起始地址。接下来我们就可以开始运算了,这就需要用到运算类指令。运算类指令包括逻辑运算指令,移位指令,还有算术运算指令。我们就选择加法指令为例进行介绍。这里有三条加法指令,第一条是ADD指令,它有两个操作数,所做的操作是将这两个操作数中的内容相加,并将结果存放到第一个操作数当中去,这里前两条就是ADD指令的示例,这我们应该比较熟悉了,就不再详细介绍。另外一条特殊的加法指令是INC指令,这条指令只有一个操作数,它要做的就是将这个操作数加1。就像这个例子,就是把CL寄存器当中的数加1,结果还保存在CL寄存器中。INC指令的功能很简单,它的指令编码也很短,这条指令只需要一个字节,是最短的x86指令之一。那加1其实就是加法的一种特殊的情况,为什么要单独设一种指令呢?从这里我们也可以看出x86的设计思想。因为在程序当中,我们经常会进行每次加1的计数的操作,那为这种常见的情况设计一种很短的指令,就可以大大减小程序代码的长度,这在存储空间非常有限的情况下,是非常有意义的。那第三个加法指令是ADC指令,就是带进位的加法,ADC指令看上去也只有两个操作数,但实际它的加法运算是将这两个操作数相加,再加上CF标志位,运算的结果放回到第一个操作数中去。
我们结合这个模型机来看一看。对于一般的加法指令,ADD指令会用到ALU,如果这个加法运算产生了进位,就会去改写标志寄存器当中的CF位。而如果当前执行的是ADC指令,那标志寄存器当中的CF位也会被送到ALU参与运算,这样之前的运算指令的结果实际就影响了现在这条加法指令。当然ADC指令的进位也同样会影响标志寄存器当中的CF标志位。所以我们要记住ADD指令和ADC指令都会根据自己的运算结果来改变标志寄存器当中的CF位。而ADC指令还会将CF标志位的值加入到运算当中。那我们接着来看这段程序,这条MOV指令是将SI寄存器所指向的内存单元的数传送到AX寄存器中,也是将第一个数的第一个字,注意是两个字节,传送到AX寄存器当中,然后用ADC指令将AX寄存器当中的内容和DI所指向的内存单元中的内容,也就是第二个数的头两个字节相加,结果还保存在AX寄存器中。然后再将AX寄存器中的内容传送到SI所指向的内存单元。那我们要注意这里用的是ADC指令,为什么要用这条指令呢?实际上过一会我们还会跳回到这里反复地执行这段指令,从而将这两个很长的数累加起来。因此在累加的过程中,低位相加如果产生了进位,我们就得让这个进位传递到下一次的加法当中,这样运算结果才不会发生错误,但我们还要注意第一次加这两个数的最低字节的时候,本来是不应该带上进位的,所以我们得提前把CF标志位清零。
这里就用到了一条CPU的控制指令CLC,它的作用就是把标志寄存器当中的CF位清零。这样我们就完成了第一个字的累加。然后我们执行了两次INC指令去递增SI寄存器,然后用两个INC指令递增了DI寄存器,这就为下一轮的累加做好了准备。
不过这里有一个小问题,我们是否可以用ADD SI 2这样一条指令来代替这两条INC指令?是否可以就留给你来思考。那做好了准备之后,我们就应该想办法跳回到前面的指令,继续进行累加的操作,这就会用到转移类指令。转移类指令的作用是改变指令的执行顺序。我们现在要用到的是条件转移指令,而且是直接转移。这里我们首先执行了DEC指令,这条指令的操作是将CL寄存器的内容减1,那CL寄存器中存放的是这个数的长度,将它减1就说明我们已经完成了其中一个字的累加工作。那如果减完之后,CL寄存器当中的值不为0,这就说明我们还需要继续累加。那这时就应该跳转到LOOP1这个标号继续执行,这个操作就是由这条JNZ指令完成的。这是一条条件转移指令,它所检查的条件就是之前指令的运算结果是否为0,其实准确地说,它并不是真的去检查之前一条指令的 运算,而是去检查标志寄存器当中的标志位。标志寄存器当中有一个ZF标志位,如果DEZ指令的运算结果为0,就会将ZF标志位置为1,代表这次运算的结果为0,否则就会把ZF标志位置为0。
从模型机上来看,当执行刚才那条JNZ的转移指令时,CPU会来检查标志寄存器当中的ZF位,从而决定如何改变下一条指令的地址。根据我们刚才那个程序所需要的功能,如果DEZ指令运算的结果为0,我们希望不转移,而如果运算的结果不为0,那我们应该将下一条 指令的地址改为LOOP1那个标号所指向的指令的地址。那么在这种情况下,我们就要从这么多条件转移指令当中选择我们合适的指令。根据刚才的分析,我们就应该选择这条JNZ指令,它是在ZF=0的时候转移。我们也注意到x86提供了很多种不同的条件转移指令,比如说有在CF为1的时候转移,其实还有更复杂的条件,可以将多个标志位的组合作为转移的判断条件,这样对于编程是非常方便的。但同时我们也要想到CPU要提供这么多不同的条件转移的判断方式,它内部的电路就会变得非常的复杂。那我们还是回到这个程序,当CL寄存器的内容不为0的时候,说明这个数的累加工作还没有做完,那我们会跳回到LOOP1的标号这里继续做下一次的累加,直到某一次CL减到0了,那这个条件转移指令的条件不满足,因此会继续执行后面的指令。那我们发现后面还有三条指令,那最后的这三条指令又是想做什么呢?这其实很简单, 就留给你来思考吧。
那最后一类就是控制类指令。这里就包括我们刚才已经用过的CLC指令,就是将CF标志位清零,还有一些对其他标志位的操作,以及其他一些对CPU进行控制的指令。那现在我们就使用了这些简单的指令完成了这个累加两个数的程序。即使是作为基础的x86指令也很难在短时间内一一介绍,而且也没有那个必要。大部分指令还是非常容易理解和掌握的,能够读懂最基础的代码就可以了。至于那些复杂的变化,用到的时候再查手册也来得及。
Reference:北京大学陆俊林老师计算机组成原理课程
Notice:如有侵权,请告知我,我会删除,谢谢!