203-x86 指令简介
# 203-x86 指令简介
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 位,从而决定如何改变下一条指令的地址。 根据我们刚才那个程序所需要的功能, 如果 DEC 指令运算的结果为 0,我们希望不转移, 而如果运算的结果不为 0,那我们应该将下一条 指令的地址改为 LOOP1 那个标号所指向的指令的地址。 那么在这种情况下,我们就要从 这么多条件转移指令当中选择我们合适的指令。 根据刚才的分析,我们就应该选择这条 JNZ 指令, 它是在 ZF=0 的时候转移。
我们也注意到 x86 提供了很多种不同的条件转移指令,比如说有在 CF 为 1 的时候转移, 其实还有更复杂的条件,可以将多个标志位的组合作为转移的判断条件, 这样对于编程是非常方便的。 但同时我们也要想到 CPU 要提供这么多不同的条件转移的判断方式, 它内部的电路就会变得非常的复杂。 那我们还是回到这个程序,当 CL 寄存器的内容不为 0 的时候,说明这个数的累加工作还没有做完, 那我们会跳回到 LOOP1 的标号这里继续做下一次的累加, 直到某一次 CL 减到 0 了,那这个条件转移指令的条件不满足, 因此会继续执行后面的指令。
那我们发现后面还有三条指令, 那最后的这三条指令又是想做什么呢?这其实很简单, 就留给你来思考吧。 那最后一类就是控制类指令。 这里就包括我们刚才已经用过的 CLC 指令, 就是将 CF 标志位清零,还有一些对其他标志位的操作,以及其他一些对 CPU 进行控制的指令。 那现在我们就使用了这些简单的指令完成了这个累加两个数的程序。
即使是作为基础的 x86 指令也很难在短时间内一一介绍, 而且也没有那个必要。大部分指令还是非常容易理解和掌握的, 能够读懂最基础的代码就可以了。 至于那些复杂的变化,用到的时候再查手册也来得及。