一、历史观点
Intel系列处理器的每个后继处理器的设计都是向后兼容的——较早版本上编译的代码可以在较新的处理器上运行。
许多公司(比如AMD)生产出了与Intel处理器兼容的处理器,能够运行完全相同的机器级程序。
二、程序编码
gcc编译器的-Og参数选项告诉编译器使用会生成符合原始C代码整体结构的机器代码的优化等级。使用较高级别优化产生的代码会严重变形,以至于产生的机器代码和初始源代码之间的关系非常难以理解。
2.1、机器级代码
对于机器级编程来说,其中两种抽象尤为重要:
- 指令集架构(Instruction Set Architecture,ISA) —— 定义机器级程序的格式和行为,它定义了处理器状态、指令的格式,以及每条指令对状态的影响。
- 虚拟内存 —— 将主存和I/O设备抽象成一个非常大的字节数组
汇编代码非常接近于机器代码。与机器代码的二进制格式相比,汇编代码的主要特点是它用可读性更好的文本格式表示。
在汇编语言中一些对C语言程序员隐藏的处理器状态都是可见的:
- 程序计数器 —— 给出将要执行的下一条指令在内存中的地址
- 整数寄存器 —— 存储地址或整数数据
- 条件码寄存器 —— 保存着最近执行的算术或逻辑指令的状态信息
- 向量寄存器 —— 存放一个或多个整数或浮点数值
虽然C语言提供了一种模型,可以在内存中声明和分配各种数据类型的对象,但是机器代码只是简单地将内存看成一个很大的、按字节寻址的数组。
2.2、代码示例
mstore.c
使用gcc的-S参数,能将C语言源代码编译成汇编代码:
以上命令产生一个汇编代码文件mstore.s,包括下面几行:
使用gcc的-c参数,能将C语言代码编译并汇编:
以上命令会产生一个目标文件mstore.o,它是二进制格式的。
将mstore.o其中的一段二进制代码使用十六进制表示如下:
这就是上面列出的汇编指令对应的目标代码。
机器执行的程序只是一个字节序列,它是对一系列指令的编码。机器对产生这些指令的源代码几乎一无所知。
利用反汇编器可以根据机器代码产生一种类似于汇编代码的格式:
结果如下:
生成实际可执行的代码需要对一组目标代码文件运行链接器,而这一组目标代码文件中必须含有一个main函数。
增加一个main.c文件:
通过编译、汇编、链接生成可执行文件只需:
可以反汇编prog文件:
结果内容包括下面这段:
2.3、关于格式的注解
mstore.s的完整内容如下:
所有以"."(点符号)开头的行都是指导汇编器和链接器工作的伪指令。我们通常可以忽略这些行。
为了更清楚地说明汇编代码,可以使用这样一种带解释的格式来表示汇编代码:
对于一些应用程序,程序员必须用汇编代码来访问机器的低级特性:
- 一种是用汇编代码编写整个函数,在链接阶段把它们和C函数组合起来
- 另一种方法是利用GCC的支持,直接在C程序中嵌入汇编代码
在C程序中包含汇编代码使得这些代码与某类特殊的机器相关(例如x86-64),所以只应该在想要的特性只能以此种方式才能访问到时才使用它。
三、数据格式
由于是从16位体系结构扩展成32位的,Intel用术语"字(word)"表示16位数据类型。
因此,称32位数为"双字(double words)",称64位数为"四字(quad words)"。
x86-64指令集包括完整的针对字节、字和双字的指令:
汇编代码也使用后缀'l'来表示4字节整数和8字节双精度浮点数。这不会产生歧义,因为浮点数使用的是一组完全不同的指令和寄存器。
四、访问信息
一个x86-64的中央处理单元(CPU)包含一组16个存储64位值的通用目的寄存器。这些寄存器用来存储整数数据和指针。
字节级操作可以访问最低的字节,16位操作可以访问最低的2个字节,32位操作可以访问最低的4个字节,而64位操作可以访问整个寄存器。
当这些指令以寄存器作为目标时,对于生成小于8字节结果的指令,寄存器中剩下的字节会怎么样,对此有两条规则:
- 生成1字节和2字节数字的指令会保持剩下的字节不变
- 生成4字节数字的指令会把高位4个字节置为0
4.1、操作数指示符
大多数指令有一个或多个操作数,指示出执行一个操作中要使用的源数据值,以及放置结果的目的位置。
源数据值可以以常数形式给出,或是从寄存器或内存中读出。结果可以存放在寄存器或内存中。
因此各种不同的操作数的可能性被分为三种类型:
- 立即数 —— 用来表示常数值,立即数的书写方式是'$'后面跟一个用标准C表示法表示的整数
- 寄存器 —— 表示某个寄存器的内容。用符号
来表示任意寄存器a,用引用R[
]来表示它的值,这是寄存器集合看成一个数组R,用寄存器标识符作为索引。
- 内存引用 —— 根据计算出来的地址访问某个内存位置。用符号
[Addr]表示对存储在内存中从地址Addr开始的b个字节值的引用,为了简便,通常省去下标b。
4.2、数据传送指令
最频繁使用的指令是将数据从一个位置复制到另一个位置的指令。
可以把许多不同的指令划分成指令类,每一类中的指令执行相同的操作,只不过操作数大小不同。
例如以下的数据传送指令MOV类,这些指令都执行同样的操作,主要区别在于它们操作的数据大小不同。
x86-64加了一条限制:传送指令的两个操作数不能都指向内存位置。将一个值从内存位置复制到另一个内存位置需要两条指令——第一条指令将源值加载到寄存器中,第二条将该寄存器值写入目的位置。
下面的MOV指令示例给出了源和目的类型的五种可能的组合:
下面两张图记录了将较小的源值复制到较大的目的时使用的两类数据移动指令。
所有这些指令都把数据从源(在寄存器或内存中)复制到目的寄存器。
MOVZ类中的指令把目的中剩余的字节填充为0,而MOVS类中的指令通过符号扩展来填充,把源操作的最高位进行复制。
cltq指令没有操作数,因为它总是以寄存器%eax作为源,%rax作为符号扩展结果的目的。它的效果与指令movslq %eax, %rax完全一致,不过编码更紧凑。
4.3、数据传送示例
过程参数xp和y分别存储在寄存器%rdi和%rsi中(参数通过寄存器传递给函数)。
指令2从内存中读出xp,把它存放到寄存器%rax中(像x这样的局部变量通常是保存在寄存器中,而不是在内存中)。
指令3将y写入到寄存器%rdi中的xp指向的内存位置。
指令4用寄存器%rax从这个函数返回一个值。
4.4、压入和弹出栈数据
最后两个数据传送操作可以将数据压入程序栈中,以及从程序栈中弹出数据。
在x86-64中,程序栈存放在内存中某个区域。栈向下增长,这样一来,栈顶元素的地址是所有栈中元素地址中最低的。
栈指针%rsp保存着栈顶元素的地址。
pushq指令的功能是把数据压入到栈上,而popq指令是弹出数据。这些指令都只有一个操作数——压入的数据源和弹出的数据目的。
将一个四字值压入栈中,首先要将栈指针减8,然后将值写到新的栈顶地址。因此,指令pushq %rbp的行为等价于下面两条指令:
弹出一个四字的操作包括从栈顶位置读出数据,然后将栈指针加8。因此,指令popq %rax等价于下面两条指令:
五、算术和逻辑操作
下图列出了x86-64的一些整数和逻辑操作:
5.1、加载有效地址
指令leaq没有引用内存,它只是将有效地址写入到目的操作数。
指令leaq有一些灵活用法,根本就与有效地址计算无关。目的操作数必须是一个寄存器。
5.2、一元和二元操作
一元操作,只有一个操作数,既是源又是目的。这个操作数可以是一个寄存器,也可以是一个内存位置。
二元操作,有两个操作数,第二个操作数既是源又是目的。第一个操作数可以是立即数、寄存器或是内存位置。第二个操作数可以是寄存器或是内存位置。
5.3、移位操作
移位量可以是一个立即数,或者放在单字节寄存器%cl中。(移位指令只允许以这个特定的寄存器作为操作数)
移位量由%cl寄存器的低m位决定的,这里=
,高位会被忽略。所以当寄存器%cl的十六进制值为0xFF时,指令salb会移7位,salw会移15位,sall会移31位,而salq会移63位。
左移指令有两个名字:SAL和SHL,两者的效果都是进行逻辑左移,将右边填上0。
右移指令中SAR执行算术移位,SHR执行逻辑移位。
5.4、略
5.5、特殊的算术操作
x86-64指令集对128位(16字节)数的操作提供有限的支持。Intel把16字节的数称为八字。
下图是支持产生两个64位数字的全128位乘积以及整数除法的指令:
①乘法指令
imulq指令有两种形式:
- imulq指令作为一个“双操作数"乘法指令 —— 从两个64位操作数产生一个64位乘积
- imulq指令作为一个"单操作数"乘法指令 —— 计算两个64位值的全128位乘积(补码乘法)
mulq指令也是一个"单操作数"乘法指令,也是用于计算两个64位值的全128位乘积,但是它是无符号数乘法。
②除法指令
有符号除法指令idivq和无符号除法指令divq都是“单操作数指令"。将128位数作为被除数,而除数作为指令的操作数给出。
③cqto指令
这条指令不需要操作数,它的功能是读处%rax的符号位,并将它复制到%rdx的所有位。