Skip to main content

ControlForge Modbus TCP Protocol Guide

James M. Belcher Founder, JMB Technical Services LLC June 2026 | ControlForge v1.0.1064


1. Architecture Overview

ControlForge implements a complete Modbus TCP stack — both client and server — callable directly from IEC 61131-3 Structured Text. No external libraries, no configuration files, no code generation. You create connections, read/write registers, and manage servers with plain function calls in your ST programs.

RoleFunctionsUse Case
ClientMB_CLIENT_CREATE / MB_READ_* / MB_WRITE_*Poll remote devices: VFDs, power meters, remote I/O, other PLCs
ServerMB_SERVER_CREATE / MB_SERVER_SET_* / MB_SERVER_GET_*Expose ControlForge data to SCADA, HMI, or other Modbus masters

Both roles can run simultaneously. A single ControlForge instance can poll five VFDs as a client while serving register data to a SCADA system — all from the same ST program.

System Diagram

Modbus Data Model

All Modbus devices share the same four data areas:

AreaAddress RangeAccessTypeModbus Term
Coils0-65535Read/WriteBOOLDiscrete outputs
Discrete Inputs0-65535Read-OnlyBOOLDiscrete inputs
Holding Registers0-65535Read/WriteINT (16-bit)Analog outputs
Input Registers0-65535Read-OnlyINT (16-bit)Analog inputs

Addressing Note: ControlForge uses zero-based addressing. Modbus address 0 in ControlForge corresponds to register 1 (40001) in traditional Modbus documentation. If a VFD manual says "register 40100," use address 99 in your MB_READ_HOLDING call.


2. Client Functions

The Modbus TCP client connects to remote servers (VFDs, meters, remote I/O) and performs read/write operations using standard Modbus function codes.

2.1 Connection Management

MB_CLIENT_CREATE — Create Named Connection

ParamTypeRequiredDescription
nameSTRINGYesUnique connection name
hostSTRINGYesIP address or hostname of the Modbus server
portINTYesTCP port (typically 502)
slave_idINTNoModbus unit ID (default 1)

Returns: BOOL — TRUE if the connection was created successfully.

(* Create a connection to a VFD at 10.0.0.50 *)
ok := MB_CLIENT_CREATE('vfd1', '10.0.0.50', 502);

(* Create with explicit slave ID for multi-drop gateways *)
ok := MB_CLIENT_CREATE('meter3', '10.0.0.60', 502, 3);

Named connections: Every Modbus client connection has a unique string name. This name is used in all subsequent calls. You can create as many connections as you need — one per device is the typical pattern.

MB_CLIENT_CONNECT — Establish TCP Connection

ParamTypeDescription
nameSTRINGConnection name from MB_CLIENT_CREATE

Returns: BOOL — TRUE if connected successfully.

ok := MB_CLIENT_CONNECT('vfd1');

MB_CLIENT_DISCONNECT — Close TCP Connection

ParamTypeDescription
nameSTRINGConnection name

Returns: BOOL — TRUE if disconnected successfully.

ok := MB_CLIENT_DISCONNECT('vfd1');

MB_CLIENT_CONNECTED — Check Connection State

ParamTypeDescription
nameSTRINGConnection name

Returns: BOOL — TRUE if the TCP connection is active.

IF NOT MB_CLIENT_CONNECTED('vfd1') THEN
MB_CLIENT_CONNECT('vfd1');
END_IF;

Example: Connection Lifecycle

PROGRAM POU_ModbusInit
VAR
state : INT := 0;
ok : BOOL;
END_VAR

CASE state OF
0: (* Create connection *)
ok := MB_CLIENT_CREATE('vfd1', '10.0.0.50', 502);
IF ok THEN
state := 1;
END_IF;

1: (* Connect *)
ok := MB_CLIENT_CONNECT('vfd1');
IF ok THEN
state := 10;
END_IF;

10: (* Running — read/write in other programs *)
IF NOT MB_CLIENT_CONNECTED('vfd1') THEN
state := 1; (* Reconnect *)
END_IF;
END_CASE;
END_PROGRAM

2.2 Read Functions

MB_READ_COILS — FC01: Read Coils

ParamTypeDescription
nameSTRINGConnection name
addressINTStarting coil address (0-based)
countINTNumber of coils to read (1-2000)

Returns: []BOOL — Array of coil states.

(* Read 8 coils starting at address 0 *)
coils := MB_READ_COILS('vfd1', 0, 8);
(* coils[0] = TRUE/FALSE, coils[1] = TRUE/FALSE, ... *)

MB_READ_DISCRETE — FC02: Read Discrete Inputs

ParamTypeDescription
nameSTRINGConnection name
addressINTStarting input address (0-based)
countINTNumber of inputs to read (1-2000)

Returns: []BOOL — Array of input states.

(* Read 16 discrete inputs starting at address 0 *)
inputs := MB_READ_DISCRETE('vfd1', 0, 16);

MB_READ_HOLDING — FC03: Read Holding Registers

ParamTypeDescription
nameSTRINGConnection name
addressINTStarting register address (0-based)
countINTNumber of registers to read (1-125)

Returns: []INT — Array of 16-bit register values.

(* Read 10 holding registers starting at address 0 *)
regs := MB_READ_HOLDING('vfd1', 0, 10);
(* regs[0] = first register value, regs[1] = second, ... *)

(* Read VFD output frequency — typically at a specific register *)
freq_regs := MB_READ_HOLDING('vfd1', 8451, 1);

Register limit: The Modbus spec allows a maximum of 125 holding registers per read request. If you need more, split into multiple reads.

MB_READ_INPUT — FC04: Read Input Registers

ParamTypeDescription
nameSTRINGConnection name
addressINTStarting register address (0-based)
countINTNumber of registers to read (1-125)

Returns: []INT — Array of 16-bit register values.

(* Read 4 input registers — process values from a meter *)
measurements := MB_READ_INPUT('meter3', 0, 4);

Example: Periodic Register Poll

PROGRAM POU_ReadRegisters
VAR
regs : ARRAY[0..9] OF INT;
speed_hz : INT;
current_amps : INT;
voltage_v : INT;
END_VAR

(* Read VFD status registers every scan *)
regs := MB_READ_HOLDING('vfd1', 8451, 3);

speed_hz := regs[0]; (* Output frequency x10 *)
current_amps := regs[1]; (* Output current x10 *)
voltage_v := regs[2]; (* DC bus voltage *)
END_PROGRAM

2.3 Write Functions

MB_WRITE_COIL — FC05: Write Single Coil

ParamTypeDescription
nameSTRINGConnection name
addressINTCoil address (0-based)
valueBOOLTRUE = ON, FALSE = OFF

Returns: BOOL — TRUE if write succeeded.

(* Turn on coil at address 0 *)
ok := MB_WRITE_COIL('vfd1', 0, TRUE);

(* Turn off coil at address 0 *)
ok := MB_WRITE_COIL('vfd1', 0, FALSE);

MB_WRITE_REGISTER — FC06: Write Single Register

ParamTypeDescription
nameSTRINGConnection name
addressINTRegister address (0-based)
valueINT16-bit register value

Returns: BOOL — TRUE if write succeeded.

(* Write speed setpoint to VFD — 3000 = 30.00 Hz *)
ok := MB_WRITE_REGISTER('vfd1', 8192, 3000);

(* Write run command *)
ok := MB_WRITE_REGISTER('vfd1', 8448, 1);

MB_WRITE_COILS — FC15: Write Multiple Coils

ParamTypeDescription
nameSTRINGConnection name
addressINTStarting coil address (0-based)
values[]BOOLArray of coil values

Returns: BOOL — TRUE if write succeeded.

(* Write 4 coils starting at address 0 *)
coil_values : ARRAY[0..3] OF BOOL := [TRUE, FALSE, TRUE, TRUE];
ok := MB_WRITE_COILS('vfd1', 0, coil_values);

MB_WRITE_REGISTERS — FC16: Write Multiple Registers

ParamTypeDescription
nameSTRINGConnection name
addressINTStarting register address (0-based)
values[]INTArray of 16-bit register values

Returns: BOOL — TRUE if write succeeded.

(* Write 3 registers starting at address 100 *)
reg_values : ARRAY[0..2] OF INT := [1500, 3000, 6000];
ok := MB_WRITE_REGISTERS('vfd1', 100, reg_values);

Example: VFD Speed Command

PROGRAM POU_WriteVFD
VAR
target_speed : INT := 3000; (* 30.00 Hz *)
run_cmd : BOOL := FALSE;
ok : BOOL;
END_VAR

(* Write frequency setpoint *)
ok := MB_WRITE_REGISTER('vfd1', 8192, target_speed);

