前言
写串口助手、上位机等与串口打交道的程序的时候,可能会遇到这么一个问题:串口数据“被“拆包”(“粘包”也是一样的原因)。
这种情况多发生在使用“串口转 USB ”线材的时候,我猜测与转换芯片或者硬件电路之类的有很大的关系。
因为用同一个串口助手软件,“ RS485/232 转 USB ”的线材会导致数据被“拆包”,而“ TTL 转 USB ”的线材则不会(我的实验现象)。
“拆包”现象如下图所示,下位机一次性向串口发送了 10 个字节的数据,但是串口助手(或者上位机)却分两次接收,实际上是读了两次缓冲区。为了使肉眼可见,下图放慢了速度:
上图只是演示效果,实际串口助手做了延时,很大程度上避免了这个问题
这种现象导致本来完整的数据被拆成了两段,会对高速数据采集、处理产生困扰,那么出现这种问题的时候,应该怎么处理呢?
产生这种现象的原因是接收事件触发了两次(或者多次),但这并不是.Net 里 serialPort 类的问题。
我总结了三个解决办法:
- 按照字节数将数据拼接起来 (简单易用,但是适用面窄,而且不靠谱)
- 在接收事件里加延时 (简单易用,但是不能照顾所有的情况)
- 使用队列 (适用所有的情况,但是代码稍微多一点点,使用条件多)
一、按照字节数将数据拼接起来
将数据拼接起来,只适用一种长度的数据,如果要接收多种长度的数据,就无法使用,所以说适用面窄。如果多种数据之间有很明显的差别,还是可以区分开然后各自拼接。
这个方案不靠谱的原因是,如果数据在传输的过程中受到外部电磁等干扰,出现丢失或者增多,长度条件永远不会满足,后面就再也“收不到”数据了。
下面演示 10 个字节一个包的接收方法(只演示 16 进制接收,ASCLL 接收方法是一样的):
static string rec_data = ""; //静态变量 private void serialPort_DataReceived(object sender, SerialDataReceivedEventArgs e) { try { //实例化相应长度的数组 byte[] ReDatas = new byte[serialPort.BytesToRead]; serialPort.Read(ReDatas, 0, ReDatas.Length); StringBuilder sb = new StringBuilder(); for (int i = 0; i < ReDatas.Length; i++) { sb.AppendFormat("{0:x2}" + " ", ReDatas[i]); } //将数据拼接起来 rec_data = rec_data + sb.ToString().ToUpper(); //如果数据长度满足条件 if (rec_data.Replace (" ","").Length == 20) { textBox_Received.AppendText(rec_data); rec_data = ""; //记得清除 } sb.Clear(); } catch(Exception message) { MessageBox.Show(message .ToString ()); return; } }
二、在接收事件里加延时
在接收事件里加延时,原理是等待下位机的数据完全传输到缓冲区后,再一次性读取,这样就能得到完整的数据。
这个解决方案的关键是延时时间长短,延时时间要根据波特率、数据长度而定。波特率越高、数据长度越短,延迟时间就可以越短。后面会附上常用波特率下的延时时间。
下面演示加延时方案,
private void serialPort_DataReceived(object sender, SerialDataReceivedEventArgs e)
{
try
{
Thread.Sleep(2); //延时2ms
//实例化相应长度的数组
byte[] ReDatas = new byte[serialPort.BytesToRead];
serialPort.Read(ReDatas, 0, ReDatas.Length);//读取数据
StringBuilder sb = new StringBuilder();
for (int i = 0; i < ReDatas.Length; i++)
{
sb.AppendFormat("{0:x2}" + " ", ReDatas[i]);
}
textBox_Received.AppendText(sb.ToString().ToUpper()); //十六进制默认大写吧
sb.Clear();
}
catch(Exception message)
{
MessageBox.Show(message .ToString ());
return;
}
}
常见波特率延时时间参考表
2400波特率 | 9600波特率 | 115200波特率 | |
1字节 | 4.17ms | 1.04ms | 0.087ms |
5字节 | 20.85ms | 5.2ms | 0.44ms |
10字节 | 41.7ms | 10.4ms | 0.88ms |
20字节 | 83.4ms | 20.8ms | 1.76ms |
计算公式:延时时间(单位:毫秒) = 字节数 / (波特率/10/1000) |
注:
1,上面的公式中,波特率除以 10,而没有除以 8 ,是因为串口数据在发送的时候还会有起始位、结束位,这个时间不能忽略。如果数据传输还包含校验位等信息,则要除以比 10 更大的数。
2,公式只是大致的推算,只能给出延时时间的大致长度,具体延时时间还需要具体调试,因为下位机或者其他设备存在反应时间等其他特性。
三、使用队列
使用队列解决串口数据 “拆包” 问题,优点有:
- 适应所有的波特率、所有的数据长度
- 适应所有数据格式
- 无惧数据丢失或异常
使用队列解决串口数据 “拆包” 的前提条件是串口数据有明确的规律,以便于将数据正确的拆、合。一般情况下只有上位机软件可以使用这个方案。
比如某设备不停的向上位机监控软件发送温度数据,以 16 进制发送,数据格式为 : “ 2E + 1 字节地址 + 1 字节关键字 + 3字节数据位 + 1 字节和校验位 ” 2E 00 AA 01 11 23 63 “2E” 代表数据起始,“00” 表示设备地址为0, “AA” 表示这段数据是温度,“01” 表示温度数据为正数,“11 23” 表示温度为 11.23 度,“63” 是校验位。
类似这样的情况,就可以使用队列。接收事件收到数据就往队列里添加,数据处理线程从队列里取数据,取到 “2E” 就作为数据开始,直到取到下一个 “2E” ,就得到了一个完整的数据段,然后再验证校验确保数据正确。
由以上的例子可以看出,地址位、数据位、校验位不能出现 “2E” ,否则就会错误处理至少 2 个数据段,当然还可以有更好的办法解决,比如以 “2E” + 地址 两个字节作为起始标志,以减少这种情况造成的干扰,甚至可以用 “2E” + 地址 + 关键字 三个字节作为起始。
这个方案我就不贴代码了,因为不具有通用性。总之,使用队列解决 “拆包” 问题的核心思路就是串口数据要有规律可循,只要从队列取数据,根据规律识别出数据段即可。
最后附上我写的一个串口助手,简单易用,界面简洁,功能丰富,稳定靠谱。部分功能:
- 自定义波特率
- 定时存储
- 定时发送
- 接收频率显示
- HEX 自动换行
- 多条循环发送
- 和校验、RCR校验、LRC校验计算器
- 退出参数保存
- 一键恢复默认设置
附件:iCOM串口调试助手
提取码:k25f
2018/3/16 更新
修复多条发送最后一条指令重复发送的bug,最新版本号:2.0.9
2018/6/13 更新
新增自动换行延迟时间选择,更好的适应自动换行的不同情况。最新版本号:2.1.1
2018/10/30 更新
新增多条发送可选只循环一次。最新版本号:2.1.2
2019/5/2 更新
修复重启后,ASCII发送的情况下“发送新行”未使能的bug;
界面显示更多信息;
新增自动存储文件名自定义
新增版本更新,再也不用担心新版本从哪里找啦! (o゜▽゜)o☆
最新版本号:2.1.6
请问第三个方案(使用队列),能提供一下基本的代码吗?
受益非浅
谢谢支持!
博主的串口助手的确不错 功能很全 特别是定时存储的小加载条 细节到位
持续关注!
开心 (≧∀≦)ゞ
网上下载您的大作,使用起来有两点不顺手。
1,您的串口波特率不支持我们高速率的自定义,3000000即3M速率,有些设备还有5M的通信速率
2,存储的文件可以设置名称么?还是暂不支持。
希望大神们,继续更新,我也会持续关注您的博客。里面硬货不少,再次感谢作者的无私奉献。
感谢亲的支持!快试试最新版本2.1.6吧,可以自定义存储文件名了,但是高波特率还没能解决。
学习了