Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions src/MAS.Communication.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,9 @@

<GenerateDocumentationFile>True</GenerateDocumentationFile>
<DocumentationFile>../../build/APIDocumentation/MAS.Communication.xml</DocumentationFile>
<Version>1.1.0</Version>
<AssemblyVersion>1.1.0.0</AssemblyVersion>
<FileVersion>1.1.0.0</FileVersion>
<Version>1.1.1</Version>
<AssemblyVersion>1.1.1.0</AssemblyVersion>
<FileVersion>1.1.1.0</FileVersion>
<Authors>MAS(厦门威光)研发部</Authors>
<Company>MAS(厦门威光)</Company>
<Copyright>Copyright (c) MAS(厦门威光) Corporation. All rights reserved.</Copyright>
Expand Down
10 changes: 8 additions & 2 deletions src/McProtocol/Commands/McCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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];
Expand All @@ -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] };
Expand Down
113 changes: 113 additions & 0 deletions src/McProtocol/Helpers/McFrameReader.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// MC 协议响应帧读取帮助类
/// </summary>
/// <remarks>
/// TCP 是字节流,一帧 MC 响应可能被拆成多个分段到达。此处按协议帧结构读满整帧:
/// 先读满定长帧头,再根据帧头中的「响应数据长度」字段读够剩余字节,
/// 避免用单次读的长度判断消息边界而拿到残帧、进而让后续读取永久错位(desync)。
/// </remarks>
internal static class McFrameReader {
/// <summary>
/// 获取指定帧类型的响应帧头长度(字节)
/// </summary>
/// <remarks>
/// MC1E 响应帧头为「子头(1) + 结束代码(1)」共 2 字节;
/// MC3E 为 11 字节、MC4E 为 15 字节,两者帧头内均含结束代码与响应数据长度字段。
/// </remarks>
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."),
};
}

/// <summary>
/// 从流中读取一帧完整的 MC 响应
/// </summary>
/// <param name="stream">网络流</param>
/// <param name="frameType">协议帧类型</param>
/// <param name="expectedDataLength">
/// MC1E 正常响应的预期数据字节数(由请求推算)。MC1E 响应头没有长度字段,
/// 只能依据请求推算数据长度;MC3E/MC4E 从帧头的长度字段计算,忽略此参数。
/// </param>
/// <param name="cts">取消令牌</param>
/// <returns>完整的响应帧字节数组</returns>
/// <exception cref="ConnectionException">读到 0 字节(对端已关闭连接/EOF)时抛出</exception>
/// <exception cref="OperationCanceledException">读取被取消或超时</exception>
public static async Task<byte[]> 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;
}

/// <summary>
/// 从流中精确读取指定字节数
/// </summary>
/// <remarks>
/// 循环读取直至读满 <paramref name="count"/> 字节。读到 0 字节表示对端已关闭连接(EOF),
/// 判定为断线并抛出 <see cref="ConnectionException"/>;读取被取消/超时则抛出
/// <see cref="OperationCanceledException"/>,两者语义严格区分,取消不当作断线。
/// </remarks>
private static async Task<byte[]> 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;
}
}
4 changes: 3 additions & 1 deletion src/McProtocol/Helpers/MitsubishiHelper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -78,8 +78,10 @@ public static PlcDeviceType ParsePlcDeviceType(string deviceType) {
/// <param name="frameType">协议类型</param>
/// <returns>如果响应数据不正确,返回 true;否则返回 false</returns>
public static bool IsIncorrectResponse(McFrame frameType, byte[] response, int minLength) {
// 响应长度不足一个帧头即为残帧(不完整),必须判为「不正确」;
// 早前误将其判为「正确」放行,导致 TCP 分段产生的残帧被当作有效数据。
if (response.Length < minLength) {
return false;
return true;
}

switch (frameType) {
Expand Down
74 changes: 29 additions & 45 deletions src/McProtocol/McProtocolClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -389,8 +389,9 @@ private async Task<int> 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);
Expand All @@ -405,8 +406,10 @@ private async Task<byte[]> 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);
Expand All @@ -418,6 +421,7 @@ private async Task<byte[]> ReadDeviceBlockAsync(PlcDeviceType type, int address,
private async Task<byte[]> TryExecutionAsync(
byte[] command,
int minlength,
int expectedDataLength,
int maxRetries = 10,
TimeSpan? retryDelay = null,
CancellationToken cts = default) {
Expand All @@ -426,7 +430,9 @@ private async Task<byte[]> 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)) {
Expand All @@ -443,43 +449,29 @@ private async Task<byte[]> TryExecutionAsync(
return response;
}

private async Task<byte[]> ExecuteAsync(byte[] command, CancellationToken cts = default) {
if (_stream == null) {
private async Task<byte[]> 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<byte>.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<byte>.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();
}
Expand All @@ -493,14 +485,6 @@ private static Task WriteToStreamAsync(NetworkStream stream, byte[] buffer, Canc
#endif
}

private static Task<int> 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();
Expand Down
Loading
Loading