> 文章列表 > 【CSAPP 3.3~3.4】x86-64访问数据

【CSAPP 3.3~3.4】x86-64访问数据

【CSAPP 3.3~3.4】x86-64访问数据

文章目录

  • 1. 数据格式
  • 2. 访问信息
    • 2.1. 操作数指示符
      • 2.1.1. 练习
    • 2.2. 数据传送指令
      • 2.2.1. 练习1
      • 2.2.2. 练习2
    • 2.3. 数据传送示例
      • 2.3.1. 练习
      • 2.3.2. 练习
    • 2.4. 压入和弹出栈数据

1. 数据格式

在汇编语言层面,Intel用术语字word表示16位数据类型,双字double words表示32位数据类型,四字quad words表示64位数据类型。

这里字的概念和字长不一样,要注意区分。

下表给出了x86-64环境(64位机器+64位编译)下C语言的基本数据类型表示。

C声明 Intel数据类型 汇编代码后缀 字节大小
char 字节 b 1
short 字 w 2
int 双字 l 4
long 四字 q 8
char * 四字 q 8
float 单精度 s 4
double 双精度 l 8

此外还有不太常用的数据类型,如long long、long double。

大多数gcc生成的汇编代码指令都有一个字符的后缀,表面操作数的大小。例如:传送字节movb、传送字movw、传送双字movl、传送四字movq。
用后缀l表示双字,因为32位数被看成是长字long word。汇编代码也使用后缀l表示8字节双精度浮点数,这不会产生歧义,因为浮点数使用的是一组完全不同的指令和寄存器。

2. 访问信息

一个x86-64的CPU包含一组16个存储64位值的通用目的寄存器。这些寄存器用来存储整数数据和指针,它们的名字都以%r开头。
在这里插入图片描述
最初的8086中有8个16位寄存器,即%ax ~ %sp。扩展到IA32架构时,为了兼容旧架构,这些寄存器的标号扩展为%eax ~ %esp。扩展到64位时,原来的8个寄存器标号为%rax ~ %rsp,此外还增加了8个新的寄存器%r8 ~ %r15。

指令可以对这16个寄存器的低位字节中存放的不同大小的数据进行操作。字节级操作可以访问最低的字节,16位操作可以访问最低的2个字节,32位操作可以访问最低的4个字节,64位操作可以访问整个寄存器。

对于操作小于8字节数据的指令,寄存器中剩下的字节会怎样,有以下规则:

  • 仅操作最低的1字节或2字节时,寄存器中其他的字节不变。
  • 仅操作低4字节时,高4字节会被置0。

在常见的程序里,不同的寄存器扮演不同的角色。如%rsp用来指明运行时栈的结束位置。
有一组标准的编程规范控制着如何使用寄存器来管理栈、传递函数参数、存储函数的返回值,存储局部和临时数据。

2.1. 操作数指示符

大多数指令有一个或多个操作数,指示执行一个操作要使用的源数据值、目的位置。源数据可以以常数形式给出,或从寄存器或内存中读出。目的位置可以是寄存器或内存。因此操作数的类型有三种:

  • 立即数,用来表示常数值。在ATT格式的汇编代码中,立即数的书写方式是$后面跟一个标准C表示法表示的整数,如$-577、$0xff、$010等。不同的指令允许的立即数值范围不同,汇编器会自动选择最紧凑的方式进行数值编码。
  • 寄存器,表示某个寄存器的内容。使用16个寄存器的低1、2、4或8字节作为操作数。我们使用rar_ara​表示任意寄存器a,使用R[ra]R[r_a]R[ra​]表示该寄存器的值。这里将寄存器集合看成数组R,寄存器标号是数组下标。
  • 内存引用,它会根据计算出来的地址访问某个内存位置。将内存看成一个很大的字节数组,我们用符号Mb[Addr]M_b[Addr]Mb​[Addr]表示对存储在内存中从地址Addr开始的b的字节的访问。通常省略下标b。
