[译]鲜为人知的结构体包装艺术(中)

作者:Eric S. Raymond

导语

我们书接上文,继续来看结构体到底是需要怎样的包装和填充。该篇是原文中占用篇幅比较大的部分,也是技术讲解的核心部分,翻译过程中一些语言可能组织的不太得到,欢迎指正。闲言少叙,请欣赏。

4. 填充

现在我们看一个简单的示例,变量在内存中的布局。假设下边的几个变量是在在C模块最外层声明的。

char *p;
char c;
int x;

如果对数据对齐一无所知的话,你可能会认为这三个变量在内存中将会占一个连续的字节区间。即在一个32位机上,首先是一个4字节的指针变量,紧接着是1字节的字符变量,然后又紧接着的是4字节的整型变量。在64位机上唯一的不同就是指针变量将会是8字节。

事实上,这里隐含了一个假设,静态变量分配的顺序是它们在源码中出现的顺序,这并不一定是合法的,因为C语言标准并没有强制要求这么做。下边我将忽略这个小细节,因为(a)这个假设通常是正确的,(b)实际的目的是讲述结构体外部的填充和包装的技术,以为理解结构体内部的做法做铺垫。

下边是真实发生的情形(在x86或者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。填充空间处的值是未定义的;特别的是不会保证这些字节被置0了。

比较一下,如果x是2字节对齐的短整型会发生什么:

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位机上的长整型

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;

M和N又分别是多少呢?

首先,在这个实例中,N将会是0。x的地址,紧随p其后。由于指针对齐比整形对齐更严格,自然保证了x的地址是自对齐的。

M的值则不太好预测了。如果编译器碰巧将c放到一个机器字的最后一个字节,那么接下来的字节(p的第一个字节)将是下一个字的首字节,这样正好指针对齐。M将会是0。

更常见的是将c放置到机器字到第一个字节。这种情况下,为了确保p是指对齐的,在32位机上M将会是3,在64位机上M则会是7。

中间状态是可能出现的。M可以是从0到7(在32位机上0到3)的任何一个值,因为字符变量c可以在一个机器字里的任何一个字节。

如果想要使这些变量占用更少的空间,你可以尝试一下将原来顺序中的c交换到x之后。

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

通常,使用改变声明的方式为你的C程序中少量的标量变量省出几个字节的空间,并不会显著地帮到你。当这项技术被用于非标量变量-特别是结构体是会更有趣。

在开始前,我们先了解一下数组类型的标量。在一个有自对齐类型的机器上,字符型/短整型/整型/长整型/指针类型数组都没有内部的填充;每个成员自动按照先后顺序依次摆放。

所有这些规则和示例都适用于Go语言,但是语义会有所不同。

在下一节我们将会看到在结构体数组情况下,就不是这样了。

5. 结构体的对齐与填充

通常结构体实例会按照其最宽的标量成员来对齐。编译器通过这个最简单的方式来确保所有成员都是子对齐的,从而可以更快的访问。

另外,在C(GO和Rust)语言中,结构体的地址与其第一个成员的地址相同-没有前置填充。注意:在C++中,与结构体很类似的类可能会打破这个规则!(它们遵守或者不遵守这个规则取决于基类和虚成员函数是如何被实现的,而且各个编译器的做法也会不同。)

(当你最这类事情有疑惑时,ANSI C提供了一个offset()宏,它可以用来读出结构体成员的偏移量。)

考虑这个结构体:

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

假设在一个64位机上,任何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可以起始于任意的字节地址,填充的大小也会不同。因为foo2结构体的最宽的成员是指针对齐,就没有其他可能了。现在c必须也得是指针对齐的,紧接着的7个字节也被锁定了。

现在我们谈一下结构体的尾部填充。为了解释这个问题,需要引入一个我称之为轨道地址的基本概念。它是紧接着结构体数据的第一个地址,并且与前边结构体具有相同的对齐方式。

结构体尾填充的一般规则是:编译器会在结构体尾部填充到其轨道地址。这个规则决定了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](译注:类似于长度为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后的一个字节,所以foo4结构体整体只需要一个字节的尾填充。它的内存布局就想这样:

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

sizeof(struct foo4)将返回4.

最后一个重要的细节是:如果你的结构体中含有结构体成员,内部结构体也需要按照结构体的最长的标量对齐。假设你这样声明结构体:

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

这个结构体给了我们一个提示,重新打包结构体可以带来空间的节约-有超过50%的空间被浪费了。

6. 字节域

现在我们来考虑C语言的字节域。声明结构体字节域可以使你可以获取小于一个字节,最小到1位空间的能力,就像这样:

struct foo6 {
    short s;
    char c;
    int flip:1;
    int nybble:4;
    int septet:7;
};

关于字节域需要知晓的是,它们通过字级或着字节级的掩码指令和旋转指令来操作机器字,且不能跨越字边界。C99标准确保了在它们不跨越存储单元边界的情况下, 字节域会尽可能紧凑(6.7.2.1 #10)。

这个限制在C11标准(6.7.2.1p11)和C++14 ([class.bit]p1)中放松了;这些修改不再真的要求struct foo9分配64位而不是32位(译注:这个表述似乎有误,与下文对应不上,可以略过);一个字节域可以跨越多个存储单元,而不是起始于一个新的单元。这个留给了具体实现来做决定;GCC将这个决定留给了ABI,在x64中不会禁止它们共享一个存储单元。

假设在32位机上,C99标准暗示了内存布局可能像这样:

struct foo6 {
    short s;       /* 2 bytes */
    char c;        /* 1 byte */
    int flip:1;    /* total 1 bit */
    int nybble:4;  /* total 5 bits */
    int pad1:3;    /* pad to an 8-bit boundary */
    int septet:7;  /* 7 bits */
    int pad2:25;   /* pad to 32 bits */
};

但是这不是唯一的可能结果,因为C标准没有定义位的分配顺序是从低到高的。因此内存布局也可能像这样:

struct foo6 {
    short s;       /* 2 bytes */
    char c;        /* 1 byte */
    int pad1:3;    /* pad to an 8-bit boundary */
    int flip:1;    /* total 1 bit */
    int nybble:4;  /* total 5 bits */
    int pad2:25;   /* pad to 32 bits */
    int septet:7;  /* 7 bits */
};

那就是填充可能在位域之前而不是之后。

同样需要注意的是,就像正常的结构体填充,位域填充也不会保证被置0;C99提到了这个。

注意解释位域的基础类型时要考虑符号,而不考虑大小。由实现决定"short flip:1"或"long flip:1"是否被支持,并且决定是否改变存储填充后的字节域的存储大小。

谨慎地前进并且如果有的话打开-Wpadded选项(例如使用clang)。在奇怪硬件上编译器很可能用奇怪的方式执行C99标准,比较老的编译器可能就不遵守这个标准。

限制字节域不能跨越机器字意味着,正如预期的在C99标准下,当前两个结构体以此填充到第一,二个32位的字后,第三个成员(struct foo9)将占据第三个32位的字,只使用了最后一个字中的一个位。

struct foo7 {
    int bigfield:31;      /* 32-bit word 1 begins */
    int littlefield:1;
};

struct foo8 {
    int bigfield1:31;     /* 32-bit word 1 begins /*
    int littlefield1:1;
    int bigfield2:31;     /* 32-bit word 2 begins */
    int littlefield2:1;
};

struct foo9 {
    int bigfield1:31;     /* 32-bit word 1 begins */
    int bigfield2:31;     /* 32-bit word 2 begins */
    int littlefield1:1;
    int littlefield2:1;   /* 32-bit word 3 begins */
};

然而,C11和C++14会把foo9打包的跟紧凑一些,但是去考虑这个也行是不明智的。

另一方面,结构体foo8将会被放进一个64位的字中,如果这个机器是64位的。

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

版权声明:

原文版权归原作者所有,本译文没有版权,但是转载请注明出处,仅此一个小要求。

你可能感兴趣的:([译]鲜为人知的结构体包装艺术(中))