程序的机器级表示
计算机执行机器代码,用字节序列编码低级的操作,包括处理数据、管理存储器、读写存储设备上的数据,以及利用网络通信。编译器基于编程语言的原则、目标机器的指令集和操作系统遵循的规则,经过一系列的阶段产生机器代码。
GCC C语言编译器以汇编代码的形式产生输出,汇编代码是机器代码的文本表示,给出程序中的每一条指令。然后GCC调用汇编器和链接器,从而根据汇编代码生成可执行的机器代码。在本章中,我们会近距离地观察机器代码,以及人类可读的表示-——汇编代码。
高级语言编程,机器会屏蔽程序实现的细节, 且我们使用起来不容易出错,为什么我们还需要花时间学习机器代码?
- 对于严谨的程序员来说,能够阅读和理解汇编代码仍是一项很重要的技能。以适当的命令行选项调用编译器,编译器就会产生一个以汇编代码形式表示的输出文件。通过阅读这些汇编代码,我们能够理解编译器的优化能力,并分析代码中隐含的低效率。试图最大化一段关键代码的性能的程序员,通常会尝试源代码的各种形式,每次编译并检查产生的汇编代码,从而了解程序将要运行的效率如何。
- 高级语言提供的抽象层会隐藏我们想要了解的有关程序运行时行为的信息。例如,当用线程包写并发程序时,知道存储器保存不同的程序变量的区域是很重要的。这些信息在汇编代码级是可见的。
- 另外再举一个例子,程序遭受攻击(使得蠕虫和病毒能够侵扰系统)的许多方式中,都涉及程序存储运行时控制信息方式的细节。许多攻击利用了系统程序中的漏洞重写信息,从而获得系统的控制权。了解这些漏洞是如何出现的以及如何防御它们,需要具备程序机器级表示的知识。
程序员学习汇编代码的需求随着时间的推移也发生了变化,开始时只要求程序员能直接用汇编语言编写程序,现在则是要求他们能够阅读和理解编译器产生的代码。
我们将详细学习两种特别的汇编语言: 了解如何将 C程序编译成这些形式的机器代码。阅读编译器产生的汇编代码,需要具备的技能不同于手工编写汇编代码。我们必须了解典型的编译器在将 C程序结构变换成机器代码时所做的转换。相对于C代码表示的计算操作,优化编译器能够重新排列执行顺序,消除不必要的计算,用快速操作替换慢速操作,甚至将递归计算变换成迭代计算。源代码与对应的汇编码的关系通常不太容易理解——就像要拼出的拼图与盒子上图片的设计不太一样。
学习建议: 精通细节是理解更深合更基本概念的先决条件。 “理解一般规则,而不愿意劳神学习细节”实际上是自欺欺人。
本文讲解事项点:
- 基于两种机器语言 InterIA32 和 X86-64。前者是计算机主导语言,后者是64位机器上运行的扩展。
- 先浏览C语言,汇编语言和机器代码的关系,然后介绍IA32的细节,从数据的表示和处理以及控制的实现开始, 之后会讲到过程的实现,如果维护运行栈来支持过程间数据和控制的传递,以及局部空间的存储。紧接着会考虑在机器级别实现数组,结构和联合这样的数据结构,结尾,我们将给出GDB调试器检查机器级程序运行时行为的技巧。
历史观点
Linux使用了平坦寻址方式将整个存储空间看成一个大的字节数组。
程序编码
编译选项 -01 告诉编译器使用第一级别的优化, 提高优化级别会使最终程序运行得更快,但编译时间更长,从得到的程序性能方面考虑,第二级别的优化 -02 是被认为较好的选择。
实际上gcc命令调用了一系列程序,使得源代码转化为可执行的代码。
- C预处理器扩展源代码,插入所有用 #include命令指定的文件,并扩展 #define声明指定的宏
- 编译器产生两个源代码的汇编代码,名字为 p1.s, p2.s
- 汇编器将汇编代码转换为二进制目标文件名为 p1.o和 p2.o, 目标文件是机器代码的一种形式,包含所有指令的二进制表示,但是还没有填入地址的全局值。
- 最后链接器将两个目标代码文件与实现库函数的代码合并,并产生最终的可执行代码文件p。
1. 机器级代码
机器级编程,其中两种抽象尤为重要。
- 机器级别程序的格式和行为,定义为指令集体系结构(Instruction set archiecture, ISA) ,定义了处理器状态,指令的格式,以及每条指令对状态的影响。
- 机器级程序使用的存储器地址是虚拟地址,提供的存储器模型看上去是一个非常大的字节数组。
在整个编译过程中,编译器会完成大部分的工作,将把用C语言提供的相对比较抽象的执行模型表示的程序转化成处理器执行的非常基本的指令。汇编代码有一个的主要特点,即它用可读性更好的文本格式来表示。能够理解汇编代码以及它与原始C代码的联系,是理解计算机如何执行程序的关键一步。
IA32机器代码和原始C代码的差别非常大,一些通常对C语言程序员隐藏的处理器状态是可见的。
- 程序计数器(在IA32中,通常称为“PC”,用 %eip表示)指示将要执行的下一条指令在存储器中的地址。
- 整数寄存器文件包含8个命名的位置,分别存储32位的值。这些寄存器可以存储地址(对应于C语言的指针)或整数数据。有的寄存器被用来记录某些重要的程序状态,而其他的寄存器则用来保存临时数据,例如过程的局部变量和函数的返回值。
- 条件码寄存器保存着最近执行的算术或逻辑指令的状态信息。它们用来实现控制或数据流中的条件变化,比如说用来实现if和while语句。
- 一组浮点寄存器存放浮点数据。
2. 代码示例
int accum = 0;
int sum(int x, int y) {
int t = x + y;
accum += t;
return t;
}
命令上使用 -S 能得到C语言编译器产生的汇编代码 code.s :
gcc -O1 -S code.c
以上代码中每个缩进去的行都对应一条机器指令。比如,pushl指令表示应该将寄存器 % ebp 的内容压入程序栈。这段代码中已经除去了所有关于局部变量名或数据类型的信息。我们还看到了一个对全局变量 accum 的引用,这是因为编译器还不能确定这个变量会放在存储器中的哪个位置。
使用 -c 命令行选项, GCC会编译并汇编该代码 生成 code.o :
gcc -O1 -c code.c
如何找到程序的字节表示? 可以利用反汇编器,根据目标代码生成类似汇编代码的格式。 在Linux中 带 -d 命令行标志的程序
OBJDUMP可以充当这个角色。
我们看到按照前面的字节顺序排列的17个十六进制字节值,它们分成了几组,每组有1~6个字节。每组都是一条指令,右边是等价的汇编语言。
其中一些关于机器代码和它的反汇编表示的特性值得注意:
- IA32指令长度从1到 15个字节不等。常用的指令以及操作数较少的指令所需的字节数少,而那些不太常用或操作数较多的指令所需字节数较多。 ·
- 设计指令格式的方式是,从某个给定位置开始,可以将字节唯一地解码成机器指令。例如,只有指令 pushl %ebp是以字节值55开头的。
- 反汇编器只是基于机器代码文件中的字节序列来确定汇编代码。它不需要访问程序的源代码或汇编代码。
- 反汇编器使用的指令命名规则与GCC生成的汇编代码使用的有些细微的差别。在我们的示例中,它省略了很多指令结尾的’i’。这些后缀是大小指示符,在大多数情况下可以忽略。
生成实际可执行的代码需要对一组目标代码文件运行链接器, 这一组目标代码文件必须有一个main函数, 定义一个main.c文件,里面有这样的函数:
int main() {
return sum(1, 3);
}
gcc -O1 -o prog code.o main.c
文件prog变成了9123个字节,因为它不仅包含两个过程的代码,还包含了用来启动和终止程序的信息,以及用来与操作系统交互的信息。我们也可以反汇编prog文件∶
这段代码与code.c反汇编产生的代码几乎完全一样。
- 其中一个主要的区别是左边列出的地址不同——链接器将代码的地址移到一段不同的地址范围中。
- 第二个不同之处在于链接器确定了存储全局变量 accum的地址。在code.o反汇编代码的第6行,accum的地址还是0。在prog 的反汇编代码中,地址就设成了0x804a018。这可以从指令的汇编代码格式中看到。还可以从指令的最后4个字节中看出来,从最低位到最高位列出就是18 a0 04 08。
数据格式
由于是从16位体系结构扩展成32 位的,Intel用术语"字"(word)表示16 位数据类型。因此,称32位数为"双字"double words),称64位数为"四字"(quad words)。我们后面遇到的大多数指令都是对字节或双字操作的。
如图所示,大多数GCC生成的汇编代码指令都有一个字符后缀,表明操作数的大小。例如,数据传送指令有三个变种; movb(传送字节)、movw(传送字)和movl(传送双字)。后缀’l’用来表示双字,因为将32位数看成是"长字"(long word),这是由于沿用了16 位字为标准那个时代的习惯。
注意的是:汇编代码也使用后缀’l’来表示4字节整数和8字节双精度浮点数。这不会产生歧义,因为浮点数使用的是一组完全不同的指令和寄存器。
访问信息
一个IA32中央处理单元(CPU)包含一组8个存储32位值的寄存器。这些寄存器用来存储整数数据和指针。
图3-2显示了这8个寄存器。它们的名字都以%e开头。在大多数情况,前6 个寄存器都可以看成通用寄存器,对它们的使用没有限制。我们说“在大多数情况”,是因为有些指令以固定的寄存器作为源寄存器和/或目的寄存器。另外,在过程处理中,对前3个寄存器(%eax、%ecx和 %edx)的保存和恢复惯例不同于接下来的三个寄存器(%ebx、%edi 和 %esi)。我们后续对此加以讨论。最后两个寄存器(%ebp和%esp)保存着指向程序栈中重要位置的指针。只有根据栈管理的标准惯例才能修改这两个寄存器中的值。
如图3-2所示,字节操作指令可以独立地读或者写前4个寄存器的2个低位字节。8086中提供这样的特性是为了后向兼容8008和 8080-—两款可以追溯到1974年的微处理器。
- 当一条字节指令更新这些单字节"寄存器元素"中的一个时,该寄存器余下的3个字节不会改变。
- 类似地,字操作指令可以读或者写每个寄存器的低16位。这个特性源自IA32从16 位微处理器演化而来的这个传统,当对大小指示符为 short 的整数进行运算时,也会用到这些特性。
1. 操作数指示符
大多数指令有一个或多个操作数(operand),指示出执行一个操作中要引用的源数据值,以及放置结果的目标位置。
IA32支持多种操作数格式(参见图3-3)。源数据值可以以常数形式给出,或是从寄存器或存储器中读出。结果可以存放在寄存器或存储器中。因此,各种不同的操作数的可能性被分为三种类型。
- 第一种类型是立即数(immediate),也就是常数值。在 ATT格式的汇编代码中,立即数的书写方式是 s 后面跟一个用标准C表示法表示的整数,比如,'$-577’或$0xlF。任何能放进一个32位的字里的数值都可以用做立即数,不过汇编器在可能时会使用一个或两个字节的编码。
- 第二种类型是寄存器(register),它表示某个寄存器的内容,对双字操作来说,可以是8个32位寄存器中的一个(例如,%eax),对字操作来说,可以是8个16 位寄存器中的一个(例如,%ax),或者对字节操作来说,可以是8个单字节寄存器元素中的一个(如%al)。在图3-3中,我们用符号Ea 来表示任意寄存器a,用引用R[ Ea ]来表示它的值,这是将寄存器集合看成一个数组 R,用寄存器标识符作为索引。
- 第三类操作数是存储器(memory)引用,它会根据计算出来的地址(通常称为有效地址)访问某个存储器位置。因为将存储器看成一个很大的字节数组,我们用符号Mb【Addr】表示对存储在存储器中从地址 Addr开始的b个字节值的引用。为了简便,我们通常省去下方的b。
如图所示,有多种不同的寻址模式,允许不同形式的存储器引用。表中底部用语法Imm(Eb, Ei, s)表示的是最常用的形式。这样的引用有四个组成部分: 一个立即数偏移 Imm,一个基址寄存器Eb,一个变址寄存器Ei 和一个比例因子s,这里s必须是1、2、4或者8。然后,有效地址被计算为Imm + R【Eb】+ R【Ei】·s<