perf(modbus): pooled request ADUs, a reused MBAP header and single-buffer RTU deframing
R07:L5 (Modbus half, former F2): ModbusTcpTransport builds the MBAP ADU in a pooled buffer and reads the reply header into one per-transport buffer (sends are single-flight under the gate). ModbusRtuOverTcpTransport writes its ADU into a pooled buffer (ModbusRtuFraming.WriteAdu, byte-identical to BuildAdu), and ReadResponsePduAsync reads and validates the whole frame in one pooled buffer instead of five arrays. Only the returned PDU is allocated. The exchange stays one async method: splitting it cost a state-machine box that ate the saving. Scratch A/B per op: RTU deframe 352 -> 184 B, RTU send 640 -> 408 B, TCP send 864 -> 800 B; timings within noise; Driver.Modbus.Tests 2 s before and after. NormalizeWordOrder lives in ModbusDriver (not a transport) and is left alone. Refs #747 Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
using System.Buffers;
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus;
|
||||
|
||||
/// <summary>
|
||||
@@ -55,12 +56,33 @@ public static class ModbusRtuFraming
|
||||
/// <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);
|
||||
var frame = new byte[AduLength(pdu.Length)];
|
||||
WriteAdu(unitId, pdu, frame);
|
||||
return frame;
|
||||
}
|
||||
|
||||
/// <summary>The length of the RTU ADU for a PDU of <paramref name="pduLength"/> bytes (unit id + PDU + CRC).</summary>
|
||||
/// <param name="pduLength">The bare PDU length.</param>
|
||||
/// <returns>The ADU length.</returns>
|
||||
internal static int AduLength(int pduLength) => 1 + pduLength + 2;
|
||||
|
||||
/// <summary>Writes the RTU ADU into <paramref name="destination"/> (exactly <see cref="AduLength"/> bytes).</summary>
|
||||
/// <param name="unitId">The RTU slave/unit address.</param>
|
||||
/// <param name="pdu">The bare PDU.</param>
|
||||
/// <param name="destination">The ADU buffer, exactly <see cref="AduLength"/>(<paramref name="pdu"/>.Length) long.</param>
|
||||
internal static void WriteAdu(byte unitId, ReadOnlySpan<byte> pdu, Span<byte> destination)
|
||||
{
|
||||
destination[0] = unitId;
|
||||
pdu.CopyTo(destination[1..]);
|
||||
var body = destination[..(1 + pdu.Length)];
|
||||
var crc = ModbusCrc.Compute(body);
|
||||
destination[1 + pdu.Length] = (byte)(crc & 0xFF); // CRC low byte first
|
||||
destination[2 + pdu.Length] = (byte)(crc >> 8);
|
||||
}
|
||||
|
||||
/// <summary>The largest RTU response frame: addr + fc + byteCount + 255 data bytes + CRC.</summary>
|
||||
private const int MaxResponseFrame = 3 + 255 + 2;
|
||||
|
||||
/// <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.
|
||||
@@ -82,58 +104,56 @@ public static class ModbusRtuFraming
|
||||
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];
|
||||
// R07:L5: the whole frame is read into ONE pooled buffer and validated in place; only the returned PDU is
|
||||
// allocated (it used to be five arrays: header, byteCount, rest, the reassembled frame and the PDU).
|
||||
var frame = ArrayPool<byte>.Shared.Rent(MaxResponseFrame);
|
||||
try
|
||||
{
|
||||
// Header: addr(1) + fc(1). The function code selects how many more bytes to read.
|
||||
await ReadExactlyAsync(stream, frame.AsMemory(0, 2), ct).ConfigureAwait(false);
|
||||
var fc = frame[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 or 0x17)
|
||||
{
|
||||
// Read response (and FC23 Read/Write Multiple, whose reply is a read): 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). FC23 is
|
||||
// variable-length and handled above; any NEW variable-length response FC must be added there
|
||||
// or it is mis-sized here and surfaces as a desync.
|
||||
trailing = 4 + 2;
|
||||
}
|
||||
int length; // the whole frame, addr through CRC
|
||||
var already = 2; // bytes of the frame read so far
|
||||
if ((fc & 0x80) != 0)
|
||||
{
|
||||
// Exception frame: excCode(1) + CRC(2).
|
||||
length = 2 + 1 + 2;
|
||||
}
|
||||
else if (fc is 0x01 or 0x02 or 0x03 or 0x04 or 0x17)
|
||||
{
|
||||
// Read response (and FC23 Read/Write Multiple, whose reply is a read): byteCount(1) then
|
||||
// byteCount data bytes + CRC(2).
|
||||
await ReadExactlyAsync(stream, frame.AsMemory(2, 1), ct).ConfigureAwait(false);
|
||||
length = 3 + frame[2] + 2;
|
||||
already = 3;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Fixed 4-byte echo: correct for the write FCs this driver emits (05/06/0F/10). FC23 is
|
||||
// variable-length and handled above; any NEW variable-length response FC must be added there
|
||||
// or it is mis-sized here and surfaces as a desync.
|
||||
length = 2 + 4 + 2;
|
||||
}
|
||||
|
||||
var tail = new byte[trailing];
|
||||
await ReadExactlyAsync(stream, tail, ct).ConfigureAwait(false);
|
||||
return ValidateAndStrip(expectedUnit, header, ReadOnlySpan<byte>.Empty, tail);
|
||||
await ReadExactlyAsync(stream, frame.AsMemory(already, length - already), ct).ConfigureAwait(false);
|
||||
return ValidateAndStrip(expectedUnit, frame.AsSpan(0, length));
|
||||
}
|
||||
finally
|
||||
{
|
||||
ArrayPool<byte>.Shared.Return(frame);
|
||||
}
|
||||
}
|
||||
|
||||
/// <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.
|
||||
/// 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)
|
||||
private static byte[] ValidateAndStrip(byte expectedUnit, ReadOnlySpan<byte> frame)
|
||||
{
|
||||
// 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 body = frame[..^2];
|
||||
var expected = ModbusCrc.Compute(body);
|
||||
var actual = (ushort)(frame[^2] | (frame[^1] << 8));
|
||||
if (actual != expected)
|
||||
@@ -172,12 +192,12 @@ public static class ModbusRtuFraming
|
||||
/// <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)
|
||||
private static async Task ReadExactlyAsync(Stream stream, Memory<byte> buf, CancellationToken ct)
|
||||
{
|
||||
var read = 0;
|
||||
while (read < buf.Length)
|
||||
{
|
||||
var n = await stream.ReadAsync(buf.AsMemory(read), ct).ConfigureAwait(false);
|
||||
var n = await stream.ReadAsync(buf[read..], ct).ConfigureAwait(false);
|
||||
if (n == 0)
|
||||
throw new ModbusTransportDesyncException(
|
||||
ModbusTransportDesyncException.DesyncReason.TruncatedFrame,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.Buffers;
|
||||
using System.Net.Sockets;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus;
|
||||
@@ -150,14 +151,17 @@ public sealed class ModbusRtuOverTcpTransport : IModbusTransport
|
||||
var stream = CurrentStream;
|
||||
if (stream is null) throw new InvalidOperationException("Transport not connected");
|
||||
|
||||
// RTU ADU: [unitId] + PDU + CRC — no MBAP header, no TxId.
|
||||
var adu = ModbusRtuFraming.BuildAdu(unitId, pdu);
|
||||
// RTU ADU: [unitId] + PDU + CRC — no MBAP header, no TxId. Built in a pooled buffer (R07:L5); the same bytes
|
||||
// ModbusRtuFraming.BuildAdu produces (pinned by a test).
|
||||
var aduLength = ModbusRtuFraming.AduLength(pdu.Length);
|
||||
var adu = ArrayPool<byte>.Shared.Rent(aduLength);
|
||||
ModbusRtuFraming.WriteAdu(unitId, pdu, adu.AsSpan(0, aduLength));
|
||||
|
||||
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
cts.CancelAfter(_timeout);
|
||||
try
|
||||
{
|
||||
await stream.WriteAsync(adu.AsMemory(), cts.Token).ConfigureAwait(false);
|
||||
await stream.WriteAsync(adu.AsMemory(0, aduLength), cts.Token).ConfigureAwait(false);
|
||||
await stream.FlushAsync(cts.Token).ConfigureAwait(false);
|
||||
|
||||
// Framing owns the length-less RTU deframe + CRC + unit-address validation. A CRC-valid
|
||||
@@ -181,6 +185,11 @@ public sealed class ModbusRtuOverTcpTransport : IModbusTransport
|
||||
ModbusTransportDesyncException.DesyncReason.Timeout,
|
||||
$"Modbus per-op response timeout after {_timeout.TotalMilliseconds:0}ms");
|
||||
}
|
||||
finally
|
||||
{
|
||||
// The write has completed, or was cancelled and the stream torn down, so nothing still reads it.
|
||||
ArrayPool<byte>.Shared.Return(adu);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Asynchronously disposes the transport and underlying socket resources.</summary>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.Buffers;
|
||||
using System.Net.Sockets;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus;
|
||||
@@ -59,6 +60,10 @@ public sealed class ModbusTcpTransport : IModbusTransport
|
||||
/// <summary>The socket lifecycle this transport composes (test seam for its clock).</summary>
|
||||
internal ModbusSocketLifecycle Lifecycle => _life;
|
||||
|
||||
// R07:L5: the 7-byte MBAP header of the reply being read. SendOnceAsync runs only under _gate (single-flight), so
|
||||
// one buffer per transport serves every transaction (it used to be a fresh array per reply).
|
||||
private readonly byte[] _header = new byte[7];
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task ConnectAsync(CancellationToken ct) => _life.ConnectAsync(ct);
|
||||
|
||||
@@ -139,11 +144,16 @@ public sealed class ModbusTcpTransport : IModbusTransport
|
||||
if (stream is null) throw new InvalidOperationException("Transport not connected");
|
||||
var txId = ++_nextTx;
|
||||
|
||||
// MBAP: [TxId(2)][Proto=0(2)][Length(2)][UnitId(1)] + PDU
|
||||
var adu = new byte[7 + pdu.Length];
|
||||
// MBAP: [TxId(2)][Proto=0(2)][Length(2)][UnitId(1)] + PDU. Built in a pooled buffer (R07:L5): the request
|
||||
// never escapes, and the write has completed (or been cancelled and the socket torn down) before the finally
|
||||
// hands it back. Only the reply PDU, which the caller keeps, is allocated.
|
||||
// (One async method on purpose: a second awaited helper costs a state-machine box per call, which ate the saving.)
|
||||
var aduLength = 7 + pdu.Length;
|
||||
var adu = ArrayPool<byte>.Shared.Rent(aduLength);
|
||||
adu[0] = (byte)(txId >> 8);
|
||||
adu[1] = (byte)(txId & 0xFF);
|
||||
// protocol id already zero
|
||||
adu[2] = 0; // protocol id
|
||||
adu[3] = 0;
|
||||
var len = (ushort)(1 + pdu.Length); // unit id + pdu
|
||||
adu[4] = (byte)(len >> 8);
|
||||
adu[5] = (byte)(len & 0xFF);
|
||||
@@ -154,10 +164,10 @@ public sealed class ModbusTcpTransport : IModbusTransport
|
||||
cts.CancelAfter(_timeout);
|
||||
try
|
||||
{
|
||||
await stream.WriteAsync(adu.AsMemory(), cts.Token).ConfigureAwait(false);
|
||||
await stream.WriteAsync(adu.AsMemory(0, aduLength), cts.Token).ConfigureAwait(false);
|
||||
await stream.FlushAsync(cts.Token).ConfigureAwait(false);
|
||||
|
||||
var header = new byte[7];
|
||||
var header = _header;
|
||||
await ReadExactlyAsync(stream, header, cts.Token).ConfigureAwait(false);
|
||||
var respTxId = (ushort)((header[0] << 8) | header[1]);
|
||||
if (respTxId != txId)
|
||||
@@ -211,6 +221,11 @@ public sealed class ModbusTcpTransport : IModbusTransport
|
||||
ModbusTransportDesyncException.DesyncReason.Timeout,
|
||||
$"Modbus per-op response timeout after {_timeout.TotalMilliseconds:0}ms");
|
||||
}
|
||||
finally
|
||||
{
|
||||
// The write has completed, or was cancelled and the socket torn down, so nothing still reads it.
|
||||
ArrayPool<byte>.Shared.Return(adu);
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task ReadExactlyAsync(Stream s, byte[] buf, CancellationToken ct)
|
||||
|
||||
Reference in New Issue
Block a user