类型 格式 操作数值
立即数 $ImmmImmmImmm ImmmImmmImmm
寄存器 rar_ara​ R[ra]R[r_a]R[ra​]
存储器 ImmImmImm M[Imm]M[Imm]M[Imm]
存储器 (rar_ara​) M[R[ra]]M[R[r_a]]M[R[ra​]]
存储器 Imm(rb)Imm(r_b)Imm(rb​) M[Imm+R[rb]]M[Imm+R[r_b]]M[Imm+R[rb​]]
存储器 (rb,ri)(r_b, r_i)(rb​,ri​) M[R[rb]+R[ri]]M[R[r_b]+R[r_i]]M[R[rb​]+R[ri​]]
存储器 Imm(rb,rt)Imm(r_b, r_t)Imm(rb​,rt​) M[Imm+R[rb]+R[ri]M[Imm+R[r_b]+R[r_i]M[Imm+R[rb​]+R[ri​]]
存储器 (,ri,s)(,r_i,s)(,ri​,s) M[R[ri]∗s]M[R[r_i]*s]M[R[ri​]∗s]
存储器 Imm(,ri,s)Imm(,r_i,s)Imm(,ri​,s) M[Imm+R[ri]∗s]M[Imm+R[r_i]*s]M[Imm+R[ri​]∗s]
存储器 (rb,ri,s)(r_b, r_i, s)(rb​,ri​,s) M[R[rb]+R[ri]∗s]M[R[r_b]+R[r_i]*s]M[R[rb​]+R[ri​]∗s]
存储器 Imm(rb,ri,s)Imm(r_b, r_i, s)Imm(rb​,ri​,s) M[Imm+R[rb]+R[ri]∗s]M[Imm+R[r_b]+R[r_i]*s]M[Imm+R[rb​]+R[ri​]∗s]

2.1.1. 练习

假设下面的值存放在指明的内存地址和寄存器中。

地址 值
0x100 0xFF
0x104 0xAB
0x108 0x13
0x10c 0x11
寄存器 值
%rax 0x100
%rcx 0x1
%rdx 0x3

填写下表,给出操作数的值:

操作数 值
%rax 0x100
0x104 0xAB
$0x108 0x108
(%rax) 0xFF
4(%rax) 0xAB
9(%rax, %rdx) 0x11
260(%rcx, %rdx) 0x13
0xFC(, %rcx, 4) 0xFF
(%rax, %rdx, 4) 0x11

2.2. 数据传送指令

最频繁使用的指令是将数据从一个位置复制到另一个位置的指令。下表列出来的是最简单的数据传送指令——MOV类,由四条指令组成:movb、movw、movl和movq,这些指令都执行同样的操作,区别在于所操作的数据大小不同。

指令 效果 描述
MOV S,D S→\\to→D 传送
movb 传送字节
movw 传送字
movl 传送双字
movq 传送四字
movabsq I,R I→\\to→R 传送绝对的四字

源操作数指定的值是一个立即数,存储在寄存器或内存中;目的操作数指定一个位置,要么是一个寄存器,要么是一个内存地址。x86-64中两个操作数不能都指向内存位置。寄存器操作数可以是16个寄存器有标号部分中的任意一个,寄存器部分的大小必须与指令最后一个字符(b、w、l或q)指定的大小匹配。通常情况下,MOV指令只会更新目的操作数指定的那些寄存器字节或内存位置。唯一例外的是movl指令以寄存器作为目的操作数时,它会把寄存器的高4字节设置为0。

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

movl $0x4050, %eax
movw %bp, %sp
movb (%rdi, %rcx), %al
movb $-17, (%rsp)
movq %rax, -12(%rbp)

movabsq指令是处理64位立即数的。常规的movq指令只能以表示为32位补码数字的立即数作为源操作数,然后把这个值符号扩展到64位,放到目的位置。movabsq指令能够以任意64位立即数作为源操作数,且目的操作数只能是寄存器。

在将较小的源值复制到较大的目的时,需要使用MOVZ和MOVS做数据扩展。MOVZ类中的指令把目的中剩余的字节填充为0,MOVS类中的指令通过符号扩展填充。每条指令名字的最后两个字符分别表示源大小和目的大小。如:

movzbw S, R  // 将做了零扩展的字节传送到字
movsbw S, R  // 将做了符号扩展的字节传送到字
cltq  // 把%eax符号扩展到%rax

MOVZ和MOVS只能以寄存器或内存地址作为源,以寄存器作为目的。
movl %ebx, %rax中movl默认做了4字节传送到8字节的零扩展,等价于movzlq %ebx, %rax。

2.2.1. 练习1

对于下面汇编代码的每一行,根据操作数,确定适当的指令后缀。

movl %eax, (%rsp)
movw (%rax), %dx
movb $0xFF, %bl
movb (%rsp, %rdx, 4), %dl
movq (%rdx), %rax
movw %dx, (%rax)

2.2.2. 练习2

请解释一下如下的每行代码分别有什么问题。

movb $0xF, (%ebx)  // x86-64环境下,内存地址是64位而不是32位,应该使用(%rbx)而不是(%ebx)
movl %rax, (%rsp)  // 传送8字节数据应该使用movq
movw (%rax), 4(%rsp)  // 两个操作数不能都为内存地址
movb %al, %sl  // 不存在寄存器%sl
movq %rax, $0x123  // 立即数不能作为目的操作数
movl %eax, %rdx  // 目的操作数大小大于源操作数大小,逻辑上应该使用`movzlq`或`movslq`
movb %si, 8(%rbp)  // %si是2字节大小,应该用movw

不存在movzlq这个指令,movzlq %eax, %rdx等价于movl %eax, %edx,%rdx的高4字节会被自动置0。

2.3. 数据传送示例

有如下C代码,exchange.c

long exchange(long *xp, long y)
{long x = *xp;*xp = y;return x;
}

使用gcc产生的汇编代码:

[root@localhost2 3]# gcc -Og -S exchange.c
[root@localhost2 3]# cat exchange.s.file	"exchange.c".text.globl	exchange.type	exchange, @function
exchange:
.LFB0:.cfi_startprocmovq	(%rdi), %raxmovq	%rsi, (%rdi)ret.cfi_endproc
.LFE0:.size	exchange, .-exchange.ident	"GCC: (GNU) 4.8.5 20150623 (Red Hat 4.8.5-44)".section	.note.GNU-stack,"",@progbits

核心部分代码是:

exchange:movq	(%rdi), %rax  // long x = *xp;movq	%rsi, (%rdi)  // *xp = y;ret  // return x;

函数调用的参数通过寄存器传递,第一个参数xp保存在%rdi,第二个参数y保存在%rsi。%rax保存函数的返回值。

可以看到C语言中所谓的“指针”其实就是地址,引用指针就是将该指针放在一个寄存器中,然后在内存引用中使用这个寄存器。像x这样的局部变量通常保存在寄存器,而不是内存中。CPU访问寄存器比访问内存快得多。如果程序中有对x取地址的操作&x,那一定要在内存中为x分配空间。

2.3.1. 练习

假设变量sp和dp的声明如下:

src_t *sp;
dest_t *dp;

这里的src_t和dest_t是用typedef声明的数据类型。我们想使用适当的数据传送指令来实现下面的操作:

*dp = (dest_t)*sp;

假设sp和dp的值分别存储在寄存器%rdi和%rsi中。对于下表中每个表项,给出实现指定数据传送的两条指令。其中第一条指令应该从内存中读数,做适当的转换,并设置寄存器%rax的适当部分。第二条指令要把%rax的适当部分写到内存,在这两种情况中,寄存器的部分可以是%rax、%eax、%ax或%al。

当执行强制类型转换既涉及大小变化又涉及C语言中符号变化时,操作应该先改变大小。

src_t dest_t 指令
long long movq (%rdi), %rax
movq %rax, (%rsi)
char int movsbl (%rdi), %eax
movl %eax, (%rsi)
char unsigned movsbl (%rdi), %eax
movl %eax, (%rsi)
unsigned char long movzbl (%rdi), %eax
movq %rax, (%rsi)
int char movl (%rdi), %eax
movb %al, (%rsi)
unsigned unsigned char movl (%rdi), %eax
movb %al, (%rsi)
char short movsbw (%rdi), %ax
movw %ax, (%rsi)

数据扩展时,第一条指令读源数据并扩展,第二条指令存放数据。(带扩展的mov指令只能以寄存器为目的操作数)
数据截断时,第一条指令读完整的源数据,第二条指令获取低位字节。

数据截断时,能不能把截断操作放在第一条指令,即从内存中只读低几个字节的数据?跟上述方案有啥区别?

2.3.2. 练习

已知某个函数的原型如下:

void decode1(long *xp, long *yp, long *zp);

它的编码代码表示如下:

decode1:movq	(%rdi), %r8movq	(%rsi), %rcxmovq	(%rdx), %raxmovq	%r8, (%rsi)movq	%rcx, (%rdx)movq	%rax, (%rdi)ret

其中xp、yp、zp分别存储在%rdi、%rsi和%rdx中。
请写出等价于上面汇编代码的decode1的C代码。

void decode1(long *xp, long *yp, long *zp)
{long x = *xp;long y = *yp;long z = *zp;*yp = x;*zp = y;*xp = z;
}

2.4. 压入和弹出栈数据

栈是一种数据结构,可以添加或者删除值,不过要遵循“后进先出”的原则。通过push操作把数据压入栈中,通过pop操作删除数据。它具有一个属性:弹出的值永远是最近被压入而且仍然在栈中的值。栈可以实现为一个数组,总是从数组的一端插入和删除元素,这一端被称为栈顶。

指令 效果 描述
pushq S R[%rsp] - 8 →\\to→ R[%rsp]; S →\\to→ M[R[%rsp]] 将四字压入栈
pop D M[R[%rsp]] →\\to→ D; R[%rsp] + 8 →\\to→ R[%rsp] 将四字弹出栈

在x86-64中,程序栈存放在内存中某个区域,栈向下增长,栈顶元素的地址是所有栈中元素地址中最低的。栈指针%rsp保存着栈顶元素的地址。

pushq %rbp等价于

subq $8, %rsp
movq %rbp, (%rsp)

popq %rax等价于

movq (%rsp), %rax
add $8, %rsp

pop时不会主动清理原来栈顶位置的数据。