Modbus

Modbus

Open in ChatGPT
Ask ChatGPT about this page
Open in Claude
Ask Claude about this page

ModBee Modbus

ModBee Modbus

Modbus that never blocks, and tells you the truth about what happened.

RTU, ASCII and TCP. Client and server. Arduino, Linux, macOS and Windows, from
one C++17 source tree.

#include <ModBeeModbus.h>

ModbusRTU mb;
uint16_t  setpoint = 500;

void setup() {
    Serial1.begin(19200, SERIAL_8E1);
    mb.begin(&Serial1, 19200);
    mb.slave(17);                 // answer as unit 17
    mb.addHreg(40, &setpoint);    // this variable IS holding register 40
}

void loop() {
    mb.poll();                    // the whole contract
}

One object. One poll(). Your variables are the registers.

A note on words: this library says client for the side that asks and
server for the side that answers. Older documents, and most device manuals,
say master and slave; they mean the same thing. The one place the old word
survives is slave(unit), the call that makes a serial node answer as a unit id,
because that is the word people search for.


Reading another device uses the same object

ModbusRTU mb;

uint16_t level    = 0;
uint16_t setpoint = 500;

void setup() {
    Serial1.begin(19200, SERIAL_8E1);
    mb.begin(&Serial1, 19200);
}

void loop() {
    mb.poll();

    mb.readIreg(17, 30, level);        // queues; `level` updates on a later poll
    mb.writeHreg(17, 40, setpoint);    // queues; only the value you settled on goes out
}

Call those every scan if you like. Nothing blocks, nothing stacks up, and
nothing is sent inside the call that made it.


If you have used a Modbus library before

You know the shape. What is different is what happens between your call and the
wire.

Most libraries ModBee
A read blocks until the answer or the timeout queues and returns; the answer lands in your variable
The same read twice two requests on the wire the second is refused as Duplicate
A setpoint written every scan a queue of every value you passed through only the value you settled on, and whoever was waiting on a replaced write is told
A request that fails returns an error, sometimes every accepted operation reaches exactly one conclusion
A 32-bit float you work out the word order mb.readFloat(17, 100, flow, ModbusOrder::CDAB), and probeOrder() when the data sheet does not say
A value that stops arriving looks exactly like one that has not changed quality() says Good, Uncertain, Bad or Unknown
RTU through a USB adapter splits frames, and nobody can say why length framing where it can; framingReason() explains the choice in a sentence
A gateway timing out a timeout 0x0A "no route" or 0x0B "the device is not there", so a client can tell them apart

Four objects, and that is the library

ModbusRTU    mb;   // RS-485 or RS-232
ModbusASCII  mb;   // the same, for links that cannot hold RTU's timing
ModbusTCP    mb;   // Ethernet or WiFi, client or server
ModbusBridge b;    // TCP in front, a serial segment behind

Each one can be client and server at once. A device that answers a SCADA system
and reads three sensors of its own is one object, not two.

Nothing underneath is hidden. mb.client(), mb.server(), mb.map(),
mb.link(), mb.poller() and mb.points() stay public and reachable, so the day
you need something the short form does not offer, you reach past it without
rewriting what you already have.


What it does for you

Between your call and the wire, with no configuration at all:

  • Queues the work. Operations queue as you make them. Nothing blocks and
    nothing is sent inside the call that made it.
  • Discards what is redundant. Queue the same read twice and the second is
    refused. Write the same value twice and so is that.
  • Sends only the latest. A control loop moving a setpoint faster than the
    bus can carry it sends the value it settled on. Whoever was waiting on the
    replaced write is told Superseded, not left hanging.
  • Concludes everything, exactly once. Success, refusal, timeout, superseded,
    queue full: every accepted operation reaches one conclusion and one only.
    That is what lets you open a slot per request and never stall it.
  • Times itself. The response timeout is measured per unit and includes the
    airtime the medium physically needs. A slow VFD and a fast I/O block on one
    bus each get the timeout they need, and a 125-register read on a 9600 baud
    line is not declared dead while it is still arriving.
  • Retries and reports. With backoff and jitter, so two clients on one bus do
    not stay in step.

There is nothing to tune. mb.timeout() and mb.retries() exist so you can
pin something a segment demonstrably needs; an object opened with no
configuration at all is a fully working one.


Knowing whether to believe a reading

A variable holding 1234 tells you nothing about whether 1234 is still true.
On a bus where one device has quietly failed, its last reading looks identical
to a healthy one.

size_t levelWatch;

void setup() {
    // ... begin() as above ...
    levelWatch = mb.watchIreg(17, 30, &level, 1, 500);   // said once: every 500 ms
}

void loop() {
    mb.poll();

    if (mb.quality(levelWatch) == ModbusQuality::Good) control(level);
    else                                               holdSafeState();
}

Unknown (never read at all: a commissioning problem) and Bad (read once and
now stale: a running fault) are deliberately different answers. An alarm list
that conflates them sends somebody to the wrong end of the plant.


Function codes

