程序员学习汇编代码的需求随着时间的推移发生了变化,开始时只要求程序员能直接用汇编语言编写程序,现在则要求他们能够阅读和理解编译器产生的代码。
下面是针对32位机器
数据格式
Intel用术语“字”(word)表示16位数据类型,32位数称为双字(double words),64位数称为四字(quad words),下面是C语言数据类型在32位机器上的字节长度:
大多数GCC生成的汇编代码指令都有一个字符后缀,表面操作数的大小,例如movb传送字节,movw传送字,movl传送双字,但是汇编代码也使用后缀l来表示8字节双精度浮点数,这不会产生歧义,因为浮点数使用的是一组完全不同的寄存器和指令。
访问信息
程序处理的信息存储在何处?存储器和寄存器。IA32的CPU中包含一组8个存储32位值得寄存器:
%esp为栈指针,%sbp为帧指针,其他6个为通用寄存器;32位寄存器的低16位为16位寄存器(%ax,%cx…%bp),同时(%al,%ah…%bh)为8个8位寄存器。
大多数指令有一个或多个操作数(oprand),指示出执行一个操作中要引用的源数据值,及放置结果的目标位置。操作数可分为三种类型:1)立即数(immediate),也即常数值;2)寄存器(register),表示某个寄存器的内容(寄存器没有地址,无法寻址),可用符号Ea表示任意寄存器a,用引用R[Ea]来表示它的值;3)存储器(memory)引用,根据计算出来的存储器地址访问某个存储器位置,因为将存储器看成是一个很大的数组,用符号Mb[Addr]表示岁存储在存储器中从地址Addr开始的b个字节值的引用。
寻址模式有如下几种:
数据传送指令
几点说明:
1)MOV类中指令将源操作数的值复制到目的操作数中,目的操作数不能是立即数,同时,传送指令的两个操作数不能都指向存储器位置。
2)pushl和popl是压栈和出栈操作,程序栈存在在存储器的某个区域,栈向下增长,即:栈顶元素的地址是所有栈中元素地址最低的。栈指针%esp保存着栈顶元素的地址。
算术和逻辑操作
几点说明:
1)leal指令时加载有效地址(load effective address)。我们会经常使用leal的变体,例如:leal 7(%edx, %edx, 4), %eax等价于%eax<--5x+7(设%edx中的值为x)。目的操作数必须是一个寄存器!
2)对于二元操作,两个操作数不能同时为存储器位置(同mov)。
3)移位操作分算术右移和逻辑右移。移位量用单个字节编码,只允许进行0到31位的移位。移位量可以是一个立即数,或者是放在单字节寄存器元素%cl中。
4)除了右移,上述指令既可以用于无符号运算,也可以用于补码运算。
5)关于两个32位数字的全64位乘积以及整数除法如下:
控制语句
C语言中的某些结构,如条件语句,循环语句和分支语句,要求有条件的执行,根据数据测试的结果来决定操作执行的顺序。
CPU中除了整数寄存器外,还维护着一组条件码寄存器(还有程序计数器和一组浮点寄存器存放浮点数),条件码寄存器指述了最近的算术或逻辑操作的属性,可以检测这些寄存器来执行条件分支指令。最常用的条件码有:1)CF:进位标志,可用来检测无符号操作数的溢出;2)ZF:零标志,最近操作得出结果为0;3)SF:符号标志,最近的操作得出结果为负;4)OF:溢出标志,最近操作导致一个补码溢出——正溢出或负溢出。
那么有哪些指令可设置条件码?
1)leal指令不会改变条件码,除此之外的所有算术和逻辑操作都会设置条件码;
2)对于逻辑操作,CF和OF会被设置为0;对于移位操作,CF会被设置为最后一个被移出的位,OF设置为0;INC和DEC会设置OF和ZF,但是不会改变CF;
3)有两类指令只设置条件码,而不会改变任何其他的东西:CMP和TEST。CMP的效果等同于SUB,但是不改变操作数;TEST效果等同于AND,如下:
怎么访问条件码呢?条件码通常不会直接读取,常用的使用方法有三种:1)可以根据条件码的某个组合,将一个字节设置为0或1;2)可以条件跳转到程序的某个其他部分;3)可以有条件地传送数据。
对于第一种情况,我们称之为SET指令:
需要注意的地方是:
1)D是8个单字节寄存器元素之一,或者是存储一个字节的存储器位置,将这个字节设置为0或1,为了得到32位结果,我们必须对最高的24位清0;
2)set的后缀并不是操作数大小,而是操作数条件。
3)关于判断有符号数大小关系和无符号数大小关系是不一样的,对于无符号数(a-b)来说,如果a<b,则有a-b<0,则有进位标志;如果a=b,则有零标志。所以,无符号数大小比较通过CF和ZF来判断。 对于有符号数(a-b)来说,如果a<b则有a-b<0,即为负数,或者有负溢出(a-b<-2w-1 ),此时a<0,b>0,a<b,同时a-b会大于0,故SF设为0。
对于第二种情况,我们称之为JUMP指令:
这里的条件跳转的判断同上,不再赘述。值得一提的是,在汇编程序中我们的跳转目标是标号(Label)。但是汇编器及链接器会产生跳转目标的适当编码。如何将跳转目的地编码,是一个常见的问题。最常用的都是PC相关的(PC即程序计数器)。它们将目标指令的地址与紧跟在跳转指令后面那条指令的地址之间的差作为编码(采用补码!)。为什么会是跳转指令后面那条指令的地址呢?这种惯例可以追溯到早期实现,当时的处理器会将更新程序计数器作为执行一条指令的第一步。
对于第三种情况,我们称之为条件传送指令(实际应用中,不会使用):
实现条件操作的传统方法是利用控制的条件转移:当条件满足时,沿一条执行路径进行,条件不满足时,JUMP。然而在现代处理器中,它可能是非常低效的。低效在哪儿呢?处理器通过流水线来获得高性能,要求流水线中充满了待执行的指令。当机器遇到条件跳转时,处理器会采用“分支预测逻辑”猜测每条跳转指令是否执行,如果发生了错误的预测,那么处理器就会丢掉它为该跳转指令后所有指令做了的工作,然后再开始从正确位置填充流水线。这样的错误预测会导致严重的惩罚。
与条件跳转指令不同,条件传送指令无需预测测试结果。条件传送指令会测试条件码的值,要么什么也不做,要么用源数据更新目的寄存器。例如 v=test-exp? then-expr:else-expr;对于条件传送指令来说是这样的:vt=then-expr; v=else-expr; if(test-expr) v=vt;
注意:
1)使用条件传送指令的时候,then-expr和else-expr都求值,无论测试结果如何。如果这两个表达式中的任意一个可能产生错误条件或副作用,就会导致非法行为。例如v=(*xp? *xp:0);即使xp为空指针的情况下,也会解引用;
2)由于then-expr和else-expr都求值,所以只有在表达式非常简单(最好只有一条指令)的情况下,条件传送指令才会有性能上的明显提高。根据经验,GCC不会使用条件传送指令。
控制语句的机器级表示
既然我们已经了解了访问条件码的三种类型(最常用条件跳转指令),下面我们来看看C语言中条件语句,循环语句和switch语句的编译代码:
C: 汇编形式: if(test-expr) t = test-expr;
then-statement if(!t)
else goto .false
else-statement then-statement
goto .done;
.false:
else-statement
.done:
do-while循环
C: 汇编形式:
do .loop:
body-statement body-statement
while(test-expr); t = test-expr;
if(t)
goto .loop
while 循环 C: 汇编形式:
while(test-expr) t=test-expr;
body-statement if(!t)
goto .done;
.loop
body-statement
t = test-expr;
if(t)
goto .loop;
.done:
for循环
C: 汇编代码:
for(init-expr; test-expr; update-expr) init-expr;
body-statement t = test-expr;
if(!t)
goto .done;
.loop:
body-statement
update-expr;
t = test-expr;
if(t)
goto .loop;
.done:
switch语句使用跳转表。
过程与栈
一个过程调用包括将数据(以过程参数和返回值的形式)和控制从代码的一部分传递到另一个部分,还必须在进入时为过程的局部变量分配空间,并在退出时释放这些空间。控制转移指令由机器给出,数据传递、局部变量的分配和释放通过程序栈来实现。
栈帧结构
为单个过程分配的那部分栈称为栈帧(stack frame)。当程序执行时,栈指针可以移动,因此大多数信息的访问都是相对于帧指针的。
注意图中,当前过程栈帧与调用者的栈帧。当过程P调用过程Q时,Q完成后的返回地址,Q所需要的参数都保存在P的栈帧里,也即:一个过程的栈帧包括:上个栈帧的帧指针;被保存的寄存及和局部变量;以及将要调用过程所需要的参数,和返回地址。
过程调用和返回指令
call指令是将返回地址压栈,并跳转到被调用过程的起始处;ret指令时从栈中弹出地址,并调转到这个地址。要正确使用这条指令,就必须要使栈做好准备——栈指针要指向call指令存储返回地址的位置。
寄存器使用惯例
程序寄存器是唯一能被所有过程共享的资源。我们要保证被调用者不会覆盖调用者稍后会使用的寄存器的值。所以IA32采用一组寄存器使用惯例:寄存器%eax、%edx和%ecx为调用者保存的寄存器,当P调用Q时,Q可以覆盖这些寄存器的值,而不用担心会破环P所需要的数据;寄存器%ebx、%esi,%edi为被调用保存的寄存器,这意味着Q必须在覆盖这些寄存器的值之前,先把她们保存在栈中,并在返回前恢复它们。
数据对齐
无论数据是否对齐,IA32硬件都能正确工作,不过Intel还是建议要对齐数据以提高存储器系统的性能,Linux沿用的对齐策略是:2字节数据类型的地址必须是2的倍数,而较大的数据类型的地址必须是4的倍数。windows的策略是:任何K字节基本对象的地址必须是K的倍数。