一、概述
计算机执行机器代码,用字节序列编码低级的操作,包括处理数据,管理内存,读写存储设备上的数据,以及网络通信。编译器基于编程语言的规则,目标机器的指令集和操作系遵循的惯例,经过一系列生成机器代码。本章中我们近距离观察机器代码,以及人类可以阅读的表示方式「汇编代码」。
二、高级语言和汇编
当我们使用高级语言的时候,机器屏蔽了细节,即机器级的实现。高级语言提供的抽象级别比较高,大多数时候这种抽象级别的工作效率会更高,也会更可靠。最大的优点是用高级语言编写程序可以在很多不同的机器上执行,而汇编是跟机器密切相关的。学习机器代码的意义,编译器承担了把高级语言生成汇编代码的工作,理解汇编代码可以尝试理解编译器的优化能力,并分析其中隐含的低效率。汇编代码非常接近于机器代码,与机器代码的二进制文件相比,汇编代码的主要特点是它用可读性更好的文本格式表示。能够理解汇编和原始C之间的联系,是理解计算机如何执行程序的关键一步。一些对C语言程序员隐藏的处理器状态都是可见的:
程序计数器:下一条指令在内存中的地址。
整数寄存器:包含8个命名的位置,可以存储一些地址或者整数的数据。有的用来记录某些重要的程序状态,有的则用来保存临时数据。
条件码寄存器:保存最近执行的算数或逻辑指令的状态信息,它们用来实现控制或数据流中的条件变化,比如用来实现 if 和 while 语句。
向量寄存器:存储浮点数。
三、程序编码以及数据格式
机器的抽象架构:
1)指令集架构:定义机器级程序的格式和行为,定义了处理器状态、指令的格式,以及每条指令对状态的影响。将程序的行为描述成好像每条指令都是按照顺序执行的。处理器的硬件远比描述的精细复杂,并发的执行很多命令,却能保证整体行为和指令集架构的顺序完全一致。
2)虚拟内存地址:提供的内存模型看上去像一个非常大的字节数组。
四、数据结构
由于计算机是由16位体系结构扩展为32位体系结构的,Intel 用术语 “字”(word) 表示16位数据类型,因此 32 位表示 “双字”(double words),64 位数称为“四字”(quad words)。
前面的汇编代码我们可以看到所有的汇编指令都带有字母 l,比如movl、addl、subl、pushl等等,这个l的后缀其实就是表示的数据格式,表示我们操作的是32位的数值。
下面我们看一下 C 语言基本数据类型对应的 IA32 表示:
上面的图示很好理解,比如mov指令,它是一个数据传送的指令,那么movb就代表传送一个字节的数据,movw就代表传送两个字节的数据,而movl就代表传送四个字节的数据。需要注意的是,long long int在IA32架构中是不支持这种数据格式的。而且汇编代码使用后缀 “l” 来表示 4 字节整数和8字节双精度浮点数,这不会产生歧义,因为浮点数使用的是一组完全不同的指令和寄存器。
五、访问信息:操作数指示符和数据传送指令
一个 X86-64 的CPU包含一组 16 个存储 64 位值的通用目的寄存器,这些寄存器用来存储整数数据和指针。常见的程序中16个寄存器扮演着不同的角色,特别是栈指针用来指明运行时栈的结束位置。有的程序会明确读写这个寄存器。另外 15 个寄存器的用法更灵活,少量指令会使用某些特定的寄存器。更重要的是有一组编程规范控制着如何使用寄存器来管理栈、传递参数,从参数返回值,以及存储局部和临时数据。
1)操作数指示符
大多数指令都有一个或多个操作数(operand),指示出执行一个操作中要引用的源数据值,以及放置结果的目标位置。我们可以看出源数据值可以是常数形式给出,或者是从寄存器或存储器中读出。而结果可以存放在寄存器或存储器中。我们将不同的操作数分为如下三种类型:
①、立即数(immediate):书写方式是$符号后跟一个标准C表示的整数,比如$52,$0x1F等等。任何能放进一个32位的字里面的数值都可以做立即数。
②、寄存器(register):它表示某个寄存器的内容,可以是8个32位寄存器中的一个(比如%eax),也可以是8个16位寄存器中的一个(比如%ax),还可以是8个单字节寄存器寄存器(比如%al)。上图是用Ea来表示任意寄存器a,用引用 R[Ea]来表示它的值。
③、存储器(memory):它会根据计算出来的地址(通常称为有效地址)来访问某个存储器位置。我们将存储器看成一个很大的字节数组,用符号Mb[Addr] 表示对存储在存储器中从地址 Addr 开始的 b 个字节值的引用。上图省略了下方的 b。
从上图我们知道,第一行是立即数,第二行则是寄存器,剩下的全部是存储器。其中最后一行存储器语法 Imm(Eb,Ei,s),表示的是最常用的形式,分为四个部分,
一、Imm 是立即偏移数
二、Eb 是基址寄存器
三、Ei 是变址寄存器
四、s 是比例因子,必须是 1、2、4或8
然后有效地址计算公式为: Imm + R[Eb]+R[Ei]*s。比如对于2(%esp,%eax,4)这个操作数来讲,它代表的是内存地址为2+%esp+4*%eax的存储器区域的值。
2)数据传送指令
数据传送指令:将数据从一个位置复制到另一个位置的指令。下图为最简单形式的数据传送指令MOV类:这些指令把数据从源位置复制到目的位置,不做任何变化。
源操作数指定的值是一个立即数,存储在寄存器中或者内存中。目的操作数指定一个位置,要么是一个寄存器,要么是一个内存地址。X86-64加了一条限制,传送指令的两个操作数不能都指向内存位置。将一个值从内存地址复制到另一个内存位置需要两条指令,第一条指令将源值加载到寄存器中,第二条将该寄存器值写入目的位置。
移动数据命令 MOVS 和 MOVZ:将较小的源值复制到较大的目的时使用。所有这些指令都把数据从源复制到目的寄存器。MOVZ类中的指令把目的中剩余的字节填充为0,而MOVS中的指令通过符号扩展来填充,把源操作的最高位进行复制。
3)压栈和弹栈
我们知道 栈 是一个数据结构,可以添加或删除值,遵循“后进先出”的原则。
push:把数据压入栈中,添加数据。
pop:把数据移出栈,删除数据。注意移出的值总是最近被压入而仍然在栈中的值。
栈可以实现为一个数组,总是从数组的一端插入或删除元素。而这一端称为栈顶,在 IA32 中,程序栈存放在存储器某个区域,如下图所示:
第三栏中说明的是在执行完 pushq 后立即执行命令 popq %rdx 的效果。先从内存中读取值0x123,然后写到寄存器 %rdx 中,然后寄存器 %rsp 的值将增加回到 0x108。如图所示,值0x123任然会保存在内存位置0x100中,直到被覆盖。无论如何,%rsp指向的地址总是栈顶。
六、总结
本篇主要讲解了汇编和机器级代码之间的关系。汇编能够让人更容易理解机器的行为。介绍了常见的汇编指令集操作。寄存器的种类和功能是需要慢慢感受的,至于使用方面,第一次阅读点到即止。