Skip to main content

ControlForge Modbus RTU Protocol Guide

James M. Belcher Founder, JMB Technical Services LLC April 2026 | ControlForge v1.0.533


1. Architecture Overview

ControlForge provides a full Modbus RTU implementation covering both client (master) and server (slave) roles over serial ports. The driver handles framing, CRC-16, inter-character timing (t1.5/t3.5), and automatic retransmission — your ST code works with named connections and typed registers, never raw bytes.

RoleFunctionsUse Case
Client (Master)MB_RTU_CONNECT / MB_RTU_READ_* / MB_RTU_WRITE_*Poll field devices — VFDs, power meters, sensors
Server (Slave)MB_RTU_SERVER_CREATE / MB_RTU_SERVER_SET_* / MB_RTU_SERVER_GET_*Expose ControlForge data to SCADA, HMI, or other masters

Both roles support RTU-over-TCP for long-distance serial tunneling (serial device servers, Moxa gateways, etc.).

System Diagram

RS-485 Wiring

ControlForge uses standard RS-485 half-duplex wiring via USB-to-RS485 adapters (FTDI, CH340, etc.):

SignalDescription
A (D-)Inverting — negative when idle
B (D+)Non-inverting — positive when idle
GNDSignal ground — always connect between devices

Termination: Add a 120-ohm resistor across A/B at both ends of the bus for runs over 10 meters or baud rates above 19200.

Bias: If the bus floats when no device is transmitting, add 680-ohm pull-up on B and pull-down on A to prevent ghost frames. Most USB adapters include on-board bias resistors.


2. Client Functions

2.1 Connection Management

MB_RTU_CONNECT — Open Serial Connection

ok := MB_RTU_CONNECT(name, device, baud);
ok := MB_RTU_CONNECT(name, device, baud, slave_id);
ok := MB_RTU_CONNECT(name, device, baud, slave_id, parity);
ParamTypeDescription
nameSTRINGConnection identifier (used by all subsequent calls)
deviceSTRINGSerial port path: /dev/ttyUSB0, /dev/ttyS1, COM3
baudINTBaud rate: 9600, 19200, 38400, 57600, 115200
slave_idINTDefault slave address 1-247 (optional, default 1)
paritySTRING'N' = none, 'E' = even, 'O' = odd (optional, default 'E')

Returns TRUE on success.

(* Connect to a VFD on /dev/ttyUSB0 at 9600 baud, slave 1, even parity *)
ok := MB_RTU_CONNECT('vfd', '/dev/ttyUSB0', 9600, 1, 'E');

(* Minimal form — defaults to slave 1, even parity *)
ok := MB_RTU_CONNECT('meter', '/dev/ttyUSB1', 19200);

Parity convention: Most Modbus RTU devices default to 8E1 (8 data bits, even parity, 1 stop bit). If the device uses 8N2 (no parity, 2 stop bits), pass 'N'. Both formats produce 11-bit character frames per the Modbus specification.

MB_RTU_CLOSE — Close Connection

ok := MB_RTU_CLOSE('vfd');

Releases the serial port. Returns TRUE on success.

MB_RTU_CONNECTED — Check Connection

connected := MB_RTU_CONNECTED('vfd');

Returns TRUE if the named connection is open and the serial port is accessible.

MB_RTU_SET_SLAVE — Change Target Slave

ok := MB_RTU_SET_SLAVE('bus', 3);

Changes the target slave address for subsequent read/write calls on the named connection. Use this when polling multiple devices on the same RS-485 bus through a single serial port.

ParamTypeDescription
nameSTRINGConnection name
slaveIDINTNew target slave address 1-247

MB_RTU_LIST — List Active Connections

names := MB_RTU_LIST();
(* Returns: ['vfd', 'meter'] *)

Returns an array of all active client connection names.


2.2 Read Functions

All read functions take a connection name, a starting register address, and a count. They return typed arrays.

MB_RTU_READ_HOLDING — FC03 Read Holding Registers

