ModBus in Practice: From RS485 Buses to Secure, Scalable Automation

07 Apr 2026 - tsp
Last update 07 Apr 2026
Reading time 24 mins

Modern automation systems often appear deceptively complex. Fieldbuses, industrial protocols, cloud integrations, and proprietary stacks suggest a level of complexity that is often unnecessary for many real-world applications. At the core of many reliable automation systems, however, lies a much simpler idea: a shared communication medium with deterministic request–response semantics.

One of the most enduring implementations of this idea is ModBus, particularly in its RS485-based RTU variant. Despite its age, ModBus remains widely used in industrial control, laboratory environments, energy systems, and increasingly in small-scale automation such as homes, gardens, and greenhouses.

This article explores ModBus from a practical systems perspective. It focuses on my own RS485-based deployments, the challenges that arise when integrating such systems into modern software environments, and presents a set of tools I personally designed to bridge the gap between legacy fieldbus systems and contemporary infrastructure that I use with my own deployments, including a gateway service, a hardware implementation for Atmel AVR microcontrollers and a software client for Python applications and scripts.

Why ModBus Still Matters

What makes ModBus particularly interesting is not its feature set - but the absence of it. Its simplicity leads to robustness, debuggability and long-term stability. There are no hidden layers, no opaque negotiation steps, and no dynamic topology discovery. Everything is explicit and implementable by any manufacturer or hobbyist.

This simplicity extends all the way down to the physical and protocol layers, making ModBus exceptionally easy to implement even on very constrained hardware. Unlike CAN or Ethernet-based systems - which require more complex controllers, protocol stacks and often significantly more expensive interface hardware - ModBus RTU over RS485 can be realized on sub-Euro microcontrollers with minimal resources and very inexpensive transceiver ICs. This makes it particularly attractive for distributed sensing and control applications where cost, simplicity and reliability are more important than raw throughput.

Compared to more modern systems such as MQTT-based automation or high-performance fieldbuses like EtherCAT, ModBus trades flexibility and throughput for predictability, reliability and ease of implementation. In many environments - especially where timing constraints are moderate and reliability is critical - this tradeoff is highly desirable.

Limitations

While ModBus excels through its simplicity, this minimalism also imposes a number of practical limitations that must be considered when designing real-world systems.

One of the most prominent constraints is throughput. Especially in RS485-based RTU deployments, the achievable data rate is relatively low. At 9600 baud, effective payload throughput is on the order of only a few kilobytes per second, and even at higher baud rates the request–response nature of the protocol introduces unavoidable overhead. As the number of devices grows, polling cycles become longer, increasing latency for both control and monitoring tasks. Though for most simple monitoring and control tasks that operate more on the order of multiple seconds to minutes this does not matter.

Closely related to this is the strict master–slave model, which prevents concurrent access to the bus. All communication must be serialized and initiated by the master, and even read-only operations cannot be performed in parallel. This becomes increasingly problematic in modern systems where multiple independent services require access to the same data. Without an additional coordination layer, such as the gateway architecture presented later in this article, this leads to contention and non-deterministic behavior. In addition the requirement of the master initiating the communication prevents fast event notification from sensors, the time constraint is defined by the polling interval by the master.

Another limitation lies in the lack of higher-level protocol features. ModBus provides no built-in mechanisms for device discovery, configuration, or semantic description of data. Registers are purely numerical and their meaning is defined externally, often in device-specific documentation. This makes integration straightforward for simple systems, but increasingly complex as systems grow and heterogeneous devices are introduced.

From a security perspective, ModBus in its original form offers no authentication, no encryption, and no integrity protection beyond basic checksums. While this is acceptable in isolated industrial networks, it becomes a critical issue when systems are connected to larger infrastructures or exposed to untrusted environments.

Finally, although often described as deterministic, real-world ModBus systems can exhibit variable latency due to device response times, retries, and bus contention. Determinism exists primarily at the protocol level, but system-level timing guarantees depend heavily on implementation details and network design.

These limitations do not diminish the value of ModBus - in many cases, they are the direct consequence of its simplicity. However, they highlight the need for carefully designed system architectures when integrating ModBus into modern, distributed environments.

