C结构体打包技艺

一直对结构体内存对齐,只知其一;不知其二, 发现不错文章引用过来。git

字节对齐主要是为了提升内存的访问效率,好比intel 32为cpu,每一个总线周期都是从偶地址开始读取32位的内存数据,若是数据存放地址不是从偶数开始,则可能出现须要两个总线周期才能读取到想要的数据,所以须要在内存中存放数据时进行对齐。程序员

 

一般咱们说字节对齐不少时候都是说struct结构体的内存对齐,好比下面的结构体:github

struct A{
    char a;
    int b;
    short c;
}

在32位机器上char 占1个字节,int 占4个字节,short占2个字节,一共占用7个字节.可是实际真的是这样吗?算法

咱们先看下面程序的输出:编程

#include <stdio.h>

struct A{
    char a;
    int b;
    short c;
};
int main(){
    struct A a;
    printf("A: %ld\n", sizeof(a));
    return 0;
}

测试输出的结果是A: 12, 比计算的7多了5个字节。这个就是由于编译器在编译的时候进行了内存对齐致使的。数组

 

内存对齐主要遵循下面三个原则:缓存

  1. 结构体变量的起始地址可以被其最宽的成员大小整除
  2. 结构体每一个成员相对于起始地址的偏移可以被其自身大小整除,若是不能则在前一个成员后面补充字节
  3. 结构体整体大小可以被最宽的成员的大小整除,如不能则在后面补充字节

其实这里有点不严谨,编译器在编译的时候是能够指定对齐大小的,实际使用的有效对齐实际上是取指定大小和自身大小的最小值,通常默认的对齐大小是4。数据结构

 

再回到上面的例子,若是默认的对齐大小是4,结构体a的其实地址为0x0000,可以被最宽的数据成员大小(这里是int, 大小为4,有效对齐大小也是4)整除,姑char a的从0x0000开始存放占用一个字节即0x0000~0x0001,而后是int b,其大小为4,故要知足2,须要从0x0004开始,因此在char a后填充三个字节,所以a对齐后占用的空间是0x0000~0x0003,b占用的空间是0x0004~0x0007, 而后是short c其大小是2,故从0x0008开始占用两个字节,即0x0008~0x000A。 此时整个结构体占用的空间是0x0000~0x000A, 占用11个字节,11%4 != 0, 不知足第三个原则,因此须要在后面补充一个字节,即最后内存对齐后占用的空间是0x0000~0x000B,一共12个字节。多线程

下面是另外一篇讲解架构

原文连接:http://www.catb.org/esr/structure-packing/

 

谁应阅读本文

本文探讨如何经过手工从新打包C结构体声明,来减少内存空间占用。你须要掌握基本的C语言知识,以理解本文所讲述的内容。

若是你在内存容量受限的嵌入式系统中写程序,或者编写操做系统内核代码,就有必要了解这项技术。若是数据集巨大,应用时常逼近内存极限,这项技术会有所帮助。假若你很是很是关心如何最大限度地减小处理器缓存段(cache-line)未命中状况的发生,这项技术也有所裨益。

最后,理解这项技术是通往其余C语言艰深话题的门径。若不掌握,就算不上高级C程序员。当你本身也能写出这样的文档,而且有能力明智地评价它以后,才称得上C语言大师。

缘何写做本文

2013年末,我大量应用了一项C语言优化技术,这项技术是我早在二十余年前就已掌握的,但彼时以后,鲜有使用。

我须要减小一个程序对内存空间的占用,它使用了上千(有时甚至几十万)C结构体实例。这个程序是cvs-fast-export,在将其应用于大规模软件仓库时,程序会出现内存耗尽错误。

经过精心调整结构成体员的顺序,能够在这种状况下大幅减小内存占用。其效果显著——在上述案例中,能够减小40%的内存空间。程序应用于更大的软件仓库,也不会因内存耗尽而崩溃。

