《深入理解计算机系统》(CSAPP)第三章读书笔记

CSAPP

第3章 程序的机器级表示

本章节中有大量汇编代码,关于汇编的更多信息,请自行查阅x86-64汇编的资料

3.2 程序编码

假设一个C程序, 有两个文件 p1.c 和 p2.c . 用 Unix 命令行编译这些代码

linux> gcc -Og -o p1.c p2.c

编译选项 -Og 告诉编译器使用会符合原始C代码整体结构的机器代码的优化等级. 使用较高优化等级产生的代码会严重变形, 以至于产生的机器代码和初始源代码非常难理解.

实际上, 从程序性能考虑, 较高级别优化 (如-O1或-O2) 被认为是较好的选择

3.2.1 机器级代码

指令集体系结构或指令集架构 (ISA) 定义了机器级程序的格式和行为, 它定义了处理器状态, 指令的格式, 以及每条指令对状态的影响

机器级程序使用的内存地址是虚拟地址, 提供的内存模型看上去是一个非常大的字节数组

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

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

在命令行中使用 “-S” 的选项, 就能看到 C语言 编译器产生的汇编代码

linux> gcc -Og -S example.c

汇编代码文件包含各种声明, 每个缩进去的行都对应一条机器指令

example:
	pushq	%rbx
	movq	%rdx, %rdx
	call	mult2
	movq	%rax, (%rbx)
	popq	%rbx
	ret

使用 “-c” 的选项, 就能编译并汇编原有代码

linux> gcc -Og -c example.c

如果要查看机器代码文件产生的内容, 可以使用反汇编器

可以使用objdump查看反汇编 (注: gdb的disas也可以查看反汇编, bomb lab里面会频繁用到)

linux> objdump -d example.o

结果如下

0000000000000000 <example>
	0: 53					push	%rbx
	1: 48 89 d3				mov		%rdx, %rbx
	4: e8 00 00 00 00		callq	9 <example+0x9>
	9: 48 89 03				mov		%rax, (%rbx)
	c: 5b					popq	%rbx
	d: c3					retq

其中一些关于机器代码和它的反汇编表示的特性值得注意:

  • x86-64的指令长度从1到15个字节不等. 常用的指令以及操作数较少的指令的字节数少, 而那些不常用或操作数较多的指令的字节数多
  • 设计指令格式的方式是, 从某个给定位置开始, 可以将字节唯一地解码成机器指令.
  • 反汇编只是基于机器代码文件中的字节序列来确定汇编代码, 它不需要访问源程序的源代码或汇编代码
  • 反汇编器使用的指令命名规则与 GCC 生成的汇编代码使用的有些细微差别
3.2.3 关于格式的注解

汇编代码中所有以 ‘.’ 开头的行都是指导汇编器和链接器工作的伪指令, 通常可以忽略

3.3 数据格式

Intel用术语 “字” 表示16位数据类型. 因此, 称32位数为"双字", 64位数为"四字"

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

3.4 访问信息

一个x86-64的中央处理单元包含一组16个存储64位值的通用目的寄存器. 这些寄存器用来存储整数数据和指针.
在这里插入图片描述

3.4.1 操作数指示符

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

  1. 立即数 , 用来表示常数值. 立即数的书写方式是 ‘$’ 后面跟一个用标准 C表示法表示的整数
  2. 寄存器 , 表示某个寄存器的内容
  3. 内存引用 , 它会根据计算出的地址访问某个内存位置. 用符号 M b [ A d d r ] M_b[Addr] Mb[Addr] 表示对存储在内存中从地址 A d d r Addr Addr 开始的 b b b 个字节值的引用

I m m ( r b , r i , s ) Imm(r_b, r_i,s) Imm(rb,ri,s) 表示的是最常用的内存引用形式

I m m Imm Imm 立即数偏移, r b r_b rb 基址寄存器, r i r_i ri 变址寄存器, s s s 比例因子, 这里 s s s 必须是 1, 2, 4或者是8

​ 有效地址被计算为 I m m + R [ r b ] + R [ r i ] × s Imm + R[r_b] + R[r_i] \times s Imm+R[rb]+R[ri]×s

3.4.2 数据传送指令

MOV类, 这些指令把数据从源位置复制到目的位置
在这里插入图片描述
源操作数指定的值是一个立即数, 存储在寄存器中或者内存中. 目的操作数指定一个位置, 要么是一个寄存器, 要么是一个内存地址.

将一个值从一个内存地址复制到另一个内存地址需要两条指令, 第一条指令将源值加载到寄存器中, 第二条将该寄存器值写入目的位置

寄存器部分的大小必须与指令最后一个字符指定的大小匹配

movabsq指令能够以任意64位立即数值作为源操作数, 并且只能以寄存器作为目的

MOVZ类中的指令把目的中剩余的字节填充为0, 而MOVS类中的指令通过符号拓展来填充, 把源操作数的最高位进行复制.
在这里插入图片描述
在这里插入图片描述

3.4.3 数据传送示例

间接引用指针就是将该指针放在一个寄存器中, 任何在内存引用中使用这个寄存器

局部变量通常保存在寄存器中, 而不是内存中. 访问寄存器比访问内存要快得多

3.4.4 压入和弹出栈数据

通过push操作把数据压入栈中, 通过pop操作删除数据

在x86-64中, 程序栈存放在内存中某个区域, 栈向下增长, 这样一来, 栈顶元素的地址是所有栈中元素地址最低的

将一个四字值压入栈中, 首先要把栈指针减8, 然后将值写到新的栈顶地址

弹出一个四字值的操作包括从栈顶位置读出数据, 然后将栈指针加8

3.5 算术和逻辑操作

在这里插入图片描述

3.5.1 加载有效地址

加载有效地址 指令 leaq 实际上是 movq 指令的变形, 它的指令形式是从内存中读数据到寄存器, 实际上它根本没有引用内存

并不是从指定的位置中读入数据, 而是将有效地址写入目的操作数

目的操作数必须是一个寄存器

例如下面的C程序

long scale(long x, long y, long z) {
    long t = x + 4 * y + 12 * z;
    return t;
}

它的汇编代码

scale:
	leaq	(%rdi, %rsi, 4), %rax
	leaq	(%rdx, %rdx, 2), %rdx
	leaq	(%rax, %rdx, 4), %rax
	ret

leaq 指令能执行加法和有限形式的乘法

3.5.2 一元和二元操作

第二组中的操作是一元操作, 只有一个操作数, 既是源又是目的

操作数可以是一个寄存器, 也可以是一个内存位置

第三组中的操作是二元操作, 源操作数是第一个, 目的操作数是第二个

3.5.3 移位操作

移位操作对 w w w 位长的数据值进行操作, 移位量是由 %cl 寄存器的低 m m m 位决定的, 这里 2 m = w 2^m = w 2m=w

左移指令有两个名字: SAL和SHL. 两者的效果是一样的, 都是将右边填上0. 右移指令不同, SAR执行算术移位, SHR执行逻辑移位, 移位操作的目的操作数可以是一个寄存器也可以是一个内存位置

例如下面的C程序

long arith(long x, long y, long z) {
    long t1 = x ^ y;
    long t2 = z * 48;
    long t3 = t1 & 0x0F0F0F0F;
    long t4 = t2 - t3;
    return t4;
}

它的汇编代码

arith:
	xorq	%rsi, %rdi
	leaq	(%rdx, %rdx, 2), %rax
	salq	$4, %rax
	andl	$252645135, %edi
	subq	%rdi, %rax
	ret

3.6 控制

3.6.1 条件码

除了整数寄存器, CPU还维护着一组单个位的 条件码寄存器

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

CMP指令根据两个操作数之差来设置条件码. 除了只设置条件码而不更新目的寄存器之外, CMP指令与SUB指令的行为是一样的

TEST指令的行为与AND指令一样, 除了它们只设置条件码而不更新目的寄存器的值
在这里插入图片描述

3.6.2 访问条件码

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

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

对于第一种情况, 我们把一整类指令称为 SET指令 , 它们之间的区别就在于它们考虑的条件码的组合是什么, 这些指令的后缀表示不同的条件而不是操作数大小

一条 SET指令 的目的操作数是低位单字节寄存器元素之一, 或是一个字节的内存位置, 指令会将这个字节设置成 0 或 1.
在这里插入图片描述
当没有发生溢出时, 我们有 a − w t b < 0 a -^t_wb < 0 awtb<0 a < b a<b a<b , 将SF设置为1即指明这一点, 而当 a − w t b ≤ 0 a-^t_wb\le 0 awtb0 a ≥ b a\ge b ab , 由SF设置为0指明

当发生溢出时, 我们有当 a − w t b > 0 a-^t_wb > 0 awtb>0 a < b a<b a<b , 而当 a − w t b < 0 a-^t_wb < 0 awtb<0 a > b a>b a>b , 当 a = b a=b a=b 时不会有溢出, 当OF被设置为1时, 当且仅当SF被设置为0

CMP指令会设置进位标志, 因而无符号数比较使用的是进位标志和零标志的组合

3.6.3 跳转指令

