diff --git a/src/MAS.Communication.csproj b/src/MAS.Communication.csproj
index 3dc8356..4039352 100644
--- a/src/MAS.Communication.csproj
+++ b/src/MAS.Communication.csproj
@@ -7,9 +7,9 @@
True
../../build/APIDocumentation/MAS.Communication.xml
- 1.1.0
- 1.1.0.0
- 1.1.0.0
+ 1.1.1
+ 1.1.1.0
+ 1.1.1.0
MAS(厦门威光)研发部
MAS(厦门威光)
Copyright (c) MAS(厦门威光) Corporation. All rights reserved.
diff --git a/src/McProtocol/Commands/McCommand.cs b/src/McProtocol/Commands/McCommand.cs
index afbe585..b20dceb 100644
--- a/src/McProtocol/Commands/McCommand.cs
+++ b/src/McProtocol/Commands/McCommand.cs
@@ -112,7 +112,10 @@ public int SetResponse(byte[] response) {
case McFrame.MC1E:
min = 2;
if (min > response.Length) {
- break;
+ // 帧长不足帧头即为残帧,抛出明确异常而不是静默返回空 Response,
+ // 避免上层把「不完整响应」误当成「读到 0 字节数据」。
+ throw new ReadErrorException(
+ $"Incomplete MC1E response frame. Expected at least {min} bytes, but got {response.Length} bytes.");
}
_resultCode = response[min - 2];
@@ -129,7 +132,10 @@ public int SetResponse(byte[] response) {
case McFrame.MC4E:
min = FrameType == McFrame.MC3E ? 11 : 15;
if (min > response.Length) {
- break;
+ // 帧长不足帧头即为残帧,抛出明确异常而不是静默返回空 Response,
+ // 避免上层把「不完整响应」误当成「读到 0 字节数据」。
+ throw new ReadErrorException(
+ $"Incomplete MC response frame. Expected at least {min} bytes, but got {response.Length} bytes.");
}
var btCount = new[] { response[min - 4], response[min - 3] };
diff --git a/src/McProtocol/Helpers/McFrameReader.cs b/src/McProtocol/Helpers/McFrameReader.cs
new file mode 100644
index 0000000..267fea4
--- /dev/null
+++ b/src/McProtocol/Helpers/McFrameReader.cs
@@ -0,0 +1,113 @@
+// =============================================================================
+// MAS.Communication
+// https://www.mas-automation.com/
+//
+// Copyright 2026 MAS (厦门威光) Corporation
+//
+// Licensed under the Apache License, Version 2.0
+// See LICENSE file in the project root for full license information.
+// =============================================================================
+
+namespace MAS.Communication.McProtocol;
+
+///
+/// MC 协议响应帧读取帮助类
+///
+///
+/// TCP 是字节流,一帧 MC 响应可能被拆成多个分段到达。此处按协议帧结构读满整帧:
+/// 先读满定长帧头,再根据帧头中的「响应数据长度」字段读够剩余字节,
+/// 避免用单次读的长度判断消息边界而拿到残帧、进而让后续读取永久错位(desync)。
+///
+internal static class McFrameReader {
+ ///
+ /// 获取指定帧类型的响应帧头长度(字节)
+ ///
+ ///
+ /// MC1E 响应帧头为「子头(1) + 结束代码(1)」共 2 字节;
+ /// MC3E 为 11 字节、MC4E 为 15 字节,两者帧头内均含结束代码与响应数据长度字段。
+ ///
+ public static int GetResponseHeaderLength(McFrame frameType) {
+ return frameType switch {
+ McFrame.MC1E => 2,
+ McFrame.MC3E => 11,
+ McFrame.MC4E => 15,
+ _ => throw new Exception("Message frame not supported."),
+ };
+ }
+
+ ///
+ /// 从流中读取一帧完整的 MC 响应
+ ///
+ /// 网络流
+ /// 协议帧类型
+ ///
+ /// MC1E 正常响应的预期数据字节数(由请求推算)。MC1E 响应头没有长度字段,
+ /// 只能依据请求推算数据长度;MC3E/MC4E 从帧头的长度字段计算,忽略此参数。
+ ///
+ /// 取消令牌
+ /// 完整的响应帧字节数组
+ /// 读到 0 字节(对端已关闭连接/EOF)时抛出
+ /// 读取被取消或超时
+ public static async Task ReadResponseFrameAsync(
+ Stream stream,
+ McFrame frameType,
+ int expectedDataLength,
+ CancellationToken cts = default) {
+
+ int headerLength = GetResponseHeaderLength(frameType);
+
+ // 先按定长读满帧头,避免 TCP 分段导致的残帧
+ byte[] header = await ReadExactlyAsync(stream, headerLength, cts).ConfigureAwait(false);
+
+ int remaining = frameType switch {
+ // MC3E/MC4E 帧头末尾为「响应数据长度」字段,其值 = 结束代码(2) + 数据字节数,
+ // 结束代码已包含在帧头内,故仍需读取的数据字节数为「长度字段值 - 2」。
+ McFrame.MC3E or McFrame.MC4E =>
+ (header[headerLength - 4] | (header[headerLength - 3] << 8)) - 2,
+ // MC1E 响应头无长度字段:结束代码(第 2 字节)为 0 表示正常,随后为预期数据;
+ // 非 0 表示异常,随后固定为 1 字节异常代码。
+ McFrame.MC1E => header[1] == 0x00 ? expectedDataLength : 1,
+ _ => throw new Exception("Message frame not supported."),
+ };
+
+ if (remaining <= 0) {
+ return header;
+ }
+
+ byte[] body = await ReadExactlyAsync(stream, remaining, cts).ConfigureAwait(false);
+
+ byte[] frameBytes = new byte[headerLength + remaining];
+ Buffer.BlockCopy(header, 0, frameBytes, 0, headerLength);
+ Buffer.BlockCopy(body, 0, frameBytes, headerLength, remaining);
+ return frameBytes;
+ }
+
+ ///
+ /// 从流中精确读取指定字节数
+ ///
+ ///
+ /// 循环读取直至读满 字节。读到 0 字节表示对端已关闭连接(EOF),
+ /// 判定为断线并抛出 ;读取被取消/超时则抛出
+ /// ,两者语义严格区分,取消不当作断线。
+ ///
+ private static async Task ReadExactlyAsync(Stream stream, int count, CancellationToken cts) {
+ byte[] buffer = new byte[count];
+ int offset = 0;
+
+ while (offset < count) {
+#if NETFRAMEWORK
+ int read = await stream.ReadAsync(buffer, offset, count - offset, cts).ConfigureAwait(false);
+#else
+ int read = await stream.ReadAsync(buffer.AsMemory(offset, count - offset), cts).ConfigureAwait(false);
+#endif
+ if (read == 0) {
+ // 读到 0 字节说明对端已关闭连接(TCP 进入半开/EOF 状态),判定为断线。
+ throw new ConnectionException("The connection has been closed by the remote host.");
+ }
+
+ offset += read;
+ }
+
+ return buffer;
+ }
+}
diff --git a/src/McProtocol/Helpers/MitsubishiHelper.cs b/src/McProtocol/Helpers/MitsubishiHelper.cs
index f424eb9..0a19bb6 100644
--- a/src/McProtocol/Helpers/MitsubishiHelper.cs
+++ b/src/McProtocol/Helpers/MitsubishiHelper.cs
@@ -78,8 +78,10 @@ public static PlcDeviceType ParsePlcDeviceType(string deviceType) {
/// 协议类型
/// 如果响应数据不正确,返回 true;否则返回 false
public static bool IsIncorrectResponse(McFrame frameType, byte[] response, int minLength) {
+ // 响应长度不足一个帧头即为残帧(不完整),必须判为「不正确」;
+ // 早前误将其判为「正确」放行,导致 TCP 分段产生的残帧被当作有效数据。
if (response.Length < minLength) {
- return false;
+ return true;
}
switch (frameType) {
diff --git a/src/McProtocol/McProtocolClient.cs b/src/McProtocol/McProtocolClient.cs
index 3ff0c78..67287e8 100644
--- a/src/McProtocol/McProtocolClient.cs
+++ b/src/McProtocol/McProtocolClient.cs
@@ -8,9 +8,6 @@
// See LICENSE file in the project root for full license information.
// =============================================================================
-#if !NETFRAMEWORK
-using System.Buffers;
-#endif
using System.Net.Sockets;
namespace MAS.Communication.McProtocol;
@@ -323,6 +320,9 @@ private bool IsSocketAlive() {
return false;
}
+ // socket.Connected 反映的是最近一次收发时的状态,对半开连接并不可靠;
+ // 这里再用 Poll + Available 识别对端已关闭的情况:Poll 返回可读且
+ // Available == 0,说明收到了对端的 FIN(连接已断),判定为不可用。
if (socket.Poll(0, SelectMode.SelectRead) && socket.Available == 0) {
return false;
}
@@ -389,8 +389,9 @@ private async Task WriteDeviceBlockAsync(PlcDeviceType type, int address, i
_ => throw new Exception("Message frame not supported"),
};
+ // 写请求的响应仅为确认帧(无数据部分),MC1E 预期数据长度为 0。
byte[] rtResponse = await TryExecutionAsync(
- sdCommand, length, cts: cts
+ sdCommand, length, expectedDataLength: 0, cts: cts
).ConfigureAwait(false);
return mcCommand.SetResponse(rtResponse);
@@ -405,8 +406,10 @@ private async Task ReadDeviceBlockAsync(PlcDeviceType type, int address,
_ => throw new Exception("Message frame not supported"),
};
+ // 读请求响应含数据部分。MC1E 响应头无长度字段,需按请求推算数据字节数
+ // (与 MC3E/MC4E 一致,返回 size 个点、每点 2 字节);MC3E/MC4E 会改用帧头长度字段。
byte[] rtResponse = await TryExecutionAsync(
- sdCommand, length, cts: cts
+ sdCommand, length, expectedDataLength: size * 2, cts: cts
).ConfigureAwait(false);
_ = mcCommand.SetResponse(rtResponse);
@@ -418,6 +421,7 @@ private async Task ReadDeviceBlockAsync(PlcDeviceType type, int address,
private async Task TryExecutionAsync(
byte[] command,
int minlength,
+ int expectedDataLength,
int maxRetries = 10,
TimeSpan? retryDelay = null,
CancellationToken cts = default) {
@@ -426,7 +430,9 @@ private async Task TryExecutionAsync(
int retryCount = 0;
do {
- response = await ExecuteAsync(command, cts).ConfigureAwait(false);
+ // ExecuteAsync 已按帧结构读满整帧;若对端关闭/传输异常会抛出并失效连接,
+ // 这里得到的始终是一帧完整响应(长度不小于帧头)。
+ response = await ExecuteAsync(command, expectedDataLength, cts).ConfigureAwait(false);
retryCount++;
if (MitsubishiHelper.IsIncorrectResponse(_config.ProtocolFrame, response, minlength)) {
@@ -443,43 +449,29 @@ private async Task TryExecutionAsync(
return response;
}
- private async Task ExecuteAsync(byte[] command, CancellationToken cts = default) {
- if (_stream == null) {
+ private async Task ExecuteAsync(byte[] command, int expectedDataLength, CancellationToken cts = default) {
+ if (_stream is not NetworkStream stream) {
throw new InvalidOperationException("The network flow is uninitialized");
}
await _streamLock.WaitAsync(cts).ConfigureAwait(false);
try {
- await WriteToStreamAsync(_stream, command, cts).ConfigureAwait(false);
- await FlushStreamAsync(_stream, cts).ConfigureAwait(false);
-
- using MemoryStream memoryStream = new();
-
-#if NETFRAMEWORK
- byte[] buffer = new byte[256];
-#else
- byte[] buffer = ArrayPool.Shared.Rent(256);
-#endif
+ await WriteToStreamAsync(stream, command, cts).ConfigureAwait(false);
+ await FlushStreamAsync(stream, cts).ConfigureAwait(false);
- try {
- int bytesRead;
- while ((bytesRead = await ReadFromStreamAsync(_stream, buffer, cts).ConfigureAwait(false)) > 0) {
- memoryStream.Write(buffer, 0, bytesRead);
- if (bytesRead < buffer.Length) {
- break;
- }
- }
-
- if (memoryStream.Length == 0) {
- throw new Exception("The connection has been disconnected");
- }
-
- return memoryStream.ToArray();
- } finally {
-#if !NETFRAMEWORK
- ArrayPool.Shared.Return(buffer);
-#endif
- }
+ // TCP 是字节流,一帧 MC 响应可能被拆成多个分段到达。必须按协议帧结构读满
+ // 整帧,不能用单次读的长度判断消息边界,否则会拿到残帧并让后续读取永久错位。
+ return await McFrameReader.ReadResponseFrameAsync(
+ stream, _config.ProtocolFrame, expectedDataLength, cts).ConfigureAwait(false);
+ } catch (OperationCanceledException) {
+ // 读写超时或被主动取消不属于断线,保持内部连接状态不变,直接向上传播。
+ throw;
+ } catch {
+ // 发送/接收阶段出现 EOF 或 IO/Socket 等传输异常,均视为连接已失效:
+ // 复位内部连接状态后再向上传播,确保随后重连时能真正重建连接,
+ // 也避免残帧留下的脏字节让之后每次读取都错位、故障蔓延。
+ InvalidateConnection();
+ throw;
} finally {
_ = _streamLock.Release();
}
@@ -493,14 +485,6 @@ private static Task WriteToStreamAsync(NetworkStream stream, byte[] buffer, Canc
#endif
}
- private static Task ReadFromStreamAsync(NetworkStream stream, byte[] buffer, CancellationToken cts) {
-#if NETFRAMEWORK
- return stream.ReadAsync(buffer, 0, buffer.Length, cts);
-#else
- return stream.ReadAsync(buffer, cts).AsTask();
-#endif
- }
-
private static Task FlushStreamAsync(NetworkStream stream, CancellationToken cts) {
#if NETFRAMEWORK
return stream.FlushAsync();
diff --git a/tests/McProtocol/McFrameReaderTest.cs b/tests/McProtocol/McFrameReaderTest.cs
new file mode 100644
index 0000000..ffb2117
--- /dev/null
+++ b/tests/McProtocol/McFrameReaderTest.cs
@@ -0,0 +1,173 @@
+// =============================================================================
+// MAS.Communication
+// https://www.mas-automation.com/
+//
+// Copyright 2026 MAS (厦门威光) Corporation
+//
+// Licensed under the Apache License, Version 2.0
+// See LICENSE file in the project root for full license information.
+// =============================================================================
+
+using MAS.Communication;
+using MAS.Communication.McProtocol;
+
+namespace MAS.CommunicationUnitTest.McProtocol;
+
+///
+/// McFrameReader 读帧逻辑单元测试(不依赖真实 PLC)。
+/// 重点验证:TCP 分段到达的响应能被按帧结构正确拼接为完整帧,
+/// 以及读到 0 字节(对端关闭)时判定为断线、取消不被误判为断线。
+///
+[TestClass]
+public class McFrameReaderTest {
+ ///
+ /// 构造一帧完整的 MC3E/MC4E 响应:定长帧头(含数据长度字段与结束代码)+ 数据。
+ ///
+ private static byte[] BuildSelfDescribingFrame(McFrame frame, byte[] data) {
+ int headerLength = McFrameReader.GetResponseHeaderLength(frame);
+ int rsCount = data.Length + 2; // 数据长度字段 = 结束代码(2) + 数据
+
+ byte[] frameBytes = new byte[headerLength + data.Length];
+
+ // 子头
+ frameBytes[0] = frame == McFrame.MC3E ? (byte)0xD0 : (byte)0xD4;
+
+ // 数据长度字段位于帧头末尾第 4、3 字节
+ frameBytes[headerLength - 4] = (byte)rsCount;
+ frameBytes[headerLength - 3] = (byte)(rsCount >> 8);
+
+ // 结束代码 0x0000(正常)位于帧头末尾第 2、1 字节
+ frameBytes[headerLength - 2] = 0x00;
+ frameBytes[headerLength - 1] = 0x00;
+
+ Buffer.BlockCopy(data, 0, frameBytes, headerLength, data.Length);
+ return frameBytes;
+ }
+
+ [TestMethod]
+ [DataRow(McFrame.MC3E, 1)]
+ [DataRow(McFrame.MC3E, 2)]
+ [DataRow(McFrame.MC3E, 3)]
+ [DataRow(McFrame.MC3E, 7)]
+ [DataRow(McFrame.MC3E, 256)]
+ [DataRow(McFrame.MC4E, 1)]
+ [DataRow(McFrame.MC4E, 2)]
+ [DataRow(McFrame.MC4E, 5)]
+ [DataRow(McFrame.MC4E, 256)]
+ public async Task 分段到达的自描述帧能被正确拼接为完整帧(McFrame frame, int chunkSize) {
+ byte[] data = [0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88];
+ byte[] expected = BuildSelfDescribingFrame(frame, data);
+
+ using var stream = new ChunkedReadStream(expected, chunkSize);
+ byte[] actual = await McFrameReader.ReadResponseFrameAsync(stream, frame, expectedDataLength: 0);
+
+ CollectionAssert.AreEqual(expected, actual,
+ $"帧类型 {frame} 在每次最多读 {chunkSize} 字节的分段下未能正确拼接完整帧");
+ }
+
+ [TestMethod]
+ [DataRow(1)]
+ [DataRow(2)]
+ [DataRow(3)]
+ [DataRow(256)]
+ public async Task 分段到达的MC1E正常帧能按预期数据长度拼接完整(int chunkSize) {
+ // MC1E 响应头:子头(0x80) + 结束代码(0x00 正常),随后为预期数据字节
+ byte[] data = [0xAA, 0xBB, 0xCC, 0xDD];
+ byte[] expected = [0x80, 0x00, .. data];
+
+ using var stream = new ChunkedReadStream(expected, chunkSize);
+ byte[] actual = await McFrameReader.ReadResponseFrameAsync(
+ stream, McFrame.MC1E, expectedDataLength: data.Length);
+
+ CollectionAssert.AreEqual(expected, actual);
+ }
+
+ [TestMethod]
+ public async Task MC1E异常帧读取1字节异常代码() {
+ // 结束代码非 0 时,随后固定为 1 字节异常代码,与预期数据长度无关
+ byte[] expected = [0x80, 0x51, 0xAB];
+
+ using var stream = new ChunkedReadStream(expected, 1);
+ byte[] actual = await McFrameReader.ReadResponseFrameAsync(
+ stream, McFrame.MC1E, expectedDataLength: 100);
+
+ CollectionAssert.AreEqual(expected, actual);
+ }
+
+ [TestMethod]
+ public async Task 读到0字节判定为断线并抛出连接异常() {
+ // 帧头尚未读满即 EOF(对端关闭)
+ byte[] partial = [0xD0, 0x00, 0x00, 0xFF]; // 不足 MC3E 的 11 字节帧头
+
+ using var stream = new ChunkedReadStream(partial, 8);
+ _ = await Assert.ThrowsExactlyAsync(async () =>
+ await McFrameReader.ReadResponseFrameAsync(stream, McFrame.MC3E, expectedDataLength: 0));
+ }
+
+ [TestMethod]
+ public async Task 数据段中途EOF判定为断线并抛出连接异常() {
+ // 帧头完整、声明还有 4 字节数据,但数据只到达 2 字节即 EOF
+ byte[] frame = BuildSelfDescribingFrame(McFrame.MC3E, [0x11, 0x22, 0x33, 0x44]);
+ byte[] truncated = frame[..(frame.Length - 2)];
+
+ using var stream = new ChunkedReadStream(truncated, 8);
+ _ = await Assert.ThrowsExactlyAsync(async () =>
+ await McFrameReader.ReadResponseFrameAsync(stream, McFrame.MC3E, expectedDataLength: 0));
+ }
+
+ [TestMethod]
+ public async Task 取消令牌触发时抛出取消异常而非连接异常() {
+ byte[] frame = BuildSelfDescribingFrame(McFrame.MC3E, [0x11, 0x22]);
+ using var cts = new CancellationTokenSource();
+ cts.Cancel();
+
+ using var stream = new ChunkedReadStream(frame, 4);
+ _ = await Assert.ThrowsExactlyAsync(async () =>
+ await McFrameReader.ReadResponseFrameAsync(stream, McFrame.MC3E, expectedDataLength: 0, cts.Token));
+ }
+
+ ///
+ /// 模拟 TCP 分段的只读流:每次 Read 最多返回 chunkSize 字节,数据耗尽后返回 0(EOF)。
+ ///
+ private sealed class ChunkedReadStream(byte[] data, int chunkSize) : Stream {
+ private readonly byte[] _data = data;
+ private readonly int _chunkSize = chunkSize;
+ private int _position;
+
+ public override ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken = default) {
+ cancellationToken.ThrowIfCancellationRequested();
+
+ int available = _data.Length - _position;
+ if (available <= 0) {
+ return ValueTask.FromResult(0);
+ }
+
+ int toCopy = Math.Min(Math.Min(_chunkSize, buffer.Length), available);
+ _data.AsMemory(_position, toCopy).CopyTo(buffer);
+ _position += toCopy;
+ return ValueTask.FromResult(toCopy);
+ }
+
+ public override int Read(byte[] buffer, int offset, int count) {
+ int available = _data.Length - _position;
+ if (available <= 0) {
+ return 0;
+ }
+
+ int toCopy = Math.Min(Math.Min(_chunkSize, count), available);
+ Array.Copy(_data, _position, buffer, offset, toCopy);
+ _position += toCopy;
+ return toCopy;
+ }
+
+ public override bool CanRead => true;
+ public override bool CanSeek => false;
+ public override bool CanWrite => false;
+ public override long Length => _data.Length;
+ public override long Position { get => _position; set => throw new NotSupportedException(); }
+ public override void Flush() { }
+ public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException();
+ public override void SetLength(long value) => throw new NotSupportedException();
+ public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException();
+ }
+}