在资源有限的单片机设备间进行数据交换时,我们通常采用最基础的字节流方式通信。这种方式直接、简单,但也带来了几个典型的痛点:数据帧的“粘包”和“丢包”问题如何处理?数据域中如果恰好包含了与帧头、帧尾相同的特殊字节,又该如何避免解析错误?
为了应对MCU硬件资源(如RAM、ROM、算力)有限的特点,我们需要设计一套足够轻量且易于解析的通信协议。它需要通过校验、重传、转义等多种机制,在有限的资源开销下实现高效且可靠的数据传输。

一、协议设计原则
这套专为单片机场景设计的通信协议,核心在于平衡“可靠性”与“轻量性”,主要解决以下三类问题:
- 数据帧边界:通过固定的帧结构(帧头、帧尾)来解决粘包问题。
- 数据完整性:通过数据校验和数据重传机制,来解决丢包和错包问题。
- 特殊字节符:通过数据转义机制,避免数据域中的有效数据与帧头帧尾等控制字符冲突,保证解析无误。
二、数据帧结构
协议数据帧采用了“帧头 + 原始长度 + 转义数据 + 校验码 + 帧尾”的结构,兼顾了帧边界识别与特殊字节兼容。
| 帧头 |
原始长度 |
转义数据域 |
校验码 |
帧尾 |
| 0xAA |
1 Byte |
N Bytes |
1 Byte |
0x55 |
协议各字段说明:
- 帧头 + 帧尾:固定的数据帧边界,用于快速定位数据帧的起始和结束位置,是解决粘包的基础。
- 原始长度:记录未转义前的业务数据字节数(0~255),用于验证数据还原后的完整性。
- 转义数据域:对含有特殊字节的原始业务数据进行转义处理后的数据,避免其与帧头帧尾冲突。
- 校验码:原始数据的异或(XOR)校验结果,用于确保数据完整性,识别错包或丢包。
核心机制详解:
-
转义机制
为解决特殊字节冲突,定义转义字节为 0x7D,转义异或值为 0x08。
- 发送端(转义):在数据域中,若出现
0xAA(帧头),则替换为 0x7D, 0xAB;若出现 0x55(帧尾),则替换为 0x7D, 0x54;若出现 0x7D(转义字节本身),则替换为 0x7D, 0x7C。
- 接收端(还原):遇到
0x7D 时,读取下一个字节并与 0x08 进行异或运算,即可还原为原始字节。
-
粘包解决机制
接收端通过逐个字节扫描来匹配帧头,匹配成功后读取“原始数据长度”字段。随后,按照该长度校验转义还原后的数据。只有当帧头、长度、帧尾、校验码全部匹配时,才判定为一个有效数据包,从而有效避免多个包粘连在一起的情况。
-
丢包解决机制
当接收端发现校验码不匹配或数据还原失败时,会向发送端回复“重传指令”。发送端维护一个数据重传计数器(通常设为3次),收到重传指令后,会重新发送对应的数据包。这种机制在保证数据可靠性的同时,也兼顾了硬件资源的有限性。
三、示例代码
以下是一份参考性的C语言实现代码,涵盖了该通信协议的封装、发送、接收与解析,完整应对了粘包、丢包及特殊字节冲突问题。
#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;
}
四、协议优势
该轻量级通信协议具备以下几项核心优势:
- 轻量高效:转义和校验均采用简单的位运算,解析逻辑不涉及复杂计算,完美适配8位或32位单片机。
- 兼容性强:转义机制彻底解决了数据域内特殊字节的冲突问题,帧结构中保留的原始长度字段确保了协议解析时无二义性。
- 可靠性高:结合帧头帧尾的边界识别(解决粘包)、校验与重传机制(解决丢包),显著提升了通信过程的可靠性。
- 可扩展性好:可在数据域中方便地加入数据包序号等字段,轻松适配需要连续传输多个数据包的场景。
五、总结
回顾整个设计,我们可以提炼出几个关键点:
- MCU字节流通信协议帧的核心是“帧头+原始长度+校验码+帧尾”的基础结构,并通过附加的转义机制来解决特殊字节冲突。
- 通过“帧头帧尾+长度校验”解决粘包问题;通过“有限次重传+校验码”解决丢包问题;通过“转义与还原”解决特殊字节冲突问题。
- 协议设计必须时刻考虑MCU的硬件资源特性,优先选择异或校验这类轻量级算法,避免过度消耗单片机有限的算力和内存。
希望这份关于轻量级嵌入式通信协议的设计与实现详解,能为你带来启发。更多嵌入式开发、系统设计相关的深度讨论和资源分享,欢迎在云栈社区与广大开发者一同交流。