Modbus TCP in Go: client e server da zero

Modbus TCP in Go: client e server da zero

Modbus è uno dei protocolli industriali più longevi in circolazione. Nato alla fine degli anni Settanta per far dialogare i PLC, è ancora oggi il linguaggio comune di inverter fotovoltaici, contatori di energia, centraline HVAC, sensori ambientali e azionamenti. La ragione di questa longevità è la semplicità: il protocollo si descrive in poche pagine, non richiede librerie complesse e funziona allo stesso modo su una linea seriale RS-485 e su una rete Ethernet.

In questo articolo implementiamo Modbus TCP in Go senza dipendenze esterne, usando solo la libreria standard. Scriveremo un client in grado di leggere e scrivere coil e registri, un server con una memoria dati in RAM utile per test e simulazioni, le funzioni per convertire i registri in tipi numerici e il calcolo del CRC usato da Modbus RTU. Alla fine avremo un piccolo package completo di test, e soprattutto una comprensione precisa di cosa viaggia sul filo.

Il modello dati di Modbus

Un dispositivo Modbus espone i propri dati attraverso quattro tabelle distinte. Ogni tabella ha uno spazio di indirizzamento a 16 bit, quindi può contenere fino a 65.536 elementi, e ha un tipo e un diritto di accesso ben precisi.

TabellaTipoAccessoUso tipico
Coil1 bitlettura e scritturauscite digitali, comandi on/off
Discrete input1 bitsola letturaingressi digitali, stati
Holding register16 bitlettura e scritturaparametri di configurazione, setpoint
Input register16 bitsola letturamisure, valori acquisiti

Nella documentazione dei dispositivi si incontra spesso la vecchia notazione a cinque cifre, in cui il primo numero indica la tabella: 00001 per i coil, 10001 per i discrete input, 30001 per gli input register e 40001 per gli holding register. Questi numeri partono da 1, mentre gli indirizzi che viaggiano nei pacchetti partono da 0. Il registro 40001 corrisponde quindi all'indirizzo 0 della tabella degli holding register, e il registro 40108 all'indirizzo 107. Confondere le due numerazioni è l'errore più frequente quando si integra un dispositivo nuovo: se i valori letti sembrano spostati di una posizione, il problema è quasi sempre questo.

PDU, ADU e intestazione MBAP

Il cuore del protocollo è la PDU (Protocol Data Unit), indipendente dal mezzo di trasporto. Una PDU è composta da un byte con il codice funzione seguito dai dati della funzione, per un massimo di 253 byte. Per trasportarla, ogni variante di Modbus la avvolge in una ADU (Application Data Unit) con informazioni aggiuntive.

In Modbus RTU, su linea seriale, l'ADU aggiunge davanti alla PDU l'indirizzo del dispositivo e in coda un CRC a 16 bit. In Modbus TCP il CRC sparisce, perché l'integrità è già garantita da TCP, e al suo posto compare un'intestazione di sette byte chiamata MBAP (Modbus Application Protocol header):

  • Transaction identifier (2 byte): un numero scelto dal client che il server ricopia nella risposta, così da associare risposte e richieste.
  • Protocol identifier (2 byte): vale sempre 0 per Modbus.
  • Length (2 byte): il numero di byte che seguono, cioè il byte di unit identifier più la PDU.
  • Unit identifier (1 byte): l'indirizzo del dispositivo finale, fondamentale quando il server TCP è un gateway verso una linea seriale con più dispositivi.

Tutti i campi multibyte sono in big-endian. Una richiesta di lettura di dieci holding register a partire dall'indirizzo 0, inviata all'unità 1 con transaction identifier 1, è quindi questa sequenza di dodici byte:

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

Il server risponde ricopiando i primi quattro byte, con la propria lunghezza, il codice funzione, un byte di conteggio (20, cioè dieci registri da due byte) e i valori. Se qualcosa va storto, il server risponde con il codice funzione con il bit più alto impostato (0x83 invece di 0x03) seguito da un codice di eccezione: 0x01 per una funzione non supportata, 0x02 per un indirizzo non valido, 0x03 per un valore non valido, 0x04 per un guasto del dispositivo.

Struttura del progetto

Organizziamo il codice in un unico package modbus diviso in file tematici, più un piccolo programma dimostrativo:

modbus/
├── go.mod
├── pdu.go          codifica e decodifica delle PDU
├── client.go       client Modbus TCP
├── server.go       server e memoria dati
├── types.go        conversioni tra registri e tipi numerici
├── crc.go          CRC16 per Modbus RTU
├── modbus_test.go
└── cmd/demo/main.go
module example.com/modbus

go 1.24

Codici funzione ed eccezioni

Iniziamo dal file pdu.go definendo i codici funzione che supportiamo, i codici di eccezione e i limiti di quantità fissati dalla specifica. Questi limiti non sono arbitrari: derivano dalla dimensione massima della PDU. Una risposta di lettura registri contiene il codice funzione, un byte di conteggio e due byte per registro, quindi 125 registri occupano 252 byte e stanno nei 253 disponibili, mentre 126 no.

package modbus

import (
	"encoding/binary"
	"errors"
	"fmt"
)

// Codici funzione supportati
const (
	FuncReadCoils              byte = 0x01
	FuncReadDiscreteInputs     byte = 0x02
	FuncReadHoldingRegisters   byte = 0x03
	FuncReadInputRegisters     byte = 0x04
	FuncWriteSingleCoil        byte = 0x05
	FuncWriteSingleRegister    byte = 0x06
	FuncWriteMultipleRegisters byte = 0x10
)

