C语言学到指针和函数之后,很多人会遇到一道坎:系统自带的基本类型不够用了。你想表达一个学生、一个订单、一个网络包,用int、char这些基础类型拼来拼去,代码又乱又容易出错。这时候就该轮到自定义类型出场了——结构体、联合体、枚举,这三兄弟是C语言里组织数据的核心手段,也是从"写代码"走向"设计代码"的关键一步。这篇文章我把它们掰开揉碎讲清楚,从定义语法到内存布局,从初始化到实际项目中的用法,该踩的坑都帮你提前标出来。刚学完C基础的朋友可以照着一步步敲,工作了一两年想回头补补细节的,也值得花几分钟扫一遍。
1. 为什么C语言需要自定义类型
C语言自带的类型其实很少,掰着手指头数得过来:char、int、float、double,再加上修饰符signed、unsigned、short、long,说到底就是整数、小数和字符这几种。这就像工具箱里只给你配了螺丝刀、锤子和钳子,修个简单的东西没问题,但真要组装一台机器,你会特别想要一些专用的零件。
假设你要写一个图书管理系统,每本书有书名、作者、价格、库存量这些信息。最朴素的做法是定义四个数组,书名数组、作者数组、价格数组、库存数组,用同一个下标去关联,这就是所谓的"平行数组"。代码写起来很别扭,比如你要把一本书传给函数处理,得同时传四个参数;要交换两本书的信息,得四个数组挨个交换一遍。稍微改个需求,加一个"出版年份"字段,所有用到这四个数组的地方全得跟着动。
碰到这种情况,大家心里都会想要一个东西:把一堆相关的数据打包成一个整体,这个整体有个名字,整体内部有各自的字段,能一起赋值、一起传参、一起存放。结构体就是干这个事的。联合体和枚举解决的问题不太一样:联合体是让不同的数据共享同一块内存,在嵌入式开发和底层协议解析里特别有用;枚举是把一堆有意义的常量起个名字,让代码不再堆满魔法数字。这三个自定义类型各管一摊,组合起来才能把真实世界里的数据模型装进C语言。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 结构体:最常用的组包方式
结构体是C语言自定义类型里的绝对主角,学会结构体是把数据组织好的第一步。这一章从定义一直讲到内存布局,把结构体给你彻底打通。
2.1 定义结构体的三种姿势
定义结构体的标准写法是用struct关键字加结构体标签,后面跟一对花括号,里面写成员变量,最后别忘了分号。比如定义一个学生类型:
c复制struct Student {
char name[32];
int age;
float score;
};
这里struct Student合起来才是一个完整的类型名。用的时候要写struct Student stu;,每次都要带struct,有些人觉得麻烦,于是就有了typedef改造的写法:
c复制typedef struct Student {
char name[32];
int age;
float score;
} Student;
这样写完之后,直接Student stu;就行,类型名和关键字解耦了,代码清爽不少。还有一种更常见的写法是匿名结构体加typedef:
c复制typedef struct {
char name[32];
int age;
float score;
} Student;
这种写法把结构体标签直接省了,用起来最方便,很多开源项目都这么干。但要注意,匿名结构体没法在定义之后再用struct关键字去创建对象,因为标签不存在了,只能通过typedef出来的名字创建。三种写法没有绝对的好坏:小项目里第三种最省事,大项目里带标签的写法在自查引用时更清晰,选一种风格坚持下去就好。
2.2 初始化:顺序、指定、嵌套
结构体初始化的最简单方式是定义的时候直接按成员顺序赋值:
c复制Student stu = {"张三", 20, 89.5f};
这个顺序必须和结构体里成员声明的顺序一致,编译器会按位置一一对应。如果只想初始化其中几个成员,C99标准提供了一个更灵活的方式,指定成员名赋值:
c复制Student stu = {
.name = "张三",
.score = 89.5f
};
用点号加成员名的方式,可以跳过中间没想好的字段,也不必严格按照声明顺序来写,代码将来调整成员顺序时也不容易出问题。还没初始化的成员会被自动清零,这个行为是标准保证的,可以放心用。
嵌套结构体的情况也很常见,一个结构体里面套另一个结构体:
c复制typedef struct {
int year;
int month;
int day;
} Date;
typedef struct {
char name[32];
Date birthday;
float score;
} Student;
初始化嵌套结构体时,可以用花括号一层一层套进去:
c复制Student stu = {
"张三",
{2003, 5, 12},
89.5f
};
也可以结合指定初始化来写,内层结构体继续用点号指定:
c复制Student stu = {
.name = "张三",
.birthday = {.year = 2003, .month = 5, .day = 12},
.score = 89.5f
};
这种写法层级关系一目了然,推荐优先使用。我见过新手在初始化嵌套结构体时少写一层花括号,编译器报错后一脸懵,其实只要记住:一个结构体对应一层花括号,嵌套几层就套几层,逻辑就顺了。
2.3 访问、赋值与结构体指针
初始化完之后访问成员,用点运算符就能搞定:
c复制printf("%s 的年龄是 %d\n", stu.name, stu.age);
数组和字符串这类成员在结构体里也可以直接赋值吗?要注意一个坑:结构体之间可以直接用等号整体赋值,但结构体成员里的数组不能用等号直接赋值。比如stu.name = "李四"是不合法的,必须用strcpy(stu.name, "李四")才行。整体赋值的意思是Student stu2 = stu;,两个结构体变量之间拷贝,这个C语言是允许的,内部实际上是逐字节复制。
实际开发中更常用的是结构体指针。定义指针指向结构体,访问成员有两种写法:
c复制Student stu;
Student *p = &stu;
(*p).age = 21; // 第一种:先解引用再取成员
p->age = 21; // 第二种:箭头运算符
第一种写法括号不能省,因为点号的优先级高于星号;第二种写法就是专门为结构体指针设计的,又清晰又不容易写错。函数传参时,结构体变量作为参数是值传递,整个结构体被拷贝一份;如果一个结构体很大,每个字段加起来几百字节,每次传参都全量复制,性能就有点紧张了。这时候推荐传结构体指针,只拷贝一个地址(8字节),函数内部用箭头访问成员,效率高得多。如果担心函数会意外修改结构体的内容,加上const修饰就稳了:
c复制void printStudent(const Student *stu) {
printf("%s\n", stu->name);
}
这样const Student *stu表示通过这个指针不能修改结构体内容,只读操作随便传,编译器会替你盯着。
2.4 结构体内存对齐:为什么不是简单相加
很多新手以为结构体占用内存就是所有成员大小相加,比如上面的Student,算一下char name[32]是32字节、int age是4字节、float score是4字节,一共40字节,好像没问题。但换个成员顺序就不一定了:
c复制typedef struct {
char c; // 1字节
int i; // 4字节
char d; // 1字节
} Test;
直觉上觉得是1+4+1=6字节,实际上用sizeof(Test)一测,结果是12字节。为什么?这就是C语言的结构体内存对齐规则在起作用。
内存对齐的核心思想是:变量存放的起始地址必须是它自身对齐值的整数倍。对齐值一般等于类型大小,比如int要放在4的整数倍地址上,char比较宽松哪里都能放。编译器会在成员之间插入填充字节(padding)来满足对齐要求,结构体末尾也可能补字节,让整个结构体的大小是对齐值的整数倍。
上面那个Test的布局实际是这样的:c占地址0,然后填充3个字节,i从地址4开始占4个字节,d占地址8共1字节,最后末尾再补3个字节对齐到12。用offsetof宏可以验证:
c复制#include <stddef.h>
printf("%zu %zu %zu\n", offsetof(Test, c), offsetof(Test, i), offsetof(Test, d));
// 输出 0 4 8
把结构体成员按类型大小从大到小排列,可以显著减少填充浪费。比如把上面的顺序改成int i; char c; char d;,大小就是4+1+1+2=8字节,比12字节省了三分之一。这在嵌入式开发等内存受限的场景里很有意义。另外#pragma pack可以修改对齐方式,比如#pragma pack(1)让结构体紧凑排列,读写文件、发送网络包时常用,但要牺牲一点访问效率,平时不建议乱调。
2.5 结构体数组与链表节点
结构体数组就是一个数组,每个元素都是结构体,用下标访问,配合循环,就能批量处理一堆同类型的数据。比如学生成绩管理里:
c复制Student students[50];
for (int i = 0; i < 50; i++) {
printf("%s 的分数是 %.2f\n", students[i].name, students[i].score);
}
结构体数组适合数据量固定、增删不频繁的场景。如果数据量不确定,频繁插入删除,就该用链表了。链表节点本身就是一个经典的结构体,一个成员存数据,另一个成员存指向下一个节点的指针:
c复制typedef struct Node {
int data;
struct Node *next;
} Node;
注意结构体内部引用自己,必须用struct Node *next的方式,因为typedef别名此时还没定义完。自己指向自己的这种定义在二叉树里也一样,左右孩子指针各自指向自己的结构体类型。这就是为什么掌握结构体指针特别重要——没有指针,动态数据结构根本玩不转。
3. 联合体:让不同类型共享一段内存
联合体(union)在教科书里通常一笔带过,但它在底层开发里是个利器。搞清楚它的内存布局,很多"魔法操作"就豁然开朗了。
3.1 联合体的定义与内存布局
联合体的定义语法和结构体特别像,只是把struct换成union:
c复制union Data {
int i;
float f;
char bytes[4];
};
和结构体每个成员各占各的空间不同,联合体所有成员共享同一块内存,联合体的大小等于最大成员的大小。上面这个union Data里,int i占4字节、float f占4字节、char bytes[4]占4字节,最大值是4,所以sizeof(union Data)就是4。你对i赋值,内存里就是4字节的整数;你把同一块内存按float去读,就会得到一个完全不同的浮点数值。联合体的所有操作本质都是对这同一块内存做不同的解释。
结构体是"和"的关系,所有成员同时存在;联合体是"或"的关系,同一时刻只可能让其中一个成员有意义。这就决定了它的典型用途:同一份数据,有时候需要一个一个字节看,有时候需要整个整体看。
3.2 大小端判断:字节序实测
大小端是计算机字节序最经典的例子,用联合体判断特别直观。大端模式是高位字节存低地址,小端模式是低位字节存低地址。x86架构基本都是小端,但嵌入式里不同处理器差别巨大,跨平台通信时经常需要检测。用联合体实现:
c复制#include <stdio.h>
union EndianTest {
int i;
char c;
};
int main() {
union EndianTest test;
test.i = 1;
if (test.c == 1) {
printf("小端(低位在低地址)\n");
} else {
printf("大端(高位在低地址)\n");
}
return 0;
}
test.i = 1,内存里4字节的布局有两种可能:小端模式是01 00 00 00,大端模式是00 00 00 01。test.c取的是内存首地址上的第一个字节,如果是1就是小端,是0就是大端。就这么简单。这就是联合体的价值:同一个缓冲区,既可以用int整体访问,也可以用char逐字节看。
3.3 联合体在协议解析中的应用
网络协议解析是联合体的主场。比如一个网络数据包,头部的前4字节既要整体作为一个32位长度字段读出来,又要能在调试时逐字节看它的二进制分布。用结构体加联合体组合的方式就很优雅:
c复制typedef struct {
uint32_t length;
uint16_t type;
uint16_t flags;
} PacketHeader;
typedef union {
PacketHeader header;
uint8_t raw[8];
} PacketView;
这样你在解析收到的字节流时,可以从raw数组往缓冲区塞数据,也可以用header直接读字段,两块视角共享同一段内存,不用手动做指针转换。类似的手法在嵌入式寄存器操作里也常见,一个联合体里既有uint32_t寄存器整体值,又有按位段拆分的结构体,读和写都直接操作内存,不需要位移和掩码操作。
用联合体要注意的就是"读出来的是什么取决于最后一次写了什么"。你写了一个成员,去读另一个成员,结果是未定义行为,虽然很多编译器实际执行的是按位重解释,但标准不保证。所以,老老实实遵守"写哪个成员就立刻读哪个成员"的规律,别指望读别的成员得到符合直觉的结果。
4. 枚举:给魔法数字起名字
枚举(enum)是三者里最轻量、最容易被低估的。很多人学的时候觉得它不就是几个常量吗,但实际项目里,它在提升代码可读性方面贡献巨大。
4.1 枚举的定义与默认取值规则
用enum关键字定义枚举类型:
c复制enum Color {
RED,
GREEN,
BLUE
};
默认情况下,第一个枚举常量从0开始,后面的依次加1。所以RED是0、GREEN是1、BLUE是2。想要指定某个成员的值也可以手动干预:
c复制enum Color {
RED = 1,
GREEN, // 自动变成 2,因为前面是 1
BLUE = 10
};
这里GREEN没有被指定值,会按前一个RED的值1再加1,得到2。后面BLUE重新指定为10,如果再往下加枚举成员,就从10递增。枚举成员本质上是int类型的常量,可以直接和整数比较,甚至能出现在switch的case里,这是它最主流的用法。
C语言里枚举有个特点常被忽略:enum Color本身是一个类型,但它的变量在C语言中本质还是int,把任意整数赋给枚举变量编译器最多给个警告,不是错误。C++在这方面严格一些。写C代码时要有自觉:枚举变量尽量只赋它定义过的枚举值。
4.2 枚举的典型场景:状态机与选项标志
状态机是枚举最发光发热的领域。比如一个简单的任务状态:
c复制typedef enum {
TASK_IDLE,
TASK_RUNNING,
TASK_DONE,
TASK_ERROR
} TaskState;
定义状态机的状态时用枚举,switch分支处理也就顺理成章了:
c复制switch (state) {
case TASK_IDLE:
start_task();
state = TASK_RUNNING;
break;
case TASK_RUNNING:
if (task_finished()) state = TASK_DONE;
break;
case TASK_DONE:
cleanup();
state = TASK_IDLE;
break;
default:
state = TASK_ERROR;
break;
}
如果不定义枚举,你会看到一堆0、1、2的魔法数字散落在代码里,读代码的人还要猜每个数字是什么意思。有了枚举,状态的流转就像在读一份文档。
枚举还能当选项标志用,配合位运算可以描述组合状态:
c复制typedef enum {
FLAG_NONE = 0,
FLAG_A = 1 << 0,
FLAG_B = 1 << 1,
FLAG_C = 1 << 2
} Flags;
用FLAG_A | FLAG_B组合出同时打开A和B的效果,再通过&去判断某个标志有没有被设置。注意这里用的是1 << 0、1 << 1这种写法,而不是直接写1、2、4,这样加新标志时不容易看错位移位置。
4.3 枚举转字符串的查表法
枚举一个让人头疼的短板是:它是整数,想打印它的名字居然不直接支持。比如printf("%s", TASK_RUNNING)是不行的,%s需要的是字符串指针,而TASK_RUNNING就是个整数。调试时打印出来一堆数字,你还得对着枚举定义一个个找,烦得很。我用的方案是查表法,维护一个和枚举顺序一致的字符串数组:
c复制const char *task_state_str(TaskState state) {
static const char *names[] = {
"TASK_IDLE",
"TASK_RUNNING",
"TASK_DONE",
"TASK_ERROR"
};
return names[state];
}
然后printf("%s\n", task_state_str(state))就能输出可读的状态名了。这个查表法的前提是枚举值从0开始连续,而且数组下标和枚举成员严格对应。如果枚举里有跳号或者手动指定了不连续的数值,这个简单数组就不行了,得用switch或者更复杂的映射:
c复制const char *color_str(enum Color c) {
switch (c) {
case RED: return "RED";
case GREEN: return "GREEN";
case BLUE: return "BLUE";
default: return "UNKNOWN";
}
}
switch版本繁琐但稳,任何枚举值都能兜底处理。在嵌入式里还有一种思路,用宏X-Macro把枚举和字符串放在同一个列表源里,改一处两处同步更新,代码量虽多但维护方便,有兴趣的可以自己研究。
4.4 枚举的坑:大小与类型安全
枚举有几个容易被坑的细节。一是枚举常量本质上还是int,所以它的取值范围和int一致,在8位单片机这类平台上,内存里实际可能占用4字节,比你想的"只是一个状态值"大得多,节省内存的意图完全没达到。想紧凑一些,嵌入式里通常会手动用uint8_t存储状态值,枚举负责可读性,存储类型负责省空间。
第二个坑是给枚举变量赋一个未定义的整数值。C标准说不清楚,编译器给了警告就当没看见,运行时行为就成了未知的。养成好习惯:switch里永远写default分支,要么报错,要么把状态纠正到安全的枚举值上,别留着意外的空白。我排查过不少难缠的bug,最后发现就是某个枚举变量被写入了不在枚举列表里的整数值。那些多疑的default分支看起来多余,但在关键时刻能兜住系统性异常。
5. 结构体、联合体、枚举的对比与选型
三个自定义类型各有各的定位,放在一起看清楚它们最本质的区别,用的时候就不会混淆了。
| 对比项 | 结构体 struct | 联合体 union | 枚举 enum |
|---|---|---|---|
| 核心思想 | 多个成员同时存在,各占各的空间 | 多个成员共享一块内存,同时只用一个 | 一组有名字的整型常量 |
| 内存大小 | 所有成员大小之和加上对齐填充 | 最大成员的大小(考虑对齐) | 通常是4字节(int) |
| 成员访问 | 所有成员都可以通过点号访问 | 只能有效访问最后一次赋值的成员 | 不是成员,是常量,直接用名字 |
| 典型用途 | 组织数据模型、函数传参、链表节点 | 协议解析、字节序转换、寄存器操作 | 状态机、选项标志、替代魔法数字 |
| 底层本质 | 逻辑上的一组字段打包 | 同一内存的多重视角 | 带名字的整数 |
选型时,我自己的判断思路是这样:
- 需要同时保存多个不同属性的数据,用结构体。比如学生信息、坐标点、订单详情,这些字段各有意义、同时存在,用结构体天然合适。
- 同一个数据需要多种观察方式或需要省内存,用联合体。比如同一块缓存既当整数又当字节数组,或者在嵌入式里几个互斥的全局变量共用一个联合体变量。
- 一个整型变量只有几个固定的可选值,用枚举。不管你是状态机还是配置选项,枚举能让代码自己说明自己。
三者还经常组合使用。结构体里可以放枚举成员,比如一个结构体存任务信息,状态字段用枚举;联合体里可以嵌结构体,比如协议头用结构体表示字段,整体放进联合体里方便取字节;枚举的值可以作为数组下标,配合结构体数组来实现一张配置表。越到后面你越会发现,自定义类型的"组合拳"才是C语言数据组织的精髓。
实际开发中有一条经验值得单独拎出来说:能用枚举解决的问题,别用宏定义常量。宏只做文本替换,不提供类型检查,调试器里看到的也只是一个裸数字;枚举是类型,在IDE里能自动补全,在编译器里能参与类型检查,在调试器里能识别出你赋的是枚举常量还是无符号整数。两者功能经常重叠,但工程体验差的不是一点点。
在我个人用C写了这么多年代码之后,最大的体会是:结构体、联合体、枚举这种基础知识,学校里讲的时候几分钟就带过去了,但真正写工程时,对它们的理解深度决定了代码质量。结构体定义得好不好,直接影响整个项目的接口设计;联合体用得巧不巧,能省下大量指针强制转换的代码;枚举用得规范不规范,决定了半年后你自己回来看代码还要不要翻定义。写C不是把语法背熟,而是把这些数据组织的工具用顺手。遇到不知道该用哪个的场景,回头翻一翻这篇文章的对比表,基本就不会纠结了。
