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:

  • Modbus TCP is simple and open, and almost every device that talks to a PLC supports it.
  • EtherNet/IP is the CIP-based protocol from ODVA. It is native to Allen-Bradley and widely supported by other vendors.
  • 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 TypeModbus AddressAccessData SizeTypical Use
    Coils0xxxxRead/Write1 bitDigital outputs
    Discrete Inputs1xxxxRead Only1 bitDigital inputs
    Input Registers3xxxxRead Only16 bitAnalog inputs, status
    Holding Registers4xxxxRead/Write16 bitSetpoints, config

    The function codes you will use

    CodeNamePurpose
    01Read CoilsRead digital outputs
    02Read Discrete InputsRead digital inputs
    03Read Holding RegistersRead 16-bit R/W registers
    04Read Input RegistersRead 16-bit read-only registers
    05Write Single CoilWrite one digital output
    06Write Single RegisterWrite one 16-bit register
    15Write Multiple CoilsWrite multiple digital outputs
    16Write Multiple RegistersWrite 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:

    TypeNameUse Case
    Implicit (I/O)Cyclic data exchangeFast, deterministic I/O updates
    ExplicitRequest/response messagingConfiguration, 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

    CriterionModbus TCPEtherNet/IP
    ComplexityLow: register reads/writesMedium: CIP object model
    SpeedGood (10–50ms typical)Better (1–10ms with implicit I/O)
    DeterminismNon-deterministic (TCP)Implicit I/O can be deterministic
    MultivendorExcellent: nearly universalGood: ODVA members
    Data ModelFlat registersRich object model with attributes
    CostFree: no licensingMay require EDS files, conformance testing
    Best ForSensors, meters, simple I/ODrives, robots, complex devices
    In practice, 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 saves you work.

    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:

  • Segment the networks, keeping the control LAN (192.168.10.x) separate from the enterprise LAN.
  • Put a firewall between zones and only allow specific traffic through (IEC 62443 zones and conduits).
  • Turn off HTTP/FTP/SNMP on devices that do not need them.
  • Monitor the traffic. An unexpected Modbus write to a safety PLC should raise an immediate alarm.
  • Quick reference

    TopicKey Takeaway
    Modbus TCPSimple register model: 4 register types, 8 function codes
    32-bit valuesSpan two registers; document byte order per device
    Polling architectureRound-robin with priority override for critical devices
    EtherNet/IP implicitFast cyclic I/O, configured in hardware, not code
    EtherNet/IP explicitCIP messaging for parameters and diagnostics
    Health monitoringTrack comm quality, response time, consecutive errors
    SecurityNetwork segmentation, firewalls, traffic monitoring
    Give the communication code the same engineering care as your control logic: the timeouts, retries, word order and health monitoring above all need to be designed and tested.