CSAPP(深入理解计算机系统)——第3章 程序的机器级表示 笔记

第3章 程序的机器级表示

3.1 历史观点

3.2 程序编码

  • 假设一个C程序,有两个文件p1.c和p2.c。我们用Unix命令行编译这些代码:gcc -Og -o p p1.c p2.c
  • 命令gcc指的就是GCC C编译器,因为这是Linux上默认的编译器,我们也可以简单地用cc来启动它。编译选项-Og告诉编译器使用会生成符合原始C代码整体结构的机器代码的优化等级。使用较高级别优化产生的代码会严重变形,以至于产生的机器代码和初始源代码之间的关系非常难以理解。实际中,从得到的程序的性能考虑,较高级别的优化(例如,以选项-O1或-O2指定)被认为是较好的选择。
  • 实际上gcc命令调用了一整套程序将源代码转化为可执行代码。首先,C预处理器扩展源代码,插入所有用#include命令指定的文件,并扩展所有用#define声明指定的宏。其次编译器产生的两个源文件的汇编代码名字分别为p1.s p2.s。接下来,汇编器会将汇编代码转化为二进制目标文件p1.o p2.o 。目标代码是机器代码的一种形式,它包含所有指令的二进制表示,但是还没有填入全局值的地址。最后链接器将两个目标代码文件与实现库函数的代码合并,并产生最终的可执行代码文件p

3.2.1 机器级代码

  • 计算机系统使用了多种不同形式的抽象,利用更简单的抽象模型来隐藏实现的细节。对于机器级编程来说,其中两种抽象尤为重要,第一种是由指令集体系结构或指令集架构来定义机器级程序的格式和行为,它定义了处理器状态、指令的格式,以及每条指令对状态的影响。第二种抽象是,机器级程序使用的内存地址是虚拟地址,提供的内存模型看上去是一个非常大的字节数组,存储器系统的实际实现时将多个硬件存储器和操作系统软件组合起来。
  • x86-64的机器代码和原始的C代码差别非常大,一些通常对C语言程序员隐藏的处理器状态都是可见的:
    • 程序计数器(用%rip表示)给出将要执行的下一条指令在内存中的地址
    • 整数寄存器文件包括16个命名的位置,分别存储64位的值,这些寄存器可以存储地址或整数数据,有的寄存器用来记录某些重要的程序状态,而其他的存储器用来保存临时数据,例如过程的参数和局部变量,以及函数的返回值
    • 条件码寄存器保存最近执行的算术或逻辑指令的状态信息,它们用来实现控制或数据流中条件变化
    • 一组向量寄存器可以存放一个或多个整数或浮点数值
  • 程序内存包含:程序的可执行机器代码,操作系统需要的一些信息,用来管理过程调用和返回的运行时栈,以及用户分配的内存块

3.2.2 代码示例

  • 假如我们写了一个C语言代码文件mstore.c,包含如下的函数定义:
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.s,但是不做其他进一步的工作。(通常情况下,它还会继续调用汇编器产生目标代码文件)
  • 汇编代码文件包含各种生命,包含下面几行:
multstore:
	pushq %rbx
	movq &rdx, %rbx
	call mult2
	movq %rax, (%rbx)
	popq %rbx
	ret
  • 上面的代码中每个缩进去的行都对应一条机器指令
  • 如果我们使用“-c”命令行选项,GCC会编译并汇编该代码
  • gcc -Og -c mstore.c
  • 这就会产生目标代码文件mstore.o,它是二进制格式的,所以无法直接查看,产生的字节序列中其中有14字节序列就是上面汇编指令对应的目标代码,从中得到一个重要信息,即机器执行的程序只是一个字节序列,它是对一系列指令的编码,机器对产生这些指令的源代码几乎一无所知
  • 要查看机器代码文件内容,有一类称为反汇编器的程序非常有用,这些程序根据机器代码产生一种类似于汇编代码的格式,在Linux系统中,带“-d”命令行标志的程序OBJDUMP可以充当这个角色
  • objdump -d mstore.o
  • 一些关于机器代码和它的反汇编表示的特性值得注意:
    • x86-64的指令长度从1字节到15字节不等,常用指令以及操作数较少的指令所需的字节数少,不太常用或操作数较多的指令字节数较多
    • 设计指令格式的方式是,从某个给定的位置开始,可以将字节唯一地解码成机器指令
    • 反汇编器只是基于机器代码文件中的字节序列来确定汇编代码,不需要访问源代码或汇编代码
    • 反汇编器使用的指令命名规则与GCC生成的汇编代码使用的有些细微的差别,在我们的示例中,它省略了很多指令结尾的q,这些后缀是大小指示符,在大多数情况中可以省略,相反,反汇编器给call个ret指令添加了后缀,同样,省略这些后缀也没有问题
#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;
}
  • gcc -Og -o prog main.c mstore.c
  • objdump -d prog
  • 这段代码和mstore.c反汇编产生的代码区别:地址不同,因为链接器将这段代码的地址移动到一段不同的地址范围。链接器填上了callq指令调用函数mult2需要使用的地址。最后多了两行代码,这些指令是为了使函数代码变成16字节,使得就存储器系统性能而言能更好的放置下一个代码块

3.2.3 关于格式的注解

  • GCC产生的汇编代码所有以“.”开头的行都是指导汇编器和链接器工作的伪指令。我们通常忽略这些行,另一方面,也没有关于这些指令用途以及它们与源代码之间关系的解释说明。
  • 为了更清楚的说明汇编代码,我们用这样一种格式来表示汇编代码,省略大部分伪指令,但包含行号和解释性说明。
  • 通常我们只会讨论内容相关的代码行,每一行的左边都有编号供引用,右边是注释,简单的描述指令的效果以及它与原始C语言代码中的计算机操作的关系,这是一种汇编语言程序员写代码的风格
  • 对于一些应用程序,程序员必须使用汇编代码来访问机器的低级特性,一种方法是用汇编代码编写整个函数,在链接阶段把它们和C函数组合起来,另一种方法是利用GCC的支持,直接在C程序中嵌入汇编代码

ATT与Intel汇编代码格式

我们的表述是ATT格式的汇编代码,这是GCC、OBJDUMP和其他一些我们使用的工具的默认格式,其他一些编程工具,包括Microsoft的工具以及来自Intel的文档,其汇编代码都是Intel格式的,这两种格式在许多方面有所不同,例如,使用下述命令行,GCC可以产生multstore函数的Intel格式的代码

gcc -Og -S -masm=intel mstore.c

在这里插入图片描述

不同:

  • Intel代码省略了指示大小的后缀
  • Intel代码省略了寄存器名字前面的%
  • Intel代码用不同的方式描述内存中的位置
  • 在带有多个操作数的指令情况下,列出操作数的顺序相反。