Physical Layer: RS485 in Practice

RS485 provides a differential signaling scheme that allows reliable communication over long distances and in electrically noisy environments. Unlike single-ended signaling, RS485 transmits the difference between two lines, making it highly resilient against common-mode noise. The following image shows the capture of the A line (yellow), the B line (turquoise) and the calculate difference (violet) of a RS485 transmission on a cheap USB oscilloscope

Example oscilloscope trace showing an RS485 transmission

Another important characteristic is its physical reach. At relatively low baud rates such as 9600, cable lengths of up to roughly 1400 meters are achievable on standard twisted-pair copper cabling without requiring fiber optics (note that you are usually using $0.75 \mathrm{mm}^2$ cabling with 4 poles for A, B, ground and DC supply voltage between 5 and 36V). Even at higher data rates like 115200 baud, distances on the order of 400 meters are still realistic within a single segment. This makes RS485 particularly attractive for distributed installations such as gardens, greenhouses, industrial halls, or laboratory environments where devices are spread across medium scale areas.

Typical deployments use a linear bus topology with termination resistors at both ends. Correct termination ($120 \Omega$ resistors) and biasing are essential to avoid reflections and undefined bus states. In practice, many issues attributed to protocol problems are in fact caused by improper physical layer implementation.

The bus is usually operated in half-duplex mode, meaning that only one device can transmit at a time. This leads directly to one of the central architectural constraints of ModBus RTU systems: arbitration.

There are, however, practical limits. While the protocol allows addressing up to 255 devices, real-world deployments are usually constrained by electrical loading of the bus. In many cases, a single RS485 segment supports on the order of 32-128 devices, depending on transceiver characteristics, bus loading, termination quality, and topology. Careful design - such as using repeaters or segmenting the bus - may be required for larger installations.

Protocol Layer: ModBus RTU

ModBus RTU operates on a strict master–slave model. A single master initiates all communication, while slaves only respond to requests addressed to them.

Frames are transmitted within timing constraints, including mandatory silent intervals that delimit frames (typically 3.5 character times), which is used as frame delemiter and to detect message boundaries. Each frame contains:

Even though timing is part of the protocol and slave implementations must be careful when interpreting silence periods, on the master side, typical implementations using hardware UARTs (for example in USB-to-RS485 adapters) do not impose strict timing constraints and are largely insensitive to operating system scheduling or buffering and thus easy to implement from the software side. Timing requirements are primarily relevant on the slave side, where frame detection depends on correct interpretation of inter-frame gaps.

The Real Problem: Multi-Master Access

ModBus RTU assumes a single master. In practice, modern systems often require multiple independent software components to access the same bus. These software architectures are typically composed of multiple loosely coupled services - as of today often following microservice principles - to improve modularity, scalability and maintainability through separation of concerns. In such environments, it is common that different services require access to the same physical devices for control, monitoring or logging purposes. Techniques such as Command Query Responsibility Segregation (CQRS) further emphasize this separation by distinguishing between control paths (commands that modify system state) and read paths (queries used for monitoring and reporting), which may also operate under different security constraints.

Without coordination, this leads to collisions, corrupted frames and undefined system behavior. Even if collisions are avoided, interleaving requests from multiple sources can break assumptions about timing and state. This mismatch between the original design and modern usage patterns is one of the key challenges when integrating ModBus into contemporary systems.

I resolved the problem for my personal deployments by developing modbusgw, presented in the next section.

Architecture: A Central ModBus Gateway

To resolve the multi-master problem, a gateway can be introduced. This gateway acts as the only physical master on the given RS485 bus, while exposing multiple logical interfaces to clients. Conceptually, the gateway acts as a serialization layer for bus access while exposing a parallel interface to clients.

The gateway performs:

It allows multiple applications to interact with the same physical buses safely and deterministically.

My Implementation: modbusgw

The presented solution, modbusgw, implements this gateway architecture in a modular fashion.

Frontends allow clients to access the service:

Backends connect to actual devices:

A routing layer in between allows mapping of device IDs and registers, as well as filtering requests. This enables the creation of security boundaries within the system and allows selective exposure of functionality to different sets of clients.

