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.
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
| 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 |
When to Use What
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:
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_TYPEVAR_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_VARR_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:
| 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 |
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_VARR_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_VARR_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_VARUptimeTimer(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
Test communication data mapping patterns in our online ST editor. For protocol parsing logic, see our string handling tutorial.