第三章 程序的机器级表示
3.2 程序编码
3.2.1 机器级代码
对于机器级编程来说,有两种抽象尤为重要。第一种是由指令集体系结构或指令集架构(Instruction Set Architecture,ISA)来定义机器级程序的格式和行为,它定义了处理器状态、指令的格式,以及每条指令对状态的影响。第二种是机器级程序使用的内存地址是虚拟内存地址,提供的内存模型看上去是一个非常大的字节数组。
程序内存包含:程序的可执行机器代码,操作系统需要的一些信息,用来管理过程调用和返回的运行时栈,以及用户分配的内存块(比如malloc库函数分配的)。
3.4 访问信息
3.4.2 数据传送指令
**传送指令的两个操作数不能都指向内存位置。**将一个值从一个内存位置复制到另一个内存位置需要两条指令——第一条指令将源值加载到寄存器中,第二条将该寄存器值写入目的位置。
两类特殊的数据移动指令,MOVZ类中的指令把目的中剩余的字节填充为0;MOVS类中的指令通过符号拓展来填充。
3.5 算术和逻辑操作
3.5.1 加载有效地址
leaq S,D
,加载有效地址(load effective address),实际上是movq指令的变形,它的指令形式是从内存读数据到寄存器,将有效地址写入到目的操作数,即 D <- &S
.
3.6 控制
3.6.1 条件码
除了整数寄存器,CPU还维护着一组单个位的条件码(condition code)寄存器,描述了最近的算术或逻辑操作的属性。最常用的有:
- CF:进位标志
- ZF:零标志
- SF:符号标志
- OF:溢出标志
3.6.2 访问条件码
条件码通常不会直接读取,常用的使用方法有三种:
- 可以根据条件码的某种组合,将一个字节设置为 0 或 1
- 可以条件跳转到程序的某个其他的部分
- 可以有条件地传送数据
3.7 过程
假设过程P调用过程Q,Q执行完后返回P,这些动作包括下面一个或多个机制:
- 传递控制:进入Q后将程序计数器设为Q代码的起始地址,返回P将程序计数器设为下一条指令的地址。
- 传递数据:P必须能够向Q提供一个或多个参数,Q必须能够向P返回一个值
- 分配和释放内存:在开始时,Q可能需要为局部变量分配空间,而在返回前,又必须释放这些存储空间。
3.7.1 运行时栈
当 x86-64 过程需要的存储空间超出寄存器能够存放的大小,就会在栈上分配空间,这个部分称为过程的栈帧。
3.7.3 数据传送
x86-64 中,大部分过程间的数据传送是通过寄存器实现的,最多可以通过寄存器传递6个整型参数。当参数 n > 6 时,要把参数1~ 6 复制到对应的寄存器,把参数7 ~ n 放到栈上,而参数7位于栈顶。
3.7.4 栈上的局部存储
一般来说,过程通过减小栈指针实现在栈上分配空间,分配的结果作为栈帧的一部分,标号为**“局部变量”**。
例如:以下的caller 的代码开始的时候把栈指针减掉了16,实际上是在栈上分配了16个字节。最后,该函数把栈指针加16,释放栈帧。
3.7.5 寄存器中的局部存储空间
寄存器组是唯一被所有过程共享的资源。
寄存器%rbx、%rbp和%r12~%r15被划分为被调用者保存寄存器,当过程P调用过程Q时,Q必须奥村这些寄存器的值,保证他们的值在Q返回到P时与Q调用时一样。
所有其他寄存器,除了栈指针%rsp,都分类为调用者保存寄存器,过程P在某个此类寄存器中有局部数据,然后调用过程Q之前,需要保存好这个数据。
3.9 异质的数据结构
结构(structure),用关键字 struct 来声明,将多个对象集合到一个单位中。
联合(union),用关键字 union 来声明,允许用几种不同的类型来引用一个对象。
3.9.2 联合
一个联合的总的大小等于它最大字段的大小。
- 一个数据结构中的两个不同字段的使用是互斥的,那么将这两个字段声明为联合的一部分,而不是结构的一部分,会减小分配空间的总量。
例如:实现一个二叉树,每个叶子节点都有两个double类型的数据值,每个内部节点都有指向两个孩子节点的指针。(两种情况不会同时出现)
如果使用结构体,每个节点需要32个字节。
struct node_s {
struct node_s *left;
struct node_s *right;
double data[2];
}
如果使用联合,只需要24个字节(4+4+16,有4个字节用于数据对齐)
typedef enum { N_LEAF, N_INTERNAL} nodetype_t;
struct node_t {
nodetype_t type;
union {
struct {
struct node_t *left;
struct node_t *right;
} internal;
double data[2];
} info
}
- 联合还可以用来访问不同数据类型的位模式。
unsigned long double2bits(double d) {
union {
double d;
unsigned long u;
} temp;
temp.d = d;
return temp.u;
}
- 当用联合来将各种不同大小的数据类型结合到一起时,字节顺序问题就变得很重要了。
double uu2double(unsigned word0, unsigned word1) {
union {
double d;
unsigned u[2];
} temp;
temp.u[0] = word0;
temp.u[1] = word1;
return temp.d;
}
3.9.3 数据对齐
许多计算机系统对基本数据类型的合法地址做出了一些限制,要求某种类型对象的地址必须是某个值K(通常是2、4或8)的倍数。这种对齐限制简化了形成处理器和内存系统之间接口的硬件设计。
例如:下面的结构声明:
struct s1 {
int i;
char c;
int j;
};
整个结构体的大小为12字节(4+4+4),char本来为1个字节,补齐3个字节。
3.10 在机器级程序中将控制与数据结合起来
3.10.1 理解指针
-
void *
通用指针。比如说,malloc 函数返回一个通用指针,然后通过显式强制转换或者赋值操作那样的隐式强制转换,将它转换成一个有类型的指针。 -
函数指针。
int fun(int x, int *p);
int (*fp)(int, int *);
fp = fun;
3.10.3 内存越界引用和缓冲区溢出
缓冲区溢出。通常,在栈中分配某个字符数组来保存一个字符串,但是字符串的长度超出了为数组分配的空间。
使用gets
或其他任何能导致存储溢出的函数,都是不好的编程习惯。很多库函数,包括strcpy
,strcat
,sprintf
,都有一个属性——不告诉他们目标缓冲区的大小,就产生一个字节序列 [97]。这样的情况就会导致缓冲区溢出漏洞。
3.10.4 对抗缓冲区溢出攻击
- 栈随机化
思想:使得栈的位置在程序每次运行时都有变化。 因此,即使许多机器都运行同样的代码,它们的栈地址都是不同的。
实现:程序开始时,在栈上分配一段 0~n 字节之间的随机大小的空间。
在Linux系统中,栈随机化已经变成了标准行为。它是更大的一类技术的一种,这种技术称为地址空间布局随机化(Address Space Layout Randomization),简称ASLR。采用ASLR,每次运行时程序的不同部分,包括程序代码、库代码、栈、全局变量和堆数据,都会被加载到内存的不同区域。这样就能够对抗一些形式的攻击。
然而,攻击者使用**空操作雪橇(nop sled)**靠蛮力克服随机化,意思就是程序会“滑过”这个序列。如果建立一个256字节的 nop sled,那么枚举213</sup5 = 32768个起始地址,就能破解 n = 223 的随机化。
- 栈破坏检测
计算机第二道防线是能检测何时栈已经被破坏。
GCC在产生的代码中加入一种栈保护者(stack protector)机制来检测缓冲区越界。其思想是在栈帧中任何局部缓冲区与栈状态之间存储一个特殊的金丝雀(canary)值。
void echo() {
char buf[8];
gets(buf);
puts(buf);
}
这个金丝雀值是在程序每次运行时随机产生的。在恢复寄存器状态和从函数返回之前,程序检查这个金丝雀值是否被该函数的某个操作或者该函数调用的某个函数的某个操作改变了。如果是的,那么程序异常中止。
- 限制可执行代码区域
最后一招是消除攻击者向系统中插入可执行代码的能力。
一种方法是限制哪些内存区域能存放可执行代码。在典型的程序中,只有保存编译器产生的代码的那部分内存才需要是可执行的,其他部分可以被限制为只允许读和写。
AMD为它的64位处理器的内存保护引入了**“NX(No-Execute,不执行)位”**,有了这个特性,栈可以被标记为可读和科协,但是不可执行。
是的,那么程序异常中止。