文章目录
一、结构体内存对齐
🍉在了解了结构体类型的声明及变量创建之后,我们还需要学习关于结构体的大小(字节)及结构体在内存中是如何存储的。
1.对齐规则
我们先来看两个结构体类型:
struct Stu1
{
char a;
int b;
char c;
}s1;
struct Stu2
{
int a;
char b;
int c;
short d;
}s2;
我们可能会想,对于s1所占的内存大小应该是1+4+1=6;s2所占内存大小(字节)应该是4+1+4+2=11。而通过计算机计算出它们所占内存的大小却不是如此:
#include <stdio.h>
struct Stu1
{
char a;
int b;
char c;
}s1;
struct Stu2
{
int a;
char b;
int c;
short d;
}s2;
int main()
{
printf("%zd\n",sizeof(s1));
printf("%zd\n",sizeof(s2));
return 0;
}
程序运行的结果:
这里计算出变量s1所占内存的大小为12(单位字节),变量s2所占内存大小为16;为什么是这样呢?这个内存大小到底是怎么计算出来的呢?这就引出了我们即将要讲的知识点:🍋内存对齐🍋。
我们要先掌握结构体的内存对齐规则,之后我们才能学会计算结构体类型的大小:
● 1.结构体的第一个成员对齐到和结构体变量起始位置偏移量为0的地址处。
● 2.其他成员变量要对齐到某个数字(对齐数)的整数倍的地址处。
🍓对齐数 = 编译器默认的一个对齐数 与 该成员变量大小的较小值(两者中的较小值)
🔗VS编译器的默认对齐数为8
🔗Linux中gcc没有默认对齐数,对齐数就是成员自身的大小
● 3.结构体总大小为最大对齐数(结构体中每个成员变量都有一个对齐数,取所有对齐数中最大的)的整数倍。
● 4.如果结构体中有嵌套结构体的情况,则嵌套的结构体成员对齐到自己成员中最大对齐数的整数倍处,结构体的整体大小就是所有最大对齐数(含嵌套结构体中成员的对齐数)的整数倍。
了解了结构体的内存对齐规则,我们再来看上面的两个结构体变量s1和s2。
(1)首先看结构体变量s1:
struct Stu1
{
char a;
int b;
char c;
}s1;
假设编译器在内存中为结构体变量s1开辟了如下一块内存空间:
根据结构体的内存对齐规则:
1.假设结构体变量s1在内存中存储的起始位置,就是上图中上虚线处的第一个字节。则该结构体类型的第一个成员a要对齐到偏移量为0的地址处,就是第一个字节处了(即从第一个字节开始存放)。这里要理解偏移量是啥意思?偏移量就是相对于起始位置的偏移数量。因为该结构体的第一个成员是char类型的变量,只占一个字节,所以就存储在起始位置的第一个字节处(起始位置的第一个字节相对于起始位置本身的偏移量就是0啦)。
2.剩下的其他成员要对齐到某个数字的整数倍的地址处,这个某个数字就是对齐数,我使用的编译器是VS,默认对齐数是8,该结构体的第二个成员是int类型(占4个字节)的变量b,则对齐数就是取两者之间的较小值,就是4了。则整型变量b的存储位置要从4的整数倍数的地址处开始存放,那就从偏移量为4的字节处开始存放,共占4个字节。该结构体的第三个成员c是char类型的变量,占一个字节,VS编译器的默认对齐数是8,取两者之间的较小值就是1,那么只要是大于1的整数都是1的整数倍,所以变量c就紧接着存放在变量b的后面。
3.最后我们计算这个结构体变量的总大小,总大小(字节)要为最大对齐数的整数倍,这个最大对齐数是所有成员当中最大的那个对齐数。则这三个成员中最大的对齐数是4(第一个成员的对齐数是1,因为相较于VS编译器的默认对齐数是8),最后一个成员c是存储到了偏移量为8的位置,即现在的大小为9,而9不是4的倍数,所以还要往下再浪费3个字节,到12就是4的倍数了,所以结构体变量s1的总大小就为12(单位字节)。
(2)再看结构体变量s2:
struct Stu2
{
int a;
char b;
int c;
short d;
}s2;
也是假设编译器在内存中为结构体变量s2开辟了如下一块空间:
🥝根据结构体的内存对齐规则:
1.首先结构体的第一个成员a对齐到偏移量为0的地址处,第一个成员为int类型的变量,所以从起始位置的第一个字节开始往后占4个字节。
2.剩下的其他成员要对齐到各自对齐数的整数倍处,该结构体的第二个成员是char类型的变量b,占一个字节,VS编译器的默认对齐数是8,取两者中的较小值,则对齐数是1,大于1的整数都是1的整数倍,所以直接就排在偏移量为4的位置。第三个成员为int类型的变量c,对比VS编译器的默认对齐数是8,取两者中的较小值,则第三个成员的对齐数是4,从偏移量为8的位置开始往后存储4个字节。还有最后一个成员,为short类型(占两个字节)的变量d,对比VS编译器的默认对齐数是8,取两者中的较小值,则最后一个成员的默认对齐数是2,刚好12就为2的整数倍,则从偏移量为12的位置往后占据2个字节。
3.最后计算该结构体变量的总大小,总大小要为所有成员中最大对齐数的整数倍。
由上图可知,所有成员中的最大对齐数为4。而存储的最后一个成员的最后一个字节是在偏移量为13的位置,则现在的大小为14个字节,但14并不是4的倍数,所以还要浪费2个字节,到16个字节就为4的倍数了,所以该结构体变量s2的总大小就为16(单位字节)。
结构体中嵌套结构体的情况
如果一个结构体中嵌套了一个结构体,那要怎么计算这个结构体的大小呢?
#include <stdio.h>
struct Stu1
{
char a;
int b;
char c;
}s1;
struct Stu2
{
char d;
struct Stu1 s1;
double e;
}s2;
int main()
{
printf("%zd\n",sizeof(s2));
return 0;
}
程序运行结果:
还是假设编译器在内存中为结构体变量s2申请了如下一块内存空间:
根据结构体的内存对齐规则:
1.首先该结构体的第一个成员 char d 对齐到偏移量为0的地址处,则起始位置的第一个字节被变量d占掉。
2.该结构体变量的第二个成员是一个结构体,而这个结构体变量的大小在上面我们已经计算过了,大小为12个字节,根据结构体内存对齐规则的第四条:🍐如果结构体中有嵌套结构体的情况,则嵌套的结构体成员对齐到自己成员中最大对齐数的整数倍处。🍐这里嵌套的结构体变量s1的成员中最大对齐数是4,则从4的倍数处开始存放变量s1。由上图偏移量为4的位置正好是4的倍数,所以从偏移量为4的位置开始往后占12个字节。还剩下最后一个成员,该成员是double类型的变量e,由VS编译器的默认对齐数是8,取两者中的最小值,所以对齐数就为8。所以变量e从8的倍数处开始存放,由上图可知,偏移量为16的位置正好是8的倍数,则从偏移量为16的位置处开始往后的8个字节存储变量e。
3.最后计算该结构体变量的总大小,由于该结构体中嵌套了一个结构体,所以按照内存对齐规则:🍑结构体的整体大小就是所有成员中最大对齐数(含嵌套结构体中成员的对齐数)的整数倍。🍑嵌套的结构体也是按照内存对齐规则,先找出嵌套结构体中所有成员各自的对齐数,再找出其中的最大对齐数。最后再来看该结构体的总大小,整体大小就是所有成员中最大对齐数的整数倍,这里的最大对齐数,就包含了刚刚找出的嵌套结构体中的最大对齐数。
由上图可以看出,该结构体的最大对齐数就是8,则该结构体的大小就是8的整数倍。而该结构体的最后一个成员 double e 存储的最后一个字节是在偏移量为23的位置,即现在的大小就为24,而24刚好就为8的倍数,所以该结构体的总大小就为24(单位字节)。
🍯🍯🍯如果在计算结构体变量大小时,怕混淆偏移量和实际内存字节数,我们可以直接记住,结构体的第一个成员永远是从偏移量为0的位置处开始存放,其他剩下的成员,要对齐到自己对齐数的整数倍处,直接看偏移量就可以了,如果偏移量是自己对齐数的整数倍,那就从该位置处开始存放。最后计算整个结构体大小时,要看的是实际的字节个数,如果实际字节个数是最大对齐数的整数倍,那这个实际字节数就是结构体类型的总大小(单位字节)。
🥔🥔🥔还有一点就是,如果结构体中有成员是数组的情况。其实非常简单,因为数组在内存中是连续存放的,在找到对齐数后,按数组的大小从对齐数的整数倍处开始存放。对于数组成员的对齐数,就看数组元素类型的大小与编译器默认对齐数之间的较小值即可。举一个例子就可以理解了:
#include<stdio.h>
struct S
{
int arr[2];
char c;
short s;
char ch[5];
};
int main()
{
struct S s = { 0 };
printf("%zd\n", sizeof(s));
return 0;
}
这个结构体变量的大小计算得:
画出这个结构体变量的内存布局示意图:
首先该结构体类型的第一个成员是数组arr,这个数组的大小为2*sizeof(int)=8(单位字节)。这个数组的元素类型为int类型(4字节),VS编译器的默认对齐数是8,则取两者之间的较小值4,则第一个成员的对齐数就为4。结构体的第一个成员永远从偏移量为0的位置处开始存放。注意:数组在内存中是连续存放的。由上图,数组arr一就开始占去了8个字节。第二个成员为char类型的变量c,该成员的对齐数是1。所以紧接着往后占据一个字节。第三个成员为short类型(2字节)的变量s,该成员的对齐数是2,而偏移量为9的位置并不是2的倍数,所以浪费两个字节,从偏移量为10的位置开始存放变量s,往后占据两个字节。最后一个成员是数组ch,该数组的大小为5*sizeof(char)=5,数组元素的类型为char(1字节),所以该成员的对齐数是1,紧接着往后占据5个字节。
最后计算这个结构体变量的总大小,总大小要为最大对齐数的整数倍,这四个成员中的最大对齐数为4,而最后一个成员ch存储的最后一个字节是在偏移量为16的位置,现在的大小为17个字节,但17并不是4的倍数,所以还要再浪费3个字节,到20就是4的倍数了,所以这个结构体变量的总大小就为20。
二、为什么要进行内存对齐?
🥑🥑🥑1.平台原因(移植原因):
不是所有的硬件平台都能访问任意地址上的任意数据;某些硬件平台只能在某些地址处取某些特定类型的数据,否则会抛出硬件异常。
🍆🍆🍆2.性能原因:
数据结构(尤其是栈)应该尽可能地在自然边界上对齐。原因在于,为了访问未对齐的内存,处理器需要作两次内存访问;而对齐的内存访问仅需要一次访问。假设一个处理器总是从内存中取8个字节,则地址必须是8的倍数。如果我们能保证将所有的double类型的数据地址都对齐成8的倍数,那么就可以用一个内存操作来读或者写值了。否则,我们可能需要执行两次内存访问,因为对象可能被分放在两个8字节的内存块中。
总体来说:结构体的内存对齐就是拿空间来换取时间的做法。若有如下一个结构体:
struct S
{
char a;
int b;
};
如果是按照内存对齐的情况,则该结构体总共占8个字节。如果如内存不对齐的情况,那就紧接着往下排就行了,该结构体就占5个字节。
由上图,如果机器是32位平台,则有32根数据总线,则一次就可以访问4个字节的数据,如果是内存对齐的情况下,第一次访问就读取到了变量a的数据,第二次访问就可以完全读取到变量b的数据。如果是内存不对齐的情况,一次读取4个字节的数据,那么变量a是读取到了,但是变量b只读取到了前3个字节,还有一个字节没有读取到,所以还要再读一次,才能完全读取到变量b中存储的数据。即在内存不对齐的情况下对于变量b要进行两次访问才能完全读取到其存储的数据。相比较于内存对齐的情况,内存不对齐的情况下访问数据可能会浪费时间。而内存对齐的情况下虽然浪费了空间,但是时间效率得到了提高。
🍒🍒那结构体如何做到既满足对齐,又节省空间呢?答案是:让占用空间小的成员尽量集中在一起。
#include <stdio.h>
struct Stu1
{
char a;
int b;
char c;
};
struct Stu2
{
char a;
char b;
int c;
};
int main()
{
printf("%zd\n",sizeof(struct Stu1));
printf("%zd\n", sizeof(struct Stu2));
return 0;
}
程序运行结果:
可以看到上面的两个结构体,是有一样的成员,但是第二个结构体Stu2的大小就比第一个结构体Stu1的小。原因就是我们把占用空间小的成员集中在了一起,这样就节省了空间。
1.修改默认对齐数
在上面我们讲到了对于编译器有自己的默认对齐数,那这个对齐数可不可以修改呢?答案是可以的。修改默认对齐数要用到一个预处理指令#pragma pack( ) 。而括号中是我们想要的对齐数,这个对齐数最好是2的次方数。
#include <stdio.h>
#pragma pack(1)//修改编译器默认对齐数为1
struct S
{
char c1;
int i;
char c2;
};
#pragma pack()//取消设置的对⻬数,还原为默认
int main()
{
printf("%zd\n", sizeof(struct S));
return 0;
}
程序运行结果:
上面输出该结构体的大小为6,这种就跟没有默认对齐数的结果是一样的,因为修改了VS编译器的默认对齐数为1。则每个成员的对齐数就都为1,所有大于1的整数都是1的倍数,所以该结构体在内存中就是紧挨着存储的。注意:Linux编译器下,是没有默认对齐数的。对齐数就是成员自身的大小。所以记住不要在Linux编译器下使用这个预处理指令。🥖🥖🥖当结构体在对齐方式不合适的时候,我们就可以自己更改默认对齐数啦。
三、结构体传参
结构体传参就是将结构体作为参数传递给函数,那对于函数传参有两种形式,一种是传值,还有一种就是传址。
#include<stdio.h>
struct S
{
int data[1000];
int num;
};
struct S s = { {1,2,3,4}, 1000 };
//结构体传参
void print1(struct S s)
{
int i = 0;
for (i = 0; i < 4; i++)
{
printf("%d ", s.data[i]);
}
printf("%d\n", s.num);
}
//结构体地址传参
void print2(struct S* ps)
{
int i = 0;
for (i = 0; i < 4; i++)
{
printf("%d ", ps->data[i]);
}
printf("%d\n", ps->num);
}
int main()
{
print1(s);//传结构体
print2(&s); //传地址
return 0;
}
程序运行结果:
上面两种形式的函数传参,打印的结果都是一样的。则哪一种传参更好呢?答案是第二种:传地址。
🍎🍎🍎原因:
1.函数传参的时候,参数是需要压栈的,会有时间和空间上的系统开销。
2.如果传递一个结构体对象的时候,结构体过大,参数压栈的系统开销比较大,所以会导致性能的下降。
可能有同学会说,如果采用传地址的这种函数传参形式,那要是结构体中的数据不小心被修改了怎么办?我们并不想修改结构体中的数据。那怎么办呢?其实我们可以在函数传参的时候,在前面加上const修饰即可(void print2(const struct S* ps),这样通过指针访问结构体就不会修改到结构体中的数据了。
🥒🥒🥒结论:
结构体传参的时候,要传结构体的地址。
四、结构体实现位段
上面我们讲完了结构体在内存中的存储形式,紧接着我们还要讲一个知识点,那就是结构体实现位段的能力。
1.什么是位段?
🍏🍏位段又称为位域是以位(bit)为单位来定义结构体(或联合体)中的成员变量所占的空间。C语言中没有专门的位段类型,位段的定义要借助于结构体,即以二进制位为单位来定义结构体成员所占存储空间。从而就可以按照"位"来访问结构体中的成员。[引用]🍏🍏
位段的声明和结构体是类似的,但是有两个不同:
🥥1.位段的成员必须是 int、unsigned int 或signed int,在C99中位段成员的类型也可以选择其他类型
🥥2.位段的成员名后边有一个冒号和一个数字
例如:
struct S
{
int _a:2;
int _b:5;
int _c:10;
int _d:30;
};
S就是一个位段类型。那位段S所占内存的大小是多少?我们通过sizeof(struct S)计算得这个位段类型的大小为如下结果:
先解释一下结构体成员冒号后边的数字的含义:比如上面结构体的第一个成员_a冒号后边的2表示该变量只占2个bit位。虽然_a变量是一个整型(4字节),但是冒号后边的2规定它只能存2个bit位的数据。剩下的成员_b、_c、_d冒号后面的数字也是相应的占5、10、30个bit位。为什么要出现位段呢?试想如果对于上面结构体中第一个成员_a的值如果是0、1、2或者3,他们的二进制数分别是00、01、10、11,那么只需要2个bit位就可以存储了,所以这里限制_a只占两个bit。如果在32位平台下,不用位段的形式来存储整型0、1、2、3,那就有30个bit位被浪费掉了。所以出现了位段的知识点。只要记住位段是专门用来节省内存的。
2.位段的内存分配
学习了位段的意义了,现在要知道上面位段类型的大小是如何计算出来的,就要先知道位段类型的存储规则:
🍅1.位段的成员可以是int、unsigned int、signed int或者是char等类型。
🍅2.位段在空间上是按照需要以4个字节(int)或者1个字节(char)的方式来开辟的。
🍅3.位段涉及很多不确定的因素,位段是不跨平台的,注重可移植的程序应该避免使用位段。
第一条规则意思就是:一般位段类型里面放的都是整形家族或者是字符类型家族的成员。对于第二条规则,意思是:比如说位段类型里面放的都是整型类型(4字节)的成员,那么编译器就先为第一个成员开辟4个字节的空间,根据冒号后面限定的二进制位数进行数据存储,当存储到空间不够用了,再继续申请空间。不怎么能够理解这里的意思,没事。通过下面的例子来讲解:
#include<stdio.h>
struct S
{
char _a:3;
char _b:4;
char _c:5;
char _d:4;
};
int main()
{
struct S s = { 0 };
s._a = 10;
s._b = 12;
s._c = 3;
s._d = 4;
return 0;
}
那么上面这个位段类型的大小要怎么计算呢?假设编译器为该结构体S申请如下一块空间:
首先该结构体类型的第一个成员是char类型(1个字节)的变量_a,那就先为该变量申请一个字节的空间,记住一个字节等于8个bit位。其次该变量冒号后边的数字是3,则只占三个bit位,如上图。为什么在这一个字节的空间里要从右往左存储呢?因为C语言没有规定位段是要从左往右存储还是从右往左存储,那就要看编译器了。我使用的编译器是VS,而VS是从右往左存储的,所以这里是从右往左存储。下一个成员冒号后边的数字是4,而一开始申请的一个字节空间只被第一个成员占去了3个bit位,还有5个bit位,足够存储第二个成员,所以紧接着又占去4个bit位。那这一个字节就只剩下一个bit位了,第三个成员冒号后面的数字是5,那第一次申请的空间就只剩下一个bit位了,不够第三个成员存储,所以再申请一个字节的空间,问题是第一个字节剩下的那个bit位要不要用来存储第三个成员的数据呢?答案是不用(直接浪费掉)。在VS编译器中,当申请的空间不够用来存储剩下的成员时,则剩下的成员就从新申请的空间里开始存储。所以第三个成员就从新申请的一个字节空间里存储,第三个成员要占5个bit位,则申请的一个字节空间就还剩下3个bit位。如上图。最后一个成员的冒号后边的数字是4,而刚刚的一个字节只剩下3个bit位,不够4个比特位,那就再申请一个字节用来存储最后一个成员。如上图,最后一个成员占去4个bit位,还剩下4个bit位。由上图,这个位段类型的大小应该是3个字节。
那是不是3个字节呢?通过sizeof(struct S)计算得到的结果如下:
由上图,确实是3个字节。上面我们对该结构体的各个成员进行了赋值,我们通过逐过程调试程序,在内存中查看结构体变量s中存储的值是多少:
通过&s我们看到了变量s中存储的数据如上图,有四列,但是只有三列有数据,因为这个位段类型我们刚刚计算过,大小就为3个字节,所以这里只有3列有效数据,但是这个数据是怎么存进去的呢?为什么是上面的数字。
首先我们看结构体变量s的第一个成员_a被赋值为了10,而10的二进制序列为1010,但是🥜第一个成员已经被限制只能存储3个bit的数据,那这里的1010就要发生截断,只能存储010这三个bit位的数据。🥜同样的第二个成员被赋值为了12,12的二进制序列为1100,而第二个成员能存储4个bit位的数据,所以不用发生截断,刚好能存储完这4位。第三个成员被赋值为了3,3的二进制序列为0011,第三个成员所占位数为5,那这里的0011还不够5位,不够的高位就用0来补位即可。最后一个成员被赋值为了4,4转化为二进制数就为0100,最后一个成员只能存储4位,这里也是刚好够4位。如下图所示:
这个位段类型是占3个字节,刚刚我们浪费掉的那些bit位用0来补位即可,所以在内存中看到的3列就是62 03 04。
3.位段的跨平台问题
🌽1.int位段被当成有符号数还是无符号数是不确定的。
🌽2.位段中最大位的数目不能确定。(16位机器最大为16,32位机器最大为32,如果写成27,在16位机器下会出问题)
🌽3.位段中的成员在内存中从左向右分配,还是从右向左分配,标准尚未定义。
🌽4.当一个结构包含两个位段,第二个位段成员比较大,无法容纳于第一个位段剩余的位时,是舍弃剩余的位还是利用剩余的位,这是不确定的。
int类型的成员,在跨平台时会被当成有符号数还是无符号数是不确定的。早期是有16位机器的,在16位机器平台下,整型只占2个字节,如果结构体成员冒号后面写一个大于16的数字,比如27。那一次申请的2个字节空间就存不下这么大的数据,那机器就会报错。剩下的第三第四条,上面我们都遇到过。总的来说,位段并不适用于跨平台,位段有他存在的优势,也有他的不足,我们应该根据不同的平台来写不同的代码。
总结:
跟结构相比,位段可以达到同样的效果,并且可以很好的节省空间,但是有跨平台的问题存在。
4.位段的应用
下图是🍈网络协议🍈中,IP数据报的格式,我们可以看到其中很多的属性只需要几个bit位就能描述,这里使用位段,能够实现想要的效果,也节省了空间,这样网络传输的数据报大小也会较小一些,对网络的畅通是有帮助的。
就像寄邮件一样,比如你要寄一个本书,去邮局后要把书包装起来,在外包装上写上寄件人的信息,寄件人的地址,还有收件人的信息和地址。这样才能寄件。那在网络上传输信息也是一样的,比如你要和某人发信息,就要有源地址和目的地址,还有你要发送的数据信息。可以看到上面的IP数据报就合理的使用了位段,对数据进行了封装,极大的节省了空间,网络的畅通度就会好很多。
5.位段使用的注意事项
🧀🧀🧀位段的几个成员共有同一个字节,这样有些成员的起始位置并不是某个字节的起始位置,那么这些位置处是没有地址的。内存中是每个字节分配一个地址,而一个字节内部的bit位是没有地址的。所以不能对位段的成员使用&操作符,这样就不能使用scanf直接给位段的成员输入值,只能是先输入放在一个变量中,然后将这个变量赋值给位段的成员。如下所示:
#include<stdio.h>
struct S
{
int _a:2;
int _b:5;
int _c:10;
int _d:30;
};
int main()
{
struct S s = { 0 };
scanf("%d", &s._b);//这是错误的
//正确的⽰范
int b = 0;
scanf("%d", &b);
s._b = b;
return 0;
}