Fork me on GitHub

前言

大家好,我是 wacky。

书接上回,我们上回探讨了如何解决TCP粘包和半包的问题,并在最后引入了环形缓冲区的概念。虽然环形缓冲区主要是通过固定数组+读写索引+模运算来实现循环,但是实际开发过程中,还存在手动管理 offset、count,数组扩容、数据移动,容易越界、内存拷贝多等一系列的问题。

而实际上在.NET中,依然有更简洁高效的API来代替手写环形缓冲区,它就是Pipelines管道。如果还想回顾一下手写环形缓冲区的概念,可以从传送门出发:.NET上位机踩坑:为什么有时读取数据需要Sleep?

Pipelines管道

命名空间为System.IO.Pipelines,它是.NET内置的高性能内存缓冲组件。之前在和一些技术大佬聊环形缓冲区的过程中,他们很多人已经把这个组件用于实际生产环境中了,那我们今天就来讲一讲,在解决Modbus协议TCP粘包和半包的问题中这个组件要怎么用。

(来自公众号:wacky的碎碎念,违者必究)

它主要包含以下几部分内容:

  1. Pipe:内置一对PipeWriter 和PipeReader,Writer 负责往管道塞网络收到的数据;Reader 负责从管道读取、解析报文。Pipe 内部自带环形缓冲。
  2. Socket 异步接收:用Socket.ReceiveAsync 持续接收 PLC 下发的字节流,写入PipeWriter。TCP 是字节流,没有边界,这一步只管收字节,不关心是不是完整帧。
  3. PipeReader 循环解析:
  • ReadAsync():从管道拿一段可用内存(无需拷贝,直接内存切片)
  • 尝试在这段内存里查找完整 ModbusTCP 帧(MBAP 头固定 7 字节:事务 ID (2)+ 协议 ID (2)+ 长度 (2)+ 单元 ID (1),后面跟 N 个功能码数据)
  • 如果找到完整帧:AdvanceTo 标记已经消费掉的字节,交给业务处理;剩下半包留在管道缓冲区,下次继续解析
  • 如果不够一帧:停止解析,等待后续 Socket 继续收到数据写入管道
4. 断开 / 异常:Complete PipeReader/PipeWriter,释放资源。

实例

现在我们来结合C#实例继续讲解