3.3 数据格式

  • 由于是从16位体系结构扩展成32位的,Intel用术语 字 表示16位数据类型,因此,成32位数为双字,成64位数为四字

  • 在这里插入图片描述

  • 大多数GCC生成的汇编代码指令都有一个字符的后缀,表明操作数的大小,例如,数据传送指令的四个变种:movb movw movl movq

  • 4字节整数和8字节双精度浮点数后缀都是l ,不会歧义,因为浮点数使用一组完全不同的指令和寄存器

3.4 访问信息

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

  • 在这里插入图片描述

  • 指令可以对这16个寄存器的低位字节中存放的不同大小的数据进行操作,字节级操作可以访问最低字节,16位操作可以访问最低的2个字节,32位操作可以访问最低的4个字节。

  • 当这些指令以寄存器作为目标时,对于生成小于8字节结果的指令,寄存器剩下的字节会怎么样,对此有两条规则:生成1字节和2字节数字的指令会保持剩下的字节不变,生成4字节数字的指令会把高位4字节置为0

3.4.1 操作数指示符

  • 各种不同的操作数的可能性被分为三种类型,
    • 第一种类型是立即数,用来表示常数值,在ATT格式的汇编代码中,立即数的书写方式是$后面跟一个用C表示法表示的整数。不同的指令允许的立即数值范围不同,汇编器会自动选择最紧凑的方式进行数值编码
    • 第二种类型是寄存器,它表示某个寄存器的内容,16个寄存器的低位1、2、4、8字节中的一个作为操作数
    • 第三类操作数是内存引用,它会根据计算出来的地址(有效地址)访问某个内存位置
  • Imm(rb. ri, s)表示的是最常用的形式,这样的引用分为四个组成部分,一个立即数偏移,一个基址寄存器,一个变址寄存器和一个比例因子(1 2 4 8)。基址和变址寄存器都必须是64位寄存器,有效地址被计算为Imm+R[rb]+R[ri].s。
  • 引用数组元素时会用这种通用格式,其他形式都是这种通用形式的特殊情况,只是省略了某些部分
  • 在这里插入图片描述

3.4.2 数据传送指令

  • 在我们的讲述中,把许多不同的指令划分成指令类,每一类中的指令执行相同的操作,只不过操作数的大小不同

  • 最简单形式的数据传送指令——MOV指令,这些指令把数据从源位置复制到目的位置,不做任何变化。MOV类由四条指令组成:movb movw movl movq。

  • 在这里插入图片描述

  • 源操作数指定的值是一个立即数,存储在寄存器或内存中,目的操作数指定一个位置,寄存器或内存地址。两个操作数不能都指向内存,只能同两条指令代替(内存到寄存器,寄存器到目的内存)

  • 寄存器部分的大小必须与指令最后一个字符指定的大小匹配,大多数情况下,MOV指令只会更新目的操作数指定的哪些寄存器字节或内存位置,唯一例外就是movl指令以寄存器作为目的时,会把寄存器的高位4字节置0.

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

  • 下面两图记录的是两类数据移动指令,在将较小的源值复制到较大的目的时使用。所有这些指令都把数据从源(寄存器或内存)复制到目的寄存器。MOVZ类中的指令把目的中剩余的字节填充0,而MOVS类中的指令通过符号扩展填充。每条指令的最后两个字符都是大小指示符,第一个字符指定源的大小,而第二个指明目的的大小

  • 在这里插入图片描述

  • 注意:没有一条明确的指令把4字节源值零扩展到8字节目的。这样的指令逻辑上应该被命名为movzlq,但是并没有。不过,这样的数据传送可以用以寄存器为目的的movl指令来实现。对于64位的目标,所有三种源类型都有对应的符号扩展传送,而只有两种较小的源类型有零扩展传送。

  • cltq指令。这条指令没有操作数,它总是以寄存器eax为源,rax作为符号扩展结果为目的,与movslq %eax, %rax效果一致

3.4.3 数据传送示例

在这里插入图片描述

3.4.4 压入和弹出栈数据

  • 在x86-64中,程序栈存放在内存中某个区域,栈向下增长,这样一来, 栈顶元素的地址是所有栈中元素地址中最低的。栈指针%rsp保存着栈顶元素的地址

  • 在这里插入图片描述

  • 因为栈和程序代码以及其他形式的程序数据都是放在内存中的,所以程序可以用标准的内存寻址方式访问栈内的任意位置。例如,栈顶元素是四字,指令movq 8(%rsp), %rdx会将第二个四字从栈中复制到寄存器rdx

3.5 算术和逻辑操作

  • 这些指令类有各种带不同大小操作数的变种(只有leaq没有其他大小的变种)。

在这里插入图片描述

3.5.1 加载有效地址

  • 加载有效地址指令leaq实际上是movq指令的变形,它的指令形式是从内存读数据到寄存器,但实际上它根本就没有引用内存,它的第一个操作数看上去是一个内存引用,但该指令并不是从指定位置读入数据,而是将有效地址写入目的操作数
  • 如果寄存器%rdx的值为x,那么指令leaq 7(%rdx,%rdx,4),%rax将设置寄存器%rax的值为5x+7.编译器经常会发现leaq的一些灵活用法,根本就与有效地址的计算无关,目的操作数必须是一个寄存器
  • 在这里插入图片描述

3.5.2 一元和二元操作

  • 第二组的操作时一元操作,只有一个操作数,既是源又是目的。这个操作数可以是一个寄存器,也可以是内存位置。

3.5.3 移位操作

  • 最后一组是移位操作,先给出移位量,然后第二项给出要移位的数,可以进行算术和逻辑右移,移位量可以是一个立即数,或者放在但字节寄存器%cl中。这些指令很特别,只允许以这个特定的寄存器作为操作数。原则上说,1个字节的移位量使得移位量的编码范围可以达到255。x86-64中,移位操作对w为长的数据值进行操作,移位量是由%cl寄存器的低m为决定的。2m=w。高位被忽略,所以例如当寄存器%cl的十六进制值为0xFF时,指令salb会移动7位,salw移动15位,sall会移动31位,salq会移动63位

3.5.4 讨论

3.5.5 特殊的算数操作

  • 正如我们在2.3节看到的,两个64位有符号或无符号整数相乘得到的结果需要128位来表示,x86-64指令集对128位数的操作提供了有限的支持。Intel把16字节的数称为八字

  • 在这里插入图片描述

  • x86-64指令集提供了两条单操作数乘法指令,以计算两个64位值的全128位乘积——一个是无符号乘法,而另一个是补码乘法。这两条指令都要求一个参数必须在寄存器%rax中,另一个作为指令的源操作数给出,乘积存放在寄存器rdx(高64位)和rax(低64位)中。

  • 前面的算数运算表没有列出除法或取模操作,这些操作都是由单操作数除法指令来提供的,类似于单操作数乘法指令,有符号除法指令idivl将寄存器rdx和rax中的128位数作为被除数,除数作为指令的操作数给出,指令将商存储在寄存器rax中,余数存储在寄存器rdx中

  • 对于大多数64位除法应用来说,除数也常常是一个64位的值,这个值应该存放在rax中,rdx的位应该设置为全0(无符号运算)或rax的符号位(有符号运算)。后面这个操作可以用指令cqto来完成。这条指令不需要操作数——它隐含读出%rax的符号位,并将它复制到rdx的所有位

  • 无符号除法使用divq指令,通常,寄存器rdx会事先设置为0