jmp指令 是无条件跳转, 它可以是直接跳转, 即跳转目标是作为指令的一部分编码的, 也可以是间接跳转, 即跳转目标是从寄存器或内存位置上读出的
在这里插入图片描述
表中所示的其他跳转指令都是有条件的, 它们根据条件码的某种组合, 或者跳转, 或者继续执行代码中下一条指令

3.6.4 跳转指令的编码

跳转指令有几种不同的编码, 最常用的是 PC相对的 , 也就是, 它们会将目标指令的地址和紧跟在跳转指令后面的那条指令的地址之间的差作为编码, 这些地址偏移量可以设置成1, 2或4个字节

下面是一个PC相对寻址的例子

	movq	%rdi, %rax
	jmp		.L2
   .L3
   	sarq	%rax
   .L2
   	testq	%rax, %rax
   	jg .L3
   	rep; ret

这个代码段中包含两个跳转: 第2行的jmp指令前向跳转到更高的地址, 而第7行的jg指令后向跳转到更低的地址

汇编器产生的反汇编版本如下:

	0:	48 89 f8			mov	%rdi, %rax
	3:	eb 03				jmp 8 <loop+0x8>
	5:	48 d1 f8			sar	%rax
	8:	48 85 c0			test %rax, %rax
	b:	7f f8				jg	5 <loop+0x5>
	d:	f3 c3				repz retq

当执行PC相对寻址时, 程序计数器的值时跳转指令后面的那条指令的地址, 而不是跳转指令本身的地址

3.6.5 用条件控制实现条件分支
long lt_cnt = 0;
long ge_cnt = 0;

long absdiff_se(long x, long y) {
    long result;
    if (x < y) {
        lt_cnt++;
        result = y - x;
    } else {
        ge_cnt++;
        result = x - y;
    }
    return result;
}
	absdiff_se:
		cmpq	%rsi, %rdi
		jge		.L2
		addq	$1, lt_cnt(%rip)
		movq	%rsi, %rax
		subq 	%rdi, %rax
		ret
	.L2:
		addq	$1, ge_cnt(%rip)
		movq	%rdi, %rax
		subq	%rsi, %rax
		ret

对于C语言的 if-else 代码, 汇编会将其改写成 goto 代码

long gotodiff_se(long x, long y) {
    long result;
    if (x >= y)
        goto x_ge_y;
    lt_cnt++;
    result = y - x;
    return;
x_ge_y:
    ge_cnt++;
    result = x - y;
    return;
}
3.6.6 用条件传送实现条件分支

实现条件操作的传统方法是通过使用控制的条件转移, 但是在现代处理器上, 它可能会非常低效

一种替代的策略是使用 数据 的条件转移. 这种方法计算一个条件操作的两种结果, 然后再根据条件是否满足从中选取一个

处理器通过 流水线 获得高性能, 当机器遇到条件跳转, 只有当分支条件求值完成之后, 才能决定分支往哪边走. 处理器采用非常精密的分支预测逻辑来猜测每条跳转指令是否会执行. 错误预测一个跳转, 处理器要丢掉它为该跳转指令后所有指令已做的工作, 然后再开始从正确位置处起始的指令去填充流水线, 这会导致程序性能严重下降

3.6.7 循环

汇编中没有相应的指令存在, 可以用条件测试和跳转组合起来实现循环的效果

3.6.8 switch语句

switch语句可以根据一个整数索引值进行多重分支.

它们提高了C代码的可读性, 而且通过使用 跳转表 使得实现更加高效

和使用一组很长的 if-else 语句相比, 使用跳转表的优点是执行开关语句的时间与开关情况的数量无关

3.7 过程

在不同的编程语言中, 过程的类型多样: 函数, 方法, 子例程, 处理函数等

要提供对过程的机器级支持, 必须要处理许多不同的属性. 假设过程P调用过程Q, Q执行后又返回P. 这些动作包括下面一个或多个机制:

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

在这里插入图片描述
程序用栈管理它的过程所需要的存储空间, 栈和程序寄存器存放着控制和数据, 分配内存所必须的信息. 当P调用Q时, 控制和数据信息添加到栈尾. 当P返回时, 这些数据会释放掉

当x86-64过程需要的存储空间超出寄存器能够存放的大小时, 就会在栈上分配空间, 这个部分被称为过程的栈帧

当过程P调用过程Q时, 会把返回地址压入栈中, 指明当Q返回时, 要从P程序的哪个位置继续执行

3.7.2 转移控制

在x86-64机器中, 指令 call Q 调用过程Q
在这里插入图片描述
下面的反汇编代码选自3.2.2中example和main函数的反汇编

