程序的机器级表示(二)

访问信息

一个x86-64的中央处理单元(CPU)包含一组16个存储64位值的通用目的寄存器。这些寄存器用来存储整数数据和指针。图3-2显示了这16个寄存器。它们的名字都以%r开头,不过后面还跟着一些不同的命名规则的名字,这是由于指令集历史演化造成的。最初的8086中有8个16位的寄存器,即图3-2中的%ax到%bp。每个寄存器都有特殊的用途,它们的名字就反应了这些不同的用途。扩展到IA32架构时,这些寄存器也扩展到32位寄存器,标号从%eax到%ebp。扩展到x86-64后,原来的8个寄存器扩展到64位,标号从%rax到%rbp。除此之外,还增加了8个新的寄存器,它们的标号是按照新的命名规则制定的:从%r8到%r15。

图3-2   整数寄存器。所有16个寄存器的低位部分都可以作为字节、字(16位)、双字(32位)和四字(64位)数字来访问

如图3-2中嵌套的方框标明,指令可以对这16个寄存器的低位字节中存放不同大小的数据进行操作。字节级操作可以访问最低的字节,16位操作可以访问最低的2个字节,32位操作可以访问最低的4个字节,64位操作可以访问整个寄存器。

我们后面会展现很多指令,复制和生成1字节、2字节、4字节和8字节值。当这些指令以寄存器作为目标时,对于生成小于8字节结果的指令,寄存器中剩下的字节会怎样?对齐有两条规则:生成1字节和2字节数字的指令会保持剩下的字节不变;生成4字节数字的指令会把高位4个字节置为0。后面这条规则是作为从IA32到x86-64的扩展的一部分而采用的。

就像图3-2右边的解释说明的那样,在常见的程序里不同的寄存器扮演不同的角色,其中最特别的是栈指针%rsp,用来指明运行时栈的结束位置。有些程序会明确地读写这个寄存器。另外15个寄存器的用法更灵活。少量指令会使用某些特定的寄存器,更重要的是,有一组标准的编程规范控制着如何使用寄存器来管理栈、传递函数参数、从函数的返回值,以及存储局部和临时数据。

操作数指示符

大多数指令有一个或多个操作数,指示出执行一个操作中要使用的源数据值,以及放置结果的目的位置。x86-64支持多种操作数格式(如图3-3)。源数据值可以以常数形式给出,或是从寄存器或内存读出。结果可以存放在寄存器或内存中。因此,各种不同额操作数的可能性被分为三种类型:

  • 第一种是立即数,用来表示常数值。在ATT格式的汇编代码中,立即数的书写方式是‘$’后面跟一个用标准C表示法表示的整数,比如,$-577或$0x1F。不同的指令允许的立即数数值范围不同,汇编器会自动选择最紧凑的方式进行数值编码。
  • 第二种类型是寄存器,它表示某个寄存器的内容,16个寄存器的低位1字节、2字节、4字节或8字节中的一个操作数,这些字节数分别对应于8位、16位、32位或64位。在图3-3中,我们用符号来表示任意寄存器a,用引用来表示它的值,这是将寄存器集合看成一个数组R,用寄存器标识符作为索引。
  •  第三类操作数是内存引用,它会根据计算出来的地址(通常称为有效地址)访问某个内存位置。因为将内存看成一个很大的字节数组,我们用符号表示对存储在内存中从地址Addr开始的b个字节值的引用。为了简便,我们通常省去下表b。

如图3-3所示,有很多不同的寻址模式,允许不同形式的内存引用。表中底部用语法表示的是最常用的形式。这样的引用有四个组成部分:一个立即数偏移,一个基址寄存器,一个变址寄存器和一个比例因子,这里的s必须是1、2、4或者8。基址和变址寄存器都必须是64位寄存器。有效地址被计算为。引用数组元素时,会用到这种通用形式。其他形式都是这种通用形式的特殊情况,只是省略了某些部分。正如我们将看到的,当引用数组和结构元素时,比较复杂的寻址模式是很有用的。 

 

图3-3   操作数格式。操作数可以表示立即数(常数)值、寄存器值或是来自内存的值。比例因子s必须是1、2、4或8

数据传送指令

最频繁使用的指令是将数据从一个位置复制到另一个位置的指令。操作数表示的通用性使得一条简单的数据传送指令能够完成在许多机器中要好几条不同指令才能完成的功能。 我们会介绍多种不同的数据传送指令,它们或者源和目的类型不同,或者执行的转换不同,或者具有的一些副作用不同。

图3-4列出的是最简单形式的数据传送指令——MOV类。这些指令把数据从源位置复制到目的位置,不做任何变化。MOV类由四条指令组成:movb、movw、movl和movq。这些指令都执行同样的操作;主要的区别在于它们操作的数据大小不同:分别是1、2、4和8字节。

图3-4   简单的数据传送指令

源操作数指定的值是一个立即数,存储在寄存器中或内存中。目的操作数指定一个位置,要嘛是寄存器或是内存地址。x86-64加上一条限制,传送指令的两个操作数不能都指向内存位置。将一个值从一个内存位置复制到另一个内存位置需要两条指令——第一条指令将源值加在到寄存器中,第二条将该寄存器值写入目的位置。参考图3-2,这些指令的寄存器操作数可以是16个寄存器有标号部分中的任意一个,寄存器部分的大小必须与指令最后一个字符(‘b’、‘w’、‘l’或‘q’)指定的大小匹配。大多数情况中,MOV指令只会更新目的操作数指定的那些寄存器字节或内存位置。唯一的例外就是movl指令以寄存器作为目的时,它会把寄存器的高位4位字节设置为0。造成这个例外的原因是x86-64采用的惯例,即任何为寄存器生成32位值的指令都会把该寄存器的高位部分置为0。

