前言
大家好,我是 wacky。
今天这篇文章,也是源自于一个真实的探讨。曾经有朋友说,做上位机开发对接PLC的时候,有时会出现玄学,读取不到完整的数据。这时就需要加一个Thread.Sleep(30),或者一个类似的时间间隔,就完全好了,至于为什么,完全搞不清楚。
我相信应该不少人都遇到过类似的情况,包括我自己,曾经做数据采集的时候,也加过类似的代码,但是当时没想明白为什么,只是以为下位机返回数据存在延迟的现象。但是随着时间的推移和经验的积累,我认为任何问题都有迹可循,恰好近期在梳理工控系列文章的时候,想明白了这个问题,我认为有以下两种可能性:
PLC扫描周期未到
参考我们之前写的内容:.NET工控概念科普------PLC扫描周期:理解了它才算入了工控的门
PLC是存在扫描周期的概念的,如果一个扫描周期过了,但是未到下一个扫描周期,这时你刚好发送了一个读取数据的指令,那么PLC即使收到了报文,也只会返回旧数据,因为没有把最新的值给到通信缓冲区。
但是这种情况的可能性其实很低,因为我们的问题是"读不到完整的数据",而不是"一直读取的是旧数据",那么就要引入第二种可能性。
TCP存在粘包/半包
这个就是我们今天要探讨的重点了,因为TCP协议属于流式协议,它是没有报文边界的。所以这就会出现PLC发送了一包100字节,我们上位机可能会收到:
- 全部完整数据;
- 拆成两段(半包);
- 两包数据合并到一起(粘包)。
我们一般在上位机中接收数据的写法是这种:
using System;
using System.Net.Sockets;
class Program
{
static void Main(string[] args)
{
// PLC IP和端口 Modbus TCP默认502
string plcIp = "127.0.0.1";
int port = 502;
Socket socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
try
{
socket.Connect(plcIp, port);
byte[] recvBuffer = new byte[4096]; // 接收缓冲区大小
int recvLen = socket.Receive(recvBuffer);
byte[] result = new byte[recvLen];
Array.Copy(recvBuffer, result, recvLen);
string hexStr = BitConverter.ToString(result).Replace("-", " ");
socket.Close();
}
catch (Exception ex)
{
Console.WriteLine("异常:" + ex.Message);
}
Console.ReadKey();
}
}
看似没任何毛病,实际上这里面有两个很大的问题:第一个问题是socket.Receive的时候,我们读取的是socket内核缓冲区中已经到达的数据,有多少就会读多少,这个实际上不一定会填满数组;第二个问题是只处理本次读到的 recvLen 个字节,读完就结束,不会继续收剩下的数据,这样也就导致了数据可能是半包,并不全。
这也就能解释为什么我们加入了Thread.Sleep(30)后会变得正常,因为这样可以让剩下的数据全部进入了缓冲区,下一次再Receive就可以拿到完整的帧。
但是这样其实只是一个取巧的办法,并不靠谱,可能是30毫秒,也可能需要50~100毫秒,如果有网络波动、PLC负载高的情况时,这个方法可能就会失灵。而且不同的设备可能需要Sleep的时间也不同,做上位机开发时就得不停地根据不同的设备来回改,也是一件很麻烦的事。
那么究竟要如何解决粘包或者半包的问题呢?这里要引入一个概念:环形缓冲区。
环形缓冲区(Ring Buffer)
环形缓冲区又称循环缓冲区或环形队列,是一种逻辑上首尾相连的固定大小数组结构。当数据写入缓冲区末尾时,会自动回绕到开头继续写入;读取数据时也可以循环读取,形成一个"环"的效果。它遵循FIFO(先进先出)原则,保证数据按写入顺序被读取 。
环形缓冲区的设计中需要下面几个组件:
- 缓冲区数组:固定大小的连续内存空间,用于存储数据。
- 写指针:指向下一个写入的位置。
- 读指针:指向下一个读取的位置。
- 大小计数器:当前缓冲区有效字节数量。
有了上述要素后,我们在设计环形缓冲区就有了以下示例:
internal class RingBuffer
{
private readonly byte[] _buffer;
private int _readPos;
private int _writePos;
private readonly object _lockObj = new object();
public int Capacity => _buffer.Length;
public int DataLen
{
get
{
lock (_lockObj)
{
return (_writePos - _readPos + Capacity) % Capacity;
}
}
}
public RingBuffer(int capacity)
{
_buffer = new byte[capacity];
_readPos = 0;
_writePos = 0;
}
public int Write(byte[] data, int offset, int len)
{
lock (_lockObj)
{
if (len <= 0) return 0;
int free = Capacity - DataLen;
int realWrite = Math.Min(len, free);
for (int i = 0; i < realWrite; i++)
{
_buffer[_writePos] = data[offset + i];
_writePos = (_writePos + 1) % Capacity;
}
return realWrite;
}
}
public bool Peek(byte[] target, int offset, int len)
{
lock (_lockObj)
{
if (DataLen < len) return false;
int pos = _readPos;
for (int i = 0; i < len; i++)
{
target[offset + i] = _buffer[pos];
pos = (pos + 1) % Capacity;
}
return true;
}
}
public void Skip(int len)
{
lock (_lockObj)
{
len = Math.Min(len, DataLen);
_readPos = (_readPos + len) % Capacity;
}
}
}
缓冲区一次性把byte数组全部分配,全程不需要进行扩容,这样可以有效减少GC压力。
计算有效字节数量时,我们使用了(_writePos - _readPos + Capacity) % Capacity的方式去进行计算,这里的+ Capacity是为了防止_writePos < _readPos (写指针绕回了数组开头,写在前面,而读在后面),减法出现负数,取模会出错。
然后我们的Write方法负责把socket收到的字节写入环形缓存,这里会先计算空余空间free,在缓冲区满的时候,会丢弃新来的数据。然后再逐个字节写入,写完后移动写指针,%Capacity来实现环形回绕。最后会返回实际写入的字节数量,用于其他逻辑做判断。
这里我们还定义了一个Peek方法,主要是用于预览报文头,而不消费任何数据。如果数据不够,则直接返回false,保留字节,等待下一轮socket接收。这个Peek方法不会移动读指针,也不会删除缓存的数据,只有确认拿到了完整报文之后,才会调用Skip移动读指针,才算消费。
Skip方法负责移动读指针,主要是为了在解析成功一整帧之后,读指针直接前进,代表这一段字节数据处理完成,可以丢弃。这里没有做任何的数组拷贝,只移动了索引,这样也保证了环形缓冲区的高效运行。
总结一下所有的流程,大概是这样:
我们还在读写操作中加了锁,防止出现线程安全的问题。
后记
今天我们这篇文,从为什么要加Sleep开始讲起,引入了TCP粘包和半包产生问题的原因,并加入了环形缓冲区的概念。事实上环形缓冲区的设计并不复杂,主要就是固定数组+读写索引+模运算来实现循环,但它却可以有效解决粘包和半包的问题,不失为一个好的解决方案。
所以我们后续在遇到难以解释的问题时,一定要抽时间想一想问题本质可能产生的原因。诚然,Sleep可以解决当下的问题,但保不了一世。希望每位开发人员都可以从这个问题中有所感悟,锻炼自己的抽象思维和设计能力,成长为更好的自己!诸君共勉!
本文首发于我的公众号【wacky的碎碎念】,欢迎关注追更!