Modbus TCP in C++20: client e server con i socket POSIX

Abbiamo costruito un'implementazione di Modbus TCP completa per le funzioni più usate, sfruttando le caratteristiche di C++20.

C++ di Gabriele Romanato

C++ è il linguaggio di elezione quando Modbus va implementato vicino all'hardware: firmware di gateway industriali, software di controllo su Linux embedded, driver per sistemi SCADA, applicazioni che devono interrogare centinaia di dispositivi con risorse limitate. In questi contesti si vuole il pieno controllo su memoria, thread e chiamate di sistema, e un protocollo semplice come Modbus si presta bene a un'implementazione diretta, senza strati di astrazione intermedi.

In questo articolo implementiamo Modbus TCP in C++20 usando i socket POSIX e la libreria standard. Costruiremo un wrapper RAII per i descrittori di file, un client con timeout basati su poll, un server con un std::jthread per connessione e una memoria dati protetta da std::shared_mutex. Aggiungeremo poi le conversioni numeriche con std::bit_cast e un CRC di Modbus RTU calcolato a tempo di compilazione con constexpr. Il progetto si compila con CMake e i test usano Catch2.

Il modello dati

Un dispositivo Modbus espone quattro tabelle, ciascuna con indirizzi a 16 bit:

TabellaElementoAccessoUso tipico
Coil1 bitlettura e scritturauscite digitali, comandi
Discrete input1 bitsola letturaingressi digitali, allarmi
Holding register16 bitlettura e scritturasetpoint, configurazione
Input register16 bitsola letturamisure

Nella documentazione dei dispositivi si trova spesso la numerazione storica: 40001 indica il primo holding register, 30001 il primo input register. Questa numerazione parte da 1, mentre gli indirizzi trasmessi partono da 0: il registro 40001 si legge all'indirizzo 0. È la causa più frequente di valori che sembrano spostati di una posizione.

Il formato dei frame

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

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

Una lettura di dieci holding register a partire dall'indirizzo 0, verso l'unità 1, è questa sequenza di byte:

00 01  00 00  00 06  01  03  00 00  00 0A
 tid   proto  length unit fc  addr   qty

Per codificare i campi in big-endian non useremo htons né conversioni tramite puntatori: scriveremo e leggeremo i byte uno alla volta con spostamenti e maschere. È un codice portabile, indipendente dall'architettura e privo dei comportamenti indefiniti legati all'aliasing e all'allineamento che i cast tra puntatori portano con sé.

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

Struttura del progetto

modbus-cpp/
├── CMakeLists.txt
├── include/modbus/
│   ├── errors.hpp
│   ├── pdu.hpp
│   ├── socket.hpp
│   ├── client.hpp
│   ├── server.hpp
│   ├── codec.hpp
│   └── crc.hpp
├── src/
│   ├── pdu.cpp
│   ├── socket.cpp
│   ├── client.cpp
│   └── server.cpp
├── tests/modbus_tests.cpp
└── examples/demo.cpp
cmake_minimum_required(VERSION 3.24)
project(modbus_cpp LANGUAGES CXX)

set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_EXTENSIONS OFF)

find_package(Threads REQUIRED)

add_library(modbus
    src/pdu.cpp
    src/socket.cpp
    src/client.cpp
    src/server.cpp)
target_include_directories(modbus PUBLIC include)
target_link_libraries(modbus PUBLIC Threads::Threads)
target_compile_options(modbus PRIVATE -Wall -Wextra -Wpedantic)

add_executable(demo examples/demo.cpp)
target_link_libraries(demo PRIVATE modbus)

include(CTest)
if(BUILD_TESTING)
    include(FetchContent)
    FetchContent_Declare(Catch2
        GIT_REPOSITORY https://github.com/catchorg/Catch2.git
        GIT_TAG v3.16.1)
    FetchContent_MakeAvailable(Catch2)

    add_executable(modbus_tests tests/modbus_tests.cpp)
    target_link_libraries(modbus_tests PRIVATE modbus Catch2::Catch2WithMain)
    include(Catch)
    catch_discover_tests(modbus_tests)
endif()

Gli errori

Gli errori sono rappresentati da una gerarchia di eccezioni che deriva da std::runtime_error. La distinzione importante è tra DeviceException, cioè un rifiuto ordinato da parte del dispositivo dopo il quale la connessione resta valida, e TransportError, che indica un problema di rete dopo il quale la connessione non è più affidabile. TimeoutError è un caso particolare di errore di trasporto, quindi un singolo catch su TransportError li intercetta entrambi.

#pragma once

#include <cstdint>
#include <stdexcept>
#include <string>

namespace modbus {

// Classe base per tutti gli errori della libreria
class ModbusError : public std::runtime_error {
public:
    using std::runtime_error::runtime_error;
};

// Risposta malformata o incoerente con la richiesta
class ProtocolError : public ModbusError {
public:
    using ModbusError::ModbusError;
};

// Errore di rete, connessione chiusa o client gia' chiuso
class TransportError : public ModbusError {
public:
    using ModbusError::ModbusError;
};

// Nessuna risposta entro il timeout
class TimeoutError : public TransportError {
public:
    using TransportError::TransportError;
};

// Il dispositivo ha risposto con un'eccezione Modbus
class DeviceException : public ModbusError {
public:
    DeviceException(std::uint8_t function, std::uint8_t code);

    std::uint8_t function() const noexcept { return function_; }
    std::uint8_t code() const noexcept { return code_; }

private:
    std::uint8_t function_;
    std::uint8_t code_;
};

}  // namespace modbus

Codifica delle PDU

Il file pdu.hpp dichiara costanti, limiti e funzioni di codifica. Le costanti sono inline constexpr: inline evita definizioni multiple quando l'header è incluso in più unità di traduzione. 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.

#pragma once

#include <cstddef>
#include <cstdint>
#include <span>
#include <vector>

#include "modbus/errors.hpp"

namespace modbus {

using Bytes = std::vector<std::uint8_t>;

// Codici funzione supportati
namespace function {
inline constexpr std::uint8_t read_coils = 0x01;
inline constexpr std::uint8_t read_discrete_inputs = 0x02;
inline constexpr std::uint8_t read_holding_registers = 0x03;
inline constexpr std::uint8_t read_input_registers = 0x04;
inline constexpr std::uint8_t write_single_coil = 0x05;
inline constexpr std::uint8_t write_single_register = 0x06;
inline constexpr std::uint8_t write_multiple_registers = 0x10;
}  // namespace function

// Codici di eccezione definiti dalla specifica
namespace exception_code {
inline constexpr std::uint8_t illegal_function = 0x01;
inline constexpr std::uint8_t illegal_data_address = 0x02;
inline constexpr std::uint8_t illegal_data_value = 0x03;
inline constexpr std::uint8_t server_device_failure = 0x04;
}  // namespace exception_code

// Quantita' massime per singola richiesta
inline constexpr std::size_t max_read_bits = 2000;
inline constexpr std::size_t max_read_registers = 125;
inline constexpr std::size_t max_write_registers = 123;

// Accoda un valore a 16 bit in big-endian
inline void put_u16(Bytes& out, std::uint16_t value) {
    out.push_back(static_cast<std::uint8_t>(value >> 8));
    out.push_back(static_cast<std::uint8_t>(value & 0xFF));
}

// Legge un valore a 16 bit in big-endian
inline std::uint16_t get_u16(std::span<const std::uint8_t> data, std::size_t offset) {
    return static_cast<std::uint16_t>((data[offset] << 8) | data[offset + 1]);
}

const char* describe_exception(std::uint8_t code) noexcept;

Bytes read_request(std::uint8_t fn, std::uint16_t address, std::uint16_t quantity);
Bytes write_single_coil_request(std::uint16_t address, bool value);
Bytes write_single_register_request(std::uint16_t address, std::uint16_t value);
Bytes write_multiple_registers_request(std::uint16_t address, std::span<const std::uint16_t> values);

Bytes pack_bits(const std::vector<bool>& bits, std::size_t offset, std::size_t count);
std::vector<bool> unpack_bits(std::span<const std::uint8_t> data, std::size_t count);

void check_function(std::uint8_t fn, std::span<const std::uint8_t> pdu);
std::vector<bool> parse_bits(std::uint8_t fn, std::span<const std::uint8_t> pdu, std::size_t quantity);
std::vector<std::uint16_t> parse_registers(std::uint8_t fn, std::span<const std::uint8_t> pdu,
                                           std::size_t quantity);
void parse_echo(std::span<const std::uint8_t> request, std::span<const std::uint8_t> pdu);

}  // namespace modbus

