一、结构体类型的声明与基础使用
1.1 结构体回顾
结构体(struct)是不同类型数据的集合 ,这些数据称为成员变量。
1.1.1 结构的声明
通用语法:
struct tag {
member-list;
} variable-list;
示例:描述一个学生信息
struct Stu {
char name[20]; // 名字
int age; // 年龄
char sex[5]; // 性别
char id[20]; // 学号
}; // 分号不能丢
1.1.2 结构体变量的创建和初始化
两种初始化方式:
#include <stdio.h>
struct Stu {
char name[20];
int age;
char sex[5];
char id[20];
};
int main() {
// 1. 按成员顺序初始化
struct Stu s = {"张三", 20, "男", "20230818001"};
// 2. 按指定成员初始化(推荐,顺序无关)
struct Stu s2 = {.age = 18, .name = "lisi", .id = "20230818002", .sex = "女"};
printf("name: %s\n", s.name);
printf("age : %d\n", s.age);
return 0;
}
1.2 结构体的特殊声明(匿名结构体)
声明时可以省略结构体标签 (tag),但有明显限制(只能使用一次不能进行定义):
// 匿名结构体1
struct {
int a;
char b;
float c;
} x;
// 匿名结构体2
struct {
int a;
char b;
float c;
} a[20], *p;
⚠️ 关键点 :编译器会把这两个匿名结构体当成完全不同的类型,所以下面的代码是非法的
p = &x; // 类型不匹配,编译报错
匿名结构体如果不配合**typedef重命名**,基本只能使用一次。
1.3 结构体的自引用(链表节点核心)
错误写法(自己放自己)
struct Node {
int data;
struct Node next; // ❌ 结构体包含自身变量,大小会无限递归
};
问题:结构体里不能包含自身类型的成员变量,否则结构体大小无法确定。
正确写法(指针自引用)
struct Node {
int data;
struct Node* next; // ✅ 指向同类型结构体的指针
};
指针的大小是固定的 (32 位系统 4 字节,64 位系统 8 字节),不会导致结构体大小无限递归。
匿名结构体 + typedef 的坑
下面的代码是错误的:
typedef struct {
int data;
Node* next; // ❌ 此时Node还未被定义
} Node;
解决方法:必须给结构体加上标签
typedef struct Node {
int data;
struct Node* next; // ✅ 标签Node已经声明,可以使用
} Node;
二、核心:结构体内存对齐规则
这是 C 语言高频考点,也是计算结构体大小的唯一标准,记住这 4 条就够了:
- 第一个成员 :直接放在结构体起始地址 (偏移量为 0)。
- 后续成员 :对齐到「对齐数」的整数倍地址处。
- 对齐数 =
编译器默认对齐数和成员自身大小的较小值 - VS 默认对齐数是 8 ;Linux(GCC)默认无对齐数 ,对齐数就是成员自身大小。
- 对齐数 =
- 结构体总大小 :必须是「最大对齐数」的整数倍 (所有成员的对齐数中 ,取最大值)。
- 嵌套结构体 :嵌套的结构体成员,要对齐到自己内部最大对齐数的整数倍处 ;整体结构体大小 要对齐到所有最大对齐数 (含嵌套结构体的 )的整数倍。
2.1 练习题逐题解析(VS 环境,默认对齐数 8)
练习 1:struct S1
struct S1 {
char c1; // 大小1,对齐数1 → 偏移0
int i; // 大小4,对齐数4 → 偏移需为4的倍数 → 偏移4(填充3字节)
char c2; // 大小1,对齐数1 → 偏移8
};
- 成员占的总字节:1 + 4 + 1 = 6
- 最大对齐数:4(来自 int i)
- 总大小需为 4 的倍数 → 最终
sizeof(struct S1) = 12
练习 2:struct S2
struct S2 {
char c1; // 大小1,对齐数1 → 偏移0
char c2; // 大小1,对齐数1 → 偏移1
int i; // 大小4,对齐数4 → 偏移需为4的倍数 → 偏移4(填充2字节)
};
- 成员占的总字节:1 + 1 + 4 = 6
- 最大对齐数:4(来自 int i)
- 总大小需为 4 的倍数 → 最终**
sizeof(struct S2) = 8**
💡 对比:S1 和 S2 成员完全一样 ,只是顺序不同 ,大小差了 4 字节! 这就是**「空间优化」的关键。**
练习 3:struct S3
struct S3 {
double d; // 大小8,对齐数8 → 偏移0
char c; // 大小1,对齐数1 → 偏移8
int i; // 大小4,对齐数4 → 偏移需为4的倍数 → 偏移12(填充3字节)
};
- 成员占的总字节:8 + 1 + 4 = 13
- 最大对齐数:8(来自 double d)
- 总大小需为 8 的倍数 → 最终
sizeof(struct S3) = 16
练习 4:嵌套结构体 struct S4
struct S4 {
char c1; // 大小1,对齐数1 → 偏移0
struct S3 s3; // 嵌套结构体S3,其内部最大对齐数为8 → 偏移需为8的倍数 → 偏移8(填充7字节)
double d; // 大小8,对齐数8 → 偏移24
};
- 成员占的总字节:1 + 16(S3 大小) + 8 = 25
- 最大对齐数:8(来自 double d 和 S3 的最大对齐数)
- 总大小需为 8 的倍数 → 最终
sizeof(struct S4) = 32
2.2 为什么要内存对齐?(用空间换取时间)
内存对齐本质是用空间换时间,核心原因有 2 个:
- 平台原因(移植性) :不是所有硬件都能访问任意地址的数据,部分平台只能在特定地址读取特定类型的数据,否则会抛出硬件异常。
- 性能原因 :处理器访问未对齐的内存时 ,需要执行两次内存访问 ;而对齐的内存只需要一次访问 。比如读取一个 double 类型(8 字节),如果地址是 8 的倍数 ,一次就能读完 ;如果不是,可能要分两次读取跨内存块的数据。(提高效率)
2.3 结构体空间优化技巧
核心原则 :让占用空间小的成员尽量集中在一起。
- 反例(S1):char → int → char → 总大小 12 字节
- 正例(S2):char → char → int → 总大小 8 字节
- 效果**:成员完全相同,顺序调整后节省了 4 字节**!
2.3 修改默认对齐数:#pragma pack(最好为1 2 4 8这类数)
可以通过预处理指令修改编译器默认对齐数,在特殊场景下节省空间。
#include <stdio.h>
#pragma pack(1) // 设置默认对齐数为1
struct S {
char c1; // 对齐数=min(1,1)=1 → 偏移0
int i; // 对齐数=min(1,4)=1 → 偏移1
char c2; // 对齐数=min(1,1)=1 → 偏移5
};
#pragma pack() // 取消自定义对齐数,恢复默认
int main() {
// 此时总大小为1+4+1=6字节,无需填充
printf("%d\n", sizeof(struct S)); // 输出6
return 0;
}
⚠️ 注意:修改默认对齐数会牺牲部分性能 ,一般只在空间极度紧张的场景(如嵌入式开发)使用。
三、结构体传参:为什么推荐传地址?
3.1 对比两种传参方式:
struct S {
int data[1000]; // 4000字节
int num; // 4字节
};
struct S s = {{1,2,3,4}, 1000};
// 1. 传值调用
void print1(struct S s) {
printf("%d\n", s.num);
}
// 2. 传地址调用
void print2(struct S* ps) {
printf("%d\n", ps->num);
}
int main() {
print1(s); // 传结构体
print2(&s); // 传地址
return 0;
}
输出的结果都是 100
3.2 两种方式的核心区别
| 方式 | 开销 | 特点 | 适用场景 |
|---|---|---|---|
| 传值调用(print1) | 极高 | 会拷贝整个结构体副本 ,栈空间开销大,性能差 | 结构体极小 (如只有 1-2 个成员),且不希望修改原数据 |
| 传地址调用(print2) | 极低 | 只传一个指针(8 字节) ,栈开销极小,性能高 | 绝大多数场景 ,尤其是结构体较大时 |
3.3 关键结论与补充
-
性能差异 :
struct S大小是4000 + 4 = 4004字节,传值调用时,每次调用都要压入 4004 字节的栈帧;而传地址只压入 8 字节指针,性能差距非常明显。 -
修改原数据 :传值调用无法修改原结构体内容,只能操作副本;传地址可以直接修改原数据(配合
const还能保证只读安全)。 -
最佳实践 :结构体传参优先传地址 ,如果只是读取数据,可以用
const struct S* ps保证安全,既高效又不会误修改。// 优化后的安全只读版本
void print2(const struct S* ps) {
printf("%d\n", ps->num);
// ps->num = 2000; // 编译报错,无法修改原数据
}
四、什么是位段?
位段 是结构体的特殊形式,核心作用是用更少的内存存储数据。它的声明和普通结构体类似,但有两个关键区别:
- 成员类型必须是
int/unsigned int/signed int(C99 支持char等其他整型) - 成员名后必须跟一个冒号和数字,表示该成员占用的二进制位数
示例:
struct A {
int _a : 2; // 占用2个bit
int _b : 5; // 占用5个bit
int _c : 10; // 占用10个bit
int _d : 30; // 占用30个bit
};
4.1 位段的内存分配与大小计算
1. 分配规则
- 位段会按「基础类型大小」分批开辟空间:
int类型按 4 字节(32bit)开辟,char类型按 1 字节(8bit)开辟。 - 同一批的位段成员会被打包进同一个基础类型的空间中,直到空间用完,才会开辟新的空间。
2. 计算 struct A 的大小
我们按 VS 环境的规则计算:
- 第 1 批:
_a(2bit) + _b(5bit) + _c(10bit) = 17bit,小于 32bit → 存进第一个int(4 字节) - 第 2 批:
_d(30bit),需要新开辟一个int(4 字节) - 总大小:
4 + 4 = 8字节
3. struct S 内存布局示例(VS 环境)
struct S {
char a:3; // 占3bit
char b:4; // 占4bit
char c:5; // 占5bit
char d:4; // 占4bit
};
- 第 1 个
char(8bit):a(3bit) + b(4bit) = 7bit,剩余 1bit 空间不够放下c(5bit),开辟新的char - 第 2 个
char(8bit):c(5bit),剩余 3bit 空间不够放下**d(4bit)** ,开辟新的char - 第 3 个
char(8bit):d(4bit) - 总大小:
3字节
VS 中实际存储:
a=10(二进制1010)取低 3 位010b=12(二进制1100)取低 4 位1100c=3(二进制0011)取低 5 位00011d=4(二进制0100)取低 4 位0100

