Modbus TCP in Java: client e server con socket e virtual thread

In questo articolo implementiamo Modbus TCP in Java 21 senza dipendenze, usando solo il JDK.

Java di Gabriele Romanato

Java ha una lunga storia nell'automazione industriale: sistemi SCADA, MES, middleware di integrazione e gateway OPC UA sono spesso scritti per la JVM. In tutti questi sistemi, prima o poi, c'è da parlare con un dispositivo sul campo, e il protocollo più probabile è Modbus. Nato nel 1979 per i PLC Modicon, è sopravvissuto a decenni di evoluzione tecnologica grazie a una qualità rara: è semplice da capire e da implementare.

In questo articolo implementiamo Modbus TCP in Java 21 senza dipendenze, usando solo il JDK. Il client si appoggia ai socket bloccanti classici, che con i virtual thread tornano a essere la scelta più semplice e scalabile. Il server crea un virtual thread per ogni connessione, e una memoria dati protetta da un ReadWriteLock fa da backend. Completeremo il lavoro con le conversioni tra registri e tipi numerici, il CRC di Modbus RTU e una suite di test con JUnit.

Il modello dati

Un dispositivo Modbus espone quattro tabelle, ognuna con uno spazio di indirizzamento a 16 bit:

TabellaElementoAccessoUso tipico
Coil1 bitlettura e scritturauscite digitali, comandi
Discrete input1 bitsola letturaingressi digitali, stati
Holding register16 bitlettura e scritturaparametri, setpoint
Input register16 bitsola letturamisure

La documentazione dei dispositivi usa spesso la numerazione storica: 40001 per il primo holding register, 30001 per il primo input register. Questi numeri partono da 1, mentre gli indirizzi trasmessi partono da 0, quindi il registro 40001 si legge all'indirizzo 0. È la causa più comune di valori che sembrano spostati di una posizione.

Il formato dei frame

Ogni messaggio contiene una PDU, cioè un byte di codice funzione seguito dai parametri, fino a 253 byte. Su TCP la PDU è preceduta dall'intestazione MBAP di sette byte:

  • 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.

Tutti i campi sono in big-endian, e qui Java ci viene incontro: ByteBuffer e DataInputStream usano il big-endian come ordine predefinito, lo stesso di Modbus. Una lettura di dieci holding register dall'indirizzo 0 per l'unità 1 si costruisce così:

ByteBuffer frame = ByteBuffer.allocate(12)
        .putShort((short) 1)   // transaction identifier
        .putShort((short) 0)   // protocol identifier
        .putShort((short) 6)   // length
        .put((byte) 1)         // unit identifier
        .put((byte) 0x03)      // codice funzione
        .putShort((short) 0)   // indirizzo
        .putShort((short) 10); // quantita'
// 00 01 00 00 00 06 01 03 00 00 00 0A

Quando 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.

Il problema dei tipi senza segno

Java non ha tipi interi senza segno, e questo è il dettaglio che causa più errori quando si implementano protocolli binari. Un byte va da -128 a 127, uno short da -32768 a 32767. Un registro Modbus con valore 0xFF38 letto con getShort diventa -200, e un byte 0x83 diventa -125. Per confrontarli correttamente vanno convertiti in int con una maschera:

int function = buffer.get() & 0xFF;          // 0x83 -> 131, non -125
int register = buffer.getShort() & 0xFFFF;   // 0xFF38 -> 65336, non -200
// in alternativa: Byte.toUnsignedInt(b), Short.toUnsignedInt(s)

Nel resto del codice useremo int per tutti i valori del protocollo, mascherando a ogni lettura e troncando con un cast a ogni scrittura.

Struttura del progetto

modbus-java/
├── pom.xml
└── src/
    ├── main/java/com/example/modbus/
    │   ├── FunctionCode.java
    │   ├── ExceptionCode.java
    │   ├── ModbusException.java
    │   ├── ModbusProtocolException.java
    │   ├── ModbusDeviceException.java
    │   ├── Pdu.java
    │   ├── ModbusTcpClient.java
    │   ├── DataStore.java
    │   ├── ModbusTcpServer.java
    │   ├── WordOrder.java
    │   ├── Codec.java
    │   ├── Crc16.java
    │   └── Demo.java
    └── test/java/com/example/modbus/
        └── ModbusTest.java
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <groupId>com.example</groupId>
    <artifactId>modbus-java</artifactId>
    <version>0.1.0</version>

    <properties>
        <maven.compiler.release>21</maven.compiler.release>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
    </properties>

    <dependencyManagement>
        <dependencies>
            <dependency>
                <groupId>org.junit</groupId>
                <artifactId>junit-bom</artifactId>
                <version>6.1.3</version>
                <type>pom</type>
                <scope>import</scope>
            </dependency>
        </dependencies>
    </dependencyManagement>

    <dependencies>
        <dependency>
            <groupId>org.junit.jupiter</groupId>
            <artifactId>junit-jupiter</artifactId>
            <scope>test</scope>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-surefire-plugin</artifactId>
                <version>3.6.1</version>
            </plugin>
        </plugins>
    </build>
</project>

Costanti ed eccezioni

I codici funzione e di eccezione sono costanti intere, perché devono essere confrontati con i byte letti dalla rete. 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.

package com.example.modbus;

// Codici funzione supportati e limiti di quantita' per richiesta
public final class FunctionCode {
    public static final int READ_COILS = 0x01;
    public static final int READ_DISCRETE_INPUTS = 0x02;
    public static final int READ_HOLDING_REGISTERS = 0x03;
    public static final int READ_INPUT_REGISTERS = 0x04;
    public static final int WRITE_SINGLE_COIL = 0x05;
    public static final int WRITE_SINGLE_REGISTER = 0x06;
    public static final int WRITE_MULTIPLE_REGISTERS = 0x10;

    public static final int MAX_READ_BITS = 2000;
    public static final int MAX_READ_REGISTERS = 125;
    public static final int MAX_WRITE_REGISTERS = 123;

    private FunctionCode() {
    }
}
package com.example.modbus;

// Codici di eccezione definiti dalla specifica
public final class ExceptionCode {
    public static final int ILLEGAL_FUNCTION = 0x01;
    public static final int ILLEGAL_DATA_ADDRESS = 0x02;
    public static final int ILLEGAL_DATA_VALUE = 0x03;
    public static final int SERVER_DEVICE_FAILURE = 0x04;

    private ExceptionCode() {
    }

    public static String describe(int code) {
        return switch (code) {
            case ILLEGAL_FUNCTION -> "illegal function";
            case ILLEGAL_DATA_ADDRESS -> "illegal data address";
            case ILLEGAL_DATA_VALUE -> "illegal data value";
            case SERVER_DEVICE_FAILURE -> "server device failure";
            default -> "unknown";
        };
    }
}

Per gli errori estendiamo IOException: così tutti i metodi del client dichiarano un'unica eccezione controllata, e chi vuole distinguere i casi può farlo con blocchi catch specifici. ModbusDeviceException rappresenta un rifiuto ordinato del dispositivo, dopo il quale la connessione resta valida. ModbusProtocolException indica invece una risposta malformata. I timeout restano SocketTimeoutException del JDK, anch'essa una IOException.

package com.example.modbus;

import java.io.IOException;

// Classe base per gli errori specifici di Modbus
public class ModbusException extends IOException {
    public ModbusException(String message) {
        super(message);
    }
}
package com.example.modbus;

// Risposta malformata o incoerente con la richiesta
public class ModbusProtocolException extends ModbusException {
    public ModbusProtocolException(String message) {
        super(message);
    }
}
package com.example.modbus;

// Il dispositivo ha risposto con un'eccezione Modbus
public class ModbusDeviceException extends ModbusException {
    private final int function;
    private final int exceptionCode;

    public ModbusDeviceException(int function, int exceptionCode) {
        super("exception 0x%02X (%s) for function 0x%02X"
                .formatted(exceptionCode, ExceptionCode.describe(exceptionCode), function));
        this.function = function;
        this.exceptionCode = exceptionCode;
    }

    public int function() {
        return function;
    }

    public int exceptionCode() {
        return exceptionCode;
    }
}

Codifica delle PDU

La classe Pdu contiene metodi statici puri, senza alcuna dipendenza dalla rete. Le scritture singole hanno lo stesso layout delle letture, con il valore al posto della quantità; per i coil la specifica impone 0xFF00 per acceso e 0x0000 per spento.

package com.example.modbus;

import java.nio.ByteBuffer;
import java.util.Arrays;

// Codifica e decodifica delle PDU Modbus
public final class Pdu {
    private Pdu() {
    }

    public static byte[] readRequest(int function, int address, int quantity) {
        return ByteBuffer.allocate(5)
                .put((byte) function)
                .putShort((short) address)
                .putShort((short) quantity)
                .array();
    }

    public static byte[] writeSingleCoilRequest(int address, boolean value) {
        return readRequest(FunctionCode.WRITE_SINGLE_COIL, address, value ? 0xFF00 : 0x0000);
    }

    public static byte[] writeSingleRegisterRequest(int address, int value) {
        return readRequest(FunctionCode.WRITE_SINGLE_REGISTER, address, value);
    }

    public static byte[] writeMultipleRegistersRequest(int address, int[] values) {
        ByteBuffer buffer = ByteBuffer.allocate(6 + 2 * values.length)
                .put((byte) FunctionCode.WRITE_MULTIPLE_REGISTERS)
                .putShort((short) address)
                .putShort((short) values.length)
                .put((byte) (2 * values.length));
        for (int value : values) {
            buffer.putShort((short) value);
        }
        return buffer.array();
    }

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


    public static byte[] packBits(boolean[] bits, int offset, int count) {
        byte[] out = new byte[(count + 7) / 8];
        for (int i = 0; i < count; i++) {
            if (bits[offset + i]) {
                out[i / 8] |= (byte) (1 << (i % 8));
            }
        }
        return out;
    }

    public static boolean[] unpackBits(byte[] data, int offset, int count) {
        boolean[] bits = new boolean[count];
        for (int i = 0; i < count; i++) {
            bits[i] = (data[offset + i / 8] & (1 << (i % 8))) != 0;
        }
        return bits;
    }

Le risposte vanno verificate con cura: codice funzione, byte di conteggio e lunghezza effettiva. Per le scritture, le funzioni 0x05 e 0x06 rispondono con una copia della richiesta, la 0x10 con i suoi primi cinque byte; in entrambi i casi confrontiamo i primi cinque byte con Arrays.equals sugli intervalli.


    public static void checkFunction(int function, byte[] pdu) throws ModbusException {
        if (pdu.length == 0) {
            throw new ModbusProtocolException("empty response");
        }
        int code = pdu[0] & 0xFF;
        if (code == (function | 0x80)) {
            if (pdu.length != 2) {
                throw new ModbusProtocolException("malformed exception response");
            }
            throw new ModbusDeviceException(function, pdu[1] & 0xFF);
        }
        if (code != function) {
            throw new ModbusProtocolException("unexpected function 0x%02X".formatted(code));
        }
    }

    public static boolean[] parseBits(int function, byte[] pdu, int quantity) throws ModbusException {
        checkFunction(function, pdu);
        int expected = (quantity + 7) / 8;
        if (pdu.length != 2 + expected || (pdu[1] & 0xFF) != expected) {
            throw new ModbusProtocolException("unexpected byte count");
        }
        return unpackBits(pdu, 2, quantity);
    }

    public static int[] parseRegisters(int function, byte[] pdu, int quantity) throws ModbusException {
        checkFunction(function, pdu);
        int expected = 2 * quantity;
        if (pdu.length != 2 + expected || (pdu[1] & 0xFF) != expected) {
            throw new ModbusProtocolException("unexpected byte count");
        }
        ByteBuffer buffer = ByteBuffer.wrap(pdu, 2, expected);
        int[] values = new int[quantity];
        for (int i = 0; i < quantity; i++) {
            values[i] = buffer.getShort() & 0xFFFF;
        }
        return values;
    }

    public static void parseEcho(byte[] request, byte[] pdu) throws ModbusException {
        checkFunction(request[0] & 0xFF, pdu);
        if (pdu.length != 5 || !Arrays.equals(pdu, 0, 5, request, 0, 5)) {
            throw new ModbusProtocolException("write echo mismatch");
        }
    }
}

Il client

Il client incapsula un Socket con i relativi stream. DataInputStream.readFully risolve il problema del framing: TCP è uno stream e un frame può arrivare in più segmenti, ma readFully restituisce solo quando ha letto esattamente i byte richiesti, oppure solleva EOFException se la connessione si chiude prima.

Per la sincronizzazione usiamo un ReentrantLock invece di synchronized. Su Java 21 un virtual thread che si blocca su I/O all'interno di un blocco synchronized resta agganciato al proprio thread di piattaforma, un fenomeno chiamato pinning che riduce la scalabilità. Il problema è stato risolto in Java 24, ma con un ReentrantLock il codice si comporta bene su tutte le versioni.

package com.example.modbus;

import java.io.BufferedOutputStream;
import java.io.DataInputStream;
import java.io.DataOutputStream;
import java.io.IOException;
import java.net.InetSocketAddress;
import java.net.Socket;
import java.net.SocketTimeoutException;
import java.time.Duration;
import java.util.concurrent.locks.ReentrantLock;

// Client Modbus TCP sicuro per l'uso da piu' thread
public final class ModbusTcpClient implements AutoCloseable {
    private final Socket socket;
    private final DataInputStream in;
    private final DataOutputStream out;
    private final int unitId;
    private final Duration timeout;
    private final ReentrantLock lock = new ReentrantLock();
    private int transactionId;
    private boolean closed;

    private ModbusTcpClient(Socket socket, int unitId, Duration timeout) throws IOException {
        this.socket = socket;
        this.in = new DataInputStream(socket.getInputStream());
        this.out = new DataOutputStream(new BufferedOutputStream(socket.getOutputStream()));
        this.unitId = unitId;
        this.timeout = timeout;
    }

    public static ModbusTcpClient connect(String host, int port, int unitId, Duration timeout)
            throws IOException {
        Socket socket = new Socket();
        try {
            socket.connect(new InetSocketAddress(host, port), (int) timeout.toMillis());
            // i frame Modbus sono piccoli: disabilitiamo l'algoritmo di Nagle
            socket.setTcpNoDelay(true);
            return new ModbusTcpClient(socket, unitId, timeout);
        } catch (IOException e) {
            socket.close();
            throw e;
        }
    }

    @Override
    public void close() throws IOException {
        lock.lock();
        try {
            closed = true;
            socket.close();
        } finally {
            lock.unlock();
        }
    }

Il metodo transact esegue una transazione completa. Il timeout di lettura si imposta con setSoTimeout, e lo ricalcoliamo prima di ogni lettura sulla base di una scadenza unica, così un dispositivo che risponde a rilento non può prolungare indefinitamente l'attesa. Lo stream di uscita è bufferizzato, così l'intero frame parte con un solo pacchetto TCP alla chiamata di flush.

La gestione degli errori segue una regola semplice: dopo qualunque errore di I/O o di protocollo la connessione viene chiusa. Dopo un timeout, infatti, la risposta potrebbe arrivare con un istante di ritardo e verrebbe scambiata per la risposta alla transazione successiva. Il controllo sul transaction identifier resta come difesa aggiuntiva contro le risposte duplicate.


    private byte[] transact(byte[] pdu) throws IOException {
        lock.lock();
        try {
            if (closed) {
                throw new IOException("client closed");
            }
            transactionId = (transactionId + 1) & 0xFFFF;
            int tid = transactionId;
            long deadline = System.nanoTime() + timeout.toNanos();
            try {
                out.writeShort(tid);
                out.writeShort(0);
                out.writeShort(pdu.length + 1);
                out.writeByte(unitId);
                out.write(pdu);
                out.flush();
                while (true) {
                    setReadTimeout(deadline);
                    int responseTid = in.readUnsignedShort();
                    int protocol = in.readUnsignedShort();
                    int length = in.readUnsignedShort();
                    int responseUnit = in.readUnsignedByte();
                    if (protocol != 0 || length < 2 || length > 254) {
                        throw new ModbusProtocolException("invalid MBAP header");
                    }
                    byte[] body = new byte[length - 1];
                    setReadTimeout(deadline);
                    in.readFully(body);
                    if (responseTid != tid) {
                        // risposta tardiva a una transazione precedente
                        continue;
                    }
                    if (responseUnit != unitId) {
                        throw new ModbusProtocolException("unexpected unit id " + responseUnit);
                    }
                    return body;
                }
            } catch (IOException e) {
                // lo stream non e' piu' affidabile: chiudiamo la connessione
                closed = true;
                socket.close();
                throw e;
            }
        } finally {
            lock.unlock();
        }
    }

    private void setReadTimeout(long deadline) throws IOException {
        long remaining = Duration.ofNanos(deadline - System.nanoTime()).toMillis();
        if (remaining <= 0) {
            throw new SocketTimeoutException("response timeout");
        }
        socket.setSoTimeout((int) remaining);
    }

I metodi readUnsignedShort e readUnsignedByte di DataInputStream restituiscono già valori senza segno in un int, il che elimina le maschere nella lettura dell'intestazione. Il blocco catch intercetta anche ModbusProtocolException, perché estende IOException: una risposta malformata è un motivo sufficiente per non fidarsi più della connessione.

L'API pubblica valida i parametri prima di toccare la rete. In Java la validazione è ancora più importante che altrove: i cast a short troncano silenziosamente, quindi senza controlli un valore di 70000 verrebbe scritto come 4464.


    private static void check(int address, int quantity, int max) {
        if (address < 0 || address > 0xFFFF) {
            throw new IllegalArgumentException("address " + address + " out of range 0-65535");
        }
        if (quantity < 1 || quantity > max) {
            throw new IllegalArgumentException("quantity " + quantity + " out of range 1-" + max);
        }
    }

    private static void checkValues(int... values) {
        for (int value : values) {
            if (value < 0 || value > 0xFFFF) {
                throw new IllegalArgumentException("register value " + value + " out of range 0-65535");
            }
        }
    }

    private boolean[] readBits(int function, int address, int quantity) throws IOException {
        check(address, quantity, FunctionCode.MAX_READ_BITS);
        return Pdu.parseBits(function, transact(Pdu.readRequest(function, address, quantity)), quantity);
    }

    private int[] readRegisters(int function, int address, int quantity) throws IOException {
        check(address, quantity, FunctionCode.MAX_READ_REGISTERS);
        return Pdu.parseRegisters(function, transact(Pdu.readRequest(function, address, quantity)), quantity);
    }

    public boolean[] readCoils(int address, int quantity) throws IOException {
        return readBits(FunctionCode.READ_COILS, address, quantity);
    }

    public boolean[] readDiscreteInputs(int address, int quantity) throws IOException {
        return readBits(FunctionCode.READ_DISCRETE_INPUTS, address, quantity);
    }

    public int[] readHoldingRegisters(int address, int quantity) throws IOException {
        return readRegisters(FunctionCode.READ_HOLDING_REGISTERS, address, quantity);
    }

    public int[] readInputRegisters(int address, int quantity) throws IOException {
        return readRegisters(FunctionCode.READ_INPUT_REGISTERS, address, quantity);
    }

    public void writeSingleCoil(int address, boolean value) throws IOException {
        check(address, 1, 1);
        byte[] request = Pdu.writeSingleCoilRequest(address, value);
        Pdu.parseEcho(request, transact(request));
    }

    public void writeSingleRegister(int address, int value) throws IOException {
        check(address, 1, 1);
        checkValues(value);
        byte[] request = Pdu.writeSingleRegisterRequest(address, value);
        Pdu.parseEcho(request, transact(request));
    }

    public void writeMultipleRegisters(int address, int... values) throws IOException {
        check(address, values.length, FunctionCode.MAX_WRITE_REGISTERS);
        checkValues(values);
        byte[] request = Pdu.writeMultipleRegistersRequest(address, values);
        Pdu.parseEcho(request, transact(request));
    }
}

La memoria dati

La memoria del server contiene le quattro tabelle in un record e le protegge con un ReentrantReadWriteLock: le letture dei client possono procedere in parallelo, mentre scritture e aggiornamenti dell'applicazione sono esclusivi. I metodi read e update accettano una funzione da eseguire sotto il lock, così un aggiornamento composto, come la scrittura di un float su due registri, risulta atomico per i client.

package com.example.modbus;

import java.nio.ByteBuffer;
import java.util.Arrays;
import java.util.concurrent.locks.ReadWriteLock;
import java.util.concurrent.locks.ReentrantReadWriteLock;
import java.util.function.Consumer;
import java.util.function.Function;

// Memoria dati del server con le quattro tabelle Modbus
public final class DataStore {
    public record Tables(
            boolean[] coils,
            boolean[] discreteInputs,
            int[] holdingRegisters,
            int[] inputRegisters) {
    }

    private final Tables tables;
    private final ReadWriteLock lock = new ReentrantReadWriteLock();

    public DataStore(int size) {
        tables = new Tables(new boolean[size], new boolean[size], new int[size], new int[size]);
    }

    public <T> T read(Function<Tables, T> action) {
        lock.readLock().lock();
        try {
            return action.apply(tables);
        } finally {
            lock.readLock().unlock();
        }
    }

    public void update(Consumer<Tables> action) {
        lock.writeLock().lock();
        try {
            action.accept(tables);
        } finally {
            lock.writeLock().unlock();
        }
    }

    private static byte[] exception(int function, int code) {
        return new byte[] {(byte) (function | 0x80), (byte) code};
    }

Il metodo handle usa uno switch con le frecce per smistare le richieste, e ogni gestore segue l'ordine di controllo indicato dalla specifica: prima la forma della richiesta e le quantità (eccezione 0x03), poi gli indirizzi (eccezione 0x02).


    // Elabora una PDU di richiesta e restituisce la PDU di risposta
    public byte[] handle(byte[] pdu) {
        if (pdu.length == 0) {
            return exception(0, ExceptionCode.ILLEGAL_FUNCTION);
        }
        int function = pdu[0] & 0xFF;
        return switch (function) {
            case FunctionCode.READ_COILS, FunctionCode.READ_DISCRETE_INPUTS -> read(t -> readBits(t, function, pdu));
            case FunctionCode.READ_HOLDING_REGISTERS, FunctionCode.READ_INPUT_REGISTERS ->
                    read(t -> readRegisters(t, function, pdu));
            case FunctionCode.WRITE_SINGLE_COIL -> write(t -> writeSingleCoil(t, pdu));
            case FunctionCode.WRITE_SINGLE_REGISTER -> write(t -> writeSingleRegister(t, pdu));
            case FunctionCode.WRITE_MULTIPLE_REGISTERS -> write(t -> writeMultipleRegisters(t, pdu));
            default -> exception(function, ExceptionCode.ILLEGAL_FUNCTION);
        };
    }

    private byte[] write(Function<Tables, byte[]> action) {
        lock.writeLock().lock();
        try {
            return action.apply(tables);
        } finally {
            lock.writeLock().unlock();
        }
    }

    private static byte[] readBits(Tables t, int function, byte[] pdu) {
        if (pdu.length != 5) {
            return exception(function, ExceptionCode.ILLEGAL_DATA_VALUE);
        }
        ByteBuffer buffer = ByteBuffer.wrap(pdu, 1, 4);
        int address = buffer.getShort() & 0xFFFF;
        int quantity = buffer.getShort() & 0xFFFF;
        if (quantity < 1 || quantity > FunctionCode.MAX_READ_BITS) {
            return exception(function, ExceptionCode.ILLEGAL_DATA_VALUE);
        }
        boolean[] table = function == FunctionCode.READ_COILS ? t.coils() : t.discreteInputs();
        if (address + quantity > table.length) {
            return exception(function, ExceptionCode.ILLEGAL_DATA_ADDRESS);
        }
        byte[] data = Pdu.packBits(table, address, quantity);
        return ByteBuffer.allocate(2 + data.length)
                .put((byte) function)
                .put((byte) data.length)
                .put(data)
                .array();
    }

    private static byte[] readRegisters(Tables t, int function, byte[] pdu) {
        if (pdu.length != 5) {
            return exception(function, ExceptionCode.ILLEGAL_DATA_VALUE);
        }
        ByteBuffer buffer = ByteBuffer.wrap(pdu, 1, 4);
        int address = buffer.getShort() & 0xFFFF;
        int quantity = buffer.getShort() & 0xFFFF;
        if (quantity < 1 || quantity > FunctionCode.MAX_READ_REGISTERS) {
            return exception(function, ExceptionCode.ILLEGAL_DATA_VALUE);
        }
        int[] table = function == FunctionCode.READ_HOLDING_REGISTERS ? t.holdingRegisters() : t.inputRegisters();
        if (address + quantity > table.length) {
            return exception(function, ExceptionCode.ILLEGAL_DATA_ADDRESS);
        }
        ByteBuffer response = ByteBuffer.allocate(2 + 2 * quantity)
                .put((byte) function)
                .put((byte) (2 * quantity));
        for (int i = 0; i < quantity; i++) {
            response.putShort((short) table[address + i]);
        }
        return response.array();
    }

    private static byte[] writeSingleCoil(Tables t, byte[] pdu) {
        int function = FunctionCode.WRITE_SINGLE_COIL;
        if (pdu.length != 5) {
            return exception(function, ExceptionCode.ILLEGAL_DATA_VALUE);
        }
        ByteBuffer buffer = ByteBuffer.wrap(pdu, 1, 4);
        int address = buffer.getShort() & 0xFFFF;
        int value = buffer.getShort() & 0xFFFF;
        if (value != 0xFF00 && value != 0x0000) {
            return exception(function, ExceptionCode.ILLEGAL_DATA_VALUE);
        }
        if (address >= t.coils().length) {
            return exception(function, ExceptionCode.ILLEGAL_DATA_ADDRESS);
        }
        t.coils()[address] = value == 0xFF00;
        return pdu.clone();
    }

    private static byte[] writeSingleRegister(Tables t, byte[] pdu) {
        int function = FunctionCode.WRITE_SINGLE_REGISTER;
        if (pdu.length != 5) {
            return exception(function, ExceptionCode.ILLEGAL_DATA_VALUE);
        }
        ByteBuffer buffer = ByteBuffer.wrap(pdu, 1, 4);
        int address = buffer.getShort() & 0xFFFF;
        if (address >= t.holdingRegisters().length) {
            return exception(function, ExceptionCode.ILLEGAL_DATA_ADDRESS);
        }
        t.holdingRegisters()[address] = buffer.getShort() & 0xFFFF;
        return pdu.clone();
    }

    private static byte[] writeMultipleRegisters(Tables t, byte[] pdu) {
        int function = FunctionCode.WRITE_MULTIPLE_REGISTERS;
        if (pdu.length < 6) {
            return exception(function, ExceptionCode.ILLEGAL_DATA_VALUE);
        }
        ByteBuffer buffer = ByteBuffer.wrap(pdu, 1, pdu.length - 1);
        int address = buffer.getShort() & 0xFFFF;
        int quantity = buffer.getShort() & 0xFFFF;
        int byteCount = buffer.get() & 0xFF;
        if (quantity < 1 || quantity > FunctionCode.MAX_WRITE_REGISTERS
                || byteCount != 2 * quantity || pdu.length != 6 + byteCount) {
            return exception(function, ExceptionCode.ILLEGAL_DATA_VALUE);
        }
        if (address + quantity > t.holdingRegisters().length) {
            return exception(function, ExceptionCode.ILLEGAL_DATA_ADDRESS);
        }
        for (int i = 0; i < quantity; i++) {
            t.holdingRegisters()[address + i] = buffer.getShort() & 0xFFFF;
        }
        return Arrays.copyOf(pdu, 5);
    }
}

Una nota su ByteBuffer.wrap(pdu, 1, 4): crea un buffer posizionato sull'offset 1 dell'array, senza copie, da cui leggiamo indirizzo e quantità in sequenza. Il calcolo address + quantity avviene in int, quindi non c'è rischio di overflow anche con valori vicini a 65535.

Il server con i virtual thread

Prima di Java 21, un server con un thread per connessione era considerato poco scalabile, perché ogni thread di piattaforma occupa memoria e risorse del sistema operativo. Con i virtual thread il modello torna praticabile: un virtual thread costa poche centinaia di byte quando è fermo su una lettura, e la JVM ne può gestire milioni. Il codice resta sequenziale e leggibile, senza callback né selector.

package com.example.modbus;

import java.io.BufferedInputStream;
import java.io.BufferedOutputStream;
import java.io.DataInputStream;
import java.io.DataOutputStream;
import java.io.IOException;
import java.net.InetSocketAddress;
import java.net.ServerSocket;
import java.net.Socket;
import java.net.SocketException;
import java.time.Duration;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

// Server Modbus TCP con un virtual thread per connessione
public final class ModbusTcpServer implements AutoCloseable {
    private final DataStore store;
    private final int unitId;
    private final Duration idleTimeout;
    private final ServerSocket serverSocket = new ServerSocket();
    private final ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
    private final Set<Socket> connections = ConcurrentHashMap.newKeySet();

    /**
     * @param unitId 0 accetta qualunque unit identifier
     * @param idleTimeout inattivita' massima, Duration.ZERO per disabilitare
     */
    public ModbusTcpServer(DataStore store, int unitId, Duration idleTimeout) throws IOException {
        this.store = store;
        this.unitId = unitId;
        this.idleTimeout = idleTimeout;
    }

    public void start(String host, int port) throws IOException {
        serverSocket.bind(new InetSocketAddress(host, port));
        Thread.ofVirtual().name("modbus-accept").start(this::acceptLoop);
    }

    public int port() {
        return serverSocket.getLocalPort();
    }

    private void acceptLoop() {
        while (!serverSocket.isClosed()) {
            try {
                Socket socket = serverSocket.accept();
                connections.add(socket);
                executor.submit(() -> serve(socket));
            } catch (IOException e) {
                // il server socket e' stato chiuso: usciamo dal ciclo
                return;
            }
        }
    }

Il metodo serve legge un frame alla volta, delega la PDU alla memoria dati e risponde ricopiando transaction identifier e unit identifier. Un'intestazione non valida chiude la connessione, perché dopo un errore di framing non è possibile capire dove inizi il frame successivo. Il timeout di inattività, impostato con setSoTimeout, fa sì che le connessioni abbandonate vengano chiuse: molti dispositivi reali accettano pochissime connessioni contemporanee, e un client che si riavvia senza chiudere il socket ne occuperebbe una per sempre.


    private void serve(Socket socket) {
        try (socket) {
            socket.setTcpNoDelay(true);
            socket.setSoTimeout((int) idleTimeout.toMillis());
            DataInputStream in = new DataInputStream(new BufferedInputStream(socket.getInputStream()));
            DataOutputStream out = new DataOutputStream(new BufferedOutputStream(socket.getOutputStream()));
            while (true) {
                int tid = in.readUnsignedShort();
                int protocol = in.readUnsignedShort();
                int length = in.readUnsignedShort();
                int unit = in.readUnsignedByte();
                if (protocol != 0 || length < 2 || length > 254) {
                    return;
                }
                byte[] pdu = new byte[length - 1];
                in.readFully(pdu);
                if (unitId != 0 && unit != unitId) {
                    // un dispositivo reale ignora le richieste destinate ad altri
                    continue;
                }
                byte[] response = store.handle(pdu);
                out.writeShort(tid);
                out.writeShort(0);
                out.writeShort(response.length + 1);
                out.writeByte(unit);
                out.write(response);
                out.flush();
            }
        } catch (IOException e) {
            // disconnessione, timeout di inattivita' o errore di rete
        } finally {
            connections.remove(socket);
        }
    }

    @Override
    public void close() throws IOException {
        serverSocket.close();
        for (Socket socket : connections) {
            try {
                socket.close();
            } catch (SocketException ignored) {
                // la connessione era gia' chiusa
            }
        }
        executor.shutdown();
    }
}

Con setSoTimeout(0), cioè con Duration.ZERO, la lettura attende all'infinito e il controllo di inattività è disattivato. Il blocco try (socket), possibile da Java 9 con le variabili effettivamente finali, garantisce la chiusura del socket in ogni caso.

Dai registri ai valori

Un registro contiene 16 bit, e il produttore decide cosa rappresentano. 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 l'ordine dei registri non è fissato dalla specifica: alcuni dispositivi mettono per prima la parola alta (ordine ABCD), altri la parola bassa (CDAB).

package com.example.modbus;

// Ordine dei registri nei valori a 32 bit
public enum WordOrder {
    // parola piu' significativa nel primo registro (ABCD)
    HIGH_WORD_FIRST,
    // parola meno significativa nel primo registro (CDAB)
    LOW_WORD_FIRST
}
package com.example.modbus;

// Conversioni tra registri Modbus e tipi numerici
public final class Codec {
    private Codec() {
    }

    // Interpreta un registro come intero con segno in complemento a due
    public static int int16(int register) {
        return (short) register;
    }

    public static long uint32(int[] registers, WordOrder order) {
        requireTwo(registers);
        int high = order == WordOrder.HIGH_WORD_FIRST ? registers[0] : registers[1];
        int low = order == WordOrder.HIGH_WORD_FIRST ? registers[1] : registers[0];
        return ((long) high << 16) | low;
    }

    public static int[] fromUint32(long value, WordOrder order) {
        int high = (int) (value >>> 16) & 0xFFFF;
        int low = (int) value & 0xFFFF;
        return order == WordOrder.HIGH_WORD_FIRST ? new int[] {high, low} : new int[] {low, high};
    }

    public static float float32(int[] registers, WordOrder order) {
        return Float.intBitsToFloat((int) uint32(registers, order));
    }

    public static int[] fromFloat32(float value, WordOrder order) {
        return fromUint32(Integer.toUnsignedLong(Float.floatToIntBits(value)), order);
    }

    private static void requireTwo(int[] registers) {
        if (registers.length != 2) {
            throw new IllegalArgumentException("exactly two registers are required");
        }
    }
}

Qui la mancanza di tipi senza segno diventa un vantaggio: il cast (short) register reinterpreta i 16 bit in complemento a due, quindi 0xFF38 diventa -200 senza alcuna aritmetica. Per gli interi a 32 bit senza segno usiamo invece un long, perché un int renderebbe negativi i valori sopra 231. 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.

package com.example.modbus;

import java.util.Arrays;

// CRC16 di Modbus RTU
public final class Crc16 {
    private Crc16() {
    }

    public static int compute(byte[] data) {
        int crc = 0xFFFF;
        for (byte b : data) {
            crc ^= b & 0xFF;
            for (int i = 0; i < 8; i++) {
                crc = (crc & 1) != 0 ? (crc >>> 1) ^ 0xA001 : crc >>> 1;
            }
        }
        return crc;
    }

    // Aggiunge il CRC al frame, byte meno significativo per primo
    public static byte[] append(byte[] frame) {
        int crc = compute(frame);
        byte[] out = Arrays.copyOf(frame, frame.length + 2);
        out[frame.length] = (byte) crc;
        out[frame.length + 1] = (byte) (crc >>> 8);
        return out;
    }
}

La maschera b & 0xFF è indispensabile: senza di essa un byte come 0x83 verrebbe esteso con segno a 0xFFFFFF83 e il CRC risulterebbe sbagliato. La richiesta RTU di dieci holding register all'unità 1 è 01 03 00 00 00 0A C5 CD.

I test

I test di integrazione avviano un server reale sulla porta 0, così il sistema operativo sceglie una porta libera, e lo interrogano con il client. Con @BeforeEach e @AfterEach ogni test riceve un server nuovo e pulito.

package com.example.modbus;

import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;

import java.io.IOException;
import java.net.InetAddress;
import java.net.ServerSocket;
import java.net.SocketTimeoutException;
import java.time.Duration;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;

class ModbusTest {
    private DataStore store;
    private ModbusTcpServer server;
    private ModbusTcpClient client;

    @BeforeEach
    void setUp() throws IOException {
        store = new DataStore(100);
        server = new ModbusTcpServer(store, 0, Duration.ZERO);
        server.start("127.0.0.1", 0);
        client = ModbusTcpClient.connect("127.0.0.1", server.port(), 1, Duration.ofSeconds(1));
    }

    @AfterEach
    void tearDown() throws IOException {
        client.close();
        server.close();
    }

    @Test
    void registersRoundTrip() throws IOException {
        client.writeMultipleRegisters(10, 1, 2, 0xBEEF);
        client.writeSingleRegister(13, 42);
        assertArrayEquals(new int[] {1, 2, 0xBEEF, 42}, client.readHoldingRegisters(10, 4));
    }

    @Test
    void coilsAndInputs() throws IOException {
        store.update(t -> {
            t.discreteInputs()[9] = true;
            t.inputRegisters()[0] = 0xFF38;
        });
        client.writeSingleCoil(3, true);
        boolean[] coils = client.readCoils(0, 10);
        for (int i = 0; i < coils.length; i++) {
            assertEquals(i == 3, coils[i]);
        }
        boolean[] inputs = client.readDiscreteInputs(0, 10);
        assertTrue(inputs[9]);
        assertFalse(inputs[8]);
        assertEquals(-200, Codec.int16(client.readInputRegisters(0, 1)[0]));
    }

    @Test
    void illegalAddressKeepsConnection() throws IOException {
        ModbusDeviceException e = assertThrows(ModbusDeviceException.class,
                () -> client.readHoldingRegisters(95, 10));
        assertEquals(ExceptionCode.ILLEGAL_DATA_ADDRESS, e.exceptionCode());
        // dopo un'eccezione Modbus la connessione resta valida
        assertArrayEquals(new int[] {0}, client.readHoldingRegisters(0, 1));
    }

    @Test
    void clientSideValidation() {
        assertThrows(IllegalArgumentException.class, () -> client.readHoldingRegisters(0, 126));
        assertThrows(IllegalArgumentException.class, () -> client.writeSingleRegister(0, 70000));
    }

    @Test
    void timeoutClosesClient() throws IOException {
        // un server socket che non chiama mai accept: la connessione resta nel backlog
        try (ServerSocket silent = new ServerSocket(0, 1, InetAddress.getLoopbackAddress());
             ModbusTcpClient slow = ModbusTcpClient.connect(
                     "127.0.0.1", silent.getLocalPort(), 1, Duration.ofMillis(200))) {
            assertThrows(SocketTimeoutException.class, () -> slow.readCoils(0, 1));
            IOException closed = assertThrows(IOException.class, () -> slow.readCoils(0, 1));
            assertEquals("client closed", closed.getMessage());
        }
    }

    @Test
    void illegalFunction() {
        assertArrayEquals(new byte[] {(byte) 0xAB, 0x01}, new DataStore(10).handle(new byte[] {0x2B, 0x0E}));
    }

    @Test
    void floatWordOrder() {
        for (WordOrder order : WordOrder.values()) {
            assertEquals(230.5f, Codec.float32(Codec.fromFloat32(230.5f, order), order));
        }
        assertArrayEquals(new int[] {0x3F80, 0x0000}, Codec.fromFloat32(1.0f, WordOrder.HIGH_WORD_FIRST));
        assertArrayEquals(new int[] {0x0000, 0x3F80}, Codec.fromFloat32(1.0f, WordOrder.LOW_WORD_FIRST));
    }

    @Test
    void crc16() {
        byte[] frame = Crc16.append(new byte[] {0x01, 0x03, 0x00, 0x00, 0x00, 0x0A});
        assertEquals((byte) 0xC5, frame[6]);
        assertEquals((byte) 0xCD, frame[7]);
    }
}

Il test sul timeout sfrutta un comportamento del kernel: un socket in ascolto completa l'handshake TCP anche se l'applicazione non chiama mai accept, quindi la connessione riesce ma nessuno risponderà. Si eseguono i test con mvn test.

Un programma dimostrativo

Il programma dimostrativo simula un misuratore di energia, che pubblica la tensione come float negli input register 0 e 1, e la legge con il client. L'aggiornamento avviene con update, quindi i due registri cambiano insieme dal punto di vista dei client.

package com.example.modbus;

import java.time.Duration;
import java.util.concurrent.Executors;
import java.util.concurrent.ThreadLocalRandom;
import java.util.concurrent.TimeUnit;

public final class Demo {
    private Demo() {
    }

    public static void main(String[] args) throws Exception {
        DataStore store = new DataStore(10);
        Runnable simulate = () -> {
            float voltage = 228 + ThreadLocalRandom.current().nextFloat() * 4;
            int[] registers = Codec.fromFloat32(voltage, WordOrder.HIGH_WORD_FIRST);
            store.update(t -> System.arraycopy(registers, 0, t.inputRegisters(), 0, 2));
        };
        var scheduler = Executors.newSingleThreadScheduledExecutor();
        scheduler.scheduleAtFixedRate(simulate, 0, 1, TimeUnit.SECONDS);

        try (var server = new ModbusTcpServer(store, 0, Duration.ofMinutes(1))) {
            server.start("127.0.0.1", 1502);
            try (var client = ModbusTcpClient.connect("127.0.0.1", 1502, 1, Duration.ofSeconds(2))) {
                for (int i = 0; i < 5; i++) {
                    int[] registers = client.readInputRegisters(0, 2);
                    float voltage = Codec.float32(registers, WordOrder.HIGH_WORD_FIRST);
                    System.out.printf("voltage: %.2f V (raw %04X %04X)%n", voltage, registers[0], registers[1]);
                    Thread.sleep(1000);
                }
            }
        } finally {
            scheduler.shutdownNow();
        }
    }
}

Si esegue con mvn compile exec:java -Dexec.mainClass=com.example.modbus.Demo, oppure dopo mvn compile con java -cp target/classes com.example.modbus.Demo. La porta 1502 evita di dover eseguire la JVM 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. Il ciclo di polling deve ricrearlo con un backoff esponenziale, mentre una ModbusDeviceException non richiede di riaprire la connessione.
  • Polling concorrente: con i virtual thread è naturale dedicare un thread a ogni dispositivo. Un Executors.newVirtualThreadPerTaskExecutor() con un ciclo di polling per dispositivo scala senza difficoltà a migliaia di apparati.
  • Letture raggruppate: ogni transazione costa almeno un round trip, e dietro un gateway seriale anche decine di millisecondi. Conviene leggere blocchi contigui di registri in una sola richiesta.
  • Sicurezza: Modbus TCP non ha 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 il supporto nei dispositivi è ancora limitato.
  • 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.

Per progetti che richiedono anche il trasporto seriale, la versione sicura con TLS o prestazioni elevate su migliaia di connessioni, la libreria di riferimento è com.digitalpetri.modbus, basata su Netty, che dalla serie 2.x supporta Modbus TCP, Modbus TCP Security e RTU su seriale e su TCP. In alternativa esiste j2mod, più tradizionale e con un'API sincrona. Conoscere il formato dei byte resta comunque prezioso: con una cattura di Wireshark 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 il solo JDK. Il client gestisce framing, scadenze complessive e risposte tardive. Il server sfrutta i virtual thread per servire molte connessioni con codice sequenziale. Una memoria dati con ReadWriteLock garantisce aggiornamenti atomici, e a completare il quadro ci sono le conversioni numeriche e il CRC di Modbus RTU. Lungo il percorso abbiamo affrontato la peculiarità più insidiosa di Java nei protocolli binari, l'assenza di tipi senza segno, che si gestisce con disciplina: maschere in lettura, cast in scrittura e validazione esplicita dei valori.

Commenti