(* Write run/stop command *)
IF run_cmd THEN
ok := MB_WRITE_REGISTER('vfd1', 8448, 1); (* Run forward *)
ELSE
ok := MB_WRITE_REGISTER('vfd1', 8448, 0); (* Stop *)
END_IF;
END_PROGRAM

2.4 Diagnostics and Management

MB_CLIENT_STATS — Connection Statistics

ParamTypeDescription
nameSTRINGConnection name

Returns: MAP — Statistics including request count, response count, and error count.

stats := MB_CLIENT_STATS('vfd1');
(* Returns: {"requests": 1542, "responses": 1540, "errors": 2} *)

Error tracking: Compare requests vs. responses to detect communication problems. A growing error count may indicate cabling issues, device overload, or network congestion.

MB_CLIENT_DELETE — Remove Connection

ParamTypeDescription
nameSTRINGConnection name

Returns: BOOL — TRUE if the connection was deleted.

(* Disconnect and remove *)
MB_CLIENT_DISCONNECT('vfd1');
ok := MB_CLIENT_DELETE('vfd1');

MB_CLIENT_LIST — List All Connections

Returns: []STRING — Array of all client connection names.

clients := MB_CLIENT_LIST();
(* Returns: ['vfd1', 'meter3', 'remote_io'] *)

3. Server Functions

The Modbus TCP server listens for incoming connections and exposes four standard Modbus data areas. Remote SCADA systems, HMIs, or other Modbus masters can read and write ControlForge data.

3.1 Server Management

MB_SERVER_CREATE — Create Named Server

ParamTypeRequiredDescription
nameSTRINGYesUnique server name
portINTYesTCP listen port
slave_idINTNoModbus unit ID (default 1)

Returns: BOOL — TRUE if the server was created.

(* Create a server on the standard Modbus port *)
ok := MB_SERVER_CREATE('plc_server', 502);

(* Create on a non-standard port with explicit slave ID *)
ok := MB_SERVER_CREATE('line2_server', 5020, 2);

Port selection: Port 502 is the standard Modbus TCP port and may require root/admin privileges on some systems. Using a port above 1024 (e.g., 5020) avoids permission issues during development.

MB_SERVER_START — Begin Listening

ParamTypeDescription
nameSTRINGServer name

Returns: BOOL — TRUE if the server started listening.

ok := MB_SERVER_START('plc_server');

MB_SERVER_STOP — Stop Listening

ParamTypeDescription
nameSTRINGServer name

Returns: BOOL — TRUE if the server stopped.

ok := MB_SERVER_STOP('plc_server');

MB_SERVER_IS_RUNNING — Check Server State

ParamTypeDescription
nameSTRINGServer name

Returns: BOOL — TRUE if the server is actively listening.

IF NOT MB_SERVER_IS_RUNNING('plc_server') THEN
MB_SERVER_START('plc_server');
END_IF;

Example: Server Lifecycle

PROGRAM POU_ModbusServer
VAR
state : INT := 0;
ok : BOOL;
END_VAR

CASE state OF
0: (* Create server *)
ok := MB_SERVER_CREATE('plc_server', 502);
IF ok THEN
state := 1;
END_IF;

1: (* Start listening *)
ok := MB_SERVER_START('plc_server');
IF ok THEN
state := 10;
END_IF;

10: (* Running — update registers in other programs *)
IF NOT MB_SERVER_IS_RUNNING('plc_server') THEN
state := 1; (* Restart *)
END_IF;
END_CASE;
END_PROGRAM

3.2 Coil Access (Read/Write Booleans)

MB_SERVER_SET_COIL — Write a Coil Value

ParamTypeDescription
nameSTRINGServer name
addressINTCoil address (0-based)
valueBOOLTRUE = ON, FALSE = OFF

Returns: BOOL — TRUE if the value was set.

(* Set coil 0 to ON — visible to any connected Modbus client *)
ok := MB_SERVER_SET_COIL('plc_server', 0, TRUE);

MB_SERVER_GET_COIL — Read a Coil Value

ParamTypeDescription
nameSTRINGServer name
addressINTCoil address (0-based)

Returns: BOOL — Current coil state.

(* Read coil 0 — may have been written by a remote SCADA master *)
run_cmd := MB_SERVER_GET_COIL('plc_server', 0);

Bidirectional data flow: Remote Modbus clients can write coils (FC05/FC15) and holding registers (FC06/FC16) on your server. Use MB_SERVER_GET_COIL and MB_SERVER_GET_HOLDING to read values that remote masters have written. This is how SCADA systems send commands to ControlForge.

Example: Coil-Based Remote Control

PROGRAM POU_CoilControl
VAR
remote_run : BOOL;
remote_reset : BOOL;
motor_running : BOOL;
END_VAR

(* Read commands from SCADA via coils *)
remote_run := MB_SERVER_GET_COIL('plc_server', 0);
remote_reset := MB_SERVER_GET_COIL('plc_server', 1);

(* Execute commands *)
IF remote_run AND NOT motor_running THEN
motor_running := TRUE;
END_IF;

IF remote_reset THEN
motor_running := FALSE;
MB_SERVER_SET_COIL('plc_server', 1, FALSE); (* Auto-clear reset *)
END_IF;

(* Report status back *)
MB_SERVER_SET_COIL('plc_server', 10, motor_running);
END_PROGRAM

3.3 Discrete Input Access (Read-Only Booleans)

MB_SERVER_SET_DISCRETE — Set a Discrete Input Value

ParamTypeDescription
nameSTRINGServer name
addressINTInput address (0-based)
valueBOOLTRUE = ON, FALSE = OFF

Returns: BOOL — TRUE if the value was set.

(* Expose sensor states as discrete inputs *)
ok := MB_SERVER_SET_DISCRETE('plc_server', 0, limit_switch_1);
ok := MB_SERVER_SET_DISCRETE('plc_server', 1, limit_switch_2);
ok := MB_SERVER_SET_DISCRETE('plc_server', 2, e_stop_ok);

MB_SERVER_GET_DISCRETE — Read a Discrete Input Value

ParamTypeDescription
nameSTRINGServer name
addressINTInput address (0-based)

Returns: BOOL — Current discrete input state.

value := MB_SERVER_GET_DISCRETE('plc_server', 0);

Read-only to clients: Remote Modbus masters can only read discrete inputs (FC02). They cannot write them. Use this area for status and sensor data that should not be overwritten remotely.


3.4 Holding Register Access (Read/Write Integers)

MB_SERVER_SET_HOLDING — Write a Holding Register

ParamTypeDescription
nameSTRINGServer name
addressINTRegister address (0-based)
valueINT16-bit register value

Returns: BOOL — TRUE if the value was set.

(* Expose process values as holding registers *)
ok := MB_SERVER_SET_HOLDING('plc_server', 0, motor_speed);
ok := MB_SERVER_SET_HOLDING('plc_server', 1, motor_current);
ok := MB_SERVER_SET_HOLDING('plc_server', 2, temperature);

MB_SERVER_GET_HOLDING — Read a Holding Register

ParamTypeDescription
nameSTRINGServer name
addressINTRegister address (0-based)

Returns: INT — Current register value.

(* Read setpoint written by remote SCADA *)
speed_setpoint := MB_SERVER_GET_HOLDING('plc_server', 100);

Example: Bidirectional Holding Registers

PROGRAM POU_RegisterExchange
VAR
speed_setpoint : INT;
actual_speed : INT;
ok : BOOL;
END_VAR

(* SCADA writes setpoint to register 100 *)
speed_setpoint := MB_SERVER_GET_HOLDING('plc_server', 100);

(* ControlForge publishes actual speed to register 0 *)
actual_speed := 2950; (* From VFD feedback *)
ok := MB_SERVER_SET_HOLDING('plc_server', 0, actual_speed);
END_PROGRAM

3.5 Input Register Access (Read-Only Integers)

MB_SERVER_SET_INPUT — Set an Input Register

ParamTypeDescription
nameSTRINGServer name
addressINTRegister address (0-based)
valueINT16-bit register value

Returns: BOOL — TRUE if the value was set.

(* Expose analog measurements as input registers *)
ok := MB_SERVER_SET_INPUT('plc_server', 0, pressure_psi);
ok := MB_SERVER_SET_INPUT('plc_server', 1, flow_gpm);
ok := MB_SERVER_SET_INPUT('plc_server', 2, level_percent);

MB_SERVER_GET_INPUT — Read an Input Register

ParamTypeDescription
nameSTRINGServer name
addressINTRegister address (0-based)

Returns: INT — Current register value.

value := MB_SERVER_GET_INPUT('plc_server', 0);

Input registers vs. holding registers: Use input registers (FC04) for sensor data and measured values. Use holding registers (FC03/FC06) for setpoints and bidirectional data. This follows the Modbus convention and makes your register map intuitive to integrators.


