fcc7ed698e
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
188 lines
9.3 KiB
C#
188 lines
9.3 KiB
C#
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus;
|
|
|
|
/// <summary>
|
|
/// Modbus RTU framing: builds a request ADU (<c>[unitId] + PDU + CRC</c>) and deframes a
|
|
/// response back to its bare PDU. Byte-stream-agnostic — it operates on a <see cref="Stream"/>
|
|
/// and knows nothing about sockets or serial ports, so a future serial transport can reuse it.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// The single genuinely-new correctness risk of the RTU path: an RTU frame carries <b>no
|
|
/// length field</b> (unlike TCP's MBAP header), so the response size must be derived from
|
|
/// the function code. After reading <c>addr(1)</c> + <c>fc(1)</c>, the remaining byte count
|
|
/// is one of three shapes:
|
|
/// </para>
|
|
/// <list type="table">
|
|
/// <listheader><term>Shape</term><description>Trailing bytes after <c>addr,fc</c></description></listheader>
|
|
/// <item>
|
|
/// <term>Exception (<c>fc & 0x80</c>)</term>
|
|
/// <description><c>excCode(1)</c> + <c>CRC(2)</c></description>
|
|
/// </item>
|
|
/// <item>
|
|
/// <term>Read (FC 01/02/03/04)</term>
|
|
/// <description><c>byteCount(1)</c> then <c>byteCount</c> data bytes + <c>CRC(2)</c></description>
|
|
/// </item>
|
|
/// <item>
|
|
/// <term>Write echo (FC 05/06/15/16)</term>
|
|
/// <description>fixed <c>4</c> bytes + <c>CRC(2)</c></description>
|
|
/// </item>
|
|
/// </list>
|
|
/// <para>
|
|
/// The trailing CRC is validated over <c>[addr, ...pdu]</c> (everything except the two CRC
|
|
/// bytes) <b>before</b> the frame is interpreted — so a corrupt exception frame surfaces as a
|
|
/// desync, never as a bogus <see cref="ModbusException"/>. A CRC mismatch or a short read
|
|
/// (truncation / stream closed mid-frame) throws <see cref="ModbusTransportDesyncException"/>;
|
|
/// a CRC-valid exception PDU throws <see cref="ModbusException"/> carrying the original
|
|
/// function code (<c>fc & 0x7F</c>) and exception code.
|
|
/// </para>
|
|
/// <para>
|
|
/// Once the CRC passes, the response's address byte is validated against the addressed unit.
|
|
/// On an RS-485 multi-drop bus a CRC-valid reply from the <b>wrong</b> slave would otherwise be
|
|
/// silently accepted as the addressed unit's data; such a frame is a unit-mismatch
|
|
/// <see cref="ModbusTransportDesyncException"/>. This ordering is deliberate — a corrupt frame
|
|
/// stays a CRC/desync failure, and only a coherent-but-mis-addressed frame becomes a
|
|
/// unit-mismatch desync.
|
|
/// </para>
|
|
/// </remarks>
|
|
public static class ModbusRtuFraming
|
|
{
|
|
/// <summary>
|
|
/// Builds an RTU request ADU: the unit id, the PDU, and the trailing CRC-16/MODBUS
|
|
/// (appended low byte first).
|
|
/// </summary>
|
|
/// <param name="unitId">The RTU slave/unit address.</param>
|
|
/// <param name="pdu">The bare PDU (function code + data).</param>
|
|
/// <returns>A new array: <c>[unitId, ...pdu, crcLo, crcHi]</c>.</returns>
|
|
public static byte[] BuildAdu(byte unitId, ReadOnlySpan<byte> pdu)
|
|
{
|
|
var frame = new byte[1 + pdu.Length];
|
|
frame[0] = unitId;
|
|
pdu.CopyTo(frame.AsSpan(1));
|
|
return ModbusCrc.Append(frame);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Reads a single RTU response frame from <paramref name="stream"/> and returns its bare PDU
|
|
/// (<c>[fc, ...data]</c>), with the leading unit-id and trailing CRC stripped.
|
|
/// </summary>
|
|
/// <param name="stream">The byte stream to read the response from.</param>
|
|
/// <param name="expectedUnit">
|
|
/// The unit id the request was addressed to. The response's address byte is validated against
|
|
/// it after the CRC passes; a mismatch is a unit-mismatch desync.
|
|
/// </param>
|
|
/// <param name="ct">A token to cancel the read.</param>
|
|
/// <returns>The bare response PDU (function code followed by its data).</returns>
|
|
/// <exception cref="ModbusTransportDesyncException">
|
|
/// The frame was truncated (stream closed mid-frame), its trailing CRC did not validate, or its
|
|
/// address byte did not match <paramref name="expectedUnit"/>.
|
|
/// </exception>
|
|
/// <exception cref="ModbusException">
|
|
/// The frame was a CRC-valid Modbus exception PDU (high bit set on the function code).
|
|
/// </exception>
|
|
public static async Task<byte[]> ReadResponsePduAsync(
|
|
Stream stream, byte expectedUnit, CancellationToken ct)
|
|
{
|
|
// Header: addr(1) + fc(1). The function code selects how many more bytes to read.
|
|
var header = new byte[2];
|
|
await ReadExactlyAsync(stream, header, ct).ConfigureAwait(false);
|
|
var fc = header[1];
|
|
|
|
int trailing; // bytes after addr+fc, up to and including the 2 CRC bytes.
|
|
if ((fc & 0x80) != 0)
|
|
{
|
|
// Exception frame: excCode(1) + CRC(2).
|
|
trailing = 1 + 2;
|
|
}
|
|
else if (fc is 0x01 or 0x02 or 0x03 or 0x04)
|
|
{
|
|
// Read response: byteCount(1) then byteCount data bytes + CRC(2).
|
|
var bc = new byte[1];
|
|
await ReadExactlyAsync(stream, bc, ct).ConfigureAwait(false);
|
|
var byteCount = bc[0];
|
|
var rest = new byte[byteCount + 2];
|
|
await ReadExactlyAsync(stream, rest, ct).ConfigureAwait(false);
|
|
return ValidateAndStrip(expectedUnit, header, bc, rest);
|
|
}
|
|
else
|
|
{
|
|
// Fixed 4-byte echo: correct for the write FCs this driver emits (05/06/0F/10). A future
|
|
// variable-length response FC outside 01-04 (e.g. FC23) would be mis-sized here and
|
|
// surface as a desync — revisit the FC-shape table if the driver starts emitting one.
|
|
trailing = 4 + 2;
|
|
}
|
|
|
|
var tail = new byte[trailing];
|
|
await ReadExactlyAsync(stream, tail, ct).ConfigureAwait(false);
|
|
return ValidateAndStrip(expectedUnit, header, ReadOnlySpan<byte>.Empty, tail);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Assembles the full frame from its pieces, validates the trailing CRC over everything
|
|
/// except the CRC bytes, then validates the response address against
|
|
/// <paramref name="expectedUnit"/> and strips <c>addr</c> + <c>CRC</c> to return the bare PDU.
|
|
/// A CRC-valid exception PDU throws <see cref="ModbusException"/>.
|
|
/// </summary>
|
|
private static byte[] ValidateAndStrip(
|
|
byte expectedUnit, ReadOnlySpan<byte> header, ReadOnlySpan<byte> middle, ReadOnlySpan<byte> tail)
|
|
{
|
|
// Reassemble the whole frame: header(addr,fc) + middle(optional byteCount) + tail.
|
|
var frame = new byte[header.Length + middle.Length + tail.Length];
|
|
header.CopyTo(frame);
|
|
middle.CopyTo(frame.AsSpan(header.Length));
|
|
tail.CopyTo(frame.AsSpan(header.Length + middle.Length));
|
|
|
|
// CRC covers everything except the trailing 2 CRC bytes.
|
|
var body = frame.AsSpan(0, frame.Length - 2);
|
|
var expected = ModbusCrc.Compute(body);
|
|
var actual = (ushort)(frame[^2] | (frame[^1] << 8));
|
|
if (actual != expected)
|
|
throw new ModbusTransportDesyncException(
|
|
ModbusTransportDesyncException.DesyncReason.TruncatedFrame,
|
|
$"Modbus RTU CRC mismatch: computed {expected:X4} got {actual:X4}");
|
|
|
|
// Unit-address validation runs only AFTER the CRC passes: a corrupt frame is a CRC/desync
|
|
// failure, and only a coherent-but-mis-addressed frame (a CRC-valid reply from the wrong
|
|
// RS-485 slave) is a unit-mismatch desync. Never silently accept another unit's data.
|
|
var addr = frame[0];
|
|
if (addr != expectedUnit)
|
|
throw new ModbusTransportDesyncException(
|
|
ModbusTransportDesyncException.DesyncReason.TruncatedFrame,
|
|
$"Modbus RTU unit mismatch: expected {expectedUnit} got {addr}");
|
|
|
|
// Bare PDU = frame minus leading addr and trailing CRC.
|
|
var pdu = body[1..].ToArray();
|
|
|
|
// Exception PDU: high bit set on the function code. The CRC already validated, so this is a
|
|
// coherent protocol-level error — surface the ORIGINAL function code (fc & 0x7F).
|
|
if ((pdu[0] & 0x80) != 0)
|
|
{
|
|
var fc = (byte)(pdu[0] & 0x7F);
|
|
var exCode = pdu[1];
|
|
throw new ModbusException(fc, exCode, $"Modbus exception fc={fc} code={exCode}");
|
|
}
|
|
|
|
return pdu;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Fills <paramref name="buf"/> completely from <paramref name="stream"/>, throwing a
|
|
/// truncation desync if the stream ends first. Mirrors
|
|
/// <c>ModbusTcpTransport.ReadExactlyAsync</c>, but normalises the end-of-stream to a
|
|
/// <see cref="ModbusTransportDesyncException"/> so a length-less RTU frame that arrives
|
|
/// short is classified as a desync rather than a bare <see cref="EndOfStreamException"/>.
|
|
/// </summary>
|
|
private static async Task ReadExactlyAsync(Stream stream, byte[] buf, CancellationToken ct)
|
|
{
|
|
var read = 0;
|
|
while (read < buf.Length)
|
|
{
|
|
var n = await stream.ReadAsync(buf.AsMemory(read), ct).ConfigureAwait(false);
|
|
if (n == 0)
|
|
throw new ModbusTransportDesyncException(
|
|
ModbusTransportDesyncException.DesyncReason.TruncatedFrame,
|
|
$"Modbus RTU frame truncated: expected {buf.Length} bytes, got {read}");
|
|
read += n;
|
|
}
|
|
}
|
|
}
|