Decoding MODBUS With Golang’s “Binary” Package
By: Tim Möhlmann
Tags: Go, Tcp, Binary, Modbus, Encoding

Decoding a TCP message

Let’s start with reading the MBAP header and than continue on the PDU. I’m using Go’s net.Conn connection type for the TCP connections. net.Conn is a io.ReadWriteCloser . ADU’s don’t have a message boundary, and we are not closing the TCP connection after every request. That would be highly inefficient. Instead, we need get the Length field as soon as we can and then read up to that amount of data.

For the example code I’ll be using a bytes.Buffer in place of net.Conn , which also implements the io.ReadWriteCloser interface and allows us to inspect the intermediate content.

First, let’s define a response which we will be decoding:

// Remember: Big-endian values!

var responseADU = []byte{

// MBAP HEADER:

0x00, 0xFF, // Tx ID #255, typically an incremental value

0x00, 0x00, // Protocol ID, always 0

0x00, 0x0B, // Length of the following data (8+3=11)

0x00, // Unit identifier, 0 unless a Gateway is used // PROTOCOL DATA UNIT:

0x03, // Function Code 03: Read Holding Registers

0x08, // Byte count of the following data (4*2=8)

0x00, 0x0A, // First register; value 10

0x0A, 0x00, // Second register; value 2560

0xFF, 0xFF, // Third register; value 65535 or -1 when signed.

0x00, 0x00, // Fourth register; value 0

}

For demonstration purposes, I’m also defining a small print function. This way we can check the intermediate state of the buffer.:

func printBuffer(b *bytes.Buffer) {

fmt.Printf("Length: %d; Values: %# 02X

", b.Bytes())

}

Decoding a message

Define a struct which represents the MBAP header from above:

type mbap struct {

TxID uint16

ProtoID uint16

Length uint16

UnitID uint8

}

Now we are going to write a function which will strip the header from the ADU, and returns the remaining PDU, still encoded. The function takes an io.Reader and a binary.ByteOrder as arguments.

A ByteOrder specifies how to convert byte sequences into 16-, 32-, or 64-bit unsigned integers.

The binary package comes with two predefined ByteOrder variables: binary.BigEndian and binary.LittleEndian :

BigEndian is the big-endian implementation of ByteOrder. LittleEndian is the little-endian implementation of ByteOrder.

But in some cases you might need to role your own. For those interested, this is a nice article on byte swapping in MODBUS: https://store.chipkin.com/articles/how-real-floating-point-and-32-bit-data-is-encoded-in-modbus-rtu-messages

Reading the MBAP: the clumsy way

Create a slice of 7 bytes; this is the size of the MBAP header. Read into the byte slice; this will take 7 bytes from the Reader. Decode the bytes into the MBAP header fields, one by one. Use the Length field to create a new byte slice. Read the PDU into the slice; this will the remaining bytes from the Reader.

You will see that the actual length of the PDU is mbap.Length — 1 . This is because the spec mentions that the UnitID is included in this count.

// ReadClumsy a response PDU from the connection.

// strips the MBAP header and returns only the PDU

func ReadClumsy(conn io.Reader, decoder binary.ByteOrder) ([]byte, error) {

bs := make([]byte, mbapHeaderSize) if n, err := conn.Read(bs); err != nil {

return nil, err

} else if n < mbapHeaderSize {

return nil, errors.New("Insufficient header data")

} header := mbap{

TxID: decoder.Uint16(bs[:2]),

ProtoID: decoder.Uint16(bs[2:4]),

Length: decoder.Uint16(bs[4:6]),

UnitID: uint8(bs[6]),

} // Contents of header at this stage

fmt.Printf("Header :: %+v

", header)

// State of the Read buffer

printBuffer(conn.(*bytes.Buffer)) // Substract the UnitID field

pl := header.Length - 1

bs = make([]byte, pl) // Read the remaining portion

if n, err := conn.Read(bs); err != nil {

return nil, err

} else if n < mbapHeaderSize {

return nil, errors.New("Insufficient PDU data")

} // Read buffer is now empty

printBuffer(conn.(*bytes.Buffer))

// All the values are now in the byte slice

fmt.Printf("PDU :: %# 02X

", bs) return bs, nil

}

Decoding the PDU: the clumsy way

Normally, the ReadHoldingsRegister() function would first send a request and wait for the resulting response. In this case we just just the resulting PDU from ReadClumsy() .

Obtain the PDU Check de function code in the first byte in the PDU. Create a slice of uint16 with length of Byte Count, PDU byte 2. Iterate over values, decoding sub slices of 2 bytes from the PDU.

// ReadHoldingRegistersClumsy obtains and decodes a PDU with a function 03 reply format.

func ReadHoldingRegistersClumsy(conn io.ReadWriter, decoder binary.ByteOrder) ([]uint16, error) {

pdu, err := ReadClumsy(conn, decoder)

if err != nil {

return nil, err

} // First byte is function code

if pdu[0] != 0x03 {

return nil, fmt.Errorf("Unexpected response Function Code: %# 03x", pdu[0])

} // Second byte is Byte count of the remaining data

values := make([]uint16, pdu[1]/2) for i := 0; i < len(values); i++ {

// Subslice start at offset 2, for function code and byte count

// up to index 4 gives 2 bytes.

// Add i*2 to advance 2 bytes for every uint16 iteration.

values[i] = decoder.Uint16(pdu[2+i*2 : 4+i*2])

} fmt.Printf("Values :: %d

", values) return values, nil

}

A mayor drawback of this method is that it can only decode to uint16 values. Potentially giving the caller more tasks in forming the result if alternative types are desired.

Write less, do more

Up till now, this was a fun journey in understanding the MODBUS binary protocol encoding. Somehow, I missed this when looking for ways of decoding, but check out binary.Read !

This nifty tool can decode data from an io.Reader into a fixed-width destination, including structs. And the best part: it only reads the bytes it needs to populate data! The rest of the Reader’s content remains available for later use. So let’s redefine our Read function.

// Read a response PDU from the connection and Copy it into dest.

func Read(conn io.Reader, order binary.ByteOrder, dest *bytes.Buffer) error {

var header mbap if err := binary.Read(conn, order, &header); err != nil {

return err

} // Contents of header at this stage

fmt.Printf("Header :: %+v

", header)

// State of the Read buffer

printBuffer(conn.(*bytes.Buffer)) // Substract the UnitID field and Copy the remaining portion

if _, err := io.CopyN(dest, conn, int64(header.Length)); err != nil {

return err

} fmt.Printf("Dest PDU :: %# 02X

", dest.Bytes())



return nil

}

Now, that’s way cleaner and most certainly less clumsy and fragile! If you take out the informative print statements, there are only 2 calls happening to obtain the same results.

The first improvement is direct decoding into the mbap header struct. No more sub-slicing.

As a second improvement, this function now takes a bytes.Buffer to Copy the remaining PDU data into. We don’t have to allocate memory for intermediate slices. Additionally, the caller could reuse the buffer. For instance, by using a sync.Pool .

By using io.CopyN , we no longer need to check the read data length. io.CopyN only returns after the required amount is copied or if an error occurs.

Let’s rewrite ReadHoldingRegisters :

var bufPool = sync.Pool{

New: func() interface{} { return new(bytes.Buffer) },

} // ReadHoldingRegisters obtains and decodes a PDU with a function 03 reply format.

// The result is scanned into dest, which should be a slice of fixed-width types.

// See binary.Read() documentation.

func ReadHoldingRegisters(conn io.ReadWriter, order binary.ByteOrder, dest interface{}) error {

buf := bufPool.Get().(*bytes.Buffer)

defer func() {

buf.Reset()

bufPool.Put(buf)

}() if err := Read(conn, order, buf); err != nil {

return err

} // Discard the Function Code and Byte Count, we don't need them.

buf.Next(2) if err := binary.Read(buf, order, dest); err != nil {

return fmt.Errorf("Decode PDU: %w", err)

} return nil

}

We use a sync.Pool to reuse the PDU buffer. Deferred Reset and Put. Fill the buffer with PDU data by Read . Discard the Function Code and Byte count bytes. Use binary.Read to decode the PDU into the destination interface, which can be any fixed-width type or a slice thereof.

For instance, we can now decode to signed int16 :

dest := make([]int16, 4)

if err := ReadHoldingRegisters(conn, binary.BigEndian, dest); err != nil {

log.Fatal(err)

}

Even a single int64 or float64 :

var n int64

ReadHoldingRegisters(conn, binary.BigEndian, &n) var fl float64

ReadHoldingRegisters(conn, binary.BigEndian, &fl)

The flipside of the last destination scanning, is that the caller has to assume the response length based on the requested length. I’m not too sure if devices are always returning the same count as the the request. So in production you probably don’t want to discard the Byte Count and do some sanity checks.

Final thoughts

The complete program, with al the variations can be ran at the Go playground. In the bottom of this article I will also share a Gist with the code.

This work is part of the MODBUS driver and IO server that I’m writing, just for fun. But perhaps it will grow out to something useful worth publishing!