3.6 Server Diagnostics and Management

MB_SERVER_STATS — Server Statistics

ParamTypeDescription
nameSTRINGServer name

Returns: MAP — Statistics including request count, response count, and error count.

stats := MB_SERVER_STATS('plc_server');
(* Returns: {"requests": 8420, "responses": 8420, "errors": 0} *)

MB_SERVER_CONNECTIONS — List Connected Clients

ParamTypeDescription
nameSTRINGServer name

Returns: []MAP — Array of connected client information.

connections := MB_SERVER_CONNECTIONS('plc_server');
(* Returns: [{"remote_addr": "10.0.0.100:49832", "connected_at": "2026-04-03T10:15:00Z"},
{"remote_addr": "10.0.0.101:52100", "connected_at": "2026-04-03T10:16:30Z"}] *)

Security awareness: Any device on the network can connect to your Modbus server. Use MB_SERVER_CONNECTIONS to audit who is connected. For production systems, consider placing the Modbus server on a dedicated VLAN or using firewall rules.

MB_SERVER_DELETE — Remove Server

ParamTypeDescription
nameSTRINGServer name

Returns: BOOL — TRUE if the server was deleted.

MB_SERVER_STOP('plc_server');
ok := MB_SERVER_DELETE('plc_server');

MB_SERVER_LIST — List All Servers

Returns: []STRING — Array of all server names.

servers := MB_SERVER_LIST();
(* Returns: ['plc_server', 'line2_server'] *)

4. Complete Example: Polling a VFD Over Modbus TCP Client

This example connects to an ABB ACS355 variable frequency drive, reads status registers, and writes speed commands. The register addresses follow ABB's Modbus register map — adapt them for your specific VFD model.

PROGRAM POU_VFD_Control
VAR
(* Connection state *)
state : INT := 0;
ok : BOOL;
retry_count : INT := 0;

(* VFD feedback *)
status_regs : ARRAY[0..4] OF INT;
output_freq : REAL; (* Hz *)
output_current : REAL; (* Amps *)
dc_bus_voltage : INT; (* Volts *)
drive_status : INT;
fault_code : INT;

(* VFD commands *)
speed_setpoint : INT := 3000; (* 30.00 Hz x100 *)
run_forward : BOOL := FALSE;
run_reverse : BOOL := FALSE;
control_word : INT;
END_VAR

CASE state OF
0: (* Create connection to VFD *)
ok := MB_CLIENT_CREATE('acs355', '10.0.0.50', 502, 1);
IF ok THEN
state := 1;
END_IF;

1: (* Connect *)
ok := MB_CLIENT_CONNECT('acs355');
IF ok THEN
retry_count := 0;
state := 10;
ELSE
retry_count := retry_count + 1;
IF retry_count > 5 THEN
state := 99; (* Fault *)
END_IF;
END_IF;

10: (* Running — read status registers *)
IF NOT MB_CLIENT_CONNECTED('acs355') THEN
state := 1; (* Reconnect *)
END_IF;

(* Read 5 status registers starting at address 1 *)
status_regs := MB_READ_HOLDING('acs355', 1, 5);

output_freq := INT_TO_REAL(status_regs[0]) / 100.0;
output_current := INT_TO_REAL(status_regs[1]) / 100.0;
dc_bus_voltage := status_regs[2];
drive_status := status_regs[3];
fault_code := status_regs[4];

state := 11;

11: (* Write command registers *)
(* Build control word *)
control_word := 0;
IF run_forward THEN
control_word := 1; (* Run forward *)
ELSIF run_reverse THEN
control_word := 2; (* Run reverse *)
END_IF;

ok := MB_WRITE_REGISTER('acs355', 0, control_word);
ok := MB_WRITE_REGISTER('acs355', 1, speed_setpoint);

state := 10; (* Loop back to read *)

99: (* Fault — connection failed *)
(* Log error, wait for operator intervention *)
END_CASE;
END_PROGRAM

Scan time consideration: Each Modbus transaction (read or write) takes 5-50 ms depending on network latency and device response time. Avoid reading hundreds of registers every scan. Group related registers into single reads, and stagger reads across multiple scans if needed.


5. Complete Example: Exposing ControlForge Data as a Modbus TCP Server

This example creates a Modbus server that exposes process data to a SCADA system. The register map is documented so integrators know where to find each value.

Register Map

