如图所示,大多数 GCC 生成的汇编代码指令都有一个字符的后缀(本文中都是使用 ATT 而非 Intel 汇编代码格式),表明操作数的大小。例如,数据传送指令有四个变种:movb(传送字节)、movw(传送字)、movl(传送双字)和 movq(传送四字)。后缀“l”用来表示双字,因为 32 位数被看成是“长字(long word)”。注意,虽然汇编代码中的 4 字节整数和 8 字节双精度浮点数都使用了该后缀来表示,但这不会产生歧义,因为浮点数使用的是一组完全不同的指令和寄存器。
一个 X86-64 的 CPU 包含一组 16 个存储 64 位值的通用目的寄存器,用来存储整数数据和指针。下图中显示了这 16 个寄存器。它们的名字都以“%r”开头,后面则跟以随指令集历史演化而来的不同命名规则的名字。最初的 8086 中有 8 个 16 位的寄存器,对应图中的 %ax 到 %bp。每个寄存器都有特殊的用途。扩展到 IA32 架构时,这些寄存器也扩展成 32 位,标号从 %eax 到 %ebp。扩展到 x86-64 后,原来的 8 个寄存器也随之扩展成 64 位,标号从 %rax 到 %rbp。除此之外,还增加了 8 个新的寄存器,它们的标号是按照新的命名规则制定的:从 %r8 到 %r15。
指令可以对这 16 个寄存器的低位字节中存放的不同大小的数据进行操作:字节级操作可以访问最低的字节,16 位操作可以访问最低的 2 个字节,32 位操作可以访问最低的 4 个字节,而 64 位操作可以访问整个寄存器。
当相关指令以寄存器作为目标时,对于生成小于 8 字节结果的指令,对寄存器中剩下的字节的处理有两条规则:生成 1 字节和 2 字节数字的指令会保持剩下的字节不变;生成 4 字节数字的指令会把高位 4 个字节置为 0,这是作为从 IA32 到 x86-64 的扩展的一部分而采用的。
多数指令都有一个或多个操作数(operand),用于指示一个操作中要使用的源数据值,以及放置结果的目的位置。源数据值可以是常数,或是从寄存器或内存中读出,结果可以存放在寄存器或内存中。因此,各种不同的操作数的可能性被分为三种类型。第一种是立即数(immediate),用来表示常数值。在 ATT 格式的汇编代码中,立即数的书写方式是“$”后面跟一个用标准 C 表示法表示的整数,如 $-577 或 $0x1F。不同的指令允许的立即数值范围不同,汇编器会自动选择最紧凑的方式进行数值编码。第二种是寄存器(register),它表示某个寄存器的内容。第三类是内存引用,它会根据计算出来的地址(通常称为有效地址)访问某个内存位置。x86-64 支持多种不同的寻址模式,允许不同形式的内存引用,如下表所示。
图中,使用符号 ra 来表示任意寄存器 a,用引用 R[ra] 来表示它的值,这是将寄存器集合看成一个数组 R,用寄存器标识符作为索引。对于内存引用,因为可以将内存看成一个很大的字节数组,所以用符号 Mb[Addr] 表示对存储在内存中从地址 Addr 开始的 b 个字节值的引用(为了简便,通常省略下标 b)。表中底部用语法 Imm(rb, ri, s) 表示的是最常用的形式,它有四个组成部分:一个立即数偏移 Imm,一个基址寄存器 rb,一个变址寄存器 ri 和一个比例因子 s。注意,这里 s 必须是 1、2、4 或者 8,基址和变址寄存器都必须是 64 位寄存器。有效地址被计算为:Imm + R[rb] + R[ri]*s。引用数组元素时,会用到这种通用形式,其他形式都是这种的特殊情况。在引用数组和结构元素时,比较复杂的寻址模式是很有用的。
下面是一个数据交换函数的 C 语言代码。
long exchange(long *xp, long y){ long x = *xp; *xp = y; return x; }
其对应的汇编代码主体部分如下。
; long exchange(long *xp, long y) ; xp in %rdi, y in %rsi exchange: movq (%rdi), %rax ; Get x at xp. Set as return value. movq %rsi, (%rdi) ; Store y at xp. ret ; Return.
从这段汇编代码中可以看出,C 语言中所谓的“指针”其实就是地址。间接引用指针就是将该指针放在一个寄存器中,然后在内存引用中使用这个寄存器。其次,像 x 这样的局部变量通常是保存在寄存器而不是内存中,因为访问寄存器要比访问内存快得多。
参考书籍:《深入理解计算机系统》第三版第三章——程序的机器级表示。