前面几讲我们用的都是 C 语言内置的类型:int、char、double、指针……但这些类型太"原子"了。真实世界的数据从来不是孤立的数值——一个学生有名字、年龄、学号;一个点有 x 坐标和 y 坐标;一个网络包有源地址、目标地址、数据长度和载荷。你用分散的变量来表示这些数据,写着写着就会发现自己在三个函数之间传七八个参数,顺序还不能错——这简直是代码维护的噩梦。
你需要把一组相关的变量打包成一个整体。这就是结构体(struct)的作用——它是 C 语言实现数据抽象的第一步。从简单的数据聚合,到链表、树、图等复杂数据结构,结构体始终是那块最底层、最基础的基石。
但结构体不只是"打包变量"那么简单。它的内存布局受制于一套称为"内存对齐"的规则——这直接决定了结构体占用多少字节,也决定了你的代码能不能高效地访问数据。理解内存对齐,是你从"能写代码"跨越到"写出高性能代码"的分水岭。
结构体类型的声明
声明结构体就是定义一个新的"模具"——告诉编译器这个类型由哪些字段组成:
struct Student {
char name[20]; // 名字
int age; // 年龄
char sex[5]; // 性别
char id[20]; // 学号
}; // 分号绝对不能丢!这是初学者最容易犯的语法错误记住:声明只是定义了一个类型,不分配内存——就像 int 本身不占内存,只有 int x; 才分配 4 字节。同样,只有当你用 struct Student s; 声明变量时,sizeof(struct Student) 的空间才被分配。
关于结构体声明,还有几个进阶语法需要知道:
- 声明和定义可以分开:你可以在头文件里声明结构体类型,在
.c文件里使用——这是模块化编程的基础。 - 可以同时定义类型和变量:
struct Point { int x; int y; } p1, p2;声明类型的同时定义了两个变量。但这种写法把"类型定义"和"变量定义"混在一起,代码可读性差,一般不推荐。 - typedef 简化写法:
typedef struct Student { ... } Stu;之后就可以直接Stu s;而不必写struct Stu s;。C 语言里struct关键字必须跟着类型名(C++ 中可省略),typedef 让这个写法更接近其他语言的习惯。
结构体 vs 数组:打包方式的不同
初学者常把结构体和数组搞混,其实它们的"打包"方式完全不同:
| 维度 | 数组 | 结构体 |
|---|---|---|
| 元素类型 | 必须相同 | 可以不同 |
| 元素数量 | 声明时指定 | 成员数固定(类型的一部分) |
| 访问方式 | 下标 arr[i] | 成员名 s.age |
| 拷贝 | 数组不能整体赋值拷贝 | 结构体可以整体赋值 s2 = s1; |
| 内存布局 | 连续同尺寸元素 | 成员按对齐规则排列 |
特别要注意最后一行:数组不能整体赋值(int a[3]; int b[3]; b = a; 是编译错误),但结构体可以(struct Point p2 = p1;)——这是 C 语言对结构体的特殊优待。函数参数传结构体也是整体拷贝(值传递)。
结构体变量的创建和初始化
创建变量和初始化有两种方式。C89 标准只支持按顺序初始化:
#include <stdio.h>
struct Student
{
char name[20]; /* 名字 */
int age; /* 年龄 */
char sex[5]; /* 性别 */
char id[20]; /* 学号 */
};
int main()
{
/* 方式一:按声明顺序初始化(C89) */
struct Student s1 = { "张三", 20, "男", "20230818001" };
/* 方式二:指定成员初始化(C99) */
struct Student s2 = {
.age = 18,
.name = "lisi",
.id = "20230818002",
.sex = "女"
};
printf("s1: %s, %d, %s, %s\n", s1.name, s1.age, s1.sex, s1.id);
printf("s2: %s, %d, %s, %s\n", s2.name, s2.age, s2.sex, s2.id);
return 0;
}C99 的指定初始化器(designated initializer)让你按名字初始化,顺序无所谓——这在初始化大型结构体时非常实用。未指定的字段自动置零。
struct Student s3 = { .age = 20 }; /* 其余字段自动为 0 / NULL / 空字符串 */这一点很关键:部分初始化时,未列出的成员自动置零(数值成员为 0,指针为 NULL,字符数组为全 '\0')。这是 C 标准明确规定的行为,也是"初始化和赋值"的重要区别——struct Student s; 之后 s 的成员是垃圾值,而 struct Student s = {0}; 则全部清零。所以结构体变量声明后立即 = {0} 清零是一个好习惯。
结构体的整体赋值与成员访问
访问成员时有两条规则:如果你持有的是结构体变量本身,用 . 操作符(s.name);如果你持有的是结构体指针,用 -> 操作符(ps->name),它等价于 (*ps).name。记住:. 跟变量,-> 跟指针。
#include <stdio.h>
#include <string.h>
struct Point
{
int x;
int y;
};
int main()
{
struct Point p1 = {10, 20};
struct Point p2; /* 未初始化,成员是垃圾值 */
struct Point *ptr = &p1;
/* 通过变量访问:用 . */
printf("p1.x = %d, p1.y = %d\n", p1.x, p1.y);
/* 通过指针访问:用 ->(等价于 (*ptr).x) */
printf("ptr->x = %d, ptr->y = %d\n", ptr->x, ptr->y);
/* 通过指针修改成员 */
ptr->x = 100;
ptr->y = 200;
printf("修改后: p1.x = %d, p1.y = %d\n", p1.x, p1.y);
/* 结构体整体赋值:把 p1 的每个字节拷贝给 p2(包括填充字节) */
p2 = p1;
printf("整体赋值后: p2.x = %d, p2.y = %d\n", p2.x, p2.y);
/* 注意:结构体整体赋值是值拷贝,p2 是独立的一份,
修改 p2 不会影响 p1 */
p2.x = 999;
printf("p1.x = %d, p2.x = %d(互不影响)\n", p1.x, p2.x);
return 0;
}结构体数组:批量管理同类型对象
既然结构体是一种类型,那么它自然可以组成数组——结构体数组是管理"多条记录"的最直接方式,比散落的变量清晰得多:
#include <stdio.h>
struct Student
{
char name[20];
int age;
float score;
};
int main()
{
/* 声明包含 3 个学生的结构体数组 */
struct Student stu[3] = {
{"张三", 20, 95.5f},
{"李四", 19, 88.0f},
{"王五", 21, 92.5f}
};
/* 用下标访问结构体数组的元素,再用 . 访问成员 */
int i;
for (i = 0; i < 3; i++)
{
printf("%s 年龄%d 分数%.1f\n",
stu[i].name, stu[i].age, stu[i].score);
}
return 0;
}关键语法点:stu 是一个数组,stu[i] 是一个结构体,stu[i].name 是这个结构体的成员。如果再用指针,就形成了三级嵌套:(*(stu + i)).name 等价于 stu[i].name。数组名退化后 stu 是 struct Student * 类型。
思考题:stu 在表达式里退化成指针后,stu + 1 一次跳多少字节?答案是 sizeof(struct Student)——指针算术的步长永远是"指向类型的完整大小"。这在上面的数组遍历中尤其重要:&stu[1] - &stu[0] == 1(一个元素的距离),不是 4 也不是 20。
结构体指针数组 vs 结构体数组
还有一种常见结构:指针数组——数组里存的是指针,每个指针指向一个独立分配的结构体。这两种方式的使用场景不同:
#include <stdio.h>
#include <stdlib.h>
struct Student
{
char name[20];
int age;
};
int main()
{
/* 方式一:结构体数组——内存连续,一次性分配 */
struct Student arr[3] = {{"A", 20}, {"B", 21}, {"C", 22}};
/* 方式二:结构体指针数组——每个元素指向堆上独立分配的结构体 */
struct Student *parr[3];
int i;
for (i = 0; i < 3; i++)
{
parr[i] = (struct Student *)malloc(sizeof(struct Student));
parr[i]->age = 20 + i;
sprintf(parr[i]->name, "P%d", i);
}
printf("结构体数组: ");
for (i = 0; i < 3; i++)
printf("%s ", arr[i].name);
printf("\n");
printf("指针数组: ");
for (i = 0; i < 3; i++)
printf("%s ", parr[i]->name);
printf("\n");
/* 指针数组需要逐个释放 */
for (i = 0; i < 3; i++)
free(parr[i]);
return 0;
}- 结构体数组:内存连续,缓存友好,但大小编译期固定。
- 指针数组:每个结构体可以单独分配/释放,适合大小动态变化的场景(如链表),但多了指针间接访问和内存碎片。
结构体的特殊声明
你可以在声明时省略结构体标签(tag),这叫匿名结构体:
struct {
int a;
char b;
float c;
} x; // x 是这种匿名类型的唯一实例匿名结构体只能用一次——后面没法再声明同类型的变量。更隐蔽的问题是:两个看起来完全一样的匿名结构体,编译器也认为它们是不同的类型。所以 struct { int a; } x; 和 struct { int a; } *p; 之间,p = &x; 会触发警告甚至错误——编译器把它们当作完全不同的类型。
#include <stdio.h>
/* 匿名结构体:类型没有名字,只能通过定义变量来使用 */
struct
{
int a;
int b;
} anonymous_var;
/* 带标签的结构体:类型有名字,可以反复使用 */
struct Named
{
int a;
int b;
};
int main()
{
anonymous_var.a = 1;
anonymous_var.b = 2;
struct Named n1;
struct Named n2; /* 可以再声明第二个 */
/* struct { int a; int b; } *p = &anonymous_var; */
/* 上面的写法会触发"类型不兼容"警告——虽然看起来一模一样,
但匿名结构体的每个声明都是独立类型 */
printf("anonymous: %d %d\n", anonymous_var.a, anonymous_var.b);
return 0;
}所以匿名结构体的实用价值很有限:一般只在"这个类型只用一次"且"你不想给它起名字"的场景使用(比如临时定义一个一次性变量)。
结构体的自引用
你无法在结构体内部包含一个同类型的结构体变量——那样结构体的大小会变成无穷大:
struct Node { int data; struct Node next; }; // 编译错误!大小无限递归为什么?因为结构体的大小是"所有成员大小之和(加对齐)",如果包含一个同类型的完整成员,计算 sizeof 时就会无限递归——就像"盒子里套一个一模一样的盒子,永远套不完"。C 语言标准甚至不允许不完整类型作为成员。
但你可以包含一个指向同类型的指针——指针的大小是固定的(4 或 8 字节),不依赖指向的类型是否完整。这正是链表节点的标准定义:
#include <stdio.h>
#include <stdlib.h>
/* 链表节点的正确自引用方式 */
typedef struct Node
{
int data;
struct Node *next; /* 指向同类型的指针 */
} Node;
int main()
{
/* 创建三个节点 */
Node *head = (Node *)malloc(sizeof(Node));
Node *second = (Node *)malloc(sizeof(Node));
Node *third = (Node *)malloc(sizeof(Node));
head->data = 1;
head->next = second;
second->data = 2;
second->next = third;
third->data = 3;
third->next = NULL;
/* 遍历链表 */
Node *current = head;
while (current != NULL)
{
printf("%d -> ", current->data);
current = current->next;
}
printf("NULL\n");
free(head);
free(second);
free(third);
return 0;
}这里有一个结合 typedef 时容易犯的错——链表节点的正确写法是:
typedef struct Node {
int data;
struct Node *next; // 必须用 struct Node,此时 Node 这个别名还不存在
} Node;如果你写成匿名结构体再用 typedef,内部就不能用 Node *next 了——因为那个时候 Node 还没定义。typedef 的别名从声明结束才生效,结构体内部只能用自己的"真名"(tag)自引用。 这是 C 语言里非常经典的一个顺序问题。
/* 错误写法:Node 别名在花括号内部还没生效 */
typedef struct {
int data;
Node *next; /* 编译错误!Node 未定义 */
} Node;结构体内存对齐
这是结构体最核心也最容易在面试中被考到的知识点。绝大多数初学者第一次运行 sizeof(struct S) 时都会被结果吓一跳——算出来的数字比"各成员大小之和"要大。多出来的字节就是对齐产生的"填充"(padding)。
对齐规则(必背四条)
完整的对齐规则有四条:
- 第一个成员对齐到结构体起始地址的偏移量 0 处。
- 其他成员要对齐到"对齐数"的整数倍地址处。对齐数 = min(编译器默认对齐数, 该成员自身大小)。VS 中默认对齐数为 8;而 Linux 的 GCC 没有人为设定的"默认对齐数",对齐数直接取成员自身大小。这里补充说明一点容易误解的细节:在 x86-64 平台上,个别类型(如
long double)的自身对齐要求是 16 字节,此时相关成员的对齐数就会按 16 来算——但这是"成员自身大小"带来的自然结果,并不是说 GCC 存在一个固定的默认对齐数 16。 - 结构体总大小必须是"最大对齐数"的整数倍。最大对齐数是所有成员对齐数中的最大值。
- 嵌套结构体的对齐:嵌套的结构体成员对齐到其内部成员的最大对齐数的整数倍处;整体大小是所有对齐数(含嵌套结构体内部的对齐数)中最大值的整数倍。
拿一个具体的例子来算:
#include <stdio.h>
/* 两个结构体的成员完全相同,只是顺序不同 */
struct S1
{
char c1; /* 1 字节 */
int i; /* 4 字节 */
char c2; /* 1 字节 */
};
struct S2
{
char c1; /* 1 字节 */
char c2; /* 1 字节 */
int i; /* 4 字节 */
};
int main()
{
printf("sizeof(struct S1) = %zu\n", sizeof(struct S1));
/* 典型输出: 12
c1(偏移0) + 3填充 + i(偏移4) + c2(偏移8) + 3填充 = 12 */
printf("sizeof(struct S2) = %zu\n", sizeof(struct S2));
/* 典型输出: 8
c1(偏移0) + c2(偏移1) + 2填充 + i(偏移4) = 8 */
printf("成员完全相同,但 S2 比 S1 少了 %zu 字节!\n",
sizeof(struct S1) - sizeof(struct S2));
return 0;
}S1 和 S2 的成员一模一样,只是声明顺序不同——S1 把两个 char 分开了,S2 把它们放在一起。结果 S2 比 S1 小了 4 字节,节省了 33% 的空间。这个简单的对比揭示了结构体设计的核心原则:让占用空间小的成员尽量集中在一起。
为什么需要内存对齐?两个原因:平台原因——不是所有的硬件都能从任意地址读取任意类型的数据,某些 CPU 在读取未对齐的 int 时会直接抛出硬件异常;性能原因——即使 CPU 支持非对齐访问,它也通常需要两次内存读取(因为数据跨越了两个"对齐块"的边界),而对齐的访问只需要一次。说到底,内存对齐是"用空间换时间"的经典设计。
用代码验证每个成员的偏移量
纸上算完了,还要亲手验证。C 语言标准提供了一个专门获取成员偏移的宏——offsetof(type, member),定义在 <stddef.h> 中。它的实现原理非常巧妙(通常是一个把 0 地址强转成结构体指针再取成员的技巧),但使用起来很简单:
#include <stdio.h>
#include <stddef.h> /* offsetof 在这里 */
struct S
{
char c1; /* 对齐数 1 */
int i; /* 对齐数 4 */
char c2; /* 对齐数 1 */
};
int main()
{
struct S s;
/* offsetof 返回成员在结构体内的偏移量(从 0 开始算) */
printf("offsetof(c1) = %zu\n", offsetof(struct S, c1)); /* 0 */
printf("offsetof(i) = %zu\n", offsetof(struct S, i)); /* 4 */
printf("offsetof(c2) = %zu\n", offsetof(struct S, c2)); /* 8 */
printf("sizeof(struct S) = %zu\n", sizeof(struct S)); /* 12 */
/* 等价验证:取成员地址减去结构体首地址 */
struct S *p = &s;
printf("手动计算 offsetof(i) = %td\n",
(char *)&(p->i) - (char *)p); /* 也是 4 */
return 0;
}offsetof 在序列化、反射式遍历结构体、写调试工具时非常有用。它的标准实现通常长这样(示意):
#define offsetof(type, member) ((size_t)&(((type *)0)->member))把 0 强转成 type *,再用 ->member 取地址——一个"假想的、位于地址 0 的结构体"的成员地址,就是该成员在结构体中的偏移。因为取地址不产生真正的内存访问,所以 (type *)0 不会崩溃(这是纯编译期计算)。这个宏堪称 C 宏定义的经典之作。
嵌套结构体的对齐计算
再看看嵌套结构体的对齐计算:
#include <stdio.h>
struct S3
{
double d; /* 8 字节,对齐数 8 */
char c; /* 1 字节,对齐数 1 */
int i; /* 4 字节,对齐数 4 */
};
struct S4
{
char c1; /* 偏移 0 */
struct S3 s3; /* S3 最大对齐数=8,从偏移 8 开始 */
double d; /* 对齐数 8,从偏移 24 开始 */
};
int main()
{
printf("sizeof(struct S3) = %zu\n", sizeof(struct S3));
/* 典型: d(0-7) + c(8) + 3填充 + i(12-15) + 最后补到8的倍数 = 16 */
printf("sizeof(struct S4) = %zu\n", sizeof(struct S4));
/* 典型: c1(0) + 7填充 + s3(8-23,16字节) + d(24-31,8字节) = 32 */
return 0;
}一步步算 S4:
c1是 char,对齐数 1,放偏移 0。s3的内部最大对齐数是 8(double d),所以 s3 要从 8 的倍数开始——偏移 8。s3 占 16 字节(8 到 23)。d对齐数 8,当前偏移是 24(正好 8 的倍数),直接放 24~31。- 总大小 32,是最大对齐数 8 的倍数,不需要再补。
如果调换一下 S4 的成员顺序,结果可能完全不同——这就是"对齐计算必须按声明顺序一步步来"的原因,不能只看成员类型组合。
空结构体:一个冷知识
#include <stdio.h>
/* C 语言中空结构体是 GNU 扩展(标准不允许);
如果编译器支持,sizeof 通常为 0 */
struct Empty
{
};
int main()
{
printf("sizeof(struct Empty) = %zu\n", sizeof(struct Empty));
/* GCC 输出 0;C++ 输出 1(C++ 规定任何对象大小至少为 1) */
return 0;
}C 标准其实不允许空结构体(GNU 扩展允许),而 C++ 规定任何对象大小至少为 1(为了给不同对象唯一地址)。这个差异偶尔出现在"C 和 C++ 混编"的项目里。
#pragma pack:手动控制对齐
如果你想精确控制内存布局——比如在解析网络协议或文件格式时——可以用 #pragma pack 修改默认对齐数:
#include <stdio.h>
/* 默认对齐(VS: 8, GCC: 成员自身大小) */
struct S_default
{
char c1;
int i;
char c2;
};
/* 1 字节对齐(紧凑排列) */
#pragma pack(1)
struct S_packed
{
char c1;
int i;
char c2;
};
#pragma pack() /* 恢复默认对齐 */
int main()
{
printf("默认对齐: sizeof = %zu\n", sizeof(struct S_default));
printf("1字节对齐: sizeof = %zu\n", sizeof(struct S_packed));
/* 典型输出:
默认对齐: sizeof = 12
1字节对齐: sizeof = 6 */
return 0;
}#pragma pack(1) 让对齐数变为 1,等于取消对齐,所有成员紧密排列。但要小心:过度压缩对齐会导致某些平台上的性能下降甚至运行错误(比如某些 ARM 平台对未对齐访问直接抛异常)。pack 的典型用途是网络协议头、文件格式头——它们要求严格按字段顺序紧凑排列,否则字节数对不上、结构体无法直接映射到缓冲区。
/* 网络协议头的经典写法:紧凑 + 明确的字节数 */
#pragma pack(1)
typedef struct
{
unsigned char version; /* 1 字节 */
unsigned char type; /* 1 字节 */
unsigned short length; /* 2 字节 */
unsigned int checksum; /* 4 字节 */
} PacketHeader; /* 共 8 字节,无填充 */
#pragma pack()
/* 这样 sizeof(PacketHeader) == 8,可以直接把一个缓冲区
强转成 PacketHeader* 来解读 */对齐规则的坑:到底该记哪条?
面试中最常考的就是"算 sizeof",这里给你一个万能的三步走方法:
- 找对齐数:每个成员的对齐数 = min(编译器默认对齐数, 成员大小)。VS 默认 8,GCC 默认 16(实际效果同成员自身大小)。
- 按声明顺序放:从偏移 0 开始,每个成员放到"对齐数的整数倍"偏移处,放不下就补填充。
- 尾部补全:结构体总大小取整为"最大对齐数"的倍数。
多算几个例子就熟练了。核心记住一句话:对齐数是"成员大小和默认值取小",总大小是"最大对齐数取整"。
结构体传参
函数传结构体时,有两种选择:
#include <stdio.h>
struct LargeData
{
int data[1000]; /* 4000 字节的数据 */
int count;
};
/* 值传递:每次调用都拷贝 4004 字节 */
void print_by_value(struct LargeData ld)
{
printf("count = %d (值传递)\n", ld.count);
}
/* 指针传递:每次调用只拷贝 4 或 8 字节(指针大小) */
void print_by_pointer(const struct LargeData *ld)
{
printf("count = %d (指针传递)\n", ld->count);
}
int main()
{
struct LargeData ld = { .count = 42 };
print_by_value(ld);
print_by_pointer(&ld);
return 0;
}永远优先传指针。函数传参时,参数需要压栈——空间和时间双重开销。传一个包含 1000 个 int 的结构体(4004 字节)和传一个 8 字节的指针,效率差距是数量级的。如果你不希望函数修改原数据,用 const struct LargeData *ld 来保护。
另外要注意:结构体指针做参数还有一个"副作用"优势——函数内部对 ld->x = 100 的修改会反映到调用者的结构体上。如果你想要"传进去还能改出来"的效果,指针是唯一选择(C 语言没有引用类型)。
结构体作为返回值呢?C 允许返回结构体,编译器会把它放在一个隐藏的临时区域。但大结构体返回值同样有拷贝开销——C99 之后允许返回匿名结构体字面量 return (struct Point){1, 2};,这在函数工厂模式中很常用。
位段
位段(bit-field)让你可以精确指定一个成员占用几个二进制位。它和普通结构体相比有两个不同:一是成员类型有限制——必须是 int、unsigned int 或 signed int(C99 之后放宽为也可以是 char 等其他类型);二是成员名后跟一个冒号和一个数字,数字就是该成员占用的 bit 数:
#include <stdio.h>
/* 位段:精确控制每个成员占用的 bit 数 */
struct BitFields
{
unsigned int a : 2; /* 2 位,0-3 */
unsigned int b : 5; /* 5 位,0-31 */
unsigned int c : 10; /* 10 位,0-1023 */
unsigned int d : 15; /* 15 位,0-32767 */
/* 合计 32 位 = 4 字节 */
};
int main()
{
struct BitFields bf = {0};
bf.a = 3; /* 最大只能存 3(2 位) */
bf.b = 25; /* 最大能存 31(5 位) */
bf.c = 500; /* 最大能存 1023(10 位) */
bf.d = 10000; /* 最大能存 32767(15 位) */
printf("sizeof(struct BitFields) = %zu\n", sizeof(struct BitFields));
/* 通常输出: 4(恰好占满 32 位) */
printf("a = %u, b = %u, c = %u, d = %u\n",
bf.a, bf.b, bf.c, bf.d);
/* 演示溢出:给 2 位的 a 赋值为 5(二进制 101) */
bf.a = 5;
printf("a = 5 时实际存储: %u (只取低 2 位: 01)\n", bf.a);
/* 输出 1,因为只有低 2 位(01)被保留 */
return 0;
}位段特别适合表示那些只需要几个 bit 的数据——网络协议中的标志位(IP 协议头中版本号占 4 位、首部长度占 4 位)、硬件寄存器的控制位等。IP 协议头就是一个活生生的例子:版本(4bit) + 首部长度(4bit) + 服务类型(8bit) + 总长度(16bit)……用位段可以"按位"描述这些字段,比手写移位运算清晰得多。
位段的内存分配规则
位段的内存分配有一个基本规则:按成员类型以 4 个字节(int)或 1 个字节(char)为单位开辟空间,一个位段放不进当前单位剩余的位时,就另起一个新的单位。来看课件里的经典例子——全是 char 类型的位段:
#include <stdio.h>
struct S
{
char a : 3;
char b : 4;
char c : 5;
char d : 4;
};
int main()
{
printf("sizeof(struct S) = %zu\n", sizeof(struct S));
/* 输出: 3 */
struct S s = {0};
s.a = 10; /* 3 位最大能存 7,10 的二进制 1010 只保留低 3 位 = 010 = 2 */
s.b = 12; /* 4 位能存 0~15,12 完整存下 */
s.c = 3; /* 5 位能存 0~31,3 完整存下 */
s.d = 4;
printf("a=%d, b=%d, c=%d, d=%d\n", s.a, s.b, s.c, s.d);
/* 输出: a=2, b=12, c=3, d=4 */
return 0;
}为什么大小是 3 而不是 2?因为 char 位段按 1 个字节为单位开辟:
- 第 1 个字节:
a占 3 位 +b占 4 位 = 7 位,剩下 1 位放不下c的 5 位 → 浪费这 1 位,另起一个字节; - 第 2 个字节:
c占 5 位,剩下 3 位放不下d的 4 位 → 浪费这 3 位,再起一个字节; - 第 3 个字节:
d占 4 位。
这个例子同时演示了位段的溢出行为:给 3 位的 a 赋 10,超出部分被直接丢弃,只保留低 3 位 010,所以读出来是 2。和前面 unsigned int 位段的截断规则完全一样。
位段的限制与跨平台问题
但位段有一个重要的使用限制——你不能对位段成员取地址。因为位段的成员可能不落在字节边界上(比如一个 2 位的字段和 5 位的字段共享同一个字节),所以它没有一个独立的地址:
#include <stdio.h>
struct BitField
{
unsigned int a : 2;
unsigned int b : 5;
};
int main()
{
struct BitField bf = {0};
int temp;
/* 错误:不能对位段成员取地址
scanf("%d", &bf.b); // 编译错误! */
/* 正确做法:先读到临时变量,再赋值 */
printf("请输入 b 的值 (0-31): ");
scanf("%d", &temp);
bf.b = temp;
printf("b = %u\n", bf.b);
return 0;
}还有几个位段相关的"实现定义"问题(不同编译器行为可能不同):
- int 位段是有符号还是无符号?——
int a : 3;中 int 位段可能有符号(范围 -43)也可能无符号(07),由实现决定。为了可移植,建议总是用unsigned int或显式signed int。 - 位段在字节内从左分配还是从右分配?——有的编译器从高位开始,有的从低位开始。跨平台解析协议时可能顺序不同。
- 放不下的位段是跨字节还是新建单位?——上面 char 的例子展示了"新建单位",但有些实现允许跨字节。
所以注重可移植性的程序应该尽量避免使用位段,或者至少用 unsigned int 来消除符号性问题。如果一定要精确控制位布局(比如协议头),更稳妥的做法是用整数 + 位运算,虽然写起来啰嗦但行为完全确定。
综合实战:学生管理系统
最后,让我们用一个综合示例——学生管理系统——来看看结构体、结构体数组、结构体指针在实际项目中的完整用法:
#include <stdio.h>
#include <string.h>
#define MAX_STUDENTS 100
typedef struct
{
char name[50];
int age;
float score;
char id[20];
} Student;
typedef struct
{
Student students[MAX_STUDENTS];
int count;
} StudentManager;
/* 添加学生 */
void add_student(StudentManager *mgr, const char *name,
int age, float score, const char *id)
{
if (mgr->count >= MAX_STUDENTS)
{
printf("学生已满!\n");
return;
}
Student *s = &mgr->students[mgr->count];
strncpy(s->name, name, sizeof(s->name) - 1);
s->name[sizeof(s->name) - 1] = '\0'; /* 确保终止符 */
s->age = age;
s->score = score;
strncpy(s->id, id, sizeof(s->id) - 1);
s->id[sizeof(s->id) - 1] = '\0';
mgr->count++;
}
/* 按姓名查找:返回指向学生的指针 */
Student *find_by_name(StudentManager *mgr, const char *name)
{
int i;
for (i = 0; i < mgr->count; i++)
{
if (strcmp(mgr->students[i].name, name) == 0)
return &mgr->students[i];
}
return NULL;
}
/* 按学号查找 */
Student *find_by_id(StudentManager *mgr, const char *id)
{
int i;
for (i = 0; i < mgr->count; i++)
{
if (strcmp(mgr->students[i].id, id) == 0)
return &mgr->students[i];
}
return NULL;
}
/* 打印所有学生 */
void print_all(const StudentManager *mgr)
{
int i;
printf("%-20s %-5s %-8s %-15s\n", "姓名", "年龄", "分数", "学号");
printf("----------------------------------------------\n");
for (i = 0; i < mgr->count; i++)
{
const Student *s = &mgr->students[i];
printf("%-20s %-5d %-8.1f %-15s\n",
s->name, s->age, s->score, s->id);
}
}
int main()
{
StudentManager mgr = { .count = 0 };
add_student(&mgr, "张三", 20, 95.5f, "2024001");
add_student(&mgr, "李四", 19, 88.0f, "2024002");
add_student(&mgr, "王五", 21, 92.5f, "2024003");
printf("所有学生:\n");
print_all(&mgr);
printf("\n查找\"李四\":\n");
Student *found = find_by_name(&mgr, "李四");
if (found)
printf("找到: %s, 分数: %.1f\n", found->name, found->score);
printf("\n查找学号 2024002:\n");
found = find_by_id(&mgr, "2024002");
if (found)
printf("找到: %s\n", found->name);
return 0;
}注意这个例子里的几个关键点:
StudentManager内部直接内嵌了Student students[100]——这是"结构体数组作为成员"的典型用法,比Student *students(需要手动 malloc)简单得多,代价是大小固定。- 查找函数返回
Student *——调用方拿到指针后可以直接修改该学生信息,这就是"指针作为返回值"的价值。 const StudentManager *mgr表示只读访问——防止 print_all 误改数据。strncpy后手动补'\0'——第 5 讲的老朋友了,防止源字符串超长导致数组没有终止符。
高级话题:弹性数组成员
C99 还引入了弹性数组成员(Flexible Array Member)——允许结构体的最后一个成员是一个未指定大小的数组,然后你可以用 malloc(sizeof(struct) + data_size) 来分配刚好够用的内存。
#include <stdio.h>
#include <stdlib.h>
/* 柔性数组:最后一个成员是未定长数组 */
typedef struct
{
int length;
int data[]; /* 弹性数组成员(C99) */
} FlexArray;
int main()
{
int n = 10;
/* 一次性分配:结构体 + 数组 */
FlexArray *fa = (FlexArray *)malloc(sizeof(FlexArray) + n * sizeof(int));
if (fa == NULL) return 1;
fa->length = n;
int i;
for (i = 0; i < n; i++)
fa->data[i] = i * i;
printf("sizeof(FlexArray) = %zu(不含 data 数组)\n", sizeof(FlexArray));
free(fa);
return 0;
}弹性数组的硬性条件:必须是最后一个成员、前面至少有一个其他成员、sizeof 不含数组大小、必须用 malloc 一次性分配(详见动态内存那一讲)。
结构体与函数指针:模拟面向对象
Linux 内核中大量使用结构体 + 函数指针来模拟面向对象设计——struct file_operations 包含多个函数指针,不同的设备驱动提供不同的实现。这是结构体的高级用法,也是"C 语言也能写出面向对象味道"的证明:
#include <stdio.h>
/* 用结构体 + 函数指针模拟"接口" */
typedef struct
{
void (*speak)(void); /* 函数指针成员 */
void (*move)(void);
} Animal;
void dog_speak(void) { printf("汪汪!\n"); }
void dog_move(void) { printf("四条腿跑\n"); }
void bird_speak(void){ printf("叽叽喳喳\n"); }
void bird_move(void) { printf("扇翅膀飞\n"); }
int main()
{
Animal dog = { dog_speak, dog_move }; /* 函数指针也可以初始化 */
Animal bird = { bird_speak, bird_move };
printf("狗: "); dog.speak();
printf(" "); dog.move();
printf("鸟: "); bird.speak();
printf(" "); bird.move();
return 0;
}这个模式就是"多态"的雏形——同一个 Animal 类型,不同的"实例"表现出不同的行为。理解了它,你就为后面学习更高级的 C 设计模式打下了基础。
总结
结构体是 C 语言从"简单的计算"走向"复杂的数据组织"的桥梁。回顾一下这次学到的最重要的东西:
- 声明末尾的分号不能丢——这是语法层面最经典的错误。
.跟变量,->跟指针——访问成员的两条铁律。- 自引用必须用指针——否则大小无限递归。
- 传参优先传地址——避免大结构体拷贝开销。
- 内存对齐——对齐数 = min(默认值, 成员大小),总大小取最大对齐数的倍数,成员按声明顺序排列,小成员尽量集中。
- 位段——精确控制位宽度,但跨平台行为是实现定义的。
链表、树、图、哈希表……所有复杂数据结构的第一块基石,都是这个看似简单的 struct。而内存对齐规则——结构体大小的计算——是你从"能写代码"到"理解底层"的关键一步。
思考题
struct A { char c; int i; char c2; };的 sizeof 是多少?如果把成员顺序改成char c; char c2; int i;呢?为什么差 4 字节?offsetof宏为什么不会崩溃(它把 0 强转成了结构体指针)?它是编译期计算还是运行期计算?- 结构体
s2 = s1整体赋值时,填充字节(padding)会被拷贝吗?这有什么隐患?(提示:联系 memcmp 比较结构体的陷阱) struct Node { int data; struct Node *next; };中sizeof(struct Node)是多少?struct Node和Node两种写法分别出现在什么场景?- 位段
unsigned int a : 3;的 a 能存的最大值是多少?int a : 3;呢?(提示:后者取决于实现) - 写一个函数,输入一个结构体指针,打印该结构体每个成员的偏移量(用 offsetof)。
- 设计一个
struct RGB { unsigned char r, g, b; },思考为什么它的大小是 3 而不是 4?(提示:对齐到 1 的倍数即可)
思考题参考答案
1. struct A { char c; int i; char c2; }; 的 sizeof 是多少?改成 char c; char c2; int i; 呢?为什么差 4 字节?
第一种布局 char c; int i; char c2; 大小是 12:c 放偏移 0,i 对齐数 4 放偏移 4(0~3 之后补 3 字节填充),c2 放偏移 8,总大小需取最大对齐数(4)的倍数,8+1 补到 12。第二种布局 char c; char c2; int i; 大小是 8:c 偏移 0、c2 偏移 1、i 对齐数 4 放偏移 4,总大小取 4 的倍数正好 8。
差 4 字节的根源是成员顺序改变了填充字节的数量。第一种把两个 1 字节的 char 分别挤在 int 两侧,各自都要为 4 字节对齐"让位";第二种把两个 char 放一起,共用一个填充间隙(偏移 2~3),从而省下 4 字节。这正印证正文那句话:让占用空间小的成员尽量集中在一起,就能减少填充、压缩结构体大小。
2. offsetof 宏为什么不会崩溃(它把 0 强转成了结构体指针)?它是编译期计算还是运行期计算?
offsetof(type, member) 的标准形如 ((size_t)&(((type *)0)->member))。它把整数 0 强转成一个"假想地位于地址 0 的结构体"指针,再取该结构体某个成员的地址。关键在于 这整个过程只是"算地址",并不会真的去内存 0 地址读写数据——取一个成员的地址不产生任何内存访问,只是计算"相对结构体起点的偏移量",因此不会触发空指针解引用或崩溃。
它是编译期可求值的常量表达式(标准要求 offsetof 的结果可用于静态初始化,如 static int off = offsetof(struct S, i);)。即便个别实现内部用了"看起来像运行时"的写法,编译器也会在编译期把偏移量直接算成常量。它本质上是 C 宏"用临时/假想对象获取偏移"的技巧,安全、零运行时开销。
3. 结构体 s2 = s1 整体赋值时,填充字节(padding)会被拷贝吗?这有什么隐患?
会被拷贝。 结构体整体赋值在多数实现上是"原样拷贝整块内存"(包括成员间的填充字节)。只要 padding 的值在源 s1 中被确定了,拷贝后 s2 的 padding 就与 s1 一致。
隐患在于非确定值:如果结构体是从 malloc 得到、或成员是部分初始化,s1 的 padding 里可能装着未定义的垃圾值。当随后用 memcmp(&s1, &s2, sizeof(...)) 比较两个"逻辑上相等"(各成员都相同)的结构体时,由于两边的 padding 垃圾值不同,memcmp 会返回"不相等"——这正是第 18 讲特别提醒的 trap:用 memcmp 比较结构体不可靠,除非先 memset(&p, 0, sizeof(p)) 把 padding 也确定成 0。所以要么初始化前清零使 padding 确定,要么就逐成员手工比较。
4. struct Node { int data; struct Node *next; }; 中 sizeof(struct Node) 是多少?struct Node 和 Node 两种写法分别出现在什么场景?
sizeof(struct Node) 取决于指针宽度:
- 32 位平台:
data占 4 字节(偏移 0~3),next指针占 4 字节(对齐数 4,偏移 4)→ 总大小 8,是最大对齐数 4 的倍数。sizeof = 8。 - 64 位平台:
data占 4 字节(偏移 03),15)→ 总大小 16,是最大对齐数 8 的倍数。sizeof = 16。next指针占 8 字节(对齐数 8,需从偏移 8 开始,中间补 4 字节填充,偏移 8
写法场景:struct Node 是"带 tag 的正式类型名",C 中声明这类变量必须写全 struct Node *p;;Node 是通过 typedef struct Node { ... } Node; 建立的别名,声明时可直接 Node *p; 少写 struct 关键字。在结构体内部实现自引用时只能用 struct Node *next;——因为此时别名 Node 还没生效(typedef 的别名在声明结束后才可用)。所以"声明类型用 typedef ... Node;,自引用时在内部写 struct Node *"是标准套路。
5. 位段 unsigned int a : 3; 的 a 能存的最大值是多少?int a : 3; 呢?
unsigned int a : 3;:3 位无符号,能表示 0~7,最大值是 7(2³−1)。int a : 3;:3 位有符号 bit 域,其符号性取决于实现(这正是位段的跨平台问题之一)。若实现把它当作有符号,范围是 -4~3(补码 3 位:100=-4,011=3);若实现当作无符号,则是 0~7。
所以 int a : 3 的最大值不固定(可能是 3 也可能是 7)。这也是正文反复强调的:为了可移植,位段成员尽量用 unsigned int(或显式 signed int),不要依赖 int 的默认符号性。
6. 写一个函数,输入一个结构体指针,打印该结构体每个成员的偏移量(用 offsetof)。
用 offsetof 遍历各成员即可(结构体成员数在写代码时已知,逐一列出并打印):
#include <stdio.h>
#include <stddef.h>
typedef struct
{
char c1; /* 对齐数 1 */
int i; /* 对齐数 4 */
char c2; /* 对齐数 1 */
double d; /* 对齐数 8 */
} Example;
/* 打印偏移量的通用小函数:传入结构体首地址便于对照 */
void print_offsets(const void *base)
{
printf("%-12s %-12s %-12s %-12s\n", "成员", "offsetof", "相对地址", "偏移(字节)");
printf("c1: %-9zu %p %td字节\n",
offsetof(Example, c1),
(const char *)base + offsetof(Example, c1),
(const char *)&(((const Example *)base)->c1) - (const char *)base);
printf("i : %-9zu %p %td字节\n",
offsetof(Example, i),
(const char *)base + offsetof(Example, i),
(const char *)&(((const Example *)base)->i) - (const char *)base);
printf("c2: %-9zu %p %td字节\n",
offsetof(Example, c2),
(const char *)base + offsetof(Example, c2),
(const char *)&(((const Example *)base)->c2) - (const char *)base);
printf("d : %-9zu %p %td字节\n",
offsetof(Example, d),
(const char *)base + offsetof(Example, d),
(const char *)&(((const Example *)base)->d) - (const char *)base);
printf("结构体总大小: %zu 字节\n", sizeof(Example));
}
int main()
{
Example e;
print_offsets(&e);
return 0;
}输出(典型 64 位、默认对齐):c1=0、i=4、c2=8、d=16,sizeof(Example)=24。offsetof 得到的偏移量与"用首地址加偏移"或"成员地址减结构体地址"这两种方式的结果完全一致,可作为交叉验证。
7. 设计一个 struct RGB { unsigned char r, g, b; },为什么它的大小是 3 而不是 4?
每个成员都是 unsigned char,自身大小和对齐数都是 1。按对齐规则:
- 第一个成员
r放偏移 0; g对齐数 1,任一偏移都可以,放偏移 1;b对齐数 1,放偏移 2;- 结构体总大小需是最大对齐数(=1)的整数倍,3 已经是 1 的倍数,无需补填充。
所以 sizeof(struct RGB) == 3,没有任何 padding。
为什么不是 4?因为没有哪个成员要求 4 字节对齐——int 才需要对齐到 4,而这里全是 char。是否产生填充,取决于"是否存在对齐数大于 1 的成员"。若给这个结构体加上一个 int 成员,立刻就可能因对齐而补上填充、把大小顶到 4 的倍数。RGB 三通道刚好都是单字节,天然紧凑,这也便于它直接作为像素缓冲区使用。
还没有评论 — 第一条由你来留。