一、结构体
前言:有关结构体的声明、定义、初始化以及结构体的传参等结构体的基本使用在文章【逐步剖C】-第六章-结构体初阶中已进行了详细的介绍,需要的朋友们可以看看。这里主要讲解的是有关结构体的内存问题。
1. 结构体的内存对齐
(1)对齐规则与结构体大小的计算
我们知道,一个结构体内部可能有多个不同类型的成员,那么对于整个结构体而言,它的大小怎么计算呢?这就要涉及到结构体内存对齐这个重要知识点了
这里先放上结构体内存对齐的规则:
(1)结构体的第一个成员,对齐到与该结构体成员对比偏移量为0的地址处;
(2)从第二个成员开始,每个成员都要对齐到(偏移量为)其对应对齐数的整数倍的地址处
- 每个成员对应的对齐数 = 编译器默认的一个对齐数 与 该成员大小 的较小者。
VS中默认的值为8 Linux gcc中没有默认的对齐数,对齐数就是结构体成员的自身大小
(3)结构体总大小为最大对齐数(每个成员变量都有一个对齐数)的整数倍。
(4)如果有嵌套了结构体的情况,嵌套的结构体对齐到自己的最大对齐数的整数倍处,结构体的整体大小就是所有最大对齐数(含嵌套结构体的对齐数)的整数倍。
下面将结合代码并配合示意图进行讲解。
代码1:
struct S1
{
char ch1;
int i;
char ch2;
};
printf("%d\n", sizeof(struct S1));
运行结果:
解释:
-
首先,结构的第一个成员为
ch1
,其类型为char
,那么根据规则的第一条,其将对齐到偏移量为0的地址处,示意图如下:
-
其次,结构的第二个成员为
i
,其类型为int
,根据规则的第二条,其将对齐到(偏移量为)它对应的对齐数的整数倍的地址处,又因为int
类型的大小为4个字节,编译器(VS)默认的对齐数为8,取二者中的较小者,故该成员i
的对齐数为4,故成员i
将在成员ch1
的基础上对齐到(偏移量为)4的整数倍的地址处,示意图如下:
-
接着,结构体的第三个成员为
ch2
,其类型为char
,同第二个成员i
,根据规则的第二条,其将对齐到(偏移量为)它对应的对齐数的整数倍的地址处,又因为char
类型的大小为1个字节,编译器(VS)默认的对齐数为8,取二者中的较小者,故该成员的对齐数为1,故成员ch2
将在前面成员的基础上对齐到(偏移量为)1的整数倍的地址处,示意图如下:
-
最后,根据规则3,在所有成员都对齐到对应地址处的基础上,整个结构体的总大小为所有成员中最大对齐数的整数倍,也就是4(成员
i
的对齐数)的整数倍,故整个结构体的大小为12。
代码2:
struct S2
{
char c1;
char c2;
int i;
};
printf("%d\n", sizeof(struct S2));
运行结果:
解释:
-
首先,结构的第一个成员为
c1
,其类型为char
,那么根据规则的第一条,其将对齐到偏移量为0的地址处,示意图如下:
-
其次,结构的第二个成员为
c2
,其类型为char
,根据规则的第二条,其将对齐到(偏移量为)它对应的对齐数的整数倍的地址处,又因为char
类型的大小为1个字节,编译器(VS)默认的对齐数为8,取二者中的较小者,故该成员的对齐数为1,故成员c2
将在成员c1
的基础上对齐到(偏移量为)1的整数倍的地址处,示意图如下:
-
接着,结构体的第三个成员为
i
,其类型为int
,同第二个成员c2
,根据规则的第二条,其将对齐到(偏移量为)它对应的对齐数的整数倍的地址处,又因为int
类型的大小为4个字节,编译器(VS)默认的对齐数为8,取二者中的较小者,故该成员的对齐数为4,故成员i
将在前面成员的基础上对齐到(偏移量为)4的整数倍的地址处,示意图如下:
-
最后,根据规则3,在所有成员都对齐到对应地址处的基础上,整个结构体的总大小为所有成员中最大对齐数的整数倍,也就是4(成员i的对齐数)的整数倍,故整个结构体的大小为8。
代码3:
struct S3
{
double d;
char c;
int i;
};
printf("%d\n", sizeof(struct S3));
运行结果:
解释:
-
首先,结构的第一个成员为
d
,其类型为double
,那么根据规则的第一条,其将对齐到偏移量为0的地址处,又因为其本身大小为8个字节,故其在内存中的示意图如下:
-
其次,结构的第二个成员为
c
,其类型为char
,根据规则的第二条,其将对齐到(偏移量为)它对应的对齐数的整数倍的地址处,又因为char
类型的大小为1个字节,编译器(VS)默认的对齐数为8,取二者中的较小者,故该成员的对齐数为1,故成员c
将在成员d
的基础上对齐到(偏移量为)1的整数倍的地址处,示意图如下:
-
接着,结构体的第三个成员为
i
,其类型为int
,同第二个成员c
,根据规则的第二条,其将对齐到(偏移量为)它对应的对齐数的整数倍的地址处,又因为int
类型的大小为4个字节,编译器(VS)默认的对齐数为8,取二者中的较小者,故该成员的对齐数为4,故成员i将在前面成员的基础上对齐到(偏移量为)4的整数倍的地址处,示意图如下:
-
最后,根据规则3,在所有成员都对齐到对应地址处的基础上,整个结构体的总大小为所有成员中最大对齐数的整数倍,也就是8(成员
d
的对齐数)的整数倍,故整个结构体的大小为16。
代码4:
struct S4
{
char c1;
struct S3 s3;
double d;
};
printf("%d\n", sizeof(struct S4));
运行结果:
解释:
-
首先,结构的第一个成员为
c1
,其类型为char
,那么根据规则的第一条,其将对齐到偏移量为0的地址处,示意图如下:
-
其次,第二个成员
s3
为另一个结构体类型的变量,该类型就是我们代码3中的结构体类型S3,这里就涉及到结构体嵌套的情况,那么结合规则4与对代码3的分析我们可得,成员s3
将对齐到(偏移量为)自己结构中最大对齐数(也就是结构S3中成员d
的对齐数,为8)的整数倍的地址处,又因为成员本身的大小为16个字节,故内存中的参考示意图如下:
-
接着,结构体的第三个成员为
d
,其类型为double
,根据规则的第二条,其将对齐到(偏移量为)它对应的对齐数的整数倍的地址处,又因为double
类型的大小为8个字节,编译器(VS)默认的对齐数为8,取二者中的较小者,故该成员的对齐数为8,故成员i
将在前面成员的基础上对齐到(偏移量为)8的整数倍的地址处,示意图如下:
-
最后,根据规则3,在所有成员都对齐到对应地址处的基础上,整个结构体的总大小为所有成员中最大对齐数的整数倍,也就是16(成员
s3
的对齐数)的整数倍,故整个结构体的大小为32。
(2)内存对齐的意义
通过如上讲解,大家可能想问:内存示意图中那些空出来的白色部分去哪了呢?
答案是:这部分的内存会为了完成结构体的对齐而浪费掉。
大家可能会追问到:那么为了内存对齐而浪费这些内存空间真的有必要吗?
那么接下来为大家介绍一下结构体内存对齐的意义
- 为什么会存在结构体对齐呢?
通过大部分资料可总结出如下两个主要原因:
- 平台原因(移植原因):
不是所有的硬件平台都能访问任意地址上的任意数据的;某些硬件平台只能在某些地址处取某些特
定类型的数据,否则抛出硬件异常。- 性能原因:
数据结构(尤其是栈)应该尽可能地在自然边界上对齐。
原因在于,为了访问未对齐的内存,处理器需要作两次内存访问;而对齐的内存访问仅需要一次访
问。
其中对与性能原因中的两次访问可以这么理解:
如上面代码2中的这个结构体
struct S1
{
char c1;
int i;
char c2;
};
看下面这张内存示意图的对比:
假设以4个字节(一般是成员最大对齐数)为单位进行内存访问,那么对于内存对齐的情况,由于中间的内存空间是不用的,所以每次访问都能完整地读取到每个成员的内存信息:
- 访问地址0 1 2 3得到成员ch1的信息
- 访问地址4 5 6 7得到成员i的信息
- 访问地址8 9 10 11得到成员ch2的信息
而对于内存内存不对齐的情况,由于内存空间都是连在一起的,所以在每次访问内存时都可能会出现“割裂” 访问的情况:
- 访问地址0 1 2 3时既有成员ch1的信息,又有成员i的信息;故对于成员i而言,需要两次访问才能完整地得到成员i内存中的信息。
如此看来,采用内存对齐后更方便了对结构体成员内存的访问。
所以对于内存对齐,总的来说就是,牺牲一定的空间来换取时间上的效率。
那么我们在设计结构体的时候我们如何在内存对齐的情况下尽量地节省空间呢?
答案是:让占用空间小的成员尽量集中在一起。让上面的代码(1)与代码(2),结构体成员的类型相同,但由于结构体成员在结构体中的位置不同,导致最终结构体大小也不同。
(3)修改默认对齐数
上面提到,不同编译器可能有不同的默认对齐数,对这个默认对齐数,其实我们可以通过一个预处理指令#pragma
对其进行更改,请看下面这段代码:
#pragma pack(8)//设置默认对齐数为8
struct S1
{
char c1;
int i;
char c2;
};
#pragma pack()//取消设置的默认对齐数,还原为默认
#pragma pack(1)//设置默认对齐数为1
struct S2
{
char c1;
int i;
char c2;
};
#pragma pack()//取消设置的默认对齐数,还原为默认
int main()
{
printf("%d\n", sizeof(struct S1));
printf("%d\n", sizeof(struct S2));
return 0;
}
程序输出结果:
这里的分析方法呢和(1)部分中的内容是相同的,唯一的区别就在于编译器默认对齐数的改变导致了结构体成员最大对齐数的改变,这里就不在赘述啦。
所以,我们可以判断结构体对齐方式是否合适,从而自己更改合适的默认对齐数。
2. 结构体实现位段
(1)位段的定义
- 什么是位段呢?
位段,C语言允许在一个结构体中以位为单位来指定其成员所占内存长度,这种以位为单位的成员称为“位段”或称“位域”( bit field) 。利用位段能够用较少的位数存储数据。———百度百科
-
位段的声明方式与规则:
- 位段的成员必须是 int、unsigned int 或signed char
- 位段的成员名后边有一个冒号和一个数字
如下面这段代码:
struct A
{
int a:2;
int b:5;
int c:10;
int d:30;
};
结构体A就是一个位段类型,那么位段A的总大小该如何计算呢?请看下面一部分。
(2)位段的内存分配
- 这里先以上面的结构体A作为例子进行分析,然后再介绍一些位段内存分配的细节问题
由位段的概念可知,我们可以以位(bit)为单位来指定结构体成员所占的内存长度。
对于结构体A而言,其占用内存的情况,可以按以下过程进行理解:- 第一个成员,
int a:2;
,编译器先申请32个比特位,然后给成员a
分配2个比特位,分配完成后,剩余30个比特位; - 第二个成员,
int b:5;
,编译器再从剩余的30个比特位中给成员b
分配5个比特位,分配完成后,剩余25个比特位; - 第三个成员,
int c:10;
,编译器再从剩余的25个比特位中给成员c
分配10个比特位,分配完成后,剩余15个比特位; - 第四个成员,
int d:30;
,编译器需要为其分配30个比特位,但剩余的比特位不够分配的需求,编译器会重新申请32个比特位,并用这新申请的32个比特位来为其进行内存分配。
此时就会产生一个问题:第一次申请中的剩余的15个比特位去哪了呢?
可能舍弃了,也可能保存起来了,这里没有明确的标准规定,所以是不确定的。 - 回到过程中,至此,对每个成员的内存分配就完成了,那么由如上过程我们得到,为成员分配内存时共申请两次32个比特位,总计也就是64个比特位,8个字节。故结构体A最终的大小就为8个字节。我们也可通过打印进行验证:
- 第一个成员,
代码:
int main()
{
struct A
{
int a : 2;
int b : 5;
int c : 10;
int d : 30;
};
printf("%d\n", sizeof(struct A));
return 0;
}
结果:
注:上面所谓内存分配的“过程”仅是一种理解方式,实际中的内存空间是一次就开辟好了的,这一点需要注意。
- 接下来说明一下位段在内存分配中的一点细节问题:
请看下面这一段代码:
struct S
{
char a:3;
char b:4;
char c:5;
char d:4;
};
struct S s = {0};
s.a = 10;
s.b = 12;
s.c = 3;
s.d = 4;
在(1)中我们介绍了一下内存分配的 “过程”,接下来将通过编译器的调试带大家看看在对结构体成员进行赋值时,内存中内容的实际变化(PS:接下来的内容需要用到机器大小端字节序存储的知识点,如果有不太了解的朋友可以看看这篇文章:【逐步剖C】-第七章-数据的存储)。
- 执行完语句
struct S s = {0};
的初始状态:
解释:可以看到整个结构体在内存中只占了3个字节的长度,这里同样可以用(1)中的 “分配过程” 来理解:编译器先申请8个比特位(1个字节)(PS:内存的开辟形式编译器会按需分配,最后的小结中还会提到),给成员a
分配3个比特位后剩余5个比特位,接着给成员b
分配4个比特位后还剩1个比特位;接着编译器新申请8个比特位分配给成员c
后剩3个比特位;由于剩余比特位又不够成员d
的分配,故最后编译器又再申请了8个字节分配给成员d
。
综上,编译器一共申请了3个字节的内存。故整个结构体在内存中的大小就为3个字节。
继续往下,
- 执行完赋值语句
s.a = 10;
后:
解释:
可以看到,内存中第一个字节的内容被改为了02
,但按理来说成员a
被赋值为了10,第一个字节的内容应该是0a
(十六进制)呀。这就是位段的效果了,我们知道,按8个比特位来看,10的二进制序列为:
0000 1010
对于成员a
而言,其在内存中其实只占用了前3个比特位的内容,即010
,理解起来就是 “截断往里存”,故在内存中二进制序列的实际情况为:
0000 0010
这样换算成十六进制就是图中的02
了。
- 执行完赋值语句
s.b = 12;
后:
解释:
可以看到第一个字节的内容变为了62
,其中的原因和上面一样:
首先,12的二进制序列为:
0000 1100
由于成员b
只占用4个比特位的内容,所以截断为1100
,并在成员a的基础上往里存,也就是内存中的二进制序列变为:
0111 0010
这样换算成十六进制就是图中的62
了。
- 执行完赋值语句
s.c = 3;
后:
解释:可以看到,第二个字节中的内容变为了03
,由初始状态的解释可以知道,在为成员c
分配空间时新申请了一个字节,成员c
占用5个比特位的内存所以 “截断” 为00011
往里存,那么在前面的基础上,我们从16个比特位来看,此时内存中的二进制序列如下:
0000 0011 0110 0010
- 最后,执行完赋值语句
s.d = 4;
后:
解释:可以看到,第三个字节的内容变为了04
。同样由初始状态的解释可以知道,在为成员d
分配空间时又新申请了一个字节,成员d
占用4个比特位的内存所以 “截断” 为0100
往里存,那么在前面的基础上,我们从24个比特位来看,此时内存中的二进制序列如下:
0000 0100 0000 0011 0110 0010
这里大家可能会发现,实际二进制序列的内容和编译器上显示出来的内容好像是反着的,即:
0000 0100 0000 0011 0110 0010
对应转为16进制应为:
04 03 62
而编译器显示的内容为:
62 03 04
这是因为当前机器的存储形式为小端字节序存储(低位的数据存储在低地址,高位的数据存储在高地址),对于这部分的详细介绍感兴趣的朋友们可以看看这部分开始时提到的那篇文章,下面是示意图:
小结:
- 位段的成员可以是 int、unsigned int 、signed int 或者是 char (属于整形家族)类型
- 位段的空间上是按照需要以4个字节( int )(32个比特位)或者1个字节( char )(8个比特位)的方式来开辟的(按需分配)。
(3)位段的一些问题
- int 位段被当成有符号数还是无符号数是不确定的。
- 位段中最大位的数目不能确定。(16位机器最大16,32位机器最大32,写成27,在16位机
器会出问题。)且一般来说,位的指令不能超过自身类型的大小。- 位段中的成员在内存中从左向右分配,还是从右向左分配标准尚未定义。
- 当一个结构包含两个位段,第二个位段成员比较大,无法容纳于第一个位段剩余的位时,是
舍弃剩余的位还是利用,这是不确定的- 位段是不存在对齐的
解释一下其中的第三点:
以(2)中的例子为例,在为结构体成员a
和b
分配空间时,我们看到其实是从右向左进行内存分配的,即:
0111 0010
但严格来说,分配的方式是标准未定义的,即也有可能从左向右进行内存分配,即:
0101 1000
总结:
跟结构相比,位段可以达到同样的效果,但是可以很好的节省空间,但是有跨平台的问题存在(严格来说,位段是不跨平台的) ;位段涉及很多不确定因素,故注重可移植的程序应该避免使用位段。
(4)位段的应用
位段主要运用于数据在网络中的运输 (PS:这里仅简单说明一下位段的作用,更多有关数据在网络中的运输等计算机网络的知识由于博主仍在学习,这里就不做过多介绍啦)。
数据在网络中运输时,会在数据之上再封装数据,以确保数据的准确运输。那么用来封装的数据肯定不能都像int
等类型一样使用固定字节的大小。我们可以把网络想像为高速公路,若全为大卡车则非常容易造成拥挤,而通过位段可以到达 “缩小” 的作用,从而减少流量的压力。
总结来说就是,位段的使用有利于应对网络拥堵的问题。
二、枚举
顾名思义很好理解,就是把某个事物所有可能的情况进行一一列举,如:掷骰子总共会出现六种情况;一周总共有七天等等。
1. 枚举类型的定义
如上的两个例子作为枚举变量我们就可以定义为如下形式,请看:
enum Roll
{
One,
Two,
Three,
Four,
Five,
Six
};
enum Day//星期
{
Mon,
Tues,
Wed,
Thur,
Fri,
Sat,
Sun
};
以上的enum Roll
和 enum Day
就是枚举类型,花括号中的数据就是枚举类型可能的取值,称为枚举常量,那么在定义一个枚举类型和枚举变量时有以下这么几个需要注意的点:
(1)定义枚举类型时,枚举常量间是使用逗号进行分隔的,且最后一个枚举常量不需要加逗号;
(2)枚举常量是有默认值的,默认从0开始,一次递增1,也可以在定义的时候就进行赋初值,如:
enum Roll
{
One = 1,
Two = 2,
Three = 3,
Four = 4,
Five = 5,
Six = 6
};
若只给其中一个枚举常量赋了初值,那么该枚举常量前的枚举常量仍采用默认值;而其后的枚举常量将以其为基准,一次递增1;如:
enum Roll
{
One,
Two,
Three = 24,
Four,
Five,
Six
};
其中枚举常量One和Two的值为0,1;而枚举常量Four,Five,Six的值分别为25,26,27。
(3)和定义结构体变量相同,若没有使用typedef
关键字进行类型重命名,那么在定义变量时就需要写全定义,如用枚举类型定义一个名为Dice的枚举变量时,正确的写法为:enum Roll Dice;
;而不能写为:Roll Dice;
;
(4)只能用枚举常量给枚举变量赋值,也不能直接更改枚举常量的值(因为是“常量”)。
2. 枚举的使用
这里简单展现一下枚举的使用,请看:
enum Roll
{
One = 1,
Two,
Three,
Four,
Five,
Six
};
int main()
{
enum Roll dice = One;
printf("Roll:%d\n", dice);
dice = Six;
printf("Roll:%d\n", dice);
return 0;
}
输出结果:
3. 枚举的优点
从枚举的使用可以看出,枚举的使用其实和#define
定义常量非常类似,那么相比之下我们使用枚举有什么优点呢?
枚举的优点主要为以下几点:
(1)增加代码的可读性和可维护性
(2)和#define
定义的标识符相比,枚举有类型检查,更加严谨。
(3)一定程度上实现了封装,防止了命名污染
(4)便于调试
(5)使用方便,一次可以定义多个常量
额外补充一点:其实#define
定义的常量在编译期间就已经被替换为所定义的值了,此时从整体代码的视角看来就会有些许的 “分裂” 感。
如:
#define Max 100
int main()
{
int m = Max;
return 0;
}
上面代码中,我们希望表达的是变量m中存着定义的最大值Max;但代码经过编译后,如上代码就变为了:
#define Max 100
int main()
{
int m = 100; //Max直接替换为了100
return 0;
}
如此一来,代码其实就不能很好地表达出我们所希望表达的意思了。
三、联合
1. 联合类型的定义
联合类型和结构体类型相似,包含着一系列的成员,但独有的特征是:这些成员共用同一块内存空间(故联合类型也被称为共用体或联合体)
联合体可通过如下方式进行定义,请看:
//联合类型的定义
union Un
{
char c;
int i;
};
//联合变量的定义
union Un un;
2. 联合的特点
开头提到了,联合类型的特点其实就是联合体中的成员会共用同一块内存空间。
可以用这样一段简单的代码进行验证,请看:
union Un
{
int i;
char c1;
};
int main()
{
union Un un;
printf("%p\n", &(un.i));
printf("%p\n", &(un.c1));
return 0;
}
输出结果:
可以看出,联合体中的两个成员所占用的内存空间的地址是相同的,也就是说,两个成员共用同一块内存空间。
那么再请大家看一下下面代码会输出什么结果呢?
union Un
{
int i;
char c1;
};
int main()
{
union Un un;
un.i = 0x11223344;
printf("%x\n", un.i);
un.c1 = 0x55;
printf("%x\n", un.i);
return 0;
}
输出结果:
解释:
我们还是通过调试的方法来进行说明:
- 初始状态:
- 执行完语句
un.i = 0x11223344;
后:
由于成员i
的类型为整型,故内存中4个字节的内容被改为了44 33 22 11
这里数据的顺序和打印出来的数据之所以反过来是因为当前机器的存储方式为小端存储(在介绍位段时也已提到,这里不再赘述啦)。 - 执行完语句
un.c1 = 0x55;
后:
由于成员c1
的类型为字符型,故原内存中第一个字节的内容被改为了55
。
故最后以十六进制输出就为11223355
在上面所提到的那篇介绍大小端的文章中,给出了判断当前机器存储方式的一种方法,这里根据联合类型的特点再提供一种判断方法,请看:
int check_sys() //大端返回0,小端返回1
{
union Un un;
un.i = 1;
return(un.c == 1);
}
解释:
若机器的存储方式为小端存储,那么语句un.i = 1;
就会将内存中的内容改为01 00 00 00
:
低地址----------------------->高地址
//小端存储:
01 00 00 00
//大端存储:
00 00 00 01
那么此时成员c
(大小为一个字节)中的内容也就为01
,故可直接对成员c
中的值进行判断,并返回判断结果即可。
那么对于一个联合体而言,它所占用内存空间的大小究竟应该如何分配(计算)呢?
请朋友们继续往下看。
3. 联合大小的计算
这里先放上联合体大小计算的规则:
(1)联合体的大小至少为最大成员的大小;
(2)联合体也是存在内存对齐的,若最大成员的大小不是最大对齐数的整数倍时,就要对齐到(偏移量为)最大对齐数的整数倍的地址处。
下面结合例子进行说明:
union Un1
{
char c[5];
int i;
};
union Un2
{
short c[7];
int i;
};
printf("%d\n", sizeof(union Un1));
printf("%d\n", sizeof(union Un2));
程序输出结果:
解释:
- 对于联合体
union Un1
:
成员char c[5]
的大小为5个字节,对齐数为1;
成员int i
的大小为4个字节,对齐数为4;
故最终联合体的大小需对齐到(偏移量为)最大对齐数(也就是4)的整数倍的地址处,且需要保证联合体的大小至少为最大成员的大小,故最终联合体的大小为8个字节; - 对于联合体
union Un2
:
成员short c[7]
的大小为14个字节,对齐数为2;
成员int i
的大小为4个字节,对齐数为4;
故最终联合体的大小需对齐到(偏移量为)最大对齐数(也就是4)的整数倍的地址处,且需要保证联合体的大小至少为最大成员的大小,故最终联合体的大小为16个字节;
总结:在计算联合体的大小时,关键在于注意区分最大成员的大小与最大对齐数的概念;前者除了关注类型还需关注个数,而后者可只关注类型。
本章完。
看完觉得有觉得帮助的话不妨点赞收藏鼓励一下,有疑问或有误地方的地方还恳请过路的朋友们留个评论,多多指点,谢谢朋友们!🌹🌹🌹