Why Modbus Still Dominates Industrial IoT
Introduced in 1979, Modbus is still the most widely deployed industrial protocol. Every PLC, VFD, energy meter, and industrial sensor you will encounter in manufacturing likely speaks Modbus. It's simple, deterministic, and supported everywhere.
The Go ecosystem has solid Modbus support — but real hardware has quirks that tutorials never mention.
Basic Modbus TCP Client in Go
goimport "github.com/goburrow/modbus" func newModbusClient(host string, port int) (modbus.Client, error) { handler := modbus.NewTCPClientHandler(fmt.Sprintf("%s:%d", host, port)) handler.Timeout = 5 * time.Second handler.SlaveId = 1 // Unit ID — varies per device if err := handler.Connect(); err != nil { return nil, fmt.Errorf("modbus connect: %w", err) } return modbus.NewClient(handler), nil }
Reading Registers
Modbus has four data tables. You need to know which your device uses:
go// Holding Registers (most common — read/write) results, err := client.ReadHoldingRegisters(0, 10) // address 0, count 10 // Input Registers (read-only sensor data) results, err = client.ReadInputRegisters(100, 5) // Coils (digital outputs) coils, err := client.ReadCoils(0, 8) // Discrete Inputs (digital inputs) inputs, err := client.ReadDiscreteInputs(0, 8)
Decoding Register Values
Raw Modbus register values are 16-bit unsigned integers. Real values need conversion:
gofunc decodeFloat32(registers []byte, offset int) float32 { // Big-endian 32-bit float across two 16-bit registers raw := binary.BigEndian.Uint32(registers[offset*2 : offset*2+4]) return math.Float32frombits(raw) } func decodeInt16(registers []byte, offset int) int16 { return int16(binary.BigEndian.Uint16(registers[offset*2 : offset*2+2])) } func decodeUint32(registers []byte, offset int) uint32 { return binary.BigEndian.Uint32(registers[offset*2 : offset*2+4]) }
Word order matters — some devices use Big-Endian, others Little-Endian, and some use Mid-Big or Mid-Little (yes, really). Always check the device datasheet.
Concurrent Multi-Device Poller
gotype DevicePoller struct { devices []DeviceConfig out chan<- Metric } func (p *DevicePoller) Run(ctx context.Context) error { g, ctx := errgroup.WithContext(ctx) for _, dev := range p.devices { dev := dev g.Go(func() error { return p.pollDevice(ctx, dev) }) } return g.Wait() } func (p *DevicePoller) pollDevice(ctx context.Context, dev DeviceConfig) error { client, err := newModbusClient(dev.Host, dev.Port) if err != nil { return err } ticker := time.NewTicker(dev.Interval) defer ticker.Stop() for { select { case <-ctx.Done(): return nil case <-ticker.C: regs, err := client.ReadHoldingRegisters(dev.StartAddr, dev.Count) if err != nil { // Log and continue — hardware blips are normal log.Printf("device %s read error: %v", dev.ID, err) continue } for i, reg := range dev.RegisterMap { p.out <- Metric{ DeviceID: dev.ID, Tag: reg.Name, Value: reg.Decode(regs, i), Time: time.Now(), } } } } }
Production Quirks
- Don't share a Modbus connection across goroutines — Modbus TCP is request-response, not multiplexed. One goroutine per connection.
- Hardware timeouts are aggressive — 5 seconds is generous; most PLCs will timeout at 3s. Reconnect logic is mandatory.
- Unit ID (Slave ID) 255 — Some gateways use 255 as a broadcast address. Test with the actual device.
- Register address offset — Modbus addresses are 0-indexed in protocol but some tools show them 1-indexed. Address 40001 in documentation = register 0 in code.
Key Takeaways
- Modbus TCP is simple — the complexity is in the device datasheets, not the protocol
- One connection per device — never share connections across goroutines
- Always implement reconnect — hardware drops connections; your code must recover silently
- Decode carefully — word order and data type vary by manufacturer