第 2 节
二、上下位机串口通信协议(数据打包+解析)
以下为旧版代码内容:
/*
针对较为复杂的一种通信场景进行总结,需要发送变长的数据帧。其他场景可在此基础上进行简化。为了发送变长数据帧,使用 帧头+帧长+命令字节+数据字节+校验字节+帧尾 的格式对数据包进行打包,其实帧头和帧长已经足够解决帧对齐问题,帧尾可以去掉,为了适应更复杂的情况,这里保留帧尾。
帧头:使用 0xA5,0x5A两个字节作为帧头,二进制位0与1的个数相同,分布均匀不易出错。
帧长:根据数据帧实际长度确定帧长字节,这里只使用1个字节,故帧长字节最大为255,为提高利用率,规定帧长字节描述的是数据字节的长度,故应重新命名为数据长度字节。
命令字节:利用命令字节指定数据字节的功能,例如命令字节为1表示传输温度,为2表示传输湿度等,1字节命令+n字节数据是工业中比较常用的一种格式。可以不使用
数据字节:数据字节长度可变,帧长字节为0,表示没有数据,帧长字节为255,表示有255字节数据。
校验字节:比较简单的一种校验方式为和校验,即把校验字节前的所以字节求和,最后保留低8位作为校验字节。在MODBUS协议中常用CRC16循环冗余校验方式,将校验字节前的所以字节加入计算,得到两字节CRC16校验码,采用此方式。
帧尾:与帧头相似,这里使用0xFF作为帧尾。
帧头 数据长度 命令 数据 CRC16校验 帧尾
A5 5A XX XX … XX …XX XX FF
2字节 1字节 1字节 n字节 … 2字节 1字节
*/
//<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<< 发送方操作 >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
/* 自己写的地方 1 */
void SendByte(uint8_t date)
{
//在这里写一个发送一个字节的函数(上位机可以是用Boost库写的)
//假如要发送一条8字节的数据,调用Send_Cmd_Data()函数则对应SendByte函数会被调用8次
}
void Send(const uint8_t *data,uint8_t len)
{
uint8_t i;
for (i = 0; i < len; i++)
{
SendByte(data[i]);//发送一个字节
}
}
//16位循环冗余校验
uint16_t CRC16_Check(const uint8_t *data,uint8_t len)
{
uint16_t CRC16 = 0xFFFF;
uint8_t state,i,j;
for(i = 0; i < len; i++ )
{
CRC16 ^= data[i];
for( j = 0; j < 8; j++)
{
state = CRC16 & 0x01;
CRC16 >>= 1;
if(state)
{
CRC16 ^= 0xA001;
}
}
}
return CRC16;
}
//发送数据包
void Send_Cmd_Data(uint8_t cmd,const uint8_t *datas,uint8_t len)
{
uint8_t buf[300],i,cnt=0;
uint16_t crc16;
buf[cnt++] = 0xA5;
buf[cnt++] = 0x5A;
buf[cnt++] = len;
buf[cnt++] = cmd;
for(i=0;i<len;i++)
{
buf[cnt++] = datas[i];
}
crc16 = CRC16_Check(buf,len+4);
buf[cnt++] = crc16>>8;
buf[cnt++] = crc16&0xFF;
buf[cnt++] = 0xFF;
Send(buf,cnt);//调用数据帧发送函数将打包好的数据帧发送出去
}
//<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<< 监听方解析 >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
/* 自己写的地方 2 */
// 数据帧解析 自己写
void Data_Analysis(uint8_t cmd, const uint8_t* datas, uint8_t len)
{
switch (cmd)
{
case 0x00:
// 解析命令1的数据
break;
case 0x01:
// 解析命令2的数据
break;
// 添加更多命令的解析逻辑...
default:
// 未知命令,进行错误处理或者忽略
std::cerr << "Unknown command: " << cmd << std::endl;
break;
}
}
// 接收数据 不需要改动
void Receive(uint8_t bytedata)
{
static uint8_t step = 0; // 状态变量初始化为0 在函数中必须为静态变量
static uint8_t cnt = 0, Buf[300], len, cmd, * data_ptr;
static uint16_t crc16;
// 进行数据解析 状态机
switch (step)
{
case 0: // 接收帧头1状态
if (bytedata == 0xA5)
{
step++;
cnt = 0;
Buf[cnt++] = bytedata;
}
break;
case 1: // 接收帧头2状态
if (bytedata == 0x5A)
{
step++;
Buf[cnt++] = bytedata;
}
else if (bytedata == 0xA5)
{
step = 1;
}
else
{
step = 0;
}
break;
case 2: // 接收数据长度字节状态
step++;
Buf[cnt++] = bytedata;
len = bytedata;
break;
case 3: // 接收命令字节状态
step++;
Buf[cnt++] = bytedata;
cmd = bytedata;
data_ptr = &Buf[cnt]; // 记录数据指针首地址
if (len == 0)
step++; // 数据字节长度为0则跳过数据接收状态
break;
case 4: // 接收len字节数据状态
Buf[cnt++] = bytedata;
if (data_ptr + len == &Buf[cnt]) // 利用指针地址偏移判断是否接收完len位数据
{
step++;
}
break;
case 5: // 接收crc16校验高8位字节
step++;
crc16 = bytedata;
break;
case 6: // 接收crc16校验低8位字节
crc16 <<= 8;
crc16 += bytedata;
if (crc16 == CRC16_Check(Buf, cnt)) // 校验正确进入下一状态
{
step++;
}
else if (bytedata == 0xA5)
{
step = 1;
}
else
{
step = 0;
}
break;
case 7: // 接收帧尾
if (bytedata == 0xFF) // 帧尾接收正确
{
Data_Analysis(cmd, data_ptr, len); // 数据解析
step = 0;
}
else if (bytedata == 0xA5)
{
step = 1;
}
else
{
step = 0;
}
break;
default:
step = 0;
break; // 多余状态 正常情况下不可能出现
}
}
上面是初始的串口通信协议。
由于上位机在封装串口通信实现时,采用的是面向对象的原则,使用C++类Class对串口通信基础功能及拓展功能进行的封装,故在调用的时候,也采用的使用对象调用的方式。
在该串口通信协议的使用上函数参数,我进行了轻微的修改,如下⬇:
/*
需要发送变长的数据帧
帧头:使用 0xA5,0x5A两个字节作为帧头
帧长:根据数据帧实际长度确定帧长字节,这里只使用1个字节
命令字节:利用命令字节指定数据字节的功能
数据字节:数据字节长度可变,帧长字节为0 表示没有数据,帧长字节为255,表示有255字节数据
校验字节:在MODBUS协议中常用CRC16循环冗余校验方式 将校验字节前的所以字节加入计算,得到两字节CRC16校验码,本文采用此方式
帧尾:这里使用0xFF作为帧尾。
帧头 数据长度 命令 数据 CRC16校验 帧尾
A5 5A XX XX …XX …XX XX FF
2字节 1字节 1字节 n字节 … 2字节 1字节
*/
#include"Task_Date_pack.h"
//<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<< 发送方解析 >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
/* 自己写的地方 1 */
// 发送一个字节数据 自己写的函数
void SendByte(uint8_t date, SerialIF& port)
{
port.write(date);
}
// 发送定长数据 不需要改动
void Send(const uint8_t* data, uint8_t len, SerialIF& port)
{
uint8_t i;
for (i = 0; i < len; i++)
{
SendByte(data[i], port); // 发送一个字节
}
}
// 16位循环冗余校验 不需要改动
uint16_t CRC16_Check(const uint8_t* data, uint8_t len)
{
uint16_t CRC16 = 0xFFFF;
uint8_t state, i, j;
for (i = 0; i < len; i++)
{
CRC16 ^= data[i];
for (j = 0; j < 8; j++)
{
state = CRC16 & 0x01;
CRC16 >>= 1;
if (state)
{
CRC16 ^= 0xA001;
}
}
}
return CRC16;
}
// 发送数据包 不需要改动
void Send_Cmd_Data(uint8_t cmd, const uint8_t* datas, uint8_t len, SerialIF& port)
{
uint8_t buf[300], i, cnt = 0;
uint16_t crc16;
buf[cnt++] = 0xA5;
buf[cnt++] = 0x5A;
buf[cnt++] = len;
buf[cnt++] = cmd;
for (i = 0; i < len; i++)
{
buf[cnt++] = datas[i];
}
crc16 = CRC16_Check(buf, len + 4);
buf[cnt++] = crc16 >> 8;
buf[cnt++] = crc16 & 0xFF;
buf[cnt++] = 0xFF;
Send(buf, cnt, port); // 调用数据帧发送函数将打包好的数据帧发送出去
}
//<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<< 监听方解析 >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
/* 自己写的地方 2 */
// 数据帧解析 自己写
void Data_Analysis(uint8_t cmd, const uint8_t* datas, uint8_t len)
{
switch (cmd)
{
case 0x00:
// 解析命令1的数据
break;
case 0x01:
// 解析命令2的数据
break;
// 添加更多命令的解析逻辑...
default:
// 未知命令,进行错误处理或者忽略
std::cerr << "Unknown command: " << cmd << std::endl;
break;
}
}
// 接收数据 不需要改动
void Receive(uint8_t bytedata)
{
static uint8_t step = 0; // 状态变量初始化为0 在函数中必须为静态变量
static uint8_t cnt = 0, Buf[300], len, cmd, * data_ptr;
static uint16_t crc16;
// 进行数据解析 状态机
switch (step)
{
case 0: // 接收帧头1状态
if (bytedata == 0xA5)
{
step++;
cnt = 0;
Buf[cnt++] = bytedata;
}
break;
case 1: // 接收帧头2状态
if (bytedata == 0x5A)
{
step++;
Buf[cnt++] = bytedata;
}
else if (bytedata == 0xA5)
{
step = 1;
}
else
{
step = 0;
}
break;
case 2: // 接收数据长度字节状态
step++;
Buf[cnt++] = bytedata;
len = bytedata;
break;
case 3: // 接收命令字节状态
step++;
Buf[cnt++] = bytedata;
cmd = bytedata;
data_ptr = &Buf[cnt]; // 记录数据指针首地址
if (len == 0)
step++; // 数据字节长度为0则跳过数据接收状态
break;
case 4: // 接收len字节数据状态
Buf[cnt++] = bytedata;
if (data_ptr + len == &Buf[cnt]) // 利用指针地址偏移判断是否接收完len位数据
{
step++;
}
break;
case 5: // 接收crc16校验高8位字节
step++;
crc16 = bytedata;
break;
case 6: // 接收crc16校验低8位字节
crc16 <<= 8;
crc16 += bytedata;
if (crc16 == CRC16_Check(Buf, cnt)) // 校验正确进入下一状态
{
step++;
}
else if (bytedata == 0xA5)
{
step = 1;
}
else
{
step = 0;
}
break;
case 7: // 接收帧尾
if (bytedata == 0xFF) // 帧尾接收正确
{
Data_Analysis(cmd, data_ptr, len); // 数据解析
step = 0;
}
else if (bytedata == 0xA5)
{
step = 1;
}
else
{
step = 0;
}
break;
default:
step = 0;
break; // 多余状态 正常情况下不可能出现
}
}建议深入了解一下该代码的运行过程:
对于接收方,入口函数是void Receive(uint8_t bytedata);
对于发送方,入口函数是void Data_Analysis(uint8_t cmd, const uint8_t* datas, uint8_t len)
打包解包过程是根据,头帧[A5] [5A],命令帧[cmd],数据帧长度[len],CRC16校验位[xx] [xx],尾帧[FF],来判断数据格式是否正确的;根据命令帧、数据帧长度、CRC16校验来判断数据帧数据是否正确的。其中如果有任何一项不正确,数据都不会被打包或解析。
【扩充】CRC校验原理
https://www.bilibili.com/video/BV1V4411Z7VA?vd_source=2a70e8cbccfb71570f2b97be1853e0df
在串口通信协议的使用上,后续我将其发送监听的解析部分,拆分直接融入串口通信类的封装中,避免头文件循环调用导致的接口过多,难以维护的问题



