CSAPP阅读笔记-程序的机器级表示

程序的机器级表示

计算机执行机器代码,用字节序列编码低级的操作,包括处理数据、管理内存、读写存储设备上的数据,以及利用网络通信。编译器基于编程语言的规则、目标机器的指令集和操作系统遵循的惯例,经过一系列的阶段生成机器代码。GCC C语言编译器以汇编代码的形式产生输出,汇编代码是机器代码的文本表示,给出程序中的每一条指令。然后GCC调用汇编器和链接器,根据汇编代码生成可执行的机器代码。

那么为什么我们还要花时间学习机器代码呢?即使编译器承担了生成汇编代码的大部分工作,对于严谨的程序员来说,能够阅读和理解汇编代码仍是一项很重要的技能。以适当的命令行选项调用编译器,编译器就会产生一个以汇编代码形式表示的输出文件。通过阅读这些汇编代码,我们能够理解编译器的优化能力,并分析代码中隐含的低效率。

试图最大化一段关键代码性能的程序员,通常会尝试源代码的各种形式,每次编译并检查产生的汇编代码,从而了解程序将要运行的效率如何。

此外,也有些时候,高级语言提供的抽象层会隐藏我们想要了解的程序的运行时行为。例如,第12 章会讲到,用线程包写并发程序时,了解不同的线程是如何共享程序数据或保持数据私有的,以及准确知道如何在哪里访问共享数据,都是很重要的。

这些信息在机器代码级是可见的。另外再举一个例子,程序遭受攻击(使得恶意软件侵扰系统)的许多方式中,都涉及程序存储运行时控制信息的方式的细节。许多攻击利用了系统程序中的漏洞重写信息从而获得了系统的控制权。了解这些漏洞是如何出现的,以及如何防御它们,需要具备程序机器级表示的知识。程序员学习汇编代码的需求随着时间的推移也发生了变化,开始时要求程序员能直接用汇编语言编写程序,现在则要求他们能够阅读和理解编译器产生的代码。

历史

深入理解计算机系统 第三版 3.1 历史观点 介绍了 Intel 处理器的发展。

每个后继处理器的设计都是后向兼容的一一较早版本上编译的代码可以在较新的处理器上运行。正如我们看到的那样,为了保持这种进化传统,指令集中有许多非常奇怪的东西。Intel处理器系列有好几个名字,包括 IA32,也就是“Intel 32 位体系结构(IntelArchitecture 32-bit)”,以及最新的 Intel64,即IA32 的64 位扩展,我们也称为 x86-64。最常用的名字是“x86”,我们用它指代整个系列,也反映了直到 486 处理器命名的惯例。

程序编码

gcc -Og -o p p1.c p2.c

命令 gcc 指的就是 GCC C编译器。因为这是 Linux 上默认的编译器,我们也可以简单地用 cc 来启动它。编译选项-Og告诉编译器使用会生成符合原始 C代码整体结构的机器代码的优化等级。使用较高级别优化产生的代码会严重变形,以至于产生的机器代码和初始源代码之间的关系非常难以理解。因此我们会使用-Og 优化作为学习工具,然后当我们增加优化级别时,再看会发生什么。实际中,从得到的程序的性能考虑,较高级别的优化(例如,以选项-O1-O2 指定)被认为是较好的选择。

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

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

我们会在第 7 章更详细地介绍这些不同形式的机器代码之间的关系以及链接的过程。

机器级代码

计算机系统使用了多种不同形式的抽象,利用更简单的抽象模型来隐藏实现的细节。对于机器级编程来说,其中两种抽象尤为重要:

第一种是由**指令集体系结构或指令集架构(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,所以一个地址实际上能够指定的是 2 48 2^{48} 248或 64TB 范围内的一个字节。较为典型的程序只会访问几兆字节或几千兆字节的数据。操作系统负责管理虚拟地址空间,将虚拟地址翻译成实际处理器内存中的物理地址。

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

代码示例

产生 .s 汇编文件

gcc -Og -S mstore.c

所有以.开头的行都是指导汇编器和链接器工作的伪指令。我们通常可以忽略这些行。另一方面,也没有关于指令的用途以及它们与源代码之间关系的解释说明。

注意,默认GCC、OBJDUMP等软件解析的汇编是 ATT 格式的,还有微软的 Intel 格式的汇编代码。它们的格式不一样:

  • Intel 代码胜率了指示大小的后缀,如push而不是 pushq
  • Intel 代码省略了寄存器名字前面的 %,如rbx而不是 %rbx
  • Intel 代码使用不同的方式进行寻址,如 QWORD PTR [rbx] 而不是 (%rbx)
  • 多操作数的指令,列出的操作数相反!如 MOV D,S 而不是 MOV S,D

后面的表述也都采用 ATT 格式。

GCC 编译器可以为 C 语言内嵌汇编代码(比如直接使用奇偶位的寄存器值)

产生 .o 机器指令文件:

gcc -Og -c mstore.c

反汇编

objdump -d mstore.o

其中一些关于机器代码和它的反汇编表示的特性值得注意:

  • x86-64 的指令长度从1到 15 个字节不等。常用的指令以及操作数较少的指令所需的字节数少,而那些不太常用或操作数较多的指令所需字节数较多。
  • 设计指令格式的方式是,从某个给定位置开始,可以将字节唯一地解码成机器指令。例如,只有指令 pushq %rbx 是以字节值 53 开头的。
  • 反汇编器只是基于机器代码文件中的字节序列来确定汇编代码。它不需要访问该程序的源代码或汇编代码。
  • 反汇编器使用的指令命名规则与 GCC 生成的汇编代码使用的有些细微的差别。在我们的示例中,它省略了很多指令结尾的q。这些后缀是大小指示符,在大多数情况中可以省略。相反,反汇编器给 call ret 指令添加了 q 后缀,同样,省略这些后缀也没有问题。

生成实际可执行的代码需要对一组目标代码文件运行链接器,而这一组目标代码文件中必须含有一个 main函数。

数据格式

由于是从 16 位体系结构扩展成 32 位的,Intel 用语“字(word)”表示 16 位数据类型。因此,称 32 位数为“双字(double words)”,称 64 位数为“四字(quad words)”。x86-64 指令集同样包括完整的针对字节、字和双字的指令。

C语言 Intel数据类型 汇编代码后缀 大小(字节)
char 字节 b 1
short w 2
int 双字 l 4
long 四字 q 8
char* 四字 q 8
float 单精度 s 4
double 双精度 l 8

浮点数的后缀和整数重合了,但是浮点数使用的是一组完全不同的指令和寄存器。

注意这里的”字“不是指操作系统的字长(32/64)

访问信息

x86-64 的中央处理单元(CPU)包含一组 16 个存储 64 位值的通用目的寄存器。这些寄存器用来存储整数数据和指针。图 3-2 显示了这 16 个寄存器。它们的名字都以%r开头,不过后面还跟着一些不同的命名规则的名字,这是由于指令集历史演化造成的。

最初的 8086 中有 8个16 位的寄存器,即图 3-2 中的%ax%bp。每个寄存器都有特殊的用途,它们的名字就反映了这些不同的用途。扩展到 IA32 架构时,这些寄存器也扩展成 32位寄存器,标号从%eax%ebp。扩展到 x86-64 后,原来的8 个寄存器扩展成 64 位,标号从%rax%rbp。除此之外,还增加了 8 个新的寄存器,它们的标号是按照新的命名规则制定的:从%r8%r15
CSAPP阅读笔记-程序的机器级表示_第1张图片
(此图来自 深入理解计算机系统 第三版原图)
如图 3-2 中嵌套的方框标明的,指令可以对这 16 个存器的低位字节中存放的不同大小的数据进行操作。字节级操作可以访问最低的字节,16 位操作可以访问最低的 2 个字节,32 位操作可以访问最低的 4 个字节,而 64 位操作可以访问整个寄存器。

当这些指令以寄存器作为目标时,对于生成小于8 字节结果的指令,寄存器中剩下的字节会怎么样,对此有两条规则:

  • 生成 1 字节和2字节数字的指令会保持剩下的字节不变;
  • 生成 4 字节数字的指令会把高位 4 个字节置为 0,

后面这条规则是作为从 IA32 到x86-64 的扩展的一部分而采用的。

其中最特别的是栈指针 %rsp,用来指明运行时栈的结束位置。有些程序会明确地读写这个寄存器。另外 15 个寄存器的用法更灵活。少量指令会使用某些特定的寄存器。更重要的是,有一组标准的编程规范控制着如何使用寄存器来管理栈、传递函数参数、从函数的返回值,以及存储局部和临时数据。

寄存器以 % 开头。

我们还应该留意的是:

  • 第一个参数:%rdi
  • 第二个参数:%rsi
  • 第三个参数:%rdx
  • 第四个参数:%rcx
  • 第五个参数:%r8
  • 第六个参数:%r9
  • 返回值:%rax
  • 被调用者保存:%rbx%rbp%r12%r13%r14%r15
  • 调用者保存:%r10%r11
  • 栈指针:%rsp

之前一节说过了!我们讲的是 ATT 格式的汇编。与 Intel 代码的区别见前面 “程序编码-代码示例” 一节。

操作数指示符

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

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

  • 第一种类型是立即数(immediate),用来表示常数值。在 ATT 格式的汇编代码中,立即数的书写方式是 $ 后面跟一个用标准 C 表示法表示的整数,比如,$-577$0x1F。不同的指令允许的立即数值范围不同,汇编器会自动选择最紧凑的方式进行数值编码。
  • 第二种类型是寄存器(register),它表示某个寄存器的内容,16 个寄存器的低位 1字节、2字节、4 字节或 8 字节中的一个作为操作数这些字节数分别对应于 8位、16 位、32 位或 64 位。在图 3-3 中,我们用符号 r a r_a ra 来表示任意寄存器 α \alpha α,用引用 R [ r a ] R[r_a] R[ra]来表示它的值,这是将寄存器集合看成一个数组 R R R,用寄存器标识符作为索引。
  • 第三类操作数是内存引用,它会根据计算出来的地址(通常称为有效地址)访问某个内存位置。因为将内存看成一个很大的字节数组,我们用符号 M b [ A d d r ] M_b[Addr] Mb[Addr]表示对存储在内存中从地址 A d d r Addr Addr 开始的 b b b 个字节值的引用。为了简便,我们通常省去下标 b b b

(下面是CSAPP中图3-3的内容)

操作数格式,操作数可以表示立即数(常数)值、寄存值或是来自内存的值。比例因子s必须是1、2、4或者8:

类型 格式 操作数值 名称
立即数 $$Imm$ I m m Imm Imm 立即数寻找
寄存器 r a r_a ra R [ r a ] R[r_a] R[ra] 寄存器寻址
存储器 I m m Imm Imm M [ I m m ] M[Imm] M[Imm] 绝对寻址
存储器 ( r a ) (r_a) (ra) M [ R [ r a ] ] M[R[r_a]] M[R[ra]] 间接寻址
存储器 I m m ( r b ) Imm(r_b) Imm(rb) M [ I m m + R [ r b ] ] M[Imm+R[r_b]] M[Imm+R[rb]] (基址+偏移量)寻址
存储器 ( r b , r i ) (r_b,r_i) (rb,ri) M [ R [ r b ] + R [ r i ] ] M[R[r_b]+R[r_i]] M[R[rb]+R[ri]] 变址寻址
存储器 I m m ( r b , r i ) Imm(r_b,r_i) Imm(rb,ri) M [ I m m + R [ r b ] + R [ r i ] ] M[Imm+R[r_b]+R[r_i]] M[Imm+R[rb]+R[ri]] 变址寻址
存储器 ( , r i , s ) (,r_i,s) (,ri,s) M [ R [ r i ] ⋅ s ] M[R[r_i] \cdot s] M[R[ri]s] 比例变址寻址
存储器 I m m ( , r i , s ) Imm(,r_i,s) Imm(,ri,s) M [ I m m + R [ r i ] ⋅ s ] M[Imm+R[r_i] \cdot s] M[Imm+R[ri]s] 比例变址寻址
存储器 ( r b , r i , s ) (rb,r_i,s) (rb,ri,s) M [ R [ r b ] + R [ r i ] ⋅ s ] M[R[r_b]+R[r_i] \cdot s] M[R[rb]+R[ri]s] 比例变址寻址
存储器 I m m ( r b , r i , s ) Imm(r_b,r_i,s) Imm(rb,ri,s) I m m + M [ R [ r b ] + R [ r i ] ⋅ s ] Imm+M[R[r_b]+R[r_i] \cdot s] Imm+M[R[rb]+R[ri]s] 比例变址寻址

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

数据传送指令

最频繁使用的指令是将数据从一个位置复制到另一个位置的指令。操作数表示的通用性使得一条简单的数据传送指令能够完成在许多机器中要好几条不同指令才能完成的功能。我们会介绍多种不同的数据传送指令,它们或者源和目的类型不同,或者执行的转换不同,或者具有的一些副作用不同。在我们的讲述中,把许多不同的指令划分成指令类,每一类中的指令执行相同的操作,只不过操作数大小不同。

图 3-4 列出的是最简单形式的数据传送指令——MOV 类。这些指令把数据从源位置复制到目的位置,不做任何变化。MOV 类由四条指令组成:movbmovwmovl movq。这些指令都执行同样的操作,但它们操作的数据大小别是 1、2、4和8字节。

CSAPP阅读笔记-程序的机器级表示_第2张图片
(此图来自 深入理解计算机系统 第三版原图)
源操作数指定的值是一个立即数,存储在寄存器中或者内存中。目的操作数指定一个位置,要么是一个寄存器,要么是一个内存地址。x86-64 加了一条限制,传送指令的两个操作数不能都指向内存位置。将一个值从一个内存位置复制到另一个内存位置需要两条指令:第一条指令将源值加载到寄存器中,第二条将该寄存器值写入目的位置。这些指令的寄存器操作数可以是图 3-2 中 16 个寄存器有标号部分中的任意一个,寄存器部分的大小必须与指令最后一个字符(bwlq )指定的大小匹配。

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

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

movl $0x4050,%eax  	;立即数到寄存器,   4 bytes
movw %bp,%sp		;寄存器到寄存器,   2 bytes
movb (%rdi,%rcx),%al;内存到寄存器,	1 bytes
movb $-17,(%rsp)	;立即数到内存,	1 bytes
movq %rax,-12(%rbp)	;寄存器到内存,	8 bytes

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

下图 3-5 和图 3-6 记录的是两数据移动指令,在将较小的源值复制到较大的目的时使用。所有这些指令都把数据从源(在寄存器或内存中)复制到目的寄存器。MOVZ 类中的指令把目的中剩余的字节填充为 0,而 MOVS 类中的指令通过符号扩展来填充,把源操作的最高位进行复制。可以观察到,每条指令名字的最后两个字符都是大小指示符:第一个字符指定源的大小,而第二个指明目的的大小。正如看到的那样,这两个类中每个都有三条指令,包括了所有的源大小为 1 个和 2个字节、目的大小为 2个和 4 个的情况,当然只考虑目的大于源的情况。

CSAPP阅读笔记-程序的机器级表示_第3张图片

CSAPP阅读笔记-程序的机器级表示_第4张图片

注意图 3-5 中并没有一条明确的指令把 4 字节源值零扩展到 8 字节目的。这样的指令逻辑上应该被命名为 movzlq,但是并没有这样的指令。不过,这样的数据传送可以用以寄存器为目的的 movl 指令来实现。这一技术利用的属性是,生成 4 字节值并以寄存器作为目的的指令会把高 4 字节置为 0。

图 3-6 还给出 cltq 指令。这条指令没有操作数:它总是以寄存器 %eax 作为源,%rax作为符号扩展结果的目的。它的效果与指令 movslq %eax,%rax 完全一致,不过编码更紧凑。

有些临时变量放在寄存器里,而不是内存。访问寄存器要比访问内存快很多。

压入和弹出栈数据

最后两个数据传送操作可以将数据压入程序栈中,以及从程序栈中弹出数据,如下所示。

pushq S ;将四字压入栈
; R[%rsp] <- R[%rsp]-8;
; M[R[%rsp]] <- S

popq D 	;将四字弹出栈
; D <- M[R[%rsp]]
; R[%rsp] <- R[%rsp]+8;

在 x86-64 中,程序栈存放在内存中某个区域。如图 3-9 所示,栈向下增长,这样一来,栈顶元素的地址是所有栈中元素地址中最低的。(根据惯例,我们的栈是倒过来画的,栈“顶”在图的底部。)栈指针 %rsp 保存着栈顶元素的地址。

CSAPP阅读笔记-程序的机器级表示_第5张图片

pushq 指令的功能是把数据压入到栈上,而 popq 指令是弹出数据。这些指令都只有一个操作数——压入的数据源和弹出的数据目的。将一个四字值压入栈中,首先要将栈指针减 8,然后将值写到新的栈顶地址。因此,指令 pushq rbp 的行为等价于下面两条指令:

subq $8,%rsp
movq %rbq,(%rsp)

它们之间的区别是在机器代码中 pushq 指令编码为 1个字节,而上面那两条指令一共需要8个字节。

因为栈和程序代码以及其他形式的程序数据都是放在同一内存中,所以程序可以用标准的内存寻址方法访问栈内的任意位置。

算术和逻辑操作

图 3-10列出了 x86-64 的一些整数和逻辑操作。大多数操作都分成了指令类,这些指令类有各种带不同大小操作数的变种(只有 leaq 没有其他大小的变种)。

操作被分为四组:加载有效地址、一元操作、二元操作和移位。二元操作有两个操作数,而一元操作有一个操作数。(操作数的格式见之前小节"操作符指示符")

CSAPP阅读笔记-程序的机器级表示_第6张图片

加载有效地址
leaq S,D ;加载有效地址,效果:D <- &S

加载有效地址(load effective address)指令 leaq 实际上是 movq 指令的变形。它的第一个操作数看上去是一个内存引用,但该指令并不是从指定的位置读入数据,而是将有效地址写入到目的操作数。类似于 C 语言中的 D = &S这条指令可以为后面的内存引用产生指针。

另外,它还可以简洁地描述普通的算术操作。例如,设寄存器 %rdx 的值为 x,那么指令 leaq 7(%rdx,%rdx,4),%rax 将设置寄存器 %rax 的值为 5x+7。

leaq 的目的操作数必须是一个寄存器。

为了说明 leaq 在编译出的代码中的使用,看看下面这个 C程序:

long scale(long x,long y,long z){
    long t = x + 4 * y + 12 * z;
    return t;
}

该代码可以用三条 leaq 指令实现:

scale:
	leaq (%rdi,%rsi,4), %rax    # x+4*y
	leaq (%rdx,%rdx,2), %rdx    # z+2*z=3*z
	leaq (%rax,%rdx,4), %rax    # x+4*y+4*3*z
	ret 

leaq 指令能执行加法和有限形式的乘法,在编译如上简单的算术表达式时,是很有用处的。

一元和二元操作
INC D ;D <- D + 1
DEC D ;D <- D - 1
NEG D ;D <- -D ,取负
NOT D ;D <- ~D ,取补

以上是一元操作,只有一个操作数,既是源又是目的。这个操作数可以是一个寄存器,也可以是一个内存位置。比如:incq (%rsp) 会使栈顶的 8 字节元素加一。

ADD S,D ;D <- D + S
SUB S,D ;D <- D - S
IMUL S,D ;D <- D * S
XOR S,D ;D <- D ^ S
OR S,D ;D <- D | S
AND S,D ;D <- D & S

以上是二元操作,其中,第二个操作数既是源又是目的。所以第一个操作数可以是立即数、寄存器或是内存位置。第二个操作数可以是寄存器或是内存位置。注意,当第二个操作数为内存地址时,处理器必须从内存读出值,执行操作,再把结果写回内存。

我们主要到这里只有有符号乘法(结果会截断到64位),之后会介绍 128 位的全乘法。为什么这里不区分有无符号?回想一下之前介绍"无符号/补码乘法"时,当将乘积截取到 64 位时,无符号乘和补码乘的位级行为是一样的。

移位操作
SAL k,D ;D <- D << k,左移
SHL k,D ;D <- D << k,左移(等同于SAL)
SAR k,D ;D <- D >>A k,算术右移
SHR k,D ;D <- D >>L k,逻辑右移

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

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

原则上来说,1 个字节的移位最使得移位量的编码范围可以达到 2 8 − 1 = 255 2^8-1=255 281=255。x86-64 中,移位操作对 w w w 位长的数据值进行操作,移位量是由 %cl 寄存器的低 m m m 位决定的,这里 2 m = w 2^m=w 2m=w。高位会被忽略。所以,例如当寄存器 cl 的十六进制值为 0xFE 时,指令 salb 会移7位,salw 会移 15 位,sall 会移31位,而 salq会移63 位。

可以理解成算术左移和逻辑左移是一样的。而右移则需要考虑符号位的问题,算术右移会在左边填上符号位;而逻辑右移则是填0。为什么这样?考虑补码编码。

讨论

我们看到图 3-10 所示的大多数指令,既可以用于无符号运算,也可以用于补码运算只有右移操作要求区分有符号和无符号数。这个特性使得补码运算成为实现有符号整数运算的一种比较好的方法的原因之一。

一个例子:

long arith(long x, long y, long z)
{
    long t1 = x ^ y;
    long t2 = z * 48;
    long t3 = t1 & 0X0F0F0F0F;
    long t4 = t2 - t3;
    return t4;
}

对应的汇编是:

;long arith(long x, long y, long z)
;x in %rdi, y in %rsi, z in %rdx
arith:
xorq %rsi,%rdi			;t1 = x ^ y
leaq (%rdx,%rdx,2),%rax	;3*z
salq $4,%rax			;t2 = 16 * (3*z) = 48*z
andl $252645135,%edi	;t3 = t1 & 0X0F0F0F0F
subq %rdi,%rax			; 返回 t2-t3
ret
特殊的算数操作

两个 64 位有符号或无符号整数相乘得到的乘积需要 128位来表示。x86-64 指令集对 128 位(16 字节)数的操作提供有限的支持。延续字(2 字节)双字(4字节)和四字(8 字节)的命名惯例,Intel 把 16 字节的数称为八字(oct word)。下

面描述的是支持产生两个 64 位数字的全 128 位乘积以及整数除法的指令。

八字一般通过一对寄存器 %rdx%rax 存放。

乘法指令:

imulq S	;有符号全乘法
;效果:R[%rdx]:R[%rax] <- S * R[%rax] 

mulq S	;无符号全乘法	
;效果:R[%rdx]:R[%rax] <- S * R[%rax] 

值得注意的是,前面我们介绍过一个二元操作 imulq,只不过那个会截断出64位结果。使用一元版本则会执行128位全乘法。

四字转化为八字(符号扩展):

cqto  
;效果:R[%rdx]:R[%rax] <- 符号扩展(R[%rax])

注意这里书上喜的是 clto,应该是错了。

有符号除法:

idivq S
;效果:R[%rdx] <- [%rdx]:R[%rax] mod S
;R[%rax] <- [%rdx]:R[%rax] / S
  • 被除数: %rdx 为高64位,%rax 为低64位,
  • 除数:操作数 S
  • 商:%rax
  • 余数: %rdx

无符号除法:

divq S
;效果:R[%rdx] <- [%rdx]:R[%rax] mod S
;R[%rax] <- [%rdx]:R[%rax] / S

类似于有符号除法

对于大多数只使用64位除法的应用来说(也就是被除数是64位),则把被除数放在 rax,对于 rdx 则需要进行符号扩展。这一点则使用指令 cqto cqto: Convert Quadword to Octoword)完成。