values := MB_RTU_READ_HOLDING(name, address, count);
ParamTypeDescription
nameSTRINGConnection name
addressINTStarting register address (0-based, 0-65535)
countINTNumber of registers to read (1-125)

Returns []INT — array of 16-bit unsigned register values.

(* Read 10 holding registers starting at address 0 *)
regs := MB_RTU_READ_HOLDING('vfd', 0, 10);

(* Read single register *)
speed := MB_RTU_READ_HOLDING('vfd', 8451, 1);

MB_RTU_READ_INPUT — FC04 Read Input Registers

values := MB_RTU_READ_INPUT(name, address, count);

Same signature as MB_RTU_READ_HOLDING. Input registers are read-only sensor/status values maintained by the device.

(* Read motor current from input register 0 *)
current := MB_RTU_READ_INPUT('vfd', 0, 1);

MB_RTU_READ_COILS — FC01 Read Coils

coils := MB_RTU_READ_COILS(name, address, count);
ParamTypeDescription
nameSTRINGConnection name
addressINTStarting coil address (0-based, 0-65535)
countINTNumber of coils to read (1-2000)

Returns []BOOL — array of coil states.

(* Read 8 coils starting at address 0 *)
coils := MB_RTU_READ_COILS('plc', 0, 8);
running := coils[0];
fault := coils[1];

MB_RTU_READ_DISCRETE — FC02 Read Discrete Inputs

inputs := MB_RTU_READ_DISCRETE(name, address, count);

Same signature as MB_RTU_READ_COILS. Discrete inputs are read-only digital status bits maintained by the device.

(* Read 16 discrete inputs *)
inputs := MB_RTU_READ_DISCRETE('io_module', 0, 16);

2.3 Write Functions

MB_RTU_WRITE_REGISTER — FC06 Write Single Register

ok := MB_RTU_WRITE_REGISTER(name, address, value);
ParamTypeDescription
nameSTRINGConnection name
addressINTRegister address (0-based)
valueINT16-bit value to write (0-65535)
(* Set VFD frequency setpoint to 3000 = 30.00 Hz *)
ok := MB_RTU_WRITE_REGISTER('vfd', 8451, 3000);

MB_RTU_WRITE_COIL — FC05 Write Single Coil

ok := MB_RTU_WRITE_COIL(name, address, value);
ParamTypeDescription
nameSTRINGConnection name
addressINTCoil address (0-based)
valueBOOLTRUE = ON (0xFF00), FALSE = OFF (0x0000)
(* Start VFD — write coil 0 ON *)
ok := MB_RTU_WRITE_COIL('vfd', 0, TRUE);

MB_RTU_WRITE_REGISTERS — FC16 Write Multiple Registers

ok := MB_RTU_WRITE_REGISTERS(name, address, values);
ParamTypeDescription
nameSTRINGConnection name
addressINTStarting register address
values[]INTArray of 16-bit values to write (max 123)
(* Write PID parameters: Kp=100, Ki=50, Kd=25 *)
ok := MB_RTU_WRITE_REGISTERS('controller', 100, [100, 50, 25]);

MB_RTU_WRITE_COILS — FC15 Write Multiple Coils

ok := MB_RTU_WRITE_COILS(name, address, values);
ParamTypeDescription
nameSTRINGConnection name
addressINTStarting coil address
values[]BOOLArray of coil states
(* Set outputs 0-3: ON, OFF, ON, ON *)
ok := MB_RTU_WRITE_COILS('io_module', 0, [TRUE, FALSE, TRUE, TRUE]);

2.4 Diagnostics

MB_RTU_STATS — Connection Statistics

stats := MB_RTU_STATS('vfd');

Returns a MAP with:

KeyTypeDescription
requestsINTTotal requests sent
responsesINTSuccessful responses received
errorsINTCRC errors + timeouts + exception responses
crc_errorsINTFrames with bad CRC-16
timeoutsINTNo response within timeout
last_errorSTRINGMost recent error description
avg_response_msREALAverage round-trip time
stats := MB_RTU_STATS('vfd');
(* stats['errors'] = 3, stats['avg_response_ms'] = 12.4 *)

