我是老温,一名热爱学习的嵌入式工程师,关注我,一起变得更加优秀!
对于资源有限的单片机设备之间的通信,我们通常用字节流的方式,因此经常会面临数据帧粘包和丢包的问题,以及数据域里面含有特殊字节(如帧头帧尾)的问题,经常容易导致数据值解析错误。
针对MCU硬件外设资源有限的特征,我们需要设计一套轻量级且容易解析的同学协议,通过多种机制(比如校验、重传、转义)来实现数据的高效可靠传输。
一、协议设计原则
这套通信协议应用在单片机场景下,需要兼顾“可靠性”与“轻量性”原则,核心主要解决三类问题:
(1)数据帧边界:通过固定的帧结构(帧头、帧尾)解决粘包问题;
(2)数据完整性:通过数据校验和数据重传的方式,解决丢包和错包的问题;
(3)特殊字节符:通过数据转义机制,避免数据域里面的帧头帧尾干扰数据解析。
二、数据帧结构
协议数据帧主要采用以下结构:“帧头+原始长度+转义数据+校验码+帧尾”,同时兼顾数据帧的边界识别与特殊的字节兼容。
| 帧头 | 原始长度 | 转义数据域 | 校验码 | 帧尾 |
| 0xAA | 1 | N | 1 | 0x55 |
协议各字段说明:
1、帧头+帧尾:固定的数据帧边界,用来快速定位数据帧的起止位置,解决粘包问题。
2、原始长度:用来记录未转义的业务数据字节数(0~255),用于验证数据还原后的完整性。
3、转义数据域:对含有特殊字节的原始数据进行转义后的业务数据,避免与帧头帧尾冲突。
4、校验码:原始数据的异或校验结果,确保数据完整性,用来识别错包/丢包。
核心机制详解:
1、转义机制主要用来解决特殊字节冲突的问题,定义转义字节0x7D、转义异或值0x08,规则如下:
(1)发送端转义,数据域中出现0xAA(帧头),替换为0x7D+0xAB;出现0x55(帧尾),替换为0x7D+0x54;出现0x7D(转义字节),替换为0x7D+0x7C;
(2)接收端还原,遇到0x7D时,读取下一个字节并异或0x08,还原为原始字节。
2、粘包解决机制,接收端通过逐个字节扫描的方式,匹配帧头后读取“原始数据长度”的方式,按照长度校验转义还原后的数据,仅当帧头、长度、帧尾、校验码都匹配的时候,才判定为有效包,从而避免多包粘连。
3、丢包解决机制,接收端校验码不匹配,数据还原失败的时候,向发送端回复“重传指令”,发送端维护数据重传次数(通常是3次),收到指令后重新发送对应的数据包,从而平衡数据可靠性和硬件资源的占用。
三、示例代码
提供一份参考代码,实现该通信协议的封装、发送、接收、解析,覆盖了粘包、丢包、特殊字节的冲突问题。
#include "stdint.h"#include "string.h"// 协议核心定义#define FRAME_HEAD 0xAA // 帧头#define FRAME_TAIL 0x55 // 帧尾#define ESCAPE_BYTE 0x7D // 转义字节#define ESCAPE_XOR 0x08 // 转义异或值#define MAX_DATA_LEN 255 // 最大原始数据长度#define MAX_FRAME_LEN 512 // 转义后最大帧长度#define MAX_RETRY_CNT 3 // 最大重传次数// 数据包结构体typedef struct {uint8_t data_len; // 原始数据长度uint8_t data[MAX_DATA_LEN];// 原始业务数据uint8_t check_sum; // 原始数据校验码} Packet_t;// 接收全局变量uint8_t recv_buf[MAX_FRAME_LEN] = {0}; // 接收缓冲区uint8_t recv_state = 0; // 0:未匹配帧头 1:已匹配帧头 2:解析完成uint8_t recv_idx = 0; // 接收缓冲区索引uint8_t is_escape = 0; // 转义标记// 计算原始数据异或校验码uint8_t calc_check_sum(uint8_t *data, uint8_t len) {uint8_t check_sum = 0;for (uint8_t i = 0; i < len; i++) check_sum ^= data[i];return check_sum;}// 发送端:数据转义uint8_t escape_data(uint8_t *src, uint8_t src_len, uint8_t *dst, uint8_t *dst_len) {if (src_len == 0 || src_len > MAX_DATA_LEN) return 0;uint8_t idx = 0;for (uint8_t i = 0; i < src_len; i++) {if (idx >= MAX_FRAME_LEN - 4) return 0;if (src[i] == FRAME_HEAD || src[i] == FRAME_TAIL || src[i] == ESCAPE_BYTE) {dst[idx++] = ESCAPE_BYTE;dst[idx++] = src[i] ^ ESCAPE_XOR;} else {dst[idx++] = src[i];}}*dst_len = idx;return 1;}// 接收端:数据还原uint8_t unescape_data(uint8_t *src, uint8_t src_len, uint8_t *dst, uint8_t *dst_len) {if (src_len == 0) return 0;uint8_t idx = 0, escape_flag = 0;for (uint8_t i = 0; i < src_len; i++) {if (escape_flag) {dst[idx++] = src[i] ^ ESCAPE_XOR;escape_flag = 0;} else if (src[i] == ESCAPE_BYTE) {escape_flag = 1;} else {dst[idx++] = src[i];}}if (escape_flag) return 0;*dst_len = idx;return 1;}// 模拟ACK收发(实际替换为硬件接口)uint8_t recv_ack(void) { return 0; }void send_ack(uint8_t ack) { uint8_t ack_byte = ack; }// 发送端:封装并发送数据包(含转义+重传)uint8_t send_packet(uint8_t *data, uint8_t len) {if (len > MAX_DATA_LEN) return 0;Packet_t pkt;pkt.data_len = len;memcpy(pkt.data, data, len);pkt.check_sum = calc_check_sum(data, len);// 数据转义uint8_t escaped_data[MAX_FRAME_LEN] = {0};uint8_t escaped_len = 0;if (!escape_data(pkt.data, pkt.data_len, escaped_data, &escaped_len)) return 0;// 组装帧uint8_t frame[MAX_FRAME_LEN] = {0}, frame_idx = 0;frame[frame_idx++] = FRAME_HEAD;frame[frame_idx++] = pkt.data_len;memcpy(&frame[frame_idx], escaped_data, escaped_len);frame_idx += escaped_len;frame[frame_idx++] = pkt.check_sum;frame[frame_idx++] = FRAME_TAIL;// 重传逻辑uint8_t retry_cnt = 0;while (retry_cnt < MAX_RETRY_CNT) {// HAL_UART_Transmit(&huart1, frame, frame_idx, 100); // 替换为实际发送接口if (recv_ack() == 0) return 1;retry_cnt++;}return 0;}// 接收端:逐字节解析(含还原+粘包处理)void recv_byte_handler(uint8_t byte) {switch (recv_state) {case 0: // 匹配帧头if (byte == FRAME_HEAD) {recv_state = 1;recv_buf[recv_idx++] = byte;is_escape = 0;}break;case 1: // 接收数据并处理转义if (is_escape) {recv_buf[recv_idx++] = byte ^ ESCAPE_XOR;is_escape = 0;} else if (byte == ESCAPE_BYTE) {is_escape = 1;} else {recv_buf[recv_idx++] = byte;}// 校验帧尾并解析if (recv_idx >= 4) {uint8_t origin_len = recv_buf[1];if (recv_buf[recv_idx - 1] == FRAME_TAIL) {// 还原数据并校验uint8_t escaped_len = recv_idx - 4;uint8_t origin_data[MAX_DATA_LEN] = {0}, real_len = 0;if (unescape_data(&recv_buf[2], escaped_len, origin_data, &real_len)&& real_len == origin_len) {uint8_t calc_check = calc_check_sum(origin_data, real_len);if (calc_check == recv_buf[2 + escaped_len]) {recv_state = 2;send_ack(0); // 解析成功,发送确认} else {send_ack(1); // 校验失败,请求重传}} else {send_ack(1); // 还原失败,请求重传}} else {send_ack(1); // 帧尾错误,请求重传}// 重置状态recv_state = 0;recv_idx = 0;is_escape = 0;memset(recv_buf, 0, sizeof(recv_buf));}break;default: // 异常重置recv_state = 0;recv_idx = 0;is_escape = 0;memset(recv_buf, 0, sizeof(recv_buf));break;}}// 提取解析成功的数据包uint8_t get_packet(uint8_t *out_data, uint8_t *out_len) {if (recv_state == 2) {uint8_t origin_len = recv_buf[1];uint8_t escaped_len = recv_idx - 4;unescape_data(&recv_buf[2], escaped_len, out_data, out_len);recv_state = 0;return 1;}return 0;}
四、协议优势
该通信协议的优势如下:
(1)轻量高效:转义和校验均为简单的bit位运算,协议的解析没有复杂的计算逻辑,适配8位/32位单片机。
(2)兼容性强:转义机制解决了数据域特殊字节的冲突,帧结构保留了原始的长度字段,确保协议解析的时候无歧义。
(3)可靠性高:结合帧头帧尾的边界识别(解决粘包问题),校验+重传(解决丢包问题),提高协议通信的可靠性。
(4)可扩展:可以在数据域增加数据包的序号,从而适配多包连续传输的场景。
五、总结
(1)MCU字节流通信协议帧的核心结构是“帧头+原始长度+校验码+帧尾”的基础结构,附加转义机制来解决特殊字节之间的冲突。
(2)通过“帧头帧尾+校验码”来解决粘包问题;通过“有限重传+校验码”来解决丢包问题;通过“转义+还原”来解决特殊字节冲突问题。
(3)协议设计需要适配MCU的硬件资源特性,优先选择轻量级算法(比如异或校验),避免过度消耗单片机有限的算力和内存资源。
382