std::span, introdotto in C++20, è una vista non proprietaria su una sequenza contigua: le funzioni che lo ricevono accettano indifferentemente un std::vector, un std::array o un array C, senza copie. Per i bit usiamo invece std::vector<bool>, che però è una specializzazione particolare: memorizza i valori impacchettati e non espone una memoria contigua di bool, quindi non può essere visto come uno std::span<const bool>. Per questo pack_bits riceve il vettore con un offset e un conteggio.

L'implementazione delle richieste è diretta. 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.

#include "modbus/pdu.hpp"

#include <algorithm>
#include <format>

namespace modbus {

const char* describe_exception(std::uint8_t code) noexcept {
    switch (code) {
        case exception_code::illegal_function: return "illegal function";
        case exception_code::illegal_data_address: return "illegal data address";
        case exception_code::illegal_data_value: return "illegal data value";
        case exception_code::server_device_failure: return "server device failure";
        default: return "unknown";
    }
}

DeviceException::DeviceException(std::uint8_t function, std::uint8_t code)
    : ModbusError(std::format("exception 0x{:02X} ({}) for function 0x{:02X}", code,
                              describe_exception(code), function)),
      function_(function),
      code_(code) {}

Bytes read_request(std::uint8_t fn, std::uint16_t address, std::uint16_t quantity) {
    Bytes pdu{fn};
    put_u16(pdu, address);
    put_u16(pdu, quantity);
    return pdu;
}

Bytes write_single_coil_request(std::uint16_t address, bool value) {
    return read_request(function::write_single_coil, address, value ? 0xFF00 : 0x0000);
}

Bytes write_single_register_request(std::uint16_t address, std::uint16_t value) {
    return read_request(function::write_single_register, address, value);
}

Bytes write_multiple_registers_request(std::uint16_t address, std::span<const std::uint16_t> values) {
    Bytes pdu{function::write_multiple_registers};
    put_u16(pdu, address);
    put_u16(pdu, static_cast<std::uint16_t>(values.size()));
    pdu.push_back(static_cast<std::uint8_t>(2 * values.size()));
    for (auto value : values) {
        put_u16(pdu, value);
    }
    return pdu;
}

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


Bytes pack_bits(const std::vector<bool>& bits, std::size_t offset, std::size_t count) {
    Bytes out((count + 7) / 8, 0);
    for (std::size_t i = 0; i < count; ++i) {
        if (bits[offset + i]) {
            out[i / 8] |= static_cast<std::uint8_t>(1u << (i % 8));
        }
    }
    return out;
}

std::vector<bool> unpack_bits(std::span<const std::uint8_t> data, std::size_t count) {
    std::vector<bool> bits(count);
    for (std::size_t i = 0; i < count; ++i) {
        bits[i] = (data[i / 8] & (1u << (i % 8))) != 0;
    }
    return bits;
}

La verifica delle risposte controlla codice funzione, byte di conteggio, lunghezza effettiva ed eco delle scritture. Le funzioni 0x05 e 0x06 rispondono con una copia della richiesta, la 0x10 con i suoi primi cinque byte: in entrambi i casi confrontiamo i primi cinque byte con std::equal.


void check_function(std::uint8_t fn, std::span<const std::uint8_t> pdu) {
    if (pdu.empty()) {
        throw ProtocolError("empty response");
    }
    if (pdu[0] == (fn | 0x80)) {
        if (pdu.size() != 2) {
            throw ProtocolError("malformed exception response");
        }
        throw DeviceException(fn, pdu[1]);
    }
    if (pdu[0] != fn) {
        throw ProtocolError(std::format("unexpected function 0x{:02X}", pdu[0]));
    }
}

std::vector<bool> parse_bits(std::uint8_t fn, std::span<const std::uint8_t> pdu, std::size_t quantity) {
    check_function(fn, pdu);
    const std::size_t expected = (quantity + 7) / 8;
    if (pdu.size() != 2 + expected || pdu[1] != expected) {
        throw ProtocolError("unexpected byte count");
    }
    return unpack_bits(pdu.subspan(2), quantity);
}

std::vector<std::uint16_t> parse_registers(std::uint8_t fn, std::span<const std::uint8_t> pdu,
                                           std::size_t quantity) {
    check_function(fn, pdu);
    const std::size_t expected = 2 * quantity;
    if (pdu.size() != 2 + expected || pdu[1] != expected) {
        throw ProtocolError("unexpected byte count");
    }
    std::vector<std::uint16_t> values(quantity);
    for (std::size_t i = 0; i < quantity; ++i) {
        values[i] = get_u16(pdu, 2 + 2 * i);
    }
    return values;
}

void parse_echo(std::span<const std::uint8_t> request, std::span<const std::uint8_t> pdu) {
    check_function(request[0], pdu);
    if (pdu.size() != 5 || !std::equal(pdu.begin(), pdu.end(), request.begin())) {
        throw ProtocolError("write echo mismatch");
    }
}

}  // namespace modbus

Un socket RAII

I socket POSIX sono descrittori di file, cioè interi che vanno chiusi con close. Dimenticarlo in un percorso di errore produce una perdita di descrittori, che in un servizio di lunga durata porta prima o poi all'errore "too many open files". La soluzione idiomatica in C++ è una classe RAII che possiede il descrittore, lo chiude nel distruttore e può essere spostata ma non copiata.

Tutti i nostri socket sono non bloccanti, e le attese avvengono con poll. Questo approccio dà un controllo preciso sui timeout: ogni operazione riceve una scadenza assoluta, calcolata con std::chrono::steady_clock. A differenza dell'orologio di sistema, steady_clock non salta avanti o indietro quando NTP corregge l'ora.

#pragma once

#include <chrono>
#include <cstdint>
#include <span>
#include <string>
#include <utility>

namespace modbus {

using Clock = std::chrono::steady_clock;
using Deadline = Clock::time_point;

// Socket TCP non bloccante che possiede il proprio descrittore
class Socket {
public:
    Socket() noexcept = default;
    explicit Socket(int fd) noexcept : fd_(fd) {}
    ~Socket() { close(); }

