我是老温,一名热爱学习的嵌入式工程师,关注我,一起变得更加优秀!
在嵌入式软件开发领域,串口通信或网络通信,对不定长数据进行收发是比较常见的需求之一。
因为直接在串口中断函数里面处理数据,可能会导致阻塞/丢包/数据错乱等问题,而环形队列是解决异步数据缓存的最优方案。
搭配上自定义的数据通信协议,可以精准地解析帧头/帧尾/数据长度/校验位,完美地解决了不定长数据的分包/粘包问题。
本文提供一个可以直接移植的C语言代码模板,讲解一下环形队列+自定义协议的核心原理,设计一个串口数据收发的完整流程,适用于单片机(比如STM32、C51、ESP32)、Linux串口编程、网络Socket等数据接收场景。
一、核心原理
(1)为什么要用环形队列?
因为数据通常是连续和不定长的,中断接收的速度比较快,如果主程序处理数据比较慢,就需要使用先进先出(FIFO)的环形队列进行数据缓冲,并且在中断里面做入队操作,主程序进行出队解析。
(2)为什么要自定义协议?
如果不对数据进行协议封包,就无法判断一帧数据从哪里开始、到哪里结束、是否完整、是否出错。
自定义协议可以标准化数据格式,实现帧定位(帧头+帧尾)、数据长度标识、数据校验(防止传输错误)、精准解析不定长数据。
二、设计自定义通信协议
协议帧数据字段格式如下:
【帧头】【数据长度】【数据内容】【校验位】【帧尾】
字段定义:
| 字段 | 长度 | 说明 | 示例值 |
| 帧头 | 2字节 | 固定标识:0x55AA | 0x55AA |
| 数据长度 | 1字节 | 有效数据长度 | 0x03 |
| 数据内容 | N字节 | 数据载荷 | 自定义 |
| 校验位 | 1字节 | 累加校验和 | 计算值 |
| 帧尾 | 2字节 | 固定标识:0xBBCC | 0xBBCC |
协议优势:
采用双帧头+帧尾设计,抗干扰能力强,避免错误解析,长度字段可以明确知道需要接收多少数据,校验位用来保证数据传输的完整性,完美地适配了不定长数据的解析。
三、环形队列的代码实现
环形队列是整个代码模板的核心,支持入队、出队、判空、判满、查询长度等基础操作。
#include <stdint.h>#include <string.h>// 环形队列配置#define RING_BUF_SIZE 1024 // 队列大小,可根据需求修改// 环形队列结构体typedef struct {uint8_t buffer[RING_BUF_SIZE]; // 数据缓冲区uint16_t head; // 队头(数据出队位置)uint16_t tail; // 队尾(数据入队位置)uint16_t size; // 当前数据长度} RingQueue_t;// 全局环形队列实例(串口接收专用)static RingQueue_t uart_rx_queue;/*** @brief 初始化环形队列*/void ring_queue_init(RingQueue_t *q) {memset(q, 0, sizeof(RingQueue_t));}/*** @brief 判断队列是否为空*/uint8_t ring_queue_is_empty(RingQueue_t *q) {return (q->size == 0);}/*** @brief 判断队列是否已满*/uint8_t ring_queue_is_full(RingQueue_t *q) {return (q->size == RING_BUF_SIZE);}/*** @brief 数据入队(中断中调用)* @param data: 入队字节* @return 0:成功 1:失败(队列满)*/uint8_t ring_queue_enqueue(RingQueue_t *q, uint8_t data) {if(ring_queue_is_full(q)) return 1;q->buffer[q->tail] = data;q->tail = (q->tail + 1) % RING_BUF_SIZE;q->size++;return 0;}/*** @brief 数据出队(主程序中调用)* @param data: 出队数据存储指针* @return 0:成功 1:失败(队列空)*/uint8_t ring_queue_dequeue(RingQueue_t *q, uint8_t *data) {if(ring_queue_is_empty(q)) return 1;*data = q->buffer[q->head];q->head = (q->head + 1) % RING_BUF_SIZE;q->size--;return 0;}/*** @brief 获取队列中当前数据长度*/uint16_t ring_queue_get_size(RingQueue_t *q) {return q->size;}
四、串口驱动+环形队列对接
在串口中断里面,将接收到的字节压入队列,不做任何解析处理,保证中断函数的执行时间极短。
串口中断服务函数
// 串口接收中断服务函数(平台相关,仅示例)void USART1_IRQHandler(void) {uint8_t ch;// 判断是否为接收中断if(USART_GetITStatus(USART1, USART_IT_RXNE) != RESET) {ch = USART_ReceiveData(USART1); // 读取串口数据ring_queue_enqueue(&uart_rx_queue, ch); // 数据入队}}
五、自定义协议的解析函数
主程序的大循环从环形队列里面读取数据,按照自定义协议进行解析,自动识别完整的数据帧,处理粘包和分包问题。
1、协议解析配置
// 协议定义#define FRAME_HEAD1 0xAA // 帧头1#define FRAME_HEAD2 0x55 // 帧头2#define FRAME_TAIL1 0xBB // 帧尾1#define FRAME_TAIL2 0xCC // 帧尾2#define MAX_DATA_LEN 256 // 最大数据长度// 解析状态枚举typedef enum {PARSE_HEAD1, // 等待帧头1PARSE_HEAD2, // 等待帧头2PARSE_LEN, // 等待数据长度PARSE_DATA, // 接收数据内容PARSE_CHECK, // 等待校验位PARSE_TAIL1, // 等待帧尾1PARSE_TAIL2 // 等待帧尾2} ParseState_t;// 解析结构体typedef struct {ParseState_t state; // 当前解析状态uint8_t data_len; // 数据长度uint8_t data_buf[MAX_DATA_LEN]; // 数据缓存uint8_t data_cnt; // 已接收数据长度uint8_t check_sum; // 校验和} FrameParse_t;static FrameParse_t parse;
2、协议解析主函数
/*** @brief 从环形队列解析一帧完整数据* @param out_data: 输出解析后的有效数据* @param out_len: 输出有效数据长度* @return 0:解析成功 1:无完整帧/解析失败*/uint8_t frame_parse(uint8_t *out_data, uint8_t *out_len) {uint8_t ch;// 循环读取队列中的数据while(ring_queue_dequeue(&uart_rx_queue, &ch) == 0) {switch(parse.state) {// 1. 匹配帧头1case PARSE_HEAD1:if(ch == FRAME_HEAD1) {parse.state = PARSE_HEAD2;}break;// 2. 匹配帧头2case PARSE_HEAD2:if(ch == FRAME_HEAD2) {parse.state = PARSE_LEN;parse.check_sum = 0; // 重置校验和} else {parse.state = PARSE_HEAD1; // 匹配失败,重新等待帧头}break;// 3. 读取数据长度case PARSE_LEN:parse.data_len = ch;parse.data_cnt = 0;parse.state = PARSE_DATA;break;// 4. 接收数据内容(核心:不定长数据接收)case PARSE_DATA:parse.data_buf[parse.data_cnt++] = ch;parse.check_sum += ch; // 累加计算校验和if(parse.data_cnt >= parse.data_len) {parse.state = PARSE_CHECK; // 数据接收完成}break;// 5. 校验和验证case PARSE_CHECK:if(ch == parse.check_sum) {parse.state = PARSE_TAIL1;} else {parse.state = PARSE_HEAD1; // 校验失败,重置}break;// 6. 匹配帧尾1case PARSE_TAIL1:if(ch == FRAME_TAIL1) {parse.state = PARSE_TAIL2;} else {parse.state = PARSE_HEAD1;}break;// 7. 匹配帧尾2,一帧解析完成case PARSE_TAIL2:if(ch == FRAME_TAIL2) {// 复制有效数据memcpy(out_data, parse.data_buf, parse.data_len);*out_len = parse.data_len;// 重置解析状态,准备接收下一帧memset(&parse, 0, sizeof(FrameParse_t));return 0; // 解析成功}parse.state = PARSE_HEAD1;break;default:parse.state = PARSE_HEAD1;break;}}return 1; // 无完整数据帧}
六、串口数据发送函数
按照自定义协议对数据进行封装然后发送,确保收发双方的数据格式统一。
/*** @brief 按自定义协议发送串口数据* @param data: 待发送有效数据* @param len: 有效数据长度*/void uart_send_frame(uint8_t *data, uint8_t len) {uint8_t check_sum = 0;uint8_t i;// 1. 发送帧头uart_send_byte(FRAME_HEAD1);uart_send_byte(FRAME_HEAD2);// 2. 发送数据长度uart_send_byte(len);// 3. 发送数据内容 + 计算校验和for(i=0; i<len; i++) {uart_send_byte(data[i]);check_sum += data[i];}// 4. 发送校验位uart_send_byte(check_sum);// 5. 发送帧尾uart_send_byte(FRAME_TAIL1);uart_send_byte(FRAME_TAIL2);}// 底层串口单字节发送函数(平台自行实现)void uart_send_byte(uint8_t ch) {// STM32示例:USART_SendData(USART1, ch);// while(USART_GetFlagStatus(USART1, USART_FLAG_TXE) == RESET);}
七、主程序使用示例
主程序无限循环调用解析函数,对数据进行解析成功后,处理业务逻辑,非阻塞并且不丢包。
int main(void) {uint8_t rx_data[MAX_DATA_LEN];uint8_t rx_len;uint8_t test_tx_data[] = {0x01, 0x02, 0x03};// 初始化uart_init();while(1) {// 1. 解析串口数据(核心:不定长数据解析)if(frame_parse(rx_data, &rx_len) == 0) {// 解析成功,处理数据// 示例:打印接收数据 / 执行指令for(uint8_t i=0; i<rx_len; i++) {// rx_data[i] 为有效数据}}// 2. 测试发送数据// uart_send_frame(test_tx_data, sizeof(test_tx_data));}}
八、代码模板的优势
(1)数据接收与处理已经解耦,中断只做入队操作,主程序进行数据解析。
(2)通过协议长度字段,自动适配任意长度的数据,完美支持不定长数据。
(3)采用帧头+帧尾+校验位,抗干扰防粘包,解决串口数据传输问题。
(4)C语言编写的代码非常通用,无平台依赖,方便移植。
(5)资源占用极少,非常适合在MCU等嵌入式环境中使用。
九、总结
总的来说,本文提供了环形队列+自定义协议的代码模板,是串口和网络不定长数据接收的通用解决方案。
环形队列解决了异步数据缓存、丢包、阻塞等问题,自定义协议解决了不定长数据解析、粘包分包、数据校验等问题。
代码结构清晰并且可以直接移植,适配了大多数嵌入式通信场景,可以直接将代码复制到项目中使用,也可以根据业务需求扩展协议功能。
239