// Codici di eccezione definiti dalla specifica
const (
	ExIllegalFunction     byte = 0x01
	ExIllegalDataAddress  byte = 0x02
	ExIllegalDataValue    byte = 0x03
	ExServerDeviceFailure byte = 0x04
)

// Quantita' massime per singola richiesta, derivate dalla dimensione della PDU
const (
	MaxReadBits       = 2000
	MaxReadRegisters  = 125
	MaxWriteRegisters = 123
)

// ErrProtocol segnala una risposta malformata o incoerente con la richiesta
var ErrProtocol = errors.New("modbus: protocol error")

Le eccezioni Modbus non sono errori di rete: il dispositivo ha ricevuto la richiesta, l'ha capita e ha deciso di rifiutarla. È utile rappresentarle con un tipo dedicato, così il codice chiamante può distinguerle con errors.As e reagire in modo diverso, per esempio senza riaprire la connessione.


// ExceptionError rappresenta una risposta di eccezione inviata dal dispositivo
type ExceptionError struct {
	Function byte
	Code     byte
}

func (e *ExceptionError) Error() string {
	return fmt.Sprintf("modbus: exception 0x%02X (%s) for function 0x%02X",
		e.Code, exceptionName(e.Code), e.Function)
}

func exceptionName(code byte) string {
	switch code {
	case ExIllegalFunction:
		return "illegal function"
	case ExIllegalDataAddress:
		return "illegal data address"
	case ExIllegalDataValue:
		return "illegal data value"
	case ExServerDeviceFailure:
		return "server device failure"
	default:
		return "unknown"
	}
}

Costruire le richieste

Le funzioni di lettura hanno tutte la stessa forma: codice funzione, indirizzo iniziale e quantità, ciascuno su due byte. Le scritture singole hanno indirizzo e valore. Per i coil il valore non è un semplice 0 o 1: la specifica impone 0xFF00 per acceso e 0x0000 per spento, e qualunque altro valore deve essere rifiutato dal server.


// readRequest costruisce la PDU per le funzioni di lettura 0x01-0x04
func readRequest(fn byte, address, quantity uint16) []byte {
	pdu := make([]byte, 5)
	pdu[0] = fn
	binary.BigEndian.PutUint16(pdu[1:], address)
	binary.BigEndian.PutUint16(pdu[3:], quantity)
	return pdu
}

func writeSingleCoilRequest(address uint16, value bool) []byte {
	var raw uint16
	if value {
		raw = 0xFF00
	}
	return readRequest(FuncWriteSingleCoil, address, raw)
}

func writeSingleRegisterRequest(address, value uint16) []byte {
	return readRequest(FuncWriteSingleRegister, address, value)
}

func writeMultipleRegistersRequest(address uint16, values []uint16) []byte {
	pdu := make([]byte, 6+2*len(values))
	pdu[0] = FuncWriteMultipleRegisters
	binary.BigEndian.PutUint16(pdu[1:], address)
	binary.BigEndian.PutUint16(pdu[3:], uint16(len(values)))
	pdu[5] = byte(2 * len(values))
	for i, v := range values {
		binary.BigEndian.PutUint16(pdu[6+2*i:], v)
	}
	return pdu
}

Il riuso di readRequest per le scritture singole non è una scorciatoia sporca: le PDU delle funzioni 0x05 e 0x06 hanno esattamente lo stesso layout di una lettura, con il valore al posto della quantità.

Impacchettare i bit

Coil e discrete input viaggiano impacchettati, otto per byte. Il primo coil richiesto occupa il bit meno significativo del primo byte, il secondo il bit successivo e così via. Se la quantità non è multipla di otto, i bit in eccesso dell'ultimo byte valgono zero.


// packBits impacchetta i bit partendo dal bit meno significativo del primo byte
func packBits(bits []bool) []byte {
	out := make([]byte, (len(bits)+7)/8)
	for i, b := range bits {
		if b {
			out[i/8] |= 1 << (i % 8)
		}
	}
	return out
}

// unpackBits estrae i primi n bit dai byte ricevuti
func unpackBits(data []byte, n int) []bool {
	bits := make([]bool, n)
	for i := range bits {
		bits[i] = data[i/8]&(1<<(i%8)) != 0
	}
	return bits
}

Validare le risposte

Un client robusto non deve fidarsi della risposta: deve verificare che il codice funzione corrisponda a quello inviato, che la lunghezza sia coerente con quanto richiesto e che i dati di eco delle scritture coincidano. Un dispositivo difettoso o un gateway configurato male possono restituire risposte formalmente valide ma sbagliate, e accorgersene subito evita di propagare valori errati nel resto del sistema.


// checkFunction verifica il codice funzione e trasforma le eccezioni in errori
func checkFunction(fn byte, pdu []byte) error {
	if len(pdu) == 0 {
		return fmt.Errorf("%w: empty response", ErrProtocol)
	}
	if pdu[0] == fn|0x80 {
		if len(pdu) != 2 {
			return fmt.Errorf("%w: malformed exception", ErrProtocol)
		}
		return &ExceptionError{Function: fn, Code: pdu[1]}
	}
	if pdu[0] != fn {
		return fmt.Errorf("%w: unexpected function 0x%02X", ErrProtocol, pdu[0])
	}
	return nil
}

func parseBits(fn byte, pdu []byte, quantity uint16) ([]bool, error) {
	if err := checkFunction(fn, pdu); err != nil {
		return nil, err
	}
	expected := (int(quantity) + 7) / 8
	if len(pdu) != 2+expected || int(pdu[1]) != expected {
		return nil, fmt.Errorf("%w: unexpected byte count", ErrProtocol)
	}
	return unpackBits(pdu[2:], int(quantity)), nil
}