    Socket(const Socket&) = delete;
    Socket& operator=(const Socket&) = delete;
    Socket(Socket&& other) noexcept : fd_(std::exchange(other.fd_, -1)) {}
    Socket& operator=(Socket&& other) noexcept {
        if (this != &other) {
            close();
            fd_ = std::exchange(other.fd_, -1);
        }
        return *this;
    }

    static Socket connect(const std::string& host, std::uint16_t port, std::chrono::milliseconds timeout);
    static Socket listen(const std::string& host, std::uint16_t port);

    // Restituisce un socket non valido se nessun client si connette entro il timeout
    Socket accept(std::chrono::milliseconds timeout) const;
    bool wait_readable(std::chrono::milliseconds timeout) const;
    void read_exact(std::span<std::uint8_t> out, Deadline deadline) const;
    void write_all(std::span<const std::uint8_t> data, Deadline deadline) const;
    std::uint16_t local_port() const;

    bool valid() const noexcept { return fd_ >= 0; }
    void close() noexcept;

private:
    int fd_ = -1;
};

}  // namespace modbus

Nell'implementazione, alcune funzioni di supporto vivono in un namespace anonimo, visibile solo in questa unità di traduzione. getaddrinfo restituisce una lista concatenata da liberare con freeaddrinfo, e la affidiamo a un std::unique_ptr con un deleter personalizzato: anche questa risorsa viene così rilasciata automaticamente.

#include "modbus/socket.hpp"

#include <fcntl.h>
#include <netdb.h>
#include <netinet/in.h>
#include <netinet/tcp.h>
#include <poll.h>
#include <sys/socket.h>
#include <unistd.h>

#include <cerrno>
#include <cstring>
#include <memory>

#include "modbus/errors.hpp"

namespace modbus {
namespace {

#ifdef MSG_NOSIGNAL
// evita il segnale SIGPIPE quando il peer ha chiuso la connessione
constexpr int send_flags = MSG_NOSIGNAL;
#else
constexpr int send_flags = 0;
#endif

[[noreturn]] void throw_errno(const std::string& what) {
    throw TransportError(what + ": " + std::strerror(errno));
}

void set_non_blocking(int fd) {
    const int flags = ::fcntl(fd, F_GETFL, 0);
    if (flags < 0 || ::fcntl(fd, F_SETFL, flags | O_NONBLOCK) < 0) {
        throw_errno("fcntl");
    }
}

void set_no_delay(int fd) {
    // i frame Modbus sono piccoli: disabilitiamo l'algoritmo di Nagle
    int one = 1;
    ::setsockopt(fd, IPPROTO_TCP, TCP_NODELAY, &one, sizeof one);
}

int remaining_ms(Deadline deadline) {
    const auto left = std::chrono::duration_cast<std::chrono::milliseconds>(deadline - Clock::now()).count();
    return left > 0 ? static_cast<int>(left) : 0;
}

// Attende un evento sul descrittore; false se il timeout scade
bool wait_for(int fd, short events, int timeout_ms) {
    pollfd pfd{fd, events, 0};
    while (true) {
        const int rc = ::poll(&pfd, 1, timeout_ms);
        if (rc > 0) {
            return true;
        }
        if (rc == 0) {
            return false;
        }
        if (errno != EINTR) {
            throw_errno("poll");
        }
    }
}

struct AddrInfoDeleter {
    void operator()(addrinfo* info) const noexcept { ::freeaddrinfo(info); }
};
using AddrInfoPtr = std::unique_ptr<addrinfo, AddrInfoDeleter>;

AddrInfoPtr resolve(const std::string& host, std::uint16_t port, bool passive) {
    addrinfo hints{};
    hints.ai_family = AF_UNSPEC;
    hints.ai_socktype = SOCK_STREAM;
    if (passive) {
        hints.ai_flags = AI_PASSIVE;
    }
    addrinfo* result = nullptr;
    const auto service = std::to_string(port);
    if (const int rc = ::getaddrinfo(host.c_str(), service.c_str(), &hints, &result); rc != 0) {
        throw TransportError(std::string("getaddrinfo: ") + ::gai_strerror(rc));
    }
    return AddrInfoPtr(result);
}

}  // namespace
}  // namespace modbus

La connessione con timeout segue lo schema classico dei socket non bloccanti: connect restituisce subito EINPROGRESS, si attende con poll che il socket diventi scrivibile e infine si legge l'esito reale con getsockopt(SO_ERROR). Se il nome risolve in più indirizzi, per esempio IPv6 e IPv4, li proviamo in ordine.


