CSAPP(三)——运算命令 程序的机器级表示
x86-64中的一些处理器状态
- 程序计数器:下一条将要执行的指令在内存中的地址
- 整数寄存器文件:有16个命名的位置,每个都能存储64位的值,可以存储地址或整数数据,它们根据命名的不同,经常被用于处理不同的数据。
- 条件码寄存器:保存最近执行的算数或逻辑指令的状态信息,可以用来实现条件或分支控制结构。
- 一组向量寄存器:可以存放一个或多个整数或浮点数值。
一个简单的汇编例子
// filename: mstore.c
long mult2(long, long);
void multstore(long x, long y, long *dest) {
long t = mult2(x, y);
*dest = t;
}
这是一个简单的c语言程序,我们使用gcc -S
通知gcc仅将它编译成文本形式的汇编代码(而不做后面的汇编和链接步骤),下图是一个c程序编译过程的简单示例,gcc -S
编译后的.s
文件处于下图中的第三个步骤。
gcc -Og -S mstore.c
下面是.s
文件中的内容,以.xxx
开头的行相当于是编译过程对后面汇编和链接过程的一些指导,你可以理解为它们只是注解。
如果你使用-c
指令,就会生成.o
文件。
gcc -Og -c mstore.c
.o
文件已经是将文本形式的汇编代码转换成二进制的目标代码了,我们可以使用objdump
工具来查看它。
objdump -d mstore.o
不同版本的gcc编译后的代码可能不尽相同,如果你在非x86_64指令集下编译可能得到相差更远的内容。而且目前我们也不太认识这些汇编代码,目前我们只需要知道如何生成
.s
和.o
文件以及它们在整个编译过程中扮演的角色即可。
目前,整个编译过程还差一个链接步骤来生成可执行文件,链接器需要我们的目标代码文件(.o
)中包含main
函数,创建main.c
// filename: main.c
#include <stdio.h>
void multstore(long, long, long *);
int
main()
{
long d;
multstore(2, 3, &d);
printf("2 * 3 --> %ld\n", d);
return 0;
}
long mult2(long a, long b) {
long s = a * b;
return s;
}
编译:
gcc -Og -o prog main.c mstore.c
编译后的文件疯狂变大,因为其中包含了两个文件以及依赖的库函数printf
的链接,并且还有一些用于启动和中止程序的代码,以及与操作系统进行交互的代码。
使用objdump
查看生成的可执行文件prog
objdump -d prog
该文件中多了好多和我们编写的代码无关的内容,并且可执行程序中,每行代码的地址已经被填入了,并且callq
中的参数也有了一个要调用的程序的地址,也就是说编译后的可执行程序的内存布局已经确定。
数据格式
Intel由于历史原因,使用字(word) 代表16位数据,使用 双字(double words) 代表32位数据,64位则是 四字(quad words)。
下面是一张表,代表了一些C中的声明映射到Intel上的数据类型,以及所使用的汇编代码后缀。所以,上面的指令集中总有什么callq
、retq
这种指令,这代表你使用这些指令时使用的是四字操作指令。
访问信息
下图是16个通用寄存器以及它们的常见作用。
这些通用寄存器虽然可以保存64位数据,但是也可以使用它们的低位来单独保存32位、16位和8位的数据。%r寄存器名
用来访问整个64位寄存器数据,%e寄存器名
用来访问32个低位中保存的数据,%寄存器名
用来访问低16位数据,还有一些可以访问寄存器中单个字节的名称。
操作数指示符
操作数经常会有一个或多个参数,这些参数可能是
- 立即数(Immediate):一个常数值,在ATT格式的汇编代码中,
$
前缀用来表示立即数,如$-577
、$0x1F
。 - 寄存器:表示取某个寄存器中的内容,下图中用\(r_a\)表示任意一个寄存器,\(R[r_a]\)表示该寄存器中的值。
- 内存引用:下面使用符号\(M_b[Addr]\)表示存储在内存中从地址\(Addr\)开始的\(b\)个字节的值,有时会省去下标\(b\)。
下面使用的内存引用的形式\(Imm(r_b, r_i, s)\)表示一个立即数偏移量\(Imm\)加上一个基址寄存器\(r_b\),加上一个变址寄存器\(r_i\)和一个比例因子\(s\),s必须是1,2,4或8。有效地址会被计算为\(Imm+R[r_b]+R[r_i] * s\)。
说起来可能很难懂,看下面的表格就懂了。
练习题3.1
假设下面的值存放在指明的内存地址和寄存器中:
填写下表,给出操作数的值:
操作数 | 值 |
---|---|
%rax |
0x100 |
0x104 |
0xAB |
$0x108 |
0x108 |
(%rax) |
0xFF |
4(%rax) |
0xAB |
9(%rax, %rdx) |
0x11 |
260(%rcx, %rdx) |
0x13(260转换成16进制是104) |
0xFC(, %rcx, 4) |
0xFF |
(%rax, %rdx, 4) |
0x11 |
数据传送指令
将数据从一个位置复制到另一个位置的指令。
MOV类
mov类命令具有两个操作数,第一个是源位置,第二个是目的位置,mov要把原位置的数据复制到目的位置。下面是一些mov类命令,它们的区别就是传送的数据大小不同。
- 源位置可以是立即值、存储在寄存器位置或内存位置,目的位置则可以是寄存器或内存位置。
- 如果使用寄存器,命令的后缀必须和寄存器的大小相匹配。
- 除了movl外,这些指令都只操作64位寄存器中它们需要的部分
- x86-64中新增了一条限制,原位置和目标位置不能都是内存地址,如果需要在内存地址间进行复制,考虑使用一个寄存器做中间变量,并使用两条指令来完成。
MOVZ类
MOV类只会对寄存器中它们所需要的位进行操作,MOVZ则不同,如果你使用MOVZ向寄存器中复制数据并且这个数据没有占满64位,剩余的部分将被设置成0,这个操作叫零扩展。
MOVS类
和MOVZ类似,MOVZ比较适用于操作无符号数,如果你操作基于补码的有符号数,MOVS可能更加好用。它会将源操作数中的最高位进行复制,以填满寄存器中剩余的高位(相当于保持符号)。
没有movzlq的原因是movl操作和movzlq的操作一样
练习题3.2
- movl —— 一次寄存器中双字到内存的复制
- movw —— 一次内存中到寄存器的单字复制
- movb —— 一次立即值到寄存器的单字节复制
- movb —— 一次内存值到寄存器的单字节复制
- movq —— 一次内存值到寄存器的四字复制
- movw —— 一次寄存器到内存的单字复制
练习题3.3
- 地址需要64位,所以不能从%ebx中取,它只有32位
- movl只能处理四字节,也就是两个字,%rax则是八个字节,也就是四个字
- 两个操作数都是内存引用
- 没有%sl寄存器
- 目的位置不能是立即值
- movl不能处理八字节四个字的%rdx
- %si长度是两个字节,与movb不符
注意,这里的所有答案都是基于x86-64的
数据传送示例
上面的代码会被编译成下面的汇编代码,其中xp被存在%rdi
中,y在%rsi中。
所以指针可能会编译成保存在寄存器中的一个内存地址,函数中的局部变量一般也会存在寄存器中。
*xp
这种对指针进行取值操作的c代码和(%rdi)
这种根据寄存器中的内存地址取内存值的操作相关。
习题3.4
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 %eax, (%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) |
可能的疑问:
- 当一个小的有符号数据类型转向大的时,需要使用
movs
保留符号位 - 当一个小的无符号数据类型转向大的时,需要使用
movz
进行零扩展 - 当大的数据类型转向小的时,不需要考虑什么符号位,直接截断。
习题3.5
压入和弹出栈数据
如下图,栈是这样一种数据,push
操作会将数据压入到栈顶,pop
操作会将栈顶的数据从栈中弹出,栈顶向下增长,所以栈顶的地址是越来越小的。
下面是pushq
指令的效果,%rsp
是栈指针,它记录的是当前栈顶的内存地址。4字即8字节,它会将栈指针寄存器中的值减8,表示栈向下延伸了8字节,并将数据塞入到这8字节中。popq
差不多不说了。
算数和逻辑操作
leaq —— 加载有效地址
leaq 地址, 目的
:用于将第一个地址写入到目的。
比如leaq 7(%rdx, %rdx, 4), %rax
,将运算前面的地址并写入到%rax
中。该指令还可以用来简洁的描述一些算数操作,比如上面的那个指令,若%rdx
的值为4,它可以代表7 + 5x
这个算数表达式。
下面的一个例子中,x被存在%rdi
,y被存在%rsi
,z被存在%rdx
习题3.6
第一个应该是leaq 6(%rax), %rdx
表达式 | 结果 |
---|---|
leaq 6(%rax), %rdx | 6 + x |
leaq (%rax, %rcx), %rdx | x + y |
leaq (%rax, %rcx, 4), %rdx | x + 4 * y |
leaq 7(%rax, %rax, 8), %rdx | 7 + 9 * x |
leaq 0xA(, %rcx, 4), %rdx | 10 + 4 * y |
leaq 9(%rax, %rcx, 2), %rdx | 9 + x + 2 * y |
习题3.7
/**
* Let x in %rdi, y in %rsi, z in %rdx
*
* scale2:
* leaq (%rdi, %rdi, 4), %rax
* leaq (%rax, %rsi, 2), %rax
* leaq (%rax, %rdx, 8), %rax
* ret
*
*/
long scale2(long x, long y, long z) {
long result = ____________________;
return result;
}
long result = 5 * x + 2 * y + 8 * z;
一元和二元操作
一元操作只有一个操作数,既是源又是目的,比如incr (%rsp)
将栈顶的8字节元素自增1。
二元操作有两个操作数,第二个操作数既是源又是目的,比如subq %rax, %rdx
使寄存器%rdx
减去寄存器%rax
中的值。第一个操作数可以是立即数,寄存器或内存位置,第二个不能是立即数。当第二个操作数是内存地址时,处理器必须从内存读出值,运算并写回到内存。
指令 | 目的 | 值 |
---|---|---|
addq %rcx, (%rax) | 0x100 | 0x100 |
subq %rdx, 8(%rax) | 0x108 | 0xA8 |
imulq $16, (%rax, %rdx, 8) | 0x118 | 0x110 |
incq 16(%rax) | 0x110 | 0x14 |
decq %rcx | 0x1 | 0x0 |
subq %rdx, %rax | %rax | 0xFD |
感谢计算器
移位操作
移位操作的第一个操作数是移位量,可以是立即数或单字节寄存器%cl
,第二项是要移位的数。
因为移位量被限定在一个字节,所以最大移位量是255。在x86-64中,如果你对w位长的数据值进行移位,那么移位量是由%cl
寄存器的低m位决定的,\(2^m=w\),意思就是说,如果你对一个一字节大小的数据进行移位,那么2^3=8
,只有%cl%的低三位会被读取。
如%cl=0xFF
,那么salb
会移动7位(低三位最大是7)salw
会移动15位,sall
会移动31位,salq
移动63位。
SAL和SHL左移,将右面填0,SAR和SHR右移,但SAR会填上符号位,SHR只填0.
练习题3.9
salq $4, %rax
sarq %cl, %rax
示例
下图是一个充满算数和逻辑运算的c代码,以及它被编译后的汇编代码。比较有趣的是z * 48
的操作被分解成了一个z * 3
和一个z * 16
,而这个z * 16
使用了左移四位来完成。
习题3.10
/**
*
* Let x in %rdi, y in %rsi, z in %rdx
* arith2:
* orq %rsi, %rdi
* sarq $3, %rdi
* notq %rdi
* movq %rdx, %rax
* subq %rdi, %rax
* ret
*
*/
long arith2(long x, long y, long z) {
long t1 = ______;
long t2 = ______;
long t3 = ______;
long t4 = ______;
return t4;
}
long arith2(long x, long y, long z) {
long t1 = x | y;
long t2 = t1 >> 3;
long t3 = ~t2;
long t4 = z - t3;
return t4;
}
练习题3.11
A. 异或的源操作数和目的操作数相同,它们取出的值本无异,所以会将%rdx
设为0。
B. movq $0, %rdx
C. 不会
C. 汇编和反汇编这段代码,我们发现使用 xorq 的版本只需要 3 个字节,而使用 movq 的版本需要 7 个字节。其他将 %rdx 设置为 0 的方法都依赖于这样一个属性,即任何更新低位 4 字节的指令都会把高位字节设置为 0 。因此,我们可以使用 xorl %edx, %edx(2 字节)或 movl $0, %edx(5 字节)。
特殊算数操作
因为两个64位数乘积可能需要128位来保存,所以x86-64提供了一些对128位数的支持。