程序编码
假设一个C程序,有两个文件p1.c和p2.c。我们用Unix命令行编译这些代码:
linux> gcc -Og-o p p1.c p2.c
命令gcc就是GCC编译器,这是Linux默认的编译器。编译选项-Og告诉编译器使用会生成符合原始C代码整体结构的机器代码的优化等级,使用较高级别的优化产生的代码会严重变形,以至于产生的机器代码和初始源代码之间的关系难以理解。
实际上,gcc命令调用了一整套程序,将源代码转换为可执行代码。首先,C预处理器扩展源代码,插入所有用#include命令指定的文件,并扩展所有用#define声明指定的宏。其次,编译器产生两个源文件的汇编代码,名字分别为p1.s和p2.s。接下来,汇编器会将汇编代码转换为二进制目标代码文件p1.o和p2.o。目标代码是机器代码的一种形式,它包含所有指令的二进制表示,但是还没填入全局值的地址。最后,链接器将两个目标代码文件与实现函数(如printf)的代码合并,并产生最终的可执行文件p(由-o p指定的)。
机器级代码
对机器级编程来说,其中两种抽象尤为重要。第一种是由指令集体系结构或指令集架构(Instruction Set Architecture,ISA)来定义机器级程序的格式和行为,它定义了处理器状态、指令的格式,以及每条指令对状态的影响。大多数ISA,包括x86-64,将程序的行为描述成好像每条指令都是按顺序执行的,一条指令结束后,下一条指令再开始。处理器的硬件远比描述的精细复杂,它们并发的执行许多指令,但是可以采取措施保证整体行为与ISA指定的顺序执行的行为完全一致。第二种抽象是,机器级程序使用的内存地址是虚拟地址,提供的内存模型看上去是一个非常大的字节数组。存储器系统的实际实现是将多个硬件存储器和操作系统软件组合起来。
在整个编译的过程中,编译器会完成大部分的工作,把用C语言提供的比较抽象的执行模型表示的程序转化成处理器执行的非常基本的指令。汇编代码表示非常接近机器代码。与机器代码的二进制格式相比,汇编代码的主要特点是它用可读性更好的文本格式表示。
x86-64的机器代码和原始的C代码差别非常大,一些通常对C语言程序员隐藏的处理状态都是可见的:
- 程序计数器(通常称为PC,在x86-64中用%rip表示)给出将要执行的下一条指令在内存中的地址。
- 整数寄存器文件包含16个命名的位置,分别存储64位的值。这些寄存器可以存储地址(对应C语言中的指针)或整数数据。有的寄存器被用来记录某些重要的程序状态,而其他寄存器用来保存临时数据,例如过程的参数和局部变量,以及函数的返回值。
- 条件码寄存器保存着最近执行的算数或逻辑指令的状态信息。它们用来实现控制或数据流中的条件变化,比如说用来实现if和while语句。
- 一组向量寄存器可以存放一个或多个整数或浮点数值。
虽然C语言提供了一种模型,可以在内存中声明和分配各种数据类型的对象,但是机器代码只是将内存看成一个很大的、按字节寻址的数组。C语言的聚合数据类型,例如数组和结构,在机器代码中用一组连续的字节来表示。即使是对标量数据类型,汇编代码也不区分有符号或无符号整数,不区分各种类型的指针,甚至不区分指针和整数。
程序内存包含:程序的可执行机器代码,操作系统需要一些信息,用来管理过程调用和返回的运行时栈,以及用户分配的内存块(比如说用malloc库函数分配的)。正如前面提到的,程序内存用虚拟地址来寻址。在任意给定的时刻,只有有限的一部分虚拟地址被认为是合法的。例如,x86-64的虚拟地址是由64位的字来表示的。在目前的实现中,这些地址的高16位必须设置为0,所以一个地址实际上能指定的是248或64TB范围内的一个字节。较为典型的程序只会访问几兆字节或几千兆字节的数据。操作系统负责管理虚拟地址空间,将虚拟地址翻译成实际处理器内存中的物理地址。
一条机器指令只执行一个非常基本的操作。例如,将存放在寄存器中的两个数字相加,在存储器和寄存器之间传送数据,或是条件分支转移到新的指令地址。编译器必须告诉这些指令的序列,从而实现像表达式求值、循环或过程调用和返回这样的程序结构。
代码示例
long mult2(long,long); void multstore(long x, long y, long *dest) { long t = mult2(x, y); *dest = t; }
在命令行上使用-S选项,就能看到C语言编译器产生的汇编代码:
# gcc -Og -S mstore.c
这会使GCC运行编译器,产生一个汇编文件mstore.c,但是不做其他进一步的工作。汇编代码文件包含各种声明,包括下面几行:
multstore: pushq %rbx movq %rdx, %rbx call mult2 movq %rax, (%rbx) popq %rbx ret
上面的代码中每个缩进去的行都对应于一条机器指令。比如pushq指令表示应该将寄存器%rbx的内容压入程序栈中。这种代码中已经除去了所有关于局部变量名或数据类型的信息。
如果我们使用-c命令行选项来编译并汇编该代码:
# gcc -Og -c mstore.c # ll total 637312 …… -rw-r--r-- 1 root root 1368 Aug 7 14:59 mstore.o ……
这就会产生目标代码文件mstore.o,它是二进制格式的,所以无法直接查看。1368字节的文件mstore.o中有一段14字节的序列,它的十六进制表示为:
53 48 89 d3 e8 00 00 00 00 48 89 03 5b c3
这就是上面列出的汇编指令对应的目标代表。从中可以得到一个信息,即机器执行的程序只是一个字节序列,它是对一系列指令的编码。机器对产生这些指令的源代码几乎一无所知。
要查看机器代码文件的内容,有一类称为反汇编器的程序非常有用。这些程序根据机器代码产生一种类似于汇编代码的格式。在Linux系统中,带'-d'命令行标志的程序OBJDUMP(表示“object dump”)可以充当这个角色:
# objdump -d mstore.o mstore.o: file format elf64-x86-64 Disassembly of section .text: 0000000000000000 <multstore>: 0: 53 push %rbx 1: 48 89 d3 mov %rdx,%rbx 4: e8 00 00 00 00 callq 9 <multstore+0x9> 9: 48 89 03 mov %rax,(%rbx) c: 5b pop %rbx d: c3 retq
在左边,我们看到按照前面给出的字节顺序排列的14个十六进制字节值,它们分成若干组,每组有1~5个字节。每组都是一条指令,右边是等价的汇编语言。
其中一些关于机器代码和它的反汇编表示的特性值得注意:
- x86-64的指令长度从1到15个字节不等。常用的指令以及操作数较少的指令所需的字节数较少,而那些不太常用或操作数较多的指令所需的字节数较多。
- 设计指令格式的方式是,从某个给定的位置开始,可以将字节唯一地解码成机器指令。例如,只有指令pushq %rbx是以字节值53开头的。
- 反汇编器只是基于机器代码文件中的字节序列来确定汇编代码。它不需要访问该程序的源代码或汇编代码。
- 反汇编器使用的指令命名规则与GCC生成的汇编代码使用的有些细微的差别。在我们的示例中,它省略了很多指令结尾的'q'。这些后缀是大小指示符,在大多数情况下可以省略。相反,反汇编器给call和ret指令加上'q'后缀,同样,省略这些后缀也没问题。
生成可执行的代码需要对一组目标代码文件运行链接器,而这一组目标代码文件中必须含有一个main函数。假设在文件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; }
然后,我们用如下方法生成可执行文件prog:
# gcc -Og -o prog main.c mstore.c # ll total 637312 …… -rwxr-xr-x 1 root root 8616 Aug 7 15:55 prog
文件prog变成8616个字节,因为它不仅包含了两个过程的代码,还包含了用来启动和终止程序的代码,以及用来与操作系统交互的代码。我们也可以反汇编prog文件:
# objdump -d prog …… 0000000000400563 <mult2>: 400563: 48 89 f8 mov %rdi,%rax 400566: 48 0f af c6 imul %rsi,%rax 40056a: c3 retq 000000000040056b <multstore>: 40056b: 53 push %rbx 40056c: 48 89 d3 mov %rdx,%rbx 40056f: e8 ef ff ff ff callq 400563 <mult2> 400574: 48 89 03 mov %rax,(%rbx) 400577: 5b pop %rbx 400578: c3 retq 400579: 0f 1f 80 00 00 00 00 nopl 0x0(%rax) ……
<multstore>这段代码与mstore.c反汇编出来额代码几乎一模一样。其中一个主要的区别是左边列出的地址不同——链接器将这段代码的地址移到额一段不同的地址范围中。第二个不同之处在于链接器填上了callq指令调用函数mult2需要使用的地址(反汇编代码第40056f行)。链接器的任务之一就是为函数调用找到匹配函数的可执行代码的位置。最后一个区别是多了一行代码(第400579行),这条指令对程序没有影响,因为它们出现在返回指令的后面(第400578行)。
关于格式的注解
GCC产生的汇编代码对我们来说有点难读。一方面,它包含一些我们不需要关心的信息,另一方面,它不提供任何程序的描述或它是如何工作的描述。例如,假设我们用如下命令生成文件mstore.s。
# gcc -Os -S mstore.c # cat mstore.s .file "mstore.c" .text .globl multstore .type multstore, @function multstore: .LFB0: .cfi_startproc pushq %rbx .cfi_def_cfa_offset 16 .cfi_offset 3, -16 movq %rdx, %rbx call mult2 movq %rax, (%rbx) popq %rbx .cfi_def_cfa_offset 8 ret .cfi_endproc .LFE0: .size multstore, .-multstore .ident "GCC: (GNU) 4.8.5 20150623 (Red Hat 4.8.5-16)" .section .note.GNU-stack,"",@progbits
所有以'.'开头的行都是指导汇编器和链接器工作的伪指令。我们通常可以忽略这些行。另一方面,也没有关于指令的用途以及它们与源代码之间关系的解释说明。
为了更清楚地说明汇编代码,我们用这样一种格式来表示汇编代码,它省略了大部分伪指令,包括行号和解释性说明。对于我们的示例,带解释的汇编代码如下:
数据格式
由于是从16位体系结构扩展成32位,Intel用术语“字(word)”表示十六位数据类型。因此,称32位数为“双字(double words)”,称64位为“四字(quad words)”。图3-1给出了C语言基本数据类型对应的x86-64表示。标准int值存储为双字(32位)。指针(在此用char *表示)存储为8字节的四字,64位机器本来就预期如此。在x86-64中,数据类型long实现为64位,允许表示的值范围较大。下面的代码示例中大部分都使用了指针和long数据类型,所以都是四字操作。x86-64指令集同样包括完整的针对字节、字和双字的指令。
图3-1 C语言数据类型在x86-64中的大小。在64位机器中,指针长8字节
浮点数主要有两种形式:单精度(4字节)值,对应于C语言数据类型float;双精度(8字节)值,对应于C语言数据类型double。
如图所示,大多数GCC生成的汇编代码指令都有一个字符的后缀,表明操作数的大小。例如,数据传送指令有四个变种:movb(传送字节)、movw(传送字)、movl(传送双字)和movq(传送四字)。后缀'l'用来表示双字,因为32位数被看成是“长字(long word)”。注意,汇编代码也使用后缀'l'来表示4字节整数和8字节双精度浮点数。这不会产生歧义,因为浮点数使用的是一组完全不同的指令和寄存器。