深入理解计算机系统——程序的机器级表示学习笔记

程序的机器级表示

程序编码

  1. C预处理器扩展源代码,插入所有#include命令指定的文件,并扩展所有#define声明指定的宏。
  2. 编译器产生两个源文件的汇编代码,后缀名为.s。
  3. 汇编器将汇编代码转化为二进制目标代码文件,后缀名为.o。
  4. 链接器将两个目标代码文件与实现库函数的代码合并,并产生最终的可执行代码文件

机器级代码

计算机系统利用抽象模型来隐藏实现的细节。对于机器级编程有两种重要的抽象:

  1. 指令集体系结构或指令集架构:定义机器级程序的格式和行为,它定义了处理器状态、指令的格式,以及每条指令对状态的影响
  2. 虚拟地址的抽象,存储器系统的实际实现是将多个硬件存储器和操作系统软件组合起来。
  • 程序计数器
    给出将要执行的下一条指令在内存中的地址。
  • 整数寄存器文件包含16个命名的位置,分别存储64位的值。存储地址或整数数据。
  • 条件码寄存器保存着最近执行的算数或逻辑指令的状态信息。用来实现控制或数据流中的条件变化。
  • 一组向量寄存器可以存放一个或多个整数或浮点数值。

数据格式

  • Intel用术语“字”表示16位数据类型。

C语言数据类型在X86-64中的大小如下:

C声明Intel数据类型汇编代码后缀大小(字节)
char字节b1
shortw2
int双字l4
long四字q8
char*四字q8
float单精度s4
double双精度l8

访问信息

  • 一个x86-64的中央单元(CPU)包含一组16个存储64位值的通用目的寄存器,用来存储整数数据和指针。
    在这里插入图片描述
  • 指令可以对这16个寄存器的低位字节中存放的数据进行操作
  • 字节级操作可以访问最低的字节,16位操作可以访问最低的2个字节,32位操作可以访问最低的4个字节,64位访问整个寄存器

操作数指示符

  • 大多数指令有一个或多个操作数,指示出执行一个操作中要使用的源数据值,以及放置结果的目的位置
  • 有三中类型的操作数:
    1. 立即数,用来表示常数值
    2. 寄存器,表示某个寄存器的内容
    3. 内存引用,根据计算出来的地址(通常称为有效地址)访问某个内存位置

数据传送指令

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

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

  • MOV类:把数据从源位置复制到目的位置,不做任何变化。
    在这里插入图片描述

  • x86-64加了一条限制,传送指令的两个操作数不能都指向内存位置。即将一个值从内存复制到另一个内存位置需要两条指令——第一条指令将源值加载到寄存器中,第二条将该寄存器值写入目的位置。

  • 修改目的寄存器高位字节,包括零扩展和符号扩展,例:
    在这里插入图片描述

算术和逻辑操作

  • x86-64的一些整数和逻辑操作如下:
    在这里插入图片描述

  • 操作被分为四组:加载有效地址、一元操作、二元操作和移位。

加载有效地址

  • 该指令并不是从指定的位置读入数据,而是将有效地址写入到目的操作数,C语言中的地址操作符&就是说明这种计算。

一元和二元操作

  • 一条指令可以有一个或者多个操作数
  • 只有一个操作数就是一元操作,二个就是二元操作

移位操作

  • 算术左移和逻辑左移是一样的都是将右边填上0,算术右移在左填上符号位,逻辑右移在左边填0

控制

条件码

CPU维护者一组单个位的条件码寄存器,它们描述了最近的算数或逻辑操作的属性

常见的条件码有:

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

访问条件码

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

  1. 根据条件码的某种组合,将一个字节设置为0或者1
  2. 可以条件跳转到程序的某个其他部分
  3. 可以有条件的传送数据
  • 这类指令被称为SET指令,指令名字的后缀指明了条件码的组合。

  • 这些后缀表示不同的条件而不是操作数大小,如setl和setb 表示“小于时设置(set less)”和“低于时设置(set below)”, 而不是“设置字长(set long word)” 和“设置字节(set byte)”

  • 一条SET指令的目的操作数是低位单字节寄存器元素之一,或是一个字节的内存位置,指令会将这个字节设置成0或者1
    在这里插入图片描述

  • 有符号比较测试基于SF ^ OF和 ZF 的组合

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

  • 大多数情况下,机器代码对于有符号和无符号两种情况都是用一样的指令。有些情况需要不同的指令来处理有符号和无符号操作:使用不同版本的右移、除法和乘法指令,以及不同的条件码组合。