控制

条件码

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

  • CF:进位标志。最近的操作使最高位产生了进位。可用来检查无符号操作的溢出。
  • ZF:零标志。最近的操作得出的结果为 0。
  • SF:符号标志。最近的操作得到的结果为负数。
  • OF:溢出标志。最近的操作导致一个补码溢出(正溢出或负溢出)。

注意有些指令是否会影响标志。

leaq 指令不改变任何条件码,因为它是用来进行地址计算的。除此之外,图 3-10 中列出的所有指令都会设置条件码。对于逻辑操作,例如 XOR,进位标志和溢出标志会设置成 0。对于移位操作,进位标志将设置为最后一个被移出的位,而溢出标志设置为 0,INC DEC 指令会设置溢出和零标志,但是不会改变进位标志,至于原因,我们就不在这里深入探讨了。

除了图 3-10 中的指令会设置条件码,还有两类指令(有 8、16、32 和 64位形式),它们只设置条件码而不改变任何其他寄存器,如图 3-13 所示。

CSAPP阅读笔记-程序的机器级表示_第7张图片
(此图来自 深入理解计算机系统 第三版原图)

CMP 指令根据两个操作数之差来设置条件码。除了只设置条件码而不更新目的寄存器之外,CMP 指令与 SUB 指令的行为是一样的。在 ATT 格式中,列出操作数的顺序是相反的,这使代码有点难读。如果两个操作数相等,这些指令会将零标志设置为 1,而其他的标志可以用来确定两个操作数之间的大小关系。

TEST 指令的行为与 AND 指令一样,除了它们只设置条件码而不改变目的寄存器的值。典型的用法是,两个操作数是一样的(例如,testq %rax,%rax 用来检查 %rax 是负数零,还是正数),或其中的一个操作数是一个掩码,用来指示哪些位应该被测试。

访问条件码

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

  • 可以根据条件码的某种组合将一个字节设置为 0 或者 1;
  • 可以条件跳转到程序的某个其他的部分;
  • 可以有条件地传送数据。

