PLC-to-SCADA Communication: OPC UA, Modbus & Industrial Protocols in Structured Text

Master PLC-to-SCADA communication with practical Structured Text patterns for OPC UA, Modbus TCP/RTU, PROFINET, and EtherNet/IP — including handshake logic and buffer management.

The Communication Layer in Industrial Automation

Every modern automation system relies on communication between PLCs, SCADA servers, HMIs, and enterprise systems. Getting data reliably from a PLC to a supervisory system isn't just about wiring — it requires structured data mapping, handshake protocols, error detection, and careful timing. This guide covers the major industrial protocols and how to implement robust communication logic in Structured Text.

Industrial Protocol Landscape

ProtocolLayerTypical UseDeterministic?
OPC UAApplicationPLC↔SCADA, MES, CloudNo (pub/sub variant can be)
Modbus TCPApplication/TCPPLC↔HMI, instrumentsNo
Modbus RTUSerial (RS-485)Field instruments, legacyNo
PROFINETEthernet (Layer 2)Siemens I/O, drivesYes (IRT mode)
EtherNet/IPEthernet/TCP+UDPAllen-Bradley, RockwellYes (implicit messaging)
PROFIBUS DPSerial (RS-485)Legacy Siemens I/OYes

When to Use What

  • OPC UA: Cross-vendor interoperability, vertical integration (PLC → MES → ERP), information modeling
  • Modbus TCP: Simple register-based exchange with instruments, energy meters, third-party HMIs
  • PROFINET/EtherNet/IP: Real-time I/O communication with drives, remote I/O racks, safety modules
  • MQTT via gateway: Cloud/IoT scenarios where OPC UA is too heavy
  • OPC UA: The Modern Standard

    OPC UA (Unified Architecture) is the successor to classic OPC DA. It's platform-independent, transport-agnostic, and includes built-in security (certificates, encryption, authentication). Most modern PLCs (Siemens S7-1500, CODESYS 3.5, Beckhoff TwinCAT 3, B&R Automation Studio) include native OPC UA server functionality.

    OPC UA Architecture Concepts

    Information Model: OPC UA doesn't just expose raw registers — it provides a structured namespace with:

  • Nodes: Objects, variables, methods, organized hierarchically
  • Data Types: Mapped from IEC 61131-3 types (BOOL, INT, REAL, STRING, STRUCT)
  • Timestamps: Source timestamp (PLC scan) + server timestamp
  • Quality codes: Good, Bad, Uncertain — critical for SCADA trend reliability
  • PLC-Side Data Preparation for OPC UA

    Most PLCs auto-publish variables marked for OPC UA access. The key engineering task is organizing data into meaningful structures:

    // Data structure optimized for OPC UA exposure
    TYPE UnitProcessData :
    STRUCT
        // Analog measurements — SCADA trending
        Temperature     : REAL;      // °C, source: TC-101
        Pressure        : REAL;      // bar, source: PT-102
        FlowRate        : REAL;      // m³/h, source: FT-103
        Level           : REAL;      // %, source: LT-104
        
        // Discrete states — SCADA alarm/status
        PumpRunning     : BOOL;
        ValveOpen       : BOOL;
        HighTempAlarm   : BOOL;
        LowLevelAlarm   : BOOL;
        
        // Setpoints — SCADA writes these
        TempSetpoint    : REAL;      // Operator-adjustable
        FlowSetpoint    : REAL;
        AutoMode        : BOOL;     // TRUE = auto, FALSE = manual
        
        // Diagnostics
        CommStatus      : WORD;      // Bit-packed status
        ScanCounter     : UDINT;     // Increments each PLC scan
        LastUpdateTime  : DT;        // PLC local time
    END_STRUCT
    END_TYPE

    VAR_GLOBAL Unit1 : UnitProcessData; // Published to OPC UA namespace Unit2 : UnitProcessData; Unit3 : UnitProcessData; END_VAR

    Handling SCADA Writes Safely

    When SCADA writes setpoints to the PLC, you must validate inputs and implement write handshakes to prevent race conditions:

    FUNCTION_BLOCK FB_SafeSetpointReceiver
    VAR_INPUT
        ScadaWriteValue  : REAL;     // Raw value from SCADA
        ScadaWriteTrig   : BOOL;     // Rising edge = new write
        MinLimit         : REAL;     // Engineering minimum
        MaxLimit         : REAL;     // Engineering maximum
        RampRate         : REAL;     // Max change per second
    END_VAR
    VAR_OUTPUT
        ActiveSetpoint   : REAL;     // Validated, ramped output
        WriteAccepted    : BOOL;     // Feedback to SCADA
        WriteRejected    : BOOL;     // Feedback if out of range
        RejectReason     : INT;      // 1=OutOfRange, 2=RampExceeded
    END_VAR
    VAR
        R_Write          : R_TRIG;
        TargetSP         : REAL;
        LastScanTime     : TIME;
        DeltaT           : REAL;     // Seconds since last scan
    END_VAR

    R_Write(CLK := ScadaWriteTrig); WriteAccepted := FALSE; WriteRejected := FALSE;

    // Validate on rising edge of write trigger IF R_Write.Q THEN IF ScadaWriteValue < MinLimit OR ScadaWriteValue > MaxLimit THEN WriteRejected := TRUE; RejectReason := 1; // Out of range ELSE TargetSP := ScadaWriteValue; WriteAccepted := TRUE; RejectReason := 0; END_IF; END_IF;

    // Ramp toward target (prevents step changes) DeltaT := 0.01; // Assume 10ms scan cycle IF ABS(TargetSP - ActiveSetpoint) > (RampRate * DeltaT) THEN IF TargetSP > ActiveSetpoint THEN ActiveSetpoint := ActiveSetpoint + (RampRate * DeltaT); ELSE ActiveSetpoint := ActiveSetpoint - (RampRate * DeltaT); END_IF; ELSE ActiveSetpoint := TargetSP; END_IF;

    Modbus TCP/RTU Communication

    Modbus remains the most widely deployed industrial protocol due to its simplicity. It uses a register-based model: Coils (bits), Discrete Inputs (read-only bits), Holding Registers (16-bit R/W), and Input Registers (16-bit read-only).

    Modbus Register Mapping Table

    A disciplined register map is essential. Document every register:

    RegisterAddressTypeDescriptionUnitsScale
    Tank Level40001HoldingProcess level%×0.1
    Flow Rate40002-40003Holding32-bit floatm³/hIEEE 754
    Pump Command00001CoilStart/Stop1=Run
    Pump Status10001DiscreteRunning feedback1=Running
    Fault Code30001InputActive faultEnum

    Modbus Data Extraction in ST

    Most PLCs provide a Modbus function block (e.g., MB_CLIENT in Siemens, ModbusTCPClient in CODESYS). The PLC-side logic focuses on mapping received registers into usable engineering values:

    FUNCTION_BLOCK FB_ModbusDataMapper
    VAR_INPUT
        RawRegisters   : ARRAY[0..49] OF WORD;  // From Modbus read
        CommValid      : BOOL;                   // Communication OK
    END_VAR
    VAR_OUTPUT
        TankLevel      : REAL;    // Scaled from register 0
        FlowRate       : REAL;    // 32-bit float from reg 1-2
        PumpFeedback   : BOOL;    // Discrete input
        FaultCode      : INT;     // Input register
        DataQuality    : BOOL;    // FALSE if stale
    END_VAR
    VAR
        StaleTimer     : TON;
        FloatRaw       : DWORD;
    END_VAR

    // Stale data detection: if no valid comm for 5 seconds StaleTimer(IN := NOT CommValid, PT := T#5s); DataQuality := CommValid AND NOT StaleTimer.Q;

    IF DataQuality THEN // Register 0: Level as 0-1000 → 0.0-100.0% TankLevel := WORD_TO_REAL(RawRegisters[0]) * 0.1; // Register 1-2: IEEE 754 float (big-endian word order) FloatRaw := SHL(WORD_TO_DWORD(RawRegisters[1]), 16) OR WORD_TO_DWORD(RawRegisters[2]); FlowRate := DWORD_TO_REAL(FloatRaw); // Discrete: bit 0 of register 10 PumpFeedback := (RawRegisters[10] AND 16#0001) <> 0; // Fault code FaultCode := WORD_TO_INT(RawRegisters[20]); END_IF;

    Handling 32-Bit Floats Over Modbus

    Modbus registers are 16-bit. Transmitting a 32-bit REAL requires two consecutive registers. The byte/word order varies by manufacturer:

    // Big-Endian (most common: Siemens, ABB)
    // Register N = high word, Register N+1 = low word
    FloatDword := SHL(WORD_TO_DWORD(RegN), 16) OR WORD_TO_DWORD(RegN1);

    // Little-Endian (some Schneider, Modicon devices) // Register N = low word, Register N+1 = high word FloatDword := SHL(WORD_TO_DWORD(RegN1), 16) OR WORD_TO_DWORD(RegN);

    // Mid-Endian / Byte-Swapped (rare, check device docs) // Swap bytes within each word first, then combine

    Critical: Always verify byte order with a known test value (e.g., write 123.456 and check what the PLC receives). Incorrect byte order produces garbage floats that can look plausible but are wrong.

    Communication Handshake Patterns

    Request-Response with Timeout

    For command/response protocols (e.g., serial instruments, robot controllers):

    FUNCTION_BLOCK FB_CommHandshake
    VAR_INPUT
        SendCommand  : BOOL;     // Trigger to send
        ResponseRx   : BOOL;     // Response received flag
        MaxRetries   : INT := 3;
    END_VAR
    VAR_OUTPUT
        Busy         : BOOL;
        Done         : BOOL;
        Error        : BOOL;
        ErrorCode    : INT;       // 1=Timeout, 2=MaxRetries
    END_VAR
    VAR
        State        : INT;
        TimeoutTmr   : TON;
        RetryCount   : INT;
        R_Send       : R_TRIG;
    END_VAR

    R_Send(CLK := SendCommand);

    CASE State OF 0: // IDLE Busy := FALSE; Done := FALSE; Error := FALSE; IF R_Send.Q THEN State := 10; RetryCount := 0; Busy := TRUE; END_IF; 10: // WAIT FOR RESPONSE TimeoutTmr(IN := TRUE, PT := T#2s); IF ResponseRx THEN TimeoutTmr(IN := FALSE); State := 20; // Success ELSIF TimeoutTmr.Q THEN TimeoutTmr(IN := FALSE); RetryCount := RetryCount + 1; IF RetryCount >= MaxRetries THEN State := 99; // Failed ErrorCode := 2; ELSE State := 10; // Retry END_IF; END_IF; 20: // DONE Busy := FALSE; Done := TRUE; State := 0; 99: // ERROR Busy := FALSE; Error := TRUE; State := 0; END_CASE;

    Data Buffer and Batch Transfer

    When transferring large datasets (e.g., recipe parameters, production logs) between PLC and SCADA, use a buffered approach with sequence control:

    FUNCTION_BLOCK FB_BlockTransfer
    VAR_INPUT
        StartTransfer : BOOL;
        SourceData    : ARRAY[1..200] OF REAL;  // Full dataset
        BlockSize     : INT := 20;              // Registers per transfer
    END_VAR
    VAR_OUTPUT
        TransferBlock : ARRAY[1..20] OF REAL;   // Current block
        BlockIndex    : INT;                     // Current block number
        TotalBlocks   : INT;                     // Total blocks needed
        TransferDone  : BOOL;
        Progress      : REAL;                    // 0.0 to 100.0%
    END_VAR
    VAR
        State         : INT;
        ItemIndex     : INT;
        i             : INT;
        R_Start       : R_TRIG;
    END_VAR

    R_Start(CLK := StartTransfer);

    // BlockSize is a VAR_INPUT — if the caller forgets to wire it and the // declared default is overridden to 0, this divide faults the cycle. // Cheap guard: refuse to run with an invalid block size. IF BlockSize <= 0 THEN TransferDone := FALSE; RETURN; END_IF; TotalBlocks := 200 / BlockSize; // = 10 blocks at BlockSize = 20

    CASE State OF 0: // IDLE TransferDone := FALSE; IF R_Start.Q THEN BlockIndex := 1; ItemIndex := 1; State := 10; END_IF; 10: // FILL CURRENT BLOCK FOR i := 1 TO BlockSize DO IF ItemIndex <= 200 THEN TransferBlock[i] := SourceData[ItemIndex]; ItemIndex := ItemIndex + 1; END_IF; END_FOR; Progress := INT_TO_REAL(BlockIndex) / INT_TO_REAL(TotalBlocks) * 100.0; State := 20; 20: // WAIT FOR SCADA ACKNOWLEDGMENT // SCADA reads TransferBlock, then sets an ACK bit // (simplified: auto-advance after 1 cycle) BlockIndex := BlockIndex + 1; IF BlockIndex > TotalBlocks THEN State := 30; ELSE State := 10; END_IF; 30: // COMPLETE TransferDone := TRUE; Progress := 100.0; State := 0; END_CASE;

    Communication Diagnostics

    Monitor link health and log communication failures:

    FUNCTION_BLOCK FB_CommDiagnostics
    VAR_INPUT
        CommOK        : BOOL;     // Cyclic comm status
        ResponseTime  : TIME;     // Last response duration
    END_VAR
    VAR_OUTPUT
        LinkUptime    : TIME;     // Total time connected
        FailCount     : DINT;     // Total failures
        AvgResponse   : REAL;     // Moving average (ms)
        LinkQuality   : INT;      // 0-100%
        CommState     : STRING(20);
    END_VAR
    VAR
        UptimeTimer   : TON;
        DownTimer     : TON;
        F_CommLost    : F_TRIG;
        ResponseSum   : REAL;
        ResponseCount : DINT;
    END_VAR

    UptimeTimer(IN := CommOK, PT := T#49d); // Max TIME range LinkUptime := UptimeTimer.ET;

    F_CommLost(CLK := CommOK); IF F_CommLost.Q THEN FailCount := FailCount + 1; END_IF;

    // Moving average response time IF CommOK THEN ResponseCount := ResponseCount + 1; ResponseSum := ResponseSum + TIME_TO_REAL(ResponseTime); AvgResponse := ResponseSum / DINT_TO_REAL(ResponseCount); CommState := 'CONNECTED'; ELSE CommState := 'DISCONNECTED'; END_IF;

    // Quality: 100% if no failures, degrades with failure ratio IF ResponseCount > 0 THEN LinkQuality := 100 - DINT_TO_INT( (FailCount * 100) / (ResponseCount + FailCount) ); ELSE LinkQuality := 0; END_IF;

    PROFINET & EtherNet/IP Notes

    For real-time fieldbus protocols, the PLC runtime handles cyclic I/O exchange automatically. Your ST code interacts with mapped I/O variables — not raw protocol frames. Key ST-side responsibilities:

    // PROFINET device monitoring (Siemens-style)
    VAR
        Drive1_Status  : WORD;     // Mapped from PROFINET device
        Drive1_Speed   : INT;      // Cyclic process data
        Drive1_Online  : BOOL;     // Device present on network
        Drive1_Diag    : DWORD;    // Diagnostic data
    END_VAR

    // Check device health every scan IF NOT Drive1_Online THEN // Device lost — trigger safe state DriveSpeedCmd := 0; DriveEnable := FALSE; AlarmCommLoss := TRUE; END_IF;

    // Decode diagnostic bits IF (Drive1_Diag AND 16#00000001) <> 0 THEN // Bit 0: Wire break detected AlarmWireBreak := TRUE; END_IF;

    Best Practices for PLC-SCADA Communication

  • Always implement stale-data detection — if communication fails, mark data quality as BAD. Never display stale values as current.
  • Validate all SCADA writes — range-check, rate-limit, and require operator confirmation for critical setpoints.
  • Use structured data types — organize process data into STRUCTs that map cleanly to OPC UA namespaces.
  • Document your register map — every Modbus register should have a defined address, type, units, and scaling factor.
  • Handle byte order explicitly — verify 32-bit float encoding with test values before commissioning.
  • Implement heartbeats — a rolling counter that SCADA monitors to detect PLC program stoppage vs. communication loss.
  • Buffer large transfers — never try to send 1000+ registers in one cycle; use block transfer with handshakes.
  • Log communication events — track connection/disconnection timestamps and failure counts for maintenance.
  • Test communication data mapping patterns in our online ST editor. For protocol parsing logic, see our string handling tutorial.