Computer Systems:A Programmer's Perspective

深入理解计算机系统(第三章)

Posted by LT on July 15, 2020

第三章 程序的机器级表示

基础

  1. 汇编代码、机器代码
    • 机器代码的形式:
      • 汇编代码是机器代码的文本表示。
      • 目标代码是机器代码的一种形式,包含所有指令的二进制表示,但是还没有填入全局值的地址。
      • 可执行代码是机器代码的第二种形式,也就是处理器执行的代码格式。
    • 高级语言编写的程序可以在很多不同的机器上编译和执行,而汇编代码则是与特定机器密切相关的。
    • 在第7章,会详细介绍不同形式的机器代码之间的关系以及链接的过程。
  2. 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指定)。
  3. 机器级编程的两种抽象
    • 第一种是由指令集体系结构或指令集架构(Instruction Set Architecture, ISA)来定义机器级程序的格式和行为。处理器的硬件远比描述的精细复杂,它们并发地执行许多指令,但是可以采取措施保证整体行为与ISA指定的顺序执行的行为完全一致。
    • 第二种抽象是,机器级程序使用的内存地址是虚拟地址,提供的内存模型看上去是一个非常大的字节数组。
  4. 虽然C语言可以在内存中声明和分配数据类型对象,但是机器代码只是将内存看成一个很大的、按字节寻址的数组。
    • 聚合数据类型,例如数组和结构,在机器代码中用一组连续的字节来表示。
    • 即使是对标量数据类型,汇编代码也不区分有符号或无符号整数,不区分各种类型的指针,甚至于不区分指针和整数。
  5. 指令里的操作数分为三种类型:
    • 操作数第一种类型是立即数(immediate),用来表示常数。对应立即数寻址;
    • 第二种类型是寄存器(register),对应寄存器寻址;
    • 第三类操作数是内存引用。对应比例变址寻址。
  6. 数据传送指令中:
    • 源操作数指定的值是一个立即数,存储在寄存器中或者内存中。
    • 目的操作数指定一个位置,要么是一个寄存器或者,要么是一个内存地址。
    • x86-64加了一条限制,传送指令的两个操作数不能同时指向内存位置。将一个值从一个内存位置复制到另一个内存位置需要两条指令:第一条指令将源值加载到寄存器中,第二条将该寄存器值写人目的位置。
  7. 数据传送例子:swap
    • C语言“指针”就是地址。间接引用指针就是将该指针放在一个寄存器中,然后在内存引用中使用这个寄存器。
    • 局部变量通常是保存在寄存器中,而不是内存中。访问寄存器比访问内存要快得多。

控制:条件转移/条件传送

  1. 条件表达式V=(test-expr? then-expr: else-expr)有两种编译方式
    • 条件控制转移:if-else形式。
    • 条件数据传送:if和else分支下的两种运算都进行。根据条件真假,从两种里面取得正确运算结果。
  2. 条件数据传送局限性的例子:
    • 比如int i = (p? *p: 0)
    • 不能使用条件数据传送来编译,否则会引起空指针访问的危险。
    • 所以必须使用条件控制转移来编译。
  3. 两种编译方式-性能对比
    1. 观点1(流水线):条件数据传送更好。
      • 条件数据传送会比条件控制转移性能要好。在处理器的流水线中,一条指令的处理要经过一系列的阶段,每个阶段执行所需操作的一小部分(例如,从内存取指令、确定指令类型、从内存读数据、执行算术运算、向内存写数据,以及更新程序计数器)。这种方法通过重叠连续指令的步骤来获得高性能,
      • 例如,在取一条指令的同时,执行它前面一条指令的算术运算。要做到这一点,要求事先确定要执行的指令序列,这样才能保持流水线中充满了待执行的指令。
      • 当机器遇到条件跳转(分支)时,只有当分支条件求值完成之后,才能决定分支往哪边走。处理器采用精密的分支预测逻辑来猜测每条跳转指令是否会执行。只要它的猜测还比较可靠(现代微处理器设计试图达到90%以上的成功率),指令流水线中就会充满着指令。
      • 但是,错误预测一个跳转,处理器就要丢掉自己为该跳转指令后所有指令已做的工作,然后再开始用从正确位置处起始的指令去填充流水线。这样一个错误预测会招致很严重的惩罚,浪费大约15~30个时钟周期,导致程序性能严重下降。
    2. 观点2:条件控制转移更好
      • 条件传送不一定总是会提高代码的效率。例如,如果then-expr或者else-expr的求值需要大量的计算,当相对应的条件不满足时,这些工作就白费了。
      • 编译器必须考虑浪费的计算和由于分支预测错误所造成的性能处罚。然而,编译器并不具有足够的信息来做出可靠的决定;例如,它们不知道分支会多好地遵循可预测的模式。
      • 实验表明,只有当两个表达式都很容易计算时,例如表达式分别都只是一条加法指令,它才会使用条件传送。
      • 根据经验,即使许多分支预测错误的开销会超过更复杂的计算,GCC还是会使用条件控制转移。
    3. 小结:两种编译方式的使用场景
      • 多数情况下,GCC使用条件控制转移更多;
      • 只有if和else分支下的两种运算都很简单时(比如加法指令),GCC才会使用条件数据传送
  4. 硬件处理:
    • 条件码寄存器保存着最近执行的算术或逻辑指令的状态信息。它们用来实现控制或数据流中的条件变化,比如说用来实现if和while语句。
    • 多重分支(switch):跳转表

