一、结构体介绍-自定义类型
解释:
结构体是C语言中一种用户自定义的数据类型,它允许将多个不同类型的数据项组合成一个单一的单位。结构体用于表示具有多个属性的实体。
示例:
// 定义坐标点结构体
struct Point {
int x;
int y;
};
// 定义学生信息结构体
struct Student {
char name[20];
int age;
float score;
};
注意事项:
-
结构体定义以分号结束,很多初学者会忘记这个分号
-
结构体定义不分配内存,只有声明变量时才分配内存
-
结构体名通常首字母大写,提高代码可读性
-
结构体定义可以放在函数内(局部)或函数外(全局)
二、结构体的声明
1. 结构体声明
解释:
结构体声明有三种方式,每种方式有不同的使用场景。
示例:
c
// 方式1:标准声明
struct Person {
char name[30];
int age;
};
// 方式2:声明同时定义变量
struct Employee {
char name[30];
int id;
} emp1, emp2;
// 方式3:使用typedef简化
typedef struct {
char name[30];
double salary;
} Worker;
注意事项:
方式1需要每次都写struct关键字,方式3更简洁
方式2适用于需要立即使用的情况
结构体可以前向声明:struct Person;(不完全类型)
2. 结构体变量的定义和初始化
解释:
结构体变量可以在声明时或之后初始化,支持多种初始化方式。
示例:
// 声明时初始化
struct Student {
char name[20];
int age;
} stu1 = {"张三", 20};
// 指定成员初始化(C99)
struct Student stu2 = {.name = "李四", .age = 21};
// 逐个成员初始化
struct Student stu3;
strcpy(stu3.name, "王五");
stu3.age = 22;
注意事项:
-
初始化时成员顺序必须与定义时一致
-
字符串赋值必须使用strcpy,不能直接赋值
-
部分初始化时,未指定的成员自动初始化为0
-
结构体变量可以在定义时初始化,但不能整体赋值
3. 结构体变量访问成员
解释:
使用点运算符(.)访问结构体变量的成员。
示例:
struct Student stu = {"张三", 20};
// 访问成员
printf("姓名: %s\n", stu.name);
printf("年龄: %d\n", stu.age);
// 修改成员
stu.age = 21;
strcpy(stu.name, "张小三");
注意事项:
-
点运算符优先级很高,仅次于括号
-
访问不存在的成员会导致编译错误
-
数组成员访问要注意边界检查
-
const结构体的成员不能被修改
4. 结构体的自引用
解释:
结构体可以包含指向自身类型的指针,用于实现链表、树等数据结构。
示例:
// 正确的自引用
struct Node {
int data;
struct Node* next; // 指向同类型的指针
};
// 错误的自引用
struct WrongNode {
int data;
struct WrongNode node; // 错误!不能包含自身实例
};
注意事项:
-
结构体不能直接包含自身实例,会导致无限递归
-
只能包含指向自身类型的指针
-
使用typedef时要注意声明顺序
-
链表操作时要正确处理指针关系
三、结构体数组
解释:
结构体数组用于存储多个相同类型的结构体实例。
示例:
struct Student {
char name[20];
int score;
};
// 定义并初始化结构体数组
struct Student class[3] = {
{"张三", 90},
{"李四", 85},
{"王五", 78}
};
// 访问数组元素
for(int i = 0; i < 3; i++) {
printf("学生%d: %s, 分数: %d\n",
i+1, class[i].name, class[i].score);
}
注意事项:
-
数组下标从0开始,不要越界访问
-
结构体数组作为函数参数时会退化为指针
-
大结构体数组可能占用大量内存,考虑动态分配
-
遍历时注意性能,特别是嵌套结构体
四、结构体与指针及函数传参
1. 指向结构体变量的指针
解释:
指针可以指向结构体变量,通过指针访问结构体成员。
示例:
struct Student stu = {"张三", 20};
struct Student *pStu = &stu; // 指向结构体的指针
// 通过指针访问成员
printf("姓名: %s\n", (*pStu).name); // 方式1
printf("年龄: %d\n", pStu->age); // 方式2(推荐)
注意事项:
-
使用->运算符更简洁,(*ptr).member方式容易出错
-
指针必须指向有效的结构体变量
-
空指针解引用会导致段错误
-
指针算术运算以结构体大小为步长
2. 指针访问成员变量
解释:
箭头运算符(->)是访问指针指向结构体成员的简便方式。
示例:
struct Point {
int x;
int y;
};
struct Point pt = {10, 20};
struct Point *ptr = &pt;
// 修改成员值
ptr->x = 100; // 等价于 (*ptr).x = 100
ptr->y = 200; // 等价于 (*ptr).y = 200
// 读取成员值
int x_val = ptr->x;
int y_val = ptr->y;
注意事项:
-
->运算符优先级很高
-
确保指针指向有效的内存地址
-
const指针不能修改成员值
-
多级指针访问需要多次解引用
3. STM32寄存器映射
解释:
在嵌入式开发中,结构体常用于寄存器映射,提供硬件访问的抽象层。
示例:
// GPIO寄存器结构体
typedef struct {
volatile uint32_t MODER; // 模式寄存器
volatile uint32_t OTYPER; // 输出类型寄存器
volatile uint32_t OSPEEDR; // 输出速度寄存器
volatile uint32_t PUPDR; // 上拉下拉寄存器
volatile uint32_t IDR; // 输入数据寄存器
volatile uint32_t ODR; // 输出数据寄存器
} GPIO_TypeDef;
// 寄存器映射
#define GPIOA_BASE 0x40020000U
#define GPIOA ((GPIO_TypeDef *)GPIOA_BASE)
// 使用
GPIOA->MODER = 0xABADF00D; // 设置GPIOA模式
uint32_t input = GPIOA->IDR; // 读取输入数据
注意事项:
-
必须使用volatile关键字防止编译器优化
-
寄存器地址必须精确匹配硬件手册
-
注意结构体对齐和填充字节
-
位域操作要小心,不同编译器实现可能不同
4. 结构体传参
解释:
结构体可以作为函数参数传递,支持值传递和指针传递。
示例:
// 值传递(产生拷贝)
void printStudent(struct Student stu) {
printf("姓名: %s, 年龄: %d\n", stu.name, stu.age);
}
// 指针传递(推荐,高效)
void modifyStudent(struct Student *pStu) {
pStu->age += 1;
strcpy(pStu->name, "修改后的名字");
}
// const指针传递(防止修改)
void displayStudent(const struct Student *pStu) {
printf("姓名: %s\n", pStu->name); // 可以读取
// pStu->age = 100; // 错误!不能修改
}
注意事项:
-
大结构体应该使用指针传递,避免拷贝开销
-
如果不需要修改,使用const指针保护数据
-
值传递会创建副本,修改不影响原结构体
-
指针传递要注意空指针检查
五、结构体在内存的存储
1. 结构体内存对齐
解释:
为了提高访问效率,编译器会对结构体成员进行内存对齐。
示例:
struct Example1 {
char a; // 1字节
int b; // 4字节
char c; // 1字节
}; // 总大小可能是12字节(不是6字节)
struct Example2 {
int b; // 4字节
char a; // 1字节
char c; // 1字节
}; // 总大小可能是8字节(优化后)
注意事项:
-
对齐规则因编译器和平台而异
-
成员顺序影响结构体总大小
-
使用#pragma pack可以修改对齐方式
-
网络传输时要考虑字节序和对齐问题
2. 内存对齐的原因
解释:
内存对齐主要出于性能考虑,现代CPU通常以字长为单位访问内存。
原因:
-
性能优化:对齐的内存访问更快
-
硬件要求:某些架构要求特定对齐
-
原子操作:对齐访问可以保证原子性
注意事项:
-
不同平台的对齐要求可能不同
-
嵌入式系统可能有关键的对齐要求
-
跨平台数据传输要处理对齐问题
-
调试时要注意内存布局
3. 修改默认对齐数
解释:
可以使用预处理指令修改编译器的默认对齐设置。
示例:
#pragma pack(1) // 设置为1字节对齐
struct TightPacked {
char a;
int b;
char c;
}; // 大小=1+4+1=6字节
#pragma pack() // 恢复默认对齐
// 另一种方式(GCC)
struct __attribute__((packed)) TightStruct {
char a;
int b;
char c;
};
注意事项:
-
修改对齐可能影响性能
-
打包的结构体可能无法在某些平台上运行
-
硬件寄存器映射通常需要严格对齐
-
跨平台代码要小心使用#pragma pack
4. 实现offsetof宏
解释:
offsetof宏用于获取结构体成员相对于结构体起始地址的偏移量。
示例:
// 标准库中的定义
#define offsetof(type, member) ((size_t)&(((type *)0)->member))
// 使用示例
struct Test {
char a;
int b;
double c;
};
printf("a偏移: %zu\n", offsetof(struct Test, a)); // 0
printf("b偏移: %zu\n", offsetof(struct Test, b)); // 4
printf("c偏移: %zu\n", offsetof(struct Test, c)); // 8
注意事项:
-
offsetof是编译时计算,不会产生运行时开销
-
不能用于位域成员
-
结果类型是size_t,适合指针运算
-
在嵌入式开发中常用于寄存器访问
六、枚举
1. 定义枚举类型
解释:
枚举是一种用户定义的类型,用于定义一组命名的整数常量。
示例:
// 定义枚举类型
enum Color {
RED, // 0
GREEN, // 1
BLUE // 2
};
// 使用typedef简化
typedef enum {
MONDAY, // 0
TUESDAY, // 1
WEDNESDAY, // 2
THURSDAY, // 3
FRIDAY, // 4
SATURDAY, // 5
SUNDAY // 6
} Weekday;
注意事项:
-
枚举常量默认从0开始,依次递增
-
可以指定特定值:
enum {A=1, B=2, C=4};
-
枚举类型大小通常是int的大小
-
不同枚举类型的常量不能混用
2. 枚举的使用
解释:
枚举变量只能取枚举中定义的值,提高了代码的可读性和安全性。
示例:
enum TrafficLight {RED, YELLOW, GREEN};
// 声明枚举变量
enum TrafficLight light = RED;
// 使用switch语句
switch(light) {
case RED:
printf("停止\n");
break;
case YELLOW:
printf("准备\n");
break;
case GREEN:
printf("通行\n");
break;
}
// 枚举与整数的转换
int value = GREEN; // 枚举转整数
enum TrafficLight light2 = 1; // 整数转枚举(可能不安全)
注意事项:
-
枚举提供了类型检查,比直接使用整数更安全
-
不要假设枚举值的具体数值
-
枚举可以提升代码可读性和可维护性
-
整数转枚举可能产生无效值
3. 用枚举限定STM32模式的取值
解释:
在嵌入式开发中,枚举常用于限定硬件配置的合法取值。
示例:
// GPIO模式枚举
typedef enum {
GPIO_MODE_INPUT, // 输入模式
GPIO_MODE_OUTPUT, // 输出模式
GPIO_MODE_AF, // 复用功能
GPIO_MODE_ANALOG // 模拟模式
} GPIO_Mode_TypeDef;
// 使用枚举配置GPIO
void GPIO_Init(GPIO_TypeDef *GPIOx, GPIO_Mode_TypeDef mode) {
// 只能传入枚举中定义的值
uint32_t temp = GPIOx->MODER;
temp &= ~(0x3U << (pin * 2));
temp |= (mode << (pin * 2));
GPIOx->MODER = temp;
}
// 正确使用
GPIO_Init(GPIOA, GPIO_MODE_OUTPUT);
// 错误使用(编译可能通过,但逻辑错误)
GPIO_Init(GPIOA, 5); // 5不是有效的模式值
注意事项:
-
枚举提供编译时检查,减少配置错误
-
枚举值应该与硬件寄存器值匹配
-
使用枚举而不是魔数(magic number)
-
文档化枚举值的含义和用法
4. 枚举的优点
解释:
枚举相比直接使用整数常量有多重优势。
优点:
-
类型安全:编译器检查赋值合法性
-
代码可读性:有意义的名称代替魔数
-
可维护性:修改只需改一处定义
-
调试友好:调试器显示名称而不是数字
-
自动编号:无需手动维护常量值
注意事项:
-
枚举不是类型安全的(C语言的限制)
-
不同枚举类型的值可能意外相等
-
枚举值的作用域是全局的,注意命名冲突
-
C++中的enum class提供真正的类型安全
七、联合体-共用体
1. 联合体的定义及初始化
解释:
联合体是所有成员共享同一块内存空间的数据结构,同一时间只能使用一个成员。
示例:
// 定义联合体
union Data {
int i;
float f;
char str[20];
};
// 初始化
union Data data;
data.i = 10; // 现在data.f和data.str无意义
printf("%d\n", data.i);
data.f = 3.14; // 现在data.i被覆盖
printf("%f\n", data.f);
// 初始化指定成员
union Data data2 = {.f = 2.718}; // C99标准
注意事项:
-
联合体大小等于最大成员的大小
-
同一时间只能有一个成员有效
-
读取未初始化的成员是未定义行为
-
常用于类型转换和节省内存
2. 用联合体判断计算机存储方式
解释:
利用联合体可以检测处理器的字节序(大端序或小端序)。
示例:
union EndianTest {
int value;
char bytes[sizeof(int)];
};
union EndianTest test;
test.value = 0x12345678;
if(test.bytes[0] == 0x78) {
printf("小端序(Little Endian)\n");
} else if(test.bytes[0] == 0x12) {
printf("大端序(Big Endian)\n");
} else {
printf("未知字节序\n");
}
注意事项:
-
字节序影响网络通信和文件存储
-
嵌入式系统通常是小端序
-
网络协议通常使用大端序(网络字节序)
-
跨平台数据传输需要处理字节序转换
3. 联合体大小的计算
解释:
联合体的大小至少等于最大成员的大小,还要考虑内存对齐。
示例:
union Example1 {
char a; // 1字节
int b; // 4字节
double c; // 8字节
}; // 大小=8字节(对齐到double)
union Example2 {
char arr[10]; // 10字节
int b; // 4字节
}; // 大小=12字节(对齐到4字节)
printf("Example1大小: %zu\n", sizeof(union Example1));
printf("Example2大小: %zu\n", sizeof(union Example2));
注意事项:
-
联合体大小必须是最大成员对齐要求的倍数
-
包含数组时,大小可能比数组本身大
-
使用#pragma pack会影响联合体大小
-
嵌入式系统中要注意联合体的内存布局
八、结尾
总结:
结构体、枚举和联合体是C语言中强大的工具,它们提供了数据抽象和组织的能力。正确使用这些特性可以大大提高代码的可读性、可维护性和效率。
最终建议:
-
选择合适的工具:根据需求选择结构体、枚举或联合体
-
注意内存布局:特别是嵌入式开发中的对齐要求
-
使用typedef简化:提高代码可读性
-
错误检查:始终验证指针和输入的有效性
-
文档化:为复杂结构体添加注释说明
最后的思考:
C语言给了你足够的力量来 shoot yourself in the foot(自找麻烦),
但也给了你所有工具来 build amazing things(创造奇迹)。
结构体、枚举、联合体就是这些工具中的重要组成部分。
使用它们 wisely(明智地)!