9.结构体内存对齐
我们已经掌握了结构体的基本使用了。
现在我们深入讨论一个问题:
计算结构体的大小。
这也是一个特别热门的考点: 结构体内存对齐
首先我们先来探讨一个问题,大家思考一下下面这两个结构体S1,S2的大小是多少?
#include <stdio.h> struct S1 { char c1; int i; char c2; }; struct S2 { char c1; char c2; int i; }; int main() { printf("%d\n", sizeof(struct S1)); printf("%d\n", sizeof(struct S2)); return 0; }
先给大家说一下我在第一次看到这个问题时是怎么算的吧。
我想的是,结构体包含的所有成员的大小之和就是结构体的总大小。
首先是S1,三个成员变量,第一个c1是char类型,1个字节,第二个i是int类型,4个字节,第三个c2也是char类型,1个字节。所以S1的大小为1+4+1=6个字节。
那S2的话成员和S1一模一样,只是顺序不同,所以大小也应该是6个字节。
是这样吗?
我们来看一下运行结果到底是多少?
结果是12和8,我们算错了,那说明像上面那样计算是不对的。
那结构体的大小到底要怎么计算才是正确的呢?
要解决这个问题,我们就需要掌握——结构体内存对齐。
9.1内存对齐规则
我们接着讨论上面计算结构体大小的问题。
就拿S1来说,如果我们用struct S1创建一个结构体变量s(当然s的大小和结构体类型struct S1的大小肯定是一样的),那内存肯定要为s分配空间,我们简单画一个图。
那s的成员要怎么往内存里边存放呢?成员c1,i,c2分别应该放在那个位置呢?为什么s的最终大小是12个字节呢?
这就需要我们了解一下结构体内存对齐的规则:
规则1
结构体的第一个成员直接对齐到相对于结构体变量起始位置偏移量为0的地址处
这里解释一下什么是偏移量:
偏移量,计算机汇编语言,是指把存储单元的实际地址与其所在段的段地址之间的距离称为段内偏移,也称为“有效地址或偏移量”。
这里规则1中说的是相对于结构体起始位置的偏移量,画个图
大家就明白了:
那我们怎么知道结构体每个成员相对于起始位置的偏移量呢?
对于s来说,根据规则1我们知道第一个成员c1直接对齐到对于起始位置偏移量为0的地址处,那i和c2呢,它们的偏移量又如何得知呢?
这里给大家介绍一个宏——offsetof。
offsetof可以用来计算结构体中的成员相对于结构体起始位置的偏移量。
我们来简单认识一下,它在cplusplus.com也可以查询到:
注意需要包含头文件#include
那我们现在就可以借助offsetof来看一下i和c2的偏移量:
int main() { printf("%d\n", offsetof(struct S1, c1)); printf("%d\n", offsetof(struct S1, i)); printf("%d\n", offsetof(struct S1, c2)); return 0; }
好的,是0,4,8,那知道了偏移量,我们就知道i和c2应该放在什么位置了。
好,现在我们已经知道s的成员在内存中的存放了,那s的大小是12个字节,这样的话图中红色空间的6个字节根本没有使用,不是白白被浪费掉了嘛。
那既然这样放了,就一定有它的原因,这些都是我们接下来要探讨的问题。
规则2
从第二个成员变量开始,要对齐到当前对齐数的整数倍的偏移处
那这里又出现一个新的概念——对齐数。
对齐数:结构体成员自身大小与当前环境下默认对齐数中的较小值。
这里我们使用的是vs2022。
vs环境下:默认对齐数为8。
Linux环境下:无默认对齐数,对齐数取结构体成员自身大小。
那现在根据第二条规则,我们就可以计算i和c2应该对齐到那个位置了。
先看i:
对于i来说,i的类型为int,4个字节,而当前环境(vs)下的默认对齐数是8,4<8,所以对于i来说,对齐数就是4。
而i的前面,c1放到了偏移量为0的位置,而且之占了1个字节,所有0后面,偏移量为1,2,3,4…处都可以用。
但根据规则i应该放到偏移量为4的位置,因为0之后第一个4个倍数就是4。
这也与我们之前看到的结果一致。
接着我们看c2:
i放到偏移量为4的位置,i为为int,占4个字节(4,5,6,7),那后面偏移量为8,9…的位置都是可用的。
对于c2来说,char类型,自身大小1个字节,默认对齐数8,1<8,所以c2的对齐数是1,那8就是1的整数倍啊,所以c2放到偏移量为8的位置就行。
这样一分析,我们就知道为什么成员c1,i,c2的偏移量是0,4,8了。
那现在又有一个问题,s的最后一个成员c2放在偏移量为8的位置,而且只占1个字节,那为什么结构体s的总大小为12个字节呢?
要解决这个问题,我们来看内存对齐的第3条规则:
规则3
结构体的总大小,必须是最大对齐数(即所有成员变量的对齐数中的最大值)的整数倍
s的3个成员c1,i,c2。
i,c2的对齐数我们上面计算过了,是1,4。c1大小跟c2一样,所以c1的对齐数应该也是1。
那它们之中最大的对齐数就是4,所以结构体s的大小应该是4的整数倍。
而最后一个成员c2放在了偏移量为8的位置,从0到8,已经占用了9个字节,再往后第一个4的倍数就是12,所以s的最终大小是12。
即偏移量是从0到11。
这与我们上面得到的结果是一致的。
s是用struct S1创建的,所以大小就是12。
下面我们再来看一个结构体,试着计算一下它的大小是多少:
struct S3 { double d; char c; int i; }; struct S4 { char c1; struct S3 s3; double d; }; int main() { printf("%d\n", sizeof(struct S3)); printf("%d\n", sizeof(struct S4)); return 0; }
大家思考一下,struct S4的大小应该是多少呢?
我们看到,struct S4里面的一个成员是用struct S3创建的一个结构体变量s3。
那我们先来计算一下s3的大小和每个成员的对齐数吧,正好练习一下:
直接上图吧:
那接下来我们是不是应该思考一下,被嵌套的这个结构体s3,它应该对齐到哪个位置呢?
要解决这个问题,就需要我们了解第4条规则了。
规则4
对于嵌套结构体的情况,嵌套的结构体需要对齐到自己的最大对齐数的整数倍处,结构体的总大小是最大对齐数(含被嵌套结构体的对齐数)的整数倍。
好,那知道了第4条规则,我们就来计算一下struct S4的大小:
是32字节吗?我们来验证一下:
两个大小我们算的都是正确的。
9.2为什么存在内存对齐?
现在我们已经知道了内存对齐的规则,那大家有没有想过一个问题:
为什么要规定一些这么复杂的规则去搞这个内存对齐呢?
直接让结构体的成员紧挨着在内存中存放不行吗,搞一个内存对齐,不仅计算结构体大小麻烦,而且还浪费空间,有什么用啊?
那么,既然它存在,就一定又它的道理,那到底是因为什么呢?
大部分的参考资料都是如是说的:
平台原因(移植原因):
不是所有的硬件平台都能访问任意地址上的任意数据的;某些硬件平台只能在某些地址处取某些特定类型的数据,否则抛出硬件异常。
性能原因:
数据结构(尤其是栈)应该尽可能地在自然边界上对齐。
原因在于,为了访问未对齐的内存,处理器需要作两次内存访问;而对齐的内存访问仅需要一次访问。
总体来说:
结构体的内存对齐是拿空间来换取时间的做法。
9.3如何设计结构体
我们回过头来看最开始我们计算大小的两个结构体:
#include <stdio.h> struct S1 { char c1; int i; char c2; }; struct S2 { char c1; char c2; int i; }; int main() { printf("%d\n", sizeof(struct S1)); printf("%d\n", sizeof(struct S2)); return 0; }
我们发现,这两个结构体的成员变量是一样的,只是顺序不同,但是,它们的大小却相差了4个字节。
我们知道,这时内存对齐造成的。
那现在有一个问题:
如何设计结构体,可以既满足对齐,又节省空间?
对比struct S1和struct S2可以发现,S2的大小比S1小了4个字节,而S2与S1的不同之处在于S2中占用空间小的成员放在了一起。
所以,在设计结构体时,我们要:
让占用空间小的成员尽量集中在一起。
9.4 修改默认对齐数
我们已经知道了在vs环境下,默认的对齐数是8,那如果我们想修改这个默认对齐数,能不能做到呢?
是可以的!
使用 #pragma 这个预处理指令,可以修改默认对齐数。
那怎么使用呢?
#pragma pack(n) ——设置默认对齐数为n
#pragma pack()——取消设置的默认对齐数,还原为默认
末尾不需要加分号(;)
#include <stdio.h> #pragma pack(1)//设置默认对齐数为1 struct S2 { char c1; int i; char c2; }; #pragma pack()//取消设置的默认对齐数,还原为默认 struct S4 { char c1; int i; char c2; }; int main() { printf("%d\n", sizeof(struct S2)); printf("%d\n", sizeof(struct S4)); return 0; }
我们计算两个结构体类型的大小,它们的成员变量包括顺序都是一模一样的,唯一的区别在于:
我们用#pragma pack(1)将设置默认对齐数为1,然后声明了struct S2类型;
又用#pragma pack()取消修改的默认对齐数,还原为默认值,然后声明了struct S4类型。
这两个结构体类型声明时对应的默认对齐数是不同的,所以大小计算出来也应该时不一样的。
我们打印出来看看:
确实如此。
大家可以计算一下,它们的大小和修改前后的对齐数时相对应的。
结论:
结构体在对齐方式不合适的时候,我么可以自己更改默认对齐数。
10.结构体传参
我们之前在学习函数的时候,知道函数调用有两种方式——传值调用和传址调用。
那我们将结构体作为函数参数进行传参也是这样:
1.传值调用:直接将结构体变量作为实参传递给形参,形参将是实参的一份临时拷贝。
2.传址调用:将结构体变量的地址作为实参传递给形参,用一个结构体指针接收,传址调用可以通过形参改变结构体变量的值,而传值调用不能。
上代码:
#include <stdio.h> struct S { int data[1000]; int num; }; //传值 void print1(struct S s) { printf("%d\n", s.num); } //传址 void print2(struct S* ps) { printf("%d\n", ps->num); } int main() { struct S s = { {1,2,3,4}, 1000 }; print1(s); //传结构体 print2(&s); //传地址 return 0; }
函数print1为传值调用,函数print2为传址调用。
思考一个问题,上面的 print1 和 print2 函数哪个好些?
首选print2函数。
为什么呢?
原因是:函数传参的时候,参数是需要压栈,会有时间和空间上的系统开销。
如果传递一个结构体对象的时候,结构体过大,参数压栈的的系统开销比较大,所以会导致性能的下降。
而如果我们传的是地址,地址无非就是4或8个字节,那参数压栈的开销就比较小,效率可能就会提高很多。
因此,我们得出结论:
结构体传参的时候,传结构体的地址比较好。
好了,以上内容就是对结构体的一个详细讲解,欢迎大家指正!!!