- 最终按低地址到高地址打包成 3 个字节:
0x62 0x03 0x04。
4.2 位段的跨平台问题(⚠️ 重点避坑)
位段的实现标准并未完全统一 ,存在很多不确定因素,直接影响可移植性:
- 符号性不确定 :
int类型的位段是否被当作有符号数,由编译器决定。 - 最大位数限制 :位段的最大位数不能超过基础类型的总位数,比如 16 位机器上
int是 16bit,定义:27会报错。 - 分配方向不确定:位段是从左到右分配,还是从右到左分配,标准没有定义。
- 剩余空间处理不确定:当前基础类型空间剩余的 bit 不够放下下一个成员时,是直接开辟新空间,还是跳过剩余空间再开辟,由编译器决定。
4.3 位段的应用场景
位段最经典的应用场景是网络协议(如 IP 数据报) :IP 数据报的头部有很多字段只需要几个 bit 就能表示,比如:
- 版本号 :4bit
- 首部长度 :4bit
- 标志位 :3bit
- 片偏移 :13bit使用位段可以把这些字段打包进同一个字节 / 字中,大幅减少数据报的大小,提升网络传输效率。
4.4 位段使用的注意事项
-
无法对位段成员取地址 :
&运算符不支持位段成员 ,因为它们没有独立的内存地址 ,不能用scanf直接输入值 ,只能先存到普通变量中再赋值。struct A sa = {0}; scanf("%d", &sa._b); // ❌ 错误:位段成员没有地址 int b = 0; scanf("%d", &b); sa._b = b; // ✅ 正确:先输入到普通变量,再赋值 -
位段成员的取值范围有限 :比如
unsigned int a:3只能存储 0~7(2³-1)的值,超出范围会溢出,需要手动处理。 -
不适合跨平台通用代码:如果你的代码需要在不同编译器 / 平台上运行,尽量避免使用位段。
4.5 总结
| 特性 | 说明 |
|---|---|
| 优点 | 大幅节省内存空间,适合嵌入式、网络协议等内存敏感场景 |
| 缺点 | 可移植性差,存在跨平台兼容性问题,无法直接取地址 |
| 核心规则 | 按基础类型大小分批打包,同一批成员共用基础类型的空间 |