下面的MOV指令示例给出了源和目的类型的五种可能组合,第一个是源操作数,第二个是目的操作数:

图3-4中记录的最后一条指令是处理64位立即数的数据的。常规的movq指令只能以表示为32位补码数字的立即数作为源操作数,然后把这个值符号扩展得到64位的值,放到目标位置。movabsq指令能够以任意64位立即数值作为源操作数,并且只能以寄存器作为目的。

图3-5和图3-6记录的是两类数据移动指令,在将较小的源值复制得到较大的目的时使用。所有这些指令都把数据从源(在寄存器或内存中)复制到目的寄存器。MOVZ类中的指令把目的中剩余的字节填充为0,而MOVS类中的指令通过符号扩展来填充,把源操作的最高位进行复制。可以观察到,每条指令名字的最后两个字符都是大小指示符:第一个字符指定源的大小,而第二个指明了目的的大小。正如看到的那样,这两个类中每个都有三条指令,包括了所有的源大小为1个和2个字节、目的大小为2个和4个的情况,当然只考虑目的大于源的情况。

图3-5   零扩展数据传送指令。这些指令以寄存器或内存地址作为源,以寄存器作为目的

 

图3-6   符号扩展数据传送指令。MOVS指令以寄存器或内存地址作为源,以寄存器作为目的。cltq指令只作用于寄存器%eax和%rax

数据传送示例

作为一个使用数据传送指令的代码示例,考虑图3-7中所示的数据交换函数,既有C代码,也有GCC产生的汇编代码:

图3-7   exchange函数的C语言和汇编代码。寄存器%rdi和%rsi分别存放参数xp和y

如图3-7b所示,函数exchange由三条指令实现:两个数据传送(movq),加上一条返回函数被调用点的指令(ret)。函数通过把值存储在寄存器%rax或该寄存器的某个低位部分中返回。当过程开始执行时,过程参数xp和y分别存储在寄存器%rdi和%rsi中。然后指令2从内存中读出x,把它存放到寄存器%rax中,直接实现了C程序中的操作x=*xp。稍后,用寄存器%rax从这个函数返回一个值,因而返回值就是x。指令3将y写入到寄存器%rdi中的xp指向的内存位置,直接实现了操作*xp=y。这个示例说明了如何用MOV指令从内存中读到寄存器(第二行),如何从寄存器写到内存(第三行)。

关于这段汇编代码有两点值的注意。首先,我们看到C语言中所谓的指针就是地址。间接引用指针就是将该指针放在一个寄存器中,然后在内存引用中使用这个寄存器。其次,像x这样的局部变量通常保存在寄存器中,而非内存中。访问寄存器比访问内存快得多。

压入和弹出栈数据

最后两个数据传送操作可以将数据压入程序栈中,以及从程序栈中弹出数据。如图3-8所示:

 

图3-8   入栈和出栈指令

 正如我们看到的,栈在处理过程调用中起到至关重要的作用。栈是一种数据结构,可以添加或删除值,不过要遵循后进先出原则。通过push操作把元素压入栈中,通过pop操作删除数据;它具有一个属性:弹出的值永远是最近被压入而且仍然在栈中的值。栈可以实现为一个数组,总是从数组的一端插入和删除元素。这一端被称为栈顶。在x86-64中,程序栈存放在内存中某个区域。如图3-9所示,栈向下增长,这样一来,栈顶元素的地址是所有栈中元素最低的。栈指针%rsp保存着栈顶元素的地址。

pushq指令的功能是把数据压入到栈中,而popq指令是弹出数据。这些指令都只有一个操作数——压入的数据源和弹出的数据目的。将一个四字值压入栈中,首先要将栈指针减8,然后将值写到新的栈顶地址。因此,指令pushq %rbp的行为等价于下面两条指令:

 

它们之间的区别是在机器代码中pushq指令编码为1个字节,而上面那两条指令一共需要八个字节。图3-9中前两栏给出的是,当%rsp为0x108, %rax为0x123时,执行指令pushq %rax的效果。首先%rsp会减8,得到0x100,然后会将0x123存放到内存地址0x100处。

 

图3-9   栈操作说明。根据惯例,我们的栈操作是倒过来画的,因而栈顶在底部。x86-64中,栈向低地址方向增长,所以压栈是减小栈指针(寄存器%rsp)的值,并将数据存放到内存中,而出栈是从内存中读数据,并增加栈指针的值

弹出一个四字的操作包括从栈顶位置读出数据,然后将栈指针加8。因此,指令popq %rax等价于下面两条指令:

 

图3-9的第三栏说明的是在执行完pushq后立即执行指令popq %rdx的效果。先从内存中读出值0x123,再写到寄存器%rdx中,然后,寄存器%rsp的值将增加回到0x108。如图所示,值0x123仍然会保持在内存位置0x100中,直到被覆盖。无论如何,%rsp指向的地址总是栈顶。

因为栈和程序代码以及其他形式的程序数据都是放在同一内存中,所以程序可以用标准的内存寻址方法访问栈内任意位置。例如,假设栈顶元素是四字,指令movq 8(%rsp),%rdx会将第二个四字从栈中复制到寄存器%rdx。

posted @ 2019-10-02 15:06  北洛  阅读(929)  评论(0编辑  收藏  举报