目录
[1 · 结构体类型的声明](#1 · 结构体类型的声明)
[1 - 1 · 结构体的声明与简单介绍](#1 - 1 · 结构体的声明与简单介绍)
[1 - 1 - 1 · 结构体变量与初始化](#1 - 1 - 1 · 结构体变量与初始化)
[1 - 2 · 结构体的特殊声明](#1 - 2 · 结构体的特殊声明)
[1 - 3 · 结构体的自引用](#1 - 3 · 结构体的自引用)
[2 · 结构体内存对齐](#2 · 结构体内存对齐)
[2 - 1 · 对齐规则](#2 - 1 · 对齐规则)
[2 - 2 · 为什么存在内存对齐](#2 - 2 · 为什么存在内存对齐)
[2 - 3 · 修改默认对齐数](#2 - 3 · 修改默认对齐数)
[3 · 结构体传参](#3 · 结构体传参)
[4 · 结构体实现位段](#4 · 结构体实现位段)
[4 - 1 · 什么是位段](#4 - 1 · 什么是位段)
[4 - 2 · 位段的内存分配](#4 - 2 · 位段的内存分配)
[4 - 3 · 位段的跨平台问题](#4 - 3 · 位段的跨平台问题)
[4 - 4 · 位段的应用](#4 - 4 · 位段的应用)
[4 - 5 · 位段使用的注意事项](#4 - 5 · 位段使用的注意事项)
C语言中有内置类型,也有自定义类型,内置类型是C语言自带的,自定义类型是我们可以自己设计的。
结构体就是一种自定义类型。
1 · 结构体类型的声明
1 - 1 · 结构体的声明与简单介绍
我们之前在详解操作符那篇中简单介绍过结构体,我们这里快速回顾一下:
结构体是一些值的集合,这些值被称为成员变量,这些成员变量的类型可以是整型,浮点型,数组,指针,甚至是结构体。
结构体的声明:
struct tag
{
member-list;
}variable-list;
拿描述一个学生来举例:
struct student
{
char name[20];
double high;
int age;
};//分号不能丢
1 - 1 - 1 · 结构体变量与初始化
cpp
#include <stdio.h>
struct student
{
char name[20];
double high;
int age;
}s1;//分号不能丢
int main()
{
struct student s1 = { "zhangsan",1.73,18 };//按顺序初始化
struct student s2 = { .age = 19,.name = "lisi",.high = 1.81 };//不按顺序初始化
printf("%s \n", s1.name);
printf("%d \n", s1.age);
printf("%lf \n", s1.high);
printf("%s \n", s2.name);
printf("%d \n", s2.age);
printf("%lf \n", s2.high);
return 0;
}
运行一下:

1 - 2 · 结构体的特殊声明
在声明结构体时,可以不完全的声明。
比如:
cpp
struct
{
int a;
double b;
char c;
}a = { 5,3.14,'x' };
这个结构体在声明的时候省略了标签名(tag),是个匿名结构体。因为不知道这个结构体的标签名,所以要想创建这种结构体的变量,只能在声明的大括号之后。
那么我们这样写:
cpp
#include <stdio.h>
struct
{
int a;
double b;
char c;
}a = { 5,3.14,'x' };
struct
{
int a;
double b;
char c;
}*p;
int main()
{
p = &a;
return 0;
}
这里两个匿名结构体有着同样的成员变量,让指针 p 指向 a ,可以看到,报警告了:

编译器会把上⾯的两个声明当成完全不同的两个类型,所以是⾮法的。
匿名的结构体类型,如果没有对结构体类型重命名的话,基本上只能使用⼀次。
1 - 3 · 结构体的自引用
在一个结构体中包含一个类型为该结构体本身的成员变量可以吗?
比如我们这样写:
cpp
struct Node
{
int data;
struct Node next;
};
那么我们思考一下:如果这样写,sizeof(struct Node) 是多少?
所以这样写是错误的,会导致sizeof(struct Node)无穷大。
所以正确的写法应该是包含一个该结构体的指针,如下:
cpp
struct Node
{
int data;
struct Node* next;
};
在结构体自引用使用的过程中,夹杂了 typedef 对匿名结构体类型重命名,也容易出问题,比如:
cpp
typedef struct
{
int data;
Node* next;
}Node;
这样写是不行的,
因为Node是对前面的匿名结构体类型的重命名产⽣的,但是在匿名结构体内部提前使用Node类型来创建成员变量,这样写是不行的。
正确写法如下:定义结构体不要用匿名结构体。
cpp
typedef struct Node
{
int data;
struct Node* next;
}Node;
这样写的话 将结构体重命名为 Node 这样以后想使用这个结构体类型时,写Node即可。
2 · 结构体内存对齐
结构体的大小应该如何计算,是否是成员变量的大小相加呢?
这就涉及到 结构体内存对齐 了。
2 - 1 · 对齐规则
- 结构体的首个成员变量需要对齐到结构体起始地址偏移量为0的地址
2.结构体的其他成员变量需要对齐到对齐数的整数倍的地址(由于结构体的起始位置就是对齐的边界,所以只需要找对齐数)
3.对齐数是编译器的一个默认值与该成员大小中的较小值,VS2022中默认值为8字节
4.结构体的总大小需要为最大对齐数(全部成员变量的对齐数中最大的那个)的整数倍
5.如果有嵌套结构,嵌套的结构体成员对齐到自己的成员中最大对齐数的整数倍处,结构
体的整体大小就是所有最大对齐数(含嵌套结构体中成员的对齐数)的整数倍。
下面我们举个栗子:
cpp
#include <stdio.h>
struct S1
{
char c1;
int i;
char c2;
};
int main()
{
printf("%d\n", sizeof(struct S1));
return 0;
}
我们画个图方便理解。

结构体的首成员变量是 c1,放在偏移量为0的地址处,占1字节,接下来是 i ,int 的大小是4字节,默认对齐数是8,所以 i 的对齐数为4,放在偏移量为4的倍数的地方,向后离得最近的是偏移量为4的地方,占4个字节,最后是 c2 ,大小是1字节,默认对齐数是8,所以 c2 的对齐数为1,放在向后里的最近的偏移量为1的倍数的地方,是偏移量为8的地方,占1字节。
那么此时总字节数为 从偏移量为0到偏移量为8的这一块,总字节数为9,但是最大对齐数是4,结构体的整体大小需要为4的倍数,所以该结构体大小为12。
运行一下看看:

如果我们调换一下成员变量的顺序,结果可能是不一样的。
比如这里我们将 i 和 c2 进行调换:
cpp
#include <stdio.h>
struct S1
{
char c1;
char c2;
int i;
};
int main()
{
printf("%d\n", sizeof(struct S1));
return 0;
}
运行一下:

2 - 2 · 为什么存在内存对齐
那我们上面第一个例子来说,存放1个int 类型,2个char类型,理论上只需要6个字节,但结构体却占了12个字节,这不是白白浪费了6个字节吗
那么为什么存在内存对齐呢?
大部分的参考资料都是这样说的:
平台原因 (移植原因):
不是所有的硬件平台都能访问任意地址上的任意数据的;某些硬件平台只能在某些地址处取某些特定类型的数据,否则抛出硬件异常。
性能原因:
数据结构(尤其是栈)应该尽可能地在自然边界上对齐。原因在于,为了访问未对齐的内存,处理器需要作两次内存访问;而对齐的内存访问仅需要⼀次访问。假设⼀个处理器总是从内存中取8个字节,则地址必须是8的倍数。如果我们能保证将所有的double类型的数据的地址都对齐成8的倍数,那么就可以用⼀个内存操作来读或者写值了。否则,我们可能需要执行两次内存访问,因为对象可能被分放在两个8字节内存块中。
总体来说 :结构体的内存对齐是拿空间 来换取时间的做法。
那我们在设计结构体的时候,既要满足对齐,又要节省空间,那就可以让内存占用小的成员尽量集中在一起,合理分配。
就像上面我们的两个例子,成员是相同的,但是顺序不同,导致了结构体的大小不同。
2 - 3 · 修改默认对齐数
#pragma 这个预处理指令,可以改变编译器的默认对齐数。
cpp
#pragma pack(1)
这样写就是将默认对齐数设置成了1
那我们这样写:
cpp
#include <stdio.h>
#pragma pack(1)
struct S1
{
char c1;
int i;
char c2;
};
int main()
{
printf("%d\n", sizeof(struct S1));
return 0;
}
修改了默认对齐数,那么 i 的 对齐数就是1了,这样的话结构体占的大小就是6了。
运行一下:

结构体在对齐方式不合适的时候,我们就可以自己修改默认对齐数。
3 · 结构体传参
结构体传参可以传结构体,也可以传地址。如下:
cpp
#include <stdio.h>
struct S
{
int a;
char c[20];
};
void Print1(struct S s)
{
printf("%d\n", s.a);
printf("%s\n", s.c);
}
void Print2(struct S* ps)
{
printf("%d\n", ps->a);
printf("%s\n", ps->c);
}
int main()
{
struct S s1 = { 10,"abcdef" };
Print1(s1);//传结构体
Print2(&s1);//传结构体的地址
}
运行一下:

可以看到,这两种传参方法都是可行的,但是 我们的 Print1 和 Print2 哪个更好一点呢?
答案是 Print2 。
这是因为函数传参的时候,参数需要压栈,会有时间和空间上的系统开销。
如果传递⼀个结构体对象的时候,结构体过大,参数压栈的的系统开销比较大,所以会导致性能的下降。
所以对于结构体的传参,首选传结构体的地址。
4 · 结构体实现位段
结构体是可以用来实现位段的
4 - 1 · 什么是位段
位段是基于结构体的。
位段的声明和结构是类似的,有两个不同:
- 位段的成员必须是 int 、 unsigned int 或 signed int ,在C99中位段成员的类型也可以选择其他类型。
- 位段的成员名后边有⼀个冒号和⼀个数字。
比如:
cpp
struct S
{
int a : 2;
int b : 5;
int c : 20;
int d : 30;
};
S就是一个位段类型。
位指的是二进制位,冒号后的数字指的是占多少 bit 位。
原本 int 类型的数据占4字节 也就是32bit位 但有时我们实际使用不会将32位全部用上,这就造成了空间的浪费。
int a : 2;
这样 a 就只占2个bit位。
所以位段是用来节省内存的。
那么S的大小是多少呢,我们测试一下:
cpp
#include <stdio.h>
struct S
{
int a:2;
int b : 5;
int c : 20;
int d : 30;
};
int main()
{
printf("%zd\n", sizeof(struct S));
return 0;
}
运行一下:

4 - 2 · 位段的内存分配
位段的成员可以是 int unsigned int signed int 或者是 char 等类型
位段的空间上是按照需要的方 式来开辟的。
比如放入一个 int 类型,如果不够,就开辟四个字节的空间,放入一个char类型,如果不够,就开辟一个字节的空间。
位段涉及很多不确定因素,位段是不跨平台的,注重可移植的程序应该避免使用位段。
我们举个栗子来一步步看是如何分配的:
cpp
struct S
{
char a:3;
char b:4;
char c:5;
char d:4;
};
假设我们先拿一块内存空间出来。

此时要分配第一个成员变量 a 的空间,但现在没有空间,所以要开辟一块,由于 a 是char 类型,所以开辟8 bit 位空间。假设黄色是我们第一次开辟的空间:

接下来我们要给a 分配空间,但是有一个问题,应该从左向右分配还是从右向左?很遗憾,C语言中对此无明确规定,所以不同的编译器是有差异的,在我使用的VS2022中是从右向左,所以我们这里用从右向左演示,给a 分配3bit位空间:

接下来是给b分配空间,需要分配4个bit位,我们所开辟的空间足够,那就接着分配:

接下来是给c分配空间,由于我们开辟的空间现在仅剩1bit位没被占用,此时c是放不下的,那么就要再次开辟,根据类型开辟8bit位(假设紫色是第二次开辟的空间),然后给c分配5bit位空间。但是此时又有一个问题,我们第一次开辟的空间有1bit位没有被使用,那么这个剩余的空间是浪费掉还是继续使用呢?在VS2022中,是浪费掉,所以给c分配空间如下图:

最后给 d 分配空间由于第二次开辟的空间不足以分配d的4bit位,所以再次开辟,舍弃剩余3bit位。如下:

那么此时我们也能看到这个位段的大小是3个字节,测试一下看看:
cpp
#include <stdio.h>
struct S
{
char a : 3;
char b : 4;
char c : 5;
char d : 4;
};
int main()
{
printf("%zd\n", sizeof(struct S));
return 0;
}
运行一下:

那么我们最开始的位段的大小为8现在也能够理解了,这里简单画一下:

那么现在我们再运行一下下面这段代码:
cpp
#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;
}
此时内存中存的是什么呢?我们分析一下:
给成员变量a赋值10,10的二进制表示是 00001010,但是a只被分配了3bit位,所以发生截断,存010。
给 b 赋值12,12二进制表示是 00001100,存 1100。
同理,c和d 分别存 00011 和 0100
对于那些被浪费的空间,放的就是0,没有更改。
我们画个图便于理解:

然后二进制转十六进制,为 0x62 03 04
我们通过调试看看:

可以看到,的确是 0x620304
4 - 3 · 位段的跨平台问题
- int 位段被当成有符号数还是 无符号数是不确定的。
- 位段中最大位的数目不能确定。(16位机器最大16,32位机器最大32,写成27,在16位机器会
出问题。 - 位段中的成员在内存中从左向右分配,还是从右向左分配,标准尚未定义。
- 当⼀个结构包含两个位段,第⼆个位段成员比较大,无法容纳于第⼀个位段剩余的位时,是舍弃
剩余的位还是利用,这是不确定的。
所以 跟结构体相比,位段可以达到同样的效果,并且可以很好的节省空间,但是有跨平台的问题存在。
4 - 4 · 位段的应用
下图是网络协议中,IP数据报的格式,我们可以看到其中很多的属性只需要几个bit位就能描述,这里使用位段,能够实现想要的效果,也节省了空间,这样网络传输的数据报大小也会较小⼀些,对网络的畅通是有帮助的。

4 - 5 · 位段使用的注意事项
位段的几个成员共有同⼀个字节,这样有些成员的起始位置并不是某个字节的起始位置,那么这些位置处是没有地址的。内存中每个字节分配⼀个地址,⼀个字节内部的bit位是没有地址的。
所以不能对位段的成员使用&操作符,这样就不能使用scanf直接给位段的成员输入值,只能是先输入放在⼀个变量中,然后赋值给位段的成员。
cpp
#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.a);//不能这样写
return 0;
}
这样写是错误的。正确写法如下:
cpp
int main()
{
struct S s = { 0 };
int b = 0;
scanf("%d", &b);
s.a = b;
return 0;
}
总结
以上简单介绍了结构体相关内容,关于C语言的其余内容,请期待后续更新
以上内容如有错误或不准确之处,欢迎指出,或者你有更好的想法,也欢迎交流。