嵌入式C语言:结构体
目录
一、结构体的定义
二、结构体变量的声明
三、结构体成员的访问
四、结构体指针
五、结构体初始化
六、结构体数组
七、结构体嵌套
八、结构体作为函数参数
九、位字段(Bit-fields)
十、结构体在嵌入式系统中的应用
10.1. 硬件寄存器映射
10.2. 数据通信与协议解析
10.3. 任务与事件管理
10.4. 设备驱动与配置
10.5. 资源管理
10.6. 状态机实现
十一、注意事项
11.1. 字节对齐
11.2. 结构体初始化
11.3. 结构体指针
11.4. 作用域
11.5. 嵌套结构体
11.6. 与硬件交互
在嵌入式系统编程中,结构体(struct)是一种非常重要的数据类型,它允许将多个不同类型的数据项组合成一个单一的数据类型。这在组织和管理复杂数据、提高代码可读性和可维护性方面非常有用。
一、结构体的定义
结构体的定义使用 struct
关键字,后面跟着结构体标签(可选),以及花括号内的成员列表。例如,定义一个表示坐标点的结构体:
struct Point {
int x;
int y;
};
struct Point
是结构体类型,x
和 y
是结构体的成员,它们都是 int
类型。
二、结构体变量的声明
可以在定义结构体的同时声明变量,也可以在之后单独声明。
// 定义结构体的同时声明变量
struct Point {
int x;
int y;
} point1, point2;
// 单独声明变量
struct Point point3;
三、结构体成员的访问
通过结构体变量名和成员访问运算符(.
)来访问结构体的成员。
struct Point point;
point.x = 10;
point.y = 20;
四、结构体指针
可以声明指向结构体的指针,通过指针访问结构体成员时,使用 ->
运算符。 在处理大型结构体或传递结构体到函数时特别有用,因为它可以避免复制整个结构体。
struct Point *ptr;
struct Point point = {10, 20};
ptr = &point;
int x_value = ptr->x; // 使用指针访问结构体成员
五、结构体初始化
在声明结构体变量时,可以对其进行初始化。
struct Point point1 = {10, 20};
struct Point point2 = {.y = 30,.x = 25}; // 命名初始化,可按任意顺序
六、结构体数组
可以定义结构体数组,用于存储多个相同结构体类型的数据。
struct Point points[3] = {
{10, 20},
{30, 40},
{50, 60}
};
七、结构体嵌套
结构体可以包含其他结构体作为成员,形成嵌套结构体。这对于表示复杂数据结构非常有用。
struct Address {
char street[50];
char city[50];
char state[20];
int zip;
};
struct Person {
char name[50];
int age;
struct Address addr; // 嵌套结构体
};
八、结构体作为函数参数
结构体可以作为函数参数传递。按值传递会复制整个结构体,可能效率不高,特别是对于大型结构体。通常,通过指针传递结构体更为高效。
#include <stdio.h>
#include <stdint.h>
// 定义一个表示点的结构体
struct Point {
int16_t x;
int16_t y;
};
void printPoint(struct Point *p) {
printf("Point: (%d, %d)\n", p->x, p->y);
}
int main() {
struct Point p = {10, 20};
printPoint(&p); // 传递结构体指针
return 0;
}
九、位字段(Bit-fields)
在嵌入式系统中,内存是非常宝贵的资源。位字段允许在一个结构体中定义位级别的成员,对于硬件寄存器的映射非常有用。
struct BitFieldExample {
uint32_t bit0 : 1;
uint32_t bit1 : 1;
uint32_t bit2_4 : 3; // 3位宽的字段
uint32_t bit5_31 : 27; // 剩余的27位
};
十、结构体在嵌入式系统中的应用
10.1. 硬件寄存器映射
内存地址映射:嵌入式系统中,硬件设备的寄存器与特定内存地址关联,通过结构体可将这些寄存器映射为结构体成员,实现对硬件设备的便捷控制。例如,对于一个简单的 GPIO 控制寄存器,可定义如下结构体:
#include <stdint.h>
// 假设GPIO寄存器的地址(通常是微控制器数据手册中定义的)
#define GPIO_BASE_ADDR 0x40004000
// 定义GPIO寄存器结构体
typedef struct {
volatile uint32_t DATA; // 数据寄存器
volatile uint32_t DIR; // 方向寄存器
volatile uint32_t IS; // 中断敏感寄存器
volatile uint32_t IBE; // 边缘检测中断使能寄存器
volatile uint32_t IEV; // 边缘检测方向寄存器
volatile uint32_t IM; // 中断屏蔽寄存器
volatile uint32_t RIS; // 原始中断状态寄存器
volatile uint32_t MIS; // 屏蔽中断状态寄存器
volatile uint32_t ICR; // 中断清除寄存器
// ... 可能还有其他寄存器
} GPIO_TypeDef;
// 将GPIO基地址转换为GPIO结构体指针
#define GPIO ((GPIO_TypeDef *)GPIO_BASE_ADDR)
// 使用示例:设置GPIO方向为输出并点亮LED
void LED_On(void) {
GPIO->DIR |= (1 << 5); // 假设LED连接在GPIO的第5位
GPIO->DATA |= (1 << 5); // 设置GPIO第5位为高电平,点亮LED
}
10.2. 数据通信与协议解析
数据封装:在嵌入式系统间或与外部设备通信时,需将数据按特定协议封装与解析。结构体可用于构建符合通信协议的数据结构。以 SPI 通信协议为例,数据帧可能包含起始位、地址位、数据位和校验位等,使用结构体可清晰表示该数据帧:
#include <stdio.h>
#include <stdint.h>
#include <stdbool.h>
#include <string.h>
// 定义SPI数据帧结构体
typedef struct {
uint8_t startBit : 1; // 起始位
uint8_t addrBits : 7; // 地址位(假设为7位)
uint8_t data[8]; // 数据位(假设为8字节)
uint8_t checksum; // 校验位
} SPIFrameTypeDef;
// 初始化SPI数据帧
void SPIFrame_Init(SPIFrameTypeDef *frame, uint8_t start, uint8_t addr, uint8_t *data, uint8_t checksum) {
frame->startBit = start;
frame->addrBits = addr;
memcpy(frame->data, data, 8);
frame->checksum = checksum;
}
// 计算校验和(简单示例,实际可能更复杂)
uint8_t CalculateChecksum(uint8_t *data, uint8_t length) {
uint8_t sum = 0;
for (uint8_t i = 0; i < length; i++) {
sum += data[i];
}
// 取反作为校验和(简单示例)
return ~sum + 1;
}
// 封装数据到SPI数据帧
bool PackSPIFrame(SPIFrameTypeDef *frame, uint8_t addr, uint8_t *data) {
// 假设起始位总是1
uint8_t checksum = CalculateChecksum(data, 8);
SPIFrame_Init(frame, 1, addr, data, checksum);
// 在这里可以添加额外的错误检查,比如地址是否有效等
// 为了简单起见,我们总是返回true
return true;
}
// 解析SPI数据帧
bool UnpackSPIFrame(SPIFrameTypeDef *frame, uint8_t *addr, uint8_t *data, uint8_t *receivedChecksum) {
// 检查起始位
if (frame->startBit != 1) {
return false; // 起始位错误
}
// 提取地址和数据
*addr = frame->addrBits;
memcpy(data, frame->data, 8);
*receivedChecksum = frame->checksum;
// 计算预期的校验和并比较
uint8_t expectedChecksum = CalculateChecksum(data, 8);
if (frame->checksum != expectedChecksum) {
return false; // 校验和错误
}
return true; // 解析成功
}
int main() {
// 示例数据
uint8_t dataToSend[8] = {0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08};
uint8_t receivedAddr;
uint8_t receivedData[8];
uint8_t receivedChecksum;
// 封装数据帧
SPIFrameTypeDef frameToSend;
if (PackSPIFrame(&frameToSend, 0x7F, dataToSend)) {
// 打印封装后的数据帧
printf("Packed Frame:\n");
printf("Start Bit: %d\n", frameToSend.startBit);
printf("Address: 0x%02X\n", frameToSend.addrBits);
printf("Data: ");
for (int i = 0; i < 8; i++) {
printf("0x%02X ", frameToSend.data[i]);
}
printf("\nChecksum: 0x%02X\n", frameToSend.checksum);
// 解析数据帧(模拟接收到的数据帧与发送的相同)
if (UnpackSPIFrame(&frameToSend, &receivedAddr, receivedData, &receivedChecksum)) {
// 打印解析后的数据
printf("Unpacked Data:\n");
printf("Received Address: 0x%02X\n", receivedAddr);
printf("Received Data: ");
for (int i = 0; i < 8; i++) {
printf("0x%02X ", receivedData[i]);
}
printf("\nReceived Checksum: 0x%02X\n", receivedChecksum);
} else {
printf("Failed to unpack SPI frame.\n");
}
} else {
printf("Failed to pack SPI frame.\n");
}
return 0;
}
10.3. 任务与事件管理
任务描述:在多任务嵌入式系统中,结构体可用于描述任务属性与状态,如任务 ID、优先级、堆栈指针、任务函数指针等。以简单的任务管理系统为例,定义任务结构体:
#include <stdio.h>
#include <stdint.h>
#include <stdbool.h>
// 定义任务函数指针类型
typedef void (*TaskFunction)(void);
// 定义任务结构体
typedef struct {
uint8_t task_id;
uint8_t priority;
void *stack_pointer; // 在实际的多任务系统中,堆栈指针的初始化和使用由RTOS管理
TaskFunction task_function;
bool is_active; // 标记任务是否处于活动状态
} Task;
// 假设的任务函数定义
void Task1(void) {
printf("Task 1 is running\n");
// 在实际的任务中,这里会包含任务的主体逻辑
}
void Task2(void) {
printf("Task 2 is running\n");
// 在实际的任务中,这里会包含任务的主体逻辑
}
// 定义任务实例(注意:在实际系统中,堆栈指针应由RTOS初始化)
Task tasks[] = {
{1, 1, NULL, Task1, false}, // 初始时,任务未激活
{2, 2, NULL, Task2, false} // 初始时,任务未激活
};
// 简单的任务“调度”函数(注意:这不是真正的任务调度器)
void RunTasks(Task *tasks, size_t task_count) {
for (size_t i = 0; i < task_count; ++i) {
if (tasks[i].is_active) {
tasks[i].task_function(); // 调用任务函数
}
}
}
int main() {
// 在实际的多任务系统中,RTOS会初始化任务并管理它们的调度
// 在这个示例中,我们将手动激活任务并“调度”它们
// 激活任务
tasks[0].is_active = true;
tasks[1].is_active = true;
// 运行任务(注意:这不是真正的多任务并行运行)
// 在实际系统中,RTOS会负责在任务之间切换
while (1) {
RunTasks(tasks, sizeof(tasks) / sizeof(tasks[0]));
// 在实际系统中,这里会有一个延迟或等待中断的机制
// 在这个示例中,使用一个简单的sleep来模拟(注意:sleep函数可能不在所有嵌入式系统中都可用)
#ifdef _WIN32 // 如果在Windows上编译和运行此代码,则使用Sleep函数(注意:这是毫秒级的)
Sleep(1000); // 休眠1秒
#else // 如果在类Unix系统(如Linux)上编译和运行,则使用sleep函数(注意:这是秒级的)
sleep(1); // 休眠1秒
#endif
}
// 在实际的多任务系统中,main函数通常不会返回一个值,因为RTOS会接管控制权
// 在这个示例中,由于我们没有实现RTOS,所以main函数会返回0以结束程序
return 0; // 在实际的多任务嵌入式系统中,这行代码通常不会被执行
}
10.4. 设备驱动与配置
设备信息存储:不同硬件设备需不同配置参数,结构体可用于存储这些设备相关信息。以串口设备为例,其配置参数可能包括波特率、数据位、停止位和校验位等,定义如下结构体:
#include <stdio.h>
#include <stdint.h>
// 定义串口配置结构体
typedef struct {
uint32_t baud_rate; // 波特率
uint8_t data_bits; // 数据位
uint8_t stop_bits; // 停止位
uint8_t parity; // 校验位(0: 无校验, 1: 奇校验, 2: 偶校验, 等)
} UART_Config;
// 模拟的串口初始化函数(在实际应用中,这将与硬件接口)
void UART_Init(UART_Config *config) {
// 在这里,我们仅打印配置以模拟初始化过程
printf("UART Initialized with the following configuration:\n");
printf("Baud Rate: %u\n", config->baud_rate);
printf("Data Bits: %u\n", config->data_bits);
printf("Stop Bits: %u\n", config->stop_bits);
printf("Parity: %u\n", config->parity);
// 在实际的应用中,这里会包含与硬件寄存器交互的代码来配置串口
}
int main() {
// 配置串口参数
UART_Config uart_config = {
.baud_rate = 9600,
.data_bits = 8,
.stop_bits = 1,
.parity = 0 // 无校验
};
// 初始化串口(在实际应用中,可能会启动串口通信)
UART_Init(&uart_config);
// 在实际应用中,这里会包含串口通信的代码
// ...
// 由于这是一个模拟示例,我们在这里结束程序
return 0;
}
10.5. 资源管理
内存管理:在嵌入式系统中,内存是有限资源,结构体可用于管理内存分配与释放。例如,实现简单内存池时,定义内存块结构体:
#include <stdio.h>
#include <stdlib.h>
#include <stdbool.h>
#include <stdint.h>
#include <string.h>
// 定义内存块结构体
typedef struct MemoryBlock {
struct MemoryBlock *next; // 指向下一个内存块的指针
uint8_t is_used; // 标记内存块是否被使用
// 这里可以添加一个数据字段来存储实际的数据,但为了简化示例,我们省略了它
} MemoryBlock;
// 定义内存池结构体
typedef struct {
MemoryBlock *head; // 内存池头部指针
uint32_t block_size; // 每个内存块的大小(不包括结构体本身的大小)
uint32_t total_blocks; // 内存池中内存块的总数
uint32_t free_blocks; // 当前可用的内存块数量
} MemoryPool;
// 初始化内存池
void MemoryPool_Init(MemoryPool *pool, uint32_t block_size, uint32_t total_blocks) {
pool->head = NULL;
pool->block_size = block_size;
pool->total_blocks = total_blocks;
pool->free_blocks = total_blocks;
// 动态分配内存块并链接它们
MemoryBlock *current = NULL;
MemoryBlock *previous = NULL;
for (uint32_t i = 0; i < total_blocks; i++) {
MemoryBlock *new_block = (MemoryBlock*)malloc(sizeof(MemoryBlock) + block_size);
if (new_block == NULL) {
// 处理内存分配失败的情况(在实际应用中,可能需要更复杂的错误处理)
fprintf(stderr, "Memory allocation failed\n");
exit(EXIT_FAILURE);
}
new_block->is_used = 0; // 初始化为未使用状态
new_block->next = NULL;
if (previous == NULL) {
// 这是第一个内存块,设置为内存池的头部
pool->head = new_block;
} else {
// 将前一个内存块的next指针指向新的内存块
previous->next = new_block;
}
previous = new_block;
}
}
// 从内存池中分配一个内存块
void* MemoryPool_Alloc(MemoryPool *pool) {
if (pool->free_blocks == 0) {
// 没有可用的内存块
return NULL;
}
MemoryBlock *current = pool->head;
while (current != NULL && current->is_used != 0) {
// 查找第一个未使用的内存块
current = current->next;
}
if (current != NULL) {
// 标记内存块为已使用
current->is_used = 1;
pool->free_blocks--;
// 返回指向数据字段的指针(跳过MemoryBlock结构体本身)
return (void*)((uint8_t*)current + sizeof(MemoryBlock));
}
// 在理论上,这里不应该发生,因为我们已经检查了free_blocks不为0
// 但为了代码的健壮性,我们还是返回NULL
return NULL;
}
// 释放一个内存块回内存池
void MemoryPool_Free(MemoryPool *pool, void *data) {
if (data == NULL) {
// 尝试释放NULL指针,什么也不做
return;
}
// 将数据指针转换回MemoryBlock指针(考虑到MemoryBlock结构体的大小)
MemoryBlock *block = (MemoryBlock*)((uint8_t*)data - sizeof(MemoryBlock));
// 标记内存块为未使用
block->is_used = 0;
pool->free_blocks++;
// 注意:在这个简单的实现中,我们没有重新链接内存块或进行碎片整理
// 在实际应用中,可能需要更复杂的逻辑来处理这些情况
}
// 销毁内存池并释放所有内存块
void MemoryPool_Destroy(MemoryPool *pool) {
MemoryBlock *current = pool->head;
while (current != NULL) {
MemoryBlock *next = current->next;
free(current);
current = next;
}
pool->head = NULL;
pool->free_blocks = 0; // 可选,但有助于避免悬挂指针等潜在问题
}
int main() {
// 初始化一个内存池,每个内存块大小为16字节,总共有10个内存块
MemoryPool pool;
MemoryPool_Init(&pool, 16, 10);
// 分配两个内存块
void *data1 = MemoryPool_Alloc(&pool);
void *data2 = MemoryPool_Alloc(&pool);
// 检查分配是否成功
if (data1 == NULL || data2 == NULL) {
fprintf(stderr, "Memory allocation failed\n");
MemoryPool_Destroy(&pool);
return EXIT_FAILURE;
}
// 使用分配的内存块(在实际应用中,这里会存储或处理数据)
// ...
// 释放内存块
MemoryPool_Free(&pool, data1);
MemoryPool_Free(&pool, data2);
// 销毁内存池
MemoryPool_Destroy(&pool);
return EXIT_SUCCESS;
}
10.6. 状态机实现
状态表示:在实现有限状态机(FSM)时,结构体可用于表示状态和状态转移条件。例如,定义一个简单的 LED 闪烁状态机:
// 定义状态枚举
typedef enum {
STATE_OFF,
STATE_ON,
STATE_BLINKING
} LED_State;
// 定义状态机结构体
typedef struct {
LED_State current_state;
void (*enter_state)(void);
void (*exit_state)(void);
void (*update)(void);
} LED_FSM;
// 状态机函数定义
void enter_off_state(void);
void exit_off_state(void);
void update_off_state(void);
// 初始化状态机
LED_FSM led_fsm = {
.current_state = STATE_OFF,
.enter_state = enter_off_state,
.exit_state = exit_off_state,
.update = update_off_state
};
十一、注意事项
11.1. 字节对齐
对齐规则:为了提高内存访问效率,编译器会按照特定规则对结构体成员进行字节对齐。例如,在 32 位系统中,编译器可能会将 4 字节的int
类型成员对齐到 4 字节边界上。假设有如下结构体:
struct Example {
char a; // 1字节
int b; // 4字节
};
char
类型成员a
后面可能会填充 3 个字节,使得int
类型成员b
从 4 字节对齐的地址开始存储,这样整个结构体占用 8 字节,而不是 5 字节。
- 影响:字节对齐会导致结构体实际占用内存大于所有成员大小之和。这在内存资源紧张的嵌入式系统中需要特别关注,避免不必要的内存浪费。
- 控制对齐:可使用
#pragma pack(n)
预编译指令或__attribute__((packed))
属性来控制对齐方式。例如__attribute__((packed))
可使结构体按紧凑方式存储,取消字节对齐:
struct __attribute__((packed)) CompactExample {
char a;
int b;
};
上述CompactExample
结构体占用 5 字节,但可能会降低某些硬件平台的访问效率,因为未对齐的内存访问可能需要额外的周期。
11.2. 结构体初始化
顺序初始化:在初始化结构体时,按照成员声明顺序进行初始化。例如:
struct Point {
int x;
int y;
};
struct Point p = {10, 20}; // 先初始化x为10,再初始化y为20
命名初始化:C99 标准引入命名初始化,可按任意顺序初始化成员:
struct Point p = {.y = 20,.x = 10};
部分初始化:可对部分成员初始化,未初始化的成员会被赋予默认值(对于普通变量,默认值通常是不确定的;对于全局变量,会初始化为 0):
struct Point p = {.x = 10}; // y未初始化,值不确定(如果是全局变量则为0)
11.3. 结构体指针
指针运算:结构体指针运算与普通指针类似,但要注意结构体成员的偏移。例如,假设有一个结构体数组:
struct Data {
int value;
char flag;
};
struct Data data_array[10];
struct Data *ptr = data_array;
ptr++; // 指针移动的字节数为结构体Data的大小,而不是单个成员的大小
内存管理:当使用结构体指针指向动态分配的内存时,要确保正确释放内存,避免内存泄漏。例如:
struct Node {
int data;
struct Node *next;
};
struct Node *new_node = (struct Node *)malloc(sizeof(struct Node));
if (new_node!= NULL) {
// 使用new_node
free(new_node); // 使用完毕后释放内存
}
11.4. 作用域
- 局部与全局:在函数内部定义的结构体,其作用域仅限于该函数内部;在文件作用域(函数外部)定义的结构体,可被同一文件内的函数使用。若要在多个文件中使用同一结构体,可在头文件中定义,然后在需要的源文件中包含该头文件。
- 结构体标签:结构体标签的作用域遵循 C 语言一般作用域规则。不同作用域内可定义同名结构体标签,但在同一作用域内,结构体标签必须唯一。
11.5. 嵌套结构体
成员访问:访问嵌套结构体成员时,需使用多个成员访问运算符。例如:
struct Inner {
int a;
};
struct Outer {
struct Inner inner;
int b;
};
struct Outer outer;
outer.inner.a = 10; // 访问嵌套结构体Inner的成员a
outer.b = 20;
初始化:初始化嵌套结构体时,可使用嵌套的初始化列表:
struct Outer outer = {
.inner = {.a = 10 },
.b = 20
};
11.6. 与硬件交互
- 类型匹配:在进行硬件寄存器映射时,确保结构体成员类型与硬件寄存器位宽和功能匹配。例如,对于一个 16 位的硬件寄存器,应使用
uint16_t
类型成员与之对应。 - volatile 关键字:当结构体用于访问硬件寄存器时,通常需使用
volatile
关键字修饰,防止编译器优化掉对硬件寄存器的读写操作。例如:
volatile struct {
uint32_t register1;
uint32_t register2;
} *hardware_registers = (volatile struct {
uint32_t register1;
uint32_t register2;
} *)0x10000000; // 假设硬件寄存器地址
volatile
关键字确保每次对hardware_registers
成员的访问都是对实际硬件寄存器的操作。
总之,结构体是嵌入式 C 语言编程中非常强大的工具,它帮助开发者有效地组织和管理数据,提高代码的可读性和可维护性,特别是在处理与硬件相关的数据和复杂的数据结构时。