Installation

The gateway has been implemented in Python and is available on GitHub. It can be installed via it’s PyPi package:

pip install modbus-gateway

Configuration

The application is configured from a single JSON configuration file. By default it resides - when using the FreeBSD rc.init script - at /usr/local/etc/modbusgateway.cfg, when executing the program from the commandline the default configuration resides at ~/.config/modbusgateway.cfg. The location configuration file can be overriden via the --config flag or the modbusgw_config option in /etc/rc.conf

The configuration is split into different sections:

The service section configures PID file to prevent multiple running instances, the state directory that will be used for log- and tracefiles as well as the loglevel:

"service" : {
   "log_level" : "INFO",
   "pid_file" : "/var/run/modbusgw.pid",
   "state_dir" : "/var/modbusgw/",
   "reload_grace_seconds" : 5
}

The bus configuration configures the internal buffer for incoming requests that are routed to various backends:

"bus" : {
   "request_queue_size" : 64,
   "response_timeout_ms" : 1500
}

Note that this timeout should be shorter than the applications and frontends timeouts.

Frontend Configurations

Virtual Serial Ports (pty)

Virtual serial ports are directly accessible via pyserial and similar interfaces. This allows existing legacy software to access the gateway via unmodified code by pointing it at the virtual serial port file handles:

{
   "id" : "virtual_serial_rtu",
   "type" : "serial_rtu_socket",
   "socket_path" : "/var/modbusgw/ttyBus0",
   "pty_mode" : "rw",
   "idle_close_seconds" : 600,
   "frame_timeout_ms" : 5.0
}

The shown configuration instantiates a virtual serial port at the specified socket_path, allowing read-write transactions. The frame timeout handles incomplete messages on the application side. The name virtual_serial_rtu is an arbitrary chosen name that is used in the routing configuration.

ModBus IP TCP Socket

A ModBus IP socket speaks the ModBus IP protocol over an TCP socket (optionally supporting TLS or mTLS for authenticated sessions). The following configuration exposes unencrypted ModBus IP applying only IP subnet based filters:

{
   "id" : "frontend_tcp",
   "type" : "tcp_modbus_tcp",
   "host" : "192.0.2.1",
   "port" : 1234,
   "cidr_allow" : [
      "127.0.0.0/8",
      "192.0.2.0/24"
   ]
}

If TLS is desired the following configuration can be added to the frontend configuration object:

   "tls" : {
      "cert_file" : "/path/to/server.crt",
      "key_file" : "/path/to/server.key",
      "ca_file" : "/path/to/rootca.crt",
      "require_client_cert" : true,
      "client_dn_allow" : [
         "CN=ModbusGW Test Client"
      ]
   }

The cert_file and key_file establish the server identity. The ca_file is only used when require_client_cert is set to true to allow client authentication. The additional (optional) client_dn_allow filter allows to filter the DNs from valid certificates (after certificate validation) that are allowed to access the frontend.

Backend Configurations

Hardware Serial Ports

The pyserial backend uses the pyserial library to access an USB to RS485 based interface. This is the most simple hardware interface for DIY setups. The specified serial configuration is applied when accessing the backend. Again the arbitrary id is used in the routing configuration.

{
   "id" : "hardware_serial",
   "type" : "pyserial",
   "device" : "/dev/ttyU0",
   "baudrate" : 9600,
   "parity" : "N",
   "stop_bits" : 1,
   "request_timeout_ms" : 1200
}
ModBus IP via TCP

A TCP backend can be configured via the tcp_modbus backend:

{
   "id" : "tcp_backend",
   "type" : "tcp_modbus",
   "host" : "127.0.0.1",
   "port" : 1234,
   "connect_timeout" : 2.0,
   "pool_size" : 2,
   "use_tls" : true,
   "tls" : {
      "ca_file" : "/path/to/root.crt",
      "cert_file" : "/path/to/client.crt",
      "key_file" : "/path/to/client.key"
   }
}

The use_tls and tls blocks are optional and are only used when (m)TLS is desired. The root.crt is used for validation, the client keys for authentication via mTLS.

Routing Configuration

The routing configuration is provided as a list of routing commands that are matched against incoming requests from the frontends. The first match determines to which backend a message is routed. The backend key and the mirror_to_mqtt key is not used for matching, all other fields apply:

{
   "frontend" : "virtual_serial_rtu",
   "backend" : "hardware_serial",
   "match" : {
      "unit_ids" : [ "*" ],
      "function_codes" : [ "*" ]
   },
   "mirror_to_mqtt" : [ ]
}

The routing match block allows to filter given device IDs and function codes as well as operations. For example to allow only function code 1 (read coils) for the virtual device 5, redirecting the operation to the backend device id 1, one would use

{
   "frontend" : "virtual_serial_rtu",
   "backend" : "hardware_serial",
   "match" : {
      "unit_ids" : [ 5 ],
      "function_codes" : [ 1 ],
      "operations" : [ "read" ]
   },
   "unit_override" : 1,
   "mirror_to_mqtt" : [ ]
}

Here the match block specifies conditions that have to be fulfilled (all have to be fulfilled). The optional unit_override replaces the device ID on the virtual frontend bus to the given unit number before handing off the the backend device. All fields can be used in arbitrary combinations.

Example configuration file

The following configuration exposes a single serial to RS485 interface via a local virtual serial port as well as a ModBus IP socket available via unencrypted TCP:

{
   "service" : {
      "log_level" : "INFO",
      "pid_file" : "/var/run/modbusgw.pid",
      "state_dir" : "/var/modbusgw/",
      "reload_grace_seconds" : 5
   },
   "bus" : {
      "request_queue_size" : 64,
      "response_timeout_ms" : 1500
   },
   "frontends" : [
      {
         "id" : "virtual_serial_rtu",
         "type" : "serial_rtu_socket",
         "socket_path" : "/var/modbusgw/ttyBus0",
         "pty_mode" : "rw",
         "idle_close_seconds" : 600,
         "frame_timeout_ms" : 5.0
      },
      {
         "id" : "frontend_tcp",
         "type" : "tcp_modbus_tcp",
         "host" : "192.0.2.1",
         "port" : 1234,
         "cidr_allow" : [
            "127.0.0.0/8",
            "192.0.2.0/24"
         ]
      }
   ],
   "backends" : [
      {
         "id" : "hardware_serial",
         "type" : "pyserial",
         "device" : "/dev/ttyU0",
         "baudrate" : 9600,
         "parity" : "N",
         "stop_bits" : 1,
         "request_timeout_ms" : 1200
      }
   ],
   "routes" : [
      {
         "frontend" : "virtual_serial_rtu",
         "backend" : "hardware_serial",
         "match" : {
            "unit_ids" : [ "*" ],
            "function_codes" : [ "*" ]
         },
         "mirror_to_mqtt" : [ ]
      },
      {
         "frontend" : "frontend_tcp",
         "backend" : "hardware_serial",
         "match" : {
            "unit_ids" : [ "*" ],
            "function_codes" : [ "*" ]
         },
         "mirror_to_mqtt" : [ ]
      }
   ]
}

Launching and Controlling the Gateway

The gateway can be executed in foreground mode on the command line:

$ modbusgw

In addition it supports executing daemonized. To control the daemon the command line client supports the usual commands:

$ modbusgw start
$ modbusgw stop
$ modbusgw status
$ modbusgw restart
$ modbusgw reload

For usage on FreeBSD, my operating system of choice, one can use an rc.init script stored in /usr/local/etc/rc.d/modbusgw:

#!/bin/sh
# PROVIDE: modbusgw
# REQUIRE: LOGIN
# KEYWORD: shutdown

. /etc/rc.subr

name="modbusgw"
rcvar="modbusgw_enable"

load_rc_config $name

: ${modbusgw_enable:="NO"}
: ${modbusgw_command:="/usr/local/bin/modbusgw"}
: ${modbusgw_config:="/usr/local/etc/modbusgateway.cfg"}
: ${modbusgw_user:="modbusgw"}
: ${modbusgw_group:="modbusgw"}
: ${modbusgw_pidfile:="/var/run/modbusgw.pid"}
: ${modbusgw_var_dir:="/var/modbusgw"}
: ${modbusgw_log_file:="${modbusgw_var_dir}/modbusgw.log"}
: ${modbusgw_timeout:="15"}
: ${modbusgw_flags:=""}