MB_RTU_SCAN_BUS — Discover Devices

devices := MB_RTU_SCAN_BUS(name, startID, endID);
ParamTypeDescription
nameSTRINGConnection name (must be open)
startIDINTFirst slave ID to probe (1-247)
endIDINTLast slave ID to probe (1-247)

Returns []INT — array of slave IDs that responded. Sends a read request to each address and waits for a response.

(* Scan entire bus *)
devices := MB_RTU_SCAN_BUS('bus', 1, 247);
(* Returns: [1, 3, 17] — three devices found *)

Timing: A full 1-247 scan takes 30-60 seconds depending on timeout settings. Use narrower ranges when you know the expected address range.


3. Server Functions

The server role turns ControlForge into a Modbus slave device. External masters (SCADA, HMI, other PLCs) can read and write ControlForge's register tables. The server supports three transport modes: serial, RTU-over-TCP listener, and RTU-over-TCP client.

3.1 Server Lifecycle

MB_RTU_SERVER_CREATE — Create Server Instance

ok := MB_RTU_SERVER_CREATE(name, slave_id);
ok := MB_RTU_SERVER_CREATE(name, slave_id, baud);
ParamTypeDescription
nameSTRINGServer instance name
slave_idINTThis server's slave address (1-247)
baudINTBaud rate for serial mode (optional, default 9600)
ok := MB_RTU_SERVER_CREATE('srv', 1, 19200);

MB_RTU_SERVER_START_SERIAL — Listen on Serial Port

ok := MB_RTU_SERVER_START_SERIAL(name, device);
ParamTypeDescription
nameSTRINGServer instance name
deviceSTRINGSerial port path

Starts listening for master requests on the specified serial port.

ok := MB_RTU_SERVER_START_SERIAL('srv', '/dev/ttyUSB0');

MB_RTU_SERVER_START_TCP — Listen on TCP (RTU-over-TCP)

ok := MB_RTU_SERVER_START_TCP(name, addr);
ParamTypeDescription
nameSTRINGServer instance name
addrSTRINGListen address — host:port or :port

Starts a TCP listener that accepts RTU-framed connections. This is standard RTU framing (slave ID + function + data + CRC) transported over TCP instead of serial.

(* Listen on all interfaces, port 5020 *)
ok := MB_RTU_SERVER_START_TCP('srv', ':5020');

MB_RTU_SERVER_CONNECT_TCP — RTU-over-TCP Client

ok := MB_RTU_SERVER_CONNECT_TCP(name, addr);

Connects as a client to a remote RTU-over-TCP endpoint. The server responds to requests received over this TCP connection as if it were on a serial bus.

(* Connect to remote serial device server *)
ok := MB_RTU_SERVER_CONNECT_TCP('srv', '10.0.0.50:8502');

MB_RTU_SERVER_STOP — Stop Server

ok := MB_RTU_SERVER_STOP('srv');

Stops the server. Closes the serial port or TCP listener/connection.

MB_RTU_SERVER_IS_RUNNING — Check Server Status

running := MB_RTU_SERVER_IS_RUNNING('srv');

Returns TRUE if the server is actively listening for requests.

MB_RTU_SERVER_DELETE — Delete Server Instance

ok := MB_RTU_SERVER_DELETE('srv');

Stops (if running) and removes the server instance. Frees all associated register memory.

MB_RTU_SERVER_LIST — List Active Servers

servers := MB_RTU_SERVER_LIST();
(* Returns: ['srv', 'bridge'] *)

3.2 Register Access

The server maintains four register tables per the Modbus specification:

TableAddress RangeTypeAccessFunctions
Holding Registers40001-4999916-bit INTRead/WriteSetHolding, GetHolding
Input Registers30001-3999916-bit INTRead OnlySetInput
Coils00001-09999BOOLRead/WriteSetCoil, GetCoil
Discrete Inputs10001-19999BOOLRead OnlySetDiscrete

Addressing: All ControlForge functions use 0-based addressing. Register 40001 in Modbus documentation = address 0 in ControlForge. The driver handles the offset translation.

MB_RTU_SERVER_SET_HOLDING / GetHolding — Holding Registers

ok := MB_RTU_SERVER_SET_HOLDING(name, address, value);
val := MB_RTU_SERVER_GET_HOLDING(name, address);
ParamTypeDescription
nameSTRINGServer instance name
addressINTRegister address (0-based)
valueINT16-bit value (0-65535)
(* Expose motor speed to SCADA *)
MB_RTU_SERVER_SET_HOLDING('srv', 0, motor_rpm);

(* Read setpoint written by SCADA *)
target_rpm := MB_RTU_SERVER_GET_HOLDING('srv', 1);

MB_RTU_SERVER_SET_INPUT — Input Registers

ok := MB_RTU_SERVER_SET_INPUT(name, address, value);

Input registers are read-only from the master's perspective. Your ST code populates them; the master reads them via FC04.

(* Expose temperature reading *)
MB_RTU_SERVER_SET_INPUT('srv', 0, temp_raw);
MB_RTU_SERVER_SET_INPUT('srv', 1, pressure_raw);

MB_RTU_SERVER_SET_COIL / GetCoil — Coils

ok := MB_RTU_SERVER_SET_COIL(name, address, value);
state := MB_RTU_SERVER_GET_COIL(name, address);
ParamTypeDescription
nameSTRINGServer instance name
addressINTCoil address (0-based)
valueBOOLTRUE or FALSE
(* Expose pump running status *)
MB_RTU_SERVER_SET_COIL('srv', 0, pump_running);

(* Check if SCADA wrote a start command *)
start_cmd := MB_RTU_SERVER_GET_COIL('srv', 10);

MB_RTU_SERVER_SET_DISCRETE / GET_DISCRETE — Discrete Inputs

ok := MB_RTU_SERVER_SET_DISCRETE(name, address, value);
val := MB_RTU_SERVER_GET_DISCRETE(name, address);

Discrete inputs are read-only from the master's perspective (FC02). Your ST code updates them with SET and reads them back with GET.

(* Expose digital input states *)
MB_RTU_SERVER_SET_DISCRETE('srv', 0, limit_switch_1);
MB_RTU_SERVER_SET_DISCRETE('srv', 1, limit_switch_2);

(* Read back *)
val := MB_RTU_SERVER_GET_DISCRETE('srv', 0);

3.3 Server Diagnostics

MB_RTU_SERVER_STATS — Server Statistics

stats := MB_RTU_SERVER_STATS('srv');

Returns a MAP with:

KeyTypeDescription
requestsINTTotal requests received from master
responsesINTSuccessful responses sent
errorsINTMalformed requests + CRC errors
slave_idINTConfigured slave address
transportSTRING"serial", "tcp_listen", or "tcp_connect"

4. Examples

4.1 Bus Scanning — Discovering Devices

Before writing polling logic, scan the bus to find what is connected:

PROGRAM POU_BusScan
VAR
ok : BOOL;
devices : ARRAY[0..246] OF INT;
device_count : INT;
scan_done : BOOL := FALSE;
state : INT := 0;
i : INT;
END_VAR

CASE state OF
0: (* Open connection on the RS-485 port *)
ok := MB_RTU_CONNECT('scan', '/dev/ttyUSB0', 9600, 1, 'E');
IF ok THEN
state := 1;
END_IF;

1: (* Scan the bus — probe addresses 1 through 32 *)
devices := MB_RTU_SCAN_BUS('scan', 1, 32);
state := 2;