internal class ModbusTcpPipelinesClient
    {
        private readonly string _ip;
        private readonly int _port;
        private Socket _socket;
        private Pipe _pipe;
        private Task _readTask;
        private Task _writeTask;
        private CancellationTokenSource _cts;


        // 收到完整Modbus报文回调,上位机在这里解析数据
        public Action<byte[]> OnModbusFrameReceived { get; set; }


        public ModbusTcpPipelinesClient(string ip, int port = 502)
        {
            _ip = ip;
            _port = port;
        }


        public async Task ConnectAsync()
        {
            _cts = new CancellationTokenSource();
            _socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
            await _socket.ConnectAsync(_ip, _port, _cts.Token);
            _pipe = new Pipe(new PipeOptions(pauseWriterThreshold: 4096, resumeWriterThreshold: 2048));


            // 两个独立任务:1. Socket收数据写入PipeWriter;2. PipeReader解析报文
            _writeTask = FillPipeFromSocketAsync(_socket, _pipe.Writer, _cts.Token);
            _readTask = ParsePipeFramesAsync(_pipe.Reader, _cts.Token);
        }


        // 从Socket接收字节,写入PipeWriter(相当于填充缓冲区)
        private async Task FillPipeFromSocketAsync(Socket socket, PipeWriter writer, CancellationToken ct)
        {
            try
            {
                while (!ct.IsCancellationRequested)
                {
                    // 获取一块内存,最小分配512字节,可根据PLC调大小
                    Memory<byte> memory = writer.GetMemory(512);
                    int readLen = await socket.ReceiveAsync(memory, SocketFlags.None, ct);
                    if (readLen == 0) break; // 远端关闭连接


                    writer.Advance(readLen); // 告诉writer实际收到多少字节
                    FlushResult flushResult = await writer.FlushAsync(ct);
                    if (flushResult.IsCompleted) break;
                }
            }
            catch (Exception ex)
            {
                Console.WriteLine($"接收异常:{ex.Message}");
            }
            finally
            {
                writer.Complete();
            }
        }


        // PipeReader 循环解析Modbus TCP报文(核心,替代环形缓冲区解析)
        private async Task ParsePipeFramesAsync(PipeReader reader, CancellationToken ct)
        {
            try
            {
                while (!ct.IsCancellationRequested)
                {
                    ReadResult result = await reader.ReadAsync(ct);
                    ReadOnlySequence<byte> buffer = result.Buffer;
                    SequencePosition? consumedPos = null;


                    // 循环解析缓冲区里所有完整Modbus帧(处理粘包:一次多个报文)
                    while (TryParseModbusFrame(buffer, out var frame, out var consumed))
                    {
                        OnModbusFrameReceived?.Invoke(frame.ToArray());
                        consumedPos = consumed;
                        buffer = buffer.Slice(consumed); // 切掉已经解析完的数据
                    }


                    // AdvanceTo:标记消费位置、查看位置;Pipelines自动回收内存
                    reader.AdvanceTo(consumedPos ?? buffer.Start, buffer.End);


                    if (result.IsCompleted) break;
                }
            }
            catch (Exception ex)
            {
                Console.WriteLine($"解析异常:{ex.Message}");
            }
            finally
            {
                reader.Complete();
            }
        }


        /// <summary>
        /// 尝试从ReadOnlySequence解析ModbusTCP帧
        /// MBAP: 7字节 [TransId(2)+ProtoId(2)+Len(2)+UnitId(1)] + PDU
        /// </summary>
        private bool TryParseModbusFrame(in ReadOnlySequence<byte> seq, out ReadOnlySequence<byte> frame, out SequencePosition consumed)
        {
            frame = default;
            consumed = seq.Start;
            if (seq.Length < 7) return false; // 不足MBAP头,半包,等待更多数据


            // 读取MBAP第5、6字节:PDU长度
            var headerReader = new SequenceReader<byte>(seq);
            headerReader.Advance(4);
            headerReader.TryReadBigEndian(out short pduLen);
            int totalFrameLen = 7 + pduLen;


            if (seq.Length < totalFrameLen) return false; // 收到的数据不够完整报文,等待


            frame = seq.Slice(0, totalFrameLen);
            consumed = seq.GetPosition(totalFrameLen);
            return true;
        }


        // 发送Modbus请求
        public async Task SendAsync(byte[] data)
        {
            if (_socket == null || !_socket.Connected) throw new InvalidOperationException("未连接PLC");
            int sendTotal = 0;
            while (sendTotal < data.Length)
            {
                int sent = await _socket.SendAsync(data.AsMemory(sendTotal), SocketFlags.None);
                sendTotal += sent;
            }
        }


        public async Task CloseAsync()
        {
            _cts?.Cancel();
            try
            {
                await Task.WhenAll(_readTask, _writeTask);
            }
            catch { }
            _socket?.Close();
            _pipe = null;
        }
    }

(来自公众号:wacky的碎碎念,违者必究)

在上述代码段中,我们把写入和解析作为2个异步方法分开来执行,用于职责分离。

socket在异步拿到数据后,会告诉PipeWriter实际收到了多少字节,然后把数据提交给PipeReader用于读取。在这一步,socket的职责只有把TCP字节流输入管道中,不做任何的协议解析。

而在解析的方法中,只负责拿出管道中当前可用的所有数据进行解析,这里引入了ReadOnlySequence的概念,这个类型是代表跨多个内存块的连续逻辑字节流,不需要合并数组。其中又包含一个我们自定义的TryParseModbusFrame方法,用于解析ModbusTCP帧。

在TryParseModbusFrame方法内部,我们处理了半包或者粘包的场景:

由于一个完整的MBAP(Modbus TCP)报文头的长度是7字节,因此我们在不足7字节的时候,直接return false,判断此为半包,不会继续进行解析。

然后我们会计算整个帧的总长度totalFrameLen = 7 + pduLen,这里我们会继续判断收到的数据是否为完整的报文长度,如果不足,那么依然return false,继续等待数据完整后,将buffer切片,输出给调用方。

最后我们通过AdvanceTo来标记消费位置、查看位置,用于下次继续从指定的位置继续读取,这样我们就可以有效解决TCP存在粘包和半包的问题。

后记

现代编程语言API的进化总是让人欣喜,直接引入Pipelines就可以避免手写RingBuffer存在的很多问题,可谓是大大提升了效率。但是我们依然还是需要"知其然,知其所以然",明白了存在什么问题,再去根据问题的本质进行解决,懂得怎么解决之后,再做改进,各位不知今天看懂了吗?下一期,我们再见,诸君共勉。

posted on 2026-10-08 09:24  Wackysoft  阅读(48)  评论(0)    收藏  举报