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.

Getting data from the PLC to SCADA

Every modern automation system depends on PLCs, SCADA servers, HMIs and enterprise systems talking to each other. Getting data from a PLC to a supervisory system reliably takes more than wiring: you need structured data mapping, handshakes, error detection and careful timing. This guide covers the main industrial protocols and shows how to write the communication logic for them in Structured Text.

Which protocol does what

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

Choosing a protocol

Use OPC UA when you need different vendors to work together, when data has to travel up from the PLC to MES and ERP, or when you want an information model rather than bare registers. Modbus TCP suits simple register-based exchange with instruments, energy meters and third-party HMIs. PROFINET and EtherNet/IP carry real-time I/O to drives, remote I/O racks and safety modules. For cloud and IoT work where OPC UA is too heavy, MQTT through a gateway is the usual route.

OPC UA

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

What an OPC UA server exposes

OPC UA gives SCADA a structured namespace instead of a list of raw registers. The namespace is built from nodes (objects, variables and methods) arranged in a hierarchy. Data types are mapped from IEC 61131-3 types such as BOOL, INT, REAL, STRING and STRUCT. Each value carries two timestamps, a source timestamp from the PLC scan and a server timestamp, plus a quality code of Good, Bad or Uncertain. SCADA trends are only as trustworthy as those quality codes.

Preparing PLC data for OPC UA

Most PLCs publish variables automatically once you mark them for OPC UA access. The engineering work is in organizing the data into structures that make sense to whoever browses the namespace:

// 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, validate every value and use a write handshake so two writes cannot race each other:

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

Modbus is one of the most widely deployed industrial protocols because it is simple. It uses a register model with four tables: coils (bits), discrete inputs (read-only bits), holding registers (16-bit read/write) and input registers (16-bit read-only).

A Modbus register map

Keep a written register map and document every register in it:

RegisterAddressTypeDescriptionUnitsScale
Tank Level40001HoldingProcess level%×0.1
Flow Rate40002-40003Holding32-bit floatm³/hIEEE 754
Pump Command00001CoilStart/Stop-1=Run
Pump Status10001DiscreteRunning feedback-1=Running
Fault Code30001InputActive fault-Enum

Turning Modbus registers into engineering values in ST

Most PLCs supply a Modbus function block (for example MB_CLIENT in Siemens, the Modbus TCP client blocks in the CODESYS Modbus library). The logic you write on the PLC side maps the received registers into values you can use:

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;

Sending 32-bit floats over Modbus

Modbus registers are 16 bits wide, so a 32-bit REAL takes two consecutive registers. The byte and word order depends on the 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

Verify the byte order with a known test value before you trust any float, for example by writing 123.456 and checking what the PLC receives. The wrong byte order produces floats that can look plausible and still be wrong.

Communication handshake patterns

Request and response with a timeout

For command/response exchanges, such as serial instruments or 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;

Moving large data sets in blocks

When you move a large data set between PLC and SCADA (recipe parameters, production logs), send it in blocks under 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 count 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 and EtherNet/IP notes

On the real-time fieldbuses the PLC runtime handles the cyclic I/O exchange for you. Your ST code works with mapped I/O variables, not raw protocol frames. What you do have to write on the ST side is device monitoring like this:

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

Checklist for PLC-SCADA communication

  • Detect stale data. If communication fails, mark the data quality BAD, and never display stale values as current.
  • Validate every SCADA write: range-check it, rate-limit it, and require operator confirmation for critical setpoints.
  • Organize process data into STRUCTs that map cleanly onto OPC UA namespaces.
  • Document the register map. Every Modbus register needs a defined address, type, units and scaling factor.
  • Handle byte order explicitly, and verify 32-bit float encoding with test values before commissioning.
  • Add a heartbeat: a rolling counter that SCADA monitors, so it can tell a stopped PLC program from a lost link.
  • Split large transfers. Never try to send 1000+ registers in one cycle; use block transfer with handshakes.
  • Log communication events, with connect and disconnect timestamps and failure counts, so maintenance has a history to work from.
  • You can test these data-mapping patterns in the online ST editor. For protocol parsing logic, see the string handling tutorial.