第三章 程序的机器级表示
基础
- 汇编代码、机器代码
- 机器代码的形式:
汇编代码
是机器代码的文本表示。目标代码
是机器代码的一种形式,包含所有指令的二进制表示,但是还没有填入全局值的地址。可执行代码
是机器代码的第二种形式,也就是处理器执行的代码格式。
- 高级语言编写的程序可以在很多不同的机器上编译和执行,而汇编代码则是与特定机器密切相关的。
- 在第7章,会详细介绍不同形式的机器代码之间的关系以及链接的过程。
- 机器代码的形式:
- gcc将源代码p1.c,p2.c转化成可执行代码的过程:
- 输入
gcc -Og -o p pl.c p2.c
- C预处理器扩展源代码,插入#include指定的文件,并扩展#define声明指定的宏。
- 编译器产生两个源文件的
汇编代码
pl.s和p2.s。- 编译器将C源码变换成机器代码时,可能有优化操作(比如递归👉迭代)
- 汇编器会将汇编代码转化成二进制
目标代码
文件p1.o和p2.o。 - 最后,链接器将两个目标代码文件与实现库函数(例如printf)的代码合并,并产生最终的
可执行代码
文件p(由命令-o p指定)。
- 输入
- 机器级编程的两种抽象
- 第一种是由指令集体系结构或指令集架构(Instruction Set Architecture, ISA)来定义机器级程序的格式和行为。处理器的硬件远比描述的精细复杂,它们并发地执行许多指令,但是可以采取措施保证整体行为与ISA指定的顺序执行的行为完全一致。
- 第二种抽象是,机器级程序使用的内存地址是虚拟地址,提供的内存模型看上去是一个非常大的字节数组。
- 虽然C语言可以在内存中声明和分配数据类型对象,但是机器代码只是将内存看成一个很大的、按字节寻址的数组。
- 聚合数据类型,例如数组和结构,在机器代码中用一组连续的字节来表示。
- 即使是对标量数据类型,汇编代码也不区分有符号或无符号整数,不区分各种类型的指针,甚至于不区分指针和整数。
- 指令里的操作数分为三种类型:
- 操作数第一种类型是立即数(immediate),用来表示常数。对应立即数寻址;
- 第二种类型是寄存器(register),对应寄存器寻址;
- 第三类操作数是内存引用。对应比例变址寻址。
- 数据传送指令中:
- 源操作数指定的值是一个立即数,存储在寄存器中或者内存中。
- 目的操作数指定一个位置,要么是一个寄存器或者,要么是一个内存地址。
- x86-64加了一条限制,传送指令的两个操作数
不能同时指向内存
位置。将一个值从一个内存位置复制到另一个内存位置需要两条指令:第一条指令将源值加载到寄存器中,第二条将该寄存器值写人目的位置。
- 数据传送例子:swap
- C语言“指针”就是地址。间接引用指针就是将该指针放在一个寄存器中,然后在内存引用中使用这个寄存器。
- 局部变量通常是保存在寄存器中,而不是内存中。访问寄存器比访问内存要快得多。
控制:条件转移/条件传送
- 条件表达式
V=(test-expr? then-expr: else-expr)
有两种编译方式- 条件控制转移:if-else形式。
- 条件数据传送:if和else分支下的两种运算都进行。根据条件真假,从两种里面取得正确运算结果。
- 条件数据传送局限性的例子:
- 比如
int i = (p? *p: 0)
- 不能使用条件数据传送来编译,否则会引起空指针访问的危险。
- 所以必须使用条件控制转移来编译。
- 比如
- 两种编译方式-性能对比
- 观点1(流水线):条件数据传送更好。
- 条件数据传送会比条件控制转移性能要好。在处理器的
流水线
中,一条指令的处理要经过一系列的阶段,每个阶段执行所需操作的一小部分(例如,从内存取指令、确定指令类型、从内存读数据、执行算术运算、向内存写数据,以及更新程序计数器)。这种方法通过重叠连续指令的步骤
来获得高性能, - 例如,在取一条指令的同时,执行它前面一条指令的算术运算。要做到这一点,要求
事先确定要执行的指令序列
,这样才能保持流水线中充满
了待执行的指令。 - 当机器遇到条件跳转(分支)时,只有当分支条件求值完成之后,才能决定分支往哪边走。处理器采用精密的
分支预测
逻辑来猜测每条跳转指令是否会执行。只要它的猜测还比较可靠(现代微处理器设计试图达到90%以上的成功率),指令流水线中就会充满着指令。
- 但是,错误预测一个跳转,处理器就要丢掉自己为该跳转指令后所有指令已做的工作,然后再开始用从正确位置处起始的指令去填充流水线。这样一个错误预测会招致很严重的惩罚,浪费大约15~30个时钟周期,导致程序性能严重下降。
- 条件数据传送会比条件控制转移性能要好。在处理器的
- 观点2:条件控制转移更好
- 条件传送不一定总是会提高代码的效率。例如,如果then-expr或者else-expr的求值需要大量的计算,当相对应的条件不满足时,这些工作就白费了。
- 编译器必须考虑
浪费的计算
和由于分支预测错误
所造成的性能处罚。然而,编译器并不具有足够的信息来做出可靠的决定;例如,它们不知道分支会多好地遵循可预测的模式。 - 实验表明,只有当两个表达式都很容易计算时,例如表达式分别都只是一条加法指令,它才会使用条件传送。
- 根据经验,即使许多分支预测错误的开销会超过更复杂的计算,GCC还是会使用条件控制转移。
- 小结:两种编译方式的使用场景
- 多数情况下,GCC使用条件控制转移更多;
- 只有if和else分支下的两种运算都很简单时(比如加法指令),GCC才会使用条件数据传送。
- 观点1(流水线):条件数据传送更好。
- 硬件处理:
- 条件码寄存器保存着最近执行的算术或逻辑指令的状态信息。它们用来实现控制或数据流中的条件变化,比如说用来实现if和while语句。
- 多重分支(switch):跳转表
过程
- 假设过程P调用过程Q,Q执行后返回到P。这些动作包括下面一个或多个机制:
- 传递控制。在进人过程Q的时候,程序计数器必须被设置为Q的代码的起始地址,然后在返回时,要把程序计数器设置为P中调用Q后面那条指令的地址。
- 传递数据。P必须能够向Q提供一个或多个参数,Q必须能够向P返回一个值。
- 大部分过程间的数据传送是通过
寄存器
实现的。
- 大部分过程间的数据传送是通过
- 分配和释放内存。在开始时,Q可能需要为局部变量分配空间,而在返回前,又必须释放这些存储空间。
- 栈帧
- 当x86-64过程需要的存储空间超出
寄存器
能够存放的大小时,就会在栈
上分配空间。这个部分称为过程的栈帧
(stack fram)。 - 函数不需要栈帧的情况:
- 当过程有6个或者更少的
参数
时,所有参数都可以通过寄存器
传递。(如果整型参数大于6个,超出6个的部分就要通过栈
来传递) - 当所有的
局部变量
都可以保存在寄存器
中,而且该函数不会调用任何其他函数(有时称之为叶子过程,此时把过程调用看做树结构)时。
- 当过程有6个或者更少的
- 当x86-64过程需要的存储空间超出
- 局部数据必须存放在内存中-常见情况:
- 寄存器不足够存放所有的本地数据。
- 对一个局部变量使用地址运算符‘&’,必须能够为它产生一个地址。
- 比如swap里的
址传递
。由于使用地址运算符,所以必须分配一个栈帧。
- 比如swap里的
- 某些局部变量是数组或结构,因此必须能够通过数组或结构引用被访问到。
- 寄存器组是唯一被所有过程共享的资源。
- x86-64的内存引用指令可以用来简化数组访问。
- 假设整型数组E的起始地址和整数索引i,分别存放在寄存器%rdx和%rcx中。
- 然后,执行
地址计算
,读这个内存
位置的值,结果存放在寄存器%eax(如果是数据)或寄存器%rax(如果是指针)中。
其它
- 异质的数据结构
- 联合union里每个字段偏移量都是0,即数据结构的起始位置。
- 一个union的总大小 == 最大字段的大小。
- 一种应用情况是,我们事先知道对一个数据结构中的两个不同字段的使用是
互斥
的,那么将这两个字段声明为union的一部分,而不是struct的一部分,会减小分配空间的总量。 - 联合还可以用来访问
不同数据类型
的位模式
。例如,强制类型转换。
- 对抗缓冲区溢出攻击
- 栈随机化
- 栈破坏检测:金丝雀值
- 限制可执行代码区域
- 为了管理
变长
栈帧,x86-64代码使用寄存器%rbp作为帧指针
(frame pointer),有时称为基指针
(base pointer)。