The Modbus Protocol Demystified

|

The world of IoT and industrial automation is massive, so many devices and communication protocols to learn. Though, if you had to learn one protocol and ONLY one protocol? I would easily suggest Modbus because it is one of the most widely supported protocols when working with PLCs, RTUs, and field devices.

High level overview of the Modbus protocol

Modbus is a communication protocol for sending data between devices. Modbus works as a communication protocol over the network, or through a direct wire connection that uses either 2 or 4 wires. Similar to HTTP, Modbus uses a request/response model, so you have a client that initiates a request, and a server that serves the request with a response. The response data is usually transmitted either as binary data or ASCII hex values. The response can also return error codes.

Modbus concepts

Unit ID

This is basically the identifier for a specific device in a Modbus network. Each Modbus server must have its own identifier in case there are multiple devices connected on a network and so each message can be routed to the correct device.

Function Code

This is a numerical code in a Modbus message that specifies the type of operation to perform. For example, a Function Code might indicate whether the operation should read from a coil, write to a register, or perform some other action. The Function Code doesn’t execute the command itself; instead, it categorizes what type of operation should be executed.

Addresses

Addresses serve as specific locations with a device’s memory where data is stored. These addresses are organized into different categories which determine what size and type of information they carry, as well as if they are read/write or read-only.

Data

This is the actual information that is being transmitted as part of a Modbus request or response. The data is usually transmitted in binary format, and once it reaches its destination is often transformed into an array of bytes for easier manipulation and interpretation by downstream systems like SCADA systems, PLCs, RTUs, or some kind of other device.

Sending and receiving Modbus

Modbus can be transmitted in different ways:

  • Modbus RTU: When connecting devices via serial wires.
  • Modbus TCP/IP: When connecting devices over Ethernet and/or the internet.
  • Modbus UDP/IP: Another way of connecting devices over a network. For brevity, we won’t go deep into how this one works.
  • Modbus RTU over TCP/IP: This is a hybrid approach that combines elements from the two previous methods. It allows for network connections while maintaining the message structures used in the RTU variant.

Modbus RTU

RTU stands for Remote Terminal Unit. In this context, an RTU is a device that can read/write to multiple different sensors/machines.

In the event that the RTU and sensors/devices all support Modbus RTU, you would typically have the RTU connected to each device/sensor serially via connectors like RS-232 or RS-485.

The RTU variation of the protocol is typically configured for communication over wires. This means that all devices need to be configured to communicate at the same baud rate, stop bits, parity bits, etc.

Modbus RTU has a specific “shape” it wants it’s requests to be sent over the wire:

Modbus TCP/IP

Now we’re communicating over a network, so things will either be plugged into each other via the LAN or via some kind of WAN. The main difference from the RTU variation is around the structure of the request and response. Specifically its lack of a dedicated CRC as part of the Modbus portion of the data frame, as it can leverage the checksum provided by the TCP layer.

Here is what a raw binary transmission might look like for Modbus TCP/IP:

While we might not be working with RS-232 and RS-485 connections, we’re still transmitting messages over wires. The specifics of the physical transmissions are abstracted from you, which is one of the nice benefits of working with TCP/IP.

Constructing a simple Modbus TCP/IP request

class ModbusRequest
{
    public function __construct(
        private int $transactionId,
        private int $unitId,
        private int $functionCode,
        private int $startAddress,
        private int $quantity
    ) {}

    public function buildRequest(): string
    {
        // The pack() function in PHP converts a value into a binary string
        
        $packet  = pack("n", $this->transactionId);
        $packet .= pack("n", 0); // The protocol ID should always be 0
        
        // Calculating the length: 
        // Unit ID (1 byte) + 
        // Function code (1 byte) +
        // Start address (2 bytes) + 
        // Number of addresses from starting (2 bytes)
        $length = 1 + 1 + 2 + 2;
        
        $packet .= pack("n", $length);
        $packet .= pack("C", $this->unitId);
        $packet .= pack("C", $this->functionCode);
        $packet .= pack("n", $this->startAddress);
        $packet .= pack("n", $this->quantity);

        return $packet;
    }
}

// Usage
$modbusRequestFactory = new ModbusRequestFactory(1, 6, 1, 3, 0, 10);

// Typically you send this over a raw TCP/IP socket
$requestAsBinaryStr = $modbusRequestFactory->buildRequest(); 

Modbus Responses

Now that you know about what the Modbus TCP/IP Data Frame looks like, you know the general shape that a response will take! If you want to get the value from the Modbus response you will have to interpret the bytes in the data section of the data frame.

Here is one example of how you would interpret those bytes:

<?php

namespace Bytes;

use function array_pad;

class BigEndianInterpreter extends Interpreter
{
    public function __construct(private readonly WordOrder $wordOrder) { }

    public function bytes_to_16_unsigned_integer(array $bytes): int
    {
        if (count($bytes) < 2) {
            $bytes = array_pad($bytes, -2, 0);
        } elseif (count($bytes) > 2) {
            $bytes = array_slice($bytes, count($bytes) - 2, 2);
        }

        $bytes = $this->sanitizeBytes($bytes);

        return $bytes[0] << 8 | $bytes[1];
    }

    public function bytes_to_32_unsigned_integer(array $bytes): int
    {
        if (count($bytes) < 4) {
            $bytes = array_pad($bytes, -4, 0);
        } elseif (count($bytes) > 4) {
            $bytes = array_slice($bytes, count($bytes) - 4, 4);
        }

        if ($this->wordOrder === WordOrder::REVERSED) {
            $bytes = $this->reverseWords($bytes);
        }

        $bytes = $this->sanitizeBytes($bytes);

        return $bytes[0] << 24 | $bytes[1] << 16 | $bytes[2] << 8 | $bytes[3];
    }
}

For the complete code snippet, you can check out this example repository on GitHub:

https://github.com/iPwnPancakes/simple-byte-interpreter

Conclusion

If you’ve gotten this far, I must give you my most genuine congratulations! Working with Modbus and raw binary strings is definitely a lesser-known skill set in the industry. It can get pretty hard to understand all the stuff necessary just to get a friggin 16 bit integer from a sensor!

You can see how this protocol fits into the large IoT picture via my other article here: