1 2 3 4

CSAPP(第三版)第三章程序的机器级表示学习笔记

高级语言通过编译变成汇编语言,汇编代码则与特定的机器密切相关。汇编代码中包含了管理存储器(memory)和执行计算的低级指令的一些细节(写高级程序的人员一般不需要考虑的)。编译器基于编程语言的原则、目标机器的指令集和操作系统遵循的规则,经过一系列的阶段产生机器代码

程序编码

机器级代码

对于机器级编程来说,其中两种抽象尤为重要。第一种是由指令及体系结构或者指令集架构(Instruction Set Architecture,ISA)来定义机器级程序的格式和行为,包括x86-65,将程序的行为描述成好像每条指令都是按顺序执行的,一条指令结束后,下一条再开始。处理器的硬件远比描述的精细复杂,他们并发的执行了许多指令,但是可以采取措施保证整体行为与ISA指定的顺序执行的行为完全一致。第二种抽象是,机器级程序使用的内存地址是虚拟地址,提供的内存模型看上去是一个非常大的字节数组。

x86-64的机器代码和原始的C代码差别非常大。一些通常对C语言程序员隐藏的处理器状态都是可见的:

  • 程序计数器(通常称为“PC”,在x86-64中用%rip表示)给出将要执行的下一条指令在内存中的地址。
  • 整数寄存器文件包含 8 个命名的位置,分别存储 32 位的值。这些寄存器可以存储地址(对应于 C 语言的指针)或证书数据。有的寄存器被用来记录某些重要的程序状态,而其他的寄存器则用来保存临时数据,例如过程的参数和局部变量,以及函数的返回值。
  • 条件码(codition code)寄存器保存着最近执行的算术或逻辑指令的状态信息。它们用来实现控制或数据流中的条件变化。
  • 一组浮点寄存器存放浮点数据

c语言中的聚合数据类型,例如数组和结构,在机器代码中用一组连续的字节来表示。即使是对标量数据类型,汇编代码也不区分有符号或无符号整数,不区分各种类型的指针,甚至于不区分指针和整数。

程序内存包含:程序的可执行机器代码,操作系统需要的一些信息,用来管理过程调用和返回的运行时栈,以及用户分配的内存块。操作系统负责管理虚拟地址空间,将虚拟地址翻译成实际处理器内存中的物理地址。

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

代码示例

关于机器代码和它的反汇编的特性值:

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

关于格式的注解

所有以‘.’开头的行都是知道汇编器和链接器工作的伪指令。我们通常可以忽略这些行

数据格式

enter description here

访问信息

一个x86-64的中央处理单元(CPU)包含一组16个存储64位值的通用目的寄存器。这些寄存器用来存储整数数据和指针。
enter description here

操作数指示符

大多数指令有一个或者多个操作室,指示出执行的一个操作中要是用的源数据值,以及放置结果的目的位置。x86-64支持多种操作数格式。源数据值可以以常数形式给出,或是从寄存器或内存中读出。结果可以存放在寄存器或内存中。因此,各种不同的操作数的可能性被分为三种类型:

  • 立即数,用来表示常数值。
  • 寄存器,它表示某个寄存器的内容
  • 内存引用,它会根据计算的地址访问某个内存位置
    enter description here

数据传送指令

最频繁使用的指令是将数据从一个位置复制到另一个位置的指令。
enter description here

压入和弹出栈数据

栈在处理过程调用中起到至关重要的作用。栈是一种数据接口,可以添加或者删除值,不过要遵循“后进先出”的原则。push压入,pop删除,弹出的值永远是最新被压入而且仍在栈中的值。

算数和逻辑操作

image

加载有效地址

加载有效地址(load effective address)指令leaq实际上是movq指令的变形。他的指令形式是从内存读数据到寄存器,但实际上它根本没有引用内存。

移位操作

移位量可以是一个立即数,或者放在单字节寄存器%cl中。

特殊的算数操作

image

控制

条件码

除了整数寄存器,CPU还维护着一组单个位的条件码寄存器,他们秒速了最新的算数或者逻辑操作的属性。常用的条件码有:

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

访问条件码

条件码重唱不回直接读取。常用的使用方法有三种:

  1. 可以根据条件吗的某种组合,将一个字节设置为0或者1.
  2. 可以条件跳转到程序的某个其他的部分。
  3. 可以有条件的传送数据。
    image

跳转指令

跳转指令会导致执行切换到程序中一个全新的位置。
image

跳转指令的编码

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

将条件表达式和语句从C语言翻译成机器代码,最常用的方式是结合有条件和无条件跳转。
基本有if,else if 和 switch两种,但是编译的代码可能按照一样的方式处理

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

