Decoding MODBUS With Golang’s “Binary” Package

Tim Möhlmann
May 18, 2020 · 9 min read

MODBUS is a serial communication protocol which dates back to 1975. It is primarily used for industrial applications, like communications between workstation (SCADA), Human Machine Interfaces, PLC’s and many more. Modern day MODBUS supports TCP/IP communications which makes it highly attractive for use in “The internet of things”.

I assume most readers know what Golang is: a statically typed, compiled, high level programming language.

Writing a MODBUS driver

A long standing wish of mine was to write my own MODBUS driver. In my novice years as programmer, I’ve had an attempt in Java, but I couldn’t wrap my head around it. Too little knowledge and experience. Both then and now this project is for personal education and fun.

Now, 6 years later, in the COVID-19 self isolation and not much to do, I decided to get rid if long lasting disappointment within and give it another shot! I never proceeded to work with Java, so I went with Golang for this one.

In this post I’ll be writing about decoding of a TCP MODBUS message.

The protocol

The official specifications of the MODBUS protocol can be obtained from Lets start what a MODBUS message looks like.

First there is the Protocol Data Unit or PDU. It is used for both serial and TCP communication. It holds the actual MODBUS request or reply, but it does not carry any transport information. Every PDU starts with 1 byte containing the MODBUS function code, from there on the PDU can be fixed length (as specified by the spec for that function) or dynamic length. With dynamic length, there is some length information on a fixed location inside the message, which is not always the same place and size for all function codes.

Messages typically contain coils (outputs), discrete inputs and registers. Either to set or get. Coils and DIs are boolean values and registers are 16-bit / 2 byte words. MODBUS doesn’t care about a type system, so the sender and receiver have to agree what those 16 bits represent. Also, it is not uncommon to combine 2 registers to build a 32-bit integer or float. But I won’t get too deep into that.

For example, function 03 Read Holding Registers has a fixed size of 5 bytes. The request should contain the MODBUS function code 0x03, 16-bit unsigned integer with the starting address and another one with the quantity of registers we want to read:

The response starts with the same function code, a 8-bits number for the following byte count and a variable length of register values.

A PDU then needs to be packed in an Application Data Unit, or ADU. On TCP/IP the ADU consists of a MODBUS Application Protocol header, (MBAP header) and the PDU.

MODBUS Application Data Unit
MODBUS Application Data Unit

The MBAP header is always 7 bytes long. It contains information such as the Transaction ID (useful for asynchronous request mapping), message length information and an Unit Identifier, in case accessing devices behind Gateways.

Furthermore, the maximum size for PDU is 253 bytes. Plus 7 makes 260 bytes size limit for the ADU. The standard dictates numbers are Big-endian encoded. In practice this really depends on the manufacturer.

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:

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

Decoding a message

Define a struct which represents the MBAP header from above:

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:

Reading the MBAP: the clumsy way

  1. Create a slice of 7 bytes; this is the size of the MBAP header.
  2. Read into the byte slice; this will take 7 bytes from the Reader.
  3. Decode the bytes into the MBAP header fields, one by one.
  4. Use the Length field to create a new byte slice.
  5. 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.

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().

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

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.

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:

  1. We use a sync.Pool to reuse the PDU buffer. Deferred Reset and Put.
  2. Fill the buffer with PDU data by Read.
  3. Discard the Function Code and Byte count bytes.
  4. 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:

Even a single int64 or float64:

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!

The Startup

Get smarter at building your thing. Join The Startup’s +725K followers.

Medium is an open platform where 170 million readers come to find insightful and dynamic thinking. Here, expert and undiscovered voices alike dive into the heart of any topic and bring new ideas to the surface. Learn more

Follow the writers, publications, and topics that matter to you, and you’ll see them on your homepage and in your inbox. Explore

If you have a story to tell, knowledge to share, or a perspective to offer — welcome home. It’s easy and free to post your thinking on any topic. Write on Medium

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store