计算机执行机器代码,用字节序列编码低级的操作,包括处理数据,管理内存,读写存储设备上的数据,以及利用网络通信。汇编代码是机器代码的文本表示。
3.1 历史观点
intel处理器系列俗称x86,它是第一代单芯片,16位微处理器之一。
以下列举一些intel处理器的模型:
3.2 程序编码
3.2.1 机器级代码
对于机器级编程来说,有两种抽象尤为重要。
第一种是由指令集体系结构或者指令集架构来定义机器集程序的格式和行为,它定义了处理器状态,指令的格式,以及每条指令对状态的影响。
第二种抽象是,机器级程序使用的内存地址是虚拟地址,提供的内存模型看上去是非常大的字节数组。
x86-64的机器代码和原始的c代码差别非常大。一些通常对c语言程序员隐藏的处理器状态都是可见的:
3.2.2 代码示例
在命令行上使用“-S”选项,就能看到C语言编译器产生的汇编代码:
如果我们使用“-c”命令行选项,GCC会编译并且汇编该代码:
这样就会产生目标文件mstore.o,它是二进制格式文件。
这就是上面列出的汇编指令对应的目标代码。从中得到一个重要信息,即机器执行的程序只是一个字节序列,它是对一系列指令的编码。
要想查看机器代码文件的内容,需要使用反汇编器。
生成实际可执行的代码需要对一组目标代码文件运行链接器,而这一组目标代码文件中必须含有一个main函数。
3.2.3 关于格式的注解
所有以“。”开头的行都是指导汇编器和连接器工作的伪指令。我们通常可以忽略这些行。另一方面,也没关于指令的用途以及它们与源代码之间关系的解释说明。
3.3 数据格式
如图所示,大多数GCC生成的汇编代码指令都有一个字符的后缀,表明操作数的大小。例如,数据传送指令有四个变种:movb(传字节),movw(传字),movl(传双字)和movq(传送四字)。后缀‘l’用来表示双字,因为32位数被看成是“长字(long word)”。注意,汇编代码也使用用后缀‘l’来表示4字节整数和8字节双精度浮点数。
3.4 访问信息
3.4.1 操作数指示符
3.4.2 数据传送指令
3.4.3 数据传送示例
数据传送指令的代码示例。
3.4.4 压入和弹出栈数据
pushq指令的功能是把数据压入到栈上,而popq指令是弹出数据。这些指令都只有一个操作数——压入的数据源和弹出的数据目的。
将一个四字值压入栈中,首先要将栈指针减8,然后将值写入到新的栈顶地址。因此,指令pushq %rbp的行为等价于下面两条指令:
它们之间的区别是在机器代码中pushq指令编码为1个字节,而上面那两条指令一共需要8个字节。如下图,当%rsp为0x108,%rax为0x123时,执行指令pushq%rax的效果。首先%rsp会减8,得到0x100,然后会将0x123存放到内存地址0x100处。
3.5 算术和逻辑操作
3.6 控制
3.6.1 条件码
3.6.2 指令
3.7 过程
过程是软件中一种很重要的抽象。它提供了一种封装代码的方式,用一组指定的参数和一个可选的返回值实现了某种功能。
3.7.1 运行时栈
3.7.2 转移控制
3.7.3 栈上的局部存储
到目前为止我们看到的大多数过程示例都不需要超出寄存器大小的本地存储区域。不过有些时候,局部数据必须存放在内存中,常见的情况包括:
1. 寄存器不足够存放所有的本地数据。
2. 对一个局部变量使用地址运算符‘&’,因此必须能够为它产生一个地址。
3. 某些局部变量是数组或者结构,因此必须能够通过数组或结构引用被访问到。
3.7.4 递归过程
3.8 数组分配和访问
3.8.1 基本原则
对于数据类型T和整型常数N,声明如下:
T A[N];
起始位置表示为Xa。这个声明有两个效果。首先,它在内存中分配一个L*Z字节的连续区域,这里L是数据类型T的大小。其次,它引入了标识符A,可以用A来作为指向数组开头的指针,这个指针的值就是XA。可以用0~N-1的整数索引来访问该数组元素。数组元素i会被存放在地址为Xa + L*i的地方。
作为示例,让我们来看看下面这样的声明:
char A[12];
char *B[8];
int C[6];
double *D[5];
这些声明会产生带下列参数的数组:
3.8.2 指针运算
C语言允许对指针进行运算,而计算出来的值会根据该指针引用的数据类型的大小进行伸缩。也就是说,如果p是一个指向类型为T的数据的指针,p的值为Xp,那么表达式p+i的值为Xp + L * i, 这里L是数据类型T的大小。
单操作数操作符‘&’和‘*’可以产生指针和间接引用指针。也就是,对于一个表示某个对象的表达式Expr, &Expr是给出该对象地址的一个指针。对于一个表示地址的表达式AExpr, *AExpr给出该地址处的值。因此,表达式Expr与* &Expr是等价的。可以对数组和指针应用数组下标操作。数组引用A[i]等同于表达式*(A+i)。它计算递i个数组元素的地址,然后访问这个内存位置。
3.8.3 嵌套的数组
嵌套的数组就是多维数组,但是多维数组也就是一维数组。
int A[2][2] = {0,0,0,0};
也可以 int A[2][2] = {{0,0},{0,0}};
3.9 异质的数据结构
C语言提供了两种将不同类型的对象组合到一起创建数据类型的机制:结构体,用关键字struct来声明,将多个对象集合到一个单位中;联合,用关键字union来声明,允许用几种不同的类型来引用一个对象。
3.9.1 结构
C语言的struct声明创建一个数据类型,将可能不同类型的对象聚合到一个对象中。用名字来引用结构的各个组成部分。类似于数组的实现,结构的所有组成部分都存放在内存中一段连续的区域内,而指向结构的指针就是结构第一个字节的地址。编译器维护关于每个结构类型的信息,指示每个字段的字节偏移。它以这些偏移作为内存引用指令中的位移,从而产生对结构元素的引用。
3.9.2 联合
联合提供一种方式,能够规避C语言的类型系统,允许以多种类型来引用一个对象。联合声明的语法与结构的语法一样,只不过语义相差比较大。它们是用不同的字段来引用相同的内存块。
3.10 在机器级程序中将控制与数据结合起来
3.10.1 理解指针
指针是C语言的一个核心特色。它们以一种统一方式,对不同数据结构中的元素产生引用。对于编程新手来说,指针总是会带来很多的困惑,但是基本概念其实非常简单。在此,我们重点介绍一些指针和它们映射到机器代码的关键原则。
3.10.2 应用: 使用GDB调试器
GUN的调试器GDB提供了许多有用的特性,支持机器级程序的运行时评估和分析。对于本书中的示例和练习,我们试图通过阅读代码,来推断出程序的行为。有了GDB,可以观察正在运行的程序,同时又对程序的执行有相当的控制,这使得研究程序的行为变为可能。
先运行OBJ-DUMP来获得程序的反汇编版本,是很有好处的。我们的示例都基于对文件prog运行GDB。我们用下面的命令行来启动GDB:
linux> gdb prog
通常的方法是在程序中感兴趣的地方附近设置断点。断点可以设置在函数入口后面,或是一个程序的地址处。程序在执行过程中遇到一个断点时,程序会停下来,并将控制返回给用户。在断点处,我们能够以各种方式查看各个寄存器和内存位置,我们也可以单步跟踪程序,一次只执行几条指令,或是前进到一个断点。
3.10.3 内存越界引用和缓冲区溢出
3.10.4 对抗缓冲区溢出攻击
1. 栈随机化
栈随机化的思想使得栈的位置在程序每次运行时都有变化。因此,即使许多机器运行同样的代码,它们的栈地址都是不同的,实现的方式是:程序开始时,在栈上分配一段0~n字节之间的随机大小的空间。
2. 栈破坏检测
GCC版本产生的代码中加入了一种栈保护者机制,来检测缓冲区越界。其思想是在栈帧中任何局部缓冲区与栈状态之间存储一个特殊的金丝雀值,也称为哨兵值,是在程序每次运行时随机产生的。因此,攻击者没有简单的办法能够知道它是什么,在恢复寄存器状态和从函数返回之前,程序检查这个金丝雀值是否被该函数的某个操作或者该函数调用的某个函数的某个操作改变了,如果是的,那么程序异常中止。
3. 限制可执行代码区域
限制哪些内存区域能够存放可执行代码,消除攻击者向系统中插入可执行代码的能力。
3.11 浮点代码
处理器的浮点体系结构包括多个方面,会影响对浮点数据操作的程序如何被映射到机器上,包括: