目录
一、什么是结构体?
在 C 语言中,结构体(struct)是一种用户自定义的数据类型,它可以把不同类型的数据组合在一起。
二、结构体变量的创建和初始化
在创建结构体变量之前,需要对该结构体类型进行声明,因为这是我们创建的类型,需要告知编译器。如下是一个简单的学生结构体声明:
// Stu 结构体声明
struct Stu
{
char name[20]; // 姓名
int age; // 年龄
double weight; // 体重
};
上述声明告诉编译器,我们创建了一个 Stu 结构体类型,该结构体有三个成员,字符数组 name,int 变量 age,double 变量 weight。现在就可以像使用 int、double 等类型一样创建变量了,但是创建变量时前面要加上关键字 struct,
// 创建 Stu 结构体变量
int main()
{
// 创建 Stu 结构体变量
struct Stu stu1 = { "李华", 18, 60 };
}
上述代码创建了一个 Stu 结构体变量 stu1,并给它的每个成员都赋了初值。结构体变量初始化采用列表初始化的方式,如果其内还有数组或者其他结构体作为成员,则在内部再次使用列表初始化。
还可以在声明结构体类型的同时创建该结构体变量:
// Stu 结构体声明
struct Stu
{
char name[20]; // 姓名
int age; // 年龄
double weight; // 体重
}stu1 = { "李华", 18, 60.24 }, stu2 = { "张三", 20, 62.4 };
结构体声明在所有函数之外就是全局结构体,在所有函数中均可使用该结构体;如果该结构体声明在特定的函数之内,那么只能在该函数中使用该结构体。
1. C99 指定初始化
从 C99 标准之后,C 语言的结构体可以使用指定初始化。其格式为:
struct 结构体名 变量名 = {.成员名1 = 值1,.成员名2 = 值2,…};
那么上面的 Stu 结构体变量 stu1 的初始化就可以改成下面的代码:
// 使用指定成员初始化
struct Stu stu1 = { .name = "李华", .age = 18, .weight = 60.2 };
2. 结构体最好不要使用部分初始化
因为结构体使用部分初始化后,剩余的成员不一定会初始化为 0,且没有标准规定。一般取决于使用的环境,作者使用的 VS 2022 部分初始化的剩余成员是被设置为 0 的:
但是最好不要使用部分初始化。
3. 结构体不能自己包含自己
如下代码:
// 结构体自己包含自己
struct Stu
{
char name[20]; // 20 字节
Stu stu1; // ?字节
}
请问如何计算上述结构体变量的大小?这根本无法计算,只会陷入无线的套娃之中。所以,结构体是不能自己包含自己的,只能包含其他结构体变量,或者包含自己的指针。
4. 使用 typedef 简化结构体类型
前面创建结构体变量的代码中,都需要前缀关键字 struct。如果想要像使用 int 等类型一样创建变量,那么只需要在声明结构体类型的时候使用关键字 typedef 就可以,下面两种形式均可:
(1)声明的同时使用 typedef
// Stu 结构体声明
typedef struct Stu
{
char name[20]; // 姓名
int age; // 年龄
double weight; // 体重
}Stu;
(2)声明之后使用 typedef
// Stu 结构体声明
struct Stu
{
char name[20]; // 姓名
int age; // 年龄
double weight; // 体重
};
typedef struct Stu Stu;
然后,现在就可以像创建普通变量一样,创建 Stu 结构体变量了:
Stu s1 = { "李华", 18, 60.24 };
5. 匿名结构体
匿名结构体是没有名称的结构体,且只能在声明的时候创建变量:
// 匿名结构体
struct
{
int i;
}A;
上述匿名结构体只有 A 这个变量,由于没有名称,所以不能在其他地方再创建该结构体变量。
可以使用关键字 typedef 解决上述问题:
// 匿名结构体
typedef struct
{
int i;
}Unknown;
int main()
{
Unknown A = { 10 };
return 0;
}
但是如果使用了 typedef 关键字,就失去了使用匿名结构体的意义。
三、结构体变量的使用和传参
我们可以通过成员运算符(.)来访问结构体变量的每个成员,如下代码:
// Stu 结构体声明
typedef struct Stu
{
char name[20];
int age;
double weight;
}Stu;
int main()
{
Stu stu1 = { "张三", 18, 60.24 };
// 打印
printf("姓名:%s\n", stu1.name);
printf("年龄:%d\n", stu1.age);
printf("体重:%f\n", stu1.weight);
return 0;
}
从上述代码中,我们可以像使用普通变量一样使用结构体的成员,如:struct.age 就是一个 int 变量,可以进行 int 变量的所有操作。那么结构体数组也同样如此:
// 创建 Stu 结构数组并初始化
Stu stus[3] = {
{"张三", 18, 60.24},
{"李四", 19, 61.1 },
{"王五", 20, 62.3}
};
// 打印
int i;
printf("%-10s%-5s%-10s\n", "姓名", "年龄", "体重");
for (i = 0; i < 3; ++i)
{
printf("%-10s%-5d%-8.2f\n", stus[i].name, stus[i].age, stus[i].weight);
}
结果如下:
1. 结构体传参的两种方式
结构体和其他类型一样,传参时都是值传递和址传递两种方式。我们通过一个打印结构体信息的函数,来看看结构体的这两种传参方式。
(1)值传递
// 值传递
void print_Stu(Stu stu)
{
printf("%s ", stu.name);
printf("%d ", stu.age);
printf("%f ", stu.weight);
}
(2)址传递
// 址传递
void print_pStu(Stu* ps)
{
printf("%s ", (*ps).name);
printf("%d ", (*ps).age);
printf("%f ", (*ps).weight);
}
从上述代码来看,结构体指针的创建和普通变量的指针创建是类似的。上述址传递的时候先对指针解引用拿到该指针所指向的结构体,然后再打印该结构体成员的信息。而 C 语言还提供了一种通过指针间接访问结构体成员的办法:
// 址传递
void print_pStu(Stu* ps)
{
printf("%s ", ps->name);
printf("%d ", ps->age);
printf("%f ", ps->weight);
}
符号 -> 称为箭头运算符,是专门提供给指针访问结构体成员的运算符。而 ps->name 的实质还是是 (*ps).name,这就和数组中 arr[i] 的实质是 *(arr+i) 一样,箭头运算符和下标运算符都是为了让使用者更加方便和更容易理解。
四、结构体内存对齐
结构体大小的计算和其他类型不一样,它有自己的一套规则。结构体内存对齐后才能计算其所占空间大小。
1. 内存对齐的规则
(1)结构体的第⼀个成员对⻬到和结构体变量起始位置偏移量为0的地址处
(2)其他成员变量要对⻬到某个数字(对⻬数)的整数倍的地址处。对⻬数 = 编译器默认的⼀个对⻬数与该成员变量⼤⼩的较⼩值。VS 中默认的值为 8 ,Linux中 gcc 没有默认对⻬数,对⻬数就是成员⾃⾝的⼤⼩。
(3)结构体总⼤⼩为最⼤对⻬数(结构体中每个成员变量都有⼀个对⻬数,所有对⻬数中最⼤的)的整数倍。
(4)如果嵌套了结构体的情况,嵌套的结构体成员对⻬到⾃⼰的成员中最⼤对⻬数的整数倍处,结构体的整体⼤⼩就是所有最⼤对⻬数(含嵌套结构体中成员的对⻬数)的整数倍。
2. 内存对齐的练习题
1. 练习1
首先 c1 是 S1 的第一个成员,其类型为 char 站一个字节,直接对齐到起始位置,如下:
然后是 S1 的第二个成员 i,其类型为 int 占 4 个字节,VS 下默认对其数为 8,那么成员 i 的对其数为 min{4, 8} = 4。所以,i 需要在 4 的倍数的地方开始存储,
最后是 S1 的第三个成员 c2,其类型为 char,占 1 个字节,那么其对齐数为 min{1, 8} = 1,所以 c2 需要在 1 的倍数的地方开始存储:
存储完结构体 S1 的每个成员之后,需要给整个结构体对其,而整个结构体的对齐数是所有成员中对其数最大的那个,也就是 max{1,4,1} = 4。而目前结构体所占空间为 4 + 4 + 1 = 9,不是 4 的整数倍,所以需要补 3 个字节,达到 12 字节。所以最后结构体 S1 的大小为 12 字节。
下面是在 64 位环境下,程序运行的结果:
2. 练习2
首先,结构体 S2 的第一个成员 c1 对齐到 0 位置,类型为 char,占 一个字节。
其次,结构体 S2 的第二个成员 c2,类型为 char,占一个字节,其对齐数为 min{1, 8} = 1,对齐到 1 位置。
最后,结构体 S2 的第三个成员 i,类型为 int,占 4 个字节,对齐数为 min{4, 8} = 4,对齐到 4 的整数倍,也就是 4 的位置。
最后是结构体对其,目前结构 S2 的大小为 8,需要对齐到整个结构体的最大对齐数,也就是 max{1,4} = 4,所以 S2 的大小为 8。
下面是 64 位环境下,程序运行的结果:
3. 练习3
首先,结构体 S3 的第一个成员 d,类型为 double,占 8 个字节,对齐到 0 位置。
其次,结构体 S3 第二个成员 c,类型为 char,对齐数为 min{1, 8} = 1,对其到 8 位置。
最后结构体 S3 的第三个成员 i,类型为 int,对齐数为 min{4, 8} = 4,对齐到 12 位置。
现在进行结构体对齐,结构体的对齐数为所有成员对齐数的最大数,也就是 max{8,1,4} = 8,而现在结构体的大小为 16,刚好是 8 的整数倍。所以,结构体 S3 的大小为 8。
下面是 64 位环境下程序运行的结果:
4. 练习4
首先,结构体 S4 的第一个成员 c1,类型为 char,占 1 个字节,对齐到 0 位置。
其次,结构体 S4 第二个成员 s3,其类型为结构体 S3,大小为 16,结构体S3 的最大对其数为 8,则成员 s3 的对其数为 min{8,8} = 8,则对其到 8 位置。
最后,结构体第三个成员 d,类型为 double,对齐数为 min{8, 8} = 8,对齐到 24 位置。
现在进行结构体对其,该结构体的对齐数为所有成员对齐数的最大值,即 max{1,8,8} = 8,现在结构体的大小为 32,是 8 的整数倍。所以结构体 S4 的大小为 32。
下面是 64 位环境下程序运行的结果:
3. 为什么存在内存对⻬?
⼤部分的参考资料都是这样说的:
-
平台原因(移植原因):
不是所有的硬件平台都能访问任意地址上的任意数据的;某些硬件平台只能在某些地址处取某些特定类型的数据,否则抛出硬件异常。 -
性能原因:
数据结构(尤其是栈)应该尽可能地在⾃然边界上对⻬。原因在于,为了访问未对⻬的内存,处理器需要作两次内存访问;⽽对⻬的内存访问仅需要⼀次访问。假设⼀个处理器总是从内存中取8个字节,则地址必须是8的倍数。如果我们能保证将所有的double类型的数据的地址都对⻬成8的倍数,那么就可以⽤⼀个内存操作来读或者写值了。否则,我们可能需要执⾏两次内存访问,因为对象可能被分放在两个8字节内存块中。
总体来说:结构体的内存对⻬是拿空间来换取时间的做法。
那在设计结构体的时候,我们既要满⾜对⻬,⼜要节省空间,如何做到:让占⽤空间⼩的成员尽量集中在⼀起
如下代码:
//例如:
struct S1
{
char c1;
int i;
char c2;
};
struct S2
{
char c1;
char c2;
int i;
};
虽然两个结构体的成员相同,但是 S1 占 12 个字节,S2 占 8 个字节。
五、结构体实现位段
1. 什么是位段
位段的声明和结构是类似的,有两个不同:
- 位段的成员必须是 int、unsigned int 或signed int ,在C99中位段成员的类型也可以选择其他类型。
- 位段的成员名后边有⼀个冒号和⼀个数字。
比如:
struct A
{
int _a:2;
int _b:5;
int _c:10;
int _d:30;
};
在上述代码中,A 就是一个位段。那么如何计算 A 的大小?
2. 位段内存的分配
- 位段的成员可以是 int unsigned int signed int 或者是 char 等类型
- 位段的空间上是按照需要以4个字节( int )或者1个字节( char )的⽅式来开辟的。
- 位段涉及很多不确定因素,位段是不跨平台的,注重可移植的程序应该避免使⽤位段
下面通过一个简单的示例,来解释位段的内存分配:
//⼀个例⼦
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;
}
首先,在 VS 2022 下通过上述规则,成员 a 和 b共占一个字节,剩下一位不够成员 c 存储,然后开辟一个字节,成员 c 在这个新的字节上存储,而上一个字节剩下的一个位就浪费了。然后新字节剩下的 4 各位不够 3 个位不够 d 存储,又开辟一个字节。所以一共位段 S 一共需要 3 个字节的空间。
有了上面的图,就可以知道在 VS2022 中,位段的成员是从当前字节的低位开始使用的。现在我们来解释一下 main() 函数中的赋值语句是如何进行的:
s.a = 10,且 a 为 char 类型,所以二进制为:00001010,但是 a 只有 3 位的空间,所以只取后 3 位如下:
同理,其他成员也是如此:
然后我们把初始化的 0 补上,算出这三个字节的十六进制:
这里没有大小端的问题哈,大小端是字节的顺序,这里都是 char 类型,最大就是一个字节。
下面是 64 位环境下,程序运行时结构体 s 在内存中的十六进制,与上述计算的十六进制相同:
3. 位段的跨平台问题
- int 位段被当成有符号数还是⽆符号数是不确定的。
- 位段中最⼤位的数⽬不能确定。(16位机器最⼤16,32位机器最⼤32,写成27,在16位机器会出问题。
- 位段中的成员在内存中从左向右分配,还是从右向左分配,标准尚未定义。
- 当⼀个结构包含两个位段,第⼆个位段成员⽐较⼤,⽆法容纳于第⼀个位段剩余的位时,是舍弃剩余的位还是利⽤,这是不确定的。
总结:
跟结构相⽐,位段可以达到同样的效果,并且可以很好的节省空间,但是有跨平台的问题存在。
4. 位段的应用(了解)
位段在计算机编程中有很多重要的应用,以下是一些主要的方面:
一、内存优化
在资源受限的系统中,如嵌入式设备,内存空间非常宝贵。位段可以将多个布尔值或小整数紧凑地存储在一个字中,极大地节省了内存空间。例如,在一个嵌入式系统中,用于表示设备状态的多个标志可以组合成一个位段结构。假设我们有一个设备,其状态包括电源状态(开 / 关)、连接状态(已连接 / 未连接)和工作模式(正常 / 节能 / 高性能)。如果不使用位段,每个状态可能需要占用一个字节的内存空间,而使用位段,可以将这三个状态存储在一个字节甚至更少的空间中。
二、协议解析
在网络通信和数据传输中,各种协议通常会定义一组标志位来表示不同的状态或选项。位段可以方便地解析和处理这些标志位。比如在 TCP/IP 协议中,IP 报头中的标志字段就可以用位段来表示和解析。其中包括保留位、不分片标志和更多分片标志等。通过位段,可以快速地检查这些标志位的值,从而确定如何处理数据包。
三、图形编程
在图形处理中,位段可以用于表示像素的属性。例如,在一个图像格式中,每个像素可能包含多个属性,如透明度、颜色通道和特殊效果标志等。使用位段可以将这些属性紧凑地存储在一个数据结构中,方便对图像进行处理和渲染。
四、硬件描述语言
在硬件设计中,硬件描述语言(如 Verilog 和 VHDL)经常使用位段来描述寄存器和信号的位级属性。例如,在设计一个处理器的控制寄存器时,可以使用位段来表示不同的控制信号,如中断使能、流水线控制和缓存策略等。这样可以更清晰地描述硬件的行为,并方便进行综合和仿真。
五、配置文件存储
位段可以用于存储配置文件中的选项和设置。例如,在一个软件的配置文件中,可以使用位段来表示各种功能的启用或禁用状态。这样可以节省文件空间,并方便快速地读取和解析配置信息。
总之,位段在计算机编程中是一种非常有用的技术,可以在多个领域中实现内存优化、高效的数据处理和紧凑的数据存储。它虽然在使用上需要一些小心和注意,但在合适的场景下可以发挥重要的作用。
5. 位段使用的注意事项
在 C 语言中,内存中的地址都是以字节为最小单位划分的。但是在位段中,有时会有几个成员共用一个字节,那么其中不是从该字节起始位置开始存储的成员是没有地址的,因为一个字节内部的 bit 位是没有地址的。所以就不能使用 scanf() 函数对该类成员进行输入,只能通过赋值语句进行赋值。如下示例:
struct A
{
`int _a : 2;
int _b : 5;
int _c : 10;
int _d : 30;
};
int main()
{
struct A sa = {0};
scanf("%d", &sa._b);//这是错误的
//正确的⽰范
int b = 0;
scanf("%d", &b);
sa._b = b;
return 0;
}
``