张银峰的编程课堂

结构体大小

在数据的底层表达中,我们可以使用比特位来压缩空间的使用;在上层设计中,更多的抽象概念通常由结构体来表达,在这一层,我们也可以通过合理的安排数据成员的布局,来减少空间的使用。

偏移地址

在引入所有内容之前,我们首先需要掌握偏移这个概念,这里我们从打印字段的地址来开始。

#include <stdio.h>

int main()
{
    struct S1 { char  a, b, c; } s1;
    struct S2 { short a, b, c; } s2;
    struct S3 { int   a, b, c; } s3;

    printf("OP\t S1\t\t S2\t\t S3\n");
    for (int i = 50; i >= 0; putchar(i-- ? '=' : '\n'));
    printf("&s\t %p\t %p\t %p\n", &s1,   &s2,   &s3);
    printf("sizeof\t %zd\t\t %zd\t\t %zd\n", sizeof(s1), sizeof(s2), sizeof(s3));
    printf("&a\t %p\t %p\t %p\n", &s1.a, &s2.a, &s3.a);
    printf("&b\t %p\t %p\t %p\n", &s1.b, &s2.b, &s3.b);
    printf("&c\t %p\t %p\t %p\n", &s1.c, &s2.c, &s3.c);

    return 0;
}

glimix.com

glimix.com

glimix.com

这里展示了程序三次不同的输出结果,通过观察我们可以得到这些结论:

  • 结构体变量起始于一个偶数地址
  • 第一个成员的地址就是结构体变量的地址
  • 成员变量的地址按照其定义的顺序从低到高存储

如果将结构体变量的地址看作起点,由于第一个数据成员的地址与它相同,两者距离为0,因此我们可以说:第一个数据成员在结构体中的偏移地址为0。

这个示例程序比较特殊,因为每个结构体的成员都是同一类型的,我们以此为探索起点,看看能否找出成员地址之间的关系。对于S1其成员a存储在偏移为0的位置,因为每个成员仅占一个字节,所以b存储在偏移为1的地址处,c则位于偏移为2的地址处。对于每个成员都是short类型的S2,a位于偏移地址0处,因为a要占用2个字节,因此b位于偏移地址2处,c位于偏移地址4处,最后S3与它们的情形相似。

成员布局

接下来我们看看结构由不同类型的组成的情况。

#include <stdio.h>

int main()
{
    struct S1 { char a; short b; } s1;
    struct S2 { char a; int b; } s2;
    struct S3 { char a; double b; } s3;
    struct S4 { int  a; float b; } s4;

    printf("OP\t S1\t\t S2\t\t S3\t\t S4\n");
    for (int i = 65; i >= 0; putchar(i-- ? '=' : '\n'));
    printf("sizeof\t %zd\t\t %zd\t\t %zd\t\t %zd\n", sizeof(s1), sizeof(s2), sizeof(s3), sizeof(s4));
    printf("&a\t %p\t %p\t %p\t %p\n", &s1.a, &s2.a, &s3.a, &s4.a);
    printf("&b\t %p\t %p\t %p\t %p\n", &s1.b, &s2.b, &s3.b, &s4.b);

    return 0;
}

glimix.com

glimix.com

从两次运行结果看,成员b并不一定会紧跟a而存储于其后,不过它们之间仍是有迹可循的。我们从S1分析:成员a位于偏移地址0处,a是char类型,占用1个字节,下一个偏移地址是1,这个地址不是下一个成员b的大小sizeof(short)的倍数,即1 % 2 != 0,而接下来的偏移地址2则是sizeof(short)的整数倍,从图上看,b恰好存储于此处,对应的实际位置就是0x004FF830 + sizeof(short) = 0x004FF832;观察s2、s3也会有同样的定论;对于S4,在存储完a之后,下一个偏移地址4,恰好是sizeof(float)的整数倍,所以b紧随其后,由此我们可以得出:

下一个数据成员存储于前一个成员之后,距结构体偏移地址是其大小整数倍的位置处。

这表示不同类型的成员之间可能有空隙,如S1 char-short有1个字节的空隙;而S4 int-float之间却没有,编译器之所以这么安排是为了让CPU更高效的访问数据,这种安排称为字节对齐,这使得结构体的大小并不是所有数据成员大小的和。如S1的理论大小是3字节,而实际大小是4字节。

#include <stdio.h>

int main()
{
    struct S1 { char a; int b; char c; double d; } s1;
    struct S2 { int a; double b; char c; short d; } s2;
    struct S3 { double a; char b; int c; short d; } s3;

    printf("OP\t S1\t\t S2\t\t S3\n");
    for (int i = 50; i >= 0; putchar(i-- ? '=' : '\n'));
    printf("sizeof\t %zd\t\t %zd\t\t %zd\n", sizeof(s1), sizeof(s2), sizeof(s3));
    printf("&a\t %p\t %p\t %p\n", &s1.a, &s2.a, &s3.a);
    printf("&b\t %p\t %p\t %p\n", &s1.b, &s2.b, &s3.b);
    printf("&c\t %p\t %p\t %p\n", &s1.c, &s2.c, &s3.c);
    printf("&d\t %p\t %p\t %p\n", &s1.d, &s2.d, &s3.d);

    return 0;
}

glimix.com

00 01 02 03 04 05 06 07 08 09 0A 0B 0C 0D 0E 0F 10 11 12 13 14 15 16 17
50 51 52 53 54 55 56 57 58 59 5A 5B 5C 5D 5E 5F 60 61 62 63 64 65 66 67
a0 -- -- -- b0 b1 b2 b3 c0 -- -- -- -- -- -- -- d0 d1 d2 d3 d4 d5 d6 d7

这里参考图中的输出结果,给出了S1数据成员的布局,其中

  • 行1:偏移地址
  • 行2:实际的内存地址,如 50 就代表 0x0053F8-50
  • 行3:数据成员在内存中的位置,多字节成员分解为多个独立单字节。

这里比较夸张的是成员c与d之间的间隙。c位于结构体偏移地址08处,下一个数据成员类型是double,它存储于c之后,偏移能整除sizeof(double)的位置处,即相对于c的偏移08+8=1,实际内存为0x0053F8-60的地方,终止于0x0053F8-67处。

我们再来看一下S2中a与b的排列:

00 01 02 03 04 05 06 07 08 09 0A 0B 0C 0D 0E 0F
30 31 32 33 34 35 36 37 38 39 3A 3B 3C 3D 3E 3F
a0 a1 a2 a3 -- -- -- -- b0 b1 b2 b3 b4 b5 b6 b7

a占用4个字节,b是double类型,下一个能整除sizeof(double)的偏移地址是08,即实际地址的0x0053F8-38,这里int-double之间有4个字节的对齐间隙。

我们再来看一下S3中c与d的排列:

00 01 02 03 04 05
1C 1D 1E 1F 20 21
c0 c1 c2 c3 d0 d1

因为c之前的数据已经被布局了,所以我们可以将c的起始位置看作偏移0,下一个能整除sizeof(short)的偏移地址是02,但是c要占用4个字节,即 00 01 02 03 这个4字节是分配给c的,而d是要存储在c之后的,所以下一个能整除sizeof(short)的偏移地址就是04,即实际地址为0x0053F820。

结构体大小

现在我们看看经过对齐后,各个结构体纸面上的大小(占用字节数):

  • S1 = 0x0053F867 - 0x0053F850 + 1 = 24
  • S2 = 0x0053F843 - 0x0053F830 + 1 = 20
  • S3 = 0x0053F821 - 0x0053F810 + 1 = 18

可以看到,对齐后的结构体大小,并不一定等于实际结构体的大小,两者间存在一个小于等于的关系,也就是说,在对齐成员后,整个结构体还需要占用一些额外空间,以期达到某个期望值;这个期望值就是,能容纳成员对齐后的整个结构体,结构体最大成员的最小整数倍(我写这句话时,都感觉好难组织)。

结构体的总大小,就是能容纳所有对齐后成员,内部最大成员的最小整数倍。

上面的三个结构体,最大的成员都是double类型,S1成员对齐后是24字节,sizeof(double)3=24,恰好能够容纳S1的所有数据成员,因此S1的大小就是24字节;S2成员对齐后是20个字节,sizeof(double)3=24,是最小的能容纳20的值,所以S2的大小就是24,此时整个结构额外的在最后一个数据成员后填充了4个字节;S3同理S2。

现在我们看看结构体嵌套的情况。

#include <stdio.h>

int main()
{
    struct S0 { double v[3]; };
    struct S1 { char a; struct S0 b; } s1;
    struct S3 { char a; struct S0 b[2]; } s2;
    struct S2 { char a; struct S0 b[3]; } s3;

    printf("OP\t S1\t\t S2\t\t S3\n");
    for (int i = 50; i >= 0; putchar(i-- ? '=' : '\n'));
    printf("sizeof\t %zd\t\t %zd\t\t %zd\n", sizeof(s1), sizeof(s2), sizeof(s3));
    printf("&a\t %p\t %p\t %p\n", &s1.a, &s2.a, &s3.a);
    printf("&b\t %p\t %p\t %p\n", &s1.b, &s2.b, &s3.b);

    return 0;
}

glimix.com

结构体S0的大小是24个字节,从上面的结论看,S1的大小应该是“内部最大成员的最小整数倍”,即S1的大小是sizeof(struct S0)*2=48,成员b应该存储在偏移24的位置,但从输出上看并非这样。是不是我们上面的结论错了?不是!从输出看,内嵌的结构体第一个成员在偏移地址8处,也就是一个double的偏移,如果我们将S0在S1中展开,刚好如此。

