深入理解计算机系统(3.3)---数据传送(或者说复制)指令详解

引言

  上一章我们已经介绍了汇编语言的基础部分,包括数据格式、寄存器以及操作数的标识方式,接下来我们就应该去认识一下汇编语言当中的各个指令了。这些指令大多数都非常简单,但是组合在一起却能模拟出我们程序当中想要的任何效果,确实是十分神奇的一件事。

数据传送指令

  数据传送指令的目的是为了将一个数据从一个位置复制到另外一个位置。既然如此,那么数据传送指令就会包含一个源操作数和一个目的操作数,指令会将原操作数的值复制到目的操作数并覆盖。

  数据传送指令一共可分为五种,分别是mov、movs、movz、push以及pop,下面LZ依次介绍一下这五个指令的作用。

mov指令

  mov指令的作用是将源操作数S中的数据复制到目的操作数D中,mov指令有一个数据格式和两个操作数,因此一般的形式为[movx S D]。其中x为数据格式,S为源操作数,D为目的操作数。

  这里举一个简单的例子,比如我们有一条指令为movl %edx %eax。那么它的执行过程就如下图所示。

  可以看到,在指令执行之后,%edx寄存器当中的内容会被复制到%eax寄存器。需要一提的是,mov指令可以在后面加上任何数据格式,比如上面这一过程中,数据格式则为四个字节,也就是双字。因此不难推断出,我们还可以使用movb和movw去复制一个字节或者两个字节。

movs指令

  movs指令的作用是将源操作数S中的数据做符号扩展后,再复制到目的操作数D中,movs指令有两个数据格式和两个操作数,因此一般的形式为[movsxy S D]。其中x、y为数据格式,S为源操作数,D为目的操作数。其中x、y的组合一共有三种,分别是bw、bl、wl,这三个组合代表的意思分别是单字节到双字节,单字节到双字以及双字节到双字。

  这里LZ依然举一个例子,对于指令movswl %dx %eax来讲,它的作用如下图所示。

  这里为了可以看出符号位的扩展,因此LZ这里使用了十六进制的整数表示方式。可以看到,movs指令将0x8FFF扩展以后存入%eax寄存器,其中%dx为寄存器%edx的后16位表示。

movz指令

  movz指令的作用是将源操作数S做零扩展后,再复制到目的操作数中。它与movs指令十分相似,也有两个数据格式和两个操作数,因此一般的形式为[movzxy S D]。其中x、y为数据格式,S为源操作数,D为目的操作数。其中x、y的组合一共有三种,分别是bw、bl、wl,这三个组合代表的意思分别是单字节到双字节,单字节到双字以及双字节到双字。

  这里依然采用相似的示例,我们来看看对于指令movzwl %dx %eax来讲,它的作用与上面的movs有何不同。

  可以看出,movz与movs指令是十分相似的,只是这里扩展后,目标寄存器%eax的前16位为0而不再是1。

push指令

  push指令与上面的mov族指令有着不同,它的目的操作数被固定为栈顶,因此它的指令当中没有目的操作数。另外有一点需要注意的是,它在进行复制操作之前,需要移动栈顶指针(-4)。push指令的一般形式为[pushl S],其中l代表数据格式为双字,S为源操作数,目的操作数默认为栈顶。

  这里LZ举一个简单的例子,比如pushl %edx这条命令,它的任务是将%edx寄存器的值复制到栈顶。我们首先来看一下命令执行前,寄存器以及存储器的状态。

  可以看到,寄存器%ebp和%esp分别指向帧指针和栈指针,而%esp实际上就是指向的栈顶。由于现在栈顶位于-16的位置,因此若要将%edx压入栈,则先需要将栈顶移动到-20的位置,然后再进行复制,移动后的状态如下图所示。

  可以看到,这里栈指针的位置已经发生了变化,向下移动了四位,并且将%edx寄存器的值放入新的栈顶,因此pushl %edx指令就相当于下面两条指令。

                  subl $4,%esp

                  movl %edx,(%esp)

  这里可以看出来,其实pushl指令做了一个隐藏操作,就是移动栈指针(-4),这一点希望各位猿友们注意。

pop指令

  pop指令与push指令是做的相反的操作,一个是入栈一个是出栈。对于pop指令来讲,它的源操作数被固定为栈顶,相反,它会先进行复制操作,然后再移动栈指针。pop指令的一般形式为[popl D],其中l代表数据格式为双字,D为目的操作数,源操作数默认为栈顶。

  接下来我们举一个例子,与上面的例子类似,我们考虑popl %edx这条指令的效果,它会将栈顶的值弹出到寄存器%edx。首先来看执行之前,寄存器以及存储器的状态。

  接下来执行pop指令时,会先将栈顶的值复制到%edx,然后再将栈指针移动(+4)。我们来看一下它执行后的状态。

  可以看到,之前栈顶的内容已经被弹出到%edx寄存器,并且当前栈顶已经移动到了-16的位置,也就是进行了+4操作。因此popl %edx指令就相当于下面两条指令。

                  movl (%esp),%edx

                  addl $4,%esp     

  这里可以看出来,其实popl指令也同样做了一个隐藏操作,就是移动栈指针(+4)。