2: (* Log results *)
device_count := LEN(devices);
FOR i := 0 TO device_count - 1 DO
LOG('Found device at slave ID: ' + INT_TO_STRING(devices[i]));
END_FOR;
scan_done := TRUE;
state := 3;

3: (* Done — close or keep connection for polling *)
MB_RTU_CLOSE('scan');
state := 99;

99: (* Idle *)
;
END_CASE;
END_PROGRAM

Tip: Run the scan program once from the IDE, check the log output, then remove it. Scanning is a commissioning tool, not something to run every scan cycle.


4.2 Multi-Slave Polling — VFD + Power Meter on One Bus

This example polls two devices on the same RS-485 bus using MB_RTU_SET_SLAVE to switch between them each scan cycle:

PROGRAM POU_MultiSlavePoll
VAR
ok : BOOL;
connected : BOOL := FALSE;
state : INT := 0;

(* VFD data — slave ID 1 *)
vfd_speed_hz : INT; (* x0.01 Hz *)
vfd_current_a : INT; (* x0.1 A *)
vfd_fault : BOOL;

(* Power meter data — slave ID 3 *)
meter_voltage : INT; (* x0.1 V *)
meter_power_w : INT;

(* Polling state *)
poll_target : INT := 0; (* 0 = VFD, 1 = meter *)
stats : MAP;
regs : ARRAY[0..9] OF INT;
END_VAR

CASE state OF
0: (* Initialize — open serial port *)
ok := MB_RTU_CONNECT('bus', '/dev/ttyUSB0', 19200, 1, 'E');
IF ok THEN
state := 10;
END_IF;

10: (* Alternate between devices each scan *)
IF poll_target = 0 THEN
(* ---- Poll VFD (slave 1) ---- *)
MB_RTU_SET_SLAVE('bus', 1);

(* Read output frequency and current *)
regs := MB_RTU_READ_HOLDING('bus', 8451, 2);
vfd_speed_hz := regs[0];
vfd_current_a := regs[1];

(* Read fault coil *)
vfd_fault := MB_RTU_READ_COILS('bus', 0, 1)[0];

poll_target := 1;
ELSE
(* ---- Poll meter (slave 3) ---- *)
MB_RTU_SET_SLAVE('bus', 3);

(* Read voltage and power *)
regs := MB_RTU_READ_INPUT('bus', 0, 2);
meter_voltage := regs[0];
meter_power_w := regs[1];

poll_target := 0;
END_IF;
state := 10; (* loop *)

END_CASE;
END_PROGRAM

Scan time impact: Each Modbus transaction takes 10-50ms depending on baud rate and device response time. Polling two devices per cycle doubles your effective scan time. For faster updates, use a longer task interval and batch reads.


4.3 RTU-over-TCP Bridge — Serial to Network

This example creates a bridge between a physical RS-485 bus and a remote SCADA system. The ControlForge server exposes local device data over TCP, allowing a remote Modbus master to read the same registers without direct serial access.

PROGRAM POU_RtuBridge
VAR
ok : BOOL;
state : INT := 0;

(* Client — reads from local RS-485 devices *)
sensor_temp : INT;
sensor_pressure : INT;
pump_running : BOOL;

(* Server — exposes data over TCP *)
scada_setpoint : INT;
scada_start_cmd : BOOL;
END_VAR

CASE state OF
0: (* Initialize client — read from RS-485 bus *)
ok := MB_RTU_CONNECT('field', '/dev/ttyUSB0', 9600, 1, 'E');
IF ok THEN
state := 1;
END_IF;

1: (* Initialize server — expose data over TCP *)
ok := MB_RTU_SERVER_CREATE('bridge', 1, 9600);
IF ok THEN
ok := MB_RTU_SERVER_START_TCP('bridge', ':5020');
IF ok THEN
state := 10;
END_IF;
END_IF;

10: (* Running — poll field devices *)
(* Read temperature sensor at slave 1 *)
MB_RTU_SET_SLAVE('field', 1);
sensor_temp := MB_RTU_READ_INPUT('field', 0, 1)[0];

