1. 程序编码
:::info 实际上gcc命令调用了一整套的程序,将源代码转化成可执行代码。
- 首先,C预处理器扩展源代码,插入所有用
#include
命令指定的文件,并扩展所有用#define
声明指定的宏。 - 其次,编译器产生两个源文件的汇编代码,xx.s 。
- 接下来,汇编器会将汇编代码转化成二进制目标代码文件xx.o。目标代码是机器代码的一种形式,它包含所有指令的二进制表示,但是还没有填入全局值的地址。
- 最后,链接器将两个目标代码文件与实现库函数(例如printf)的代码合并,并产生最终的可执行代码文件
:::
2. 机器级代码
对于机器级编程来说,其中两种抽象尤为重要
- 第一种是由指令集体系结构或指令集架构 (
InstruetionSet Arehiteeture, ISA
)来定义机器级程序的格式和行为,它定义了处理器状态、指令的格式,以及每条指令对状态的影响,(定义指令对于机器的操作?) 第二种抽象是,机器级程序使用的内存地址是虚拟地址,提供的内存模型看上去是一个非常大的字节数组。 :::info 机器执行的程序只是一个字节序列,它是对一系列指令的编码。机器对产生这些指令的源代码几乎一无所知。 ::: 举例来看,如果对一个文件反汇编,得到的只是一串二进制序列,这些二进制序列实际上每一组对应着一个指令或者说等价于一段汇编语言
3. 目标文件里有什么
从反汇编中可以看出x86-64的指令长度从1到15个字节不等。
- 设计指令格式的方式是,从某个给定位置开始,可以将字节唯一地解码成机器指令。
- 反汇编器只是基于机器代码文件中的字节序列来确定汇编代码。它不需要访问该程序的源代码或汇编代码。
生成实际可执行的代码需要对一组目标代码文件运行链接器:
2. 编译与连接
3. 数据格式
由于是从16位机器扩展过来的,Intel用术语”字(word)”表示16位数据类型。因此,称32位数为“双字(double words)”,称64位数为“四字(quad words)”。
大多数GCC生成的汇编代码指令都有一个字符的后缀,表明操作数的大小。例如,数据传送指令有四个变种:movb(传送字节)、movw(传送字)、movl(传送双字)和movq(传送四字)
4. 访问信息
一个x86-64的中央处理单元(CPU)包含一组16个存储64位值的通用目的寄存器。这些寄存器用来存储整数数据和指针。它们的名字都以%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位操作可以访问整个寄存器。
操作数指示符
大多数指令有一个或多个操作数(operand),指示出执行一个操作中要使用的源数据值,以及放置结果的目的位置。x86-64支持多种操作数格式,操作数可以分成三种类型
- 第一种类型是立即数(immediate),用来表示常数值。在ATT格式的汇编代码中,立即数的书写方式是
$
后面跟一个用标准C表示法表示的整数,($-577
) - 第二种类型是寄存器(register),它表示某个寄存器的内容,16个寄存器的低位1字节、2字节、4字节或8字节中的一个作为操作数,这些字节数分别对应于8位、16位、32位或64位
- 第三类操作数是内存引用,它会根据计算出来的地址(通常称为有效地址)访问某个内存位置。因为将内存看成一个很大的字节数组,我们用符号
Mb[Addr]
表示对存储在内存中从地址Addr
开始的b
个字节值的引用
数据传送指令
- 源操作数指定的值是一个立即数,存储在寄存器中或者内存中。目的操作数指定一个位置,要么是一个寄存器或者,要么是一个内存地址。
- x86-64加了一条限制,传送指令的两个操作数不能都指向内存位置。将一个值从一个内存位置复制到另一个内存位置需要两条指令——第一条指令将源值加载到寄存器中,第二条将该寄存器值写入目的位置。 ``` movl $0x4050,%eax #Immediate—Register, 4 bytes movw %bp,%sp #Register—Register, 2 bytes movb (%rdi,%rcx),%al #Memory一Register,1 byte movb $-17, (%rsp) #Immediate—Memory, 1 byte movq %rax,-12(%rbp) #Register—Memory, 8 bytes
<a name="gsgDu"></a>
### 实例
例如给出一个交换函数的实例:
```cpp
long exchange(long* xp,long y)
{
long x=*xp;
*xp=y;
return x;
}
生成的对应的汇编代码如下
exchange:
movq (%rdi), %rax
movq %rsi, (%rdi)
ret
可以看出
- 寄存器一般用来存传入的参数,指针实际上存入的是地址,因此要用间接寻址来获得内容
- 局部变量比如x一般也是保存在寄存器中而非内存中
另一个例子:
char* sp; //8 bit 1字节
int* dp; //32bit 4字节
*dp=(int) *sp;
//------------
movsbl (%rdi), %eax;
#涉及位宽的转换,应该先改变大小、
# movsbl, 把字节移动到双字,并且扩展符号位
movl %eax, (%rsi);
压入和弹出栈数据
栈是一种数据结构,可以添加或者删除值,不过要遵循”后进先出"的原则。通过push操作把数据压入
栈中,通过pop操作删除数据;栈是从低地址向高地址扩展的,
上面的指令和效果虽然等价,但是pushq的指令编码只有一个字节,而等价的效果的代码(分步实现)需要8字节
3.5 算数和逻辑操作
大多数操作都分成了指令类,这些指令类有各种带不同大小操作数的变种(只有leaq没有其他大小的变种)。例如,指令类ADD由四条加法指令组成:addb、addw、addl和addq,分别是字节加法、字加法、双字加法和四字加法。事实上,给出的每个指令类都有对这四种不同大小数据的指令。
加载有效地址(leaq)
:::info
加载有效地址(load effective address)指令leaq
实际上是movq
指令的变形。它的指令形式是从内存读数据到寄存器,但实际上它根本就没有引用内存。它的第一个操作数看上去是一个内存引用,但该指令并不是从指定的位置读入数据,而是将有效地址写人到目的操作数。
:::
实际操作可以理解成movq;。另外,它还可以简洁地描述普通的算术操作。例如,如果寄存器
%rdx
的值为x
,那么指令leaq 7(%rdx, %rdx, 4), %rax
将设置寄存器%rax
的值为5x+7
。 因为7(%rdx, %rdx, 4)
相当于寄存器的间接寻址,对应的内存单元的地址是7+(%rdx)+(%rdx*4)
,因此求地址操作实际上得到的就是这个值。
移位操作
- 左移指令有两个名字:
SAL
和SHL
。两者的效果是一样的,都是将右边填上0。 - 右移指令不同,
SAR
执行算术移位(填上符号位),而SHR
执行逻辑移位(填上0)。移位操作的目的操作数可以是一个寄存器或是一个内存位置。