/** 在S1中展开S0 */
struct S1
{
    char a;
    double v0;
    double v1;
    double v2;
};

现在一眼便能看出S1的大小是32字节。以同样的角度看,S2相当于7个double,所以是56字节;而S3则相当于10个double,即80个字节。所以结论所描述的“内部最大成员的最小整数倍”是指这些基础类型中那个最大的。想想看,这也是合理的,如果仅仅是因为S1多了一个char,为此就要将成员b存储到偏移24处,白白浪费了23个字节!如果S0的成员v是一个有100个double的数组,那S1岂不是要占用sizeof(double)*100*2=1600个字节!

那么这是不是说明,被内嵌结构体的话,其成员起始于偏移8的倍数位置?非也!

#include <stdio.h>

int main()
{
    struct SA { char v[3]; };
    struct SB { int v[3]; };
    struct SC { double v[3]; };
    struct S1 { char a; struct SA b; } s1;
    struct S3 { char a; struct SB b[2]; } s2;
    struct S2 { char a; struct SC b[3]; } s3;

    printf("OP\t S1\t\t S2\t\t S3\n");
    for (int i = 50; i >= 0; putchar(i-- ? '=' : '\n'));
    printf("sizeof\t %zd\t\t %zd\t\t %zd\n", sizeof(s1), sizeof(s2), sizeof(s3));
    printf("&a\t %p\t %p\t %p\n", &s1.a, &s2.a, &s3.a);
    printf("&b\t %p\t %p\t %p\n", &s1.b, &s2.b, &s3.b);

    return 0;
}

glimix.com

这里SA的大小是3字节,最大成员类型是char,整个结构体按1字节对齐,因此在S1布局完a后,以1字节大小对齐b,即内嵌的结构体b的成员从偏移1处存储,因此b的首元素开始于0x008FFD14+1=0x008FFD15处。SB的大小是12个字节,最大成员类型是int,按4字节对齐,因此在S2中,b的首元素开始于0x008FFCF0+4=0x008FFCF4处。

这就是说,对于内嵌的结构体,其首个成员开始于其内部最大成员的整数倍偏移地址处。

我们再试着验证一下。

#include <stdio.h>

int main()
{
    struct SA { char x; int y; double z[3]; };
    struct SB { int x; float y[3]; char z; char u; int v; };
    struct S1 { char a; struct SA sa; } s1;
    struct S2 { char a; struct SB sb; } s2;
    struct S3 { char a; struct SA sa; struct SB sb; } s3;

    printf("SA size \t %zd\n", sizeof(struct SA));
    printf("SB size \t %zd\n", sizeof(struct SB));
    printf("S1 size \t %zd\n", sizeof(struct S1));
    printf("S2 size \t %zd\n", sizeof(struct S2));
    printf("S3 size \t %zd\n", sizeof(struct S3));

    printf("\n");
    printf("&s1.a    \t %p\n", &s1.a);
    printf("&s1.sa.x \t %p\n", &s1.sa.x);

    printf("\n");
    printf("&s2.a    \t %p\n",  &s2.a);
    printf("&s2.sb.x \t %p\n",  &s2.sb.x);

    printf("\n");
    printf("&s3.a    \t %p\n", &s3.a);
    printf("&s3.sa.x \t %p\n", &s3.sa.x);
    printf("&s3.sb.x \t %p\n", &s3.sb.x);

    return 0;
}

glimix.com

SA的最大成员是double类型,因此其首元素会被安排在能整除8的偏移地址处。因此在S1中sa起始于0x00DFFAB8+8=0x00DFFAC0处;SB的最大成员是float类型,因此在S2中sb起始于0x00DFFA94+4=0x00DFFA94处;S3中的SA结束于0x00DFFA6C+7=0x00DFFA73,将0x00DFFA6C看作偏移0,存储完s3.sa.z[2]后,下一个偏移地址是8,刚好是SB的最大类型的整数倍,因此sb起始于偏移8处,即0x00DFFA6C+8=0x00DFFA74处。

优化排列

现在我们看看同样的数据表达,成员排列顺序对结体大小的影响。

struct S1 { char x; int y; char z; };
struct S2 { char x; char y; int z; };

S1共计12个字节,在内存中布局如下:

00 01 02 03 04 05 06 07 08 09 0A 0B 0C 0D 0E 0F 10
x0 -- -- -- y0 y1 y2 y3 z0 -- -- --

S2共计8个字节,在内存中布局如下:

00 01 02 03 04 05 06 07 08 09 0A 0B 0C 0D 0E 0F 10
x0 y0 -- -- y0 y1 y2 y3

可以看到,有效的安排数据成员的顺序可以减少内存花销。