(* Read pressure sensor at slave 2 *)
MB_RTU_SET_SLAVE('field', 2);
sensor_pressure := MB_RTU_READ_INPUT('field', 0, 1)[0];

(* Read pump status coil at slave 3 *)
MB_RTU_SET_SLAVE('field', 3);
pump_running := MB_RTU_READ_COILS('field', 0, 1)[0];

state := 20;

20: (* Update server registers — SCADA reads these *)
MB_RTU_SERVER_SET_INPUT('bridge', 0, sensor_temp);
MB_RTU_SERVER_SET_INPUT('bridge', 1, sensor_pressure);
MB_RTU_SERVER_SET_COIL('bridge', 0, pump_running);

(* Read commands written by SCADA *)
scada_setpoint := MB_RTU_SERVER_GET_HOLDING('bridge', 0);
scada_start_cmd := MB_RTU_SERVER_GET_COIL('bridge', 10);

(* Apply SCADA commands to field devices *)
IF scada_start_cmd THEN
MB_RTU_SET_SLAVE('field', 3);
MB_RTU_WRITE_COIL('field', 0, TRUE);
END_IF;

state := 10; (* loop *)

END_CASE;
END_PROGRAM

RTU-over-TCP vs Modbus TCP: RTU-over-TCP wraps the exact same RTU frame (slave ID + function + data + CRC-16) inside a TCP stream. It is not Modbus TCP (which uses a MBAP header and no CRC). Many serial device servers (Moxa, USR, Waveshare) use RTU-over-TCP mode. If your master speaks Modbus TCP, use the MBTcp* functions instead.


4.4 Server-Only — Exposing ControlForge Data to HMI

A minimal server that exposes process values for an HMI to read:

PROGRAM POU_HmiServer
VAR
ok : BOOL;
state : INT := 0;

(* Process values updated elsewhere *)
tank_level : INT; (* 0-10000 = 0.0-100.0% *)
flow_rate : INT; (* x0.1 L/min *)
valve_pos : INT; (* 0-10000 = 0.0-100.0% *)
alarm_high : BOOL;
alarm_low : BOOL;
pump_enable : BOOL;
END_VAR

CASE state OF
0: (* Create and start server *)
ok := MB_RTU_SERVER_CREATE('hmi', 1, 19200);
IF ok THEN
ok := MB_RTU_SERVER_START_SERIAL('hmi', '/dev/ttyUSB1');
IF ok THEN
state := 10;
END_IF;
END_IF;

10: (* Update server registers every scan *)
(* Input registers — HMI reads these (FC04) *)
MB_RTU_SERVER_SET_INPUT('hmi', 0, tank_level);
MB_RTU_SERVER_SET_INPUT('hmi', 1, flow_rate);
MB_RTU_SERVER_SET_INPUT('hmi', 2, valve_pos);

(* Discrete inputs — HMI reads alarm states (FC02) *)
MB_RTU_SERVER_SET_DISCRETE('hmi', 0, alarm_high);
MB_RTU_SERVER_SET_DISCRETE('hmi', 1, alarm_low);

(* Coils — HMI can read and write pump enable (FC01/FC05) *)
pump_enable := MB_RTU_SERVER_GET_COIL('hmi', 0);

state := 10; (* loop *)

END_CASE;
END_PROGRAM

5. Modbus RTU Protocol Notes

5.1 Framing

Every Modbus RTU frame on the wire:

┌──────────┬──────────┬───────────────┬──────────┐
│ Slave ID │ Function │ Data (0-252) │ CRC-16 │
│ 1 byte │ 1 byte │ N bytes │ 2 bytes │
└──────────┴──────────┴───────────────┴──────────┘
  • CRC-16 (polynomial 0xA001) is appended LSB first
  • Inter-frame gap: 3.5 character times of silence delimits frames
  • Inter-character timeout: 1.5 character times max between bytes within a frame
  • ControlForge handles all timing, CRC generation, and validation automatically

5.2 Function Code Summary

FCNameClient FunctionDirection
01Read CoilsMB_RTU_READ_COILSMaster reads slave coils
02Read Discrete InputsMB_RTU_READ_DISCRETEMaster reads slave inputs
03Read Holding RegistersMB_RTU_READ_HOLDINGMaster reads slave registers
04Read Input RegistersMB_RTU_READ_INPUTMaster reads slave registers
05Write Single CoilMB_RTU_WRITE_COILMaster writes slave coil
06Write Single RegisterMB_RTU_WRITE_REGISTERMaster writes slave register
15Write Multiple CoilsMB_RTU_WRITE_COILSMaster writes slave coils
16Write Multiple RegistersMB_RTU_WRITE_REGISTERSMaster writes slave registers

5.3 Exception Responses

When a slave cannot fulfill a request, it returns an exception response (function code + 0x80):

CodeNameMeaning
0x01Illegal FunctionFunction code not supported
0x02Illegal Data AddressRegister address out of range
0x03Illegal Data ValueValue outside allowed range
0x04Slave Device FailureUnrecoverable error on the device
0x06Slave Device BusyDevice is processing a long-running command

ControlForge logs exception responses and increments the errors counter in MB_RTU_STATS. The last_error field contains the decoded exception name.

5.4 Addressing Conventions

Modbus documentation uses 1-based register numbers with a leading digit indicating the table:

DocumentationTableControlForge AddressFunction
00001-09999Coils0-9998MB_RTU_READ_COILS / MB_RTU_WRITE_COIL
10001-19999Discrete Inputs0-9998MB_RTU_READ_DISCRETE
30001-39999Input Registers0-9998MB_RTU_READ_INPUT
40001-49999Holding Registers0-9998MB_RTU_READ_HOLDING / MB_RTU_WRITE_REGISTER

Always subtract the table prefix and 1. Documentation register 40001 = ControlForge address 0. Register 40100 = address 99. This is the most common source of off-by-one errors.

5.5 32-Bit Values

Modbus registers are 16 bits. 32-bit values (IEEE 754 float, DINT) occupy two consecutive registers. Word order varies by manufacturer:

(* Big-endian word order (most common): high word first *)
regs := MB_RTU_READ_HOLDING('meter', 0, 2);
value_32 := regs[0] * 65536 + regs[1];

(* Little-endian word order (some devices): low word first *)
regs := MB_RTU_READ_HOLDING('meter', 0, 2);
value_32 := regs[1] * 65536 + regs[0];

Check the device manual. There is no standard for 32-bit word order. Some devices use big-endian (AB CD), others use little-endian (CD AB), and a few use byte-swapped (BA DC or DC BA). Get it wrong and your floats will read as garbage.


6. Troubleshooting

Common Issues

SymptomCauseFix
All reads timeoutWrong baud rate or parityMatch device settings exactly — check with MB_RTU_SCAN_BUS
CRC errors in statsElectrical noise, wrong parityAdd termination resistors, verify wiring, check parity setting
Intermittent timeoutsBus contention, slow deviceIncrease timeout, reduce poll frequency
Wrong register valuesAddress offset errorSubtract table prefix and 1 from documentation address
"Illegal Data Address"Register out of device rangeCheck device register map
Reads work, writes failDevice in read-only modeCheck device write-enable DIP switch or configuration register

Diagnostic Workflow

(* Step 1: Check if connected *)
IF NOT MB_RTU_CONNECTED('dev') THEN
LOG('Serial port not open');
END_IF;

(* Step 2: Check error stats *)
stats := MB_RTU_STATS('dev');
LOG('Requests: ' + INT_TO_STRING(stats['requests']));
LOG('Errors: ' + INT_TO_STRING(stats['errors']));
LOG('CRC Errors: ' + INT_TO_STRING(stats['crc_errors']));
LOG('Timeouts: ' + INT_TO_STRING(stats['timeouts']));
LOG('Last Error: ' + stats['last_error']);
LOG('Avg Response: ' + REAL_TO_STRING(stats['avg_response_ms']) + ' ms');