func parseRegisters(fn byte, pdu []byte, quantity uint16) ([]uint16, error) {
	if err := checkFunction(fn, pdu); err != nil {
		return nil, err
	}
	expected := 2 * int(quantity)
	if len(pdu) != 2+expected || int(pdu[1]) != expected {
		return nil, fmt.Errorf("%w: unexpected byte count", ErrProtocol)
	}
	values := make([]uint16, quantity)
	for i := range values {
		values[i] = binary.BigEndian.Uint16(pdu[2+2*i:])
	}
	return values, nil
}

// parseEcho verifica le risposte di scrittura, che ripetono parte della richiesta
func parseEcho(request, pdu []byte) error {
	if err := checkFunction(request[0], pdu); err != nil {
		return err
	}
	if len(pdu) != 5 || string(pdu) != string(request[:5]) {
		return fmt.Errorf("%w: write echo mismatch", ErrProtocol)
	}
	return nil
}

La funzione parseEcho sfrutta una proprietà comoda del protocollo: per le funzioni 0x05 e 0x06 la risposta è identica alla richiesta, mentre per la 0x10 contiene i primi cinque byte della richiesta, cioè codice funzione, indirizzo e quantità. In entrambi i casi basta confrontare i primi cinque byte.

Il client TCP

Il client mantiene una connessione TCP, l'unit identifier del dispositivo di destinazione, un timeout predefinito e un contatore per i transaction identifier. Il mutex garantisce che una sola transazione alla volta usi la connessione: molti dispositivi embedded gestiscono una sola richiesta per volta e non tollerano richieste in pipeline, quindi la serializzazione è la scelta più prudente.

package modbus

import (
	"context"
	"encoding/binary"
	"errors"
	"fmt"
	"io"
	"net"
	"sync"
	"time"
)

// ErrClientClosed viene restituito dopo la chiusura o dopo un errore di I/O
var ErrClientClosed = errors.New("modbus: client closed")

// Client e' un client Modbus TCP sicuro per l'uso concorrente
type Client struct {
	mu      sync.Mutex
	conn    net.Conn
	unitID  byte
	timeout time.Duration
	nextTID uint16
	broken  bool
}

// Dial apre la connessione verso il dispositivo
func Dial(ctx context.Context, address string, unitID byte, timeout time.Duration) (*Client, error) {
	var d net.Dialer
	conn, err := d.DialContext(ctx, "tcp", address)
	if err != nil {
		return nil, err
	}
	return &Client{conn: conn, unitID: unitID, timeout: timeout}, nil
}

// Close chiude la connessione sottostante
func (c *Client) Close() error {
	c.mu.Lock()
	defer c.mu.Unlock()
	c.broken = true
	return c.conn.Close()
}

Il metodo centrale è do, che esegue una transazione completa. I punti delicati sono tre. Il primo è la scadenza: usiamo il timeout del client, ma se il contesto ha una scadenza più vicina prevale quella, e con context.AfterFunc forziamo lo sblocco delle operazioni di I/O anche quando il contesto viene annullato senza scadenza. Il secondo è la lettura: TCP è uno stream, quindi un frame può arrivare spezzato in più segmenti, e io.ReadFull legge esattamente i byte richiesti. Prima leggiamo i sette byte dell'intestazione, poi i byte indicati dal campo length.

Il terzo punto riguarda gli errori. Dopo un timeout non sappiamo più in che stato sia lo stream: la risposta potrebbe arrivare un istante dopo e verrebbe scambiata per la risposta alla richiesta successiva. La strategia più sicura è considerare la connessione compromessa e chiuderla. Il controllo sul transaction identifier resta comunque come difesa aggiuntiva contro risposte duplicate.


// do invia una PDU e restituisce la PDU di risposta
func (c *Client) do(ctx context.Context, pdu []byte) ([]byte, error) {
	c.mu.Lock()
	defer c.mu.Unlock()
	if c.broken {
		return nil, ErrClientClosed
	}

	deadline := time.Now().Add(c.timeout)
	if d, ok := ctx.Deadline(); ok && d.Before(deadline) {
		deadline = d
	}
	if err := c.conn.SetDeadline(deadline); err != nil {
		return nil, c.fail(err)
	}
	// l'annullamento del contesto sblocca subito letture e scritture
	stop := context.AfterFunc(ctx, func() {
		_ = c.conn.SetDeadline(time.Unix(1, 0))
	})
	defer stop()

	c.nextTID++
	tid := c.nextTID
	frame := make([]byte, 7+len(pdu))
	binary.BigEndian.PutUint16(frame[0:], tid)
	binary.BigEndian.PutUint16(frame[2:], 0)
	binary.BigEndian.PutUint16(frame[4:], uint16(len(pdu)+1))
	frame[6] = c.unitID
	copy(frame[7:], pdu)
	if _, err := c.conn.Write(frame); err != nil {
		return nil, c.fail(ctxErr(ctx, err))
	}

	header := make([]byte, 7)
	for {
		if _, err := io.ReadFull(c.conn, header); err != nil {
			return nil, c.fail(ctxErr(ctx, err))
		}
		length := binary.BigEndian.Uint16(header[4:])
		if binary.BigEndian.Uint16(header[2:]) != 0 || length < 2 || length > 254 {
			return nil, c.fail(fmt.Errorf("%w: invalid MBAP header", ErrProtocol))
		}
		body := make([]byte, length-1)
		if _, err := io.ReadFull(c.conn, body); err != nil {
			return nil, c.fail(ctxErr(ctx, err))
		}
		if binary.BigEndian.Uint16(header[0:]) != tid {
			// risposta a una transazione precedente: la scartiamo
			continue
		}
		if header[6] != c.unitID {
			return nil, c.fail(fmt.Errorf("%w: unexpected unit id %d", ErrProtocol, header[6]))
		}
		return body, nil
	}
}

