结构体大小
在数据的底层表达中,我们可以使用比特位来压缩空间的使用;在上层设计中,更多的抽象概念通常由结构体来表达,在这一层,我们也可以通过合理的安排数据成员的布局,来减少空间的使用。
偏移地址
在引入所有内容之前,我们首先需要掌握偏移这个概念,这里我们从打印字段的地址来开始。
#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;
}



这里展示了程序三次不同的输出结果,通过观察我们可以得到这些结论:
- 结构体变量起始于一个偶数地址
- 第一个成员的地址就是结构体变量的地址
- 成员变量的地址按照其定义的顺序从低到高存储
如果将结构体变量的地址看作起点,由于第一个数据成员的地址与它相同,两者距离为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;
}


从两次运行结果看,成员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;
}

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

结构体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;
}

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

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
可以看到,有效的安排数据成员的顺序可以减少内存花销。