namespace modbus {

Socket Socket::connect(const std::string& host, std::uint16_t port, std::chrono::milliseconds timeout) {
    const auto deadline = Clock::now() + timeout;
    const auto addresses = resolve(host, port, false);
    std::string last_error = "no address";
    for (auto* ai = addresses.get(); ai != nullptr; ai = ai->ai_next) {
        Socket sock(::socket(ai->ai_family, ai->ai_socktype, ai->ai_protocol));
        if (!sock.valid()) {
            last_error = std::strerror(errno);
            continue;
        }
        set_non_blocking(sock.fd_);
        if (::connect(sock.fd_, ai->ai_addr, ai->ai_addrlen) < 0) {
            if (errno != EINPROGRESS) {
                last_error = std::strerror(errno);
                continue;
            }
            if (!wait_for(sock.fd_, POLLOUT, remaining_ms(deadline))) {
                throw TimeoutError("connect timeout");
            }
            int error = 0;
            socklen_t length = sizeof error;
            ::getsockopt(sock.fd_, SOL_SOCKET, SO_ERROR, &error, &length);
            if (error != 0) {
                last_error = std::strerror(error);
                continue;
            }
        }
        set_no_delay(sock.fd_);
        return sock;
    }
    throw TransportError("connect to " + host + ":" + std::to_string(port) + " failed: " + last_error);
}

Socket Socket::listen(const std::string& host, std::uint16_t port) {
    const auto addresses = resolve(host, port, true);
    for (auto* ai = addresses.get(); ai != nullptr; ai = ai->ai_next) {
        Socket sock(::socket(ai->ai_family, ai->ai_socktype, ai->ai_protocol));
        if (!sock.valid()) {
            continue;
        }
        int one = 1;
        ::setsockopt(sock.fd_, SOL_SOCKET, SO_REUSEADDR, &one, sizeof one);
        if (::bind(sock.fd_, ai->ai_addr, ai->ai_addrlen) == 0 && ::listen(sock.fd_, SOMAXCONN) == 0) {
            set_non_blocking(sock.fd_);
            return sock;
        }
    }
    throw_errno("listen on " + host + ":" + std::to_string(port));
}

Socket Socket::accept(std::chrono::milliseconds timeout) const {
    if (!wait_for(fd_, POLLIN, static_cast<int>(timeout.count()))) {
        return Socket{};
    }
    Socket client(::accept(fd_, nullptr, nullptr));
    if (!client.valid()) {
        if (errno == EAGAIN || errno == EWOULDBLOCK || errno == EINTR || errno == ECONNABORTED) {
            return Socket{};
        }
        throw_errno("accept");
    }
    set_non_blocking(client.fd_);
    set_no_delay(client.fd_);
    return client;
}

L'opzione SO_REUSEADDR permette di riavviare subito un server sulla stessa porta: senza di essa, il kernel tiene la porta occupata per qualche decina di secondi nello stato TIME_WAIT dopo la chiusura.

Restano le operazioni di lettura e scrittura. read_exact risolve il problema del framing: TCP è uno stream, un frame può arrivare in più segmenti, e la funzione continua a leggere finché non ha riempito l'intero buffer. Prova prima a leggere e attende con poll solo quando il kernel risponde EAGAIN, cioè quando non ci sono dati disponibili. Una lettura che restituisce 0 significa che il peer ha chiuso la connessione.


bool Socket::wait_readable(std::chrono::milliseconds timeout) const {
    return wait_for(fd_, POLLIN, static_cast<int>(timeout.count()));
}

void Socket::read_exact(std::span<std::uint8_t> out, Deadline deadline) const {
    std::size_t done = 0;
    while (done < out.size()) {
        const ssize_t n = ::recv(fd_, out.data() + done, out.size() - done, 0);
        if (n > 0) {
            done += static_cast<std::size_t>(n);
            continue;
        }
        if (n == 0) {
            throw TransportError("connection closed by peer");
        }
        if (errno == EINTR) {
            continue;
        }
        if (errno != EAGAIN && errno != EWOULDBLOCK) {
            throw_errno("recv");
        }
        if (!wait_for(fd_, POLLIN, remaining_ms(deadline))) {
            throw TimeoutError("response timeout");
        }
    }
}

void Socket::write_all(std::span<const std::uint8_t> data, Deadline deadline) const {
    std::size_t done = 0;
    while (done < data.size()) {
        const ssize_t n = ::send(fd_, data.data() + done, data.size() - done, send_flags);
        if (n >= 0) {
            done += static_cast<std::size_t>(n);
            continue;
        }
        if (errno == EINTR) {
            continue;
        }
        if (errno != EAGAIN && errno != EWOULDBLOCK) {
            throw_errno("send");
        }
        if (!wait_for(fd_, POLLOUT, remaining_ms(deadline))) {
            throw TimeoutError("write timeout");
        }
    }
}

std::uint16_t Socket::local_port() const {
    sockaddr_storage address{};
    socklen_t length = sizeof address;
    if (::getsockname(fd_, reinterpret_cast<sockaddr*>(&address), &length) < 0) {
        throw_errno("getsockname");
    }
    if (address.ss_family == AF_INET6) {
        return ntohs(reinterpret_cast<const sockaddr_in6*>(&address)->sin6_port);
    }
    return ntohs(reinterpret_cast<const sockaddr_in*>(&address)->sin_port);
}

void Socket::close() noexcept {
    if (fd_ >= 0) {
        ::close(fd_);
        fd_ = -1;
    }
}

}  // namespace modbus

Il flag MSG_NOSIGNAL merita una nota. Su Linux, scrivere su un socket il cui peer ha chiuso la connessione genera il segnale SIGPIPE, che per impostazione predefinita termina il processo. Con il flag, send restituisce invece l'errore EPIPE, che trasformiamo in un'eccezione come gli altri. Su macOS il flag non esiste e si usa l'opzione di socket SO_NOSIGPIPE.

Il client

Il client possiede un Socket e un std::mutex che serializza le transazioni: molti dispositivi embedded elaborano una richiesta alla volta, e la serializzazione rende il client sicuro anche se condiviso tra thread. Poiché std::mutex non è né copiabile né spostabile, non lo è nemmeno Client. Il metodo statico connect può comunque restituirlo per valore grazie alla copy elision garantita da C++17: l'oggetto viene costruito direttamente nella destinazione del chiamante.

#pragma once

#include <chrono>
#include <cstdint>
#include <mutex>
#include <span>
#include <string>
#include <vector>

#include "modbus/pdu.hpp"
#include "modbus/socket.hpp"

namespace modbus {

// Client Modbus TCP sicuro per l'uso da piu' thread
class Client {
public:
    static Client connect(const std::string& host, std::uint16_t port, std::uint8_t unit_id = 1,
                          std::chrono::milliseconds timeout = std::chrono::seconds(2));

    std::vector<bool> read_coils(std::uint16_t address, std::uint16_t quantity);
    std::vector<bool> read_discrete_inputs(std::uint16_t address, std::uint16_t quantity);
    std::vector<std::uint16_t> read_holding_registers(std::uint16_t address, std::uint16_t quantity);
    std::vector<std::uint16_t> read_input_registers(std::uint16_t address, std::uint16_t quantity);
    void write_single_coil(std::uint16_t address, bool value);
    void write_single_register(std::uint16_t address, std::uint16_t value);
    void write_multiple_registers(std::uint16_t address, std::span<const std::uint16_t> values);
    void close();

private:
    Client(Socket socket, std::uint8_t unit_id, std::chrono::milliseconds timeout);

    Bytes transact(std::span<const std::uint8_t> pdu);
    std::vector<bool> read_bits(std::uint8_t fn, std::uint16_t address, std::uint16_t quantity);
    std::vector<std::uint16_t> read_registers(std::uint8_t fn, std::uint16_t address, std::uint16_t quantity);

    std::mutex mutex_;
    Socket socket_;
    std::uint8_t unit_id_;
    std::chrono::milliseconds timeout_;
    std::uint16_t transaction_id_ = 0;
};

}  // namespace modbus

Il metodo transact esegue una transazione completa con un'unica scadenza per scrittura e letture. Prima legge i sette byte dell'intestazione in un std::array sullo stack, poi i byte indicati dal campo length. In caso di errore di trasporto o di protocollo chiude il socket: dopo un timeout 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 tardive.

#include "modbus/client.hpp"

#include <array>
#include <stdexcept>

