Communication
PLC Network Communication: Modbus TCP & EtherNet/IP in Structured Text
Connect PLCs to sensors, drives, and SCADA systems over industrial Ethernet — with production-grade Modbus TCP and EtherNet/IP implementations in Structured Text.
Two protocols on one cable
Field devices no longer each need dedicated point-to-point wiring. Most new plants run on industrial Ethernet, which uses the same physical layer as office IT with protocols built for control traffic on top. Two of those protocols cover most of what a PLC programmer meets:
This article looks at both from the PLC programmer's side: how to structure your data, how to handle communication errors, and how to organize the polling when there are many devices.
How Modbus TCP organizes data
Modbus TCP wraps the classic Modbus RTU protocol in a TCP/IP packet. Its data model has just four register types:
| Register Type | Modbus Address | Access | Data Size | Typical Use |
|---|---|---|---|---|
| Coils | 0xxxx | Read/Write | 1 bit | Digital outputs |
| Discrete Inputs | 1xxxx | Read Only | 1 bit | Digital inputs |
| Input Registers | 3xxxx | Read Only | 16 bit | Analog inputs, status |
| Holding Registers | 4xxxx | Read/Write | 16 bit | Setpoints, config |
The function codes you will use
| Code | Name | Purpose |
|---|---|---|
| 01 | Read Coils | Read digital outputs |
| 02 | Read Discrete Inputs | Read digital inputs |
| 03 | Read Holding Registers | Read 16-bit R/W registers |
| 04 | Read Input Registers | Read 16-bit read-only registers |
| 05 | Write Single Coil | Write one digital output |
| 06 | Write Single Register | Write one 16-bit register |
| 15 | Write Multiple Coils | Write multiple digital outputs |
| 16 | Write Multiple Registers | Write multiple 16-bit registers |
Mapping Modbus registers in Structured Text
Most of the work is mapping PLC data types onto 16-bit Modbus registers. Keep the connection settings in one structure and the register map in another, with the register number and the scaling in a comment on every field:
TYPE ModbusDeviceConfig :
STRUCT
IPAddress : STRING[15];
Port : UINT := 502; // Standard Modbus TCP port
UnitID : BYTE := 1; // Modbus slave ID
TimeoutMS : UINT := 1000; // Response timeout
RetryCount : UINT := 3;
PollIntervalMS : UINT := 100; // Polling cycle time
END_STRUCT;
END_TYPE
TYPE ModbusRegisterMap :
STRUCT
// Input registers (read from device)
IR_Temperature : INT; // Register 30001 — scaled ×10
IR_Pressure : INT; // Register 30002 — scaled ×100
IR_FlowRate : INT; // Register 30003
IR_StatusWord : WORD; // Register 30004
// Holding registers (read/write)
HR_TempSetpoint : INT; // Register 40001
HR_SpeedRef : INT; // Register 40002
HR_ControlWord : WORD; // Register 40003
HR_AlarmReset : BOOL; // Register 40004, bit 0
END_STRUCT;
END_TYPE
32-bit values across two registers
REAL (floating point) and DINT (32-bit integer) values span two consecutive 16-bit registers. The order of those two words varies by device manufacturer, and if you get it wrong the value is garbage:
FUNCTION FC_RegsToReal : REAL
VAR_INPUT
HighWord : WORD; // Register N
LowWord : WORD; // Register N+1
BigEndian: BOOL; // TRUE for Modbus standard (big-endian)
END_VAR
VAR
RawDWord : DWORD;
TempReal : REAL;
END_VAR
IF BigEndian THEN
// Big-endian (AB CD): High word first — Modbus standard
RawDWord := SHL(WORD_TO_DWORD(HighWord), 16) OR WORD_TO_DWORD(LowWord);
ELSE
// Little-endian (CD AB): Low word first — some devices swap
RawDWord := SHL(WORD_TO_DWORD(LowWord), 16) OR WORD_TO_DWORD(HighWord);
END_IF;
// Type-pun DWORD to REAL (platform-specific, may need MEMCPY)
TempReal := DWORD_TO_REAL(RawDWord);
FC_RegsToReal := TempReal;
FUNCTION FC_RealToRegs : BOOL
VAR_INPUT
Value : REAL;
BigEndian: BOOL;
END_VAR
VAR_IN_OUT
HighWord : WORD;
LowWord : WORD;
END_VAR
VAR
RawDWord : DWORD;
END_VAR
RawDWord := REAL_TO_DWORD(Value);
IF BigEndian THEN
HighWord := DWORD_TO_WORD(SHR(RawDWord, 16));
LowWord := DWORD_TO_WORD(RawDWord AND 16#0000FFFF);
ELSE
LowWord := DWORD_TO_WORD(SHR(RawDWord, 16));
HighWord := DWORD_TO_WORD(RawDWord AND 16#0000FFFF);
END_IF;
FC_RealToRegs := TRUE;
Document the byte order convention for each device in the project. Swapped registers showing garbage values can eat hours of commissioning time.
A Modbus TCP communication manager
A production system polls its devices one after another. This FB handles one device as a state machine: connect, poll, wait for the response with a timeout, retry, and reconnect after a 5 s pause when the retries run out. It also keeps a rolling window of the last 100 polls to give a communication quality figure:
FUNCTION_BLOCK FB_ModbusTCPManager
VAR_INPUT
Enable : BOOL;
DeviceConfig : ModbusDeviceConfig;
END_VAR
VAR_OUTPUT
Connected : BOOL;
CommOK : BOOL;
CommError : BOOL;
ErrorCode : WORD;
ResponseTimeMS : UINT;
FailedPolls : DINT;
TotalPolls : DINT;
CommQuality : REAL; // 0–100%
END_VAR
VAR
State : INT := 0;
RetryCounter : UINT := 0;
tmrTimeout : TON;
tmrPollCycle : TON;
tmrReconnect : TON;
// Comm health tracking
SuccessWindow : ARRAY[1..100] OF BOOL;
WindowIndex : INT := 1;
SuccessCount : INT := 0;
END_VAR
IF NOT Enable THEN
State := 0;
Connected := FALSE;
CommOK := FALSE;
RETURN;
END_IF;
CASE State OF
0: // CONNECT
// Platform-specific: open TCP socket to DeviceConfig.IPAddress:502
// Most PLCs have built-in Modbus TCP client FBs
State := 1;
1: // WAIT FOR CONNECTION
tmrTimeout(IN := TRUE, PT := UINT_TO_TIME(DeviceConfig.TimeoutMS));
IF Connected THEN
tmrTimeout(IN := FALSE);
State := 2;
ELSIF tmrTimeout.Q THEN
tmrTimeout(IN := FALSE);
State := 5; // Connection failed
END_IF;
2: // POLL — READ INPUT REGISTERS
// FC03: Read Holding Registers or FC04: Read Input Registers
// Start address, quantity, destination buffer
TotalPolls := TotalPolls + 1;
tmrTimeout(IN := TRUE, PT := UINT_TO_TIME(DeviceConfig.TimeoutMS));
State := 3;
3: // WAIT FOR RESPONSE
IF (* response received *) TRUE THEN
tmrTimeout(IN := FALSE);
CommOK := TRUE;
CommError := FALSE;
RetryCounter := 0;
// Track success
SuccessWindow[WindowIndex] := TRUE;
WindowIndex := WindowIndex + 1;
IF WindowIndex > 100 THEN WindowIndex := 1; END_IF;
State := 4;
ELSIF tmrTimeout.Q THEN
tmrTimeout(IN := FALSE);
FailedPolls := FailedPolls + 1;
SuccessWindow[WindowIndex] := FALSE;
WindowIndex := WindowIndex + 1;
IF WindowIndex > 100 THEN WindowIndex := 1; END_IF;
RetryCounter := RetryCounter + 1;
IF RetryCounter >= DeviceConfig.RetryCount THEN
CommError := TRUE;
CommOK := FALSE;
State := 5;
ELSE
State := 2; // Retry
END_IF;
END_IF;
4: // POLL INTERVAL — wait before next poll
tmrPollCycle(IN := TRUE, PT := UINT_TO_TIME(DeviceConfig.PollIntervalMS));
IF tmrPollCycle.Q THEN
tmrPollCycle(IN := FALSE);
State := 2;
END_IF;
5: // ERROR — attempt reconnection
Connected := FALSE;
tmrReconnect(IN := TRUE, PT := T#5S);
IF tmrReconnect.Q THEN
tmrReconnect(IN := FALSE);
RetryCounter := 0;
State := 0;
END_IF;
END_CASE;
// Calculate communication quality
SuccessCount := 0;
FOR WindowIndex := 1 TO 100 DO
IF SuccessWindow[WindowIndex] THEN
SuccessCount := SuccessCount + 1;
END_IF;
END_FOR;
CommQuality := INT_TO_REAL(SuccessCount);
Polling many devices
Real systems talk to dozens of devices. A round-robin scheduler gives each device its turn, and a priority override lets a critical device jump the queue:
PROGRAM ModbusMultiDevice
VAR
Devices : ARRAY[1..16] OF ModbusDeviceConfig;
DeviceData : ARRAY[1..16] OF ModbusRegisterMap;
DeviceComm : ARRAY[1..16] OF FB_ModbusTCPManager;
NumDevices : INT := 8;
ActiveDevice : INT := 1;
PriorityDevice : INT := 0; // 0=none, >0=force poll this device next
// Aggregate status
AllDevicesOK : BOOL;
DevicesInError : INT;
END_VAR
// Priority polling: if a critical device needs attention, poll it first
IF PriorityDevice > 0 AND PriorityDevice <= NumDevices THEN
ActiveDevice := PriorityDevice;
PriorityDevice := 0;
END_IF;
// Poll active device
DeviceComm[ActiveDevice](
Enable := TRUE,
DeviceConfig := Devices[ActiveDevice]
);
// When current poll completes, move to next device
IF DeviceComm[ActiveDevice].CommOK OR DeviceComm[ActiveDevice].CommError THEN
ActiveDevice := ActiveDevice + 1;
IF ActiveDevice > NumDevices THEN
ActiveDevice := 1;
END_IF;
END_IF;
// Aggregate health
DevicesInError := 0;
AllDevicesOK := TRUE;
FOR ActiveDevice := 1 TO NumDevices DO
IF DeviceComm[ActiveDevice].CommError THEN
DevicesInError := DevicesInError + 1;
AllDevicesOK := FALSE;
END_IF;
END_FOR;
EtherNet/IP and CIP messaging
EtherNet/IP uses the Common Industrial Protocol (CIP), which is more structured than Modbus: data lives in objects with classes, instances and attributes. It has two communication types:
| Type | Name | Use Case |
|---|---|---|
| Implicit (I/O) | Cyclic data exchange | Fast, deterministic I/O updates |
| Explicit | Request/response messaging | Configuration, diagnostics, non-cyclic data |
Implicit I/O and assembly mapping
Implicit messaging maps device data directly to PLC I/O tags. You configure it in the controller's hardware tree, not in ST code, and the PLC sees the data as input and output arrays that your code decodes:
// EtherNet/IP implicit I/O — data appears as mapped I/O
PROGRAM EIP_ImplicitIO
VAR
// These tags are mapped to EtherNet/IP adapter assemblies
// Configuration is done in the hardware config, not code
VFD_Input : ARRAY[0..3] OF DINT; // Input assembly from drive
VFD_Output : ARRAY[0..3] OF DINT; // Output assembly to drive
// Decoded values
DriveStatus : WORD;
ActualSpeed : REAL;
ActualCurrent : REAL;
// Commands
DriveControl : WORD;
SpeedReference : REAL;
END_VAR
// Decode input assembly (layout is device-specific)
DriveStatus := DINT_TO_WORD(VFD_Input[0] AND 16#0000FFFF);
ActualSpeed := DINT_TO_REAL(VFD_Input[1]);
ActualCurrent := DINT_TO_REAL(VFD_Input[2]);
// Build output assembly
VFD_Output[0] := WORD_TO_DINT(DriveControl);
VFD_Output[1] := REAL_TO_DINT(SpeedReference);
Explicit messaging with the MSG pattern
For non-cyclic data, such as reading a parameter or writing a configuration, use explicit CIP messages. The FB below is a generic version of the MSG pattern: you trigger it with Execute, then wait for Done or Error:
TYPE CIP_MessageConfig :
STRUCT
ServiceCode : BYTE; // 0x4C=Read, 0x4D=Write, 0x01=GetAttrAll
ClassID : UINT; // CIP object class
InstanceID : UINT; // Object instance
AttributeID : UINT; // Specific attribute (0 for service-level)
TargetIP : STRING[15];
TargetSlot : UINT; // For backplane routing
DataLength : UINT;
END_STRUCT;
END_TYPE
FUNCTION_BLOCK FB_CIP_ExplicitMsg
VAR_INPUT
Execute : BOOL;
Config : CIP_MessageConfig;
END_VAR
VAR_OUTPUT
Done : BOOL;
Error : BOOL;
ErrorCode : DINT;
ResponseData : ARRAY[0..63] OF BYTE;
ResponseLength : UINT;
END_VAR
VAR
State : INT := 0;
tmrTimeout : TON;
END_VAR
CASE State OF
0: // IDLE
Done := FALSE;
Error := FALSE;
IF Execute THEN
// Build CIP request packet
// Route: port 2 (Ethernet), target IP, slot
// Service: Config.ServiceCode
// Path: Class/Instance/Attribute
State := 1;
END_IF;
1: // WAITING FOR RESPONSE
tmrTimeout(IN := TRUE, PT := T#5S);
IF (* response received *) FALSE THEN
tmrTimeout(IN := FALSE);
Done := TRUE;
State := 0;
ELSIF tmrTimeout.Q THEN
tmrTimeout(IN := FALSE);
Error := TRUE;
ErrorCode := 16#0001; // Timeout
State := 0;
END_IF;
END_CASE;
Reading a drive parameter with an explicit message
// Example: Read motor nameplate current from a PowerFlex drive
// CIP Path: Class 0x93 (Drive Object), Instance 1, Attribute 47 (Nameplate Current)
PROGRAM ReadDriveParam
VAR
msgReadCurrent : FB_CIP_ExplicitMsg;
msgConfig : CIP_MessageConfig;
NameplateCurrent: REAL;
ReadDone : BOOL;
END_VAR
msgConfig.ServiceCode := 16#0E; // Get Attribute Single
msgConfig.ClassID := 16#93; // Drive object
msgConfig.InstanceID := 1;
msgConfig.AttributeID := 47; // Motor rated current
msgConfig.TargetIP := '192.168.1.10';
msgReadCurrent(
Execute := NOT ReadDone,
Config := msgConfig
);
IF msgReadCurrent.Done THEN
// Parse response — REAL is 4 bytes
// NameplateCurrent := BytesToReal(msgReadCurrent.ResponseData);
ReadDone := TRUE;
END_IF;
Monitoring connection health
On a production system you want to see a link degrading before it drops. This monitor keeps a diagnostics record per device, warns below 95% communication quality, treats a device below 50% as offline, and flags when one of the critical devices is down:
TYPE NetworkDiagnostics :
STRUCT
DeviceName : STRING[20];
IPAddress : STRING[15];
Protocol : STRING[12]; // 'ModbusTCP' or 'EtherNetIP'
IsConnected : BOOL;
CommQuality : REAL; // 0–100%
AvgResponseMS : REAL;
MaxResponseMS : REAL;
TotalPackets : DINT;
LostPackets : DINT;
LastErrorCode : WORD;
LastErrorTime : STRING[20];
ConsecErrors : INT;
END_STRUCT;
END_TYPE
FUNCTION_BLOCK FB_NetworkMonitor
VAR
Devices : ARRAY[1..32] OF NetworkDiagnostics;
NumDevices : INT;
NetworkHealthPct : REAL;
CriticalDevDown : BOOL;
END_VAR
VAR
i : INT;
HealthyCount : INT;
END_VAR
HealthyCount := 0;
CriticalDevDown := FALSE;
FOR i := 1 TO NumDevices DO
// Flag devices with degraded communication
IF Devices[i].CommQuality < 95.0 AND Devices[i].IsConnected THEN
// Log warning: device communication degraded
END_IF;
IF Devices[i].CommQuality < 50.0 THEN
// Device is effectively offline
Devices[i].IsConnected := FALSE;
END_IF;
IF Devices[i].IsConnected THEN
HealthyCount := HealthyCount + 1;
END_IF;
// Check for critical devices (first 4 are critical in this example)
IF i <= 4 AND NOT Devices[i].IsConnected THEN
CriticalDevDown := TRUE;
END_IF;
END_FOR;
IF NumDevices > 0 THEN
NetworkHealthPct := (INT_TO_REAL(HealthyCount) / INT_TO_REAL(NumDevices)) * 100.0;
END_IF;
Choosing between Modbus TCP and EtherNet/IP
| Criterion | Modbus TCP | EtherNet/IP |
|---|---|---|
| Complexity | Low: register reads/writes | Medium: CIP object model |
| Speed | Good (10–50ms typical) | Better (1–10ms with implicit I/O) |
| Determinism | Non-deterministic (TCP) | Implicit I/O can be deterministic |
| Multivendor | Excellent: nearly universal | Good: ODVA members |
| Data Model | Flat registers | Rich object model with attributes |
| Cost | Free: no licensing | May require EDS files, conformance testing |
| Best For | Sensors, meters, simple I/O | Drives, robots, complex devices |
Basic network security
Industrial Ethernet is increasingly targeted by cyber attacks. A PLC can at least check that a device address sits in the expected control subnet:
// Network segmentation validation
// Ensure control network devices are on the correct subnet
FUNCTION FC_ValidateIPRange : BOOL
VAR_INPUT
DeviceIP : STRING[15];
SubnetBase : STRING[15]; // e.g., '192.168.10.'
END_VAR
// Simple prefix check — production systems use proper subnet masking
FC_ValidateIPRange := (FIND(DeviceIP, SubnetBase) = 1);
The real protection is in the network design:
Quick reference
| Topic | Key Takeaway |
|---|---|
| Modbus TCP | Simple register model: 4 register types, 8 function codes |
| 32-bit values | Span two registers; document byte order per device |
| Polling architecture | Round-robin with priority override for critical devices |
| EtherNet/IP implicit | Fast cyclic I/O, configured in hardware, not code |
| EtherNet/IP explicit | CIP messaging for parameters and diagnostics |
| Health monitoring | Track comm quality, response time, consecutive errors |
| Security | Network segmentation, firewalls, traffic monitoring |