过程

  1. 假设过程P调用过程Q,Q执行后返回到P。这些动作包括下面一个或多个机制:
    • 传递控制。在进人过程Q的时候,程序计数器必须被设置为Q的代码的起始地址,然后在返回时,要把程序计数器设置为P中调用Q后面那条指令的地址。
    • 传递数据。P必须能够向Q提供一个或多个参数,Q必须能够向P返回一个值。
      • 大部分过程间的数据传送是通过寄存器实现的。
    • 分配和释放内存。在开始时,Q可能需要为局部变量分配空间,而在返回前,又必须释放这些存储空间。
  2. 栈帧
    • 当x86-64过程需要的存储空间超出寄存器能够存放的大小时,就会在上分配空间。这个部分称为过程的栈帧(stack fram)。
    • 函数不需要栈帧的情况:
      • 当过程有6个或者更少的参数时,所有参数都可以通过寄存器传递。(如果整型参数大于6个,超出6个的部分就要通过来传递)
      • 当所有的局部变量都可以保存在寄存器中,而且该函数不会调用任何其他函数(有时称之为叶子过程,此时把过程调用看做树结构)时。
  3. 局部数据必须存放在内存中-常见情况:
    • 寄存器不足够存放所有的本地数据。
    • 对一个局部变量使用地址运算符‘&’,必须能够为它产生一个地址。
      • 比如swap里的址传递。由于使用地址运算符,所以必须分配一个栈帧。
    • 某些局部变量是数组或结构,因此必须能够通过数组或结构引用被访问到。
  4. 寄存器组是唯一被所有过程共享的资源。
    • x86-64的内存引用指令可以用来简化数组访问。
    • 假设整型数组E的起始地址和整数索引i,分别存放在寄存器%rdx和%rcx中。
    • 然后,执行地址计算,读这个内存位置的值,结果存放在寄存器%eax(如果是数据)或寄存器%rax(如果是指针)中。

其它

  1. 异质的数据结构
    • 联合union里每个字段偏移量都是0,即数据结构的起始位置。
    • 一个union的总大小 == 最大字段的大小。
    • 一种应用情况是,我们事先知道对一个数据结构中的两个不同字段的使用是互斥的,那么将这两个字段声明为union的一部分,而不是struct的一部分,会减小分配空间的总量。
    • 联合还可以用来访问不同数据类型位模式。例如,强制类型转换。
  2. 对抗缓冲区溢出攻击
    • 栈随机化
    • 栈破坏检测:金丝雀值
    • 限制可执行代码区域
  3. 为了管理变长栈帧,x86-64代码使用寄存器%rbp作为帧指针(frame pointer),有时称为基指针(base pointer)。