跳转指令

  • 跳转指令会导致执行切换到程序中一个全新的位置,跳转的目的地用一个标号指明。
  • jmp指令无条件跳转。可以是直接跳转:跳转目标是作为指令的一部分编码的;间接跳转:跳转目标是从寄存器或内存位置中读出的。
  • 直接跳转是给出一个标号作为跳转目标的,间接跳转的写法是‘*’后面跟一个操作数指示符。如:jmp *%rax用寄存器%rax中的值作为跳转目标;jmp *(rax)以%rax中的值作为读地址,从内存中读出跳转目标。
    在这里插入图片描述

跳转指令的编码

跳转指令有几种不同的编码,常用的有两种:

  1. PC相对的——将目标指令的地址与紧跟在跳转指令后面那条指令的地址之间的差作为编码
  2. 给出“绝对”地址,用四个字节直接指定目标

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

汇编代码实现C语言中的条件分支可以使用跳转指令实现:
在这里插入图片描述

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

  • 用条件控制实现条件分支是非常低效的,使用数据的条件转移比前者性能好——计算一个条件操作的两种结果,然后在根据条件是否满足从中选取一个
    在这里插入图片描述

  • 处理器通过使用流水线来获得高性能。

  • 当机器遇到条件分支时,只有当分支条件求值完成之后,才能决定分支往哪边走,指令就会充满流水线。

  • 处理器采用非常精密的分支预测逻辑猜测每条指令是否会执行。猜对就会正确跳转,错误预测一个跳转,要求处理器丢掉它为该跳转指令后所有指令已做的工作,然后再从正确的位置处起始的指令去填充流水线。

  • 错误预测是非常浪费时间资源的,所以使用条件传送比使用条件控制效率要高

  • 不是所有条件表达式都可以用条件传送来编译
    在这里插入图片描述

    上边的代码即使Test执行为正确,也会出现第2行的空指针错误。

    所以,条件数据传送只是提供了一种条件控制转移来实现条件操作的替代策略,只能用于非常受限制的情况,但是这些情况却是非常常见的。

循环

C语言中三种形式的所有循环——do-while、while和for——都可以用一种简单的策略来翻译,产生包含一个或多个条件分支的代码。控制的条件转移提供了将循环翻译成机器代码的基本机制。

switch 语句

  • switch语句通过使用跳转表这种数据结构实现。
  • 跳转表是一个数组,表项i是一个代码段地址,这个代码段实现当开关索引值等于i时程序应该采取的动作。

过程

函数、方法、子例程、处理函数等都是过程。

假设过程P调用过程Q,Q执行后返回到P,这些动作包括多个机制:

  • 传递控制。在进入过程Q的时候,程序计数器必须被设置为Q的代码的起始地址,然后在返回时,要把程序计数器设置为P中调用Q后面那条指令的地址。
  • 传递数据。P必须能够向Q提供一个或多个参数,Q必须能够向P返回一个值。
  • 分配和释放内存。在开始时,Q可能需要局部变量分配空间,而在返回前,又必须释放这些存储空间

运行时栈

  • 过程调用机制的一个关键特性是使用了

  • 程序用栈管理过程所需的存储空间,栈和程序寄存器存放着传递控制和数据、分配内存所需要的信息。

  • x86-64 的栈向底地址方向增长,栈指针指向栈顶元素。当压栈时就减小指针,当出栈时就增大指针。

  • 过程需要的存储空间超出寄存器能够存放的大小,就会在栈上分配空间。这个部分称为栈帧
    在这里插入图片描述

    如图正在执行的过程的帧总是在栈顶,大多数过程的栈帧都是定长的,在过程的开始就分配好了。

转移控制

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

  • x86-64使用call指令调用函数,同跳转一样,调用可以是直接的,也可以是间接的,直接调用的目标是一个标号,间接调用的目标是*后面跟一个操作数指示符。
    在这里插入图片描述

