阅读经典——《深入理解计算机系统》05
本文讲述三个比较冷门的话题:联合、数据对齐和缓冲区溢出攻击。
- 联合体
- 数据对齐
- 栈帧为什么必须16字节对齐?
- 缓冲区溢出攻击
联合体
在C语言中有这么一个不常用的数据类型union
,往往被人们遗忘。它就是联合体。
与结构体类似,都是用来封装多种数据类型,但含义不同。结构体会将各个字段按顺序分配各自独立的内存空间。而联合体则是只申请一块内存空间,由所有字段共用。听起来有些不可思议,共用一块内存空间的字段岂不是只能有一个值,那那些字段还怎么区分?下面给出一个使用联合体的经典案例。
我们想要实现一个二叉树结构,所有的内部结点具有左孩子和右孩子,但没有数据;所有的叶子结点既没有左孩子也没有右孩子,但有数据。很容易想到可以用如下的结构体来实现:
struct NODE_S {
struct NODE_S *left;
struct NODE_S *right;
double data;
};
这样的话每个结点需要16字节,是不是有点浪费?因为总是有一半的空间处于无用状态,当作为内部结点时,data
字段为空,当作为叶子结点时,left
和right
结点都为空。
这时候就可以用联合体来节约空间,原型如下:
union NODE_U {
struct {
union NODE_U *left;
union NODE_U *right;
} internal;
double data;
};
这样的话每个结点只需要8个字节了,因为internal
大小为8个字节,double
大小也是8个字节,取最大值还是8个字节。对于该联合体类型的指针n
,我们可以用n->internal.left
来访问内部结点的左孩子,也可以用n->data
来访问叶子结点。
可是,这个结果仍然不够令人满意,因为我们无法分辨出当前结点是内部结点还是叶子结点。那么我们可以增加一个枚举字段来表示结点类型:
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;
} info;
};
这样一来每个结点需要12个字节了,type
字段占用4个字节,info
字段占用8个字节。当internal
和data
占用空间非常大时,该方案可以极大地降低内存消耗。
数据对齐
IA32并不要求数据对齐,但不同的平台有着额外的要求。Linux要求2字节数据类型(例如short
)必须2字节对齐(意思是该数据的地址必须是2的整数倍),大于2字节的数据类型必须4字节对齐。而Windows要求K字节数据类型必须K字节对齐,除了long double
要求4字节对齐。
对齐的数据有利于提高CPU的存取效率,更详细的说明见参考资料。
值得一提的是,为了对齐数据,结构体中往往会采取增加间隙的措施。例如对于如下结构体:
struct S1 {
int i;
char c;
int j;
};
如果以完全紧密放置的方式保存的话,内存空间分配如下:
这导致j
不满足4字节对齐的要求。因此编译器会在c
后面插入一个3字节的间隙,如下所示:
此时所有数据都满足了对齐要求。
栈帧为什么必须16字节对齐?
现在我们来解释上一篇文章《函数调用栈》中提出的问题,栈帧为什么必须16字节对齐。
Intel从Pentium III处理器开始推出的SSE指令集(Streaming SIMD Extensions,单指令多数据流扩展)要求操作对象为16字节对齐的数据。因此,栈帧为了支持该指令集,必须使自己16字节对齐,从而栈帧内部的数据才可能16字节对齐。否则即使数据相对于栈顶对齐,地址也不是16的整数倍。
缓冲区溢出攻击
缓冲区溢出的含义是为缓冲区提供了多于其存储容量的数据,就像往杯子里倒入了过量的水一样。通常情况下,缓冲区溢出的数据只会破坏程序数据,造成意外终止。但是如果有人精心构造溢出数据的内容,那么就有可能获得系统的控制权!例如,对于如下的简单程序:
void echo()
{
char buf[8];
gets(buf);
puts(buf);
}
该函数的功能是读取输入的字符串,并输出。对应的栈帧结构如下:
如果gets
函数中给buf
赋予了长度超过8的字符串,可以想象,这个字符串将覆盖buf
上方的内容。随着字符串长度的增大,echo栈帧中的“保存的%ebx
”、“保存的%ebp
”,以及调用者的栈帧中的“返回地址”将被依次覆盖。大部分情况下,覆盖会使程序紊乱并出错,从而导致程序终止,但如果有人巧妙地设计覆盖的内容,就实现了所谓的缓冲区溢出攻击。
简单来讲,黑客可以把恶意代码通过buf
传入内存,并恰好使返回地址指向恶意代码的起始位置。这样的话,一旦echo函数返回,程序将立即跳转到恶意代码段,如果程序具有管理员权限,恶意代码就有了任意操作整个计算机的能力,后果不堪设想。
当然,时至今日,缓冲器溢出攻击已经不能通过这种简单的方式实现了。人们在编译器、处理器中切断了缓冲区溢出攻击的必经之路。但是,道高一尺魔高一丈,黑客们总能找到系统的漏洞,让系统安全人员防不胜防。正应了那句话:没有绝对安全的系统。在参考资料提到的另一篇博文中,详细讲述了缓冲区溢出攻击的细节,并给出了简单的实现代码,感兴趣的读者可以前往阅读。