我说“在现代处理器上”,是因为在有些更老的处理器上,强迫你的C代码违反对齐限制(比如,把一个奇数地址转换为int指针并试图使用它)不仅会让你的代码变慢,还会造成非法指令异常。 比如在Sun SPARC芯片上就是这样。 事实上,只要有足够的决心和正确的硬件标志(e18),你也可以在X86上触发该异常。
自对齐还不是唯一的规则。 历史上,有些处理器(特别是那些没有barrel shifters的)有更严格的规则。如果你在做嵌入式系统,你可能撞到这些暗礁。要有心理准备。
有时你可以让编译器不遵守处理器的正常对齐规则,一般是使用pragma,比如 #pragma pack。 请不要随意使用,因为它会生成开销更大、更慢的代码。 通过使用我介绍的技术,你可以节省同样、甚至更多的内存。
使用#pragma pack的唯一合理理由是,你需要C数据分布完全匹配某些硬件或协议,比如一个经过内存映射的物理端口,则不违反对齐规则就无法做下去。 如果你处在那种情况,而不理解本文的内容,你会遇到大麻烦,祝你好运。
现在我们来看一个简单的例子,变量在内存中的分布。 考虑在C模块的顶部,有这些变量声明:
char *p;
char c;
int x;
这是(在x86或ARM或任何自对齐的机器上)实际的情况:p 存储在4字节或8字节对齐的位置上(由机器的字长决定)。 这是指针对齐-可能的最严格的情况。
c的存储紧跟着p。但x的4字节对齐要求造成一个缺口,就好像有第四个变量插入其中:
char *p; /* 4 or 8 bytes */
char c; /* 1 byte */
char pad[3]; /* 3 bytes */
int x; /* 4 bytes */
比较如果x 是2字节的short会怎样:
char *p;
char c;
short x;
char *p; /* 4 or 8 bytes */
char c; /* 1 byte */
char pad[1]; /* 1 byte */
short x; /* 2 bytes */
char *p;
char c;
long x;
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;
首先,N 是0。 x 的地址紧接着p,保证了x 是指针对齐的,而指针对齐肯定比整型对齐更严。
c极有可能被映射到机器字的第一个字节上。 因此M是能让p满足指针对齐的数目-在32位机上是3,在64位上是7。
中间情况也是可能的。 因为char有可能被安排在一个机器字中的任意位置,M有可能是0到7(在32位机上是0到3)。
如果你想让这些变量占用较少的空间,你可以交换x和c的位置:
char *p; /* 8 bytes */
long x; /* 8 bytes */
char c; /* 1 byte
在我们继续之前,先说一下标量数组。 在一个自对齐类型的平台上,char/short/int/long/pointer 数组内部没有填充;每个成员都跟在前一个成员后面,自动对齐了。
在下一节我们将看到,在结构体数据里,以上规律并不一定正确。
总的来说,结构体实例会和它的最宽成员一样对齐。 编译器这样做因为这是保证所有成员自对齐以获得快速存取的最容易方法。
而且,在C中,结构的地址等于它的第一个成员的地址-没有前导填充。 注意:在C++中,形似结构的类可能会破坏这个规则!(跟基类和虚函数如何实现有关,也因编译器而异。)
(当你对此有疑惑时,你可以使用ANSI C 提供的offset()宏来得到结构成员的偏移。)
考虑这个结构:
struct foo1 {
char *p;
char c;
long x;
};
struct foo1 {
char *p; /* 8 bytes */
char c; /* 1 byte
char pad[7]; /* 7 bytes */
long x; /* 8 bytes */
};
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];
struct foo3 {
char *p; /* 8 bytes */
char c; /* 1 byte */
char pad[7];
};
struct foo4 {
short s; /* 2 bytes */
char c; /* 1 byte */
};
struct foo4 {
short s; /* 2 bytes */
char c; /* 1 byte */
char pad[1];
};
现在让我们考虑位域(bitfields)。 它们使得你能声明比字节宽度更小的成员,低至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;
};
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;
};
理解了编译器在结构体中间和尾部插入填充的原因和方式后,我们要检查一下如何挤压这些溢出(slop)。 这就是结构体压缩技术。
首先我们注意到溢出只发生在两个地方。 一个是较大的数据类型(从而需要更严格的对齐)跟在较小的数据后面。 另一个是结构体自然结束的位置到跨步地址之间需要填充,以使下一个相同结构能正确地对齐。
最简单的消除溢出的方式是按对齐值的递减来排序成员。 即让指针对齐的成员排在最前面,因为在64位机上它们是8字节;然后是4字节的int;然后是2字节的short,然后是字符。
因此,以简单的链表结构为例:
struct foo7 {
char c;
struct foo7 *p;
short x;
};
struct foo7 {
char c; /* 1 byte */
char pad1[7]; /* 7 bytes */
struct foo7 *p; /* 8 bytes */
short x; /* 2 bytes */
char pad2[6]; /* 6 bytes */
};
struct foo8 {
struct foo8 *p;
short x;
char c;
};
struct foo8 {
struct foo8 *p; /* 8 bytes */
short x; /* 2 bytes */
char c; /* 1 byte */
char pad[5]; /* 5 bytes */
};
struct foo9 {
struct foo9_inner {
char *p; /* 8 bytes */
int x; /* 4 bytes */
} inner;
char c; /* 1 byte*/
};
把填充写明:
struct foo9 {
struct foo9_inner {
char *p; /* 8 bytes */
int x; /* 4 bytes */
char pad[4]; /* 4 bytes */
} inner;
char c; /* 1 byte*/
char pad[7]; /* 7 bytes */
};
如果符号调试器能显示枚举类型的名称而不原始的数字,使用枚举来代替#define是个好办法。然而,虽然枚举必须与某种整型兼容,C标准却没有指定到底是何种整型。
请当心重打包结构体的时候,枚举型变量通常是int,这跟编译器相关;但它们也可能是short,long,甚至默认是char。你的编译器可能会有progma或命令行选项指定枚举的尺寸。
long double 是个类似的故障点。 有些C平台以80位实现它,有些是128位,而一些80位平台把它填充到96或128位。
在以上两种情况下最好用sizeof()来检查存储的尺寸。
按成员尺寸重排是最简单的消除溢出的方式,但不一定是正确的方式。 还有两个问题:可读性和cache局部性。
程序不仅与计算机交流,还与人类交流。 特别当交流的观众是将来的你的时候,代码可读性更重要的。
一个笨拙的、机械的重排可能影响可读性。有可能的话,最好这样重排成员:使得语义相关的数据放在一起,形成连贯的组。 最理想的是,结构体的设计要与程序的设计相互沟通。
当你的程序频繁地存取某个结构或它的一部分,如果存取总是能放进一条cache 行,对提高性能是很有帮助的。cache 行是这样的内存块,当处理器要去取该内存块内的任何单个地址时,会把整个内存块都取出来。 在64位x86上,一条cache 行是64字节,开始于自对齐的地址。在其它平台上通常是32字节。
你为保持可读性而做的事-把相关的和同时要存取的数据放在相邻的位置-也会提高cache行局部性。 它们都是聪明地重排、把数据的存取模式放在心上的原因。
如果你的代码从多个线程上同时存取一个结构体,会有第三个问题:cache line bouncing。 为了减少昂贵的总线通信,你应该这样安排数据,使得在一个更紧的循环里,从一条cache line 里读数据,而往另一条写数据。
是的,这种做法与前面说的把相关的数据放入与cache line长度相同的块矛盾。多线程是困难的。 Cache line bouncing 和其它多线程优化问题是很高级的话题,值得单独为它们写个指导。 这里我能做的只是让你了解有这些问题存在。
在为你的结构瘦身的时候,重排序与其它技术结合在一起工作得最好。如果你在结构里有几个布尔标志,可以考虑把它们压缩成1位的位域,然后把它们打包放在本来可能成为slop(溢出)的地方。
你可能会有一点儿存取时间的损失-但如果它把工作空间压缩得足够小,那点损失可以从避免cache miss 来补偿。
总的原则是,选择能把数据类型缩短的方法。 以cvs-fast-export为例,我使用的一个压缩方法是:利用RCS和CVS在1982年前还不存在这个事实,我弃用了64位的Unix time_t(在1970年开始的时候是零),而用了一个32位的、从1982-01-01T00:00:00开始的偏移量;这样日期会覆盖到2118年。(注意,如果你使用这样的技巧,要用边界条件检查以防讨厌的bug!)
每样缩短法不仅减小了结构的可见尺寸,还可以消除溢出或创造额外的机会来进行重新排序。 这种效果的良性互动是不难被触发的。
最冒险的打包方法是使用union。 如果你知道结构体中的某些域永远不会跟另一些域一起使用,考虑用union使它们共享存储空间。 不过请特别小心,要用回归测试验证你的做法。因为如果你的分析有一丁点儿错误,就会有从程序崩溃到(更糟的)微妙的数据损坏。
有个叫 pahole 的工具, 我自己没有使用过它,不过有一些反馈说它挺好的。该工具与编译器协同工作,输出关于结构体的填充、对齐和cache line 边界的报告。