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.

Industrial Ethernet: The Backbone of Modern Automation

Gone are the days when every field device needed dedicated point-to-point wiring. Modern plants run on industrial Ethernet — the same physical layer as office IT, but with deterministic protocols designed for real-time control. The two dominant protocols are:

  • Modbus TCP: Simple, open, universal. The "lingua franca" of industrial communication
  • EtherNet/IP: CIP-based protocol from ODVA, native to Allen-Bradley and widely supported
  • This article covers both protocols from the PLC programmer's perspective: how to structure your data, handle communication errors, and build robust multi-device architectures.

    Modbus TCP Fundamentals

    Modbus TCP wraps the classic Modbus RTU protocol in a TCP/IP packet. The data model is simple — 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 |

    Function Codes You'll Actually 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 |

    Modbus TCP Data Mapping in Structured Text

    The key challenge is mapping PLC data types to 16-bit Modbus registers. Here's a complete mapping strategy:

    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

    Handling 32-Bit Values Across Two Registers

    REAL (floating point) and DINT (32-bit integer) values span two consecutive 16-bit registers. The byte order matters — and it varies by device manufacturer:

    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;

    Production tip: Always document your byte order convention in the project. Nothing wastes more commissioning time than swapped registers showing garbage values.

    Modbus TCP Communication Manager

    A production system polls multiple devices sequentially. Here's a robust polling architecture:

    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);

    Multi-Device Polling Architecture

    Real systems talk to dozens of devices. A round-robin scheduler ensures fair polling:

    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 DeviceCommActiveDevice;

    // 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: CIP Messaging

    EtherNet/IP uses the Common Industrial Protocol (CIP) — a more structured approach than Modbus. 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: Assembly Mapping

    Implicit messaging maps device data directly to PLC I/O tags — configured in the controller's hardware tree, not in ST code. The PLC sees the data as input/output arrays:

    // 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: MSG Instruction Pattern

    For non-cyclic data (reading a parameter, writing a configuration), use explicit CIP messages:

    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 via 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;

    Connection Diagnostics and Health Monitoring

    Production systems need visibility into communication health:

    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;

    Protocol Selection Guide

    | 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 |

    Pragmatic advice: Use Modbus TCP for simple devices (power meters, temperature transmitters, basic I/O). Use EtherNet/IP for complex devices (VFDs, robots, safety controllers) where the richer data model pays off.

    Security Considerations

    Industrial Ethernet is increasingly targeted by cyber attacks. Basic security hygiene:

    // 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);

  • Segment networks: Control LAN (192.168.10.x) separate from enterprise LAN
  • Firewall between zones: Only allow specific traffic through (IEC 62443 zones and conduits)
  • Disable unused services: Turn off HTTP/FTP/SNMP on devices that don't need them
  • Monitor traffic: Unexpected Modbus writes to a safety PLC = immediate alarm
  • Summary

    | 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 |

    The communication layer is the nervous system of your automation architecture. Build it with the same engineering rigor you apply to your control logic.