Modbus TCP in PHP: client e server con gli stream nativi

Modbus TCP in PHP: client e server con gli stream nativi

PHP non è il primo linguaggio che viene in mente quando si parla di automazione industriale, eppure capita spesso di doverlo usare in questo ambito. Un gestionale che deve leggere i contatori di un impianto, un pannello web che mostra lo stato di un inverter, un job pianificato che raccoglie le misure di una centralina e le salva nel database: sono tutti scenari in cui l'applicazione esiste già in PHP, e aggiungere un servizio in un altro linguaggio solo per parlare Modbus sarebbe uno spreco.

Modbus è il protocollo più diffuso per questo tipo di integrazioni. In questo articolo lo implementiamo in PHP 8.3 senza estensioni particolari, usando solo le funzioni di stream della libreria standard e pack e unpack per la codifica binaria. Scriveremo un client sincrono con timeout affidabili, un server a processo singolo basato su stream_select capace di servire più client contemporaneamente, le conversioni tra registri e valori numerici e il CRC di Modbus RTU.

Il modello dati

Modbus organizza i dati di un dispositivo in quattro tabelle, ognuna con indirizzi da 0 a 65535:

TabellaDimensioneAccessoContenuto tipico
Coil1 bitlettura e scritturauscite digitali, comandi
Discrete input1 bitsola letturaingressi digitali, allarmi
Holding register16 bitlettura e scritturaconfigurazione, setpoint
Input register16 bitsola letturamisure

Nei manuali dei dispositivi si trova spesso la notazione storica a cinque cifre, in cui il registro 40001 è il primo holding register e il 30001 il primo input register. Questa numerazione parte da 1, mentre l'indirizzo trasmesso parte da 0: per leggere il registro 40001 si chiede l'indirizzo 0. Se i valori letti risultano sfasati di una posizione rispetto alla documentazione, la causa è quasi sempre questa.

Anatomia di un frame Modbus TCP

Il messaggio Modbus vero e proprio si chiama PDU: un byte di codice funzione seguito dai parametri, fino a 253 byte in tutto. Su TCP la PDU è preceduta da un'intestazione MBAP di sette byte:

  • transaction identifier (2 byte): numero scelto dal client e ricopiato nella risposta;
  • protocol identifier (2 byte): sempre 0;
  • length (2 byte): numero di byte successivi, cioè unit identifier più PDU;
  • unit identifier (1 byte): indirizzo del dispositivo finale, usato dai gateway verso le linee seriali.

Tutti i campi sono in big-endian, che nella sintassi di pack corrisponde al formato n per gli interi a 16 bit e N per quelli a 32 bit. Il formato C indica un byte senza segno. Una lettura di dieci holding register a partire dall'indirizzo 0, per l'unità 1, si costruisce così:

$pdu = pack('Cnn', 0x03, 0, 10);
$frame = pack('nnnC', 1, 0, strlen($pdu) + 1, 1) . $pdu;
echo bin2hex($frame); // 00010000000601030000000a

Il risultato, scritto byte per byte, è 00 01 00 00 00 06 01 03 00 00 00 0A. Quando il dispositivo non può soddisfare la richiesta, 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 progetto

Organizziamo il codice come un pacchetto Composer con autoload PSR-4:

modbus-php/
├── composer.json
├── src/
│   ├── FunctionCode.php
│   ├── ExceptionCode.php
│   ├── Exceptions.php
│   ├── Pdu.php
│   ├── TcpClient.php
│   ├── DataStore.php
│   ├── TcpServer.php
│   ├── Codec.php
│   └── Crc16.php
├── bin/
│   ├── simulator.php
│   └── poll.php
└── tests/
    ├── fixtures/server.php
    └── ModbusTest.php
{
    "name": "example/modbus-php",
    "description": "Minimal Modbus TCP client and server",
    "type": "library",
    "require": {
        "php": ">=8.3"
    },
    "require-dev": {
        "phpunit/phpunit": "^12.0"
    },
    "autoload": {
        "psr-4": {
            "Modbus\\": "src/"
        },
        "files": ["src/Exceptions.php"]
    },
    "autoload-dev": {
        "psr-4": {
            "Modbus\\Tests\\": "tests/"
        }
    }
}

Enumerazioni ed eccezioni

Le enumerazioni con valore intero di PHP 8.1 sono perfette per i codici funzione: FunctionCode::tryFrom converte il byte ricevuto e restituisce null per i codici sconosciuti, il che ci tornerà utile nel server.

<?php

declare(strict_types=1);

namespace Modbus;

// Codici funzione supportati
enum FunctionCode: int
{
    case ReadCoils = 0x01;
    case ReadDiscreteInputs = 0x02;
    case ReadHoldingRegisters = 0x03;
    case ReadInputRegisters = 0x04;
    case WriteSingleCoil = 0x05;
    case WriteSingleRegister = 0x06;
    case WriteMultipleRegisters = 0x10;
}
<?php

declare(strict_types=1);

namespace Modbus;

// Codici di eccezione definiti dalla specifica
enum ExceptionCode: int
{
    case IllegalFunction = 0x01;
    case IllegalDataAddress = 0x02;
    case IllegalDataValue = 0x03;
    case ServerDeviceFailure = 0x04;

    public function description(): string
    {
        return match ($this) {
            self::IllegalFunction => 'illegal function',
            self::IllegalDataAddress => 'illegal data address',
            self::IllegalDataValue => 'illegal data value',
            self::ServerDeviceFailure => 'server device failure',
        };
    }
}

Per gli errori definiamo una piccola gerarchia. La distinzione chiave è tra DeviceException, che rappresenta un'eccezione Modbus inviata dal dispositivo e lascia la connessione utilizzabile, e TransportException, che indica un problema di rete o un timeout dopo il quale la connessione non è più affidabile. Per brevità raccogliamo le classi in un unico file, caricato tramite la sezione files dell'autoload che abbiamo già dichiarato in composer.json: l'autoload PSR-4 cerca infatti un file per classe e non le troverebbe. In un progetto reale conviene separarle, una per file.

<?php

declare(strict_types=1);

namespace Modbus;

// Classe base per tutti gli errori della libreria
class ModbusException extends \RuntimeException
{
}

// Risposta malformata o incoerente con la richiesta
class ProtocolException extends ModbusException
{
}

// Errore di rete, timeout o client gia' chiuso
class TransportException extends ModbusException
{
}

class TimeoutException extends TransportException
{
}

// Il dispositivo ha risposto con un'eccezione Modbus
class DeviceException extends ModbusException
{
    public function __construct(
        public readonly int $function,
        public readonly int $exceptionCode,
    ) {
        $name = ExceptionCode::tryFrom($exceptionCode)?->description() ?? 'unknown';
        parent::__construct(sprintf(
            'exception 0x%02X (%s) for function 0x%02X',
            $exceptionCode,
            $name,
            $function,
        ), $exceptionCode);
    }
}

Codifica delle PDU

La classe Pdu raccoglie le funzioni pure di codifica e decodifica, senza alcun riferimento alla rete. I limiti di quantità derivano dalla dimensione massima della PDU: una risposta con 125 registri occupa 252 byte, l'ultima misura che entra nei 253 disponibili.

<?php

declare(strict_types=1);

namespace Modbus;

final class Pdu
{
    // Quantita' massime per singola richiesta
    public const int MAX_READ_BITS = 2000;
    public const int MAX_READ_REGISTERS = 125;
    public const int MAX_WRITE_REGISTERS = 123;

    public static function readRequest(FunctionCode $function, int $address, int $quantity): string
    {
        return pack('Cnn', $function->value, $address, $quantity);
    }

    public static function writeSingleCoilRequest(int $address, bool $value): string
    {
        return pack('Cnn', FunctionCode::WriteSingleCoil->value, $address, $value ? 0xFF00 : 0x0000);
    }

    public static function writeSingleRegisterRequest(int $address, int $value): string
    {
        return pack('Cnn', FunctionCode::WriteSingleRegister->value, $address, $value);
    }

    /**
     * @param list<int> $values
     */
    public static function writeMultipleRegistersRequest(int $address, array $values): string
    {
        $count = count($values);
        return pack('CnnC', FunctionCode::WriteMultipleRegisters->value, $address, $count, 2 * $count)
            . pack('n*', ...$values);
    }

Le costanti tipizzate, come public const int, sono una novità di PHP 8.3. Per le scritture di un coil la specifica impone 0xFF00 per acceso e 0x0000 per spento: qualunque altro valore viene rifiutato dal dispositivo.

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


    /**
     * @param list<bool> $bits
     */
    public static function packBits(array $bits): string
    {
        $bytes = array_fill(0, intdiv(count($bits) + 7, 8), 0);
        foreach ($bits as $i => $bit) {
            if ($bit) {
                $bytes[intdiv($i, 8)] |= 1 << ($i % 8);
            }
        }
        return pack('C*', ...$bytes);
    }

    /**
     * @return list<bool>
     */
    public static function unpackBits(string $data, int $count): array
    {
        $bits = [];
        for ($i = 0; $i < $count; $i++) {
            $bits[] = (ord($data[intdiv($i, 8)]) & (1 << ($i % 8))) !== 0;
        }
        return $bits;
    }

Sul lato delle risposte controlliamo tutto ciò che è verificabile: il codice funzione, il byte di conteggio, la lunghezza reale e l'eco delle scritture. Le funzioni 0x05 e 0x06 rispondono con una copia della richiesta, mentre la 0x10 restituisce i primi cinque byte, cioè funzione, indirizzo e quantità. In entrambi i casi basta confrontare i primi cinque byte della richiesta con la risposta.


    public static function checkFunction(FunctionCode $function, string $pdu): void
    {
        if ($pdu === '') {
            throw new ProtocolException('empty response');
        }
        $code = ord($pdu[0]);
        if ($code === ($function->value | 0x80)) {
            if (strlen($pdu) !== 2) {
                throw new ProtocolException('malformed exception response');
            }
            throw new DeviceException($function->value, ord($pdu[1]));
        }
        if ($code !== $function->value) {
            throw new ProtocolException(sprintf('unexpected function 0x%02X', $code));
        }
    }

    /**
     * @return list<bool>
     */
    public static function parseBits(FunctionCode $function, string $pdu, int $quantity): array
    {
        self::checkFunction($function, $pdu);
        $expected = intdiv($quantity + 7, 8);
        if (strlen($pdu) !== 2 + $expected || ord($pdu[1]) !== $expected) {
            throw new ProtocolException('unexpected byte count');
        }
        return self::unpackBits(substr($pdu, 2), $quantity);
    }

    /**
     * @return list<int>
     */
    public static function parseRegisters(FunctionCode $function, string $pdu, int $quantity): array
    {
        self::checkFunction($function, $pdu);
        $expected = 2 * $quantity;
        if (strlen($pdu) !== 2 + $expected || ord($pdu[1]) !== $expected) {
            throw new ProtocolException('unexpected byte count');
        }
        return array_values(unpack('n*', substr($pdu, 2)));
    }

    public static function parseEcho(FunctionCode $function, string $request, string $pdu): void
    {
        self::checkFunction($function, $pdu);
        if ($pdu !== substr($request, 0, 5)) {
            throw new ProtocolException('write echo mismatch');
        }
    }
}

Un dettaglio da ricordare: unpack con un formato ripetuto come n* restituisce un array indicizzato a partire da 1. La chiamata ad array_values lo riporta a una lista ordinaria indicizzata da 0.

Il client

Il client apre la connessione con stream_socket_client, che accetta un timeout di connessione, e mantiene un contatore per i transaction identifier. Essendo PHP sincrono, ogni transazione blocca fino alla risposta o al timeout, il che corrisponde esattamente al comportamento atteso dalla maggior parte dei dispositivi: una richiesta alla volta per connessione.

<?php

declare(strict_types=1);

namespace Modbus;

final class TcpClient
{
    /** @var resource|null */
    private $socket;
    private int $transactionId = 0;

    /**
     * @param resource $socket
     */
    private function __construct(
        $socket,
        private readonly int $unitId,
        private readonly float $timeout,
    ) {
        $this->socket = $socket;
    }

    public static function connect(
        string $host,
        int $port = 502,
        int $unitId = 1,
        float $timeout = 2.0,
    ): self {
        $socket = @stream_socket_client("tcp://{$host}:{$port}", $errno, $errstr, $timeout);
        if ($socket === false) {
            throw new TransportException("connection to {$host}:{$port} failed: {$errstr}", $errno);
        }
        stream_set_blocking($socket, true);
        return new self($socket, $unitId, $timeout);
    }

    public function close(): void
    {
        if ($this->socket !== null) {
            fclose($this->socket);
            $this->socket = null;
        }
    }

    public function __destruct()
    {
        $this->close();
    }

La parte delicata è la lettura. TCP è uno stream, quindi un frame può arrivare spezzato in più segmenti e fread può restituire meno byte di quelli richiesti. Il metodo readExactly continua a leggere finché non ha il numero esatto di byte, ricalcolando a ogni giro il tempo rimasto rispetto a una scadenza unica per l'intera transazione. In questo modo un dispositivo che risponde un byte alla volta non può prolungare il timeout all'infinito.

Per il timeout usiamo stream_set_timeout e poi controlliamo il campo timed_out restituito da stream_get_meta_data. È un approccio più affidabile di stream_select per un client, perché tiene conto del buffer interno degli stream di PHP: stream_select osserva solo il socket del sistema operativo e potrebbe segnalare che non c'è nulla da leggere mentre i byte attesi sono già nel buffer di PHP.


    private function readExactly(int $length, float $deadline): string
    {
        $buffer = '';
        while (strlen($buffer) < $length) {
            $remaining = $deadline - microtime(true);
            if ($remaining <= 0) {
                throw new TimeoutException('response timeout');
            }
            $seconds = (int) $remaining;
            stream_set_timeout($this->socket, $seconds, (int) (($remaining - $seconds) * 1_000_000));
            $chunk = fread($this->socket, $length - strlen($buffer));
            if ($chunk === false || $chunk === '') {
                if (stream_get_meta_data($this->socket)['timed_out']) {
                    throw new TimeoutException('response timeout');
                }
                if (feof($this->socket)) {
                    throw new TransportException('connection closed by peer');
                }
                continue;
            }
            $buffer .= $chunk;
        }
        return $buffer;
    }

    private function writeAll(string $data): void
    {
        while ($data !== '') {
            $written = @fwrite($this->socket, $data);
            if ($written === false || $written === 0) {
                throw new TransportException('write failed');
            }
            $data = substr($data, $written);
        }
    }

Anche fwrite può scrivere solo una parte dei dati, quindi writeAll ripete la scrittura finché il buffer non è vuoto. Con frame di poche decine di byte succede raramente, ma un codice che si basa sul "raramente" è un codice che prima o poi fallisce in produzione.

Il metodo transact mette insieme i pezzi. In caso di qualunque errore di trasporto chiude la connessione: dopo un timeout la risposta potrebbe arrivare con un istante di ritardo e verrebbe scambiata per la risposta alla transazione successiva. Il controllo del transaction identifier resta come ulteriore difesa contro le risposte tardive.


    private function transact(string $pdu): string
    {
        if ($this->socket === null) {
            throw new TransportException('client closed');
        }
        $this->transactionId = ($this->transactionId + 1) & 0xFFFF;
        $tid = $this->transactionId;
        $deadline = microtime(true) + $this->timeout;

        try {
            $this->writeAll(pack('nnnC', $tid, 0, strlen($pdu) + 1, $this->unitId) . $pdu);
            while (true) {
                $header = unpack('ntid/nprotocol/nlength/Cunit', $this->readExactly(7, $deadline));
                if ($header['protocol'] !== 0 || $header['length'] < 2 || $header['length'] > 254) {
                    throw new ProtocolException('invalid MBAP header');
                }
                $body = $this->readExactly($header['length'] - 1, $deadline);
                if ($header['tid'] !== $tid) {
                    // risposta tardiva a una transazione precedente
                    continue;
                }
                if ($header['unit'] !== $this->unitId) {
                    throw new ProtocolException("unexpected unit id {$header['unit']}");
                }
                return $body;
            }
        } catch (TransportException | ProtocolException $e) {
            // lo stream non e' piu' affidabile: chiudiamo la connessione
            $this->close();
            throw $e;
        }
    }

Il formato ntid/nprotocol/nlength/Cunit di unpack assegna un nome a ogni campo e restituisce un array associativo, rendendo il codice molto più leggibile dei soli indici numerici.

L'API pubblica valida i parametri prima di inviare qualunque byte. In PHP la validazione è doppiamente utile: pack non segnala gli overflow, ma tronca silenziosamente i valori, quindi senza un controllo esplicito un valore di 70000 verrebbe scritto come 4464.


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

    /** @return list<bool> */
    public function readCoils(int $address, int $quantity): array
    {
        return $this->readBits(FunctionCode::ReadCoils, $address, $quantity);
    }

    /** @return list<bool> */
    public function readDiscreteInputs(int $address, int $quantity): array
    {
        return $this->readBits(FunctionCode::ReadDiscreteInputs, $address, $quantity);
    }

    /** @return list<int> */
    public function readHoldingRegisters(int $address, int $quantity): array
    {
        return $this->readRegisters(FunctionCode::ReadHoldingRegisters, $address, $quantity);
    }

    /** @return list<int> */
    public function readInputRegisters(int $address, int $quantity): array
    {
        return $this->readRegisters(FunctionCode::ReadInputRegisters, $address, $quantity);
    }

    public function writeSingleCoil(int $address, bool $value): void
    {
        self::check($address, 1, 1);
        $request = Pdu::writeSingleCoilRequest($address, $value);
        Pdu::parseEcho(FunctionCode::WriteSingleCoil, $request, $this->transact($request));
    }

    public function writeSingleRegister(int $address, int $value): void
    {
        self::check($address, 1, 1);
        self::checkValues([$value]);
        $request = Pdu::writeSingleRegisterRequest($address, $value);
        Pdu::parseEcho(FunctionCode::WriteSingleRegister, $request, $this->transact($request));
    }

    /**
     * @param list<int> $values
     */
    public function writeMultipleRegisters(int $address, array $values): void
    {
        self::check($address, count($values), Pdu::MAX_WRITE_REGISTERS);
        self::checkValues($values);
        $request = Pdu::writeMultipleRegistersRequest($address, array_values($values));
        Pdu::parseEcho(FunctionCode::WriteMultipleRegisters, $request, $this->transact($request));
    }

    /** @return list<bool> */
    private function readBits(FunctionCode $function, int $address, int $quantity): array
    {
        self::check($address, $quantity, Pdu::MAX_READ_BITS);
        $response = $this->transact(Pdu::readRequest($function, $address, $quantity));
        return Pdu::parseBits($function, $response, $quantity);
    }

    /** @return list<int> */
    private function readRegisters(FunctionCode $function, int $address, int $quantity): array
    {
        self::check($address, $quantity, Pdu::MAX_READ_REGISTERS);
        $response = $this->transact(Pdu::readRequest($function, $address, $quantity));
        return Pdu::parseRegisters($function, $response, $quantity);
    }

    /**
     * @param array<int> $values
     */
    private static function checkValues(array $values): void
    {
        foreach ($values as $value) {
            if ($value < 0 || $value > 0xFFFF) {
                throw new \InvalidArgumentException("register value {$value} out of range 0-65535");
            }
        }
    }
}

La memoria dati

La classe DataStore contiene le quattro tabelle come array e trasforma una PDU di richiesta in una PDU di risposta. L'espressione match sull'enumerazione rende la gestione dei codici funzione esaustiva: il ramo null raccoglie i codici sconosciuti restituiti da tryFrom.

<?php

declare(strict_types=1);

namespace Modbus;

final class DataStore
{
    /** @var list<bool> */
    public array $coils;
    /** @var list<bool> */
    public array $discreteInputs;
    /** @var list<int> */
    public array $holdingRegisters;
    /** @var list<int> */
    public array $inputRegisters;

    public function __construct(int $size)
    {
        $this->coils = array_fill(0, $size, false);
        $this->discreteInputs = array_fill(0, $size, false);
        $this->holdingRegisters = array_fill(0, $size, 0);
        $this->inputRegisters = array_fill(0, $size, 0);
    }

    // Elabora una PDU di richiesta e restituisce la PDU di risposta
    public function handle(string $pdu): string
    {
        if ($pdu === '') {
            return self::exception(0, ExceptionCode::IllegalFunction);
        }
        $code = ord($pdu[0]);
        $function = FunctionCode::tryFrom($code);
        return match ($function) {
            FunctionCode::ReadCoils, FunctionCode::ReadDiscreteInputs => $this->readBits($function, $pdu),
            FunctionCode::ReadHoldingRegisters, FunctionCode::ReadInputRegisters => $this->readRegisters($function, $pdu),
            FunctionCode::WriteSingleCoil => $this->writeSingleCoil($pdu),
            FunctionCode::WriteSingleRegister => $this->writeSingleRegister($pdu),
            FunctionCode::WriteMultipleRegisters => $this->writeMultipleRegisters($pdu),
            null => self::exception($code, ExceptionCode::IllegalFunction),
        };
    }

    private static function exception(int $function, ExceptionCode $code): string
    {
        return pack('CC', $function | 0x80, $code->value);
    }

Ogni gestore segue l'ordine di controllo indicato dalla specifica: prima la forma della richiesta e le quantità, con l'eccezione 0x03, poi l'intervallo degli indirizzi, con l'eccezione 0x02. Il terzo argomento di unpack indica l'offset da cui iniziare a leggere, così evitiamo di creare sottostringhe intermedie.


    private function readBits(FunctionCode $function, string $pdu): string
    {
        if (strlen($pdu) !== 5) {
            return self::exception($function->value, ExceptionCode::IllegalDataValue);
        }
        ['address' => $address, 'quantity' => $quantity] = unpack('naddress/nquantity', $pdu, 1);
        if ($quantity < 1 || $quantity > Pdu::MAX_READ_BITS) {
            return self::exception($function->value, ExceptionCode::IllegalDataValue);
        }
        $table = $function === FunctionCode::ReadCoils ? $this->coils : $this->discreteInputs;
        if ($address + $quantity > count($table)) {
            return self::exception($function->value, ExceptionCode::IllegalDataAddress);
        }
        $data = Pdu::packBits(array_slice($table, $address, $quantity));
        return pack('CC', $function->value, strlen($data)) . $data;
    }

    private function readRegisters(FunctionCode $function, string $pdu): string
    {
        if (strlen($pdu) !== 5) {
            return self::exception($function->value, ExceptionCode::IllegalDataValue);
        }
        ['address' => $address, 'quantity' => $quantity] = unpack('naddress/nquantity', $pdu, 1);
        if ($quantity < 1 || $quantity > Pdu::MAX_READ_REGISTERS) {
            return self::exception($function->value, ExceptionCode::IllegalDataValue);
        }
        $table = $function === FunctionCode::ReadHoldingRegisters ? $this->holdingRegisters : $this->inputRegisters;
        if ($address + $quantity > count($table)) {
            return self::exception($function->value, ExceptionCode::IllegalDataAddress);
        }
        return pack('CC', $function->value, 2 * $quantity)
            . pack('n*', ...array_slice($table, $address, $quantity));
    }

    private function writeSingleCoil(string $pdu): string
    {
        $function = FunctionCode::WriteSingleCoil->value;
        if (strlen($pdu) !== 5) {
            return self::exception($function, ExceptionCode::IllegalDataValue);
        }
        ['address' => $address, 'value' => $value] = unpack('naddress/nvalue', $pdu, 1);
        if ($value !== 0xFF00 && $value !== 0x0000) {
            return self::exception($function, ExceptionCode::IllegalDataValue);
        }
        if ($address >= count($this->coils)) {
            return self::exception($function, ExceptionCode::IllegalDataAddress);
        }
        $this->coils[$address] = $value === 0xFF00;
        return $pdu;
    }

    private function writeSingleRegister(string $pdu): string
    {
        $function = FunctionCode::WriteSingleRegister->value;
        if (strlen($pdu) !== 5) {
            return self::exception($function, ExceptionCode::IllegalDataValue);
        }
        ['address' => $address, 'value' => $value] = unpack('naddress/nvalue', $pdu, 1);
        if ($address >= count($this->holdingRegisters)) {
            return self::exception($function, ExceptionCode::IllegalDataAddress);
        }
        $this->holdingRegisters[$address] = $value;
        return $pdu;
    }

    private function writeMultipleRegisters(string $pdu): string
    {
        $function = FunctionCode::WriteMultipleRegisters->value;
        if (strlen($pdu) < 6) {
            return self::exception($function, ExceptionCode::IllegalDataValue);
        }
        ['address' => $address, 'quantity' => $quantity, 'bytes' => $bytes] =
            unpack('naddress/nquantity/Cbytes', $pdu, 1);
        if ($quantity < 1 || $quantity > Pdu::MAX_WRITE_REGISTERS
            || $bytes !== 2 * $quantity || strlen($pdu) !== 6 + $bytes) {
            return self::exception($function, ExceptionCode::IllegalDataValue);
        }
        if ($address + $quantity > count($this->holdingRegisters)) {
            return self::exception($function, ExceptionCode::IllegalDataAddress);
        }
        foreach (array_values(unpack('n*', substr($pdu, 6))) as $i => $value) {
            $this->holdingRegisters[$address + $i] = $value;
        }
        return substr($pdu, 0, 5);
    }
}

Un server a processo singolo con stream_select

PHP non ha thread nella configurazione standard, ma per servire più client contemporaneamente non ne servono. La funzione stream_select attende che uno qualunque fra più stream sia pronto per la lettura o per la scrittura, e permette di costruire un ciclo degli eventi in cui un solo processo gestisce il socket di ascolto e tutte le connessioni aperte. È lo stesso modello usato da librerie come ReactPHP, ridotto all'essenziale.

Con un ciclo degli eventi non possiamo più leggere un frame alla volta in modo bloccante: una connessione lenta bloccherebbe tutte le altre. Ogni connessione ha quindi un buffer di ingresso, in cui accumuliamo i byte ricevuti, e un buffer di uscita, in cui mettiamo le risposte da inviare quando il socket è pronto ad accettarle.

<?php

declare(strict_types=1);

namespace Modbus;

final class TcpServer
{
    /** @var resource|null */
    private $listener = null;
    /** @var array<int, resource> */
    private array $clients = [];
    /** @var array<int, string> */
    private array $input = [];
    /** @var array<int, string> */
    private array $output = [];
    /** @var array<int, float> */
    private array $lastActivity = [];
    private bool $running = false;

    /**
     * @param int $unitId 0 accetta qualunque unit identifier
     * @param float $idleTimeout secondi di inattivita' prima della chiusura, 0 per disabilitare
     */
    public function __construct(
        public readonly DataStore $store,
        private readonly int $unitId = 0,
        private readonly float $idleTimeout = 0.0,
    ) {
    }

    public function listen(string $address): void
    {
        $listener = @stream_socket_server("tcp://{$address}", $errno, $errstr);
        if ($listener === false) {
            throw new TransportException("listen on {$address} failed: {$errstr}", $errno);
        }
        stream_set_blocking($listener, false);
        $this->listener = $listener;
    }

    public function port(): int
    {
        $name = stream_socket_get_name($this->listener, false);
        return (int) substr($name, strrpos($name, ':') + 1);
    }

Il metodo poll esegue un singolo giro del ciclo: costruisce l'elenco degli stream da osservare, attende al massimo il tempo indicato, accetta le nuove connessioni, legge i dati disponibili, scrive le risposte in sospeso e chiude le connessioni inattive. Un socket viene inserito nell'elenco di scrittura solo se ha dati da inviare, altrimenti stream_select ritornerebbe subito a ogni giro, perché un socket è quasi sempre pronto per la scrittura.


    public function poll(float $timeout): void
    {
        $read = [$this->listener, ...array_values($this->clients)];
        $write = [];
        foreach ($this->output as $id => $pending) {
            if ($pending !== '') {
                $write[] = $this->clients[$id];
            }
        }
        $except = null;
        $seconds = (int) $timeout;
        $ready = @stream_select($read, $write, $except, $seconds, (int) (($timeout - $seconds) * 1_000_000));
        if ($ready === false) {
            // interrotto da un segnale: riproveremo al prossimo giro
            return;
        }
        foreach ($read as $stream) {
            if ($stream === $this->listener) {
                $this->accept();
            } else {
                $this->receive((int) $stream);
            }
        }
        foreach ($write as $stream) {
            $this->flush((int) $stream);
        }
        $this->closeIdle();
    }

    private function accept(): void
    {
        $conn = @stream_socket_accept($this->listener, 0);
        if ($conn === false) {
            return;
        }
        stream_set_blocking($conn, false);
        stream_set_read_buffer($conn, 0);
        $id = (int) $conn;
        $this->clients[$id] = $conn;
        $this->input[$id] = '';
        $this->output[$id] = '';
        $this->lastActivity[$id] = microtime(true);
    }

La chiamata a stream_set_read_buffer con valore 0 disattiva il buffer interno di PHP sulla connessione, così tutti i byte ricevuti restano visibili a stream_select oppure finiscono nel nostro buffer. Il cast (int) $conn restituisce l'identificativo numerico della risorsa, comodo come chiave degli array.

Il metodo receive estrae dal buffer tutti i frame completi. Un frame è completo quando il buffer contiene almeno i sei byte che precedono il campo length incluso, più i byte indicati da quel campo. Se il buffer contiene un frame e mezzo, elaboriamo il primo e lasciamo il resto in attesa dei byte mancanti.


    private function receive(int $id): void
    {
        $chunk = @fread($this->clients[$id], 65536);
        if ($chunk === false || ($chunk === '' && feof($this->clients[$id]))) {
            $this->disconnect($id);
            return;
        }
        $this->input[$id] .= $chunk;
        $this->lastActivity[$id] = microtime(true);

        while (strlen($this->input[$id]) >= 7) {
            $header = unpack('ntid/nprotocol/nlength/Cunit', $this->input[$id]);
            if ($header['protocol'] !== 0 || $header['length'] < 2 || $header['length'] > 254) {
                // errore di framing: impossibile ritrovare l'inizio del frame successivo
                $this->disconnect($id);
                return;
            }
            $frameLength = 6 + $header['length'];
            if (strlen($this->input[$id]) < $frameLength) {
                break;
            }
            $pdu = substr($this->input[$id], 7, $header['length'] - 1);
            $this->input[$id] = substr($this->input[$id], $frameLength);
            if ($this->unitId !== 0 && $header['unit'] !== $this->unitId) {
                // un dispositivo reale ignora le richieste destinate ad altri
                continue;
            }
            $response = $this->store->handle($pdu);
            $this->output[$id] .= pack('nnnC', $header['tid'], 0, strlen($response) + 1, $header['unit'])
                . $response;
        }
        $this->flush($id);
    }

    private function flush(int $id): void
    {
        if (!isset($this->clients[$id]) || $this->output[$id] === '') {
            return;
        }
        $written = @fwrite($this->clients[$id], $this->output[$id]);
        if ($written === false) {
            $this->disconnect($id);
            return;
        }
        $this->output[$id] = substr($this->output[$id], $written);
    }

Alla fine di receive tentiamo subito l'invio della risposta: nella stragrande maggioranza dei casi il socket la accetta per intero e la transazione si chiude senza attendere un altro giro del ciclo. Solo se la scrittura è parziale i byte rimanenti restano nel buffer di uscita e vengono inviati quando stream_select segnala il socket come scrivibile.

Restano la gestione delle connessioni inattive, la disconnessione e il ciclo principale. Il metodo run accetta una funzione opzionale da chiamare a intervalli regolari: è il punto in cui un simulatore aggiorna le misure o un adattatore copia nella memoria i dati provenienti da un'altra fonte.


    private function closeIdle(): void
    {
        if ($this->idleTimeout <= 0) {
            return;
        }
        $now = microtime(true);
        foreach ($this->lastActivity as $id => $last) {
            if ($now - $last > $this->idleTimeout) {
                $this->disconnect($id);
            }
        }
    }

    private function disconnect(int $id): void
    {
        if (isset($this->clients[$id])) {
            fclose($this->clients[$id]);
        }
        unset($this->clients[$id], $this->input[$id], $this->output[$id], $this->lastActivity[$id]);
    }

    /**
     * @param (callable(DataStore): void)|null $tick
     */
    public function run(?callable $tick = null, float $tickInterval = 1.0): void
    {
        $this->running = true;
        $nextTick = microtime(true);
        while ($this->running) {
            if ($tick !== null && microtime(true) >= $nextTick) {
                $tick($this->store);
                $nextTick += $tickInterval;
            }
            $this->poll(0.1);
        }
        foreach (array_keys($this->clients) as $id) {
            $this->disconnect($id);
        }
    }

    public function stop(): void
    {
        $this->running = false;
    }
}

Il timeout di inattività serve a proteggere il server dalle connessioni abbandonate, frequenti con client che si riavviano senza chiudere il socket. Poiché tutto gira in un solo processo, la memoria dati non ha bisogno di lock: la funzione tick e le richieste dei client non vengono mai eseguite contemporaneamente.

Interpretare i registri

Un registro Modbus è un intero senza segno a 16 bit, e il produttore decide come usarlo. Le codifiche più frequenti sono l'intero con segno a 16 bit, spesso scalato, l'intero a 32 bit su due registri e il float32 IEEE 754 su due registri. Per i valori a 32 bit l'ordine dei due registri non è fissato dalla specifica: alcuni dispositivi mettono per prima la parola alta (ordine ABCD), altri la parola bassa (CDAB).

<?php

declare(strict_types=1);

namespace Modbus;

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

final class Codec
{
    // Interpreta un registro come intero con segno in complemento a due
    public static function int16(int $register): int
    {
        return ($register & 0x8000) !== 0 ? $register - 0x10000 : $register;
    }

    /**
     * @param array{int, int} $registers
     */
    public static function uint32(array $registers, WordOrder $order): int
    {
        [$high, $low] = self::ordered($registers, $order);
        return ($high << 16) | $low;
    }

    /**
     * @return array{int, int}
     */
    public static function fromUint32(int $value, WordOrder $order): array
    {
        $high = ($value >> 16) & 0xFFFF;
        $low = $value & 0xFFFF;
        return $order === WordOrder::LowWordFirst ? [$low, $high] : [$high, $low];
    }

    /**
     * @param array{int, int} $registers
     */
    public static function float32(array $registers, WordOrder $order): float
    {
        [$high, $low] = self::ordered($registers, $order);
        return unpack('G', pack('nn', $high, $low))[1];
    }

    /**
     * @return array{int, int}
     */
    public static function fromFloat32(float $value, WordOrder $order): array
    {
        return self::fromUint32(unpack('N', pack('G', $value))[1], $order);
    }

    /**
     * @param array{int, int} $registers
     * @return array{int, int}
     */
    private static function ordered(array $registers, WordOrder $order): array
    {
        if (count($registers) !== 2) {
            throw new \InvalidArgumentException('exactly two registers are required');
        }
        [$first, $second] = array_values($registers);
        return $order === WordOrder::LowWordFirst ? [$second, $first] : [$first, $second];
    }
}

Il formato G di pack codifica un float a precisione singola in big-endian. Su PHP a 64 bit gli interi hanno spazio sufficiente per rappresentare un uint32 senza segno; su un'installazione a 32 bit, ormai rarissima, i valori sopra 231 diventerebbero negativi. Come sempre, quando la documentazione non chiarisce l'ordine delle parole, conviene leggere una grandezza nota e provare entrambe le interpretazioni.

Il CRC di Modbus RTU

Molti dispositivi parlano solo Modbus RTU su linea seriale, e alcuni gateway espongono sulla rete i frame RTU così come sono, con il CRC in coda e senza intestazione MBAP. Il CRC è un CRC-16 con polinomio riflesso 0xA001 e valore iniziale 0xFFFF, trasmesso con il byte meno significativo per primo, che in pack corrisponde al formato little-endian v.

<?php

declare(strict_types=1);

namespace Modbus;

final class Crc16
{
    public static function compute(string $data): int
    {
        $crc = 0xFFFF;
        $length = strlen($data);
        for ($i = 0; $i < $length; $i++) {
            $crc ^= ord($data[$i]);
            for ($bit = 0; $bit < 8; $bit++) {
                $crc = ($crc & 1) !== 0 ? ($crc >> 1) ^ 0xA001 : $crc >> 1;
            }
        }
        return $crc;
    }

    // Aggiunge il CRC al frame, byte meno significativo per primo
    public static function append(string $frame): string
    {
        return $frame . pack('v', self::compute($frame));
    }
}

La richiesta RTU di dieci holding register all'unità 1 è 01 03 00 00 00 0A C5 CD, un valore di riferimento comodo per i test.

I test

Poiché il nostro server e il nostro client sono entrambi bloccanti dal punto di vista di chi li usa, nei test non possiamo farli girare nello stesso processo. La soluzione più pulita è avviare il server come processo separato con proc_open. Lo script di fixture si mette in ascolto sulla porta 0, così il sistema operativo sceglie una porta libera, e la comunica al test stampandola sullo standard output.

<?php

declare(strict_types=1);

require __DIR__ . '/../../vendor/autoload.php';

use Modbus\DataStore;
use Modbus\TcpServer;

$store = new DataStore(100);
$store->discreteInputs[9] = true;
$store->inputRegisters[0] = 0xFF38;

$server = new TcpServer($store);
$server->listen('127.0.0.1:0');
fwrite(STDOUT, $server->port() . PHP_EOL);
$server->run();
<?php

declare(strict_types=1);

namespace Modbus\Tests;

use Modbus\Codec;
use Modbus\Crc16;
use Modbus\DataStore;
use Modbus\DeviceException;
use Modbus\ExceptionCode;
use Modbus\TcpClient;
use Modbus\TimeoutException;
use Modbus\TransportException;
use Modbus\WordOrder;
use PHPUnit\Framework\TestCase;

final class ModbusTest extends TestCase
{
    /** @var resource|null */
    private static $process = null;
    private static int $port = 0;

    public static function setUpBeforeClass(): void
    {
        $command = [PHP_BINARY, __DIR__ . '/fixtures/server.php'];
        self::$process = proc_open($command, [1 => ['pipe', 'w']], $pipes);
        self::$port = (int) fgets($pipes[1]);
    }

    public static function tearDownAfterClass(): void
    {
        if (self::$process !== null) {
            proc_terminate(self::$process);
            proc_close(self::$process);
        }
    }

    private function client(): TcpClient
    {
        return TcpClient::connect('127.0.0.1', self::$port, 1, 1.0);
    }

    public function testRegistersRoundTrip(): void
    {
        $client = $this->client();
        $client->writeMultipleRegisters(10, [1, 2, 0xBEEF]);
        $client->writeSingleRegister(13, 42);
        $this->assertSame([1, 2, 0xBEEF, 42], $client->readHoldingRegisters(10, 4));
    }

    public function testCoilsAndInputs(): void
    {
        $client = $this->client();
        $client->writeSingleCoil(3, true);
        $coils = $client->readCoils(0, 10);
        foreach ($coils as $i => $value) {
            $this->assertSame($i === 3, $value);
        }
        $inputs = $client->readDiscreteInputs(0, 10);
        $this->assertTrue($inputs[9]);
        $this->assertFalse($inputs[8]);
        $this->assertSame(-200, Codec::int16($client->readInputRegisters(0, 1)[0]));
    }

    public function testIllegalAddressKeepsConnection(): void
    {
        $client = $this->client();
        try {
            $client->readHoldingRegisters(95, 10);
            $this->fail('expected DeviceException');
        } catch (DeviceException $e) {
            $this->assertSame(ExceptionCode::IllegalDataAddress->value, $e->exceptionCode);
        }
        // dopo un'eccezione Modbus la connessione resta valida
        $this->assertCount(1, $client->readHoldingRegisters(0, 1));
    }

    public function testClientSideValidation(): void
    {
        $this->expectException(\InvalidArgumentException::class);
        $this->client()->writeSingleRegister(0, 70000);
    }

    public function testTimeoutClosesClient(): void
    {
        // un listener che non accetta mai: la connessione resta nel backlog del kernel
        $listener = stream_socket_server('tcp://127.0.0.1:0');
        $name = stream_socket_get_name($listener, false);
        $port = (int) substr($name, strrpos($name, ':') + 1);
        $client = TcpClient::connect('127.0.0.1', $port, 1, 0.2);

        $started = microtime(true);
        try {
            $client->readCoils(0, 1);
            $this->fail('expected TimeoutException');
        } catch (TimeoutException) {
            $this->assertLessThan(1.0, microtime(true) - $started);
        }
        $this->expectException(TransportException::class);
        $client->readCoils(0, 1);
    }

    public function testHandleIllegalFunction(): void
    {
        $this->assertSame("\xAB\x01", (new DataStore(10))->handle("\x2B\x0E"));
    }

    public function testFloatWordOrder(): void
    {
        foreach (WordOrder::cases() as $order) {
            $registers = Codec::fromFloat32(230.5, $order);
            $this->assertSame(230.5, Codec::float32($registers, $order));
        }
        $this->assertSame([0x3F80, 0x0000], Codec::fromFloat32(1.0, WordOrder::HighWordFirst));
        $this->assertSame([0x0000, 0x3F80], Codec::fromFloat32(1.0, WordOrder::LowWordFirst));
    }

    public function testCrc16(): void
    {
        $frame = Crc16::append("\x01\x03\x00\x00\x00\x0A");
        $this->assertSame("\xC5\xCD", substr($frame, -2));
    }
}

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à mai. Il valore 230,5 nel test dei float è scelto perché rappresentabile esattamente a 32 bit, così il confronto con assertSame non risente degli arrotondamenti. Si eseguono i test con:

composer install
vendor/bin/phpunit tests

Un simulatore e un client di polling

Mettiamo insieme i pezzi con due script. Il primo simula un misuratore di energia che pubblica la tensione come float32 negli input register 0 e 1 e la aggiorna ogni secondo tramite la funzione di tick. La gestione dei segnali permette di fermarlo in modo pulito con Ctrl+C.

<?php

declare(strict_types=1);

require __DIR__ . '/../vendor/autoload.php';

use Modbus\Codec;
use Modbus\DataStore;
use Modbus\TcpServer;
use Modbus\WordOrder;

$server = new TcpServer(new DataStore(10), idleTimeout: 60.0);
$server->listen('127.0.0.1:1502');

if (function_exists('pcntl_async_signals')) {
    pcntl_async_signals(true);
    pcntl_signal(SIGINT, fn () => $server->stop());
    pcntl_signal(SIGTERM, fn () => $server->stop());
}

echo "simulator listening on 127.0.0.1:1502\n";
$server->run(function (DataStore $store): void {
    $voltage = 228 + mt_rand() / mt_getrandmax() * 4;
    [$store->inputRegisters[0], $store->inputRegisters[1]] =
        Codec::fromFloat32($voltage, WordOrder::HighWordFirst);
});

Il secondo script è un client che legge la tensione cinque volte e gestisce separatamente le eccezioni del dispositivo e gli errori di trasporto, che in un collettore reale porterebbero rispettivamente a registrare un allarme e a tentare una riconnessione.

<?php

declare(strict_types=1);

require __DIR__ . '/../vendor/autoload.php';

use Modbus\Codec;
use Modbus\DeviceException;
use Modbus\TcpClient;
use Modbus\TransportException;
use Modbus\WordOrder;

try {
    $client = TcpClient::connect('127.0.0.1', 1502, unitId: 1, timeout: 2.0);
    for ($i = 0; $i < 5; $i++) {
        $registers = $client->readInputRegisters(0, 2);
        $voltage = Codec::float32($registers, WordOrder::HighWordFirst);
        printf("voltage: %.2f V (raw %04X %04X)\n", $voltage, $registers[0], $registers[1]);
        sleep(1);
    }
} catch (DeviceException $e) {
    fwrite(STDERR, "device error: {$e->getMessage()}\n");
    exit(1);
} catch (TransportException $e) {
    fwrite(STDERR, "transport error: {$e->getMessage()}\n");
    exit(2);
}

Si avvia il simulatore in un terminale con php bin/simulator.php e il client in un altro con php bin/poll.php. La porta 1502 evita di dover eseguire PHP con privilegi elevati, necessari su Linux per ascoltare sulla porta standard 502.

Considerazioni per la produzione

  • Dove eseguire il polling: non va mai fatto all'interno di una richiesta web. Un dispositivo che non risponde terrebbe occupato un worker di PHP-FPM per tutta la durata del timeout. Il posto giusto è un processo a riga di comando di lunga durata, gestito da systemd o da Supervisor, oppure un job pianificato, che salva i valori nel database o in una cache da cui le pagine web li leggono.
  • Riconnessione: il client si chiude dopo ogni errore di trasporto. Il ciclo di polling deve ricrearlo, possibilmente con un backoff esponenziale, mentre le DeviceException non richiedono di riaprire la connessione.
  • Letture raggruppate: ogni transazione costa un round trip, e dietro un gateway seriale anche decine di millisecondi. Leggere blocchi contigui di registri riduce drasticamente i tempi.
  • Sicurezza: Modbus TCP non ha né autenticazione né cifratura. Chiunque raggiunga la porta 502 può scrivere registri e comandare uscite. I dispositivi vanno isolati in una rete dedicata, con il firewall a filtrare gli accessi. La variante Modbus/TCP Security incapsula il protocollo in 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 seguendo lo stesso schema.

Per chi preferisce una libreria già pronta, il pacchetto Composer aldas/modbus-tcp-client offre un client per TCP, UDP e RTU over TCP, con classi per comporre e interpretare richieste e risposte che si possono riusare anche per costruire un server. Conoscere il formato dei byte resta comunque prezioso, perché permette di leggere una cattura di rete e capire subito se il problema è nel dispositivo, nella rete o nel codice.

Conclusione

Con le sole funzioni di stream di PHP abbiamo costruito un'implementazione completa di Modbus TCP per le funzioni più usate. Il client gestisce correttamente il framing su TCP, i timeout complessivi e le risposte tardive. Il server serve più client in un unico processo grazie a stream_select, con buffer di ingresso e di uscita per ogni connessione. A questo si aggiungono le conversioni tra registri e valori numerici e il CRC di Modbus RTU. Le funzioni pack e unpack, spesso trascurate, si sono rivelate uno strumento potente per lavorare con i protocolli binari, a patto di ricordare che non segnalano gli overflow e che la validazione dei valori resta compito nostro.