3.4 访问信息

一个 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 位操作可以访问最低的 2 个字节,32 位操作可以访问最低的 4 个字节,而 64 位操作可以访问整个寄存器。
在后面的章节中,我们会展现很多指令,复制和生成 1 字节、2 字节、4 字节和 8 字节值。当这些指令以寄存器作为目标时,对于生成小于 8 字节结果的指令,寄存器中剩下的字节会怎么样,对此有两条规则:生成 1 字节和 2 字节数字的指令会保持剩下的字节不变;生成 4 字节数字的指令会把高位 4 个字节置为 0。后面这条规则是作为从 IA32 到 x86-64 的扩展的一部分而采用的。
就像图 3-2 右边的解释说明的那样,在常见的程序里不同的寄存器扮演不同的角色。其中最特别的是栈指针 %rsp,用来指明运行时栈的结束位置。有些程序会明确地读写这个寄存器。另外 15 个寄存器的用法更灵活。少量指令会使用某些特定的寄存器。更重要的是,有一组标准的编程规范控制着如何使用寄存器来管理栈、传递函数参数、从函数的返回值,以及存储局部和临时数据。我们会在描述过程的实现时(特别是在 3.7 节中),讲述这些惯例。

3.4.1 操作数指示符

大多数指令有一个或多个操作数(operand),指示出执行一个操作中要使用的源数据值,以及放置结果的目的位置。x86-64 支持多种操作数格式(参见图 3-3)。源数据值可以以常数形式给出,或是从寄存器或内存中读出。结果可以存放在寄存器或内存中。因此,各种不同的操作数的可能性被分为三种类型。
  • 第一种类型是立即数(immediate),用来表示常数值。在 ATT 格式的汇编代码中,立即数的书写方式是 ‘$’ 后面跟一个用标准 C 表示法表示的整数,比如,$-577 或 $0x1F。不同的指令允许的立即数值范围不同,汇编器会自动选择最紧凑的方式进行数值编码。
  • 第二种类型是寄存器(register),它表示某个寄存器的内容,16 个寄存器的低位 1 字节、2 字节、4 字节或 8 字节中的一个作为操作数,这些字节数分别对应于 8 位、16 位、32 位或 64 位。在图 3-3 中,我们用符号
    ra\rm r_a
    来表示任意寄存器 a,用引用
    R[ra]\rm R[r_a]
    来表示它的值,这是将寄存器集合看成一个数组 R,用寄存器标识符作为索引。
  • 第三类操作数是内存引用,它会根据计算出来的地址(通常称为有效地址)访问某个内存位置。因为将内存看成一个很大的字节数组,我们用符号
    Mb[Addr]\rm M_b[Addr]
    表示对存储在内存中从地址 Addr 开始的 b 个字节值的引用。为了简便,我们通常省去下标 b。
如图 3-3 所示,有多种不同的寻址模式,允许不同形式的内存引用。表中底部用语法
Imm(rb,ri,s)Imm(r_b,r_i,s)
表示的是最常用的形式。这样的引用有四个组成部分:一个立即数偏移 Imm,一个基址寄存器
rbr_b
,一个变址寄存器
rir_i
,和一个比例因子 s,这里 s 必须是 1、2、4 或者 8. 基址和变址寄存器都必须是 64 位寄存器。有效地址被计算为
Imm+R[rb]+R[ri]sImm +R[r_b]+R[r_i] \cdot s
。引用数组元素时,会用到这种通用形式。其他形式都是这种通用形式的特殊情况,只是省略了某些部分。正如我们将看到的,当引用数组和结构元素时,比较复杂的寻址模式是很有用的。
类型
格式
操作数值
名称
立即数
ImmImm
立即数寻址
寄存器
rar_a
R[ra]R[r_a]
寄存器寻址
存储器
ImmImm
M[Imm]M[Imm]
绝对寻址
存储器
(ra)(r_a)
M[R[ra]]M[R[r_a]]
间接寻址
存储器
Imm(rb)Imm(r_b)
M[Imm+R[rb]]M[Imm+R[r_b]]
(基址+偏移量)寻址
存储器
(rb,ri)(r_b,r_i)
M[R[rb]+R[ri]]M[R[r_b]+R[r_i]]
变址寻址
存储器
Imm(rb,ri)Imm(r_b,r_i)
M[Imm+R[rb]+R[ri]]M[Imm+R[r_b]+R[r_i]]
变址寻址
存储器
(,ri,s)(,r_i,s)
M[R[ri]s]M[R[r_i]\cdot s]
比例变址寻址
存储器
Imm(,ri,s)Imm(,r_i,s)
M[Imm+R[ri]s]M[Imm+R[r_i]\cdot s]
比例变址寻址
存储器
(rb,ri,s)(r_b,r_i,s)
M[R[rb]+R[ri]s]M[R[r_b]+R[r_i]\cdot s]
比例变址寻址
存储器
Imm(rb,ri,s)Imm(r_b,r_i,s)
M[Imm+R[rb]+R[ri]s]M[Imm+R[r_b]+R[r_i]\cdot s]
比例变址寻址
图 3-3 操作数格式。操作数可以表示立即数(常数)值、寄存器值或是来自内存的值,比例因子 s 必须是 1、2,4 或者 8

