上是3,64位机器上是7。
如果你想让那些变量占用更少的空间,你可以通过交换原序列中的x和c来达到效果。
char *p; /* 8 bytes */
long x; /* 8 bytes */
char c; /* 1 byte
通常,对于C程序里少数的简单变量,你可以通过调整声明顺序来压缩掉极少几个字节数,不会有显著的节约。但当用于非标量变量(nonscalar variables),尤其是结构体时,这项技术会变得更有趣。
在我们讲到非标量变量之前,让我们讲一下标量数组。在一个有自对齐类型的平台上,字符、短整型、整型、长整型、指针数组没有内部填充。每个成员会自动自对齐到上一个之后(译者注:原文 self-aligned at the end of the next one 似有误)。
在下一章,我们会看到对于结构体数组,一样的规则并不一定正确。
5. 结构体的对齐和填充
总的来说,一个结构体实例会按照它最宽的标量成员对齐。编译器这样做,把它作为最简单的方式来保证所有成员是自对齐,为了快速访问的目的。
而且,在C语言里,结构体的地址与它第一个成员的地址是相同的——没有前置填充。注意:在C++里,看上去像结构体的类可能不遵守这个规则!(遵不遵守依赖于基类和虚拟内存函数如何实现,而且因编译器而不同。)
(当你不能确定此类事情时,ANSI C提供了一个offsetof()宏,能够用来表示出结构体成员的偏移量。)
考虑这个结构体:
struct foo1 {
char *p;
char c;
long x;
};
假设一台64位的机器,任何struct foo1的实例会按8字节对齐。其中的任何一个的内存分布看上去无疑应该像这样:
struct foo1 {
char *p; /* 8 bytes */
char c; /* 1 byte
char pad[7]; /* 7 bytes */
long x; /* 8 bytes */
};
它的分布就恰好就像这些类型的变量是单独声明的。但是如果我们把c放在第一个,这就不是了。
struct foo2 {
char c; /* 1 byte */
char pad[7]; /* 7 bytes */
char *p; /* 8 bytes */
long x; /* 8 bytes */
};
如果成员是单独的变量,c可以起始于任何字节边界,并且pad的大小会不同。但因为struct foo2有按其最宽成员进行的指针对齐,那就不可能了。现在c必须于指针对齐,之后7个字节的填充就被锁定了。
现在让我们来说说关于在结构体成员的尾随填充(trailing padding)。要解释这个,我需要介绍一个基本概念,我称之为结构体的跨步地址(stride address)。它是跟随结构体数据后的第一个地址,与结构体拥有同样对齐方式。
结构体尾随填充的通常规则是这样的:编译器的行为就如把结构体尾随填充到它的跨步地址。这条规则决定了sizeof()的返回值。
考虑在64位的x86或ARM上的这个例子:
struct foo3 {
char *p; /* 8 bytes */
char c; /* 1 byte */
};
struct foo3 singleton;
struct foo3 quad[4];
你可能会认为,sizeof(struct foo3)应该是9,但实际上是16。跨步地址是(&p)[2]的地址。如此,在quad数组中,每个成员有尾随填充的7字节,因为每个跟随的结构体的第一个成员都要自对齐到8字节的边界上。内存分布就如结构体像这样声明:
struct foo3 {
char *p; /* 8 bytes */
char c; /* 1 byte */
char pad[7];
};
作为对照,考虑下面的例子:
struct foo4 {
short s; /* 2 bytes */
char c; /* 1 byte */
};
因为s只需对齐到2字节, 跨步地址就只有c后面的一个字节,struct foo4作为一个整体,只需要一个字节的尾随填充。它会像这样分布
struct foo4 {
short s; /* 2 bytes */
char c; /* 1 byte */
char pad[1];
};
并且sizeof(struct foo4)会返回4。
现在让我们考虑位域(bitfield)。它们是你能够声明比字符宽度还小的结构体域,小到1位,像这样:
struct foo5 {
short s;
char c;
int flip:1;
int nybble:4;
int septet:7;
};
关于位域需要知道的事情是,它们以字或字节级别的掩码和移位指令来实现。从编译器的观点来看,struct foo5的位域看上去像2字节,16位的字符数组里只有12位被使用。接着是填充,使得这个结构体的字节长度成为sizeof(short)的倍数即最长成员的大小。
struct foo5 {
short s; /* 2 bytes */
char c; /* 1 byte */
int flip:1; /* total 1 bit */
int nybble:4; /* total 5 bits */
int septet:7; /* total 12 bits */
int pad1:4; /* total 16 bits = 2 bytes */
char pad2; /* 1 byte */
};
这里是最后一个重要的细节:如果你的结构体含有结构体的成员,里面的结构体也需要按最长的标量对齐。假设如果你写成这样:
struct foo6 {
char c;
struct foo5 {
char *p;
short x;
} inner;
};
内部结构体的char *p成员使得外部的结构体与内部的一样成为指针对齐。在64位机器上,实际的分布是像这样的:
struct foo6 {
char c; /* 1 byte*/
char pad1[7]; /* 7 bytes */
struct foo6_inner {
char *p; /* 8 bytes */
short x; /* 2 bytes */
char pad2[6]; /* 6 bytes */
} inner;
};
这个结构体给了我们一个启示,重新封装结构体可能节省空间。24个字节中,有13个字节是用作填充的。超过50%的无用空间!
6. 结构体重排序(reordering)
现在你知道如何以及为何编译器要插入填充,在你的结构体之中或者之后,我们要考察你可以做些什么来挤掉这些“水坑”。这就是结构体封装的艺术。
第一件需要注意的事情是,“水坑”仅发生于两个地方。一个是大数据类型(有更严格的对齐要求)的存储区域紧跟在一个较小的数据类型的存储区域之后。另一个是结构体自然结束于它的跨步地址之前,需要填充,以使下一个实例可以正确对齐。
消除“水坑”的最简单的方法是按对齐的降序来对结构体成员重排序。就是说:所有指针对齐的子域在前面,因为在64位的机器上,它们会有8字节