第3章 程序的机器级表示
3.1 程序编码
1. 将源代码转化为可执行代码:首先,C预处理器扩展源代码,插入所有用 # include 声明指定的宏。然后,编译器产生源代码的汇编代码,名字为 .s。接下来,汇编器将汇编代码转化成二进制目标代码文件 .o。目标代码是机器代码的一种形式,它包含所有指令的二进制表示,但是还没有填入地址的全局值。最后,链接器将目标码文件与实现库函数(例如:printf)的代码合并,并产生最终的可执行文件p。
2. 计算机系统使用的两种抽象模型:第一种是机器级程序的格式和行为,定义为指令集体系结构,它定义了处理器状态、指令的格式,以及每条指令对状态的影响;第二种抽象是,机器级程序使用的存储器地址是虚拟地址,提供的存储器模型看上去是一个非常大的字节数组。
3. IA32机器代码中重要的处理器状态:
- 程序计数器(通常称为PC,用%eip表示)指示将要执行的下一条指令存储器中的地址。
- 整数寄存器文件包含8个命名的位置,分别存储32位的值。这些寄存器可以存储地址(对应于C语言的指针)或整数数据。有的寄存器被用来记录某些重要的程序状态,而其他的寄存器则用来保存临时数据,例如过程的局部变量和函数的返回值。
- 条件码寄存器保存着最近执行的算术或逻辑指令的状态信息。它们用来实现控制或数据流中的条件变化,比如说用来实现if和while语句。
- 一组浮点寄存器存放浮点数据。
4. 程序存储器包含:程序的可执行机器代码,操作系统需要的一些信息,用来管理过称调用和返回的运行时栈,以及用户分配的存储器块(比如说用malloc库函数分配的),程序存储器用虚拟地址来寻址。操作系统负责管理虚拟地址空间,将虚拟地址翻译成实际处理器存储器中的物理地址。
3.2 数据格式
1. Intel用术语“字”(word)表示16位数据类型,因此,成32位数为“双字”(double words),称64位数为“四字”(quad words)。C语言数据类型在IA32中的大小如下表所示:
3.3 访问信息
1. 一个IA32中央处理器包含一组8个存储32位值的寄存器。在大多数情况下,前6个寄存器都可以看成通用寄存器,对它们的使用没有限制。最后两个寄存器(%ebp和%esp)保存着指向程序栈中重要位置的指针:栈指针和帧指针。
2. 大多数指令有一个或多个操作数,指示出执行一个操作要引用的源数据值,以及放置结果的目标位置。各种不同的操作数的可能性被分为三种类型:第一种类型是立即数,也就是常数值;第二种类型是寄存器,它表示某个寄存器的内容,对双字操作来说,可以是8个32位寄存器中的一个,对字操作来说,可以是8个16位寄存器中的一个,或者对于字节操作来说,可以是8个单字节寄存器元素中的一个;第三类操作数是存储器引用,它会根据计算出来的地址访问某个存储器位置。
3. MOV类中的指令将源操作数的值复制到目的操作数中。源操作数指定的值是一个立即数,存储在寄存器中或者存储器中。目的操作数指定一个位置,要么是一个寄存器,要么是一个存储器地址。IA32加了一条限制,传送指令的两个操作数不能都指向存储器位置。将一个值从一个存储器位置复制到另一个存储器位置需要两条指令——第一条指令将原值加载到寄存器中,第二条将该寄存器值写入目的位置。
3.4 算术和逻辑操作
1. 加载有效地址指令leal是从存储器读数据到寄存器,但实际上它根本就没有引用存储器。它的第一个操作数看上去是一个寄存器引用,但该指令并不是从指定的位置读入数据,而是将有效地址写入到目的操作数。
2. 一元操作,只有一个操作数,既是源又是目的,这个操作数可以是一个寄存器,也可以是一个存储器位置;二元操作中,第二个操作数既是源又是目的,源操作数是第一个,目的操作数是第二个,第一个操作数可以是立即数、寄存器或是存储器位置,第二个操作数可以是寄存器或是存储器位置,两个操作数不能同时是存储器位置。
3. 移位操作的目的操作数可以是一个寄存器或是一个寄存器位置。
4. 无符号数乘法(mull)和补码乘法(imull),要求一个参数必须在寄存器%eax中,而另一个作为指令的源操作数给出,然后乘积存放在寄存器%edx(高32位)和%eax(低32位)中。
5. 有符号除法指令idivl将寄存器%edx(高32位)和%eax(低32位)中的64位数作为被除数,而除数作为指令的操作数给出。指令将商存储在寄存器%eax中,将余数存储在寄存器%edx中。
3.5 控制
1. 机器代码提供两种基本的低级机制来实现有条件的行为:测试数据值,然后根据测试的结果来改变控制流或者数据流。
2. 除了整数寄存器,CPU还维护着一组单个位的条件码寄存器,它们描述了最近的算术或逻辑操作的属性。可以检测这些寄存器来执行条件分支指令。最常用的条件码有:
- CF:进位标志。最近的操作使最高位产生了进位。可以用来检查无符号操作数的溢出。
- ZF:零标志。最近的操作得出的结果为0。
- SF:符号标志。最近的操作得到的结果为负数。
OF:溢出标志。最近的操作导致一个补码溢出——正溢出或负溢出。 3. leal指令不改变任何条件码,因为它是用来进行地址计算的,除此之外的所有指令都会设置条件码。其中,CMP指令根据它们的两个操作数之差来设置条件码,TEST指令行为与AND指令一样用来指示哪些位应该被测试,这两条指令都只设置条件码而不改变其他任何寄存器。
4. 条件码通常不会直接读取,常用的使用方法有三种:可以根据条件码的某个组合,将一个字节设置为0或者1;可以条件转移到程序的某个其他部分;可以有条件地传送数据。
5. 一条SET指令的目的操作数是8单个字节寄存器元素之一,或是存储一个字节的存储器位置,将这个字节设置成0或者1。
6. 在汇编代码中,跳转目标用符号标号书写。汇编器,以及后面的链接器,会产生跳转目标的适当编码。跳转指令有几种不同的编码,但是最常用的都是PC相关的:它们会将目标指令的地址与紧跟在跳转指令后面那条指令的地址之间的差作为编码,这些地址偏移量可以编码为1、2或4个字节。第二种编码方法是给出“绝对”地址,用4个字节直接指定目标。
7. 将条件表达式和语句从C语言翻译成机器代码,最常用的方式是结合有条件和无条件跳转。
8. C语言提供了多种循环结构,即do-while、while和for。汇编中没有相应的指令存在,可以用条件测试和跳转组合起来实现循环的效果。大多数汇编器根据一个循环的do-while形式来产生循环代码,即使在实际程序中这种形式用的相对较少。其他的循环会首先转换成do-while形式,然后再编译成机器代码。
9. 条件传送指令有两个操作数:源寄存器或者存储器地址S,和目的寄存器R。与不同的SET和跳转指令一样,这些指令的结果取决于条件码的值。源值可以从存储器或者源寄存器中读取,但是只有在满足指定的条件时,才被复制到目的寄存器中。
10. 和使用一组很长的if-else语句相比,使用跳转表的优点是执行开关语句switch的时间与开关情况的数量无关。GCC根据开关情况的数量和开关情况值的稀少程度来翻译开关语句。当开关情况数量比较多,并且值的范围跨度比较小时,就会使用跳转表。
3.6 过程
1. 一个过程调用包括将数据(以过程参数和返回值的形式)和控制从代码的一部分传递到另一部分。另外,它还必须在进入时为过程的局部变量分配空间,并在退出时释放这些空间。
2. IA32程序用程序栈来支持过程调用。机器用栈来传递过程参数、存储返回信息、保存寄存器用于以后恢复,以及本地存储。为单个过程分配的那部分栈称为栈帧。最顶端的栈帧以两个指针界定,寄存器%ebp为帧指针,而寄存器%esp为栈指针。当程序执行时,栈指针可以移动,因此大多数信息的访问都是相对于帧指针的。
3. 假设过程P(调用着)调用过程Q(被调用者),则Q的参数放在P的栈帧中。另外,当P调用Q时,P中的返回地址被压入栈中,形成P的栈帧的末尾。返回地址就是当程序从Q返回时应该继续执行的地方。Q的栈帧从保存的栈指针的值(例如寄存器%ebp的副本)开始,后面也是保存的其他寄存器的值。
4. call指令有一个目标,即指明被调用过程起始的指令地址。同跳转一样,调用可以是直接的,也可以是间接的。在汇编代码中,直接调用的目标是一个标号,而间接调用的目标是*后面跟一个操作数指示符。
5. call指令的效果是将返回地址入栈,并跳转到被调用过程的起始处。返回地址是在程序中紧跟在call后面的那条指令的地址,这样当被调用过程返回时,执行会从此处继续。ret指令从栈中弹出地址,并跳转到这个位置。
6. 根据惯例,寄存器%eax、%edx和%ecx被划分为调用者保存寄存器。当过程P调用过程Q时,Q可以覆盖这些寄存器,而不会破坏任何P所需要的数据。另一方面,寄存器%ebx、%esi和%edi被划分为被调用者保存寄存器。这意味着Q必须在覆盖这些寄存器的值之前,先把它们保存到栈中,并在返回前恢复它们,因为P可能会在今后的计算中需要这些值。此外,必须保持寄存器%ebp和%esp。
3.7 数组分配和访问
1. 对于数据类型T和整型常数N,声明:T A[N];有两个效果,首先,它在存储器中分配一个L*N字节的连续区域,这里L是数据类型T的大小(单位是字节),用xA来表示起始位置。其次,它引入了标识符A,可以用A作为指向数组开头的指针,这个指针的值就是xA,可以用从0到N-1之间的整数索引来访问数组元素,数组元素i会被存放在地址为xA+L*i的地方。
2. C语言允许对指针进行运算,而计算出来的值会根据该指针引用的数据类型的大小进行伸缩。也就是说,如果p是一个指向类型为T的数据指针,p的值为xp,那么表达式p+i的值为xp+L*i,这里L是数据类型T的大小。
3. 单操作数的操作符&和*可以产生指针和间接引用指针。也就是说,对于一个表示某个对象的表达式Expr,&Expr是给出该对象地址的一个指针,对于一个表示地址的表达式AExpr,*AExpr是给出该地址处的值。因此,表达式Expr与*&Expr是等价的,数组引用A[i]等同于表达式*(A+i)。
4. 要访问多维数组的元素,编译器会以数组起始为基地址,(可能需要经过伸缩的)偏移量为索引,产生计算期望的元素的偏移量,然后使用某种MOV指令。通常来说,对于一个数组声明如下:T D[R][C];它的数组元素D[i][j]的存储器地址为:&D[i][j]=xD+L(C*i+j),这里,L是数据类型T以字节为单位的大小。
3.8 异质的数据结构
1. C语言提供了两种结合不同类型的对象来创建数据类型的机制:结构(structure),用关键字structure声明,将多个对象集合到一个单位中;联合(union),用关键字union声明,允许用几种不同的类型来引用一个对象。
2. 类似于数组的实现,结构的所有组成部分都存放在存储器中一段连续的区域内,而指向结构的指针就是结构第一个字节的地址。编译器维护关于每个结构类型的信息,只是每个字段的字节偏移,它以这些偏移作为存储器引用指令中的位移,从而产生对结构元素的引用。
3. 对于类型union U3*的指针p,p->c、p->i[0]和p->v引用的都是数据结构的起始位置,还可以观察到,一个联合的总的大小等于它最大字段的大小。联合的一种应用情况是,我们事先知道对一个数据结构中的两个不同字段的使用是互斥的,那么将这两个字段声明为联合的一部分,而不是结构的一部分,以减小分配空间的总量。
4. 许多计算机系统对基本数据类型合法地址做出了一些限制,要求某种类型对象的地址必须是某个值K(通常是2、4、8)的倍数。这种对齐限制简化了形成处理器和存储器系统之间接口的硬件设计。确保每种数据类型都按照指定方式来组织和分配,即每种类型的对象都满足它的对齐限制,就可以保证实施对齐,编译器在汇编代码中放入命令,指明全局数据所需的对齐。
5. 分配存储器的库例程(例如malloc)的设计必须使它们返回的指针满足运行机器最糟糕情况下的对齐限制,通常是4或者8。对于包含结构的代码,编译器可能需要在字段的分配中插入间隙,以保证每个结构元素都满足它的对齐要求。
3.9 综合:理解指针
- 每个指针都对应一个类型,这个类型表明指针指向哪一类对象。特殊的void *类型代表通用指针,比如说,malloc函数返回一个通用指针,然后通过显式强制类型转换或者赋值操作那样的隐式强制类型转换,将它转换成一个有类型的指针。
- 每个指针都有一个值,这个值是某个指定类型对象的地址。特殊的NULL(0)值表示该指针没有指向任何地方。
- 指针用&运算符创建。因为leal指令是设计用来计算存储器引用的地址的,&运算符的机器代码实现常常用这条指令来计算表达式的值。
- 运算符*用于指针的间接引用,其结果是一个值,它的类型与该指针的类型相关。间接引用是通过存储器引用来实现的,要么是存储到一个指定的地址,要么是从指定的地址读取。
- 数组与指针紧密联系,一个数组的名字可以像一个指针变量一样引用。
- 将指针从一种类型强制转换成另一种类型,只改变它的类型,而不改变它的值。
指针也可以指向函数。这提供了一个很强大的存储和向代码传递引用的功能,这些引用可以被程序的某个其他部分调用。
3.10 存储器的越界引用和缓冲区溢出
1. 缓冲区溢出是指,通常,在栈中分配某个字节数组来保存一个字符串,但是字符串的长度超出了为数组分配的空间。
2. 缓冲区溢出的一个更加致命的使用就是让程序执行它本来不愿意执行的函数,这是一种最常见的通过计算机网络攻击系统安全的方法。通常,输入给程序一个字符串,这个字符串包含一些可执行代码的字节编码,称为攻击代码,另外,还有一些字节会用一个指向攻击代码的指针覆盖返回地址,那么,执行ret指令的效果就是跳转到攻击代码。一种攻击形式是,攻击代码会使用系统调用启动一个外壳程序,给攻击者提供一组操作系统函数;另一种攻击形式是,攻击代码会执行一些未授权的任务,修复对栈的破坏,然后第二次执行ret指令,(表面上)正常返回给调用者。
3. 对抗缓冲区溢出攻击的手段:栈随机化、栈破坏检测、限制可执行代码区域。
3.11 x86-64:将IA32扩展到64位
1. Intel和AMD提供的新硬件和以往这些机器为目标的GCC新版本的组合,使得x86-64代码与为IA32机器生成的代码有极大的不同:
- 指针和长整数是64位长。整数算术运算支持8、16、32和64位数据类型。
- 通用目的寄存器组从8个扩展到16个。
- 许多程序状态都保存在寄存器中,而不是栈上。整型和指针类型的过程参数(最多6个)通过寄存器传递,有些过程根本不需要访问栈。
- 如果可能,条件操作用条件传送指令实现,会得到比传统分支代码更好的性能。
- 浮点操作用面向寄存器的指令集来实现,而不用IA32支持的基于栈的方法来实现。
2. x86-64下的通用寄存器组,与IA32的寄存器相比:
- 寄存器的个数翻倍至16个。
- 所有的寄存器都是64位长。IA32寄存器的64位扩展分别命名为%rax、%rcx、%rdx、%rbx、%rsi、%rdi、%rsp和%rbp。新增加的寄存器命名为%r8~%r15。
- 可以直接访问每个寄存器的低32位。这就给我们IA32中熟悉的那些寄存器:%eax、%ecx、%edx、%ebx、%esi、%edi、%esp和%ebp,以及8个新32位寄存器:%r8d~%r15d。
- 同IA32中的情况一样,可以直接访问每个寄存器的低16位。新寄存器的字大小版本命名为%r8w~%r15w。
- 可以直接访问每个寄存器的低8位。在IA32中,只有对前4个寄存器(%al、%cl、%dl和%bl)才可以这样。其他IA32寄存器的字节大小版本命名%sil、%dil、%spl和%bpl。新寄存器的字节大小版本命名为%r8b~%r15b。
- 为了后向兼容性,具有单字节操作数的指令可以直接访问%rax、%rcx、%rdx和%rbx的第二个字节。
3. 寄存器%rsp有特殊的状态,它会保存指向栈顶元素的指针。与IA32不同的是,没有帧指针寄存器,可以用寄存器%rbp作为通用寄存器。
4. 从较小的数据大小传送到较大的数据大小可以用符号扩展(MOVS)或者零扩展(MOVZ)。