C语言结构体详解:从定义、内存对齐到位段应用
✨ 用 清晰易懂的图解 帮你建立直观认知 ,用通俗的 代码语言 帮你落地理解, 让每个知识点都能 轻松get !
🚀 个人主页 :0xCode小新 · CSDN
🌱 代码仓库 :0xCode小新· Gitee
📌 专栏系列
- 📖 《c语言》
📖 《鸿蒙应用开发项目教程》💬 座右铭 : “ 积跬步,以致千里。”
目录
- 1. 结构体类型的声明
- 1.1 什么是结构体
- 1.2 结构体的基本声明
- 1.3 结构体变量的创建和初始化
- 1.4 结构体的特殊声明
- 1.5 结构体的自引用
- 1.6 使用typedef简化结构体
- 1.7 结构体声明的完整示例
- 2. 结构体内存对齐
- 2.1 什么是结构体内存对齐?
- 2.2 内存对齐的核心规则
- 2.3 实战案例分析
- 2.4 为什么需要内存对齐?
- 2.5 修改默认对齐数
- 3. 结构体传参
- 4. 结构体实现位断
- 4.1 什么是位段?
- 4.2 位段的基本语法
- 4.3 位段的内存分配规则
- 4.4 位段的跨平台问题
- 4.5 位段的实际应用场景
- 4.6 位段的操作与限制
- 4.6.1 重要的使用限制
- 4.6.2 位段的最佳实践
结构体是C语言中用于组织和存储不同数据类型数据的重要工具。我们从结构体的基本概念讲起,逐步深入到内存对齐、传参技巧及位段实现等高级话题,以全面掌握结构体的核心知识。
1. 结构体类型的声明
1.1 什么是结构体
结构体是C语言中一种重要的自定义数据类型,它允许我们将多个不同类型的变量组合在一起,形成一个逻辑上的整体。这些组合在一起的变量称为结构体的成员变量。
1.2 结构体的基本声明
标准声明格式
struct tag
{member-list;
}variable-list;
struct
:关键字,表示结构体类型tag
:结构体标签,用于标识这种结构体类型member-list
:成员变量列表variable-list
:变量列表(可选的)
实际应用示例
我们用结构体来描述一个学生信息:
struct Stu
{char name[20]; // 名字int age; // 年龄char sex[5]; // 性别char id[20]; // 学号
};
1.3 结构体变量的创建和初始化
创建结构体变量
方式一:声明时直接创建
struct Stu
{char name[20];int age;
}student1, student2;
方式二:先声明类型,再创建变量
struct Stu student3;
初始化结构体变量
方式一:顺序初始化
struct Stu s1 = {"张三", 20, "男", "20230818001"};
方式二:指定成员初始化(C99标准)
struct Stu s2 = {.age = 18, .name = "李四", .id = "20230818002", .sex = "女"};
这种方式更加清晰,且不依赖成员的定义顺序。
1.4 结构体的特殊声明
匿名结构体
在声明结构体时可以省略标签(tag),这就是匿名结构体:
// 匿名结构体类型
struct
{int a;char b;float c;
}x;struct
{int a;char b;float c;
}arr[20], *p;
重要限制:
编译器将每个匿名结构体声明视为不同的类型
以下代码是非法的:
p = &x; // 错误:类型不匹配
匿名结构体基本上只能使用一次
1.5 结构体的自引用
错误的自引用方式
struct Node
{int data;struct Node next; // 会导致无限递归
};
正确的自引用方式
struct Node
{int data;struct Node* next; // 使用指针
};
这种方式是链表等数据结构的基础。
typedef与自引用的注意事项
错误写法:
typedef struct
{int data;Node* next; // Node尚未定义
}Node;
Node是对前面的匿名结构体类型的重命名产生的,但是在匿名结构体内部提前使用Node类型来创建成员变量,这是不行的。
正确写法:
定义结构体时就不要使用匿名结构体了
typedef struct Node
{int data;struct Node* next; // 使用完整的结构体名称
}Node;
1.6 使用typedef简化结构体
typedef struct Student
{char name[20];int age;
}Student;// 直接使用Student创建变量
Student stu1, stu2;
这种方式让代码更加简洁易读。
1.7 结构体声明的完整示例
#include <stdio.h>
#include <string.h>// 声明结构体
typedef struct Employee
{char name[50];int id;float salary;char department[30];
}Employee;int main()
{// 创建并初始化结构体变量Employee emp1 = {"王五", 1001, 8500.0, "技术部"};// 逐个成员赋值Employee emp2;strcpy(emp2.name, "赵六");emp2.id = 1002;emp2.salary = 9200.0;strcpy(emp2.department, "市场部");return 0;
}
2. 结构体内存对齐
2.1 什么是结构体内存对齐?
结构体内存对齐是编译器为了优化内存访问性能而采用的一种内存布局策略。它要求结构体的每个成员都存储在特定地址边界上,这些边界通常是成员类型大小的整数倍。
2.2 内存对齐的核心规则
我们先初步了解内存对齐的基础规则,即便现在看不太明白也无需担心 —— 接下来我会结合具体实例与配图,为大家拆解每一个细节,帮你彻底搞懂它。
基本对齐规则
-
第一个成员规则:结构体的第一个成员始终从偏移量为0的地址开始
-
成员对齐规则:其他成员需要对齐到对齐数的整数倍地址处
- 对齐数 = 编译器默认对齐数和成员自身大小的较小值
- VS编译器默认对齐数:8
- Linux gcc:无默认对齐数,对齐数等于成员自身大小
-
总大小规则:结构体总大小必须是最大对齐数的整数倍
-
嵌套结构体规则:嵌套的结构体对齐到其内部最大对齐数的整数倍处
2.3 实战案例分析
案例一:基础对齐分析
struct S1
{char c1; int i; char c2;
};
printf("%d\n", sizeof(struct S1));
内容 | 本身的大小(字节) | VS 默认对齐数(字节) | 较小值(字节) | 对齐数(字节) |
---|---|---|---|---|
c1 | 1 | 8 | 1 | 1 |
i | 4 | 8 | 4 | 4 |
c2 | 1 | 8 | 1 | 1 |
根据上述解释,现在你能试着自己算出 S1
实际占用多少字节的空间吗?
按照结构体总大小的规则,结构体的总大小得是最大对齐数的整数倍。S1
的最大对齐数是 4,所以它实际占用的空间是 12 个字节。
案例二:优化布局节省空间
struct S2
{char c1; char c2; int i;
};
printf("%d\n", sizeof(struct S2));
内容 | 本身的大小(字节) | VS 默认对齐数(字节) | 较小值(字节) | 对齐数(字节) |
---|---|---|---|---|
c1 | 1 | 8 | 1 | 1 |
c2 | 1 | 8 | 1 | 1 |
i | 4 | 8 | 4 | 4 |
S2实际占用的空间是 12 个字节。
不知道大家有没有观察到一个细节:哪怕是同一段代码,只是调整了其中部分内容的位置,它在内存中实际占据的空间就可能不一样。
在下文我会把 “如何写代码更省内存” 的方法总结给大家,不过在这之前,也欢迎大家先思考:为什么仅仅是位置变化,会导致内存占用出现这样的不同呢?
案例三:包含double类型
struct S3
{double d; char c; int i;
};
printf("%d\n", sizeof(struct S3));
内容 | 本身的大小(字节) | VS 默认对齐数(字节) | 较小值(字节) | 对齐数(字节) |
---|---|---|---|---|
d | 8 | 8 | 8 | 8 |
c | 1 | 8 | 1 | 1 |
i | 4 | 8 | 4 | 4 |
S3的最大对齐数是8,所以它实际占用的空间是 16 个字节。
案例四:嵌套结构体
struct S3
{double d; char c; int i;
}; struct S4
{char c1; struct S3 s3; double d;
};
printf("%d\n", sizeof(struct S4));
内容 | 本身的大小(字节) | VS 默认对齐数(字节) | 较小值(字节) | 对齐数(字节) |
---|---|---|---|---|
c1 | 1 | 8 | 1 | 1 |
s3 | 16(S3 结构体大小) | 8(S3 的最大对齐数为 8) | 8 | 8 |
d | 8 | 8 | 8 | 8 |
S4的最大对齐数是8,所以它实际占用的空间是 32 个字节。
2.4 为什么需要内存对齐?
⼤部分的参考资料都是这样说的:
- 硬件平台限制
- 访问限制:某些硬件平台只能从特定对齐的地址读取特定类型的数据
- 硬件异常:未对齐的访问可能导致程序崩溃或硬件异常
- 性能优化考虑
数据结构(尤其是栈)应该尽可能地在⾃然边界上对齐。如果内存没有对齐,那么为了访问未对齐的内存,处理器需要作两次内存访问;而对齐的内存仅需要访问一次。
假设⼀个处理器总是从内存中取8个字节,则地址必须是8的倍数。如果我们能保证将所有的double
类型的数据的地址都对齐成8的倍数,那么就只需要用⼀个内存操作来读或者写值了。否则,我们可能需要执行两次内存访问,因为对象可能被分放在两个8字节内存块中。
总体来说:结构体的内存对齐是拿空间来换取时间的做法。
那在设计结构体的时候,我们想要既满足对齐,又要节省空间,应该怎么做呢?
我们先看下面的代码,计算一下S1和S2所占空间的大小有什么区别
struct S1 {char a; int b; char c; double d;
}; struct S2 {double d; int b; char a; char c;
};
所以我们就可以得出以下的空间优化原则:
- 从大到小排列:将占用空间大的成员放在前面
- 相同类型集中:相同类型的成员尽量放在一起
- 考虑访问频率:高频访问的成员可以放在前面优化缓存
2.5 修改默认对齐数
使用#pragma pack
#include <stdio.h>#pragma pack(1) // 设置对齐数为1(即不对齐)
struct S
{char c1; int i; char c2;
};
#pragma pack() int main()
{printf("%d\n", sizeof(struct S));return 0;
}
结构体在对齐方式不合适的时候,我们可以自己更改默认对齐数
3. 结构体传参
结构体作为函数参数传递时,主要有两种方式:值传递和地址传递。这两种方式在性能、内存使用和安全性方面有着显著差异。
struct S
{int data[1000];int num;
};
struct S s = { {1,2,3,4}, 1000 };//值传递
void print1(struct S s)
{printf("%d\n", s.num);
}//地址传递
void print2(struct S* ps)
{printf("%d\n", ps->num);
}int main()
{print1(s); print2(&s);return 0;
}
上面的 print1 和 print2 函数哪个好些?
答案是:首选 print2 函数。
原因:
print1
(值传递):调用
print1(s)
时,会把结构体变量 s 完整地拷贝一份,传递给函数print1
。而这个结构体
struct S
里,有一个包含 1000 个int
元素的数组和一个int
类型的num
。int
占 4 字节,所以光是data
数组就占 4000 字节,再加上num
的 4 字节,整个结构体的大小至少是 4004 字节。传值传参时,要把这么大的结构体完整拷贝,会消耗大量内存,还会增加 CPU 拷贝数据的时间,当结构体更复杂、数据更多时,这种性能损耗会更严重。
print2
(地址传递):调用
print2(&s)
时,传递的是结构体变量 s 的地址(本质是一个指针)。在大多数系统中,指针的大小是固定的(比如 4 字节或 8 字节),和结构体本身的大小无关。传址传参时,只需要拷贝一个指针(几字节),内存拷贝的成本极低,CPU 几乎不需要花时间在拷贝数据上。
结论:
结构体传参的时候,要传结构体的地址。
4. 结构体实现位断
结构体讲完之后,我们来讲讲结构体实现 位段 的能力
4.1 什么是位段?
位段是C语言中一种特殊的数据结构,允许我们在结构体中以位(bit)为单位来指定成员变量所占用的内存大小。这是一种极其精细的内存控制机制。
4.2 位段的基本语法
位段的声明
位段的声明和结构是类似的,有两个不同:
位段的成员必须是
int
、unsigned int
或signed int
,在C99中位段成员的类型也可以选择其他类型。位段的成员名后边有一个冒号和一个数字。
比如:
struct A
{int _a:2;int _b:5;int _c:10;int _d:30;
};
A就是一个位段类型。
那位段A所占内存的大小是多少呢?
不同的编译器针对位段有不同的处理方式。在VS编译器中,编译器对位段的处理方式为:如果当前整型单元剩余空间不足以容纳下一个位段,就会开辟新的整型单元存放。
_a
占用 2 位,_b
占用 5 位,_c
占用 10 位,这三个位段成员一共占用2 + 5 + 10 = 17
位,它们可以被存储在同一个int
类型的空间内。- 当存储
_d
时,由于它需要 30 位,而前一个int
类型空间只剩下32 - 17 = 15
位,不足以容纳_d
,所以编译器会开辟一个新的int
类型空间来存储_d
。
编译器特性 | Visual Studio(VS) | GCC | Clang |
---|---|---|---|
内存分配策略 | 从低地址向高地址分配,依赖默认对齐数(通常为 8),剩余空间不足则开辟新整型单元。 | 默认从低地址向高地址分配,可通过__attribute__((packed)) 等实现紧凑布局,灵活处理边界。 | 默认从低地址向高地址分配,支持类似__attribute__((packed)) 的紧凑布局,跨平台一致性好。 |
未使用位处理 | 按内部对齐 / 填充规则处理,易留填充位,结构体可能更占内存。 | 默认保留未使用位,优化编译时可合理利用 / 省略填充,减少内存浪费。 | 类似 GCC,默认保留未使用位,可通过优化选项提升内存效率。 |
跨平台兼容性 | 主要面向 Windows,跨平台移植需大量调整(因内存模型、对齐规则差异)。 | 跨平台(Linux/macOS/Windows 等),对 C 标准遵循度高,特殊平台(嵌入式)需微调。 | 跨平台性好(macOS/iOS 等默认编译器),C 标准遵循度高,跨平台行为一致性优。 |
4.3 位段的内存分配规则
内存分配机制
- 按需分配:位段空间按照需要以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;
}
4.4 位段的跨平台问题
主要的不确定性
问题点 | 不同平台表现 | 影响 |
---|---|---|
分配方向 | 从左到右 / 从右到左 | 内存布局不同 |
类型符号 | 有符号数 / 无符号数 | 数值解释不同 |
最大位数 | 16位 / 32位 / 64位限制 | 可定义位数不同 |
边界处理 | 舍弃剩余位 / 利用剩余位 | 内存使用效率不同 |
int
位段被当成有符号数还是无符号数是不确定的。- 位段中最大位的数目不能确定。(16位机器最大16,32位机器最大32,写成27,在16位机器会出问题。)
- 位段中的成员在内存中从左向右分配,还是从右向左分配,标准尚未定义。
- 当⼀个结构包含两个位段,第⼆个位段成员比较大,无法容纳于第一个位段剩余的位时,是舍弃剩余的位还是利用,这是不确定的
总结:
跟结构相比,位段可以达到同样的效果,并且可以很好的节省空间,但是有跨平台的问题存在。
4.5 位段的实际应用场景
下图是网络协议中,IP数据报的格式,我们可以看到其中很多的属性只需要几个bit
位就能描述,这里使用位段,能够实现想要的效果,也节省了空间,这样网络传输的数据报大小也会较小⼀些,对网络的畅通是有帮助的。
网络协议头
// IP数据报头部(简化版)
struct IPHeader
{unsigned int version : 4; // 版本号(4位)unsigned int ihl : 4; // 首部长度(4位)unsigned int tos : 8; // 服务类型(8位)unsigned int total_length : 16; // 总长度(16位)unsigned int identification : 16; // 标识符(16位)unsigned int flags : 3; // 标志(3位)unsigned int fragment_offset : 13; // 片偏移(13位)unsigned int ttl : 8; // 生存时间(8位)unsigned int protocol : 8; // 协议(8位)unsigned int checksum : 16; // 首部校验和(16位)unsigned int src_addr[4]; // 源IP地址(32位)unsigned int dest_addr[4]; // 目的IP地址(32位)
};
4.6 位段的操作与限制
位段的几个成员共有同一个字节,这样有些成员的起始位置并不是某个字节的起始位置,那么这些位置处是没有地址的。内存中每个字节分配一个地址,一个字节内部的bit位是没有地址的。
所以不能对位段的成员使用&操作符,这样就不能使用scanf
直接给位段的成员输入值,只能是先输入放在一个变量中,然后赋值给位段的成员
位段的赋值操作
struct Flags
{unsigned int flag1 : 1;unsigned int flag2 : 1;unsigned int value : 4;
};int main()
{struct Flags f = {0};f.flag1 = 1; // 正确:赋值0或1(1位)f.value = 12; // 正确:12在0-15范围内(4位)f.value = 20; // 危险:20超出4位表示范围(0-15)printf("flag1 = %u, value = %u\n", f.flag1, f.value);return 0;
}
4.6.1 重要的使用限制
限制1:不能取地址
struct BitField
{unsigned int a : 4;
};int main()
{struct BitField bf;// unsigned int* ptr = &bf.a; // 错误:不能对位段成员取地址return 0;
}
限制2:不能使用scanf
直接输入
struct Data
{unsigned int value : 8;
};int main()
{struct Data d;int temp;// scanf("%u", &d.value); // 错误:不能直接输入scanf("%u", &temp); // 正确:先读到临时变量d.value = temp; // 再赋值给位段成员return 0;
}
限制3:不能定义位段数组
struct InvalidExample
{// unsigned int array[10] : 4; // 错误:不能定义位段数组
};
4.6.2 位段的最佳实践
适用场景
✅ 推荐使用位段:
- 硬件寄存器映射
- 网络协议头定义
- 内存极度受限的嵌入式系统
- 需要精确控制内存布局的场景
❌ 避免使用位段:
- 需要跨平台移植的代码
- 性能关键路径
- 需要取地址操作的场景
- 复杂的位操作需求
通过本篇对结构体的探讨,我们见证了C语言在数据组织方面的强大能力。从基础的结构体声明到高级的内存对齐技巧,再到精细的位段操作,结构体为我们提供了多层次的数据抽象和控制能力。
掌握了结构体,你就为学习链表、树、图等高级数据结构打下了坚实基础。
“程序是对复杂性的管理,而结构体是我们对抗混乱的第一道防线。”