command="${modbusgw_command}"
pidfile="${modbusgw_pidfile}"
required_files="${modbusgw_config}"
extra_commands="reload restart status"
start_cmd="${name}_start"
stop_cmd="${name}_stop"
reload_cmd="${name}_reload"
restart_cmd="${name}_restart"
status_cmd="${name}_status"

modbusgw_ensure_var_dir()
{
	if [ ! -d "${modbusgw_var_dir}" ]; then
		install -d -o "${modbusgw_user}" -g "${modbusgw_group}" -m 0750 "${modbusgw_var_dir}"
	else
		chown "${modbusgw_user}:${modbusgw_group}" "${modbusgw_var_dir}"
	fi
}

modbusgw_build_cmd()
{
	_subcmd="$1"
	shift
	_cmd="${command} -c \"${modbusgw_config}\" ${_subcmd}"
	if [ -n "${modbusgw_log_file}" ]; then
		_cmd="${_cmd} --log-file \"${modbusgw_log_file}\""
	fi
	for _arg in "$@"; do
		_cmd="${_cmd} ${_arg}"
	done
	if [ -n "${modbusgw_flags}" ]; then
		_cmd="${_cmd} ${modbusgw_flags}"
	fi
	echo "${_cmd}"
}

modbusgw_run()
{
	_cmd=$(modbusgw_build_cmd "$@")
	if [ "$(id -un)" = "${modbusgw_user}" ]; then
		/bin/sh -c "${_cmd}"
	else
		su -m "${modbusgw_user}" -c "${_cmd}"
	fi
}

modbusgw_start()
{
	modbusgw_ensure_var_dir
	modbusgw_run start
}

modbusgw_stop()
{
	modbusgw_run stop --timeout "${modbusgw_timeout}"
}

modbusgw_reload()
{
	modbusgw_run reload
}

modbusgw_restart()
{
	modbusgw_stop
	sleep 1
	modbusgw_start
}

modbusgw_status()
{
	modbusgw_run status
}

run_rc_command "$1"

Then the configuration happens as usual for this system via /etc/rc.conf:

modbusgw_enable="YES"
modbusgw_config="/usr/local/etc/modbusgateway.cfg"
modbusgw_user="modbusgw"
modbusgw_group="modbusgw"
modbusgw_pidfile="/var/modbusgw/modbusgw.pid"
modbusgw_var_dir="/var/modbusgw"
modbusgw_log_file="/var/modbusgw/modbusgw.log"

Control then is performed via the following commands:

$ /usr/local/etc/rc.d/modbusgw start
$ /usr/local/etc/rc.d/modbusgw stop
$ /usr/local/etc/rc.d/modbusgw status
$ /usr/local/etc/rc.d/modbusgw restart
$ /usr/local/etc/rc.d/modbusgw reload

Client Library

A corresponding client library, also available in the same GitHub repository and installable via a separate PyPi package modbusgw-client, provides a unified interface across different transports to Python applications and scripts. It supports:

This abstraction allows applications to switch between local and remote deployments without changes to application logic and without exposure to the actual protocol encoding.

A simple example

First, let’s install the package

$ pip install modbusgw-client

Now one can use the TcpClient or the SerialClient classes in a very simple fashion:

#!/usr/local/bin/python3

from time import sleep

from modbusgw_client.tcp_client import TcpClient
from modbusgw_client.serial_client import SerialClient
from modbusgw_client.pdu import WriteSingleCoilRequest, ReadCoilsRequest, ReadHoldingRegistersRequest

# TCP backend

