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

wuchangjian2021-10-27 20:55:39编程学习

计算机系统——程序的机器级表示

  • 程序的机器级表示
  • 程序编码
    • 机器级代码
    • 关于格式的注解
  • 数据格式
  • 访问信息
    • 操作数指示符
    • 数据传送指令
    • 压入和弹出栈数据
  • 算术和逻辑操作
    • 加载有效地址
    • 一元和二元操作
    • 移位操作
    • 特殊的算术操作符
  • 控制
    • 条件码
    • 访问条件码
    • 跳转指令
    • 跳转指令的编码
    • 用条件控制来实现条件分支
    • 用条件传送来实现条件分支
    • 循环
    • switch 语句
  • 过程
    • 运行时栈
    • 转移控制
    • 数据传送
    • 栈上的局部存储
    • 寄存器中的局部存储空间
    • 递归过程
  • 数组分配和访问
    • 基本原则
    • 指针运算
    • 嵌套的数组
    • 定长数组
    • 变长数组
  • 异质的数据结构
    • 结构
    • 联合
    • 数据对齐
  • 在机器级程序中将控制与数据结合起来
    • 理解指针
    • 应用:使用GDB调试器
    • 内存越界引用和缓冲区溢出
    • 对抗缓冲区溢出攻击
      • 栈随机化
      • 栈破坏检测
      • 限制可执行代码区域
    • 支持变长栈帧
  • 浮点代码
    • 浮点传送和转换操作
    • 过程中的浮点代码
    • 浮点运算操作
    • 定义和使用浮点常数
    • 在浮点代码中使用位级操作
    • 浮点比较操作
    • 对浮点代码的观察结论

程序的机器级表示

表述基于 x86-64 ,它是现在笔记本电脑和台式机中最常见处理器的机器语言,也是驱动大型数据中心和超级计算机的最常见处理器的机器语言。

程序编码

gcc 命令调用一整套的程序,将源代码转化成可执行代码。

机器级代码

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

  • 第一种是由指令集体 系结 构或指令 集架构 O ns t ruet ion Set Arehiteeture, IS A)
    来定义机器级程序的格式和行为,它定义了处理器状态、指令的格式,以及每条指令对状态的影响。
  • 第二种抽象是 , 机器级程序使用的内存地址是虚拟地址,提供的内存模型看上去是一个非常大的字节数组。

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

 - 程序计数器(通常称为 " PC" , 在 x86-64 中用%rip 表示)给出将要执行的下一条指令在内存中的地址。
 - 整数寄存器文件包含 16 个命名的位置, 分别存储 64 位的值。
 - 条件码寄存器保存着最近执行的算术或逻辑指令的状态信息。
 - 一组向量寄存器可以存放一个或多个整数或浮点数值。

程序内存包含:程序的可执行机器代码,操作系统需要的一些信息,用来管理过程调用和返回的运行时栈,以及用户分 配的内存块(比如说用 ma l l o c 库函数分配的)。

关于格式的注解

使用gcc编译文件生成 .s 的汇编文件。
命令格式如下,例:

linux> gee -Og -S mstore.e

汇编代码中以’.'开头的行是指导汇编器,链接器工作的伪指令。

带解释的汇编代码如下:
在这里插入图片描述

通常我们只会给出与讨论内容相关的代码行。
每一行的左边都有编号供引用,
右边是注释,简单地描述指令的效果以及它与原始 C 语言代码中的计算操作的关系。

数据格式

Intel 用术语 ” 字( word )" 表示 16 位数据类型。
称 32 位数为“ 双字( double words)",
称 64 位数为“ 四字( quad words )"。

x86-64 指令集同样包括完整的针对字节、字和双字的指令。
在这里插入图片描述

浮点数主要有两种形式 :

 单精度( 4 字节)值, 对应于 C 语言数据类型 float ; 
 双精度(8 字节)值, 对应于 C 语言数据类型 double。

大多数 GCC 生成的汇编代码指令都有一个字符的后缀, 表明操作数的大小。

访问信息

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

在这里插入图片描述

指令可以对这 16 个寄存器的低位字节中存放的不同大小的数据进行操作。
字节级操作可以访问最低的字节, 
16 位操作可以访问最低的 2 个字节,
32 位操作可以访问最低的 4 个字节,
而 64 位操作可以访问整个寄存器。

操作数指示符

大多数指令有 一个或多个操作数( operand ) , 指示出执行一个操作中要使用的源数据值,以及放置结果的目的位置。

各种不同的操作数的可能性被分为三种类型。

第一种类型是立即数( immediate )  , 用来表示常数值。
第二种类型是寄存器 ( register )  , 它表示某个寄存器的 内容,16 个寄存器的低位 1 字节、2 字节、4 字节或 8 字节中的一个作为操作数, 这些字节数分别对应于 8 位、16 位、32 位或 64 位。
第三类操作数是内存引用,它会根据计算出来的地址(通常称为有效地址)访问某个内存位置 。

在这里插入图片描述

数据传送指令

最简单形式的数据传送指令-—— MOV 类。

这些指令把数据从源位 置复制到目的位置,不做任何变化。
这些指令都 执行同样的操作; 主要区别在于它们操作的数据大小不同;
movb 、 movw、 movl   和movq   分别是 1 、2、 4 和 8 字节。

在这里插入图片描述
下面的 MOV 指令示例给出了源和目的类型的五种可能的组合。
记住, 第一个是源操作数,第二个是目的操作数:
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

压入和弹出栈数据

栈是一种数据结构,可以添加或者删除值,不过要遵循 ” 后进先出" 的原则。

通过 push 操作把数据压入栈中 , 通过 pop 操作删除数据;
它具有一个属性: 弹出的值永远是最近被压入而且仍然在栈中的值。

栈可以实现为一个数组,总是从数组的一端插入和删除元素。这一端被称为栈顶。

在这里插入图片描述

pushq 指令的功能是 把数据压入到栈上,
而 popq 指令是弹出数据。
这些指令都只有一个操作数 一一压入的数据源和弹出的 数据目的。

将一个四字值压入栈中,首先要将栈指针减 8 , 然后将值写到新的栈顶地址。
因此,指令 pushq %rbp 的行为等价于下面两条指令:

subq  $8,%rsp 				Decrement stack pointer 
movq %rbp, (%rsp)           Store %rbp on stack

弹出一个四字的操作包括从栈顶位置读出数据, 然后将栈指针加 8。因此,指令 popq%rax 等价于下面两条指令 :

movq (%rsp),%rax          Read 7.rax from stack
addq $8,%rsp              Increment stack pointer

算术和逻辑操作

在这里插入图片描述

加载有效地址

加载有效地 址(load effective address ) 指令 leaq 实际上是 movq 指令的变形。

它的指令形式是从内存读数据到寄存器,但实际上它根本就没有引用内存。
它的第一个操作数看上去是一个内存引用,
但该指令并不是从指定的位置读入数据,而是将有效地址写人到目的操作数。

一元和二元操作

一元操作,只有一个操作数,既是源又是目的。
这个操作数可以是一个寄存器, 也可以是一个内存位置。

二元操作, 其中, 第二个操作数既是源又是目的 。
注意,源操作数是第一个,目的操作数是第二个。

移位操作

移位操作,先给出移位量,然后第二项给出的是要移位的数。
可以进行算术和逻辑右移。
移位量可以 是一个立即数, 或者放在单字节寄存器%c1 中。(这些指令很特别, 因为只允 许以这个特定的寄存器作 为操作 数。)
原则上来说, 1 个字节的移位量使得移位量的编码范围 可以达到 2^8 - 1 = 255 。

特殊的算术操作符

在这里插入图片描述

控制

机器代码 提供两种基本的 低级机制来实现有条件的行为:测试数据值,然后根据测试的结果来改变控制流或者数据流。

与数据相关的控制流是实现有条件行为的更一般和更常见的方法。

条件码

除了整数寄存 器, CPU 还维护着一组单个位的条件码( condition code ) 寄存器, 它们描述了最近的算术或逻辑操作的属性。

最常用的条件码有:

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

在这里插入图片描述

访问条件码

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

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

在这里插入图片描述
某些底层 的机器指令可能有多个名字, 我们称之为“同义名( synonym ) " 。

跳转指令

跳转( jump ) 指令会导致执行切换到程序中一个全新的位置。
在汇编代码中,这些跳转的目的地通常用一个标号(label) 指明。