00000000004000540 <example>:
	400540:		53				push	%rbx
	400541:		48 89 d3		mov		%rdx, %rbx
	..........
	40054d:		c3				retq
	..........
	400563:		e8 d8 ff ff ff	callq	400540 <example>
	400568:		48 8b 54 24 08	mov		0x8(%rsp), %rdx

call的效果是将返回地址0x400568压入栈中, 并跳到函数example的第一条指令, 地址为0x400540. 函数example继续执行, 直到遇到地址0x40054d处的ret指令. 这条指令从栈中弹出值0x400568, 然后跳转到这个地址, 就在call指令之后, 继续main函数的执行

3.7.3 数据传送

x86-64中, 通过寄存器最多传递 6个整型参数 . 寄存器的使用是由特殊顺序的, 名字取决于要传递的数据类型的大小
在这里插入图片描述
如果一个函数有大于6个整型参数, 超出6个的部分就要通过栈来传递. 假设过程P调用过程Q, 有 n n n 个整型参数, 且 n > 6 n > 6 n>6 . 那么P的代码分配栈帧就必须要能够容纳7-n号参数的存储空间. 参数1-6复制到对应的寄存器, 7-n放到栈上, 参数7位于栈顶. 通过栈传递参数时, 所有的数据大小都向8的倍数对齐

3.7.4 栈上的局部存储

有些时候, 局部数据必须放在内存中

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

一般情况, 过程通过减小栈指针在栈上分配空间. 分配的结果作为栈帧的一部分, 标号为"局部变量"

3.7.5 寄存器中的局部存储空间

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

寄存器 %rbx %rbp 和 %r12-%r15被划分为 被调用者保存寄存器

所有其他的寄存器, 除了栈指针寄存器 %rsp , 都分类为 调用者保存寄存器

3.8 数组分配和访问

3.8.1 基本原则

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

T T T A[ N N N]

起始位置表示为 x A x_A xA . 首先, 它在内存中分配一个 L × N L\times N L×N 的连续区域, 这里 L L L 是数据类型 T T T 的大小, 其次, 它引入了标识符A, 可以用A作为指向数组开头的指针, 这个指针的值就是 x A x_A xA

数组元素 i i i 会被存放在 x A + L × i x_A + L \times i xA+L×i 的地方

3.8.3 嵌套的数组

数组元素在内存中按照"行优先"的顺序排列

对于一个声明如下的数组:

T T T D[ R R R] [ C C C]

它的数据元素 D[ i i i] [ j j j] 的内存地址为

&D [ i i i] [ j j j] = x D x_D xD + L ( C × i + j ) L(C\times i + j) L(C×i+j)

这里, L L L 是数据类型 T T T 以字节为单位的大小

3.9 异质的数据结构

3.9.1 结构

结构所有组成部分都存放在内存中一段连续的区域内, 而指向结构的指针就是结构第一个字节的地址. 编译器维护关于每个结构类型的信息, 指示每个字段的 字节偏移

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

3.9.3 内存对齐

某种类型对象的地址必须是某个值 K K K 的倍数, 这种 对齐限制 简化了形成处理器和内存系统之间接口的硬件设计

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

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

C语言对于数组引用不做边界检查, 而且局部变量和状态信息都保存在栈中, 对越界的数组元的写操作会破坏在栈中的状态信息. 当程序使用到这个被破坏的状态, 试图重新加载寄存器或执行ret指令, 就会引发严重的错误

一种特别的状态破坏被称为 缓冲区溢出

3.10.4 对抗缓冲区溢出攻击
  1. 栈随机化

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

在Linux系统中, 栈随机化已经编程了标准化, 这类技术统称为 地址空间布局随机化 , 简称 ASLR. ASLR技术使得每次运行时程序的不同部分, 包括程序代码, 库代码, 栈, 全局变量和堆数据, 都会被加载到内存的不同区域

  1. 栈破坏检测

栈保护者 机制可检测缓冲区越界. 其思想是在栈帧任何局部缓冲区与栈状态之间存储一个特殊的 金丝雀 值, 也称为 哨兵值 , 是在程序每次运行时随机产生的

将金丝雀值存放在一个特殊的段中, 标志为 “只读” , 这样攻击者就不能覆盖存储的金丝雀值. 在恢复寄存器状态和返回前, 函数将存储在栈位置处的值与金丝雀值作比较, 如果两个数相同, xorq指令就会得到0, 函数会按照正常的方式完成, 非0的值表明栈上的金丝雀值被修改过, 那么代码就会调用一个错误例程

  1. 限制可执行代码区域

限制内存可执行代码的部分. Intel 和 AMD为它们的64位处理器的内存保护引入了 “NX” 位, 有了这个特性, 栈可以被标记为可读和可写, 但是不可执行, 而检查页是否执行由硬件完成, 效率上没有损失

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值