程序的机器级表示
数据格式:大多数汇编指令都有一个字符的后缀,表明操作数的大小。
字符后缀 b 表示一个字节,字符后缀 w 表示一个字(两个字节),字符后缀 l 表示2个字(4个字节),字符后缀 q 表示4个字(8个字节)
操作数的可能性被分为三种类型,第一种类型是立即数,用来表示常数值;第二种类型是寄存器,它表示某个寄存器中的内容;第三种类型是内存引用,它会根据计算出来的地址访问某个内存位置。
对于第三种类型的操作数,通常有多种形式的寻址模式来指向内存中的某个地址。然而需要注意的是,基址寄存器和变址寄存器都必须是64为的寄存器,并且比例因子必须是1、2、4、或者8。
数据传输指令MOV:这些指令把数据从源位置复制到目的位置,不做任何变化。但是有两条限制条件:
- 传送指令中的不能都指向内存位置。如果要将一个值从一个内存位置复制到另一个内存位置就必须使用两条传送指令。
- 寄存器部分的大小必须与指令的字符后缀指定的大小匹配。
MOV指令的使用格式为MOV DST, SRC
在将较小的源值复制到较大的目的时需要使用数据扩展传送指令。这样的指令分为两种,一种(MOVZ)是把目的中剩余的字节填充为0,而另一种(MOVS)则是通过符号位来填充。
需要注意的是,当movl指令以寄存器作为目的时,它会把该寄存器的高4字节设置为0。此外指令 movabsq 能够以任意64位立即数值作为源操作数,并且只能以寄存器作为目的。
压栈和出栈指令:pushq S,指令的效果是,先将%rsp中的值减8,然后再将S指定的值存储到%rsp所指向的内存(栈顶)中。
popq D,指令的效果是,先将%rsp中指向的内存地址中的内容复制到D中,然后%rsp中的值加8.
算术与逻辑操作:
一元操作指令:
INC D,效果是:D+1->D
DEC D,效果是:D-1->D
NEG D,效果是:-D->D
NOT D,效果是:~D->D
二元操作指令:
ADD S,D,效果是:D+S->D
SUB S,D,效果是:D-S->D
IMUL S,D,效果是:D*S->D
XOR S,D,效果是:D^S->D
OR S,D,效果是:D|S->D
AND S,D,效果是:D&S->D
SAL k,D,效果是:D<<k->D
SHL k,D,效果是:D<<k->D
SAR k,D,效果是:D>>Ak(算术右移)
SHR k,D,效果是:D>>Lk(逻辑右移)
特殊的算术操作:
imulq S、mulq S:一个是补码乘法,一个是无符号乘法。这两条指令都要求一个参数必须在寄存器%rax中,而另一个操作数作为指令的源操作数给出。然后乘积的高64位放在%rdx中,低64位放在%rax中。
idivq S、divq S:一个是补码乘法,一个是无符号乘法。这两条指令将寄存器%rdx(高64位)和%rax(低64位)中的128位数作为被除数,而除数作为指令的操作数给出。然后将商存放在寄存器%rax中,将余数存储在寄存器%rdx中。通常,寄存器%rdx会事先设置为0.
clto S:转换为八字
条件控制:
条件控制码:
CF:进位标志,最近的操作是最高为产生了进位时等于1,用于检查无符号操作的溢出。
ZF:零标志,最近的操作得出结果为0时等于1。
SF:符号标志,最近的操作得到的结果为负时等于1。
OF:溢出标志,最近的操作导致一个补码溢出——正溢出或负溢出。
除了上述的逻辑运算与算术运算会改变条件码外,下面两条指令也能改变条件码:
CMP S1, S2,根据两个操作数之差来设置条件码。
TEST S1, S2,该指令的行为与AND一样,但是这条指令只设置条件码而不会改变目的寄存器的值。典型的用法为testq %rax, %rax,用于检查%rax是否为0
跳转指令:
jmp Label,直接跳转
jmp *Operand,间接跳转
je Label,相等时跳转
jne Label,不相等时跳转
js Label,负数时跳转
jns Label,非负数时跳转
jg Label,有符号大于时跳转
jge Label,有符号大于等于时跳转
jl Label,有符号小于时跳转
jle Label,有符号小于等于时跳转
ja Label,无符号大于时跳转
jae Label,无符号大于等于时跳转
jb Label,无符号小于时跳转
jbe Label,无符号小于等于时跳转
Switch语句:
switch语句可以根据一个整数索引值进行多重分支。在处理具有多种可能结果的测试时,这种语句特别有用。它们不仅提高了C代码的可读性,而且通过是同跳转表这种数据结构使得其实现更加高效。跳转表是一个数组,表项 i 是一个代码段的地址,这个代码段实现当索引值等于 i 时程序应该采取的动作。
jmp指令的操作数有 前缀 ' * ' ,表明这是一个间接跳转,操作数指定一个内存位置,而索引由寄存器%rsi给出。
下面给出一个例子:
这是一个带有switch结构的C程序:
将其翻译到扩展的C语言:
其中数组jt就是跳转表,这是一个数组,数组的每一个元素都是一块代码的地址。
注意&符号表示一个变量的地址,而&&符号表示一个代码的地址。
过程:
过程调用,过程P调用过程Q:
转移控制,将控制从函数P转移到Q只需要简单地把程序计数器(PC)设置为Q的起始地址。不过当稍后从Q返回的时候,处理器必须记录好它需要继续P的执行的代码的位置。指令call Q,可以调用过程Q,这条指令会把地址A压入栈中,并将PC置为Q的起始地址。压入的地址A被称为返回地址,是紧跟call指令后面的那条指令的地址。对应的 ret 指令会从栈中弹出地址A,并把PC设置为A。
数据传送,当调用一个过程时,过程调用还可能包括把数据作为参数传递,而从过程返回还有可能包括返回一个值。可以通过寄存器最多传递6个整形参数。寄存器的使用是有特殊顺序的,寄存器使用的名字取决于传递的数据类型的大小。如果一个函数有大于6个整型参数,那么超出6 个的部分就要通过栈来传递。
运行时栈:
栈向低地址增长,而栈指针%rsp始终指向栈顶元素。可以用 pushq 和 popq 指令将数据存入栈中或者是从栈中取出来,而将栈指针减小一个适当的量可以为没有指定初始值的数据在栈上分配空间。
当过程需要的存储空间超出寄存器能够存放的大小时,就会在栈上分配空间。这部分空间被称为过程的栈帧。
一个进程对应于一个栈,一个函数(过程)对应于一个帧。
下面给出了运行时栈的通用结构,包括把他划分为栈帧:
当前正在执行的过程的帧总是在栈顶。当过程P调用过程Q时,会把返回地址压入栈中,指明当Q返回时,要从P程序的哪个位置继续执行,我们把这个返回地址当作P的栈帧的一部分,因为它存放的是与P相关的状态。Q的代码会扩展当前栈的边界,分配它的栈帧所需的空间。在这个空间中,它可以保存寄存器的值,分配局部变量空间,为他调用的过程设置参数。
栈上的局部存储:
接着讨论一个过程的栈帧的具体细节。一般在下面几种情况下,必须为局部变量分配内存:
- 寄存器不足够存放所有的本地数据。
- 对一个局部变量使用地址运算符 ' & ' ,因此必须能够为它产生一个地址。
- 某些局部变量是数组或结构,因此必须能够通过数组或者结构引用被访问到。
被保存的寄存器部分,因为寄存器是一个被所有过程共享资源,为了避免被调用过程覆盖调用者稍后需要用的寄存器的内容,必须遵循这样的规则:寄存器%rbx、%rbp和%r12~%r15被划分为被调用者保存寄存器。当过程P调用过程Q时,Q必须保存这些寄存器的值,保证它们的值在Q返回P时和Q被调用时是一样的,要么根本就不用它们,要么就把它们压入栈中,在返回的时候弹出;所有其他的寄存器,除了栈指针%rsp,都被分为调用者保存寄存器。这意味着任何函数都能够修改它们。也就是说,因为每个函数都能够修改这些寄存器的值,所以调用函数之前,调用者必须自己保存这些寄存器中的值。
局部变量部分,为必须分配内存的局部变量分配的内存区域。
参数构造区,如果函数Q还要调用其他函数,并且调用的参数个数超过了6个,那么在局部变量之下的内存区域就会被用于保存参数,也就是说在这种情况下,栈指针指向的字节保存着第7个参数。
一个C过程的大致流程如下:
- 准备阶段:
形成栈底:push指令和mov指令。pushl %ebp;movl %esp, %ebp
生成栈帧(如果需要):sub指令或and指令。subl $24, %esp
保存现场(如果有被调用者保存寄存器):mov指令
- 过程(函数)体:
分配局部变量空间,并赋值。
具体处理逻辑,如果遇到函数调用时,先准备参数,将实参送到栈帧入口参数处,然后再调用call指令,最后在EAX中准备返回参数。
- 结束阶段:
退栈:leave指令或者pop指令,然后用ret指令取返回地址。
递归调用:递归调用一个函数本身与调用其他函数是一样。栈规则提供了一种机制,每次函数调用都有它自己私有的状态信息(保存的返回地址和被调用者保存的寄存器值)存储空间。如果需要,它还可以提供局部变量的存储。栈分配和释放的规则很自然地就与函数调用-返回的顺序匹配。
数组:
假设E是一个int型的数组,而我们想计算 E [ i ] ,其中E的地址存放在寄存器%rdx中,而 i 存放在寄存器%rcx中。然后指令movl (%rdx, %rcx, 4), %eax,就可将E [ i ]的值传送到寄存器%eax中
而对于嵌套的数组,声明这样一个数组:T D[R][C],它的数组元素D[ i ][ j ]的内存地址为:&D[ i ][ j ]=Xd+L( C*i + j)可以用于获得D[ i ][ j ]的地址。
异质的数据结构:
结构:类似数组的实现,结构的所有组成部分都存放在内存中的一段连续的区域内,而指向结构的指针就是结构第一个字节的地址。编译器维护关于每个结构类型的信息,指示每个字段(filed)的字节偏移。它以这些偏移作为内存引用指令中的位移,从而产生结构元素的引用。结构的各个字段的选取完全是在编译时处理的,机器代码不包含关于字段声明或字段名字信息。
联合:联合提供了一种方式,能够规避C语言的类型系统,允许以多种类型来引用一个对象。联合声明的语法与结构的语法一样,只不过语义相差比较大。它们使用不同的字段来引用相同的内存块。一个联合的总的大小等于它最大字段的大小。
数据对齐:许多计算机对基本数据类型的合法地址做出了一些限制,要求某种类型对象的地址必须是某个值K的倍数。这种对齐限制简化了形成处理器和内存系统之间接口的硬件设计。例如,如果一个处理器总是从内存中取8个字节,则地址必须为8的倍数。如果我们能保证将所有double类型数据的地址对齐成8的倍数,那么就可以用一个内存操作来读或者写值了。而对其的原则是任何K字节的基本对象必须是K的倍数。
需要注意的是,对于结构体类型的数组,为了是后面的元素能够满足数据对齐的原则,需要将char类型的元素填充至4字节。