您现在的位置是:首页 >其他 >【CSAPP 3.3~3.4】x86-64访问数据网站首页其他
【CSAPP 3.3~3.4】x86-64访问数据
文章目录
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
字节作为操作数。我们使用 r a r_a ra表示任意寄存器a
,使用 R [ r a ] R[r_a] R[ra]表示该寄存器的值。这里将寄存器集合看成数组R
,寄存器标号是数组下标。 - 内存引用,它会根据计算出来的地址访问某个内存位置。将内存看成一个很大的字节数组,我们用符号
M
b
[
A
d
d
r
]
M_b[Addr]
Mb[Addr]表示对存储在内存中从地址
Addr
开始的b
的字节的访问。通常省略下标b
。
类型 | 格式 | 操作数值 |
---|---|---|
立即数 | $ I m m m Immm Immm | I m m m Immm Immm |
寄存器 | r a r_a ra | R [ r a ] R[r_a] R[ra] |
存储器 | I m m Imm Imm | M [ I m m ] M[Imm] M[Imm] |
存储器 | ( r a r_a ra) | M [ R [ r a ] ] M[R[r_a]] M[R[ra]] |
存储器 | I m m ( r b ) Imm(r_b) Imm(rb) | M [ I m m + R [ r b ] ] M[Imm+R[r_b]] M[Imm+R[rb]] |
存储器 | ( r b , r i ) (r_b, r_i) (rb,ri) | M [ R [ r b ] + R [ r i ] ] M[R[r_b]+R[r_i]] M[R[rb]+R[ri]] |
存储器 | I m m ( r b , r t ) Imm(r_b, r_t) Imm(rb,rt) | M [ I m m + R [ r b ] + R [ r i ] M[Imm+R[r_b]+R[r_i] M[Imm+R[rb]+R[ri]] |
存储器 | ( , r i , s ) (,r_i,s) (,ri,s) | M [ R [ r i ] ∗ s ] M[R[r_i]*s] M[R[ri]∗s] |
存储器 | I m m ( , r i , s ) Imm(,r_i,s) Imm(,ri,s) | M [ I m m + R [ r i ] ∗ s ] M[Imm+R[r_i]*s] M[Imm+R[ri]∗s] |
存储器 | ( r b , r i , s ) (r_b, r_i, s) (rb,ri,s) | M [ R [ r b ] + R [ r i ] ∗ s ] M[R[r_b]+R[r_i]*s] M[R[rb]+R[ri]∗s] |
存储器 | I m m ( r b , r i , s ) Imm(r_b, r_i, s) Imm(rb,ri,s) | M [ I m m + R [ r b ] + R [ r i ] ∗ 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 → o →D | 传送 |
movb | 传送字节 | |
movw | 传送字 | |
movl | 传送双字 | |
movq | 传送四字 | |
movabsq I,R | I → o →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_startproc
movq (%rdi), %rax
movq %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), %r8
movq (%rsi), %rcx
movq (%rdx), %rax
movq %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 → o → R[%rsp]; S → o → M[R[%rsp]] | 将四字压入栈 |
pop D | M[R[%rsp]] → o → D; R[%rsp] + 8 → o → R[%rsp] | 将四字弹出栈 |
在x86-64
中,程序栈存放在内存中某个区域,栈向下增长,栈顶元素的地址是所有栈中元素地址中最低的。栈指针%rsp
保存着栈顶元素的地址。
pushq %rbp
等价于
subq $8, %rsp
movq %rbp, (%rsp)
popq %rax
等价于
movq (%rsp), %rax
add $8, %rsp
pop时不会主动清理原来栈顶位置的数据。