练习题 3.1

练习题 3.1
假设下面的值存放在指明的内存地址和寄存器中:
地址
0x100
0xFF
0x104
0xAB
0x108
0x13
0x10C
0x11
寄存器
%rax
0x100
%rcx
0x1
%rdx
0x3
填写下表,给出所示操作数的值:
操作数
%rax
0x104
$0x108
(%rax)
4(%rax)
9(%rax,%rdx)
260(%rcx,%rdx)
0xFC(,%rcx,4)
(%rax,%rdx,4)

3.4.2 数据传送指令

最频繁使用的指令是将数据从一个位置复制到另一个位置的指令。操作数表示的通用性使得一条简单的数据传送指令能够完成在许多机器中要好几条不同指令才能完成的功能。我们会介绍多种不同的数据传送指令,它们或者源和目的类型不同,或者执行的转换不同,或者具有的一些副作用不同。在我们的讲述中,把许多不同的指令划分成指令类,每一类中的指令执行相同的操作,只不过操作数大小不同。
图 3-4 列出的是最简单形式的数据传送指令——MOV 类。这些指令把数据从源位置复制到目的位置,不做任何变化。MOV 类由四条指令组成:movb、movw、movl 和 movq。这些指令都执行同样的操作;主要区别在于它们操作的数据大小不同:分别是 1、2、4 和 8 字节。
指令
效果
描述
MOV S, D
D ← S
传送
movb
传送字节
movw
传送字
movl
传送双字
movq
传送四字
movabsq I, R
R ← I
传送绝对的四字
图 3-4 简单的数据传送指令
源操作数指定的值是一个立即数,存储在寄存器中或者内存中。目的操作数指定一个位置,要么是一个寄存器或者,要么是一个内存地址。X86-64 加了一条限制,传送指令的两个操作数不能都指向内存位置。将一个值从一个内存位置复制到另一个内存位置需要两条指令一第一条指令将源值加载到寄存器中,第二条将该寄存器值写入目的位置。参考图 3-2,这些指令的寄存器操作数可以是 16 个寄存器有标号部分中的任意一个,寄存器部分的大小必须与指令最后一个字符(‘b’,‘w’,‘l’ 或 ‘q’)指定的大小匹配。大多数情况中,MOV 指令只会更新目的操作数指定的那些寄存器字节或内存位置。唯一的例外是 movl 指令以寄存器作为目的时,它会把该寄存器的高位 4 字节设置为 0。造成这个例外的原因是 x86-64 采用的惯例,即任何为寄存器生成 32 位值的指令都会把该寄存器的高位部分置成 0。
下面的 MOV 指令示例给出了源和目的类型的五种可能的组合。记住,第一个是源操作数,第二个是目的操作数:
movl $0x4050,%eax # Immediate--Register, 4 bytes
movw %bp,%sp # Register--Register, 2 bytes
movb (%rdi,%rcx),%al # Memory--Register, 1 byte
movb $-17,(%esp) # Immediate--Memory, 1 byte
movq %rax,-12(%rbp) # Register--Memory, 8 bytes
图 3-4 中记录的最后一条指令是处理 64 位立即数数据的。常规的 movq 指令只能以表示为 32 位补码数字的立即数作为源操作数,然后把这个值符号扩展得到 64 位的值,放到目的位置。movabsq 指令能够以任意 64 位立即数值作为源操作数,并且只能以寄存器作为目的。
图 3-5 和图 3-6 记录的是两类数据移动指令,在将较小的源值复制到较大的目的时使用。所有这些指令都把数据从源(在寄存器或内存中)复制到目的寄存器。MOVZ 类中的指令把目的中剩余的字节填充为 0,而 MOVS 类中的指令通过符号扩展来填充,把源操作的最高位进行复制。可以观察到,每条指令名字的最后两个字符都是大小指示符:第一个字符指定源的大小,而第二个指明目的的大小。正如看到的那样,这两个类中每个都有三条指令,包括了所有的源大小为 1 个和 2 个字节、目的大小为 2 个和 4 个的情况,当然只考虑目的大于源的情况。
指令
效果
描述
MOVZ S, R
R ← 零扩展(S)
以零扩展进行传送
movzbw
将做了零扩展的字节传送到字
movzbl
将做了零扩展的字节传送到双字
movzwl
将做了零扩展的字传送到双字
movzbq
将做了零扩展的字节传送到四字
movzwq
将做了零扩展的字传送到四字
图 3-5 零扩展数据传送指令。这些指令以寄存器或内存地址作为源,以寄存器作为目的
指令
效果
描述
MOVS S, R
R ← 符号扩展(S)
传送符号扩展的字节
movsbw
将做了符号扩展的字节传送到字
movsbl
将做了符号扩展的字节传送到双字
movswl
将做了符号扩展的字传送到双字
movsbq
将做了符号扩展的字节传送到四字
movswq
将做了符号扩展的字传送到四字
movslq
将做了符号扩展的双字传送到四字
cltq
%rax ← 符号扩展(%eax)
把 %eax 符号扩展到 %rax
图 3-6 符号扩展数据传送指令。MOVS 指令以寄存器或内存地址作为源,以寄存器作为目的。cltq 指令只作用于寄存器 %eax 和 %rax

