CS:APP CH03程序的机器级表示

计算机执行机器代码用字节序列编码低级的操作,包括处理数据、管理内存、读写存储设备上的数据,以及利用网络通信。

编译器基于编程语言的规则、操作系统的惯例、目标机器的指令集经过一系列的阶段生成机器代码。

GCC C语言编译器以汇编代码的形式产生输出,然后GCC调用汇编器链接器,根据汇编代码生成可执行的机器代码。

汇编代码是机器代码的一种形式,它是机器代码的文本表示。给出程序中的每一条指令。

高级代码可移植性好,而汇编代码与特定机器密切相关。

能够阅读汇编代码:

  • 好处:可以理解编译器的优化能力,并分析代码中隐含的低效率

  • 条件:了解编译器将高级语言转换为机器代码的转换方式。

相对于C代码表示的计算操作,优化编译器能够重新排列执行顺序,消除不必要的计算,用快速操作替换慢速操作,甚至将递归计算变换成迭代计算。源代码与对应的汇编代码的关系通常不太容易理解。这是一种逆向工程(reverse engineering)一一通过研究系统和逆向工作,来试图了解系统的创建过程。

精通细节很重要,是理解更深和更基本概念的先决条件。要认真研究示例、完成练习。

网络旁注ASM:IA32编程

IA32,x86-64的32位前身,是Intel在1985年提出的。几十年来一直是Intel的机器语言之选。今天出售的大多数x86微处理器,以及这些机器上安装的大多数操作系统,都是为运行x86-64设计的。不过,它们也可以向后兼容执行IA32程序。所以,很多应用程序还是基于IA32的。除此之外,由于硬件或系统软件的限制,许多已有的系统不能够执行x86-64。

32位机器可以使用约4GB(2^32字节)的随机访问存储器,64位机器可以使用256TB(2^48字节)的内存空间(这里说的是主存)。

3.1历史观点

"K"表示1000,"M"表示1 000 000,而"G"表示1 000 000 000

进化型设计

▪向后兼容:直到1978年推出的8086

▪随着时间的推移增加了更多的功能

▪现在有3卷,约5 000页的文件

旁注 摩尔定律(Moore'sLaw)

1965年,Gordon Moore,Intel公司的创始人

预测在未来10年,芯片上的晶体管数量每年都会翻一番。

3.2程序编码

假设一个C程序,有两个文件pl.c和p2.c。我们用Unix命令行编译这些代码:

linux> gcc -Og -o pp1.c p2.c

命令gcc指的就是GCC C编译器。因为这是Linux上默认的编译器,我们也可以简单地用cc来启动它。

编译选项-Og告诉编译器使用会生成符合原始C代码整体结构的机器代码的优化等级。使用较高级别优化产生的代码会严重变形,以至于产生的机器代码和初始源代码之间的关系非常难以理解。实际中,从得到的程序的性能考虑,较高级别的优化(例如,以选项-O1或-O2指定)被认为是较好的选择。

实际上gcc命令调用了一整套的程序,将源代码转化成可执行代码。

首先,C预处理器扩展源代码,插入所有用#include命令指定的文件,并扩展所有用#define声明指定的宏。其次,编译器产生两个源文件的汇编代码,名字分别为pl.s和p2.s。接下来,汇编器会将汇编代码转化成二进制目标代码文件p1.o和p2.o。汇编器产生的目标代码是机器代码的一种形式,它包含二进制形式表示的所有指令,但还没有填入全局值的地址。最后,链接器将两个目标代码文件与实现库函数(例如printf)的代码合并,并产生最终的可执行代码文件p(由命令行指示符-op指定的)

3.2.1机器级代码

计算机系统使用了多种不同形式的抽象,利用更简单的抽象模型来隐藏实现的细节。

对于机器级编程来说,其中两种抽象尤为重要。

第一种是指令集体系结构或指令集架构(InstructionSet Architecture,ISA)来定义机器级程序的格式和行为,它定义了处理器状态、指令的格式,以及每条指令对状态的影响。大多数ISA,包括x86-64,将程序的行为描述成好像每条指令都是按顺序执行的,一条指令结束后,下一条再开始。处理器的硬件远比描述的精细复杂,它们并发地执行许多指令,但是可以采取措施保证整体行为与ISA指定的顺序执行的行为完全一致。

第二种抽象是,机器级程序使用的内存地址是虚拟地址,提供的内存模型看上去是一个非常大的字节数组。存储器系统的实际实现是将多个硬件存储器和操作系统软件组合起来。

总结:影响机器级程序的两种抽象:

  • 指令集架构:定义了处理器状态、指令的格式、指令对状态的影响。

  • 虚拟地址:机器代码将内存看成一个按字节寻址的数组。

整个编译过程中,编译器将把用C语言提供的相对比较抽象的执行模型表示的程序转化成处理器执行的非常基本的指令。

汇编代码表示非常接近于机器代码。与机器代码的二进制格式相比,汇编代码的主要特点是它用可读性更好的文本格式表示

对机器代码可见(即对C语言程序员隐藏)的处理器状态:

  1. 程序计数器(通常称为"PC",在x86-64中用%rip表示)给出将要执行的下一条指令在内存中的地址。

  1. 整数寄存器文件:保存临时数据或重要的程序状态。包含16个命名的位置,分别存储8字节(64位)的值。这些寄存器可以存储地址(对应于C语言的指针)或整数数据。有的寄存器被用来记录某些重要的程序状态,而其他的寄存器用来保存临时数据,例如过程的参数和局部变量,以及函数的返回值。

  1. 条件码寄存器:保存最近执行的算术或逻辑指令的状态信息。它们用来实现控制或数据流中的条件变化,比如说用来实现if和while语句。

  1. 一组向量寄存器:保存一个或多个整数或浮点数值。

C语言中的数组和结构,在机器代码中用一组连续的字节来表示。

汇编代码不区分有符号数和无符号数,不区分指针的不同类型,不区分指针和整数。

程序内存包含:程序的可执行机器代码,操作系统需要的一些信息,用来管理过程调用和返回的运行时栈,以及用户分配的内存块(比如说用malloc库函数分配的)。

程序内存用虚拟地址来寻址。

在任意给定的时刻,只有有限的一部分虚拟地址被认为是合法的。例如,x86-64的虚拟地址是由64位的字来表示的。在目前的实现中,这些地址的高16位必须设置为0,所以一个地址实际上能够指定的是2^48或256TB范围内的一个字节。较为典型的程序只会访问几兆字节或几千兆字节的数据。

操作系统负责管理虚拟地址空间,将虚拟地址翻译成实际处理器内存中的物理地址。

一条机器指令只执行一个非常基本的操作。例如,将存放在寄存器中的两个数字相加,在存储器和寄存器之间传送数据,或是条件分支转移到新的指令地址。编译器必须产生这些指令的序列,从而实现(像算术表达式求值、循环或过程调用和返回这样的)程序结构。

3.2.2代码示例

在命令行上使用"-s"选项,就能看到C语言编译器产生的汇编代码:

linux> gcc -Og -S mstore.c

这会使GCC运行编译器,产生一个汇编文件mstore.s,但是不做其他进一步的工

作。mstore.c是一个C语言代码文件。

如果我们使用"-c"命令行选项,GCC会编译并汇编该代码:

linux> gcc -Og -c mstore.c

这就会产生目标代码文件mstore.o,它是二进制格式的,所以无法直接查看。

机器执行的程序只是一个字节序列,它是对一系列指令的编码。机器对产生这些指令的源代码几乎一无所知。

反汇编

使用反汇编器(disassembler)可以根据机器代码产生汇编代码。

这些程序根据机器代码产生一种类似于汇编代码的格式。在Linux系统中,带‘d’命令行标志的程序OBJDUMP(表示"object dump")可以充当这个角色:

linux> objdump -d mstore.o

机器代码与反汇编表示的特性:

  1. x86-64的指令长度范围为1~15字节。常用指令和操作数少的指令所需字节少。

  1. 设计指令格式的方式是,从某个给定位置开始,可以将字节唯一地解码成机器指令。例如,只有指令pushq %rbx是以字节值53开头的。

  1. 反汇编器只是基于机器代码文件中的字节序列来确定汇编代码。它不需要访问该程序的源代码或汇编代码。

  1. 反汇编器使用的指令命名规则与GCC生成的汇编代码使用的有些细微的差别。指令结尾的'q'是大小指示符,大多数情况下可以省略。

  1. 从十六进制字节值到汇编指令,格式为:某个数字唯一地对应某个汇编指令,比如mov指令以48开头。

用如下方法生成可执行文件prog:

linux> gcc -Og -o prog main.cmstore.c

从源程序转换来的可执行目标文件中,除了程序过程的代码,还包含启动和终止程序的代码,与操作系统交互的代码。

反汇编prog文件:

linux> objdump -d prog

反汇编器会抽取出各种代码序列,包括下面这段:

这段代码与mstore.c反汇编产生的代码几乎完全一样。其中一个主要的区别是左边列出的地址不同——链接器将这段代码的地址移到了一段不同的地址范围中。第二个不同之处在于链接器填上了callq指令调用函数mult2需要使用的地址(反汇编代码第4行)。链接器的任务之一就是为函数调用找到匹配的函数的可执行代码的位置。最后一个区别是多了两行代码(第8和9行)。这两条指令对程序没有影响,因为它们出现在返回指令后面(第7行)。插入这些nop指令是为了使函数代码变为16字节,使得就存储器系统性能而言,能更好地放置下一个代码块。

3.2.3关于格式的注解

在汇编代码中,以‘.’(点)开头的行是指导汇编器和链接器工作的伪指令,通常可以忽略。

旁注:ATT与Intel汇编代码格式

ATT(根据"AT&T"命名的,AT&T是运营贝尔实验室多年的公司)格式的汇编代码,这是GCC、OBJDUMP和其他一些我们使用的工具的默认格式

Microsoft的工具,以及来自Intel的文档,其汇编代码都是Intel格式的。

Intel和ATT格式在如下方面有所不同:

•Intel代码省略了指示大小的后缀。我们看到指令push和mov,而不是pushq和movq。

•Intel代码省略了寄存器名字前面的‘%’符号,用的是rbx,而不是%rbx。

•Intel代码用不同的方式来描述内存中的位置,例如是'QWORDPTR[rbx]'而不是’(%rbx)'

•在带有多个操作数的指令情况下,列出操作数的顺序相反

网络旁注 把C程序和汇编代码结合起来

对于一些应用程序,程序员必须用汇编代码来访问机器的低级特性。

每次x86-64处理器执行算术或逻辑运算时,如果得到的运算结果的低8位中有偶数个1,那么就会把一个名为PF的1位条件码(conditioncode)标志设置为1,否则就设置为0。这里的PF表示"parityflag(奇偶标志)”。在C语言中计算这个信息需要至少7次移位、掩码和异或运算(参见习题2.65)。即使作为每次算术或逻辑运算的一部分,硬件都完成了这项计算,而C程序却无法知道PF条件码标志的值。在程序中插入几条汇编代码指令就能很容易地完成这项任务。

在C程序中插入汇编代码有两种方法:

第一种是,我们可以用汇编代码编写完整的函数放进一个独立的汇编代码文件中,让汇编器和链接器把它和用C语言书写的代码合并起来

第二种方法是,我们可以使用GCC的内联汇编(inline assembly)特性,用asm伪指令可以在C程序中包含简短的汇编代码。这种方法的好处是减少了与机器相关的代码量。

在C程序中包含汇编代码使得这些代码与某类特殊的机器相关(例如x86-64),所以只应该在想要的特性只能以此种方式才能访问到时才使用它。

3.3数据格式

字节:byte,8位;字:word,16位;双字:double words,32位;四字:quad words,64位。

x86家族的微处理器历史上实现过对一种特殊的80位(10字节)浮点格式进行全套的浮点运算(参见家庭作业2.86)。可以在C程序中用声明long double来指定这种格式。不过我们不建议使用这种格式。它的移植性不好、相较其他数据类型不高效。

数据传送指令有四个变种:movb(传送字节)、movw(传送字)、movl(传送双字)和movq(传送四字)

后缀'l'用来表示双字,因为32位数被看成是“长字(longword)"。注意,汇编代码也使用后缀'l'来表示4字节整数和8字节双精度浮点数。这不会产生歧义,因为浮点数使用一组完全不同的指令和寄存器。

3.4访问信息

一个64位CPU中包含一组16个存储64位值的通用目的寄存器,用来存储整数和指针

它们的名字都以%r开头,不过后面还跟着一些不同的命名规则的名字,这是由于指令集历史演化造成的。最初的8086中有8个16位的寄存器,即图3-2中的%ax到%bp。每个寄存器都有特殊的用途,它们的名字就反映了这些不同的用途。扩展到IA32架构时,这些寄存器也扩展成32位寄存器,标号从%eax到%ebp。扩展到x86-64后,原来的8个寄存器扩展成64位,标号从%rax到%rbp。除此之外,还增加了8个新的寄存器,它们的标号是按照新的命名规则制定的:从%r8到%r15。

16个寄存器标号为rax~rbp,r8~r15

16个寄存器的低位部分都可以作为字节、字、双字、四字来单独访问。分别表示为al,ax,eax,rax(以第一行为例)。

最特别的是栈指针%rsp,用来指明运行时栈的结束位置。有些程序会明确地读写这个寄存器。

低位操作的规则:

  1. 将寄存器作为目标位置时,生成字节和字的指令会保持剩下的字节不变

  1. 生成双字的指令会把高位四字节置为0.

16个寄存器的作用

  1. rax:返回值

  1. rsp:栈指针

  1. rdi,rsi,rdx,rcx,r8,r9:第1到第6个参数

  1. rbx,rbp,r12~r15:被调用者保存

  1. r10,r11:调用者保存

3.4.1操作数指示符

指令的操作数有三种类型:立即数,寄存器,内存引用

最常用的寻址方式:Imm(rb,ri,s):Imm + rb + ri*s

s为比例因子,只能是1,2,4,8中的某一个

大多数指令有一个或多个操作数(operand),指示出执行一个操作中要使用的源数据值,以及放置结果的目的位置。x86-64支持多种操作数格式(参见图3-3)。

源数据值可以以常数形式给出,或是从寄存器或内存中读出。结果可以存放在寄存器或内存中。因此,各种不同的操作数的可能性被分为三种类型。

第一种类型是立即数(immediate),用来表示常数值。在ATT格式的汇编代码中,立即数的书写方式是‘$’后面跟一个用标准C表示法表示的整数,比如,$-577或$0x1F。不同的指令允许的立即数值范围不同,汇编器会自动选择最紧凑的方式进行数值编码。

第二种类型是寄存器(register),它表示某个寄存器的内容,16个寄存器的低位1字节、2字节、4字节或8字节中的一个作为操作数,这些字节数分别对应于8位、16位、32位或64位。在图3-3中,我们用符号ra

来表示任意寄存器a,用引用R[ra]来表示它的值,这是将寄存器集合看成一个数组R,用寄存器标识符作为索引。

第三类操作数是内存引用,它会根据计算出来的地址(通常称为有效地址)访问某个内存位置。因为将内存看成一个很大的字节数组,我们用符号Mb[Addr]表示对存储在内存中从地址Addr开始的b个字节值的引用。为了简便,我们通常省去下标b。

如图3-3所示,有多种不同的寻址模式,允许不同形式的内存引用。表中底部用语法Imm(rb,ri,s)表示的是最常用的形式。这样的引用有四个组成部分:一个立即数偏移Imm,一个基址寄存器rb,一个变址寄存器ri,和一个比例因子s,这里s必须是1、2、4或者8。基址和变址寄存器都必须是64位寄存器。有效地址被计算为Imm + R[rb] +R[ri] • s。引用数组元素时,会用到这种通用形式。其他形式都是这种通用形式的特殊情况,只是省略了某些部分。正如我们将看到的,当引用数组和结构元素时,比较复杂的寻址模式是很有用的。

3.4.2数据传送指令

操作数表示的通用性使得一条简单的数据传送指令能够完成在许多机器中要好几条不同指令才能完成的功能。

MOV类

MOV只会更新目的操作数指定的寄存器字节或内存位置。

MOV类是最简单的数据传送指令。这些指令把数据从源位置复制到目的位置,不做任何变化。MOV类由四条指令组成:movb、movw、movl和movq。这些指令都执行同样的操作;主要区别在于它们操作的数据大小不同:分别是l、2、4和8字节mov类有5种:

源操作数指定的值是一个立即数,存储在寄存器中或者内存中。

目的操作数指定一个位置,要么是一个寄存器或者,要么是一个内存地址。

x86-64加了一条限制,传送指令的两个操作数不能都指向内存位置,不能将值从内存复制到内存。将一个值从一个内存位置复制到另一个内存位置需要两条指令——第一条指令将源值加载到寄存器中,第二条将该寄存器值写入目的位置。

这些指令的寄存器操作数可以是16个寄存器有标号部分中的任意一个寄存器部分的大小必须与指令最后一个字符('b','w','l'或'q')指定的大小匹配。

MOV指令只会更新目的操作数指定的那些寄存器字节或内存位置。唯一的例外是movl指令以寄存器作为目的时,它会把该寄存器的高位4字节设置为0。造成这个例外的原因是x86-64采用的惯例,即任何为寄存器生成32位值的指令都会把该寄存器的高位部分置成0

下面的MOV指令示例给出了源和目的类型的五种可能的组合。记住,第一个是源操作数,第二个是目的操作数

  1. movb,movw,movl:传送字节、字、双字

  1. movq:传送四字。如果源操作数是立即数,只能是双字,然后符号扩展到四字(假的四字)

  1. movabsq:传送绝对的四字。只能以立即数作为源操作数,以寄存器为目的。可以传送任意64位立即数。

movq指令只能以表示为32位补码数字的立即数作为源操作数,然后把这个值符号扩展得到64位的值,放到目的位置。movabsq指令能够以任意64位立即数值作为源操作数,并且只能以寄存器作为目的。

总结与注意:

movq用来传送寄存器和内存引用中的四字,movabsq用来传送四字的立即数

mov类的源操作数和目的操作数不能同时为内存,即不能将值从内存复制到内存。

mov指令中寄存器的大小必须与mov的后缀字符大小匹配。

movz类

movz系列和movs系列可以把较小的源值复制到较大的目的,目的都是寄存器,源可以在寄存器或内存中。

movz将目的寄存器剩余字节做零扩展,movs做符号扩展

这两个类中每个都有三条指令,包括了所有的源大小为1个和2个字节、目的大小为2个和4个的情况

movz类:movzbw,movzbl,movzbq,movzwl,movzwq(movzbw即从字节复制到字,其他类似)

movs类:movsbw,movsbl,movsbq,movswl,movswq,movslq,cltq

没有movzlq,因为可以用以寄存器为目的的movl指令来实现。

cltq:没有操作数,将eax符号扩展到rax,等价于movslq%eax,%rax,优点是编码更紧凑。

3.4.3数据传送示例

数据传送如何改变目的寄存器的示例:

数据传送指令如何改变或者不改变目的的高位字节示例:

参数xp是一个指向long类型的整数的指针,而y是一个long类型的整数。语句

long gx =*xp;

表示我们将存储在xp所指位置中的值,并将它存放到名字为x的局部变量中。这个读操作称为指针的间接引用(pointer dereferencing),C操作符*执行指针的间接引用

语句

*xp = y;

正好相反——它将参数y的值写到xp所指的位置。这也是指针间接引用的一种形式(所以有操作符*),但是它表明的是一个写操作,因为它在赋值语句的左边。

C操作符&(称为“取址”操作符)创建一个指针。

C语言中所谓的”指针”其实就是地址。间接引用指针就是将该指针放在一个寄存器中,然后在内存引用中使用这个寄存器。

总结与注意:

局部变量通常保存在寄存器而不是内存中。访问寄存器比访问内存要快得多。

函数返回指令ret返回的值为寄存器rax中的值或该寄存器的某个低位部分。

强制类型转换是通过mov指令实现的。

当指针存在寄存器中时,a = *p的汇编指令为:mov (%rdi),%rax

3.4.4压入和弹出栈数据

栈可以实现为一个数组,总是从数组的一端插入和删除元素。这一端被称为栈顶

程序栈存放在内存中某个区域.

栈向下增长,栈顶的地址是栈中元素地址中最低的。栈指针%rsp保存栈顶元素的地址

出入栈指令:

  • pushq rax:压栈,栈指针减8并将rax中的值写入新的栈顶地址,等价于:

subq $8, %rsp
movq %rax,(%rsp)
  • popq rax:出栈,栈指针加8并将出栈的值写入rax中,等价于:

movq (%rsp),%rax
add $8,%rsp

使用mov指令和标准的内存寻址方法可以访问栈内的任意位置,而非仅限于栈顶。

3.5算术和逻辑操作

x86-64的整数和逻辑操作分为四组,四个操作类:加载有效地址、一元操作、二元操作和移位

每个指令类都有对应四种不同大小数据的指令(只有leaq没有其他大小的变种):b、w、l、q。

算术和逻辑操作共有四组:

  1. 加载有效地址

  1. leaqS,D:将S的地址保存到D中,D必须是寄存器

  1. 一元操作

  1. inc D: D+1

  1. decD:D-1

  1. negD:取负

  1. notD:取补

  1. 二元操作(加减乘,与或异或,没有除法)

  1. add s, d: d=d+s

  1. sub s, d: d=d-s

  1. imul s, d: d=d*s 乘

  1. xor s, d: d=d^s 异或

  1. or s, d: d=d|s 或

  1. and s,d: d=d&s 与

  1. 移位

  1. sal k,d: d=d<<k

  1. shl k,d: d=d<<k

  1. sar k,d: d=d>>k,算数右移

  1. shr k,d: d=d>>k,逻辑右移

3.5.1加载有效地址(load effective address)

leaq实际上是movq指令的变形。操作是从内存读数据地址到寄存器。

leaq在实际应用中常常不用来取地址,而用来计算加法和有限形式的乘法

leaq 9(%rdi, %rsi, 4), %rax;//x in %rdi,y in %rsi。

此操作实际上等于将x+4*y+9的结果存入%rax。

3.5.2一元和二元操作

一元操作中的操作数既是源又是目的,这个操作数可以是一个寄存器,也可以是一个内存位置

比如说,指令incq(%rsp)会使栈顶的8字节元素加1。类似于C语言中的++和--运算符。

二元操作中的第二个操作数既是源又是目的。第一个操作数可以是立即数、寄存器或是内存位置。第二个操作数可以是寄存器或是内存位置。

比如说,指令subq %rax,%rdx表示“从%rdx中减去%rax";

sub s,d是d-s而不是s-d

因为不能从内存到内存,因此当第二个操作数是内存地址时,要先从内存读出值,执行操作后再把结果写回去。

3.5.3移位操作

移位操作,第一项先给出移位量,然后第二项给出的是要移位的数。

移位操作的移位量可以是一个立即数或放在单字节寄存器%cl中(只允许以这个特定的寄存器作为操作数)。

当移位量大于目的数的长度时,只取移位量低字节中的值(小于等于目的数长度)来作为真实的移位量。(如salb,一个byte数据只有8位,故无论给出的位移量是多少,也只能最多移8位)

左移指令有两个名字:SAL和SHL。两者的效果是一样的,都是将右边填上0。

右移指令不同,SAR执行算术移位(填上符号位),而SHR执行逻辑移位(填上0)。

移位操作的目的操作数可以是一个寄存器或是一个内存位置。

用>>A(算术)和>>L(逻辑)来表示这两种不同的右移运算。

3.5.4讨论

只有右移操作要求区分有符号和无符号数。这个特性使得补码运算成为实现有符号整数运算的一种比较好的方法的原因之一。

汇编代码指令和C源代码行对应很紧密。

示例:

3.5.5特殊的算术操作(复杂)

两个64位数的乘积需要128位来表示,x86-64指令集可以有限的支持对128位数的操作,包括乘法和除法。Intel把16字节的数称为八字(octword)

上表有误:idivq和divq的第二行,目的地都是rax而非rdx;第一行的目的地才是rdx。

imulq指令有两种不同的形式。其中一种是IMUL指令类中的一种。这种形式的imulq指令是一个“双操作数"乘法指令。它从两个64位操作数产生一个64位乘积,实现了2.3.4和2.3.5节中描述的操作*u64*t64。(回想一下,当将乘积截取到64位时,无符号乘和补码乘的位级行为是一样的。)

此外,x86-64指令集还提供了两条不同的“单操作数”乘法指令,以计算两个64位值的全128位乘积————一个是无符号数乘法(mulq),而另一个是补码乘法(imulq)。这两条指令都要求一个参数必须在寄存器%rax中,而另一个作为指令的源操作数给出。然后乘积存放在寄存器%rdx(高64位)和%rax(低64位)中。虽然imulq这个名字可以用于两个不同的乘法操作,但是汇编器能够通过计算操作数的数目,分辩出想用哪条指令

示例:如何从两个无符号64位数字x和y生成128位的乘积:

解读:上面的代码显式地把x和y声明为64位的数字,使用文件inttypes.h中声明的定义,这是对标准C扩展的一部分。不幸的是,这个标准没有提供128位的值。所以我们只好依赖GCC提供的128位整数支持,用名字__intl28来声明。代码用typedef声明定义了一个数据类型uintl28_t,沿用的inttypes.h中其他数据类型的命名规律。这段代码指明得到的乘积应该存放在指针dest指向的16字节处。

解读:存储乘积需要两个movq指令:一个存储低8个字节(第4行),一个存储高8个字节(第5行)。由于生成这段代码针对的是小端法机器,所以高位字节存储在大地址,正如地址8(%rdi)表明的那样。

除法或取模操作是由单操作数除法指令来提供的,类似于单操作数乘法指令。有符号除法指令将寄存器%rdx(高64位)和%rax(低64位)中的128位数作为被除数,而除数作为指令的操作数给出。指令将商存储在寄存器%rax中,将余数存储在寄存器%rdx中。

对于大多数64位除法应用来说,除数也常常是一个64位的值。这个值应该存放在%rax中,%rdx的位应该设置为全0(无符号运算)或者%rax的符号位(有符号运算)。后面这

个操作可以用指令cqto来完成。这条指令不需要操作数,因为它隐含读出%rax的符号位,

并将它复制到%rdx的所有位。

128位数需要两个寄存器来存储,移动时也需要两个movq指令来移动。

这种情况对于有符号数和无符号数采用了不同的指令。

3.6控制

条件语句、循环语句、分支语句都要求有条件的执行。

机器代码提供两种低级机制来实现有条件的行为:

  1. 测试数据值,然后根据测试的结果来改变控制流或数据流

  1. 使用jump指令进行跳转

3.6.1条件码

条件码寄存器(condition code)都是单个位的,是不同于整数寄存器的另一组寄存器。

条件码描述了最近的算术或逻辑操作的属性,可以通过检测这些寄存器来执行条件分支指令。

常用条件码:

  1. CF:进位标志。最近的操作使最高位产生了进位。可以用来检查无符号数的溢出

  1. ZF:零标志。最近的操作的结果为0

  1. SF:符号标志。最近的操作的结果为负数。

  1. OF:溢出标志。最近的操作导致了补码溢出(正负皆可能)

举例:

用一条ADD指令完成等价于C表达式t=a+b的功能,这里a、b和t都是整型的。然后,根据下面的C表达式来设置条件码:

除了leaq指令外,其余的所有算术和逻辑指令都会根据运算结果设置条件码。

leaq指令不改变任何条件码,因为它是用来进行地址计算的。

对于逻辑操作,例如XOR,进位标志和溢出标志会设置成0

对于移位操作,进位标志将设置为最后一个被移出的位,而溢出标志设置为0

INC和DEC指令会设置溢出和零标志,但是不会改变进位标志。

此外还有两类特殊的指令,他们只设置条件码不更新目的寄存器:

  • cmp s1,s2:除了不更新目的寄存器外与sub指令的行为相同

  • 如果两个操作数相等,这些指令会将零标志设置为1,而其他的标志可以用来确定testq测试四字两个操作数之间的大小关系。

  • tests1,s2:除了不更新目的寄存器外与and指令的行为相同

  • 典型的用法是,两个操作数是一样的(例如,testq %rax,%rax用来检查%rax是负数、零,还是正数),或其中的一个操作数是一个掩码,用来指示哪些位应该被测试。

  • 检查原理:如果%rax是正数,SF置0;%rax是负数,SF置1.从而实现检查。等价于cmpq $0,%rax。

3.6.2访问条件码

条件码一般不直接读取,常用的使用方法有3种:

  1. 根据条件码的某种组合,使用set指令类将一个字节设置为0或1。图3-14中描述的指令根据条件码的某种组合,将一个字节设置为0或者1。我们将这一整类指令称为SET指令;它们之间的区别就在于它们考虑的条件码的组合是什么,这些指令名字的不同后缀指明了它们所考虑的条件码的组合。这些指令的后缀表示不同的条件(考虑的条件码的组合)而不是操作数大小。指令setl和setb表示“小于时设置(set less)"和“低于时设置(set below)"

  1. 条件跳转到程序的某个其他部分

  1. 有条件地传送数据

set指令类

set指令的目的操作数是低位单字节寄存器元素或一个字节的内存位置。set会将该字节设置为0或1

为了得到一个32位或64位结果,我们必须对高位清零。

一个计算C语言表达式a<b的典型指令序列如下所示,这里a和b都是long类型:

解析:

注意cmpq指令的比较顺序(第2行)。虽然参数列出的顺序先是%rsi(b)再是%rdi(a),实际上比较的是a和b。

movzbl指令不仅会把%eax的高3个字节清零,还会把整个寄存器%rax的高4个字节都清零。

某些底层的机器指令可能有多个名字,我们称之为“同义名(synonym)"。比如说,setg(表示“设置大于")和setnle(表示“设置不小于等于")指的就是同一条机器指令。编译器和反汇编器会随意决定使用哪个名字。

各个SET命令的描述都适用的情况是:执行比较指令,根据计算t=a-b设置条件码。更具体地说,假设a、b和t分别是变量a、b和t的补码形式表示的整数,因此t=a-twb,这里w取决于a和b的大小。

注意到图3.14中,set指令对于大于、小于的比较分为了有符号和无符号两类。

大多数时候,机器代码对无符号和有符号两种情况使用一样的指令(因为许多算术运算对无符号和补码算术都有一样的位级行为)。

使用不同指令来处理无符号和有符号操作的情况:

  1. 不同的条件码组合:

  1. 不同版本的右移:sar和shr

  1. 不同的乘法和除法指令

汇编语言中数据本身不区分有符号和无符号,通过不同的指令来区分有符号操作和无符号操作。

注意在汇编代码中,8字节的操作数可能是long,longlong或指针

3.6.3跳转指令

跳转指令的目的地由一个标号指明

jmp.L1          //跳转到.L1
movq(rax),rdx   //本行被跳过,直接前往.L1处
.L1:
popq rdx

在实际的跳转指令中,.L1会直接编码为跳转目标的地址。

jmp可以是直接跳转,即操作数为标号。也可以间接跳转,即操作数是寄存器或内存引用,这种情况下跳转到寄存器中存储的地址处。

跳转指令分为有条件跳转和无条件跳转,只有jmp是无条件跳转。有条件跳转都只能是直接跳转。

有条件跳转类似set指令系列,根据条件码寄存器的值来判断是否进行跳转。

3.6.4跳转指令的编码

跳转指令的机器编码(就是纯粹数字表示的机器语言)有几种方式,其中两种如下:

  • PC相对跳转(PC-relative):使用目标地址与跳转指令之后下一条指令的地址之间的差来编码。可以用1、2或4个字节来编码。

  • 绝对地址编码:使用目标的绝对地址。用4个字节直接指出。

汇编器和链接器会自己选择适当的编码方式

当执行PC相对寻址时,程序计数器的值是跳转指令后面的那条指令的地址,而不是跳转指令本身的地址。因为早期的处理器会将更新程序计数器作为执行一条指令的第一步。

旁注 指令rep和repz

repz是rep的同义名,而retq是ret的同义名。

rep指令是一种空操作,除了能使代码在AMD上运行得更快之外,不会改变代码的其他行为。所以在本书后面其他代码中再遇到rep或repz时,我们可以很放心地无视它们。

3.6.5用条件控制来实现条件分支

汇编代码层面的条件控制类似于c语言的goto语句。

汇编语言使用条件码和条件跳转来起到和c语言中if相似的作用

'C语言'
if( x<y ) { 
i++; 
} else { 
i--;
} 
'汇编' 
cmpq rsi,rdi 
jge .L2 
incl rax
.L2: 
decl rax 

C语言中的if-else语句的通用形式模板如下左图:

汇编实现通常会使用下面右图这种形式,这里,我们用C语法来描述控制流:

汇编器为then-statement和else-statement产生各自的代码块。它会插入条件和无条件分支,以保证能执行正确的代码块。

3.6.6用条件传送来实现条件分支

控制的条件转移:这种机制简单而通用,但是在现代处理器上,它可能会非常低效

一种替代的策略是使用数据的条件转移。这种方法计算一个条件操作的两种结果,然后再根据条件是否满足从中选取一个。只有在一些受限制的情况中,这种策略才可行,但是如果可行,就可以用一条简单的条件传送指令来实现它,条件传送指令更符合现代处理器的性能特性。

处理器通过使用流水线(pipelining)来获得高性能。

在流水线中,一条指令的处理要经过一系列的阶段,每个阶段执行所需操作的一小部分(例如,从内存取指令、确定指令类型、从内存读数据、执行算术运算、向内存写数据,以及更新程序计数器)。

这种方法通过重叠连续指令的步骤来获得高性能,例如,在取一条指令的同时,执行它前面一条指令的算术运算。

要做到这一点,要求能够事先确定要执行的指令序列,这样才能保持流水线中充满了待执行的指令。

当机器遇到条件跳转(也称为“分支")时,只有当分支条件求值完成之后,才能决定分支往哪边走。处理器采用非常精密的分支预测逻辑来猜测每条跳转指令是否会执行。只要它的猜测还比较可靠(现代微处理器设计试图达到90%以上的成功率),指令流水线中就会充满着指令。

另一方面,错误预测一个跳转,要求处理器丢掉它为该跳转指令后所有指令己做的工作,然后再开始用从正确位置处起始的指令去填充流水线。正如我们会看到的,这样一个错误预测会招致很严重的惩罚,浪费大约15~30个时钟周期,导致程序性能严重下降。

源和目的的值可以是16位、32位或64位长。不支持单字节的条件传送。

汇编器可以从目标寄存器的名字推断出条件传送指令的操作数长度,所以对所有的操作数长度,都可以使用同一个的指令名字。

同条件跳转不同,处理器无需预测测试的结果就可以执行条件传送。

举例:

v = test-expr ? then-expr: else-expr;

使用条件传送的错误例子:测试为真时,将会间接引用指针,否则不引用指针,因为指针为空。但是如果采用条件传送,将两个引用都执行,将会导致一个间接引用空指针的错误。所以,必须用分支代码来编译这段代码。

使用条件传送也不总是会提高代码的效率。例如,如果then-expr或者else-expr的求值需要大量的计算。

编译器必须考虑浪费的计算和由于分支预测错误所造成的性能处罚之间的相对性能才能做到总是提高效率。事实上,编译器的能力不足以考虑这些。

实践表明,只有当两个表达式都很容易计算时,例如表达式分别都只是一条加法指令,它才会使用条件传送。

3.6.7循环

C语言有三种循环结构,即do-while、while和for。

汇编中没有相应的指令存在,可以用条件测试和跳转组合起来实现循环的效果

1.do-while循环

旁注:逆向工程循环

关键:找到程序值和寄存器之间的映射关系。

通用的策略:看看在循环之前如何初始化寄存器,在循环中如何更新和测试寄存器,以及在循环之后又如何使用寄存器。

2.while循环

将while循环翻译成机器代码方法很多,GCC使用其中的两种方法。它们实现初始测试的方法不同。

第一种翻译方法:跳转到中间(jump to middle)

GCC带优化命令行选项-Og时使用。

它执行一个无条件跳转跳到循环结尾处的测试,以此来执行初始的测试。可以用以下模板来表达这种方法,这个模板把通用的while循环格式翻译到goto代码:

第二种翻译方法:guarded-do

首先用条件分支,如果初始条件不成立就跳过循环,把代码变换为do-while循环。当使用较高优化等级编译时,例如-01,GCC会采用这种策略。

一个有趣的特性:汇编代码的循环测试从原始C代码的n>1变成了n≠1。编译器知道只有当n>1时才会进入循环,所以将n减1意味着n>1或者n=1。因此,测试n≠1就等价于测试n≤1。

3.for循环

3.6.8switch语句

switch(开关)语句根据一个整数索引值进行多重分支(multiway branching)。在处理具有多种可能结果的测试时特别有用。

提高了C代码的可读性,而且通过使用跳转表(jump table)这种数据结构使得实现更加高效。

跳转表是一个数组,表项i是一个代码段的地址,这个代码段实现当开关索引值等于i时程序应该采取的动作。程序代码用开关索引值来执行一个跳转表内的数组引用,确定跳转指令的目标。为了这个扩展,GCC的作者们创造了一个新的运算符&&,这个运算符创建一个指向代码位置的指针。

和使用一组很长的if-else语句相比,使用跳转表的优点是执行开关语句的时间与开关情况的数量无关

GCC根据开关情况的数量和开关情况值的稀疏程度来翻译开关语句。当开关情况数量比较多(例如4个以上),并且值的范围跨度比较小时,就会使用跳转表。

在汇编代码中,跳转表用以下声明表示,我们添加了一些注释:

这些声明表明,在叫做".rodata"(只读数据,Read-Only Data)的目标代码文件的段中,应该有一组7个“四”字(8个字节),每个字的值都是与指定的汇编代码标号(例如.L3)相关联的指令地址。标号.L4标记出这个分配地址的起始。与这个标号相对应的地址会作为间接跳转(第5行)的基地址。

3.7过程

过程是软件中一种很重要的抽象,提供了一种封装代码的方式,用一组指定的参数和一个可选的返回值实现了某种功能。可以在程序中不同的地方调用这个函数。

不同编程语言中过程的形式不同:

函数(function)、方法(method)、子例程(subroutine)、处理函数(handler)等等,但是它们有一些共有的特性。

假设过程P调用过程Q,Q执行后返回到P。

这些动作包括下面一个或多个机制:

传递控制。在进入过程Q的时候,程序计数器必须被设置为Q的代码的起始地址,然后在返回时,要把程序计数器设置为P中调用Q后面那条指令的地址。

传递数据。P必须能够向Q提供一个或多个参数,Q必须能够向P返回一个值。

分配和释放内存。在开始时,Q可能需要为局部变量分配空间,而在返回前,又必须释放这些存储空间。

x86-64的过程实现遵循最低要求策略的方法,只实现上述机制中每个过程所必需的那些,尽量减少过程调用的开销。

3.7.1运行时栈

C语言过程调用机制的关键特性:使用了栈数据结构提供的后进先出的内存管理原则

过程P调用过程Q时,当Q在执行时,P以及所有在向上追湖到P的调用链中的过程,都是暂时被挂起的。当Q运行时,它只需要为局部变量分配新的存储空间,或者设置到另一个过程的调用。

当Q返回时,任何它所分配的局部存储空间都可以被释放。

x86-64的栈向低地址方向增长,而栈指针%rsp指向栈顶元素

pushq和popq指令将数据存入栈中或是从栈中取出

栈指针减小一个适当的量可以为没有指定初始值的数据在栈上分配空间。增加栈指针来释放空间。

当×86-64过程需要的存储空间超出寄存器能够存放的大小时,就会在栈上分配空间。这个部分称为过程的栈帧(stack frame)。

当前正在执行的过程的帧总是在栈顶

P调用Q,将Q返回时回到P的地址压栈,这个地址是P的栈帧的一部分。

Q的代码会扩展当前栈的边界,分配它的栈帧所需的空间。Q可以用这个空间可保存寄存器的值,分配局部变量空间,为它调用的过程设置参数。

大多数过程的栈帧都是定长的,在过程的开始就分配好了。

通过寄存器,过程P可以传递最多6个整数值(也就是指针和整数),但是如果Q需要更多的参数,P可以在调用Q之前在自己的栈帧里存储好这些参数。

当过程所有的局部变量都可以保存在寄存器中(有6个或者更少的参数),而且该函数不会调用任何其他函数(有时称之为叶子过程,此时把过程调用看做树结构)时,就不会创建栈帧,因为没必要。

3.7.2转移控制

将控制从函数P转移到函数Q:把程序计数器(PC)设置为Q的代码的起始位置即可。稍后从Q返回的时候,处理器必须记录好它需要继续P的执行的代码位置。

x86-64机器中,上述动作用指令call Q调用过程Q来记录的。

call指令会把地址A压入栈中,并将PC设置为Q的起始地址。压入的地址A被称为返回地址,是紧跟在call指令后面的那条指令的地址。对应的指令ret会从栈中弹出地址A,并把PC设置为A。

下表给出的是call和ret指令的一般形式:

程序OBJDUMP产生的反汇编输出中被称为callq和retq。添加的后缀'q'只是为了强调这些是x86-64版本的调用和返回,而不是IA32的。在x86-64汇编代码中,这两种版本都行,可以互换。

3.7.3数据传送

过程调用还可能包括:把数据作为参数传递,而从过程返回还有可能包括返回一个值

x86-64中,可以通过寄存器最多传递6个整型(例如整数和指针)参数。

寄存器的使用是有特殊顺序的,第1~6个参数都有固定的寄存器。不同大小的参数用对应大小的寄存器。例如,如果第一个参数32位的,那么可以用%edi来访问它。

如果一个函数有大于6个整型参数,超出6个的部分就要通过栈来传递

假设过程P调用过程Q,有n个整型参数,且n>6。那么P的代码分配的栈帧必须要能容纳7到n号参数的存储空间,在“参数构造区”中分配空间。

参数1~6复制到对应的寄存器,把参数7~n放到栈上,而参数7位于栈顶。通过栈传递参数时,所有的数据大小都向8的倍数对齐。

3.7.4栈上的局部存储

有些时候,局部数据必须存放在内存中,常见的情况包括:

寄存器不足够存放所有的本地数据。

•对一个局部变量使用地址运算符'&',因此必须能够为它产生一个地址。

•某些局部变量是数组或结构,因此必须能够通过数组或结构引用被访问到。

过程通过减小栈指针在栈上分配空间。分配的结果作为栈帧的一部分,标号为“局部变量”。

3.7.5寄存器中的局部存储空间

寄存器组是唯一被所有过程共享的资源。

虽然在给定时刻只有一个过程是活动的,我们仍然必须确保当一个过程(调用者)调用另一个过程(被调用者)时,被调用者不会覆盖调用者稍后会使用的寄存器值。

为此,x86-64有一组统一的寄存器使用惯例,所有的过程(包括程序库)都必须遵循:寄存器%rbx、%rbp和%r12~%r15被划分为被调用者保存寄存器。当过程P调用过程Q时,Q必须保存这些寄存器的值,保证它们的值在Q返回到P时与Q被调用时是一样的。

过程Q保存一个寄存器的值不变的方法:

1根本不去改变它

2把原始值压入栈中,改变寄存器的值,然后在返回前从栈中弹出旧值。压入寄存器的值会在栈帧中创建标号为“保存的寄存器”的一部分。

除了栈指针%rsp,所有其他的寄存器都分类为调用者保存寄存器。这就意味着任何函数都能修改它们。

正确理解与用法:过程P在某个调用者保存寄存器中有局部数据,然后调用过程Q。因为Q可以随意修改这个寄存器,所以在调用之前首先保存好这个数据是P(调用者)的责任。

3.7.6递归过程

递归调用一个函数本身与调用其他函数是一样的。

根据栈规则:每次函数调用都有它自己私有的状态信息(保存的返回位置和被调用者保存寄存器的值)存储空间。

如果需要,它还可以提供局部变量的存储。

栈分配和释放的规则与函数调用-返回的顺序匹配。这种实现函数调用和返回的方法甚至对更复杂的情况也适用,包括相互递归调用(例如,过程P调用Q,Q再调用P)。

3.8数组分配和访问

C语言中的数组是一种将标量数据聚集成更大数据类型的方式

C语言的一个不同寻常的特点是可以产生指向数组中元素的指针,并对这些指针进行运算。在机器代码中,这些指针会被翻译成地址计算

优化编译器非常善于简化数组索引所使用的地址计算。不过这使得C代码和它到机器代码的翻译之间的对应关系有些难以理解。

3.8.1基本原则

数据类型T和整型常数N,声明如下:

T A[N];

令起始位置是xA,L是数据类型T的大小(单位为字节)。这个声明有两个效果:

1它在内存中分配一个L*N字节的连续区域

2引入标识符A。A是指向数组开头的指针,这个指针的值就是xA。可以用0~N-1的整数索引来访问该数组元素。数组元素i会被存放在地址为xA+L*i的地方

例如,假设E是一个int型的数组,要计算E[i]。

在此,E的地址存放在寄存器%rdx中,而i存放在寄存器%rcx中。然后,指令

movl (%rdx, %rcx, 4),%eax

会执行地址计算xE+4i,读这个内存位置的值,并将结果存放到寄存器%eax中。允许的伸缩因子1、2、4和8覆盖了所有基本简单数据类型的大小。

3.8.2指针运算

C语言允许对指针进行运算,而计算出来的值会根据该指针引用的数据类型的大小进行伸缩。

解读:p是一个指向类型为T的数据的指针,p的值为xP,那么表达式p+i的值为xP+L•i。

单操作数操作符’&’和'*'可以产生指针和间接引用指针。

解读:Expr是表示某个对象的表达式,

&Expr给出该对象地址的一个指针。

AExpr是表示地址的表达式,

*AExpr给出该地址处的值。

因此,表达式Expr与*&Expr是等价的

可以对数组和指针应用数组下标操作。

解读:数组引用A[i]等同于表达式*(A+i)。它计算第i个数组元素的地址,然后访问这个内存位置。

一组例子:假设整型数组E的起始地址和整数索引i分别存放在寄存器%rdx和%rcx中。结果存放在寄存器%eax(如果是数据)或寄存器%rax(如果是指针)中。

解读:最后一个例子表明可以计算同一个数据结构中的两个指针之差,结果的数据类型为long,值等于两个地址之差除以该数据类型的大小。

3.8.3嵌套的数组

声明

int A[5][3]; 

理解1:等价于声明

typedef int row3_t[3];
row3_t A[5];

数据类型row3_t被定义为一个3个整数的数组。

数组A包含5个这样的元素,需要12个字节来存储3个整数。

整个数组的大小是4*5*3=60字节。

理解2:数组A可以被看成一个5行3列的二维数组,用A[0][0]到A[4][2]来引用。数组元素在内存中按照“行优先”的顺序排列,意味着第0行的所有元素,可以写作A[0],后面跟着第1行的所有元素(A[1]),以此类推。这种排列顺序是嵌套声明的结果。

理解3:将A看作一个有5个元素的数组,每个元素都是3个int的数组,首先是A[0],然后是A[1],以此类推。

访问多维数组的元素,编译器会以数组起始为基地址,(可能需要经过伸缩的)偏移量为索引,产生计算期望的元素的偏移量,然后使用某种MOV指令。

举例1:对于一个声明如下的数组:

    T D[R][C];

它的数组元素D[i][j];的内存地址为

    &D[i][j] = x0 + L(C • i + j)

举例2:可以用下面的代码将数组元素A[i][j]复制到寄存器%eax中:

这段代码计算的地址是:

xA + 12i + 4j = xA + 4(3i+8)

3.8.4定长数组

优化等级设置为-O1时GCC对定长多维数组上的操作采用的一些优化(这些优化一般在遍历数组的循环中多见):

举例:

原代码使用A[i][j]来获得数组元素值,GCC去掉整数索引j,并把所有的数组引用都转换成了指针间接引用,其中包括

(1)生成一个指针,命名为Aptr,指向A的行i中连续的元素;

(2)生成一个指针,命名为Bptr,指向B的列k中连续的元素;

(3)生成一个指针,命名为Bend,当需要终止该循环时,它会等于Bptr的值。

3.8.5变长数组

历史上:C语言只支持大小在编译时就能确定的多维数组(定长数组)(对第一维可能有些例外)。需要变长数组用malloc或calloc函数为这些数组分配存储空间,并且需要显式地编码,即用行优先索引将多维数组映射到一维数组。

ISOC99新功能:允许数组的维度是表达式,数组被分配的时候才计算出来。

解读:C99之后这样声明变长数组

int A[expr1][expr2]

它可以作为一个局部变量,或函数参数,遇到这个声明的时候,再当场计算表达式expr1和expr2来确定数组的维度。确定后再使用数组。

变长数组地址的计算类似于定长数组的地址计算,不同点在于:

1)由于增加了参数n,寄存器的使用变化了

2)用了乘法指令来计算n*i,而不是用leaq指令来计算3i。

循环中引用变长数组和定长数组风格不同:

1)保留了循环变量j,用以判定循环是否结束、作为到A的行i的元素组成的数组的索引。

2)如果允许使用优化,GCC能够识别出程序访问多维数组的元素的步长(一行或一列所占的空间)。生成的代码会避免每次都计算偏移量再引用而导致的乘法。

3.9异质的数据结构

C语言提供了两种将不同类型的对象组合到一起创建数据类型的机制:

结构(structure),用关键字struct来声明,将多个对象集合到一个单位中;

联合(union),用关键字union来声明,允许用几种不同的类型来引用一个对象。

3.9.1结构

C语言的struct声明创建一个数据类型,将可能不同类型的对象聚合到一个对象中。用名字来引用结构的各个组成部分。

结构的所有组成部分都存放在内存中一段连续的区域内。

指向结构的指针就是结构第一个字节的地址。

编译器指示每个字段(field)的字节偏移。它以这些偏移作为内存引用指令中的位移,从而产生对结构元素的引用。

要产生一个指向结构内部对象的指针,我们只需将结构的地址加上该字段的偏移量。

结构的各个字段的选取完全是在编译时处理的。机器代码不包含关于字段声明或字段名字的信息。

3.9.2联合

以多种类型来引用一个对象。也就是用不同的字段来引用相同的内存块。

联合的应用场景之一:事先知道对一个数据结构中的两个不同字段的使用是互斥的,那么将这两个字段声明为联合的一部分,而不是结构的一部分,会减小分配空间的总量

问题:无法判断一个联合节点具体用的是哪个类型。

解决:引入一个枚举类型,定义这个联合中可能的不同选择,然后再创建一个结构,包含一个标签字段和这个联合:

对于有较多字段的数据结构,这样做节省空间的效果会非常明显。

联合的应用场景之二:访问不同数据类型的位模式

强转:同值 不同位表示

联合:不同值 同位表示

访问方法1:使用简单的强制类型转换将一个double类型的值d转换为unsigned long类型的值u,值u会是d的整数表示。除了d的值为0.0的情况以外,u的位表示会与d的很不一样

unsigned long u = (unsigned long) d;

访问方法2:在这段代码中,我们以一种数据类型来存储联合中的参数又以另一种数据类型来访问它。结果会是u具有和d一样的位表示,包括符号位字段、指数和尾数,如3.11节中描述的那样。u的数值与d的数值没有任何关系,除了d等于0.0的情况。

注意:当用联合来将各种不同大小的数据类型结合到一起时,字节顺序问题就变得很重要了。

举例:以两个unsigned形成数组word[2],再以double读出这个数组:在x86-64等小端法机器上,参数word0是d的低位4个字节,而word1是高位4个字节。在大端法机器上,这两个参数的角色刚好相反。

3.9.3数据对齐

许多计算机系统要求某种类型对象的地址必须是某个值K(通常是2、4或8)的倍数。

对齐限制简化了形成处理器和内存系统之间接口的硬件设计。

例如,假设一个处理器总是从内存中取8个字节,则地址必须为8的倍数。如果我们能保证将所有的double类型数据的地址对齐成8的倍数,那么就可以用一个内存操作来读或者写值了。否则,我们可能需要执行两次内存访问,因为对象可能被分放在两个8字节内存块中。

无论数据是否对齐,x86-64硬件都能正确工作。不过对齐数据可以提高内存系统的性能。

对齐原则:任何K字节的基本对象的地址必须是K的倍数。

编译器在汇编代码中放入命令,指明全局数据所需的对齐。如:

.align 8

这个命令可以保证它后面的数据的起始地址是8的倍数。

对于结构体,编译器可能需要在字段的分配中插入间隙,以保证每个结构元

素都满足它的对齐要求。而结构本身对它的起始地址也有一些对齐要求。

举例:第一个存储方案按照最小空间存储,是不满足对齐要求的,只有像第二个方案这样填充3字节才能满足4字节对齐要求。如果结构体的首地址也是4的倍数,那么就完全满足了4字节对齐要求。

整个结构体的大小必须满足4字节倍数要求,所以有时会在结构体末尾进行填充,如第三张图。

旁注 强制对齐的情况

某些型号的Intel和AMD处理器对于实现多媒体操作的SSE指令,如果数据没有对齐就无法正确执行。SSE指令对16字节数据块进行操作,在SSE单元和内存之间传送数据的指令要求内存地址必须是16的倍数。任何试图以不满足对齐要求的地址未访问内存都会导致异常,默认的行为是程序终止。

因此,任何针对x86-64处理器的编译器和运行时系统都必须保证:分配用来保存可能会被SSE寄存器读或写的数据结构的内存,都必须满足16字节对齐。这个要求有两个后果:

任何内存分配函数(alloca、malloc、calloc或realloc)生成的块的起始地址都必须是16的倍数。

•大多数函数的栈帧的边界都必须是16字节的倍数。

3.10在机器级程序中将控制与数据结合起来

3.10.1理解指针

1.每个指针都对应一个类型。这个类型表明该指针指向的是哪一类对象。如果对象类型为T,那么指针的类型为T*。以下面的指针声明为例:

int *ip;
char **cpp;

变量ip是一个指向int类型对象的指针。

cpp指针指向的对象自身就是一个指向char类型对象的指针。

void*类型代表通用指针。比如,malloc函数返回一个通用指针,然后通过显式强制类型转换或者赋值操作那样的隐式强制类型转换,将它转换成一个有类型的指针。

2.每个指针都有一个值。这个值是某个指定类型的对象的地址。

NULL(0)表示该指针没有指向任何地方。

3.指针用‘&’运算符创建。&可用于任何可以出现在赋值语句左边的表达式,包括变量以及结构、联合和数组的元素。

&运算符的机器代码实现常常用leaq指令来计算表达式的值。

4.*操作符用于间接引用指针。其结果是一个与该指针的类型一致的值。间接引用是用内存引用来实现的,要么是存储到一个指定的地址,要么是从指定的地址读取。

5.数组与指针紧密联系。一个数组的名字可以像一个指针变量一样引用(但是不能修改)。

举例:数组引用和指针运算+间接引用等价。如:a[3]和*(a+3)等价。

p + i的地址计算为p + L·i。

6.将指针从一种类型强制转换成另一种类型,只改类型,不改值。

强制类型转换的一个效果是改变指针运算的伸缩。例如,如果p是一个char*类型的指针,它的值为p,那么表达式(int*)p+7计算为p+28,而(int*)(p+7)计算为p+7。(注意:强制类型转换的优先级高于加法。)

7.指针也可以指向函数。(下面的“给C语言初学者”有详细讲解)这提供了一个很强大的存储和向代码传递引用的功能,这些引用可以被程序的某个其他部分调用。

例如,如果我们有一个函数,用下面这个原型定义:

int fun(int x, int *p);

然后声明一个指针fp,将它赋值为这个函数:

int (*fp)(int, int*);
fp = fun;

然后用这个指针来调用这个函数:

int y = 1;
int result= fp(3, &y);

函数指针的值是该函数机器代码表示中第一条指令的地址。

给C语言初学者 函数指针

int (*f)(int*);

要从里(从"f"开始)往外读。

"(*f)"表明f是一个指针;

"(*f)(int*)"表明f是一个指向函数的指针,这个函数以一个int*作为参数,返回值是int。

总结:f是指向以int*为参数并返回int的函数的指针。

注意:*f两边的括号是必需的,否则声明变成

int *f(int*);

它会被解读成

(int*)f(int*);

即:它会被解释成一个函数原型,声明了一个函数f,它以一个int*作为参数并返回一个int*。

3.10.2应用:使用GDB调试器

先运行OBJDUMP来获得程序的反汇编版本。

用下面的命令行来启动GDB:

linux> gdb prog

3.10.3内存越界引用和缓冲区溢出

事实1:C对于数组引用不进行任何边界检查。

事实2:局部变量和状态信息(例如保存的寄存器值和返回地址)都存放在栈中。

这两种情况结合到一起就能导致严重的程序错误,对越界的数组元素的写操作会破坏存储在栈中的状态信息。

缓冲区溢出(bufferoverflow)

在栈中分配某个字符数组来保存一个字符串,但是字符串的长度超出了为数组分配的空间。

举例:库函数gets从标准输入读入一行,在遇到一个回车换行字符或某个错误情况时停止。它将这个字符串复制到参数指明的位置,并在字符串结尾加上null字符。gets的问题是它没有办法确定是否为保存整个字符串分配了足够的空间。

解读:echo函数声明了buf字符串,长度为8。但是可以看到:在这里,只要输入字符数量少于24个,其实都不会有问题。但是如果多于23个,会把返回地址、caller等信息冲击掉了。

解决:使用fgets函数,它包括一个参数,限制待读入的最大字节数。

很多常用的库函数,包括strcpy、strcat和sprintf,都不需要告诉它们目标缓冲区的大小,就产生一个字节序列,可能会导致缓冲区溢出漏洞。

缓冲区溢出的一个更加致命的使用就是让程序执行它本来不愿意执行的函数。这是一种最常见的通过计算机网络攻击系统安全的方法。

攻击方式1:输入给程序一个字符串,这个字符串包含一些可执行代码的字节编码,称为攻击代码(exploitcode)。字符串的有一些字节用一个指向攻击代码的指针覆盖返回地址。那么,执行ret指令的效果就是跳转到攻击代码。

攻击方式2:攻击代码使用系统调用启动一个shell程序,给攻击者提供一组操作系统函数

攻击方式3:攻击代码会执行一些未授权的任务,然后修复对栈的破坏,最后第二次执行ret指令,(表面上)正常返回到调用者。

所以,任何到外部环境的接口都应该是“防弹的",这样,外部代理的行为才不会导致系统出现错误。

旁注 蠕虫和病毒

蠕虫和病毒都试图在计算机中传播它们自己的代码段。

蠕虫(worm)可以自己运行,并且能够将自己的等效副本传播到其他机器。

病毒(virus)能将自己添加到包括操作系统在内的其他程序中,但它不能独立运行。大众媒体中,“病毒”用来指各种在系统间传播攻击代码的策略,所以你可能会听到人们把本来应该叫做"蠕虫”的东西称为“病毒”。

3.10.4对抗缓冲区溢出攻击

本节介绍一些Linux上最新GCC版本所提供的对抗机制。

1.栈随机化

存在问题安全单一化(security monoculture)

过去,对于所有运行同样程序和操作系统版本的不同的机器之间,栈的位置是相当固定的。因此,攻击者在一个机器上确定栈的地址,可以轻松攻击进其他机器。

解决思路:栈随机化的思想使得栈的位置在程序每次运行时都有变化。即使许多机器都运行同样的代码,它们的栈地址各不相同。

实现方式:程序开始时,在栈上分配一段0~n字节之间的随机大小的空间,例如,使用分配函数alloca在栈上分配指定字节数量的空间。

程序不使用这段空间,但是它会导致程序每次执行时后续的栈位置发生了变化。分配的范围n要大到能获得足够多的栈地址变化,但同时小到不至于浪费太多的空间。

32位Linux分配大概223空间,而64位Linux分配大概232空间。

地址空间布局随机化(Address-SpaceLayout Randomization),或者简称ASLR。采用ASLR,每次运行时程序的不同部分,包括程序代码、库代码、栈、全局变量和堆数据,都会被加载到内存的不同区域。栈随机化属于ASLR的一小部分。

新的攻击思路:蛮力克服随机化。反复地用不同的地址进行攻击。

一种实现方法:在实际的攻击代码前插入很长一段的nop指令(读作"no op",no operation的缩写)。执行这种指令除了对程序计数器加一,使之指向下一条指令之外,没有任何的效果。只要攻击者能够猜中这段序列中的某个地址,程序就会经过这个序列,到达攻击代码。这个序列常用的术语是空操作雪橇(nop sled)”(sledge,雪橇),意思是程序会“滑过”这个序列。如果我们建立一个256个字节的nop sled,那么枚举2^15 = 32768个起始地址,就能破解n=2^23的随机化,完全可行。对于64位的情况,比较困难。

栈随机化和其他一些ASLR技术能够增加攻击难度,降低病毒或者蠕虫的传播速度,但是也不能提供完全的安全保障。

2.栈破坏检测

存在问题:C语言中,没有可靠的方法来防止对数组的越界写。

解决思路:在发生了越界写的时候,在造成任何有害结果之前,尝试检测到它。

具体实现:栈保护者(stack protector)机制,用来检测缓冲区越界。在栈帧中任何局部缓冲区与栈状态之间存储一个特殊的金丝雀(canary)值,也称为哨兵值(guard value)。

金丝雀值在程序每次运行时随机产生,攻击者没有简单的办法能够知道它是什么。

在恢复寄存器状态和从函数返回之前,程序检查这个金丝雀值是否被该函数的某个操作或者该函数调用的某个函数的某个操作改变了。如果是的,那么程序异常中止。

最近的GCC版本会试着确定一个函数是否容易遭受栈溢出攻击,并且自动插入这种溢出检测。现在如果想参观栈溢出的状态,不得不用命令行选项

-fno-stack-protector

来阻止GCC产生金丝雀值。

3限制可执行代码区域

解决思路:消除攻击者向系统中插入可执行代码的能力。限制哪些内存区域能够存放可执行代码。

原理讲解:

1虚拟内存空间在逻辑上被分成了页(page),典型的每页是2048或者4096个字节。

2硬件支持多种形式的内存保护,能够指明用户程序和操作系统内核所允许的访问形式。许多系统允许控制三种访问形式:读(从内存读数据)、写(存储数据到内存)和执行(将内存的内容看作机器级代码)。

以前,x86将读和执行合并成一个1位的标志,任何被标记为可读的页也都是可执行的。栈必须是既可读又可写的,因而栈上的字节也都是可执行的。有方法能够限制页可读但不可执行,问题是性能损失很大。

现在,引入了"NX"(No-Execute,不执行)位,将读和执行访问模式分开。栈可以被标记为可读和可写,但是不可执行,而检查页是否可执行由硬件来完成,效率上没有损失。

注意:有些类型的程序要求动态产生和执行代码的能力。例如,”即时(just-in-time)”编译技术为解释语言(例如Java)编写的程序动态地产生代码,以提高执行性能。是否能够将可执行代码限制在由编译器在创建原始程序时产生的那个部分中,取决于语言和操作系统。

具体实现:只有保存编译器产生的代码的那部分内存才需要是可执行的。其他部分可以被限制为只允许读和写。

3.10.5支持变长栈帧

有些函数,需要的局部存储是变长的。

例如,标准库函数alloca可以在栈上分配任意字节数量的存储;声明一个局部变长数组。

为了管理变长栈帧,x86-64使用寄存器%rbp作为帧指针(frame pointer),有时称为基指针(base pointer)(bp由来)。当使用帧指针时,栈帧的组织结构与图3-44中函数vframe的情况一样。

3.11浮点代码(略)

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值