文章目录
- 前言
- 本文旨在系统性地介绍C语言中结构体的进阶知识。
- 一、结构体类型声明
- 1.1、嵌套结构体类型
- 1.2、结构体的自引用
- 1.3、typedef简化结构体类型
- 1.4、匿名结构体类型
- 1.4.1、匿名结构体类型唯一
- 1.4.2、匿名结构体作为成员
- 二、结构体数组
- 三、结构体内存对齐
- 3.1、对齐规则
- 3.2、存在内存对齐原因
- 3.3、结构体设计技巧
- 3.4、修改默认对齐数
- 四、结构体的位段(位域)成员
- 4.1、位段的内存分配
- 4.2、位段的跨平台不确定因素
- 4.3、位段使用的注意事项
- 4.4、位段的特殊写法
前言
本文旨在系统性地介绍C语言中结构体的进阶知识。
一、结构体类型声明
1.1、嵌套结构体类型
在一个结构体中使用另一个结构体类型作为成员。
代码示例:
//学生有生日,生日包含年/月/日 → 把"生日"单独设计成一个结构体,再放进"学生"结构体里。// 第一步:定义日期结构体structDate{intyear;intmonth;intday;};// 第二步:在学生结构体中嵌套日期结构体structStudent{charname[20];structDatebirthday;// 嵌套charid[20];};intmain(){// 嵌套结构体的初始化structStudents={"aoyun",{2008,8,8},"2026010102"};// 成员的访问printf("⽣⽇:%d-%d-%d\n",s.birthday.year,s.birthday.month,s.birthday.day);return0;}1.2、结构体的自引用
结构体内部包含一个指向同类型结构体的指针。
代码示例:
#include<stdio.h>#include<stdlib.h>// 定义链表节点(自引用结构体)structNode{intdata;structNode*next;// 指向下一个节点};intmain(){// 创建 3 个节点structNoden1,n2,n3;// 存储数据n1.data=1;n2.data=2;n3.data=3;// 连接成链表n1.next=&n2;// n1 指向 n2n2.next=&n3;// n2 指向 n3n3.next=NULL;// n3 是最后一个节点// 遍历链表并打印structNode*p=&n1;while(p!=NULL){printf("%d ",p->data);p=p->next;}// 输出:1 2 3return0;}1.3、typedef简化结构体类型
写法1:定义结构体 + typedef 分开写
代码示例:
// 先定义结构体structStudent{charname[20];intage;};// 再用 typedef 起别名typedefstructStudentStudent;// 使用Student s1;// 等价于 struct Student s1;写法2:定义结构体的同时使用 typedef
代码示例:
typedefstructStudent{charname[20];intage;}Student;// 使用Student s1;1.4、匿名结构体类型
匿名结构体是指省略了结构体标签(tag) 的结构体类型。它没有名字,因此通常只能在定义时直接创建变量。
代码示例:
// 普通结构体(有名字)structX{inta;charb;floatc;};structXx;// 可以多次使用// 匿名结构体(没有名字)struct{inta;charb;floatc;}x;// 只能使用这一次使用 typedef 重命名后,它就变成了一个可复用的类型,解决了匿名结构体的限制。
代码示例:
// 使用 typedef 给匿名结构体重命名typedefstruct{inta;charb;floatc;}X;// X 是新的类型名X x1;// 创建全局变量 x1(类型 X)X arr[20];// 创建数组X*p=&x1;// 类型匹配intmain(){X x2;// 创建局部变量 x2return0;}1.4.1、匿名结构体类型唯一
即使两个匿名结构体的成员完全相同,编译器也视为不同类型,不能相互赋值。
代码示例:
struct{inta;charb;floatc;}x;// 创建变量 x,类型是“匿名结构体类型A”struct{inta;charb;floatc;}*p=&x;// 错误,类型A ≠ 类型B1.4.2、匿名结构体作为成员
情况1:匿名结构体成员直接展开
代码示例:
structS{inta;charb;struct// 匿名结构体(未创建变量){floatc;intd;};};intmain(){structSs;s.a=10;s.c=3.5f;// 直接访问,成员 c 和 d 会“上浮”成为外层结构体的成员。s.d=100;return0;}情况2:匿名结构体作为具名成员
代码示例:
structS{inta;charb;struct// 创建变量 x{floatc;intd;}x;};intmain(){structSs;s.a=10;s.x.c=3.5f;// 需要通过 x 访问s.x.d=100;return0;}二、结构体数组
结构体数组就是元素类型为结构体的数组,用于存储多个结构体数据。
代码示例:
// 定义日期结构体structDate{intyear;intmonth;intday;};// 定义学生结构体(包含嵌套结构体)typedefstructStudent{charname[20];structDatebirthday;// 嵌套结构体charid[20];}Student;intmain(){// 结构体数组初始化Student stu_arr[3]={{"zhangsan",{2008,8,8},"2026010101"},{"lisi",{2009,1,10},"2026010102"},{"wangwu",{2009,3,20},"2026010103"}};// 遍历访问结构体数组for(inti=0;i<3;i++){printf("name:%s\tbirth:%d-%d-%d\tid:%s\n",stu_arr[i].name,stu_arr[i].birthday.year,stu_arr[i].birthday.month,stu_arr[i].birthday.day,stu_arr[i].id);}return0;}三、结构体内存对齐
结构体内存对齐是指:编译器为了提升 CPU 访问内存的效率,会在结构体成员之间插入“填充字节”(padding),使得每个成员的起始地址都位于其“对齐边界”上。
内存对齐是用少量内存填充字节(空间),换取 CPU 一次读取完成(时间)。
3.1、对齐规则
规则1:结构体的第1个成员对齐到0偏移处(结构体起始位置)
规则2:从第2个成员开始,每个成员对齐到其对应的对齐数的整数倍偏移处,对齐数 = min(编译器默认对齐数, 成员大小)
规则3:结构体的总大小必须是最大对齐数(包含嵌套结构体的最大对齐数)的整数倍
规则4:嵌套的结构体类型成员对齐到其自身最大对齐数的整数倍偏移处
VS 默认对齐数固定为 8,Linux环境中GCC 没有默认对齐数(对齐数 = 成员自身大小)。
代码示例:
#include<stdio.h>structs1{doubled;// 大小 8,对齐数 = min(8, 8) = 8// 放在偏移 0(第1个成员,从0开始)charc;// 大小 1,对齐数 = min(8, 1) = 1// 放在偏移 8inti;// 大小 4,对齐数 = min(8, 4) = 4// 放在偏移 12// 当前已占:0~15(共 16 字节)// 最大对齐数 = max(8, 1, 4) = 8// 总大小必须是 8 的倍数 → 16 已是 8 的倍数};structs2{charc1;// 大小 1,对齐数 = min(8, 1) = 1// 放在偏移 0structs1s1_member;// 嵌套结构体,对齐数 = 自身最大对齐数 = 8// 放在偏移 8// s1_member 内部占 16 字节(偏移 8~23)doubled;// 大小 8,对齐数 = min(8, 8) = 8// 放在偏移 24,占 8 字节(24~31)// 当前已占:0~31(共 32 字节)// 最大对齐数 = max(1, 8, 8) = 8// 总大小必须是 8 的倍数 → 32 已是 8 的倍数};intmain(){structs1s1={0};structs2s2={0};printf("sizeof(struct s1) = %zu\n",sizeof(structs1));// 16printf("sizeof(struct s2) = %zu\n",sizeof(structs2));// 32return0;}3.2、存在内存对齐原因
平台原因(移植性):某些 CPU 只能从特定地址读取特定类型的数据,否则会抛出硬件异常
性能原因(效率):CPU 以总线宽度(4/8 字节)为粒度发起内存访问,对齐的数据一次读完,未对齐的数据需要多次读取+拼接
| 概念 | 定义 | 决定者 | 作用 |
|---|---|---|---|
| CPU总线宽度 | CPU与内存之间一次传输的数据位数 | 硬件 | 决定 CPU 能一次读多少数据 |
| 默认对齐数 | 编译器设定的对齐上限 | 编译器 | 决定结构体成员最大按多少字节对齐 |
默认对齐数 ≤ CPU总线宽度,这样才能保证对齐后的数据不会超过 CPU 一次读取的能力。
3.3、结构体设计技巧
将占用空间小的成员集中放在一起,可以有效减少填充字节,节省内存。
核心原理:char 类型对齐数为 1,可以放在任意偏移位置,不会产生填充。将多个 char 放在一起,它们会连续排列,共用同一个填充“间隙”。
代码示例:
#include<stdio.h>structS1{charc1;// 偏移 0inti;// 偏移 4(对齐数4,偏移1~3填充)charc2;// 偏移 8};// 总大小:12 字节(最大对齐数4,12是4的倍数,9→12)structS2{charc1;// 偏移 0charc2;// 偏移 1(对齐数1,紧挨着)inti;// 偏移 4(对齐数4,偏移2~3填充)};// 总大小:8 字节(最大对齐数4,8是4的倍数)3.4、修改默认对齐数
#pragma pack(n) 可以临时修改默认对齐数,控制结构体的紧凑程度。pack(1) 消除填充节省空间,但可能降低访问速度;用完后及时 #pragma pack() 恢复默认。
代码示例:
#include<stdio.h>#pragmapack(1)// 设置默认对齐数为 1structS{charc1;// 对齐数 = min(1, 1) = 1inti;// 对齐数 = min(1, 4) = 1charc2;// 对齐数 = min(1, 1) = 1};#pragmapack()// 取消设置,恢复默认对齐数intmain(){printf("%zu\n",sizeof(structS));// 6(1+4+1,无填充)return0;}四、结构体的位段(位域)成员
在结构体定义中,如果成员名后面加上一个冒号和数字,就称为位段成员。这个数字表示该成员占用的比特位数,而不是字节数。
C语言标准规定:C89/C90:位段成员的类型必须是 int、unsigned int 或 signed int。C99:新增支持 _Bool 类型。
现代编译器的扩展,大多数编译器(GCC、Clang、MSVC)在实际实现中放宽了类型限制,额外支持:char、unsigned char、short、unsigned short、long、unsigned long、long long、unsigned long long、枚举类型(enum)。
注意:这些扩展支持取决于具体编译器,不是C标准强制要求的。如果追求可移植性,建议只使用标准规定的类型。
代码示例:
structA{unsignedinta:2;// 占用2位unsignedintb:5;// 占用5位unsignedintc:10;// 占用10位unsignedintd:30;// 占用30位};4.1、位段的内存分配
①成员类型:位段成员可以是 int、unsigned int、signed int 或 char 等类型(现代编译器通常支持更丰富的类型)。
②按类型申请空间:成员为 unsigned int / signed int 时,每次按 4 字节申请内存空间。成员为 unsigned char / signed char 时,每次按 1 字节申请内存空间。按需扩展,当前单元不够用时申请下一个单元。
③实现依赖:位段对内存的使用细节(填充、跨单元、顺序等)高度依赖编译器实现,C标准未作严格规定。
代码示例:
//分配方向:从低位到高位分配// 剩余位策略:当前单元剩余位不够时,舍弃填充,申请新单元#include<stdio.h>// 定义位段结构体// 成员类型为 unsigned char → 每次按 1 字节(8位)申请空间structS{unsignedchara:3;// a 占用 3 位(bit0~bit2)unsignedcharb:4;// b 占用 4 位(bit3~bit6),当前字节剩余5位,足够存放unsignedcharc:5;// c 占用 5 位,当前字节只剩1位(bit7),不够// → 申请第2个字节,c 从新字节的 bit0 开始存放// → 第1字节剩余 bit7 被填充废弃unsignedchard:4;// d 占用 4 位,当前字节(第2字节)剩余3位(bit3~bit5),不够// → 申请第3个字节,d 从新字节的 bit0 开始存放// → 第2字节剩余 bit3~bit7 被填充废弃};// 总大小 = 3 字节intmain(){structSs={0};// 将所有字节初始化为 0// 赋值注意:值不能超过位段能容纳的范围// a 占 3 位,最大能存 2^3 - 1 = 7s.a=10;// 10 = 二进制 1010,但 a 只有 3 位 → 截断为 010(即 2)// b 占 4 位,最大能存 2^4 - 1 = 15s.b=12;// 12 = 二进制 1100,完整存入 b 的 4 位// c 占 5 位,最大能存 2^5 - 1 = 31s.c=3;// 3 = 二进制 00011,完整存入 c 的 5 位// d 占 4 位,最大能存 2^4 - 1 = 15s.d=4;// 4 = 二进制 0100,完整存入 d 的 4 位return0;}4.2、位段的跨平台不确定因素
| 不确定因素 | 说明 | 影响层面 |
|---|---|---|
| 符号性 | int 位段被当作有符号还是无符号,由编译器决定 | 语义(数据解释) |
| 最大位数 | 16位机器最大16位,32位机器最大32位,写成27在16位机器上会出问题 | 语义(数据范围) |
| 分配顺序 | 从高位还是低位开始分配,标准未定义 | 布局(顺序) |
| 剩余位处理 | 当前单元剩余位不够时,是填充废弃还是跨单元利用,标准未定义 | 布局(填充/跨单元) |
注:①填充:剩余位被废弃不用,称为填充。②跨单元:剩余位不够时,编译器可选择将成员放入下一个单元(舍弃剩余位),也可选择跨单元存储(部分在当前单元,部分在下一个单元)。③顺序:小端系统从低位(bit0)开始分配,大端系统从高位(bit7)开始分配。
位段能节省内存,但存在大量实现定义(编译器必须在文档中说明)行为,跨平台性差。注重可移植性的程序应避免使用位段,或针对平台做专门处理。
4.3、位段使用的注意事项
①sizeof 返回的是字节数,而位段成员可能只占几个 bit,不够 1 个字节。C 标准规定 sizeof 不能作用于位段成员。
②内存中每个字节分配一个地址,而字节内部的 bit 位没有独立地址。位段成员可能从某个字节的中间位置开始,没有对应的地址,因此不能使用 & 操作符。
代码示例:
structA{unsignedint_a:2;unsignedint_b:5;unsignedint_c:10;unsignedint_d:30;};intmain(){structAsa={0};// printf("%zu\n", sizeof(sa._a)); // 错误,位段成员不能作为 sizeof 的操作数// scanf("%d", &sa._b); // 错误,不能对位段成员取地址//正确,先读入普通变量,再赋值给位段成员intb=0;scanf("%d",&b);sa._b=b;return0;}4.4、位段的特殊写法
① 无名位段(跳过指定位数)
作用:在当前存储单元中跳过指定位数,不分配给任何成员。跳过的位被填充废弃,且无法被程序访问。
代码示例:
structExample{unsignedchara:4;// 占低4位unsignedchar:4;// 匿名位段,跳过4位(不命名,无法访问)unsignedcharb:8;// 从下一个字节开始};② 零宽度位段(强制对齐到下一个存储单元)
作用:告诉编译器当前存储单元剩余位全部废弃,下一个成员从新的存储单元起始位置开始分配。
代码示例:
structExample2{unsignedchara:4;// 占当前字节的低4位unsignedchar:0;// 零宽度位段:强制结束当前存储单元unsignedcharb:1;// 从新存储单元(下一个字节)开始unsignedcharc:8;// 再占1个字节};