但随着工做展开,我意识到这项技术在近些年几乎已被遗忘。Web搜索证明了个人想法,现今的C程序员们彷佛已再也不谈论这些话题,至少从搜索引擎中看不到。维基百科有些条目涉及这一主题,但不曾有人完整阐述。

事出有因。计算机科学课程(正确地)引导人们远离微观优化,转而寻求更理想的算法。计算成本一路走低,令压榨内存的必要性变得愈来愈低。旧日里,黑客们经过在陌生的硬件架构中跌跌撞撞学习——现在已很少见。

然而这项技术在关键时刻仍颇具价值,而且只要内存容量有限,价值就始终存在。本文意在节省C程序员从新发掘这项技术所需的时间,让他们有精力关注更重要任务。

对齐要求

首先须要了解的是,对于现代处理器,C编译器在内存中放置基本C数据类型的方式受到约束,以令内存的访问速度更快。

在x86或ARM处理器中,基本C数据类型一般并不存储于内存中的随机字节地址。实际状况是,除char外,全部其余类型都有“对齐要求”:char可起始于任意字节地址,2字节的short必须从偶数字节地址开始,4字节的int或float必须从能被4整除的地址开始,8比特的long和double必须从能被8整除的地址开始。不管signed(有符号)仍是unsigned(无符号)都不受影响。

用行话来讲,x86和ARM上的基本C类型是“自对齐(self-aligned)”的。关于指针,不管32位(4字节)仍是64位(8字节)也都是自对齐的。

自对齐可令访问速度更快,由于它有利于生成单指令(single-instruction)存取这些类型的数据。另外一方面,如若没有对齐约束,可能最终不得不经过两个或更多指令访问跨越机器字边界的数据。字符数据是种特殊状况,因其始终处在单一机器字中,因此不管存取何处的字符数据,开销都是一致的。这也就是它不须要对齐的缘由。

我提到“现代处理器”,是由于有些老平台强迫C程序违反对齐规则(例如,为int指针分配一个奇怪的地址并试图使用它),不只令速度减慢,还会致使非法指令错误。例如Sun SPARC芯片就有这种问题。事实上,若是你下定决心,并恰当地在处理器中设置标志位(e18),在x86平台上,也能引起这种错误。

另外,自对齐并不是惟一规则。纵观历史,有些处理器,由其是那些缺少桶式移位器(Barrel shifter)的处理器限制更多。若是你从事嵌入式系统领域编程,有可能掉进这些潜伏于草丛之中的陷阱。当心这种可能。

你还能够经过pragma指令(一般为#pragma pack)强迫编译器不采用处理器惯用的对齐规则。但请别随意运用这种方式,由于它强制生成开销更大、速度更慢的代码。一般,采用我在下文介绍的方式,能够节省相同或相近的内存。

使用#pragma pack的惟一理由是——假如你需让C语言的数据分布,与某种位级别的硬件或协议彻底匹配(例如内存映射硬件端口),而违反通用对齐规则又不可避免。若是你处于这种困境,且不了解我所讲述的内容,那你已深陷泥潭,祝君好运。

填充

咱们来看一个关于变量在内存中分布的简单案例。思考形式以下的一系列变量声明,它们处在一个C模块的顶层。

char *p;
char c; int x;

假如你对数据对齐一无所知,也许觉得这3个变量将在内存中占据一段连续空间。也就是说,在32位系统上,一个4字节指针以后紧跟着1字节的char,其后又紧跟着4字节int。在64位系统中,惟一的区别在于指针将占用8字节。

然而实际状况(在x8六、ARM或其余采用自对齐类型的平台上)以下。存储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个字节的空间被浪费掉了。老派术语将其称之为“废液(slop)”。

若是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 */

另外一方面,若是x为64位系统中的long:

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;

MN分别为几何?

首先,在此例中,N将为0,x的地址紧随p以后,能确保是与指针对齐的,由于指针的对齐要求总比int严格。

M的值就不易预测了。编译器如果刚好将c映射为机器字的最后一个字节,那么下一个字节(p的第一个字节)将刚好由此开始,并刚好与指针对齐。这种状况下,M将为0。

不过更有可能的状况是,c将被映射为机器字的首字节。因而乎M将会用于填充,以使p指针对齐——32位系统中为3字节,64位系统中为7字节。

中间状况也有可能发生。M的值有可能在0到7之间(32位系统为0到3),由于char能够从机器字的任何位置起始。

假若你但愿这些变量占用的空间更少,那么能够交换xc的次序。

char *p;     /* 8 bytes */ long x; /* 8 bytes */ char c; /* 1 byte */

一般,对于C代码中的少数标量变量(scalar variable),采用调换声明次序的方式能节省几个有限的字节,效果不算明显。而将这种技术应用于非标量变量(nonscalar variable)——尤为是结构体,则要有趣多了。

在讲述这部份内容前,咱们先对标量数组作个说明。在具备自对齐类型的平台上,char、short、int、long和指针数组都没有内部填充,每一个成员都与下一个成员自动对齐。

在下一节咱们将会看到,这种状况对结构体数组并不适用。

结构体的对齐和填充

一般状况下,结构体实例以其最宽的标量成员为基准进行对齐。编译器之因此如此,是由于此乃确保全部成员自对齐,实现快速访问最简便的方法。

此外,在C语言中,结构体的地址,与其第一个成员的地址一致——不存在头填充(leading padding)。当心:在C++中,与结构体类似的类,可能会打破这条规则!(是否真的如此,要看基类和虚拟成员函数是如何实现的,与不一样的编译器也有关联。)

假如你对此有疑惑,ANSI C提供了一个offsetof()宏,可用于读取结构体成员位移。

考虑这个结构体:

struct foo1 {
    char *p; char c; long x; };

假定处在64位系统中,任何struct fool的实例都采用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的1字节以后,整个struct foo4也只须要1字节的尾填充。形式以下:

struct foo4 {
    short s; /* 2 bytes */ char c; /* 1 byte */ char pad[1]; };

sizeof(struct foo4)的返回值将为4。

如今咱们考虑位域(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; };

内层结构体成员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%的空间!

结构体成员重排

理解了编译器在结构体中间和尾部插入填充的缘由与方式后,咱们来看看如何榨出这些废液。此即结构体打包的技艺。

首先注意,废液只存在于两处。其一是较大的数据类型(须要更严格的对齐)跟在较小的数据类型以后。其二是结构体天然结束的位置在跨步地址以前,这里须要填充,以使下个结构体能正确地对齐。

消除废液最简单的方式,是按对齐值递减从新对结构体成员排序。即让全部指针对齐成员排在最前面,由于在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 */ };

总共是24字节。若是按长度重排,咱们获得:

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 */ };

从新打包将空间降为16字节。也许看起来不算不少,但假如这个链表的长度有20万呢?将会聚沙成塔。

注意,从新打包不能确保在全部状况下都能节省空间。将这项技术应用于更靠前struct foo6的那个例子,咱们获得:

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 */ };

结果仍是24字节,由于c没法做为内层结构体的尾填充。要想节省空间,你须要得新设计数据结构。

棘手的标量案例

只有在符号调试器能显示枚举类型的名称而非原始整型数字时,使用枚举来代替#define才是个好办法。然而,尽管枚举一定与某种整型兼容,但C标准却没有指明到底是何种底层整型。

请小心,重打包结构体时,枚举型变量一般是int,这与编译器相关;但也多是short、long、甚至默认为char。编译器可能会有progma预处理指令或命令行选项指定枚举的尺寸。

long double是个相似的故障点。有些C平台以80位实现,有些是128位,还有些80位平台将其填充到96或128位。

以上两种状况,最好用sizeof()来检查存储尺寸。

最后,在x86 Linux系统中,double有时会破自对齐规则的例;在结构体内,8字节的double可能只要求4字节对齐,而在结构体外,独立的double变量又是8字节自对齐。这与编译器和选项有关。

可读性与缓存局部性

尽管按尺寸重排是最简单的消除废液的方式,却不必定是正确的方式。还有两个问题须要考量:可读性与缓存局部性。

程序不只与计算机交流,还与其余人交流。甚至(尤为是!)交流的对象只有未来你本身时,代码可读性依然重要。

笨拙地、机械地重排结构体可能有损可读性。假若有可能,最好这样重排成员:将语义相关的数据放在一块儿,造成连贯的组。最理想的状况是,结构体的设计应与程序的设计相通。

当程序频繁访问某一结构体或其一部分时,若能将其放入一个缓存段,对提升性能很有帮助。缓存段是这样的内存块——当处理器获取内存中的任何单个地址时,会把整块数据都取出来。 在64位x86上,一个缓存段为64字节,它开始于自对齐的地址。其余平台一般为32字节。

为保持可读性所作的工做(将相关和同时访问的数据放在临近位置)也会提升缓存段的局部性。这些都是须要明智地重排,并对数据的存取模式了然于心的缘由。

若是代码从多个线程并发访问同一结构体,还存在第三个问题:缓存段弹跳(cache line bouncing)。为了尽可能减小昂贵的总线通讯,应当这样安排数据——在一个更紧凑的循环里,从一个缓存段中读数据,而向另外一个写入数据。

是的,某些时候,这种作法与前文将相关数据放入与缓存段长度相同块的作法矛盾。多线程的确是个难题。缓存段弹跳和其余多线程优化问题是很高级的话题,值得单独为它们写份指导。这里我所能作的,只是让你了解有这些问题存在。

其余打包技术

在为结构体瘦身时,重排序与其余技术结合在一块儿效果最好。例如结构体中有几个布尔标志,能够考虑将其压缩成1位的位域,而后把它们打包放在本来可能成为废液的地方。

你可能会有一点儿存取时间的损失,但只要将工做集合压缩得足够小,那点损失能够靠避免缓存未命中补偿。

更通用的原则是,选择能把数据类型缩短的方法。以cvs-fast-export为例,我使用的一个压缩方法是:利用RCS和CVS在1982年前还不存在这个事实,我弃用了64位的Unixtime_t(在1970年开始为零),转而用了一个32位的、从1982-01-01T00:00:00开始的偏移量;这样日期会覆盖到2118年。(注意:若使用这类技巧,要用边界条件检查以防讨厌的Bug!)

这不只减少告终构体的可见尺寸,还能够消除废液和/或创造额外的机会来进行从新排序。这种良性串连的效果不难被触发。

最冒险的打包方法是使用union。假如你知道结构体中的某些域永远不会跟另外一些域共同使用,能够考虑用union共享它们存储空间。不过请特别当心并用回归测试验证。由于若是分析出现一丁点儿错误,就会引起从程序崩溃到微妙数据损坏(这种状况糟得多)间的各类错误。

工具

clang编译器有个Wpadded选项,能够生成有关对齐和填充的信息。

还有个叫pahole的工具,我本身没用过,但听说口碑很好。该工具与编译器协同工做,生成关于结构体填充、对齐和缓存段边界报告。

证实和例外

读者能够下载一段程序源代码packtest.c,验证上文有关标量和结构体尺寸的结论。

若是你仔细检查各类编译器、选项和罕见硬件的稀奇组合,会发现我前面提到的部分规则存在例外。越早期的处理器设计例外越常见。

理解这些规则的第二个层次是,知其什么时候及如何会被打破。在我学习它们的日子里(1980年代早期),咱们把不理解这些规则的人称为“全部机器都是VAX综合症”的牺牲品。记住,世上全部电脑并不是都是PC。

相关文章
相关标签/搜索