实现条件操作的传统方法是通过使用控制的条件转移。替代的策略是使用数据的条件转移。
image

循环

1.do-while
2.while循环

switch语句

switch语句可以根基一个整数索引值进行多重分支。在处理具有多种可能结果的测试时,这种语句特别有用。他们不仅提高代码的可读性,而且通过跳转表这种数据结构使得实现更加高效。

过程

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

  • 传递控制
  • 传递数据
  • 分配和释放内存

运行时栈

C语言过程调用机制的一个关键特性在于使用了栈数据结构提供的后劲先出的内存管理原则。
image

转移控制

数据传送

当调用一个过程时,除了要把控制传递传递给他并在过程返回时再传递回来之外,过程调用还可能包括吧数据作为参数传递,而从过程返回还有可能包括返回一个值。

栈上的局部储存

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

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

寄存器中的局部存储空间

寄存器组是唯一被所有过程共享的资源。虽然在给定时刻只能有一个过程是活动的,但是我们必须保证当一个过程调用另一个过程时,被调用者不会覆盖某个调用者稍后会使用的寄存器的值。

根据惯例,寄存器 %eax、%edx、%ecx 被划分为调用者保存寄存器(caller save)。当过程 P 调用 Q 时,Q 可以覆盖这些寄存器,而不会破坏任何 P 所需要的数据。另一方面, 寄存器 %ebx、%esi、%edi 被划分为被调用者保存寄存器(callee save)。

递归过程

递归调用一个函数本身与调用其他函数是一样的。栈规则提供了一种机制,每次函数调用都有它自己私有的状态信息(保存的返回位置和被调用者保存寄存器的值)存储空间。

数组的分配和访问

C语言中的数组是一种将标量数据聚集成更大数据类型的方式。C语言实现数组的方式非常简单,因此很容易翻译成机器代码。C语言的一个不同寻常的特点是可以产生指向数组中元素的指针,并对这些指针进行运算。

基本原则

对于数据类型T和整型常数N,声明如下:
\(T A[N]\);
起始位置表示为\(x_A\)。这个审批有两个效果,首先,它在内存中分配一个L·N字节的连续区域,这里L是数据类型T的大小(单位为字节)。其次,它引入了标识符A,可用A来座位纸箱数组开头的指针,这个指针的值就是X_A。可以用0-N-1的整数索引来访问该数组元素。数组元素i会被存放在地址为\(x_A\) + L·i的地方

指针运算

C语言允许对指针进行运算,而算出来的值会更具该指针引用的数据类型的大小进行伸缩。也就是说,如果p是一个指向类型为T的数据的指针,p的值为\(x_p\),name表达式\(p+i\)的值为\(x_p + L·i\)

嵌套的数组

int \(A[5][3]\);
等价下面的声明:
\(typedef\) \(int\) \(row3\_t[3]\);
\(row3\_t\) \(A[5]\)

定长数组

C语言编译器能够优化定长多维数组上的操作代码。

变长数组

历史上,C语言只支持大小在编译时就能确定的多维数组。程序员需要变长数组时不得不用malloc或calloc这样的函数为这些数组分配存储空间,而且不得不显示地编码,用行优先索引将多维数组映射到一维数组。ISOC99引入了一种功能,允许数组的维度是表达式,在数组被分配的时候才计算出来。

异质的数据结构

C语言提供了两种将不同类型的对象组合到一起创建数据类型的机制:结构(structure),用关键之struct来申明,将多个对象集合到一个单位中;联合(union),用关键字union来什么,允许用集中不同的类型来引用一个对象。

结构

C语言的struct声明创建一个数据类型,将可能不同类型的对象聚合到一个对象中。用名字来引用结构的各个组成部分。类似于数组的实现,结构的所有组成部分都存放在内存中一段联系的区域内,而指向结构的指针就是结构第一个字节的地址。编译器维护关于每个结构的信息,指示每个字段(field)的字节偏移。它以这些偏移座位内存引用指令中的位移,从而产生对结构元素的引用。

联合

联合提供了一种方式,能够规避C语言的类型系统,允许以多种类型来引用一个对象。联合声明的语法与结构的语法一样,只不过语义相差比较大。它们是用不同的字段来引用相同的存储器块。

数据对齐

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

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

理解指针