在这里插入图片描述
表中所示的其 他跳转指令都是有条件的它们根据条件码的某种组合,或者跳转, 或者继续执行代码序列中下一条指令。
这些指令的名字和跳转条件与 SET 指令的名字和设置条件是 相匹配的(参见图 3-1 4 ) 。
同 SET 指令一样 , 一些底层的机器指令有多个名字。条件跳转只能是直接跳转。

跳转指令的编码

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

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

将条件表达式 和语句从 C 语言翻译成机器代码 , 最常用的方式是结合有条件和无条件跳转。

在这里插入图片描述

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

实现条件操作的传统方法是通过使用 控制的条件转移。当条件满足时,程序沿着一条执行路 径执行, 而当条件不满足时 , 就走另一条路径。

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

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

在这里插入图片描述

循环

C 语言提供了多种循环结构, 即 do-while 、 while 和 for 。
汇编中没有相应的指令存在, 可以用条件测试和跳转组合起来实现循环的效果。

do-while 循环

do-while 语句的通用形式如下:
	do
	body-statement 
	while (test-expr);
	
这个循环 的效果就是重复执行 body-statement , 对 test-expr 求值, 如果求值的结果为非零, 就继续循环。

翻译成如下所示的条 件和 go t o 语句:
 loop:
	  body-statement
	  t = test-expr;
	  if (t)
	  goto loop;

在这里插入图片描述

while 循环

while 语句的通用形式如下:
	while (test-expr) body-statement
	
与 do-while 的不同之处在于,在第一次执行 body-statement 之前, 它会对 test-expr 求值, 循环有可能就中止了。

将 while 循环翻译成机器代码的方法:

第一种翻译方法,我们称之为 跳转到中间 ( jump to middle), 它执行一个无条件跳转跳到循环结尾处的测试, 以此来执行初始的测试。
把通用的 while 循环格式翻译 到 goto 代码:
	goto test; loop:
	body-statement test:
	t = test-expr;
	if (t)
	goto loop;


第二种翻译方法,我们称之为 guarded-do , 首先用条件分支,如果初始条件不成立就跳过循环, 把代码变换为 do-while 循 环 。
用如下模板来表达这种方法, 把通用的 while 循环格式翻译成 do-while 循环:
	t = test-expr;
	if	(!t)
	goto done;
	do
	body-statement while (test-expr) ;
	done:
相应地, 还可以 把它翻译成 goto 代码如下:
	t = test-expr;
	if	(! t)
	goto done; loop:
	body-statement
	t = test-expr;
	if	(t)
	goto loop;
	done:

for 循环

for 循环的通用形式如下 :

for (init-expr;  test-expr;  update-expr)
body-statement

这样一个循环的行为与下面这段使 用 wh il e 循环的代码的行为一样:

init-expr;
while (test-expr) 
{ body-statement 
  update-exp;

跳转到中间策略会得到如下 go to 代码:

	init-expr; 
	goto test;
loop:
	body-statement 
	update-expr;
test:
	t=test-expr; 
	if (t)
		goto loop;

而 guarded-do 策略得到 :

	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 ) 。
通过使用跳转表( jump table ) 这种数据结构使得实现更加高效。

在这里插入图片描述

过程

过程是软件中一种很重要的抽象。

它提供了一种封装代码的方式,用一组指定的参数和一个可选的返回值实现了某种功能。

设计良好的软件用过程作为抽象机制,隐藏某个行为的具体实现,同时又提供清晰简洁的接口定义,说明要计算的是哪些值,过程会对程序状态产生什么样的影响。

过程的形式多样:
函数( function) 、方法( method) 、子例程( sub ro utine) 、处理函数( handler) 等等。

为了讨论方便,假设过程p 调用过程 Q , Q 执行后返回到 P。

这些动作包括下面一个或多个机制:
传递控制。
传递数据。
分配和释放内存。

运行时栈

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

在这里插入图片描述

转移控制

在这里插入图片描述

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

call 指令有一个目标,即指明被调用过程起始的指令地址。

数据传送

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

x86-64中,大部分过程间的数据传送是通过寄存器实现的。

寄存器的使用是有特殊顺序的,寄存器使用的名字取决于要递的数据类型的大小。
在这里插入图片描述

栈上的局部存储

有些时候,局部数据必须存放在内存中,常见的情况包括:
- 寄存器不足够存放所有的本地数据。
- 对一个局部变蜇使用 地址运算符'&' ,因此必须能够为它产生一个地址。
- 某些局部变量是数组或结构,因此必须能够通过数组或结构引用被访问到。

寄存器中的局部存储空间

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

虽然在给定时刻只有一个过程是活动的,我们仍然必须确保当一个过程(调用者)调用另一个过程(被调用者)时,被调用者不会覆盖调用者稍后会使用的寄存器值。

根据惯例, 寄存器%rbx 、%rbp 和 %r12~ %r15 被划分为被调用者保存寄存器。

所有其他的寄存器,除了栈指针%rsp , 都分类为调用者保存寄存器。这就意味着任何函数都能修改它们。

递归过程

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

如果需要,它还可以提供局部变量的存储。栈分配和释放的规则很自然地就与函数调用-返回的顺序匹配。
这种实现函数调用和返回的方法甚至对更复杂的 情况也适用 ,包括相互递归调用(例如, 过程 P 词用 Q , Q 再调用 p ) 。

在这里插入图片描述

数组分配和访问

C语言中的数组是一种将标量数据聚集成更大数据类型的方式。

C 语言的一个不同寻常的特点是可以产生指向数组中元素的指针,并对这些指针进行运算。
在机器代码中,这些指针会被翻译成地址计算。

基本原则

对于数据类型 T 和整型常数 N , 声明如下:

T A[N];

起始位置表示为环。这个声明有两个效果。
首先 , 它在内存中分配一个 L • N 字节的连续
区域, 这里 L 是数据类型 T 的大小(单位为字节)。
其次 , 它引入了标识符 A , 可以用 A 来作为指向数组开头的指针, 这个指针的值就是环。

x86-64 的内存引用指令可以用来简化数组访问。

指针运算

C 语言允许对指针进行运算 , 而计算出 来的值会根据该指针引用的数据类型的大小进行伸缩。
也就是说, 如果 p 是一个指向类型为 T 的数据的指针, p 的值为 x p x_p xp, 那么表达式 p + i p+ i p+i 的值为 x p + L • i x_p+ L • i xp+Li, 这里 L 是数据类型 T 的大小。

单操作数操作符’&'和 ‘ * ’可以产生指针和间接引用指针。也就是,对于一个表示某个对象的 表达式 Expr , &Expr 是给出该对象地址的一个指针。

对于一个表示地址的表达式 AExpr , *AExpr 给出该地址处的值。因此,表达式 Expr 与 * &Expr 是等价的。

嵌套的数组

当我们创建数组的数组时 ,数组分配和引用的一般原则也是成立的。

例如,声明

	int	A[5][3] ;
	
等价于下面的声明

	typedef int row3_t[3]; 
	row3_t A[5];

数据类型row3 _t 被定义为一个3个整数的数组。数组A包含 5 个这样的元素, 每个元素需要 12 个字节来存储 3个整数 。整个数组 的大小就是 4X5X3 = 60 字节。

要访问多维数组的元素,编译器会以数组起始为基地址,(可能需要经过伸缩的)偏移量为索引 , 产生计算期望的元素的偏移量, 然后使用某种 MOY 指令。
通常来说,对于一个声明如下的数组:

T D[R][C];

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

定长数组

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

当程序要用一个常数作为数组的维度或者缓冲区的大小时,最好通过 #define 声明将这个常数与一个名字联系起来, 然后在后面一直使用这个名字代替常数的数值。这样一来,如果需要修改这个值,只用简单地修改这个 #define 声明就可以了。

在这里插入图片描述

变长数组

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

int	A[expr1][expr2]

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

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

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

异质的数据结构

C语言提供了两种将不同类型的对象组合到一起创建数据类型的机制:

结构( struc­ture) , 用关键字struc­t来声明, 将多个对象集合到一个单位中;
联合( union ) , 用关键字union来声明 ,允许用几种不同的类型来引用一个对象。

结构

C语言的struct声明创建一个数据类型, 将可能不同类型的对象聚合到一个对象中。用名字来引用结构的各个组成部分。