对于第一种情况,根据条件码的某种组合,将一个字节设置为0或者 1。我们将这一整类指令称为 SET 指令,它们之间的区别就在于它们考虑的条件码的组合是什么,这些指令名字的不同后缀指明了它们所考虑的条件码的组合。

(书中图3-14 SET 指令)

零标志 ZF:

指令 同义名 效果 设置条件
sete D setz D <- ZF 相等/零
setne D setnz D <- ~ZF 不等/非零

符号标志 SF:

指令 同义名 效果 设置条件
sets D D <- SF 负数
setns D D <- ~SF 非负数

零标志 ZF 与 溢出标志 OF:

指令 同义名 效果 设置条件
setg D setnle D <- ~(SF^OF)&(~ZF) 大于(有符号>)
setge D setnl D <- ~(SF^OF) 大于等于(有符号≥)
setl D setnge D <- SF^OF 小于(有符号<)
setle D setng `D <- (SF^OF) ZF`

进位标志 CF 与 零标志 ZF:

指令 同义名 效果 设置条件
seta D setnbe D <- ~CF & ~ZF 大于(无符号>)
setae D setnb D <- ~CF 大于等于(无符号≥)
setb D setnae D <- CF 小于(无符号<)
setbe D setna `D <- CF ZF`

一条 SET 指令的目的操作数是低位单字节寄存器元素(图 3-2)之一,或是一个字节的内存位置,指令会将这个字节设置成0或者 1。为了得到一个 32 位或 64 位结果,需要对高位清零(使用 movzbl 等指令)。

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

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

理解有符号无符号的比较时位级变化。

书上对 setl 进行了分析,但也可以自己分析。

**建议:注意到机器代码如何区分有符号和无符号值是很重要的。**同 C 语言不同,机器代码不会将每个程序值都和一个数据类型联系起来。相反,大多数情况下,机器代码对于有符号和无符号两种情况都使用一样的指令,这是因为许多算术运算对无符号和补码算术都有一样的位级行为。有些情况需要用不同的指令来处理有符号和无符号操作,例如,使用不同版本的右移、除法和乘法指令,以及不同的条件码组合。

跳转指令

正常执行的情况下,指令按照它们出现的顺序一条一条地执行。跳转(jump)指令会导致执行切换到程序中一个全新的位置。在汇编代码中,这些跳转的目的地通常用一个标号(label)指明。考虑下面的汇编代码序列(完全是人为编造的):

  movq $0,%rax
  jmp .L1
  movq (%rax),%rdx
.L1:
  popq %rdx

指令jmp .L1会导致程跳过movq指令,而从popq指令开继执行。在产生目标代码文件时,汇编器会确定所有带标号指令的地址,并将挑战目标(目的指令的地址)编码为跳转指令的一部分。

下面列举了不同的跳转指令。

下面的指令和前面访问条件码一小节中的图3-14类似,但是多了无条件跳转。

(下面原书图3-15,我把它分成了多个表,jump 指令)

无条件跳转:

指令 同义名 跳转条件 描述
jmp Label 1 直接跳转
jmp *Operand ` 1 间接跳转

jmp 指令是无条件跳转。它可以是直接跳转,即跳转目标是作为指令的一部分编码的;也可以是间接跳转,即跳转目标是从寄存器或内存位置中读出的。

汇编语言中,直接跳转是给出一个标号作为跳转目标的,例如上面所示代码中的标号“.L1”。间接跳转的写法是*后面跟一个操作数指示符,使用图 3-3 中描述的内存操作数格式中的一种。举个例子,指令:

jmp *%rax

用寄存器 %rax 中的值作为跳转目标,而指令

jmp *(%rax)

%rax 中的值作为读地址,从内存中读出跳转目标。

零标志 ZF:

指令 同义名 跳转条件 描述
je Label jz ZF 相等/零
jne Label jnz ~ZF 不等/非零

符号标志 SF:

指令 同义名 跳转条件 描述
js Label SF 负数
jns Label ~SF 非负数

零标志 ZF 与 溢出标志 OF:

指令 同义名 跳转条件 描述
jg Label jnle ~(SF^OF)&(~ZF) 大于(有符号>)
jge Label jnl ~(SF^OF) 大于等于(有符号≥)
jl Label jnge SF^OF 小于(有符号<)
jle Label jng `(SF^OF) ZF`

进位标志 CF 与 零标志 ZF:

指令 同义名 跳转条件 描述
ja Label jnbe ~CF & ~ZF 大于(无符号>)
jae Label jnb ~CF 大于等于(无符号≥)
jb Label jnae CF 小于(无符号<)
jbe Label jna `CF ZF`

跳转指令的编码

虽然我们不关心机器代码格式的细节,但是理解跳转指令的目标如何编码,这对第 7 章研究链接非常重要。此外,它也能帮助理解反汇编器的输出。

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

下面是一个 PC 相对寻址的例子:

  movq 	%rdi,%rax
  jmp 	.L2
.L3:
  sarq	%rax
.L2:
  testq	%rax,%rax
  jg	.L3
  rep  ;ret

汇编器产生的 .o 格式的反汇编版本你如下:

0: 48 89 f8			mov	%rdi,%rax
3: eb 03			jmp	8 
5: 48 d1 f8			sar	%rax
8: 48 85 c0			test %rax,%rax
b: 7f f8			jg	5 
d: f3 c3			repz retq

右边反汇编器产生的注释中,第 2 行中跳转指令的跳转目标指明为 0x8,第 5 行中跳转指令的跳转目标是 0x5(反汇编器以十六进制格式给出所有的数字)。

不过,观察指令的字节编码,会看到第一条跳转指令的目标编码(在第二个字节中)为 0x03。把它加上 0x5,也就是下一条指令的地址,就得到跳转目标地址 0x8,也就是第 4 行指令的地址。类似,第二个跳转指令的目标用单字节、补码表示编码为 0xf8(十进制-8)。将这个数加上 0xd(十进制 13),即第 6 行指的地址,我们得到 0x5,即第 3 行指的地址。

看二进制编码,二进制编码是相对地址;后面汇编是反汇编的注释。

这些例子说明,当执行 PC 相对寻址时,程序计数器的值是跳转指令后面的那条指令的地址,而不是跳转指令本身的地址。这种惯例可以追溯到早期的实现,当时的处理器会将更新程序计数器作为执行一条指令的第一步

这样做(使用相对地址)的好处是,链接时可以不用修改代码:

下面时链接后的程序的反汇编版本:

4004d0: 48 89 f8			mov	%rdi,%rax
4004d3: eb 03				jmp	4004d8 
4004d5: 48 d1 f8			sar	%rax
4004d8: 48 85 c0			test %rax,%rax
4004db: 7f f8				jg	4004d5 
4004dd: f3 c3				repz retq

这些指令被重定位到不同的地址,但是第 2 行和第 5 行中跳转目标的编码并没有变。通过使用与 PC 相对的跳转目标编码,指令编码很简洁(只需要 2个字节),而且目标代码可以不做改变就移到内存中不同的位置。

旁注 指令 reprepz 有什么用
本节开始的汇编代码的第 8 行包含指令组合 rep; ret。它们在反汇编代码中(第 6行)对应于repz retq。可以推测出 repzrep 的同义名,而 retqret 的同义名。

查阅Intel和 AMD有关 rep 的文档,我们发现它通常用来实现重复的字符串操作。在这里用它似乎很不合适。这个问题的答案可以在 AMD 给编译器编写者的指导意见书[1]中找到。他们建议用 rep 后面跟 ret 的组合来避免使 ret 指令成为条件跳转指令的目标。如果没有 rep 指令,当分支不跳转时,jg 指令(汇编代码的第 7 行)会继续到 ret 指令。根据 AMD的说法,ret 指令通过跳转指今到达时,处理器不能正确预测 ret 指令的目的。这里的 rep 指令就是作为一种空操作,因此作为跳转目的插入它,除了能使代码在 AMD上运行得更快之外,不会改变代码的其他行为。在本书后面其他代码中再遇到 reprepz 时,我们可以很放心地无视它们。

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

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

if (test_expr)
    then_statement
else
	else_statement

这里 test_expr 是一个整数表达式,它的取值为0(解释为“假”)或者为非 0(解释为“真”)。两个分支语句中(then-statement 或else-statement)只会执行一个。

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

    t = test_expr;
    if (!t)
        goto false;
    then_statement
    goto done;
false:
	else_statement
done:

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

条件传送指令

为后面一节条"件传送来实现条件分支"做准备,也可以先看那一节。

下面列举了 x86-64 上一些可用的条件传送指令。每条指令都有两个操作数:源寄存器或者内存地址 S,和目的寄存器 R。与 SET 和跳转指令一样,这些指令的结果取决于条件码的值。源值可以从内存或者源寄存器中读取,但是只有在指定的条件满足时,才会被复制到目的寄存器中。

源和目的的值可以是 16 位、32 位或 64 位长。不支持单字节的条件传送。无条件指令的操作数的长度显式地编码在指令名中(例如 movw movl),汇编器可以从目标寄存器的名字推断出条件传送指令的操作数长度,所以对所有的操作数长度,都可以使用同一个的指令名字。

(下面几个表对应原书图3-18)

零标志 ZF:

指令 同义名 传送条件 描述
cmove S, R cmovz ZF 相等/零
cmovne S, R cmovnz ~ZF 不等/非零

符号标志 SF:

指令 同义名 传送条件 描述
cmovs S, R SF 负数
cmovns S, R ~SF 非负数

零标志 ZF 与 溢出标志 OF:

指令 同义名 传送条件 描述
cmovg S, R cmovnle ~(SF^OF)&(~ZF) 大于(有符号>)
cmovge S, R cmovnl ~(SF^OF) 大于等于(有符号≥)
cmovl S, R cmovnge SF^OF 小于(有符号<)
cmovle S, R cmovng `(SF^OF) ZF`

进位标志 CF 与 零标志 ZF:

指令 同义名 传送条件 描述
cmova S, R cmovnbe ~CF & ~ZF 大于(无符号>)
cmovae S, R cmovnb ~CF 大于等于(无符号≥)
cmovb S, R cmovnae CF 小于(无符号<)
cmovbe S, R cmovna `CF ZF`

同条件跳转不同,处理器无需预测测试的结果就可以执行条件传送。处理器只是读源值(可能是从内存中),检查条件码,然后要么更新目的寄存器,要么保持不变。我们会在之后关于"处理器体系结构"一章中探讨条件传送的实现。

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

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

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

看下面例子,计算两种结果,通过条件传送达到将正确答案传送到目的的作用。

(下面三个代码来自原书例3-17)

原始 C 语言代码:

long absdiff(long x,long y)
{
    long result;
    if (x < y) {
        result = y - x;
    }
    else {
        result = x - y;
    }
    return result;
}

汇编代码:

absdiff(long, long):
  movq    %rsi, %rax
  subq    %rdi, %rax    ;rval = y - x;
  movq    %rdi, %rdx
  subq    %rsi, %rdx	;eval = x - y;
  cmpq    %rsi, %rdi	;比较 x 与 y
  cmovge  %rdx, %rax
 ret

将其翻译成相同意思的C语言:

long cmovdiff(long x,long y)
{
    long rval = y - x;
    long eval = x - y;
    long ntest = x >= y;
    if (ntest) rval = eval;
    return rval;
}

为了理解为什么基于条件数据传送的代码会比基于条件控制转移的代码性能要好,我们必须了解一些关于现代处理器如何运行的知识。

下面这三段,简单来说程序会在流水线里塞指令,遇到分支会预测一个分支并接着塞,如果猜错了,之前塞的就白塞了,相当于浪费了时间。而条件传送指令的时间是固定的周期,不会浪费时间。

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

作为一个示例,我们在 Intel Haswell 处理器上运行 absdiff 函数,用两种方法来实现条件操作。在一个典型的应用中,x< y 的结果非常地不可预测,因此即使是最精密的分支预测硬件也只能有大约 50%的概率猜对。此外,两个代码序列中的计算执行都只需要一个时钟周期。因此,分支预测错误处罚主导着这个函数的性能。对于包含条件跳转的x86-64 代码,我们发现当分支行为模式很容易预测时,每次调用函数需要大约 8 个时钟周期;而分支行为模式是随机的时候,每次调用需要大约 17.50 个时钟周期。由此我们可以推断出分支预测错误的处罚是大约 19 个时钟周期。这就意味着函数需要的时间范围大约在8到27个周期之间,这依赖于分支预测是否正确。

另一方面,无论测试的数据是什么,编译出来使用条件传送的代码所需的时间都是大约8个时钟周期。控制流不依赖于数据,这使得处理器更容易保持流水线是满的。

如何确定分支预测错误的惩罚

假设预测错误的概率是 p , 如果没有预测错误, 执行代码的时间是 T O K T_{\mathrm{OK}} TOK , 而预测错误的处罚是 T M P T_{\mathrm{MP}} TMP 。那么, 作为 p 的一个函数, 执行代码的平均时间是 T a v g ( p ) = ( 1 − p ) T O K + p ( T O K + T M P ) = T O K + p T M P T_{\mathrm{avg}}(p)=(1-p) T_{\mathrm{OK}}+p\left(T_{\mathrm{OK}}+T_{\mathrm{MP}}\right)=T_{\mathrm{OK}}+p T_{\mathrm{MP}} Tavg(p)=(1p)TOK+p(TOK+TMP)=TOK+pTMP 。如果已知 T O K T_{\mathrm{OK}} TOK T r a n T_{\mathrm{ran}} Tran (当 p=0.5 时的平均时间), 要确定 T M P T_{\mathrm{MP}} TMP 。将参数代入等式, 我们有 T r a n = T a v g ( 0.5 ) = T O K + 0.5 T M P T_{\mathrm{ran}}=T_{\mathrm{avg}}(0.5)=T_{\mathrm{OK}}+0.5 T_{\mathrm{MP}} Tran=Tavg(0.5)=TOK+0.5TMP , 所以有 T M P = 2 ( T r a n − T O K ) T_{\mathrm{MP}}=2 \left(T_{\mathrm{ran}}-T_{\mathrm{OK}}\right) TMP=2(TranTOK) 。因此, 对于 T O K = 8 T_{\mathrm{OK}}=8 TOK=8 T r a n = 17.5 T_{\mathrm{ran}}=17.5 Tran=17.5 , 我们有 T M P = 19 T_{\mathrm{MP}}=19 TMP=19

为了理解如何通过条件数据传输来实现条件操作,考虑下面的条件表达式和赋值的通用形式:

v = test_expr ? then_expr : else_expr

用条件控制转移的标准方法来编译该表达式会得到如下形式:

	if (!test_expr)
		goto false;
	v = then_expr;
	goto done;
false:
	v = else_expr;
done:

用条件传送编译会得到:

v = then_expr;
ve = else_expr;
t = test_expr;
if (!t) v = ve;

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

书上的一个例子(图 3-16)就是这种情况。实际上,我们在该例中引入副作用就是为了强制 GCC 用条件转移来实现这个函数。

作为说明,考虑:

long cread(long *xp){
	return (xp? *xp:0);
}

乍一看,这段代码似平很适合被编译成使用条件传送,当指针为空时将结果设置为 0,
如下面的汇编代码所示:

; long cread(long *xp)
; 错误实现
; xp 在寄存器 %rdi 中
cread:
  movq	(%rdi), %rax	; v = *xp
  testq	%rdi, %rdi		; Text x
  movl	$0, %edx		; ve = 0
  cmove	%rdx, %rax		;if x==0, v= ve
  ret

这个实现是非法的,因为即使当测试为假时,movq 指令(第 5 行)对 xp 的间接引用还是发生了,导致一个间接引用空指针的错误。所以,必须用分支代码来编译这段代码

使用条件传送也不总是会提高代码的效率。例如,如果 then-expr 或者 else-expr 的求值需要大量的计算,那么当相对应的条件不满足时,这些工作就白费了。编译器必须考虑浪费的计算和由于分支预测错误所造成的性能处罚之间的相对性能。

说实话,编译器并不具有足够的信息来做出可靠的决定;例如,它们不知道分支会多好地遵循可预测的模式。我们对 GCC 的实验表明,只有当两个表达式都很容易计算时,例如表达式分别都只是一条加法指令,它才会使用条件传送。根据我们的经验,即使许多分支预测错误的开销会超过更复杂的计算,GCC 还是会使用条件控制转移。所以,总的来说,条件数据传送提供了一种用条件控制转移来实现条件操作的替代策略。它们只能用于非常受限制的情况,但是这些情况还是相当常见的,而且与现代处理器的运行方式更契合。

循环

C语言提供了多种循环结构,即 do-while、while 和 for。汇编中没有相应的指令存在,可以用条件测试和跳转组合起来实现循环的效果。GCC 和其他汇编器产生的循环代码主要基于两种基本的循环模式。我们会循序渐进地研究循环的翻译,从 do-while 开始,然后再研究具有更复杂实现的循环,并覆盖这两种模式。

do-while 循环

通用形式:

do
    body_statement
while (test_expr);

翻译至汇编:

loop:
	body-statement
	t = test_expr;
	if(t)
		goto loop;
while 循环

通用形式:

while (test_expr)
    body_statement

翻译至汇编:

第一种:

	goto test;
loop:
	body-statement
test:
	t = test_expr;
	if(t)
		goto loop;

第二种(guarded-do)(优化等级 -O1 时会用该策略):

t = test_expr;
if(!t)
	goto done;
loop:
	body_statement
	t = test_expr
	if(t)
		goto loop;
done:

利用这种实现策略,编译器常常可以优化初始的测试,例如认为测试条件总是满足。

for 循环

通用形式:

for(init_expr; test_expr;update_expr)
    body-statement

翻译至汇编(其实就是 while 循环改的):

第一种

init_expr;
goto test;
loop:
	body-statement
    update_expr;
test:
	t = test_expr;
	if(t)
		goto loop;

第二种(guarded-do)(优化等级 -O1 时会用该策略):

    init_expr;
    t = test_expr;
    if(!t)
        goto done;
loop:
	body_statement
    update_expr;
	t = test_expr
	if(t)
		goto loop;
done:
switch 语句

switch(开关)语句可以根据一个整数索引值进行多重分支(multiway branching)。在处理具有多种可能结果的测试时,这种语句特别有用。它们不仅提高了 C代码的可读性,而且通过使用**跳转表(jump table)**这种数据结构使得实现更加高效。跳转表是一个数组表项 i 是一个代码段的地址,这个代码段实现当开关索引值等于时程序应该采取的动作程序代码用开关索引值来执行一个跳转表内的数组引用,确定跳转指令的目标。和使用一组很长的 if-else 语句相比,使用跳转表的优点是执行switch语句的时间与switch情况的数量无关。GCC 根据switch情况的数量和switch情况值的稀疏程度来翻译switch语句。当switch情况数量比较多(例如 4 个以上),并且值的范围跨度比较小时,就会使用跳转表。

这是 C 语言和其 C翻译版汇编的示例:

C 语言原代码:

void switch_eg(long x, long n, long *dest)
{
    long val = x;
    switch (n) {
        case 100:
            val *= 13;
            break;
        case 102:
            val += 10;
        case 103:
            val += 11;
            break;
        case 104:
        case 106:
            val *= val;
            break;
        default:
            val = 0;
    }
    *dest = val;
}

翻译到扩展的 C语言:

void switch_eg_impl(long x, long n, long *dest)
{
    static void *jt[7] = {
    	&&loc_A, &&loc_def, &&loc_B,
        &&loc_C, &&loc_D, &&loc_def,
        &&loc_D,
    };
    unsigned long index = n - 100;
    long val;
    if (index > 6)
        goto loc_def;
    goto *jt[index];

loc_A: //case 100
    val = x * 13;
    goto done;
loc_B: //case 100
    x = x + 10;
loc_C: //case 100
    val = x + 11;
    goto done;
loc_D: //case 100
    val = x * x;
    goto done;
loc_def: //case 100
    val = 0;
done:
    *dest = val;
}

GCC 扩展了 C 语言,整了个跳转表。

下面是汇编:

;void switch_eg_impl(long x, long n, long *dest)
;x in %rdi, n in %rsi, dest in %rdx
switch_eg(long, long, long*):
        subq    $100, %rsi
        cmpq    $6, %rsi
        ja      .L8
        jmp     *.L4(,%rsi,8)
.L7:
        leaq    (%rdi,%rdi,2), %rax
        leaq    (%rdi,%rax,4), %rdi
        jmp     .L2
.L6:
        addq    $10, %rdi
.L5:
        addq    $11, %rdi
        jmp		.L2
.L3:
        imulq   %rdi, %rdi
        jmp     .L2
.L8:
        movl    $0, %edi
.L2:
        movq    %rdi, (%rdx)
        ret

在汇编代码中,跳转表用一下声明表示:

    .section	.rodata
    .align 8
.L4:
    .quad   .L7
    .quad   .L8
    .quad   .L6
    .quad   .L5
    .quad   .L3
    .quad   .L8
    .quad   .L3

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

检查所有这些代码需要很仔细的研究,但是关键是领会使用跳转表是一种非常有效的实现多重分支的方法。在我们的例子中,程序可以只用一次跳转表引用就分支到 5 个不同的位置。甚至当 switch 语句有上百种情况的时候,也可以只用一次跳转表访问去处理。

过程

过程:用一组指定的参数和一个可选的返回值实现了某种功能。

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

要提供对过程的机器级支持,必须要处理许多不同的属性。为了讨论方便,假设过程P调用过程Q,Q执行后返回到P。这些动作包括下面一个或多个机制:

  • 传递控制。在进入过程 Q 的时候,程序计数器必须被设置为的代码的起始地址,然后在返回时,要把程序计数器设置为P中调用 Q 后面那条指令的地址。
  • 传递数据。P必须能够向提供一个或多个参数,Q必须能够向P返回一个值。
  • 分配和释放内存。在开始时,Q可能需要为局部变量分配空间,而在返回前,又必须释放这些存储空间。x86-64的过程实现包括一组特殊的指令和一些对机器资源(例如寄存器和程序内存)使用的约定规则。
运行时栈

x86-64 的栈向低地址方向增长。

CSAPP阅读笔记-程序的机器级表示_第8张图片

此图看不懂可以看着后面就懂了。

x86-64 的栈向低地址方向增长,而栈指针%rsp指向栈项元素。可以用 pushq popq 指令将数据存入栈中或是从栈中取出。将栈指针减小一个适当的量可以为没有指定初始值的数据在栈上分配空间。类似地,可以通过增加栈指针来释放空间。

这个栈是用来存放数据(局部变量)的;call 压入返回地址,过程开头先保存寄存器;然后减少栈指针申请空间存放数据,过程结束时增加栈指针释放内存,然后弹出栈得到返回地址。

当x86-64过程需要的存储空间超出寄存器能够存放的大小时,就会在栈上分配空间。这个部分称为过程的栈帧(stack frame)。上图3-25给出了运行时栈的通用结构,包括把它划分为栈帧。

当前正在执行的过程的帧总是在栈顶。当过程 P 调用过程 Q 时,会把返回地址压入栈中,指明当Q返回时,要从P程序的哪个位置继续执行。我们把这个返回地址当做P的栈帧的一部分,因为它存放的是与P相关的状态。

Q的代码会扩展当前栈的边界,分配它的栈帧所需的空间。在这个空间中,它可以保存寄存器的值,分配局部变量空间,为它调用的过程设置参数。大多数过程的栈帧都是定长的,在过程的开始就分配好了。但是有些过程需要变长的帧。通过寄存器,过程 P 可以传递最多6个整数值(也就是指针和整数),但是如果 Q 需要更多的参数,P 可以在调用 Q 之前在自己的栈帧里存储好这些参数。

为了提高空间和时间效率,x86-64 过程只分配自己所需要的栈帧部分。例如,许多过程有6个或者更少的参数,那么所有的参数都可以通过寄存器传递。因此,图3-25中画出的某些栈帧部分可以省略。实际上,许多函数甚至根本不需要栈帧。当所有的局部变量都可以保存在寄存器中,而且该函数不会调用任何其他函数(有时称之为叶子过程,此时把过程调用看做树结构)时,就可以这样处理。

转移控制

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

在x86-64机器中,这个信息是用指令 call Q 调用过程Q来记录的。该指令会把地址A压入栈中,并将PC设置为 Q 的起始地址。压入的地址A被称为返回地址,是紧跟在 call 指令后面的那条指令的地址。对应的指令 ret 会从栈中弹出地址A,并把PC设置为A

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

call Label # 过程调用
call *Operand # 过程调用
ret # 从过充调用中返回

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

call 指令有一个目标,即指明被调用过程起始的指令地址。同跳转一样,调用可以是直接的,也可以是间接的。在汇编代码中,直接调用的目标是一个标号,而间接调用的目标是*后面跟一个操作数指示符,如上面代码所示。

数据传送

大部分数据传送是通过寄存器实现的。

如果一个函数有大于6个整型参数,超出6个的部分就要通过栈来传递。假设过程P调用过程Q,有n个整型参数,且n> 6。那么P的代码分配的栈帧必须要能容纳7到n号参数的存储空间。要把参数1~6复制到对应的寄存器,把参数 7~n 放到栈上,而参数 7 位于栈顶。通过栈传递参数时,所有的数据大小都向8的倍数对齐。

操作数大小(位) 1个参数 2个参数 3个参数 4个参数 5个参数 6个参数
64 %rdi %rsi %rdx %rcx %r8 %r9
32 %edi %esi %edx %ecx %e8d %r9d
16 %di %si %dx %cx %r8w %r9w
8 %dil %sil %dl %cl %r8b %r9b

参数到位以后,程序就可以执行 call 指令将控制转移到过程Q了。过程Q可以通过寄存器访问参数,有必要的话也可以通过栈访问。相应地,如果Q也调用了某个有超过6个参数的函数,它也需要在自己的栈帧中为超出6个部分的参数分配空间,如上一小节图3-25中标号为“参数构造区”的区域所示。

作为参数传递的示例,考虑图3-29a所示的C函数proc。这个函数有8个参数,包括字节数不同的整数(8、4、2和1)和不同类型的指针,每个都是8字节的。

(以下代码对应原书上图3-29)

原C语言代码

void proc(long a1, long *a1p,
         int a2, int *a2p,
         short a3, short *a3p,
         char a4, char *a4p)
{
    *a1p += a1;
    *a2p += a2;
    *a3p += a3;
    *a4p += a4;
}

汇编代码:

;void proc(a1,a1p,a2,a2p,a3,a3p,a4,a4p)
;a1 in %rdi  (64bit)
;a1p in %rsi  (64bit)
;a2 in %edx  (32bit)
;a1p in %rcx  (64bit)
;a3 in %r8w  (16bit)
;a3p in %r9  (64bit)
;a4 in %rsp+8  (8bit)
;a4p in %rsp+16 (64bit)
movq    16(%rsp), %rax
addq    %rdi, (%rsi)
addl    %edx, (%rcx)
addw    %r8w, (%r9)
movl    8(%rsp), %edx
addb    %dl, (%rax)
ret


CSAPP阅读笔记-程序的机器级表示_第9张图片

栈上的局部存储

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

  • 寄存器不足够存放所有的本地数据。
  • 对一个局部变量使用地址运算符 ‘&’,因此必须能够为它产生一个地址。
  • 某些局部变量是数组或结构,因此必须能够通过数组或结构引用被访问到。在描述数组和结构分配时,我们会讨论这个问题。

一般来说,过程通过减小栈指针在栈上分配空间。分配的结果作为栈帧的一部分,标号为“局部变量”,如图3-25所示。

在返回前,增加栈指针即可释放空间。

例子:

(以下代码对应原书上图3-32)

原C语言代码:

long call_proc()
{
    long x1 = 1;
    int x2 = 2;
    short x3 = 3;
    char x4 = 4;
    proc(x1,&x1,x2,&x2,x3,&x3,x4,&x4);
    return (x1+x2)*(x3-x4);
}

汇编代码:

; long call_proc()
call_proc:
subq    $32, %rsp
movq    $1, 24(%rsp)
movl    $2, 20(%rsp)
movw    $3, 18(%rsp)
movb    $4, 17(%rsp)
leaq    17(%rsp), %rax
movq	%rax, 8(%rsp)
movl	$4, (%rsp)
leaq    18(%rsp), %r9
movl    $3, %r8d
leaq    20(%rsp), %rcx
movl    $2, %edx
leaq    24(%rsp), %rsi
movl    $1, %edi
call    proc(long, long*, int, int*, short, short*, char, char*)
movslq  20(%rsp), %rdx
addq    24(%rsp), %rdx
movswl  18(%rsp), %eax
movsbl  17(%rsp), %ecx
subl    %ecx, %eax
cltq
imulq   %rdx, %rax
addq    $32, %rsp
ret

CSAPP阅读笔记-程序的机器级表示_第10张图片

寄存器中的局部存储空间

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

为了 让被调用者不会覆盖调用者稍后会使用的寄存器值。x86-64 采用了一组统一的寄存器使用惯例,所有的过程(包括程序库)都必须遵循:

根据惯例,寄存器 %rbx%rbp%r12~%r15 被划分为被调用者保存寄存器,会在栈帧中创建标号为“保存的寄存器”的一部分来保存这些寄存器,如图3-25中所示。

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

递归过程

因为使用了栈,所以多个未完成调用的局部变量不会相互影响,即被调用时分配局部存储,当返回时释放存储。

数组分配和访问

基本原则

x86-64 的内存引用指令可以用来简化数组访问。例如,假设 E 是一个 int 型的数组,而我们想计算 E[i],在此,E的地址存放在寄存器号 %rdx 中,而 i 存放在寄存器 %rcx 中。

然后,指令

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

执行地址计算 ,读这个内存位置的值,并将结果存放到寄存器 %eax 中。

允许的缩放因子1,2,4,8覆盖了所有基本简单数据类型的大小。

指针运算

数组访问本质就是指针运算。

拓展一下前面的例子,假设整型数组E的起始地和整数索引 i 分别存放在寄存器 %rdx 和 %rcx 中。下面是一些与E有关的表式。我们还给出了每个表式的汇编代码实现,结果存放在存 %eax(如果是数据)或存器 %rax(如果是指针)中。

表达式 类型 汇编代码
E int* x e x_e xe movq %rdx, %rax
E[0] int M [ x E ] M[x_E] M[xE] movl (%rdx),%rax
E[i] int M [ x E + 4 i ] M[x_E+4i] M[xE+4i] movl (%rdx,%rcx,4),%eax
&E[2] int* x E + 8 x_E+8 xE+8 leaq 8(%rdx),%rax
E+i-1 int* x E + 4 i − 4 x_E+4i-4 xE+4i4 leaq -4(%rdx,%rcx,4),%rax
*(E+i-3) int M [ x E + 4 i − 12 ] M[x_E+4i-12] M[xE+4i12] movl -12(%rdx,%rcx,4),%eax
``&E[i]-E` long i i i movq %rcx,%rax

最后一个可以看出,指针之差为 long 型。

嵌套的数组

多维数组:

T D[R][C];

计算数组元素 D[i][j] 的内存地址为:
& D [ i ] [ j ] = x D + L ( C ⋅ i + j ) \&D[i][j] = x_D + L(C \cdot i + j) &D[i][j]=xD+L(Ci+j)
这里,L 是数据类型 T 以字节为单位的大小。

数组作为数组元素时会做为一个新的类型,它的大小会被计算偏移时考虑。

定长数组

当数组 长度确定时,编译器会在编译时进行一些优化(-O1 时)。

举例,例如一个 16x16 的整数数组:

#define N 16
typedef int fix_matrix[N][N];

计算矩阵 A 和 B 乘积的元素 i,k,即 A 的 i 行和 B 的 k 列做内积。

原 C 代码如下:

int fix_prod_ele(fix_matrix A, fix_matrix B,long i, long k)
{
    long j;
    int result = 0;
    for (j = 0; j < N; j++)
        result += A[i][j] * B[j][k];
    return result;
}

汇编代码反汇编成C(优化后的C):

int fix_prod_ele_opt(fix_matrix A, fix_matrix B,long i, long k)
{
    int *Aptr = &A[i][0];
    int *Bptr = &B[0][k];
    int *Bend = &B[N][k];
    int result = 0;
    do {
        result += *Aptr * *Bptr;
        Aptr ++;
        Bptr += N;
    }while (Bptr != Bend);
    return result;
}

