Modbus TCP in C#: client e server asincroni con .NET

Abbiamo costruito un'implementazione di Modbus TCP completa per le funzioni più usate con la sola libreria di base di .NET.

C# di Gabriele Romanato

.NET è una piattaforma molto presente negli stabilimenti: applicazioni HMI per Windows, servizi di raccolta dati, integrazioni tra MES ed ERP, e sempre più spesso servizi containerizzati su Linux che fanno da gateway verso il cloud. In quasi tutti questi contesti si incontrano dispositivi che parlano Modbus, il protocollo nato nel 1979 per i PLC e diventato lo standard di fatto per l'interoperabilità tra apparati di produttori diversi.

In questo articolo implementiamo Modbus TCP in C# su .NET 10 senza pacchetti esterni. Sfrutteremo le API moderne della piattaforma: async e await con CancellationToken per timeout e annullamenti, Span<T> e BinaryPrimitives per la codifica binaria senza allocazioni superflue, il nuovo tipo System.Threading.Lock per la memoria dati del server. Il risultato sarà una piccola libreria con client, server, conversioni numeriche e CRC di Modbus RTU, verificata da una suite di test xUnit.

Il modello dati

Ogni dispositivo Modbus espone quattro tabelle con indirizzi a 16 bit:

TabellaTipo C#AccessoUso tipico
Coilboollettura e scritturauscite digitali, comandi
Discrete inputboolsola letturaingressi digitali, allarmi
Holding registerushortlettura e scritturasetpoint, configurazione
Input registerushortsola letturamisure

C# ha un vantaggio rispetto a molti altri linguaggi: il tipo ushort corrisponde esattamente a un registro Modbus, e lo stesso vale per gli indirizzi. Usandolo nelle firme dei metodi, il compilatore impedisce per costruzione di passare un indirizzo negativo o un valore di registro superiore a 65535.

Nella documentazione dei dispositivi si incontra spesso la numerazione storica, in cui 40001 è il primo holding register e 30001 il primo input register. Questi numeri partono da 1, mentre gli indirizzi nei pacchetti partono da 0: il registro 40001 si legge all'indirizzo 0. È la causa più frequente di valori che sembrano sfasati di una posizione.

Il formato dei frame

Il cuore di ogni messaggio è la PDU: un byte di codice funzione seguito dai parametri, per un massimo di 253 byte. Su TCP la PDU è preceduta da un'intestazione MBAP di sette byte, con tutti i campi in big-endian:

  • transaction identifier (2 byte): scelto dal client e ricopiato nella risposta;
  • protocol identifier (2 byte): sempre 0;
  • length (2 byte): byte che seguono, cioè unit identifier più PDU;
  • unit identifier (1 byte): indirizzo del dispositivo finale dietro un gateway.

Poiché .NET gira quasi sempre su processori little-endian, non possiamo usare BitConverter così com'è. La classe BinaryPrimitives offre metodi espliciti per entrambi gli ordini di byte, che lavorano direttamente su Span<byte>:

var frame = new byte[12];
BinaryPrimitives.WriteUInt16BigEndian(frame.AsSpan(0), 1);  // transaction identifier
BinaryPrimitives.WriteUInt16BigEndian(frame.AsSpan(2), 0);  // protocol identifier
BinaryPrimitives.WriteUInt16BigEndian(frame.AsSpan(4), 6);  // length
frame[6] = 1;                                               // unit identifier
frame[7] = 0x03;                                            // codice funzione
BinaryPrimitives.WriteUInt16BigEndian(frame.AsSpan(8), 0);  // indirizzo
BinaryPrimitives.WriteUInt16BigEndian(frame.AsSpan(10), 10); // quantita'
// 00 01 00 00 00 06 01 03 00 00 00 0A

Se la richiesta non può essere soddisfatta, il dispositivo risponde con il codice funzione con il bit più alto acceso, per esempio 0x83, seguito da un codice di eccezione: 0x01 funzione non supportata, 0x02 indirizzo non valido, 0x03 valore non valido, 0x04 guasto del dispositivo.

Struttura della soluzione

ModbusDotNet/
├── Modbus/
│   ├── Modbus.csproj
│   ├── Codes.cs
│   ├── Exceptions.cs
│   ├── Pdu.cs
│   ├── ModbusTcpClient.cs
│   ├── DataStore.cs
│   ├── ModbusTcpServer.cs
│   ├── Codec.cs
│   └── Crc16.cs
├── Modbus.Tests/
│   ├── Modbus.Tests.csproj
│   └── ModbusTests.cs
└── Demo/
    ├── Demo.csproj
    └── Program.cs
<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <TargetFramework>net10.0</TargetFramework>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
    <TreatWarningsAsErrors>true</TreatWarningsAsErrors>
  </PropertyGroup>

</Project>

Codici ed eccezioni

I codici funzione e di eccezione sono costanti byte, così possono comparire direttamente nei pattern delle espressioni switch. I limiti di quantità derivano dalla dimensione massima della PDU: 125 registri in lettura producono una risposta di 252 byte, l'ultima misura compatibile con i 253 disponibili.

namespace Modbus;

// Codici funzione supportati
public static class FunctionCodes
{
    public const byte ReadCoils = 0x01;
    public const byte ReadDiscreteInputs = 0x02;
    public const byte ReadHoldingRegisters = 0x03;
    public const byte ReadInputRegisters = 0x04;
    public const byte WriteSingleCoil = 0x05;
    public const byte WriteSingleRegister = 0x06;
    public const byte WriteMultipleRegisters = 0x10;
}

// Codici di eccezione definiti dalla specifica
public static class ExceptionCodes
{
    public const byte IllegalFunction = 0x01;
    public const byte IllegalDataAddress = 0x02;
    public const byte IllegalDataValue = 0x03;
    public const byte ServerDeviceFailure = 0x04;

