在嵌入式开发中,我们经常需要处理不同类型数据的传输(如通过UART、SPI、I2C)与存储(如写入EEPROM或SPI Flash)。一个常见的挑战是:这些外设通常以字节为单位进行操作,而我们需要读写的可能是uint16_t、float、uint32_t等复合数据类型。手动拆分和组装这些数据不仅代码冗长,还容易出错。
本文将介绍一种基于C语言结构体的简易方法,可以极大地简化这一过程,并附带讨论与之相关的字节序(大小端)问题。
关于字节序(大小端)
在进行跨设备或跨协议的数据交换时,字节序是一个必须考虑的因素。本文主要讨论的Cortex-M内核同时支持大端和小端模式。
但在实际应用中,绝大多数Cortex-M芯片(包括ST的STM32全系列)在出厂时就被固化为小端(Little-Endian)模式,且不可更改。 其他厂商的芯片也大多如此。因此,在单一小端设备内部进行数据打包与解析,或在小端设备之间通信时,我们可以暂时忽略字节序转换问题。
以下是STM32各系列参考手册的佐证:
- F1系列:明确说明数据以小端格式存储。

- F3/F4系列:同样指出存储器系统采用小端格式。

- F7/H7系列:延续了这一设计。

了解这一背景后,我们就可以专注于如何高效地组织数据。
方法核心:使用结构体封装数据
本方法的核心思想是:定义一个结构体,将需要传输或存储的所有数据成员封装在一起。这样,整个结构体变量在内存中就是一段连续的字节序列,可以直接用于字节读写操作。
1. 应用于数据存储(如EEPROM、Flash)
假设我们需要向SPI Flash存储一个配置参数集合,包含一个字节、一个短整型和一个浮点数。
首先,定义结构体:
typedef struct {
uint8_t status;
uint16_t counter;
float voltage;
} Config_T;
Config_T g_tConfig;
写入时,可以直接将结构体变量的地址强制转换为uint8_t*指针,并以其大小为长度进行写入:
// 假设 spiFlash_Write 函数原型为: spiFlash_Write(uint32_t addr, uint8_t *pBuf, uint32_t len)
spiFlash_Write(SAVE_ADDR, (uint8_t*)&g_tConfig, sizeof(Config_T));
读取时,采用类似的方式:
// 假设 spiFlash_Read 函数原型为: spiFlash_Read(uint32_t addr, uint8_t *pBuf, uint32_t len)
spiFlash_Read(SAVE_ADDR, (uint8_t*)&g_tConfig, sizeof(Config_T));
// 之后即可直接访问成员
// uint16_t cnt = g_tConfig.counter;
2. 应用于数据通信(以UART为例)
| 在通信协议设计中,这种方法尤为高效。例如,主机需要向从机发送一帧包含多种数据类型的数据,格式如下: |
起始符 |
CO2值 |
PM2.5值 |
湿度值 |
温度值 |
参数 |
结束符1 |
结束符2 |
| 1字节 |
2字节 |
2字节 |
2字节 |
4字节 |
4字节 |
1字节 |
1字节 |
我们可以定义一个与之完全对应的结构体:
typedef struct {
uint8_t ucStart; // 起始符,例如 ‘$'
uint16_t usCO2;
uint16_t usPM25;
uint16_t usHumidity;
float fTemperature; // 温度值
uint32_t ulParam;
uint8_t ucEnd1; // 结束符1,例如 ‘\r’
uint8_t ucEnd2; // 结束符2,例如 ‘\n’
} UART_FRAME_T;
UART_FRAME_T g_tTxFrame; // 发送帧
主机发送时,直接将结构体作为字节流发出:
// 假设 comSendBuf 函数原型为: comSendBuf(uint8_t com, uint8_t *pBuf, uint16_t len)
comSendBuf(COM1, (uint8_t*)&g_tTxFrame, sizeof(UART_FRAME_T));
从机接收时,在接收缓冲区上定义同样的结构体指针,即可直接访问各个字段:
uint8_t uartRxBuf[128];
UART_FRAME_T *pRxFrame;
// 将接收缓冲区的地址强制转换为结构体指针
pRxFrame = (UART_FRAME_T *)uartRxBuf;
// 现在可以直接像访问结构体一样访问协议数据
uint16_t co2_value = pRxFrame->usCO2;
float temp_value = pRxFrame->fTemperature;
这种方法避免了手动解析每一个字段的繁琐操作,极大地提高了代码的简洁性和可维护性,是进行嵌入式通信协议处理的实用技巧。
完整代码示例
以下是一个模拟通过两个串口(COM1发送,COM2接收)进行数据帧收发的简单例程,演示了上述结构体方法的完整应用。
#include <stdint.h>
#include <string.h>
// 假设必要的硬件驱动头文件已包含
/* 定义通信帧结构体 */
typedef struct {
uint8_t ucStart;
uint16_t usCO2;
uint16_t usPM25;
uint16_t usHumidity;
float fTemperature;
uint32_t ulParam;
uint8_t ucEnd1;
uint8_t ucEnd2;
} UART_FRAME_T;
/* 全局变量定义 */
UART_FRAME_T g_tTxFrame; // 发送帧(用于COM1)
UART_FRAME_T *pRxFrame; // 接收帧指针(用于COM2)
uint8_t rxBuffer[128]; // 原始接收缓冲区
uint8_t rxStatus = 0; // 接收状态机状态
uint8_t rxIndex = 0; // 缓冲区索引
int main(void) {
uint8_t key;
uint8_t uartByte;
float testTemp = 0.11f;
/* 硬件初始化 */
bsp_Init();
pRxFrame = (UART_FRAME_T *)rxBuffer; // 将缓冲区映射为结构体
/* 主循环 */
while (1) {
bsp_Idle(); // 处理休眠、看门狗等
/* 按键K1触发发送 */
key = bsp_GetKey();
if (key == KEY_DOWN_K1) {
// 填充发送帧数据
g_tTxFrame.ucStart = ‘$’;
g_tTxFrame.usCO2 = 1;
g_tTxFrame.usPM25 = 2;
g_tTxFrame.usHumidity = 3;
g_tTxFrame.fTemperature = testTemp++;
g_tTxFrame.ulParam = 5;
g_tTxFrame.ucEnd1 = ‘\r’;
g_tTxFrame.ucEnd2 = ‘\n’;
// 以字节流形式发送整个结构体
comSendBuf(COM1, (uint8_t*)&g_tTxFrame, sizeof(UART_FRAME_T));
printf(">> 数据帧发送完成。\r\n");
}
/* 从COM2接收并解析数据 */
if (comGetChar(COM2, &uartByte)) {
switch (rxStatus) {
case 0: // 等待起始符
if (uartByte == ‘$’) {
memset(rxBuffer, 0, sizeof(rxBuffer));
rxBuffer[rxIndex++] = uartByte;
rxStatus = 1;
}
break;
case 1: // 接收数据体
rxBuffer[rxIndex] = uartByte;
// 检查是否接收到完整的帧(以\r\n结尾)
if (rxIndex >= 1 && rxBuffer[rxIndex-1] == ‘\r’ && rxBuffer[rxIndex] == ‘\n’) {
// 接收完成,通过结构体指针直接访问数据
printf("<< 接收到数据帧:\r\n");
printf(" CO2: %d\r\n", pRxFrame->usCO2);
printf(" PM2.5: %d\r\n", pRxFrame->usPM25);
printf(" 湿度: %d\r\n", pRxFrame->usHumidity);
printf(" 温度: %.2f\r\n", pRxFrame->fTemperature);
printf(" 参数: %lu\r\n", pRxFrame->ulParam);
printf("\r\n");
// 重置状态,准备接收下一帧
rxStatus = 0;
rxIndex = 0;
} else {
rxIndex++;
// 简单防止缓冲区溢出
if (rxIndex >= sizeof(rxBuffer)) {
rxStatus = 0;
rxIndex = 0;
}
}
break;
default:
rxStatus = 0;
break;
}
}
}
return 0;
}
总结
通过使用C语言结构体来映射通信协议或存储格式,我们可以将一段内存字节流直接转化为有组织的、易于访问的强类型数据,反之亦然。这种方法在嵌入式系统编程中具有显著优势:
- 代码简洁:省去了大量的移位、掩码和拼接操作。
- 易于维护:协议格式或存储结构的变更,只需修改结构体定义即可。
- 逻辑清晰:数据访问方式直观(
frame->sensorValue)。
需要注意的要点:
- 字节对齐:编译器可能会在结构体成员间插入填充字节以保证对齐。在跨平台或严格遵循字节协议的场景下,需要使用编译器指令(如GCC的
__attribute__((packed)))来强制结构体紧凑排列。
- 字节序:如前所述,本文方法在双方均为小端设备时可直接使用。若与大端设备或网络协议(如TCP/IP)通信,则必须在打包或解包前进行必要的字节序转换(如使用
htonl、ntohl等函数)。
- 数据填充:确保接收缓冲区或存储区的大小至少等于结构体的大小。
掌握这一技巧,能让你在处理嵌入式系统的数据交换与持久化时更加得心应手。