汇编代码:

;int fix_prod_ele_opt(fix_matrix A, fix_matrix B,long i, long k)
;A in %rdi, B in %rsi, i in %rdx, k in %rcx,
fix_prod_ele:
        salq    $6, %rdx
        addq    %rdx, %rdi
        leaq    (%rsi,%rcx,4), %rcx
        leaq    1024(%rcx), %rsi
        movl    $0, %eax
.L2:
        movl    (%rdi), %edx
        imull   (%rcx), %edx
        addl    %edx, %eax
        addq    $4, %rdi
        addq    $64, %rcx
        cmpq    %rsi, %rcx
        jne     .L2
        rep; ret

优化:

  • 去掉了整数索引 j,将所有数组引用转换成指针间接引用。
  • 生成指针 Aptr、Bptr
  • 生成指针 Bend,作为终止循环时 Bptr 的终点
变长数组

ISO C99 引入变长数组:即数组的维度是一个表达式的结果而不是常数,允许在数组被分配的时候才计算出来。

在变长数组的C版本中,我们可以将一个数组声明如下:

int A[expr1][expr2];

它可以作为一个局部变量,也可以作为一个函数的参数,然后在到这个声明的时候,通过对表达式 expr1expr2 求值来确定数组的维度。因此,例如要访间 nxn 数组的元素,我们可以写一个如下的函数:

int var_ele(long n, int A[n][n], long i, long j) 
{
    return A[i][j];
}

参数n必须在参数A[n][n]之前,这样函数就可以在遇到这个数组的时候计算出数组的维度。

GCC 为这个引用函数产生的代码如下所示:

;int var_ele(long n, int A[n][n], long i, long j) 
;n in %rdi, A in %rsi, i in %rdx, j in %rcx,
var_ele:
imulq   %rdx, %rdi
leaq    (%rsi,%rdi,4), %rax
movl    (%rax,%rcx,4), %eax
ret