AddressAreaDescriptionUnitsScale
HR 0HoldingLine speedRPMx1
HR 1HoldingMotor currentAmpsx10
HR 2HoldingTemperatureDeg Fx10
HR 3HoldingPressurePSIx10
HR 4HoldingBatch countCountx1
HR 100HoldingSpeed setpoint (SCADA writes)RPMx1
HR 101HoldingMode select (SCADA writes)Enumx1
IR 0InputUptimeSecondsx1
IR 1InputScan countCountx1
DI 0DiscreteE-Stop OK
DI 1DiscreteGuard door closed
DI 2DiscreteSystem running
Coil 0CoilStart command (SCADA writes)
Coil 1CoilStop command (SCADA writes)
Coil 10CoilRunning status
Coil 11CoilFault active
PROGRAM POU_SCADA_Server
VAR
(* Server state *)
state : INT := 0;
ok : BOOL;

(* Process data (from other programs or I/O) *)
line_speed : INT := 1750;
motor_current : INT := 125; (* 12.5 A x10 *)
temperature : INT := 1680; (* 168.0 F x10 *)
pressure : INT := 450; (* 45.0 PSI x10 *)
batch_count : INT := 0;
uptime_sec : DINT := 0;
scan_count : DINT := 0;

(* Discrete status *)
e_stop_ok : BOOL := TRUE;
guard_closed : BOOL := TRUE;
system_running : BOOL := FALSE;
fault_active : BOOL := FALSE;

(* Commands from SCADA *)
scada_start : BOOL;
scada_stop : BOOL;
scada_setpoint : INT;
scada_mode : INT;
END_VAR

CASE state OF
0: (* Create and start server *)
ok := MB_SERVER_CREATE('scada', 502);
IF ok THEN
state := 1;
END_IF;

1: (* Start listening *)
ok := MB_SERVER_START('scada');
IF ok THEN
state := 10;
END_IF;

10: (* Running — update all data areas every scan *)
scan_count := scan_count + 1;

(* === Write process values to holding registers === *)
MB_SERVER_SET_HOLDING('scada', 0, line_speed);
MB_SERVER_SET_HOLDING('scada', 1, motor_current);
MB_SERVER_SET_HOLDING('scada', 2, temperature);
MB_SERVER_SET_HOLDING('scada', 3, pressure);
MB_SERVER_SET_HOLDING('scada', 4, batch_count);

(* === Write input registers === *)
MB_SERVER_SET_INPUT('scada', 0, DINT_TO_INT(uptime_sec));
MB_SERVER_SET_INPUT('scada', 1, DINT_TO_INT(scan_count));

(* === Write discrete inputs (read-only to SCADA) === *)
MB_SERVER_SET_DISCRETE('scada', 0, e_stop_ok);
MB_SERVER_SET_DISCRETE('scada', 1, guard_closed);
MB_SERVER_SET_DISCRETE('scada', 2, system_running);

(* === Write coil status === *)
MB_SERVER_SET_COIL('scada', 10, system_running);
MB_SERVER_SET_COIL('scada', 11, fault_active);

(* === Read commands from SCADA === *)
scada_start := MB_SERVER_GET_COIL('scada', 0);
scada_stop := MB_SERVER_GET_COIL('scada', 1);
scada_setpoint := MB_SERVER_GET_HOLDING('scada', 100);
scada_mode := MB_SERVER_GET_HOLDING('scada', 101);

(* Process commands *)
IF scada_start AND NOT system_running THEN
system_running := TRUE;
MB_SERVER_SET_COIL('scada', 0, FALSE); (* Auto-clear *)
END_IF;

IF scada_stop AND system_running THEN
system_running := FALSE;
MB_SERVER_SET_COIL('scada', 1, FALSE); (* Auto-clear *)
END_IF;

(* Health check *)
IF NOT MB_SERVER_IS_RUNNING('scada') THEN
state := 1; (* Restart *)
END_IF;
END_CASE;
END_PROGRAM

6. Gateway Example: Modbus TCP to MQTT Bridge

This example reads holding registers from a remote Modbus device and publishes them to an MQTT broker. ControlForge acts as a protocol gateway — bridging the OT (Modbus) and IT (MQTT) worlds.

PROGRAM POU_Modbus_MQTT_Gateway
VAR
(* State machine *)
state : INT := 0;
ok : BOOL;
scan_count : DINT := 0;
publish_interval : DINT := 10; (* Publish every 10 scans *)