Code Function Client Server
0x01 Read Coils yes yes
0x02 Read Discrete Inputs yes yes
0x03 Read Holding Registers yes yes
0x04 Read Input Registers yes yes
0x05 Write Single Coil yes yes
0x06 Write Single Register yes yes
0x0F Write Multiple Coils yes yes
0x10 Write Multiple Registers yes yes
0x16 Mask Write Register yes yes
0x17 Read/Write Multiple Registers yes yes
0x2B / 0x0E Read Device Identification yes yes
0x07 Read Exception Status yes serial only
0x08 Diagnostics yes serial only
0x0B Get Comm Event Counter raw PDU serial only
0x0C Get Comm Event Log raw PDU serial only
0x11 Report Server ID yes serial only
0x18 Read FIFO Queue yes your handler
0x14 / 0x15 Read / Write File Record raw PDU your handler

"Serial only" follows the specification: those codes describe a shared serial
bus, so a TCP server refuses them with Illegal Function. "Raw PDU" means the
client sends it with client().forward(), which carries any request byte for
byte. "Your handler" means the server answers it through a function handler you
register, because what it reads (a file store, a FIFO) belongs to your
application.

Every exception code is reported too, including the two most libraries skip:
0x05 Acknowledge ("accepted, poll, do not retry") and 0x06 Busy ("retry
later"). They are different instructions, so they come back as different results.

0x16 Mask Write is worth calling out: it is the only atomic
read-modify-write Modbus has. Without it, two clients setting different bits of
one register erase each other's.


Platforms

Arduino 32-bit boards. library.properties declares esp32, esp8266, samd, mbed, rp2040, stm32 and renesas_uno; CI compiles every example for ESP32, ESP8266, SAMD (MKR WiFi 1010) and RP2040 (Pico) on every push.
Linux, macOS, Windows CMake 3.16 or later and a C++17 compiler. Built and verified on all three.

A link costs about 2 KB of RAM before any of your own data, so the 8-bit AVR
boards (Uno R3, Mega, Nano) are out of reach.

On Arduino, serial links take any Stream you have opened (Serial1, a
HardwareSerial on chosen pins). TCP works with any core's Client and
Server classes, WiFiClient and WiFiServer for example, through the small
adapters described in the API reference.


Install

Arduino IDE

Download this repository as a ZIP and add it with Sketch → Include Library →
Add .ZIP Library…
, or clone it into the libraries folder of your sketchbook.
Then:

#include <ModBeeModbus.h>

The Arduino sketches are in examples/arduino and appear in the IDE under
File → Examples → ModBee Modbus.

CMake

The library builds as a static library with the target ModBee::modbus.
Switch off its own examples when you include it in your project:

set(MODBUS_BUILD_EXAMPLES OFF)
add_subdirectory(modbee-modbus)

target_link_libraries(my_app PRIVATE ModBee::modbus)

Or fetch it at configure time:

include(FetchContent)
set(MODBUS_BUILD_EXAMPLES OFF)
FetchContent_Declare(modbee_modbus
    GIT_REPOSITORY https://github.com/mod-bee/modbee-modbus.git
    GIT_TAG        main)            # pin a release tag in production
FetchContent_MakeAvailable(modbee_modbus)

target_link_libraries(my_app PRIVATE ModBee::modbus)
Option Default
MODBUS_BUILD_EXAMPLES ON build the host examples in examples/cpp
MODBUS_SANITIZE OFF build with the address and undefined-behaviour sanitizers
MODBUS_SANITIZE_THREAD OFF build with the thread sanitizer (cannot be combined with the one above)

Try it with no hardware

cmake -S . -B build && cmake --build build -j
./build/examples/cpp/modbee_tcp_server 1502 &
./build/examples/cpp/modbee_tcp_client 127.0.0.1 1502

Documentation

Getting started From nothing to a working device, in order
Best practices How to keep a bus healthy, and why it matters
Recipes RTU over TCP, bridges, VFDs, control words, and the rest
API Every call, grouped by what you are trying to do
Architecture The five layers, and why each boundary is where it is

Examples

examples/arduino/RTU_Server A server on RS-485, with failsafe
examples/arduino/RTU_Client A client on RS-485
examples/arduino/TCP_Server An ESP32 serving several clients at once
examples/arduino/TCP_Client An ESP32 reading a device
examples/arduino/Polling_With_Quality Several sensors, and which readings to believe
examples/cpp/tcp_server, tcp_client Host, TCP
examples/cpp/rtu_server, rtu_client Host, serial
examples/cpp/gateway TCP in front, a serial segment behind

How it is verified

Against the Modbus specification's own worked examples, byte for byte; against a
simulated channel that loses, truncates, corrupts and duplicates frames; on a
virtual serial bus paced at the real line rate, the only honest test of RTU's
silence-based framing; against other implementations, including pymodbus and
libmodbus; and by compiling every Arduino example for the ESP32, ESP8266, SAMD
and RP2040 cores. The suite runs under the address, undefined-behaviour and
thread sanitizers. Architecture has a
little more.


Not for

Modbus TCP Security (TLS). Networks where you need more than 247 serial units.
Anything expecting Modbus to be real-time: it is a polled protocol and this
library does not pretend otherwise.

Contributing

Issues and pull requests are welcome. Please say how you verified any change in
behaviour. CI builds with -Wall -Wextra -Werror, so a new warning fails the
pull request.

Licence

Version 0.1.0. Released under the
GNU Affero General Public License v3.0, with additional terms set
out at the end of that file. Commercial use needs a separate commercial
licence: contact info@modbee.org.


Ask for the value. Get on with the application. Be told what happened.

Last updated 1 hour ago
Was this helpful?
Thanks!