内存对齐与位段
导读
大家好,很高兴又和大家见面啦!!!
在上一篇内容中我们介绍了什么是自定义类型、什么是结构体以及结构体的声明与使用的相关内容。
在今天的内容中我们将要介绍的是C语言中的内存对齐以及位段的相关内容。这两个名词对我们来说还是比较陌生的,那么我们就一起来认识一下什么是内存对齐,以及位段又是什么吧!!!
一、内存对齐
在C语言中,每一种数据类型都有其自身的大小,并且数据类型所占空间大小一般是不会发生变化的。
这里的特例就是数组类型,数组类型的大小会根据数组大小以及数组元素的数据类型的不同而有一定的区别,不过整个数组的大小我们可以通过数组元素的数据类型所占空间大小与数组的大小的乘积来获取,如int arr[10];
这个数组所占空间大小为
4
∗
10
=
40
4 * 10 = 40
4∗10=40 个字节;
但是在自定义类型中,由于成员变量的数量以及数据类型是不确定的,并且自定义类型的大小并不能通过简单的成员所占空间大小之和来确定。如下所示:
可以看到,对于成员类型相同,但是顺序不同的两个结构体类型,其所占内存空间大小也不相同。为此我们就需要通过了解成员变量在内存中的存放方式,从而来计算自定义类型所占空间的大小。
C语言中的内存对齐自定义类型成员在内存中存放的一些规则,要弄清什么是内存对齐,首先我们得掌握自定义类型中的对齐规则;
1.1 对齐规则
这里我们以结构体为例,在结构体的的内存对齐中有4点规则:
- 结构体的第一个成员对齐到和结构体变量起始位置偏移量为0的地址处
- 其他成员变量要对齐到某个数字(对齐数)的整数倍的地址处。
- 结构体总大小为最大对齐数(结构体中每个成员变量都有一个对齐数,所有对齐数中最大的)的
整数倍。 - 如果嵌套了结构体的情况,嵌套的结构体成员对齐到自己的成员中最大对齐数的整数倍处,结构
体的整体大小就是所有最大对齐数(含嵌套结构体中成员的对齐数)的整数倍。
1.1.1 内存对齐中的名词
这些规则似乎有点不太好理解,下面我们先来认识在结构体内存对齐中的几个概念;
- 偏移量:结构体成员所在地址与结构体起始地址的差值
- 对齐数: 编译器默认的一个对齐数 与 该成员变量大小的较小值。
- VS 中默认的值为 8 字节
- Linux中 gcc 没有默认对齐数,对齐数就是成员自身的大小
- 最大对齐数:结构体成员中成员所占空间大小的最大值
1.1.2 内存对齐规则的理解
接下来我们再来通过例子逐一理解这4点对齐规则。
typedef struct test1 {
char a;
char b;
int c;
}t1;
在这个结构体中,有三个结构体成员——两个char
类型与一个int
类型,在这些成员中,我们默认第一个成员的起始地址的偏移量为0,如下所示:
从第二个成员开始,其内存地址为对齐数的整数倍的偏移量地址,如下所示:
同理,第三个成员的地址同样为对齐数的整数倍偏移量地址,如下所示:
现在所有成员都存储到了内存中,接下来我们需要计算结构体的所占内存大小,根据规则3,结构体的总大小为最大对齐数的整数倍,在该结构体成员中,成员c的对齐数为结构体的最大对齐数,因此结构体的总大小为其对齐数的整数倍,如下所示:
通过这个例子,相信大家对前三点规则已经有了一定程度的认知了,接下来我们再来看这个结构体:
typedef struct test3 {
char a;
char b;
int c;
t1 d;
}t3;
在这个结构体中,除了有与t1
相同的三个成员对象外,它还多了一个成员对象——t1
类型的结构体成员d,下面我们紧接着来分析一下该结构体所占空间大小,前三个成员对象我们就直接跳过了,下面我们直接来看成员d,如下所示:
经过我们的分析,对于t3
这个结构体类型,它所需要的空间大小为16,接下来我们就来验证一下是不是这样;
从测试结果中可以看到,t3
所占的内存空间大小确实是16。
1.1.3 宏offsetof
现在有朋友肯定会有疑问,难道我们就单从结构体所占空间大小就能判断我们的分析是正确的吗?有没有可以验证我们分析的成员偏移量的方法呢?
诶,你别说,你还真别说,C语言中确实有一个可以查看结构体成员偏移量的宏——offsetof
,这个宏位于头文件<stddef.h>
中,下面我们来看一下这个宏是如何使用的,如下所示:
可以看到,在这个宏中有两个参数——结构体类型以及结构体成员,下面我们就来测试一下t3中的各个成员的偏移值是否如我们所分析的一样:a = 0, b = 1, c = 4, d = 8
,如下所示:
可以看到,没有任何问题,与我们的分析与理解是一致的。为了帮助大家进一步的理解与巩固内存对齐的规则,下面请大家分析一下t2
为什么是占12个字节而不是8个字节?在本篇文章的结尾会给大家进行分析,现在就留一点时间给各位自己思考。
1.2 内存对齐存在的原因
之所以存在内存对齐,主要有两个方面的原因:
- 平台原因 (移植原因):
不是所有的硬件平台都能访问任意地址上的任意数据的;某些硬件平台只能在某些地址处取某些特定类型的数据,否则抛出硬件异常。 - 性能原因:
数据结构(尤其是栈)应该尽可能地在自然边界上对齐。原因在于,为了访问未对齐的内存,处理器需要作两次内存访问;而对齐的内存访问仅需要一次访问。假设一个处理器总是从内存中取8个字节,则地址必须是8的倍数。如果我们能保证将所有的double类型的数据的地址都对齐成8的倍数,那么就可以用一个内存操作来读或者写值了。否则,我们可能需要执行两次内存访问,因为对象可能被分放在两个8字节内存块中。
总体来说:结构体的内存对齐是拿空间来换取时间的做法。
因此根据内存对齐的规则,我们在设计结构体时,可以将较小的结构体成员放在一起以达到减少空间的消耗。
1.3 修改默认对齐数
接下来我们简单的了解一下修改默认对齐数的方法——#pragma pack(key)
设置默认对齐数为key。如下所示:
可以看到,当我们将默认对齐数修改为1时,在这种情况下,结构体所占空间大小就是结构体成员所占空间大小之和,现在就不存在位置摆放导致所占内存不同的问题了。
这个内容仅做了解即可,咱们不需要过分深究,在内存对齐中我们需要掌握的是内存对齐的规则,以及我们在遇到一个陌生的结构体时,能够分析出其所占内存空间的大小。
二、位段
位段对我们来说是一个陌生到不能再陌生的名词,什么是位段?位段应该如何使用这就是我们今天需要探讨的问题。
在计算机中,信息的存取一般以字节为单位。实际上,有时存储一个信息不必用一个或多个字节,例如,“真”或“假”用0或1表示,只需1位即可。在计算机用于过程控制、参数检测或数据通信领域时,控制信息往往只占一个字节中的一个或几个二进制位,常常在一个字节中放几个信息。
像这种根据二进制位对信息进行内存分配的方式就是我们今天要介绍的位段。
2.1 什么是位段
位段(或称“位域”,Bit field)为一种数据结构,可以把数据以位的形式紧凑的储存,并允许程序员对此结构的位进行操作。
这种数据结构的好处:可以使数据单元节省储存空间,当程序需要成千上万个数据单元时,这种方法就显得尤为重要。
位段可以很方便的访问一个整数值的部分内容从而可以简化程序源代码。而位域这种数据结构的缺点在于,其内存分配与内存对齐的实现方式依赖于具体的机器和系统,在不同的平台可能有不同的结果,这导致了位段在本质上是不可移植的。
看着这一板一眼的概念,着实有点让人难以理解,下面我们就来将位段进行一下拆解,看一看位段的一个庐山真面目。
2.1.1 个人理解
位段我们可以看做是一种特殊的结构体,为什么是特殊的结构体呢?因为它与结构体有相同之处,同时也有一定的区别:
我们先来看一下位段的一个声明格式:
struct tag {
member_list;
}variable_list;
//struct——结构体关键字,用于结构体的声明
//tag——结构体的名字,用于表示结构体的用途
//member_list——结构体成员列表
//variable_list——结构体变量列表(可有可无)
可以看到,与结构体的声明格式基本一致。但是,它与结构体的区别就体现在成员列表中:
- 位段的成员列表的数据类型规定为一定是
int、unsigned int、signed int
- 在C99中,位段的成员类型加入了其它类型,如
char
…… - 位段的成员后边必须跟上一个冒号
':'
和一个数字,如int a:2;
- 数字代表的是该成员被分配的比特位的数量,如2就代表的是两个比特位
- 在位段成员中,被分配的比特位不能超过成员本身的数据类型所占的比特位的数量
- 16位操作系统中,
int
占2个字节也就是16个比特位; - 32位操作系统中,
int
占4个字节也就是32个比特位; - 在32为的操作系统中定义的整型位段成员,做多可以被分配32个比特位,但是在16位系统中,则只能被分配16个比特位
- 16位操作系统中,
- 位段不具备跨平台性
对这些内容总结一下就是,位段是按照比特位来给成员变量分配内存的,而结构体是按照字节来给成员分配内存的。
经过前面的内存对齐,我们已经知道了结构体成员在内存中是如何进行分配的,那对于位段来说,它的成员在内存中又是如何进行内存分配的呢?
2.2 位段的内存分配
为了探讨位段的内存分配,接下来我们以一个例子来进行说明,如下所示:
struct S {
char a : 1;
char b : 2;
char c : 3;
char d : 4;
char e : 5;
}s;
现在我们声明了一个位段,位段的成员都是char
类型,位段的五个成员分别被分配了1、2、3、4、5
个比特位,下面大家来猜一下,这个位段在内存中应该申请了多大的内存空间,或者说是申请了多少个字节的内存空间?
2个字节?3个字节?4个字节?5个字节?具体是多少呢?我也不知道,所以我们一起来借助sizeof
来计算一下,如下所示:
可以看到,在这个位段中,总共向内存申请了3个字节的空间,现在有朋友可能就会奇怪了,我们明明只分配了15个比特位,为什么会有用到3个字节呢?
为了解开这个咱们心中的异或,下面我们先来简单的了解一下位段中内存分配的一些规则:
- 位段的成员可以是int unsigned int signed int 或者是char 等类型
- 位段的空间上是按照需要以4个字节( int )或者1个字节( char )的方式来开辟的。
- 位段成员在分配内存空间时有两种分配方式——1.从左往右分配,2.从右往左分配
- 位段在对剩余空间不足以分配给一个成员时的处理也有两种方式——1.浪费,2.利用
- 位段涉及很多不确定因素,位段是不跨平台的,注重可移植的程序应该避免使用位段。
PS:这里的分配方式从左往右与从右往左指的是在1个字节中比特位的分配方式
我们需要注意的是,位段成员在进行内存分配时,具体的分配方式是依赖于所处的环境的,也就是说在不同的环境下,位段成员的内存分配方式也有不同,这也就是为什么说位段不具备跨平台性的原因。因为它有太多的不确定性了。
2.2.1 VS中的位段内存分配
因为我所采用的是VS的编程环境,那我就VS为例来给大家介绍一下位段的一种内存分配方式:
- 在VS环境下,位段在进行内存分配时,是从右往左分配内存;
- 当剩余的空间不够时,会直接将其浪费,而申请一块新的内存空间
- 由于位段的内存分配是采用的比特位进行分配的,因此成员存储的数值对应的比特位超过成员被分配的比特位时,会舍弃掉高位的比特位保留低位;
在了解了VS中的位段内存分配后,接下来我们就来通过图片来进行理解,如下所示:
这里要注意,在VS中,数据的存储是采用的小端存储,这里我之所以将大端存储的形式展示出来,是为了帮助不了解小端存储与大端存储的朋友来进行理解。
Tips: 数据采用小端存储就是倒着存(逆序存储),数据采用大端存储就是顺着存(顺序存储)。
既然VS是从右往左分配的内存空间,那么聪第一个字节开始,各个位段成员就会以此被分配对应数量的比特位的空间,也就是上图中所展示的一样。
在第一个字节中,由于给前三个成员分配完比特位后,只剩下两个比特位了,并不能满满足成员d的要求,因此剩余的两个比特位将会被舍弃,从而开辟一个新的空间来给d分配比特位;
同理,在第二个字节中,当给d分配完比特位后,剩余4个比特位,并不满足e的要求,因此剩余的四个比特位会被舍弃,从而开辟一个新的空间来给e分配比特位。
这也就是为什么在VS中,虽然我们只分配了15个比特位,但是该位段却占用了3个字节的空间。
2.2.2 VS位段分配方式的验证
接下来我们就来验证一下在VS中,位段的分配方式是否真如我们分析的那样。具体的验证方式很简单,我们只需要给位段成员依次赋予一个值,然后再来看该位段变量在内存空间中是如何存储这些数值的就行,如下所示:
void test6() {
s.a = 10;
//10——1010——超过了1个比特位,舍弃高位,保留低位
//s.a最终存放0
s.b = 12;
//12——1100——超过了2个比特位,舍弃高位,保留低位
//s.b最终存放00
s.c = 14;
//14——1110——超过了3个比特位,舍弃高位,保留低位
//s.c最终存放110
s.d = 16;
//1 0000——超过了4个比特位,舍弃高位,保留低位
//s.d最终存放0000
s.e = 18;
//1 0010——满足5个比特位
//s.e最终存放10010
}
现在我们给这5个成员依次赋予了一个值,按照我们的分析来看,最终内存中按小端存储存放的比特位应该是:
0011 0000 0000 0000 0001 0010
其对应的16进制表示应该是:
0011 0000——30
0000 0000——00
0001 0010——12
下面我们就来通过内存窗口一趟究竟,如下所示:
从内存窗口中我们可以看到,前三个字节中存储的内容正好是我们前面分析的数据300012
,这个测试也进一步证明了VS中的位段的内存分配是从左往右进行分配,并且不足的比特位将会被浪费。
2.2.3 结构体与位段在内存上的差距
现在又一个问题,如果我这里定义的是结构体而不是位段,那么此时结构体所占的内存空间应该是多大呢?如下所示:
可以看到,同样的成员类型,在结构体中需要5个字节的空间,而在位段中只需要3个字节的空间,从这个例子我们就能很清晰的感受到位段在内存上的优势——通过位段确实可以在一定程度上很好的节省内存空间。
2.3 位段的跨平台问题
了解了位段在VS中的内存分配方式之后,下面我们就来谈一谈位段的跨平台问题。
前面我们一直有在强调,位段是不能跨平台的,那为什么不能跨平台能,大致原因可以总结为4点:
- int 位段被当成有符号数还是无符号数是不确定的。
- 位段中最大位的数目不能确定。
- 16位机器最大16
- 32位机器最大32
- 当我们在32为机器下给位段成员分配了27个比特位后,在16位机器会出问题。
- 位段中的成员在内存中从左向右分配,还是从右向左分配标准尚未定义。
- 现已知的在VS环境下,位段成员的内存分配是从左往右分配
- 当一个结构包含两个位段,第二个位段成员比较大,无法容纳于第一个位段剩余的位时,是舍弃
剩余的位还是利用,这是不确定的。- 现已知的在VS环境下,是舍弃剩余的位
因此位段是不具备跨平台的能力的。
2.4 位段的应用
既然位段不能跨平台,那为什么要有位段这个数据结构呢?这是因为在一些特定的场合下,位段能够节省空间的优点是急需的。如在IP网络协议中,IP数据报的格式,就是精确到比特位来进行相应数据的存储的,如下所示:
可以看到在这种实际应用场景下,位段就相比结构体来说会更加的有优势。
2.5 位段使用的注意事项
在位段中由于每个成员的内存空间都是通过比特位进行分配的,但是在内存中地址是给每个字节的空间大小,而不是给比特位的,因此,当有多个位段成员共用一个字节空间时,它们的起始地址都会是同一个地址。再细究一下的话就是除了该空间中的第一个成员有地址外,其它的成员是没有自己的内存地址的。
因此,当我们要给位段成员进行赋值时,我们是不能通过取地址操作符&
来完成赋值操作的,如scanf
函数。
那如果我们想要通过输入的形式给位段成员赋值,那应该怎么做呢?
在这种情况下我们可以借助第三方变量的形式来完成赋值操作,如下所示:
可以看到此时我们通过变量x就很好的完成了对位段成员e的赋值操作。而且从测试结果中我们还可以获得一条信息——char
在VS中被解释为有符号字符类型。
2.6 小结
下面我们就来给位段的这一部分内容做个小结:
- 位段作为一种特殊的结构体,在一定程度上位段可以在达到与结构体相同效果的同时,还能够节省空间;
- 因为在不同的环境中,位段的实现方式有所不同,因此位段不具备跨平台性
- 由于位段的成员是按比特位进行内存分配的,所以在给位段成员进行赋值时,不能使用取地址操作符;
三、t2
所占内存大小的分析
一转眼就到了本文的末尾了,前面博主留个各位的问题,大家有暂停下来思考吗?如果你有认真思考的话那就太棒了,如果你没有思考也没关系,接下来我们就来一起分析一下t2
所占内存空间的大小。
在分析之前,我们还是先把t2
的声明展示一下,如下所示:
typedef struct test2 {
char a;
int c;
char b;
}t2;
在t2
中,a,b,c的对齐数分别是1、1、4,那也就是说该结构体的最大对齐数为4,所以结构体的总大小应该为4的倍数;
由内存对齐的规则1可知,第一个成员a的偏移量为0,并且a所占空间大小为1个字节,那么就是说明第二个对象是从偏移量为1的地址开始寻找自己的对齐地址;
在该结构体中,第二个对象是整型,根据规则2,在VS这种默认对齐数为8的环境下,整型成员变量所在偏移量的地址应该是4的整数倍,如果从1开始寻找的话,那么1、2、3这3个内存空间是直接被浪费掉的,因此c的对齐地址为偏移量为4的地址,c所占的空间为4、 5、 6、 7这四个字节的空间,那么就是说明第三个成员变量是从偏移量为8的地址开始寻找自己的对齐地址;
t2
的第三个成员变量为字符类型,也就是对齐数为1,那么它所在的地址应该是1的整数倍,因此,第三个成员的地址就应该是偏移量为8的地址。
那也就是说从第一个成员变量到第三个成员变量,它们共占据了9个字节的空间大小,根据我们前面的分析可知,整个结构体的内存空间大小应该是4的整数倍,因此我们如果从10个字节往后找的话,那我们能够最先找到的4的整数倍的空间大小正好就是12个字节。如下图所示:
接下来我们来通过offsetof
来验证一下成员的偏移量是不是 a = 0, c = 4, b = 8
,如下所示:
可以看到,和我们的分析是一致的,因此我们不难得到结论,之所以t2
所占的内存空间要大于t1
,是因为第三个成员的对齐位置发生了变化,导致整个空间大小超过了8个字节,达到了9个字节,为了保证结构体的大小为4的整数倍,因此,需要继续浪费一部分空间。
结语
今天的内容到这里就全部结束了,在下一篇内容中我们将介绍《联合体与枚举类型》的相关内容,大家记得关注哦!如果大家喜欢博主的内容,可以点赞、收藏加评论支持一下博主,当然也可以将博主的内容转发给你身边需要的朋友。最后感谢各位朋友的支持,咱们下一篇再见!!!