3.6 控制

3.6.1 条件码

  • 除了整数寄存器,CPU还维护着一组单个位的条件码寄存器,它们描述了最近的算术运算或逻辑操作的属性,可以检测这些寄存器来执行条件分支指令,最常用的条件码有:

    • CF:进位标志,最近的操作使最高位产生了进位,可用来检查无符号数的溢出
    • ZF:零标志。最近的操作得出的结果是0
    • SF:符号标志。最近的操作得到的结果为负数
    • OF:溢出标志,最近的操作导致一个补码溢出——正溢出或负溢出
  • leaq指令不改变任何条件码,因为它是用来进行地址计算的,除此之外,图3-10列出的所有指令都会设置条件码,对于逻辑操作,进位标志和溢出标志会设置为0,对于移位操作,进位标志将设置为最后一个被移出的位,而溢出标志设置为0。INC和DEC指令会设置溢出和零标志,但是不会改变进位标志

  • 在这里插入图片描述

  • 它们只设置条件码而不改变任何其他寄存器,CMP指令根据两个操作数之差来设置条件码。除了只设置条件码而不更新目的寄存器之外,CMP指令与SUB指令的行为一样

  • TEST指令的行为和AND指令一样,除了它们只设置条件码而不改变目的寄存器的值。典型用法是两个操作数一样,用来检查正负数

3.6.2 访问条件码

  • 条件码通常不会直接读取,常用的使用方法有三种:

    1. 可以根据条件码的某种组合将一个字节设置为0或1
    2. 可以条件跳转到程序的某个其他部分
    3. 可以有条件的传送数据
  • 对于第一种情况描述的指令根据条件码的某种组合,将一个字节设置为0或1,我们将这一整类指令称为SET指令,它们之间的区别就在于它们考虑的条件码的组合是什么,这些指令名字的不同后缀指明了它们考虑的条件码的组合

  • 一个SET指令的目的操作数是低位单字节寄存器元素之一,或者是一个字节的内存位置,指令将这个字节设置为0或1

  • 在这里插入图片描述

  • 虽然所有的算术和逻辑操作都会设置条件码,但是各个SET命令的描述都适用的情况是:执行比较命令,根据计算t = a-b设置条件码

  • 考虑用setl,即当小于时设置(set when less)指令,测试一个有符号比较,当没有发生溢出时(OF为0),我们有当a<b,将SF设置为1即说明这个情况,而当a>=b,由SF=0(相减为非负数)指明。另一方面,当发生溢出时,OF=1,a-b>0时a<b(负溢出),正溢出反之。因此当OF=1时,当且仅当SF=0,有a<b。将这些情况组合起来,溢出和符号位的异或提供了a<b是否为真的测试。其他的有符号比较测试基于SF^OF和ZF的其他组合

  • 无符号比较使用的是进位标志和零标志的组合

  • 大多数情况下,机器代码对于有符号和无符号两种情况使用一样的指令,这是因为许多算术运算对无符号和补码运算都有一样的位级行为。有些情况需要不同的指令来处理有无符号操作,例如使用不同版本的右移、除法和乘法指令,以及不同的条件码组合

3.6.3 跳转指令

  • 在产生目标代码文件时,汇编器回确定所有带标号指令的地址,并将跳转目标(目标指令的地址)编码为跳转指令的一部分
  • jmp指令是无条件跳转,它可以直接跳转,即跳转目标是作为指令的一部分编码的,也可以是间接跳转,即跳转目标是从寄存器或内存位置读出的。汇编语言中直接跳转是给出一个标号作为跳转目标的,间接跳转的写法是‘*’后面跟一个操作数指示符。
  • 指令jmp *%rax用寄存器%rax中的值作为跳转目标,而指令jmp *(%rax)以%rax中的值作为读地址,从内存中读出跳转目标
  • 在这里插入图片描述

3.6.4 跳转指令的编码

  • 汇编器以及后来的链接器会产生跳转目标的适当编码,跳转指令有几种不同的编码,但是最常用的都是PC相对的,也就是他们将目标指令的地址与紧跟在跳转指令后面的那条指令的地址之间的差作为编码,这些地址偏移量可以编码为1 2或4个字节,第二种编码方法是给出绝对地址,用4字节直接指定目标,汇编器和链接器会选择适当的跳转目的编码

  • 在这里插入图片描述

  • 观察指令的字节编码,会看到第一条指令的目标编码为0x03,把它加上0x5,也就是下一条指令的地址

  • 类似第二个跳转指令的目标用单字节、补码表示编码为0xf8,将这个数加上0xd,即第6行指令的地址,我们得到0x5,第3行地址

  • rep指令就是作为一种空操作,因此作为跳转目的插入它,除了能使代码在AMD上运行更快之外,不会改变代码的其他行为,在后面遇到rep或repz时,可以无视

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

  • 将条件表达式和语句从C语言翻译成机器代码最常用的方式是结合有条件和无条件跳转(有些条件可以用数据的条件转移实现,而不是用控制的条件转移来实现)

  • 在这里插入图片描述

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

  • 实现条件操作的传统方法是通过使用控制的条件转移。当条件满足时程序沿着一条执行路径执行,而当条件不满足时,就走另一条路径,这种机制简单而通用,但是在现代处理器上,它可能会非常低效

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

  • 在这里插入图片描述

  • 我们可以看到它既计算了y-x,也计算了x-y,然后再测试条件,返回两个值的其中一个

  • 处理器通过使用流水线来获得高性能,在流水线中,一条指令的处理要经过一系列的阶段,每个阶段执行所需操作的一小部分(如,从内存取指令,确定指令类型,从内存读数据,执行算术运算,向内存写数据,以及更新PC)。这种方法通过重叠连续指令的步骤来获得高性能,例如,在取一条指令的同时,执行它前面一条指令的算术运算,要做到这一点,要求能够事先确定要执行的指令序列,这样才能保持流水线中充满了待执行的指令,当机器遇到条件跳转时,只有当分支条件求值完成后才能确定往哪个分支走。处理器采用非常精密的分支预测逻辑来猜测每条跳转指令是否会执行,只要它的猜测还比较可靠指令流水线就会充满指令,另一方面,错误预测一个跳转,要求处理器丢掉它为该跳转指令后所有指令所有指令已做的工作,然后再从正确位置处起始的指令来填充流水线,这样一个错误预测会招致很严重的惩罚,浪费大约15-30个时钟周期,导致程序性能严重下降

    如果确定分支预测错误的处罚:

    假设预测错误的概率是p,如果没有预测错误,执行代码的时间是TOK,而预测错误的处罚是TMP,那么作为p的一个函数,执行代码的平均时间为Tavg§ = (1-p)Tok+p(Tok+TMP)=Tok+pTMP。如果已知Tok和Tran要确定TMP,将参数代入等式,我们就有Tran = Tavg(0.5) = Tok + 0.5Tmp,Tmp = 2(Tran - Tok)

  • 源和目的的值可以是16位、32位或64位长,不支持单字节条件传送,汇编器可以从目标寄存器的名字推断出条件传送指令的操作数长度,所以对所有额操作数长度都可以使用同一个指令名字

  • 在这里插入图片描述

  • 同条件跳转不同,处理器无需预测测试的结果就可以执行条件传送,处理器只是读源值,检查条件码,然后要么更新目的寄存器,要么保持不变

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

    v = then-expr

    ve = else-expr

    t = test-expr

    if(!t)

    v = ve

  • 不是所有的条件表达式都可以用条件传送来编译,最重要的是,无论测试结果如何,我们给出的抽象代码会对两个表达式都求值,如果其中一个会产生错误条件或副作用,就会导致非法行为

  • 使用条件传送也不总是会提高效率,如果两个表达式需要大量计算,那么当相对应的条件不满足时,这些工作就白费了,编译器必须考虑浪费的计算和由于分支预测错误所造成的性能处罚之间的相对性能。实际上,编译器不具有足够的信息来做出可靠的决定。实验表明,GCC只有当两个表达式都很容易计算时,例如表达式分别都只是一条加法指令 它才会使用条件传送。根据经验,即使许多分支预测错误的开销超过复杂的计算,GCC还是会使用条件控制转移