    public static string Describe(byte code) => code switch
    {
        IllegalFunction => "illegal function",
        IllegalDataAddress => "illegal data address",
        IllegalDataValue => "illegal data value",
        ServerDeviceFailure => "server device failure",
        _ => "unknown",
    };
}

// Quantita' massime per singola richiesta
public static class Limits
{
    public const int MaxReadBits = 2000;
    public const int MaxReadRegisters = 125;
    public const int MaxWriteRegisters = 123;
}

Per gli errori usiamo i costruttori primari, disponibili per le classi da C# 12. ModbusDeviceException rappresenta un rifiuto ordinato del dispositivo, dopo il quale la connessione resta valida; ModbusProtocolException segnala una risposta malformata. Per i timeout useremo il TimeoutException della libreria standard.

namespace Modbus;

// Classe base per gli errori specifici di Modbus
public class ModbusException(string message) : Exception(message);

// Risposta malformata o incoerente con la richiesta
public sealed class ModbusProtocolException(string message) : ModbusException(message);

// Il dispositivo ha risposto con un'eccezione Modbus
public sealed class ModbusDeviceException(byte function, byte exceptionCode)
    : ModbusException($"exception 0x{exceptionCode:X2} ({ExceptionCodes.Describe(exceptionCode)}) for function 0x{function:X2}")
{
    public byte Function { get; } = function;
    public byte ExceptionCode { get; } = exceptionCode;
}

Codifica delle PDU

La classe statica Pdu contiene le funzioni di codifica e decodifica. I parametri di tipo ReadOnlySpan accettano indifferentemente array, porzioni di array o memoria allocata sullo stack, senza copie. Per i coil la specifica impone 0xFF00 per acceso e 0x0000 per spento.

using System.Buffers.Binary;

namespace Modbus;

// Codifica e decodifica delle PDU Modbus
public static class Pdu
{
    public static byte[] ReadRequest(byte function, ushort address, ushort quantity)
    {
        var pdu = new byte[5];
        pdu[0] = function;
        BinaryPrimitives.WriteUInt16BigEndian(pdu.AsSpan(1), address);
        BinaryPrimitives.WriteUInt16BigEndian(pdu.AsSpan(3), quantity);
        return pdu;
    }

    public static byte[] WriteSingleCoilRequest(ushort address, bool value) =>
        ReadRequest(FunctionCodes.WriteSingleCoil, address, value ? (ushort)0xFF00 : (ushort)0x0000);

    public static byte[] WriteSingleRegisterRequest(ushort address, ushort value) =>
        ReadRequest(FunctionCodes.WriteSingleRegister, address, value);

    public static byte[] WriteMultipleRegistersRequest(ushort address, ReadOnlySpan<ushort> values)
    {
        var pdu = new byte[6 + 2 * values.Length];
        pdu[0] = FunctionCodes.WriteMultipleRegisters;
        BinaryPrimitives.WriteUInt16BigEndian(pdu.AsSpan(1), address);
        BinaryPrimitives.WriteUInt16BigEndian(pdu.AsSpan(3), (ushort)values.Length);
        pdu[5] = (byte)(2 * values.Length);
        for (var i = 0; i < values.Length; i++)
        {
            BinaryPrimitives.WriteUInt16BigEndian(pdu.AsSpan(6 + 2 * i), values[i]);
        }
        return pdu;
    }

Coil e discrete input sono impacchettati otto per byte, a partire dal bit meno significativo del primo byte:


    public static byte[] PackBits(ReadOnlySpan<bool> bits)
    {
        var output = new byte[(bits.Length + 7) / 8];
        for (var i = 0; i < bits.Length; i++)
        {
            if (bits[i])
            {
                output[i / 8] |= (byte)(1 << (i % 8));
            }
        }
        return output;
    }

    public static bool[] UnpackBits(ReadOnlySpan<byte> data, int count)
    {
        var bits = new bool[count];
        for (var i = 0; i < count; i++)
        {
            bits[i] = (data[i / 8] & (1 << (i % 8))) != 0;
        }
        return bits;
    }

La verifica delle risposte controlla codice funzione, byte di conteggio, lunghezza effettiva ed eco delle scritture. Le funzioni 0x05 e 0x06 rispondono con una copia della richiesta, la 0x10 con i suoi primi cinque byte: in entrambi i casi basta confrontare i primi cinque byte con SequenceEqual, che sugli span di byte è vettorizzato.


    public static void CheckFunction(byte function, ReadOnlySpan<byte> pdu)
    {
        if (pdu.IsEmpty)
        {
            throw new ModbusProtocolException("empty response");
        }
        if (pdu[0] == (function | 0x80))
        {
            if (pdu.Length != 2)
            {
                throw new ModbusProtocolException("malformed exception response");
            }
            throw new ModbusDeviceException(function, pdu[1]);
        }
        if (pdu[0] != function)
        {
            throw new ModbusProtocolException($"unexpected function 0x{pdu[0]:X2}");
        }
    }

    public static bool[] ParseBits(byte function, ReadOnlySpan<byte> pdu, int quantity)
    {
        CheckFunction(function, pdu);
        var expected = (quantity + 7) / 8;
        if (pdu.Length != 2 + expected || pdu[1] != expected)
        {
            throw new ModbusProtocolException("unexpected byte count");
        }
        return UnpackBits(pdu[2..], quantity);
    }

    public static ushort[] ParseRegisters(byte function, ReadOnlySpan<byte> pdu, int quantity)
    {
        CheckFunction(function, pdu);
        var expected = 2 * quantity;
        if (pdu.Length != 2 + expected || pdu[1] != expected)
        {
            throw new ModbusProtocolException("unexpected byte count");
        }
        var values = new ushort[quantity];
        for (var i = 0; i < quantity; i++)
        {
            values[i] = BinaryPrimitives.ReadUInt16BigEndian(pdu[(2 + 2 * i)..]);
        }
        return values;
    }