namespace modbus {

Client::Client(Socket socket, std::uint8_t unit_id, std::chrono::milliseconds timeout)
    : socket_(std::move(socket)), unit_id_(unit_id), timeout_(timeout) {}

Client Client::connect(const std::string& host, std::uint16_t port, std::uint8_t unit_id,
                       std::chrono::milliseconds timeout) {
    return Client(Socket::connect(host, port, timeout), unit_id, timeout);
}

void Client::close() {
    std::lock_guard lock(mutex_);
    socket_.close();
}

Bytes Client::transact(std::span<const std::uint8_t> pdu) {
    std::lock_guard lock(mutex_);
    if (!socket_.valid()) {
        throw TransportError("client closed");
    }
    const std::uint16_t tid = ++transaction_id_;
    Bytes frame;
    frame.reserve(7 + pdu.size());
    put_u16(frame, tid);
    put_u16(frame, 0);
    put_u16(frame, static_cast<std::uint16_t>(pdu.size() + 1));
    frame.push_back(unit_id_);
    frame.insert(frame.end(), pdu.begin(), pdu.end());

    const auto deadline = Clock::now() + timeout_;
    try {
        socket_.write_all(frame, deadline);
        std::array<std::uint8_t, 7> header{};
        while (true) {
            socket_.read_exact(header, deadline);
            const auto length = get_u16(header, 4);
            if (get_u16(header, 2) != 0 || length < 2 || length > 254) {
                throw ProtocolError("invalid MBAP header");
            }
            Bytes body(length - 1u);
            socket_.read_exact(body, deadline);
            if (get_u16(header, 0) != tid) {
                // risposta tardiva a una transazione precedente
                continue;
            }
            if (header[6] != unit_id_) {
                throw ProtocolError("unexpected unit id " + std::to_string(header[6]));
            }
            return body;
        }
    } catch (const TransportError&) {
        socket_.close();
        throw;
    } catch (const ProtocolError&) {
        // lo stream non e' piu' affidabile: chiudiamo la connessione
        socket_.close();
        throw;
    }
}

L'API pubblica deve validare solo le quantità, perché indirizzi e valori sono già std::uint16_t e non possono uscire dall'intervallo. Lanciamo std::out_of_range, un'eccezione della libreria standard pensata proprio per gli argomenti fuori dai limiti ammessi.


namespace {

void check_quantity(std::size_t quantity, std::size_t max) {
    if (quantity < 1 || quantity > max) {
        throw std::out_of_range("quantity " + std::to_string(quantity) + " out of range 1-" +
                                std::to_string(max));
    }
}

}  // namespace

std::vector<bool> Client::read_bits(std::uint8_t fn, std::uint16_t address, std::uint16_t quantity) {
    check_quantity(quantity, max_read_bits);
    return parse_bits(fn, transact(read_request(fn, address, quantity)), quantity);
}

std::vector<std::uint16_t> Client::read_registers(std::uint8_t fn, std::uint16_t address,
                                                  std::uint16_t quantity) {
    check_quantity(quantity, max_read_registers);
    return parse_registers(fn, transact(read_request(fn, address, quantity)), quantity);
}

std::vector<bool> Client::read_coils(std::uint16_t address, std::uint16_t quantity) {
    return read_bits(function::read_coils, address, quantity);
}

std::vector<bool> Client::read_discrete_inputs(std::uint16_t address, std::uint16_t quantity) {
    return read_bits(function::read_discrete_inputs, address, quantity);
}

std::vector<std::uint16_t> Client::read_holding_registers(std::uint16_t address, std::uint16_t quantity) {
    return read_registers(function::read_holding_registers, address, quantity);
}

std::vector<std::uint16_t> Client::read_input_registers(std::uint16_t address, std::uint16_t quantity) {
    return read_registers(function::read_input_registers, address, quantity);
}

void Client::write_single_coil(std::uint16_t address, bool value) {
    const auto request = write_single_coil_request(address, value);
    parse_echo(request, transact(request));
}

void Client::write_single_register(std::uint16_t address, std::uint16_t value) {
    const auto request = write_single_register_request(address, value);
    parse_echo(request, transact(request));
}

void Client::write_multiple_registers(std::uint16_t address, std::span<const std::uint16_t> values) {
    check_quantity(values.size(), max_write_registers);
    const auto request = write_multiple_registers_request(address, values);
    parse_echo(request, transact(request));
}

}  // namespace modbus

Nell'espressione parse_echo(request, transact(request)) il vettore restituito da transact è un temporaneo che vive fino alla fine dell'espressione completa, quindi lo std::span costruito su di esso resta valido per tutta la durata della chiamata. È una regola del linguaggio da tenere ben presente quando si lavora con le viste: lo stesso span salvato in una variabile e usato dopo il punto e virgola punterebbe a memoria già liberata.

La memoria dati e il server

La memoria dati contiene le quattro tabelle e le protegge con uno std::shared_mutex: le letture dei client possono procedere in parallelo con uno std::shared_lock, mentre scritture e aggiornamenti dell'applicazione acquisiscono uno std::unique_lock esclusivo. Il metodo modello update riceve qualunque oggetto invocabile, tipicamente una lambda, e lo esegue sotto il lock esclusivo, così un aggiornamento composto risulta atomico per i client.

#pragma once

#include <atomic>
#include <chrono>
#include <cstdint>
#include <list>
#include <memory>
#include <mutex>
#include <shared_mutex>
#include <span>
#include <string>
#include <thread>
#include <vector>

#include "modbus/pdu.hpp"
#include "modbus/socket.hpp"

namespace modbus {

struct Tables {
    std::vector<bool> coils;
    std::vector<bool> discrete_inputs;
    std::vector<std::uint16_t> holding_registers;
    std::vector<std::uint16_t> input_registers;
};

// Memoria dati del server, sicura per l'accesso concorrente
class DataStore {
public:
    explicit DataStore(std::size_t size);

    template <typename F>
    void update(F&& action) {
        std::unique_lock lock(mutex_);
        std::forward<F>(action)(tables_);
    }

    // Elabora una PDU di richiesta e restituisce la PDU di risposta
    Bytes handle(std::span<const std::uint8_t> pdu);

private:
    Bytes read_bits(std::uint8_t fn, std::span<const std::uint8_t> pdu) const;
    Bytes read_registers(std::uint8_t fn, std::span<const std::uint8_t> pdu) const;
    Bytes write_single_coil(std::span<const std::uint8_t> pdu);
    Bytes write_single_register(std::span<const std::uint8_t> pdu);
    Bytes write_multiple_registers(std::span<const std::uint8_t> pdu);

    mutable std::shared_mutex mutex_;
    Tables tables_;
};

struct ServerOptions {
    // 0 accetta qualunque unit identifier
    std::uint8_t unit_id = 0;
    // inattivita' massima per connessione, 0 per disabilitare
    std::chrono::milliseconds idle_timeout{0};
};

// Server Modbus TCP con un thread per connessione
class Server {
public:
    explicit Server(DataStore& store, ServerOptions options = {});
    ~Server();

    Server(const Server&) = delete;
    Server& operator=(const Server&) = delete;

    void start(const std::string& host, std::uint16_t port);
    void stop();
    std::uint16_t port() const { return listener_.local_port(); }

private:
    struct Connection {
        std::shared_ptr<std::atomic<bool>> done;
        std::jthread thread;
    };

    void accept_loop(std::stop_token stop);
    void serve(Socket socket, std::stop_token stop);

    DataStore& store_;
    ServerOptions options_;
    Socket listener_;
    std::mutex connections_mutex_;
    std::list<Connection> connections_;
    std::jthread acceptor_;
};

}  // namespace modbus

std::jthread, introdotto in C++20, risolve due problemi storici di std::thread. Nel distruttore richiede l'arresto e attende la fine del thread, invece di terminare il programma se il thread è ancora attivo. Inoltre trasporta uno std::stop_token che la funzione del thread può interrogare per sapere quando fermarsi. È esattamente ciò che serve per un server che deve potersi arrestare in modo ordinato.

L'implementazione di handle sceglie il tipo di lock in base alla funzione richiesta, poi delega a metodi privati che seguono l'ordine di controllo indicato dalla specifica: prima la forma della richiesta e le quantità (eccezione 0x03), poi gli indirizzi (eccezione 0x02). Indirizzi e quantità vengono convertiti in std::size_t prima della somma, così il controllo dei limiti non può andare in overflow.

#include "modbus/server.hpp"

#include <array>

