前几天和朋友一起讨论 C 的结构体,一时兴起问了朋友几个问题,大致是关于结构体成员声明顺序对结构体大小的影响;之后想起 bit-field 的声明方式便加入了 bit-field 的情况一起讨论,而此后的实验结果在我们的电脑上得出的结果却并不一致,于是我开始翻阅起了 C 标准原文说明,尝试找到最权威的说法...

引言

先抛出几个问题,在 32 位机器下编译,对于下列结构体的声明,结构体所占用的空间分别是多少字节?

typedef struct A {
    int iv;
    char cv1;
    char cv2;
} A;

typedef struct B {
    char cv1;
    int iv;
    char cv2;
} B;

如果再引入 bit-field 呢?下面的结构体又分别占用多少字节的空间?如果结构体中有多个 bit-field 的话,它们之间是紧挨着的还是按字节对齐还是按生命的类型大小对齐呢?

typedef struct C {
    unsigned char bf1:5;
    unsigned int  bf2:3;
} C;
typedef struct D {
    unsigned int  bf1:3;
    unsigned char bf2:5;
} D;
typedef struct E {
    unsigned char bf1:3;
    unsigned char bf2:5;
} E;
typedef struct F {
    unsigned int bf1:3;
    unsigned int bf2:5;
} F;
typedef struct G {
    unsigned char bf1:3;
    unsigned char bf2:6;
} G;

常见答案

相信稍微看过一些资料的朋友都知道,编译器在处理结构体的时候会进行内存对齐,其中网上能够找到的最常见的对齐方案如下:

  1. 结构体的成员顺序就是它们在内存中的顺序
  2. 结构体的成员的地址相对结构体起始地址偏移量应为这个成员类型大小的整数倍
  3. 结构体的总大小为结构体最大成员大小的整数倍

这样就不难得出答案,如果一个结构体中三个成员分别为 int, char, char,则其应该占用 8 bytes,而如果按 char, int, char 的顺序,则需要占用 12 bytes
它们在内存中的位置排列示意图如下:

(图中 c 表示 char 类型变量, int 表示 int 类型变量,地址假设从 0 开始,以 16 进制计数)
Address : |0.1.2.3|4.5.6.7|8.9.A.B|C.D.E.F|
struct A: |  int  |c|c|...|                - 8  bytes
struct B: |c|.....|  int  |c|.....|        - 12 bytes

假设结构体起始地址为 0,如果第一个成员是 char,则其占用这个位置,下一个成员是 int,但其不能从地址 0x1 开始存放,应该要从 0x4 开始放,这样就浪费了 3 个字节的空间,再下一个成员 char 则继续紧挨着 int 来摆放。

以上说法都没有大问题,那么加入 bit-field 之后呢?

涉及到 bit-field 主要有两个问题

  1. bit-field 所占用的实际空间大小和声明其所用的底层类型是否有关?
  2. 连续声明的 bit-field 是紧凑的还是按字节对齐的?抑或是按声明其所用的底层类型大小对齐的?

上网随便查阅一些资料就可以得出答案,这里引用这篇博客1中提到的相关内容,有关 bit-field 对齐的规则如下:

  1. bit-field 使用底层数据类型作为 “容器”,即声明时前面的类型
  2. bit-field 会共享底层类型大小,但一个 bit-field 不会横跨两个容器
  3. 如果有声明宽度为 0 的 bit-field,它作为一个特殊的标记,使得其之后的一个成员按这个类型的对齐方式对齐

于是按照这些规则,对于下面的声明方式,我们可以分别得到大致的结论:

char:5, int:3  ->  两个容器; char, int 共占用 8 bytes,其中 char:5 位于 0 字节,int:3 位于 4 字节
int :3,char:5  ->  两个容器; int, char 共占用 8 bytes, 其中 int:3 位于 0 字节,char:5 位于 4 字节
char:3,char:5  ->  一个容器(因为能装下),占用 1 bytes,一个字节的 8 bit 刚好分配给两个 bit-field
int :3,int :5  ->  一个容器(因为能装下),占用 4 bytes,两个 bit-field 塞满第一个字节
char:3,char:6  ->  两个容器(因为装不下),占用 2 bytes,其中 char:3 位于 0 字节,char:6 位于 1 字节

那么这些真的就是正确的结论了吗?
确实在大多数情况下没问题,我最初的实验也证实了以上所有的结论,直到....
我的朋友在他的电脑上也进行了实验并且得到了和我不一样的结论.......而且我们用的都是 gcc 编译器(虽然一个是 mingw-w64 一个是 TDM)
后来我在我的服务器上也跑了一下发现了更为奇怪的现象....经过进一步的测试发现似乎是编译器bug,但还并未进一步研究和确定

所以说,更精确来讲,以上的结论正确但不严谨;就像是限定 32 位机情况一样,是为了保证变量长度一致,方便讨论;以上的对齐方式的讨论,也只是大多数的实现方案,并不代表所有的编译器都按同样的方案实现的。

动手实验并进行分析

实验也没什么好说的,就是将最初我提到的那些个结构体定义拿出来,写到代码里测试看看各个变量的偏移量如何, bit-field 是否是紧挨着的等等;因为对 bit-field 取地址是非法的,因此判断 bit-field 位置的方法我采用了赋值并验证的方法,综合结构体的大小即可得出其位置(或者说对齐规则)。

相关代码以及运行结果我会放到文末,因为放在这可能会占用比较长的位置影响继续阅读。

从 C 标准进行严谨考究

基于和朋友实验得出不同结果,不一致主要因为 bit-field 所占用的实际空间上以及其是否紧凑排布的问题,涉及不含 bit-field 的结构时在各个地方测试表现都非常一致。
我查阅资料时,选择从最权威的 C 标准说明2开始,按照 C11 标准第 6.7.2.1 节,相关内容如下:

C11标准

大致翻译一下的话:

  • (结构体的)成员可以被声明为指定数量的 bit(包含符号位),这样的成员叫做位域(bit-field),用冒号定义其宽度
  • 声明时允许使用的类型和一些要求(略)
  • 实现应该要申请足够大的 addressable storage unit(可寻址的存储单元) 来存放这些比特,如果有足够的空间,相邻的位域应该要打包到一个单元中,如果空间不够,则需要另开一个存储单元,但后面的位域应该跨越存储单元紧挨着之前的位域还是放在新的存储单元中是由实现决定的,存储单元的对齐方式则是 Unspecified 未指定的。

另外还有说明 0 宽位域的作用则是声明和当前这个位域的 unit 中不再存放其他数据了的意思
C11标准2

  • 一个指向结构体的指针应该就是其第一个成员的地址、或位域单元地址等等,反之亦然
  • 结构体内部的非位域成员的对齐是由实现决定的