旁注 - 理解数据传送如何改变目的寄存器

正如我们描述的那样,关于数据传送指令是否以及如何修改目的寄存器的高位字节有两种不同的方法。下面这段代码序列会说明其差别:
movl $0x4050,%eax # Immediate--Register, 4 bytes
movw %bp,%sp # Register--Register, 2 bytes
movb (%rdi,%rcx),%al # Memory--Register, 1 byte
movb $-17,(%esp) # Immediate--Memory, 1 byte
movq %rax,-12(%rbp) # Register--Memory, 8 bytes
在接下来的讨论中,我们使用十六进制表示。在这个例子中,第 1 行的指令把寄存器 %rax 初始化为位模式 0011223344556677。剩下的指令的源操作数值是立即数值 -1。回想 -1 的十六进制表示形如 FF⋯F,这里 F 的数量是表述中字节数量的两倍。因此 movb 指令(第 2 行)把 %rax 的低位字节设置为 FF,而 movw 指令(第 3 行)把低 2 位字节设置为 FFFF,剩下的字节保持不变。movl 指令(第 4 行)将低 4 个字节设置为 FFFFFFFF,同时把高位 4 字节设置为 00000000。最后 movq 指令(第 5 行)把整个寄存器设置为 FFFFFFFFFFFFFFFF。
注意图 3-5 中并没有一条明确的指令把 4 字节源值零扩展到 8 字节目的。这样的指令逻辑上应该被命名为 movzlq,但是并没有这样的指令。不过,这样的数据传送可以用以寄存器为目的的 movl 指令来实现。这一技术利用的属性是,生成 4 字节值并以寄存器作为目的的指令会把高 4 字节置为 0。对于 64 位的目标,所有三种源类型都有对应的符号扩展传送,而只有两种较小的源类型有零扩展传送。
图 3-6 还给出 cltq 指令。这条指令没有操作数:它总是以寄存器 %eax 作为源,%rax 作为符号扩展结果的目的。它的效果与指令 movslq %eax,%rax 完全一致,不过编码更紧凑。

练习题 3.2

练习题 3.2
对于下面汇编代码的每一行,根据操作数,确定适当的指令后缀。(例如,mov 可以被重写成 movb、movw、movl 或者 movq。)
mov_ %eax, (%rsp)
mov_ (%rax), %dx
mov_ $0xFF, %bl
mov_ (%rsp,%rdx,4), %dl
mov_ (%rdx), %rax
mov_ %dx, (%rax)

旁注 - 字节传送指令比较

下面这个示例说明了不同的数据传送指令如何改变或者不改变目的的高位字节。仔细观察可以发现,三个字节传送指令 movb、movsbq 和 movzbq 之间有细微的差别。示例如下: