今天主要学习 C 语言中非常重要的自定义数据类型结构体、共用体和枚举。重点掌握结构体的定义、初始化、成员访问、结构体数组、结构体指针、函数传参、内存对齐以及共用体的内存共享特性和大小端检测。最后结合学生管理系统案例和课后作业进行巩固。一、为什么需要结构体在管理学生信息时一个学生通常包含姓名、学号、班级、年龄、成绩、专业等信息。如果使用多个数组分别存储cchar name[30][20]; int sno[30]; int age[30]; float score[30];这样虽然能存下来但存在几个本质问题数据分散一个学生的信息被拆到多个数组中逻辑上属于同一个对象的数据在物理上却是分离的。操作不便要打印“小明”的所有信息需要同时操作多个数组靠下标关联容易出错。无法整体传递函数之间无法把“一个学生”作为一个整体来传递。难以扩展增加一个字段就要新增一个数组维护成本高。C 语言提供结构体 struct用于把不同类型的数据组合成一个整体形成一种新的自定义数据类型。结构体的本质是把多个不同类型的变量按顺序组织在一块连续的内存中形成一个逻辑上的整体并给这个整体起一个类型名。它解决的核心问题是用基本类型不好直接描述的复杂数据对象可以通过结构体来构造。二、结构体的定义与使用1. 定义结构体类型语法cstruct 结构体名 { 成员列表; };说明结构体名是一个标识符命名规则和变量名一致。成员列表的写法就是变量定义的形式成员之间用分号分隔。结构体定义末尾必须有分号。示例cstruct stu { char name[20]; int sno; int age; float score; };这里struct stu就是一个类型名地位类似int、char、float。需要特别理解的是结构体定义只是“造类型”并不分配内存。只有定义了变量才会真正开辟空间。2. 定义变量与初始化cstruct stu s; // 定义变量未初始化 struct stu s {tom, 110, 18, 99.5}; // 按成员顺序初始化C99 还支持指定初始化cstruct stu s {.name tom, .sno 110, .age 18, .score 99.5};3. 访问成员结构体变量使用.访问成员cprintf(%s %d\n, s.name, s.sno);结构体指针使用-访问成员cstruct stu *p s; printf(%s\n, p-name); // 等价于 (*p).namep-name的本质是先解引用p得到结构体再用.访问成员。-只是语法糖。4. 同类型结构体可以直接赋值cstruct stu s1 {tom, 110, 18, 98}; struct stu s2; s2 s1; // 合法按字节整体复制这是因为同类型结构体的内存布局完全一致编译器知道该复制多少字节。三、结构体变量、数组、指针结构体是一种类型因此可以像基本类型一样使用cstruct stu s; // 变量 struct stu arr[30]; // 数组 struct stu *p; // 指针三者在内存上的关系结构体变量在栈上分配一块连续内存大小等于结构体大小。结构体数组多个结构体连续排列arr[i]与arr[i1]之间相隔sizeof(struct stu)字节。结构体指针本身只占一个指针大小指向某个结构体变量的首地址。通过指针访问结构体数组元素cstruct stu arr[3] {{tom, 110, 18, 90}, {jerry, 111, 19, 85}, {alice, 112, 18, 95}}; struct stu *p arr; // p 指向 arr[0] printf(%s\n, p-name); // tom printf(%s\n, (p 1)-name); // jerry这里p 1并不是地址加 1 字节而是加上一个结构体的大小这就是指针算术的特点。四、结构体做函数参数结构体作为函数参数有两种方式1. 值传递cvoid print_stu(struct stu s) { printf(%s %d\n, s.name, s.sno); }值传递会把整个结构体拷贝一份压栈。结构体越大拷贝开销越大。而且函数内部修改形参不影响实参。2. 指针传递推荐cvoid print_stu(const struct stu *s) { printf(%s %d\n, s-name, s-sno); }指针传递只拷贝一个指针通常 4 或 8 字节效率高。如果函数不需要修改结构体内容建议加const。结构体传参的一般原则如果只是读取传const 结构体指针。如果需要修改传结构体指针。只有在结构体很小、需要明确表达“值语义”时才考虑值传递。五、qsort 详解与结构体排序1. qsort 的本质qsort是 C 标准库stdlib.h中提供的快速排序函数。它的设计思想非常经典通过函数指针把“比较规则”从排序算法中分离出来使排序算法可以处理任意类型的数据。这就是回调函数callback的思想qsort负责排序逻辑调用者负责提供比较逻辑。2. 函数原型cvoid qsort( void *base, size_t nmemb, size_t size, int (*compar)(const void *, const void *) );参数含义参数含义base数组首地址void *表示可以接收任意类型数组nmemb元素个数size单个元素字节大小compar比较函数指针决定排序规则3. 为什么用void *qsort要能排序任意类型数组但 C 语言没有泛型所以用void *表示“指向未知类型的指针”。void *的特点是任何类型指针都可以隐式转换为void *。void *不能直接解引用必须先转回具体类型。指针算术在void *上不成立因为不知道元素大小所以qsort额外需要size参数。4. 比较函数的规则比较函数形式必须是cint compar(const void *a, const void *b);返回值含义返回值含义 0a排在b前面 0a和b顺序无所谓 0a排在b后面口诀返回负数a在前返回正数b在前返回 0顺序不变。5. 调用qsort(arr, n, sizeof(int), cmp_int);的含义这行代码的意思是对int数组arr中的n个元素按cmp_int的比较规则排序。arr退化为int *隐式转void *。n是元素个数不是字节数。sizeof(int)是单个元素大小。cmp_int是比较函数。6. 基本类型排序示例cint cmp_int(const void *a, const void *b) { int x *(const int *)a; int y *(const int *)b; return x - y; // 升序 } int main() { int arr[] {5, 2, 8, 1, 9, 3}; int n sizeof(arr) / sizeof(arr[0]); qsort(arr, n, sizeof(int), cmp_int); for (int i 0; i n; i) printf(%d , arr[i]); // 1 2 3 5 8 9 return 0; }必须先把void *转成int *再解引用。不能写*a - *b。7. 不同类型数组的比较函数浮点数不能返回差值因为返回值是int小数会被截断。cint cmp_double(const void *a, const void *b) { double x *(const double *)a; double y *(const double *)b; if (x y) return -1; if (x y) return 1; return 0; }字符串指针数组char *arr[]比较函数中要转成char **再解引用得到char *。cint cmp_str(const void *a, const void *b) { return strcmp(*(const char **)a, *(const char **)b); }结构体数组把void *转成结构体指针再按某个成员比较。cint cmp_score_desc(const void *a, const void *b) { const Student *s1 (const Student *)a; const Student *s2 (const Student *)b; if (s1-score s2-score) return -1; if (s1-score s2-score) return 1; return 0; } qsort(arr, n, sizeof(Student), cmp_score_desc);8. 多关键字排序先按一个字段比较相等时再按另一个字段比较。cint cmp_age_score(const void *a, const void *b) { const Student *s1 (const Student *)a; const Student *s2 (const Student *)b; if (s1-age ! s2-age) return s1-age - s2-age; // 年龄升序 if (s1-score s2-score) return -1; if (s1-score s2-score) return 1; return 0; }9. 注意事项qsort不稳定相同值的元素相对顺序可能改变。元素大小必须正确否则会越界。比较函数必须满足全序关系自反、反对称、传递。qsort直接交换元素所以元素类型必须可赋值。结构体可以整体赋值因此可以用。qsort_r是可重入版本但不是 C 标准函数不同平台原型不同。六、结构体内存对齐1. 为什么需要对齐CPU 访问内存时并不是按字节随意读取而是按字长成块读取。如果变量地址没有对齐可能需要多次访问甚至某些平台直接报错。因此编译器会在成员之间插入填充字节保证每个成员都从合适的地址开始。2. 对齐规则成员对齐每个成员的起始地址必须是其自身对齐数的整数倍。对齐数通常是成员大小char为 1short为 2int为 4double为 8指针为 4 或 8。结构体整体对齐结构体总大小必须是最大成员对齐数的整数倍不够则补齐。嵌套结构体嵌套的结构体按其内部最大对齐数对齐。3. 示例分析cstruct A { char c; int i; short s; };c偏移 0占 1 字节。i需要 4 字节对齐所以填充 3 字节偏移 4占 4 字节。s需要 2 字节对齐偏移 8占 2 字节。最大对齐数 4总大小 10 需补齐到 12。所以sizeof(struct A)通常为 12。如果把成员顺序调整为char c; short s; int i;总大小通常为 8。这说明成员顺序会影响结构体大小。4. 实际意义结构体大小不能想当然计算必须用sizeof。设计结构体时把大对齐成员放前面、小成员放后面通常能减少填充。网络传输和文件读写时通常需要#pragma pack或手动序列化避免对齐差异。七、结构体定义的几种形式形式一类型定义和变量定义分开cstruct demo { int a; }; struct demo d;形式二类型定义和变量定义一起cstruct demo { int a; } d {10};形式三匿名结构体cstruct { int a; } d {10};匿名结构体只能使用一次无法再定义其他同类型变量。结构体定义的位置结构体可以定义在全局也可以定义在函数内部。定义在函数内部时作用域只限于该函数。结构体与数组的区别对比项数组结构体数据类型同一类型不同类型访问方式下标.或-内存布局同类型元素连续排列成员按定义顺序排列可能有填充用途批量处理同类型数据描述复杂对象赋值不能整体赋值同类型可以整体赋值两者都是构造类型都用于批量处理数据但数组处理的是同类型数据结构体处理的是不同类型的数据。八、共用体 union1. 为什么需要共用体有时需要把几种不同类型的变量存放到同一段内存单元中。比如早期计算机硬件资源紧张希望节省内存又比如需要把同一块内存按不同类型解释实现类型转换。共用体union就是为此设计的所有成员共用同一块内存空间。2. 语法与示例cunion demo { int a; short b; char c; };3. 核心特点空间大小等于最大成员的大小。起始地址所有成员起始地址相同。同一时间只有一个成员有效写入一个成员会覆盖其他成员。初始化默认只能初始化第一个成员。访问方式和结构体一样使用.或-。连续赋值最终结果取决于最后写入的内容。4. 共用体 vs 结构体对比项结构体共用体内存各成员各自占用空间可能填充所有成员共用一块空间大小大于等于成员大小之和等于最大成员大小成员有效性同时有效同一时间只有一个有效用途描述复杂对象节省空间、类型转换5. 用共用体检测大小端大小端是数据在内存中的字节顺序小端低位字节存放在低地址。大端高位字节存放在低地址。cint check_endian() { union { int i; char c; } u; u.i 1; return u.c; // 返回 1 表示小端返回 0 表示大端 }原理i和c共用同一块内存c正好对应最低地址的那个字节。给i赋值为 1看c的值就能判断字节顺序。6. 应用场景节省空间多个字段互斥同一时间只用其中一个。例如学生和老师共用一个结构体学生用score老师用salary。类型转换把同一块内存按不同类型解释比如 IP 地址本质是 32 位整数点分十进制是四个字节。九、枚举 enum1. 为什么需要枚举程序中经常需要表示一组有限的、有名字的取值比如星期、月份、状态、错误码。如果直接用0、1、2代码可读性差容易出错。这些直接写在代码里的数字被称为幻数魔数。例如cswitch (op) { case 1: // 1 是什么可读性差 case 2: case 3: }枚举enum用于定义一组有名字的整型常量提高代码可读性。2. 语法与示例cenum 枚举类型名 { 枚举常量列表 };例如cenum Color { RED, GREEN, BLUE };默认第一个常量的值是 0后面依次递增RED 0GREEN 1BLUE 2。也可以手动指定cenum Week { MON 1, TUE, WED, THU, FRI, SAT, SUN };后续值依次递增。交通信号灯的例子cenum TrafficLight { RED, GREEN, YELLOW };3. 本质枚举的本质是整型常量。枚举变量在内存中通常按int处理。它不增加运行时开销只是提高代码可读性和可维护性。4. 枚举 vs 宏定义对比项枚举宏定义阶段编译时预处理阶段类型检查有无纯文本替换常量相关性将相关常量组织在一起分散定义可读性高一般枚举的三大好处提高可读性用名字代替数字。带类型检查编译时能发现类型问题。常量的相关性把相关的常量组织在一起。5. 使用场景状态机状态错误码选项、类型标识星期、月份等有限集合十、typedef 类型定义1. 作用typedef用于给已有类型起别名不是创造新的类型。2. 写法把之前定义变量的写法前面加上typedef则原先的变量名就成为类型的别名。ctypedef int sint32_t; // sint32_t 是 int 的别名 typedef unsigned char uint8_t; // uint8_t 是 unsigned char 的别名 sint32_t b; // 等价于 int b;3. 给结构体起别名cstruct stu { char name[20]; int sno; }; typedef struct stu stu_t; // stu_t 是 struct stu 的别名 stu_t a; // 等价于 struct stu a;更常见的写法是合并ctypedef struct { char name[20]; int sno; } stu_t;这样stu_t就直接是一个类型名使用时不需要再写struct。4. 实际意义简化复杂类型名提高代码可读性。方便跨平台移植比如uint8_t、int32_t等。C 标准库中大量使用如size_t、time_t。十一、位运算1. 为什么需要位运算位运算直接操作二进制位可以精确控制到每一位。硬件操作本质上就是在操作寄存器的位。例如 51 单片机是 8 位单片机通过软件控制某个二进制位就对应控制某个硬件引脚。text[0] -- P1.0 [1] -- P1.1 [2] -- P1.2 ... [7] -- P1.72. 按位与运算规则一假则假都为 1 才为 1。cint a 0x55; // 0101 0101 int b 0x33; // 0011 0011 a b; // 0001 0001 0x11应用清零。c0x55 0xfb; // 把第 2 位清零 // 0101 0101 // 1111 1011 // --------- // 0101 0001清零的写法creg ~(1 4 | 1 6); // 把第 4 位和第 6 位清零应用判断某一位是否为 1。cif (a (1 i)) // 判断第 i 位是否为 13. 按位或|运算规则一真则真有一个为 1 就为 1。cint a 0x55; // 0101 0101 int b 0x33; // 0011 0011 a | b; // 0111 0111 0x77应用置位置 1。creg reg | (1 i); // 把第 i 位置 14. 按位取反~运算规则真假相对0 变 11 变 0。c~0x55; // 1010 1010 0xaa5. 按位异或^运算规则相同为 0不同为 1。cint a 0x55; // 0101 0101 int b 0x33; // 0011 0011 a ^ b; // 0110 0110 0x66应用一翻转某一位。creg ^ (1 i); // 翻转第 i 位应用二加密。对数据异或一个密钥即可加密再异或同一个密钥即可还原。应用三不使用第三方变量交换两个数。cint a 0x55; int b 0x33; a a ^ b; b a ^ b; a a ^ b; // 最终 a 0x33b 0x55注意异或交换只适用于整数浮点数不能做位运算。6. 左移a n表示把a左移n位。左移时最高位丢弃最低位补 0。cint a 0x55; a 2; // 0000 0000 0000 0000 0000 0000 0101 0101 // 左移 2 位后 // 0000 0000 0000 0000 0000 0001 0101 0100 0x154左移相当于乘以 2 的 n 次方。7. 右移a n表示把a右移n位。右移时最低位丢弃高位补什么取决于类型逻辑右移无符号类型高位补 0。算术右移有符号类型高位补符号位。cint a 0x55; a 2; // 0001 0101 0x15右移相当于除以 2 的 n 次方。8. 位运算总结运算符名称应用按位与清零、判断某一位|按位或置 1~按位取反配合或|使用^按位异或翻转、加密、交换左移乘以 2 的 n 次方右移除以 2 的 n 次方9. 练习练习 1统计 int 类型数据中 1 的个数。cint count_one(int a) { int count 0; while (a) { count a 1; a 1; } return count; }练习 2让所有的偶数位清 0。cint a 0xffffffff; a 0xaaaaaaaa; // 1010 1010 ... 保留奇数位练习 3让所有的奇数位置 1。cint a 0; a | 0x55555555; // 0101 0101 ... 置位奇数位练习 4实现循环左移。cunsigned int rol(unsigned int a, int n) { int bits sizeof(a) * 8; n % bits; return (a n) | (a (bits - n)); }十二、总结本文涵盖的核心知识点结构体用于描述复杂数据对象把不同类型的数据组合成一个整体。使用结构体要先定义类型再定义变量、数组、指针。访问成员用.指针访问用-。结构体传参建议传指针提高效率不需要修改时加const。qsort通过函数指针实现通用排序核心是比较函数的编写。结构体存在内存对齐计算大小时要考虑填充字节。共用体的成员共用同一块内存可用于节省空间、类型转换、大小端检测。枚举用于定义有名字的整型常量提高代码可读性本质是整型。typedef给已有类型起别名不是创造新类型。位运算直接操作二进制位常用于硬件控制、加密、状态管理。2048 游戏的核心是移动和求和两个算法通过转置和反转可以复用同一套逻辑处理四个方向。