大致就是说位域的存储以及对齐是由实现决定的,而存储单元的对齐是 Unspecified.....
于是继续查阅 gcc 的文档34,发现相关的内容真是简洁的...可以,有兴趣围观的同学可以从脚注3和4点过去看看(

大致就是把标准中列出的 Implementation Defined 的列表拿了出来,然后用一句话回答(它是怎么实现的),而成员对齐这些内容...答案是

Determined by ABI.

居然是有关 ABI5... 而关于 bit-field 的相关说明和对齐方式一律没有(标准说是 Unspecified),于是基本只能靠自己实验或者看汇编结果来得出实际使用的实现方式了。
于是我又查了一下关于 Unspecified 以及 Undefined BehaviourImplementation Defined 的具体说法6,大致意思就是说:

  • 对于 Undefined Behaviour,标准明确说明这些行为不用理会,怎么实现都行;例如 ++i+i++,标准不鼓励写出这种只有迷惑意义的代码,所以哪怕编译器格了你的盘也是符合标准的。
  • 对于 Unspecified Behaviour,则是说实现方式也就那几种,标准让编译器实现时随意挑选,而且也不必写成文档。
  • 对于 Implementation Defined,则是标准要求编译器实现时,需要将具体的内容整理成文档。

结论

从实验的结果来看,大部分编译器的实现都是满足之前提到的对齐方式的,而涉及到位域的问题主要有两个,分别是位域存储单元占用空间以及是否紧凑,这里将测试结果以表格的方式列出。
占用空间:这里以 char:3,int:5 为例测试占用空间,也尝试了交换顺序,其中括号内的表示整个结构体占用的空间,而这两个位域所占用的空间是利用额外定义的 char pivot 的偏移得出的。
是否紧凑:例如 char:3,int:5,如果它们存放在同一个字节中,则我称之为 “紧凑
位域合并:在测试的所有平台上,均发现了“位域合并”现象,即连续的位域如果使用相同的类型声明并且没有超出存储单元时,和直接声明一个大的位域没有任何区别;例如 int:3,int:6int:9 是完全一致的。

平台编译器char:3,int:5 (结构体) 占用空间int:5,char:3 (结构体) 占用空间是否紧凑
Win10mingw-w64 gcc 8.1.08B(8B)5B(8B)
Win10gcc-TDM 4.9.25B(8B)5B(8B)
Ubuntu Servergcc 7.4.01B(4B)1B(4B)
CentOS 6gcc 4.8.51B(4B)1B(4B)
Apple OSXclang-1001.0.46.41B(4B)1B(4B)

值得注意的是,在我测试的 Win10 mingw-w64 gcc-8.1.0 下,位域的声明顺序对其占用空间也是有影响的,而gcc-TDM 下则无影响,说明两者在处理“存储单元”时的逻辑是略有不同的,而 unix 下所有编译器都执行了存储单元的合并优化实现了紧凑布局,与声明位域所使用的类型无关。

总之,结合源代码里的其他测试,得出的常见的位域对齐和处理方式大致如下,对于下面的每个问题,如果有多种回答,说明测试中发现编译器使用了这些实现;如果没有,则测试的编译器均使用该实现方案:

  • 位域的存储单元应该多大?

    1. 按连续的位域中的最大声明类型作为存储单元单位
    2. 按声明的类型大小作为那个位域的存储单元大小
  • 存储单元按怎样的方式对齐?

    1. 存储单元互相紧挨着,也即按 1B 对齐(哪怕存储单元不止 1B)
    2. 存储单元按存储单元的大小的整数倍对齐
  • 连续的位域如何决定放在哪个存储单元?

    1. 只有相邻的位域声明为相同类型,则它们可以放在同一个存储单元
    2. 不论位域声明为什么类型 ,只要能放下,则它们就放在同一个存储单元
  • 当存储单元放不下的时候,位域是否能够跨越两个存储单元?

    1. 不能

测试的源代码(的大体框架,实际上还进行了一定的修改测试了更多内容)

#include <stddef.h>
#include <stdio.h>
typedef struct A {
    int iv;
    char cv1;
    char cv2;
} A;
typedef struct B {
    char cv1;
    int iv;
    char cv2;
} B;
typedef struct C {
    unsigned char bf1:5;
    unsigned int  bf2:3;
    char p;
} C;
typedef struct D {
    unsigned int  bf1:3;
    unsigned char bf2:5;
    char p;
} D;
typedef struct E {
    unsigned char bf1:3;
    unsigned char bf2:5;
    char p;
} E;
typedef struct F {
    unsigned int bf1:3;
    unsigned int bf2:5;
    char p;
} F;
typedef struct G {
    unsigned char bf1:3;
    unsigned char bf2:6;
    char p;
} G;

C cv;
D dv;
E ev;
F fv;
G gv;
int main()
{
    printf("size: %d, %d, %d, %d, %d, %d, %d\n", sizeof(A), sizeof(B), sizeof(C), sizeof(D), sizeof(E), sizeof(F), sizeof(G));
    printf("offset: %d, %d\n", offsetof(A, cv1), offsetof(B, iv));

    int a = 0x0f;
    if (*(char*)&a > 0) printf("Little Endian mode.\n");
    else printf("Big Endian mode.\n");

    *(int*)&cv = 0xff;
    *(int*)&dv = 0xff;
    *(int*)&ev = 0xff;
    *(int*)&fv = 0xff;
    *(int*)&gv = 0xff;
    printf("Fill 0xff to the lowest addr\n");
    printf("char:5, int :3\t%d,%d\n", cv.bf1, cv.bf2);
    printf("int :3, char:5\t%d,%d\n", dv.bf1, dv.bf2);
    printf("char:3, char:5\t%d,%d\n", ev.bf1, ev.bf2);
    printf("int :3, int :5\t%d,%d\n", fv.bf1, fv.bf2);
    printf("char:3, char:6\t%d,%d\n", gv.bf1, gv.bf2);

    printf("pivot: %d, %d, %d, %d, %d\n", offsetof(C, p), offsetof(D, p), offsetof(E, p), offsetof(F, p), offsetof(G, p));
    return 0;
}

部分运行结果(整理后,并非按照程序原始输出)

大致整理的格式如下:

平台/编译器:
    pivot: 加入 pivot 后的结构体大小 (pivot 的 offset)
    none: 不加 pivot 的结构体大小
Apple clang-1001.0.46.4:
    pivot: 8 12 4 4 2 4 3 (1 1 1 1 2)
    none: 8 12 4 4 1 4 2
Ubuntu Server gcc-7.4:
    pivot: 8 12 4 4 2 4 3 (1 1 1 1 2)
    none: 8 12 4 4 1 4 2
Win10 gcc-8.1.0:
    pivot: 8 12 12 8 2 8 3 (8 5 1 4 2)
    none: 8 12 8 8 1 4 2
Win10 gcc-tdm:
    pivot: 8 12 8 8 2 8 3 (5 5 1 4 2)
    none: 8 12 8 8 1 4 2
gcc-4.8.5:
    pivot: 8 12 4 4 2 4 3 (1 1 1 1 2)
    none: 8 12 4 4 1 4 2
Ubuntu Server gcc-7.3
    pivot: 8 12 4 4 2 4 3 (1 1 1 1 2)

标签: cpp, c, 编译原理

添加新评论