本章目标:掌握结构体声明与初始化 + 理解内存对齐的来龙去脉 + 学会结构体正确传参 + 理解自引用原理
引入:数组的局限,就是结构体的舞台
学到现在,我们接触的数据类型都是"单一类型":
int a→ 一个整数double b→ 一个浮点数int arr[10]→ 一组整数(同一种类型)
但现实中的数据,往往不是同质化的。比如描述一个学生:
姓名 (字符串)、年龄 (整数)、性别 (字符)、身高 (浮点数)、学号(字符串)
用数组?不行------数组要求所有元素类型完全一致。
用 5 个独立变量?勉强能跑,但当你要管理一个班 50 个学生的时候......
c
// 用独立变量的地狱
char name1[20], name2[20], ...; // 50个名字变量?
int age1, age2, age3...; // 50个年龄变量?
char sex1[5], sex2[5]...; // 50个性别变量?
这根本没法维护,每个学生都要对应 5 个变量,改一个学生的信息要找半天。
结构体(struct)就是为了解决这类问题而生的:把不同类型的数据"打包"成一个逻辑整体。
第一部分:结构体的声明
1.1 基本语法
c
struct 结构体标签
{
成员1的类型 成员1的名字;
成员2的类型 成员2的名字;
成员3的类型 成员3的名字;
...
}; // 注意:末尾分号不能丢!丢了就是语法错误!
声明一个"学生"结构体:
c
struct Stu
{
char name[20]; // 姓名(字符数组)
int age; // 年龄(整数)
char sex[5]; // 性别(字符数组,"男"或"女")
char id[20]; // 学号(字符数组)
}; // ← 这个分号是必须的!
1.2 声明的同时定义变量
c
// 声明类型 + 定义变量,一行搞定
struct Point
{
int x;
int y;
} p1, p2; // 同时定义两个 Point 类型的变量
1.3 匿名结构体(强烈不推荐,但面试必问)
c
struct // ← 没有标签名字,叫"匿名结构体"
{
int a;
char b;
float c;
} x; // 只在声明时定义了这个唯一的变量 x
⚠️ 提醒:匿名结构体看起来省事,但有两个严重问题:
问题一 :匿名结构体只能定义这一次。之后想再创建同类型的变量?不行,因为没有类型名。
问题二(更坑) :成员完全相同的两个匿名结构体,在编译器眼里是两种完全不同的类型,不能互相赋值:
cstruct { int a; } x; struct { int a; } y; y = x; // ❌ 编译错误!类型不兼容!所以面试官问"能不能用匿名结构体"时,标准答案是:尽量别用。给结构体加个标签名(tag),又简单又不踩坑。
第二部分:结构体变量的创建和初始化
2.1 三种创建变量的方式
c
// 方式一:声明类型,再定义变量
struct Stu s1; // 创建了一个 Stu 类型的变量 s1
// 方式二:声明时直接定义
struct Stu s2 = {"张三", 20, "男", "20230001"};
// 方式三:用 typedef 取别名(最常用)
typedef struct Stu
{
char name[20];
int age;
char sex[5];
char id[20];
} Stu; // Stu 现在就是 struct Stu 的别名
Stu s3 = {"李四", 22, "女", "20230002"}; // 简洁多了!
2.2 初始化:顺序初始化
按成员声明的顺序依次赋值:
c
struct Stu s = { "张三", 20, "男", "20230818001" };
// { name, age, sex, id }(按顺序)
2.3 初始化:指定成员初始化(C99 新特性)
不按顺序也行,用 .成员名 指定:
c
struct Stu s = {
.age = 18, // 年龄放第一个,OK
.name = "李四",
.id = "20230818002",
.sex = "女"
};
💡 实战建议:用指定初始化,代码可读性更好。当成员很多、或者成员类型相似(比如都是 int)时,指定初始化能避免搞错顺序而出 bug。
2.4 访问结构体成员:点号 .
c
struct Stu s = {"张三", 20, "男", "20230818001"};
// 读成员
printf("姓名:%s\n", s.name); // 张三
printf("年龄:%d\n", s.age); // 20
// 写成员
s.age = 21; // 修改年龄
strcpy(s.name, "王五"); // 修改名字(数组不能直接赋值)
🔑 记忆口诀 :小数点
.= "的",s.name就是"s 的 name"。
第三部分:结构体的自引用(链表基础)
3.1 错误示范:直接嵌套自己
c
struct Node
{
int data; // 数据
struct Node next; // ❌ 错误!自身包含自身,大小无穷大!
};
为什么不行?
假设
struct Node包含一个int data(4字节)和一个struct Node next。要计算
struct Node的大小 = 4 + (struct Node 的大小)→ struct Node 的大小 = 4 + (4 + (4 + (4 + ...))) → 无穷大 !
编译器直接报错:
field has incomplete type
3.2 正确做法:用指针自引用
c
struct Node
{
int data; // 4 字节
struct Node *next; // 8 字节(64位系统)
};
// sizeof(struct Node) = 4 + 8 = 12 字节 ✅(编译器自动对齐到 16)
💡 核心区别:指针的大小是固定的(4 字节或 8 字节),不会随着指向的内容变大。用指针指向自身,结构体的大小就能确定了。
3.3 链表节点的完整示例
c
#include <stdio.h>
#include <stdlib.h>
struct Node {
int data;
struct Node *next; // 指向下一个节点
};
int main()
{
// 创建三个节点:1 → 2 → 3 → NULL
struct Node *n1 = malloc(sizeof(struct Node));
struct Node *n2 = malloc(sizeof(struct Node));
struct Node *n3 = malloc(sizeof(struct Node));
n1->data = 1; n1->next = n2;
n2->data = 2; n2->next = n3;
n3->data = 3; n3->next = NULL;
// 遍历链表
struct Node *p = n1;
while (p != NULL) {
printf("%d ", p->data); // 输出:1 2 3
p = p->next;
}
// 释放内存
free(n1); free(n2); free(n3);
return 0;
}
3.4 typedef 场景下的自引用陷阱
错误代码:
c
typedef struct // 匿名结构体 + typedef
{
int data;
Node *next; // ❌ 编译错误!Node 在这里还没定义完
} Node;
原因分析:
1. typedef 开始定义
2. 碰到 Node* next; 尝试使用 Node 类型
3. 但是 Node 是 typedef 给"后面"匿名结构体起的别名
4. typedef 还没执行完,Node 还不存在
5. → 编译错误
正确写法(给结构体加标签):
c
typedef struct ListNode // ✅ 有标签名
{
int data;
struct ListNode *next; // ✅ 用标签名引用自身,不依赖 typedef
} Node; // typedef 给整个类型起了别名 Node
🔑 记忆口诀 :自引用用指针,typedef 别用匿名,标签名记得写上。
第四部分:结构体内存对齐(核心难点)
4.1 什么是内存对齐?
先看一个奇怪的现象:
c
struct S1 { char c1; int i; char c2; }; // char + int + char
struct S2 { char c1; char c2; int i; }; // char + char + int
printf("%d\n", sizeof(struct S1)); // 12
printf("%d\n", sizeof(struct S2)); // 8
同样都是 1 + 4 + 1 = 6 字节的成员,为什么大小不一样?
答案就是:内存对齐(Memory Alignment)。
4.2 什么是内存对齐?(通俗解释)
内存对齐:结构体成员在内存中不是紧凑排列的,而是按一定规则"隔开"存放。每个成员的起始地址必须是某个值(对齐数)的整数倍。
💡 高铁座位比喻:想象高铁车厢,每个座位的位置是固定的(1车1座、1车2座......)。如果硬要把"两个人"塞进同一个座位,检票员会要求换座位------CPU 也有类似的"座位规则",数据必须坐在自己的对齐位置上,读取才高效。
4.3 四大对齐规则(VS 环境,默认对齐数 = 8)
| 规则 | 内容 |
|---|---|
| 规则一 | 第 1 个成员的偏移量(相对于结构体起始地址)= 0 |
| 规则二 | 从第 2 个成员起:起始地址对齐到「自身大小 」和「默认对齐数 」的较小值 |
| 规则三 | 结构体总大小 = 所有成员对齐数中最大 的值的整数倍 |
| 规则四 | 嵌套结构体:内层结构体成员对齐到其内部最大对齐数的整数倍 |
🔑 默认对齐数:VS 默认 = 8,Linux gcc 没有默认对齐数(就是成员自身大小)。大多数场景下差异不大。
4.4 练习一:char + int + char(S1 = 12 字节)
c
struct S1
{
char c1; // 大小=1,对齐数=min(1,8)=1
int i; // 大小=4,对齐数=min(4,8)=4
char c2; // 大小=1,对齐数=min(1,8)=1
};
手把手推演:
偏移量(相对结构体起始地址):
偏移 0: [ c1 ] ← 规则一:第1个成员偏移=0,占1字节
偏移 1-3: [ pad ] ← 规则二:i 对齐到 4,所以 1-3 要填充 3 字节
偏移 4-7: [ i ] ← i 占 4 字节(4,5,6,7)
偏移 8: [ c2 ] ← c2 占 1 字节
偏移 9-11: [ pad ] ← 规则三:总大小=最大对齐数(4)的倍数
目前 9 字节 → 补到 12(4的倍数)
sizeof(S1) = 12 字节 ✅
4.5 练习二:char + char + int(S2 = 8 字节)
c
struct S2
{
char c1; // 大小=1,对齐数=1
char c2; // 大小=1,对齐数=1
int i; // 大小=4,对齐数=4
};
手把手推演:
偏移量:
偏移 0: [ c1 ] ← 第1个成员,偏移=0,占1字节
偏移 1: [ c2 ] ← c2 对齐数=min(1,8)=1 → 偏移1 OK(1是1的倍数)
偏移 2-3: [ pad ] ← i 对齐到 4 → 偏移2 不是 4 的倍数 → 补到 4
偏移 4-7: [ i ] ← i 占 4 字节
目前 8 字节,最大对齐数 = 4 → 8 是 4 的倍数 ✅
sizeof(S2) = 8 字节 ✅
💡 性能优化核心经验 :把相同类型的成员写在一起,可以减少填充字节。S1 和 S2 成员完全一样,但 S1 占 12 字节,S2 占 8 字节------优化了 33% 的空间!
4.6 练习三:double + char + int(S3)
c
struct S3
{
double d; // 大小=8,对齐数=8
char c; // 大小=1,对齐数=1
int i; // 大小=4,对齐数=4
};
手把手推演:
偏移量:
偏移 0-7: [ d ] ← 第1个成员,偏移=0,占8字节
偏移 8: [ c ] ← c 对齐数=min(1,8)=1 → 偏移8 OK
偏移 9-11: [ pad ] ← i 对齐到 4 → 9 不是 4 的倍数 → 补到 12
偏移 12-15: [ i ] ← i 占 4 字节
目前 16 字节,最大对齐数 = 8 → 16 是 8 的倍数 ✅
sizeof(S3) = 16 字节 ✅
4.7 练习四:嵌套结构体(S4)
c
struct S3
{
double d; // 大小=8,对齐数=8
char c; // 大小=1,对齐数=1
int i; // 大小=4,对齐数=4
}; // S3 内部最大对齐数 = 8
struct S4
{
char c1; // 大小=1,对齐数=1
struct S3 s3; // S3 本身对齐到 S3.max_align = 8
double d; // 大小=8,对齐数=8
};
手把手推演 S4:
偏移量:
偏移 0: [ c1 ] ← 第1个成员,偏移=0,占1字节
偏移 1-7: [ pad ] ← s3 对齐数=8 → 偏移1 不是 8 的倍数 → 补到 8
偏移 8-15: [ d ] ← S3.d,占 8 字节(8~15)
偏移 16: [ c ] ← S3.c,占 1 字节
偏移 17-19: [ pad ] ← S3.i 对齐到 4 → 偏移17 不是 4 → 补到 20(但 S3 总大小 16,已对齐8倍数)
← S3 结束于偏移 23(16 字节,已是8倍数 OK)
偏移 24-31: [ d ] ← S4.d,占 8 字节
目前 32 字节,最大对齐数 = 8 → 32 是 8 的倍数 ✅
sizeof(S4) = 32 字节 ✅
第五部分:修改默认对齐数
5.1 #pragma pack 基本用法
c
#include <stdio.h>
#pragma pack(1) // 把对齐数改为 1(完全按1字节紧凑排列)
struct S
{
char c1; // 偏移 0,占 1 字节
int i; // 偏移 1,占 4 字节
char c2; // 偏移 5,占 1 字节
};
#pragma pack() // 恢复默认对齐数(VS=8)
int main()
{
printf("%d\n", sizeof(struct S)); // 输出 6(没有填充)
return 0;
}
5.2 #pragma pack(4) 的效果
c
#pragma pack(4) // 对齐数改为 4
struct S
{
char c1; // 偏移 0,占 1 字节
int i; // 偏移 4(min(4,4)),占 4 字节(4~7)
char c2; // 偏移 8,占 1 字节
// 补到 12(最大对齐数4的倍数)
};
#pragma pack()
printf("%d\n", sizeof(struct S)); // 输出 12
5.3 什么时候用 #pragma pack?
| 场景 | 说明 |
|---|---|
| 网络协议数据包 | 协议规定的字段顺序和大小必须精确,不能有多余填充 |
| 文件格式解析 | BMP、PNG、MP3 等文件格式有固定的二进制布局 |
| 硬件寄存器映射 | 硬件的寄存器地址是固定的,不能有偏移 |
⚠️ 提醒 :
#pragma pack会影响整个文件的作用范围,记得用完后pack()恢复默认,否则后面的所有结构体都会被影响。
第六部分:结构体传参------传值 vs 传地址
6.1 两种传参方式
c
struct S
{
int data[1000]; // 4000 字节
int num; // 4 字节
};
struct S s = {{1,2,3,4}, 1000};
// 传值:整个结构体(4004字节)压栈
void print1(struct S s)
{
printf("%d\n", s.num); // 只读,可以
}
// 传地址:只压一个指针(8字节,64位系统)
void print2(struct S *ps)
{
printf("%d\n", ps->num); // 用 -> 访问指针指向的结构体成员
}
6.2 传值的问题:性能开销
函数调用时参数是怎么传递的?
大多数情况下,函数参数是通过**栈(stack)**传递的。当调用 print1(s) 时:
print1(s) 传值: print2(&s) 传地址:
整个 s(4004字节) 指针 ps(8字节)
全部压入栈中 只需要把地址压栈
↓ ↓
调用开销:大 调用开销:小
栈的空间是有限的。如果结构体很大(几千字节),频繁传值会导致:
- 栈空间消耗大(甚至栈溢出)
- 拷贝操作耗时(复制 4004 字节 vs 复制 8 字节)
- 性能显著下降
6.3 -> 操作符:访问指针指向的结构体成员
c
struct Stu {
char name[20];
int age;
};
struct Stu s = {"张三", 20};
struct Stu *p = &s;
// 两种等价写法:
printf("%s\n", s.name); // 普通变量:用 . 点号
printf("%s\n", p->name); // 指针:用 -> 箭头
printf("%d\n", s.age);
printf("%d\n", p->age);
🔑 记忆口诀 :
.= "的"(直接访问),->= "指向的"(间接通过指针访问)。
第七部分:经典面试题精讲
面试题一:什么是内存对齐?为什么需要内存对齐?
参考答案:
定义:结构体成员在内存中按一定规则存放,起始地址必须是对齐数的整数倍,不是想放哪儿就放哪儿。
为什么需要(两大原因):
① 性能原因(核心):CPU 访问内存不是逐字节的,而是以"字"为单位读取(32位系统 = 4字节,64位系统 = 8字节)。
对齐访问(int 在地址 4): 地址 0 1 2 3 4 5 6 7 数据 .. .. .. .. 12 34 56 78 ──────────────── 一次读取,4字节全拿到 ✅ 不对齐访问(int 在地址 5): 地址 0 1 2 3 4 5 6 7 8 数据 .. .. .. .. 12 34 56 78 .. ──────────────── ──── 第一次读(4~7) 第二次读(8~11)→ 合并 ✅ 但慢!② 硬件兼容性:某些 RISC 架构(早期 SPARC、MIPS)根本不支持不对齐访问,会触发总线错误(Bus Error)直接崩溃。
面试题二:计算下面结构体的大小
c
struct S5
{
char a; // 1字节,对齐数=1
struct {
int b; // 4字节,对齐数=4(内层 max)
char c; // 1字节,对齐数=1
} inner;
double d; // 8字节,对齐数=8(整体 max)
};
手把手推演:
偏移量:
偏移 0: [ a ] ← 第1个成员,偏移=0,占1字节
偏移 1-3: [ pad ] ← inner 对齐到 inner.max=4 → 1 不是 4 的倍数 → 补到 4
偏移 4-7: [ b ] ← inner.b,4字节
偏移 8: [ c ] ← inner.c,1字节
偏移 9-11: [ pad ] ← inner 自身大小=9 → 对齐到 max=4 → 补到 12
← inner 结束于偏移 11,总大小 12(4的倍数 OK)
偏移 12-19: [ d ] ← S5.d,对齐数=8,偏移 12 是 8 的倍数,占 8 字节
目前 20 字节,最大对齐数 = 8 → 补到 24(8 的倍数)
sizeof(S5) = 24 字节 ✅
面试题三:结构体自引用为什么用指针而不用结构体本身?
参考答案:
结构体包含自身类型变量会导致大小无穷大,因为:
sizeof(Node) = sizeof(int) + sizeof(Node) = 4 + (4 + (4 + (4 + ...))) → ∞结构体包含自身指针:指针大小固定(4 或 8 字节),所以:
sizeof(Node) = sizeof(int) + sizeof(Node*) = 4 + 8 = 12 字节(编译器对齐后可能是 16)✅
面试题四:下面代码的输出是什么?
c
#include <stdio.h>
#pragma pack(2)
struct X {
char a; // 0 1字节
int b; // 2~5 4字节(对齐到2)
char c; // 6 1字节
};
#pragma pack()
int main()
{
printf("%d\n", sizeof(struct X)); // ?
return 0;
}
推演:
偏移 0: [ a ] ← 第1个,偏移=0,1字节
偏移 1: [ pad ] ← b 对齐到 min(4,2)=2 → 偏移1 不是 2 的倍数 → 补到 2
偏移 2-5: [ b ] ← b,4字节(2,3,4,5)
偏移 6: [ c ] ← c,1字节
目前 7 字节,最大对齐数 = 2 → 补到 8(2 的倍数)
sizeof(X) = 8 ✅
答案:8
本章总结
结构体三步走:
c
// ① 声明类型(加标签,不用匿名)
struct Stu { char name[20]; int age; char sex[5]; char id[20]; };
// ② 创建变量
struct Stu s = { "张三", 20, "男", "20230818001" };
// ③ 访问成员(.点号)
printf("%s", s.name);
内存对齐规则速记:
第一条:第1个成员偏移 = 0
第二条:其他成员对齐到「自身大小」和「默认对齐数」的较小值
第三条:总大小 = 最大对齐数的整数倍
第四条:嵌套结构体对齐到自身最大对齐数
性能优化口诀:
成员按大小从大到小排(double > int > char),填充最少空间最省。
结构体传参:
传值 → 拷贝整个结构体 → 栈开销大 → ❌
传地址 → 拷贝指针(4~8字节)→ 栈开销小 → ✅
易错点速查:
| 陷阱 | 正确做法 |
|---|---|
| 匿名结构体成员相同但类型不兼容 | 避免匿名结构体,加标签 |
| 自引用用了结构体变量 | 用 struct Tag *next 指针 |
| memset 设 int 数组为非 0/-1 | 只用 memset 设 0 和 -1 |
| 成员顺序随意导致浪费空间 | 相同类型排一起,按大小降序 |
- 结构体:把不同类型的变量打包成团,内存对齐让 CPU 读得更快,成员排好队省空间
- 同样3个成员,顺序不同,大小从12变成8------优化内存,从学会排队开始