namespace modbus {
namespace {

Bytes exception(std::uint8_t fn, std::uint8_t code) {
    return {static_cast<std::uint8_t>(fn | 0x80), code};
}

}  // namespace

DataStore::DataStore(std::size_t size)
    : tables_{std::vector<bool>(size), std::vector<bool>(size), std::vector<std::uint16_t>(size),
              std::vector<std::uint16_t>(size)} {}

Bytes DataStore::handle(std::span<const std::uint8_t> pdu) {
    if (pdu.empty()) {
        return exception(0, exception_code::illegal_function);
    }
    const std::uint8_t fn = pdu[0];
    switch (fn) {
        case function::read_coils:
        case function::read_discrete_inputs: {
            std::shared_lock lock(mutex_);
            return read_bits(fn, pdu);
        }
        case function::read_holding_registers:
        case function::read_input_registers: {
            std::shared_lock lock(mutex_);
            return read_registers(fn, pdu);
        }
        case function::write_single_coil: {
            std::unique_lock lock(mutex_);
            return write_single_coil(pdu);
        }
        case function::write_single_register: {
            std::unique_lock lock(mutex_);
            return write_single_register(pdu);
        }
        case function::write_multiple_registers: {
            std::unique_lock lock(mutex_);
            return write_multiple_registers(pdu);
        }
        default:
            return exception(fn, exception_code::illegal_function);
    }
}

Bytes DataStore::read_bits(std::uint8_t fn, std::span<const std::uint8_t> pdu) const {
    if (pdu.size() != 5) {
        return exception(fn, exception_code::illegal_data_value);
    }
    const std::size_t address = get_u16(pdu, 1);
    const std::size_t quantity = get_u16(pdu, 3);
    if (quantity < 1 || quantity > max_read_bits) {
        return exception(fn, exception_code::illegal_data_value);
    }
    const auto& table = fn == function::read_coils ? tables_.coils : tables_.discrete_inputs;
    if (address + quantity > table.size()) {
        return exception(fn, exception_code::illegal_data_address);
    }
    const auto data = pack_bits(table, address, quantity);
    Bytes response{fn, static_cast<std::uint8_t>(data.size())};
    response.insert(response.end(), data.begin(), data.end());
    return response;
}

Bytes DataStore::read_registers(std::uint8_t fn, std::span<const std::uint8_t> pdu) const {
    if (pdu.size() != 5) {
        return exception(fn, exception_code::illegal_data_value);
    }
    const std::size_t address = get_u16(pdu, 1);
    const std::size_t quantity = get_u16(pdu, 3);
    if (quantity < 1 || quantity > max_read_registers) {
        return exception(fn, exception_code::illegal_data_value);
    }
    const auto& table =
        fn == function::read_holding_registers ? tables_.holding_registers : tables_.input_registers;
    if (address + quantity > table.size()) {
        return exception(fn, exception_code::illegal_data_address);
    }
    Bytes response{fn, static_cast<std::uint8_t>(2 * quantity)};
    for (std::size_t i = 0; i < quantity; ++i) {
        put_u16(response, table[address + i]);
    }
    return response;
}

Bytes DataStore::write_single_coil(std::span<const std::uint8_t> pdu) {
    constexpr auto fn = function::write_single_coil;
    if (pdu.size() != 5) {
        return exception(fn, exception_code::illegal_data_value);
    }
    const std::size_t address = get_u16(pdu, 1);
    const auto value = get_u16(pdu, 3);
    if (value != 0xFF00 && value != 0x0000) {
        return exception(fn, exception_code::illegal_data_value);
    }
    if (address >= tables_.coils.size()) {
        return exception(fn, exception_code::illegal_data_address);
    }
    tables_.coils[address] = value == 0xFF00;
    return {pdu.begin(), pdu.end()};
}

Bytes DataStore::write_single_register(std::span<const std::uint8_t> pdu) {
    constexpr auto fn = function::write_single_register;
    if (pdu.size() != 5) {
        return exception(fn, exception_code::illegal_data_value);
    }
    const std::size_t address = get_u16(pdu, 1);
    if (address >= tables_.holding_registers.size()) {
        return exception(fn, exception_code::illegal_data_address);
    }
    tables_.holding_registers[address] = get_u16(pdu, 3);
    return {pdu.begin(), pdu.end()};
}

Bytes DataStore::write_multiple_registers(std::span<const std::uint8_t> pdu) {
    constexpr auto fn = function::write_multiple_registers;
    if (pdu.size() < 6) {
        return exception(fn, exception_code::illegal_data_value);
    }
    const std::size_t address = get_u16(pdu, 1);
    const std::size_t quantity = get_u16(pdu, 3);
    const std::size_t byte_count = pdu[5];
    if (quantity < 1 || quantity > max_write_registers || byte_count != 2 * quantity ||
        pdu.size() != 6 + byte_count) {
        return exception(fn, exception_code::illegal_data_value);
    }
    if (address + quantity > tables_.holding_registers.size()) {
        return exception(fn, exception_code::illegal_data_address);
    }
    for (std::size_t i = 0; i < quantity; ++i) {
        tables_.holding_registers[address + i] = get_u16(pdu, 6 + 2 * i);
    }
    return {pdu.begin(), pdu.begin() + 5};
}

Il server avvia un thread di accettazione che attende nuove connessioni a intervalli di 200 millisecondi, controllando tra un'attesa e l'altra se è stato richiesto l'arresto. Per ogni connessione crea un std::jthread e lo conserva in una lista, insieme a un flag atomico che il thread imposta quando termina. A ogni nuova connessione la lista viene ripulita dai thread già conclusi: la rimozione distrugge il std::jthread, il cui distruttore esegue un join immediato perché il thread è già finito.


Server::Server(DataStore& store, ServerOptions options) : store_(store), options_(options) {}

Server::~Server() {
    stop();
}

void Server::start(const std::string& host, std::uint16_t port) {
    listener_ = Socket::listen(host, port);
    acceptor_ = std::jthread([this](std::stop_token stop) { accept_loop(stop); });
}

void Server::stop() {
    if (acceptor_.joinable()) {
        acceptor_.request_stop();
        acceptor_.join();
    }
    std::lock_guard lock(connections_mutex_);
    for (auto& connection : connections_) {
        connection.thread.request_stop();
    }
    // la distruzione dei jthread attende la fine di ogni connessione
    connections_.clear();
}

void Server::accept_loop(std::stop_token stop) {
    using namespace std::chrono_literals;
    while (!stop.stop_requested()) {
        Socket client = listener_.accept(200ms);
        if (!client.valid()) {
            continue;
        }
        std::lock_guard lock(connections_mutex_);
        connections_.remove_if([](const Connection& c) { return c.done->load(); });
        auto done = std::make_shared<std::atomic<bool>>(false);
        connections_.push_back(Connection{
            done, std::jthread([this, done, socket = std::move(client)](std::stop_token token) mutable {
                serve(std::move(socket), token);
                done->store(true);
            })});
    }
}

Il thread di ogni connessione attende i dati in finestre di 200 millisecondi, così può accorgersi della richiesta di arresto e chiudere le connessioni inattive. Quando arrivano dati, il frame deve completarsi entro una scadenza ragionevole: un client che invia mezza intestazione e poi tace non può bloccare il thread per sempre. Un'intestazione non valida chiude la connessione, perché dopo un errore di framing non è possibile ritrovare l'inizio del frame successivo.


void Server::serve(Socket socket, std::stop_token stop) {
    using namespace std::chrono_literals;
    auto last_activity = Clock::now();
    std::array<std::uint8_t, 7> header{};
    try {
        while (!stop.stop_requested()) {
            if (!socket.wait_readable(200ms)) {
                if (options_.idle_timeout.count() > 0 && Clock::now() - last_activity > options_.idle_timeout) {
                    return;
                }
                continue;
            }
            // il frame deve arrivare per intero entro un tempo ragionevole
            const auto deadline = Clock::now() + 5s;
            socket.read_exact(header, deadline);
            const auto length = get_u16(header, 4);
            if (get_u16(header, 2) != 0 || length < 2 || length > 254) {
                return;
            }
            Bytes pdu(length - 1u);
            socket.read_exact(pdu, deadline);
            last_activity = Clock::now();
            const std::uint8_t unit = header[6];
            if (options_.unit_id != 0 && unit != options_.unit_id) {
                // un dispositivo reale ignora le richieste destinate ad altri
                continue;
            }
            const Bytes response = store_.handle(pdu);
            Bytes frame(header.begin(), header.begin() + 4);
            put_u16(frame, static_cast<std::uint16_t>(response.size() + 1));
            frame.push_back(unit);
            frame.insert(frame.end(), response.begin(), response.end());
            socket.write_all(frame, Clock::now() + 5s);
        }
    } catch (const TransportError&) {
        // disconnessione o errore di rete: la connessione viene chiusa
    }
}

}  // namespace modbus