数据复制示例

  上面我们已经了解了几乎所有的数据复制指令,接下来我们写一小段程序,来看下这些数据复制指令,如何完成我们的程序操作。

代码语言:javascript
复制
simple(int *xp,int y){
    int t = *xp;
    *xp=y;
    return t;
}

  上面是一个简单的C程序sum.c,它其中包含了一些赋值操作,我们来看看它的汇编代码。使用GCC -O1 -S sum.c来获取我们的汇编代码,并使用cat sum.s来查看一下。

代码语言:javascript
复制
    .file    "sum.c"
    .text
.globl simple
    .type    simple, @function
simple:
    pushl    %ebp
    movl    %esp, %ebp
    //以上为栈的建立部分
    movl    8(%ebp), %edx
    movl    (%edx), %eax
    movl    12(%ebp), %ecx
    movl    %ecx, (%edx)
    //以下为栈的完成部分
    popl    %ebp
    ret
    .size    simple, .-simple
    .ident    "GCC: (Ubuntu 4.4.3-4ubuntu5.1) 4.4.3"
    .section    .note.GNU-stack,"",@progbits

  分析这段汇编代码的时候,我们应该分为三个部分来看待,首先是栈的建立、然后是使用、最后是完成部分。可以看到,里面几乎全是数据复制指令,我们先来看看栈的建立部分。

  其实对于一开始pushl和movl指令来讲,它主要做了两件事。第一个是将原来的帧指针备份到栈顶,然后再将帧指针和栈指针统一指向这个新的栈顶,也就是完成了一个新栈的建立。它在完成后,栈的状态如下所示。

  可以看到,寄存器%ebp和寄存器%esp都指向当前帧指针的位置,其中变量xp位于+8的位置,而y位于+12的位置。由于xp是一个指针变量,因此它会指向一个内存中的区域,其中的值为*xp。

  了解完寄存器和存储器的状态,此时栈已经建立完毕,接下来我们看紧接着的一句汇编代码的作用。

代码语言:javascript
复制
    movl    8(%ebp), %edx

  这一句将内存地址为%ebp+8的值复制到%edx,很明显,从上面的图中可以看出,%ebp+8这个位置存储着xp变量。这一句指令做了一个简单的操作,就是将xp提取到%edx寄存器,如下所示。

  此时已经将%edx的值改为了变量xp,看接下来的一句操作。

代码语言:javascript
复制
movl (%edx), %eax

  这一句将内存地址为%edx的值赋给寄存器%eax,并准备返回值。此时%edx寄存器的值已经改为了xp变量,因此(%edx)其实就是*xp,而%eax寄存器一般会作为函数的返回值,因此它其实替代了临时变量t。执行后的状态如下所示。

  此时其实已经完成了程序中的int t = *xp以及为return t准备好了返回值,接下来的一句汇编代码作用也非常简单,如下。

代码语言:javascript
复制
    movl    12(%ebp), %ecx

  它的作用是将地址为%ebp+12的值复制到寄存器%ecx,从图中可以看出,%ebp+12就是存储的变量y。因此它的作用就是将y复制到寄存器%ecx,如下所示。

  上面这一步挺简单,我们来看最后一步操作,如下。

代码语言:javascript
复制
    movl    %ecx, (%edx)

  它的作用是将%ecx寄存器的值复制到内存中%edx的位置。此时%ecx的值为y,而%edx中为xp,因此目的操作数则为xp指向的位置,也就是*xp。这一句话执行的就是程序代码当中,*xp=y这个操作,它执行后的状态如下所示。

  可以看到,在执行了*xp=y以后,xp指针所指向的位置,其值已经变为了y。此时程序其实已经基本运行完毕,剩下的工作也就是栈的完成操作了,也就是popl指令。在栈完成之后,也就是pop指令执行之后,当前帧会恢复到调用者的帧上面去,如下所示。

  此时当前帧已经恢复到了调用者的帧,最后ret指令会改变程序计数器(PC)的值,然后跳出子函数,继续执行调用者当中的代码。到此,我们的数据复制示例就结束了,尽管这个例子并不难,但是麻雀虽小五脏俱全,如果理解了这个过程,相信就算是再复杂一些的汇编指令,也只是分析的时间长一点罢了。

文章小结

  本章内容比较长,LZ为了便于理解,插入了不少图,希望对各位有帮助,不过这些图是真不好画,浪费了LZ不少时间。本次主要介绍了一些数据复制指令,都是复制来复制去的,下一章我们将讨论一下有关计算的指令,那里会有一些加减乘除、移位、取地址等操作。