(* Step 3: Scan bus to verify device is responding *)
devices := MB_RTU_SCAN_BUS('dev', 1, 10);
LOG('Devices found: ' + INT_TO_STRING(LEN(devices)));

Linux Serial Port Permissions

If MB_RTU_CONNECT fails on Linux, the ControlForge process needs access to the serial device:

# Add user to dialout group (persistent)
sudo usermod -a -G dialout controlforge

# Or set permissions directly (resets on reboot)
sudo chmod 666 /dev/ttyUSB0

udev rules: For production deployments, create a udev rule to assign a stable device name and permissions. USB serial adapters can change between /dev/ttyUSB0 and /dev/ttyUSB1 on reboot.


Appendix A: Client Function Quick Reference

FunctionReturnsFCDescription
MB_RTU_CONNECT(name, device, baud [, slave_id] [, parity])BOOLOpen serial connection
MB_RTU_CLOSE(name)BOOLClose connection
MB_RTU_CONNECTED(name)BOOLCheck connection status
MB_RTU_SET_SLAVE(name, slaveID)BOOLChange target slave address
MB_RTU_READ_COILS(name, addr, count)[]BOOL01Read coil outputs
MB_RTU_READ_DISCRETE(name, addr, count)[]BOOL02Read discrete inputs
MB_RTU_READ_HOLDING(name, addr, count)[]INT03Read holding registers
MB_RTU_READ_INPUT(name, addr, count)[]INT04Read input registers
MB_RTU_WRITE_COIL(name, addr, value)BOOL05Write single coil
MB_RTU_WRITE_REGISTER(name, addr, value)BOOL06Write single register
MB_RTU_WRITE_COILS(name, addr, values)BOOL15Write multiple coils
MB_RTU_WRITE_REGISTERS(name, addr, values)BOOL16Write multiple registers
MB_RTU_STATS(name)MAPConnection statistics
MB_RTU_SCAN_BUS(name, startID, endID)[]INTDiscover devices on bus
MB_RTU_LIST()[]STRINGList active connections

Appendix B: Server Function Quick Reference

FunctionReturnsDescription
MB_RTU_SERVER_CREATE(name, slave_id [, baud])BOOLCreate server instance
MB_RTU_SERVER_START_SERIAL(name, device)BOOLListen on serial port
MB_RTU_SERVER_START_TCP(name, addr)BOOLListen on TCP (RTU-over-TCP)
MB_RTU_SERVER_CONNECT_TCP(name, addr)BOOLConnect as RTU-over-TCP client
MB_RTU_SERVER_STOP(name)BOOLStop server
MB_RTU_SERVER_IS_RUNNING(name)BOOLCheck server status
MB_RTU_SERVER_SET_HOLDING(name, addr, value)BOOLWrite holding register
MB_RTU_SERVER_GET_HOLDING(name, addr)INTRead holding register
MB_RTU_SERVER_SET_INPUT(name, addr, value)BOOLWrite input register
MB_RTU_SERVER_SET_COIL(name, addr, value)BOOLWrite coil
MB_RTU_SERVER_GET_COIL(name, addr)BOOLRead coil
MB_RTU_SERVER_SET_DISCRETE(name, addr, value)BOOLWrite discrete input
MB_RTU_SERVER_GET_DISCRETE(name, addr)BOOLRead discrete input
MB_RTU_SERVER_STATS(name)MAPServer statistics
MB_RTU_SERVER_DELETE(name)BOOLDelete server instance
MB_RTU_SERVER_LIST()[]STRINGList active servers

ControlForge v1.0.533 | Modbus RTU: Client (15 functions) + Server (15 functions) Supports: RS-485 serial, USB-serial adapters, RTU-over-TCP

(c) 2026 JMB Technical Services LLC. All rights reserved. Back to All Guides