数据传送

  • 调用一个过程不仅仅是把控制传递给它,有可能需要传递参数,从过程返回可能返回一个值
  • x86-64中,大部分过程间的数据传递是通过寄存器实现的,寄存器最多传递6个整形(例如整数和指针)参数。如果一个函数有大于6个整形参数,超出6个的部分就要通过栈来传递
  • 例如P调用Q并传递超过6个参数,那么前6个参数将被分配到寄存器中,第7到n个参数将被分配到P的栈帧中,如上图3-25所示。

栈上的局部存储

数据必须存储在内存中的情况:

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

寄存器中的局部存储空间

  • 寄存器是唯一被所有过程共享的资源
  • 必须确保当一个过程(调用者)调用另一个过程(被调用者)时,被调用者不会覆盖调用者稍后会使用的寄存器值。寄存器中的局部存储空间被划分为被调用者保存寄存器——保存好这个数据(比如说P的局部数据)是P(调用者)的责任。
  • 比如说当P调用Q的时候,当调用过程返回到P时,Q必须保证寄存器中的值和被调用之前是一样的
    1. 要么Q就不去改变寄存器中的值
    2. Q先将寄存器中的原值压入Q的栈帧中,再去修改寄存器中的值,在返回P前再将栈中的旧值返回到寄存器中。

递归过程

  • 当过程被调用时分配局部存储,当返回时释放存储。
  • 根据栈的使用特性和寄存器保存规则,可以保证递归调用返回时:
    1. 该次调用的结果会保存在寄存器中
    2. 参数的值仍然在寄存器中

数组分配和访问

基本原则

对于数据类型T和整型常数N:T A[N]

起始位置表示为XA, 数组元素 i 会被存放在地址XA + L·i 的地方

指针运算

  • 单操作数操作符‘&’‘*’可以产生指针间接引用指针
  • 对于表示某个对象Expr的表达式,&Expr给出该对象地址的一个指针。对于表示地址的表达式,AExpr, *AExpr给出改地址的值,这两个表达式是等价的。
  • 数组引用A[i]等同于表达式* (A+i).

嵌套数组

  • int A[5] [3]等价于typedef int row3_t[3] row3_t A[5];数据类型row3_t被定义为一个三个整数的数组。数组A包含5个这样的元素,每个元素需要12个字节来存储三个整数。整个数组的大小就是4×5×3=60字节

  • 也可以看成5行3列的二维数组。对于一个声明为T D[R][C]的数组,数组元素D[i][j]的位置为&D[i][j]=xD + L(C·i+j)
    在这里插入图片描述

定长数组

  • 当程序要用一个常数作为数组的维度或者缓冲区的大小时,最好通过# define声明将这个常数与一个名字联系起来,然后在后面一直使用这个名字替代常数的值

  • 当定义一个定长数组并循环遍历这个数组的时候,可以去掉整数索引,并把所有的数组引用都转换成指针间接引用:

    1. 指向行的起始地址的指针
    2. 指向列的起始地址的指针
    3. 判断数组遍历完毕,终止循环的指针
      在这里插入图片描述

异质的数据结构

  • C语言提供了两种将不同类型的对象组合到一起创建数据类型的机制:
    1. 结构:用关键字struct 来声明,将多个对象集合到一个单位中
    2. 联合:用关键字union 来声明,允许用几种不同的类型来引用一个对象

结构

  • 结构的所有元素都存放在内存的连续区域内,指向结构的指针就是结构第一个字节的地址,编译器利用字节偏移量来定位每个元素。

联合

  • 联合的语法与结构一样,但是语义不同,联合是用不同的字段来引用相同的内存块,结构是用不同的字段引用不同的内存块。
  • 联合的总的大小等于它最大字段的大小。

数据对齐

  • 计算机系统对基本数据类型的合法地址做出一些限制,要求某种类型对象的地址必须是某个值K(通常是2/4或8)的倍数。

  • 这种对齐限制简化了形成处理器和内存系统之间接口的硬件设计。

  • 对齐原则是任何K字节的基本对象的地址必须是K的倍数。
    在这里插入图片描述

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

  • 对于大多数x86-64指令来说,保持数据对其能够提高效率,但是并不影响程序的行为

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

理解指针

  • 每一个指针都对应一个类型

  • 每个指针都有一个值。这个值是某个指定类型的对象的地址。特殊的NULL(0)值表示该指针没有指向任何地方。

  • 指针用’&'运算符创建

  • ***操作运算符用于间接引用指针。**其结果是一个值,他的类型与该指正的类型一致。间接引用使用内存引用来实现的,要么是存储到一个指定的地址,要么是从指定的地址读取。

  • **数组与指针紧密联系。**数组引用(a[3])与指针运算和间接引用(* (a+ 3))有一样的效果,都需要用对象大小对偏移量进行伸缩。

  • 将指针从一种类型强制转换成另一种类型,只改变它的类型,而不改变它的值

  • 指针也可以指向函数。

    一个函数:

    int fun(int x, int *p)
    

    声明一个指针 fp ,将它赋值为这个函数:

    int (*fp)(int, int *)
    fp = fun
    

    然后用这个指针来调用这个函数:

    int y = 1
    int result = fp(3, &y)
    

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

  • 程序的局部变量和状态信息都存放在栈中,当数组越界后就会破坏存储在栈中的状态信息,一种常见的为缓冲区溢出
    在这里插入图片描述

  • 缓冲区溢出可能会让程序执行它本来不愿执行的函数,利用这个漏洞来输入给程序一个字符串,这个字符串包含一些可执行代码的字节编码,成为攻击代码

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

对抗缓冲区的溢出攻击

  1. 栈随机化

    • 攻击者插入代码的同时也要插入这段代码的指针,就需要知道指针在栈的位置。栈随机化就是使机器每次运行时,栈地址都是不同的。
    • 实现方式:在栈上分配一段0~n字节之间的随机大小的空间,程序不使用这段空间,但是它会导致每次执行时后续栈位置发生了变化。
    • 在Linux系统中,栈随机化成为了标准。它是更大的一种技术的一种——地址空间布局随机化(Address-Space Layout Randomization),简称ASLR。
  2. 栈破坏检测

    • 当发生越界时,在没有造成任何有害结果前,尝试检测它

    • 思想是在栈帧中任何局部缓冲区栈状态之间存储一个特殊的金丝雀值,也成为哨兵值,程序每次运行时随机产生。

    • 在恢复寄存器状态和从函数返回之前,程序检查这个金丝雀值是否被改变了,如果是,程序异常中止
      在这里插入图片描述

  3. 限制可执行代码区域

    • 消除攻击者向系统中插入可执行代码的能力:
      • 一种方法是限制哪些内存区域能够存放可执行代码。
    • 随机化、栈保护和限制哪部分内存可以存储可执行代码——是用于最小化程序缓冲区溢出攻击漏洞三种最常见的机制。但是并不能完全断绝被攻击。

支持变长栈帧

  • 使用寄存器%rbp作为帧指针(frame pointer),代码必须把%bp之前的值保存到栈中,因为他是一个被调用者保存寄存器。
  • 在函数的整个执行过程中,都使得%bp指向那个时刻栈的位置,然后使用固定长度的局部变量(例如 i )相对于%bp的偏移量来引用他们。

是否被改变了,如果是,程序异常中止

 [外链图片转存中...(img-zHtBAfbk-1595504533557)]
  1. 限制可执行代码区域

    • 消除攻击者向系统中插入可执行代码的能力:
      • 一种方法是限制哪些内存区域能够存放可执行代码。
    • 随机化、栈保护和限制哪部分内存可以存储可执行代码——是用于最小化程序缓冲区溢出攻击漏洞三种最常见的机制。但是并不能完全断绝被攻击。

支持变长栈帧

  • 使用寄存器%rbp作为帧指针(frame pointer),代码必须把%bp之前的值保存到栈中,因为他是一个被调用者保存寄存器。
  • 在函数的整个执行过程中,都使得%bp指向那个时刻栈的位置,然后使用固定长度的局部变量(例如 i )相对于%bp的偏移量来引用他们。
  • 1
    点赞
  • 8
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

一切如来心秘密

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

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

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

打赏作者

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

抵扣说明:

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

余额充值