    public static void ParseEcho(ReadOnlySpan<byte> request, ReadOnlySpan<byte> pdu)
    {
        CheckFunction(request[0], pdu);
        if (!pdu.SequenceEqual(request[..5]))
        {
            throw new ModbusProtocolException("write echo mismatch");
        }
    }
}

Il client asincrono

Il client incapsula un TcpClient e il relativo NetworkStream. Per serializzare le transazioni usiamo un SemaphoreSlim con un solo posto: a differenza dell'istruzione lock, può essere atteso in modo asincrono, e un await dentro un blocco lock non è consentito dal compilatore. Molti dispositivi embedded elaborano una richiesta alla volta, quindi la serializzazione è la scelta più sicura.

using System.Buffers.Binary;
using System.Net.Sockets;

namespace Modbus;

// Client Modbus TCP sicuro per l'uso concorrente
public sealed class ModbusTcpClient : IAsyncDisposable, IDisposable
{
    private readonly TcpClient _tcp;
    private readonly NetworkStream _stream;
    private readonly SemaphoreSlim _gate = new(1, 1);
    private readonly byte _unitId;
    private readonly TimeSpan _timeout;
    private ushort _transactionId;
    private bool _closed;

    private ModbusTcpClient(TcpClient tcp, byte unitId, TimeSpan timeout)
    {
        _tcp = tcp;
        _stream = tcp.GetStream();
        _unitId = unitId;
        _timeout = timeout;
    }

    public static async Task<ModbusTcpClient> ConnectAsync(
        string host,
        int port = 502,
        byte unitId = 1,
        TimeSpan? timeout = null,
        CancellationToken cancellationToken = default)
    {
        var effectiveTimeout = timeout ?? TimeSpan.FromSeconds(2);
        // i frame Modbus sono piccoli: disabilitiamo l'algoritmo di Nagle
        var tcp = new TcpClient { NoDelay = true };
        using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
        cts.CancelAfter(effectiveTimeout);
        try
        {
            await tcp.ConnectAsync(host, port, cts.Token);
        }
        catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
        {
            tcp.Dispose();
            throw new TimeoutException($"connection to {host}:{port} timed out");
        }
        catch
        {
            tcp.Dispose();
            throw;
        }
        return new ModbusTcpClient(tcp, unitId, effectiveTimeout);
    }

Il filtro when sulla clausola catch distingue i due motivi per cui l'operazione può essere annullata. Se è stato il chiamante ad annullare il proprio token, lasciamo propagare l'OperationCanceledException com'è; se invece è scaduto il nostro timer interno, la trasformiamo in un TimeoutException, che descrive meglio cosa è successo.

Il metodo TransactAsync esegue una transazione completa. Un unico CancellationTokenSource collegato al token del chiamante copre scrittura e letture con una sola scadenza. ReadExactlyAsync, disponibile da .NET 7, risolve il problema del framing su TCP: un frame può arrivare spezzato in più segmenti, ma il metodo restituisce il controllo solo quando ha riempito l'intero buffer.


    private async Task<byte[]> TransactAsync(byte[] pdu, CancellationToken cancellationToken)
    {
        await _gate.WaitAsync(cancellationToken);
        try
        {
            ObjectDisposedException.ThrowIf(_closed, this);
            var tid = ++_transactionId;
            var frame = new byte[7 + pdu.Length];
            BinaryPrimitives.WriteUInt16BigEndian(frame.AsSpan(0), tid);
            BinaryPrimitives.WriteUInt16BigEndian(frame.AsSpan(2), 0);
            BinaryPrimitives.WriteUInt16BigEndian(frame.AsSpan(4), (ushort)(pdu.Length + 1));
            frame[6] = _unitId;
            pdu.CopyTo(frame, 7);

            using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
            cts.CancelAfter(_timeout);
            try
            {
                await _stream.WriteAsync(frame, cts.Token);
                var header = new byte[7];
                while (true)
                {
                    await _stream.ReadExactlyAsync(header, cts.Token);
                    var responseTid = BinaryPrimitives.ReadUInt16BigEndian(header.AsSpan(0));
                    var protocol = BinaryPrimitives.ReadUInt16BigEndian(header.AsSpan(2));
                    var length = BinaryPrimitives.ReadUInt16BigEndian(header.AsSpan(4));
                    if (protocol != 0 || length < 2 || length > 254)
                    {
                        throw new ModbusProtocolException("invalid MBAP header");
                    }
                    var body = new byte[length - 1];
                    await _stream.ReadExactlyAsync(body, cts.Token);
                    if (responseTid != tid)
                    {
                        // risposta tardiva a una transazione precedente
                        continue;
                    }
                    if (header[6] != _unitId)
                    {
                        throw new ModbusProtocolException($"unexpected unit id {header[6]}");
                    }
                    return body;
                }
            }
            catch (Exception ex) when (ex is IOException or OperationCanceledException or ModbusProtocolException)
            {
                // lo stream non e' piu' affidabile: chiudiamo la connessione
                Abort();
                if (ex is OperationCanceledException && !cancellationToken.IsCancellationRequested)
                {
                    throw new TimeoutException("response timeout", ex);
                }
                throw;
            }
        }
        finally
        {
            _gate.Release();
        }
    }

    private void Abort()
    {
        _closed = true;
        _tcp.Dispose();
    }

La regola applicata nel blocco catch è la stessa per timeout, errori di rete e risposte malformate: la connessione viene chiusa. Dopo un timeout, infatti, la risposta potrebbe arrivare un istante dopo e verrebbe scambiata per la risposta alla transazione successiva. Il controllo sul transaction identifier resta come difesa aggiuntiva. L'incremento di _transactionId, un ushort, ritorna a zero dopo 65535 senza errori, perché per impostazione predefinita C# non controlla gli overflow aritmetici.

L'API pubblica ha bisogno di poca validazione grazie ai tipi: indirizzi e valori sono ushort, quindi restano da controllare solo le quantità. Gli helper statici di ArgumentOutOfRangeException, introdotti in .NET 8, rendono i controlli concisi e producono messaggi con il nome del parametro.


    private static void CheckQuantity(int quantity, int max, string paramName)
    {
        ArgumentOutOfRangeException.ThrowIfLessThan(quantity, 1, paramName);
        ArgumentOutOfRangeException.ThrowIfGreaterThan(quantity, max, paramName);
    }

    public Task<bool[]> ReadCoilsAsync(ushort address, ushort quantity, CancellationToken ct = default) =>
        ReadBitsAsync(FunctionCodes.ReadCoils, address, quantity, ct);

    public Task<bool[]> ReadDiscreteInputsAsync(ushort address, ushort quantity, CancellationToken ct = default) =>
        ReadBitsAsync(FunctionCodes.ReadDiscreteInputs, address, quantity, ct);

    public Task<ushort[]> ReadHoldingRegistersAsync(ushort address, ushort quantity, CancellationToken ct = default) =>
        ReadRegistersAsync(FunctionCodes.ReadHoldingRegisters, address, quantity, ct);

    public Task<ushort[]> ReadInputRegistersAsync(ushort address, ushort quantity, CancellationToken ct = default) =>
        ReadRegistersAsync(FunctionCodes.ReadInputRegisters, address, quantity, ct);

    public async Task WriteSingleCoilAsync(ushort address, bool value, CancellationToken ct = default)
    {
        var request = Pdu.WriteSingleCoilRequest(address, value);
        var response = await TransactAsync(request, ct);
        Pdu.ParseEcho(request, response);
    }

    public async Task WriteSingleRegisterAsync(ushort address, ushort value, CancellationToken ct = default)
    {
        var request = Pdu.WriteSingleRegisterRequest(address, value);
        var response = await TransactAsync(request, ct);
        Pdu.ParseEcho(request, response);
    }

    public async Task WriteMultipleRegistersAsync(ushort address, ushort[] values, CancellationToken ct = default)
    {
        CheckQuantity(values.Length, Limits.MaxWriteRegisters, nameof(values));
        var request = Pdu.WriteMultipleRegistersRequest(address, values);
        var response = await TransactAsync(request, ct);
        Pdu.ParseEcho(request, response);
    }

    private async Task<bool[]> ReadBitsAsync(byte function, ushort address, ushort quantity, CancellationToken ct)
    {
        CheckQuantity(quantity, Limits.MaxReadBits, nameof(quantity));
        var response = await TransactAsync(Pdu.ReadRequest(function, address, quantity), ct);
        return Pdu.ParseBits(function, response, quantity);
    }

    private async Task<ushort[]> ReadRegistersAsync(byte function, ushort address, ushort quantity, CancellationToken ct)
    {
        CheckQuantity(quantity, Limits.MaxReadRegisters, nameof(quantity));
        var response = await TransactAsync(Pdu.ReadRequest(function, address, quantity), ct);
        return Pdu.ParseRegisters(function, response, quantity);
    }

    public void Dispose()
    {
        Abort();
        _gate.Dispose();
    }

    public ValueTask DisposeAsync()
    {
        Dispose();
        return ValueTask.CompletedTask;
    }
}

Nei metodi di scrittura la risposta viene prima assegnata a una variabile e solo dopo passata a ParseEcho. Scrivere Pdu.ParseEcho(request, await TransactAsync(request, ct)) non compilerebbe: il primo argomento verrebbe convertito in ReadOnlySpan<byte> prima dell'await, e uno span, che vive solo sullo stack, non può sopravvivere alla sospensione di un metodo asincrono.

C'è una sottigliezza anche nei metodi di lettura: ReadCoilsAsync restituisce direttamente il Task di ReadBitsAsync, mentre la validazione avviene dentro un metodo async. In questo modo un'eventuale ArgumentOutOfRangeException viene catturata nel task e sollevata all'await, coerentemente con tutti gli altri errori, invece di essere lanciata in modo sincrono al momento della chiamata.

La memoria dati

La memoria del server contiene le quattro tabelle e le protegge con System.Threading.Lock, il tipo dedicato introdotto in .NET 9. Quando l'istruzione lock riceve un oggetto di questo tipo, il compilatore usa la sua API ottimizzata invece del meccanismo generico basato su Monitor. Il metodo Update permette all'applicazione di modificare più valori in modo atomico rispetto alle richieste dei client.

using System.Buffers.Binary;

namespace Modbus;

// Memoria dati del server con le quattro tabelle Modbus
public sealed class DataStore(int size)
{
    private readonly Lock _lock = new();

    public bool[] Coils { get; } = new bool[size];
    public bool[] DiscreteInputs { get; } = new bool[size];
    public ushort[] HoldingRegisters { get; } = new ushort[size];
    public ushort[] InputRegisters { get; } = new ushort[size];

    public void Update(Action<DataStore> action)
    {
        lock (_lock)
        {
            action(this);
        }
    }

    // Elabora una PDU di richiesta e restituisce la PDU di risposta
    public byte[] Handle(ReadOnlySpan<byte> pdu)
    {
        if (pdu.IsEmpty)
        {
            return Exception(0, ExceptionCodes.IllegalFunction);
        }
        var function = pdu[0];
        lock (_lock)
        {
            return function switch
            {
                FunctionCodes.ReadCoils or FunctionCodes.ReadDiscreteInputs => ReadBits(function, pdu),
                FunctionCodes.ReadHoldingRegisters or FunctionCodes.ReadInputRegisters => ReadRegisters(function, pdu),
                FunctionCodes.WriteSingleCoil => WriteSingleCoil(pdu),
                FunctionCodes.WriteSingleRegister => WriteSingleRegister(pdu),
                FunctionCodes.WriteMultipleRegisters => WriteMultipleRegisters(pdu),
                _ => Exception(function, ExceptionCodes.IllegalFunction),
            };
        }
    }

    private static byte[] Exception(byte function, byte code) => [(byte)(function | 0x80), code];

L'espressione [(byte)(function | 0x80), code] è una collection expression di C# 12, che qui crea un array di due byte. I gestori delle singole funzioni seguono l'ordine di controllo indicato dalla specifica: prima la forma della richiesta e le quantità (eccezione 0x03), poi gli indirizzi (eccezione 0x02). Gli slice di array come Coils.AsSpan(address, quantity) passano a PackBits una vista sui dati senza copiarli.


    private byte[] ReadBits(byte function, ReadOnlySpan<byte> pdu)
    {
        if (pdu.Length != 5)
        {
            return Exception(function, ExceptionCodes.IllegalDataValue);
        }
        int address = BinaryPrimitives.ReadUInt16BigEndian(pdu[1..]);
        int quantity = BinaryPrimitives.ReadUInt16BigEndian(pdu[3..]);
        if (quantity is < 1 or > Limits.MaxReadBits)
        {
            return Exception(function, ExceptionCodes.IllegalDataValue);
        }
        var table = function == FunctionCodes.ReadCoils ? Coils : DiscreteInputs;
        if (address + quantity > table.Length)
        {
            return Exception(function, ExceptionCodes.IllegalDataAddress);
        }
        var data = Pdu.PackBits(table.AsSpan(address, quantity));
        return [function, (byte)data.Length, .. data];
    }

    private byte[] ReadRegisters(byte function, ReadOnlySpan<byte> pdu)
    {
        if (pdu.Length != 5)
        {
            return Exception(function, ExceptionCodes.IllegalDataValue);
        }
        int address = BinaryPrimitives.ReadUInt16BigEndian(pdu[1..]);
        int quantity = BinaryPrimitives.ReadUInt16BigEndian(pdu[3..]);
        if (quantity is < 1 or > Limits.MaxReadRegisters)
        {
            return Exception(function, ExceptionCodes.IllegalDataValue);
        }
        var table = function == FunctionCodes.ReadHoldingRegisters ? HoldingRegisters : InputRegisters;
        if (address + quantity > table.Length)
        {
            return Exception(function, ExceptionCodes.IllegalDataAddress);
        }
        var response = new byte[2 + 2 * quantity];
        response[0] = function;
        response[1] = (byte)(2 * quantity);
        for (var i = 0; i < quantity; i++)
        {
            BinaryPrimitives.WriteUInt16BigEndian(response.AsSpan(2 + 2 * i), table[address + i]);
        }
        return response;
    }

    private byte[] WriteSingleCoil(ReadOnlySpan<byte> pdu)
    {
        const byte function = FunctionCodes.WriteSingleCoil;
        if (pdu.Length != 5)
        {
            return Exception(function, ExceptionCodes.IllegalDataValue);
        }
        int address = BinaryPrimitives.ReadUInt16BigEndian(pdu[1..]);
        var value = BinaryPrimitives.ReadUInt16BigEndian(pdu[3..]);
        if (value is not (0xFF00 or 0x0000))
        {
            return Exception(function, ExceptionCodes.IllegalDataValue);
        }
        if (address >= Coils.Length)
        {
            return Exception(function, ExceptionCodes.IllegalDataAddress);
        }
        Coils[address] = value == 0xFF00;
        return pdu.ToArray();
    }

    private byte[] WriteSingleRegister(ReadOnlySpan<byte> pdu)
    {
        const byte function = FunctionCodes.WriteSingleRegister;
        if (pdu.Length != 5)
        {
            return Exception(function, ExceptionCodes.IllegalDataValue);
        }
        int address = BinaryPrimitives.ReadUInt16BigEndian(pdu[1..]);
        if (address >= HoldingRegisters.Length)
        {
            return Exception(function, ExceptionCodes.IllegalDataAddress);
        }
        HoldingRegisters[address] = BinaryPrimitives.ReadUInt16BigEndian(pdu[3..]);
        return pdu.ToArray();
    }

    private byte[] WriteMultipleRegisters(ReadOnlySpan<byte> pdu)
    {
        const byte function = FunctionCodes.WriteMultipleRegisters;
        if (pdu.Length < 6)
        {
            return Exception(function, ExceptionCodes.IllegalDataValue);
        }
        int address = BinaryPrimitives.ReadUInt16BigEndian(pdu[1..]);
        int quantity = BinaryPrimitives.ReadUInt16BigEndian(pdu[3..]);
        int byteCount = pdu[5];
        if (quantity is < 1 or > Limits.MaxWriteRegisters || byteCount != 2 * quantity || pdu.Length != 6 + byteCount)
        {
            return Exception(function, ExceptionCodes.IllegalDataValue);
        }
        if (address + quantity > HoldingRegisters.Length)
        {
            return Exception(function, ExceptionCodes.IllegalDataAddress);
        }
        for (var i = 0; i < quantity; i++)
        {
            HoldingRegisters[address + i] = BinaryPrimitives.ReadUInt16BigEndian(pdu[(6 + 2 * i)..]);
        }
        return pdu[..5].ToArray();
    }
}

Indirizzi e quantità vengono letti come ushort ma assegnati a variabili int: la somma address + quantity avviene così in aritmetica a 32 bit, senza rischio di overflow con valori vicini a 65535. I pattern relazionali come quantity is < 1 or > Limits.MaxReadBits rendono i controlli di intervallo particolarmente leggibili.

Il server

Il server usa TcpListener con un ciclo di accettazione asincrono, e avvia per ogni connessione un task che legge i frame in sequenza. Un CancellationTokenSource condiviso permette di fermare tutto in modo ordinato: annullandolo si interrompono sia l'accettazione sia le letture in corso su tutte le connessioni.

using System.Buffers.Binary;
using System.Net;
using System.Net.Sockets;

namespace Modbus;

/// <param name="unitId">0 accetta qualunque unit identifier</param>
/// <param name="idleTimeout">inattivita' massima per connessione, null per disabilitare</param>
public sealed class ModbusTcpServer(DataStore store, byte unitId = 0, TimeSpan? idleTimeout = null)
    : IAsyncDisposable
{
    private readonly CancellationTokenSource _shutdown = new();
    private TcpListener? _listener;
    private Task _acceptLoop = Task.CompletedTask;

    public int Port => ((IPEndPoint)(_listener ?? throw new InvalidOperationException("server not started")).LocalEndpoint).Port;

    public void Start(IPAddress address, int port)
    {
        _listener = new TcpListener(address, port);
        _listener.Start();
        _acceptLoop = AcceptLoopAsync(_listener, _shutdown.Token);
    }

    private async Task AcceptLoopAsync(TcpListener listener, CancellationToken ct)
    {
        try
        {
            while (true)
            {
                var client = await listener.AcceptTcpClientAsync(ct);
                _ = ServeAsync(client, ct);
            }
        }
        catch (OperationCanceledException)
        {
            // arresto del server
        }
    }

Ogni connessione crea, a ogni frame, un token collegato che scade dopo il tempo di inattività. In questo modo le connessioni abbandonate vengono chiuse automaticamente, cosa importante perché molti dispositivi reali accettano pochissime connessioni contemporanee e un client che si riavvia senza chiudere il socket ne occuperebbe una per sempre. Un'intestazione non valida chiude la connessione: dopo un errore di framing non è possibile ritrovare l'inizio del frame successivo.


    private async Task ServeAsync(TcpClient client, CancellationToken ct)
    {
        using (client)
        {
            client.NoDelay = true;
            var stream = client.GetStream();
            var header = new byte[7];
            try
            {
                while (true)
                {
                    using var idle = CancellationTokenSource.CreateLinkedTokenSource(ct);
                    if (idleTimeout is { } timeout && timeout > TimeSpan.Zero)
                    {
                        idle.CancelAfter(timeout);
                    }
                    await stream.ReadExactlyAsync(header, idle.Token);
                    var protocol = BinaryPrimitives.ReadUInt16BigEndian(header.AsSpan(2));
                    var length = BinaryPrimitives.ReadUInt16BigEndian(header.AsSpan(4));
                    if (protocol != 0 || length < 2 || length > 254)
                    {
                        return;
                    }
                    var pdu = new byte[length - 1];
                    await stream.ReadExactlyAsync(pdu, idle.Token);
                    var unit = header[6];
                    if (unitId != 0 && unit != unitId)
                    {
                        // un dispositivo reale ignora le richieste destinate ad altri
                        continue;
                    }
                    var response = store.Handle(pdu);
                    var frame = new byte[7 + response.Length];
                    header.AsSpan(0, 4).CopyTo(frame);
                    BinaryPrimitives.WriteUInt16BigEndian(frame.AsSpan(4), (ushort)(response.Length + 1));
                    frame[6] = unit;
                    response.CopyTo(frame, 7);
                    await stream.WriteAsync(frame, ct);
                }
            }
            catch (Exception ex) when (ex is IOException or OperationCanceledException or ObjectDisposedException)
            {
                // disconnessione, inattivita' o arresto del server
            }
        }
    }

    public async ValueTask DisposeAsync()
    {
        await _shutdown.CancelAsync();
        _listener?.Stop();
        await _acceptLoop;
        _shutdown.Dispose();
    }
}

Il task restituito da ServeAsync viene scartato con _ =, e per questo il metodo cattura al proprio interno tutte le eccezioni attese. Un'eccezione non gestita in un task scartato non farebbe terminare il processo, ma andrebbe persa in silenzio, ed è il tipo di errore più difficile da diagnosticare.

Dai registri ai valori

Un registro contiene 16 bit, e il significato dipende dal dispositivo. Le codifiche più frequenti sono l'intero con segno a 16 bit, spesso scalato, l'intero a 32 bit su due registri e il float IEEE 754 su due registri. Per i valori a 32 bit la specifica non stabilisce l'ordine dei registri: alcuni dispositivi mettono per prima la parola alta (ordine ABCD), altri la parola bassa (CDAB).

namespace Modbus;

public enum WordOrder
{
    // parola piu' significativa nel primo registro (ABCD)
    HighWordFirst,
    // parola meno significativa nel primo registro (CDAB)
    LowWordFirst,
}

// Conversioni tra registri Modbus e tipi numerici
public static class Codec
{
    // Interpreta un registro come intero con segno in complemento a due
    public static short Int16(ushort register) => unchecked((short)register);

    public static uint UInt32(ReadOnlySpan<ushort> registers, WordOrder order)
    {
        if (registers.Length != 2)
        {
            throw new ArgumentException("exactly two registers are required", nameof(registers));
        }
        var (high, low) = order == WordOrder.HighWordFirst
            ? (registers[0], registers[1])
            : (registers[1], registers[0]);
        return ((uint)high << 16) | low;
    }

    public static ushort[] FromUInt32(uint value, WordOrder order)
    {
        var high = (ushort)(value >> 16);
        var low = (ushort)value;
        return order == WordOrder.HighWordFirst ? [high, low] : [low, high];
    }

    public static float Float32(ReadOnlySpan<ushort> registers, WordOrder order) =>
        BitConverter.UInt32BitsToSingle(UInt32(registers, order));

    public static ushort[] FromFloat32(float value, WordOrder order) =>
        FromUInt32(BitConverter.SingleToUInt32Bits(value), order);
}

Il cast unchecked((short)register) reinterpreta i 16 bit in complemento a due: 0xFF38 diventa -200, cioè -20,0 gradi per un dispositivo che lavora in decimi. I metodi UInt32BitsToSingle e SingleToUInt32Bits di BitConverter convertono tra la rappresentazione binaria e il valore in virgola mobile senza passare per array di byte. Quando la documentazione non chiarisce l'ordine delle parole, conviene leggere una grandezza nota, come la tensione di rete, e provare entrambe le interpretazioni.

Il CRC di Modbus RTU

Molti dispositivi parlano solo Modbus RTU su linea seriale, e alcuni gateway inoltrano su TCP i frame RTU senza modificarli. In questi casi serve il CRC-16 di Modbus, con polinomio riflesso 0xA001 e valore iniziale 0xFFFF, trasmesso con il byte meno significativo per primo.

using System.Buffers.Binary;

namespace Modbus;

// CRC16 di Modbus RTU
public static class Crc16
{
    public static ushort Compute(ReadOnlySpan<byte> data)
    {
        ushort crc = 0xFFFF;
        foreach (var b in data)
        {
            crc ^= b;
            for (var i = 0; i < 8; i++)
            {
                crc = (crc & 1) != 0 ? (ushort)((crc >> 1) ^ 0xA001) : (ushort)(crc >> 1);
            }
        }
        return crc;
    }

    // Aggiunge il CRC al frame, byte meno significativo per primo
    public static byte[] Append(ReadOnlySpan<byte> frame)
    {
        var output = new byte[frame.Length + 2];
        frame.CopyTo(output);
        BinaryPrimitives.WriteUInt16LittleEndian(output.AsSpan(frame.Length), Compute(frame));
        return output;
    }
}

La richiesta RTU di dieci holding register all'unità 1 è 01 03 00 00 00 0A C5 CD. Per lavorare con una porta seriale su .NET si usa il pacchetto System.IO.Ports; costruzione e verifica dei frame restano quelle viste qui.

I test

Il progetto di test usa xUnit v3, che compila i test come eseguibile autonomo. L'interfaccia IAsyncLifetime fornisce setup e teardown asincroni: ogni test riceve un server nuovo, in ascolto sulla porta 0 scelta dal sistema operativo, e un client già connesso.

<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <TargetFramework>net10.0</TargetFramework>
    <OutputType>Exe</OutputType>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.*" />
    <PackageReference Include="xunit.v3" Version="3.*" />
    <PackageReference Include="xunit.runner.visualstudio" Version="3.*" />
  </ItemGroup>

  <ItemGroup>
    <ProjectReference Include="..\Modbus\Modbus.csproj" />
  </ItemGroup>

</Project>
using System.Net;
using System.Net.Sockets;
using Modbus;
using Xunit;

namespace Modbus.Tests;

public sealed class ModbusTests : IAsyncLifetime
{
    private readonly DataStore _store = new(100);
    private ModbusTcpServer _server = null!;
    private ModbusTcpClient _client = null!;

    public async ValueTask InitializeAsync()
    {
        _server = new ModbusTcpServer(_store);
        _server.Start(IPAddress.Loopback, 0);
        _client = await ModbusTcpClient.ConnectAsync("127.0.0.1", _server.Port, timeout: TimeSpan.FromSeconds(1));
    }

    public async ValueTask DisposeAsync()
    {
        await _client.DisposeAsync();
        await _server.DisposeAsync();
    }

    [Fact]
    public async Task RegistersRoundTrip()
    {
        await _client.WriteMultipleRegistersAsync(10, [1, 2, 0xBEEF]);
        await _client.WriteSingleRegisterAsync(13, 42);
        Assert.Equal(new ushort[] { 1, 2, 0xBEEF, 42 }, await _client.ReadHoldingRegistersAsync(10, 4));
    }

    [Fact]
    public async Task CoilsAndInputs()
    {
        _store.Update(s =>
        {
            s.DiscreteInputs[9] = true;
            s.InputRegisters[0] = 0xFF38;
        });
        await _client.WriteSingleCoilAsync(3, true);
        var coils = await _client.ReadCoilsAsync(0, 10);
        for (var i = 0; i < coils.Length; i++)
        {
            Assert.Equal(i == 3, coils[i]);
        }
        var inputs = await _client.ReadDiscreteInputsAsync(0, 10);
        Assert.True(inputs[9]);
        Assert.False(inputs[8]);
        var registers = await _client.ReadInputRegistersAsync(0, 1);
        Assert.Equal(-200, Codec.Int16(registers[0]));
    }

    [Fact]
    public async Task IllegalAddressKeepsConnection()
    {
        var ex = await Assert.ThrowsAsync<ModbusDeviceException>(() => _client.ReadHoldingRegistersAsync(95, 10));
        Assert.Equal(ExceptionCodes.IllegalDataAddress, ex.ExceptionCode);
        // dopo un'eccezione Modbus la connessione resta valida
        Assert.Equal(new ushort[] { 0 }, await _client.ReadHoldingRegistersAsync(0, 1));
    }

    [Fact]
    public async Task ClientSideValidation()
    {
        await Assert.ThrowsAsync<ArgumentOutOfRangeException>(() => _client.ReadHoldingRegistersAsync(0, 126));
        await Assert.ThrowsAsync<ArgumentOutOfRangeException>(() => _client.WriteMultipleRegistersAsync(0, []));
    }

    [Fact]
    public async Task TimeoutClosesClient()
    {
        // un listener che non accetta mai: la connessione resta nel backlog del kernel
        var silent = new TcpListener(IPAddress.Loopback, 0);
        silent.Start();
        try
        {
            var port = ((IPEndPoint)silent.LocalEndpoint).Port;
            await using var slow = await ModbusTcpClient.ConnectAsync("127.0.0.1", port, timeout: TimeSpan.FromMilliseconds(200));
            await Assert.ThrowsAsync<TimeoutException>(() => slow.ReadCoilsAsync(0, 1));
            await Assert.ThrowsAsync<ObjectDisposedException>(() => slow.ReadCoilsAsync(0, 1));
        }
        finally
        {
            silent.Stop();
        }
    }

    [Fact]
    public void IllegalFunction()
    {
        Assert.Equal(new byte[] { 0xAB, 0x01 }, new DataStore(10).Handle([0x2B, 0x0E]));
    }

    [Fact]
    public void FloatWordOrder()
    {
        foreach (var order in Enum.GetValues<WordOrder>())
        {
            Assert.Equal(230.5f, Codec.Float32(Codec.FromFloat32(230.5f, order), order));
        }
        Assert.Equal(new ushort[] { 0x3F80, 0x0000 }, Codec.FromFloat32(1.0f, WordOrder.HighWordFirst));
        Assert.Equal(new ushort[] { 0x0000, 0x3F80 }, Codec.FromFloat32(1.0f, WordOrder.LowWordFirst));
    }

    [Fact]
    public void Crc16Value()
    {
        var frame = Crc16.Append([0x01, 0x03, 0x00, 0x00, 0x00, 0x0A]);
        Assert.Equal(new byte[] { 0xC5, 0xCD }, frame[^2..]);
    }
}

Il test sul timeout sfrutta un comportamento del kernel: un socket in ascolto completa l'handshake TCP anche se l'applicazione non accetta mai la connessione, quindi il client si connette ma nessuno risponderà. Le versioni dei pacchetti sono indicate con un numero di versione flottante sulla major; in un progetto reale conviene fissarle o usare la gestione centralizzata dei pacchetti. Si eseguono i test con dotnet test.

Un programma dimostrativo

Il programma dimostrativo, scritto con le istruzioni di primo livello, simula un misuratore di energia che pubblica la tensione come float negli input register 0 e 1, e la legge con il client cinque volte. Un PeriodicTimer aggiorna la misura ogni secondo.

<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net10.0</TargetFramework>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
  </PropertyGroup>

  <ItemGroup>
    <ProjectReference Include="..\Modbus\Modbus.csproj" />
  </ItemGroup>

</Project>
using System.Net;
using Modbus;

var store = new DataStore(10);

void UpdateVoltage()
{
    var voltage = 228 + Random.Shared.NextSingle() * 4;
    var registers = Codec.FromFloat32(voltage, WordOrder.HighWordFirst);
    store.Update(s => registers.CopyTo(s.InputRegisters, 0));
}

UpdateVoltage();
using var cts = new CancellationTokenSource();
var simulator = Task.Run(async () =>
{
    using var timer = new PeriodicTimer(TimeSpan.FromSeconds(1));
    try
    {
        while (await timer.WaitForNextTickAsync(cts.Token))
        {
            UpdateVoltage();
        }
    }
    catch (OperationCanceledException)
    {
        // arresto della simulazione
    }
});

await using var server = new ModbusTcpServer(store, idleTimeout: TimeSpan.FromMinutes(1));
server.Start(IPAddress.Loopback, 1502);

await using (var client = await ModbusTcpClient.ConnectAsync("127.0.0.1", 1502))
{
    for (var i = 0; i < 5; i++)
    {
        var registers = await client.ReadInputRegistersAsync(0, 2);
        var voltage = Codec.Float32(registers, WordOrder.HighWordFirst);
        Console.WriteLine($"voltage: {voltage:F2} V (raw {registers[0]:X4} {registers[1]:X4})");
        await Task.Delay(1000);
    }
}

await cts.CancelAsync();
await simulator;

Si avvia con dotnet run --project Demo. La porta 1502 evita di dover eseguire il programma con privilegi elevati, necessari su Linux per la porta standard 502.

Considerazioni per la produzione

  • Riconnessione: il client si chiude dopo ogni errore di trasporto. Un servizio di polling, tipicamente un BackgroundService ospitato nel generic host, deve ricrearlo con un backoff esponenziale. Una ModbusDeviceException, invece, non richiede di riaprire la connessione.
  • Letture raggruppate: ogni transazione costa almeno un round trip, e dietro un gateway seriale anche decine di millisecondi. Leggere blocchi contigui di registri riduce drasticamente i tempi di un ciclo di polling.
  • Rispetto dei dispositivi: un polling troppo frequente può saturare dispositivi con microcontrollori modesti e farli smettere di rispondere.
  • Sicurezza: Modbus TCP non prevede autenticazione né cifratura. Chiunque raggiunga la porta 502 può scrivere registri. I dispositivi vanno isolati in una rete dedicata, con l'accesso filtrato dal firewall. La variante Modbus/TCP Security usa TLS sulla porta 802, ma pochi dispositivi la supportano.
  • Funzioni aggiuntive: la scrittura di più coil (0x0F), la scrittura con maschera (0x16) e la lettura e scrittura combinata (0x17) si aggiungono con lo stesso schema.

Tra le librerie disponibili su NuGet, FluentModbus offre client e server TCP e RTU, con una modalità asincrona e un accesso efficiente ai buffer dei registri tramite Span; NModbus è un'altra scelta diffusa e consolidata. Conoscere il formato dei byte resta comunque prezioso: con una cattura di Wireshark e la conoscenza dell'intestazione MBAP si capisce in pochi secondi se un problema dipende dal dispositivo, dalla rete o dal codice.

Conclusione

Abbiamo costruito un'implementazione di Modbus TCP completa per le funzioni più usate con la sola libreria di base di .NET. Il sistema dei tipi ha fatto buona parte del lavoro di validazione, grazie a ushort per indirizzi e registri. BinaryPrimitives e Span hanno reso la codifica binaria esplicita ed efficiente, mentre CancellationToken ha unificato timeout, annullamenti e arresto del server in un unico meccanismo. Il risultato è un client asincrono che gestisce framing e risposte tardive, un server che serve molte connessioni con un task ciascuna e una memoria dati thread-safe basata sul nuovo tipo Lock.

Commenti