Fork me on GitHub

前言

大家好,我是 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方法负责移动读指针,主要是为了在解析成功一整帧之后,读指针直接前进,代表这一段字节数据处理完成,可以丢弃。这里没有做任何的数组拷贝,只移动了索引,这样也保证了环形缓冲区的高效运行。

总结一下所有的流程,大概是这样:

image

 

我们还在读写操作中加了锁,防止出现线程安全的问题。

后记

今天我们这篇文,从为什么要加Sleep开始讲起,引入了TCP粘包和半包产生问题的原因,并加入了环形缓冲区的概念。事实上环形缓冲区的设计并不复杂,主要就是固定数组+读写索引+模运算来实现循环,但它却可以有效解决粘包和半包的问题,不失为一个好的解决方案。

所以我们后续在遇到难以解释的问题时,一定要抽时间想一想问题本质可能产生的原因。诚然,Sleep可以解决当下的问题,但保不了一世。希望每位开发人员都可以从这个问题中有所感悟,锻炼自己的抽象思维和设计能力,成长为更好的自己!诸君共勉!

本文首发于我的公众号【wacky的碎碎念】,欢迎关注追更!

qrcode_for_gh_7c93e745425f_258

 

posted on 2026-09-14 09:21  Wackysoft  阅读(332)  评论(2)    收藏  举报