with TcpClient(host="192.0.2.2", port=1234, timeout=10) as client:
   client.execute(WriteSingleCoilRequest(
      unit_id = 2, # The device ID on the virtual bus
      address = 5, # "Coil" index
      True # Coil status
   )

# Serial backend

with SerialClient("/var/modbusgw/ptyBus0", baudrate=9600, timeout=10) as client:
   client.execute(WriteSingleCoilRequest(
      unit_id = 2, # The device ID on the virtual bus
      address = 5, # "Coil" index
      True # Coil status
   )

Embedded Side: AVR Framework

On the device side, I developed a lightweight AVR-based ModBus framework, that allows implementation of custom ModBus slaves on cheap readily available Atmel ATMega microcontrollers.

The framework is particularly useful for:

One of my current application is the readout of a Infinicon PBR260 Pirani pressure gauge with analog output, making it accessible via ModBus in a vacuum system, as well as the readout of ultrasonic water level sensors for water management in a small garden setup.

The framework is available on GitHub. It is built with avr-gcc and targets the Atmel ATMega328P and ATMega2560. It allows easy interfacing with the coils, input registers, output registers and holding registers. Examples are provided in the GitHub repository.

Practical Applications

In practical deployments, a wide range of ModBus-capable devices can be integrated into a unified system.

Relay modules (e.g. 2, 8, or 32 channel units) can control loads such as pumps, valves, or lighting. Temperature and humidity sensors provide environmental monitoring. Soil sensors measure moisture and nutrient levels (NPK), enabling automated irrigation and fertilizing strategies.

Pulse counting modules allow integration of flow sensors, making it possible to monitor water usage as well as valve operation and failure. Combined with relay-controlled valves, this enables fully automated irrigation systems.

In home and lab environments, ModBus is frequently used to monitor HVAC systems, control of lights, control and monitoring of cooling loops, and monitoring of power consumption via smart meters. In laboratory setups, RS485-based systems are commonly used for devices like vacuum pumps and other slow control systems.

My Favorite Hardware Devices

My most favorite devices are:

Security Considerations

Raw ModBus/TCP has no authentication, no encryption, and no concept of access control. Exposing it directly to untrusted networks is inherently unsafe. ModBus/TCP should never be exposed outside of an isolated automation network or VLAN.

A gateway-based architecture enables:

Unix domain sockets provide an additional option for local communication with the gateway, avoiding unnecessary network exposure.

Design Philosophy

A key design principle is to keep the physical and protocol layers simple, while moving complexity into controlled software layers. The RS485 bus remains deterministic, easy to debug and especially very easy and cheap to implement. All advanced features - security, multiplexing, abstraction - are implemented in user space, where they can be maintained, audited, and evolved without affecting system stability.

This separation leads to systems that are both robust and flexible, combining the reliability of industrial fieldbuses with the capabilities of modern software architectures.

Conclusion

ModBus, and in particular its RS485-based RTU variant, demonstrates that simplicity is not a limitation but a design strength. Its minimalism allows it to remain understandable, debuggable and implementable across a wide range of systems - from industrial installations to small-scale home and laboratory setups.

At the same time, modern software architectures impose requirements that the original protocol was never designed to address. Multiple independent services, distributed systems, and stricter security expectations fundamentally conflict with the single-master assumption and lack of built-in protection mechanisms.

By introducing a central gateway layer, these two worlds can be reconciled. The physical bus remains simple, deterministic and reliable, while higher-level concerns such as arbitration, access control, transport abstraction and security are handled in user space. This separation allows systems to scale without sacrificing the robustness of the underlying fieldbus.

In practice, this approach enables a wide range of applications - from garden irrigation and environmental monitoring to laboratory instrumentation and energy management - using inexpensive hardware and straightforward implementations.

Rather than replacing ModBus with more complex alternatives, it is often more effective to embrace its simplicity and complement it with well-designed software layers. This combination provides a powerful foundation for building reliable, secure, and maintainable automation systems.

References

This article is tagged: Programming, Electronics, Hardware, DIY, RS485, Microcontroller, Home automation, Automation, ModBus


Data protection policy

Dipl.-Ing. Thomas Spielauer, Wien (webcomplainsQu98equt9ewh@tspi.at)

This webpage is also available via TOR at http://rh6v563nt2dnxd5h2vhhqkudmyvjaevgiv77c62xflas52d5omtkxuid.onion/

Valid HTML 4.01 Strict Powered by FreeBSD IPv6 support