Interagire con i sistemi SCADA in C++
Il C++ è il linguaggio in cui sono scritti molti dei componenti che compongono un sistema SCADA: runtime delle HMI, driver di comunicazione dei server SCADA, firmware di gateway e di RTU, stack di protocollo. Quando un software di acquisizione deve girare su un dispositivo con risorse limitate, garantire latenze prevedibili o integrarsi in un prodotto esistente scritto in C o C++, la scelta del linguaggio è quasi obbligata.
In questo articolo costruiremo un acquisitore in C++ moderno che legge un PLC via Modbus TCP con libmodbus e riceve le variazioni di un server OPC UA con open62541, entrambe librerie C molto diffuse nell'industria. Il cuore del lavoro sarà avvolgere queste API C in classi C++ sicure, con gestione delle risorse RAII ed errori espressi tramite std::expected, e costruire un ciclo di acquisizione a cadenza costante che gestisce i due protocolli in un unico thread.
Il contesto
In un sistema SCADA i PLC eseguono la logica di controllo con cicli di scansione deterministici, i server di supervisione raccolgono i dati e gestiscono allarmi e storico, le HMI li presentano agli operatori. Il nostro acquisitore si affianca a questi componenti come consumatore di dati: legge, normalizza e inoltra, senza partecipare al controllo del processo.
I due protocolli sono molto diversi. Modbus TCP espone tabelle numerate di bit e registri a 16 bit, senza tipi né nomi: l'interpretazione dipende interamente dalla documentazione del dispositivo. OPC UA espone nodi tipizzati con identificativi, codici di stato e timestamp, offre sottoscrizioni guidate dal server e prevede sicurezza e autenticazione. Le due librerie riflettono questa differenza: libmodbus è piccola ed essenziale, open62541 è uno stack completo che implementa sia il client sia il server.
Preparare l'ambiente
Su Debian e Ubuntu libmodbus si installa dal gestore di pacchetti; open62541 è disponibile in alcune distribuzioni, ma conviene compilarla dai sorgenti per avere una versione recente della serie 1.5. Il codice richiede un compilatore con supporto a C++23 per std::expected: GCC 13 o Clang 17 sono sufficienti.
sudo apt install build-essential cmake pkg-config libmodbus-dev
git clone --branch v1.5.9 --depth 1 https://github.com/open62541/open62541.git
cd open62541 && git submodule update --init --depth 1
cmake -B build -DCMAKE_BUILD_TYPE=Release -DBUILD_SHARED_LIBS=ON -DUA_ENABLE_ENCRYPTION=OPENSSL
cmake --build build -j
sudo cmake --install build
L'opzione UA_ENABLE_ENCRYPTION abilita le policy di sicurezza di OPC UA. In laboratorio se ne può fare a meno, ma un client destinato alla produzione deve poter usare canali firmati e cifrati, quindi è bene compilare la libreria con il supporto fin dall'inizio.
# CMakeLists.txt
cmake_minimum_required(VERSION 3.20)
project(scada_acquirer LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 23)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
find_package(PkgConfig REQUIRED)
pkg_check_modules(LIBMODBUS REQUIRED IMPORTED_TARGET libmodbus)
find_package(open62541 REQUIRED)
add_executable(scada_acquirer src/main.cpp src/modbus_connection.cpp src/opcua_client.cpp)
target_include_directories(scada_acquirer PRIVATE src)
target_compile_options(scada_acquirer PRIVATE -Wall -Wextra -Wpedantic)
target_link_libraries(scada_acquirer PRIVATE PkgConfig::LIBMODBUS open62541::open62541)
Avvolgere libmodbus in una classe RAII
libmodbus segue lo stile classico delle librerie C: un contesto opaco modbus_t creato con modbus_new_tcp e distrutto con modbus_free, funzioni che restituiscono -1 in caso di errore e il dettaglio dell'errore in errno. Gestirlo a mano significa rischiare perdite di memoria e socket lasciati aperti in ogni percorso di errore. Un std::unique_ptr con un deleter personalizzato elimina il problema alla radice.
Per gli errori usiamo std::expected, che rende esplicito nel tipo di ritorno che un'operazione può fallire, senza il costo e l'imprevedibilità delle eccezioni in un ciclo di acquisizione. Il tipo di errore distingue le eccezioni di protocollo, cioè le risposte con cui il PLC rifiuta una richiesta, dagli errori di trasporto. libmodbus codifica le prime in errno a partire dalla costante MODBUS_ENOBASE.
// src/modbus_connection.hpp
#pragma once
#include <cstdint>
#include <expected>
#include <memory>
#include <string>
#include <vector>
#include <modbus.h>
enum class RegisterTable { Holding, Input };
struct ModbusError {
int code;
std::string message;
// true se il dispositivo ha risposto con un'eccezione Modbus: la connessione resta valida
bool protocolException;
};
class ModbusConnection {
public:
ModbusConnection(std::string host, int port, int unitId, std::uint32_t timeoutMs);
ModbusConnection(const ModbusConnection&) = delete;
ModbusConnection& operator=(const ModbusConnection&) = delete;
ModbusConnection(ModbusConnection&&) noexcept = default;
ModbusConnection& operator=(ModbusConnection&&) noexcept = default;
[[nodiscard]] std::expected<void, ModbusError> connect();
void disconnect() noexcept;
[[nodiscard]] bool connected() const noexcept { return connected_; }
[[nodiscard]] std::expected<std::vector<std::uint16_t>, ModbusError>
readRegisters(RegisterTable table, int address, int count);
[[nodiscard]] std::expected<void, ModbusError> writeRegister(int address, std::uint16_t value);
private:
struct ContextDeleter {
void operator()(modbus_t* ctx) const noexcept {
modbus_close(ctx);
modbus_free(ctx);
}
};
[[nodiscard]] static ModbusError lastError();
std::string host_;
int port_;
int unitId_;
std::uint32_t timeoutMs_;
std::unique_ptr<modbus_t, ContextDeleter> ctx_;
bool connected_ = false;
};
// src/modbus_connection.cpp
#include "modbus_connection.hpp"
#include <cerrno>
#include <utility>
ModbusConnection::ModbusConnection(std::string host, int port, int unitId, std::uint32_t timeoutMs)
: host_(std::move(host)), port_(port), unitId_(unitId), timeoutMs_(timeoutMs) {}
ModbusError ModbusConnection::lastError() {
const int code = errno;
// libmodbus mappa le eccezioni Modbus sull'intervallo che parte da MODBUS_ENOBASE
const bool protocol = code >= EMBXILFUN && code <= EMBXGTAR;
return ModbusError{code, modbus_strerror(code), protocol};
}
std::expected<void, ModbusError> ModbusConnection::connect() {
disconnect();
std::unique_ptr<modbus_t, ContextDeleter> ctx{modbus_new_tcp(host_.c_str(), port_)};
if (!ctx) {
return std::unexpected(lastError());
}
if (modbus_set_slave(ctx.get(), unitId_) == -1) {
return std::unexpected(lastError());
}
const auto seconds = timeoutMs_ / 1000;
const auto micros = (timeoutMs_ % 1000) * 1000;
modbus_set_response_timeout(ctx.get(), seconds, micros);
// Dopo un timeout libmodbus svuota il buffer di ricezione per evitare risposte fuori sequenza
modbus_set_error_recovery(ctx.get(), MODBUS_ERROR_RECOVERY_PROTOCOL);
if (modbus_connect(ctx.get()) == -1) {
auto error = lastError();
// Il contesto viene liberato dal deleter; modbus_close su un socket mai aperto è innocuo
return std::unexpected(std::move(error));
}
ctx_ = std::move(ctx);
connected_ = true;
return {};
}
void ModbusConnection::disconnect() noexcept {
ctx_.reset();
connected_ = false;
}
std::expected<std::vector<std::uint16_t>, ModbusError>
ModbusConnection::readRegisters(RegisterTable table, int address, int count) {
if (!connected_) {
return std::unexpected(ModbusError{ENOTCONN, "non connesso", false});
}
if (count < 1 || count > MODBUS_MAX_READ_REGISTERS) {
return std::unexpected(ModbusError{EINVAL, "numero di registri non valido", false});
}
std::vector<std::uint16_t> registers(static_cast<std::size_t>(count));
const int result = table == RegisterTable::Holding
? modbus_read_registers(ctx_.get(), address, count, registers.data())
: modbus_read_input_registers(ctx_.get(), address, count, registers.data());
if (result == -1) {
auto error = lastError();
if (!error.protocolException) {
disconnect();
}
return std::unexpected(std::move(error));
}
if (result != count) {
disconnect();
return std::unexpected(ModbusError{EIO, "risposta incompleta", false});
}
return registers;
}
std::expected<void, ModbusError> ModbusConnection::writeRegister(int address, std::uint16_t value) {
if (!connected_) {
return std::unexpected(ModbusError{ENOTCONN, "non connesso", false});
}
if (modbus_write_register(ctx_.get(), address, value) == -1) {
auto error = lastError();
if (!error.protocolException) {
disconnect();
}
return std::unexpected(std::move(error));
}
return {};
}
Il deleter chiama modbus_close prima di modbus_free: liberare il contesto senza chiudere il socket lascerebbe un descrittore aperto, che in un processo di lunga durata con riconnessioni frequenti finirebbe per esaurire il limite del sistema. La classe è spostabile ma non copiabile, perché una connessione ha un solo proprietario.
La regola applicata in readRegisters è la stessa che vale in qualsiasi linguaggio: dopo un'eccezione di protocollo la connessione è sana, dopo un errore di trasporto no, perché una risposta tardiva potrebbe arrivare in qualsiasi momento e venire associata alla richiesta successiva. In quel caso si chiude e si riparte.
Decodificare i registri
libmodbus restituisce i registri come uint16_t già convertiti nell'ordine dei byte della macchina. Per i valori a 32 bit offre funzioni come modbus_get_float_abcd e modbus_get_float_cdab, i cui suffissi indicano l'ordine di byte e word. Scrivere la decodifica in C++ con std::bit_cast rende però esplicito cosa accade, è verificabile a compilazione e funziona per tutti i tipi in modo uniforme.
// src/decoder.hpp
#pragma once
#include <bit>
#include <cmath>
#include <cstdint>
#include <expected>
#include <span>
#include <string>
#include "modbus_connection.hpp"
enum class DataType { Int16, UInt16, Int32, UInt32, Float32 };
enum class WordOrder { HighFirst, LowFirst };
struct ModbusTag {
std::string name;
RegisterTable table;
int address;
DataType type;
WordOrder wordOrder = WordOrder::HighFirst;
double scale = 1.0;
std::string unit;
};
constexpr int registerCount(DataType type) noexcept {
return (type == DataType::Int16 || type == DataType::UInt16) ? 1 : 2;
}
inline std::expected<double, std::string> decode(std::span<const std::uint16_t> registers, const ModbusTag& tag) {
if (registers.size() < static_cast<std::size_t>(registerCount(tag.type))) {
return std::unexpected("registri insufficienti per " + tag.name);
}
double raw = 0.0;
if (registerCount(tag.type) == 1) {
const std::uint16_t word = registers[0];
raw = tag.type == DataType::Int16 ? static_cast<double>(std::bit_cast<std::int16_t>(word))
: static_cast<double>(word);
} else {
const auto [high, low] = tag.wordOrder == WordOrder::HighFirst
? std::pair{registers[0], registers[1]}
: std::pair{registers[1], registers[0]};
const std::uint32_t bits = (static_cast<std::uint32_t>(high) << 16) | low;
switch (tag.type) {
case DataType::Int32:
raw = static_cast<double>(std::bit_cast<std::int32_t>(bits));
break;
case DataType::UInt32:
raw = static_cast<double>(bits);
break;
case DataType::Float32: {
const float value = std::bit_cast<float>(bits);
if (!std::isfinite(value)) {
return std::unexpected("float non valido per " + tag.name + ": verificare l'ordine delle word");
}
raw = static_cast<double>(value);
break;
}
default:
return std::unexpected("tipo inatteso per " + tag.name);
}
}
return raw * tag.scale;
}
static_assert(sizeof(float) == 4, "float a 32 bit richiesto per la decodifica IEEE 754");
Le operazioni di shift lavorano sui valori, non sulla rappresentazione in memoria, quindi la ricostruzione della parola a 32 bit è indipendente dall'ordine dei byte della CPU: lo stesso codice funziona su x86 e su un ARM big-endian. Il static_assert documenta l'unica ipotesi sulla piattaforma, cioè che float sia il formato IEEE 754 a 32 bit, condizione verificata da tutti i compilatori di uso comune.
Un client OPC UA con open62541
open62541 usa un modello a callback. Si crea una sottoscrizione, si registra un monitored item per ogni nodo passando un puntatore a funzione e un contesto, e si chiama periodicamente UA_Client_run_iterate, che elabora la rete e invoca le callback. Non ci sono thread nascosti: tutto avviene nel thread che chiama run_iterate, e questo rende il comportamento del client facile da prevedere.
La callback deve essere una funzione C, non una lambda con cattura. Il modo idiomatico per collegarla a un oggetto C++ è una funzione statica che riceve il puntatore all'oggetto come contesto, la cosiddetta funzione trampolino.
// src/opcua_client.hpp
#pragma once
#include <chrono>
#include <cstdint>
#include <expected>
#include <functional>
#include <memory>
#include <string>
#include <vector>
#include <open62541/client.h>
struct OpcUaNode {
std::string name;
std::uint16_t namespaceIndex;
std::string identifier;
};
struct OpcUaReading {
std::string name;
double value;
bool good;
std::string status;
std::int64_t sourceTimeUnix;
};
class OpcUaClient {
public:
using Callback = std::function<void(const OpcUaReading&)>;
OpcUaClient(std::string endpointUrl, std::vector<OpcUaNode> nodes, Callback callback);
OpcUaClient(const OpcUaClient&) = delete;
OpcUaClient& operator=(const OpcUaClient&) = delete;
[[nodiscard]] std::expected<void, std::string> connectAndSubscribe();
// Elabora la rete per al massimo il tempo indicato e restituisce false se la sessione è caduta
[[nodiscard]] bool iterate(std::chrono::milliseconds timeout);
void disconnect() noexcept;
private:
struct ClientDeleter {
void operator()(UA_Client* client) const noexcept { UA_Client_delete(client); }
};
struct ItemContext {
OpcUaClient* owner;
std::size_t index;
};
static void onDataChange(UA_Client* client, UA_UInt32 subId, void* subContext,
UA_UInt32 monId, void* monContext, UA_DataValue* value);
std::string endpointUrl_;
std::vector<OpcUaNode> nodes_;
Callback callback_;
std::unique_ptr<UA_Client, ClientDeleter> client_;
// Contesti con indirizzo stabile: il vettore non viene mai ridimensionato dopo la creazione
std::vector<ItemContext> contexts_;
};
// src/opcua_client.cpp
#include "opcua_client.hpp"
#include <utility>
#include <open62541/client_config_default.h>
#include <open62541/client_highlevel.h>
#include <open62541/client_subscriptions.h>
namespace {
// Converte i tipi numerici scalari OPC UA in double
bool toDouble(const UA_Variant& variant, double& out) {
if (!UA_Variant_isScalar(&variant) || variant.data == nullptr) {
return false;
}
const UA_DataType* type = variant.type;
if (type == &UA_TYPES[UA_TYPES_DOUBLE]) { out = *static_cast<UA_Double*>(variant.data); return true; }
if (type == &UA_TYPES[UA_TYPES_FLOAT]) { out = *static_cast<UA_Float*>(variant.data); return true; }
if (type == &UA_TYPES[UA_TYPES_INT16]) { out = *static_cast<UA_Int16*>(variant.data); return true; }
if (type == &UA_TYPES[UA_TYPES_UINT16]) { out = *static_cast<UA_UInt16*>(variant.data); return true; }
if (type == &UA_TYPES[UA_TYPES_INT32]) { out = *static_cast<UA_Int32*>(variant.data); return true; }
if (type == &UA_TYPES[UA_TYPES_UINT32]) { out = *static_cast<UA_UInt32*>(variant.data); return true; }
if (type == &UA_TYPES[UA_TYPES_INT64]) { out = static_cast<double>(*static_cast<UA_Int64*>(variant.data)); return true; }
if (type == &UA_TYPES[UA_TYPES_BOOLEAN]) { out = *static_cast<UA_Boolean*>(variant.data) ? 1.0 : 0.0; return true; }
return false;
}
} // namespace
OpcUaClient::OpcUaClient(std::string endpointUrl, std::vector<OpcUaNode> nodes, Callback callback)
: endpointUrl_(std::move(endpointUrl)), nodes_(std::move(nodes)), callback_(std::move(callback)) {}
std::expected<void, std::string> OpcUaClient::connectAndSubscribe() {
disconnect();
std::unique_ptr<UA_Client, ClientDeleter> client{UA_Client_new()};
if (!client) {
return std::unexpected("allocazione del client fallita");
}
UA_ClientConfig* config = UA_Client_getConfig(client.get());
UA_ClientConfig_setDefault(config);
config->timeout = 5000;
// In produzione: securityMode SignAndEncrypt, certificato client e policy Basic256Sha256 o superiore
UA_StatusCode status = UA_Client_connect(client.get(), endpointUrl_.c_str());
if (status != UA_STATUSCODE_GOOD) {
return std::unexpected(std::string("connessione fallita: ") + UA_StatusCode_name(status));
}
UA_CreateSubscriptionRequest request = UA_CreateSubscriptionRequest_default();
request.requestedPublishingInterval = 500.0;
UA_CreateSubscriptionResponse response =
UA_Client_Subscriptions_create(client.get(), request, nullptr, nullptr, nullptr);
if (response.responseHeader.serviceResult != UA_STATUSCODE_GOOD) {
return std::unexpected(std::string("creazione sottoscrizione fallita: ") +
UA_StatusCode_name(response.responseHeader.serviceResult));
}
const UA_UInt32 subscriptionId = response.subscriptionId;
contexts_.clear();
contexts_.reserve(nodes_.size());
for (std::size_t i = 0; i < nodes_.size(); ++i) {
contexts_.push_back(ItemContext{this, i});
}
for (std::size_t i = 0; i < nodes_.size(); ++i) {
const auto& node = nodes_[i];
// La macro _ALLOC copia la stringa: il NodeId va liberato con UA_NodeId_clear
UA_NodeId nodeId = UA_NODEID_STRING_ALLOC(node.namespaceIndex, node.identifier.c_str());
UA_MonitoredItemCreateRequest itemRequest = UA_MonitoredItemCreateRequest_default(nodeId);
itemRequest.requestedParameters.samplingInterval = 250.0;
itemRequest.requestedParameters.queueSize = 10;
UA_MonitoredItemCreateResult result = UA_Client_MonitoredItems_createDataChange(
client.get(), subscriptionId, UA_TIMESTAMPSTORETURN_BOTH, itemRequest,
&contexts_[i], &OpcUaClient::onDataChange, nullptr);
UA_NodeId_clear(&nodeId);
if (result.statusCode != UA_STATUSCODE_GOOD) {
// Un nodo inesistente non blocca gli altri: si segnala e si prosegue
OpcUaReading bad{node.name, 0.0, false, UA_StatusCode_name(result.statusCode), 0};
callback_(bad);
}
}
client_ = std::move(client);
return {};
}
void OpcUaClient::onDataChange(UA_Client*, UA_UInt32, void*, UA_UInt32, void* monContext, UA_DataValue* value) {
auto* context = static_cast<ItemContext*>(monContext);
OpcUaClient& self = *context->owner;
const OpcUaNode& node = self.nodes_[context->index];
const UA_StatusCode status = value->hasStatus ? value->status : UA_STATUSCODE_GOOD;
double number = 0.0;
const bool good = status == UA_STATUSCODE_GOOD && value->hasValue && toDouble(value->value, number);
const std::int64_t sourceTime = value->hasSourceTimestamp ? UA_DateTime_toUnixTime(value->sourceTimestamp) : 0;
// La callback gira dentro run_iterate: deve restare breve e non lanciare eccezioni verso il codice C
try {
self.callback_(OpcUaReading{node.name, number, good, UA_StatusCode_name(status), sourceTime});
} catch (...) {
}
}
bool OpcUaClient::iterate(std::chrono::milliseconds timeout) {
if (!client_) {
return false;
}
const UA_StatusCode status = UA_Client_run_iterate(client_.get(), static_cast<UA_UInt32>(timeout.count()));
return status == UA_STATUSCODE_GOOD;
}
void OpcUaClient::disconnect() noexcept {
if (client_) {
UA_Client_disconnect(client_.get());
client_.reset();
}
}
Alcuni dettagli di questo codice sono essenziali per la correttezza.
- Stabilità dei contesti. open62541 conserva il puntatore di contesto e lo restituisce a ogni notifica. Se il vettore
contexts_venisse ridimensionato dopo la creazione degli item, i puntatori diventerebbero pendenti. La chiamata areservee il riempimento prima della registrazione garantiscono che gli indirizzi non cambino. - Nessuna eccezione verso il C. Un'eccezione C++ che attraversa una funzione C ha comportamento indefinito. La callback cattura tutto prima di restituire il controllo alla libreria.
- Ciclo di vita dei NodeId.
UA_NODEID_STRING_ALLOCalloca una copia della stringa, che va liberata conUA_NodeId_clear. La libreria copia a sua volta il NodeId nella richiesta, quindi lo si può liberare subito dopo la chiamata. - Disconnessione e distruzione.
UA_Client_disconnectchiude sessione e canale in modo ordinato, poi il deleter libera la memoria conUA_Client_delete.
Il ciclo di acquisizione
Il programma principale esegue un unico ciclo che serve entrambi i protocolli. A ogni iterazione chiama iterate sul client OPC UA con un timeout breve, così le notifiche vengono elaborate con latenza bassa, e quando arriva la scadenza del ciclo Modbus esegue le letture. La cadenza Modbus è governata da una scadenza assoluta su std::chrono::steady_clock: invece di attendere un intervallo fisso dopo ogni ciclo, si calcola il momento del ciclo successivo sommando il periodo alla scadenza precedente. In questo modo la durata variabile delle letture non provoca una deriva della cadenza nel tempo.
L'uscita è una riga JSON per lettura sullo standard output, un formato che si può inoltrare facilmente a un collettore di log, a un broker MQTT tramite uno strumento esterno o a un database.
// src/main.cpp
#include <algorithm>
#include <atomic>
#include <chrono>
#include <csignal>
#include <format>
#include <iostream>
#include <optional>
#include <span>
#include <string>
#include <string_view>
#include <thread>
#include <vector>
#include "decoder.hpp"
#include "modbus_connection.hpp"
#include "opcua_client.hpp"
namespace {
std::atomic<bool> running{true};
void handleSignal(int) { running.store(false); }
std::string escapeJson(std::string_view text) {
std::string out;
out.reserve(text.size());
for (const char c : text) {
if (c == '"' || c == '\\') {
out.push_back('\\');
}
out.push_back(c);
}
return out;
}
void emit(std::string_view source, std::string_view name, std::optional<double> value,
std::string_view quality, std::string_view unit) {
const auto now = std::chrono::system_clock::now();
const auto millis = std::chrono::duration_cast<std::chrono::milliseconds>(now.time_since_epoch()).count();
std::cout << std::format(R"({{"source":"{}","tag":"{}","value":{},"quality":"{}","unit":"{}","ts":{}}})",
escapeJson(source), escapeJson(name),
value ? std::format("{:.6g}", *value) : "null",
quality, escapeJson(unit), millis)
<< '\n';
}
void pollModbus(ModbusConnection& connection, const std::vector<ModbusTag>& tags) {
if (!connection.connected()) {
if (auto result = connection.connect(); !result) {
std::cerr << std::format("connessione Modbus fallita: {}\n", result.error().message);
for (const auto& tag : tags) emit("modbus", tag.name, std::nullopt, "bad", tag.unit);
return;
}
std::cerr << "connesso al PLC Modbus\n";
}
// I tag sono contigui: un'unica lettura per tabella copre tutti i registri
for (const RegisterTable table : {RegisterTable::Holding, RegisterTable::Input}) {
int first = -1;
int last = -1;
for (const auto& tag : tags) {
if (tag.table != table) continue;
if (first == -1 || tag.address < first) first = tag.address;
last = std::max(last, tag.address + registerCount(tag.type));
}
if (first == -1) continue;
auto registers = connection.readRegisters(table, first, last - first);
for (const auto& tag : tags) {
if (tag.table != table) continue;
if (!registers) {
emit("modbus", tag.name, std::nullopt, "bad", tag.unit);
continue;
}
const std::span<const std::uint16_t> slice{registers->data() + (tag.address - first),
static_cast<std::size_t>(registerCount(tag.type))};
if (auto value = decode(slice, tag)) {
emit("modbus", tag.name, *value, "good", tag.unit);
} else {
std::cerr << value.error() << '\n';
emit("modbus", tag.name, std::nullopt, "bad", tag.unit);
}
}
if (!registers) {
std::cerr << std::format("lettura Modbus fallita: {}\n", registers.error().message);
}
}
}
} // namespace
int main() {
std::signal(SIGINT, handleSignal);
std::signal(SIGTERM, handleSignal);
const std::vector<ModbusTag> modbusTags{
{"furnace.temperature", RegisterTable::Holding, 300, DataType::Int16, WordOrder::HighFirst, 0.1, "°C"},
{"furnace.oxygen", RegisterTable::Holding, 302, DataType::Float32, WordOrder::HighFirst, 1.0, "%"},
{"furnace.gas_total", RegisterTable::Input, 40, DataType::UInt32, WordOrder::LowFirst, 0.01, "m3"},
};
ModbusConnection modbus{"10.10.1.50", 502, 1, 1000};
OpcUaClient opcua{
"opc.tcp://10.10.2.20:4840",
{
{"conveyor.speed", 2, "Conveyor.Speed"},
{"conveyor.load", 2, "Conveyor.Load"},
},
[](const OpcUaReading& reading) {
emit("opcua", reading.name, reading.good ? std::optional<double>{reading.value} : std::nullopt,
reading.good ? "good" : "bad", "");
}};
using Clock = std::chrono::steady_clock;
const auto modbusPeriod = std::chrono::milliseconds{1000};
auto nextModbusPoll = Clock::now();
auto nextOpcUaRetry = Clock::now();
auto opcuaBackoff = std::chrono::seconds{1};
bool opcuaActive = false;
while (running.load()) {
const auto now = Clock::now();
if (!opcuaActive && now >= nextOpcUaRetry) {
if (auto result = opcua.connectAndSubscribe()) {
opcuaActive = true;
opcuaBackoff = std::chrono::seconds{1};
std::cerr << "sottoscrizione OPC UA attiva\n";
} else {
std::cerr << std::format("OPC UA: {}; nuovo tentativo tra {}s\n", result.error(), opcuaBackoff.count());
nextOpcUaRetry = now + opcuaBackoff;
opcuaBackoff = std::min(opcuaBackoff * 2, std::chrono::seconds{30});
}
}
if (now >= nextModbusPoll) {
pollModbus(modbus, modbusTags);
// Scadenza assoluta: la cadenza non deriva con la durata delle letture
nextModbusPoll += modbusPeriod;
if (nextModbusPoll < Clock::now()) {
// Il ciclo è rimasto indietro, per esempio per un timeout: si riallinea senza recuperare
nextModbusPoll = Clock::now() + modbusPeriod;
}
}
const auto untilNextPoll = std::chrono::duration_cast<std::chrono::milliseconds>(nextModbusPoll - Clock::now());
const auto wait = std::clamp(untilNextPoll, std::chrono::milliseconds{0}, std::chrono::milliseconds{100});
if (opcuaActive) {
if (!opcua.iterate(wait)) {
std::cerr << "sessione OPC UA interrotta\n";
opcua.disconnect();
opcuaActive = false;
nextOpcUaRetry = Clock::now() + opcuaBackoff;
}
} else {
std::this_thread::sleep_for(wait);
}
}
std::cerr << "arresto in corso\n";
opcua.disconnect();
modbus.disconnect();
return 0;
}
La gestione dei segnali si limita a impostare una variabile atomica: in un gestore di segnale sono consentite pochissime operazioni, e scrivere su un std::atomic<bool> privo di lock è tra quelle sicure. Il ciclo principale controlla la variabile a ogni iterazione, al massimo ogni cento millisecondi, ed esegue la chiusura ordinata delle connessioni.
Il riallineamento della scadenza quando il ciclo resta indietro evita un comportamento insidioso: se il PLC non risponde per dieci secondi, senza riallineamento il ciclo eseguirebbe dieci letture consecutive appena la comunicazione riprende, per recuperare le scadenze perse. È esattamente l'opposto di quello che serve a un dispositivo che si sta riprendendo da un problema.
Scrivere un setpoint in sicurezza
L'acquisitore non scrive nulla, ed è la configurazione più sicura. Quando una scrittura è necessaria, le regole sono le stesse di qualsiasi altro linguaggio: limiti espliciti, conversione verificata, nessun nuovo tentativo automatico e rilettura di conferma.
// src/setpoint.hpp
#pragma once
#include <cmath>
#include <cstdint>
#include <expected>
#include <format>
#include <limits>
#include <string>
#include "decoder.hpp"
#include "modbus_connection.hpp"
struct WriteLimits {
double min;
double max;
};
inline std::expected<double, std::string>
writeSetpoint(ModbusConnection& connection, const ModbusTag& tag, const WriteLimits& limits, double value) {
if (tag.table != RegisterTable::Holding || tag.type != DataType::Int16) {
return std::unexpected("scrittura supportata solo per holding register Int16");
}
if (!std::isfinite(value) || value < limits.min || value > limits.max) {
return std::unexpected(std::format("valore {} fuori dai limiti [{}, {}]", value, limits.min, limits.max));
}
const double raw = std::round(value / tag.scale);
if (raw < std::numeric_limits<std::int16_t>::min() || raw > std::numeric_limits<std::int16_t>::max()) {
return std::unexpected("valore grezzo fuori dall'intervallo Int16");
}
const auto word = std::bit_cast<std::uint16_t>(static_cast<std::int16_t>(raw));
// Un errore qui lascia l'esito incerto: il chiamante deve rileggere, non ripetere la scrittura
if (auto written = connection.writeRegister(tag.address, word); !written) {
return std::unexpected("scrittura non confermata: " + written.error().message);
}
auto check = connection.readRegisters(RegisterTable::Holding, tag.address, 1);
if (!check) {
return std::unexpected("rilettura fallita: " + check.error().message);
}
auto confirmed = decode(*check, tag);
if (!confirmed) {
return std::unexpected(confirmed.error());
}
if (std::abs(*confirmed - value) > std::abs(tag.scale) / 2) {
return std::unexpected(std::format("il PLC ha mantenuto {} invece di {}", *confirmed, value));
}
return *confirmed;
}
Poiché il programma è a thread singolo, la serializzazione delle operazioni sul dispositivo è garantita per costruzione: una scrittura invocata dal ciclo principale non può mai sovrapporsi a una lettura. Se si introducono più thread, per esempio per ricevere comandi da una socket, i comandi vanno accodati e serviti dallo stesso thread che possiede la connessione.
Sicurezza
- Segmentazione di rete. Modbus TCP non autentica né cifra: l'acquisitore deve stare in una zona dedicata, l'unica autorizzata a raggiungere i PLC, secondo il modello a zone e condotti della IEC 62443.
- OPC UA cifrato. Con open62541 compilata con il supporto alla crittografia, la configurazione del client si ottiene con
UA_ClientConfig_setDefaultEncryption, passando certificato e chiave privata, e impostando la modalitàUA_MESSAGESECURITYMODE_SIGNANDENCRYPT. Il certificato del client va approvato sul server. - Robustezza dell'input. I dati che arrivano dalla rete, anche industriale, vanno trattati come non attendibili. Le librerie validano i frame, ma il codice applicativo deve comunque verificare dimensioni e tipi, come fanno
decodeetoDouble, invece di presumere che il dispositivo risponda sempre come documentato. - Privilegi minimi. Il processo non ha bisogno di privilegi di amministratore: va eseguito con un utente dedicato, idealmente in un servizio systemd con le opzioni di isolamento attive.
Conclusioni
Abbiamo costruito un acquisitore che unisce due librerie C di riferimento per l'industria in un'architettura C++ moderna: RAII per non perdere mai un socket o una sessione, std::expected per errori espliciti e senza eccezioni nel percorso critico, std::bit_cast per una decodifica indipendente dalla piattaforma, funzioni trampolino per collegare le callback C agli oggetti C++ e un ciclo a scadenza assoluta che mantiene la cadenza senza derive. È la struttura tipica dei driver di comunicazione che si trovano dentro i prodotti SCADA, e si presta a crescere: nuovi protocolli si aggiungono come nuove classi gestite dallo stesso ciclo, e l'uscita JSON può essere sostituita da un client MQTT o da un buffer circolare in memoria condivisa per i processi che consumano i dati.