(* Modbus data *)
regs : ARRAY[0..9] OF INT;
voltage : REAL;
current : REAL;
power : REAL;
energy : DINT;

(* MQTT *)
mqtt_connected : BOOL;
payload : STRING;
END_VAR

CASE state OF
0: (* Initialize Modbus client *)
ok := MB_CLIENT_CREATE('meter', '10.0.0.70', 502, 1);
IF ok THEN
state := 1;
END_IF;

1: (* Connect Modbus *)
ok := MB_CLIENT_CONNECT('meter');
IF ok THEN
state := 2;
END_IF;

2: (* Initialize MQTT *)
ok := MQTT_CLIENT_CREATE('mqtt_gw', 'tcp://10.0.0.144:1883', 'cf-gw');
IF ok THEN
ok := MQTT_CONNECT('mqtt_gw');
IF ok THEN
state := 10;
END_IF;
END_IF;

10: (* Running — read and publish *)
scan_count := scan_count + 1;

(* Reconnect if needed *)
IF NOT MB_CLIENT_CONNECTED('meter') THEN
MB_CLIENT_CONNECT('meter');
END_IF;

(* Read 10 registers from power meter *)
regs := MB_READ_HOLDING('meter', 0, 10);

(* Scale to engineering units *)
voltage := INT_TO_REAL(regs[0]) / 10.0;
current := INT_TO_REAL(regs[1]) / 100.0;
power := INT_TO_REAL(regs[2]) / 10.0;
energy := INT_TO_DINT(regs[4]) * 65536 + INT_TO_DINT(regs[5]);

(* Publish at reduced rate *)
IF (scan_count MOD publish_interval) = 0 THEN
payload := CONCAT('{"voltage":', REAL_TO_STRING(voltage),
',"current":', REAL_TO_STRING(current),
',"power":', REAL_TO_STRING(power),
',"energy":', DINT_TO_STRING(energy), '}');

MQTT_PUBLISH('mqtt_gw', 'plant/meter1/data', payload);
END_IF;

(* Publish Modbus stats periodically *)
IF (scan_count MOD 100) = 0 THEN
MQTT_PUBLISH('mqtt_gw', 'plant/meter1/stats',
MB_CLIENT_STATS('meter'));
END_IF;
END_CASE;
END_PROGRAM

Rate limiting: Modbus devices typically handle 10-50 requests per second. MQTT brokers can handle thousands of messages per second. Use the publish_interval to decouple the read rate from the publish rate. Read every scan for responsive control; publish at a slower rate for trending and logging.


7. Advanced Patterns

7.1 Multi-Device Polling

PROGRAM POU_MultiDevice
VAR
poll_index : INT := 0;
devices : ARRAY[0..3] OF STRING := ['vfd1', 'vfd2', 'meter1', 'meter2'];
regs : ARRAY[0..4] OF INT;
END_VAR

(* Round-robin: poll one device per scan to distribute bus load *)
regs := MB_READ_HOLDING(devices[poll_index], 0, 5);

poll_index := poll_index + 1;
IF poll_index > 3 THEN
poll_index := 0;
END_IF;
END_PROGRAM

Why round-robin? Each Modbus read blocks for the duration of the TCP transaction (5-50 ms). Polling four devices sequentially every scan adds 20-200 ms to your scan time. Round-robin keeps scan time consistent.

7.2 32-Bit Values Across Two Registers

Modbus registers are 16-bit. For 32-bit values (REAL, DINT), devices use two consecutive registers. The byte order varies by manufacturer.

PROGRAM POU_32Bit
VAR
regs : ARRAY[0..1] OF INT;
float_val : REAL;
dint_val : DINT;
END_VAR

(* Read two consecutive registers *)
regs := MB_READ_HOLDING('meter', 0, 2);

(* Big-endian (most common): high word first *)
dint_val := INT_TO_DINT(regs[0]) * 65536 + INT_TO_DINT(regs[1]);

(* Little-endian (some devices): low word first *)
dint_val := INT_TO_DINT(regs[1]) * 65536 + INT_TO_DINT(regs[0]);
END_PROGRAM

Word order matters: There is no standard for 32-bit value byte order in Modbus. ABB uses big-endian. Schneider uses big-endian. Some devices use little-endian or mid-endian (byte-swapped). Always check the device manual and verify with a known value.

7.3 Dual Role: Client and Server Simultaneously