// fail marca la connessione come inutilizzabile e la chiude
func (c *Client) fail(err error) error {
	c.broken = true
	_ = c.conn.Close()
	return err
}

// ctxErr preferisce l'errore del contesto, piu' significativo per il chiamante
func ctxErr(ctx context.Context, err error) error {
	if ctx.Err() != nil {
		return ctx.Err()
	}
	return err
}

Sopra do costruiamo l'API pubblica. Ogni metodo valida i parametri prima di inviare qualunque byte: chiedere 200 registri in un colpo solo è un errore di programmazione e conviene scoprirlo subito, con un messaggio chiaro, invece di ricevere un'eccezione 0x03 dal dispositivo.


func checkQuantity(quantity uint16, max int) error {
	if quantity < 1 || int(quantity) > max {
		return fmt.Errorf("modbus: quantity %d out of range 1-%d", quantity, max)
	}
	return nil
}

func (c *Client) readBits(ctx context.Context, fn byte, address, quantity uint16) ([]bool, error) {
	if err := checkQuantity(quantity, MaxReadBits); err != nil {
		return nil, err
	}
	resp, err := c.do(ctx, readRequest(fn, address, quantity))
	if err != nil {
		return nil, err
	}
	return parseBits(fn, resp, quantity)
}

func (c *Client) readRegisters(ctx context.Context, fn byte, address, quantity uint16) ([]uint16, error) {
	if err := checkQuantity(quantity, MaxReadRegisters); err != nil {
		return nil, err
	}
	resp, err := c.do(ctx, readRequest(fn, address, quantity))
	if err != nil {
		return nil, err
	}
	return parseRegisters(fn, resp, quantity)
}

// ReadCoils legge i coil (funzione 0x01)
func (c *Client) ReadCoils(ctx context.Context, address, quantity uint16) ([]bool, error) {
	return c.readBits(ctx, FuncReadCoils, address, quantity)
}

// ReadDiscreteInputs legge i discrete input (funzione 0x02)
func (c *Client) ReadDiscreteInputs(ctx context.Context, address, quantity uint16) ([]bool, error) {
	return c.readBits(ctx, FuncReadDiscreteInputs, address, quantity)
}

// ReadHoldingRegisters legge gli holding register (funzione 0x03)
func (c *Client) ReadHoldingRegisters(ctx context.Context, address, quantity uint16) ([]uint16, error) {
	return c.readRegisters(ctx, FuncReadHoldingRegisters, address, quantity)
}

// ReadInputRegisters legge gli input register (funzione 0x04)
func (c *Client) ReadInputRegisters(ctx context.Context, address, quantity uint16) ([]uint16, error) {
	return c.readRegisters(ctx, FuncReadInputRegisters, address, quantity)
}

// WriteSingleCoil scrive un coil (funzione 0x05)
func (c *Client) WriteSingleCoil(ctx context.Context, address uint16, value bool) error {
	req := writeSingleCoilRequest(address, value)
	resp, err := c.do(ctx, req)
	if err != nil {
		return err
	}
	return parseEcho(req, resp)
}

// WriteSingleRegister scrive un holding register (funzione 0x06)
func (c *Client) WriteSingleRegister(ctx context.Context, address, value uint16) error {
	req := writeSingleRegisterRequest(address, value)
	resp, err := c.do(ctx, req)
	if err != nil {
		return err
	}
	return parseEcho(req, resp)
}

// WriteMultipleRegisters scrive registri consecutivi (funzione 0x10)
func (c *Client) WriteMultipleRegisters(ctx context.Context, address uint16, values []uint16) error {
	if err := checkQuantity(uint16(min(len(values), 0xFFFF)), MaxWriteRegisters); err != nil {
		return err
	}
	req := writeMultipleRegistersRequest(address, values)
	resp, err := c.do(ctx, req)
	if err != nil {
		return err
	}
	return parseEcho(req, resp)
}

Nell'ultimo metodo la conversione passa da min, la funzione predefinita disponibile da Go 1.21: senza di essa una slice di 65.537 elementi diventerebbe, una volta convertita a uint16, una quantità pari a 1 e supererebbe la validazione.

La memoria dati del server

Un server Modbus è utile per molto più che per i test: può fare da simulatore di un dispositivo non ancora disponibile, da adattatore che espone in Modbus dati raccolti in altro modo, o da punto di integrazione con uno SCADA esistente. Il suo cuore è una memoria con le quattro tabelle, protetta da un sync.RWMutex perché sarà letta dalle connessioni dei client e aggiornata dal resto dell'applicazione.

package modbus

import (
	"encoding/binary"
	"errors"
	"io"
	"net"
	"sync"
	"time"
)

// Tables contiene le quattro tabelle del modello dati Modbus
type Tables struct {
	Coils            []bool
	DiscreteInputs   []bool
	HoldingRegisters []uint16
	InputRegisters   []uint16
}

// DataStore protegge le tabelle per l'accesso concorrente
type DataStore struct {
	mu sync.RWMutex
	t  Tables
}

// NewDataStore crea una memoria con size elementi per tabella
func NewDataStore(size int) *DataStore {
	return &DataStore{t: Tables{
		Coils:            make([]bool, size),
		DiscreteInputs:   make([]bool, size),
		HoldingRegisters: make([]uint16, size),
		InputRegisters:   make([]uint16, size),
	}}
}

// View esegue fn con un lock in lettura
func (s *DataStore) View(fn func(t *Tables)) {
	s.mu.RLock()
	defer s.mu.RUnlock()
	fn(&s.t)
}

// Update esegue fn con un lock in scrittura
func (s *DataStore) Update(fn func(t *Tables)) {
	s.mu.Lock()
	defer s.mu.Unlock()
	fn(&s.t)
}

Le funzioni View e Update evitano di esporre direttamente il mutex e rendono atomici gli aggiornamenti composti: scrivere un valore a 32 bit su due registri all'interno di un'unica chiamata a Update garantisce che nessun client legga una metà vecchia e una metà nuova.

Gestire le richieste

Il metodo Handle riceve una PDU di richiesta e restituisce sempre una PDU di risposta, normale o di eccezione. L'ordine dei controlli segue quello indicato dalla specifica: prima il codice funzione, poi la forma della richiesta e le quantità (eccezione 0x03), infine gli indirizzi (eccezione 0x02). I calcoli sugli indirizzi avvengono in int, perché la somma di indirizzo e quantità in uint16 potrebbe andare in overflow e superare il controllo.


func exception(fn, code byte) []byte {
	return []byte{fn | 0x80, code}
}

// Handle elabora una PDU di richiesta e restituisce la PDU di risposta
func (s *DataStore) Handle(pdu []byte) []byte {
	if len(pdu) == 0 {
		return exception(0, ExIllegalFunction)
	}
	fn := pdu[0]
	switch fn {
	case FuncReadCoils, FuncReadDiscreteInputs:
		if len(pdu) != 5 {
			return exception(fn, ExIllegalDataValue)
		}
		address := int(binary.BigEndian.Uint16(pdu[1:]))
		quantity := int(binary.BigEndian.Uint16(pdu[3:]))
		if quantity < 1 || quantity > MaxReadBits {
			return exception(fn, ExIllegalDataValue)
		}
		s.mu.RLock()
		defer s.mu.RUnlock()
		table := s.t.Coils
		if fn == FuncReadDiscreteInputs {
			table = s.t.DiscreteInputs
		}
		if address+quantity > len(table) {
			return exception(fn, ExIllegalDataAddress)
		}
		data := packBits(table[address : address+quantity])
		return append([]byte{fn, byte(len(data))}, data...)

	case FuncReadHoldingRegisters, FuncReadInputRegisters:
		if len(pdu) != 5 {
			return exception(fn, ExIllegalDataValue)
		}
		address := int(binary.BigEndian.Uint16(pdu[1:]))
		quantity := int(binary.BigEndian.Uint16(pdu[3:]))
		if quantity < 1 || quantity > MaxReadRegisters {
			return exception(fn, ExIllegalDataValue)
		}
		s.mu.RLock()
		defer s.mu.RUnlock()
		table := s.t.HoldingRegisters
		if fn == FuncReadInputRegisters {
			table = s.t.InputRegisters
		}
		if address+quantity > len(table) {
			return exception(fn, ExIllegalDataAddress)
		}
		resp := make([]byte, 2+2*quantity)
		resp[0], resp[1] = fn, byte(2*quantity)
		for i, v := range table[address : address+quantity] {
			binary.BigEndian.PutUint16(resp[2+2*i:], v)
		}
		return resp

	case FuncWriteSingleCoil:
		if len(pdu) != 5 {
			return exception(fn, ExIllegalDataValue)
		}
		address := int(binary.BigEndian.Uint16(pdu[1:]))
		value := binary.BigEndian.Uint16(pdu[3:])
		if value != 0xFF00 && value != 0x0000 {
			return exception(fn, ExIllegalDataValue)
		}
		s.mu.Lock()
		defer s.mu.Unlock()
		if address >= len(s.t.Coils) {
			return exception(fn, ExIllegalDataAddress)
		}
		s.t.Coils[address] = value == 0xFF00
		return append([]byte(nil), pdu...)

	case FuncWriteSingleRegister:
		if len(pdu) != 5 {
			return exception(fn, ExIllegalDataValue)
		}
		address := int(binary.BigEndian.Uint16(pdu[1:]))
		s.mu.Lock()
		defer s.mu.Unlock()
		if address >= len(s.t.HoldingRegisters) {
			return exception(fn, ExIllegalDataAddress)
		}
		s.t.HoldingRegisters[address] = binary.BigEndian.Uint16(pdu[3:])
		return append([]byte(nil), pdu...)

	case FuncWriteMultipleRegisters:
		if len(pdu) < 6 {
			return exception(fn, ExIllegalDataValue)
		}
		address := int(binary.BigEndian.Uint16(pdu[1:]))
		quantity := int(binary.BigEndian.Uint16(pdu[3:]))
		byteCount := int(pdu[5])
		if quantity < 1 || quantity > MaxWriteRegisters ||
			byteCount != 2*quantity || len(pdu) != 6+byteCount {
			return exception(fn, ExIllegalDataValue)
		}
		s.mu.Lock()
		defer s.mu.Unlock()
		if address+quantity > len(s.t.HoldingRegisters) {
			return exception(fn, ExIllegalDataAddress)
		}
		for i := 0; i < quantity; i++ {
			s.t.HoldingRegisters[address+i] = binary.BigEndian.Uint16(pdu[6+2*i:])
		}
		return append([]byte(nil), pdu[:5]...)

	default:
		return exception(fn, ExIllegalFunction)
	}
}

Separare Handle dal trasporto ha un vantaggio concreto: la stessa logica può essere riusata per un server RTU su seriale, cambiando solo il modo in cui le PDU vengono estratte dai frame, e può essere testata senza aprire alcun socket.

Il server TCP

