书接上回,前面我们已经给大家介绍了如何去声明和创建一个结构体,如何初始化结构体变量等这些关于结构体的基础知识。下面我们将继续给大家介绍和结构体有关的知识:
今天的主题是:结构体大小的计算并简单了解一下位段的相关知识。
目录
一、结构体的内存对齐
(1)发现并提出问题:
(2)内存对齐规则:
(3)解决问题:
附一:offsetof宏的介绍:
(4)为什么会存在结构体的内存对齐:
附二:带来的启发:
附三:修改编译器的默认对齐数:
附四:关于前面问题的解答:
二、关于位段:
(1)搭建应用场景:
(2)位段的概念性理解:
(3)位段的内存分配:
学习指南:如果你是期末备考的大学生,简单学习博主的结构体类型(上)的内容就够了。如果你对自己有更高的要求,希望系统深入了解结构体知识可以继续跟上博主的步伐啦!
一、结构体的内存对齐
(1)发现并提出问题:
如果要谈结构体大小的计算,就不得不提结构体的底层特性——内存对齐。什么是内存对齐呢?我们先来看一下,下面这两个例子:
#include<stdio.h>
struct S1
{
char c1;
char c2;
int i;
};
int main()
{
printf("%zd\n", sizeof(struct S1));
return 0;
}
这个结构体大小是多少呢?是不是 1 + 1 + 4(sizeof(char) + sizeof(char) + sizeof(int))的结果呢?,我们不妨在自己的机器上运行一下。
下面这张截图是在博主2022 Visual Studio这款IDE上运行的结果:
这个结果挺莫名奇妙的,我一时也想不清楚这个结果为啥是这个。嗯……我们现在不妨再来做一个事情,我们继续声明和创建一个S2的结构体,然后呢,这个结构体有和S1一样的成员变量(c1,c2,i)。然后只是改一下它们之间的顺序,我们来看一下这个结构体变量的大小又是多少。如图所示:
#include<stdio.h>
struct S1
{
char c1;
char c2;
int i;
};
struct S2
{
char c1;
int i;
char c2;
};
int main()
{
printf("%zd\n", sizeof(struct S1));
printf("%zd\n", sizeof(struct S2));
return 0;
}
运行截图:
这也太不可思议了,我仅仅是改了一下结构体各个成员变量的顺序,竟然就改变了整个结构体变量的大小。显然种种迹象均表明:结构体变量的大小并不是各个成员变量大小的简单相加。
那这具体是怎回事呢,欸,我们接着往下探索!
(2)内存对齐规则:
前面我们通过实验发现,结构体变量的大小不是其各个成员变量的简单相加。其实其本质的原因就在于有内存对齐的底层特性存在。
那关于结构体的内存对齐有哪些规则呢?这里博主总结了关于内存对齐这一块,大致有以下四条规则:
- 第一个成员在与结构体变量偏移量为0的地址处;
- 其他成员变量要对齐到某个数字(对齐数)的整数倍的地址处;
- 结构体的总大小为最大对齐数(每个成员变量都有一个对齐数)的整数倍;
- 如果嵌套了结构体,嵌套结构体要对齐到自己的最大对齐数的整数倍处,其他规则照旧。
上述规则中的一些概念,我们这里做简单的阐释。首先关于什么是对齐数:
对齐数 = Min(编译器默认的一个对齐数,成员变量自身的大小)
一般来说:结构体的每一个成员变量都有自己的一个对齐数,不同大小的成员变量一般有不同的对齐数,其中我们把一个结构体它所有成员变量中最大的那个对齐数,称之为最大对齐数。
特别地,如果成员变量是一个数组。那这里成员变量的大小,注意啦,不是指数组的大小,而是数组中单个元素的大小。我们下面以VS 2022为例子(编译器的默认对齐数是8),来说明这个问题:
struct S
{
char c; //8 < 1 —— 对齐数是1
int i; //8 < 4 —— 对齐数是4
char name[12];//8 < 12 ?No,No,No! 8 < 1 ——对齐数是1
};
另外关于编译器的默认对齐数,如果通过查阅相关编译器的技术文档,你能够查到该编译器的默认对齐数那当然再好不过了。但是如果说你实在查不到,那你就忽略这个编译器的默认对齐数(或者说假设它是无穷大)。如果说此时发现假设和实际结果不相一致,我们通过对结果进行分析,也可以推导出编译器的默认对齐数的大小。
那话说回来,我们还是希望大家记一些常见的关于编译器的默认对齐数:像VS系列都是8,然后gcc系列是无穷大,无穷大的另一种理解就是:对齐数 = 成员变量自身的大小。
(3)解决问题:
OK,当然啦,说了这么多,我们就可以分析和解决前面所提到的那个问题。我们这里假设是在VS 2022的环境下,也就是说编译器的默认对齐数是8。
#include<stdio.h>
struct S1
{
char c1;
char c2;
int i;
};
struct S2
{
char c1;
int i;
char c2;
};
int main()
{
printf("%zd\n", sizeof(struct S1));
printf("%zd\n", sizeof(struct S2));
return 0;
}
对于结构体struct S1而言,它的内存布局即为下图所示:
分析:
红色块是c1的内存空间,旁边的数字对应的是这个内存块相较于结构体起始地址的偏移量。
由于c1是第一个成员,因此偏移量为0。而且c1只需要1个字节的空间,综上红色块就是c1的内存空间。
绿色块是c2的内存空间,这是因为: 8(编译器默认对齐数)< sizeof(c2) == 1,因此对于c2来说它的对齐数就是1,也就是说c2内存的起始位置在偏移量为1的倍数的位置上,显然就要我们绿色块的位置上。
蓝色块是i的内存空间,这是因为:8(编译器默认对齐数)< sizeof(i) == 4,因此对于i来说它的对齐数就是4,偏移量为4的倍数的位置,恰好就是对应我们蓝色块的起始位置。然后往下延展4个字节,这是因为sizeof(i) == 4。
最后,显然最大对齐数就是4,此时c1 + c2 + i的内存大小是8,正好就是4的倍数,因此结构体的内存大小是8。
对于结构体struct S2而言,它的内存布局即为下图所示:
分析:
c1,i,c2的分析略,这里我们简单说一下结构体的大小分析。对于这个结构体我们不难推导出最大对齐数——4,然后此时c1 + i + c2的内存大小是9,9不是最大对齐数4的倍数,于是向下延展至12个字节,此时正好是4的倍数,由此结构体变量的大小是12。
如果你现在觉得自己强得可怕,不妨试着继续挑战一下,下面这两个结构体大小的计算:
struct S3
{
char c1;
int i;
char name[12];
};
struct S4
{
int i;
struct S3 s;
char c1;
};
对于结构体struct S3而言,大家只需要注意博主给大家提醒的如果成员变量是一个数组。那对齐数那里所说的成员变量的大小,不是指数组的大小,而是数组中单个元素的大小。因此最后结构体的最大对齐数也不是12,而是4。结构体整体的大小就是20。如图所示:
其次对于结构体struct S4而言,它里面嵌套了其他的结构体struct S3 s,我们只要注意s的对齐数是这个结构体的最大对齐数。就可以了。如果所有的一些没有问题,最后struct S4的大小就是28。如图所示:
附一:offsetof宏的介绍:
offsetof是一个宏,有了offsetof我们的用户,只需要给出一个结构体,然后再给出这个结构体的一个成员变量,你就可以获得:这个成员变量距离结构体起始位置的偏移量。然后如果你想使用这个宏,请#include<stddef.h>
我们这里以前面的struct S4结构体为例子,来看一下这个宏具体是如何使用的:
#include<stdio.h>
#include<stddef.h>
struct S3
{
char c1;
int i;
char name[12];
};
struct S4
{
int i;
struct S3 s;
char c1;
};
int main()
{
printf("struct S4 的i 的相较于起始位置的偏移量:%zd\n", offsetof(struct S4, i));
printf("struct S4 的s 的相较于起始位置的偏移量:%zd\n", offsetof(struct S4, s));
printf("struct S4 的c1的相较于起始位置的偏移量:%zd\n", offsetof(struct S4, c1));
return 0;
}
运行截图:
而这些和我们所分析的结果是一致的。
有小伙伴可能对这个宏的底层实现比较感兴趣,我们这里也把这个offsetof宏的一种实现方式给大家放在下面了:
#define offsetof(type, member) ((size_t)&((type*)0)->member)
它的基本策略就是把地址为0的位置当作是结构体的起始地址,然后结构体的成员member再取地址就是这个结构体成员相较于起始位置的偏移量了。
(4)为什么会存在结构体的内存对齐:
C语言的结构体,为什么会有内存对齐的特性。有一种说法是说如果结构体的内存是对齐的话,会带来更好的内存访问效率。
OK,为了更好的说明这个问题,我们先来假定一些场景:
假如我们的用户创建了结构体S,并定义了一个该结构体的变量s,如图所示:
struct S
{
char c;
int i;
}s;
然后我们假定我们拥有一台32位的机器,注意这里的32位是指这台机器的机器字长是32位,机器字长32位也就意味着这台机器的CPU一次性要从内存拿32bit的数据,然后能一次性对32bit的数据进行处理(现代CPU对数据的各种处理都是基于机器字长为单位的)。
然后如果说我不是内存对齐的,我这次想要处理变量s中的一个成员i,那这个过程可以用下图进行阐述:
这个图片告诉我们:如果说我读的数据是i,我们的CPU会从结构体变量s的起始地址处开始读数据(如果可以CPU可以选择不这么做,显然这里我们不得不这么做),一次读4Byte数据(4Byte == 32bit,图中绿框所标识的范围),显然我第一次只能拿到i的前3个字节的内容,我需要再读一遍才能完整地读取到i。也就是说: 如果内存是不对齐的,对i变量的读取CPU要读两次。而且对于变量i来说,还有一些没必要的东西被读进来了。
紧接着,我们继续探讨,如果说,我们的内存的是对齐的。那这个时候的过程则为下图所示:
显然,这时我们发现,如果说CPU要对i进行处理,它完全可以做到只需要读一次,就可以将变量i完整地读进来。
从另一个角度,当你的数据是对齐的情况下,我们内存控制器和CPU的设计呢,会更加简单。因为显然的一点,如果内存对齐的情况下,我们的CPU不需要去处理那种跨边界的数据请求;非对齐的情况下进行访问,出现跨边界的数据请求时可能需要多次内存操作才能获取到所需要的数据。这增加了我们硬件设计的复杂性并带来了潜在的延迟。
因此我们这里可也以认为是说:这种结构体存储成员的策略,是一种利用空间效率来换取时间效率的做法。
这是博主能给大家介绍的一种为什么说会存在有内存对齐的一个原因。当然呢,也有其他地方有说法是说:这种内存对齐的策略会有利于硬件平台的移植……嗯,我们不希望在这里把这个问题搞得更复杂了,因此这些其他的原因就留给感兴趣的读者朋友去探索一下吧,不再进行深入的探讨。
附二:带来的启发:
在设计结构体时,如果我既希望满足对齐,又希望所浪费的空间尽可能的少。我们应该做到:让占用空间小的成员尽量集中在一起。比如说下面两个结构体的设计,显然struct S1的设计优于struct S2的设计:
struct S1
{
char c1;
char c2;
int i;
};
struct S2
{
char c1;
int i;
char c2;
};
附三:修改编译器的默认对齐数:
C语言提供了#pragma pack(对齐数)这种格式的预处理指令,来对编译器的默认对齐数进行修改。理论上,对齐数都是大于0的正整数,特别地,如果指令格式里面的对齐数不写或写成0(其他的数据类型或负数往往会被认为非法),那就表示使用默认对齐数。
我们来看一个例子:
#define _CRT_SECURE_NO_WARNINGS 1
#include<stdio.h>
//先将编译器的默认对齐数设置为1:
#pragma pack(1)
struct S1
{
char c;
int i;
};
//再将编译器的默认对对齐数设置回来:
#pragma pack()
struct S2
{
char c;
int i;
};
int main()
{
printf("sizeof(struct S1) == %zd\n", sizeof(struct S1));
printf("sizeof(struct S2) == %zd", sizeof(struct S2));
return 0;
}
运行截图:
我们会发现,如果编译器的默认对齐数是1,带来的感觉就是没有进行内存对齐。当然无论怎么说,这应该和我们所预期的结果(推理的结果和实际结果一致)是一致的。
附四:关于前面问题的解答:
我们在C语言自定义数据类型详解(一)——结构体类型(上)-CSDN博客里面留了一个问题:为什么说结构体的传参,一般情况下是传址优于传值。现在我们对于一个结构体它的大小是如何计算的应该都已经是小case了,回过头来思考这个问题:如果我结构体传址的话,就如果是指针的话,那该参数对于函数栈的消耗要么是4Byte,要么是8Byte(x86是4,x64是8)。而一个结构体变量的大小往往会超过8个字节(你想一个char,一个int,然后还有内存对齐,这个大小已经就有8Byte了,更不要说更复杂的结构体)。这相应地,所带来的压栈消耗也会更大。
因此我们说结构体传参,传址往往优于传值。
二、关于位段:
(1)搭建应用场景:
A公司最近购入了四台机器。小明作为该公司的程序员被派遣了一个任务:要求建立一个数据结构用于维护四台机器的各自状态,这其中就包括了机器各自的id(0,1,2,3),机器的运行时间time,标识机器是否正常运行的状态指针state……这些可能就是作为维护人员,我们需要去关心的一些参数:
于是小明据此声明了下面这种结构,用来维护工厂里的这四台机器:
struct Machine
{
int id; //唯一标识一台机器的id
int time; //机器的运行时间
int state; //机器目前的状态:1表示正常运行,0表示不正常
}
但是小明后来在使用过程中也注意到了一个问题,在这个结构体中,有一些成员的取值是非常有限的,比如说一台机器的id只有0,1,2,3四种可能的结果,一个机器的状态也就正常和故障两种。
而对于这样的成员变量,我们用int来存,嗯……可以是可以,但是我实际上能用到的也就2个二进制位而已(00:0,01:1,10:2,11:3)。也就是我当前的这种结构,id中有30bit都是无效的。
有小伙伴说用char,当然啦,这也行,但是有没有其他的方法呢?OK,这里跟着博主的步伐来认识一下我们接下来的主角——位段。
(2)位段的概念性理解:
在某些应用场景中,为了尽可能地节省空间,我们的C语言结构体一般都有能够实现位段的能力。
位段的声明和结构体非常类似,但是有两个本质上的区别:
- 位段的成员只能是int,unsigned int 或signed int。C99之后支持了更多的数据类型,但是基本上就是int,char这样的数据类型。
- 在位段的成员名的后面,往往有一个冒号和一个数字,用来表示该成员变量的大小。以bit为单位。
也就是说,位段的成员虽然冠以int自称,但是它的实际大小往往低于int类型的4Byte。我们可以来看一个例子:
//我们这里在位段的成员名前面都加了下划线_,仅仅是为了便于区分它和一般的结构体成员变量,即这仅仅是个命名习惯罢了:
struct S
{
int _a : 2; //_a是位段成员,占2 bit位
int _b : 5; //_b是位段成员,占5 bit位
int _c : 10; //_c是位段成员,占10 bit位
int d;
};
(3)位段的内存分配:
对于位段成员的内存分配,C语言也没有提供具体的标准,但是一个大致的方向是:当你定义了一个位段成员时,它会先给你1Byte或4Byte空间(这一般取决于你前面的类型写的是int还是char),然后你不够了,编译器会继续再给你1Byte或4Byte空间。
什么意思呢?就拿我们前面定义的结构体来说,我们先把它拿过来:
#include<stdio.h>
//我们这里在位段的成员名前面都加了下划线_,仅仅是为了便于区分它和一般的结构体成员变量,即这仅仅是个命名习惯罢了:
struct S
{
int _a : 2; //_a是位段成员,占2 bit位
int _b : 5; //_b是位段成员,占5 bit位
int _c : 10; //_c是位段成员,占10 bit位
int d;
};
int main()
{
printf("sizeof(struct S) == %zd",sizeof(struct S));
return 0;
}
首先编译器看你需要一个int类型的成员变量,于是它会先给你开辟4Byte空间。显然4Byte = 32bit,这对于_a,_b,_c显然是够用了的。因此_a,_b,_c会公用这4byte的空间。加上之后的int类型的b变量,这个结构体最终的大小大概就是8Byte左右。
显然这比你直接用4个int所需要的内存少多了。如图所示:
这就是一种简单地分析位段大小的一种方式。这里面其实还有很多的细节,如图所示:
具体到内部,_a要2bit,这2bit开辟,是从左往右,还是从右往左是C语言标准没有规定的。
另外,如果说是这样的:
#include<stdio.h>
//我们这里在位段的成员名前面都加了下划线_,仅仅是为了便于区分它和一般的结构体成员变量,即这仅仅是个命名习惯罢了:
struct S
{
int _a : 15; //_a是位段成员,占15 bit位
int _b : 12; //_b是位段成员,占12 bit位
int _c : 10; //_c是位段成员,占10 bit位
};
这里我们当然知道说这个结构体是8byte,然后_a和_b用了前4Byte的27bit位。问题是来给_c分配空间的时候:我们知道前面4Byte用了27bit了(_a和_b),它还有5bit没有被使用。那这5bit会不会给到_c,然后再从后面的4Byte拿出5bit,凑成最终的10bit呢。
还是说这前面4Byte遗留下的5bit不要了,_c的10bit全部在后面的4Byte里面。这件事情也是我们C语言标准所没有规定的事情。
换句话说,位段内部在给成员分配空间时,它的不确定性因素实在是太多了。这带来的直接问题便是:位段的跨平台性非常差!!!因为没有标准,每个编译器的厂家都是自己的那一套。因此我们实际中用位段其实用得非常非常少,这是希望大家注意到的一点。
博主在这里介绍位段的目的,也仅仅只是希望大家以后在纯C里面看到种定义结构体成员的方式,能够看得懂。
至此,关于结构体所有相关的周边知识就介绍完啦,我们下期再见!🙂