下图列出了一些双字整数操作,分为四类。二元操作有两个操作数,而一元操作只有一个操作数。描述这些操作数的符号与3.4节中使用的符号完全相同,除了leal以外,每条指令都有对应的对字和对字节操作的指令。把后缀l换成w就是对字的操作,换成b就是对字节的操作。例如,addl对应有addw和addb。
这里面比较特别的指令就是leal(取地址指令),其余的指令都是比较常规的算术和逻辑运算,相比之下还比较好理解,因此LZ这里重点介绍leal指令,对于其余的指令LZ不会一一介绍,接下来我们就认识一下这个特别的leal指令吧。
leal指令是非常神奇的一个指令,它可以取一个存储器操作数的地址,并且将其赋给目的操作数。如果用C语言当中来对应的话,它就相当于&运算。
比如对于leal 4(%edx,%edx,4),%eax这条指令来讲,我们假设%edx寄存器的值为x的话,那么这条指令的作用就是将 4 + x + 4x = 5x + 4赋给%eax寄存器。它和mov指令的区别就在于,假设是movl 4(%edx,%edx,4),%eax这个指令,它的作用是将内存地址为5x+4的内存区域的值赋给%eax寄存器,而leal指令只是将5x+4这个地址赋给目的操作数%eax而已,它并不对存储器进行引用的值的计算。
为了更好的表示这条指令的效果,LZ这里简单的画个图来表示这一过程。我们假设下图是执行指令之前,寄存器和存储器的状态。
可以看到,此时在存储器中,地址为5x+4的区域的值为1000。那么此时若是进行movl 4(%edx,%edx,4),%eax操作,很显然,%eax的值应该为1000,也就是下图。
但是如果进行leal 4(%edx,%edx,4),%eax操作的话,%eax的值就不是1000了,因为leal指令不会去取存储器当中的值,因此寄存器%eax的值应该是5x+4。
试想一下,倘若在地址为5x+4的位置存储的是变量i,那么其实这条指令就相当于&i操作,这也就是C语言当中的&取地址操作的汇编级做法。
int arith(int x, int y , int z){
int t1 = x+y;
int t2 = z*48;
int t3 = t1&0xFFFF;
int t4 = t2*t3;
return t4;
}
这里面包含了加、乘、与运算,我们使用-O1和-S参数编译sum.c这个文件,使用cat sum.s查看它,会得到如下的汇编代码。
.file "sum.c"
.text
.globl arith
.type arith, @function
arith:
pushl %ebp
movl %esp, %ebp
//以上为栈帧建立
movl 16(%ebp), %eax
leal (%eax,%eax,2), %edx
sall $4, %edx
movl 12(%ebp), %eax
addl 8(%ebp), %eax
andl $65535, %eax
imull %edx, %eax
//以下为栈帧完成
popl %ebp
ret
.size arith, .-arith
.ident "GCC: (Ubuntu 4.4.3-4ubuntu5.1) 4.4.3"
.section .note.GNU-stack,"",@progbits
这里面还有leal指令,可以看到程序当中并没有取地址&操作,所以这里的leal指令不是用来取地址的,LZ使用图示来给各位演示这个程序的运行过程。首先便是栈帧的建立过程,栈帧建立好以后,寄存器和存储器的状态如下所示。
以上便是建立好的栈帧,同上一次一样,帧指针和栈指针都指向一个新的位置,在帧指针偏移量为8、12、16的地方存储着传递进来的参数x、y、z。接下来我们就开始分析,在汇编代码层次,是如何完成上述C语言程序当中的一系列动作的。
首先是一个mov指令,它的作用很简单,就是将参数z取入寄存器,下面是它的汇编代码以及图示。
movl 16(%ebp), %eax
上面的指令比较简单,接下来的这条指令就比较特别了,是一条leal指令。这里的leal指令不是用来取地址的,而是用来进行乘法运算的,它的目的是将%eax寄存器当中的值乘以3,然后发送至%edx寄存器。而采用的方式则是2*x + x的方式,这正是我们之前讲过的乘法优化算法,使用移位和加法来计算乘法。接下来看看它的指令与图示。
leal (%eax,%eax,2), %edx
上面计算3z的目的,在接下来这一条指令就看出来了。接下来的一条指令是sal左移操作,位数为4,左移4位其实就相当于乘以16,因此接下来的一条指令其实就相当于将寄存器%edx当中的值乘以16,这其实刚好是在计算48*z。从这里也可以看出来,在执行C程序的时候,并不一定会按照程序当中的顺序去计算。以下是sal指令的内容与图示。
sall $4, %edx
接下来的指令依然是简单的取参数y,因此LZ这里就不再多解释了,直接上内容和图示。
movl 12(%ebp), %eax
下面的一条指令是add加法指令,它是将左边操作数的值加到右边的目的操作数。也就是将内存地址为8(%ebp)的值加到%eax寄存器,而8(%ebp)这个位置存的刚好是x,因此这里计算的便是x+y的值,而结果会存入%eax寄存器。以下是指令的内容和图示。
addl 8(%ebp), %eax
接下来是一条与运算指令and,它计算的则是t1与0xFFFF(十进制就是65535)的与运算,t1的值为x+y,此时就存在%eax寄存器。我们来看下这条指令的内容与图示。
andl $65535, %eax
接下来是最后一个计算过程的指令imul乘法指令,它的作用也是将左边操作数的值乘到右边的目的操作数上。也就是将%edx寄存器的值乘到%eax寄存器上面去,而%edx此时的值为48*z(也就是t2),而%eax的值为(x+y)&0xFFFF(也就是t3),两者相乘则得到t4的值,结果将存在%eax寄存器,并且作为返回值返回。以下为内容与图示。
imull %edx, %eax
到此,我们整个计算过程就结束了,其中用到了一些算术与逻辑运算指令,其实它们并没有什么难度,相信各位在LZ的图示解释下,应该也不难明白。最后则是栈帧的完成部分,以下为当前帧释放后的状态。
在这里LZ提一点,各位猿友估计也注意到了,每次在%ebp偏移量为4的位置都是空着的,而参数都在8、12、16这样的位置,难道偏移量为4的位置是空的吗。这里其实不是空的,它存储的是返回地址,只是LZ这里为了简化理解。
第二类操作是一元操作,只有一个操作数,既做源,也做目的。这个操作数可以是一个寄存器,也可以是一个储存器位置,比如说,incl(%esp)会使得栈顶元素加一。
第三类是二元操作,第二个操作数既是源又是目的,这种语法想c中的+=的赋值运算。例如,指令subl %eax,%edx是的寄存器%edx的值减去%eax的值。第一个操作数可以是立即数,寄存器或是存储器位置。第二个操作数可以是寄存器或是储存器位置。不过同movl指令一样,两个操作数不能同时都是存储器位置
最后一类是移位操作,先给出移位量,然后是待移位的值。可以进行算术和逻辑右移。移位量用单个字节编码。移位量可以是一个立即数,或者放在单字节jicunq元素%cl中。左移指令有两个名字,sall和shll,两者的效果都一样,都是将右边天上0.左移指令不用,sarl执行算术移位(t填上符号位),而shrl执行逻辑移位。
示例
假设我们生成这个C函数的汇编代码
int shift_left2_rightn(int x,int n)
{
x<<=2;
x>>=n;
return x;
}
下面这段代码执行实际的移位,并将最后的结果放在寄存器%eax中,参数x和n分别存放在储存器中相对于寄存器%ebp中地址偏移8和12的地方
1 movl 12(%ebp),%ecx
2 movl 8(%ebp),%eax
3 ____________
4 ____________
看书
我们先来看看这些指令的大致介绍,如果各位看过上一章的话,会发现这里的指令有的会有些眼熟,但是它们的作用却截然不同。以下是书中的一张概图。
第一个指令有些眼熟吧,它就是我们上一章当中的imul乘法指令的双字形式。不过可以看出,这里的imull指令已经完全变了味道,它将结果存入两个寄存器。接下来,我们来仔细看看这些指令。
这两个指令一看就是双胞胎,它们一个负责有符号全64位乘法,一个负责无符号全64位乘法。细心的猿友会发现,imull这个指令好像是负责乘法的指令,而且在之前的乘法并没有区分有符号和无符号,现在怎么又成双胞胎指令了。
我们上一章当中出现的指令是imul指令,当它操作双字的时候,也就是imull指令。不过不同的是,它的一般形式是imul S D,这里有两个操作数,它将计算S和D的乘积并截断为双字,然后存储在D当中。由于在截断时,无符号以及有符号的二进制序列是一样的,因此此处的乘法指令并不区分有符号和无符号。
本次我们讨论的imull指令,则与上面的普通乘法指令稍有不同,它只有一个操作数,也就是说,它的一般形式为imull S,这点在书中的表格中也能看出来,而另外一个操作数默认为%eax寄存器。最终的结果,会将高32位存入%edx寄存器,而低32位存入%eax寄存器。
试想一下,如果我们只取%eax寄存器当中的32位结果,那其实这里计算的结果就是S*%eax,此时imull S的作用就与imull S D是一样的,只是目的操作数被固定为%eax罢了。
接下来我们看一个简单的示例,我们去看下指令imull $0x3的结果,我们假设此时%eax寄存器的值为0x82345600。也就是我们需要计算0x30x82345600的值,这里LZ直接给出两者相乘的16进制表示,各位有兴趣的可以私下乘一下,为0xFFFF FFFE 869D 0200。这个结果为64位的,因此我们寄存器的前后状态如下所示
以看到,%eax保存着低32位的结果,单说这32位的话,它的有符号数值为-2036530688,正是我们直接计算0x30x82345600的32位截断后的有符号值,显然这个结果溢出了。如果组合上高32位,则结果为-6331497984,将它加上或者取模4294967296(2的32次方)将得到我们32位的结果。这里的有符号乘法采取的是先符号扩展被乘数,然后两者相乘,将结果再截断为64位所得。
对于mull的单操作数指令来讲,就比较简单了,它采用的是无符号乘法,因此就和我们平时的十进制乘法运算类似,只是同样的,它也会将结果的高32位存入%edx,将低32位存入%eax。
这个指令相对来说就非常简单了,它就是简单的将%eax寄存器的值符号扩展32位到%edx寄存器,也就是说,如果%eax寄存器的二进制序列的最高位为0,则cltd指令将把%edx置为32个0,相反,如果%eax寄存器的二进制序列最高位为1,则cltd指令将会自从填充%edx寄存器为32个1。
这两个指令与前面的imull以及mull类似,它也将计算结果存放在两个寄存器当中,其中余数存放在%edx寄存器,商存放在%eax寄存器。如果各位理解了前面的imull以及mull,那么这里idivl和divl理解起来会非常简单。
这里LZ举一个简单的例子,考虑指令idivl $0x3的结果,我们假设此时%eax寄存器的值为0x82345600。也就是我们需要计算0x82345600/0x3的值,这里LZ直接给出两者相除的16进制表示,各位有兴趣的也可以私下除一下,商为0xD6117200,余数为0x0。因此我们寄存器的前后状态如下所示。
可以看到,在idivl这个指令执行的过程中,其实对被除数进行了符号扩展,类似于cltd指令,或者有时也会将%eax移动到%edx,然后对%edx进行算术右移31位的运算。这两种方式的结果是一样的,都是将%eax符号扩展32位并存储在%edx当中。