3.6.7 循环

  1. do-while循环

    • 在这里插入图片描述
  2. while循环

    • 有很多方法将while循环翻译成机器代码,GCC在代码生成中使用其中的两种方法,这两种方法使用同样的循环结构,与do循环一样,不过它们实现初始测试的方法不同

    • 第一种翻译方法,我们称之为跳转到中间,它执行一个无条件跳转,到循环结尾处的测试,以此来执行初始的测试

    • 在这里插入图片描述

    • [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-jnE4loQt-1626959265107)(https://i.loli.net/2021/07/20/62WEzJiPYtZ1yhw.png)]

    • 第二种翻译方法,我们称之为guarded-do,首先用条件分支,如果初始条件不成立就跳过循环,把代码变换为do循环,当使用较高优化等级编译时,例如使用命令行选项-O1,GCC会采用这种策略

    • 在这里插入图片描述

    • 在这里插入图片描述

  3. for循环

    • GCC为for循环产生的代码是while循环的两种翻译之一,这取决于优化的等级,也就是,跳转到中间策略会得到如下goto代码

    • 而guarded-do策略得到:

    • 在这里插入图片描述

    • 在这里插入图片描述

    • [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-no6XrkMf-1626959265114)(https://i.loli.net/2021/07/20/TpoLR42X65x1ObG.png)]

    • 在这里插入图片描述

3.6.8 switch语句

  • 它们不仅提高了C代码的可读性,而且通过使用跳转表这种数据结构使得实现更加高效,跳转表是一个数组,表项i是一个代码段的地址,这个代码段实现当开关索值指等于i时程序应该采取的动作。程序代码用开关索引值来执行一个跳转表内的数组引用,确定跳转指令的目标,和使用一组很长的if-else语句相比,使用跳转表的优点是执行开关语句的时间与开关情况的数量无关,GCC根据开关情况的数量和开关情况值的稀疏程度来翻译开关语句,当开关情况数量比较多,并且值的范围跨度较小时,就会使用跳转表

  • &&这个运算符创建一个指向代码位置的指针

  • 在这里插入图片描述

  • 原始的C代码有针对值100、102-104和106的情况,但是开关变量n可以是任意整数。编译器首先将n减去100,把取值范围移到0到6之间,创建一个新的程序变量,在我们的C版本中称为index,补码表示的负数会映射成无符号表示的大正数,将index看作无符号值简化了分支的可能性。因此可以通过测试index是否大于6来判定index是否在0-6的范围之外

  • 在这里插入图片描述

  • jmp指令的操作数有前缀*,表示这是一个简洁跳转,操作数指定一个内存位置,索引由寄存器rsi给出,这个寄存器保存着index的值

  • 在这里插入图片描述

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

3.7 过程

  • 过程是软件中一种很重要的抽象。它提供了一种封装代码的方式,用一组指定的参数和一个可选的返回值实现了某种功能。然后可以在程序中不同地方调用这个函数,设计良好的软件用过程作为抽象机制,隐藏某个行为的具体实现,同时又提供清晰简洁的接口定义,说明要计算的是哪些值,过程会对程序状态产生什么样的影响。不同的编程语言中,过程的形式多样:函数、方法、子例程、处理函数等等,但是它们有一些共有的特性
  • P调用过程Q,Q执行后返回P,这些动作包括下面一个或多个机制:
    • 传递控制。在进入过程Q的时候,程序计数器必须被设置为Q的代码的起始地址,然后在返回时,要把程序计数器设置为P中调用Q后面那条指令的地址
    • 传递数据。P必须能够向Q提供一个或多个参数,Q必须能够向P返回一个值
    • 分配和释放内存。在开始时,Q可能需要为局部变量分配空间,而在返回前,又必须释放这些存储空间

3.7.1 运行时栈

  • 程序可以用栈来管理它的过程所需要的存储空间,栈和程序寄存器存放着传递控制和数据、分配内存所需要的信息。当P调用Q时,控制和数据信息添加到栈尾。当P返回时,这些信息会被释放
  • 过程需要的存储空间超出寄存器能存放的大小时,就会在栈上分配空间,这个部分称为过程的栈帧
  • 当过程P调用过程Q时,会把返回地址压入栈中,指明当Q返回时,要从P程序的哪个位置继续执行。我们把这个返回地址当做P的栈帧的一部分,因为它存放的是与P相关的状态。Q的代码会扩展当前栈的边界,分配它的栈帧所需的空间,在这个空间中,它可以保存寄存器的值,分配局部变量空间,为它调用的过程设置参数。大多数过程的栈帧都是定长的,在过程的开始就分配好了。但是有些过程需要变长的帧,这个问题会在3.10.5讨论。通过寄存器,过程P可以传递最多6个整数值(整数和指针),但是如果Q需要更多的参数,P可以在调用Q之前在自己的栈帧里存储好这些参数
  • 为了提高空间和时间效率,x86-64过程只分配自己所需要的栈帧部分,例如,许多过程有6个或者更少的参数,那么所有的参数都可以通过寄存器传递,因此图中的某些栈帧部分可以省略。实际上,很多函数甚至不需要栈帧,当所有局部变量都可以保存到寄存器中,而且不会调用任何其他函数(有时称为叶子过程,此时把过程看作树结构)时,就可以这样处理
  • 在这里插入图片描述

3.7.2 转移控制

  • 将控制从函数P转移到函数Q只需要简单的把程序计数器设置为Q的代码的起始位置,不过当稍后Q返回的时候,处理器必须记录好它需要继续执行P的执行的代码的位置。在x86机器中,这个信息是用指令call Q调用过程Q来记录的,该指令会把地址A压入栈中,并将PC设置为Q的起始地址,压入的地址A被称为返回地址,是紧跟在call指令后面的那条指令的地址,对应的指令ret会从栈中弹出地址A,并把PC设置为A

  • 在这里插入图片描述

  • 在这段代码中我们可以看到,在main函数中,地址为0x400563的call指令调用函数multstore。此时的状态如图,指明了栈指针rsp和程序计数器rip的值。call的效果时将返回地址400568压入栈中,并跳到函数multstore的第一条指令,地址为400540,函数multstore继续执行,知道遇到地址40054d处的ret指令,这条指令从栈中弹出值400568,然后跳转到这个地址,就在call指令之后,继续main函数的执行

  • 在这里插入图片描述

  • 在这里插入图片描述

3.7.3 数据传送

  • 当调用一个过程时,除了要控制传递给它并在过程返回时再传递回来之外,过程调用还可能包括把数据作为参数传递,而从过程返回还有可能包括一个返回值。在x86中,大部分过程间的数据传送是通过寄存器实现的。

  • 当过程P调用过程Q时,P的代码必须首先把参数复制到适当的寄存器中,类似的,当Q返回到P时,P的代码可以访问寄存器rax中的返回值

  • 在这里插入图片描述

  • 如果一个函数有大于6个整型参数,超出6个的部分就要通过栈来传递。假设过程P调用过程Q,有n和整型参数,那么P的代码分配的栈帧必须能容纳7到n号参数的存储空间,参数7位于栈顶。 **通过栈传递参数时,所有的数据大小都向8的倍数对齐。**参数到位后,程序就可以执行call指令将控制转移到过程Q了,过程Q可以通过寄存器访问参数,必须的话也可以通过栈访问,相应的,如果Q也调用了某个有超过6个参数的函数,它也需要在自己的栈顶中为超出6的部分的参数分配空间,即参数构造区。

  • 在这里插入图片描述

  • 在这里插入图片描述

3.7.4 栈上的局部存储

  • 到目前为止我们看到的大多数过程示例都不需要超出寄存器代销的本地存储区域。不过有些时候,局部数据必须存放在内存中,常见的情况包括:

    • 寄存器不足够所有本地数据
    • 对一个局部变量使用地址运算符,因此必须能够为它产生一个地址
    • 某些局部变量是数组或结构,因此必须能够通过数据或结构的引用被访问到
  • 在这里插入图片描述

  • 在这里插入图片描述

  • 在这里插入图片描述

  • 在这里插入图片描述

  • 当调用过程proc时,程序会开始执行图3-29b的代码,如图3-30所示,参数7和8现在位于相对栈指针偏移量为8和16的地方,因为返回地址这时已经被压入栈了。

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

  • 寄存器组是唯一被所有过程共享的资源。虽然在一个时刻,只有一个活动是活动的,我们仍然必须确保当一个过程调用另一个过程时,被调用者不会覆盖调用者稍后会使用的寄存器值。为此x86采用了一组统一的寄存器使用惯例,所有过程都必须遵守

  • 根据惯例,寄存器rbx rbp和r12-r15被划分为被调用者保存寄存器,在过程P调用过程Q时,Q必须保存这些寄存器的值,保证它们的值再Q返回到P时与Q被调用时一样。过程Q保存一个寄存器的值不变,要么就是根本不改变,要么就是把原始值压入栈中,改变寄存器的值,然后返回前从栈中弹出旧值。压入寄存器的值会在栈帧中创建标号为保存的寄存器的一部分,如图3-25所示,有了这条惯例,P的代码就能安全的把值存在被调用者保存寄存器中,当然,要先把之前的值保存到栈上,调用Q,然后继续使用寄存器中的值,不用担心值被破坏

  • 所有其它的寄存器,除了栈指针rsp,都分类为调用者保存寄存器。这就意味着任何函数都能修改它们,可以这样理解调用者保存这个名字:过程P在某个此类寄存器中有局部数据,然后调用Q,因为Q可以随意修改这个寄存器,所以在调用之前首先保存好这个数据是P(调用者)的责任。

  • 在这里插入图片描述

  • 使用了两个被调用者保存寄存器:rbp保存x rbx保存计算出来的Q(y)的值。在函数开头,把这两个寄存器的值保存到栈中(2-3行)。在第一次调用Q之前吧参数x复制到rbp(第5行)。第二次调用Q之前,把这次调用的结果复制到rbx(第8行)。在函数结尾(13 14),把它们从栈中弹出,恢复这两个被调用者保存寄存器的值,注意它们的顺序与压栈的顺序相反,说明了栈的后进先出的规则。

3.7.6 递归过程

  • 前面已经描述的寄存器和栈的使用惯例使得x86过程能够递归调用它们自身,每个过程调用在栈中都有自己的私有空间,因此多个未完成的调用的局部变量不会相互影响,此外,栈的原则很自然就提供了适当的策略,当过程被调用时分配局部存储,返回时释放存储
  • 在这里插入图片描述

3.8 数组分配和访问

3.8.1 基本原则

  • x86的内存引用指令可以用来简化数组的访问,假设E是一个int型的数组,而我们想计算E[i],在此,E的地址存放在寄存器rdx中,而i存放在寄存器rcx中,然后,指令mov (%rdx,%rcx,4),%eax会执行地址计算x+4i,读这个内存位置的值,并将结果存放到寄存器eax中,允许的伸缩因子1 2 4 8覆盖了所有基本简单数据类型的大小

3.8.2 指针运算

3.8.3 嵌套的数组

  • 当我们创建数组的数组时,数组分配和引用的一般规则也是成立的。例如,声明int A[5][3];等价于下面的声明 typedef int row3_t[3]; row3_t A[5]
  • 声明如下的数组:T D[R][C];它的数组元素D[i][j]的内存地址为x+L(C*i+j)
  • 在这里插入图片描述

3.8.4 定长数组

  • 这段代码包含许多优化,它去掉了整数索引j,并把所有的数组引用都转换成了指针的间接引用,其中包括:

    1. 生成一个指针,命名为Aptr,指向A的行i中连续的元素
    2. 生成一个指针,命名为Bptr,指向B的列k中连续的元素
    3. 生成一个指针,命名为Bend,当需要终止时,它会等于Bptr。
  • Aptr的初始值是A的行i的第一元素的值,由C表达式&A[i][0]给出。Bptr的初始值是B的列k的第一个元素的地址,由C表达式&B[0][k]给出。Bend的值是假想中B的列j的第n+1个元素的地址,&B[N][k]

  • 在这里插入图片描述

3.8.5 变长数组

  • 在这里插入图片描述

  • 动态的版本必须用乘法指令对i伸缩n倍,而不能用一系列的移位和加法。在一些处理器中,乘法会招致严重的性能处罚,但是这种情况下无可避免。

  • 在一个循环中引用变长数组时,编译器常常可以利用访问模式的规律性来优化索引的计算

  • 在这里插入图片描述

3.9 异质的数据结构

3.9.1 结构

  • 编译器维护关于每个结构类型的信息,指示每个字段的字节偏移,它以这些偏移作为内存引用指令中的位移,从而产生对结构元素的引用
  • 在这里插入图片描述

3.9.2 联合

  • 在这里插入图片描述

  • 在一些上下文中,联合十分有用,但是它也能引起一些错误,因为它们绕过了C语言类型系统提供的安全措施。一种应用情况是,我们事先知道对一个数据结构中的两个不同的字段的使用是互斥的,那么将这两个字段声明为联合的一部分,而不是结构的一部分,会减少分配空间的总量

  • 联合还可以用来访问不同数据类型的位模式,例如,假设我们使用简单的强制类型转换将一个double类型的值d转换为unsigned long类型的值u,值u是d 的整数表示,u的位表示与d很不一样。

  • 下面的代码会从一个double产生一个unsigned long的值

unsigned long double2buts(double d)
{
    union
    {
        double d;
        unsigned long u;
    }temp;
    temp.d = d;
    return temp.u;
};
  • 在这段代码中,我们以一种数据类型来存储联合中的参数,又以另一种数据类型来访问它,那么两者有相同的位表示,包括符号位字段、指数和尾数,两者的数值没有任何关系

3.9.3 数据对齐

  • 许多计算机系统对基本数据类型的合法地址做出了一些限制,要求某种类型对象的地址必须是某个值K的倍数。这种对齐限制简化了形成处理器 和内存系统之间接口的硬件设计

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

  • 在这里插入图片描述

  • 编译器在汇编代码中放入命令,指明全局数据所需的对齐。例如,3.6.8节开始的跳转表的汇编代码声明在第2行包含下面的命令:.align 8。这就保证了它后面的数据的起始地址是8的倍数,因为每个表项长8个字节,后面的元素都会遵守8字节对齐的限制

  • 对于包含结构的代码,编译器可能需要在字段的分配中插入间隙,以保证每个结构元素都满足它的对齐要求,而结构本身对它的起始地址也有一些对齐要求。

  • 比如说,考虑下面的结构声明:

struct S1
{
    int i;
    char c;
    int j;
};
  • 假设编译器用最小的9字节分配,画出图来是这样的

  • 在这里插入图片描述

  • 它是不可能满足字段i和j的4字节对齐要求的,取而代之地,编译器在字段c和j之间插入一个3字节的间隙

  • 在这里插入图片描述

  • 结果,j的偏移量为8,而整个结构的大小为12字节。此外,编译器必须保证任何struct S1*类型的指针p都满足4字节对齐。用我们前面的符号,设指针p的值为x。那么x必须是4的倍数,这就保证了p->i p->j都满足4字节的对齐要求

  • 另外编译器结构的末尾可能需要一些填充,这样的结构数组中的每个元素都会满足它的对齐要求,例如,考虑下面这个结构是声明:

struct S2
{
    int i;
    int j;
    char c;
};
  • 如果我们将这个结构打包成9个字节,只要保证结构的起始地址满足4字节要求,我们仍然保证满足字段i和j的对齐要求,不过,考虑下面的声明:

  • struct S2 d[4]

  • 分配9个字节,不可能满足d的每个元素的对齐要求,因为这些元素的地址分别是x x+9 x+18 x+27。相反,编译器会为结构分配12个字节,最后3个字节是浪费的空间

  • 在这里插入图片描述

  • 这样以来,d的元素的地址分别为x x+12 x+24 x+36,只要x是4的倍数,所有对齐限制就都可以满足了。

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

3.10.1 理解指针

  • 重点介绍一些指针和它们映射到机器代码的关键原则
    • 每个指针都对应一个类型,这个类型表明该指针指向的是哪一类对象。指针类型不是机器代码的一部分,它们是C语言提供的一种抽象,帮助程序员避免寻址错误
    • 每个指针都有一个值,这个值是某个指定类型的对象的地址,特殊的NULL值表示该指针没有指向任何地方
    • 指针用&运算符创建,*操作符用于间接引用指针
    • 数组和指针紧密联系
    • 将指针从一种类型强制转换成另一种类型,只改变它的类型而不改变它的值
    • 指针也可以指向函数

3.10.2 应用:使用GDB调试器

  • GNU的调试器GDB提供了许多有用的特性,支持机器级程序的运行时评估和分析。对于本书中的示例和练习,我们试图通过阅读代码来推断出程序的行为,有了GDB,可以观察正在运行的程序,同时又对程序的执行有相当的控制,这使得研究程序的行为变为可能

  • 下图给出了一些GDB命令的例子,帮助研究机器级x86-64程序,先运行OBJ-DUMP来获得程序的反汇编版本,是有好处的。

  • 我们用下面的命令来启动GDB:gdb prog

  • 通常的方法是在程序中感兴趣的地方附近设置断点,断点可以设置在函数入口后面,或是一个程序的地址处,程序在执行过程中遇到一个断点时程序会停下来。并将控制返回给用户,在断点处,我们能够以各种方式查看各个寄存器和内存位置。我们也可以单步调试程序,一次只执行几条指令,或是前进到下一个断点

  • 在这里插入图片描述

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

  • C对于数组引用不进行边界检查,而且对于局部变量和状态信息都存放在栈中,这两种情况结合到一起就能导致严重的程序错误,对越界的数组元素的写操作会破坏存储在栈中的状态信息。当程序使用这个被破坏的状态,试图重新加载寄存器或执行ret指令时,就会出现严重错误

  • 一种特别常见的状态破坏被称为缓冲区溢出。通常,在栈中分配某个字符数组来保存一个字符串,但是字符串长度超出了为数组分配的空间。

  • 在这里插入图片描述

  • 在这里插入图片描述

  • 缓冲区溢出的一个更加致命的使用就是让程序执行它本来不愿意执行的函数,这是一种常见的通过计算机网络攻击系统安全的方法。通常,输入给程序一个字符串,这个字符串包含一些可执行代码的字节编码,称为攻击代码。另外,还有一些字节会用一个指向攻击代码的指针覆盖返回地址,那么,执行ret指令的效果就是跳转到攻击代码

  • 在一种攻击形式中,攻击代码会使用系统调用启动一个shell程序,给攻击者提供一组操作系统函数。在另一种攻击形式中,攻击代码会执行一些未授权的任务,修复对栈的破坏,然后第二次执行ret指令,表面上正常返回到调用者

3.10.4 对抗缓冲区溢出攻击

Linux上最新GCC版本所提供的机制:

  1. 栈随机化
    • 为了在系统中插入攻击代码,攻击者既要插入代码,也要插入指向这段代码的指针,这个指针也是攻击字符串的一部分。产生这个指针需要知道这个字符串放置的栈地址。在过去,程序的栈地址非常容易预测,对于所有运行同样程序和操作系统版本的系统来说,在不同机器之间,栈的位置相当固定。因此,如果攻击者可以确定一个常见的Web服务器所使用的栈空间,就可以设计一个在许多机器上都能实施的安全攻击。以传染病来比喻,许多系统都容易受到同一种病毒的攻击,这种现象常被称作安全单一化。
    • 栈随机化的思想使得栈的位置在程序每次运行时都有变化。因此,即使许多机器都运行相同的代码,它们的栈地址都是不同的,实现的方式是:程序开始时,在栈上分配一段0-n字节之间的随机大小的空间。例如,使用分配函数alloca在栈上分配指定字节数量的空间,程序不适用这段空间,但是它会导致程序每次执行时后续的栈位置发生了变化,分配的范围n必须足够大,才能获得足够多的栈地址变化,但是又要足够小,不至于浪费太多的空间
    • 在Linux系统中,栈随机化已经变成了标准行为,它是更大的一类技术中的一种,这类技术称为地址空间布局随机化,或者简称ASLR。采用这种技术,每次运行时程序的不同部分,包括程序代码,库代码,栈,全局变量和堆数据,都会被加载到内存的不同区域。这就意味着在一台机器上运行一个程序,与在其他机器上运行同样的程序,它们的地址映射完全不一样。这样才能对抗一些形式的攻击
    • 然而,一个执著的攻击者总是能够用蛮力克服随机化,他可以反复用不同地址进行攻击,一种常见的方式就是在实际攻击代码前插入很长一段的nop指令。执行这种指令除了让PC+1,没有任何效果。只要攻击者能够猜中这段序列中的某个地址,程序就会经过这个序列,到达攻击代码。这个序列常用的术语是空操作雪橇,意思是程序会滑过这个序列。如果我们建立一个256字节的空操作雪橇,那么枚举215个起始地址就能破解n=223的随机化。对于64位,枚举224就不太可行了。我们可以看到栈随机化和一些ASLR技术能够增加成功攻击一个系统的难度,因而大大降低了病毒或者蠕虫病毒的传播速度,但是也不能提供完全的安全保障
  2. 栈破坏检测
    • 计算机的第二道防线是能够检测到何时栈已经被破坏,我们在echo函数示例中看到,破坏常常发生在超越局部缓冲区边界时。在C语言中,没有可靠的办法来防止对数组的越界写,但是我们能够在发生越界写的时候,在造成任何有害结果之前尝试检测到它

    • 最近的GCC版本在产生的代码中加入了一种栈保护者机制,来检测缓冲区边界越界。其思想是在栈帧中任何局部缓冲区和栈状态之间存储一个特殊的金丝雀值,这个值也称为哨兵值,是在程序每次运行时随机产生的,因此,攻击者没有简单的办法能够知道它是什么。在恢复寄存器状态和从函数返回之前,程序检查这个金丝雀值是否被函数的某个操作或者该函数的调用的某个函数的某个操作改变了,如果是,程序异常终止

    • 最近的GCC版本会试着确定一个函数是否容易遭受栈溢出攻击,并且自动插入这种溢出检测,实际上,对于前面的栈溢出展示,我们不得不用命令行选项-fno-stack-protector来阻止GCC产生这种代码,当不用这个选项来编译echo函数时,也就是允许使用栈保护者

    • 在这里插入图片描述

    • 这个版本的函数从内存中读入一个值,再把它存放在栈中相对于rsp偏移量为8的地方。指令参数%fs : 40指明金丝雀值是用段寻址从内存中读入的,段寻址机制可以追溯到80826的寻址,在现代操作系统上运行的程序已经很少见到。将金丝雀值存放到这样一个特殊的段中,标志为只读,这样攻击者就不能覆盖存储的金丝雀值,在恢复寄存器状态和返回前,函数将存储在栈位置处的值与金丝雀值做比较,如果两个数相同,xorq指令就会得到0,函数会按照正常的方式完成。非0表示已被修改,那么就会调用一个错误处理例程

  3. 限制可执行代码区域
    • 最后一招是消除攻击者向系统中插入可执行代码的能力。一种方法是限制哪些内存区域能够存放可执行代码。在典型的程序中,只要保存编译器产生的代码的那部分内存才需要是可执行的,其他部分可以被限制为只允许读写
    • 有些类型的程序要求动态产生和执行代码的能力。例如,即使编译技术为解释语言编写的程序动态的产生代码,以提高执行性能。是否能够将可执行代码限制在由编译器在创建原始程序时产生的那部分中,取决于语言和操作系统

3.10.5 支持变长栈帧

  • 到目前为止,我们已经检查了各种函数的机器级代码,但是它们有一个共同点,即编译器能够预先确定需要为栈帧分配多少空间。但是有些函数需要的局部存储时变长的。例如当函数使用alloc时就会发生这种情况。alloca是一个标准库函数,可以在栈上分配任意字节数量的存储。当代码声明一个局部变长数组时,也会发生这种情况

  • 在这里插入图片描述

  • 在这里插入图片描述

  • 为了管理变长栈帧,x86-64代码使用寄存器rbp作为帧指针(有时被称为基址针),这也是rbp名字的由来。当使用帧指针时,栈帧的组织结构如上图。可以看到代码必须把rbp之前的值保存到栈中,因为它是一个被调用者保存寄存器。然后在函数的整个执行过程中,都使得rbp指向哪个时刻栈的位置,然后用固定长度的局部变量相对于rbp的偏移量来引用它们

  • 图3-34b是GCC为函数vframe生成的部分代码。在函数的开始,代码建立栈帧,并为数组p分配空间,首先把rbp的值压入栈中,将rbp设置为指向当前位置的值(2-3行)然后在栈上分配16个字节,其中前8字节用于存储局部变量i,后8字节未被使用。接着为数组p分配空间(5-11行)

  • 初始化循环的代码展示了如何引用局部变量i和p的例子。第13行表明数组元素p[i]被设置为q。该指令用寄存器rcx中的值作为p的起始地址。我们可以看到修改局部变量i(15)和读局部变量(17)的例子。i的地址是引用-8(%rbp),也就是相对帧指针偏移量-8 的地方。

  • 在函数结尾,leave指令将帧指针恢复到它之前的值(20)。这条指令不需要参数,等价于两条指令 movq rbp rsp popq rbp

  • 也就是首先把栈指针设置为保存为rbp的位置,然后把该值从栈中弹出到rbp,这个指令具有释放栈帧的效果

  • 第5行的leaq指令计算值8n+22,然后第6行的andq指令把它向下舍入到最接近的16的倍数。当n是奇数是,结果是8n+8,n是偶数时,结果值会是8n+16,s1减去这个值就是s2

  • 8-10行三条指令将s2舍入到最近的8的倍数

  • s2的计算方式会保留s1的偏移量为最接近的16的倍数。还可以看到p会以8的倍数对齐

3.11 浮点代码

  • 如图所示,AVX浮点体系结构允许数据存储在16个YMM寄存器中,它们的名字为%ymm0-%ymm15。每个寄存器都是256位,当对标量数据操作时,这些寄存器值保存浮点数,而且只适用低32位或64位。汇编代码使用寄存器的SSE XMM寄存器名字%xmm0-%xmm15来引用它们,每个XMM寄存器都是对应的YMM寄存器的低128位
  • 在这里插入图片描述

3.11.1 浮点传送和转换操作

  • 下图给出一组在内存和XMM寄存器之间以及从一个XMM寄存器到另一个不做任何转换的传送浮点数的指令。引用内存的指令是标量指令,意味着它们只对单个而不是一组封装好的数据值进行操作,数据要么保存在内存中,要么保存在XMM寄存器中。无论数据对齐与否,这些指令都能正确执行,不过代码优化规则建议32位内存数据满足4字节对齐,64位数据满足8字节对齐。内存引用的指定方式与MOV指令一样,包括偏移量,基址寄存器,变址寄存器和伸缩因子的所有可能组合

  • 在这里插入图片描述

  • GCC只用标量传送操作从内存传送数据到XMM寄存器或从XMM寄存器传送数据到内存。对于两个XMM寄存器之间传送数据,GCC会使用两种指令之一,即用vmovaps传送单精度数,用vmovapd传送双精度数。

  • 指令名字中的字母a表示aligned(对齐的)当用于读写内存时,如果地址不满足16字节对齐,它们会导致异常,但是在两个寄存器之间传送数据,绝不会出现错误对齐的情况

  • 在这里插入图片描述

  • 下图给出了浮点数和整数数据类型之间以及不同浮点格式之间进行转换的指令集合。这些都是对单个数据值进行操作的标量指令。图3-47是把一个从XMM寄存器或内存中读出的浮点值进行转换,并将结果写入通用寄存器。把浮点值转换为整数时,指令会执行截断,把值向0进行舍入。这是C和大多数其他编程语言的要求

  • 在这里插入图片描述

  • 图3-48中的指令把整数转换成浮点数,它们使用的是不太常见的三操作数格式,有两个源和一个目的。第一个操作数读自于内存或一个通用目的寄存器。这里可以忽略第二个操作数,因为它的值只会影响结果的高位字节。而我们的目标必须是XMM寄存器,在最常见的使用场景中,第二个源和目的操作数都是一样的,就像下面这条指令

  • vcvtsi2sdp %rax, %xmm1, %xmm1

  • 这条指令从寄存器%rax读出一个长整数,把它转换成数据类型double,并把结果存放进XMM寄存器%xmm1的低字节中

  • 最后要在两种不同的浮点格式之间转换。GCC当前版本生成的代码需要单独说明

  • 单精度转成双精度

  • 在这里插入图片描述

  • vunpcklps指令通常用来交叉放置两个XMM寄存器的值,把它们存储到第三个寄存器中,也就是说,如果一个源寄存器的内容为字[s3,s2,s1,s0],另一个源寄存器为字[d3,d2,d1,d0],那么目的寄存器的值会是[s1,d1,s0,d0]。在上面的代码中,我们看到三个操作数使用同一个寄存器,那么该指令最后会将寄存器的值更新为值[x1,x1,x0.x0]。vunpcklps指令把源XMM寄存器中两个低位单精度值扩展成目的XMM寄存器中的两个双精度值。对前面vunpcklps指令的结果应用这条指令会得到值[dx0,dx0],这里的dx0是将x转换为双精度后的结果

  • 这两条指令的效果是将原始的xmm0低位4字节中的单精度值转换为双精度值,再将其两个副本保存到xmm0中,我们并不太清楚GCC为什么生成这样的代码,这样做没有好处,也没有必要在XMM寄存器中把这个值复制一遍

  • 对于把双精度转换为单精度

  • 在这里插入图片描述

  • 假设这些指令开始执行前寄存器xmm0保存着两个双精度值[x1,x0]。然后vmovddup指令把它设置为[x0,x0]。vcvpd2psx指令将这两个值转换为单精度。再存放到该寄存器的低位一半中,并将高位置0,得到结果[0.0,0.0,x0,x0]

  • 在这里插入图片描述

3.11.2 过程中的浮点代码

  • 在x86-64中,XMM寄存器用来向函数传递浮点参数,以及从函数返回浮点值,如图3.45表示,可以看到如下规则
    • XMM寄存器xmm0-xmm7最多可以传递8个参数,按照参数列出的顺序使用这些寄存器,可以通过栈传递额外参数
    • 函数使用寄存器xmm0来返回浮点值
    • 所有的XMM寄存器都是调用者保存的,被调用者可以不用保存就覆盖这些寄存器中的任意一个

3.11.3 浮点运算操作

  • 下图描述了一组执行算术运算的标量AVX2浮点指令。每条指令有一个或两个源操作数,和一个目的操作数。第一个源操作数可以是一个XMM寄存器或一个内存位置,第二个源操作数和目的操作数必须是XMM寄存器。每个操作都有一条针对单精度的指令和一条针对双精度的指令
  • 在这里插入图片描述

3.11.4 定义和使用浮点常数

  • 和整数运算操作不同,AVX浮动操作不能以立即数值作为操作数,相反,编译器必须为所有的常量值分配和初始化存储空间,然后代码把这些值从内存读入

  • 在这里插入图片描述

  • 可以看到函数从标号为.L2的内存位置读出1.8,从标号为.L3的位置读入值32.0。观察这些标号对应的值,可以看出每一个都是通过一对.long声明和十进制表示的值指定的。因为机器采用小端法字节顺序,第一个值给出的是低4字节,给出的是十进制,展开得到二进制,根据IEEE浮点数的表示规则进行计算

3.11.5 在浮点代码中使用位级操作

  • 这些操作都作用于封装好的数据,即它们更新整个目的XMM寄存器,对两个源寄存器的所有位都实施指定的位级操作

  • 在这里插入图片描述

3.11.6 浮点比较操作

  • 在这里插入图片描述

  • 这些指令类似于CMP指令,它们都比较操作数S1和S2,并且设置条件码指示它们的相对值。S2必须在XMM寄存器中,S1可以在XMM寄存器或内存中

  • 浮点数比较指令会设置三个条件码:零标志位,进位标志位,奇偶标志位。之前没有说奇偶标志位,因为在GCC产生的x86代码中不太常见。对于整数操作,当最近一次算术或逻辑运算产生的值的最低位字节是偶校验的,那么就会设置这个标志位。不过对于浮点数比较,当两个操作数中任一个是NaN时,都会设置该位。例如当x为NaN时,比较x==x会得到0

  • 条件码的设置条件如下

  • 在这里插入图片描述

  • 在这里插入图片描述

  • 在这里插入图片描述

3.11.7 对浮点代码的观察结论

  • 2
    点赞
  • 7
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

jian圣楠

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值