Il socket arriva a serve per valore, spostato dentro la lambda e da lì nel parametro: la connessione appartiene al thread che la gestisce, e viene chiusa automaticamente dal distruttore di Socket quando serve termina, qualunque sia il motivo.

Dai registri ai valori

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

#pragma once

#include <array>
#include <bit>
#include <cstdint>

namespace modbus {

enum class WordOrder {
    high_word_first,  // parola piu' significativa nel primo registro (ABCD)
    low_word_first,   // parola meno significativa nel primo registro (CDAB)
};

// Interpreta un registro come intero con segno in complemento a due
constexpr std::int16_t to_int16(std::uint16_t reg) noexcept {
    return static_cast<std::int16_t>(reg);
}

constexpr std::uint32_t to_uint32(std::uint16_t first, std::uint16_t second, WordOrder order) noexcept {
    const auto high = order == WordOrder::high_word_first ? first : second;
    const auto low = order == WordOrder::high_word_first ? second : first;
    return (static_cast<std::uint32_t>(high) << 16) | low;
}

constexpr std::array<std::uint16_t, 2> from_uint32(std::uint32_t value, WordOrder order) noexcept {
    const auto high = static_cast<std::uint16_t>(value >> 16);
    const auto low = static_cast<std::uint16_t>(value & 0xFFFF);
    if (order == WordOrder::high_word_first) {
        return {high, low};
    }
    return {low, high};
}

constexpr float to_float32(std::uint16_t first, std::uint16_t second, WordOrder order) noexcept {
    return std::bit_cast<float>(to_uint32(first, second, order));
}

constexpr std::array<std::uint16_t, 2> from_float32(float value, WordOrder order) noexcept {
    return from_uint32(std::bit_cast<std::uint32_t>(value), order);
}

// Verifiche a tempo di compilazione
static_assert(to_int16(0xFF38) == -200);
static_assert(from_float32(1.0f, WordOrder::high_word_first) == std::array<std::uint16_t, 2>{0x3F80, 0x0000});

}  // namespace modbus

Prima di C++20 reinterpretare i bit di un intero come float richiedeva std::memcpy, perché un cast tra puntatori viola le regole di aliasing ed è comportamento indefinito. std::bit_cast esprime l'operazione in modo sicuro e funziona anche in un contesto constexpr, come dimostrano gli static_assert in fondo all'header: se le conversioni fossero sbagliate, il progetto non compilerebbe. Anche la conversione da std::uint16_t a std::int16_t è ben definita da C++20, che ha reso obbligatoria la rappresentazione in complemento a due.

Quando la documentazione non chiarisce l'ordine delle parole, conviene leggere una grandezza nota, come la tensione di rete, e provare entrambe le interpretazioni: quella sbagliata produce valori palesemente assurdi.

Un CRC calcolato a tempo di compilazione

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. In C++ possiamo scriverlo come funzione constexpr e verificarlo con uno static_assert sul frame di riferimento 01 03 00 00 00 0A, il cui CRC è C5 CD.

#pragma once

#include <array>
#include <cstdint>
#include <span>

#include "modbus/pdu.hpp"

namespace modbus {

constexpr std::uint16_t crc16(std::span<const std::uint8_t> data) noexcept {
    std::uint16_t crc = 0xFFFF;
    for (const auto byte : data) {
        crc ^= byte;
        for (int i = 0; i < 8; ++i) {
            crc = (crc & 1) != 0 ? static_cast<std::uint16_t>((crc >> 1) ^ 0xA001)
                                 : static_cast<std::uint16_t>(crc >> 1);
        }
    }
    return crc;
}

// Aggiunge il CRC al frame, byte meno significativo per primo
inline Bytes append_crc(std::span<const std::uint8_t> frame) {
    Bytes out(frame.begin(), frame.end());
    const auto crc = crc16(frame);
    out.push_back(static_cast<std::uint8_t>(crc & 0xFF));
    out.push_back(static_cast<std::uint8_t>(crc >> 8));
    return out;
}

namespace detail {
inline constexpr std::array<std::uint8_t, 6> crc_reference{0x01, 0x03, 0x00, 0x00, 0x00, 0x0A};
static_assert(crc16(crc_reference) == 0xCDC5);
}  // namespace detail

}  // namespace modbus

Il valore 0xCDC5 trasmesso con il byte meno significativo per primo diventa la sequenza C5 CD. Se servisse un CRC più veloce, la stessa funzione constexpr potrebbe generare a tempo di compilazione la classica tabella di 256 valori, senza doverla copiare a mano da qualche documento.

I test

I test usano Catch2 v3, scaricato da CMake con FetchContent. La fixture avvia un server sulla porta 0, scelta dal sistema operativo, e connette un client. L'ordine di dichiarazione dei membri conta: in C++ i membri vengono costruiti nell'ordine in cui sono dichiarati e distrutti nell'ordine inverso. Il client viene quindi chiuso prima del server, e il server prima della memoria dati a cui fa riferimento.

#include <catch2/catch_test_macros.hpp>

#include <array>
#include <chrono>
#include <cstdint>
#include <stdexcept>
#include <vector>

#include "modbus/client.hpp"
#include "modbus/codec.hpp"
#include "modbus/crc.hpp"
#include "modbus/server.hpp"

using namespace std::chrono_literals;
using namespace modbus;

namespace {

std::uint16_t start(Server& server) {
    server.start("127.0.0.1", 0);
    return server.port();
}

struct Fixture {
    DataStore store{100};
    Server server{store};
    std::uint16_t port = start(server);
    Client client = Client::connect("127.0.0.1", port, 1, 1000ms);
};

}  // namespace

TEST_CASE_METHOD(Fixture, "registers round trip") {
    client.write_multiple_registers(10, std::array<std::uint16_t, 3>{1, 2, 0xBEEF});
    client.write_single_register(13, 42);
    REQUIRE(client.read_holding_registers(10, 4) == std::vector<std::uint16_t>{1, 2, 0xBEEF, 42});
}