指针是C语言的一个核心特色。它们以一种统一方式,对不同数据结构中的元素产生引用。这里介绍一些指针和它们映射到机器代码的关键原则:

  • 每个指针都对应一个类型。这个类型表明指针指向哪一类对象。
  • 每个指针都有一个值。这个值是某个指定类型对象的地址。特殊的 NULL(0) 值表示该指针没有指向任何地方
  • 指针用 & 运算符创建。这个运算符可以应用到任何 lvalue 类的 C 表达式上。
  • 操作符用于指针的间接引用。其结果是一个值,它的类型与该指针的类型相关。间接引用是通过存储器引用来实现的,要么是存储到一个指定的地址,要么是从指定的地址读取。
  • 数组与指针紧密联系。一个数组的名字可以像一个指针变量一样引用(但是不能修改)。数组引用与指针运算和间接引用有一样的效果。数组引用和指针运算都需要用对象大小对偏移量进行伸缩。
  • 将指针从一种类型强制转换成另一种类型,只改变它的类型,而不改变它的值。强制类型转换的一个效果是改变指针运算的伸缩。来看一个例子,如果 p 是一个 char* 类型的指针,那么表达式(int)p+7 计算为 p+28, 而(int)(p+7)计算为 p+7。
  • 指针也可以指向函数。这提供了一个很强大的存储和向代码传递引用的功能,这些引用可以被程序的某个其他部分调用。

应用:使用GDB调试器

GUN的调试器GDB提供了许多有用的特效,支持机器级程序的运行时评估和分析。
\(linux> gdb \ prog\)
通常的方法是在程序感兴趣的地方附近设置断点。

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

C对于数组引用不进行任何边界检查,而且局部变量和状态信息(例如保存的寄存器值和返回地址)都存放在栈中。这两种情况结合到一起就能导致严重的程序错误,对越界的数组元素的写操作会破幻存储在栈中的状态信息。当程序使用这个被破坏的状态,驶入重新加载寄存器或执行ret指令时,就会出现很严重的错误。
一种特别常见的状态破坏称为缓冲区溢出(buffer overflow)。缓冲区溢出的一个更加致命的使用就是让程序执行它本来不愿意执行的函数。这是一种最常见的通过计算机网络攻击系统安全的方法。通常,输入给程序一个字符串,这个字符串包含一些可执行代码的字节编码,称为攻击代码(exploit code),另外,还有一些字节会用一个指向攻击代码的指针覆盖返回地址。那么执行ret指令的效果就是跳转到攻击代码。
在一种攻击形式中,攻击代码会使用系统调用启动一个shell程序,给攻击者提供一组操作系统函数。在另一种攻击形式中,攻击代码会执行一些未授权的任务,修复对栈的破坏,然后第二次执行ret指令,(表面上)正常返回到调用者。

对抗缓冲区溢出攻击

  1. 栈随机化
  2. 栈破坏检测
  3. 限制可执行代码区域

支持变长栈帧

目前为止,我们已经检查了各个函数的机器级代码,他们有一个共同点,即编译器能够预先确定需要为栈帧分配多少空间。但是有些函数,需要的局部存储是变长的。

浮点代码

处理器的浮点体系结构包括多个方面,会影响对赴死按数据操作的程序如何被映射到机器上。包括:

  • 如何存储和访问浮点数值。通常是通过某种寄存器方式来完成。
  • 对浮点数据操作的指令。
  • 向函数传递浮点数参数和从函数返回浮点数结果的规则。
  • 函数调用过程中保存寄存器的规则-例如,一些寄存器被指定为调用者保存,而其他的被指定为被调用者保存。

浮点传送和转换操作

引用内存的指令是标量指令,意味着他们只对单个而不是一组封装好的数据值进行操作

过程中的浮点代码

参数到寄存器的映射取决于他们的类型和排列顺序

浮点运算操作

定义和使用浮点常数

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

浮点代码中使用位级操作

有时,我们会发现GCC生产的代码会在CMM寄存器上执行位级操作,得到有用的浮点结果。

浮点比较操作

\(AVX_2\)提供了两条用于比较浮点数值的指令
image
浮点比较指令会设置三个条件码:领标志位ZF,进位标志位CF和积偶标志位PF。

对浮点代码的观察结论

我们可以看到,用\(AVX_2\)为浮点数上的操作产生的机器代码风格类似于为证书上的操作产生的代码风格。他们都使用一组寄存器来保存和操作数据值,也都使用这些寄存器来传奇函数参数。
当然,处理不同的数据类型以及对包含混合数据类型的表达式求值的规则有许多复杂之处,同时,\(AVX_2\)代码包括许多比只执行整数运算的函数更加不同的指令和格式。

小结

机器及程序和他们的汇编代码表示,与C程序的差别很大。各个数据类型之间的差别很小。程序时以指令序列来表示的,每条指令都完成一个单独的操作。部分程序的状态,如寄存器和运行时栈,对程序员来说是直接可见的。

posted @ 2022-05-10 11:38  无序  阅读(177)  评论(0编辑  收藏  举报