Il server accetta connessioni e avvia una goroutine per ciascuna. Ogni goroutine legge frame in sequenza, delega la PDU alla memoria dati e risponde ricopiando transaction identifier, protocol identifier e unit identifier. Un frame con intestazione non valida chiude la connessione: dopo un errore di framing non c'è modo affidabile di ritrovare l'inizio del frame successivo.


// Server espone un DataStore tramite Modbus TCP
type Server struct {
	Store *DataStore
	// UnitID filtra le richieste; 0 accetta qualunque unit identifier
	UnitID byte
	// IdleTimeout chiude le connessioni inattive; 0 disabilita il controllo
	IdleTimeout time.Duration
}

// Serve accetta connessioni finche' il listener non viene chiuso
func (s *Server) Serve(ln net.Listener) error {
	for {
		conn, err := ln.Accept()
		if err != nil {
			if errors.Is(err, net.ErrClosed) {
				return nil
			}
			return err
		}
		go s.serveConn(conn)
	}
}

func (s *Server) serveConn(conn net.Conn) {
	defer conn.Close()
	header := make([]byte, 7)
	for {
		if s.IdleTimeout > 0 {
			_ = conn.SetReadDeadline(time.Now().Add(s.IdleTimeout))
		}
		if _, err := io.ReadFull(conn, header); err != nil {
			return
		}
		length := binary.BigEndian.Uint16(header[4:])
		if binary.BigEndian.Uint16(header[2:]) != 0 || length < 2 || length > 254 {
			return
		}
		pdu := make([]byte, length-1)
		if _, err := io.ReadFull(conn, pdu); err != nil {
			return
		}
		if s.UnitID != 0 && header[6] != s.UnitID {
			// un dispositivo reale non risponde a richieste destinate ad altri
			continue
		}
		resp := s.Store.Handle(pdu)
		frame := make([]byte, 7+len(resp))
		copy(frame, header[:4])
		binary.BigEndian.PutUint16(frame[4:], uint16(len(resp)+1))
		frame[6] = header[6]
		copy(frame[7:], resp)
		if _, err := conn.Write(frame); err != nil {
			return
		}
	}
}

Il timeout di inattività protegge il server da client che aprono connessioni e le abbandonano, una situazione frequente con dispositivi che si riavviano senza chiudere il socket. Molti dispositivi reali accettano un numero molto ridotto di connessioni contemporanee, a volte una sola, e una connessione orfana può bloccare tutti gli altri client.

Interpretare i registri

Un registro è solo un intero senza segno a 16 bit. Il significato lo stabilisce il produttore del dispositivo: una temperatura può essere un intero con segno in decimi di grado, un'energia un intero a 32 bit su due registri, una tensione un float32 IEEE 754 su due registri. Per i valori a 32 bit esiste un'ulteriore complicazione: la specifica non dice in che ordine vadano i due registri. Alcuni dispositivi mettono per prima la parola più significativa (ordine detto ABCD), altri la meno significativa (CDAB, o word swap).

package modbus

import "math"

// WordOrder indica l'ordine dei registri nei valori a 32 bit
type WordOrder int

const (
	// HighWordFirst: parola piu' significativa nel primo registro (ABCD)
	HighWordFirst WordOrder = iota
	// LowWordFirst: parola meno significativa nel primo registro (CDAB)
	LowWordFirst
)

// Uint32FromRegisters combina due registri in un intero a 32 bit
func Uint32FromRegisters(regs []uint16, order WordOrder) uint32 {
	hi, lo := regs[0], regs[1]
	if order == LowWordFirst {
		hi, lo = lo, hi
	}
	return uint32(hi)<<16 | uint32(lo)
}

// RegistersFromUint32 divide un intero a 32 bit in due registri
func RegistersFromUint32(v uint32, order WordOrder) []uint16 {
	hi, lo := uint16(v>>16), uint16(v)
	if order == LowWordFirst {
		return []uint16{lo, hi}
	}
	return []uint16{hi, lo}
}

// Float32FromRegisters interpreta due registri come float32 IEEE 754
func Float32FromRegisters(regs []uint16, order WordOrder) float32 {
	return math.Float32frombits(Uint32FromRegisters(regs, order))
}

// RegistersFromFloat32 codifica un float32 in due registri
func RegistersFromFloat32(v float32, order WordOrder) []uint16 {
	return RegistersFromUint32(math.Float32bits(v), order)
}

Per gli interi con segno a 16 bit non serve una funzione: in Go la conversione int16(reg) reinterpreta i bit in complemento a due, quindi il registro 0xFF38 diventa -200, cioè -20,0 gradi se il dispositivo usa i decimi. Quando la documentazione non chiarisce l'ordine delle parole, il metodo più rapido è leggere un valore noto, come la tensione di rete intorno ai 230 volt, e provare entrambe le interpretazioni: quella sbagliata produce numeri assurdi.

Il CRC di Modbus RTU

Anche se il nostro trasporto è TCP, prima o poi si incontra Modbus RTU: molti dispositivi sono solo seriali, e alcuni gateway espongono sulla rete frame RTU incapsulati in TCP, senza intestazione MBAP e con il CRC in coda. Il CRC usato è un CRC-16 con polinomio riflesso 0xA001 e valore iniziale 0xFFFF, trasmesso con il byte meno significativo per primo.

package modbus

// CRC16 calcola il CRC di Modbus RTU
func CRC16(data []byte) uint16 {
	crc := uint16(0xFFFF)
	for _, b := range data {
		crc ^= uint16(b)
		for i := 0; i < 8; i++ {
			if crc&1 != 0 {
				crc = crc>>1 ^ 0xA001
			} else {
				crc >>= 1
			}
		}
	}
	return crc
}