TEST_CASE_METHOD(Fixture, "coils and inputs") {
    store.update([](Tables& t) {
        t.discrete_inputs[9] = true;
        t.input_registers[0] = 0xFF38;
    });
    client.write_single_coil(3, true);
    const auto coils = client.read_coils(0, 10);
    for (std::size_t i = 0; i < coils.size(); ++i) {
        REQUIRE(coils[i] == (i == 3));
    }
    const auto inputs = client.read_discrete_inputs(0, 10);
    REQUIRE(inputs[9]);
    REQUIRE_FALSE(inputs[8]);
    REQUIRE(to_int16(client.read_input_registers(0, 1)[0]) == -200);
}

TEST_CASE_METHOD(Fixture, "illegal address keeps the connection usable") {
    try {
        client.read_holding_registers(95, 10);
        FAIL("expected DeviceException");
    } catch (const DeviceException& e) {
        REQUIRE(e.code() == exception_code::illegal_data_address);
    }
    // dopo un'eccezione Modbus la connessione resta valida
    REQUIRE(client.read_holding_registers(0, 1) == std::vector<std::uint16_t>{0});
}

TEST_CASE_METHOD(Fixture, "client side validation") {
    REQUIRE_THROWS_AS(client.read_holding_registers(0, 126), std::out_of_range);
    REQUIRE_THROWS_AS(client.write_multiple_registers(0, {}), std::out_of_range);
}

TEST_CASE("timeout closes the client") {
    // un socket in ascolto che non accetta mai: la connessione resta nel backlog
    const Socket silent = Socket::listen("127.0.0.1", 0);
    Client slow = Client::connect("127.0.0.1", silent.local_port(), 1, 200ms);
    REQUIRE_THROWS_AS(slow.read_coils(0, 1), TimeoutError);
    REQUIRE_THROWS_AS(slow.read_coils(0, 1), TransportError);
}

TEST_CASE("illegal function") {
    DataStore store(10);
    const std::array<std::uint8_t, 2> request{0x2B, 0x0E};
    REQUIRE(store.handle(request) == Bytes{0xAB, 0x01});
}

TEST_CASE("float word order") {
    for (const auto order : {WordOrder::high_word_first, WordOrder::low_word_first}) {
        const auto regs = from_float32(230.5f, order);
        REQUIRE(to_float32(regs[0], regs[1], order) == 230.5f);
    }
    REQUIRE(from_float32(1.0f, WordOrder::low_word_first) == std::array<std::uint16_t, 2>{0x0000, 0x3F80});
}

TEST_CASE("crc16") {
    const std::array<std::uint8_t, 6> frame{0x01, 0x03, 0x00, 0x00, 0x00, 0x0A};
    const auto out = append_crc(frame);
    REQUIRE(out[6] == 0xC5);
    REQUIRE(out[7] == 0xCD);
}

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 il client si connette ma nessuno risponderà. Per compilare ed eseguire i test:

cmake -B build -DCMAKE_BUILD_TYPE=Debug
cmake --build build -j
ctest --test-dir build --output-on-failure

Durante lo sviluppo vale la pena compilare anche con -fsanitize=address,undefined e, separatamente, con -fsanitize=thread: AddressSanitizer individua accessi fuori dai limiti nella decodifica dei frame, mentre ThreadSanitizer verifica che la memoria dati sia davvero protetta dal lock in tutti i percorsi.

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. Un std::jthread aggiorna la misura ogni secondo e si ferma da solo alla distruzione, grazie allo std::stop_token.

#include <chrono>
#include <cstdio>
#include <random>
#include <thread>

#include "modbus/client.hpp"
#include "modbus/codec.hpp"
#include "modbus/server.hpp"

using namespace std::chrono_literals;

int main() {
    modbus::DataStore store(10);
    auto update_voltage = [&store, rng = std::mt19937{std::random_device{}()}]() mutable {
        std::uniform_real_distribution<float> dist(228.0f, 232.0f);
        const auto regs = modbus::from_float32(dist(rng), modbus::WordOrder::high_word_first);
        store.update([&](modbus::Tables& t) {
            t.input_registers[0] = regs[0];
            t.input_registers[1] = regs[1];
        });
    };
    update_voltage();

    modbus::Server server(store, {.unit_id = 0, .idle_timeout = 60s});
    server.start("127.0.0.1", 1502);

    std::jthread simulator([&](std::stop_token stop) {
        while (!stop.stop_requested()) {
            std::this_thread::sleep_for(1s);
            update_voltage();
        }
    });

    try {
        auto client = modbus::Client::connect("127.0.0.1", 1502);
        for (int i = 0; i < 5; ++i) {
            const auto regs = client.read_input_registers(0, 2);
            const float voltage = modbus::to_float32(regs[0], regs[1], modbus::WordOrder::high_word_first);
            std::printf("voltage: %.2f V (raw %04X %04X)\n", voltage, regs[0], regs[1]);
            std::this_thread::sleep_for(1s);
        }
    } catch (const modbus::ModbusError& e) {
        std::fprintf(stderr, "modbus error: %s\n", e.what());
        return 1;
    }
    return 0;
}

La struttura ServerOptions viene inizializzata con i designated initializer di C++20, che rendono esplicito il significato di ciascun campo nel punto della chiamata. Si avvia con ./build/demo. La porta 1502 evita di dover eseguire il programma con privilegi elevati, necessari su Linux per la porta standard 502.

Considerazioni per la produzione

  • Riconnessione: il client si chiude dopo ogni errore di trasporto. Il ciclo di polling deve ricrearlo con un backoff esponenziale, mentre una DeviceException non richiede di riaprire la connessione.
  • Scalabilità del server: un thread per connessione va benissimo per decine di client, che è lo scenario tipico di un dispositivo Modbus. Per migliaia di connessioni conviene passare a un modello a eventi con epoll o con una libreria come Asio, riusando senza modifiche DataStore::handle.
  • Letture raggruppate: ogni transazione costa almeno un round trip, e dietro un gateway seriale anche decine di millisecondi. Leggere blocchi contigui di registri riduce drasticamente i tempi.
  • Sicurezza: Modbus TCP non prevede autenticazione né cifratura. Chiunque raggiunga la porta 502 può scrivere registri e comandare uscite. 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.
  • Input non fidato: il server elabora byte che arrivano dalla rete. Ogni accesso agli array è preceduto da un controllo di lunghezza, ma un fuzzer come libFuzzer, applicato a DataStore::handle, è il modo più efficace per assicurarsene.
  • 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.

Se si preferisce una libreria esistente, il riferimento nel mondo C e C++ è libmodbus, una libreria C matura e diffusissima che supporta Modbus TCP e RTU ed è disponibile nei repository di tutte le principali distribuzioni Linux. 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, sfruttando le caratteristiche di C++20. RAII garantisce che descrittori e risorse di sistema vengano sempre rilasciati, anche nei percorsi di errore. std::span permette di passare buffer senza copie. std::jthread e std::stop_token rendono l'arresto del server ordinato e privo di thread orfani. std::bit_cast e constexpr, infine, spostano parte della verifica a tempo di compilazione. Il risultato è un codice vicino al sistema operativo, con timeout precisi basati su poll, e al tempo stesso sicuro nella gestione della memoria e della concorrenza.

Commenti