计算机执行机器代码,用字节序列编码低级的操作,包括数据处理、管理内存、读写存储设备上的数据,以及利用网络通信。
编译器基于编程语言的规则、目标机器的指令集和操作系统遵循的惯例,经过一系列的阶段生成机器代码。
GCC C语言编译器以汇编代码的形式产生输出,汇编代码是机器代码的文本表示,给出程序中的每一条指令,然后GCC调用汇编器和链接器,根据汇编代码生成可执行的机器代码。
程序编码
假设一个C程序,有两个文件p1.c和p2.c,用linux命令行编译代码:
1 | linux> gcc -Og -o p p1.c p2.c |
gcc:即为GCC C编译器,是linux上默认的编译器,可简写为cc
-Og:编译选项,告诉编译器会使用符合原始C代码整体结构的机器代码的优化等级
编译过程略,主要区别是最后链接器将两个目标代码文件同实现库函数的代码进行合并,产生可执行代码文件p。
机器级编码
对于机器级编程来说,两种抽象尤为重要:
第一种:由指令集体系架构或指令集架构(ISA)来定义机器级程序的格式和行为,它定义了处理器状态、指令的格式,以及每条指令对状态的影响。
第二种:机器级程序使用的内存地址是虚拟地址,提供的内存模型看上去是一个非常大的字节数组。
在整个编译过程中,编译器会完成大部分的工作,将把用C语言提供的相对比较抽象的执行模型表示的程序转化成处理器执行的非常基本的指令。
汇编代码表示非常接近于机器代码,与机器代码的二进制格式相比,汇编代码的主要特点是它用可读性更好的文本格式表示。能够理解汇编代码以及它与原始C代码的联系,是理解计算机如何执行程序的关键一步。
x86-64的机器代码和原始的C代码差别非常大。一些通常对C语言程序员隐藏的处理器状态都是可见的。
程序计数器:通常称为“PC”,在x86-64中用%rip表示。是给出将要执行的下一条指令在内存中的地址。
整数寄存器:包含16个命名的位置,分别存储64位的值。这些寄存器可以存储地址(对应于C语言的指针)或整数数据。有的寄存器被用来记录某些重要的程序状态,而其他的寄存器用来保存临时数据,例如过程的参数和局部变量,以及函数的返回值。
条件码寄存器:保存最近执行的算术或逻辑指令的状态信息。用来实现控制或数据流中的状态变化,比如用来实现if和while语句
向量寄存器:一组向量寄存器可以存放一个或多个整数或浮点数值。
例子
我们写了一个C文件mstore.c,如下:
1 | long mult2(long, long); |
我们再通过如下指令产生汇编代码:
1 | linux> gcc -Og -S mstore.c |
mstore.c里的内容如下:
1 | .file "mstore.c" |
所有以‘.’开头的行都是指导汇编器和链接器工作的伪指令,我们通常可以忽略这些行。(因为没有关于这些指令的用途和与源代码关系的解释说明)
除此之外的每个缩进去的行都对应于一条机器指令。比如pushq指令表示将寄存器%rbx的内容压入程序栈中。
上面的汇编代码格式是ATT格式,还有一种Intel格式(我们进行ida反汇编的代码即为该格式);这两种格式在许多方面有所不同。
我们通过如下命令行,产生Intel格式的代码:
1 | linux> gcc -Og -S -masm=intel mstore.c |
截取主要汇编代码:
1 | mulstore: |
两者格式的不同:
Intel代码省略了指示大小的后缀。pushq和movq变成了push和mov
Intel代码省略了寄存器名字前面的%符号,用的是rbx,而不是%rbx
Intel代码用不同的方式来描述内存中的位置,例如用‘QWORD PTR [rbx]’而不是‘(%rbx)’
在带有多个操作数的指令情况下,列出操作数的顺序相反,可能引起困惑。
我们继续用“-c”命令行选项,编译并汇编代码:
1 | linux> gcc -Og -c mstore.c |
产生目标代码文件mstore.c,为二进制格式,无法直接查看。
我们可以用linux的一个反汇编器OBJDUMP来查看:
1 | linux> objdump -d mstore.o |
结果如下:
1 | 0000000000000000 <mulstore>: |
左边我们看到按照前面给出的字节顺序排列的14个十六进制字节值,它们分成若干组,每组1~5条指令。每组都是一条指令,右边是等价的汇编语言。
机器代码和反汇编表示的一些特性:
- x86-64的指令长度1~15个字节不等。常用的指令或操作数少的指令需要的字节数少,不常用或操作数多的指令需要的字节数较多。
- 设计指令格式的方法:从给定位置开始,可以将字节唯一解码为机器指令。
- 反汇编器基于机器代码文件中的字节序列确定汇编代码,不需要访问程序源代码或汇编代码。
- 反汇编器给call和ret指令添加了‘q’后缀,省略也没有问题。
数据格式
C语言数据类型在x86-64中的大小。在64位机器中,指针长8字节。
C类型 | 英特尔数据类型 | 汇编代码后缀 | 字节大小 |
---|---|---|---|
char | 字节 | b | 1 |
short | 字 | w | 2 |
int | 双字 | l | 4 |
long | 四字 | q | 8 |
char* | 四字 | q | 8 |
float | 单精度 | s | 4 |
double | 双精度 | l | 8 |
访问信息
一个x86-64的中央处理单元(CPU)包含一组16个存储64位值的通用目的寄存器。这些寄存器用来存储整数数据和指针。
第63位~第32位,64位长度 | 第31位~第16位,32位长度 | 第15位~第8位,16位长度 | 第7位~第0位,8位长度 | 寄存器作用 |
---|---|---|---|---|
%rax |
%eax |
%ax |
%al |
返回值 |
%rbx |
%ebx |
%bx |
%bl |
被调用者保存 |
%rcx |
%ecx |
%cx |
%cl |
第四个参数 |
%rdx |
%edx |
%dx |
%dl |
第三个参数 |
%rsi |
%esi |
%si |
%sil |
第二个参数 |
%rdi |
%edi |
%di |
%dil |
第一个参数 |
%rbp |
%ebp |
%bp |
%bpl |
被调用者保存 |
%rsp |
%esp |
%sp |
%spl |
栈指针 |
%r8 |
%r8d |
%r8w |
%r8b |
第五个参数 |
%r9 |
%r9d |
%r9w |
%r9b |
第六个参数 |
%r10 |
%r10d |
%r10w |
%r10b |
调用者保存 |
%r11 |
%r11d |
%r11w |
%r11b |
调用者保存 |
%r12 |
%r12d |
%r12w |
%r12b |
被调用者保存 |
%r13 |
%r13d |
%r13w |
%r13b |
被调用者保存 |
%r14 |
%r14d |
%r14w |
%r14b |
被调用者保存 |
%r15 |
%r15d |
%r15w |
%r15b |
被调用者保存 |
上面表格中,不同位数有对应不同寄存器的名称,例如64位的以r为前缀,32位的部分以e为前缀。寄存器结构之所以如此复杂是有历史原因的,一开始只有8位,后来发展到16位和32位;最后发展到现在的64位。
操作数指示符
大多数指令有一个或多个操作数,指示出执行一个操作中要使用的源数据值,以及放置结果的目的位置。
操作数分为三种类型:
- 立即数,用来表示常数值。ATT格式下,书写方式是’$‘后面跟一个用标准C表示法表示的整数,比如$-577或$0x1F。
- 寄存器,表示某个寄存器的内容,16给寄存器的低位1字节、2字节、4字节或8字节中的一个作为操作数,这些字节数分别对应8位、16字节、32字节或64字节。
- 内存引用,根据计算出来的地址(通常为有效地址)访问某个内存位置。用符号Mb[Addr]表示对存储在内存中从地址Addr开始的b个字节值的引用。
如上图,有多种不同的寻址模式。(具体请学习计算机组成原理)
表中底部用语法Imm(rb, ri, s)表示的是最常用的形式。这样的引用有四个组成部分:一个立即数偏移Imm,一个基址寄存器rb,一个变址寄存器ri和一个比例因子s(s必须为1、2、4、8),基址于变址寄存器都必须是64位寄存器。有效地址被计算为Imm + R[rb] + R[ri] * s。
数据传送指令
最频繁的指令是将数据从一个位置复制到另一个位置的指令。
上图列出的是最简单形式的数据传送指令——MOV类。这些指令把数据从源位置复制到目的位置,不做任何变化。MOV类由四条指令组成:movb、movw、movl、和movq。这些指令都执行同样的操作;主要区别在于它们操作的数据大小不同:分别是1、2、4和8字节。
源操作数指定的值是一个立即数,存储在寄存器中或者内存中。目的操作数指定一个位置,要么是一个寄存器,要么是一个操作地址。x86-64加了一条限制,传送指令的两个操作数不能都指向内存位置。将一个值从一个内存位置复制到另一个内存位置需要两条指令——第一条指令将源值加载到寄存器中,第二条将该寄存器值写入目的位置。
数据传送示例
有如下C程序:
1 | long exchange(long *xp, long y) |
编译后主要汇编代码如下:
1 | 1 exchange: |
(寄存器%rdi和%rsi分别存放参数xp和y)
函数exchange由三条指令实现:两个数据传送(movq),加上一条返回函数被调用点的指令(ret)。
当过程开始执行时,过程参数xp和y分别存储在寄存器%rdi和%rsi中。然后,指令2从内存读出x,把他放到寄存器%rax中,直接实现了C程序中的操作x = xp。然后,用寄存器%rax从这个函数返回一个值,因而返回值就是x。指令3将y写入寄存器%rdi中的xp指向的内存位置,直接实现了操作 xp = y。
通过这段汇编代码可以看出,C语言中所谓的“指针”其实就是地址。间接引用指针就是将该指针放在一个寄存器中,然后在内存引用中使用这个寄存器。还有,像x这样的局部变量通常时保存在寄存器中,而不是内存中。访问寄存器比访问内存要快得多。
压入与弹出栈数据
栈是一种数据结构,可以添加或者删除值,要遵循“后进先出”的原则。
push:把数据压入栈中,添加数据。
pop:把数据移除栈,删除数据。
它具有一个属性:弹出的值永远是最近被压入而且仍然在栈中的值。栈可以实现为一个数组,总是从数组的一端插入和删除元素。这一端被称为栈顶。
如同所示栈是向下增长的,栈顶是在图的底部,栈顶元素的地址是所有元素中最低的。
将一个四字值压入栈中,首先要将栈指针减8(四字=8字节),然后将值写到新的栈顶地址。因此指令pushq %rbp等价于:
1 | subq $8,%rsp |
当然两者区别是机器代码中pushq指令编码为1字节,两条指令需要8字节。
同样将一个四字值弹出栈,op先从栈顶位置读出数据,然后将栈指针加8。因此指令popq %rax等价于:
1 | movq (%rsp),%rax |
上图第三栏,先从内存中读出值0x123,再写到寄存器%rdx中,然后,寄存器%rsp的值将增加到0x108。但值0x123未被删除,仍在内存位置0x100中,直到被其他数据覆盖。无论如何,%rsp指向的地址总是栈顶。
算术和逻辑操作
给出的每个指令类都有对这四种不同大小数据的指令,这些操作被分为四组:加载有效地址、一元操作(一个操作数)、二元操作(两个操作数)和移位。
加载有效地址(leap)
指令leap实际上是movq指令的变形。它的指令形式是从内存读数据到寄存器,但实际上根本没有引用内存。如上图中,leap S,D中,第一个操作数S是将有效地址写入到目的操作数,而不是从指定位置读入数据。该指令可以为后面的内存引用产生指针。
另外,它还可以简洁的描述普通的算术操作,例子如下:
1 | long scale(long x, long y, long z){ |
主要编译如下:
1 | scale: |
该函数的算术运算以三条leap指令实现(leap指令能执行加法和有限形式的乘法)。
一元和二元操作
源操作数:指在应用指令中,内容不随指令执行而变化的操作数(被操作的数据)
目标操作数:指在应用指令中,内容随执行指令而改变的操作数(命令操作数据最终要存放的地方)
一元操作:只有一个操作数,既是源又是目的。该操作数可以是一个寄存器,也可以是一个内存位置。
二元操作:第二个操作数既是源又是目的。第一个操作数可以是立即数、寄存器或是内存位置。第二个操作数可以是寄存器或是内存位置。注意:当第二个操作数为内存地址时,处理器必须从内存读出值,执行操作,再把结果写回内存。
移位操作
移位操作如 SAL k,D ; k为移位量,第二项给出的是移位的数。可以进行算术和逻辑移位。移位量可以是一个立即数,或者放在单字节寄存器%cl中。
特殊的算术操作
总结
该篇主要介绍mov指令、栈指令、算术和逻辑操作指令。