8 结构体内存对齐
8.1 问题引入
到这里,结构体的基础知识我们基本了解了。
但是结构体的大小如何计算?这我们就不得而知了,看一个样例:
struct S1 { char c1;//1 int i;//4 char c2;//1 }; struct S2 { char c1; char c2; int i; }; int main() { struct S1 s1; struct S2 s2; printf("%d\n", sizeof(s1));//12 printf("%d\n", sizeof(s2));//8 return 0; }
按照我们平时的想法,这两个结构体成员相同,那么就是1+4+1=6了吗?让我们运行一下:
8.2 offsetof
我们发现结果和我们的想法截然不同,这是为什么?
在解答之前我们先了解两部分,先介绍第一部分:offsetof
:
size_t offsetof( structName, memberName );
- structName:结构体类型的名称
- memberName:结构体成员名
计算结构体成员相对于起始位置的偏移量
让我们先计算一下S1每个成员的偏移量:
#include <stddef.h>//所需头文件 struct S1 { char c1; int i; char c2; }; int main() { printf("%u\n", offsetof(struct S1, c1)); printf("%u\n", offsetof(struct S1, i)); printf("%u\n", offsetof(struct S1, c2)); return 0; }
运行结果:
根据这个偏移量,我们假设一个位置为起始位置,画出它的内存分布图:
而其中1~3的内存单位是被浪费的,且根据大小为12。9,10,11三个位置也是被浪费的。这是什么原因?看下一部分↓
8.3 结构体的内存对齐
要说这里的原理,就要讲讲结构体的内存对齐:
第一个成员在与结构体变量偏移量为0的地址处。
其他成员变量要对齐到某个数字(对齐数)的整数倍的地址处。
对齐数 = 编译器默认的一个对齐数 与 该成员大小的较小值。
VS中默认的值为8
结构体总大小为最大对齐数(每个成员变量都有一个对齐数)的整数倍。
如果嵌套了结构体的情况,嵌套的结构体对齐到自己的最大对齐数的整数倍处,结构体的整体大小就是所有最大对齐数(含嵌套结构体的对齐数)的整数倍。
通过这些规则,我们再重新看一下S1:
分析:
c1为结构体第一个成员,在结构体变量偏移量为0的地址处,占用1个字节。
i的大小为4,默认对齐数为8,取其较小值为4。对齐到4的倍数处,也就是偏移为4的位置,占用4个字节,1,2,3三个字节被浪费。
c2的大小为1,默认对齐数为8,取其较小值对为1。对齐到1的倍数处,也就是偏移为8的位置,占用1个字节。
结构体的总大小为最大对其数的整数倍。c1,i,c2的对齐数分别为1,4,1。结构体大小为4的倍数,当前结构体所占空间大小为9字节,要为4的倍数,则大小为1字节2,9,10,11三个字节被浪费。
这样就解释了为什么S1的大小为什么是12!我们接着看S2:
struct S1 { char c1; char c2; int i; };
分析:
c1为结构体第一个成员,在结构体变量偏移量为0的地址处,占用一个字节。
c2的大小为1,默认对齐数为8,取其较小值为1。对齐到1的倍数处,也就是偏移为1的位置,占用1个字节。
i的大小为4,默认对齐数为8,取其较小值为4。对齐到4的倍数处,也就是偏移为4的位置,占用4个字节。2,3两个字节被浪费。
结构体的总大小为最大对其数的整数倍。c1,c2,i的对齐数分别为1,1,4。结构体大小为4的倍数,直接为当前结构体所占空间大小:8字节。
8.4 小试牛刀
自己试着计算两个结构体的大小并描述内存分布和画出内存分布图:
题1:
struct S3 { double d; char c; int i; };
分析:
d为结构体第一个成员,在结构体变量偏移量为0的地址处,占用8个字节。
c的大小为1,默认对齐数为8,取其较小值为1。对齐到1的倍数处,也就是偏移为8的位置,占用1个字节。
i的大小为4,默认对齐数为8,取其较小值为4。对齐到4的倍数处,也就是偏移为12的位置,占用4个字节。9,10,11三个字节被浪费。
结构体的总大小为最大对其数的整数倍。d,c,i的对齐数分别为8,1,4。结构体大小为8的倍数,当前结构体当前所占空间大小为16字节,为当前大小。
运行结果:
题2:
struct S4 { char c1; struct S3 s3; double d; }//结构体嵌套情况下,结果是多少?
分析:
结构体嵌套结构体,这时就要用到我们的第四条规则:
如果嵌套了结构体的情况,嵌套的结构体对齐到自己的最大对齐数的整数倍处,结构体的整体大小就是所有最大对齐数(含嵌套结构体的对齐数)的整数倍。
c1为结构体第一个成员,在结构体变量偏移量为0的地址处,占用1个字节。
s3为结构体第二个成员,为嵌套的结构体,对齐到自己的最大对齐数的整数倍处,s3的最大对齐数我们在上面算过,为8,那么就对齐到8字节处,上面1~7字节被浪费。s3占用16个字节。
d为结构体第三个成员,默认对齐数为8,自身大小为8,所以对齐到8的倍数处,对齐到24字节处,占用8个字节。
结构体总大小为所有最大对其数的整数倍处。c1,s3,d最大对齐数为1,8,8。对齐到8的倍数处,结构体当前所占空间大小为32字节,为8的倍数,所以结构体大小为32字节。
运行结果:
8.5 为什么存在内存对齐?
平台原因(移植原因):
不是所有的硬件平台都能访问任意地址上的任意数据的;某些硬件平台只能在某些地址处取某些特定类型的数据,否则抛出硬件异常。
性能原因:
数据结构(尤其是栈)应该尽可能地在自然边界上对齐。原因在于,为了访问未对齐的内存,处理器需要作两次内存访问;而对齐的内存访问仅需要一次访问。
下面对第二条原因做出一定解释:
假设一次读取4个字节的数据,要读取到内存中的i。
在不考虑内存对齐的情况下,需要读取两次,从c开始读,第一次读取i的三个字节,第二次读取剩余的一个字节。
而在考虑内存对齐的情况下,需要读取一次,直接从i开始读,读取i的四个字节。
总体来说:
结构体的内存对齐是拿空间换取时间的做法。
8.6 设计结构体的细节
如何在设计结构体时,既满足对齐,又要节省空间?
让占用空间小的成员尽量集中在一起。
这样,浪费的字节也就少了。并且,当成员集中到一定程度时,说不定就正好放置到下一个元素的对齐位置上方,让空间最大程度上得到利用。
例如:
struct S1 { char c1; int i; char c2; }; //更好 struct S2 { char c1; char c2; int i; };
S1和S2类型的成员一模一样,但是S1和S2所占空间的大小有了一些区别,S2大小比S1小。因为S2把占用空间小的成员集中在一起。
8.7 如何修改默认对齐数
之前我们见过了#pragma
这个预处理指令,这里我们再次使用,可以改变我们的默认对齐数。
#pragma pack(4)//设置默认对齐数为4 #pragma pack()//恢复默认对齐数
我们不妨设想一下,如果将默认对齐数设置为1,结构体的大小会是多少:
#pragma pack(1) struct S1 { char c1;//从0开始对齐 int i;//4 1 对齐数为1,对齐到1位置处 char c2;//1 1 对齐数为1,从5开始对齐 //最大对齐数为1,所以结构体大小为1的倍数即可 //6,其实也就是没对齐 }; #pragma pack() int main() { printf("%d\n", sizeof(struct S1));//6 return 0; }
c1为结构体第一个成员,在结构体变量偏移量为0的地址处,占用1个字节。
i的大小为4,默认对齐数为1,取其较小值为1。对齐到1的倍数处,也就是偏移为1的位置,占用4个字节。
c2的大小为1,默认对齐数为1,取其较小值为1。对齐到1的倍数处,也就是偏移为5的位置,占用1个字节。
结构体的总大小为最大对其数的整数倍。c1,i,c2的对齐数分别为1,1,1。结构体大小为1的倍数,所以不需要调整结构体的大小,直接为当前大小,为6个字节。
相当于对齐了个寂寞~
运行结果:
但是需要注意的是:
虽然支持这样修改默认对齐数,但是也不要胡乱修改,一般默认对齐数修改为2^n,机器在读取时,读取的字长为4/8个字节,尽量朝着适合读写的方法来设定。但是当结构体在对齐方式上不合适的时候,我们可以自己更改默认对齐数。
9. 结构体传参
struct S { int data[1000]; int num; }; struct S s = {{1,2,3,4}, 1000}; //结构体传参 void print1(struct S s) { printf("%d\n", s.data[0]);//结构体变量.结构体成员访问结构体成员 } //结构体地址传参 void print2(struct S* ps) { printf("%d\n", (*ps).data[0]);//*ps访问到结构体,结构体变量.操作符访问成员 printf("%d\n", ps->data[0]);//结构体指针->结构体成员访问成员 } int main() { struct S ss = { { 1, 2, 3, 4, 5 }, 100 }; print1(ss); //传结构体 print2(&ss); //传地址 return 0; }
运行结果:
两个函数的作用是相同的,但是上面的print1和print2函数哪个好?
在结构体成员的访问部分中,我们是通过print函数对结构体成员进行访问并打印的,而这两种传参方式截然不同,一个为结构体变量ss(传值调用),一个为结构体变量的地址&ss(传址调用)。
那么这两种传参方式哪个更好呢?当然是第二种方式,传址调用的方式。
可能大家可能会觉得print1比较好,原因是print2可能可以通过结构体指针改变结构体的内容,但是这完全可以避免,只需要对*ps加上const修饰,便可避免这种情况。
认为第二种方法更优的原因还因为:
结构体传参时,若实参为结构体变量,那么就要创建变量的一份临时拷贝,需要大量的空间,而实参为结构体指针的话,形参的大小为4/8个字节,大大节省了空间。
而函数传参的时候,参数是需要压栈的。
如果传递一个结构体对象的时候,结构体过大,参数压栈的系统开销比较大,所以会导致性能的下降。
简单来说若结构体空间过大,在压栈时需要使用大量的空间,不仅浪费了空间,更浪费了时间!
结论:结构体传参时,要传结构体的地址。
注:结构体传值时,实参结构体的地址可能和形参结构体的地址相同,编译器可能不会创建临时空间,自己进行了优化,我们使用的空间依然可能是实参的空间,为了避免这些乱七八糟的优化,我们还是选择传址调用~
10. 结语
到这里,本篇博客到此结束。相信通过这篇博客,大家对结构体也有了一定的认识。而在下篇博客中,我将利用结构体的知识,进行简易通讯录的实现,更多精彩内容,敬请期待!