Go library
go-modbus is a pure Go Modbus client and server library (no CGO). It implements the Modbus Application Protocol Specification V1.1b3 over Modbus TCP, Modbus over TLS (MBAPS), Modbus RTU and Modbus ASCII on serial lines, RTU and ASCII framing tunnelled over TCP, RTU over UDP, and Modbus UDP. The full API reference is API.md in the repository.
Install
Section titled “Install”go get github.com/otfabric/go-modbusgo-modbus requires Go 1.23 or later. Its only dependency is go-serial, used for RTU and ASCII.
The library is organized in tiers. Use the lowest one that does the job:
| Package | Purpose |
|---|---|
modbus |
Raw function-code methods, client configuration, server, errors, retry, metrics |
modbus/codec |
Typed values over several registers (uint32, float32, strings, BCD, timestamps, IP addresses) with explicit byte and word layout |
modbus/sunspec |
SunSpec marker detection and model-chain enumeration (headers only, no point decoding) |
Connect
Section titled “Connect”modbus.New validates a Config and returns a *Client. It does not open a connection; Open does. The URL scheme selects the transport.
package main
import ( "context" "fmt" "log" "time"
"github.com/otfabric/go-modbus" "github.com/otfabric/go-modbus/codec")
func main() { client, err := modbus.New(modbus.Config{ URL: "tcp://192.168.1.10:502", Timeout: 2 * time.Second, }) if err != nil { log.Fatal(err) } if err := client.Open(); err != nil { log.Fatal(err) } defer client.Close()
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) defer cancel()
// FC03: four raw holding registers from unit 1, starting at address 100. regs, err := client.ReadRegisters(ctx, 1, 100, 4, modbus.HoldingRegister) if err != nil { log.Fatal(err) } fmt.Println(regs)
// The same registers as a big-endian float32 (two registers, layout 4321). v, err := codec.ReadFromClient(client, ctx, 1, 100, modbus.HoldingRegister, codec.MustNewFloat32Codec(codec.Layout32_4321)) if err != nil { log.Fatal(err) } fmt.Println(v)}For larger setups, modbus.NewConfig(TransportConfig, ExecutionConfig, ObservabilityConfig) builds the same Config from grouped parts.
client, err := modbus.New(modbus.Config{ URL: "tcp://plc.local:502", Timeout: 2 * time.Second, DialTimeout: 5 * time.Second,})cert, _ := tls.LoadX509KeyPair("client.crt", "client.key")pool, _ := modbus.LoadCertPool("ca.crt")
client, err := modbus.New(modbus.Config{ URL: "tcp+tls://plc.local:802", TLSClientCert: &cert, TLSRootCAs: pool, Timeout: 5 * time.Second,})Both fields are required: MBAPS mandates mutual authentication. TLS 1.2 is the minimum.
client, err := modbus.New(modbus.Config{ URL: "rtu:///dev/ttyUSB0", Speed: 9600, DataBits: 8, Parity: modbus.ParityNone, StopBits: 1,})client, err := modbus.New(modbus.Config{ URL: "ascii:///dev/ttyUSB0", Speed: 9600, Parity: modbus.ParityEven,})// RTU device behind a serial-to-Ethernet bridgeclient, err := modbus.New(modbus.Config{URL: "rtuovertcp://192.168.1.50:4001"})
// Other tunnelled and UDP variants// "rtuoverudp://host:port", "asciiovertcp://host:port", "udp://host:port"| Config field | Applies to | Default |
|---|---|---|
Timeout |
all | 300 ms for RTU, 1 s for TCP and UDP modes |
DialTimeout |
network transports | 5 s for TCP and UDP, 15 s for TLS |
Speed, DataBits, Parity, StopBits |
rtu://, ascii:// |
19200 bps; 8 data bits (RTU) or 7 (ASCII); no parity; 2 stop bits without parity, 1 with |
TLSClientCert, TLSRootCAs |
tcp+tls:// |
required |
MinConns, MaxConns |
tcp, udp, rtuovertcp, rtuoverudp, asciiovertcp |
single connection |
Serial lines and go-serial
Section titled “Serial lines and go-serial”RTU and ASCII use go-serial underneath. You do not call it directly: the Speed, DataBits, Parity and StopBits fields are passed through. Serial device names are /dev/ttyUSB0 on Linux, /dev/cu.usbserial-1420 on macOS and COM3 on Windows.
go-serial is also usable on its own for non-Modbus serial work. It runs on Linux, macOS, Windows, FreeBSD, NetBSD and OpenBSD, offers optional RS-485 driver configuration (Config.RS485.Enabled, platform dependent), returns serial.ErrTimeout on read timeouts, and validates configuration at Open with a *serial.ConfigError. Its modbus subpackage has a 19200 8E1 preset, modbus.DefaultRTUConfig(address). Note that this preset (even parity, 1 stop bit) differs from the go-modbus serial defaults (no parity, 2 stop bits); both are valid Modbus RTU settings, so match the device.
Core operations
Section titled “Core operations”Every client method takes a context.Context first and the unit ID second. A context deadline overrides Timeout.
coils, err := client.ReadCoils(ctx, 1, 0, 8) // FC01in, err := client.ReadInputRegisters(ctx, 1, 0, 10) // FC04err = client.WriteCoil(ctx, 1, 8, true) // FC05err = client.WriteRegister(ctx, 1, 100, 1234) // FC06err = client.WriteRegisters(ctx, 1, 100, []uint16{10, 255, 65535}) // FC16
di, err := client.ReadAllDeviceIdentification(ctx, 1) // FC43/14for _, obj := range di.Objects { fmt.Printf("%s = %s\n", obj.Name, obj.Value)}Typed writes use the codec package: codec.WriteToClient(client, ctx, unitID, addr, value, enc). Common layouts are codec.Layout32_4321 (big-endian, ABCD) and codec.Layout32_2143 (word-swapped, CDAB), with 16, 48 and 64-bit equivalents. CODECS.md lists every codec with its stable ID.
The client also covers FC07, FC08 diagnostics, FC11, FC12, FC17, FC20, FC21, FC22 (MaskWriteRegister), FC23 (ReadWriteMultipleRegisters) and FC24. To find out what a unit supports, SupportsFunction(ctx, unitID, fc) probes one read-style function code, and ProbeFunction returns a ProbeResult with the outcome, exception code and raw payload.
SunSpec tier
Section titled “SunSpec tier”The sunspec subpackage detects the SunS marker and enumerates the model chain (model ID and length only). It reads through a one-method sunspec.Reader interface, so *modbus.Client needs a small adapter:
type reader struct{ client *modbus.Client }
func (r reader) ReadRawBytes(ctx context.Context, unitID uint8, addr, byteCount uint16, regType sunspec.RegType) ([]byte, error) { return r.client.ReadRegisterBytes(ctx, unitID, addr, byteCount, regType)}
result, err := sunspec.Discover(ctx, reader{client}, &sunspec.Options{UnitID: 1, RegType: sunspec.HoldingRegister})For decoded, scaled SunSpec points, use go-sunspec.
Server
Section titled “Server”The server supports tcp:// and tcp+tls:// only. You implement RequestHandler; the server owns framing, validation and exceptions.
type handler struct { mu sync.Mutex regs [65536]uint16}
func (h *handler) HandleHoldingRegisters(ctx context.Context, req *modbus.HoldingRegistersRequest) ([]uint16, error) { h.mu.Lock() defer h.mu.Unlock() if req.IsWrite { for i, v := range req.Args { h.regs[req.Addr+uint16(i)] = v } return nil, nil } out := make([]uint16, req.Quantity) copy(out, h.regs[req.Addr:]) return out, nil}
func (h *handler) HandleCoils(ctx context.Context, req *modbus.CoilsRequest) ([]bool, error) { return nil, modbus.ErrIllegalDataAddress}func (h *handler) HandleDiscreteInputs(ctx context.Context, req *modbus.DiscreteInputsRequest) ([]bool, error) { return nil, modbus.ErrIllegalDataAddress}func (h *handler) HandleInputRegisters(ctx context.Context, req *modbus.InputRegistersRequest) ([]uint16, error) { return nil, modbus.ErrIllegalDataAddress}
server, err := modbus.NewServer(&modbus.ServerConfig{URL: "tcp://[::]:5020", MaxClients: 20}, &handler{})if err != nil { log.Fatal(err)}if err := server.Start(); err != nil { log.Fatal(err)}defer server.Shutdown(context.Background())- Returning a Modbus sentinel such as
modbus.ErrIllegalDataAddresssends that exception. Any other error becomes Server Device Failure. Panics are recovered and also answered with Server Device Failure. - Each connection runs in its own goroutine, so handlers must be safe for concurrent use.
- FC07, FC11, FC12, FC22, FC23 and FC43/14 are served only if the handler also implements the optional interface (
ExceptionStatusHandler,CommEventCounterHandler,CommEventLogHandler,MaskWriteHandler,ReadWriteHandler,DeviceIdentificationHandler). Otherwise they get Illegal Function. FC08, FC17, FC20, FC21 and FC24 are not served. - The server does not filter unit IDs. That is the handler’s job (
req.UnitID). ServerConfig.Timeoutis the idle session timeout (default 120 s).MaxClientsdefaults to 10.- On TLS,
req.ClientRolecarries the role from the client certificate. Check it before serving writes.
Errors
Section titled “Errors”Errors fall into five categories. Use errors.Is and errors.As:
| Situation | Reported as |
|---|---|
| Bad configuration | *ConfigurationError (ErrConfigurationError) |
| Invalid arguments, nothing sent | *ParameterError (ErrUnexpectedParameters) |
| Malformed or unexpected response | *ProtocolError (ErrProtocolError), or ErrBadCRC, ErrBadLRC, ErrShortFrame |
| Device answered with an exception | *ExceptionError, matches ErrIllegalDataAddress and the other sentinels |
| Timeout | ErrRequestTimedOut |
| Connection lost or refused | standard library error (io.EOF, *net.OpError) |
_, err := client.ReadRegisters(ctx, 1, 0x9000, 10, modbus.HoldingRegister)var exc *modbus.ExceptionErrorswitch {case errors.As(err, &exc): log.Printf("device exception: fc=0x%02x code=0x%02x", exc.FunctionCode, exc.ExceptionCode)case errors.Is(err, modbus.ErrRequestTimedOut): log.Print("timeout")case err != nil: log.Print(err)}Treat a protocol error differently from an exception: the first means the link or the peer misbehaved, the second is a well-formed answer from the device.
Timeouts, retries and concurrency
Section titled “Timeouts, retries and concurrency”- Reconnects. An open client stays open. On
tcp://andtcp+tls://, a timeout, framing error or transport error closes that connection and the next request dials a new one. A Modbus exception does not. Serial-framed transports andudp://keep their link unless it is down. - Retries. Off by default.
modbus.ExponentialBackoff(base, maxDelay, maxAttempts)ormodbus.NewExponentialBackoff(cfg)retry only known transient transport errors. Timeouts are retried only withRetryOnTimeout: true. Exceptions and protocol errors are never retried. - Concurrency. A
*Clientis safe for concurrent use. WithMaxConnsof 0 or 1, requests are serialized on one connection. WithMaxConns > 1on a poolable transport, requests run in parallel on pooled connections. Serial and TLS always use one connection.
Logging and metrics
Section titled “Logging and metrics”Client logging is off by default. Set Config.Logger with modbus.NewStdLogger, modbus.NewSlogLogger, modbus.NewSlogFieldLogger or modbus.NopLogger(). Debug level can log raw TX and RX frames, including register values; treat those logs as sensitive. Note that for the server, a nil ServerConfig.Logger uses slog.Default().
Metrics are callbacks. Implement ClientMetrics (OnRequest, OnResponse, OnError, OnTimeout), optionally AttemptMetrics for per-attempt and re-dial events, or ServerMetrics, and assign it to the Metrics field. Callbacks run on the request path and must not block. client.Info() returns a snapshot for health checks.
Limitations
Section titled “Limitations”- The server is TCP and TLS only. There is no RTU or ASCII server.
- FC43 MEI type 13 (CANopen) is not supported.
- UDP transports are not in the Modbus specification and assume one request and one response per datagram with no loss or reordering. Validate them against your device before relying on them.
- The
sunspecsubpackage does not decode points or scale factors. - The interop suite covers Modbus TCP only. Other transports are covered by the repository’s own tests. The library has not been run against certified test equipment.