Communication
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
| Protocol | Layer | Typical Use | Deterministic? |
|---|---|---|---|
| OPC UA | Application | PLC↔SCADA, MES, Cloud | No (pub/sub variant can be) |
| Modbus TCP | Application/TCP | PLC↔HMI, instruments | No |
| Modbus RTU | Serial (RS-485) | Field instruments, legacy | No |
| PROFINET | Ethernet (Layer 2) | Siemens I/O, drives | Yes (IRT mode) |
| EtherNet/IP | Ethernet/TCP+UDP | Allen-Bradley, Rockwell | Yes (implicit messaging) |
| PROFIBUS DP | Serial (RS-485) | Legacy Siemens I/O | Yes |
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:
| Register | Address | Type | Description | Units | Scale |
|---|---|---|---|---|---|
| Tank Level | 40001 | Holding | Process level | % | ×0.1 |
| Flow Rate | 40002-40003 | Holding | 32-bit float | m³/h | IEEE 754 |
| Pump Command | 00001 | Coil | Start/Stop | - | 1=Run |
| Pump Status | 10001 | Discrete | Running feedback | - | 1=Running |
| Fault Code | 30001 | Input | Active 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
You can test these data-mapping patterns in the online ST editor. For protocol parsing logic, see the string handling tutorial.