引用变长数组只需要对定长数组做一点儿概括。动态的版本必须用乘法指令对 i 伸缩 n 倍,而不能用一系列的移位和加法。在一些处理器中,乘法会招致严重的性能处,但是在这种情况中无可避免。

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

例如,我们用变长数组写之前的那个计算矩阵乘法一元素的函数:

int var_prod_ele(long n,int A[n][n],int B[n][n],long i, long k)
{
    long j;
    int result = 0;
    
    for (j = 0; j < n; j++)
        result += A[i][j] * B[j][k];
    
    return result;
}

汇编代码反汇编成C(优化后的C):

int var_prod_ele_opt(long n,int A[n][n],int B[n][n],long i, long k)
{
    int *Arow = A[i];
    int *Bptr = &B[0][k];
    int result = 0;
    long j;
    for (j = 0; j < n; j++) {
        result += Arow[j] * *Bptr;
        Bptr += n;
    }
    return result;
}

这个代码与周定大小数组的优化代码(图3-37)风格不同,不过这更多的是编译器选择的结果,而不是两个函数有么根本的不同造成的。上面的代码保留了循环变量 j,用以判定循环是否结束和作为到A的 i 行的元素组成的数组的索引。

汇编代码(循环的):

.L3:
movl    (%rsi,%rdx,4), %r8d ;读 Arow[j]
imull   (%rcx), %r8d		;乘以 *Bptr
addl    %r8d, %eax			;加到结果中
addq    $1, %rdx			;j++
addq    %r9, %rcx			;Bptr += n
cmpq    %rdi, %rdx			;比较 j 和 n
jne     .L3

同样在遍历变长数组的函数中,如果允许优化,GCC 能够识别出程序访问多维数组的元素的步长。然后生成的代码会避免直接应用便宜计算而导致的乘法。这些优化都能显著提高程序的性能。

异质的数据结构

struct 和 union

结构体

用名字来引用结构的各个组成部分。类似于数组的实现,结构的所有组成部分都存放在内存中一段连续的区域内,而指向结构的指针就是结构第一个字节的地址。编译器维护关于每个结构类型的信息,指示每个字段(field)的字节偏移。它以这些偏移作为内存引用指令中的位移,从而产生对结构元素的引用。

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

联合

用不同的字段来引用相同的内存块。

在一些下上文中,联合分有用。但是,它也能引起一些讨厌的错误,因为它们绕过
两个不同字段的使用是互斥的,那么将这两个字段声明为联合的一部分,而不是结构的一部分,会减小分配空间的总量。

比如只有叶节点有数据的二叉树(还需要引入一个枚举常量来表明是节点还是叶子)。

如下:

typedef enum {N_LEAF,N_INTERNAL} nodetype_t;
struct node_t {
    nodetype_t type;
    union {
        struct {
            struct node_t *left;
            struct node_t *right;
        } internal;
        double data[2];
    } info;
};

上面的结构体中,联合 union 占了 16 字节,type占了4个字节,但是需要对齐4个字节,所以一共24个字节。

在这种情况中,相对于给代码造成的麻烦,使用联合带来的节省是很小的。对于有较多字段的数据结构,这样的节省会更加吸引人。

联合还可以用来访间不同数据类型的位模式。例如,假设我们使用简单的强制类型转换将一个doube类型的值转为unsignedong类型的值u:

unsigned long u = (unsigned long) d;

值 u 会是的整数表示。除了d 的值为 0.0 的情况以外,u 的位表会与 d 的很不一样。再看下面这段代码,从一个double产生一个unsigned long类型的值:

unsigned long double2bits(double d) {
    union {
        double d;
        unsigned long u;
    }temp;
    temp.d = d;
    return temp.u;
};

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

当用联合来将种不同大小的数据类型结合到一起时,字节顺序就变得很重要了。比如将两个四字节整型合成一个八字节类型时。

注意不同的字节顺序(大端/小端)对同一区域内字节的解释差异。

数据对齐

许多计算机系统对基本数据类型的合法地址做出了一些限制,要求某种类型对象的地址必须是某个值 K (通常是2、4或8)的倍数。这种对齐限制简化了形成处理器和内存系统之间接口的硬件设计。例如,假设一个处理器总是从内存中取8个字节,则地址必须为8的倍数。如果我们能保证将所有的double类型数据的地址对齐成8的倍数,那么就可以用一个内存操作来读或者写值了。否则,我们可能需要执行两次内存访问,因为对象可能被分放在两个8字节内存块中。

无论数据是否对齐,x86-64 硬件都能正确工作。不过,Intel 还是建议要对齐数据以提高内存系统的性能。**对齐原则是任何 K 字节的基本对象的地址必须是 K 的倍数。**可以看到这条原则会得到如下对齐:

K 类型
1 char
2 short
4 int, float
8 long, double , char*

确保每种数据类型都是按照指定方式来组织和分配,即每种型的对象都满足它的对齐限制,就可保证实施对齐。

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

.align 8

这就保证它后面的数据起始地址是 8 的倍数。

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

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

struct S1 {
    int i;
    char c; // 会插入 3 字节的间隙
    int j;
};

//整体大小为 12 字节。

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

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

struct S2 {
    int i;
    int j;
    char c;
};

struct S2 d[4];

如果我们只考虑 S2 这个结构体,将其打包成9个字节,只要保证结构的起始地址满足4字节对齐要求,我们仍然能够保证满足字段和的对齐要求。

但是一旦考虑声明该结构体的数组,就会发现文图,因此,编译器仍会为结构体 S2 分配 12 个字节。

关于强制对齐

对于大多数x86-64指今来说,保持数对齐能够提高效率,但是它不会影程序的行为。一方面,如果数据没有对齐,某型号的Intel和AMD处理器对于有些实现多媒体操作的SSE指令就无法正确行。这些指令对16字节数据块进行操作,在SSE单元和内存之间传送数据的指令要求内存地扯必须是16的倍数。任间试图以不满对齐要求的地扯来访间内存都会导致异常,默认的行为是程终止。
因此,任何针对x86-64处理器的编译器和运行时系统都必须保证分配用来保存可能会被SSE寄存器读或写的数据结构的内存必须满足16字节对齐。这个要求有两个后果:

  • 任何内存分配函数(allocamalloccallocrealloc)生成的块的起始地址都必须是16的倍数。
  • 大多数函数的栈的边界都必须是16字节的数。(这个要求有一些例外。)

较近版本的x86-64处理器实现了AVX 多媒体指令。除了提供SSE指的超集,支持AVX的指令并没有强制性的对齐要求。

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

指针
  • 每个指针都对应一个类型
  • 每个指针都有一个值
  • 指针使用 & 运算符创建
  • * 操作符用于间接引用指针
  • 数组与指针
  • 将指针从从一种类型强制换成另一种类型,只改变它的类型,而不改变它的值。
  • 指针也可以指向函数
GDB

启动 GDB:

linux> gdb prog

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

下面是一些示例:

开始和停止:

quit	退出GDB
run		运行程序(在此给出命令行参数)
kill	停止程序

断点:
break multstore	在函数multstore人口处设置断点
break *0x400540	在地址0x400540处设置断点
delete 1		删除断点1
delete			删除所有断点

执行:
stepi			执行1条指令
stepi 4			执行4条指令
nexti			类似于stepi.但以函数调用为单位
continue		继续执行
finish			运行到当前函数返回

检查代码:
disas			反汇编当前函数
disas multstore	反汇编函数 multstore
disas 0x400544	反汇编位于地址0x400544附近的函数
disas 0x400540,0x40054d	反汇编指定地址范围内的代码
print /x $rip 	以十六进制输出程序计数器的值

检查数据:
print $rax		以十进制输出%rax的内容
print /x $rax	以十六进制输出%rax的内容
print /t $rax	以二进制输出%rax的内容
print 0x100		输出0x100的十进制表示
print /x 555	输出555的十六进制表示
print /x ($rsp+8)			以十六进制输出%rsp的内容加上8
print *(1ong *) 0xfffffe818	输出位于地址0x7ffffffe818的长整数
print *(1ong *) ($rsp+ 8)	输出位于地址%rsp+8处的长整数
x/2g 0x74ff1ffe818			检查从地址0x6fffffe818开始的双(8字节)字
x/20b multstore				检查函数multstore的前20个字节

有用的信息:
info frame		有关当前栈帧的信息
info registers	所有寄存器的值
help			获取有关GDB的信息

GDB的 在线帮助信息(用GDB的help命令调用)

相对于使用命令行接口来访问GDB,许多程序员更愿意使用DDD,它是GDB的一个扩展,提供了图形用户界面。

你可能感兴趣的:(笔记,计算机系统)