PROGRAM POU_DualRole
VAR
state : INT := 0;
ok : BOOL;
vfd_speed : INT;
vfd_current : INT;
regs : ARRAY[0..1] OF INT;
END_VAR

CASE state OF
0: (* Initialize both roles *)
ok := MB_CLIENT_CREATE('vfd', '10.0.0.50', 502);
ok := MB_SERVER_CREATE('scada', 5020);
IF ok THEN state := 1; END_IF;

1: (* Connect/Start *)
MB_CLIENT_CONNECT('vfd');
MB_SERVER_START('scada');
state := 10;

10: (* Running — bridge VFD data to SCADA *)
(* Read from VFD (client role) *)
regs := MB_READ_HOLDING('vfd', 0, 2);
vfd_speed := regs[0];
vfd_current := regs[1];

(* Expose to SCADA (server role) *)
MB_SERVER_SET_HOLDING('scada', 0, vfd_speed);
MB_SERVER_SET_HOLDING('scada', 1, vfd_current);

(* Read setpoint from SCADA, forward to VFD *)
MB_WRITE_REGISTER('vfd', 10,
MB_SERVER_GET_HOLDING('scada', 100));
END_CASE;
END_PROGRAM

Appendix A: Modbus Function Code Reference

FCNameControlForge Client FunctionMax Items
01Read CoilsMB_READ_COILS2000
02Read Discrete InputsMB_READ_DISCRETE2000
03Read Holding RegistersMB_READ_HOLDING125
04Read Input RegistersMB_READ_INPUT125
05Write Single CoilMB_WRITE_COIL1
06Write Single RegisterMB_WRITE_REGISTER1
15Write Multiple CoilsMB_WRITE_COILS1968
16Write Multiple RegistersMB_WRITE_REGISTERS123

Appendix B: Quick Reference — All 31 Functions

Client Functions (15)

FunctionReturnsDescription
MB_CLIENT_CREATE(name, host, port [, slave_id])BOOLCreate named connection
MB_CLIENT_CONNECT(name)BOOLEstablish TCP connection
MB_CLIENT_DISCONNECT(name)BOOLClose TCP connection
MB_CLIENT_CONNECTED(name)BOOLCheck connection state
MB_READ_COILS(name, address, count)[]BOOLFC01: Read coils
MB_READ_DISCRETE(name, address, count)[]BOOLFC02: Read discrete inputs
MB_READ_HOLDING(name, address, count)[]INTFC03: Read holding registers
MB_READ_INPUT(name, address, count)[]INTFC04: Read input registers
MB_WRITE_COIL(name, address, value)BOOLFC05: Write single coil
MB_WRITE_REGISTER(name, address, value)BOOLFC06: Write single register
MB_WRITE_COILS(name, address, values)BOOLFC15: Write multiple coils
MB_WRITE_REGISTERS(name, address, values)BOOLFC16: Write multiple registers
MB_CLIENT_STATS(name)MAPRequest/response/error counts
MB_CLIENT_DELETE(name)BOOLRemove connection
MB_CLIENT_LIST()[]STRINGList all connections

Server Functions (16)

FunctionReturnsDescription
MB_SERVER_CREATE(name, port [, slave_id])BOOLCreate named server
MB_SERVER_START(name)BOOLBegin listening
MB_SERVER_STOP(name)BOOLStop listening
MB_SERVER_IS_RUNNING(name)BOOLCheck server state
MB_SERVER_SET_COIL(name, address, value)BOOLWrite coil value
MB_SERVER_GET_COIL(name, address)BOOLRead coil value
MB_SERVER_SET_DISCRETE(name, address, value)BOOLSet discrete input
MB_SERVER_GET_DISCRETE(name, address)BOOLRead discrete input
MB_SERVER_SET_HOLDING(name, address, value)BOOLWrite holding register
MB_SERVER_GET_HOLDING(name, address)INTRead holding register
MB_SERVER_SET_INPUT(name, address, value)BOOLSet input register
MB_SERVER_GET_INPUT(name, address)INTRead input register
MB_SERVER_STATS(name)MAPRequest/response/error counts
MB_SERVER_CONNECTIONS(name)[]MAPList connected clients
MB_SERVER_DELETE(name)BOOLRemove server
MB_SERVER_LIST()[]STRINGList all servers

ControlForge v1.0.1064 | Modbus TCP Client + Server | IEC 61131-3 Structured Text

© 2026 JMB Technical Services LLC. All rights reserved. Back to All Guides