内存对齐介绍
-
什么是内存对齐?
在现代处理器,比如在x86或ARM处理器中,基本 C 数据类型通常并不存储于内存中的随机字节地址。实际情况是,除 char 外,所有其他类型都有“对齐要求”:char 可起始于任意字节地址,2字节的 short 必须从偶数字节地址开始,4字节的int或float必须从能被4整除的地址开始,8字节的 long 和 double 必须从能被8整除的地址开始,指针也是对齐的:32位系统上指针为4字节,64位系统上为8字节。
对齐可令内存访问速度更快,因为它有利于生成单指令存取这些类型的数据。另一方面,如若没有对齐约束,可能最终不得不通过两个或更多指令访问跨越机器字边界的数据。字符数据是种特殊情况,因其始终处在单一机器字中,所以无论存取何处的字符数据,开销都是一致的。这也就是它不需要对齐的原因。
我们虽然可以通过pragma指令(通常为
#pragma pack
)强迫编译器不采用处理器惯用的对齐规则,但请别随意运用这种方式,因为它强制生成开销更大、速度更慢的代码。填充
我们来看一个关于变量在内存中分布的简单案例。思考形式如下的一系列变量声明,它们处在一个C代码块的开始。
char *p; char c; int x;
存储p需要自对齐的4或8字节空间,这取决于机器字的大小。这是指针对齐——极其严格。
c紧随其后,但接下来x的4字节对齐要求,将强制在分布中生成了一段空白,仿佛在这段代码中插入了第四个变量,如下所示。
char *p; /* 4 or 8 bytes */ char c; /* 1 byte */ char pad[3]; /* 3 bytes */ int x; /* 4 bytes */
字符数组
pad[3]
意味着在这个结构体中,有3个字节的空间被浪费掉了。如果x为2字节short,我们将得到:
char *p; /* 4 or 8 bytes */ char c; /* 1 byte */ char pad[1]; /* 1 byte */ short x; /* 2 bytes */
另一方面,如果x为64位系统中的long,我们将得到:
char *p; /* 8 bytes */ char c; /* 1 byte */ char pad[7]; /* 7 bytes */ long x; /* 8 bytes */
若你一路仔细读下来,现在可能会思索,何不首先声明较短的变量?
char c; char *p; int x;
假如实际内存分布可以写成下面这样:
char c; char pad1[M]; char *p; char pad2[N]; int x;
那
M
与N
分别为多少?首先,在此例中,
N
将为0,x
的地址紧随p
之后,能确保是与指针对齐的,因为指针的对齐要求总比int严格。M
的值就不易预测了。编译器若是恰好将c
映射为机器字的最后一个字节,那么下一个字节(p
的第一个字节)将恰好由此开始,并恰好与指针对齐。这种情况下,M
将为0。不过更有可能的情况是,
c
将被映射为机器字的首字节。于是乎M
将会用于填充,以使p
指针对齐——32位系统中为3字节,64位系统中为7字节。中间情况也有可能发生。M的值有可能在0到7之间(32位系统为0到3),因为char可以从机器字的任何位置起始。
如果你希望这些变量占用的空间更少,那么可以交换
x
与c
的次序。char *p; /* 8 bytes */ long x; /* 8 bytes */ char c; /* 1 byte */
通常,对于C代码中的少数标量变量(scalar variable),采用调换声明次序的方式能节省几个有限的字节,效果不算明显。而将这种技术应用于非标量变量(nonscalar variable)——尤其是结构体,则要有趣多了。
Note:在具有自对齐类型的平台上,char、short、int、long和指针数组都没有内部填充,每个成员都与下一个成员自动对齐。
结构体的对齐和填充
通常情况下,结构体实例以其最宽的 标量 成员为基准进行对齐。编译器之所以如此,是因为此乃确保所有成员自对齐,实现快速访问最简便的方法。
此外,在C语言中,结构体的地址,与其第一个成员的地址一致——不存在头填充(leading padding)。
Note:在C++中,与结构体相似的类,可能会打破上述规则!
考虑这个结构体:
struct foo1 { char c; char *p; long x; };
假定处在64位系统中,任何
struct fool
的实例都采用8字节对齐。其内存分布将会像下面这样:struct foo1 { char c; /* 1 byte */ char pad[7]; /* 7 bytes */ char *p; /* 8 bytes */ long x; /* 8 bytes */ };
现在,我们来谈谈结构体的尾填充(trailing padding)。为了解释它,需要引入一个基本概念,我将其称为结构体的“跨步地址(stride address)”。它是在结构体数据之后,与结构体对齐一致的首个地址。
结构体尾填充的通用法则是:编译器将会对结构体进行尾填充,直至它的跨步地址。这条法则决定了
sizeof()
的返回值。考虑64位x86或ARM系统中的这个例子:
struct foo2 { char *p; /* 8 bytes */ char c; /* 1 byte */ }; struct foo2 singleton; struct foo2 quad[4];
你以为
sizeof(struct foo2)
的值是9,但实际是16。它的跨步地址是(&p)[2]
。于是,在quad
数组中,每个成员都有7字节的尾填充,因为下个结构体的首个成员需要在8字节边界上对齐。内存分布就好像这个结构是这样声明的:struct foo2 { char *p; /* 8 bytes */ char c; /* 1 byte */ char pad[7]; };
作为对比,思考下面的例子:
struct foo3 { short s; /* 2 bytes */ char c; /* 1 byte */ };
因为
s
只需要2字节对齐,跨步地址仅在c
的1字节之后,整个struct foo3
也只需要1字节的尾填充。形式如下:struct foo3 { short s; /* 2 bytes */ char c; /* 1 byte */ char pad[1]; };
sizeof(struct foo3)
的返回值将为4。现在我们考虑位域(bitfields)。利用位域,你能声明比字符宽度更小的成员,低至1位,例如:
struct foo4 { short s; char c; int flip:1; int nybble:4; int septet:7; };
关于位域需要了解的是,它们是由字(或字节)层面的掩码和移位指令实现的。从编译器的角度来看,
struct foo4
中的位域就像2字节、16位的字符数组,只用到了其中12位。为了使结构体的长度是其最宽成员长度sizeof(short)
的整数倍,接下来进行了填充。struct foo4 { short s; /* 2 bytes */ char c; /* 1 byte */ char pad1; /* 1 byte */ int flip:1; /* total 1 bit */ int nybble:4; /* total 5 bits */ int septet:7; /* total 12 bits */ int pad2:4; /* total 16 bits = 2 bytes */ char pad3[2]; /* 2 byte */ };
这是最后一个重要细节:如果你的结构体中含有结构体成员,内层结构体也要和最长的标量有相同的对齐。假如你写下了这段代码:
struct foo5 { char c; struct foo5_inner { char *p; short x; } inner; };
内层结构体成员
char *p
强迫外层结构体与内层结构体指针对齐一致。在64位系统中,实际的内存分布将类似这样:struct foo5 { char c; /* 1 byte */ char pad1[7]; /* 7 bytes */ struct foo5_inner { char *p; /* 8 bytes */ short x; /* 2 bytes */ char pad2[6]; /* 6 bytes */ } inner; };
它启示我们,能通过重新打包节省空间。24个字节中,有13个为填充,浪费了超过50%的空间!
结构体成员重排
首先注意,内存浪费只存在于两处。其一是较大的数据类型(需要更严格的对齐)跟在较小的数据类型之后。其二是结构体自然结束的位置在跨步地址之前,这里需要填充,以使下个结构体能正确地对齐。
最简单的方式,是按对齐值递减重新对结构体成员排序。即让所有指针对齐成员排在最前面,因为在64位系统中它们占用8字节;然后是4字节的int;再然后是2字节的short,最后是字符。
内存对齐的特性
由于对齐的存在,在不同平台下指针的最后2位一般都会是0,Nginx 中利用了这一特性,将指针的最后一位当做 flag 用,节约了一定内存。
-
上面没有说清楚,最后提到的指针指向的是调用 malloc 得到的内存。
-
微机原理会讲这个,但没有这么详细
-
我们还没学微机原理啊。。