结构的所有组成部分都存放在内存中一段连续 的区域内,而指向结构的指针就是结构第一个字节的地址。

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

联合

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

在一些下上文中,联合十分有用。但是,它也能引 起一些讨厌的错误,因为它们绕过了C语言类型 系统提供的安全措施。一种应用情况是,我们事先知道对一个数据结构中的两个不同字段的使用是互斥的,那么将这两个字段声明为联合的一部分, 而不是结构的一部分 ,会减小分配空间的总量 。

联合还可以用来访问不同数据类型的位模式 。

数据对齐

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

对齐数据以提高内存系统的性能。
对齐原则是任何K字节的基本对象的地址必须是 K 的倍数。
在这里插入图片描述
确保每种数据类型都是按照指定方式来组织和分配,即每种类型的对象都满足它的对齐限制,就可保证实施对齐。

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

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

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

理解指针

指针是C语言的一个核心特色。它们以一种统一方式,对不同数据结构中的元素产生引用。

指针和它们映射到机器代码的关键原则。

每个指针都对应一个类型。
这个类型表明该指针指向的是哪一类对象。

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

指针用 ‘&’运算符创建。
这个运算符可以应用到任何 lvalue 类的 C 表达式上, lvalue 意指可以出现在赋值语句左边的表达式。

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

数组与指针紧密联系。
 一个数组的名字可以 像一个指针变最一样引用(但是不能修改)。
 
将指针从一种类型强制转换成另一种类型,只改变它的类型,而不改变它的值。
强制类型转换的 一个效果是改 变指针运算的伸缩。

指针也可以指向函数。
这提供了一个很强大的存储和向代码传递引用的功能,这些引用可以被程序的某个其他部分调用	。

应用:使用GDB调试器

GDB 提供了许多有用的特性,支持机器级程序的运行时评估和分析。

用下面的命令行来启动 GDB:

linux> gdb prog

通常的方法是在程序中感兴趣的地方附近设置断点。

断点可以设置在函数入口后面,或是 一个程序的地址处。

程序在执行过程中遇到一个 断点时,程序会停下来, 并将控制返回给用户。

在断点处,我们能够以各种方式查看各个寄存器和内存位置。

我们也可以单步跟踪程序,一次只执行几条指令,或是前进到下一 个断点。

在这里插入图片描述
在这里插入图片描述

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

一种特别常见的状态破坏称为缓冲区溢出 ( buffer overflow ) 。
通常, 在栈中分配某个字符 数组来保存一个字符串 , 但是字符串的长度超出了为数组分配的空间。

缓冲区溢出的一个更加致命的使用就是让程序执行它本来不愿意执行的函数。这是一种最常见的通过计算机网络攻击系统安全的方法。

通常,输入给程序一个字符串,这个字符串包含一些可执行代码的字节编码,称为攻击代码 ( exploit code), 另外,还有一些字节会用一个指向攻击代码的指针覆盖返回地址。

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

在这里插入图片描述

对抗缓冲区溢出攻击

栈随机化

为了在系统中插入攻击代码,攻击者既要插入代码,也要插入指向这段代码的指针,这个指针也是攻击字符串的 一部分。

栈随机化的思想使得栈的位置在程序每次运行时都有变化。

在Linux 系统中,栈随机化已经变成了标准行为。它是更大的一类技术中的一种,这类技术称为地址空间布局随机化( Address -Space Layout Randomization), 或者简 称 ASLR [ 99] 。

栈破坏检测

计算机的第二道防线是能够检测到何时栈已经被破坏。

最近的 GCC 版本在产生的代码中加入了 一种栈保 护者( stack protector ) 机制,来检测缓冲区越界。其思想是在栈帧中任何局部缓冲区与栈状态之间存储一个特殊的金丝雀 ( canary ) 值。
这个金丝雀值, 也称为哨兵值的栈帧(guard value), 是在程序每次运行时随机产生的,因此,攻击者没有简单的办法能够知道它是什么。

栈保护很好地防止了缓冲区溢出攻击破坏存储在程序栈上的状态。

限制可执行代码区域

最后一招是消除攻击者向系统中插入可执行代码的能力。

一种方法是限制哪些内存区域能够存放可执行代码。

许多系统允许控制三种访问形式:
读(从内存读数据)、写(存储数据到内存)和执行(将内存的内容看作机器级代码)。

栈必须是既可读又可写的,因而栈上的字节也都是可执行的。

支持变长栈帧

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

为了管理变长栈帧 ,x86-64 代码使用寄存器%rbp 作为帧指针( frame pointer)(有时称为基指针( base pointer) , 这也是%rbp 中 bp 两个字母的由来)。

浮点代码

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

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

浮点传送和转换操作

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

GCC 只用标量传送操作从内存传送数据到 XMM 寄存器或从 XMM 寄存器传送数据到内存。

对于在两个 XMM 寄存器之间传送数据,GCC 会使用两种指令之一,即用 vmovaps 传送单精度数,用 vmovapd 传送双精度数。

在这里插入图片描述

过程中的浮点代码

在 x86-64 中, XMM 寄存器用来向函数传递浮点参数,以及从函数返回浮点值。

规则:

- XMM 寄存器%xmm0 ~ %xmm7 最多可以传递 8 个浮点参数。按照参数列出的顺序使用这些寄存器。可以通过栈传递额外的浮点参数。
- 函数使用 寄存器 %xmm0 来返回浮点值。
- 所有的 XMM 寄存器都是调用者保存的。被调用者可以不用保存就覆盖这些 寄存器中任意一个。

当函数包含指针、整数和浮点数混合的参数时,指针和整数通过通用寄存器传递,而浮点值通过 XMM 寄存器传递。也就是说,参数到寄存器的映射取决于它们的类型和排列的顺序。

浮点运算操作

在这里插入图片描述

描述了一组执行算术运算的标量 AVX2 浮点指令。
每条指令有一个( S 1 S_1 S1) 或两个( S 1 S_1 S1 , S 2 S_2 S2 ) 源操作 数, 和一个目的操作数 D。
第一个源操作 数 S 1 S_1 S1 可以是一个 XMM 寄存器或一个内存位置。
第 二个源操作数 和目的操作数都必须是 XMM 寄存器。
每个操作都有一条针对单精度的指令和一条针对双精度的指令。结果存放在目的寄存器中。

定义和使用浮点常数

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

在浮点代码中使用位级操作

GCC 生成的代码会在 XMM 寄存器上执行位级操作,得到有用的浮点结果。

在这里插入图片描述
上图展示了一些相关的指令, 类似于它们在通用寄存器上对应的操作。
这些操作都作用于封装好的 数据, 即它们更新整个目的 XMM 寄存器, 对两个源寄存器的所有位都实施指定的位级操作。

浮点比较操作

在这里插入图片描述
这些指令类似于CMP 指令,它们都比较操作数 S1 和 S2 (但是顺序可能与预计的相反), 并且设置条件码指示它们的相对值。
与 cmpq 一样,它们遵循以相反顺序列出操 作数的 ATT 格式惯例。
参数 S2 必 须在 XMM 寄存器中, 而 S1 可以在 XMM 寄存器中,也可以在内存中。

浮点比较指令会设置三个条件码: 零标志位 ZF 、 进位标志位 CF 和奇偶标志位 PF。

在这里插入图片描述

对浮点代码的观察结论

用 AVX2 为浮点数上的操作产生的机器代码风格类似于为整数上的操作产生的代码风格。
它们都使用一组寄存器来保存和操作数据值,也都使用这些寄存器来传递函数参数。

AVX2 代码包括许多比只执行整数运算的函数更加不同的指令和格式。

AVX2 还有能力在封装好的 数据上执行并行操作,使计算执行得更快。

学习参考资料:

《深入理解计算机系统》  第3版

相关文章

JVM中的Safepoints

JVM中的Safepoints

简介 java程序员都听说过GC,大家也都知道GC的目的是扫描堆空间...

java基础知识3-Scanner、Random、流程控制语句

Java基础语法 今日内容介绍 引用数据数据类型(Scanner、Ra...

阿里云盘小白羊版 for Mac(支持满速上传下载)v2.11.07中文版

阿里云盘小白羊版是一款第三方的阿里云盘客户端,支持 Windows、 ma...

发表评论    

◎欢迎参与讨论,请在这里发表您的看法、交流您的观点。