// AppendCRC aggiunge il CRC a un frame RTU, byte meno significativo per primo
func AppendCRC(frame []byte) []byte {
	crc := CRC16(frame)
	return append(frame, byte(crc), byte(crc>>8))
}

La stessa richiesta di dieci holding register vista prima, in RTU, diventa 01 03 00 00 00 0A C5 CD: indirizzo, PDU e i due byte del CRC. È un valore di controllo comodo da tenere a mente, e lo useremo nei test.

I test

Grazie alla separazione tra logica e trasporto possiamo testare il sistema a più livelli. I test di integrazione avviano un server reale su una porta scelta dal sistema operativo, indicando 127.0.0.1:0, e lo interrogano con il client. Il pacchetto net rende questa operazione così economica che non vale la pena simulare i socket.

package modbus

import (
	"context"
	"errors"
	"math"
	"net"
	"testing"
	"time"
)

func startServer(t *testing.T) (*DataStore, string) {
	t.Helper()
	store := NewDataStore(100)
	ln, err := net.Listen("tcp", "127.0.0.1:0")
	if err != nil {
		t.Fatal(err)
	}
	srv := &Server{Store: store}
	go func() { _ = srv.Serve(ln) }()
	t.Cleanup(func() { _ = ln.Close() })
	return store, ln.Addr().String()
}

func dial(t *testing.T, addr string) *Client {
	t.Helper()
	c, err := Dial(context.Background(), addr, 1, time.Second)
	if err != nil {
		t.Fatal(err)
	}
	t.Cleanup(func() { _ = c.Close() })
	return c
}

func TestRegistersRoundTrip(t *testing.T) {
	_, addr := startServer(t)
	c := dial(t, addr)
	ctx := context.Background()

	if err := c.WriteMultipleRegisters(ctx, 10, []uint16{1, 2, 0xBEEF}); err != nil {
		t.Fatal(err)
	}
	if err := c.WriteSingleRegister(ctx, 13, 42); err != nil {
		t.Fatal(err)
	}
	got, err := c.ReadHoldingRegisters(ctx, 10, 4)
	if err != nil {
		t.Fatal(err)
	}
	want := []uint16{1, 2, 0xBEEF, 42}
	for i := range want {
		if got[i] != want[i] {
			t.Fatalf("register %d: got %d, want %d", i, got[i], want[i])
		}
	}
}

func TestCoilsAndInputs(t *testing.T) {
	store, addr := startServer(t)
	store.Update(func(tb *Tables) {
		tb.DiscreteInputs[9] = true
		tb.InputRegisters[0] = 0xFF38
	})
	c := dial(t, addr)
	ctx := context.Background()

	if err := c.WriteSingleCoil(ctx, 3, true); err != nil {
		t.Fatal(err)
	}
	coils, err := c.ReadCoils(ctx, 0, 10)
	if err != nil {
		t.Fatal(err)
	}
	for i, v := range coils {
		if v != (i == 3) {
			t.Fatalf("coil %d: got %v", i, v)
		}
	}
	inputs, err := c.ReadDiscreteInputs(ctx, 0, 10)
	if err != nil || !inputs[9] || inputs[8] {
		t.Fatalf("discrete inputs: %v %v", inputs, err)
	}
	regs, err := c.ReadInputRegisters(ctx, 0, 1)
	if err != nil || int16(regs[0]) != -200 {
		t.Fatalf("input register: %v %v", regs, err)
	}
}

func TestExceptionIllegalAddress(t *testing.T) {
	_, addr := startServer(t)
	c := dial(t, addr)
	_, err := c.ReadHoldingRegisters(context.Background(), 95, 10)
	var exc *ExceptionError
	if !errors.As(err, &exc) || exc.Code != ExIllegalDataAddress {
		t.Fatalf("expected illegal data address, got %v", err)
	}
	// dopo un'eccezione la connessione resta utilizzabile
	if _, err := c.ReadHoldingRegisters(context.Background(), 0, 1); err != nil {
		t.Fatal(err)
	}
}

func TestQuantityValidation(t *testing.T) {
	_, addr := startServer(t)
	c := dial(t, addr)
	if _, err := c.ReadHoldingRegisters(context.Background(), 0, 126); err == nil {
		t.Fatal("expected validation error")
	}
	if err := c.WriteMultipleRegisters(context.Background(), 0, make([]uint16, 65537)); err == nil {
		t.Fatal("expected validation error")
	}
}

func TestTimeout(t *testing.T) {
	// un listener che accetta ma non risponde mai
	ln, err := net.Listen("tcp", "127.0.0.1:0")
	if err != nil {
		t.Fatal(err)
	}
	defer ln.Close()
	go func() {
		conn, err := ln.Accept()
		if err == nil {
			defer conn.Close()
			time.Sleep(time.Second)
		}
	}()
	c, err := Dial(context.Background(), ln.Addr().String(), 1, 100*time.Millisecond)
	if err != nil {
		t.Fatal(err)
	}
	if _, err := c.ReadCoils(context.Background(), 0, 1); err == nil {
		t.Fatal("expected timeout")
	}
	if _, err := c.ReadCoils(context.Background(), 0, 1); !errors.Is(err, ErrClientClosed) {
		t.Fatalf("expected ErrClientClosed, got %v", err)
	}
}

func TestHandleIllegalFunction(t *testing.T) {
	resp := NewDataStore(10).Handle([]byte{0x2B, 0x0E})
	if len(resp) != 2 || resp[0] != 0xAB || resp[1] != ExIllegalFunction {
		t.Fatalf("unexpected response % X", resp)
	}
}

func TestFloatWordOrder(t *testing.T) {
	for _, order := range []WordOrder{HighWordFirst, LowWordFirst} {
		regs := RegistersFromFloat32(230.5, order)
		if v := Float32FromRegisters(regs, order); math.Abs(float64(v)-230.5) > 1e-6 {
			t.Fatalf("order %d: got %v", order, v)
		}
	}
	if regs := RegistersFromFloat32(1.0, HighWordFirst); regs[0] != 0x3F80 || regs[1] != 0 {
		t.Fatalf("unexpected encoding % X", regs)
	}
}

func TestCRC16(t *testing.T) {
	frame := AppendCRC([]byte{0x01, 0x03, 0x00, 0x00, 0x00, 0x0A})
	if frame[6] != 0xC5 || frame[7] != 0xCD {
		t.Fatalf("unexpected CRC % X", frame[6:])
	}
}

Il test sul timeout merita attenzione: verifica non solo che la chiamata fallisca entro il tempo previsto, ma anche che il client rifiuti le chiamate successive. È esattamente il comportamento che vogliamo dopo un errore di trasporto. I test si eseguono con il comando abituale, possibilmente con il race detector attivo, dato che client e server sono concorrenti:

go test -race ./...

Un programma dimostrativo

Per vedere tutto in azione scriviamo un programma che simula un misuratore di energia: espone la tensione come float32 negli input register 0 e 1, la aggiorna ogni secondo, e la rilegge con il client. In un caso reale le due metà vivrebbero in processi diversi, ma il codice sarebbe identico.

package main

import (
	"context"
	"fmt"
	"log"
	"math/rand/v2"
	"net"
	"time"

	"example.com/modbus"
)

func main() {
	store := modbus.NewDataStore(10)
	ln, err := net.Listen("tcp", "127.0.0.1:1502")
	if err != nil {
		log.Fatal(err)
	}
	srv := &modbus.Server{Store: store, IdleTimeout: time.Minute}
	go func() { log.Fatal(srv.Serve(ln)) }()

	// simulazione della misura di tensione
	go func() {
		for {
			voltage := float32(228 + rand.Float64()*4)
			regs := modbus.RegistersFromFloat32(voltage, modbus.HighWordFirst)
			store.Update(func(t *modbus.Tables) {
				copy(t.InputRegisters[0:2], regs)
			})
			time.Sleep(time.Second)
		}
	}()

	ctx := context.Background()
	client, err := modbus.Dial(ctx, "127.0.0.1:1502", 1, 2*time.Second)
	if err != nil {
		log.Fatal(err)
	}
	defer client.Close()

	for i := 0; i < 5; i++ {
		regs, err := client.ReadInputRegisters(ctx, 0, 2)
		if err != nil {
			log.Fatal(err)
		}
		v := modbus.Float32FromRegisters(regs, modbus.HighWordFirst)
		fmt.Printf("voltage: %.2f V (raw %04X %04X)\n", v, regs[0], regs[1])
		time.Sleep(time.Second)
	}
}

Abbiamo usato la porta 1502 invece della 502 standard perché su Linux le porte sotto la 1024 richiedono privilegi elevati. In produzione si può concedere al binario la capability CAP_NET_BIND_SERVICE oppure inoltrare la porta 502 con il firewall.

Verso la produzione

Il codice scritto finora è completo per le funzioni più usate, ma un sistema di acquisizione reale richiede qualche accorgimento in più.

  • Riconnessione: il client si chiude al primo errore di trasporto, per scelta. Un ciclo di polling deve quindi ricreare il client con un backoff esponenziale, distinguendo gli errori di rete, che richiedono la riconnessione, dalle ExceptionError, che non la richiedono.
  • Raggruppamento delle letture: ogni transazione costa almeno un round trip, e su un gateway seriale a 9600 baud anche decine di millisecondi. Conviene leggere blocchi contigui di registri in una sola richiesta invece di interrogare i registri uno per uno.
  • Frequenza di polling: molti dispositivi embedded hanno risorse modeste. Interrogarli più spesso del necessario può rallentarli o farli smettere di rispondere.
  • Sicurezza: Modbus TCP non prevede autenticazione né cifratura. Chiunque raggiunga la porta 502 può scrivere registri e accendere uscite. I dispositivi vanno tenuti su una rete segregata, con accesso filtrato dal firewall. Esiste anche Modbus/TCP Security, che incapsula il protocollo in TLS con certificati sulla porta 802, ma il supporto nei dispositivi è ancora limitato.
  • Funzioni mancanti: la scrittura di più coil (0x0F), la scrittura con maschera (0x16) e la lettura e scrittura combinata (0x17) si aggiungono con lo stesso schema visto per le altre funzioni.

Quando servono anche il trasporto seriale RTU, il supporto ASCII o funzioni meno comuni come la lettura dell'identificazione del dispositivo, è ragionevole adottare una libreria matura come github.com/grid-x/modbus, che offre client TCP e RTU con API basate su context. Aver scritto un'implementazione da zero resta comunque l'investimento migliore per diagnosticare i problemi sul campo: quando un dispositivo restituisce valori strani, una cattura con Wireshark e la capacità di leggere i byte dell'intestazione MBAP valgono più di qualunque astrazione.

Conclusione

Con circa settecento righe di Go, test esclusi, abbiamo coperto l'intero percorso di un dato Modbus: la codifica della PDU, l'incapsulamento nel frame TCP con l'intestazione MBAP, la gestione dei timeout e delle risposte tardive, l'elaborazione lato server con le eccezioni previste dalla specifica e l'interpretazione dei registri come valori numerici. La libreria standard di Go si è dimostrata particolarmente adatta al compito: encoding/binary per il big-endian, io.ReadFull per il framing, le deadline di net.Conn per i timeout e le goroutine per servire più client senza alcuna complessità aggiuntiva.